switest 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,85 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Switest
4
- # Custom EventEmitter with guard support for conditional event handling.
5
- # Supports hash equality, regex, array membership, and proc guards.
4
+ # Lightweight one-shot event emitter with guard support for conditional
5
+ # event handling. Supports hash equality, regex, array membership, and
6
+ # proc guards.
7
+ #
8
+ # Used for routing inbound call offers from Client to Agent.listen_for_call.
6
9
  class Events
7
10
  def initialize
8
11
  @handlers = Hash.new { |h, k| h[k] = [] }
9
- @handler_id = 0
10
- @mutex = Mutex.new
11
- end
12
-
13
- # Register a permanent event handler with optional guards
14
- # @param event [Symbol] Event name
15
- # @param guards [Hash] Guard conditions (hash equality, regex, array, or proc)
16
- # @return [Integer] Handler ID for later removal
17
- def on(event, guards = {}, &block)
18
- @mutex.synchronize do
19
- @handler_id += 1
20
- @handlers[event] << {
21
- id: @handler_id,
22
- guards: guards,
23
- callback: block,
24
- once: false
25
- }
26
- @handler_id
27
- end
28
12
  end
29
13
 
30
14
  # Register a one-time event handler with optional guards
31
- # @param event [Symbol] Event name
32
- # @param guards [Hash] Guard conditions
33
- # @return [Integer] Handler ID
34
15
  def once(event, guards = {}, &block)
35
- @mutex.synchronize do
36
- @handler_id += 1
37
- @handlers[event] << {
38
- id: @handler_id,
39
- guards: guards,
40
- callback: block,
41
- once: true
42
- }
43
- @handler_id
44
- end
16
+ @handlers[event] << { guards: guards, callback: block }
45
17
  end
46
18
 
47
- # Emit an event, triggering all matching handlers
48
- # @param event [Symbol] Event name
49
- # @param data [Hash] Event data to pass to handlers
19
+ # Emit an event, triggering and removing all matching handlers
50
20
  def emit(event, data = {})
51
- handlers_to_call = []
52
- handlers_to_remove = []
53
-
54
- @mutex.synchronize do
55
- @handlers[event].each do |handler|
56
- if guards_match?(handler[:guards], data)
57
- handlers_to_call << handler
58
- handlers_to_remove << handler[:id] if handler[:once]
59
- end
60
- end
61
- end
21
+ matched = []
22
+ remaining = []
62
23
 
63
- handlers_to_call.each { |handler| handler[:callback].call(data) }
64
-
65
- @mutex.synchronize do
66
- handlers_to_remove.each do |id|
67
- @handlers[event].reject! { |h| h[:id] == id }
68
- end
69
- end
70
- end
71
-
72
- # Remove handler(s) for an event
73
- # @param event [Symbol] Event name
74
- # @param handler_id [Integer, nil] Specific handler ID, or nil to remove all
75
- def off(event, handler_id = nil)
76
- @mutex.synchronize do
77
- if handler_id
78
- @handlers[event].reject! { |h| h[:id] == handler_id }
24
+ @handlers[event].each do |handler|
25
+ if guards_match?(handler[:guards], data)
26
+ matched << handler
79
27
  else
80
- @handlers.delete(event)
28
+ remaining << handler
81
29
  end
82
30
  end
31
+
32
+ @handlers[event] = remaining
33
+ matched.each { |handler| handler[:callback].call(data) }
83
34
  end
84
35
 
85
36
  private
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Switest
4
- module ESL
5
- # Parses a `from` string into FreeSWITCH channel variables,
4
+ # Parses a `from` string into FreeSWITCH channel variables,
6
5
  # replicating mod_rayo's parse_dial_from() behavior.
7
6
  #
8
7
  # Algorithm (matching mod_rayo):
@@ -100,6 +99,5 @@ module Switest
100
99
 
101
100
  vars
102
101
  end
103
- end
104
102
  end
105
103
  end
@@ -1,16 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "minitest/test"
4
- require "concurrent"
4
+ require "async"
5
5
 
6
6
  module Switest
7
7
  class Scenario < Minitest::Test
8
+ include Assertions
9
+
8
10
  # Make Agent accessible to subclasses
9
11
  Agent = Switest::Agent
10
12
 
13
+ # Run each test inside an async reactor so that fibers, conditions,
14
+ # and async I/O work transparently for users extending Scenario.
15
+ def run(...)
16
+ Sync { super }
17
+ end
18
+
11
19
  def setup
