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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.vimproject +18 -1
  3. data/Rakefile +9 -0
  4. data/VERSION +1 -1
  5. data/doc/Improvements.md +20 -0
  6. data/doc/StartHere.md +7 -4
  7. data/doc/developer/AnnotationSystem.md +10 -15
  8. data/doc/developer/Configuration.md +20 -30
  9. data/doc/developer/CoreUtilities.md +52 -22
  10. data/doc/developer/DesignPrinciples.md +7 -9
  11. data/doc/developer/DocumentationPractices.md +476 -0
  12. data/doc/developer/ErrorHandling.md +11 -20
  13. data/doc/developer/LockingAndConcurrency.md +20 -13
  14. data/doc/developer/StreamingModel.md +13 -14
  15. data/doc/developer/Testing.md +389 -0
  16. data/doc/user/AnnotatingData.md +9 -13
  17. data/doc/user/CommandLineOptions.md +9 -12
  18. data/doc/user/Cookbook.md +7 -10
  19. data/doc/user/HandlingStreams.md +22 -29
  20. data/doc/user/LoggingAndProgress.md +9 -11
  21. data/doc/user/ProducingResources.md +0 -1
  22. data/doc/user/RemoteData.md +10 -13
  23. data/doc/user/RunningCommands.md +12 -17
  24. data/doc/user/WorkingWithFiles.md +10 -2
  25. data/lib/scout/config.rb +1 -0
  26. data/lib/scout/indiferent_hash.rb +8 -2
  27. data/lib/scout/log/fingerprint.rb +1 -1
  28. data/lib/scout/named_array.rb +4 -0
  29. data/lib/scout/open/final.rb +1 -1
  30. data/lib/scout/open/stream.rb +14 -13
  31. data/lib/scout/path/util.rb +1 -1
  32. data/lib/scout/testing/availability.rb +264 -0
  33. data/lib/scout/testing/scout_state.rb +193 -0
  34. data/lib/scout/testing.rb +82 -0
  35. data/research/doc-audit-findings.md +7 -5
  36. data/research/doc-structure.md +89 -0
  37. data/research/lock-namespace-blindness.md +73 -0
  38. data/research/probing-guide.md +311 -0
  39. data/research/testing-availability-scout-ai-handoff.md +235 -0
  40. data/research/testing-availability-scout-rig-handoff.md +355 -0
  41. data/scout-essentials.gemspec +24 -3
  42. data/test/scout/annotation/test_dup_clone.rb +31 -0
  43. data/test/scout/indiferent_hash/test_case_insensitive_staleness.rb +27 -0
  44. data/test/scout/indiferent_hash/test_string2hash_false.rb +13 -0
  45. data/test/scout/misc/test_timespan.rb +30 -0
  46. data/test/scout/open/test_compression_detection.rb +32 -0
  47. data/test/scout/open/test_forced_gzip_broken.rb +25 -0
  48. data/test/scout/test_named_array_resolution.rb +49 -0
  49. data/test/scout/test_testing.rb +195 -0
  50. data/test/scout/test_tmpfile_cleanup.rb +23 -0
  51. data/test/scout/testing/test_availability.rb +222 -0
  52. data/test/scout/testing/test_state_bindings.rb +163 -0
  53. data/test/test_helper.rb +26 -12
  54. 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`).
@@ -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.1 ruby lib
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.1".freeze
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 = "3.7.2".freeze
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