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
@@ -31,14 +31,14 @@ def self.setup(stream, options = {}, &block) # concurrent_stream.rb:12
31
31
 
32
32
  - `threads ||= []`, `pids ||= []`, and new values are **concatenated** onto the
33
33
  existing arrays — a second `setup` adds producers, it does not replace them
34
- (probe `tmp/rewrite_B/probe_22_cs_semantics.rb`: `idempotent setup: cs? true
34
+ (`idempotent setup: cs? true
35
35
  threads=1 pids=[4]`, `after 2nd threads arg: 2`).
36
36
  - `std_err` is **reset to `""`** on every setup.
37
37
  - `autojoin`, `no_fail`, `next`, `pair`, `filename`, `lock` are only assigned
38
38
  when the corresponding option is non-nil, so an existing value survives.
39
39
  - **The block becomes a callback**: a block passed to `setup` is treated as
40
40
  `callback` and *composed* with any existing callback, so the new block runs
41
- after the old one (`probe_22`: `setup-block callbacks order: [:block, :block2]`).
41
+ after the old one (observed `[:block, :block2]`).
42
42
 
43
43
  This is why `Open.open_pipe` (thread mode) can call `setup` on both ends and
44
44
  still compose a user callback, and why `add_callback` composes the same way.
@@ -61,8 +61,8 @@ stream.add_callback { cleanup_a }
61
61
  stream.add_callback { cleanup_b } # join runs a, then b
62
62
  ```
63
63
 
64
- Verified in `probe_19_streaming_apis.rb` (`add_callback order: [:first,
65
- :second]`) and `probe_22_cs_semantics.rb`. There is no `add_abort_callback`;
64
+ (`add_callback order: [:first,
65
+ :second]`). There is no `add_abort_callback`;
66
66
  `abort_callback = proc { |exception| ... }` replaces the slot (only `setup`
67
67
  with an `:abort_callback` option composes it).
68
68
 
@@ -121,17 +121,17 @@ def abort(exception = nil) # concurrent_stream.rb:204
121
121
 
122
122
  1. records `stream_exception ||= exception`,
123
123
  2. marks the object with `AbortedStream.setup(self, exception)` and sets
124
- `@aborted = true`; a second call only logs (`Already aborted stream`) and
125
- returns — **idempotent** (probe `probe_22_cs_semantics.rb`:
126
- `abort idempotent: aborted? true`, `second abort: no raise, aborted? true`),
124
+ `@aborted = true`; a second call only logs (`Already aborted stream`) and
125
+ returns — **idempotent**:
126
+ `abort idempotent: aborted? true`, `second abort: no raise, aborted? true`),
127
127
  3. runs `abort_callback` with the exception,
128
128
  4. `abort_threads`: raises `Aborted` (or the given exception) in each producer
129
- thread and joins them — this is what unblocks a producer stuck writing to a
130
- pipe nobody reads,
129
+ thread and joins them — this is what unblocks a producer stuck writing to a
130
+ pipe nobody reads,
131
131
  5. `abort_pids`: sends `SIGINT` to each pid,
132
132
  6. clears both callbacks,
133
133
  7. **propagates to `@pair`** if it responds to `abort` and is not already
134
- aborted — killing one end of an `Open.open_pipe` pair takes down the other,
134
+ aborted — killing one end of an `Open.open_pipe` pair takes down the other,
135
135
  8. closes and unlocks in `ensure`.
136
136
 
137
137
  Producers are expected to `rescue Aborted` and finish quietly; `Open.grep`,
@@ -151,7 +151,7 @@ This is how one failing producer takes the whole stream down: the exception is
151
151
  stored in `stream_exception` (so a later `join`/`read` re-raises it even if the
152
152
  current frame recovers), it is raised in every producer thread, and the stream
153
153
  is aborted. `join_threads` and `join_pids` call it, and `ConcurrentStreamProcessFailed`
154
- carries the offending `pid` plus the stream. Verified: `probe_22_cs_semantics.rb`
154
+ carries the offending `pid` plus the stream.
155
155
  (`stream_raise_exception raised: demo`, `stream_exception set: #<Aborted: demo>`,
156
156
  `no_fail join of failed: no raise`).
157
157
 
@@ -166,7 +166,7 @@ The standard producer wrapper (concurrent_stream.rb:286): it sets the stream up
166
166
  with `kwargs`, runs the block, and in an `ensure` closes and joins the stream as
167
167
  requested. `Aborted` and any other exception are logged, the stream is aborted
