exsys 0.6 → 1.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.
@@ -7,8 +7,9 @@
7
7
  class FakeHub
8
8
  DEFAULT_PASSWORD = 'pass'.ljust(8)
9
9
 
10
- attr_reader :log, :opens, :locks, :line, :speed, :mode
11
- attr_accessor :password, :silent, :lockable, :garbage
10
+ attr_reader :log, :opens, :locks, :line, :speed, :mode, :lock_path
11
+ attr_accessor :password, :silent, :lockable, :garbage, :lock_error,
12
+ :ident
12
13
 
13
14
  # @param state [Integer] initial port bitmap
14
15
  # @param path [String] file backing the state, so that separate
@@ -18,13 +19,19 @@ class FakeHub
18
19
  # @param delay [Float] pause inside a write, widening the
19
20
  # window a concurrent process could slip
20
21
  # into
22
+ # @param ports [Integer] how many ports this hub has
23
+ # @param width [Integer] hex digits in its state word. Real hubs
24
+ # answer wider than their port count: a
25
+ # 16-port unit answers eight, and the ports
26
+ # it does not have read as 1.
21
27
  def initialize(password: DEFAULT_PASSWORD, state: 0x0000,
28
+ ports: 16, width: 8,
22
29
  path: nil, lock: nil, delay: 0)
23
30
  @password = password
24
31
  @state = state
25
32
  @flash = state
26
33
  @path = path
27
- @lock = lock
34
+ @lock_path = lock
28
35
  @delay = delay
29
36
  @log = []
30
37
  @opens = 0
@@ -32,6 +39,10 @@ class FakeHub
32
39
  @silent = false # hub answers nothing at all
33
40
  @lockable = true # platform allows locking the line
34
41
  @garbage = nil # hub answers this instead, when set
42
+ @lock_error = nil # raised by flock; for the propagation test
43
+ @ports = ports
44
+ @width = width
45
+ @ident = format('CENTOS0005%02dv02', ports) # nil = refuse ?Q
35
46
 
36
47
  # Attach to the hub the file already describes, so that a test
37
48
  # can inspect what its subprocesses did; seed it otherwise.
@@ -57,9 +68,20 @@ class FakeHub
57
68
  # Ports currently powered, as a sorted list -- the oracle the tests
58
69
  # compare against.
59
70
  def ports_on
60
- 1.upto(16).select {|p| state & (1 << (p-1)) != 0 }
71
+ 1.upto(@ports).select {|p| state & (1 << (p-1)) != 0 }
61
72
  end
62
73
 
74
+ # The power-on state the hub would come back to.
75
+ def flash_ports
76
+ 1.upto(@ports).select {|p| flash & (1 << (p-1)) != 0 }
77
+ end
78
+
79
+ # Bits for ports this hub does not have. A real one reports them
80
+ # set, which is why a client must write back what it read.
81
+ def absent = ((1 << (@width * 4)) - 1) & ~real
82
+
83
+ def real = (1 << @ports) - 1
84
+
63
85
  # Record how the line was opened, so a test can check the library
64
86
  # asks for the device and the speed the hub actually needs.
65
87
  def opened(line, speed, mode)
@@ -67,18 +89,13 @@ class FakeHub
67
89
  @opens += 1
68
90
  end
69
91
 
70
- def flock(mode)
71
- raise Errno::ENOTSUP unless @lockable
92
+ # Called by each opened line before it locks. The lock handle
93
+ # itself belongs to the line, not to the hub, so that two threads
94
+ # hold two of them and genuinely contend.
95
+ def lock_attempted
96
+ raise @lock_error if @lock_error # must NOT be swallowed
97
+ raise Errno::ENOTSUP unless @lockable # platform refusal
72
98
  @locks += 1
73
- return 0 if @lock.nil?
74
-
75
- @lockfh = File.open(@lock, File::RDWR|File::CREAT, 0o600)
76
- @lockfh.flock(mode)
77
- end
78
-
79
- def unlock
80
- @lockfh&.close
81
- @lockfh = nil
82
99
  end