12
20
  @events = Events.new
13
- @client = ESL::Client.new
21
+ @client = Client.new
14
22
  @client.start
15
23
 
16
24
  # Route inbound calls through events system
@@ -20,7 +28,7 @@ module Switest
20
28
  from: call.from,
21
29
  call: call,
22
30
  headers: call.headers,
23
- profile: call.headers["variable_sofia_profile_name"]
31
+ profile: call.headers[:variable_sofia_profile_name]
24
32
  })
25
33
  end
26
34
 
@@ -37,54 +45,5 @@ module Switest
37
45
  def hangup_all(cause: "NORMAL_CLEARING", timeout: 5)
38
46
  @client&.hangup_all(cause: cause, timeout: timeout)
39
47
  end
40
-
41
- # Assertions
42
- def assert_call(agent, timeout: 5)
43
- success = agent.wait_for_call(timeout: timeout)
44
- assert success, "Expected agent to receive a call within #{timeout} seconds"
45
- end
46
-
47
- def assert_no_call(agent, timeout: 2)
48
- sleep timeout
49
- refute agent.call?, "Expected agent to not have received a call"
50
- end
51
-
52
- def assert_answered(agent, timeout: 5)
53
- assert agent.call?, "Agent has no call"
54
- success = agent.wait_for_answer(timeout: timeout)
55
- assert success, "Expected call to be answered within #{timeout} seconds"
56
- end
57
-
58
- def assert_bridged(agent, timeout: 5)
59
- assert agent.call?, "Agent has no call"
60
- success = agent.wait_for_bridge(timeout: timeout)
61
- assert success, "Expected call to be bridged within #{timeout} seconds"
62
- end
63
-
64
- def assert_hungup(agent, timeout: 5)
65
- assert agent.call?, "Agent has no call"
66
- success = agent.wait_for_end(timeout: timeout)
67
- assert success, "Expected call to be hung up within #{timeout} seconds"
68
- end
69
-
70
- def assert_not_hungup(agent, timeout: 2)
71
- assert agent.call?, "Agent has no call"
72
- sleep timeout
73
- refute agent.ended?, "Expected call to still be active"
74
- end
75
-
76
- def assert_dtmf(agent, expected_dtmf, timeout: 5, after: 1, &block)
77
- assert agent.call?, "Agent has no call"
78
-
79
- if block
80
- agent.flush_dtmf
81
- Concurrent::ScheduledTask.execute(after) { block.call }
82
- received = agent.receive_dtmf(count: expected_dtmf.length, timeout: timeout + after)
83
- else
84
- received = agent.receive_dtmf(count: expected_dtmf.length, timeout: timeout)
85
- end
86
-
87
- assert_equal expected_dtmf, received, "Expected DTMF '#{expected_dtmf}' but received '#{received}'"
88
- end
89
48
  end
90
49
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "librevox"
4
+ require "async/promise"
5
+
6
+ module Switest
7
+ # Inbound ESL session built on librevox.
8
+ #
9
+ # A single connection to FreeSWITCH that handles both commands and events
10
+ # (filtered by UUID). Events are dispatched to Call objects via the
11
+ # call_registry. Unknown CHANNEL_PARK events trigger the offer_handler for
12
+ # inbound call detection.
13
+ #
14
+ # Class-level state (call_registry, offer_handler, connection_promise) is used
15
+ # because librevox instantiates Session internally — we cannot inject
16
+ # dependencies via the constructor. Client sets these before starting the
17
+ # connection and clears them on stop.
18
+ class Session < Librevox::Listener::Inbound
19
+ class << self
20
+ attr_accessor :call_registry # { uuid => Call }
21
+ attr_accessor :offer_handler # Proc for inbound call offers
22
+ attr_accessor :connection_promise # Async::Promise resolved when connected
23
+ end
24
+
25
+ def connection_completed
26
+ self.class.connection_promise&.resolve(self)
27
+ end
28
+
29
+ def on_event(event)
30
+ uuid = event.content[:unique_id]
31
+ call = self.class.call_registry&.[](uuid)
32
+
33
+ case event.event
34
+ when "CHANNEL_CALLSTATE", "CHANNEL_HANGUP_COMPLETE", "DTMF"
35
+ call&.handle_event(event)
36
+ when "CHANNEL_PARK"
37
+ if call
38
+ call.handle_event(event)
39
+ else
40
+ self.class.offer_handler&.call(event)
41
+ end
42
+ end
43
+ end
44
+
45
+ def bgapi(cmd)
46
+ send_message("bgapi #{cmd}")
47
+ end
48
+ end
49
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Switest
4
- VERSION = "0.5.1"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/switest.rb CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
3
  require_relative "switest/version"