168
168
  with the exception, and the exception is re-raised. `Open.open_pipe` (thread
169
- mode) and `Open.sort_stream` are built on it. Verified: `probe_22_cs_semantics.rb`
169
+ mode) and `Open.sort_stream` are built on it.
170
170
  (`process_stream: "z\ny\nx\n" src joined=true src closed=true`).
171
171
 
172
172
  ## `AbortedStream` and recovering the original cause
@@ -185,8 +185,7 @@ exception = (AbortedStream === content and content.exception) ? content.exceptio
185
185
  That exact snippet is what `Open.sensible_write` uses: when copying a stream
186
186
  into its tmp file raises, it recovers the original upstream exception from the
187
187
  marker and re-raises *that* (deleting the target and tmp file), while a plain
188
- `Aborted` is swallowed and cleaned up. Verified by P25/P33 and the
189
- `probe_22`/`probe_13` sequences in `research/behavior-probes.md`.
188
+ `Aborted` is swallowed and cleaned up.
190
189
 
191
190
  ## `pair` means pipe ends, not stdout/stderr
192
191
 
@@ -0,0 +1,389 @@
1
+ # Testing
2
+
3
+ This guide describes how testing works in `scout-essentials` and establishes conventions intended to be reusable across the Scout ecosystem.
4
+
5
+ The aim is to make tests easy to write, deterministic, isolated, and understandable by both developers and coding agents. Use the simplest test setup that demonstrates the behavior being tested. Shared helpers should remove repetitive setup, not obscure the test itself.
6
+
7
+ ## 1. Running tests
8
+
9
+ From the `scout-essentials` checkout, with development dependencies installed:
10
+
11
+ ```sh
12
+ bundle install
13
+ bundle exec rake test
14
+ ```
15
+
16
+ Run a single test file:
17
+
18
+ ```sh
19
+ bundle exec rake test TEST=test/scout/test_testing.rb
20
+ ```
21
+
22
+ Or invoke it directly:
23
+
24
+ ```sh
25
+ bundle exec ruby -Ilib -Itest test/scout/test_testing.rb
26
+ ```
27
+
28
+ Run the suite with coverage:
29
+
30
+ ```sh
31
+ bundle exec rake simplecov
32
+ ```
33
+
34
+ The repository's `Rakefile` defines the `test` task using `test/**/*.rb` files matching `test_*.rb`, with `lib` and `test` on the load path. The `simplecov` task enables coverage through `COVERAGE=true`. The default Rake task delegates to the test suite.
35
+
36
+ These commands describe `scout-essentials`; other Scout repositories may have additional test tasks, integration suites, or external prerequisites. Check the consuming repository's `Rakefile` and test documentation before assuming the same commands apply.
37
+
38
+ ## 2. Test organization
39
+
40
+ Tests should normally follow the source directory structure.
41
+
42
+ For example:
43
+
44
+ ```text
45
+ lib/scout/
46
+ ├── open.rb
47
+ ├── open/
48
+ │ └── remote.rb
49
+ └── testing.rb
50
+
51
+ test/
52
+ ├── test_helper.rb
53
+ └── scout/
54
+ ├── test_open.rb
55
+ ├── open/
56
+ │ └── test_remote.rb
57
+ └── test_testing.rb
58
+ ```
59
+
60
+ The convention is that `lib/scout/open/remote.rb` corresponds to `test/scout/open/test_remote.rb`. This is a convention, not an enforced one-to-one mapping: a source file may be covered by several tests, and related behavior may be tested together where that is clearer.
61
+
62
+ Every new behavior or bug fix should have appropriate test coverage. When adding a source file, normally add its corresponding test file as well.
63
+
64
+ ### Test bootstrap
65
+
66
+ `test/test_helper.rb` is the local entry point for test setup. It configures the test framework, load paths, coverage, and any shared support needed by the repository.
67
+
68
+ Individual tests should load the bootstrap following the conventions of their repository and then require the code under test.
69
+
70
+ Keep these responsibilities separate:
71
+
72
+ - **Test bootstrap:** framework setup, shared isolation, and repository-wide test configuration.
73
+ - **Test support:** reusable fixtures, scenario builders, fake clients, and other test-only utilities.
74
+ - **Test files:** setup and assertions specific to the behavior being tested.
75
+
76
+ Rake discovers files named `test_*.rb` beneath `test/`. Name helper and fixture-builder files differently, usually under `test/support/`, so they are not collected as tests.
77
+
78
+ ## 3. Fixtures: preparing test data
79
+
80
+ A *fixture* is data or state prepared for a test. Fixtures do not require a special framework. In Scout, ordinary files, Ruby methods, temporary directories, and simple fake objects are sufficient.
81
+
82
+ Choose between three basic approaches.
83
+
84
+ ### Inline data
85
+
86
+ Use inline values for short inputs that are easy to understand where they appear.
87
+
88
+ ```ruby
89
+ def test_parse_line
90
+ key, values = TSV.parse_line("gene1\tA\tB")
91
+
92
+ assert_equal "gene1", key
93
+ assert_equal %w(A B), values
94
+ end
95
+ ```
96
+
97
+ There is no benefit in creating a separate fixture file for every small input. Keeping the data beside the assertion often makes the test easier to read.
98
+
99
+ ### Static fixture files
100
+
101
+ Store data in files when it is sizeable, has a meaningful structure, represents an external format, or is reused by multiple tests.
102
+
103
+ New static test inputs should normally live under `test/fixtures/`, organized by purpose:
104
+
105
+ ```text
106
+ test/fixtures/
107
+ ├── backends/
108
+ │ ├── anthropic.json
109
+ │ └── anthropic_tool_use.json
110
+ └── data/
111
+ └── example.tsv
112
+ ```
113
+
114
+ For example, an AI backend test can load a recorded provider response from JSON instead of contacting a real service. A TSV test can use a representative input file instead of embedding several dozen lines in Ruby.
115
+
116
+ Scout repositories may already use `test/data/` for established datasets. Keep those conventions where they make sense; do not move existing data merely to standardize directory names.
117
+
118
+ Fixture files belong to the repository whose behavior they test, not to the installed `scout-essentials` library. The shared helper resolves fixture paths relative to the consuming repository.
119
+
120
+ The generic fixture-path API is:
121
+
122
+ ```ruby
123
+ require 'scout/testing'
124
+
125
+ path = Scout::Testing.fixture_path(
126
+ 'backends/anthropic.json',
127
+ from: __FILE__
128
+ )
129
+
130
+ content = File.read(path)
131
+ ```
132
+
133
+ `fixture_path` returns a path to the file; it does not parse the contents. Use the appropriate parser, such as `JSON.parse`, when needed. A repository may define a tiny local fixture loader to avoid repeating that parsing code.
134
+
135
+ ### Scenario builders
136
+
137
+ Sometimes a test needs a group of related files or objects rather than one static input. Examples include workflow directories, persisted conversations, dependency graphs, or a project with source files and corresponding tests.
138
+
139
+ Create these structures with a small Ruby helper when building them inline would distract from the assertions or when several tests reuse the same scenario.
140
+
141
+ For example:
142
+
143
+ ```ruby
144
+ module ChatFixtures
145
+ def write_chat(dir, name, content)
146
+ path = File.join(dir, name)
147
+ FileUtils.mkdir_p(File.dirname(path))
148
+ File.write(path, content)
149
+ path
150
+ end
151
+ end
152
+ ```
153
+
154
+ A more elaborate builder can create several related files and return their paths together. Prefer named results when a scenario has several components:
155
+
156
+ ```ruby
157
+ {
158
+ root: root_chat,
159
+ jobs: jobs,
160
+ society_chats: society_chats
161
+ }
162
+ ```
163
+
164
+ This is easier to understand than a positional array whose entries must be remembered.
165
+
166
+ Keep builders close to the tests that use them, usually in `test/support/` or a subsystem-specific support file. Introduce a shared builder only when the same meaningful scenario is needed in several places.
167
+
168
+ **Rule of thumb:** inline data for small examples, static files for substantial inputs, and Ruby builders for constructed scenarios.
169
+
170
+ ## 4. Fakes, mocks, and external services
171
+
172
+ A fixture supplies data; a fake or mock replaces a dependency or controls its behavior. They are complementary.
173
+
174
+ Use the least elaborate option that tests the required contract:
175
+
176
+ - **Inline stub or fake:** when a test needs a small, controlled response from one dependency.
177
+ - **Recorded response plus fake client:** when testing how a provider adapter constructs requests and interprets responses.
178
+ - **Scripted mock backend:** when testing agent, chat, or workflow behavior without depending on a particular provider's protocol.
179
+ - **Real service:** when the service integration itself is the subject of the test.
180
+
181
+ For normal unit tests, avoid real network requests, live inference, large model downloads, and dependence on a developer's local configuration. Use recorded responses and controlled dependencies instead.
182
+
183
+ Keep genuine infrastructure tests distinct from ordinary unit tests. Their prerequisites must be explicit, and the default unit-test task should not unexpectedly invoke external services.
184
+
185
+ ## 5. Temporary files and directories
186
+
187
+ Tests that create files should use isolated temporary storage rather than writing into shared project directories or relying on files left behind by previous runs.
188
+
189
+ The generic helper is available through an explicit require:
190
+
191
+ ```ruby
192
+ require 'scout/testing'
193
+ ```
194
+
195
+ For a temporary directory owned by one test:
196
+
197
+ ```ruby
198
+ Scout::Testing.with_tmpdir do |dir|
199
+ path = File.join(dir, 'input.txt')
200
+ File.write(path, "example\n")
201
+
202
+ # Exercise the code that uses the file.
203
+ end
204
+ ```
205
+
206
+ The directory is removed in an `ensure` block, including when the test raises an exception.
207
+
208
+ Use the helper's `parent:` and `prefix:` options when a test needs a particular temporary location or recognizable directory name. Ensure that test processes do not share a cleanup target.
209
+
210
+ The repository's test bootstrap may expose a local `tmpdir` helper that returns a Scout `Path` object. Use it in tests that depend on Scout path semantics; use the generic temporary-directory helper when building reusable, framework-independent test support.
211
+
212
+ When a test creates data outside its temporary root, it must arrange cleanup for that data separately.
213
+
214
+ ## 6. Shared testing helpers
215
+
216
+ The generic testing module is opt-in. It is deliberately not loaded by `require 'scout-essentials'` and does not depend on Scout storage abstractions.
217
+
218
+ ```ruby
219
+ require 'scout/testing'
220
+ ```
221
+
222
+ Its public helpers are:
223
+
224
+ | API | Purpose |
225
+ |---|---|
226
+ | `Scout::Testing.create_tmpdir` | Create a unique temporary directory |
227
+ | `Scout::Testing.remove_tmpdir` | Remove temporary storage |
228
+ | `Scout::Testing.with_tmpdir` | Create temporary storage, yield it, and clean it up |
229
+ | `Scout::Testing.fixture_root` | Locate the fixture directory relative to a consumer |
230
+ | `Scout::Testing.fixture_path` | Resolve a fixture file relative to a consumer |
231
+ | `Scout::Testing::Assertions#assert_equal_path` | Compare normalized path strings |
232
+ | `Scout::Testing::Assertions#assert_path_exists` | Assert that a path exists |
233
+
234
+ The assertion methods can be included in a Test::Unit test class:
235
+
236
+ ```ruby
237
+ class TestExample < Test::Unit::TestCase
238
+ include Scout::Testing::Assertions
239
+ end
240
+ ```
241
+
242
+ The fixture helpers take `from:` to identify the consuming project's context. They support normal Git repositories and worktrees, as well as conventional `test/` and `lib/` layouts when Git metadata is absent. This is a practical discovery heuristic, not universal repository detection. For nonstandard layouts, pass an explicit `directory:` or construct the path directly.
243
+
244
+ The shared functions use Ruby standard-library filesystem operations. This is intentional: generic test setup should not depend on Scout's own path resolution or filesystem abstractions, since those abstractions may themselves be under test.
245
+
246
+ ### Scout-specific state isolation
247
+
248
+ Tests that exercise Scout storage and cache behavior may also need to isolate process-global state. That functionality lives in a separately required module:
249
+
250
+ ```ruby
251
+ require 'scout/testing'
252
+ require 'scout/testing/scout_state'
253
+
254
+ Scout::Testing.with_isolated_state('tmp/test-state') do
255
+ # Test code using isolated Scout storage.
256
+ end
257
+ ```
258
+
259
+ The Scout-state layer isolates and restores selected `TmpFile`, `Persist`, and `Open` settings and the `Persist::MEMORY_CACHE` mapping. It does not reset every global variable, environment setting, singleton, registry, or configuration source.
260
+
261
+ Callers may opt in to additional simple path settings by passing
262
+ `bindings: [[owner, accessor, relative_path], ...]` to `State.new` or
263
+ `with_isolated_state`. For example:
264
+
265
+ ```ruby
266
+ Scout::Testing.with_isolated_state('tmp/test-state', bindings: [
267
+ [MyCache, :directory, 'my-cache']
268
+ ]) do
269
+ # MyCache.directory points below tmp/test-state here.
270
+ end
271
+ ```
272
+
273
+ Each binding names a class/module accessor (such as `:directory` with a
274
+ `directory=` setter) and a non-empty relative path. The target directory is
275
+ created below the canonical isolation root; absolute paths, paths escaping
276
+ that root, and existing symlink components within it are rejected. Binding
277
+ state is saved before activation, including whether its conventional
278
+ `@<accessor>` instance variable was unset. Activation and block
279
+ failures trigger restoration. Every activated binding is restored even if a
280
+ setter fails; the implementation also restores the conventional backing
281
+ instance variable as a fallback. A restoration failure is reported as
282
+ `Scout::Testing::State::RestoreError` after all bindings and built-in state
283
+ have had a restoration attempt; its `errors` array contains the failures. If
284
+ this occurs while another exception is already propagating, the restore error
285
+ retains that original exception in `original_error`.
286
+
287
+ Additional bindings are for plain settings whose setter stores its value in
288
+ `@<accessor>`. They are not a general rollback mechanism for setters with
289
+ unrelated side effects. Directory creation rejects symlink components but is
290
+ not a race-proof filesystem transaction; use a unique state root and avoid
291
+ concurrent writers.
292
+
293
+ The shared `test/test_helper.rb` may load both layers because the tests in this repository use both. A consumer that only needs generic filesystem helpers should require only `scout/testing`.
294
+
295
+ Restoration of process-global state is not synchronization, including for
296
+ caller-supplied bindings. Tests using this state-isolation mechanism must not
297
+ run concurrently in the same Ruby process unless they provide additional
298
+ synchronization. Separate processes or serialized execution are appropriate
299
+ alternatives.
300
+
301
+ ### Availability primitives
302
+
303
+ Generic environment-availability primitives live in a third, separately required module:
304
+
305
+ ```ruby
306
+ require 'scout/testing/availability'
307
+ ```
308
+
309
+ Like the other testing layers, it is opt-in and is never loaded by `require 'scout-essentials'`.
310
+
311
+ Its scope is deliberately narrow: generic mechanisms only — PATH lookup, bounded child-process execution, and a bounded TCP connect. It contains no knowledge of interpreters, packages, models, or network services. Those probes belong to the layers that own them: the Python/R runtime probes to `scout-rig`, and the accelerator, model-cache, and endpoint probes to `scout-ai`. See the handoff notes `research/testing-availability-scout-rig-handoff.md` and `research/testing-availability-scout-ai-handoff.md` for those layers, including their corrected reference implementations.
312
+
313
+ The API surface is:
314
+
315
+ | API | Purpose |
316
+ |---|---|
317
+ | `Scout::Testing::Availability.executable?(name)` | True when `name` resolves on `PATH`, or contains a path separator and names an executable file. No shell. |
318
+ | `Scout::Testing::Availability.run_command(argv, timeout:)` | Run `argv` (never a shell string) with a hard deadline; return a `CommandResult`. |
319
+ | `Scout::Testing::Availability.tcp_reachable?(host, port, timeout:)` | True when a TCP connection was established within the timeout. |
320
+
321
+ `run_command` drains both output pipes concurrently so a chatty child cannot deadlock a pipe, bounds the captured output per stream, and escalates `TERM` then `KILL` to the child's process group when the deadline passes. It is never memoized: availability can change between calls.
322
+
323
+ The returned `CommandResult` exposes `argv`, `success`/`success?`, `exit_status`, `stdout`, `stderr`, `timed_out`, `launch_error`, and `reason`. Launch failure (for example a missing executable) and timeout are reported on the result rather than raised; only programming errors — a non-positive or non-finite timeout, or an empty `argv` — raise `ArgumentError`.
324
+
325
+ For example, guarding a test on a local tool:
326
+
327
+ ```ruby
328
+ require 'scout/testing/availability'
329
+
330
+ result = Scout::Testing::Availability.run_command(
331
+ [tool, '--version'], timeout: 10
332
+ )
333
+ omit result.reason unless result.success?
334
+ ```
335
+
336
+ Tests for these primitives follow the same offline discipline as the rest of this guide. The TCP tests bind a local `TCPServer` on `127.0.0.1` with an ephemeral port and add a negative case against a closed local port; the timeout tests use a short deadline against a child that sleeps much longer; no test contacts anything outside the machine.
337
+
338
+ ## 7. Isolation and regression tests
339
+
340
+ A good test establishes its own relevant conditions, exercises the behavior, and asserts the observable result.
341
+
342
+ Follow these rules:
343
+
344
+ - Do not depend on state left by another test or on test-file execution order.
345
+ - Use isolated temporary storage for filesystem tests.
346
+ - Reset or restore caches, registries, environment variables, logger settings, and other global state that the test changes.
347
+ - Use the actual filesystem when testing filesystem semantics, including symlinks and path containment. A mock cannot establish that the real filesystem behaves as expected.
348
+ - Make failure cases explicit: invalid input, missing files, rejected operations, exceptions, and cleanup after failure.
349
+ - Avoid timing-based assertions when the same property can be checked deterministically.
350
+ - Keep test names and assertions focused on behavior rather than implementation details, unless the implementation detail is itself the contract.
351
+
352
+ For configuration or environment tests, prefer an existing scoped helper when one is available. For example, `Scout::Config.with_config` scopes configuration changes, and `Misc.with_env` restores environment changes. Explicitly save and restore any additional state that the test mutates.
353
+
354
+ A regression test should capture the smallest useful reproduction of a bug and establish the behavior that must remain stable. Do not reproduce an entire application scenario when a minimal example demonstrates the defect.
355
+
356
+ ## 8. Unit tests, integration tests, and empirical probes
357
+
358
+ These are different forms of verification.
359
+
360
+ **Unit tests** establish repeatable behavior using controlled inputs and isolated data. They should normally be fast and should not depend on external infrastructure.
361
+
362
+ **Integration tests** exercise component boundaries or real services, tools, runtimes, and environments. Record their prerequisites and keep them separate from the default unit-test task where necessary.
363
+
364
+ **Empirical probes** investigate behavior under particular conditions and record observations. A successful probe does not automatically establish a deterministic contract. When a finding is stable and worth preserving, turn it into a regular test.
365
+
366
+ These are conceptual categories, not universal Rake task types. Each repository defines how its tests and probes are discovered and executed.
367
+
368
+ ## 9. Boundaries across Scout repositories
369
+
370
+ The lowest-level testing support should remain small and reusable.
371
+
372
+ - `scout-essentials` owns generic filesystem helpers, fixture-root lookup, path assertions, its opt-in Scout-state isolation, and the generic availability primitives (`executable?`, `run_command`, `tcp_reachable?`).
373
+ - `scout-gear` owns workflow, Step, TSV, Entity, persistence-engine, and scheduler fixtures.
374
+ - `scout-ai` owns LLM mocks, fake provider clients, recorded provider payloads, agent scenarios, and Path Engine project fixtures.
375
+ - `scout-camp` owns rendering fixtures, temporary view roots, and request-level test support.
376
+
377
+ Do not put domain-specific setup in the generic test bootstrap simply because several repositories happen to use it. Share mechanics; keep fixtures close to the behavior they represent.
378
+
379
+ When adding helper code, use a clear name, one responsibility, and the simplest interface that serves its callers. Avoid introducing a general-purpose fixture framework unless repeated, concrete needs justify it.
380
+
381
+ ## References
382
+
383
+ - [Start Here](../StartHere.md) — installation, overview, and developer-guide index.
384
+ - [Architecture](Architecture.md) — library responsibilities and boundaries.
385
+ - [Configuration](Configuration.md) — `Scout::Config` behavior.
386
+ - [Design Principles](DesignPrinciples.md) — observable library conventions.
387
+ - [Caching Results](../user/CachingResults.md) — persistence and caching behavior.
388
+
389
+ The implementation details described here are grounded in the `Rakefile`, `lib/scout/testing.rb`, `lib/scout/testing/scout_state.rb`, `lib/scout/testing/availability.rb`, `test/test_helper.rb`, and the testing-support tests. The `Rakefile` remains authoritative for the commands and discovery rules of this repository.
@@ -6,9 +6,7 @@ changing their class or wrapping them. This page is the user-facing view of the
6
6
  system; internals and design notes are in