83
100
 
84
101
  # Answer one command, as the hub would.
@@ -99,9 +116,10 @@ class FakeHub
99
116
  code = cmd[0, 2]
100
117
  args = cmd[2..].to_s
101
118
 
102
- # GP is the one command the hub answers unauthenticated, with a
103
- # bare 8-digit payload rather than a G/E status.
104
- return encode(@state) + 'FFFF' if code == 'GP'
119
+ # GP and ?Q are the two commands the hub answers without a
120
+ # password, both with a bare payload rather than a G/E status.
121
+ return encode(@state | absent) if code == 'GP'
122
+ return @ident || 'E01' if code == '?Q'
105
123
 
106
124
  return 'E01' unless args.start_with?(@password)
107
125
  rest = args[@password.size..]
@@ -109,21 +127,29 @@ class FakeHub
109
127
  sleep @delay if @delay.positive?
110
128
 
111
129
  case code
112
- when 'SP' then @state = decode(rest) ; 'G'
113
- when 'FP' then @state = @flash = decode(rest) ; 'G'
130
+ when 'SP' then @state = decode(rest) & real ; 'G'
131
+ when 'FP' then @state = @flash = decode(rest) & real ; 'G'
114
132
  when 'WP' then @flash = @state ; 'G'
115
- when 'RD' then @state = @flash ; 'G'
133
+ when 'RD' then @state = @flash = 0
134
+ @password = DEFAULT_PASSWORD ; 'G'
116
135
  when 'RH' then @state = @flash ; nil
117
136
  when 'CP' then @password = rest ; 'G'
118
137
  else 'E02'
119
138
  end
120
139
  end
121
140
 
122
- # Little-endian 16-bit, spelled out rather than packed. Upper case
123
- # because that is what a real hub answers -- observed on an ExSYS
124
- # 16-port unit, which replies to GP with e.g. "C4FFFFFF".
125
- def encode(v) = format('%02X%02X', v & 0xff, (v >> 8) & 0xff)
126
- def decode(s) = s[0, 2].to_i(16) | (s[2, 2].to_i(16) << 8)
141
+ # Little-endian, any width, spelled out rather than packed. Upper
142
+ # case because that is what a real hub answers -- observed on an
143
+ # ExSYS 16-port unit, which replies to GP with "C4FFFFFF".
144
+ def encode(v)
145
+ (@width / 2).times.map {|i| format('%02X', (v >> (8 * i)) & 0xff) }
146
+ .join
147
+ end
148
+
149
+ def decode(str)
150
+ str.scan(/\h\h/).each_with_index
151
+ .sum {|byte, i| byte.to_i(16) << (8 * i) }
152
+ end
127
153
 
128
154
  def load
129
155
  return if @path.nil? || !File.exist?(@path)
data/test/support/uart.rb CHANGED
@@ -18,12 +18,29 @@ module UART
18
18
  def reset! = @hub = nil
19
19
  end
20
20
 
21
- # Stands in for the File that UART.open normally yields.
21
+ # Stands in for the File that UART.open normally yields. Each one
22
+ # carries its own lock handle, as a real open would, so that two of
23
+ # them contend instead of sharing one.
22
24
  class Serial
23
- def initialize(hub) = @hub = hub
24
- def flock(mode) = @hub.flock(mode)
25
- def write(cmd) = @reply = @hub.command(cmd.chomp("\r"))
26
- def read = @reply.nil? ? '' : "#{@reply}\r"
25
+ def initialize(hub)
26
+ @hub = hub
27
+ @lock = hub.lock_path &&
28
+ File.open(hub.lock_path, File::RDWR|File::CREAT, 0o600)
29
+ end
30
+
31
+ def flock(mode)
32
+ @hub.lock_attempted
33
+ @lock ? @lock.flock(mode) : 0
34
+ end
35
+
36
+ def write(cmd) = @reply = @hub.command(cmd.chomp("\r"))
37
+
38
+ # A real hub ends every reply CR LF -- seen in the hex off the
39
+ # wire -- so a read to the terminator takes the whole line.
40
+ def gets(_sep = "\n")
41
+ @reply.nil? ? nil : "#{@reply}\r\n"
42
+ end
43
+ def close = @lock&.close
27
44
  end
