diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 48079e7..939a9a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,29 @@ name: CI on: [push, pull_request] jobs: + test: + strategy: + matrix: + ruby-version: + - "2.4" + - "2.7" + - "3.0" + - "3.1" + name: ${{ format('Tests (Ruby {0})', matrix.ruby-version) }} + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v1 + + - name: Install Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + + - name: Run Unit Tests + run: | + bundle exec rake spec:unit lint: strategy: matrix: diff --git a/Rakefile b/Rakefile index e7a3d9f..4e51fc8 100644 --- a/Rakefile +++ b/Rakefile @@ -4,6 +4,12 @@ begin require 'rspec/core/rake_task' RSpec::Core::RakeTask.new(:spec) + RSpec::Core::RakeTask.new('spec:unit') do |t| + t.pattern = 'spec/unit/*_spec.rb' + end + RSpec::Core::RakeTask.new('spec:integration') do |t| + t.pattern = 'spec/integration/*_spec.rb' + end task default: :spec rescue LoadError diff --git a/lib/midi-eye.rb b/lib/midi-eye.rb index a1ed311..2fa113b 100644 --- a/lib/midi-eye.rb +++ b/lib/midi-eye.rb @@ -13,7 +13,6 @@ require 'forwardable' require 'midi-message' require 'nibbler' -require 'unimidi' # classes require 'midi-eye/event_handlers' diff --git a/lib/midi-eye/event_handlers.rb b/lib/midi-eye/event_handlers.rb index 6b39e7e..c7fdab0 100644 --- a/lib/midi-eye/event_handlers.rb +++ b/lib/midi-eye/event_handlers.rb @@ -5,8 +5,8 @@ module MIDIEye class EventHandlers extend Forwardable - Event = Struct.new(:handler, :message) EventHandler = Struct.new(:conditions, :proc, :name) + EnqueuedEvent = Struct.new(:handler, :event) def_delegators :@handlers, :count @@ -52,35 +52,35 @@ def handle_enqueued counter end - # Enqueue all events with the given message + # Enqueue the given event for all handlers # @return [Array] - def enqueue_all(message) - @handlers.map { |handler| enqueue(handler, message) } + def enqueue(event) + @handlers.map { |handler| enqueue_event_for_handler(handler, event) } end - # Add an event to the trigger queue + private + + # For the given handler, add an event to the queue # @return [Hash] - def enqueue(handler, message) - event = Event.new(handler, message) - @event_queue << event - event + def enqueue_event_for_handler(handler, event) + enqueued_event = EnqueuedEvent.new(handler, event) + @event_queue << enqueued_event + enqueued_event end - private - # Does the given message meet the given conditions? def meets_conditions?(conditions, message) conditions.map { |key, value| condition_met?(message, key, value) }.all? end # Trigger an event - def handle_event(event) - handler = event.handler + def handle_event(shifted_event) + handler = shifted_event.handler conditions = handler.conditions - return unless conditions.nil? || meets_conditions?(conditions, event[:message][:message]) + return unless conditions.nil? || meets_conditions?(conditions, shifted_event.event[:message]) begin - handler.proc.call(event[:message]) + handler.proc.call(shifted_event.event) rescue StandardError => e Thread.main.raise(e) end diff --git a/lib/midi-eye/listener.rb b/lib/midi-eye/listener.rb index e271937..42842da 100644 --- a/lib/midi-eye/listener.rb +++ b/lib/midi-eye/listener.rb @@ -48,12 +48,11 @@ def remove_input(inputs) alias remove_inputs remove_input # Start listening for MIDI messages - # @params [Hash] options - # @option options [Boolean] :background Run in a background thread + # @params [Boolean] background Run in a background thread (default: true) # @return [MIDIEye::Listener] self - def run(options = {}) + def run(background: true) listen - join if options[:background].nil? + join unless background self end alias start run @@ -86,13 +85,6 @@ def join self end - # Deletes the event with the given name (for backwards compat) - # @param [String, Symbol] event_name - # @return [Boolean] - def delete_event(event_name) - !@event_handlers.delete(event_name).nil? - end - # Add an event to listen for # @param [Hash] options # @return [MIDIEye::Listener] self @@ -108,13 +100,17 @@ def listen_for(options = {}, &callback) def poll @sources.each do |input| input.poll do |objs| - objs.each { |batch| input_to_messages(batch) } + handle_new_input(objs) end end end private + def handle_new_input(objs) + objs.each { |batch| input_to_messages(batch) } + end + def input_to_messages(batch) messages = [batch[:messages]].flatten.compact messages.each do |message| @@ -122,7 +118,7 @@ def input_to_messages(batch) message: message, timestamp: batch[:timestamp] } - @event_handlers.enqueue_all(data) + @event_handlers.enqueue(data) end end diff --git a/lib/midi-eye/source.rb b/lib/midi-eye/source.rb index f7d9796..6187cf3 100644 --- a/lib/midi-eye/source.rb +++ b/lib/midi-eye/source.rb @@ -23,7 +23,7 @@ def initialize(input) def poll(&block) messages = @device.buffer.slice(@pointer, @device.buffer.length - @pointer) @pointer = @device.buffer.length - messages.compact.each { |raw_message| handle_message(raw_message, &block) } + messages.compact.map { |raw_message| handle_message(raw_message, &block) } end # If this source was created from the given input @@ -42,7 +42,8 @@ def handle_message(raw_message) nil end objects = [parsed_messages].flatten.compact - yield(objects) + yield(objects) if block_given? + objects end end end diff --git a/spec/helper.rb b/spec/helper.rb index 8e3834a..9d5e4c4 100644 --- a/spec/helper.rb +++ b/spec/helper.rb @@ -5,17 +5,3 @@ require 'rspec' require 'midi-eye' - -module SpecHelper - module_function - - def devices - if @devices.nil? - @devices = {} - { input: UniMIDI::Input, output: UniMIDI::Output }.each do |type, klass| - @devices[type] = klass.gets - end - end - @devices - end -end diff --git a/spec/integration/helper.rb b/spec/integration/helper.rb new file mode 100644 index 0000000..75fb09f --- /dev/null +++ b/spec/integration/helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'unimidi' + +module IntegrationSpecHelper + module_function + + def devices + if @devices.nil? + @devices = {} + { input: UniMIDI::Input, output: UniMIDI::Output }.each do |type, klass| + @devices[type] = klass.gets + end + end + @devices + end +end diff --git a/spec/integration_spec.rb b/spec/integration/integration_spec.rb similarity index 95% rename from spec/integration_spec.rb rename to spec/integration/integration_spec.rb index 1ede89b..803b734 100644 --- a/spec/integration_spec.rb +++ b/spec/integration/integration_spec.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true require 'helper' +require 'integration/helper' describe MIDIEye do - let(:input) { SpecHelper.devices[:input] } - let(:output) { SpecHelper.devices[:output] } + let(:input) { IntegrationSpecHelper.devices[:input] } + let(:output) { IntegrationSpecHelper.devices[:output] } let(:listener) { MIDIEye::Listener.new(input) } before { sleep 0.2 } @@ -110,7 +111,7 @@ end end - describe '#delete_event' do + describe 'delete event' do it 'deletes event' do event = nil listener.listen_for(name: :test) do |e| @@ -121,7 +122,7 @@ sleep 0.5 expect(listener.event_handlers.count).to eq(1) - listener.delete_event(:test) + listener.event_handlers.delete(:test) expect(listener.event_handlers.count).to eq(0) end end diff --git a/spec/unit/event_handlers_spec.rb b/spec/unit/event_handlers_spec.rb new file mode 100644 index 0000000..6053de5 --- /dev/null +++ b/spec/unit/event_handlers_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'helper' + +describe MIDIEye::EventHandlers do + let(:event_handlers) { MIDIEye::EventHandlers.new } + let(:event_handler1) { proc { |event| "#{event} 1" } } + let(:event_handler2) { proc { |event| "#{event} 2" } } + let(:event1) do + { + message: 'a message', + timestamp: Time.now.to_i + } + end + let(:event2) do + { + message: 'another message', + timestamp: Time.now.to_i + } + end + + describe '#delete' do + it 'deletes an event handler' do + expect(event_handlers.count).to eq(0) + event_handlers.add(name: :test, &event_handler1) + expect(event_handlers.count).to eq(1) + event_handlers.delete(:test) + expect(event_handlers.count).to eq(0) + end + end + + describe '#clear' do + it 'deletes all event handlers' do + expect(event_handlers.count).to eq(0) + event_handlers.add(name: :test, &event_handler1) + event_handlers.add(name: :test, &event_handler2) + event_handlers.clear + expect(event_handlers.count).to eq(0) + end + end + + describe '#add' do + it 'adds an event handler' do + expect(event_handlers.count).to eq(0) + event_handlers.add(name: :test, &event_handler1) + expect(event_handlers.count).to eq(1) + end + end + + describe '#enqueue' do + it 'enqueues an event' do + expect(event_handlers.count).to eq(0) + event_handlers.add(name: :test, &event_handler1) + event_handlers.enqueue(event1) + event_handlers.enqueue(event2) + + expect(event_handlers.handle_enqueued).to eq(2) + end + end + + describe '#handle_enqueued' do + it 'processes the events and returns the number of events processed' do + expect(event_handler1).to receive(:call).exactly(:twice) + expect(event_handler2).to receive(:call).exactly(:twice) + expect(event_handlers.count).to eq(0) + event_handlers.add(name: :test, &event_handler1) + event_handlers.add(name: :test, &event_handler2) + expect(event_handlers.count).to eq(2) + event_handlers.enqueue(event1) + event_handlers.enqueue(event2) + + expect(event_handlers.handle_enqueued).to eq(4) + end + end +end diff --git a/spec/unit/listener_spec.rb b/spec/unit/listener_spec.rb new file mode 100644 index 0000000..d5750c1 --- /dev/null +++ b/spec/unit/listener_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'helper' + +describe MIDIEye::Listener do + let(:event1) { { data: [0x90, 0x30, 0x20], timestamp: Time.now } } + let(:event2) { { data: [0x91, 0x20, 0x10], timestamp: Time.now } } + let(:input1) { double(gets: double, buffer: [event1, event2]) } + let(:input2) { double(gets: double, buffer: [event1, event2]) } + let(:input3) { double(gets: double, buffer: [event1, event2]) } + let(:listener) { MIDIEye::Listener.new(input_arg) } + + describe '#uses_input?' do + context 'when there are multiple inputs' do + let(:input_arg) { [input1, input2] } + + it 'returns true for those inputs' do + expect(listener.uses_input?(input1)).to eq(true) + expect(listener.uses_input?(input2)).to eq(true) + expect(listener.uses_input?(input3)).to eq(false) + end + end + + context 'when there is one input' do + let(:input_arg) { input1 } + + it 'returns true for that input' do + expect(listener.uses_input?(input1)).to eq(true) + expect(listener.uses_input?(input2)).to eq(false) + end + end + end + + describe '#add_input' do + let(:input_arg) { input1 } + + it 'adds an input' do + expect(listener.uses_input?(input1)).to eq(true) + expect(listener.uses_input?(input2)).to eq(false) + listener.add_input(input2) + expect(listener.uses_input?(input1)).to eq(true) + expect(listener.uses_input?(input2)).to eq(true) + end + end + + describe '#remove_input' do + let(:input_arg) { [input1, input2] } + + it 'removes the input' do + expect(listener.uses_input?(input1)).to eq(true) + expect(listener.uses_input?(input2)).to eq(true) + listener.remove_input(input2) + expect(listener.uses_input?(input1)).to eq(true) + expect(listener.uses_input?(input2)).to eq(false) + end + end + + describe '#run' do + let(:input_arg) { input2 } + + context 'when no background param' do + it 'runs in background' do + expect(listener).to receive(:listen) + expect(listener).to_not receive(:join) + listener.run + end + end + + context 'when background param is false' do + it 'runs in foreground' do + expect(listener).to receive(:listen) + expect(listener).to receive(:join) + listener.run(background: false) + end + end + end + + describe '#close' do + let(:input_arg) { input3 } + + it 'clears event handlers and sources' do + expect(listener.event_handlers).to receive(:clear) + expect(listener.sources).to receive(:clear) + listener.close + end + end + + describe '#running?' do + let(:input_arg) { input3 } + after do + listener.close + end + + context 'when running' do + it 'returns true' do + listener.run(background: true) + + expect(listener.running?).to eq(true) + end + end + + context 'when not running' do + it 'returns false' do + expect(listener.running?).to eq(false) + end + end + end + + describe '#join' do + let(:input_arg) { input3 } + it 'joins the listener thread' do + expect_any_instance_of(Thread).to receive(:join) + + listener.run(background: true) + listener.join + listener.close + end + end + + describe '#listen_for' do + let(:input_arg) { input3 } + + it 'adds event listener' do + expect_any_instance_of(MIDIEye::EventHandlers).to receive(:add) + + listener.listen_for(name: :test) { |e| p e } + end + end + + describe '#poll' do + let(:input_arg) { [input1, input2] } + + it 'polls each input for new messages' do + expect(listener.sources.count).to eq(2) + expect(listener.sources[0]).to receive(:poll).and_return(double) + expect(listener.sources[1]).to receive(:poll).and_return(double) + + listener.poll + end + end +end diff --git a/spec/unit/source_spec.rb b/spec/unit/source_spec.rb new file mode 100644 index 0000000..faf129c --- /dev/null +++ b/spec/unit/source_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'helper' + +describe MIDIEye::Source do + let(:event1) { { data: [0x90, 0x30, 0x20], timestamp: Time.now } } + let(:event2) { { data: [0x91, 0x20, 0x10], timestamp: Time.now } } + let(:message) { double } + let(:input) { double(gets: double, buffer: [event1, event2]) } + let(:source) { MIDIEye::Source.new(input) } + before do + allow_any_instance_of(Nibbler::Session).to receive(:parse).and_return(message) + end + + describe '.compatible?' do + context 'when device is compatible' do + it 'returns true' do + expect(MIDIEye::Source.compatible?(input)).to eq(true) + end + end + + context 'when device is not compatible' do + let(:input) { double } + + it 'returns false' do + expect(MIDIEye::Source.compatible?(input)).to eq(false) + end + end + end + + describe '#poll' do + it 'moves pointer to end of buffer and yields with each message' do + expect(source.pointer).to eq(0) + source.poll do |messages| + expect(messages).to include(message) + end + expect(source.pointer).to eq(2) + end + end + + describe '#uses?' do + context 'when listener is not using device' do + it 'returns false' do + expect(source.uses?(double)).to eq(false) + end + end + + context 'when listener is using device' do + it 'returns true' do + expect(source.uses?(input)).to eq(true) + end + end + end +end