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.
@@ -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
@@ -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.5'
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: 2024-11-29 00:00:00.000000000 Z
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: 3.5.19
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: []