28
45
 
29
46
  def self.open(line, speed = 9600, mode = '8N1')
@@ -34,7 +51,7 @@ module UART
34
51
  begin
35
52
  yield serial
36
53
  ensure
37
- hub.unlock
54
+ serial.close
38
55
  end
39
56
  end
40
57
  end
@@ -50,19 +50,104 @@ class TestExsysUsb < Minitest::Test
50
50
  assert_equal [ 3 ], hub.ports_on
51
51
  end
52
52
 
53
- def test_commit_and_restore
53
+ def test_commit_saves_the_power_on_state
54
54
  exsys_usb('on', '1')
55
55
  exsys_usb('commit')
56
56
  exsys_usb('on', '2')
57
- exsys_usb('restore')
58
- assert_equal [ 1 ], hub.ports_on
57
+ assert_equal [ 1, 2 ], hub.ports_on
58
+ assert_equal [ 1 ], hub.flash_ports
59
59
  end
60
60
 
61
61
  def test_commit_flag_writes_through
62
62
  exsys_usb('-c', 'on', '4')
63
63
  exsys_usb('on', '5')
64
- exsys_usb('restore')
65
- assert_equal [ 4 ], hub.ports_on
64
+ assert_equal [ 4, 5 ], hub.ports_on
65
+ assert_equal [ 4 ], hub.flash_ports
66
+ end
67
+
68
+ # restore issues RD, the hub's factory reset -- not the inverse of
69
+ # commit. Nothing in the protocol reloads the flashed state.
70
+ def test_restore_is_a_factory_reset
71
+ exsys_usb('on', '1')
72
+ exsys_usb('commit')
73
+ _, err, st = exsys_usb('--yes', 'factory-reset')
74
+ assert_equal 0, st.exitstatus, err
75
+ assert_empty hub.ports_on
76
+ assert_empty hub.flash_ports
77
+ end
78
+
79
+ ## Reading the hub ##################################################
80
+
81
+ def test_status_lists_every_port
82
+ exsys_usb('on', '1', '3')
83
+ out, _, st = exsys_usb('status')
84
+ assert_equal 0, st.exitstatus
85
+ assert_equal 16, out.lines.size
86
+ assert_includes out.lines, "1 on\n"
87
+ assert_includes out.lines, "2 off\n"
88
+ assert_includes out.lines, "3 on\n"
89
+ end
90
+
91
+ def test_status_can_be_asked_about_named_ports
92
+ exsys_usb('on', '3')
93
+ out, _, = exsys_usb('status', '3', '4')
94
+ assert_equal "3 on\n4 off\n", out
95
+ end
96
+
97
+ def test_status_refuses_a_port_the_hub_does_not_have
98
+ _, err, st = exsys_usb('status', '99')
99
+ assert_equal 1, st.exitstatus
100
+ assert_match(/invalid port: 99/, err)
101
+ end
102
+
103
+ def test_query_reports_what_the_hub_says_it_is
104
+ out, _, st = exsys_usb('query')
105
+ assert_equal 0, st.exitstatus
106
+ assert_match(/^id:\s+CENTOS$/, out)
107
+ assert_match(/^ports:\s+16$/, out)
108
+ assert_match(/^firmware:\s+v02$/, out)
109
+ end
110
+
111
+ ## Verbose ##########################################################
112
+
113
+ def test_verbose_reports_the_state_after_a_change
114
+ out, _, = exsys_usb('-v', 'on', '2')
115
+ assert_includes out.lines, "2 on\n"
116
+ assert_equal 16, out.lines.size
117
+ end
118
+
119
+ def test_without_verbose_a_change_says_nothing
120
+ out, _, st = exsys_usb('on', '2')
121
+ assert_equal 0, st.exitstatus
122
+ assert_empty out
123
+ end
124
+
125
+ ## Confirming the irreversible ######################################
126
+
127
+ def test_reset_refuses_without_yes
128
+ _, err, st = exsys_usb('reset')
129
+ assert_equal 1, st.exitstatus
130
+ assert_match(/--yes/, err)
131
+ end
132
+
133
+ def test_reset_runs_when_meant
134
+ _, err, st = exsys_usb('--yes', 'reset')
135
+ assert_equal 0, st.exitstatus, err
136
+ end
137
+
138
+ def test_factory_reset_refuses_without_yes
139
+ exsys_usb('on', '1')
140
+ _, err, st = exsys_usb('factory-reset')
141
+ assert_equal 1, st.exitstatus
142
+ assert_match(/--yes/, err)
143
+ assert_equal [ 1 ], hub.ports_on, 'the hub must not have been touched'
144
+ end
145
+
146
+ def test_factory_reset_runs_when_meant
147
+ exsys_usb('on', '1')
148
+ _, err, st = exsys_usb('--yes', 'factory-reset')
149
+ assert_equal 0, st.exitstatus, err
150
+ assert_empty hub.ports_on
66
151
  end