7
7
  [Annotation System](../developer/AnnotationSystem.md).
8
8
 
9
- Every example below was run against the current `lib/scout/` (probes
10
- P36–P42 in `research/behavior-probes.md` plus
11
- `tmp/rewrite_C/probe_01..04.rb` and `probe_09_annotated_array.rb`).
9
+ Every example below was run against the current `lib/scout/`.
12
10
 
13
11
  ## Defining an annotation
14
12
 
@@ -41,7 +39,7 @@ annotated = SampleInfo.setup(name, organism: 'Human')
41
39
  annotated.equal?(name) # => true
42
40
  ```
43
41
 
44
- Two consequences, both live-probed (probe_02):
42
+ Two consequences:
45
43
 
46
44
  - a **frozen** object is `dup`ed first, so `setup` returns a different,
47
45
  unfrozen copy — always use the return value;
@@ -88,7 +86,7 @@ Annotation.setup('S003', 'SampleInfo', sample.annotation_hash)
88
86
  ```
89
87
 
90
88
  The type argument may be a `"A|B"` String of module names (unknown names are
91
- only `Log.warn`ed and skipped, probe_01) or an Array of module objects.
89
+ only `Log.warn`ed and skipped) or an Array of module objects.
92
90
 
93
91
  `#serialize` produces the plain Hash (`annotation_info` merged with
94
92
  `:literal`) consumed by the `:annotation` persistence driver. There is **no
