exsys 0.5 → 0.6

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,139 @@
1
+ # A model of the ExSYS managed hub's serial protocol, faithful enough
2
+ # to drive ExSYS::ManagedUSB end to end without hardware.
3
+ #
4
+ # The wire encoding here is written out independently of the library's
5
+ # own pack/unpack, so that a disagreement between the two shows up as a
6
+ # test failure rather than both sharing the same mistake.
7
+ class FakeHub
8
+ DEFAULT_PASSWORD = 'pass'.ljust(8)
9
+
10
+ attr_reader :log, :opens, :locks, :line, :speed, :mode
11
+ attr_accessor :password, :silent, :lockable, :garbage
12
+
13
+ # @param state [Integer] initial port bitmap
14
+ # @param path [String] file backing the state, so that separate
15
+ # processes share one hub
16
+ # @param lock [String] file used for a real flock, so that the
17
+ # locking is genuinely exercised
18
+ # @param delay [Float] pause inside a write, widening the
19
+ # window a concurrent process could slip
20
+ # into
21
+ def initialize(password: DEFAULT_PASSWORD, state: 0x0000,
22
+ path: nil, lock: nil, delay: 0)
23
+ @password = password
24
+ @state = state
25
+ @flash = state
26
+ @path = path
27
+ @lock = lock
28
+ @delay = delay
29
+ @log = []
30
+ @opens = 0
31
+ @locks = 0
32
+ @silent = false # hub answers nothing at all
33
+ @lockable = true # platform allows locking the line
34
+ @garbage = nil # hub answers this instead, when set
35
+
36
+ # Attach to the hub the file already describes, so that a test
37
+ # can inspect what its subprocesses did; seed it otherwise.
38
+ if @path && File.exist?(@path) then load
39
+ else save
40
+ end
41
+ end
42
+
43
+ # Build from the environment, for the executable's subprocesses.
44
+ def self.from_env(env = ENV)
45
+ new(path: env['EXSYS_TEST_HUB'],
46
+ lock: env['EXSYS_TEST_LOCK'],
47
+ delay: env['EXSYS_TEST_DELAY'].to_f).tap do |hub|
48
+ hub.silent = !env['EXSYS_TEST_SILENT'].nil?
49
+ hub.lockable = env['EXSYS_TEST_NOLOCK'].nil?
50
+ hub.garbage = env['EXSYS_TEST_GARBAGE']
51
+ end
52
+ end
53
+
54
+ def state = (load; @state)
55
+ def flash = (load; @flash)
56
+
57
+ # Ports currently powered, as a sorted list -- the oracle the tests
58
+ # compare against.
59
+ def ports_on
60
+ 1.upto(16).select {|p| state & (1 << (p-1)) != 0 }
61
+ end
62
+
63
+ # Record how the line was opened, so a test can check the library
64
+ # asks for the device and the speed the hub actually needs.
65
+ def opened(line, speed, mode)
66
+ @line, @speed, @mode = line, speed, mode
67
+ @opens += 1
68
+ end
69
+
70
+ def flock(mode)
71
+ raise Errno::ENOTSUP unless @lockable
72
+ @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
+ end
83
+
84
+ # Answer one command, as the hub would.
85
+ def command(cmd)
86
+ @log << cmd
87
+ return '' if @silent
88
+ return @garbage if @garbage
89
+
90
+ load
91
+ reply = dispatch(cmd)
92
+ save
93
+ reply
94
+ end
95
+
96
+ private
97
+
98
+ def dispatch(cmd)
99
+ code = cmd[0, 2]
100
+ args = cmd[2..].to_s
101
+
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'
105
+
106
+ return 'E01' unless args.start_with?(@password)
107
+ rest = args[@password.size..]
108
+
109
+ sleep @delay if @delay.positive?
110
+
111
+ case code
112
+ when 'SP' then @state = decode(rest) ; 'G'
113
+ when 'FP' then @state = @flash = decode(rest) ; 'G'
114
+ when 'WP' then @flash = @state ; 'G'
115
+ when 'RD' then @state = @flash ; 'G'
116
+ when 'RH' then @state = @flash ; nil
117
+ when 'CP' then @password = rest ; 'G'
118
+ else 'E02'
119
+ end
120
+ end
121
+
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)
127
+
128
+ def load
129
+ return if @path.nil? || !File.exist?(@path)
130
+ @state, @flash, @password = File.read(@path).split("\n", 3)
131
+ @state = @state.to_i(16)
132
+ @flash = @flash.to_i(16)
133
+ end
134
+
135
+ def save
136
+ return if @path.nil?
137
+ File.write(@path, "%04x\n%04x\n%s" % [ @state, @flash, @password ])
138
+ end
139
+ end
@@ -0,0 +1,40 @@
1
+ # Test double for the `uart` gem.
2
+ #
3
+ # test/helper.rb puts this directory on the load path ahead of the real
4
+ # gem, so `require "uart"` inside ExSYS::ManagedUSB picks this up and
5
+ # the suite runs against FakeHub. That is what lets the tests run with
6
+ # no hub attached and with neither the uart nor the termios gem
7
+ # installed.
8
+ require_relative 'fake_hub'
9
+
10
+ module UART
11
+ class << self
12
+ attr_writer :hub
13
+
14
+ # In-process tests set the hub directly; the subprocesses the
15
+ # executable's tests spawn build theirs from the environment.
16
+ def hub = @hub ||= FakeHub.from_env
17
+
18
+ def reset! = @hub = nil
19
+ end
20
+
21
+ # Stands in for the File that UART.open normally yields.
22
+ 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"
27
+ end
28
+
29
+ def self.open(line, speed = 9600, mode = '8N1')
30
+ hub.opened(line, speed, mode)
31
+ serial = Serial.new(hub)
32
+ return serial unless block_given?
33
+
34
+ begin
35
+ yield serial
36
+ ensure
37
+ hub.unlock
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,230 @@
1
+ require_relative 'helper'
2
+
3
+ # Behaviour of the bin/exsys-usb executable, run as a subprocess so
4
+ # that its exit status -- what a calling script actually reads -- is
5
+ # part of what is asserted.
6
+ class TestExsysUsb < Minitest::Test
7
+ include CLI
8
+
9
+ ## Switching #########################################################
10
+
11
+ def test_on_and_off_reach_the_hub
12
+ _, _, st = exsys_usb('on', '1', '2')
13
+ assert_predicate st, :success?
14
+ assert_equal [ 1, 2 ], hub.ports_on
15
+
16
+ exsys_usb('off', '1')
17
+ assert_equal [ 2 ], hub.ports_on
18
+ end
19
+
20
+ def test_bare_on_and_off_cover_every_port
21
+ exsys_usb('on')
22
+ assert_equal ExSYS::ManagedUSB::PORTS, hub.ports_on
23
+ exsys_usb('off')
24
+ assert_empty hub.ports_on
25
+ end
26
+
27
+ def test_toggle
28
+ exsys_usb('on', '1', '3')
29
+ exsys_usb('toggle', '3', '4')
30
+ assert_equal [ 1, 4 ], hub.ports_on
31
+ end
32
+
33
+ def test_set_pairs
34
+ exsys_usb('on', '5')
35
+ _, _, st = exsys_usb('set', '3:on', '5:off')
36
+ assert_predicate st, :success?
37
+ assert_equal [ 3 ], hub.ports_on
38
+ end
39
+
40
+ def test_set_accepts_every_documented_spelling
41
+ exsys_usb('set', '1:on', '2:ON', '3:true', '4:t', '5:1')
42
+ assert_equal [ 1, 2, 3, 4, 5 ], hub.ports_on
43
+ exsys_usb('set', '1:off', '2:OFF', '3:false', '4:f', '5:0')
44
+ assert_empty hub.ports_on
45
+ end
46
+
47
+ def test_default_flag_forces_the_unlisted_ports
48
+ exsys_usb('on', '8')
49
+ exsys_usb('-D', 'false', 'set', '3:on')
50
+ assert_equal [ 3 ], hub.ports_on
51
+ end
52
+
53
+ def test_commit_and_restore
54
+ exsys_usb('on', '1')
55
+ exsys_usb('commit')
56
+ exsys_usb('on', '2')
57
+ exsys_usb('restore')
58
+ assert_equal [ 1 ], hub.ports_on
59
+ end
60
+
61
+ def test_commit_flag_writes_through
62
+ exsys_usb('-c', 'on', '4')
63
+ exsys_usb('on', '5')
64
+ exsys_usb('restore')
65
+ assert_equal [ 4 ], hub.ports_on
66
+ end
67
+
68
+ ## Exit status -- regression. Every failure used to be printed and
69
+ ## then exited 0, so that `exsys-usb off 3 || alert` never fired.
70
+ ######################################################################
71
+
72
+ def test_a_refused_command_exits_non_zero
73
+ seed_hub(password: 'other')
74
+ _, err, st = exsys_usb('off', '3')
75
+ refute_predicate st, :success?
76
+ assert_equal 1, st.exitstatus
77
+ assert_match(/exsys-usb: 01/, err)
78
+ end
79
+
80
+ def test_a_silent_hub_exits_non_zero
81
+ _, err, st = exsys_usb('off', '3', env: { 'EXSYS_TEST_SILENT' => '1' })
82
+ assert_equal 1, st.exitstatus
83
+ assert_match(/exsys-usb:/, err)
84
+ end
85
+
86
+ def test_a_successful_command_exits_zero
87
+ _, _, st = exsys_usb('on', '1')
88
+ assert_equal 0, st.exitstatus
89
+ end
90
+
91
+ ## Argument handling #################################################
92
+
93
+ # Regression: a port outside 1..16, or a word that to_i turned into
94
+ # 0, used to be accepted and quietly do nothing.
95
+ def test_an_out_of_range_port_is_refused
96
+ [ '0', '17', '99' ].each do |p|
97
+ _, err, st = exsys_usb('on', p)
98
+ assert_equal 1, st.exitstatus, "port #{p} should be refused"
99
+ assert_match(/invalid port: #{p}/, err)
100
+ end
101
+ end
102
+
103
+ def test_a_port_that_is_not_a_number_is_refused
104
+ _, err, st = exsys_usb('off', 'usb3')
105
+ assert_equal 1, st.exitstatus
106
+ assert_match(/invalid port/, err)
107
+ end
108
+
109
+ def test_a_refused_port_leaves_the_hub_alone
110
+ exsys_usb('on', '1')
111
+ exsys_usb('on', '17')
112
+ assert_equal [ 1 ], hub.ports_on
113
+ end
114
+
115
+ # Regression: an action the case did not know fell through it, so
116
+ # the tool exited 0 having done nothing at all.
117
+ def test_an_unknown_action_is_refused
118
+ _, err, st = exsys_usb('onn', '1')
119
+ assert_equal 1, st.exitstatus
120
+ assert_match(/unknown action: onn/, err)
121
+ end
122
+
123
+ def test_a_malformed_set_pair_is_refused
124
+ [ 'foo', '3:maybe', ':on', '3:' ].each do |a|
125
+ _, err, st = exsys_usb('set', a)
126
+ assert_equal 1, st.exitstatus, "#{a} should be refused"
127
+ assert_match(/invalid argument/, err)
128
+ end
129
+ end
130
+
131
+ ## Startup failures -- regression. Option parsing, the debug file
132
+ ## and the hub were all built before the rescue, so their failures
133
+ ## escaped as a Ruby backtrace.
134
+ ######################################################################
135
+
136
+ def test_an_over_long_password_is_reported_not_dumped
137
+ _, err, st = exsys_usb('-p', 'verylongpassword', 'on', '1')
138
+ assert_equal 1, st.exitstatus
139
+ assert_equal "exsys-usb: password too long\n", err
140
+ end
141
+
142
+ def test_an_unopenable_debug_file_is_reported_not_dumped
143
+ _, err, st = exsys_usb('--debug=/nonexistent-dir/x.log', 'on', '1')
144
+ assert_equal 1, st.exitstatus
145
+ assert_match(/\Aexsys-usb: /, err)
146
+ refute_match(/\.rb:\d+:in/, err)
147
+ end
148
+
149
+ def test_a_bad_option_value_is_reported_not_dumped
150
+ _, err, st = exsys_usb('-D', '0', 'set', '3:on')
151
+ assert_equal 1, st.exitstatus
152
+ assert_match(/\Aexsys-usb: /, err)
153
+ refute_match(/\.rb:\d+:in/, err)
154
+ end
155
+
156
+ ## Help ##############################################################
157
+
158
+ def test_no_action_prints_the_usage
159
+ out, _, st = exsys_usb
160
+ assert_equal 0, st.exitstatus
161
+ assert_match(/Usage: exsys-usb ACTION/, out)
162
+ end
163
+
164
+ def test_help_and_version
165
+ out, _, st = exsys_usb('-h')
166
+ assert_equal 0, st.exitstatus
167
+ assert_match(/--device/, out)
168
+
169
+ out, _, st = exsys_usb('-V')
170
+ assert_equal 0, st.exitstatus
171
+ assert_match(/#{ExSYS::VERSION}/, out)
172
+ end
173
+
174
+ ## Debug file ########################################################
175
+
176
+ # Regression: opened RDWR without truncating, so a shorter session
177
+ # left the tail of a longer previous one in place, reading as if the
178
+ # hub had sent it.
179
+ def test_the_debug_file_is_appended_to
180
+ log = File.join(@tmp, 'debug.log')
181
+ File.write(log, "PREVIOUS SESSION#{'.' * 200}\n")
182
+ exsys_usb("--debug=#{log}", 'on', '1')
183
+ assert_match(/\APREVIOUS SESSION/, File.read(log))
184
+ assert_match(/<-- GP/, File.read(log))
185
+ refute_match(/\.{20}\z/, File.read(log).lines.last)
186
+ end
187
+
188
+ def test_two_runs_both_survive_in_the_debug_file
189
+ log = File.join(@tmp, 'debug.log')
190
+ exsys_usb("--debug=#{log}", 'on', '1')
191
+ exsys_usb("--debug=#{log}", 'off', '1')
192
+ assert_equal 2, File.read(log).scan(/<-- GP/).size
193
+ end
194
+
195
+ # Regression: the trace carries the password, in a file that used to
196
+ # be created world-readable.
197
+ def test_the_debug_file_is_private_and_redacted
198
+ log = File.join(@tmp, 'debug.log')
199
+ seed_hub(password: 's3cret')
200
+ exsys_usb('-p', 's3cret', "--debug=#{log}", 'on', '1')
201
+ assert_equal 0o600, File.stat(log).mode & 0o777
202
+ refute_match(/s3cret/, File.read(log))
203
+ assert_match(/<-- SP\*{8}/, File.read(log))
204
+ end
205
+
206
+ ## Concurrency #######################################################
207
+
208
+ # Regression: the GP and the SP were separate openings of the line,
209
+ # so two processes could both read the old state and the second
210
+ # write would drop the first one's port.
211
+ def test_two_concurrent_invocations_do_not_lose_an_update
212
+ env = { 'EXSYS_TEST_LOCK' => File.join(@tmp, 'lock'),
213
+ 'EXSYS_TEST_DELAY' => '0.2' }
214
+
215
+ results = [ '1', '2' ].map do |port|
216
+ Thread.new { exsys_usb('on', port, env: env) }
217
+ end.map(&:value)
218
+
219
+ results.each {|(_, err, st)| assert_equal 0, st.exitstatus, err }
220
+ assert_equal [ 1, 2 ], hub.ports_on
221
+ end
222
+
223
+ ## Packaging #########################################################
224
+
225
+ # Regression: the file carried a shebang but no execute bit, so a
226
+ # fresh clone could not run the examples in the README.
227
+ def test_the_executable_is_executable
228
+ assert_predicate File.stat(EXE).mode & 0o111, :positive?
229
+ end
230
+ end
@@ -0,0 +1,291 @@
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_on_without_argument_powers_every_port
15
+ @usb.on
16
+ assert_equal ExSYS::ManagedUSB::PORTS, @hub.ports_on
17
+ end
18
+
19
+ def test_off_without_argument_powers_nothing
20
+ @usb.on
21
+ @usb.off
22
+ assert_empty @hub.ports_on
23
+ end
24
+
25
+ def test_on_and_off_are_restricted_to_the_named_ports
26
+ @usb.on(1, 2, 16)
27
+ assert_equal [ 1, 2, 16 ], @hub.ports_on
28
+ @usb.off(2)
29
+ assert_equal [ 1, 16 ], @hub.ports_on
30
+ end
31
+
32
+ def test_toggle_inverts_the_named_ports_only
33
+ @usb.on(1, 3)
34
+ @usb.toggle(3, 4)
35
+ assert_equal [ 1, 4 ], @hub.ports_on
36
+ end
37
+
38
+ def test_switching_returns_self_so_calls_chain
39
+ assert_same @usb, @usb.on
40
+ @usb.on.off(4, 5, 6)
41
+ assert_equal ExSYS::ManagedUSB::PORTS - [ 4, 5, 6 ], @hub.ports_on
42
+ end
43
+
44
+ ## Port validation -- regression, ports outside 1..16 used to be
45
+ ## accepted and then silently shifted or truncated away, so that the
46
+ ## hub was rewritten with its existing state and the caller was told
47
+ ## the switch had happened.
48
+ ######################################################################
49
+
50
+ def test_port_zero_is_rejected_by_every_entry_point
51
+ [ ->{ @usb.on(0) }, ->{ @usb.off(0) },
52
+ ->{ @usb.toggle(0) }, ->{ @usb.set({ :on => [ 0 ] }) } ].each do |op|
53
+ assert_raises(ArgumentError, &op)
54
+ end
55
+ end
56
+
57
+ def test_port_above_the_last_one_is_rejected
58
+ [ 17, 99, 1_000 ].each do |p|
59
+ err = assert_raises(ArgumentError) { @usb.on(p) }
60
+ assert_match(/invalid port/, err.message)
61
+ end
62
+ end
63
+
64
+ def test_negative_port_is_rejected
65
+ assert_raises(ArgumentError) { @usb.on(-1) }
66
+ end
67
+
68
+ def test_a_rejected_port_leaves_the_hub_untouched
69
+ @usb.on(1)
70
+ assert_raises(ArgumentError) { @usb.on(17) }
71
+ assert_equal [ 1 ], @hub.ports_on
72
+ end
73
+
74
+ def test_set_rejects_an_out_of_range_port_in_either_notation
75
+ assert_raises(ArgumentError) { @usb.set({ 99 => :on }) }
76
+ assert_raises(ArgumentError) { @usb.set({ :on => [ 99 ] }) }
77
+ assert_raises(ArgumentError) { @usb.set({ :off => [ 0 ] }) }
78
+ end
79
+
80
+ ## set ###############################################################
81
+
82
+ def test_set_by_port_value
83
+ @usb.set({ 1 => true, 2 => false, 3 => :on, 4 => :OFF, 5 => 1 })
84
+ assert_equal [ 1, 3, 5 ], @hub.ports_on
85
+ end
86
+
87
+ def test_set_by_port_state
88
+ @usb.set({ :on => [ 1, 3 ], :off => 4 })
89
+ assert_equal [ 1, 3 ], @hub.ports_on
90
+ end
91
+
92
+ def test_set_leaves_unlisted_ports_alone_without_a_default
93
+ @usb.on(8)
94
+ @usb.set({ 1 => true })
95
+ assert_equal [ 1, 8 ], @hub.ports_on
96
+ end
97
+
98
+ def test_set_applies_the_default_to_unlisted_ports
99
+ @usb.on(8)
100
+ @usb.set({ 1 => true }, false)
101
+ assert_equal [ 1 ], @hub.ports_on
102
+ end
103
+
104
+ def test_set_treats_an_explicit_nil_as_leave_as_is
105
+ @usb.on(8)
106
+ @usb.set({ 1 => true, 8 => nil }, false)
107
+ assert_equal [ 1, 8 ], @hub.ports_on
108
+ end
109
+
110
+ def test_set_refuses_a_port_named_both_on_and_off
111
+ err = assert_raises(ArgumentError) do
112
+ @usb.set({ :on => [ 1 ], :off => [ 1 ] })
113
+ end
114
+ assert_match(/overlap/, err.message)
115
+ end
116
+
117
+ def test_set_refuses_a_mixed_or_unknown_notation
118
+ assert_raises(ArgumentError) { @usb.set({ :on => [ 1 ], 2 => :off }) }
119
+ assert_raises(ArgumentError) { @usb.set({ :bogus => [ 1 ] }) }
120
+ assert_raises(ArgumentError) { @usb.set({ 1 => :perhaps }) }
121
+ end
122
+
123
+ ## get ###############################################################
124
+
125
+ def test_get_ports
126
+ @usb.on(2)
127
+ assert_equal true, @usb.get[2]
128
+ assert_equal false, @usb.get[3]
129
+ assert_equal ExSYS::ManagedUSB::PORTS, @usb.get.keys
130
+ end
131
+
132
+ def test_get_on_and_off_lists
133
+ @usb.on(2, 5)
134
+ assert_equal [ 2, 5 ], @usb.get(:on)
135
+ assert_equal ExSYS::ManagedUSB::PORTS - [ 2, 5 ], @usb.get(:off)
136
+ end
137
+
138
+ # Regression: the :on_off hash used to grow a key only for a state
139
+ # that actually occurred, so a hub with every port alike answered
140
+ # with one key and a caller reading the other got nil.
141
+ def test_get_on_off_always_carries_both_keys
142
+ assert_equal({ :on => [], :off => ExSYS::ManagedUSB::PORTS },
143
+ @usb.get(:on_off))
144
+ @usb.on
145
+ assert_equal({ :on => ExSYS::ManagedUSB::PORTS, :off => [] },
146
+ @usb.get(:on_off))
147
+ @usb.off(1)
148
+ assert_equal [ 1 ], @usb.get(:on_off)[:off]
149
+ end
150
+
151
+ # A real hub answers in upper case; accept either, since the reply
152
+ # is only ever fed to pack('H4'), which does not care.
153
+ def test_a_lower_case_reply_decodes_the_same
154
+ @hub.garbage = 'c4ffffff'
155
+ assert_equal [ 3, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 ], @usb.get(:on)
156
+ @hub.garbage = 'C4FFFFFF'
157
+ assert_equal [ 3, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 ], @usb.get(:on)
158
+ end
159
+
160
+ def test_get_refuses_an_unknown_type
161
+ assert_raises(ArgumentError) { @usb.get(:bogus) }
162
+ end
163
+
164
+ ## Flash and reset ###################################################
165
+
166
+ def test_commit_saves_and_restore_brings_back
167
+ @usb.on(1)
168
+ @usb.commit
169
+ @usb.on(2)
170
+ assert_equal [ 1, 2 ], @hub.ports_on
171
+ @usb.restore
172
+ assert_equal [ 1 ], @hub.ports_on
173
+ end
174
+
175
+ def test_on_with_commit_writes_through_to_flash
176
+ @usb.on(3, commit: true)
177
+ @usb.on(4)
178
+ @usb.restore
179
+ assert_equal [ 3 ], @hub.ports_on
180
+ end
181
+
182
+ def test_reset_expects_no_reply
183
+ assert_same @usb, @usb.reset
184
+ end
185
+
186
+ ## Password ##########################################################
187
+
188
+ def test_default_password_is_padded_to_eight
189
+ @usb.on(1)
190
+ assert_equal 'SPpass 0100FFFF', @hub.log.last
191
+ end
192
+
193
+ def test_password_longer_than_eight_is_refused
194
+ assert_raises(ArgumentError) do
195
+ ExSYS::ManagedUSB.new('/dev/null', 'verylongpassword')
196
+ end
197
+ end
198
+
199
+ def test_wrong_password_raises_with_the_hub_code
200
+ usb = ExSYS::ManagedUSB.new('/dev/null', 'nope')
201
+ err = assert_raises(ExSYS::ManagedUSB::Error) { usb.on(1) }
202
+ assert_equal '01', err.message
203
+ end
204
+
205
+ def test_password_change_is_accepted_by_the_hub
206
+ @usb.password('secret')
207
+ assert_equal 'secret'.ljust(8), @hub.password
208
+ @usb.on(1) # still authenticated afterwards
209
+ assert_equal [ 1 ], @hub.ports_on
210
+ end
211
+
212
+ ## Debug output ######################################################
213
+
214
+ def test_debug_traces_both_directions
215
+ @usb.on(1)
216
+ assert_match(/<-- GP/, @dbg.string)
217
+ assert_match(/--> 0000FFFF/, @dbg.string)
218
+ end
219
+
220
+ # Regression: the password used to be traced verbatim, putting it in
221
+ # a file that was also created world-readable.
222
+ def test_debug_never_carries_the_password
223
+ usb = ExSYS::ManagedUSB.new('/dev/null', 's3cret', debug: @dbg)
224
+ @hub.password = 's3cret'.ljust(8)
225
+ usb.on(1)
226
+ usb.commit
227
+ usb.password('other')
228
+ refute_match(/s3cret/, @dbg.string)
229
+ refute_match(/other/, @dbg.string)
230
+ assert_match(/<-- SP\*{8}/, @dbg.string)
231
+ end
232
+
233
+ ## Malformed replies #################################################
234
+
235
+ def test_error_reply_to_a_read_is_raised_with_its_code
236
+ @hub.garbage = 'E42'
237
+ err = assert_raises(ExSYS::ManagedUSB::Error) { @usb.get }
238
+ assert_equal '42', err.message
239
+ end
240
+
241
+ # Regression: a reply of an unexpected length used to raise a bare
242
+ # Error, whose message was just the class name.
243
+ def test_unexpected_reply_length_says_what_arrived
244
+ @hub.garbage = 'wat'
245
+ err = assert_raises(ExSYS::ManagedUSB::Error) { @usb.get }
246
+ assert_match(/unexpected reply/, err.message)
247
+ assert_match(/wat/, err.message)
248
+ end
249
+
250
+ def test_silent_hub_is_an_error_not_a_hang
251
+ @hub.silent = true
252
+ assert_raises(ExSYS::ManagedUSB::Error) { @usb.get }
253
+ end
254
+
255
+ ## The serial line ###################################################
256
+
257
+ # Regression: a read-modify-write used to close the line between the
258
+ # GP and the SP, letting another process slip in between the two.
259
+ def test_read_modify_write_holds_the_line_open
260
+ @usb.on(1)
261
+ assert_equal 1, @hub.opens
262
+ assert_equal 2, @hub.log.size # GP then SP, one session
263
+ end
264
+
265
+ def test_set_holds_the_line_open_too
266
+ @usb.set({ 1 => true })
267
+ assert_equal 1, @hub.opens
268
+ end
269
+
270
+ def test_the_line_is_opened_as_the_hub_expects
271
+ @usb.on(1)
272
+ assert_equal '/dev/null', @hub.line
273
+ assert_equal ExSYS::ManagedUSB::SPEED, @hub.speed
274
+ assert_equal 9600, @hub.speed
275
+ assert_equal '8N1', @hub.mode
276
+ end
277
+
278
+ def test_the_line_is_locked_while_held
279
+ @usb.on(1)
280
+ assert_equal 1, @hub.locks
281
+ end
282
+
283
+ # A platform that will not lock a character device must not make the
284
+ # tool unusable; it degrades to unlocked and says so.
285
+ def test_an_unlockable_line_degrades_rather_than_failing
286
+ @hub.lockable = false
287
+ @usb.on(1)
288
+ assert_equal [ 1 ], @hub.ports_on
289
+ assert_match(/not lockable/, @dbg.string)
290
+ end
291
+ end