diff --git a/.github/workflows/sig.yml b/.github/workflows/sig.yml new file mode 100644 index 0000000..bb6092e --- /dev/null +++ b/.github/workflows/sig.yml @@ -0,0 +1,18 @@ +name: sig + +on: [push, pull_request] + +jobs: + sig: + runs-on: "ubuntu-latest" + env: + BUNDLE_WITH: sig + steps: + - uses: actions/checkout@v6 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + ruby-version: ruby + - name: Run RBS tests + run: bundle exec rake rbs:test diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fdca248..8f6e6f5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,6 +22,8 @@ jobs: - ruby: 2.5 os: macos-latest runs-on: ${{ matrix.os }} + env: + BUNDLE_WITHOUT: sig steps: - uses: actions/checkout@v6 - name: Set up Ruby diff --git a/Gemfile b/Gemfile index 3dc2883..b8523ab 100644 --- a/Gemfile +++ b/Gemfile @@ -7,3 +7,8 @@ group :development do gem "rake" gem "test-unit" end + +group :sig do + gem "rbs" + gem "rdoc" +end diff --git a/Rakefile b/Rakefile index 30baabd..643a977 100644 --- a/Rakefile +++ b/Rakefile @@ -7,4 +7,19 @@ Rake::TestTask.new(:test) do |t| t.test_files = FileList["test/**/test_*.rb"] end +namespace :rbs do + task :test do + sh "ruby -I lib test_sig/test_observer.rb" + end + + task :annotate do + require "tmpdir" + + Dir.mktmpdir do |tmpdir| + sh("rdoc --ri --output #{tmpdir}/doc --root=. lib") + sh("rbs annotate --no-system --no-gems --no-site --no-home -d #{tmpdir}/doc sig") + end + end +end + task :default => :test diff --git a/observer.gemspec b/observer.gemspec index 93e61b8..9ed8ea7 100644 --- a/observer.gemspec +++ b/observer.gemspec @@ -24,7 +24,7 @@ Gem::Specification.new do |spec| # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do - `git ls-files -z 2>#{IO::NULL}`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } + `git ls-files -z 2>#{IO::NULL}`.split("\x0").reject { |f| f.match(%r{^(bin|test|test_sig|spec|features|.github)/}) } end spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } diff --git a/sig/observer.rbs b/sig/observer.rbs new file mode 100644 index 0000000..64d8323 --- /dev/null +++ b/sig/observer.rbs @@ -0,0 +1,217 @@ +# +# The Observer pattern (also known as publish/subscribe) provides a simple +# mechanism for one object to inform a set of interested third-party objects +# when its state changes. +# +# ## Mechanism +# +# The notifying class mixes in the `Observable` module, which provides the +# methods for managing the associated observer objects. +# +# The observable object must: +# * assert that it has `#changed` +# * call `#notify_observers` +# +# An observer subscribes to updates using Observable#add_observer, which also +# specifies the method called via #notify_observers. The default method for +# #notify_observers is #update. +# +# ### Example +# +# The following example demonstrates this nicely. A `Ticker`, when run, +# continually receives the stock `Price` for its `@symbol`. A `Warner` is a +# general observer of the price, and two warners are demonstrated, a `WarnLow` +# and a `WarnHigh`, which print a warning if the price is below or above their +# set limits, respectively. +# +# The `update` callback allows the warners to run without being explicitly +# called. The system is set up with the `Ticker` and several observers, and the +# observers do their duty without the top-level code having to interfere. +# +# Note that the contract between publisher and subscriber (observable and +# observer) is not declared or enforced. The `Ticker` publishes a time and a +# price, and the warners receive that. But if you don't ensure that your +# contracts are correct, nothing else can warn you. +# +# require "observer" +# +# class Ticker ### Periodically fetch a stock price. +# include Observable +# +# def initialize(symbol) +# @symbol = symbol +# end +# +# def run +# last_price = nil +# loop do +# price = Price.fetch(@symbol) +# print "Current price: #{price}\n" +# if price != last_price +# changed # notify observers +# last_price = price +# notify_observers(Time.now, price) +# end +# sleep 1 +# end +# end +# end +# +# class Price ### A mock class to fetch a stock price (60 - 140). +# def self.fetch(symbol) +# 60 + rand(80) +# end +# end +# +# class Warner ### An abstract observer of Ticker objects. +# def initialize(ticker, limit) +# @limit = limit +# ticker.add_observer(self) +# end +# end +# +# class WarnLow < Warner +# def update(time, price) # callback for observer +# if price < @limit +# print "--- #{time.to_s}: Price below #@limit: #{price}\n" +# end +# end +# end +# +# class WarnHigh < Warner +# def update(time, price) # callback for observer +# if price > @limit +# print "+++ #{time.to_s}: Price above #@limit: #{price}\n" +# end +# end +# end +# +# ticker = Ticker.new("MSFT") +# WarnLow.new(ticker, 80) +# WarnHigh.new(ticker, 120) +# ticker.run +# +# Produces: +# +# Current price: 83 +# Current price: 75 +# --- Sun Jun 09 00:10:25 CDT 2002: Price below 80: 75 +# Current price: 90 +# Current price: 134 +# +++ Sun Jun 09 00:10:25 CDT 2002: Price above 120: 134 +# Current price: 134 +# Current price: 112 +# Current price: 79 +# --- Sun Jun 09 00:10:25 CDT 2002: Price below 80: 79 +# +# ### Usage with procs +# +# The `#notify_observers` method can also be used with +proc+s by using the +# `:call` as `func` parameter. +# +# The following example illustrates the use of a lambda: +# +# require 'observer' +# +# class Ticker +# include Observable +# +# def run +# # logic to retrieve the price (here 77.0) +# changed +# notify_observers(77.0) +# end +# end +# +# ticker = Ticker.new +# warner = ->(price) { puts "New price received: #{price}" } +# ticker.add_observer(warner, :call) +# ticker.run +# +module Observable + # + # Add `observer` as an observer on this object. So that it will receive + # notifications. + # + # `observer` + # : the object that will be notified of changes. + # + # `func` + # : Symbol naming the method that will be called when this Observable has + # changes. + # + # This method must return true for `observer.respond_to?` and will receive + # `*arg` when #notify_observers is called, where `*arg` is the value passed + # to #notify_observers by this Observable + # + def add_observer: (untyped observer, ?Symbol func) -> void + + # + # Set the changed state of this object. Notifications will be sent only if the + # changed `state` is `true`. + # + # `state` + # : Boolean indicating the changed state of this Observable. + # + def changed: (?bool state) -> void + + # + # Returns true if this object's state has been changed since the last + # #notify_observers call. + # + def changed?: () -> bool + + # + # Return the number of observers associated with this object. + # + def count_observers: () -> Integer + + # + # Remove `observer` as an observer on this object so that it will no longer + # receive notifications. + # + # `observer` + # : An observer of this Observable + # + def delete_observer: (untyped observer) -> void + + # + # Remove all observers associated with this object. + # + def delete_observers: () -> void + + # + # Notify observers of a change in state **if** this object's changed state is + # `true`. + # + # This will invoke the method named in #add_observer, passing `*arg`. The + # changed state is then set to `false`. + # + # `*arg` + # : Any arguments to pass to the observers. + # + def notify_observers: (*untyped arg) -> void + + VERSION: String +end diff --git a/test_sig/test_observer.rb b/test_sig/test_observer.rb new file mode 100644 index 0000000..4566f11 --- /dev/null +++ b/test_sig/test_observer.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'observer' +require 'test/unit' +require 'rbs/unit_test' + +class ObserverInstanceTest < Test::Unit::TestCase + include RBS::UnitTest::TypeAssertions + + library 'observer' + testing "::Observable" + + class Ticker + include Observable + end + + class Observer + def update(*args) + end + end + + def test_add_observer + o = Observer.new + assert_send_type '(untyped) -> void', + Ticker.new, :add_observer, o + assert_send_type '(untyped, Symbol) -> void', + Ticker.new, :add_observer, o, :update + end + + def test_changed + assert_send_type '() -> void', + Ticker.new, :changed + assert_send_type '(bool) -> void', + Ticker.new, :changed, false + end + + def test_changed? + assert_send_type '() -> bool', + Ticker.new, :changed? + end + + def test_count_observers + assert_send_type '() -> Integer', + Ticker.new, :count_observers + end + + def test_delete_observer + assert_send_type '(untyped) -> void', + Ticker.new, :delete_observer, Observer.new + end + + def test_delete_observers + assert_send_type '() -> void', + Ticker.new, :delete_observers + end + + def test_notify_observers + assert_send_type '() -> void', + Ticker.new, :notify_observers + assert_send_type '(untyped) -> void', + Ticker.new, :notify_observers, 1 + assert_send_type '(untyped, untyped) -> void', + Ticker.new, :notify_observers, 1, 2 + end +end