67
152
 
68
153
  ## Exit status -- regression. Every failure used to be printed and
@@ -220,6 +305,16 @@ class TestExsysUsb < Minitest::Test
220
305
  assert_equal [ 1, 2 ], hub.ports_on
221
306
  end
222
307
 
308
+ # Typing the old name must not silently do nothing, nor quietly do
309
+ # the reset: it must say what the command actually is.
310
+ def test_the_old_restore_action_explains_itself
311
+ _, err, st = exsys_usb('restore')
312
+ assert_equal 1, st.exitstatus
313
+ assert_match(/renamed factory-reset/, err)
314
+ assert_match(/factory defaults/, err)
315
+ assert_equal [], hub.ports_on
316
+ end
317
+
223
318
  ## Packaging #########################################################
224
319
 
225
320
  # Regression: the file carried a shebang but no execute bit, so a
@@ -11,17 +11,37 @@ class TestManagedUSB < Minitest::Test
11
11
 
12
12
  ## Switching #########################################################
13
13
 
14
- def test_on_without_argument_powers_every_port
15
- @usb.on
14
+ def test_all_powers_every_port
15
+ @usb.on(ExSYS::ManagedUSB::ALL)
16
16
  assert_equal ExSYS::ManagedUSB::PORTS, @hub.ports_on
17
17
  end
18
18
 
19
- def test_off_without_argument_powers_nothing
20
- @usb.on
21
- @usb.off
19
+ def test_all_unpowers_every_port
20
+ @usb.on(:all)
21
+ @usb.off(:all)
22
22
  assert_empty @hub.ports_on
23
23
  end
24
24
 
25
+ # Regression: an empty list used to mean every port, so a caller
26
+ # splatting a computed list that came back empty switched all
27
+ # sixteen. on(*[]) and on() are the same call, so neither can be
28
+ # allowed through.
29
+ def test_an_empty_port_list_is_refused_not_taken_as_every_port
30
+ @usb.on(:all)
31
+ [ ->{ @usb.on(*[]) }, ->{ @usb.on },
32
+ ->{ @usb.off(*[]) }, ->{ @usb.off },
33
+ ->{ @usb.toggle(*[]) }, ->{ @usb.toggle } ].each do |op|
34
+ err = assert_raises(ArgumentError, &op)
35
+ assert_match(/no port given/, err.message)
36
+ end
37
+ assert_equal ExSYS::ManagedUSB::PORTS, @hub.ports_on,
38
+ 'the hub must not have been touched'
39
+ end
40
+
41
+ def test_all_cannot_be_mixed_with_port_numbers
42
+ assert_raises(ArgumentError) { @usb.on(:all, 3) }
43
+ end
44
+
25
45
  def test_on_and_off_are_restricted_to_the_named_ports