4
4
  require_relative "switest/configuration"
5
- require_relative "switest/case_insensitive_hash"
6
5
  require_relative "switest/events"
7
- require_relative "switest/esl/escaper"
8
- require_relative "switest/esl/event"
9
- require_relative "switest/esl/connection"
10
- require_relative "switest/esl/call"
11
- require_relative "switest/esl/client"
6
+ require_relative "switest/escaper"
7
+ require_relative "switest/from_parser"
8
+ require_relative "switest/session"
9
+ require_relative "switest/call"
10
+ require_relative "switest/client"
12
11
  require_relative "switest/agent"
12
+ require_relative "switest/assertions"
13
13
  require_relative "switest/scenario"
14
14
 
15
15
  module Switest
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: switest
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Relatel A/S
@@ -11,19 +11,19 @@ cert_chain: []
11
11
  date: 1980-01-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: concurrent-ruby
14
+ name: librevox
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1.2'
19
+ version: '1.0'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '1.2'
26
+ version: '1.0'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: minitest
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -56,16 +56,15 @@ files:
56
56
  - README.md
57
57
  - lib/switest.rb
58
58
  - lib/switest/agent.rb
59
- - lib/switest/case_insensitive_hash.rb
59
+ - lib/switest/assertions.rb
60
+ - lib/switest/call.rb
61
+ - lib/switest/client.rb
60
62
  - lib/switest/configuration.rb
61
- - lib/switest/esl/call.rb
62
- - lib/switest/esl/client.rb
63
- - lib/switest/esl/connection.rb
64
- - lib/switest/esl/escaper.rb
65
- - lib/switest/esl/event.rb
66
- - lib/switest/esl/from_parser.rb
63
+ - lib/switest/escaper.rb
67
64
  - lib/switest/events.rb
65
+ - lib/switest/from_parser.rb
68
66
  - lib/switest/scenario.rb
67
+ - lib/switest/session.rb
69
68
  - lib/switest/version.rb
70
69
  homepage: https://github.com/relatel/switest
