exsys 0.6 → 1.1

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
@@ -0,0 +1,241 @@
1
+ require_relative 'helper'
2
+
3
+ # Finding the hub's serial line, per platform.
4
+ #
5
+ # The reading and the parsing are kept apart in Discovery precisely so
6
+ # that this runs on a machine with nothing attached: what is asserted
7
+ # here is the parsing, against the output the two tools produce, and
8
+ # not that this host has a hub on it.
9
+ class TestDiscovery < Minitest::Test
10
+ D = ExSYS::ManagedUSB::Discovery
11
+
12
+ # sysctl -e dev.uftdi dev.uhub.
13
+ #
14
+ # The uhub half is captured verbatim from a real FreeBSD host --
15
+ # two controllers, a Genesys hub on one and a chain of two TI hubs
16
+ # on the other -- because the walk that builds a USB path out of it
17
+ # is the part worth testing against something nobody invented.
18
+ #
19
+ # The uftdi half is written: three adapters, one hanging off the
20
+ # deepest hub, one whose EEPROM carries no serial, and an FT230X,
21
+ # which uftdi drives too and which is not what is wanted.
22
+ UHUB = <<~'SYSCTL'
23
+ dev.uhub.%parent=
24
+ dev.uhub.0.%location=
25
+ dev.uhub.0.%parent=usbus1
26
+ dev.uhub.1.%location=
27
+ dev.uhub.1.%parent=usbus0
28
+ dev.uhub.2.%location=bus=1 hubaddr=1 port=1 devaddr=3 interface=0 ugen=ugen1.3
29
+ dev.uhub.2.%parent=uhub0
30
+ dev.uhub.3.%location=bus=0 hubaddr=1 port=2 devaddr=2 interface=0 ugen=ugen0.2
31
+ dev.uhub.3.%parent=uhub1
32
+ dev.uhub.4.%location=bus=1 hubaddr=3 port=1 devaddr=4 interface=0 ugen=ugen1.4
33
+ dev.uhub.4.%parent=uhub2
34
+ dev.uhub.5.%location=bus=1 hubaddr=4 port=4 devaddr=5 interface=0 ugen=ugen1.5
35
+ dev.uhub.5.%parent=uhub4
36
+ SYSCTL
37
+
38
+ UFTDI = <<~'SYSCTL'
39
+ dev.uftdi.0.%desc=FTDI FT232R USB UART
40
+ dev.uftdi.0.%driver=uftdi
41
+ dev.uftdi.0.%location=bus=1 hubaddr=5 port=4 devaddr=9 interface=0 ugen=ugen1.9
42
+ dev.uftdi.0.%pnpinfo=vendor=0x0403 product=0x6001 devclass=0x00 devsubclass=0x00 devproto=0x00 sernum="AL03GD7X" release=0x0600 mode=host intclass=0xff intsubclass=0xff intprotocol=0xff
43
+ dev.uftdi.0.%parent=uhub5
44
+ dev.uftdi.0.ttyname=U0
45
+ dev.uftdi.0.ttyports=1
46
+ dev.uftdi.1.%desc=FTDI FT232R USB UART
47
+ dev.uftdi.1.%driver=uftdi
48
+ dev.uftdi.1.%location=bus=0 hubaddr=2 port=3 devaddr=7 interface=0 ugen=ugen0.7
49
+ dev.uftdi.1.%pnpinfo=vendor=0x0403 product=0x6001 devclass=0x00 devsubclass=0x00 devproto=0x00 sernum="" release=0x0600 mode=host intclass=0xff intsubclass=0xff intprotocol=0xff
50
+ dev.uftdi.1.%parent=uhub3
51
+ dev.uftdi.1.ttyname=U1
52
+ dev.uftdi.1.ttyports=1
53
+ dev.uftdi.2.%desc=FTDI FT230X Basic UART
54
+ dev.uftdi.2.%driver=uftdi
55
+ dev.uftdi.2.%pnpinfo=vendor=0x0403 product=0x6015 devclass=0x00 devsubclass=0x00 devproto=0x00 sernum="DT04H6789" release=0x1000 mode=host intclass=0xff intsubclass=0xff intprotocol=0xff
56
+ dev.uftdi.2.%parent=uhub3
57
+ dev.uftdi.2.ttyname=U2
58
+ dev.uftdi.2.ttyports=1
59
+ SYSCTL
60
+
61
+ # udevadm info -q property --export, for one FT232R.
62
+ UDEVADM = <<~'UDEV'
63
+ DEVNAME='/dev/ttyUSB0'
64
+ DEVPATH='/devices/pci0000:00/0000:00:14.0/usb1/1-1/1-1.2/1-1.2.4/1-1.2.4.4/1-1.2.4.4:1.0/ttyUSB0/tty/ttyUSB0'
65
+ ID_BUS='usb'
66
+ ID_MODEL='FT232R_USB_UART'
67
+ ID_MODEL_ID='6001'
68
+ ID_SERIAL_SHORT='A50285BI'
69
+ ID_VENDOR_ID='0403'
70
+ MAJOR='188'
71
+ SUBSYSTEM='tty'
72
+ UDEV
73
+
74
+ def freebsd(text = UFTDI + UHUB)
75
+ tree = D::FreeBSD.parse(text)
76
+ tree.filter_map {|name, dev|
77
+ next unless name.start_with?('uftdi')
78
+ D::FreeBSD.candidate(dev, tree)
79
+ }
80
+ end
81
+
82
+ def test_freebsd_reports_the_line_the_serial_and_the_path
83
+ assert_equal({ :device => '/dev/ttyU0',
84
+ :serial => 'AL03GD7X',
85
+ :usb_path => '1-1.1.4.4' }, freebsd.first)
86
+ end
87
+
88
+ # Nothing on FreeBSD states a path: it is walked, %parent by
89
+ # %parent, and the ports collected on the way up. uftdi0 sits on
90
+ # port 4 of uhub5, which is on port 4 of uhub4, which is on port 1
91
+ # of uhub2, which is on port 1 of the root hub of bus 1.
92
+ def test_the_path_is_walked_up_to_the_root_hub
93
+ tree = D::FreeBSD.parse(UFTDI + UHUB)
94
+ assert_equal '1-1.1.4', D::FreeBSD.usb_path(tree['uhub5'], tree)
95
+ assert_equal '1-1.1', D::FreeBSD.usb_path(tree['uhub4'], tree)
96
+ assert_equal '0-2', D::FreeBSD.usb_path(tree['uhub3'], tree)
97
+ end
98
+
99
+ # A root hub occupies no port on anything: its %location is empty.
100
+ def test_a_root_hub_has_no_path_of_its_own
101
+ tree = D::FreeBSD.parse(UFTDI + UHUB)
102
+ assert_nil D::FreeBSD.usb_path(tree['uhub0'], tree)
103
+ end
104
+
105
+ # Without the hubs there is nothing to walk. The half-built
106
+ # answer is the dangerous one: the adapter's own port alone reads
107
+ # as a whole path -- 1-4 rather than 1-1.1.4.4 -- and names a real
108
+ # socket that is not this one.
109
+ def test_a_walk_that_never_reaches_the_root_is_nil
110
+ assert_nil freebsd(UFTDI).first[:usb_path]
111
+ end
112
+
113
+ # An unprogrammed EEPROM answers sernum="", and nothing may be
114
+ # identified by an empty string.
115
+ # The case the path exists for: no serial to be named by, and the
116
+ # socket is then the only stable name it has.
117
+ def test_an_empty_serial_is_nil_and_the_path_is_still_there
118
+ assert_equal({ :device => '/dev/ttyU1',
119
+ :serial => nil,
120
+ :usb_path => '0-2.3' }, freebsd[1])
121
+ end
122
+
123
+ # uftdi drives every FTDI part, not only the one the hub uses.
124
+ def test_another_ftdi_part_is_not_a_candidate
125
+ refute_includes freebsd.map {|c| c[:device] }, '/dev/ttyU2'
126
+ assert_equal 2, freebsd.size
127
+ end
128
+
129
+ # dev.uhub.%parent has no unit number in it and must not become a
130
+ # device. Two branches are read at once, so the key is the name.
131
+ def test_the_driver_wide_key_is_not_taken_for_a_device
132
+ keys = D::FreeBSD.parse(UFTDI + UHUB).keys
133
+ assert_includes keys, 'uftdi0'
134
+ assert_includes keys, 'uhub5'
135
+ refute_includes keys, 'uhub'
136
+ assert_equal 9, keys.size
137
+ end
138
+
139
+ # Not every token in a %pnpinfo is a key=value pair --
140
+ # dev.acpi_timer.0.%pnpinfo is the bare word 'unknown' -- and a
141
+ # parser that died on one driver's wording would take the hub
142
+ # with it.
143
+ def test_a_field_that_is_not_a_pair_does_not_stop_the_parse
144
+ tree = D::FreeBSD.parse(<<~'SYSCTL')
145
+ dev.uftdi.0.%pnpinfo=unknown vendor=0x0403 product=0x6001 sernum="AL03GD7X"
146
+ dev.uftdi.0.ttyname=U0
147
+ SYSCTL
148
+ assert_equal '0x0403', tree['uftdi0'][:'%pnpinfo'][:vendor]
149
+ end
150
+
151
+ def test_nothing_attached_is_an_empty_list_not_an_error
152
+ assert_empty freebsd('')
153
+ end
154
+
155
+ def test_linux_reports_the_line_the_serial_and_the_path
156
+ assert_equal({ :device => '/dev/ttyUSB0',
157
+ :serial => 'A50285BI',
158
+ :usb_path => '1-1.2.4.4' },
159
+ D::Linux.candidate(D::Linux.parse(UDEVADM)))
160
+ end
161
+
162
+ # Every hub on the way to the device matches the shape too, so it
163
+ # is the last match that is the adapter -- not 1-1, not 1-1.2.
164
+ def test_the_usb_path_is_the_device_not_a_hub_above_it
165
+ assert_equal '1-1.2.4.4', D::Linux.usb_path(
166
+ '/devices/pci0000:00/usb1/1-1/1-1.2/1-1.2.4/1-1.2.4.4/' \
167
+ '1-1.2.4.4:1.0/ttyUSB0/tty/ttyUSB0')
168
+ end
169
+
170
+ def test_a_devpath_with_no_usb_component_has_no_path
171
+ assert_nil D::Linux.usb_path('/devices/platform/serial8250/ttyS0')
172
+ end
173
+
174
+ # The published shape, so that a caller can tell a path typed by a
175
+ # human from a serial number.
176
+ def test_what_a_usb_path_looks_like
177
+ assert_match ExSYS::ManagedUSB::USB_PATH, '1-1.2.4.4'
178
+ assert_match ExSYS::ManagedUSB::USB_PATH, '2-3'
179
+ refute_match ExSYS::ManagedUSB::USB_PATH, 'AL03GD7X'
180
+ refute_match ExSYS::ManagedUSB::USB_PATH, '1-1.2.4.4:1.0'
181
+ refute_match ExSYS::ManagedUSB::USB_PATH, '/dev/ttyUSB0'
182
+ end
183
+
184
+ def test_linux_skips_an_adapter_that_is_not_the_right_part
185
+ props = D::Linux.parse(UDEVADM.sub("'6001'", "'6015'"))
186
+ assert_nil D::Linux.candidate(props)
187
+ end
188
+
189
+ def test_linux_unquotes_the_export_format
190
+ assert_equal '/dev/ttyUSB0', D::Linux.parse(UDEVADM)[:DEVNAME]
191
+ end
192
+
193
+ # The public name delegates, so that a caller has one thing to call.
194
+ def test_available_is_what_the_platform_answered
195
+ found = [ { :device => '/dev/ttyU0', :serial => 'AL03GD7X',
196
+ :usb_path => nil } ]
197
+ D.stub(:available, found) do
198
+ assert_equal found, ExSYS::ManagedUSB.available
199
+ end
200
+ end
201
+
202
+ # A platform with no way to look says so. An empty list would read
203
+ # as "no hub attached", which is a different thing and a lie.
204
+ def test_a_platform_we_cannot_look_on_is_an_error
205
+ RbConfig::CONFIG.stub(:[], 'solaris2.11') do
206
+ e = assert_raises(ExSYS::ManagedUSB::Error) { D.available }
207
+ assert_match(/no hub discovery for this platform/, e.message)
208
+ assert_match(/solaris2\.11/, e.message)
209
+ end
210
+ end
211
+
212
+ def test_a_missing_tool_is_an_error_too
213
+ e = assert_raises(ExSYS::ManagedUSB::Error) {
214
+ D.missing('/sbin/sysctl', Errno::ENOENT.new('/sbin/sysctl'))
215
+ }
216
+ assert_match(%r{cannot look for a hub: /sbin/sysctl}, e.message)
217
+ end
218
+
219
+ # Regression, and the reason Discovery.run exists. This reader
220
+ # once ran `sysctl ... 2>/dev/null` in a backtick; the redirection
221
+ # made Ruby hand the string to /bin/sh, which reports a missing
222
+ # binary itself as exit 127 with no output, so Errno::ENOENT never
223
+ # reached the rescue and a host with no sysctl answered "no hub
224
+ # attached" -- the exact lie the comment there warns against.
225
+ #
226
+ # The status cannot stand in for it either: sysctl exits 1 for an
227
+ # unknown oid, which is the legitimate empty answer.
228
+ def test_a_reader_that_is_not_installed_raises_rather_than_answering_none
229
+ e = assert_raises(ExSYS::ManagedUSB::Error) {
230
+ D.run('/nonexistent/sysctl', '-e', 'dev.uftdi')
231
+ }
232
+ assert_match(%r{cannot look for a hub: /nonexistent/sysctl},
233
+ e.message)
234
+ end
235
+
236
+ # ... and a reader that IS installed, asked for an oid this host
237
+ # has not got, answers nothing at all rather than raising.
238
+ def test_an_oid_that_does_not_exist_is_an_empty_answer
239
+ assert_empty D.run('/usr/bin/env', 'true')
240
+ end
241
+ 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