26
46
  @usb.on(1, 2, 16)
27
47
  assert_equal [ 1, 2, 16 ], @hub.ports_on
@@ -36,8 +56,8 @@ class TestManagedUSB < Minitest::Test
36
56
  end
37
57
 
38
58
  def test_switching_returns_self_so_calls_chain
39
- assert_same @usb, @usb.on
40
- @usb.on.off(4, 5, 6)
59
+ assert_same @usb, @usb.on(:all)
60
+ @usb.on(:all).off(4, 5, 6)
41
61
  assert_equal ExSYS::ManagedUSB::PORTS - [ 4, 5, 6 ], @hub.ports_on
42
62
  end
43
63
 
@@ -141,7 +161,7 @@ class TestManagedUSB < Minitest::Test
141
161
  def test_get_on_off_always_carries_both_keys
142
162
  assert_equal({ :on => [], :off => ExSYS::ManagedUSB::PORTS },
143
163
  @usb.get(:on_off))
144
- @usb.on
164
+ @usb.on(:all)
145
165
  assert_equal({ :on => ExSYS::ManagedUSB::PORTS, :off => [] },
146
166
  @usb.get(:on_off))
147
167
  @usb.off(1)
@@ -151,6 +171,7 @@ class TestManagedUSB < Minitest::Test
151
171
  # A real hub answers in upper case; accept either, since the reply
152
172
  # is only ever fed to pack('H4'), which does not care.
153
173
  def test_a_lower_case_reply_decodes_the_same
174
+ @usb.port_count # settle ?Q before garbling every reply
154
175
  @hub.garbage = 'c4ffffff'
155
176
  assert_equal [ 3, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 ], @usb.get(:on)
156
177
  @hub.garbage = 'C4FFFFFF'
@@ -163,24 +184,191 @@ class TestManagedUSB < Minitest::Test
163
184
 
164
185
  ## Flash and reset ###################################################
165
186
 
166
- def test_commit_saves_and_restore_brings_back
187
+ def test_commit_saves_the_state_as_the_power_on_state
167
188
  @usb.on(1)
168
189
  @usb.commit