71
70
  licenses:
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Switest
4
- # Hash subclass with case-insensitive key lookup
5
- class CaseInsensitiveHash < Hash
6
- def self.from(hash)
7
- new.merge!(hash)
8
- end
9
-
10
- def [](key)
11
- return super if key?(key)
12
- key_downcase = key.downcase
13
- found_key = keys.find { |k| k.downcase == key_downcase }
14
- found_key ? super(found_key) : nil
15
- end
16
- end
17
- end
@@ -1,209 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "concurrent"
4
-
5
- module Switest
6
- module ESL
7
- class Call
8
- attr_reader :id, :to, :from, :headers, :direction
9
- attr_reader :start_time, :answer_time, :end_time, :end_reason
10
- attr_reader :state
11
-
12
- def initialize(id:, connection:, direction:, to: nil, from: nil, headers: {})
13
- @id = id
14
- @connection = connection
15
- @direction = direction # :inbound or :outbound
16
- @to = to
17
- @from = from
18
- @headers = Switest::CaseInsensitiveHash.from(headers)
19
-
20
- @state = :offered
21
- @start_time = Time.now
22
- @answer_time = nil
23
- @end_time = nil
24
- @end_reason = nil
25
-
26
- @bridged = false
27
- @dtmf_buffer = Queue.new
28
- @callbacks = { answer: [], bridge: [], end: [] }
29
- @mutex = Mutex.new
30
-
31
- @answered_latch = Concurrent::CountDownLatch.new(1)
32
- @bridged_latch = Concurrent::CountDownLatch.new(1)
33
- @ended_latch = Concurrent::CountDownLatch.new(1)
34
- end
35
-
36
- # State queries
37
- def alive?
38
- @state != :ended
39
- end
40
-
41
- def active?
42
- @state == :answered
43
- end
44
-
45
- def answered?
46
- @state == :answered || (@state == :ended && @answer_time)
47
- end
48
-
49
- def ended?
50
- @state == :ended
51
- end
52
-
53
- def bridged?
54
- @bridged
55
- end
56
-
57
- def inbound?
58
- @direction == :inbound
59
- end
60
-
61
- def outbound?
62
- @direction == :outbound
63
- end
64
-
65
- # Actions
66
- def answer(wait: 5)
67
- return unless @state == :offered && inbound?
68
- sendmsg("execute", "answer")
69
- return unless wait
70
- wait_for_answer(timeout: wait)
71
- end
72
-
73
- def hangup(cause = "NORMAL_CLEARING", wait: 5)
74
- return if ended?
75
- msg = +"sendmsg #{@id}\n"
76
- msg << "call-command: hangup\n"
77
- msg << "hangup-cause: #{cause}"
78
- @connection.send_command(msg)
79
- return unless wait
80
- wait_for_end(timeout: wait)
81
- end
82
-
83
- def reject(reason = :decline, wait: 5)
84
- return unless @state == :offered && inbound?
85
- cause = case reason
86
- when :busy then "USER_BUSY"
87
- when :decline then "CALL_REJECTED"
88
- else "CALL_REJECTED"
89
- end
90
- hangup(cause, wait: wait)
91
- end
92
-
93
- def play_audio(url, wait: true)
94
- sendmsg("execute", "playback", url, event_lock: wait)
95
- end
96
-
97
- def send_dtmf(digits, wait: true)
98
- play_audio("tone_stream://d=200;w=250;#{digits}", wait: wait)
99
- end
100
-
101
- def flush_dtmf
102
- @dtmf_buffer.clear
103
- end
104
-
105
- def receive_dtmf(count: 1, timeout: 5)
106
- digits = String.new
107
- deadline = Time.now + timeout
108
-
109
- count.times do
110
- remaining = deadline - Time.now
111
- break if remaining <= 0
112
-
113
- begin
114
- digit = @dtmf_buffer.pop(timeout: remaining)
115
- digits << digit if digit
116
- rescue ThreadError
117
- break # Timeout
118
- end
119
- end
120
-
121
- digits
122
- end
123
-
124
- # Callbacks
125
- def on_answer(&block)
126
- @mutex.synchronize { @callbacks[:answer] << block }
127
- end
128
-
129
- def on_bridge(&block)
130
- @mutex.synchronize { @callbacks[:bridge] << block }
131
- end
132
-
133
- def on_end(&block)
134
- @mutex.synchronize { @callbacks[:end] << block }
135
- end
136
-
137
- # Blocking waits
138
- def wait_for_answer(timeout: 5)
139
- @answered_latch.wait(timeout)
140
- answered?
141
- end
142
-
143
- def wait_for_bridge(timeout: 5)
144
- @bridged_latch.wait(timeout)
145
- bridged?
146
- end
147
-
148
- def wait_for_end(timeout: 5)
149
- @ended_latch.wait(timeout)
150
- ended?
151
- end
152
-
153
- # Internal state updates (called by Client)
154
- def handle_answer
155
- @mutex.synchronize do
156
- return if @state == :ended
157
- @state = :answered
158
- @answer_time = Time.now
159
- end
160
- @answered_latch.count_down
161
- fire_callbacks(:answer)
162
- end
163
-
164
- def handle_bridge
165
- @mutex.synchronize do
166
- return if @state == :ended
167
- @bridged = true
168
- end
169
- @bridged_latch.count_down
170
- fire_callbacks(:bridge)
171
- end
172
-
173
- def handle_hangup(cause, headers = {})
174
- @mutex.synchronize do
175
- return if @state == :ended
176
- @state = :ended
177
- @end_time = Time.now
178
- @end_reason = cause
179
- @headers.merge!(headers)
180
- end
181
- @answered_latch.count_down # Release any waiting threads
182
- @bridged_latch.count_down
183
- @ended_latch.count_down
184
- fire_callbacks(:end)
185
- end
186
-
187
- def handle_dtmf(digit)
188
- @dtmf_buffer.push(digit)
189
- end
190
-
191
- private
192
-
193
- def sendmsg(command, app = nil, arg = nil, event_lock: false)
194
- msg = +"sendmsg #{@id}\n"
195
- msg << "call-command: #{command}\n"
196
- msg << "execute-app-name: #{app}\n" if app
197
- msg << "execute-app-arg: #{arg}\n" if arg
198
- msg << "event-lock: true\n" if event_lock
199
- @connection.send_command(msg.chomp)
200
- end
201
-
202
-
203
- def fire_callbacks(type)
204
- callbacks = @mutex.synchronize { @callbacks[type].dup }
205
- callbacks.each { |cb| cb.call(self) }
206
- end
207
- end
208
- end
209
- end