exsys 0.5 → 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.
- checksums.yaml +4 -4
- data/README.md +211 -19
- data/Rakefile +19 -0
- data/bin/exsys-usb +77 -20
- data/exsys.gemspec +4 -2
- data/lib/exsys/managed-usb.rb +399 -79
- data/lib/exsys/version.rb +1 -1
- data/test/helper.rb +60 -0
- data/test/support/fake_hub.rb +165 -0
- data/test/support/uart.rb +57 -0
- data/test/test_exsys_usb.rb +325 -0
- data/test/test_managed_usb.rb +541 -0
- data/test/test_readme.rb +61 -0
- metadata +24 -6
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
require_relative 'helper'
|
|
2
|
+
|
|
3
|
+
# Library-level behaviour of ExSYS::ManagedUSB, driven against FakeHub.
|
|
4
|
+
class TestManagedUSB < Minitest::Test
|
|
5
|
+
def setup
|
|
6
|
+
UART.reset!
|
|
7
|
+
UART.hub = @hub = FakeHub.new
|
|
8
|
+
@dbg = StringIO.new
|
|
9
|
+
@usb = ExSYS::ManagedUSB.new('/dev/null', debug: @dbg)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
## Switching #########################################################
|
|
13
|
+
|
|
14
|
+
def test_all_powers_every_port
|
|
15
|
+
@usb.on(ExSYS::ManagedUSB::ALL)
|
|
16
|
+
assert_equal ExSYS::ManagedUSB::PORTS, @hub.ports_on
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_all_unpowers_every_port
|
|
20
|
+
@usb.on(:all)
|
|
21
|
+
@usb.off(:all)
|
|
22
|
+
assert_empty @hub.ports_on
|
|
23
|
+
end
|
|
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
|
+
|
|
45
|
+
def test_on_and_off_are_restricted_to_the_named_ports
|
|
46
|
+
@usb.on(1, 2, 16)
|
|
47
|
+
assert_equal [ 1, 2, 16 ], @hub.ports_on
|
|
48
|
+
@usb.off(2)
|
|
49
|
+
assert_equal [ 1, 16 ], @hub.ports_on
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def test_toggle_inverts_the_named_ports_only
|
|
53
|
+
@usb.on(1, 3)
|
|
54
|
+
@usb.toggle(3, 4)
|
|
55
|
+
assert_equal [ 1, 4 ], @hub.ports_on
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def test_switching_returns_self_so_calls_chain
|
|
59
|
+
assert_same @usb, @usb.on(:all)
|
|
60
|
+
@usb.on(:all).off(4, 5, 6)
|
|
61
|
+
assert_equal ExSYS::ManagedUSB::PORTS - [ 4, 5, 6 ], @hub.ports_on
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
## Port validation -- regression, ports outside 1..16 used to be
|
|
65
|
+
## accepted and then silently shifted or truncated away, so that the
|
|
66
|
+
## hub was rewritten with its existing state and the caller was told
|
|
67
|
+
## the switch had happened.
|
|
68
|
+
######################################################################
|
|
69
|
+
|
|
70
|
+
def test_port_zero_is_rejected_by_every_entry_point
|
|
71
|
+
[ ->{ @usb.on(0) }, ->{ @usb.off(0) },
|
|
72
|
+
->{ @usb.toggle(0) }, ->{ @usb.set({ :on => [ 0 ] }) } ].each do |op|
|
|
73
|
+
assert_raises(ArgumentError, &op)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def test_port_above_the_last_one_is_rejected
|
|
78
|
+
[ 17, 99, 1_000 ].each do |p|
|
|
79
|
+
err = assert_raises(ArgumentError) { @usb.on(p) }
|
|
80
|
+
assert_match(/invalid port/, err.message)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def test_negative_port_is_rejected
|
|
85
|
+
assert_raises(ArgumentError) { @usb.on(-1) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def test_a_rejected_port_leaves_the_hub_untouched
|
|
89
|
+
@usb.on(1)
|
|
90
|
+
assert_raises(ArgumentError) { @usb.on(17) }
|
|
91
|
+
assert_equal [ 1 ], @hub.ports_on
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def test_set_rejects_an_out_of_range_port_in_either_notation
|
|
95
|
+
assert_raises(ArgumentError) { @usb.set({ 99 => :on }) }
|
|
96
|
+
assert_raises(ArgumentError) { @usb.set({ :on => [ 99 ] }) }
|
|
97
|
+
assert_raises(ArgumentError) { @usb.set({ :off => [ 0 ] }) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
## set ###############################################################
|
|
101
|
+
|
|
102
|
+
def test_set_by_port_value
|
|
103
|
+
@usb.set({ 1 => true, 2 => false, 3 => :on, 4 => :OFF, 5 => 1 })
|
|
104
|
+
assert_equal [ 1, 3, 5 ], @hub.ports_on
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def test_set_by_port_state
|
|
108
|
+
@usb.set({ :on => [ 1, 3 ], :off => 4 })
|
|
109
|
+
assert_equal [ 1, 3 ], @hub.ports_on
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def test_set_leaves_unlisted_ports_alone_without_a_default
|
|
113
|
+
@usb.on(8)
|
|
114
|
+
@usb.set({ 1 => true })
|
|
115
|
+
assert_equal [ 1, 8 ], @hub.ports_on
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def test_set_applies_the_default_to_unlisted_ports
|
|
119
|
+
@usb.on(8)
|
|
120
|
+
@usb.set({ 1 => true }, false)
|
|
121
|
+
assert_equal [ 1 ], @hub.ports_on
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def test_set_treats_an_explicit_nil_as_leave_as_is
|
|
125
|
+
@usb.on(8)
|
|
126
|
+
@usb.set({ 1 => true, 8 => nil }, false)
|
|
127
|
+
assert_equal [ 1, 8 ], @hub.ports_on
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def test_set_refuses_a_port_named_both_on_and_off
|
|
131
|
+
err = assert_raises(ArgumentError) do
|
|
132
|
+
@usb.set({ :on => [ 1 ], :off => [ 1 ] })
|
|
133
|
+
end
|
|
134
|
+
assert_match(/overlap/, err.message)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def test_set_refuses_a_mixed_or_unknown_notation
|
|
138
|
+
assert_raises(ArgumentError) { @usb.set({ :on => [ 1 ], 2 => :off }) }
|
|
139
|
+
assert_raises(ArgumentError) { @usb.set({ :bogus => [ 1 ] }) }
|
|
140
|
+
assert_raises(ArgumentError) { @usb.set({ 1 => :perhaps }) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
## get ###############################################################
|
|
144
|
+
|
|
145
|
+
def test_get_ports
|
|
146
|
+
@usb.on(2)
|
|
147
|
+
assert_equal true, @usb.get[2]
|
|
148
|
+
assert_equal false, @usb.get[3]
|
|
149
|
+
assert_equal ExSYS::ManagedUSB::PORTS, @usb.get.keys
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def test_get_on_and_off_lists
|
|
153
|
+
@usb.on(2, 5)
|
|
154
|
+
assert_equal [ 2, 5 ], @usb.get(:on)
|
|
155
|
+
assert_equal ExSYS::ManagedUSB::PORTS - [ 2, 5 ], @usb.get(:off)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Regression: the :on_off hash used to grow a key only for a state
|
|
159
|
+
# that actually occurred, so a hub with every port alike answered
|
|
160
|
+
# with one key and a caller reading the other got nil.
|
|
161
|
+
def test_get_on_off_always_carries_both_keys
|
|
162
|
+
assert_equal({ :on => [], :off => ExSYS::ManagedUSB::PORTS },
|
|
163
|
+
@usb.get(:on_off))
|
|
164
|
+
@usb.on(:all)
|
|
165
|
+
assert_equal({ :on => ExSYS::ManagedUSB::PORTS, :off => [] },
|
|
166
|
+
@usb.get(:on_off))
|
|
167
|
+
@usb.off(1)
|
|
168
|
+
assert_equal [ 1 ], @usb.get(:on_off)[:off]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# A real hub answers in upper case; accept either, since the reply
|
|
172
|
+
# is only ever fed to pack('H4'), which does not care.
|
|
173
|
+
def test_a_lower_case_reply_decodes_the_same
|
|
174
|
+
@usb.port_count # settle ?Q before garbling every reply
|
|
175
|
+
@hub.garbage = 'c4ffffff'
|
|
176
|
+
assert_equal [ 3, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 ], @usb.get(:on)
|
|
177
|
+
@hub.garbage = 'C4FFFFFF'
|
|
178
|
+
assert_equal [ 3, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 ], @usb.get(:on)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def test_get_refuses_an_unknown_type
|
|
182
|
+
assert_raises(ArgumentError) { @usb.get(:bogus) }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
## Flash and reset ###################################################
|
|
186
|
+
|
|
187
|
+
def test_commit_saves_the_state_as_the_power_on_state
|
|
188
|
+
@usb.on(1)
|
|
189
|
+
@usb.commit
|
|
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
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def test_on_with_commit_writes_through_to_flash
|
|
357
|
+
@usb.on(3, commit: true)
|
|
358
|
+
@usb.on(4)
|
|
359
|
+
assert_equal [ 3, 4 ], @hub.ports_on
|
|
360
|
+
assert_equal [ 3 ], @hub.flash_ports
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def test_reset_expects_no_reply
|
|
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'
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
## Password ##########################################################
|
|
375
|
+
|
|
376
|
+
def test_default_password_is_padded_to_eight
|
|
377
|
+
@usb.on(1)
|
|
378
|
+
assert_equal 'SPpass 0100FFFF', @hub.log.last
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def test_password_longer_than_eight_is_refused
|
|
382
|
+
assert_raises(ArgumentError) do
|
|
383
|
+
ExSYS::ManagedUSB.new('/dev/null', 'verylongpassword')
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def test_wrong_password_raises_with_the_hub_code
|
|
388
|
+
usb = ExSYS::ManagedUSB.new('/dev/null', 'nope')
|
|
389
|
+
err = assert_raises(ExSYS::ManagedUSB::Error) { usb.on(1) }
|
|
390
|
+
assert_equal '01', err.message
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def test_password_change_is_accepted_by_the_hub
|
|
394
|
+
@usb.password('secret')
|
|
395
|
+
assert_equal 'secret'.ljust(8), @hub.password
|
|
396
|
+
@usb.on(1) # still authenticated afterwards
|
|
397
|
+
assert_equal [ 1 ], @hub.ports_on
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
## Debug output ######################################################
|
|
401
|
+
|
|
402
|
+
def test_debug_traces_both_directions
|
|
403
|
+
@usb.on(1)
|
|
404
|
+
assert_match(/<-- GP/, @dbg.string)
|
|
405
|
+
assert_match(/--> 0000FFFF/, @dbg.string)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Regression: the password used to be traced verbatim, putting it in
|
|
409
|
+
# a file that was also created world-readable.
|
|
410
|
+
def test_debug_never_carries_the_password
|
|
411
|
+
usb = ExSYS::ManagedUSB.new('/dev/null', 's3cret', debug: @dbg)
|
|
412
|
+
@hub.password = 's3cret'.ljust(8)
|
|
413
|
+
usb.on(1)
|
|
414
|
+
usb.commit
|
|
415
|
+
usb.password('other')
|
|
416
|
+
refute_match(/s3cret/, @dbg.string)
|
|
417
|
+
refute_match(/other/, @dbg.string)
|
|
418
|
+
assert_match(/<-- SP\*{8}/, @dbg.string)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
## Malformed replies #################################################
|
|
422
|
+
|
|
423
|
+
def test_error_reply_to_a_read_is_raised_with_its_code
|
|
424
|
+
@hub.garbage = 'E42'
|
|
425
|
+
err = assert_raises(ExSYS::ManagedUSB::Error) { @usb.get }
|
|
426
|
+
assert_equal '42', err.message
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# Regression: a reply of an unexpected length used to raise a bare
|
|
430
|
+
# Error, whose message was just the class name.
|
|
431
|
+
def test_unexpected_reply_length_says_what_arrived
|
|
432
|
+
@hub.garbage = 'wat'
|
|
433
|
+
err = assert_raises(ExSYS::ManagedUSB::Error) { @usb.get }
|
|
434
|
+
assert_match(/unexpected reply/, err.message)
|
|
435
|
+
assert_match(/wat/, err.message)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def test_silent_hub_is_an_error_not_a_hang
|
|
439
|
+
@hub.silent = true
|
|
440
|
+
assert_raises(ExSYS::ManagedUSB::Error) { @usb.get }
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
## The serial line ###################################################
|
|
444
|
+
|
|
445
|
+
# Regression: a read-modify-write used to close the line between the
|
|
446
|
+
# GP and the SP, letting another process slip in between the two.
|
|
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
|
|
450
|
+
@usb.on(1)
|
|
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] })
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def test_set_holds_the_line_open_too
|
|
456
|
+
@usb.set({ 1 => true })
|
|
457
|
+
assert_equal 1, @hub.opens # ?Q, GP and SP all inside it
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
def test_the_line_is_opened_as_the_hub_expects
|
|
461
|
+
@usb.on(1)
|
|
462
|
+
assert_equal '/dev/null', @hub.line
|
|
463
|
+
assert_equal ExSYS::ManagedUSB::SPEED, @hub.speed
|
|
464
|
+
assert_equal 9600, @hub.speed
|
|
465
|
+
assert_equal '8N1', @hub.mode
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def test_the_line_is_locked_while_held
|
|
469
|
+
@usb.on(1)
|
|
470
|
+
assert_equal 1, @hub.locks
|
|
471
|
+
end
|
|
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
|
+
|
|
533
|
+
# A platform that will not lock a character device must not make the
|
|
534
|
+
# tool unusable; it degrades to unlocked and says so.
|
|
535
|
+
def test_an_unlockable_line_degrades_rather_than_failing
|
|
536
|
+
@hub.lockable = false
|
|
537
|
+
@usb.on(1)
|
|
538
|
+
assert_equal [ 1 ], @hub.ports_on
|
|
539
|
+
assert_match(/not lockable/, @dbg.string)
|
|
540
|
+
end
|
|
541
|
+
end
|
data/test/test_readme.rb
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require_relative 'helper'
|
|
2
|
+
|
|
3
|
+
# The README documents the executable by name and by example. Both can
|
|
4
|
+
# drift away from the tool without anything saying so -- the name did,
|
|
5
|
+
# and sat wrong through four releases -- so both are checked here.
|
|
6
|
+
class TestReadme < Minitest::Test
|
|
7
|
+
include CLI
|
|
8
|
+
|
|
9
|
+
README = File.read(File.join(ROOT, 'README.md')).freeze
|
|
10
|
+
|
|
11
|
+
# Regression: every example named `exsys-hub`, which has never been
|
|
12
|
+
# the name of anything this gem installs.
|
|
13
|
+
def test_the_shell_examples_name_the_real_executable
|
|
14
|
+
named = shell_commands.map {|line| line.split.first }.uniq
|
|
15
|
+
.grep(/\Aexsys/)
|
|
16
|
+
|
|
17
|
+
refute_empty named, 'no exsys command appears in the README'
|
|
18
|
+
assert_equal [ File.basename(EXE) ], named
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# And each example must still be one the tool accepts, so that a
|
|
22
|
+
# renamed action or a changed argument syntax cannot sit in the
|
|
23
|
+
# documentation unnoticed.
|
|
24
|
+
def test_every_documented_example_is_accepted
|
|
25
|
+
examples = documented_examples
|
|
26
|
+
assert_operator examples.size, :>=, 6, 'README examples went missing'
|
|
27
|
+
|
|
28
|
+
examples.each do |line, argv|
|
|
29
|
+
_, err, st = exsys_usb_raw(argv)
|
|
30
|
+
assert_equal 0, st.exitstatus,
|
|
31
|
+
"README example is not accepted: #{line}\n#{err}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def test_the_documented_test_command_is_the_one_that_runs
|
|
36
|
+
assert_includes shell_commands, 'rake test'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Command lines from the README's shell blocks, comments and blank
|
|
42
|
+
# lines removed.
|
|
43
|
+
def shell_commands
|
|
44
|
+
README.scan(/^~~~sh$(.*?)^~~~$/m).flatten.join("\n")
|
|
45
|
+
.lines.map {|l| l.sub(/#.*/, '').strip }.reject(&:empty?)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Those that invoke the executable, as [ line, argv ]. The shell
|
|
49
|
+
# variable the README uses for the device is replaced by one that
|
|
50
|
+
# exists everywhere, and anything the shell would interpret rather
|
|
51
|
+
# than pass on -- a pipe, a conditional, a redirection -- is cut
|
|
52
|
+
# off, since these run the executable directly and not under a
|
|
53
|
+
# shell. What is checked is the invocation, not the plumbing.
|
|
54
|
+
def documented_examples
|
|
55
|
+
shell_commands.filter_map do |line|
|
|
56
|
+
next unless line.start_with?(File.basename(EXE))
|
|
57
|
+
cmd = line.split(/\s(?:\|\||&&|\||;|>>?)\s/).first
|
|
58
|
+
[ line, Shellwords.split(cmd.sub('${dev}', '/dev/null'))[1..] ]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: exsys
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: '0
|
|
4
|
+
version: '1.0'
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stéphane D'Alu
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: uart
|
|
@@ -24,6 +23,20 @@ dependencies:
|
|
|
24
23
|
- - ">="
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
25
|
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: minitest
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5'
|
|
27
40
|
- !ruby/object:Gem::Dependency
|
|
28
41
|
name: yard
|
|
29
42
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -64,16 +77,22 @@ extensions: []
|
|
|
64
77
|
extra_rdoc_files: []
|
|
65
78
|
files:
|
|
66
79
|
- README.md
|
|
80
|
+
- Rakefile
|
|
67
81
|
- bin/exsys-usb
|
|
68
82
|
- exsys.gemspec
|
|
69
83
|
- lib/exsys.rb
|
|
70
84
|
- lib/exsys/managed-usb.rb
|
|
71
85
|
- lib/exsys/version.rb
|
|
86
|
+
- test/helper.rb
|
|
87
|
+
- test/support/fake_hub.rb
|
|
88
|
+
- test/support/uart.rb
|
|
89
|
+
- test/test_exsys_usb.rb
|
|
90
|
+
- test/test_managed_usb.rb
|
|
91
|
+
- test/test_readme.rb
|
|
72
92
|
homepage: https://github.com/sdalu/ruby-exsys
|
|
73
93
|
licenses:
|
|
74
94
|
- MIT
|
|
75
95
|
metadata: {}
|
|
76
|
-
post_install_message:
|
|
77
96
|
rdoc_options: []
|
|
78
97
|
require_paths:
|
|
79
98
|
- lib
|
|
@@ -88,8 +107,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
88
107
|
- !ruby/object:Gem::Version
|
|
89
108
|
version: '0'
|
|
90
109
|
requirements: []
|
|
91
|
-
rubygems_version:
|
|
92
|
-
signing_key:
|
|
110
|
+
rubygems_version: 4.0.16
|
|
93
111
|
specification_version: 4
|
|
94
112
|
summary: ExSYS managed USB hub support
|
|
95
113
|
test_files: []
|