169
- @usb.on(2)
170
- assert_equal [ 1, 2 ], @hub.ports_on
171
- @usb.restore
172
- assert_equal [ 1 ], @hub.ports_on
190
+ assert_equal [ 1 ], @hub.flash_ports
191
+ end
192
+
193
+ # RD is the hub's "restore factory defaults", not the inverse of
194
+ # commit: there is no command that reloads the flashed state, the
195
+ # hub applies it at power-on by itself. Modelled from the vendor's
196
+ # own documentation -- running it against hardware would drop every
197
+ # port on the bench and reset the password.
198
+ ## Describing the hub ###############################################
199
+
200
+ def test_query_reports_id_ports_and_firmware
201
+ assert_equal({ :id => 'CENTOS', :ports => 16, :firmware => 'v02',
202
+ :raw => 'CENTOS000516v02' }, @usb.query)
203
+ end
204
+
205
+ def test_query_needs_no_password
206
+ usb = ExSYS::ManagedUSB.new('/dev/null', 'wrong')
207
+ assert_equal 16, usb.query[:ports]
208
+ end
209
+
210
+ # The hub is asked how many ports it has before it is read, not
211
+ # somewhere in the middle: a trace of any port operation reads the
212
+ # same way, and an invalid port is refused without touching it.
213
+ def test_the_query_leads_and_happens_once
214
+ @usb.on(1)
215
+ assert_equal %w[?Q GP SP], (@hub.log.map {|c| c[0, 2] })
216
+
217
+ @hub.log.clear
218
+ @usb.off(2)
219
+ assert_equal %w[GP SP], (@hub.log.map {|c| c[0, 2] })
220
+ end
221
+
222
+ def test_an_invalid_port_is_refused_before_the_hub_is_read
223
+ assert_raises(ArgumentError) { @usb.on(99) }
224
+ assert_equal %w[?Q], (@hub.log.map {|c| c[0, 2] })
225
+ end
226
+
227
+ def test_port_count_is_asked_once_and_remembered
228
+ 3.times { @usb.port_count }
229
+ assert_equal 1, @hub.log.count('?Q')
230
+ end
231
+
232
+ # Firmware that does not know ?Q answers an error, and the count
233
+ # falls back to what the state word can address.
234
+ def test_port_count_falls_back_when_the_hub_refuses_the_query
235
+ @hub.ident = nil # answers E01
236
+ assert_equal ExSYS::ManagedUSB::PORTS.size, @usb.port_count
237
+ assert_match(/will not answer/, @dbg.string)
238
+ end
239
+
240
+ # A reply that arrives but cannot be read is a different thing from
241
+ # a refusal, and must not be guessed at: on a wider hub a guess of
242
+ # sixteen would leave its upper ports untouched while reporting
243
+ # success.
244
+ def test_an_unreadable_query_reply_does_not_become_a_guess
245
+ @hub.ident = 'CENTOS-garbled'
246
+ assert_raises(ExSYS::ManagedUSB::Error) { @usb.port_count }
247
+ assert_raises(ExSYS::ManagedUSB::Error) { @usb.on(:all) }
248
+ end
249
+
250
+ # The firmware is whatever follows the count, not a bare number:
251
+ # a hub reporting v1.02 must not fall back to a guessed count.
252
+ def test_a_dotted_firmware_version_still_parses
253
+ @hub.ident = 'CENTOS000532v1.02'
254
+ assert_equal 32, @usb.query[:ports]
255
+ assert_equal 'v1.02', @usb.query[:firmware]
256
+ assert_equal 32, @usb.port_count
257
+ end
258
+
259
+ def test_an_unparseable_query_reply_is_an_error
260
+ @hub.ident = 'wat'
261
+ err = assert_raises(ExSYS::ManagedUSB::Error) { @usb.query }
262
+ assert_match(/unexpected query reply/, err.message)
263
+ end
264
+
265
+ # ALL means every port the hub says it has. Confirmed against the
266
+ # vendor tool, which reads the same field and reports 4, 8, 16 or
267
+ # 32 ports from it.
268
+ def test_all_covers_exactly_the_ports_the_hub_reports
269
+ @hub.ident = 'CENTOS000504v02' # a hub with 4 ports
270
+ @usb.on(:all)
271
+ assert_equal [ 1, 2, 3, 4 ], @hub.ports_on
272
+ assert_equal [ 1, 2, 3, 4 ], @usb.ports
273
+ end
274
+
275
+ def test_a_port_the_hub_does_not_have_is_refused
276
+ @hub.ident = 'CENTOS000504v02'
277
+ assert_raises(ArgumentError) { @usb.on(5) }
278
+ end
279
+
280
+ ## Hubs that are not sixteen ports ##################################
281
+
282
+ # Regression: the state word's upper half was written as a literal
283
+ # 'ffff' rather than carried back from the read. On a 16-port hub
284
+ # those bits are ports that do not exist and it went unnoticed; on
285
+ # a 32-port hub it powered ports 17..32 on every single operation.
286
+ def test_a_wider_hub_does_not_have_its_upper_ports_driven
287
+ UART.hub = wide = FakeHub.new(ports: 32, width: 8)
288
+ usb = ExSYS::ManagedUSB.new('/dev/null')
289
+
290
+ usb.on(1)
291
+ assert_equal [ 1 ], wide.ports_on
292
+ end
293
+
294
+ def test_a_wider_hub_can_use_all_of_its_ports
295
+ UART.hub = wide = FakeHub.new(ports: 32, width: 8)
296
+ usb = ExSYS::ManagedUSB.new('/dev/null')
297
+
298
+ assert_equal 32, usb.port_count
299
+ usb.on(:all)
300
+ assert_equal (1..32).to_a, wide.ports_on
301
+ usb.off(20)
302
+ assert_equal (1..32).to_a - [ 20 ], wide.ports_on
303
+ end
304
+
305
+ def test_a_narrower_hub_reports_only_the_ports_it_has
306
+ UART.hub = small = FakeHub.new(ports: 4, width: 8)
307
+ usb = ExSYS::ManagedUSB.new('/dev/null')
308
+
309
+ assert_equal [ 1, 2, 3, 4 ], usb.ports
310
+ usb.on(:all)
311
+ assert_equal [ 1, 2, 3, 4 ], small.ports_on
312
+ assert_equal({ 1 => true, 2 => true, 3 => true, 4 => true },
313
+ usb.get)
314
+ assert_raises(ArgumentError) { usb.on(5) }
315
+ end
316
+
317
+ # The ports it does not have read as 1, and must be written back
318
+ # that way rather than cleared.
319
+ def test_absent_ports_are_carried_back_untouched
320
+ UART.hub = small = FakeHub.new(ports: 4, width: 8)
321
+ usb = ExSYS::ManagedUSB.new('/dev/null')
322
+
323
+ usb.on(1)
324
+ # Little-endian: ports 1..8 in the first byte, so port 1 on with
325
+ # ports 5..32 absent and reading 1 is F1 FF FF FF.
326
+ assert_equal 'F1FFFFFF', small.log.last[-8..],
327
+ 'absent ports written back as read'
328
+ end
329
+
330
+ ## Factory reset #####################################################
331
+
332
+ def test_the_old_restore_name_is_a_tombstone
333
+ err = assert_raises(NoMethodError) { @usb.restore }
334
+ assert_match(/renamed factory_reset/, err.message)
335
+ assert_match(/not the inverse of commit/, err.message)
336
+ end
337
+
338
+ def test_factory_reset_refuses_unless_it_is_confirmed
339
+ @usb.on(:all)
340
+ err = assert_raises(ArgumentError) { @usb.factory_reset }
341
+ assert_match(/confirm: true/, err.message)
342
+ assert_equal ExSYS::ManagedUSB::PORTS, @hub.ports_on,
343
+ 'the hub must not have been touched'
344
+ end
345
+
346
+ def test_restore_is_a_factory_reset
347
+ @usb.on(1)
348
+ @usb.commit
349
+ @usb.on(:all)
350
+ @usb.factory_reset(confirm: true)
351
+ assert_empty @hub.ports_on, 'every port dropped'
352
+ assert_empty @hub.flash_ports, 'the power-on state went too'
353
+ assert_equal FakeHub::DEFAULT_PASSWORD, @hub.password
173
354
  end