@@ -104,8 +102,7 @@ in this gem.
104
102
 
105
103
  `NamedArray` (`lib/scout/named_array.rb`) is a separate annotation module
106
104
  that gives an Array named fields. It needs its **explicit**
107
- `require 'scout/named_array'` — `scout-essentials.rb` does not load it
108
- (probe_03):
105
+ `require 'scout/named_array'` — `scout-essentials.rb` does not load it:
109
106
 
110
107
  ```ruby
111
108
  require 'scout/named_array'
@@ -116,7 +113,7 @@ row[:organism] # => "Human"
116
113
  row['tissue'] # => "Liver"
117
114
  ```
118
115
 
119
- Signatures (probe_04): `NamedArray.setup(array, names, *rest)` — the names are
116
+ Signatures: `NamedArray.setup(array, names, *rest)` — the names are
120
117
  a positional Array, **not** a `key:` keyword.
121
118
 
122
119
  ### Access is via `method_missing`
@@ -137,7 +134,7 @@ positionally by name.
137
134
  ### Array methods shadow field names
138
135
 
139
136
  If a field is called `first`, `last`, `count`, `zip`, `sample` … the real
140
- `Array` method wins (probe_10):
137
+ `Array` method wins:
141
138
 
142
139
  ```ruby
143
140
  row2 = NamedArray.setup(%w[a b c], %w[first second third])
@@ -158,14 +155,13 @@ field → value.
158
155
 
159
156
  Watch out for `id` — `annotation_id` (aliased `id`) is defined by the
160
157
  annotation system itself, so a field named `id` collides and the digest is
161
- returned instead (probe_10).
158
+ returned instead.
162
159
 
163
160
  ## `AnnotatedArray` — elements inherit the container's annotations
164
161
 
165
162
  Annotate the *container*, then `extend AnnotatedArray`, and every element
166
163
  handed out by `[]`, `first`, `last`, `each`, `collect`, `select`, `compact`,
167
- `uniq`, `flatten`, `reverse`, `sort_by`, `subset`, `remove` is re-annotated
168
- (probe_09):
164
+ `uniq`, `flatten`, `reverse`, `sort_by`, `subset`, `remove` is re-annotated:
169
165
 
170
166
  ```ruby
