scout-essentials 1.9.1 → 1.9.3
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/.vimproject +18 -1
- data/Rakefile +9 -0
- data/VERSION +1 -1
- data/doc/Improvements.md +20 -0
- data/doc/StartHere.md +7 -4
- data/doc/developer/AnnotationSystem.md +10 -15
- data/doc/developer/Configuration.md +20 -30
- data/doc/developer/CoreUtilities.md +52 -22
- data/doc/developer/DesignPrinciples.md +7 -9
- data/doc/developer/DocumentationPractices.md +476 -0
- data/doc/developer/ErrorHandling.md +11 -20
- data/doc/developer/LockingAndConcurrency.md +20 -13
- data/doc/developer/StreamingModel.md +13 -14
- data/doc/developer/Testing.md +389 -0
- data/doc/user/AnnotatingData.md +9 -13
- data/doc/user/CommandLineOptions.md +9 -12
- data/doc/user/Cookbook.md +7 -10
- data/doc/user/HandlingStreams.md +22 -29
- data/doc/user/LoggingAndProgress.md +9 -11
- data/doc/user/ProducingResources.md +0 -1
- data/doc/user/RemoteData.md +10 -13
- data/doc/user/RunningCommands.md +12 -17
- data/doc/user/WorkingWithFiles.md +10 -2
- data/lib/scout/config.rb +1 -0
- data/lib/scout/indiferent_hash.rb +8 -2
- data/lib/scout/log/fingerprint.rb +1 -1
- data/lib/scout/named_array.rb +4 -0
- data/lib/scout/open/final.rb +1 -1
- data/lib/scout/open/stream.rb +14 -13
- data/lib/scout/path/util.rb +1 -1
- data/lib/scout/testing/availability.rb +264 -0
- data/lib/scout/testing/scout_state.rb +193 -0
- data/lib/scout/testing.rb +82 -0
- data/research/doc-audit-findings.md +7 -5
- data/research/doc-structure.md +89 -0
- data/research/lock-namespace-blindness.md +73 -0
- data/research/probing-guide.md +311 -0
- data/research/testing-availability-scout-ai-handoff.md +235 -0
- data/research/testing-availability-scout-rig-handoff.md +355 -0
- data/scout-essentials.gemspec +24 -3
- data/test/scout/annotation/test_dup_clone.rb +31 -0
- data/test/scout/indiferent_hash/test_case_insensitive_staleness.rb +27 -0
- data/test/scout/indiferent_hash/test_string2hash_false.rb +13 -0
- data/test/scout/misc/test_timespan.rb +30 -0
- data/test/scout/open/test_compression_detection.rb +32 -0
- data/test/scout/open/test_forced_gzip_broken.rb +25 -0
- data/test/scout/test_named_array_resolution.rb +49 -0
- data/test/scout/test_testing.rb +195 -0
- data/test/scout/test_tmpfile_cleanup.rb +23 -0
- data/test/scout/testing/test_availability.rb +222 -0
- data/test/scout/testing/test_state_bindings.rb +163 -0
- data/test/test_helper.rb +26 -12
- metadata +23 -2
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# Testing availability handoff — scout-rig
|
|
2
|
+
|
|
3
|
+
Provenance: salvage packet produced during the scout-essentials layering cleanup
|
|
4
|
+
(2026-10). The interrupted draft in scout-essentials contained Python/R probes
|
|
5
|
+
that belong to scout-rig. This note is self-contained: layer table, corrected
|
|
6
|
+
reference implementations, interpreter-selection matrix, motivation, and
|
|
7
|
+
constraints. Nothing here needs to be re-derived from scout-essentials.
|
|
8
|
+
|
|
9
|
+
## Scope for scout-rig
|
|
10
|
+
|
|
11
|
+
scout-rig owns **runtime-availability probes for interpreters and their
|
|
12
|
+
packages**: `python?`, `python_modules?`/`python_modules_reason`,
|
|
13
|
+
`rscript?`, `r_packages?`/`r_packages_reason` (R: reference code only — there
|
|
14
|
+
are no live R call sites today).
|
|
15
|
+
|
|
16
|
+
## Layer table
|
|
17
|
+
|
|
18
|
+
| Probe / method | Layer | Destination |
|
|
19
|
+
|---|---|---|
|
|
20
|
+
| `executable?(name)` — PATH lookup, no shell | scout-essentials | `lib/scout/testing/availability.rb` (already delivered: `Scout::Testing::Availability.executable?`) |
|
|
21
|
+
| `run_command(argv, timeout:)` — bounded, both streams, TERM→KILL | scout-essentials | same file, `Scout::Testing::Availability.run_command` |
|
|
22
|
+
| `tcp_reachable?(host, port, timeout:)` | scout-essentials | same file, `Scout::Testing::Availability.tcp_reachable?` |
|
|
23
|
+
| `python?` | scout-rig | scout-rig test-support module (suggested `test/support/availability.rb` equivalent, e.g. `ScoutRig::Testing::Availability`) |
|
|
24
|
+
| `python_modules?(mods)` + `python_modules_reason(mods)` | scout-rig | same module |
|
|
25
|
+
| `rscript?` | scout-rig (reference only) | same module |
|
|
26
|
+
| `r_packages?(pkgs)` + `r_packages_reason(pkgs)` | scout-rig (reference only) | same module |
|
|
27
|
+
| `cuda?` | scout-ai | scout-ai `test/support/availability.rb` (see testing-availability-scout-ai-handoff.md) |
|
|
28
|
+
| `hf_model_cached?` / `hf_model_reason` | scout-ai | same |
|
|
29
|
+
| `endpoint_reachable?`, `endpoint_configured?`, `endpoint_reason`, `configured_endpoints`, `real_endpoints`, `endpoint_available?` | scout-ai | same |
|
|
30
|
+
| `InfrastructureProbes` | scout-ai | scout-ai `test/support/infrastructure_probes.rb`, unchanged |
|
|
31
|
+
|
|
32
|
+
## Defect list relevant to scout-rig
|
|
33
|
+
|
|
34
|
+
The interrupted draft (and the scout-ai support file it came from) had 7 known
|
|
35
|
+
defects. The ones that apply to the Python/R probes:
|
|
36
|
+
|
|
37
|
+
1. **`python?` ignored the subprocess exit status.** `python -c 'import sys'`
|
|
38
|
+
can fail at startup (broken interpreter, missing stdlib) while still writing
|
|
39
|
+
output; only the exit status proves the interpreter works. Fixed in the
|
|
40
|
+
reference below.
|
|
41
|
+
2. **`python_modules_reason` read stdout.** Python import tracebacks go to
|
|
42
|
+
**stderr**; reading stdout yields an empty, useless reason. Fixed below.
|
|
43
|
+
3. **`memo` rescued `Exception`.** That swallows `SignalException`,
|
|
44
|
+
`NoMemoryError`, and friends. Rescue `StandardError` only; treat timeout and
|
|
45
|
+
launch failure deliberately (they are recorded facts, not surprises).
|
|
46
|
+
4. **`run_bounded` drained stdout before stderr** (pipe-deadlock risk with a
|
|
47
|
+
chatty child), spawned **before** its rescue (a launch failure could escape
|
|
48
|
+
the method), and the `capture:false` path drained **neither** pipe. Either
|
|
49
|
+
delegate to `Scout::Testing::Availability.run_command` (preferred — it
|
|
50
|
+
drains concurrently with a deadline and reports launch failure as a result,
|
|
51
|
+
not an exception) or apply the corrected runner included below.
|
|
52
|
+
5. (memoization ignoring an argument) — scout-rig has no endpoint probes; the
|
|
53
|
+
analogous rule is applied: memoize keyed on **executable + env + timeout**,
|
|
54
|
+
with an explicit `reset!`.
|
|
55
|
+
6. (HF cache ignoring `HF_HUB_CACHE`) — scout-ai only, not applicable here.
|
|
56
|
+
7. **Module/package names were interpolated unsafely** into Python/R source.
|
|
57
|
+
A name containing a quote escapes the source string. Pass names as **argv**
|
|
58
|
+
to a fixed probe script (Python: `sys.argv`; R: `commandArgs(TRUE)`), or
|
|
59
|
+
fall back to `Shellwords.escape` only if a source string is unavoidable.
|
|
60
|
+
|
|
61
|
+
## Corrected reference implementations
|
|
62
|
+
|
|
63
|
+
Assumes scout-essentials ≥ the version providing
|
|
64
|
+
`Scout::Testing::Availability` (`require 'scout/testing/availability'`). The
|
|
65
|
+
module is deliberately opt-in and stdlib-only; scout-rig adds the interpreter
|
|
66
|
+
knowledge on top of it.
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# scout-rig test support: interpreter/package availability probes.
|
|
70
|
+
# Ordinary unit tests must stay OFFLINE: these probes only run local
|
|
71
|
+
# executables. Product-failure assertions must never become skips.
|
|
72
|
+
require 'scout/testing/availability'
|
|
73
|
+
require 'shellwords' # only used by the *_reason fallbacks
|
|
74
|
+
|
|
75
|
+
module ScoutRig
|
|
76
|
+
module Testing
|
|
77
|
+
module Availability
|
|
78
|
+
PROBE_TIMEOUT = 30
|
|
79
|
+
|
|
80
|
+
class << self
|
|
81
|
+
# --- memoization: keyed on the probe inputs, StandardError only ---
|
|
82
|
+
|
|
83
|
+
def memo
|
|
84
|
+
@memo ||= {}
|
|
85
|
+
key = yield.to_s # caller builds the full key first
|
|
86
|
+
return @memo[key] if @memo.key?(key)
|
|
87
|
+
|
|
88
|
+
@memo[key] = yield # NOTE: see each probe; key is passed explicitly
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def reset!
|
|
92
|
+
@memo = {}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def [](key)
|
|
96
|
+
@memo ||= {}
|
|
97
|
+
@memo[key]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def []=(key, value)
|
|
101
|
+
@memo ||= {}
|
|
102
|
+
@memo[key] = value
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def memoized(key)
|
|
106
|
+
@memo ||= {}
|
|
107
|
+
return @memo[key] if @memo.key?(key)
|
|
108
|
+
|
|
109
|
+
@memo[key] = yield
|
|
110
|
+
rescue StandardError => e # defect 3: never rescue Exception
|
|
111
|
+
"probe error: #{e.class}: #{e.message}"
|
|
112
|
+
end
|
|
113
|
+
private :memoized
|
|
114
|
+
|
|
115
|
+
# --- executable selection ---
|
|
116
|
+
|
|
117
|
+
# SCOUT_TEST_PYTHON wins; else PATH lookup in order python3, python.
|
|
118
|
+
# Return is an absolute-or-PATH executable name, nil when absent.
|
|
119
|
+
def python_executable
|
|
120
|
+
configured = ENV['SCOUT_TEST_PYTHON'].to_s.strip
|
|
121
|
+
return configured unless configured.empty?
|
|
122
|
+
return 'python3' if Scout::Testing::Availability.executable?('python3')
|
|
123
|
+
return 'python' if Scout::Testing::Availability.executable?('python')
|
|
124
|
+
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def rscript_executable
|
|
129
|
+
configured = ENV['SCOUT_TEST_RSCRIPT'].to_s.strip
|
|
130
|
+
return configured unless configured.empty?
|
|
131
|
+
return 'Rscript' if Scout::Testing::Availability.executable?('Rscript')
|
|
132
|
+
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# --- Python probes ---
|
|
137
|
+
|
|
138
|
+
# defect 1: the exit status, not the output, decides.
|
|
139
|
+
def python?
|
|
140
|
+
executable = python_executable
|
|
141
|
+
return false unless executable
|
|
142
|
+
|
|
143
|
+
result = Scout::Testing::Availability.run_command(
|
|
144
|
+
[executable, '-c', 'import sys'], timeout: PROBE_TIMEOUT
|
|
145
|
+
)
|
|
146
|
+
result.success? && result.exit_status == 0
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# defect 7: module names travel as argv, never interpolated.
|
|
150
|
+
# defect 2: the reason comes from stderr, where tracebacks go.
|
|
151
|
+
def python_modules?(*modules)
|
|
152
|
+
!python_modules_reason(*modules)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def python_modules_reason(*modules)
|
|
156
|
+
executable = python_executable
|
|
157
|
+
return 'no python executable found' unless executable
|
|
158
|
+
return nil if modules.empty?
|
|
159
|
+
|
|
160
|
+
# Fixed probe source; names arrive as argv so quoting is moot.
|
|
161
|
+
probe = <<~PY
|
|
162
|
+
import importlib, sys
|
|
163
|
+
missing = []
|
|
164
|
+
for name in sys.argv[1:]:
|
|
165
|
+
try:
|
|
166
|
+
importlib.import_module(name)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
missing.append("%s: %s: %s" % (name, type(e).__name__, e))
|
|
169
|
+
if missing:
|
|
170
|
+
sys.stderr.write("; ".join(missing))
|
|
171
|
+
sys.exit(1)
|
|
172
|
+
PY
|
|
173
|
+
|
|
174
|
+
result = Scout::Testing::Availability.run_command(
|
|
175
|
+
[executable, '-c', probe, *modules], timeout: PROBE_TIMEOUT
|
|
176
|
+
)
|
|
177
|
+
return nil if result.success? && result.exit_status == 0
|
|
178
|
+
|
|
179
|
+
detail = result.stderr.to_s.strip
|
|
180
|
+
detail = "python exited #{result.exit_status}" if detail.empty?
|
|
181
|
+
detail = "python timed out after #{PROBE_TIMEOUT}s" if result.timed_out
|
|
182
|
+
detail = result.launch_error if result.launch_error
|
|
183
|
+
detail
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# --- R probes: REFERENCE ONLY ---
|
|
187
|
+
# No live call sites in scout-rig today. Keep the code, do not wire
|
|
188
|
+
# it into unit tests until an R integration exists.
|
|
189
|
+
|
|
190
|
+
def rscript?
|
|
191
|
+
executable = rscript_executable
|
|
192
|
+
return false unless executable
|
|
193
|
+
|
|
194
|
+
result = Scout::Testing::Availability.run_command(
|
|
195
|
+
[executable, '--vanilla', '-e', 'cat("ok")'], timeout: PROBE_TIMEOUT
|
|
196
|
+
)
|
|
197
|
+
result.success? && result.exit_status == 0
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# defect 7: package names as argv via commandArgs(TRUE).
|
|
201
|
+
def r_packages?(*packages)
|
|
202
|
+
!r_packages_reason(*packages)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def r_packages_reason(*packages)
|
|
206
|
+
executable = rscript_executable
|
|
207
|
+
return 'no Rscript executable found' unless executable
|
|
208
|
+
return nil if packages.empty?
|
|
209
|
+
|
|
210
|
+
probe = <<~R
|
|
211
|
+
args <- commandArgs(TRUE)
|
|
212
|
+
# `--args` itself is retained as the first element when the
|
|
213
|
+
# interpreter is invoked as `Rscript -e ... --args pkg1 pkg2`.
|
|
214
|
+
pkgs <- args[args != "--args"]
|
|
215
|
+
missing <- character(0)
|
|
216
|
+
for (p in pkgs) {
|
|
217
|
+
if (!requireNamespace(p, quietly = TRUE)) missing <- c(missing, p)
|
|
218
|
+
}
|
|
219
|
+
if (length(missing) > 0) {
|
|
220
|
+
cat(paste(missing, collapse = ", "))
|
|
221
|
+
quit(status = 1)
|
|
222
|
+
}
|
|
223
|
+
R
|
|
224
|
+
|
|
225
|
+
result = Scout::Testing::Availability.run_command(
|
|
226
|
+
[executable, '--vanilla', '-e', probe, '--args', *packages],
|
|
227
|
+
timeout: PROBE_TIMEOUT
|
|
228
|
+
)
|
|
229
|
+
return nil if result.success? && result.exit_status == 0
|
|
230
|
+
|
|
231
|
+
detail = result.stderr.to_s.strip
|
|
232
|
+
detail = result.stdout.to_s.strip if detail.empty? # R writes to stdout here
|
|
233
|
+
detail = "Rscript exited #{result.exit_status}" if detail.empty?
|
|
234
|
+
detail = "Rscript timed out after #{PROBE_TIMEOUT}s" if result.timed_out
|
|
235
|
+
detail = result.launch_error if result.launch_error
|
|
236
|
+
detail
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### If scout-essentials cannot be a dependency
|
|
245
|
+
|
|
246
|
+
Apply the same four corrections in a local bounded runner (this is the shape
|
|
247
|
+
`Scout::Testing::Availability.run_command` implements; reproduce only what is
|
|
248
|
+
needed):
|
|
249
|
+
|
|
250
|
+
```ruby
|
|
251
|
+
def run_bounded(argv, timeout: 30)
|
|
252
|
+
out_r, out_w = IO.pipe
|
|
253
|
+
err_r, err_w = IO.pipe
|
|
254
|
+
stdout = +''
|
|
255
|
+
stderr = +''
|
|
256
|
+
# defect 4a/4c: drain BOTH pipes concurrently, always, including when the
|
|
257
|
+
# caller does not want the output captured.
|
|
258
|
+
readers = {
|
|
259
|
+
out_r => stdout, err_r => stderr
|
|
260
|
+
}
|
|
261
|
+
pid = nil
|
|
262
|
+
begin # defect 4b: spawn INSIDE the rescue scope
|
|
263
|
+
pid = Process.spawn(*argv, out: out_w, err: err_w, pgroup: true)
|
|
264
|
+
rescue StandardError => e # launch failure is a fact, not an exception
|
|
265
|
+
[out_r, out_w, err_r, err_w].each { |io| io.close rescue nil }
|
|
266
|
+
return Struct.new(:success, :exit_status, :stdout, :stderr, :launch_error, :timed_out)
|
|
267
|
+
.new(false, nil, '', '', "#{e.class}: #{e.message}", false)
|
|
268
|
+
ensure
|
|
269
|
+
out_w.close rescue nil
|
|
270
|
+
err_w.close rescue nil
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
274
|
+
threads = readers.map do |io, buf|
|
|
275
|
+
Thread.new { io.each_line { |l| buf << l } }
|
|
276
|
+
end
|
|
277
|
+
timed_out = false
|
|
278
|
+
status = nil
|
|
279
|
+
until status
|
|
280
|
+
_w, st = Process.waitpid2(pid, Process::WNOHANG)
|
|
281
|
+
if st
|
|
282
|
+
status = st
|
|
283
|
+
break
|
|
284
|
+
end
|
|
285
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
286
|
+
timed_out = true
|
|
287
|
+
Process.kill('TERM', -pid) rescue nil
|
|
288
|
+
sleep 0.1
|
|
289
|
+
Process.kill('KILL', -pid) rescue nil
|
|
290
|
+
status = Process.waitpid2(pid).last
|
|
291
|
+
break
|
|
292
|
+
end
|
|
293
|
+
sleep 0.05
|
|
294
|
+
end
|
|
295
|
+
threads.each(&:join)
|
|
296
|
+
[out_r, err_r].each { |io| io.close rescue nil }
|
|
297
|
+
Struct.new(:success, :exit_status, :stdout, :stderr, :launch_error, :timed_out)
|
|
298
|
+
.new(!timed_out && status.exitstatus == 0, status.exitstatus,
|
|
299
|
+
stdout, stderr, nil, timed_out)
|
|
300
|
+
end
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Memoization discipline
|
|
304
|
+
|
|
305
|
+
Memoize each probe keyed on its full input set — for these probes that is
|
|
306
|
+
`(executable, modules/packages, timeout)`, since `SCOUT_TEST_PYTHON` or `PATH`
|
|
307
|
+
can change between calls in one process. Provide an explicit
|
|
308
|
+
`ScoutRig::Testing::Availability.reset!` and call it from test setup when the
|
|
309
|
+
environment is manipulated. Rescue `StandardError`, never `Exception`
|
|
310
|
+
(defect 3): a `SignalException` must not be cached as "unavailable".
|
|
311
|
+
|
|
312
|
+
## Interpreter-selection matrix
|
|
313
|
+
|
|
314
|
+
scout-rig has **two Python routes** (per `user/PythonEnvironments.md`):
|
|
315
|
+
|
|
316
|
+
| Route | How the interpreter is chosen | Notes |
|
|
317
|
+
|---|---|---|
|
|
318
|
+
| Embedded (PyCall) | `ScoutPython.run*` reads `ENV['PYTHON']`, else PATH `python3`/`python`; `PyCall.init` **sets `ENV['PYTHONHOME']`** | Mutates process env; affects everything spawned afterwards |
|
|
319
|
+
| Subprocess (`ScoutPython.script` / `run_file` / `python_task`) | literal `'python'` from PATH + `PYTHONPATH` augmentation; **ignores `ENV['PYTHON']`** | Splits from the embedded route: the two can silently use different interpreters |
|
|
320
|
+
|
|
321
|
+
**Setting `ENV['PYTHON']` alone breaks subprocesses** ("Failed to import
|
|
322
|
+
encodings"): the subprocess gets a `PYTHONHOME` pointing at the embedded
|
|
323
|
+
interpreter while running a different one. The reliable recipe, which the
|
|
324
|
+
reference probes inherit for free by resolving one executable name and passing
|
|
325
|
+
it as argv:
|
|
326
|
+
|
|
327
|
+
> Put the chosen environment's `bin` directory **first on PATH**, and let each
|
|
328
|
+
> route resolve the same interpreter from PATH; set `SCOUT_TEST_PYTHON` (or
|
|
329
|
+
> `SCOUT_TEST_RSCRIPT`) to pin an absolute interpreter for tests.
|
|
330
|
+
|
|
331
|
+
Never export `PYTHONHOME` from test setup.
|
|
332
|
+
|
|
333
|
+
## Motivation: the torch `test_binding` failure
|
|
334
|
+
|
|
335
|
+
scout-rig's suite is currently 9 tests, and `test_binding` hard-fails on stock
|
|
336
|
+
environments because it requires **torch**, which is not a declared dependency.
|
|
337
|
+
This is exactly the case a `python_modules?(:torch)` guard exists for: the test
|
|
338
|
+
should omit with `python_modules_reason(:torch)` when torch is absent, instead
|
|
339
|
+
of erroring. Its `test_helper.rb` has no interpreter selection or skip logic
|
|
340
|
+
today — add the `SCOUT_TEST_PYTHON`/PATH recipe above plus the guard.
|
|
341
|
+
|
|
342
|
+
"Guarded with `python_modules?`" means adding the omit and the reason message,
|
|
343
|
+
**not** adding torch to the gemspec or the README prerequisites.
|
|
344
|
+
|
|
345
|
+
## Constraints
|
|
346
|
+
|
|
347
|
+
- Ordinary unit tests stay **offline**; these probes only execute local
|
|
348
|
+
interpreters and never touch the network.
|
|
349
|
+
- **Product-failure assertions must never become skips**: a probe may omit a
|
|
350
|
+
test when an optional dependency is genuinely absent; it must not convert a
|
|
351
|
+
regression in shipped code into a quiet omission. `rscript?`/`r_packages?`
|
|
352
|
+
are reference-only until an R integration exists — do not wire them into
|
|
353
|
+
unit tests.
|
|
354
|
+
- Keep the layer boundary: no endpoint/model/accelerator probes in scout-rig;
|
|
355
|
+
those live in scout-ai (see `testing-availability-scout-ai-handoff.md`).
|
data/scout-essentials.gemspec
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
# DO NOT EDIT THIS FILE DIRECTLY
|
|
3
3
|
# Instead, edit Juwelier::Tasks in Rakefile, and run 'rake gemspec'
|
|
4
4
|
# -*- encoding: utf-8 -*-
|
|
5
|
-
# stub: scout-essentials 1.9.
|
|
5
|
+
# stub: scout-essentials 1.9.3 ruby lib
|
|
6
6
|
|
|
7
7
|
Gem::Specification.new do |s|
|
|
8
8
|
s.name = "scout-essentials".freeze
|
|
9
|
-
s.version = "1.9.
|
|
9
|
+
s.version = "1.9.3".freeze
|
|
10
10
|
|
|
11
11
|
s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
|
|
12
12
|
s.require_paths = ["lib".freeze]
|
|
@@ -33,11 +33,13 @@ Gem::Specification.new do |s|
|
|
|
33
33
|
"doc/developer/Configuration.md",
|
|
34
34
|
"doc/developer/CoreUtilities.md",
|
|
35
35
|
"doc/developer/DesignPrinciples.md",
|
|
36
|
+
"doc/developer/DocumentationPractices.md",
|
|
36
37
|
"doc/developer/ErrorHandling.md",
|
|
37
38
|
"doc/developer/LockingAndConcurrency.md",
|
|
38
39
|
"doc/developer/PathResolution.md",
|
|
39
40
|
"doc/developer/PersistenceAndResources.md",
|
|
40
41
|
"doc/developer/StreamingModel.md",
|
|
42
|
+
"doc/developer/Testing.md",
|
|
41
43
|
"doc/user/AnnotatingData.md",
|
|
42
44
|
"doc/user/CachingResults.md",
|
|
43
45
|
"doc/user/CommandLineOptions.md",
|
|
@@ -113,26 +115,37 @@ Gem::Specification.new do |s|
|
|
|
113
115
|
"lib/scout/simple_opt/get.rb",
|
|
114
116
|
"lib/scout/simple_opt/parse.rb",
|
|
115
117
|
"lib/scout/simple_opt/setup.rb",
|
|
118
|
+
"lib/scout/testing.rb",
|
|
119
|
+
"lib/scout/testing/availability.rb",
|
|
120
|
+
"lib/scout/testing/scout_state.rb",
|
|
116
121
|
"lib/scout/tmpfile.rb",
|
|
117
122
|
"research/annotations-data-analysis.md",
|
|
118
123
|
"research/behavior-probes.md",
|
|
119
124
|
"research/commands-streaming-analysis.md",
|
|
120
125
|
"research/design-philosophy-analysis.md",
|
|
121
126
|
"research/doc-audit-findings.md",
|
|
127
|
+
"research/doc-structure.md",
|
|
122
128
|
"research/ecosystem-attribution.md",
|
|
123
129
|
"research/implementation-inventory-core.md",
|
|
124
130
|
"research/implementation-inventory-open.md",
|
|
125
131
|
"research/implementation-inventory-path-persist-resource.md",
|
|
126
132
|
"research/io-paths-analysis.md",
|
|
133
|
+
"research/lock-namespace-blindness.md",
|
|
127
134
|
"research/persistence-resources-analysis.md",
|
|
135
|
+
"research/probing-guide.md",
|
|
128
136
|
"research/synthesis-report.md",
|
|
137
|
+
"research/testing-availability-scout-ai-handoff.md",
|
|
138
|
+
"research/testing-availability-scout-rig-handoff.md",
|
|
129
139
|
"scout-essentials.gemspec",
|
|
130
140
|
"share/color/color_names",
|
|
131
141
|
"share/color/diverging_colors.hex",
|
|
132
142
|
"share/software/install_helpers",
|
|
133
143
|
"test/scout/annotation/test_array.rb",
|
|
144
|
+
"test/scout/annotation/test_dup_clone.rb",
|
|
134
145
|
"test/scout/indiferent_hash/test_case_insensitive.rb",
|
|
146
|
+
"test/scout/indiferent_hash/test_case_insensitive_staleness.rb",
|
|
135
147
|
"test/scout/indiferent_hash/test_options.rb",
|
|
148
|
+
"test/scout/indiferent_hash/test_string2hash_false.rb",
|
|
136
149
|
"test/scout/log/test_color.rb",
|
|
137
150
|
"test/scout/log/test_fingerprint.rb",
|
|
138
151
|
"test/scout/log/test_progress.rb",
|
|
@@ -144,6 +157,9 @@ Gem::Specification.new do |s|
|
|
|
144
157
|
"test/scout/misc/test_matching.rb",
|
|
145
158
|
"test/scout/misc/test_math.rb",
|
|
146
159
|
"test/scout/misc/test_system.rb",
|
|
160
|
+
"test/scout/misc/test_timespan.rb",
|
|
161
|
+
"test/scout/open/test_compression_detection.rb",
|
|
162
|
+
"test/scout/open/test_forced_gzip_broken.rb",
|
|
147
163
|
"test/scout/open/test_lock.rb",
|
|
148
164
|
"test/scout/open/test_remote.rb",
|
|
149
165
|
"test/scout/open/test_stream.rb",
|
|
@@ -172,16 +188,21 @@ Gem::Specification.new do |s|
|
|
|
172
188
|
"test/scout/test_log.rb",
|
|
173
189
|
"test/scout/test_misc.rb",
|
|
174
190
|
"test/scout/test_named_array.rb",
|
|
191
|
+
"test/scout/test_named_array_resolution.rb",
|
|
175
192
|
"test/scout/test_open.rb",
|
|
176
193
|
"test/scout/test_path.rb",
|
|
177
194
|
"test/scout/test_persist.rb",
|
|
178
195
|
"test/scout/test_resource.rb",
|
|
196
|
+
"test/scout/test_testing.rb",
|
|
179
197
|
"test/scout/test_tmpfile.rb",
|
|
198
|
+
"test/scout/test_tmpfile_cleanup.rb",
|
|
199
|
+
"test/scout/testing/test_availability.rb",
|
|
200
|
+
"test/scout/testing/test_state_bindings.rb",
|
|
180
201
|
"test/test_helper.rb"
|
|
181
202
|
]
|
|
182
203
|
s.homepage = "http://github.com/mikisvaz/scout-essentials".freeze
|
|
183
204
|
s.licenses = ["MIT".freeze]
|
|
184
|
-
s.rubygems_version = "
|
|
205
|
+
s.rubygems_version = "4.0.23".freeze
|
|
185
206
|
s.summary = "Scout essential tools".freeze
|
|
186
207
|
|
|
187
208
|
s.specification_version = 4
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
require 'scout/annotation'
|
|
3
|
+
|
|
4
|
+
class TestAnnotationDupClone < Test::Unit::TestCase
|
|
5
|
+
module AnnotSetupA
|
|
6
|
+
extend Annotation
|
|
7
|
+
annotation :organism, :tissue
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def test_dup_strips_annotations_clone_keeps
|
|
11
|
+
a = AnnotSetupA.setup('S004', 'Human', 'Liver')
|
|
12
|
+
assert Annotation.is_annotated?(a)
|
|
13
|
+
|
|
14
|
+
d = a.dup
|
|
15
|
+
assert ! Annotation.is_annotated?(d)
|
|
16
|
+
assert_equal 'S004', d
|
|
17
|
+
|
|
18
|
+
c = a.clone
|
|
19
|
+
assert Annotation.is_annotated?(c)
|
|
20
|
+
assert_equal 'Human', c.organism
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def test_setup_on_frozen_object_returns_new_annotated_copy
|
|
24
|
+
a = 'S007'.freeze
|
|
25
|
+
r = AnnotSetupA.setup(a, organism: 'Human')
|
|
26
|
+
assert ! Annotation.is_annotated?(a) # original untouched
|
|
27
|
+
assert a.frozen?
|
|
28
|
+
assert Annotation.is_annotated?(r) # result is a new, annotated copy
|
|
29
|
+
assert_equal 'Human', r.organism
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
require 'scout/indiferent_hash/case_insensitive'
|
|
3
|
+
|
|
4
|
+
class TestCaseInsensitiveHashStaleness < Test::Unit::TestCase
|
|
5
|
+
def test_miss_then_write_leaves_key_invisible
|
|
6
|
+
ci = CaseInsensitiveHash.setup({'Key' => 1})
|
|
7
|
+
assert_nil ci['fourth'] # builds the downcase map without "Fourth"
|
|
8
|
+
ci['Fourth'] = 9 # plain Hash#[]= does not invalidate the map
|
|
9
|
+
assert_equal 9, ci['Fourth'] # exact lookup still works
|
|
10
|
+
assert_nil ci['fourth'] # but case-insensitive lookup is stale
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def test_symbol_key_added_after_a_miss_is_invisible
|
|
14
|
+
ci = CaseInsensitiveHash.setup({'Key' => 1})
|
|
15
|
+
assert_nil ci['absent'] # miss freezes the downcase map
|
|
16
|
+
ci[:third] = 7 # added after the freeze
|
|
17
|
+
assert_equal 7, ci[:third] # symbol lookup is exact and works
|
|
18
|
+
assert_nil ci['third'] # string lookup is stale
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def test_read_side_only
|
|
22
|
+
ci = CaseInsensitiveHash.setup({'Key' => 1})
|
|
23
|
+
ci['Other'] = 5
|
|
24
|
+
assert_equal 5, ci['other']
|
|
25
|
+
assert_equal 5, ci['OTHER']
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
require 'scout/indiferent_hash'
|
|
3
|
+
|
|
4
|
+
class TestIndiferentString2HashFalse < Test::Unit::TestCase
|
|
5
|
+
def test_true_becomes_trueclass
|
|
6
|
+
assert_equal({ 'a' => true }, IndiferentHash.string2hash('a=true'))
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def test_false_stays_the_string_false
|
|
10
|
+
# asymmetry: 'true' converts, 'false' does not
|
|
11
|
+
assert_equal({ 'a' => 'false' }, IndiferentHash.string2hash('a=false'))
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
require 'scout/misc/format'
|
|
3
|
+
|
|
4
|
+
class TestMiscTimespan < Test::Unit::TestCase
|
|
5
|
+
def test_plain_units
|
|
6
|
+
assert_equal 5, Misc.timespan('5s')
|
|
7
|
+
assert_equal 60, Misc.timespan('1m')
|
|
8
|
+
assert_equal 3600, Misc.timespan('1h')
|
|
9
|
+
assert_equal 86400, Misc.timespan('1d')
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def test_negative
|
|
13
|
+
assert_equal(-5, Misc.timespan('-5s'))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def test_space_separated_units_add_up
|
|
17
|
+
assert_equal 90, Misc.timespan('1m 30s')
|
|
18
|
+
assert_equal 5400, Misc.timespan('1h 30m')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def test_glued_units_raise
|
|
22
|
+
# The scan is /(\d+)(\w*)/ and greedy: '1m30s' pairs 1 with the
|
|
23
|
+
# unknown token 'm30s', so the multiplication sees nil and raises.
|
|
24
|
+
assert_raise(TypeError) { Misc.timespan('1m30s') }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def test_unknown_unit_raises
|
|
28
|
+
assert_raise(TypeError) { Misc.timespan('1x') }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
|
|
3
|
+
class TestOpenCompressionDetection < Test::Unit::TestCase
|
|
4
|
+
def test_suffix_detection_is_lowercase_only
|
|
5
|
+
TmpFile.with_file('content') do |file|
|
|
6
|
+
`gzip #{file}`
|
|
7
|
+
gz = file + '.gz'
|
|
8
|
+
assert Open.gzip?(gz)
|
|
9
|
+
assert Open.compressed?(gz)
|
|
10
|
+
|
|
11
|
+
upper = gz + '.GZ.tmp'
|
|
12
|
+
FileUtils.mv gz, upper
|
|
13
|
+
FileUtils.cp upper, gz + '.2' if false
|
|
14
|
+
assert ! Open.gzip?(upper), '.GZ is not detected'
|
|
15
|
+
assert ! Open.compressed?(upper)
|
|
16
|
+
FileUtils.mv upper, gz
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def test_gz_magic_bytes_without_suffix_stay_raw
|
|
21
|
+
content = "a\nb\n"
|
|
22
|
+
TmpFile.with_file(content) do |file|
|
|
23
|
+
`gzip #{file}`
|
|
24
|
+
gz = file + '.gz'
|
|
25
|
+
noext = gz + '.noext'
|
|
26
|
+
FileUtils.mv gz, noext
|
|
27
|
+
assert_equal [31, 139].first, Open.read(noext).bytes.first
|
|
28
|
+
assert_equal 'a', Open.read(noext, :noz => true).bytes.first.chr rescue nil
|
|
29
|
+
FileUtils.rm noext
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
|
|
3
|
+
class TestOpenForcedGzipBroken < Test::Unit::TestCase
|
|
4
|
+
# Regression test: :gzip => true must NOT be used to force decompression.
|
|
5
|
+
# The option is forwarded to CMD.cmd('gzip', ...) as a command-line flag,
|
|
6
|
+
# producing `gzip --gzip` which fails; :no_fail swallows the failure and
|
|
7
|
+
# the caller silently gets an empty string.
|
|
8
|
+
def test_forced_gzip_returns_empty_string
|
|
9
|
+
TmpFile.with_file("a\nb\n") do |file|
|
|
10
|
+
`gzip #{file}`
|
|
11
|
+
gz = file + '.gz'
|
|
12
|
+
|
|
13
|
+
# automatic detection works
|
|
14
|
+
assert_equal "a\nb\n", Open.read(gz)
|
|
15
|
+
|
|
16
|
+
# forcing with :gzip on the same file yields "" (documented behaviour)
|
|
17
|
+
assert_equal '', Open.read(gz, :gzip => true)
|
|
18
|
+
|
|
19
|
+
# and on a plain file too
|
|
20
|
+
TmpFile.with_file("plain\n") do |plain|
|
|
21
|
+
assert_equal '', Open.read(plain, :gzip => true)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
require File.expand_path(__FILE__).sub(%r(/test/.*), '/test/test_helper.rb')
|
|
2
|
+
require 'scout/named_array'
|
|
3
|
+
|
|
4
|
+
class TestNamedArrayResolution < Test::Unit::TestCase
|
|
5
|
+
def test_exact_match_wins_over_parenthesized_field
|
|
6
|
+
names = ['id', 'Organism', 'Organism (Human)']
|
|
7
|
+
assert_equal 0, NamedArray.identify_name(names, 'id')
|
|
8
|
+
assert_equal 1, NamedArray.identify_name(names, 'Organism')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def test_paren_fuzzy_match
|
|
12
|
+
names = ['Organism (Human)']
|
|
13
|
+
assert_equal 0, NamedArray.identify_name(names, 'Human')
|
|
14
|
+
# and the other direction
|
|
15
|
+
names = ['Human']
|
|
16
|
+
assert_equal 0, NamedArray.identify_name(names, 'Organism (Human)')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def test_space_prefix_fuzzy_match
|
|
20
|
+
names = ['Sample id']
|
|
21
|
+
assert_equal 0, NamedArray.identify_name(names, 'Sample')
|
|
22
|
+
names = ['Sample']
|
|
23
|
+
assert_equal 0, NamedArray.identify_name(names, 'Sample id')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def test_fuzzy_match_is_case_sensitive
|
|
27
|
+
names = ['Alpha']
|
|
28
|
+
assert_nil NamedArray.identify_name(names, 'alpha')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def test_numeric_string_resolves_as_position
|
|
32
|
+
names = %w(a b c)
|
|
33
|
+
assert_equal 2, NamedArray.identify_name(names, '2')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def test_unknown_name_reads_nil_and_writes_noop
|
|
37
|
+
row = NamedArray.setup([1, 2, 3], [:a, :b, :c])
|
|
38
|
+
assert_nil row[:nope]
|
|
39
|
+
assigned = (row['nope'] = 4)
|
|
40
|
+
assert_equal 4, assigned # assignment expression returns value
|
|
41
|
+
assert_equal 3, row.length # but nothing was stored
|
|
42
|
+
assert_nil row.positions('nope')
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def test_values_at_on_unknown_name_raises_type_error
|
|
46
|
+
row = NamedArray.setup([1, 2, 3], [:a, :b, :c])
|
|
47
|
+
assert_raise(TypeError) { row.values_at('nope') }
|
|
48
|
+
end
|
|
49
|
+
end
|