174
355
 
175
356
  def test_on_with_commit_writes_through_to_flash
176
357
  @usb.on(3, commit: true)
177
358
  @usb.on(4)
178
- @usb.restore
179
- assert_equal [ 3 ], @hub.ports_on
359
+ assert_equal [ 3, 4 ], @hub.ports_on
360
+ assert_equal [ 3 ], @hub.flash_ports
180
361
  end
181
362
 
182
363
  def test_reset_expects_no_reply
183
- assert_same @usb, @usb.reset
364
+ assert_same @usb, @usb.reset(confirm: true)
365
+ end
366
+
367
+ def test_reset_refuses_unless_it_is_confirmed
368
+ @usb.on(:all)
369
+ err = assert_raises(ArgumentError) { @usb.reset }
370
+ assert_match(/confirm: true/, err.message)
371
+ refute_includes @hub.log.map {|c| c[0, 2] }, 'RH'
184
372
  end
185
373
 
186
374
  ## Password ##########################################################
@@ -257,14 +445,16 @@ class TestManagedUSB < Minitest::Test
257
445
  # Regression: a read-modify-write used to close the line between the
258
446
  # GP and the SP, letting another process slip in between the two.
259
447
  def test_read_modify_write_holds_the_line_open
448
+ @usb.port_count # the one-off ?Q, out of the way
449
+ @hub.log.clear
260
450
  @usb.on(1)