171
167
  samples = SampleInfo.setup(%w[S001 S002 S003], organism: 'Human')
@@ -177,7 +173,7 @@ samples.collect { |s| s.length } # [4, 4, 4], elements annotated
177
173
  samples.select { |s| s != 'S002' } # re-annotated array
178
174
  ```
179
175
 
180
- Not overridden — **annotations are dropped** (probe_09, method owners are
176
+ Not overridden — **annotations are dropped** (method owners are
181
177
  `Array`/`Enumerable`): `map`, `zip`, `+`, `filter_map`, `flat_map`,
182
178
  `each_slice`, `values_at`. `zip` in particular does not propagate annotations
183
179
  to the *other* operand's elements.
@@ -51,7 +51,6 @@ every token it recognises:
51
51
  - `--` stops the whole loop: everything after it is left in `args`.
52
52
 
53
53
  ```ruby
54
- # probe_02 (tmp/rewrite_C/probe_02_sopt.rb)
55
54
  argv = ['-o', 'Human', '--tissue', 'Liver', 'positional', '-d']
56
55
  SOPT.consume(argv)
57
56
  # => {:organism=>"Human", :tissue=>"Liver", :"dry-run"=>true}
@@ -75,8 +74,7 @@ Booleans are true unless the value is one of `F`, `false`, `FALSE`, `no`
75
74
  `=` instead — this convenience swallows the next token, so use `=`.
