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.
- checksums.yaml +4 -4
- data/README.md +141 -17
- data/Rakefile +19 -0
- data/bin/exsys-usb +23 -13
- data/exsys.gemspec +4 -2
- data/lib/exsys/managed-usb.rb +128 -32
- data/lib/exsys/version.rb +1 -1
- data/test/helper.rb +60 -0
- data/test/support/fake_hub.rb +139 -0
- data/test/support/uart.rb +40 -0
- data/test/test_exsys_usb.rb +230 -0
- data/test/test_managed_usb.rb +291 -0
- data/test/test_readme.rb +61 -0
- metadata +24 -6
|
@@ -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
|