261
- assert_equal 1, @hub.opens
262
451
  assert_equal 2, @hub.log.size # GP then SP, one session
452
+ assert_equal %w[GP SP], (@hub.log.map {|c| c[0, 2] })
263
453
  end
264
454
 
265
455
  def test_set_holds_the_line_open_too
266
456
  @usb.set({ 1 => true })
267
- assert_equal 1, @hub.opens
457
+ assert_equal 1, @hub.opens # ?Q, GP and SP all inside it
268
458
  end
269
459
 
270
460
  def test_the_line_is_opened_as_the_hub_expects
@@ -280,6 +470,66 @@ class TestManagedUSB < Minitest::Test
280
470
  assert_equal 1, @hub.locks
281
471
  end
282
472
 
473
+ # Regression: EBADF and EINVAL were swallowed alongside the genuine
474
+ # "this platform will not lock this" errors. Both mean a bug here,
475
+ # and swallowing one runs unlocked while reporting success.
476
+ def test_an_unexpected_lock_error_is_not_swallowed
477
+ @hub.lock_error = Errno::EBADF
478
+ assert_raises(Errno::EBADF) { @usb.on(1) }
479
+ end
480
+
481
+ ## Sessions #########################################################
482
+
483
+ def test_session_yields_the_hub_and_returns_the_block_value
484
+ result = @usb.session {|h| assert_same @usb, h; :done }
485
+ assert_equal :done, result
486
+ end
487
+
488
+ def test_one_session_holds_one_line_for_every_call_inside_it
489
+ @usb.session do
490
+ @usb.get
491
+ @usb.on(1)
492
+ @usb.off(1)
493
+ end
494
+ assert_equal 1, @hub.opens
495
+ assert_equal 1, @hub.locks
496
+ end
497
+
498
+ def test_without_a_session_each_call_opens_its_own_line
499
+ @usb.get
500
+ @usb.on(1)
501
+ assert_equal 2, @hub.opens
502
+ end
503
+
504
+ def test_a_session_releases_the_line_even_when_the_block_raises
505
+ assert_raises(RuntimeError) { @usb.session { raise 'boom' } }
506
+ @usb.on(1) # must open afresh, not reuse
507
+ assert_equal 2, @hub.opens
508
+ assert_equal [ 1 ], @hub.ports_on
509
+ end
510
+
511
+ # Regression: the open line was plain instance state, so a second
512
+ # thread either borrowed a line it held no lock on -- losing one
513
+ # thread's change -- or had it closed underneath it by the first
514
+ # thread's ensure, which surfaced as a NoMethodError on nil.
515
+ def test_two_threads_never_share_one_line
516
+ Dir.mktmpdir('exsys-threads') do |dir|
517
+ fake = FakeHub.new(path: File.join(dir, 'hub'),
518
+ lock: File.join(dir, 'lock'), delay: 0.2)
519
+ UART.hub = fake
520
+ usb = ExSYS::ManagedUSB.new('/dev/null')
521
+
522
+ a = Thread.new { usb.on(1) }
523
+ sleep 0.1 # B enters while A holds the line
524
+ b = Thread.new { usb.on(2) }
525
+ [ a, b ].each(&:join)
526
+
527
+ assert_equal 2, fake.opens, 'each thread opens its own line'
528
+ assert_equal 2, fake.locks, 'each thread takes its own lock'
529
+ assert_equal [ 1, 2 ], fake.ports_on
530
+ end
531
+ end
532
+
283
533
  # A platform that will not lock a character device must not make the
284
534
  # tool unusable; it degrades to unlocked and says so.
285
535
  def test_an_unlockable_line_degrades_rather_than_failing
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: exsys
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.6'
4
+ version: '1.0'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stéphane D'Alu