76
75
 
77
76
  A word that merely *follows* a boolean flag and is not one of those four is
78
- **not** eaten: `['--dry-run', 'stray']` leaves `"stray"` in `ARGV`
79
- (probe_02).
77
+ **not** eaten: `['--dry-run', 'stray']` leaves `"stray"` in `ARGV`.
80
78
 
81
79
  ## State and reuse
82
80
 
@@ -88,7 +86,7 @@ A word that merely *follows* a boolean flag and is not one of those four is
88
86
  - `SOPT::GOT_OPTIONS`, a module-level hash that is **merged into, never
89
87
  reset**. Every `consume` call accumulates there, which is how
90
88
  sub-commands that each declare their own inputs still end up with a
91
- global picture of what was given (probe_02: two separate `consume` calls
89
+ global picture of what was given (two separate `consume` calls
92
90
  on disjoint inputs both appear in `GOT_OPTIONS`).
93
91
 
94
92
  `SOPT.get(opt_str)` is just `parse` followed by `consume(ARGV)`.
@@ -103,7 +101,7 @@ SOPT.require(options, :organism, :tissue)
103
101
  # raises ParameterException: Parameter 'tissue' not given
104
102
  ```
105
103
 
106
- `ParameterException < ScoutException < StandardError` (probe_02), so plain
104
+ `ParameterException < ScoutException < StandardError`, so plain
107
105
  `rescue` works. There is **no** variant that extracts a subset of the
108
106
  options — `SOPT.get` always parses a fresh string and consumes the whole
109
107
  `ARGV`.
@@ -128,8 +126,8 @@ myprog [--organism=<string>] [--tissue=<string>] [--dry-run[=false]]
128
126
 
129
127
  The header really is `## SYNOPSYS` — the misspelling is in the source
130
128
  (`simple_opt/doc.rb:112`) and callers grep for it; do not "fix" it in your
131
- matching code. `SOPT.usage` prints the doc and calls `exit 0` (probe_07 traps
132
- `SystemExit` and reports status 0).
129
+ matching code. `SOPT.usage` prints the doc and calls `exit 0` (trapping
130
+ `SystemExit` reports status 0).
133
131
 
134
132
  `SOPT.input_doc` (used by `doc`) is also the public way to format an
135
133
  explicit option list, and `SOPT.input_array_doc` formats
@@ -145,25 +143,24 @@ searches for a free one:
145
143
 
146
144
  1. an existing shortcut already bound to that exact long name is reused;
147
145
  2. if the long name contains `-` or `_`, the initials of its parts
148
- (`--max-cpu` → `-m` if free, else the accumulated initials);
146
+ (`--max-cpu` → `-m` if free, else the accumulated initials);
149
147
  3. if it contains digits, the first letter plus the number;
150
148
  4. otherwise it walks forward through the letters.
151
149
 
152
150
  If no shortcut can be found, `fix_shortcut` returns `nil` and the option
153
151
  simply has no short form. **Collisions are silent**: declaring `-a--alpha`
154
152
  and `-a--also` yields `{"a"=>"alpha", "al"=>"also"}` — the second entry
155
- gets a longer shortcut rather than an error (probe_02). Live probe
156
- (`tmp/rewrite_C/probe_07_sopt_extra.rb`): registering `t` while `-t` is
153
+ gets a longer shortcut rather than an error: registering `t` while `-t` is
157
154
  bound to `tissue` yields `"th" => "threshold"`; `another_one` gets the
158
155
  initials `"ao"`; `alpha2` gets `"a2"`.
159
156
 
160
157
  `SOPT.delete_inputs(['organism'])` removes an input from `inputs`,
161
158
  `input_shortcuts`, `shortcuts`, `input_types`, `input_defaults` and
162
159
  `input_descriptions` (`simple_opt/accessor.rb:39`). `input_shortcuts` is the
163
- reverse map `{'organism'=>'o'}` (probe_07).
160
+ reverse map `{'organism'=>'o'}`.
164
161
 
165
162
  `SOPT.reset` clears **only** `shortcuts` and the internal `all` registry;
166
- `inputs` and the other per-input tables survive (probe_02). Call
163
+ `inputs` and the other per-input tables survive. Call
167
164
  `SOPT.delete_inputs(SOPT.inputs.dup)` if you actually want an empty slate.
168
165
 
169
166
  ## Quirks to design around