Compare commits
8 Commits
50191ee694
...
a586257d5e
| Author | SHA1 | Date | |
|---|---|---|---|
| a586257d5e | |||
| b8163091cc | |||
| c7b3af82b4 | |||
| a42b06ba20 | |||
| 8d62973627 | |||
| 8cdefc72a1 | |||
| d3a8dc7930 | |||
| 86ebb62f84 |
@@ -1,642 +0,0 @@
|
||||
# CLI and Configuration Contract Test Plan
|
||||
|
||||
This plan adds deliberate contract and risk-based test coverage for
|
||||
`internal/cli` and `internal/core/config`. It does not pursue a statement
|
||||
coverage target. Coverage reports are diagnostic input: they may reveal an
|
||||
important untested branch, but a percentage alone does not justify a test.
|
||||
|
||||
The audience is a relatively small LLM coding agent. Implement one stage per
|
||||
prompt, in order. Each stage must leave the repository compiling and passing
|
||||
all tests.
|
||||
|
||||
## Scope and fixed decisions
|
||||
|
||||
Tests added by this plan protect:
|
||||
|
||||
- external contracts documented in `docs/cli.md`, `docs/config.md`,
|
||||
`docs/operations.md`, and maintained examples;
|
||||
- internal contracts around configuration loading, precedence, validation,
|
||||
resolution, cloning, and redaction;
|
||||
- high-risk CLI composition paths involving run selection, references, LLM
|
||||
profiles, output, cache, resume, and debug state; and
|
||||
- production composition and the shortest maintained end-to-end workflows.
|
||||
|
||||
The following decisions are fixed:
|
||||
|
||||
- Do not set or enforce a repository-wide coverage percentage.
|
||||
- Do not restore deleted test files wholesale. The former suite predates
|
||||
ADR-0006 and includes obsolete workspace and diagnostics behavior. Historical
|
||||
tests immediately before commit `26142f0` may be consulted for fixture and
|
||||
harness ideas only; every restored assertion must be checked against current
|
||||
code and canonical documentation.
|
||||
- Prefer behavior tests at the narrowest useful boundary. Pure configuration
|
||||
rules belong in `internal/core/config`; CLI tests should prove parsing,
|
||||
composition, process-facing behavior, and cross-component wiring rather than
|
||||
repeat every configuration permutation.
|
||||
- Test stable observable facts: exit code, output stream, selected collaborator,
|
||||
logical artifact, path, or resolved field. Avoid exact whole-error strings,
|
||||
entire JSON snapshots, timestamps, random identifiers, and internal call
|
||||
sequences unless those are the contract under test.
|
||||
- Tests must be deterministic, offline, and secret-free. Inject catalogs,
|
||||
registries, clocks, run-ID generators, cache collaborators, and fake LLM
|
||||
clients. Use `t.TempDir`, `t.Setenv`, and `t.Chdir` where appropriate. Never
|
||||
use a real provider, credential, user cache directory, or shared output root.
|
||||
- Do not weaken a test to match an apparent defect. If current behavior
|
||||
conflicts with a canonical contract, stop the stage and report the conflict.
|
||||
Production fixes are outside this test-coverage plan unless separately
|
||||
authorized.
|
||||
- Preserve the focused ADR-0006 state tests already present. Consolidate
|
||||
helpers only when it materially reduces duplication and does not obscure the
|
||||
contract being tested.
|
||||
- Checkpoint wire compatibility is already owned by
|
||||
`internal/framework/checkpoint` tests. Do not add a second legacy checkpoint
|
||||
fixture in CLI tests unless a distinct CLI compatibility boundary is found.
|
||||
|
||||
## Execution rules for every stage
|
||||
|
||||
Before editing:
|
||||
|
||||
1. Read `docs/development.md` and follow its task-specific reading guide.
|
||||
2. Read both files under `docs/policy/`.
|
||||
3. Read this plan and all current source, tests, and canonical documents named
|
||||
by the stage.
|
||||
4. Run `git status --short`; preserve all existing changes.
|
||||
5. Run the stage's package tests once to establish a baseline.
|
||||
|
||||
While editing:
|
||||
|
||||
- Add tests only for the cases listed in the stage. Do not expand into adjacent
|
||||
subsystem redesign.
|
||||
- Use table-driven tests for validation and syntax matrices when the setup and
|
||||
assertion are genuinely shared.
|
||||
- Give failures enough context to identify the contract case.
|
||||
- Assert both the positive outcome and important negative side effects, such as
|
||||
a root not being resolved or a file not being created.
|
||||
- Do not run environment- or working-directory-mutating tests in parallel.
|
||||
|
||||
At the end of each stage:
|
||||
|
||||
1. Run the focused tests named by that stage.
|
||||
2. Run `go test ./...`.
|
||||
3. Run `go vet ./...`.
|
||||
4. Run `go build ./cmd/notarius`.
|
||||
5. Run `git diff --check`.
|
||||
6. Review the diff for assertions tied to obsolete implementation details.
|
||||
|
||||
Do not mark a stage complete until all checks pass.
|
||||
|
||||
## Stage 1: File schema, defaults, and decoding contracts
|
||||
|
||||
**Status:** Not started
|
||||
|
||||
### Objective
|
||||
|
||||
Protect the version 3 file format and the translation from YAML into the
|
||||
runtime configuration model.
|
||||
|
||||
### Read first
|
||||
|
||||
- `docs/config.md`, through **Module Bindings**
|
||||
- `internal/core/config/config.go`
|
||||
- `internal/core/config/file_config.go`
|
||||
- `internal/core/config/v3_test.go`
|
||||
|
||||
### Implement
|
||||
|
||||
Add focused tests in `internal/core/config/file_config_contract_test.go`. Reuse
|
||||
small helper constructors within that file; do not reproduce complete example
|
||||
files for unit cases.
|
||||
|
||||
Cover these contracts:
|
||||
|
||||
1. `Default` returns the documented concurrency, output, cache-family, debug,
|
||||
and empty-pipeline defaults. Mutating maps in one returned configuration
|
||||
must not affect a later `Default` result.
|
||||
2. A minimal `version: 3` file parses and applies over defaults.
|
||||
3. Missing, version 2, and unsupported versions fail with actionable version
|
||||
context before ordinary field decoding.
|
||||
4. Unknown top-level, pipeline, lane, and module-binding fields are rejected.
|
||||
Include removed `workspace`, `diagnostics`, and `llm_profiles` fields as
|
||||
representative compatibility failures, without testing every old field.
|
||||
5. Shorthand and object module bindings preserve module, profile, retries,
|
||||
options, references, and the distinction between omitted validators and an
|
||||
explicit empty validator chain.
|
||||
6. Pipeline, target-local, and lane compatibility reference maps apply with the
|
||||
documented precedence, including `extract.references` overriding the
|
||||
lane-level alias.
|
||||
7. Chunk, extract, merge, and normalize stage-local validator bindings retain
|
||||
configured order and fields.
|
||||
8. Scriptorium source, concurrency, output, chunk-plan cache, checkpoint cache,
|
||||
and debug sections apply without coupling their roots.
|
||||
9. Keys that collide after trimming—pipeline IDs, lane IDs, and representative
|
||||
reference slots—are rejected. Do not enumerate the same normalization rule
|
||||
at every possible reference location.
|
||||
10. `LoadFileConfig` reports both missing-file and malformed-YAML context.
|
||||
|
||||
Where `v3_test.go` already proves a case completely, either leave it there or
|
||||
move it without duplicating it.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
go test ./internal/core/config -run 'Test(FileConfig|Default|Version3)'
|
||||
```
|
||||
|
||||
### Exit criteria
|
||||
|
||||
The maintained version 3 schema, defaults, binding forms, state sections, and
|
||||
normalization rules can be refactored without silently changing their contract.
|
||||
|
||||
## Stage 2: Precedence and validation matrices
|
||||
|
||||
**Status:** Not started
|
||||
|
||||
### Objective
|
||||
|
||||
Protect configuration precedence and high-risk invalid combinations after all
|
||||
sources have been applied.
|
||||
|
||||
### Read first
|
||||
|
||||
- `docs/config.md`, especially **Discovery**, **Environment Overrides**,
|
||||
**Concurrency**, **Module Bindings**, and **State Surfaces**
|
||||
- `internal/core/config/env.go`
|
||||
- `internal/core/config/validation.go`
|
||||
- current tests from Stage 1
|
||||
|
||||
### Implement
|
||||
|
||||
Add `internal/core/config/env_contract_test.go` and
|
||||
`internal/core/config/validation_contract_test.go`.
|
||||
|
||||
Precedence tests must prove:
|
||||
|
||||
1. File values override built-in defaults.
|
||||
2. Every current `NOTARIUS_*` operational variable overrides the corresponding
|
||||
file value: total LLM concurrency, extract workers, output directory,
|
||||
chunk-plan mode and directory, checkpoint directory, and debug directory.
|
||||
3. Extract workers default to the final effective total when not explicitly
|
||||
configured, but an explicit file or environment worker value is retained.
|
||||
4. Empty cache-directory fields in a file remain valid and select deferred
|
||||
per-user defaults; empty directory environment overrides are errors.
|
||||
5. Invalid integers and chunk-cache modes report the responsible environment
|
||||
variable and do not panic.
|
||||
6. Removed provider environment variables are ignored. Do not place credential
|
||||
values in assertions or failure output.
|
||||
|
||||
Validation tables must cover one representative case for each rule family:
|
||||
|
||||
- non-positive total concurrency; extract worker below one, above total, and at
|
||||
both valid boundaries; unknown and blank worker keys;
|
||||
- mutually exclusive Scriptorium profile sources;
|
||||
- blank required output/debug roots, NUL in every physical root family, and an
|
||||
invalid chunk-plan mode;
|
||||
- empty and trim-duplicated pipeline/lane/reference identifiers;
|
||||
- negative retries and whitespace-only explicit LLM profiles;
|
||||
- references on unsupported input/output bindings;
|
||||
- invalid validator bindings: empty module, retries, references, nested
|
||||
validators, and validator chains on unsupported stages; and
|
||||
- deprecated non-empty lane-level validators rejected while stage-local omitted,
|
||||
empty, and non-empty overrides remain valid structurally.
|
||||
|
||||
Assert stable contextual fragments rather than complete error text.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
go test ./internal/core/config -run 'Test(Env|Precedence|Validate)'
|
||||
```
|
||||
|
||||
### Exit criteria
|
||||
|
||||
Every documented precedence edge and every materially different validation
|
||||
rule family has a direct, readable regression test.
|
||||
|
||||
## Stage 3: Effective configuration, cloning, and redaction
|
||||
|
||||
**Status:** Not started
|
||||
|
||||
### Objective
|
||||
|
||||
Protect the internal contract that validated configuration resolves into an
|
||||
isolated, auditable pipeline without leaking sensitive options.
|
||||
|
||||
### Read first
|
||||
|
||||
- `docs/policy/architecture.md`, especially **Pipeline Composition And
|
||||
Ownership**, **Configuration And Provenance**, and **State, Output, And
|
||||
Safety**
|
||||
- `internal/core/config/effective_config.go`
|
||||
- `internal/core/config/redaction.go`
|
||||
- `internal/core/config/redaction_test.go`
|
||||
- `internal/framework/pipeline/profile.go`
|
||||
|
||||
### Implement
|
||||
|
||||
Add `internal/core/config/effective_config_contract_test.go` and extend
|
||||
`redaction_test.go` only for missing cases.
|
||||
|
||||
Use a minimal fake `pipeline.ModuleCatalog` with enough variants and
|
||||
capabilities to make each resolution outcome explicit. Cover:
|
||||
|
||||
1. Empty and unknown pipeline IDs fail; a map key with harmless surrounding
|
||||
whitespace resolves to its normalized ID.
|
||||
2. `Only` selects exactly the requested lanes, rejects unknown lanes, and does
|
||||
not mutate the source configuration.
|
||||
3. Default chunk, merge, normalize, and output modules are materialized through
|
||||
the catalog.
|
||||
4. Unknown modules, missing capabilities, missing artifact variants, invalid
|
||||
module options, and invalid validator options retain pipeline/lane/stage
|
||||
context.
|
||||
5. An LLM-profile override applies to LLM-capable pipeline module bindings
|
||||
before digest calculation, does not override validator-specific profiles,
|
||||
and changes the digest when effective behavior changes.
|
||||
6. Omitted, explicitly empty, and configured validator chains resolve
|
||||
distinctly and preserve configured order.
|
||||
7. Returned `EffectiveConfig`, resolved bindings, nested options, reference
|
||||
slices/content, and validator chains do not alias the input configuration or
|
||||
resolution inputs.
|
||||
8. Both redacted summary payloads cover every binding and recursively redact
|
||||
sensitive keys in untyped and typed/aliased option containers while
|
||||
preserving safe neighbors and excluding materialized reference content.
|
||||
|
||||
Do not duplicate detailed reference-selector behavior owned by Stage 6.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
go test ./internal/core/config -run 'Test(Resolve|Effective|Redacted)'
|
||||
```
|
||||
|
||||
### Exit criteria
|
||||
|
||||
Resolution defaults, selection, compatibility failures, profile overrides,
|
||||
digests, deep-copy isolation, and redaction are directly protected.
|
||||
|
||||
## Stage 4: CLI command and configuration-loading contracts
|
||||
|
||||
**Status:** Not started
|
||||
|
||||
### Objective
|
||||
|
||||
Protect the public command surface, exit-code classification, configuration
|
||||
discovery, validation command, and pipeline listing.
|
||||
|
||||
### Read first
|
||||
|
||||
- `docs/cli.md`
|
||||
- `docs/config.md#discovery`
|
||||
- command dispatch, `runConfigValidate`, `runPipelinesList`, and configuration
|
||||
loading in `internal/cli/run.go`
|
||||
- `internal/cli/state_surfaces_test.go`
|
||||
|
||||
### Implement
|
||||
|
||||
Add `internal/cli/command_contract_test.go`. Use injected options and temporary
|
||||
files; no case may require production credentials or provider access.
|
||||
|
||||
Cover:
|
||||
|
||||
1. No arguments and each documented help spelling write usage to stdout and
|
||||
return `0`.
|
||||
2. Unknown commands/subcommands and malformed command syntax write to stderr
|
||||
and return `2`.
|
||||
3. Configuration discovery precedence is explicit `--config`, then non-empty
|
||||
`NOTARIUS_CONFIG`, then the compiled default path. Test the first two with
|
||||
temporary files. For the compiled path, inspect its host state first: assert
|
||||
selection when it is a regular file, or the documented not-found error when
|
||||
it is absent. Never create, replace, or remove the system path.
|
||||
4. Missing and malformed configuration files return `1` with actionable path
|
||||
or parse context.
|
||||
5. `config validate` succeeds for a valid file, resolves a requested pipeline
|
||||
against an injected catalog, rejects an unknown pipeline/lane, requires
|
||||
`--pipeline` with `--only`, and returns `2` for malformed `--only` syntax.
|
||||
6. `pipelines list` sorts normalized IDs in text and JSON forms; JSON is decoded
|
||||
and compared structurally rather than as raw formatting.
|
||||
7. Removed structural/state flags such as `--diagnostics-dir` are rejected with
|
||||
exit `2` instead of being silently ignored.
|
||||
8. Representative malformed run flags and valid-runtime failures establish the
|
||||
documented `2` versus `1` boundary.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
go test ./internal/cli -run 'Test(Command|Help|ConfigDiscovery|ConfigValidate|PipelinesList|ExitCode)'
|
||||
```
|
||||
|
||||
### Exit criteria
|
||||
|
||||
The documented command grammar, streams, discovery order, listing behavior,
|
||||
and exit-code classes are protected without invoking a real pipeline.
|
||||
|
||||
## Stage 5: Run controls, profile selection, and process-facing results
|
||||
|
||||
**Status:** Not started
|
||||
|
||||
### Objective
|
||||
|
||||
Protect the public run controls and the CLI-to-pipeline composition boundary.
|
||||
|
||||
### Read first
|
||||
|
||||
- `docs/cli.md#run`
|
||||
- `internal/cli/run.go`
|
||||
- `internal/cli/scriptorium_profiles.go`
|
||||
- `internal/cli/state_hardening_test.go` and its fake harness
|
||||
|
||||
### Implement
|
||||
|
||||
Add `internal/cli/run_contract_test.go`. Reuse or minimally generalize the
|
||||
existing deterministic state-test harness. Keep helper implementations at the
|
||||
bottom of a test file or in one clearly named `test_helpers_test.go` file.
|
||||
|
||||
Cover:
|
||||
|
||||
1. Missing pipeline ID, missing `--input`, unknown flags, blank values, and
|
||||
multiple positional IDs return `2` without allocating output or debug state.
|
||||
2. Unknown pipeline/lane and unreadable input are valid invocations that return
|
||||
`1`; when debug is requested after allocation, its path is reported.
|
||||
3. A successful run writes the logical output files, reports normalized and
|
||||
rejected counts, and passes the same deterministic run ID and start time to
|
||||
the manifest and debug bundle.
|
||||
4. `--only` executes and reports only selected lanes.
|
||||
5. `--output-dir` and `--debug-dir` override environment, file, and default
|
||||
roots; `--debug-dir` without `--debug` returns `2` and creates nothing.
|
||||
6. `--llm-profile` reaches every effective LLM-capable pipeline module binding,
|
||||
leaves validator-specific profiles unchanged, selects the expected factory
|
||||
profile when exactly one is effective, and validates explicit Scriptorium
|
||||
profile IDs without making provider calls.
|
||||
7. `effectiveLLMProfileIDs` ignores deterministic stages and returns stable,
|
||||
deduplicated ordering across chunk, lane stages, and LLM-backed validators.
|
||||
8. `--session-id` rejects missing/blank values and passes a trimmed explicit
|
||||
value through prompt-facing requests. With no override, the parsed source
|
||||
document ID is used as the session identifier.
|
||||
9. LLM factory, preparation, pipeline execution, output persistence, and debug
|
||||
persistence failures return `1`, preserve the primary error, and do not print
|
||||
a success message.
|
||||
10. Successful warnings remain exit `0`, are counted on stderr, and appear in
|
||||
durable output and requested debug summary.
|
||||
|
||||
Do not repeat the output-collision and terminalization matrices already covered
|
||||
by `run_id_test.go` and `state_hardening_test.go`.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
go test ./internal/cli -run 'TestRun'
|
||||
```
|
||||
|
||||
### Exit criteria
|
||||
|
||||
Every public non-reference run flag and every major CLI-to-runner handoff has a
|
||||
contract test, including representative failures.
|
||||
|
||||
## Stage 6: CLI reference selector and override contracts
|
||||
|
||||
**Status:** Not started
|
||||
|
||||
### Objective
|
||||
|
||||
Protect the high-risk selector grammar and target-resolution behavior without
|
||||
duplicating reference materialization internals.
|
||||
|
||||
### Read first
|
||||
|
||||
- reference sections of `docs/cli.md` and `docs/config.md`
|
||||
- reference parsing and resolution helpers in `internal/cli/run.go`
|
||||
- reference resolution/materialization tests under `internal/framework/pipeline`
|
||||
|
||||
### Implement
|
||||
|
||||
Add `internal/cli/reference_contract_test.go`. Build a compact catalog with
|
||||
chunk, extractor, merger, and normalizer specs declaring distinct, shared,
|
||||
optional, and required slots, including at least two artifact kinds where
|
||||
variant lookup matters.
|
||||
|
||||
Cover:
|
||||
|
||||
1. Parse and apply every documented selector form: flat, `chunk.slot`,
|
||||
`merge.slot`, `lane.slot`, and explicit lane extract/merge/normalize.
|
||||
2. Flat and lane selectors succeed only with one eligible selected target.
|
||||
Ambiguity errors identify viable explicit selectors.
|
||||
3. Selected lanes constrain reference discovery; selectors for unselected or
|
||||
unknown lanes fail before reference file materialization.
|
||||
4. Malformed bindings—missing selector, path, separator, or excess selector
|
||||
segments—and malformed unbind selectors return `2`.
|
||||
5. Repeated overrides use the final explicit binding for that exact target;
|
||||
target-specific overrides do not alter same-named slots on other targets.
|
||||
6. `--without-reference` removes optional configured bindings and fails when a
|
||||
required chunk, extract, merge, or normalize slot would remain unbound.
|
||||
7. CLI paths resolve from the working directory while config paths resolve from
|
||||
the config file directory. Assert recorded provenance, not private helper
|
||||
call order.
|
||||
8. Target lookup uses the selected artifact-kind variant and returns useful
|
||||
context when the requested merger/normalizer/extractor variant is absent.
|
||||
|
||||
Use small UTF-8 temporary reference files. Media-type and malformed-content
|
||||
details already proven by pipeline materialization tests need only one CLI
|
||||
smoke case.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
go test ./internal/cli -run 'Test.*Reference'
|
||||
```
|
||||
|
||||
### Exit criteria
|
||||
|
||||
All public selector forms, ambiguity boundaries, lane selection, binding
|
||||
precedence, unbinding, and path-origin rules are protected.
|
||||
|
||||
## Stage 7: Cache, resume, output, and debug integration risks
|
||||
|
||||
**Status:** Not started
|
||||
|
||||
### Objective
|
||||
|
||||
Fill state-integration gaps without duplicating the focused ADR-0005 and
|
||||
ADR-0006 tests already present.
|
||||
|
||||
### Read first
|
||||
|
||||
- `docs/operations.md`
|
||||
- `docs/internal/state.md`
|
||||
- ADR-0005 and ADR-0006
|
||||
- `internal/cli/run_id_test.go`
|
||||
- `internal/cli/state_surfaces_test.go`
|
||||
- `internal/cli/state_hardening_test.go`
|
||||
- tests under `internal/framework/chunkplan` and
|
||||
`internal/framework/checkpoint`
|
||||
|
||||
### Implement
|
||||
|
||||
Add `internal/cli/cache_contract_test.go` only for uncovered CLI composition
|
||||
contracts. Extend an existing state test when it already owns the behavior.
|
||||
|
||||
Cover or confirm existing coverage for:
|
||||
|
||||
1. Chunk-plan mode precedence: CLI flag, environment, file, default.
|
||||
Invalid CLI syntax returns `2`; invalid environment/file modes return `1`.
|
||||
2. An explicit chunk-plan root is passed through exactly. An empty configured
|
||||
root selects `<UserCacheDir>/notarius/chunk-plans`. `bypass` never calls
|
||||
`UserCacheDir`, constructs a store, or creates a root.
|
||||
3. Root-resolution and store-construction failures preserve context and create
|
||||
no durable output.
|
||||
4. `auto` reuses a plan across independent invocations with the same source
|
||||
digest even when pipeline, chunk module settings, references, or selected
|
||||
lanes differ. `refresh` replaces only after valid chunking; `bypass` does no
|
||||
cache I/O. Reuse details below the CLI remain owned by chunk-plan package
|
||||
tests.
|
||||
5. `--resume` alone constructs checkpoint loader and recorder under the exact
|
||||
configured or per-user checkpoint root. Without it, neither root resolution
|
||||
nor checkpoint I/O occurs.
|
||||
6. Chunk-plan reuse and checkpoint resume remain independent across the
|
||||
relevant mode matrix. Reuse a compact table rather than duplicating every
|
||||
existing state-surface assertion.
|
||||
7. Existing tests continue to prove exclusive output allocation, atomic files,
|
||||
retained new partial output, shared run identity, debug opt-in, restrictive
|
||||
debug permissions, redacted summaries, and exactly-once terminal reports.
|
||||
Add a case only if one of these facts is not directly asserted.
|
||||
8. Config-oriented commands do not resolve or create output, cache, or debug
|
||||
state.
|
||||
|
||||
Do not introduce automatic cleanup tests: Notarius intentionally retains output
|
||||
and requested debug bundles.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
go test ./internal/cli -run 'Test.*(Cache|ChunkPlan|Checkpoint|Resume|State|Output|Debug|RunID)'
|
||||
go test ./internal/framework/chunkplan ./internal/framework/checkpoint
|
||||
```
|
||||
|
||||
### Exit criteria
|
||||
|
||||
CLI state-root selection, opt-in I/O, cache mode precedence, cross-run reuse,
|
||||
and cache-family independence are covered at their composition boundary.
|
||||
|
||||
## Stage 8: Production composition and maintained examples
|
||||
|
||||
**Status:** Not started
|
||||
|
||||
### Objective
|
||||
|
||||
Protect the small number of production and end-to-end contracts most likely to
|
||||
break while unit tests continue to pass.
|
||||
|
||||
### Read first
|
||||
|
||||
- `README.md`
|
||||
- maintained files under `examples/`
|
||||
- `docs/config.md#implemented-production-modules`
|
||||
- `internal/cli/catalog.go`
|
||||
- `internal/modules/integration` tests and helpers
|
||||
|
||||
### Implement
|
||||
|
||||
Add `internal/cli/production_contract_test.go` and
|
||||
`internal/cli/example_contract_test.go`. Reuse production registries and prompt
|
||||
assets, but always inject a deterministic structured fake LLM client.
|
||||
|
||||
Production composition tests must prove:
|
||||
|
||||
1. The production catalog exposes every module, artifact codec, validator,
|
||||
default validator chain, and prompt asset named by current configuration and
|
||||
maintained examples.
|
||||
2. Catalog/registry conversion preserves typed artifact codec variants and
|
||||
validator-chain registrations.
|
||||
3. Production prompt assets allow the configured D&D scene chunker and spell
|
||||
extractor to prepare without reading provider credentials.
|
||||
4. `config validate --pipeline` accepts the maintained production module graph
|
||||
and rejects representative unknown modules, unknown validators, invalid
|
||||
artifact variants, and deterministic validators with LLM profiles.
|
||||
5. A small test configuration selecting `dnd/scenes` records that chunker and
|
||||
its expected warning/provenance fields during a fake-LLM run.
|
||||
|
||||
Example tests must prove:
|
||||
|
||||
1. Every maintained example configuration parses, validates, resolves its
|
||||
documented pipeline, and appears correctly in `pipelines list`.
|
||||
2. The README/minimal example invocation runs with a fake LLM and produces the
|
||||
documented logical JSON bundle. Decode manifest, index, warning, rejection,
|
||||
and artifact files and assert contract fields; do not snapshot whitespace or
|
||||
incidental timestamps.
|
||||
3. The documented `--only spells` invocation selects exactly the `spells` lane.
|
||||
4. One malformed input fixture returns `1`, produces no successful output
|
||||
bundle, and records a failure report only when debug is explicitly enabled.
|
||||
|
||||
If an example requires a provider profile, supply a test-only fake through
|
||||
`cli.Options`; never modify the example to embed credentials.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
go test ./internal/cli -run 'Test(Production|Example|Maintained)'
|
||||
go test ./internal/modules/integration
|
||||
```
|
||||
|
||||
### Exit criteria
|
||||
|
||||
Production registration, prompts, maintained configuration, and the shortest
|
||||
end-to-end user workflow are protected by offline tests.
|
||||
|
||||
## Stage 9: Coverage audit and plan closure
|
||||
|
||||
**Status:** Not started
|
||||
|
||||
### Objective
|
||||
|
||||
Confirm that contract and high-risk coverage is complete without converting
|
||||
coverage percentage into a goal.
|
||||
|
||||
### Read first
|
||||
|
||||
- all tests added by Stages 1–8
|
||||
- current canonical CLI, configuration, operations, integration, and internal
|
||||
state documents
|
||||
- `docs/policy/documentation.md`
|
||||
|
||||
### Implement
|
||||
|
||||
1. Build a temporary checklist mapping every current CLI command/flag and every
|
||||
current configuration section/source to at least one owning test. Do not
|
||||
commit the checklist if the test names themselves make ownership clear.
|
||||
2. Run statement coverage for `internal/cli` and `internal/core/config`. Inspect
|
||||
uncovered functions and branches. Add a test only when the uncovered code is
|
||||
a documented contract, safety boundary, destructive/stateful path, complex
|
||||
selector/precedence branch, or failure path likely to regress.
|
||||
3. Remove redundant cases that prove no additional contract or risk. Keep a
|
||||
small end-to-end layer and more numerous narrow unit tests.
|
||||
4. Confirm tests do not access the network, depend on host credentials, use the
|
||||
real user cache directory, write outside temporary roots, or depend on test
|
||||
ordering.
|
||||
5. Run race-enabled tests for the two changed packages. Fix test races; report
|
||||
production races separately unless authorized to change production code.
|
||||
6. Update `docs/development.md` only if a stable testing procedure beyond its
|
||||
existing validation commands is now necessary. Do not document a coverage
|
||||
threshold.
|
||||
7. Once all checks pass, delete this completed implementation plan. The tests
|
||||
and canonical current-behavior documents remain authoritative.
|
||||
|
||||
### Validation
|
||||
|
||||
```sh
|
||||
go test -coverprofile=/tmp/notarius-cli.cover ./internal/cli
|
||||
go test -coverprofile=/tmp/notarius-config.cover ./internal/core/config
|
||||
go tool cover -func=/tmp/notarius-cli.cover
|
||||
go tool cover -func=/tmp/notarius-config.cover
|
||||
go test -race ./internal/cli ./internal/core/config
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./cmd/notarius
|
||||
git diff --check
|
||||
```
|
||||
|
||||
The `/tmp` coverage profiles are inspection artifacts and must not be committed.
|
||||
|
||||
### Exit criteria
|
||||
|
||||
Every current CLI and configuration contract has an identifiable owning test;
|
||||
high-risk state, safety, precedence, selector, and failure paths are covered;
|
||||
the suite remains deterministic and maintainable; and no numeric coverage target
|
||||
has been introduced.
|
||||
364
internal/cli/cache_contract_test.go
Normal file
364
internal/cli/cache_contract_test.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/chunkplan"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestRunChunkPlanModePrecedenceAndValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileMode string
|
||||
envMode string
|
||||
cliMode string
|
||||
wantStores int
|
||||
}{
|
||||
{name: "default", wantStores: 1},
|
||||
{name: "file", fileMode: "bypass"},
|
||||
{name: "environment", envMode: "bypass"},
|
||||
{name: "cli", envMode: "refresh", cliMode: "bypass"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
if tt.name == "default" {
|
||||
removeStateTestConfigLine(t, roots.config, " mode: auto\n")
|
||||
} else if tt.fileMode != "" {
|
||||
replaceStateTestConfigLine(t, roots.config, " mode: auto\n", " mode: "+tt.fileMode+"\n")
|
||||
}
|
||||
|
||||
var stores []string
|
||||
opts := newStateTestHarness().options()
|
||||
opts.LookupEnv = func(name string) (string, bool) {
|
||||
if name == "NOTARIUS_CACHE_CHUNK_PLANS_MODE" && tt.envMode != "" {
|
||||
return tt.envMode, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
opts.ChunkPlanStoreFactory = func(root string) (pipeline.ChunkPlanStore, error) {
|
||||
stores = append(stores, root)
|
||||
return chunkplan.NewFilesystemStore(root)
|
||||
}
|
||||
|
||||
args := []string{"run", "sample", "--config", roots.config, "--input", roots.input}
|
||||
if tt.cliMode != "" {
|
||||
args = append(args, "--chunk_cache", tt.cliMode)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := RunWithOptions(args, &stdout, &stderr, opts); code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertStateTestOutput(t, roots.output)
|
||||
if len(stores) != tt.wantStores {
|
||||
t.Fatalf("chunk plan store roots = %v, want %d stores", stores, tt.wantStores)
|
||||
}
|
||||
if tt.wantStores == 1 && stores[0] != roots.plans {
|
||||
t.Fatalf("chunk plan store root = %q, want %q", stores[0], roots.plans)
|
||||
}
|
||||
if tt.wantStores == 0 {
|
||||
assertAbsent(t, roots.plans)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid cli syntax is a usage error", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "invalid"}, &stdout, &stderr, newStateTestHarness().options())
|
||||
if code != 2 || stdout.Len() != 0 || stderr.Len() == 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertNoRunState(t, roots)
|
||||
})
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
fileConfig bool
|
||||
}{
|
||||
{name: "invalid environment mode"},
|
||||
{name: "invalid file mode", fileConfig: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
opts := newStateTestHarness().options()
|
||||
if tt.fileConfig {
|
||||
replaceStateTestConfigLine(t, roots.config, " mode: auto\n", " mode: invalid\n")
|
||||
} else {
|
||||
opts.LookupEnv = func(name string) (string, bool) {
|
||||
if name == "NOTARIUS_CACHE_CHUNK_PLANS_MODE" {
|
||||
return "invalid", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
|
||||
if code != 1 || stdout.Len() != 0 || stderr.Len() == 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertNoRunState(t, roots)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunChunkPlanRootSelectionAndFailures(t *testing.T) {
|
||||
t.Run("empty configured root uses the per-user cache root", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.plans))
|
||||
userCache := filepath.Join(t.TempDir(), "user-cache")
|
||||
var stores []string
|
||||
opts := newStateTestHarness().options()
|
||||
opts.UserCacheDir = func() (string, error) { return userCache, nil }
|
||||
opts.ChunkPlanStoreFactory = func(root string) (pipeline.ChunkPlanStore, error) {
|
||||
stores = append(stores, root)
|
||||
return chunkplan.NewFilesystemStore(root)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
wantRoot := filepath.Join(userCache, "notarius", "chunk-plans")
|
||||
if len(stores) != 1 || stores[0] != wantRoot {
|
||||
t.Fatalf("chunk plan store roots = %v, want [%q]", stores, wantRoot)
|
||||
}
|
||||
assertFile(t, filepath.Join(wantRoot, strings.TrimPrefix(stateTestDigest, "sha256:"), "plan.json"))
|
||||
assertAbsent(t, roots.plans)
|
||||
})
|
||||
|
||||
t.Run("bypass avoids default cache dependencies", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.plans))
|
||||
userCacheCalls := 0
|
||||
storeCalls := 0
|
||||
opts := newStateTestHarness().options()
|
||||
opts.UserCacheDir = func() (string, error) {
|
||||
userCacheCalls++
|
||||
return "", errors.New("user cache must not be resolved")
|
||||
}
|
||||
opts.ChunkPlanStoreFactory = func(string) (pipeline.ChunkPlanStore, error) {
|
||||
storeCalls++
|
||||
return nil, errors.New("chunk plan store must not be constructed")
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, &stdout, &stderr, opts)
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if userCacheCalls != 0 || storeCalls != 0 {
|
||||
t.Fatalf("user cache calls=%d store calls=%d, want none", userCacheCalls, storeCalls)
|
||||
}
|
||||
assertStateTestOutput(t, roots.output)
|
||||
assertAbsent(t, roots.plans)
|
||||
})
|
||||
|
||||
t.Run("user cache resolution failure has context and no output", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.plans))
|
||||
opts := newStateTestHarness().options()
|
||||
opts.UserCacheDir = func() (string, error) { return "", errors.New("cache home unavailable") }
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "resolve chunk plan root") || !strings.Contains(stderr.String(), "cache home unavailable") {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertNoRunState(t, roots)
|
||||
})
|
||||
|
||||
t.Run("store construction failure has context and no output", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
opts := newStateTestHarness().options()
|
||||
opts.ChunkPlanStoreFactory = func(root string) (pipeline.ChunkPlanStore, error) {
|
||||
return nil, fmt.Errorf("store unavailable")
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &stdout, &stderr, opts)
|
||||
want := fmt.Sprintf("create chunk plan store at %q", roots.plans)
|
||||
if code != 1 || !strings.Contains(stderr.String(), want) || !strings.Contains(stderr.String(), "store unavailable") {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertNoRunState(t, roots)
|
||||
})
|
||||
|
||||
t.Run("checkpoint root resolution failure has context and no output", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.checkpoints))
|
||||
opts := newStateTestHarness().options()
|
||||
opts.UserCacheDir = func() (string, error) { return "", errors.New("checkpoint cache unavailable") }
|
||||
result := runStateTest(t, roots, opts, false, true, "bypass")
|
||||
if result.code != 1 || !strings.Contains(result.stderr, "resolve checkpoint root") || !strings.Contains(result.stderr, "checkpoint cache unavailable") {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
|
||||
}
|
||||
assertNoRunState(t, roots)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunAutoReusesPlanWhenRunInputsChange(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
data, err := os.ReadFile(roots.config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configText := strings.Replace(string(data), " chunk: test/chunk\n", ` chunk:
|
||||
module: test/chunk
|
||||
options:
|
||||
strategy: first
|
||||
`, 1)
|
||||
configText = strings.Replace(configText, " output: test/output\n", ` other:
|
||||
extract: test/extract
|
||||
merge: test/merge
|
||||
normalize: test/normalize
|
||||
output: test/output
|
||||
`, 1)
|
||||
if err := os.WriteFile(roots.config, []byte(configText), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
referencePath := filepath.Join(filepath.Dir(roots.input), "reference.txt")
|
||||
if err := os.WriteFile(referencePath, []byte("reference content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
harness := newStateTestHarness()
|
||||
var firstStdout, firstStderr bytes.Buffer
|
||||
first := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input}, &firstStdout, &firstStderr, harness.options())
|
||||
if first != 0 {
|
||||
t.Fatalf("first run code=%d stdout=%q stderr=%q", first, firstStdout.String(), firstStderr.String())
|
||||
}
|
||||
configData, err := os.ReadFile(roots.config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
configText = strings.Replace(string(configData), "strategy: first", "strategy: second", 1)
|
||||
if err := os.WriteFile(roots.config, []byte(configText), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
second := RunWithOptions([]string{
|
||||
"run", "sample", "--config", roots.config, "--input", roots.input,
|
||||
"--only", "items", "--reference", "chunk.cache-reference=" + referencePath,
|
||||
}, &stdout, &stderr, harness.options())
|
||||
if second != 0 {
|
||||
t.Fatalf("second run code=%d stdout=%q stderr=%q", second, stdout.String(), stderr.String())
|
||||
}
|
||||
harness.mu.Lock()
|
||||
chunkCalls := harness.chunkCalls
|
||||
harness.mu.Unlock()
|
||||
if chunkCalls != 1 {
|
||||
t.Fatalf("chunk calls across changed run inputs = %d, want 1", chunkCalls)
|
||||
}
|
||||
assertFile(t, filepath.Join(roots.plans, strings.TrimPrefix(stateTestDigest, "sha256:"), "plan.json"))
|
||||
assertAnyFile(t, roots.output)
|
||||
}
|
||||
|
||||
func TestRunResumeSelectsConfiguredOrPerUserCheckpointRoot(t *testing.T) {
|
||||
for _, configured := range []bool{true, false} {
|
||||
name := "per-user root"
|
||||
if configured {
|
||||
name = "configured root"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
if !configured {
|
||||
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.checkpoints))
|
||||
}
|
||||
userCache := filepath.Join(t.TempDir(), "user-cache")
|
||||
userCacheCalls := 0
|
||||
opts := newStateTestHarness().options()
|
||||
opts.UserCacheDir = func() (string, error) {
|
||||
userCacheCalls++
|
||||
return userCache, nil
|
||||
}
|
||||
result := runStateTest(t, roots, opts, false, true, "bypass")
|
||||
if result.code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
|
||||
}
|
||||
wantRoot := roots.checkpoints
|
||||
wantCalls := 0
|
||||
if !configured {
|
||||
wantRoot = filepath.Join(userCache, "notarius", "checkpoints")
|
||||
wantCalls = 1
|
||||
}
|
||||
if userCacheCalls != wantCalls {
|
||||
t.Fatalf("user cache calls = %d, want %d", userCacheCalls, wantCalls)
|
||||
}
|
||||
assertAnyFile(t, wantRoot)
|
||||
if !configured {
|
||||
assertAbsent(t, roots.checkpoints)
|
||||
}
|
||||
assertStateTestOutput(t, roots.output)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("without resume avoids checkpoint root resolution", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
removeStateTestConfigLine(t, roots.config, fmt.Sprintf(" directory: %q\n", roots.checkpoints))
|
||||
opts := newStateTestHarness().options()
|
||||
opts.UserCacheDir = func() (string, error) { return "", errors.New("checkpoint cache must not be resolved") }
|
||||
result := runStateTest(t, roots, opts, false, false, "bypass")
|
||||
if result.code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
|
||||
}
|
||||
assertStateTestOutput(t, roots.output)
|
||||
assertAbsent(t, roots.checkpoints)
|
||||
})
|
||||
}
|
||||
|
||||
func TestConfigCommandsDoNotResolveRunState(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"config", "validate", "--config"},
|
||||
{"pipelines", "list", "--config"},
|
||||
} {
|
||||
name := strings.Join(args[:2], "-")
|
||||
t.Run(name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
opts := newStateTestHarness().options()
|
||||
opts.UserCacheDir = func() (string, error) { return "", errors.New("state root must not be resolved") }
|
||||
opts.ChunkPlanStoreFactory = func(string) (pipeline.ChunkPlanStore, error) {
|
||||
return nil, errors.New("chunk plan store must not be constructed")
|
||||
}
|
||||
command := append([]string(nil), args...)
|
||||
command = append(command, roots.config)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions(command, &stdout, &stderr, opts)
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertNoRunState(t, roots)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func replaceStateTestConfigLine(t *testing.T, path, old, new string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
if !strings.Contains(text, old) {
|
||||
t.Fatalf("config %q does not contain %q", path, old)
|
||||
}
|
||||
text = strings.Replace(text, old, new, 1)
|
||||
if err := os.WriteFile(path, []byte(text), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func removeStateTestConfigLine(t *testing.T, path, line string) {
|
||||
replaceStateTestConfigLine(t, path, line, "")
|
||||
}
|
||||
|
||||
func assertNoRunState(t *testing.T, roots stateTestRoots) {
|
||||
t.Helper()
|
||||
assertAbsent(t, roots.output)
|
||||
assertAbsent(t, roots.plans)
|
||||
assertAbsent(t, roots.checkpoints)
|
||||
assertAbsent(t, roots.debug)
|
||||
}
|
||||
240
internal/cli/command_contract_test.go
Normal file
240
internal/cli/command_contract_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCommandHelpSpellingsWriteUsageToStdout(t *testing.T) {
|
||||
tests := [][]string{nil, {"help"}, {"--help"}, {"-h"}}
|
||||
for _, args := range tests {
|
||||
name := "no arguments"
|
||||
if len(args) > 0 {
|
||||
name = args[0]
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions(args, &stdout, &stderr, commandContractOptions(t))
|
||||
if code != 0 || !strings.Contains(stdout.String(), "Usage:") || stderr.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandSyntaxErrorsUseStderrAndExitTwo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "unknown command", args: []string{"unknown"}, want: "unknown command"},
|
||||
{name: "missing config subcommand", args: []string{"config"}, want: "config requires a subcommand"},
|
||||
{name: "unknown pipelines subcommand", args: []string{"pipelines", "unknown"}, want: "unknown pipelines subcommand"},
|
||||
{name: "malformed run flag", args: []string{"run", "demo", "--chunk_cache", "invalid"}, want: "not supported"},
|
||||
{name: "unknown flag", args: []string{"config", "validate", "--unknown"}, want: "flag provided but not defined"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions(tt.args, &stdout, &stderr, commandContractOptions(t))
|
||||
if code != 2 || !strings.Contains(stderr.String(), tt.want) || stdout.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiscoveryPrefersExplicitPathThenEnvironment(t *testing.T) {
|
||||
explicit := writeCommandConfig(t, "explicit", "alpha")
|
||||
environment := writeCommandConfig(t, "environment", "beta")
|
||||
lookup := func(name string) (string, bool) {
|
||||
if name == "NOTARIUS_CONFIG" {
|
||||
return environment, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"pipelines", "list", "--config", explicit}, &stdout, &stderr, commandContractOptionsWithLookup(t, lookup))
|
||||
if code != 0 || stdout.String() != "alpha\nexplicit\n" || stderr.Len() != 0 {
|
||||
t.Fatalf("explicit config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"pipelines", "list"}, &stdout, &stderr, commandContractOptionsWithLookup(t, lookup))
|
||||
if code != 0 || stdout.String() != "beta\nenvironment\n" || stderr.Len() != 0 {
|
||||
t.Fatalf("environment config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiscoveryUsesCompiledDefaultOnlyWhenAvailable(t *testing.T) {
|
||||
info, statErr := os.Stat(defaultConfigPath)
|
||||
if statErr != nil && !os.IsNotExist(statErr) {
|
||||
t.Fatalf("stat compiled default config: %v", statErr)
|
||||
}
|
||||
if statErr == nil && !info.Mode().IsRegular() {
|
||||
t.Skipf("compiled default config has unexpected host state: %s", info.Mode())
|
||||
}
|
||||
|
||||
path, err := discoverConfigPath("", commandContractOptions(t))
|
||||
if statErr == nil {
|
||||
if err != nil || path != defaultConfigPath {
|
||||
t.Fatalf("discoverConfigPath() = %q, %v; want compiled default", path, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), "config file not found") {
|
||||
t.Fatalf("discoverConfigPath() error = %v, want documented not-found context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLoadingFailuresReturnOneWithPathContext(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "missing.yml")
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", missing}, &stdout, &stderr, commandContractOptions(t))
|
||||
if code != 1 || !strings.Contains(stderr.String(), missing) || stdout.Len() != 0 {
|
||||
t.Fatalf("missing config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
malformed := filepath.Join(t.TempDir(), "malformed.yml")
|
||||
if err := os.WriteFile(malformed, []byte("version: [\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"config", "validate", "--config", malformed}, &stdout, &stderr, commandContractOptions(t))
|
||||
if code != 1 || !strings.Contains(stderr.String(), malformed) || !strings.Contains(stderr.String(), "parse config file") || stdout.Len() != 0 {
|
||||
t.Fatalf("malformed config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidateResolvesPipelineAndChecksSelection(t *testing.T) {
|
||||
configPath := writeResolvableCommandConfig(t)
|
||||
options := commandContractOptions(t)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "spells"}, &stdout, &stderr, options)
|
||||
if code != 0 || !strings.Contains(stdout.String(), "valid for pipeline \"demo\"") || stderr.Len() != 0 {
|
||||
t.Fatalf("valid resolution: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "missing"}, &stdout, &stderr, options)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "pipeline \"missing\"") {
|
||||
t.Fatalf("unknown pipeline: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "missing"}, &stdout, &stderr, options)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "lane \"missing\"") {
|
||||
t.Fatalf("unknown lane: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--only", "spells"}, &stdout, &stderr, options)
|
||||
if code != 2 || !strings.Contains(stderr.String(), "--only requires --pipeline") {
|
||||
t.Fatalf("missing pipeline for only: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "demo", "--only", "spells,,other"}, &stdout, &stderr, options)
|
||||
if code != 2 || !strings.Contains(stderr.String(), "--only must contain") {
|
||||
t.Fatalf("malformed only: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipelinesListSortsNormalizedIDsInTextAndJSON(t *testing.T) {
|
||||
configPath := writeCommandConfig(t, " zeta ", "alpha")
|
||||
options := commandContractOptions(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"pipelines", "list", "--config", configPath}, &stdout, &stderr, options)
|
||||
if code != 0 || stdout.String() != "alpha\nzeta\n" || stderr.Len() != 0 {
|
||||
t.Fatalf("text list: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"pipelines", "list", "--config", configPath, "--json"}, &stdout, &stderr, options)
|
||||
var payload struct {
|
||||
Pipelines []string `json:"pipelines"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("JSON list = %q: %v", stdout.String(), err)
|
||||
}
|
||||
if code != 0 || len(payload.Pipelines) != 2 || payload.Pipelines[0] != "alpha" || payload.Pipelines[1] != "zeta" || stderr.Len() != 0 {
|
||||
t.Fatalf("JSON list: code=%d payload=%#v stderr=%q", code, payload, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemovedStructuralFlagsAndRuntimeFailuresKeepExitClasses(t *testing.T) {
|
||||
configPath := writeResolvableCommandConfig(t)
|
||||
options := commandContractOptions(t)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "demo", "--input", "missing-input", "--config", configPath, "--diagnostics-dir", t.TempDir()}, &stdout, &stderr, options)
|
||||
if code != 2 || !strings.Contains(stderr.String(), "flag provided but not defined") {
|
||||
t.Fatalf("removed flag: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
code = RunWithOptions([]string{"run", "missing", "--input", "missing-input", "--config", configPath, "--chunk_cache", "bypass"}, &stdout, &stderr, options)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "pipeline \"missing\"") || stdout.Len() != 0 {
|
||||
t.Fatalf("valid-runtime failure: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func commandContractOptions(t *testing.T) Options {
|
||||
return commandContractOptionsWithLookup(t, emptyLookup)
|
||||
}
|
||||
|
||||
func commandContractOptionsWithLookup(t *testing.T, lookup func(string) (string, bool)) Options {
|
||||
t.Helper()
|
||||
components, err := newProductionComponents()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Options{
|
||||
Catalog: catalogFromRegistries(components.registries),
|
||||
Registries: components.registries,
|
||||
LookupEnv: lookup,
|
||||
}
|
||||
}
|
||||
|
||||
func writeCommandConfig(t *testing.T, firstID, secondID string) string {
|
||||
t.Helper()
|
||||
content := fmt.Sprintf("version: 3\npipelines:\n %q:\n input: seriatim\n %q:\n input: seriatim\n", firstID, secondID)
|
||||
return writeCommandConfigContent(t, content)
|
||||
}
|
||||
|
||||
func writeResolvableCommandConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
return writeCommandConfigContent(t, `version: 3
|
||||
pipelines:
|
||||
demo:
|
||||
input: seriatim
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
`)
|
||||
}
|
||||
|
||||
func writeCommandConfigContent(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
149
internal/cli/example_contract_test.go
Normal file
149
internal/cli/example_contract_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
|
||||
func TestMaintainedExamplesLoadResolveAndList(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
for _, example := range maintainedExampleFiles(t) {
|
||||
t.Run(example.name, func(t *testing.T) {
|
||||
cfg := loadMaintainedExample(t, example.path)
|
||||
if _, err := cfg.Resolve(resolveInputForMaintainedExample(components, "dnd-session")); err != nil {
|
||||
t.Fatalf("resolve maintained example: %v", err)
|
||||
}
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions([]string{"pipelines", "list", "--config", example.path}, &stdout, &stderr, productionOptionsFromComponents(components))
|
||||
if code != 0 || stdout.String() != "dnd-session\n" || stderr.Len() != 0 {
|
||||
t.Fatalf("pipelines list: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintainedMinimalInvocationProducesJSONBundle(t *testing.T) {
|
||||
outputRoot := filepath.Join(t.TempDir(), "output")
|
||||
fake := &productionFakeLLMClient{}
|
||||
options := productionRunOptions(t, fake)
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions([]string{
|
||||
"run", "dnd-session",
|
||||
"--config", repositoryPath("examples", "dnd-spells.config.yml"),
|
||||
"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
|
||||
"--only", "spells", "--chunk_cache", "bypass", "--output-dir", outputRoot,
|
||||
}, &stdout, &stderr, options)
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if !strings.Contains(stdout.String(), `pipeline "dnd-session"`) || !strings.Contains(stdout.String(), "outputs=1 rejected=0") {
|
||||
t.Fatalf("stdout=%q, want completed pipeline and counts", stdout.String())
|
||||
}
|
||||
|
||||
runRoot := filepath.Join(outputRoot, productionRunID)
|
||||
index := readProductionJSON[exampleOutputIndex](t, filepath.Join(runRoot, "index.json"))
|
||||
if index.ManifestFile != "manifest.json" || index.RejectedFile != "rejected.json" || index.WarningsFile != "warnings.json" || len(index.OutputFiles) != 1 {
|
||||
t.Fatalf("index = %#v, want one spells output and fixed companion files", index)
|
||||
}
|
||||
entry := index.OutputFiles[0]
|
||||
if entry.LaneID != "spells" || entry.File != "lanes/spells.json" || entry.MediaType != "application/json" || entry.SchemaID != "notarius.dnd.spells" || entry.SchemaVersion != "v1" {
|
||||
t.Fatalf("index output entry = %#v, want spells JSON contract", entry)
|
||||
}
|
||||
|
||||
manifest := readProductionJSON[artifacts.RunManifest](t, filepath.Join(runRoot, "manifest.json"))
|
||||
if manifest.PipelineID != "dnd-session" || manifest.InputModule != "seriatim" || manifest.Chunker != "generic" || manifest.OutputEncoder != "json" || manifest.ValidationStatus != "approved" || manifest.ChunkPlan == nil || manifest.ChunkPlan.Action != "bypassed" {
|
||||
t.Fatalf("manifest = %#v, want approved minimal run", manifest)
|
||||
}
|
||||
if len(manifest.ArtifactLanes) != 1 {
|
||||
t.Fatalf("manifest lanes = %#v, want exactly spells", manifest.ArtifactLanes)
|
||||
}
|
||||
lane := manifest.ArtifactLanes[0]
|
||||
if lane.ID != "spells" || lane.Extractor != "dnd/spells" || lane.Merger != "appendorder" || lane.Normalizer != "noop" {
|
||||
t.Fatalf("manifest lane = %#v, want production spells composition", lane)
|
||||
}
|
||||
|
||||
artifact := readProductionJSON[dnd.SpellList](t, filepath.Join(runRoot, entry.File))
|
||||
if len(artifact.SpellCasts) != 1 || artifact.SpellCasts[0].Spell != "Cure Wounds" || artifact.SpellCasts[0].SourceRefs[0].SourceID != "session-alpha" {
|
||||
t.Fatalf("artifact = %#v, want one source-linked Cure Wounds cast", artifact)
|
||||
}
|
||||
rejected := readProductionJSON[struct {
|
||||
Rejected []json.RawMessage `json:"rejected"`
|
||||
}](t, filepath.Join(runRoot, "rejected.json"))
|
||||
if len(rejected.Rejected) != 0 {
|
||||
t.Fatalf("rejected = %#v, want empty rejection list", rejected.Rejected)
|
||||
}
|
||||
warnings := readProductionJSON[struct {
|
||||
Warnings []json.RawMessage `json:"warnings"`
|
||||
}](t, filepath.Join(runRoot, "warnings.json"))
|
||||
if len(warnings.Warnings) != 0 {
|
||||
t.Fatalf("warnings = %#v, want empty warning list", warnings.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintainedMalformedInputOnlyRecordsDebugFailureWhenRequested(t *testing.T) {
|
||||
malformed := filepath.Join(t.TempDir(), "malformed.json")
|
||||
if err := os.WriteFile(malformed, []byte("{not valid json"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, debug := range []bool{false, true} {
|
||||
name := "without debug"
|
||||
if debug {
|
||||
name = "with debug"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
outputRoot := filepath.Join(t.TempDir(), "output")
|
||||
debugRoot := filepath.Join(t.TempDir(), "debug")
|
||||
options := productionRunOptions(t, &productionFakeLLMClient{})
|
||||
args := []string{
|
||||
"run", "dnd-session",
|
||||
"--config", repositoryPath("examples", "dnd-spells.config.yml"),
|
||||
"--input", malformed, "--chunk_cache", "bypass", "--output-dir", outputRoot,
|
||||
}
|
||||
if debug {
|
||||
args = append(args, "--debug", "--debug-dir", debugRoot)
|
||||
}
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions(args, &stdout, &stderr, options)
|
||||
if code != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "parse input") {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertAbsent(t, outputRoot)
|
||||
if !debug {
|
||||
assertAbsent(t, debugRoot)
|
||||
return
|
||||
}
|
||||
bundle := onlyChildDir(t, debugRoot)
|
||||
report := readProductionJSON[debugbundle.RunReport](t, filepath.Join(bundle, "summary", "run-report.json"))
|
||||
if report.Succeeded || report.PipelineID != "dnd-session" {
|
||||
t.Fatalf("failure report = %#v, want failed dnd-session report", report)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type exampleOutputIndex struct {
|
||||
ManifestFile string `json:"manifest_file"`
|
||||
OutputFiles []exampleOutputIndexEntry `json:"output_files"`
|
||||
RejectedFile string `json:"rejected_file"`
|
||||
WarningsFile string `json:"warnings_file"`
|
||||
}
|
||||
|
||||
type exampleOutputIndexEntry struct {
|
||||
LaneID string `json:"lane_id"`
|
||||
MediaType string `json:"media_type"`
|
||||
File string `json:"file"`
|
||||
SchemaID string `json:"schema_id"`
|
||||
SchemaVersion string `json:"schema_version"`
|
||||
}
|
||||
|
||||
func resolveInputForMaintainedExample(components productionComponents, pipelineID string) config.ResolveInput {
|
||||
return config.ResolveInput{PipelineID: pipelineID, Catalog: catalogFromRegistries(components.registries)}
|
||||
}
|
||||
467
internal/cli/production_contract_test.go
Normal file
467
internal/cli/production_contract_test.go
Normal file
@@ -0,0 +1,467 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
|
||||
)
|
||||
|
||||
func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
registries := components.registries
|
||||
|
||||
assertProductionKeys(t, "inputs", registries.Inputs.RegisteredKeys(), []string{"seriatim"})
|
||||
assertProductionKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes", "generic"})
|
||||
assertProductionKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells"})
|
||||
assertProductionKeys(t, "mergers", registries.Mergers.RegisteredKeys(), []string{"appendorder"})
|
||||
assertProductionKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop"})
|
||||
assertProductionKeys(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"})
|
||||
assertProductionKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{
|
||||
"extract/dnd/spells/shape",
|
||||
"extract/dnd/spells/source_refs",
|
||||
"extract/dnd/spells/source_relatedness",
|
||||
"generic/always_accept",
|
||||
"generic/always_reject",
|
||||
"generic/valid_json",
|
||||
"generic/valid_json_schema",
|
||||
})
|
||||
if got := registries.ArtifactCodecs.RegisteredKinds(); !reflect.DeepEqual(got, []contracts.ArtifactKind{dnd.SpellListKind}) {
|
||||
t.Fatalf("artifact codec kinds = %#v, want [%q]", got, dnd.SpellListKind)
|
||||
}
|
||||
if got := registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule); !reflect.DeepEqual(got, []contracts.ArtifactKind{dnd.SpellListKind}) {
|
||||
t.Fatalf("merger variants = %#v, want [%q]", got, dnd.SpellListKind)
|
||||
}
|
||||
if got := registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule); !reflect.DeepEqual(got, []contracts.ArtifactKind{dnd.SpellListKind}) {
|
||||
t.Fatalf("normalizer variants = %#v, want [%q]", got, dnd.SpellListKind)
|
||||
}
|
||||
|
||||
wantChain := []pipeline.ModuleBinding{
|
||||
pipeline.Binding("generic/valid_json"),
|
||||
pipeline.Binding("generic/valid_json_schema"),
|
||||
pipeline.Binding("extract/dnd/spells/shape"),
|
||||
pipeline.Binding("extract/dnd/spells/source_refs"),
|
||||
pipeline.Binding("extract/dnd/spells/source_relatedness"),
|
||||
}
|
||||
if got := registries.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
|
||||
t.Fatalf("spell validator chain = %#v, want %#v", got, wantChain)
|
||||
}
|
||||
|
||||
assetNames := productionAssetNames(t, components.assets.PromptFS)
|
||||
wantAssets := []string{
|
||||
"dnd.scenes/dnd.scenes.yaml",
|
||||
"dnd.scenes/instructions.md",
|
||||
"dnd.scenes/sharedassets/common-dnd-references.md",
|
||||
"dnd.scenes/sharedassets/common-dnd-system.md",
|
||||
"dnd.scenes/sharedassets/common-dnd-transcript.md",
|
||||
"dnd.scenes/task.md",
|
||||
"dnd.spells/dnd.spells.yaml",
|
||||
"dnd.spells/instructions.md",
|
||||
"dnd.spells/sharedassets/common-dnd-references.md",
|
||||
"dnd.spells/sharedassets/common-dnd-system.md",
|
||||
"dnd.spells/sharedassets/common-dnd-transcript.md",
|
||||
"dnd.spells/task.md",
|
||||
}
|
||||
if !reflect.DeepEqual(assetNames, wantAssets) {
|
||||
t.Fatalf("production prompt assets = %#v, want %#v", assetNames, wantAssets)
|
||||
}
|
||||
|
||||
catalog := catalogFromRegistries(registries)
|
||||
converted := registriesFromCatalog(catalog)
|
||||
if converted.ArtifactCodecs != registries.ArtifactCodecs || converted.ValidatorChains != registries.ValidatorChains {
|
||||
t.Fatal("catalog/registry conversion did not preserve codec and validator-chain registries")
|
||||
}
|
||||
codecSpec, ok := catalog.ArtifactCodecs.Spec(dnd.SpellListKind)
|
||||
if !ok || codecSpec.Kind != dnd.SpellListKind || codecSpec.Schema.ID != spellcodec.SchemaID {
|
||||
t.Fatalf("catalog codec spec = %#v, ok=%t, want typed D&D spell codec", codecSpec, ok)
|
||||
}
|
||||
if got := catalog.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) {
|
||||
t.Fatalf("catalog validator chain = %#v, want %#v", got, wantChain)
|
||||
}
|
||||
|
||||
for _, example := range maintainedExampleFiles(t) {
|
||||
cfg := loadMaintainedExample(t, example.path)
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-session", Catalog: catalog})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve %s: %v", example.name, err)
|
||||
}
|
||||
if effective.ResolvedPipeline.Input.Module != "seriatim" || len(effective.ResolvedPipeline.ArtifactLanes) != 1 || effective.ResolvedPipeline.ArtifactLanes[0].ID != "spells" {
|
||||
t.Fatalf("resolved %s pipeline = %#v, want seriatim and spells", example.name, effective.ResolvedPipeline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCLICompositionResolvesMaintainedConfigurations(t *testing.T) {
|
||||
catalog, err := effectiveCatalog(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve default catalog: %v", err)
|
||||
}
|
||||
if isEmptyCatalog(catalog) {
|
||||
t.Fatal("default catalog is empty")
|
||||
}
|
||||
registries, err := effectiveRegistries(Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve default registries: %v", err)
|
||||
}
|
||||
if isEmptyRegistries(registries) {
|
||||
t.Fatal("default registries are empty")
|
||||
}
|
||||
|
||||
for _, example := range maintainedExampleFiles(t) {
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", example.path, "--pipeline", "dnd-session"}, &stdout, &stderr, Options{LookupEnv: emptyLookup})
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("validate maintained %s config with defaults: code=%d stdout=%q stderr=%q", example.name, code, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionPromptAssetsPrepareWithoutProviderCredentials(t *testing.T) {
|
||||
components := productionTestComponents(t)
|
||||
cfg := config.Default()
|
||||
cfg.Pipelines["dnd-scenes"] = pipeline.PipelineProfile{
|
||||
ID: "dnd-scenes",
|
||||
Input: pipeline.Binding("seriatim"),
|
||||
Chunk: pipeline.Binding("dnd/scenes"),
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"spells": {Extract: pipeline.Binding("dnd/spells")},
|
||||
},
|
||||
}
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "dnd-scenes", Catalog: catalogFromRegistries(components.registries)})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve production scene pipeline: %v", err)
|
||||
}
|
||||
if _, err := pipeline.Prepare(effective.ResolvedPipeline, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil {
|
||||
t.Fatalf("prepare production scene and spell modules: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionConfigValidationCoversModuleAndVariantFailures(t *testing.T) {
|
||||
base := string(readRepositoryFile(t, "examples", "dnd-spells.config.yml"))
|
||||
validPath := writeProductionContractConfig(t, base)
|
||||
options := productionCLIOptions(t)
|
||||
var stdout, stderr strings.Builder
|
||||
if code := RunWithOptions([]string{"config", "validate", "--config", validPath, "--pipeline", "dnd-session"}, &stdout, &stderr, options); code != 0 {
|
||||
t.Fatalf("valid production config: code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
for _, example := range maintainedExampleFiles(t) {
|
||||
var exampleStdout, exampleStderr strings.Builder
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", example.path, "--pipeline", "dnd-session"}, &exampleStdout, &exampleStderr, options)
|
||||
if code != 0 {
|
||||
t.Fatalf("validate maintained %s config: code=%d stdout=%q stderr=%q", example.name, code, exampleStdout.String(), exampleStderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
options Options
|
||||
fragments []string
|
||||
}{
|
||||
{
|
||||
name: "unknown module",
|
||||
content: strings.Replace(base, " input: seriatim\n", " input: missing/input\n", 1),
|
||||
options: productionCLIOptions(t),
|
||||
fragments: []string{"pipeline \"dnd-session\"", "input", "missing/input"},
|
||||
},
|
||||
{
|
||||
name: "unknown validator",
|
||||
content: strings.Replace(base, " extract: dnd/spells\n", " extract:\n module: dnd/spells\n validators:\n - module: missing/validator\n", 1),
|
||||
options: productionCLIOptions(t),
|
||||
fragments: []string{"validator", "missing/validator"},
|
||||
},
|
||||
{
|
||||
name: "invalid artifact variant",
|
||||
content: base,
|
||||
options: productionCLIOptionsWithoutSpellNormalizer(t),
|
||||
fragments: []string{"normalizer", "noop", string(dnd.SpellListKind), "variant"},
|
||||
},
|
||||
{
|
||||
name: "deterministic validator with profile",
|
||||
content: strings.Replace(base, " extract: dnd/spells\n", " extract:\n module: dnd/spells\n validators:\n - module: generic/valid_json\n llm_profile: forbidden-profile\n", 1),
|
||||
options: productionCLIOptions(t),
|
||||
fragments: []string{"deterministic validator", "llm_profile"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := writeProductionContractConfig(t, tt.content)
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions([]string{"config", "validate", "--config", path, "--pipeline", "dnd-session"}, &stdout, &stderr, tt.options)
|
||||
if code != 1 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
for _, fragment := range tt.fragments {
|
||||
if !strings.Contains(stderr.String(), fragment) {
|
||||
t.Fatalf("stderr=%q, want %q", stderr.String(), fragment)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionSceneRunRecordsChunkerWarningsAndProvenance(t *testing.T) {
|
||||
outputRoot := filepath.Join(t.TempDir(), "output")
|
||||
configPath := writeProductionContractConfig(t, productionRunConfig(outputRoot, "dnd/scenes"))
|
||||
fake := &productionFakeLLMClient{}
|
||||
options := productionRunOptions(t, fake)
|
||||
var stdout, stderr strings.Builder
|
||||
code := RunWithOptions([]string{
|
||||
"run", "dnd-session", "--config", configPath,
|
||||
"--input", repositoryPath("examples", "seriatim-minimal-transcript.json"),
|
||||
"--chunk_cache", "bypass", "--session-id", "offline-session",
|
||||
}, &stdout, &stderr, options)
|
||||
if code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
manifest := readProductionJSON[artifacts.RunManifest](t, filepath.Join(outputRoot, productionRunID, "manifest.json"))
|
||||
if manifest.Chunker != scenes.Key || manifest.ChunkPlan == nil || manifest.ChunkPlan.Action != "bypassed" || manifest.ChunkPlan.ProducerModule != scenes.Key {
|
||||
t.Fatalf("chunk manifest = %#v, want dnd scene producer", manifest.ChunkPlan)
|
||||
}
|
||||
if got := manifest.ModuleMetadata["chunker"]["prompt_id"]; got != scenes.PromptID {
|
||||
t.Fatalf("chunker prompt metadata = %#v, want %q", got, scenes.PromptID)
|
||||
}
|
||||
if got := manifest.ChunkPlan.ProducerMetadata["response_schema_id"]; got != scenes.ResponseSchemaID {
|
||||
t.Fatalf("chunk producer schema metadata = %#v, want %q", got, scenes.ResponseSchemaID)
|
||||
}
|
||||
warnings := readProductionJSON[struct {
|
||||
Warnings []contracts.Warning `json:"warnings"`
|
||||
}](t, filepath.Join(outputRoot, productionRunID, "warnings.json"))
|
||||
if len(warnings.Warnings) != 1 || warnings.Warnings[0].ReasonCode != "scene_boundary_caveat" {
|
||||
t.Fatalf("warnings = %#v, want one scene boundary warning", warnings.Warnings)
|
||||
}
|
||||
if len(fake.requestsFor(scenes.PromptID)) != 1 || len(fake.requestsFor(spells.PromptID)) != 1 {
|
||||
t.Fatalf("fake prompt requests = %#v, want one scene and one spell request", fake.requestPrompts())
|
||||
}
|
||||
}
|
||||
|
||||
type maintainedExample struct {
|
||||
name string
|
||||
path string
|
||||
}
|
||||
|
||||
func maintainedExampleFiles(t *testing.T) []maintainedExample {
|
||||
t.Helper()
|
||||
return []maintainedExample{
|
||||
{name: "minimal", path: repositoryPath("examples", "dnd-spells.config.yml")},
|
||||
{name: "production", path: repositoryPath("examples", "dnd-spells-production.config.yml")},
|
||||
}
|
||||
}
|
||||
|
||||
func loadMaintainedExample(t *testing.T, path string) config.Config {
|
||||
t.Helper()
|
||||
fileConfig, err := config.LoadFileConfig(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load maintained config %q: %v", path, err)
|
||||
}
|
||||
cfg := config.Default()
|
||||
if err := cfg.ApplyFileConfig(fileConfig); err != nil {
|
||||
t.Fatalf("apply maintained config %q: %v", path, err)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("validate maintained config %q: %v", path, err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func productionTestComponents(t *testing.T) productionComponents {
|
||||
t.Helper()
|
||||
components, err := newProductionComponents()
|
||||
if err != nil {
|
||||
t.Fatalf("new production components: %v", err)
|
||||
}
|
||||
return components
|
||||
}
|
||||
|
||||
func productionCLIOptions(t *testing.T) Options {
|
||||
t.Helper()
|
||||
components := productionTestComponents(t)
|
||||
return productionOptionsFromComponents(components)
|
||||
}
|
||||
|
||||
func productionOptionsFromComponents(components productionComponents) Options {
|
||||
return Options{
|
||||
Catalog: catalogFromRegistries(components.registries),
|
||||
Registries: components.registries,
|
||||
LookupEnv: emptyLookup,
|
||||
}
|
||||
}
|
||||
|
||||
func productionCLIOptionsWithoutSpellNormalizer(t *testing.T) Options {
|
||||
t.Helper()
|
||||
components := productionTestComponents(t)
|
||||
registries := components.registries
|
||||
registries.Normalizers = pipeline.NewNormalizerRegistry()
|
||||
if err := noop.RegisterTyped[dnd.SpellList](registries.Normalizers, contracts.ArtifactKind("test/other")); err != nil {
|
||||
t.Fatalf("register mismatched normalizer: %v", err)
|
||||
}
|
||||
return productionOptionsFromComponents(productionComponents{registries: registries, assets: components.assets})
|
||||
}
|
||||
|
||||
const productionRunID = "run-1700000000000000000-0123456789abcdef0123456789abcdef"
|
||||
|
||||
func productionRunOptions(t *testing.T, fake *productionFakeLLMClient) Options {
|
||||
t.Helper()
|
||||
options := productionCLIOptions(t)
|
||||
options.Now = func() time.Time { return time.Unix(1700000000, 0).UTC() }
|
||||
options.RunIDGenerator = func(time.Time) (string, error) { return productionRunID, nil }
|
||||
options.UserCacheDir = func() (string, error) { return "", errors.New("user cache must not be used") }
|
||||
options.LLMClientFactory = func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
return fake, nil, nil
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func productionRunConfig(outputRoot, chunkModule string) string {
|
||||
return fmt.Sprintf(`version: 3
|
||||
output:
|
||||
directory: %q
|
||||
cache:
|
||||
chunk_plans:
|
||||
mode: bypass
|
||||
checkpoints: {}
|
||||
debug:
|
||||
directory: %q
|
||||
pipelines:
|
||||
dnd-session:
|
||||
input: seriatim
|
||||
chunk: %s
|
||||
artifacts:
|
||||
spells:
|
||||
extract: dnd/spells
|
||||
`, outputRoot, filepath.Join(filepath.Dir(outputRoot), "debug"), chunkModule)
|
||||
}
|
||||
|
||||
func writeProductionContractConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func productionAssetNames(t *testing.T, getFS func() (fs.FS, error)) []string {
|
||||
t.Helper()
|
||||
fileSystem, err := getFS()
|
||||
if err != nil {
|
||||
t.Fatalf("load production prompt assets: %v", err)
|
||||
}
|
||||
var names []string
|
||||
if err := fs.WalkDir(fileSystem, ".", func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !entry.IsDir() {
|
||||
names = append(names, path)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("walk production prompt assets: %v", err)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
func assertProductionKeys(t *testing.T, name string, got, want []string) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("%s = %#v, want %#v", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func readProductionJSON[T any](t *testing.T, path string) T {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var value T
|
||||
if err := json.Unmarshal(data, &value); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type productionFakeLLMClient struct {
|
||||
mu sync.Mutex
|
||||
requests []contracts.StructuredCompletionRequest
|
||||
}
|
||||
|
||||
func (client *productionFakeLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, err
|
||||
}
|
||||
var content []byte
|
||||
switch req.PromptID {
|
||||
case scenes.PromptID:
|
||||
content = []byte(`{"scenes":[{"start_unit_id":1,"end_unit_id":2,"short_title":"Opening scene","primary_mode":"Narrative","main_participants":["Aria"],"summary":"The session opens.","boundary_note":"The opening covers the available transcript.","boundary_confidence":"High"}],"boundary_caveats":["The opening boundary is inferred from the short transcript."]}`)
|
||||
case spells.PromptID:
|
||||
content = []byte(`{"spell_casts":[{"caster":"Aria","spell":"Cure Wounds","effect":"Heals an injured ally.","narrative_description":"Aria restores the fighter after the fight.","source_refs":[{"source_id":"session-alpha","start_unit_id":1,"end_unit_id":1}]}]}`)
|
||||
default:
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", req.PromptID)
|
||||
}
|
||||
if err := json.Unmarshal(content, out); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate fake structured target: %w", err)
|
||||
}
|
||||
client.mu.Lock()
|
||||
client.requests = append(client.requests, req)
|
||||
client.mu.Unlock()
|
||||
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "deterministic", ProfileID: req.ProfileID}, nil
|
||||
}
|
||||
|
||||
func (client *productionFakeLLMClient) requestsFor(promptID string) []contracts.StructuredCompletionRequest {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
var requests []contracts.StructuredCompletionRequest
|
||||
for _, req := range client.requests {
|
||||
if req.PromptID == promptID {
|
||||
requests = append(requests, req)
|
||||
}
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
func (client *productionFakeLLMClient) requestPrompts() []string {
|
||||
client.mu.Lock()
|
||||
defer client.mu.Unlock()
|
||||
prompts := make([]string, 0, len(client.requests))
|
||||
for _, req := range client.requests {
|
||||
prompts = append(prompts, req.PromptID)
|
||||
}
|
||||
return prompts
|
||||
}
|
||||
|
||||
func repositoryPath(parts ...string) string {
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
return filepath.Join(append([]string{filepath.Dir(file), "..", ".."}, parts...)...)
|
||||
}
|
||||
|
||||
func readRepositoryFile(t *testing.T, parts ...string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(repositoryPath(parts...))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
454
internal/cli/reference_contract_test.go
Normal file
454
internal/cli/reference_contract_test.go
Normal file
@@ -0,0 +1,454 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestReferenceSelectorsParseAndApplyAllDocumentedForms(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
selector string
|
||||
only []string
|
||||
wantStage pipeline.ModuleStage
|
||||
wantLane string
|
||||
wantSlot string
|
||||
}{
|
||||
{name: "flat", selector: "alpha-slot", wantStage: pipeline.StageExtract, wantLane: "alpha", wantSlot: "alpha-slot"},
|
||||
{name: "chunk", selector: "chunk.chunk-slot", wantStage: pipeline.StageChunk, wantSlot: "chunk-slot"},
|
||||
{name: "merge", selector: "merge.alpha-merge", only: []string{"alpha"}, wantStage: pipeline.StageMerge, wantLane: "alpha", wantSlot: "alpha-merge"},
|
||||
{name: "lane", selector: "alpha.alpha-slot", wantStage: pipeline.StageExtract, wantLane: "alpha", wantSlot: "alpha-slot"},
|
||||
{name: "lane extract", selector: "alpha.extract.alpha-slot", wantStage: pipeline.StageExtract, wantLane: "alpha", wantSlot: "alpha-slot"},
|
||||
{name: "lane merge", selector: "alpha.merge.alpha-merge", wantStage: pipeline.StageMerge, wantLane: "alpha", wantSlot: "alpha-merge"},
|
||||
{name: "lane normalize", selector: "alpha.normalize.alpha-normalize", wantStage: pipeline.StageNormalize, wantLane: "alpha", wantSlot: "alpha-normalize"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := referenceContractConfig()
|
||||
catalog := referenceContractCatalog(t, true, true)
|
||||
selector, err := parseReferenceSelector(tt.selector, "--reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overrides, _, err := resolveCLIReferenceRequests(cfg, "demo", tt.only, catalog, []cliReferenceRequest{{Selector: selector, Source: "reference.txt"}}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve selector: %v", err)
|
||||
}
|
||||
if len(overrides) != 1 {
|
||||
t.Fatalf("overrides = %#v, want one binding", overrides)
|
||||
}
|
||||
got := overrides[0]
|
||||
if got.Stage != tt.wantStage || got.LaneID != tt.wantLane || got.SlotName != tt.wantSlot || got.BindingSource != contracts.ReferenceBindingSourceCLI {
|
||||
t.Fatalf("binding = %#v, want %s/%s/%s from CLI", got, tt.wantStage, tt.wantLane, tt.wantSlot)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceSelectorsRejectAmbiguityWithSpecificSuggestions(t *testing.T) {
|
||||
cfg := referenceContractConfig()
|
||||
catalog := referenceContractCatalog(t, true, true)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
selector string
|
||||
want []string
|
||||
}{
|
||||
{name: "flat shared slot", selector: "shared", want: []string{"alpha.extract.shared", "beta.extract.shared"}},
|
||||
{name: "lane shared slot", selector: "alpha.shared", want: []string{"alpha.extract.shared", "alpha.merge.shared", "alpha.normalize.shared"}},
|
||||
{name: "all mergers", selector: "merge.shared", want: []string{"alpha.merge.shared", "beta.merge.shared"}},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selector, err := parseReferenceSelector(tt.selector, "--reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = resolveCLIReferenceRequests(cfg, "demo", nil, catalog, []cliReferenceRequest{{Selector: selector, Source: "reference.txt"}}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("resolve selector succeeded, want ambiguity error")
|
||||
}
|
||||
for _, fragment := range tt.want {
|
||||
if !strings.Contains(err.Error(), fragment) {
|
||||
t.Fatalf("error = %q, want suggestion %q", err, fragment)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceSelectorsRespectSelectedLanesBeforeMaterialization(t *testing.T) {
|
||||
cfg := referenceContractConfig()
|
||||
catalog := referenceContractCatalog(t, true, true)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
selector string
|
||||
want string
|
||||
}{
|
||||
{name: "unselected lane", selector: "beta.extract.beta-slot", want: `reference lane "beta" is not selected`},
|
||||
{name: "unknown lane", selector: "missing.extract.beta-slot", want: `reference lane "missing" is not selected`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selector, err := parseReferenceSelector(tt.selector, "--reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = resolveCLIReferenceRequests(cfg, "demo", []string{"alpha"}, catalog, []cliReferenceRequest{{Selector: selector, Source: filepath.Join(t.TempDir(), "missing.txt")}}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) || strings.Contains(err.Error(), "missing.txt") {
|
||||
t.Fatalf("error = %v, want selection failure before file access", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceSyntaxErrorsReturnTwo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "reference missing value", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference"}},
|
||||
{name: "reference missing selector", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "=path.txt"}},
|
||||
{name: "reference missing separator", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "slot"}},
|
||||
{name: "reference missing path", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "slot="}},
|
||||
{name: "reference excess segments", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--reference", "a.b.c.d=path.txt"}},
|
||||
{name: "unbind with path", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--without-reference", "slot=path.txt"}},
|
||||
{name: "unbind excess segments", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--without-reference", "a.b.c.d"}},
|
||||
{name: "unbind missing value", args: []string{"run", "demo", "--config", "missing.yml", "--input", "input.txt", "--without-reference"}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions(tt.args, &stdout, &stderr, Options{LookupEnv: emptyLookup})
|
||||
if code != 2 || stdout.Len() != 0 || stderr.Len() == 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceOverridesUseFinalExactTargetBinding(t *testing.T) {
|
||||
cfg := referenceContractConfig()
|
||||
catalog := referenceContractCatalog(t, true, true)
|
||||
alphaShared, err := parseReferenceSelector("alpha.extract.shared", "--reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
betaShared, err := parseReferenceSelector("beta.extract.shared", "--reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overrides, unbinds, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, []cliReferenceRequest{
|
||||
{Selector: alphaShared, Source: "alpha-first.txt"},
|
||||
{Selector: alphaShared, Source: "alpha-final.txt"},
|
||||
{Selector: betaShared, Source: "beta-only.txt"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(unbinds) != 0 {
|
||||
t.Fatalf("unbinds = %#v, want none", unbinds)
|
||||
}
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceOverrides: overrides})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve pipeline: %v", err)
|
||||
}
|
||||
alpha := referenceContractLane(t, effective.ResolvedPipeline, "alpha")
|
||||
beta := referenceContractLane(t, effective.ResolvedPipeline, "beta")
|
||||
if source := referenceContractBindingSource(alpha.ExtractReferences.Bindings, "shared"); source != "alpha-final.txt" {
|
||||
t.Fatalf("alpha shared source = %q, want final exact-target override", source)
|
||||
}
|
||||
if source := referenceContractBindingSource(beta.ExtractReferences.Bindings, "shared"); source != "beta-only.txt" {
|
||||
t.Fatalf("beta shared source = %q, want target-specific override", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceUnbindsRemoveOptionalAndProtectRequiredSlots(t *testing.T) {
|
||||
cfg := referenceContractConfig()
|
||||
catalog := referenceContractCatalog(t, true, true)
|
||||
optional, err := parseReferenceSelector("alpha.extract.alpha-slot", "--without-reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, without, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, nil, []cliReferenceUnbindRequest{{Selector: optional}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceUnbinds: without})
|
||||
if err != nil {
|
||||
t.Fatalf("optional unbind: %v", err)
|
||||
}
|
||||
if binding := referenceContractFindBinding(referenceContractLane(t, effective.ResolvedPipeline, "alpha").ExtractReferences.Bindings, "alpha-slot"); binding != nil {
|
||||
t.Fatalf("optional binding after unbind = %#v, want absent", binding)
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
selector string
|
||||
}{
|
||||
{name: "chunk", selector: "chunk.required-chunk"},
|
||||
{name: "extract", selector: "alpha.extract.required-extract"},
|
||||
{name: "merge", selector: "alpha.merge.required-merge"},
|
||||
{name: "normalize", selector: "alpha.normalize.required-normalize"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selector, err := parseReferenceSelector(tt.selector, "--without-reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, unbinds, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, nil, []cliReferenceUnbindRequest{{Selector: selector}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceUnbinds: unbinds})
|
||||
if err == nil || !strings.Contains(err.Error(), "required reference slot") {
|
||||
t.Fatalf("resolve error = %v, want required-slot failure", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceMaterializationSeparatesCLIAndConfigPathOrigins(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
workingDir := t.TempDir()
|
||||
cfg := referenceContractConfig()
|
||||
configPath := filepath.Join(configDir, "config.yml")
|
||||
if err := os.WriteFile(configPath, []byte("version: 3\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "required.txt"), []byte("config reference"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "optional.txt"), []byte("optional reference"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workingDir, "cli-reference.txt"), []byte("CLI reference"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog := referenceContractCatalog(t, true, true)
|
||||
selector, err := parseReferenceSelector("alpha.extract.alpha-slot", "--reference")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
overrides, unbinds, err := resolveCLIReferenceRequests(cfg, "demo", nil, catalog, []cliReferenceRequest{{Selector: selector, Source: "cli-reference.txt"}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
effective, err := cfg.Resolve(config.ResolveInput{PipelineID: "demo", Catalog: catalog, ReferenceOverrides: overrides, ReferenceUnbinds: unbinds})
|
||||
if err != nil {
|
||||
t.Fatalf("resolve pipeline: %v", err)
|
||||
}
|
||||
materialized, _, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{ConfigPath: configPath, WorkingDir: workingDir})
|
||||
if err != nil {
|
||||
t.Fatalf("materialize references: %v", err)
|
||||
}
|
||||
alpha := referenceContractLane(t, materialized, "alpha")
|
||||
cliItem := alpha.ExtractReferences.ReferenceSet.Slots["alpha-slot"].Items[0]
|
||||
if string(cliItem.Content) != "CLI reference" || cliItem.BindingSource != contracts.ReferenceBindingSourceCLI || cliItem.Origin.URI != referenceContractFileURI(filepath.Join(workingDir, "cli-reference.txt")) {
|
||||
t.Fatalf("CLI materialization = %#v, want working-directory provenance", cliItem)
|
||||
}
|
||||
configItem := alpha.ExtractReferences.ReferenceSet.Slots["required-extract"].Items[0]
|
||||
if string(configItem.Content) != "config reference" || configItem.BindingSource != contracts.ReferenceBindingSourceConfig || configItem.Origin.URI != referenceContractFileURI(filepath.Join(configDir, "required.txt")) {
|
||||
t.Fatalf("config materialization = %#v, want config-directory provenance", configItem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceTargetLookupUsesArtifactVariantsAndReportsMissingContext(t *testing.T) {
|
||||
cfg := referenceContractConfig()
|
||||
full := referenceContractCatalog(t, true, true)
|
||||
targets, err := selectedReferenceTargets(cfg, "demo", nil, full)
|
||||
if err != nil {
|
||||
t.Fatalf("select reference targets: %v", err)
|
||||
}
|
||||
var alphaMerge, betaMerge selectedReferenceTarget
|
||||
for _, target := range targets {
|
||||
if target.stage == pipeline.StageMerge && target.laneID == "alpha" {
|
||||
alphaMerge = target
|
||||
}
|
||||
if target.stage == pipeline.StageMerge && target.laneID == "beta" {
|
||||
betaMerge = target
|
||||
}
|
||||
}
|
||||
if _, ok := alphaMerge.slots["alpha-merge"]; !ok {
|
||||
t.Fatalf("alpha merger slots = %#v, want alpha artifact variant", alphaMerge.slots)
|
||||
}
|
||||
if _, ok := betaMerge.slots["beta-merge"]; !ok {
|
||||
t.Fatalf("beta merger slots = %#v, want beta artifact variant", betaMerge.slots)
|
||||
}
|
||||
if _, ok := betaMerge.slots["alpha-merge"]; ok {
|
||||
t.Fatalf("beta merger slots = %#v, must not use alpha variant", betaMerge.slots)
|
||||
}
|
||||
|
||||
missingMerger := referenceContractCatalog(t, false, true)
|
||||
_, err = selectedReferenceTargets(cfg, "demo", nil, missingMerger)
|
||||
if err == nil || !strings.Contains(err.Error(), "merger") || !strings.Contains(err.Error(), string(referenceContractKindBeta)) {
|
||||
t.Fatalf("missing merger error = %v, want artifact variant context", err)
|
||||
}
|
||||
missingNormalizer := referenceContractCatalog(t, true, false)
|
||||
_, err = selectedReferenceTargets(cfg, "demo", nil, missingNormalizer)
|
||||
if err == nil || !strings.Contains(err.Error(), "normalizer") || !strings.Contains(err.Error(), string(referenceContractKindBeta)) {
|
||||
t.Fatalf("missing normalizer error = %v, want artifact variant context", err)
|
||||
}
|
||||
missingExtractor := referenceContractCatalog(t, true, true)
|
||||
missingExtractor.Extractors = pipeline.NewExtractorRegistry()
|
||||
_, err = selectedReferenceTargets(cfg, "demo", nil, missingExtractor)
|
||||
if err == nil || !strings.Contains(err.Error(), `lane "alpha" extract module`) || !strings.Contains(err.Error(), "not registered") {
|
||||
t.Fatalf("missing extractor error = %v, want lane/module context", err)
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
referenceContractKindAlpha contracts.ArtifactKind = "reference/alpha"
|
||||
referenceContractKindBeta contracts.ArtifactKind = "reference/beta"
|
||||
)
|
||||
|
||||
func referenceContractConfig() config.Config {
|
||||
cfg := config.Default()
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{
|
||||
"demo": {
|
||||
ID: "demo",
|
||||
Input: pipeline.Binding("reference/input"),
|
||||
Chunk: pipeline.Binding("reference/chunk"),
|
||||
Output: pipeline.Binding("reference/output"),
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"alpha": {
|
||||
Extract: pipeline.Binding("reference/extract-alpha"),
|
||||
Merge: pipeline.Binding("reference/shared-merge"),
|
||||
Normalize: pipeline.Binding("reference/shared-normalize"),
|
||||
References: map[string]string{"required-extract": "required.txt"},
|
||||
},
|
||||
"beta": {
|
||||
Extract: pipeline.Binding("reference/extract-beta"),
|
||||
Merge: pipeline.Binding("reference/shared-merge"),
|
||||
Normalize: pipeline.Binding("reference/shared-normalize"),
|
||||
References: map[string]string{"required-extract": "required.txt"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
profile := cfg.Pipelines["demo"]
|
||||
profile.Chunk.References = map[string]string{"required-chunk": "required.txt"}
|
||||
alpha := profile.Artifacts["alpha"]
|
||||
alpha.Extract.References = map[string]string{"required-extract": "required.txt", "alpha-slot": "optional.txt"}
|
||||
alpha.Merge.References = map[string]string{"required-merge": "required.txt"}
|
||||
alpha.Normalize.References = map[string]string{"required-normalize": "required.txt"}
|
||||
profile.Artifacts["alpha"] = alpha
|
||||
beta := profile.Artifacts["beta"]
|
||||
beta.Extract.References = map[string]string{"required-extract": "required.txt"}
|
||||
beta.Merge.References = map[string]string{"required-merge": "required.txt"}
|
||||
beta.Normalize.References = map[string]string{"required-normalize": "required.txt"}
|
||||
profile.Artifacts["beta"] = beta
|
||||
cfg.Pipelines["demo"] = profile
|
||||
return cfg
|
||||
}
|
||||
|
||||
func referenceContractCatalog(t *testing.T, includeBetaMerger, includeBetaNormalizer bool) pipeline.ModuleCatalog {
|
||||
t.Helper()
|
||||
registries := pipeline.Registries{
|
||||
Inputs: pipeline.NewInputAdapterRegistry(),
|
||||
Chunkers: pipeline.NewChunkerRegistry(),
|
||||
ArtifactCodecs: pipeline.NewArtifactCodecRegistry(),
|
||||
Extractors: pipeline.NewExtractorRegistry(),
|
||||
Mergers: pipeline.NewMergerRegistry(),
|
||||
Normalizers: pipeline.NewNormalizerRegistry(),
|
||||
Validators: pipeline.NewValidatorRegistry(),
|
||||
ValidatorChains: pipeline.NewValidatorChainRegistry(),
|
||||
Outputs: pipeline.NewOutputEncoderRegistry(),
|
||||
}
|
||||
register := func(err error) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
register(registries.Inputs.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "reference/input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.InputAdapter, error) { return stateTestInput{}, nil }))
|
||||
register(registries.Chunkers.RegisterWithSpec(pipeline.ModuleSpec{Key: "reference/chunk", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "chunk-slot"}, {Name: "required-chunk", Required: true}}}, func() (contracts.Chunker, error) { return stateTestChunker{}, nil }))
|
||||
register(pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, referenceContractCodecA{}))
|
||||
register(pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, referenceContractCodecB{}))
|
||||
register(pipeline.RegisterExtractor(registries.Extractors, pipeline.ModuleSpec{Key: "reference/extract-alpha", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: referenceContractKindAlpha, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "alpha-slot"}, {Name: "required-extract", Required: true}}}, func() (contracts.Extractor[stateTestArtifact], error) { return stateTestExtractor{}, nil }))
|
||||
register(pipeline.RegisterExtractor(registries.Extractors, pipeline.ModuleSpec{Key: "reference/extract-beta", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: referenceContractKindBeta, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "beta-slot"}, {Name: "required-extract", Required: true}}}, func() (contracts.Extractor[stateTestArtifact], error) { return stateTestExtractor{}, nil }))
|
||||
register(pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "reference/shared-merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: referenceContractKindAlpha, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "alpha-merge"}, {Name: "required-merge", Required: true}}}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{}, nil }))
|
||||
if includeBetaMerger {
|
||||
register(pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "reference/shared-merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: referenceContractKindBeta, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "beta-merge"}, {Name: "required-merge", Required: true}}}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{}, nil }))
|
||||
}
|
||||
register(pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "reference/shared-normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: referenceContractKindAlpha, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "alpha-normalize"}, {Name: "required-normalize", Required: true}}}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{}, nil }))
|
||||
if includeBetaNormalizer {
|
||||
register(pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "reference/shared-normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: referenceContractKindBeta, ReferenceSlots: []contracts.ReferenceSlot{{Name: "shared"}, {Name: "beta-normalize"}, {Name: "required-normalize", Required: true}}}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{}, nil }))
|
||||
}
|
||||
register(registries.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "reference/output", Stage: pipeline.StageOutput, Requires: []string{"normalized"}, Provides: []string{"output"}}, func() (contracts.OutputEncoder, error) { return stateTestOutput{}, nil }))
|
||||
return catalogFromRegistries(registries)
|
||||
}
|
||||
|
||||
type referenceContractCodecB struct{}
|
||||
|
||||
type referenceContractCodecA struct{}
|
||||
|
||||
func (referenceContractCodecA) Kind() contracts.ArtifactKind { return referenceContractKindAlpha }
|
||||
func (referenceContractCodecA) Schema() contracts.ArtifactSchema {
|
||||
return contracts.ArtifactSchema{ID: "reference.alpha", Name: "reference_alpha", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)}
|
||||
}
|
||||
func (referenceContractCodecA) MediaType() string { return "application/json" }
|
||||
func (referenceContractCodecA) EncodeCandidate(stateTestArtifact) ([]byte, error) {
|
||||
return []byte(`{"value":"ok"}`), nil
|
||||
}
|
||||
func (referenceContractCodecA) Encode(stateTestArtifact) ([]byte, error) {
|
||||
return []byte(`{"value":"ok"}`), nil
|
||||
}
|
||||
func (referenceContractCodecA) Decode([]byte) (stateTestArtifact, error) {
|
||||
return stateTestArtifact{Value: "ok"}, nil
|
||||
}
|
||||
|
||||
func (referenceContractCodecB) Kind() contracts.ArtifactKind { return referenceContractKindBeta }
|
||||
func (referenceContractCodecB) Schema() contracts.ArtifactSchema {
|
||||
return contracts.ArtifactSchema{ID: "reference.beta", Name: "reference_beta", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)}
|
||||
}
|
||||
func (referenceContractCodecB) MediaType() string { return "application/json" }
|
||||
func (referenceContractCodecB) EncodeCandidate(stateTestArtifact) ([]byte, error) {
|
||||
return []byte(`{"value":"ok"}`), nil
|
||||
}
|
||||
func (referenceContractCodecB) Encode(stateTestArtifact) ([]byte, error) {
|
||||
return []byte(`{"value":"ok"}`), nil
|
||||
}
|
||||
func (referenceContractCodecB) Decode([]byte) (stateTestArtifact, error) {
|
||||
return stateTestArtifact{Value: "ok"}, nil
|
||||
}
|
||||
|
||||
func referenceContractLane(t *testing.T, resolved pipeline.ResolvedPipeline, id string) pipeline.ResolvedArtifactLane {
|
||||
t.Helper()
|
||||
for _, lane := range resolved.ArtifactLanes {
|
||||
if lane.ID == id {
|
||||
return lane
|
||||
}
|
||||
}
|
||||
t.Fatalf("lane %q not found", id)
|
||||
return pipeline.ResolvedArtifactLane{}
|
||||
}
|
||||
|
||||
func referenceContractBindingSource(bindings []pipeline.ReferenceBinding, slot string) string {
|
||||
for _, binding := range bindings {
|
||||
if binding.SlotName == slot {
|
||||
return binding.Source
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func referenceContractFindBinding(bindings []pipeline.ReferenceBinding, slot string) *pipeline.ReferenceBinding {
|
||||
for i := range bindings {
|
||||
if bindings[i].SlotName == slot {
|
||||
return &bindings[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func referenceContractFileURI(path string) string {
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
absolute = path
|
||||
}
|
||||
return "file://" + filepath.ToSlash(absolute)
|
||||
}
|
||||
444
internal/cli/run_contract_test.go
Normal file
444
internal/cli/run_contract_test.go
Normal file
@@ -0,0 +1,444 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestRunControlsRejectSyntaxWithoutAllocatingState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args func(stateTestRoots) []string
|
||||
}{
|
||||
{name: "missing pipeline", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "--config", roots.config, "--input", roots.input}
|
||||
}},
|
||||
{name: "missing input", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config}
|
||||
}},
|
||||
{name: "unknown flag", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--unknown"}
|
||||
}},
|
||||
{name: "blank output directory", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--output-dir", ""}
|
||||
}},
|
||||
{name: "blank debug directory", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--debug-dir", ""}
|
||||
}},
|
||||
{name: "debug directory without debug", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--debug-dir", filepath.Join(filepath.Dir(roots.debug), "requested-debug")}
|
||||
}},
|
||||
{name: "blank session ID", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--session-id", ""}
|
||||
}},
|
||||
{name: "multiple pipeline IDs", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "extra", "--config", roots.config, "--input", roots.input}
|
||||
}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions(tt.args(roots), &stdout, &stderr, newStateTestHarness().options())
|
||||
if code != 2 || stdout.Len() != 0 || stderr.Len() == 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
assertAbsent(t, roots.output)
|
||||
assertAbsent(t, roots.debug)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunValidFailuresClassifyAndReportDebug(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args func(stateTestRoots) []string
|
||||
wantError string
|
||||
wantDebug bool
|
||||
}{
|
||||
{name: "unknown pipeline", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "missing", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}
|
||||
}, wantError: `pipeline "missing"`},
|
||||
{name: "unknown lane", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--only", "missing", "--chunk_cache", "bypass", "--debug"}
|
||||
}, wantError: `lane "missing"`, wantDebug: true},
|
||||
{name: "unreadable input", args: func(roots stateTestRoots) []string {
|
||||
return []string{"run", "sample", "--config", roots.config, "--input", filepath.Join(filepath.Dir(roots.input), "unreadable.txt"), "--chunk_cache", "bypass", "--debug"}
|
||||
}, wantError: "read input", wantDebug: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions(tt.args(roots), &stdout, &stderr, newStateTestHarness().options())
|
||||
if code != 1 || stdout.Len() != 0 || !strings.Contains(stderr.String(), tt.wantError) {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if tt.wantDebug {
|
||||
if !strings.Contains(stderr.String(), "debug=") {
|
||||
t.Fatalf("stderr=%q, want debug path", stderr.String())
|
||||
}
|
||||
onlyChildDir(t, roots.debug)
|
||||
} else {
|
||||
assertAbsent(t, roots.debug)
|
||||
}
|
||||
assertAbsent(t, roots.output)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunOnlyExecutesSelectedLanes(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
data, err := os.ReadFile(roots.config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = bytes.Replace(data, []byte(" output: test/output\n"), []byte(" other:\n extract: test/extract\n output: test/output\n"), 1)
|
||||
if err := os.WriteFile(roots.config, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
harness := newStateTestHarness()
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--only", "items", "--chunk_cache", "bypass"}, &stdout, &stderr, harness.options())
|
||||
if code != 0 || !strings.Contains(stdout.String(), "outputs=1") || stderr.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
harness.mu.Lock()
|
||||
extractCalls := harness.extractCalls
|
||||
harness.mu.Unlock()
|
||||
if extractCalls != 1 {
|
||||
t.Fatalf("extract calls = %d, want only the selected lane", extractCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStateRootsHonorEnvironmentFlagsAndDefaults(t *testing.T) {
|
||||
t.Run("environment roots", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
environmentOutput := filepath.Join(t.TempDir(), "environment-output")
|
||||
environmentDebug := filepath.Join(t.TempDir(), "environment-debug")
|
||||
opts := newStateTestHarness().options()
|
||||
opts.LookupEnv = lookupRunContractEnv(map[string]string{
|
||||
"NOTARIUS_OUTPUT_DIR": environmentOutput,
|
||||
"NOTARIUS_DEBUG_DIR": environmentDebug,
|
||||
})
|
||||
result := runWithStateRoots(t, roots, opts, nil)
|
||||
if result.code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
|
||||
}
|
||||
assertFile(t, filepath.Join(environmentOutput, filepath.Base(onlyChildDir(t, environmentOutput)), "result.json"))
|
||||
onlyChildDir(t, environmentDebug)
|
||||
assertAbsent(t, roots.output)
|
||||
assertAbsent(t, roots.debug)
|
||||
})
|
||||
|
||||
t.Run("command flags override environment", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
environmentOutput := filepath.Join(t.TempDir(), "environment-output")
|
||||
environmentDebug := filepath.Join(t.TempDir(), "environment-debug")
|
||||
flagOutput := filepath.Join(t.TempDir(), "flag-output")
|
||||
flagDebug := filepath.Join(t.TempDir(), "flag-debug")
|
||||
opts := newStateTestHarness().options()
|
||||
opts.LookupEnv = lookupRunContractEnv(map[string]string{
|
||||
"NOTARIUS_OUTPUT_DIR": environmentOutput,
|
||||
"NOTARIUS_DEBUG_DIR": environmentDebug,
|
||||
})
|
||||
result := runWithStateRoots(t, roots, opts, []string{"--output-dir", flagOutput, "--debug-dir", flagDebug})
|
||||
if result.code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
|
||||
}
|
||||
assertFile(t, filepath.Join(flagOutput, filepath.Base(onlyChildDir(t, flagOutput)), "result.json"))
|
||||
onlyChildDir(t, flagDebug)
|
||||
assertAbsent(t, environmentOutput)
|
||||
assertAbsent(t, environmentDebug)
|
||||
})
|
||||
|
||||
t.Run("built-in roots", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
data, err := os.ReadFile(roots.config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
text = strings.Replace(text, fmt.Sprintf(" directory: %q\n", roots.output), "", 1)
|
||||
text = strings.Replace(text, fmt.Sprintf(" directory: %q\n", roots.debug), "", 1)
|
||||
if err := os.WriteFile(roots.config, []byte(text), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workDir := t.TempDir()
|
||||
t.Chdir(workDir)
|
||||
opts := newStateTestHarness().options()
|
||||
result := runWithStateRoots(t, roots, opts, nil)
|
||||
if result.code != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", result.code, result.stdout, result.stderr)
|
||||
}
|
||||
assertFile(t, filepath.Join(workDir, "notarius-output", filepath.Base(onlyChildDir(t, filepath.Join(workDir, "notarius-output"))), "result.json"))
|
||||
onlyChildDir(t, filepath.Join(workDir, "notarius-debug"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunLLMProfileOverrideAndValidationUseInjectedBoundaries(t *testing.T) {
|
||||
t.Run("one effective profile reaches the factory and modules", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
profileDir := writeRunContractProfiles(t, "override-profile")
|
||||
prependRunContractConfig(t, roots, fmt.Sprintf("scriptorium:\n profile_dir: %q\n", profileDir))
|
||||
harness := newStateTestHarness()
|
||||
var factoryProfiles []string
|
||||
opts := harness.options()
|
||||
opts.LLMClientFactory = func(_ context.Context, _ config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
factoryProfiles = append(factoryProfiles, profileID)
|
||||
return nil, nil, nil
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", "override-profile"}, &stdout, &stderr, opts)
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if len(factoryProfiles) != 1 || factoryProfiles[0] != "override-profile" {
|
||||
t.Fatalf("factory profiles = %#v, want one override profile", factoryProfiles)
|
||||
}
|
||||
harness.mu.Lock()
|
||||
profiles := append([]string(nil), harness.moduleProfiles...)
|
||||
harness.mu.Unlock()
|
||||
if len(profiles) < 4 {
|
||||
t.Fatalf("module profiles = %#v, want chunk and lane stage requests", profiles)
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
if profile != "override-profile" {
|
||||
t.Fatalf("module profiles = %#v, want override on every request", profiles)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validator profile remains distinct", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
profileDir := writeRunContractProfiles(t, "override-profile", "validator-profile")
|
||||
prependRunContractConfig(t, roots, fmt.Sprintf("scriptorium:\n profile_dir: %q\n", profileDir))
|
||||
harness := newStateTestHarness()
|
||||
var validatorProfiles []string
|
||||
opts := harness.options()
|
||||
registerRunContractValidator(t, &opts, &validatorProfiles)
|
||||
factoryProfiles := []string{}
|
||||
opts.LLMClientFactory = func(_ context.Context, _ config.Config, profileID string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
factoryProfiles = append(factoryProfiles, profileID)
|
||||
return nil, nil, nil
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", "override-profile"}, &stdout, &stderr, opts)
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
if len(factoryProfiles) != 1 || factoryProfiles[0] != "" {
|
||||
t.Fatalf("factory profiles = %#v, want one call without a unique profile", factoryProfiles)
|
||||
}
|
||||
if len(validatorProfiles) != 1 || validatorProfiles[0] != "validator-profile" {
|
||||
t.Fatalf("validator profiles = %#v, want configured validator profile", validatorProfiles)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown profile is rejected without factory access", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
profileDir := writeRunContractProfiles(t, "override-profile")
|
||||
prependRunContractConfig(t, roots, fmt.Sprintf("scriptorium:\n profile_dir: %q\n", profileDir))
|
||||
factoryCalls := 0
|
||||
opts := newStateTestHarness().options()
|
||||
opts.LLMClientFactory = func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
factoryCalls++
|
||||
return nil, nil, nil
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", "missing-profile"}, &stdout, &stderr, opts)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "not configured") || factoryCalls != 0 || stdout.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q factoryCalls=%d", code, stdout.String(), stderr.String(), factoryCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEffectiveLLMProfileIDsAreSortedDeduplicatedAndLLMOnly(t *testing.T) {
|
||||
resolved := pipeline.ResolvedPipeline{
|
||||
Chunk: pipeline.ModuleBinding{LLMProfile: " zeta "},
|
||||
ArtifactLanes: []pipeline.ResolvedArtifactLane{
|
||||
{Extract: pipeline.ModuleBinding{LLMProfile: "alpha"}, Merge: pipeline.ModuleBinding{LLMProfile: "zeta"}},
|
||||
},
|
||||
ValidatorChains: []pipeline.ResolvedValidatorChain{{Validators: []pipeline.ResolvedValidator{
|
||||
{Binding: pipeline.ModuleBinding{LLMProfile: "deterministic-profile"}, ExecutionClass: contracts.ExecutionClassDeterministic},
|
||||
{Binding: pipeline.ModuleBinding{LLMProfile: "beta"}, ExecutionClass: contracts.ExecutionClassLLMBacked},
|
||||
}}},
|
||||
}
|
||||
got := effectiveLLMProfileIDs(resolved)
|
||||
want := []string{"alpha", "beta", "zeta"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("effective profiles = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSessionIDUsesExplicitValueOrSourceDocumentID(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "source document", want: "source"},
|
||||
{name: "explicit trimmed value", args: []string{"--session-id", " explicit-session "}, want: "explicit-session"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
harness := newStateTestHarness()
|
||||
args := append([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, tt.args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions(args, &stdout, &stderr, harness.options())
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
harness.mu.Lock()
|
||||
sessions := append([]string(nil), harness.sessionIDs...)
|
||||
harness.mu.Unlock()
|
||||
if len(sessions) < 4 {
|
||||
t.Fatalf("session IDs = %#v, want all prompt-facing module requests", sessions)
|
||||
}
|
||||
for _, session := range sessions {
|
||||
if session != tt.want {
|
||||
t.Fatalf("session IDs = %#v, want %q", sessions, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunFactoryAndPreparationFailuresAreProcessFailures(t *testing.T) {
|
||||
t.Run("LLM factory", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
opts := newStateTestHarness().options()
|
||||
opts.LLMClientFactory = func(context.Context, config.Config, string) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) {
|
||||
return nil, nil, errors.New("injected LLM factory failure")
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, &stdout, &stderr, opts)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "injected LLM factory failure") || stdout.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pipeline preparation", func(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
data, err := os.ReadFile(roots.config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data = bytes.Replace(data, []byte("extract: test/extract"), []byte("extract: test/failing-extract"), 1)
|
||||
if err := os.WriteFile(roots.config, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts := newStateTestHarness().options()
|
||||
if err := pipeline.RegisterExtractorBuilder(opts.Registries.Extractors, pipeline.ModuleSpec{Key: "test/failing-extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: stateTestArtifactKind}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.Extractor[stateTestArtifact], error) {
|
||||
return nil, errors.New("injected extractor construction failure")
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts.Catalog = catalogFromRegistries(opts.Registries)
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass"}, &stdout, &stderr, opts)
|
||||
if code != 1 || !strings.Contains(stderr.String(), "injected extractor construction failure") || stdout.Len() != 0 {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunWarningsRemainSuccessfulAndReachDurableSurfaces(t *testing.T) {
|
||||
roots := newStateTestRoots(t)
|
||||
harness := newStateTestHarness()
|
||||
harness.includeWarnings = true
|
||||
harness.chunkWarnings = []contracts.Warning{{Scope: "chunk", ReasonCode: "contract-warning", Message: "warning retained"}}
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug"}, &stdout, &stderr, harness.options())
|
||||
if code != 0 || !strings.Contains(stdout.String(), "outputs=1") || !strings.Contains(stderr.String(), "1 warning(s)") {
|
||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||
}
|
||||
outputPath := filepath.Join(onlyChildDir(t, roots.output), "result.json")
|
||||
output, err := os.ReadFile(outputPath)
|
||||
if err != nil || !strings.Contains(string(output), "contract-warning") {
|
||||
t.Fatalf("durable output = %q, %v", output, err)
|
||||
}
|
||||
bundle := onlyChildDir(t, roots.debug)
|
||||
var warnings []contracts.Warning
|
||||
readStateTestSummaryJSON(t, bundle, "warnings.json", &warnings)
|
||||
if len(warnings) != 1 || warnings[0].ReasonCode != "contract-warning" {
|
||||
t.Fatalf("debug warnings = %#v", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func runWithStateRoots(t *testing.T, roots stateTestRoots, opts Options, extra []string) stateTestResult {
|
||||
t.Helper()
|
||||
args := []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug"}
|
||||
args = append(args, extra...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
return stateTestResult{code: RunWithOptions(args, &stdout, &stderr, opts), stdout: stdout.String(), stderr: stderr.String()}
|
||||
}
|
||||
|
||||
func lookupRunContractEnv(values map[string]string) func(string) (string, bool) {
|
||||
return func(name string) (string, bool) {
|
||||
value, ok := values[name]
|
||||
return value, ok
|
||||
}
|
||||
}
|
||||
|
||||
func prependRunContractConfig(t *testing.T, roots stateTestRoots, prefix string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(roots.config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(roots.config, append([]byte(prefix), data...), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeRunContractProfiles(t *testing.T, ids ...string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, id := range ids {
|
||||
profile := fmt.Sprintf("id: %s\nendpoint: http://127.0.0.1:1/v1\nmodel: %s-model\n", id, id)
|
||||
if err := os.WriteFile(filepath.Join(dir, id+".yaml"), []byte(profile), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func registerRunContractValidator(t *testing.T, opts *Options, profiles *[]string) {
|
||||
t.Helper()
|
||||
if err := pipeline.RegisterTypedValidatorBuilder(opts.Registries.Validators, stateTestArtifactKind, pipeline.ValidatorSpec{Key: "run-contract-validator", ExecutionClass: contracts.ExecutionClassLLMBacked}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.TypedValidator[stateTestArtifact], error) {
|
||||
return runContractValidator{profiles: profiles}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := opts.Registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{Stage: pipeline.StageExtract, Module: "test/extract", Validators: []pipeline.ModuleBinding{{Module: "run-contract-validator", LLMProfile: "validator-profile"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts.Catalog = catalogFromRegistries(opts.Registries)
|
||||
}
|
||||
|
||||
type runContractValidator struct {
|
||||
profiles *[]string
|
||||
}
|
||||
|
||||
func (v runContractValidator) Name() string { return "run-contract-validator" }
|
||||
|
||||
func (v runContractValidator) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassLLMBacked
|
||||
}
|
||||
|
||||
func (v runContractValidator) Validate(_ context.Context, req contracts.TypedValidationRequest[stateTestArtifact]) (contracts.ValidationResult, error) {
|
||||
*v.profiles = append(*v.profiles, req.LLMProfile)
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
@@ -322,6 +322,15 @@ func TestRunUsesOneInjectedIdentityForDebugOutputAndManifest(t *testing.T) {
|
||||
if manifest.RunID != runID {
|
||||
t.Fatalf("manifest run ID = %q, want %q", manifest.RunID, runID)
|
||||
}
|
||||
wantStartedAt := time.Unix(1, 0).UTC()
|
||||
if manifest.StartedAt == nil || !manifest.StartedAt.Equal(wantStartedAt) {
|
||||
t.Fatalf("manifest started at = %v, want %v", manifest.StartedAt, wantStartedAt)
|
||||
}
|
||||
var invocation debugbundle.Invocation
|
||||
readStateTestSummaryJSON(t, debugPath, "invocation.json", &invocation)
|
||||
if invocation.RunID != runID || !invocation.StartedAt.Equal(wantStartedAt) {
|
||||
t.Fatalf("debug invocation identity = %#v, want run %q at %v", invocation, runID, wantStartedAt)
|
||||
}
|
||||
report := readStateTestRunReport(t, debugPath)
|
||||
if !report.Succeeded || report.RunID != runID || report.PipelineID != "sample" || report.OutputPath != outputPath || report.DebugPath != debugPath || report.OutputCount != 1 || report.RejectedCount != 0 || report.WarningCount != 0 || report.ValidationStatus != "approved" {
|
||||
t.Fatalf("success report = %#v", report)
|
||||
@@ -799,30 +808,36 @@ type stateTestHarness struct {
|
||||
runIDCalls uint64
|
||||
extractErr error
|
||||
chunkWarnings []contracts.Warning
|
||||
moduleProfiles []string
|
||||
sessionIDs []string
|
||||
outputWarnings []contracts.Warning
|
||||
includeWarnings bool
|
||||
}
|
||||
|
||||
func newStateTestHarness() *stateTestHarness { return &stateTestHarness{} }
|
||||
func (h *stateTestHarness) options() Options {
|
||||
registries := pipeline.Registries{Inputs: pipeline.NewInputAdapterRegistry(), Chunkers: pipeline.NewChunkerRegistry(), ArtifactCodecs: pipeline.NewArtifactCodecRegistry(), Extractors: pipeline.NewExtractorRegistry(), Mergers: pipeline.NewMergerRegistry(), Normalizers: pipeline.NewNormalizerRegistry(), Outputs: pipeline.NewOutputEncoderRegistry()}
|
||||
registries := pipeline.Registries{Inputs: pipeline.NewInputAdapterRegistry(), Chunkers: pipeline.NewChunkerRegistry(), ArtifactCodecs: pipeline.NewArtifactCodecRegistry(), Extractors: pipeline.NewExtractorRegistry(), Mergers: pipeline.NewMergerRegistry(), Normalizers: pipeline.NewNormalizerRegistry(), Validators: pipeline.NewValidatorRegistry(), ValidatorChains: pipeline.NewValidatorChainRegistry(), Outputs: pipeline.NewOutputEncoderRegistry()}
|
||||
if err := pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, stateTestCodec{}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := registries.Inputs.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "test/input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.InputAdapter, error) { return stateTestInput{}, nil }); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := registries.Chunkers.RegisterWithSpec(pipeline.ModuleSpec{Key: "test/chunk", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}}, func() (contracts.Chunker, error) { return stateTestChunker{h}, nil }); err != nil {
|
||||
if err := registries.Chunkers.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "test/chunk", Stage: pipeline.StageChunk, Requires: []string{"source"}, Provides: []string{"chunks"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "cache-reference"}}}, func(map[string]any) error { return nil }, func(pipeline.BuildRequest) (contracts.Chunker, error) { return stateTestChunker{h}, nil }); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := pipeline.RegisterExtractor(registries.Extractors, pipeline.ModuleSpec{Key: "test/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Extractor[stateTestArtifact], error) { return stateTestExtractor{h}, nil }); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "test/merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{}, nil }); err != nil {
|
||||
if err := pipeline.RegisterMerger(registries.Mergers, pipeline.ModuleSpec{Key: "test/merge", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Merger[stateTestArtifact], error) { return stateTestMerger{harness: h}, nil }); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "test/normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{}, nil }); err != nil {
|
||||
if err := pipeline.RegisterNormalizer(registries.Normalizers, pipeline.ModuleSpec{Key: "test/normalize", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: stateTestArtifactKind}, func() (contracts.Normalizer[stateTestArtifact], error) { return stateTestNormalizer{harness: h}, nil }); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := registries.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "test/output", Stage: pipeline.StageOutput, Requires: []string{"normalized"}, Provides: []string{"output"}}, func() (contracts.OutputEncoder, error) { return stateTestOutput{}, nil }); err != nil {
|
||||
if err := registries.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "test/output", Stage: pipeline.StageOutput, Requires: []string{"normalized"}, Provides: []string{"output"}}, func() (contracts.OutputEncoder, error) {
|
||||
return stateTestOutput{harness: h, includeWarnings: h.includeWarnings}, nil
|
||||
}); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return Options{Catalog: catalogFromRegistries(registries), Registries: registries, LookupEnv: emptyLookup, Now: func() time.Time { return time.Unix(1, 0) }, RunIDGenerator: func(startedAt time.Time) (string, error) {
|
||||
@@ -847,6 +862,10 @@ type stateTestChunker struct{ harness *stateTestHarness }
|
||||
func (stateTestChunker) Key() string { return "test/chunk" }
|
||||
func (stateTestChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
func (c stateTestChunker) Plan(_ context.Context, req contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
|
||||
c.harness.mu.Lock()
|
||||
c.harness.moduleProfiles = append(c.harness.moduleProfiles, req.LLMProfile)
|
||||
c.harness.sessionIDs = append(c.harness.sessionIDs, req.SessionID)
|
||||
c.harness.mu.Unlock()
|
||||
c.harness.mu.Lock()
|
||||
c.harness.chunkCalls++
|
||||
c.harness.mu.Unlock()
|
||||
@@ -879,36 +898,56 @@ type stateTestExtractor struct{ harness *stateTestHarness }
|
||||
|
||||
func (stateTestExtractor) Key() string { return "test/extract" }
|
||||
func (stateTestExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
func (e stateTestExtractor) Extract(context.Context, contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[stateTestArtifact], error) {
|
||||
func (e stateTestExtractor) Extract(_ context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[stateTestArtifact], error) {
|
||||
e.harness.mu.Lock()
|
||||
defer e.harness.mu.Unlock()
|
||||
e.harness.extractCalls++
|
||||
e.harness.moduleProfiles = append(e.harness.moduleProfiles, req.LLMProfile)
|
||||
e.harness.sessionIDs = append(e.harness.sessionIDs, req.SessionID)
|
||||
if e.harness.extractErr != nil {
|
||||
return contracts.TypedExtractionResult[stateTestArtifact]{}, e.harness.extractErr
|
||||
}
|
||||
return contracts.TypedExtractionResult[stateTestArtifact]{Value: stateTestArtifact{Value: "ok"}}, nil
|
||||
}
|
||||
|
||||
type stateTestMerger struct{}
|
||||
type stateTestMerger struct{ harness *stateTestHarness }
|
||||
|
||||
func (stateTestMerger) Key() string { return "test/merge" }
|
||||
func (stateTestMerger) Merge(_ context.Context, req contracts.TypedMergeRequest[stateTestArtifact]) (contracts.TypedMergeResult[stateTestArtifact], error) {
|
||||
func (m stateTestMerger) Merge(_ context.Context, req contracts.TypedMergeRequest[stateTestArtifact]) (contracts.TypedMergeResult[stateTestArtifact], error) {
|
||||
m.harness.mu.Lock()
|
||||
m.harness.moduleProfiles = append(m.harness.moduleProfiles, req.LLMProfile)
|
||||
m.harness.sessionIDs = append(m.harness.sessionIDs, req.SessionID)
|
||||
m.harness.mu.Unlock()
|
||||
return contracts.TypedMergeResult[stateTestArtifact]{Value: req.ExtractOutputs[0].Value}, nil
|
||||
}
|
||||
|
||||
type stateTestNormalizer struct{}
|
||||
type stateTestNormalizer struct{ harness *stateTestHarness }
|
||||
|
||||
func (stateTestNormalizer) Key() string { return "test/normalize" }
|
||||
func (stateTestNormalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
func (stateTestNormalizer) Normalize(_ context.Context, req contracts.TypedNormalizeRequest[stateTestArtifact]) (contracts.TypedNormalizeResult[stateTestArtifact], error) {
|
||||
func (n stateTestNormalizer) Normalize(_ context.Context, req contracts.TypedNormalizeRequest[stateTestArtifact]) (contracts.TypedNormalizeResult[stateTestArtifact], error) {
|
||||
n.harness.mu.Lock()
|
||||
n.harness.moduleProfiles = append(n.harness.moduleProfiles, req.LLMProfile)
|
||||
n.harness.sessionIDs = append(n.harness.sessionIDs, req.SessionID)
|
||||
n.harness.mu.Unlock()
|
||||
return contracts.TypedNormalizeResult[stateTestArtifact]{Value: req.MergeOutput.Value}, nil
|
||||
}
|
||||
|
||||
type stateTestOutput struct{}
|
||||
type stateTestOutput struct {
|
||||
harness *stateTestHarness
|
||||
includeWarnings bool
|
||||
}
|
||||
|
||||
func (stateTestOutput) Key() string { return "test/output" }
|
||||
func (stateTestOutput) Encode(context.Context, contracts.OutputRequest) (contracts.OutputResult, error) {
|
||||
return contracts.OutputResult{Files: []contracts.OutputFile{{Name: "result.json", Bytes: []byte("{\"ok\":true}\n")}}}, nil
|
||||
func (o stateTestOutput) Key() string { return "test/output" }
|
||||
func (o stateTestOutput) Encode(_ context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) {
|
||||
o.harness.mu.Lock()
|
||||
o.harness.outputWarnings = append([]contracts.Warning(nil), req.Warnings...)
|
||||
o.harness.mu.Unlock()
|
||||
data := []byte("{\"ok\":true}\n")
|
||||
if o.includeWarnings && len(req.Warnings) > 0 {
|
||||
data = []byte(fmt.Sprintf("{\"ok\":true,\"warnings\":%q}\n", req.Warnings[0].ReasonCode))
|
||||
}
|
||||
return contracts.OutputResult{Files: []contracts.OutputFile{{Name: "result.json", Bytes: data}}}, nil
|
||||
}
|
||||
|
||||
type failingDebugRecorder struct{}
|
||||
|
||||
514
internal/core/config/effective_config_contract_test.go
Normal file
514
internal/core/config/effective_config_contract_test.go
Normal file
@@ -0,0 +1,514 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestEffectiveConfigRejectsEmptyAndUnknownPipelineIDs(t *testing.T) {
|
||||
cfg := configForEffectiveTests(t, effectiveProfile())
|
||||
for _, pipelineID := range []string{"", "missing"} {
|
||||
name := pipelineID
|
||||
if name == "" {
|
||||
name = "empty"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := cfg.Resolve(ResolveInput{PipelineID: pipelineID, Catalog: effectiveCatalog(t)})
|
||||
if err == nil || !strings.Contains(err.Error(), "pipeline") {
|
||||
t.Fatalf("Resolve(%q) error = %v, want pipeline context", pipelineID, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigResolvesTrimmedPipelineMapKeys(t *testing.T) {
|
||||
profile := effectiveProfile()
|
||||
profile.ID = " main "
|
||||
cfg := Default()
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{" main ": profile}
|
||||
effective, err := cfg.Resolve(ResolveInput{PipelineID: "main", Catalog: effectiveCatalog(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if effective.PipelineID != "main" || effective.ResolvedPipeline.ID != "main" {
|
||||
t.Fatalf("resolved IDs = %q, %q", effective.PipelineID, effective.ResolvedPipeline.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigOnlySelectsRequestedLanesWithoutMutatingSource(t *testing.T) {
|
||||
profile := effectiveProfile()
|
||||
profile.Artifacts["other"] = pipeline.ArtifactLaneProfile{Extract: pipeline.Binding("extract")}
|
||||
cfg := configForEffectiveTests(t, profile)
|
||||
effective, err := cfg.Resolve(ResolveInput{
|
||||
PipelineID: "main",
|
||||
Only: []string{"other"},
|
||||
Catalog: effectiveCatalog(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
if len(effective.ResolvedPipeline.ArtifactLanes) != 1 || effective.ResolvedPipeline.ArtifactLanes[0].ID != "other" {
|
||||
t.Fatalf("resolved lanes = %#v", effective.ResolvedPipeline.ArtifactLanes)
|
||||
}
|
||||
if len(cfg.Pipelines["main"].Artifacts) != 2 {
|
||||
t.Fatalf("source lanes were mutated: %#v", cfg.Pipelines["main"].Artifacts)
|
||||
}
|
||||
|
||||
_, err = cfg.Resolve(ResolveInput{
|
||||
PipelineID: "main",
|
||||
Only: []string{"missing"},
|
||||
Catalog: effectiveCatalog(t),
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "lane \"missing\"") {
|
||||
t.Fatalf("unknown lane error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigMaterializesDefaultBindingsThroughCatalog(t *testing.T) {
|
||||
effective, err := resolveEffectiveProfile(t, effectiveProfile(), ResolveInput{})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
resolved := effective.ResolvedPipeline
|
||||
if resolved.Chunk.Module != pipeline.DefaultChunkModule || resolved.Output.Module != pipeline.DefaultOutputModule {
|
||||
t.Fatalf("default pipeline bindings = %#v, %#v", resolved.Chunk, resolved.Output)
|
||||
}
|
||||
if len(resolved.ArtifactLanes) != 1 || resolved.ArtifactLanes[0].Merge.Module != pipeline.DefaultMergeModule || resolved.ArtifactLanes[0].Normalize.Module != pipeline.DefaultNormalizeModule {
|
||||
t.Fatalf("default lane bindings = %#v", resolved.ArtifactLanes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigResolutionFailuresRetainContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*pipeline.PipelineProfile)
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "unknown module",
|
||||
mutate: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Input.Module = "missing-input"
|
||||
},
|
||||
want: []string{"pipeline \"main\"", "input"},
|
||||
},
|
||||
{
|
||||
name: "missing capability",
|
||||
mutate: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk.Module = "needs-capability"
|
||||
},
|
||||
want: []string{"pipeline \"main\"", "chunk"},
|
||||
},
|
||||
{
|
||||
name: "missing artifact variant",
|
||||
mutate: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Artifacts["lane"] = pipeline.ArtifactLaneProfile{
|
||||
Extract: pipeline.Binding("extract"),
|
||||
Merge: pipeline.Binding("other-merge"),
|
||||
}
|
||||
},
|
||||
want: []string{"pipeline \"main\"", "lane \"lane\"", "merge"},
|
||||
},
|
||||
{
|
||||
name: "invalid module options",
|
||||
mutate: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk = pipeline.ModuleBinding{Module: "generic", Options: map[string]any{"unknown": true}}
|
||||
},
|
||||
want: []string{"pipeline \"main\"", "chunk", "generic", "options"},
|
||||
},
|
||||
{
|
||||
name: "invalid validator options",
|
||||
mutate: func(profile *pipeline.PipelineProfile) {
|
||||
lane := profile.Artifacts["lane"]
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{
|
||||
Module: "option-validator",
|
||||
Options: map[string]any{"invalid": true},
|
||||
}},
|
||||
}
|
||||
profile.Artifacts["lane"] = lane
|
||||
},
|
||||
want: []string{"pipeline \"main\"", "lane \"lane\"", "extract", "option-validator", "options"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := effectiveProfile()
|
||||
tt.mutate(&profile)
|
||||
_, err := resolveEffectiveProfile(t, profile, ResolveInput{})
|
||||
if err == nil {
|
||||
t.Fatal("Resolve() error = nil, want failure")
|
||||
}
|
||||
for _, fragment := range tt.want {
|
||||
if !strings.Contains(err.Error(), fragment) {
|
||||
t.Fatalf("Resolve() error = %v, want context %q", err, fragment)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigLLMProfileOverrideChangesDigestWithoutOverridingValidators(t *testing.T) {
|
||||
profile := effectiveProfile()
|
||||
profile.Chunk.LLMProfile = "chunk-profile"
|
||||
lane := profile.Artifacts["lane"]
|
||||
lane.Extract.LLMProfile = "extract-profile"
|
||||
lane.Merge.LLMProfile = "merge-profile"
|
||||
lane.Normalize.LLMProfile = "normalize-profile"
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{
|
||||
Module: "llm-validator",
|
||||
LLMProfile: "validator-profile",
|
||||
}},
|
||||
}
|
||||
profile.Artifacts["lane"] = lane
|
||||
|
||||
base, err := resolveEffectiveProfile(t, profile, ResolveInput{})
|
||||
if err != nil {
|
||||
t.Fatalf("base Resolve() error = %v", err)
|
||||
}
|
||||
overridden, err := resolveEffectiveProfile(t, profile, ResolveInput{LLMProfileOverride: "override-profile"})
|
||||
if err != nil {
|
||||
t.Fatalf("overridden Resolve() error = %v", err)
|
||||
}
|
||||
if base.ResolvedPipeline.Digest == overridden.ResolvedPipeline.Digest {
|
||||
t.Fatal("LLM profile override did not change the pipeline digest")
|
||||
}
|
||||
resolved := overridden.ResolvedPipeline
|
||||
if resolved.Chunk.LLMProfile != "override-profile" || resolved.ArtifactLanes[0].Extract.LLMProfile != "override-profile" ||
|
||||
resolved.ArtifactLanes[0].Merge.LLMProfile != "override-profile" || resolved.ArtifactLanes[0].Normalize.LLMProfile != "override-profile" {
|
||||
t.Fatalf("pipeline profile override was not applied: %#v", resolved)
|
||||
}
|
||||
validators := findEffectiveValidatorChain(resolved, pipeline.StageExtract, "lane")
|
||||
if len(validators.Validators) != 1 || validators.Validators[0].Binding.LLMProfile != "validator-profile" {
|
||||
t.Fatalf("validator profile was overridden: %#v", validators)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigValidatorOverridesRemainDistinctAndOrdered(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value pipeline.ValidatorOverride
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "omitted uses default",
|
||||
want: []string{"default-validator"},
|
||||
},
|
||||
{
|
||||
name: "explicit empty",
|
||||
value: pipeline.ValidatorOverride{Set: true},
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "configured order",
|
||||
value: pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding("configured-a"),
|
||||
pipeline.Binding("configured-b"),
|
||||
},
|
||||
},
|
||||
want: []string{"configured-a", "configured-b"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := effectiveProfile()
|
||||
lane := profile.Artifacts["lane"]
|
||||
lane.Extract.Validators = tt.value
|
||||
profile.Artifacts["lane"] = lane
|
||||
effective, err := resolveEffectiveProfile(t, profile, ResolveInput{})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
chain := findEffectiveValidatorChain(effective.ResolvedPipeline, pipeline.StageExtract, "lane")
|
||||
got := make([]string, len(chain.Validators))
|
||||
for i, validator := range chain.Validators {
|
||||
got[i] = validator.Binding.Module
|
||||
}
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("validator chain = %#v, want %v", got, tt.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Fatalf("validator chain = %#v, want %v", got, tt.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveConfigAndResolutionInputsDoNotAliasSource(t *testing.T) {
|
||||
profile := effectiveProfile()
|
||||
profile.Chunk.Options = map[string]any{"nested": map[string]any{"safe": "source"}}
|
||||
profile.Chunk.References = map[string]string{"chunk-ref": "chunk.txt"}
|
||||
lane := profile.Artifacts["lane"]
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{
|
||||
Module: "configured-a",
|
||||
Options: map[string]any{"nested": map[string]any{"safe": "validator-source"}},
|
||||
}},
|
||||
}
|
||||
profile.Artifacts["lane"] = lane
|
||||
cfg := configForEffectiveTests(t, profile)
|
||||
only := []string{"lane"}
|
||||
overrides := []pipeline.ReferenceBinding{{Stage: pipeline.StageChunk, SlotName: "chunk-ref", Source: "source.txt"}}
|
||||
effective, err := cfg.Resolve(ResolveInput{
|
||||
PipelineID: "main",
|
||||
Only: only,
|
||||
ReferenceOverrides: overrides,
|
||||
Catalog: effectiveCatalog(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
effective.Config.Pipelines["main"].Chunk.Options["nested"].(map[string]any)["safe"] = "effective-config"
|
||||
effective.ResolvedPipeline.Chunk.Options["nested"].(map[string]any)["safe"] = "resolved-pipeline"
|
||||
effective.ResolvedPipeline.ChunkReferences.Bindings[0].Source = "resolved-reference"
|
||||
effective.ResolvedPipeline.ValidatorChains[1].Validators[0].Binding.Options["nested"].(map[string]any)["safe"] = "resolved-validator"
|
||||
effective.Only[0] = "mutated-only"
|
||||
effective.ReferenceOverrides[0].Source = "mutated-override"
|
||||
|
||||
if got := cfg.Pipelines["main"].Chunk.Options["nested"].(map[string]any)["safe"]; got != "source" {
|
||||
t.Fatalf("source config option was aliased: %v", got)
|
||||
}
|
||||
if got := cfg.Pipelines["main"].Chunk.References["chunk-ref"]; got != "chunk.txt" {
|
||||
t.Fatalf("source config references were aliased: %v", got)
|
||||
}
|
||||
if only[0] != "lane" || overrides[0].Source != "source.txt" {
|
||||
t.Fatal("resolution inputs were aliased")
|
||||
}
|
||||
}
|
||||
|
||||
func effectiveProfile() pipeline.PipelineProfile {
|
||||
return pipeline.PipelineProfile{
|
||||
ID: "main",
|
||||
Input: pipeline.Binding("input"),
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"lane": {Extract: pipeline.Binding("extract")},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func configForEffectiveTests(t *testing.T, profile pipeline.PipelineProfile) Config {
|
||||
t.Helper()
|
||||
cfg := Default()
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func resolveEffectiveProfile(t *testing.T, profile pipeline.PipelineProfile, input ResolveInput) (EffectiveConfig, error) {
|
||||
t.Helper()
|
||||
cfg := configForEffectiveTests(t, profile)
|
||||
if input.PipelineID == "" {
|
||||
input.PipelineID = "main"
|
||||
}
|
||||
if input.Catalog.Inputs == nil {
|
||||
input.Catalog = effectiveCatalog(t)
|
||||
}
|
||||
return cfg.Resolve(input)
|
||||
}
|
||||
|
||||
func findEffectiveValidatorChain(resolved pipeline.ResolvedPipeline, stage pipeline.ModuleStage, laneID string) pipeline.ResolvedValidatorChain {
|
||||
for _, chain := range resolved.ValidatorChains {
|
||||
if chain.Stage == stage && chain.LaneID == laneID {
|
||||
return chain
|
||||
}
|
||||
}
|
||||
return pipeline.ResolvedValidatorChain{}
|
||||
}
|
||||
|
||||
type effectiveArtifact struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
const effectiveArtifactKind contracts.ArtifactKind = "test/effective"
|
||||
|
||||
type effectiveCodec struct{}
|
||||
|
||||
func (effectiveCodec) Kind() contracts.ArtifactKind { return effectiveArtifactKind }
|
||||
func (effectiveCodec) Schema() contracts.ArtifactSchema {
|
||||
return contracts.ArtifactSchema{
|
||||
ID: "effective-schema",
|
||||
Name: "Effective artifact",
|
||||
Version: "1",
|
||||
JSONSchema: []byte(`{"type":"object"}`),
|
||||
}
|
||||
}
|
||||
func (effectiveCodec) MediaType() string { return "application/json" }
|
||||
func (effectiveCodec) EncodeCandidate(value effectiveArtifact) ([]byte, error) {
|
||||
return json.Marshal(value)
|
||||
}
|
||||
func (effectiveCodec) Encode(value effectiveArtifact) ([]byte, error) {
|
||||
return json.Marshal(value)
|
||||
}
|
||||
func (effectiveCodec) Decode(content []byte) (effectiveArtifact, error) {
|
||||
var value effectiveArtifact
|
||||
err := json.Unmarshal(content, &value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
type effectiveInput struct{ key string }
|
||||
|
||||
func (m effectiveInput) Key() string { return m.key }
|
||||
func (m effectiveInput) Parse(context.Context, contracts.ParseRequest) (*source.SourceDocument, error) {
|
||||
return &source.SourceDocument{}, nil
|
||||
}
|
||||
|
||||
type effectiveChunker struct{ key string }
|
||||
|
||||
func (m effectiveChunker) Key() string { return m.key }
|
||||
func (m effectiveChunker) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
if m.key == pipeline.DefaultChunkModule {
|
||||
return []contracts.ReferenceSlot{{Name: "chunk-ref"}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (m effectiveChunker) Plan(context.Context, contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
|
||||
return contracts.ChunkPlanResult{}, nil
|
||||
}
|
||||
|
||||
type effectiveExtractor struct{ key string }
|
||||
|
||||
func (m effectiveExtractor) Key() string { return m.key }
|
||||
func (m effectiveExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
func (m effectiveExtractor) Extract(context.Context, contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[effectiveArtifact], error) {
|
||||
return contracts.TypedExtractionResult[effectiveArtifact]{}, nil
|
||||
}
|
||||
|
||||
type effectiveMerger struct{ key string }
|
||||
|
||||
func (m effectiveMerger) Key() string { return m.key }
|
||||
func (m effectiveMerger) Merge(context.Context, contracts.TypedMergeRequest[effectiveArtifact]) (contracts.TypedMergeResult[effectiveArtifact], error) {
|
||||
return contracts.TypedMergeResult[effectiveArtifact]{}, nil
|
||||
}
|
||||
|
||||
type effectiveNormalizer struct{ key string }
|
||||
|
||||
func (m effectiveNormalizer) Key() string { return m.key }
|
||||
func (m effectiveNormalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
func (m effectiveNormalizer) Normalize(context.Context, contracts.TypedNormalizeRequest[effectiveArtifact]) (contracts.TypedNormalizeResult[effectiveArtifact], error) {
|
||||
return contracts.TypedNormalizeResult[effectiveArtifact]{}, nil
|
||||
}
|
||||
|
||||
type effectiveOutput struct{ key string }
|
||||
|
||||
func (m effectiveOutput) Key() string { return m.key }
|
||||
func (m effectiveOutput) Encode(context.Context, contracts.OutputRequest) (contracts.OutputResult, error) {
|
||||
return contracts.OutputResult{}, nil
|
||||
}
|
||||
|
||||
type effectiveValidator struct {
|
||||
name string
|
||||
class contracts.ExecutionClass
|
||||
}
|
||||
|
||||
func (v effectiveValidator) Name() string { return v.name }
|
||||
func (v effectiveValidator) ExecutionClass() contracts.ExecutionClass { return v.class }
|
||||
func (v effectiveValidator) Validate(context.Context, contracts.TypedValidationRequest[effectiveArtifact]) (contracts.ValidationResult, error) {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
func effectiveCatalog(t *testing.T) pipeline.ModuleCatalog {
|
||||
t.Helper()
|
||||
catalog := pipeline.ModuleCatalog{
|
||||
Inputs: pipeline.NewInputAdapterRegistry(),
|
||||
Chunkers: pipeline.NewChunkerRegistry(),
|
||||
ArtifactCodecs: pipeline.NewArtifactCodecRegistry(),
|
||||
Extractors: pipeline.NewExtractorRegistry(),
|
||||
Mergers: pipeline.NewMergerRegistry(),
|
||||
Normalizers: pipeline.NewNormalizerRegistry(),
|
||||
Validators: pipeline.NewValidatorRegistry(),
|
||||
ValidatorChains: pipeline.NewValidatorChainRegistry(),
|
||||
Outputs: pipeline.NewOutputEncoderRegistry(),
|
||||
}
|
||||
if err := pipeline.RegisterArtifactCodec(catalog.ArtifactCodecs, effectiveCodec{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := catalog.Inputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func() (contracts.InputAdapter, error) {
|
||||
return effectiveInput{key: "input"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chunkSpec := pipeline.ModuleSpec{
|
||||
Key: pipeline.DefaultChunkModule,
|
||||
Stage: pipeline.StageChunk,
|
||||
Requires: []string{"source"},
|
||||
Provides: []string{"chunk"},
|
||||
ReferenceSlots: []contracts.ReferenceSlot{{Name: "chunk-ref"}},
|
||||
}
|
||||
chunkOptions := func(options map[string]any) error { return pipeline.RejectUnknownOptions(options, "size", "nested") }
|
||||
if err := catalog.Chunkers.RegisterBuilderWithSpec(chunkSpec, chunkOptions, func(pipeline.BuildRequest) (contracts.Chunker, error) {
|
||||
return effectiveChunker{key: pipeline.DefaultChunkModule}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := catalog.Chunkers.RegisterBuilderWithSpec(pipeline.ModuleSpec{Key: "needs-capability", Stage: pipeline.StageChunk, Requires: []string{"missing"}}, chunkOptions, func(pipeline.BuildRequest) (contracts.Chunker, error) {
|
||||
return effectiveChunker{key: "needs-capability"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pipeline.RegisterExtractor(catalog.Extractors, pipeline.ModuleSpec{Key: "extract", Stage: pipeline.StageExtract, ArtifactKind: effectiveArtifactKind, Requires: []string{"chunk"}, Provides: []string{"candidate"}}, func() (contracts.Extractor[effectiveArtifact], error) {
|
||||
return effectiveExtractor{key: "extract"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pipeline.RegisterMerger(catalog.Mergers, pipeline.ModuleSpec{Key: pipeline.DefaultMergeModule, Stage: pipeline.StageMerge, ArtifactKind: effectiveArtifactKind, Requires: []string{"candidate"}, Provides: []string{"merged"}}, func() (contracts.Merger[effectiveArtifact], error) {
|
||||
return effectiveMerger{key: pipeline.DefaultMergeModule}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pipeline.RegisterMerger(catalog.Mergers, pipeline.ModuleSpec{Key: "other-merge", Stage: pipeline.StageMerge, ArtifactKind: "other-kind", Requires: []string{"candidate"}, Provides: []string{"merged"}}, func() (contracts.Merger[effectiveArtifact], error) {
|
||||
return effectiveMerger{key: "other-merge"}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pipeline.RegisterNormalizer(catalog.Normalizers, pipeline.ModuleSpec{Key: pipeline.DefaultNormalizeModule, Stage: pipeline.StageNormalize, ArtifactKind: effectiveArtifactKind, Requires: []string{"merged"}, Provides: []string{"normalized"}}, func() (contracts.Normalizer[effectiveArtifact], error) {
|
||||
return effectiveNormalizer{key: pipeline.DefaultNormalizeModule}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := catalog.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: pipeline.DefaultOutputModule, Stage: pipeline.StageOutput, Requires: []string{"normalized"}}, func() (contracts.OutputEncoder, error) {
|
||||
return effectiveOutput{key: pipeline.DefaultOutputModule}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, validator := range []struct {
|
||||
key string
|
||||
class contracts.ExecutionClass
|
||||
}{
|
||||
{key: "default-validator", class: contracts.ExecutionClassDeterministic},
|
||||
{key: "configured-a", class: contracts.ExecutionClassDeterministic},
|
||||
{key: "configured-b", class: contracts.ExecutionClassDeterministic},
|
||||
{key: "llm-validator", class: contracts.ExecutionClassLLMBacked},
|
||||
} {
|
||||
if err := pipeline.RegisterTypedValidatorBuilder(catalog.Validators, effectiveArtifactKind, pipeline.ValidatorSpec{Key: validator.key, ExecutionClass: validator.class}, func(options map[string]any) error {
|
||||
return pipeline.RejectUnknownOptions(options, "nested")
|
||||
}, func(pipeline.BuildRequest) (contracts.TypedValidator[effectiveArtifact], error) {
|
||||
return effectiveValidator{name: validator.key, class: validator.class}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := pipeline.RegisterTypedValidatorBuilder(catalog.Validators, effectiveArtifactKind, pipeline.ValidatorSpec{Key: "option-validator", ExecutionClass: contracts.ExecutionClassDeterministic}, func(options map[string]any) error {
|
||||
return pipeline.RejectUnknownOptions(options, "allowed")
|
||||
}, func(pipeline.BuildRequest) (contracts.TypedValidator[effectiveArtifact], error) {
|
||||
return effectiveValidator{name: "option-validator", class: contracts.ExecutionClassDeterministic}, nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := catalog.ValidatorChains.Register(pipeline.ValidatorChainMapping{Stage: pipeline.StageExtract, Module: "extract", Validators: []pipeline.ModuleBinding{pipeline.Binding("default-validator")}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
239
internal/core/config/env_contract_test.go
Normal file
239
internal/core/config/env_contract_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestPrecedenceFileValuesOverrideBuiltInDefaults(t *testing.T) {
|
||||
cfg := applyFileConfig(t, `version: 3
|
||||
concurrency:
|
||||
total_llm: 4
|
||||
stage_workers:
|
||||
extract: 2
|
||||
output:
|
||||
directory: ./file-output
|
||||
cache:
|
||||
chunk_plans:
|
||||
directory: ./file-plans
|
||||
mode: refresh
|
||||
checkpoints:
|
||||
directory: ./file-checkpoints
|
||||
debug:
|
||||
directory: ./file-debug
|
||||
`)
|
||||
if cfg.Concurrency.TotalLLM != 4 || cfg.Concurrency.StageWorkers["extract"] != 2 ||
|
||||
cfg.Output.Directory != "./file-output" || cfg.Cache.ChunkPlans.Directory != "file-plans" ||
|
||||
cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheRefresh || cfg.Cache.Checkpoints.Directory != "file-checkpoints" ||
|
||||
cfg.Debug.Directory != "./file-debug" {
|
||||
t.Fatalf("file values did not override defaults: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecedenceOperationalEnvironmentOverridesFileValues(t *testing.T) {
|
||||
cfg := applyFileConfig(t, `version: 3
|
||||
concurrency:
|
||||
total_llm: 2
|
||||
stage_workers:
|
||||
extract: 1
|
||||
output:
|
||||
directory: ./file-output
|
||||
cache:
|
||||
chunk_plans:
|
||||
directory: ./file-plans
|
||||
mode: refresh
|
||||
checkpoints:
|
||||
directory: ./file-checkpoints
|
||||
debug:
|
||||
directory: ./file-debug
|
||||
`)
|
||||
env := map[string]string{
|
||||
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "8",
|
||||
"NOTARIUS_STAGE_WORKERS_EXTRACT": "6",
|
||||
"NOTARIUS_OUTPUT_DIR": "/env/output",
|
||||
"NOTARIUS_CACHE_CHUNK_PLANS_MODE": "bypass",
|
||||
"NOTARIUS_CACHE_CHUNK_PLANS_DIR": "/env/plans",
|
||||
"NOTARIUS_CACHE_CHECKPOINTS_DIR": "/env/checkpoints",
|
||||
"NOTARIUS_DEBUG_DIR": "/env/debug",
|
||||
}
|
||||
if err := cfg.ApplyEnvOverridesWithLookup(lookupValues(env)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != 8 || cfg.Concurrency.StageWorkers["extract"] != 6 ||
|
||||
cfg.Output.Directory != "/env/output" || cfg.Cache.ChunkPlans.Directory != "/env/plans" ||
|
||||
cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheBypass || cfg.Cache.Checkpoints.Directory != "/env/checkpoints" ||
|
||||
cfg.Debug.Directory != "/env/debug" {
|
||||
t.Fatalf("environment values did not override file values: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecedenceExtractWorkersFollowEffectiveConcurrencyUnlessExplicit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file string
|
||||
env map[string]string
|
||||
wantTotal int
|
||||
wantWorker int
|
||||
}{
|
||||
{
|
||||
name: "default follows environment total",
|
||||
file: "version: 3\n",
|
||||
env: map[string]string{"NOTARIUS_TOTAL_LLM_CONCURRENCY": "5"},
|
||||
wantTotal: 5,
|
||||
wantWorker: 5,
|
||||
},
|
||||
{
|
||||
name: "file worker is retained",
|
||||
file: "version: 3\nconcurrency:\n total_llm: 3\n stage_workers:\n extract: 2\n",
|
||||
env: map[string]string{"NOTARIUS_TOTAL_LLM_CONCURRENCY": "6"},
|
||||
wantTotal: 6,
|
||||
wantWorker: 2,
|
||||
},
|
||||
{
|
||||
name: "environment worker is retained",
|
||||
file: "version: 3\nconcurrency:\n total_llm: 2\n",
|
||||
env: map[string]string{
|
||||
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "6",
|
||||
"NOTARIUS_STAGE_WORKERS_EXTRACT": "4",
|
||||
},
|
||||
wantTotal: 6,
|
||||
wantWorker: 4,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := applyFileConfig(t, tt.file)
|
||||
if err := cfg.ApplyEnvOverridesWithLookup(lookupValues(tt.env)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != tt.wantTotal || cfg.Concurrency.StageWorkers["extract"] != tt.wantWorker {
|
||||
t.Fatalf("concurrency = %#v, want total %d and extract %d", cfg.Concurrency, tt.wantTotal, tt.wantWorker)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecedenceEmptyFileCacheDirectoriesDeferPerUserResolution(t *testing.T) {
|
||||
cfg := applyFileConfig(t, `version: 3
|
||||
cache:
|
||||
chunk_plans:
|
||||
directory: ""
|
||||
checkpoints:
|
||||
directory: ""
|
||||
`)
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("empty file cache directories should be valid: %v", err)
|
||||
}
|
||||
if cfg.Cache.ChunkPlans.Directory != "" || cfg.Cache.Checkpoints.Directory != "" {
|
||||
t.Fatalf("empty cache directories were not preserved for deferred resolution: %#v", cfg.Cache)
|
||||
}
|
||||
resolver := func() (string, error) { return "/user/cache", nil }
|
||||
chunkPlans, err := DefaultChunkPlanRoot(resolver)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
checkpoints, err := DefaultCheckpointRoot(resolver)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if chunkPlans != "/user/cache/notarius/chunk-plans" || checkpoints != "/user/cache/notarius/checkpoints" {
|
||||
t.Fatalf("deferred cache roots = %q, %q", chunkPlans, checkpoints)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCacheRootsRejectInvalidUserCacheResolvers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resolver func() (string, error)
|
||||
want string
|
||||
}{
|
||||
{name: "nil resolver", want: "must not be nil"},
|
||||
{
|
||||
name: "resolver failure",
|
||||
resolver: func() (string, error) {
|
||||
return "", errors.New("cache home unavailable")
|
||||
},
|
||||
want: "resolve user cache directory",
|
||||
},
|
||||
{name: "empty directory", resolver: func() (string, error) { return " ", nil }, want: "must not be empty"},
|
||||
}
|
||||
families := []struct {
|
||||
name string
|
||||
root func(func() (string, error)) (string, error)
|
||||
}{
|
||||
{name: "chunk plans", root: DefaultChunkPlanRoot},
|
||||
{name: "checkpoints", root: DefaultCheckpointRoot},
|
||||
}
|
||||
|
||||
for _, family := range families {
|
||||
for _, tt := range tests {
|
||||
t.Run(family.name+"/"+tt.name, func(t *testing.T) {
|
||||
_, err := family.root(tt.resolver)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvEmptyDirectoryOverridesAreErrors(t *testing.T) {
|
||||
tests := []string{
|
||||
"NOTARIUS_OUTPUT_DIR",
|
||||
"NOTARIUS_CACHE_CHUNK_PLANS_DIR",
|
||||
"NOTARIUS_CACHE_CHECKPOINTS_DIR",
|
||||
"NOTARIUS_DEBUG_DIR",
|
||||
}
|
||||
for _, name := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
err := cfg.ApplyEnvOverridesWithLookup(lookupValues(map[string]string{name: " \t"}))
|
||||
if err == nil || !strings.Contains(err.Error(), name) {
|
||||
t.Fatalf("error = %v, want responsible environment variable", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvInvalidIntegersAndChunkCacheModesReportTheirNames(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"NOTARIUS_TOTAL_LLM_CONCURRENCY": "not-an-integer",
|
||||
"NOTARIUS_STAGE_WORKERS_EXTRACT": "not-an-integer",
|
||||
"NOTARIUS_CACHE_CHUNK_PLANS_MODE": "not-a-cache-mode",
|
||||
}
|
||||
for name, value := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
err := cfg.ApplyEnvOverridesWithLookup(lookupValues(map[string]string{name: value}))
|
||||
if err == nil || !strings.Contains(err.Error(), name) {
|
||||
t.Fatalf("error = %v, want responsible environment variable", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvRemovedProviderVariablesAreIgnored(t *testing.T) {
|
||||
before := Default()
|
||||
cfg := Default()
|
||||
removed := map[string]string{
|
||||
"NOTARIUS_LLM_DEFAULT_ENDPOINT": "ignored-provider-setting",
|
||||
"NOTARIUS_LLM_DEFAULT_MODEL": "ignored-provider-setting",
|
||||
}
|
||||
if err := cfg.ApplyEnvOverridesWithLookup(lookupValues(removed)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg, before) {
|
||||
t.Fatalf("removed provider variables changed configuration: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func lookupValues(values map[string]string) func(string) (string, bool) {
|
||||
return func(name string) (string, bool) {
|
||||
value, ok := values[name]
|
||||
return value, ok
|
||||
}
|
||||
}
|
||||
364
internal/core/config/file_config_contract_test.go
Normal file
364
internal/core/config/file_config_contract_test.go
Normal file
@@ -0,0 +1,364 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestDefaultReturnsDocumentedValuesAndIndependentMaps(t *testing.T) {
|
||||
first := Default()
|
||||
if first.Concurrency.TotalLLM != 1 || first.Concurrency.StageWorkers["extract"] != 1 {
|
||||
t.Fatalf("concurrency defaults = %#v", first.Concurrency)
|
||||
}
|
||||
if first.Output.Directory != "./notarius-output" || first.Debug.Directory != "./notarius-debug" {
|
||||
t.Fatalf("output/debug defaults = %#v, %#v", first.Output, first.Debug)
|
||||
}
|
||||
if first.Cache.ChunkPlans.Mode != pipeline.ChunkCacheAuto || first.Cache.ChunkPlans.Directory != "" || first.Cache.Checkpoints.Directory != "" {
|
||||
t.Fatalf("cache defaults = %#v", first.Cache)
|
||||
}
|
||||
if len(first.Pipelines) != 0 {
|
||||
t.Fatalf("pipeline defaults = %#v", first.Pipelines)
|
||||
}
|
||||
|
||||
first.Concurrency.StageWorkers["extract"] = 99
|
||||
first.Concurrency.StageWorkers["other"] = 100
|
||||
first.Pipelines["changed"] = pipeline.PipelineProfile{}
|
||||
second := Default()
|
||||
if second.Concurrency.StageWorkers["extract"] != 1 || len(second.Concurrency.StageWorkers) != 1 || len(second.Pipelines) != 0 {
|
||||
t.Fatalf("Default() returned state shared with an earlier result: %#v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigMinimalVersion3AppliesOverDefaults(t *testing.T) {
|
||||
file := parseFileConfig(t, "version: 3\n")
|
||||
cfg := Default()
|
||||
if err := cfg.ApplyFileConfig(file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Output.Directory != "./notarius-output" || cfg.Debug.Directory != "./notarius-debug" || cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheAuto {
|
||||
t.Fatalf("minimal file changed unrelated defaults: %#v", cfg)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != 1 || cfg.Concurrency.StageWorkers["extract"] != 1 || len(cfg.Pipelines) != 0 {
|
||||
t.Fatalf("minimal file did not retain defaults: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigMissingVersionIsReportedBeforeFieldDecoding(t *testing.T) {
|
||||
_, err := ParseFileConfigYAML([]byte("workspace:\n directory: /tmp/old\n"))
|
||||
if err == nil || !strings.Contains(err.Error(), "config version is required") {
|
||||
t.Fatalf("missing version error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigRejectsUnknownCurrentAndRemovedFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "removed diagnostics",
|
||||
yaml: "version: 3\ndiagnostics: {}\n",
|
||||
want: "field diagnostics not found",
|
||||
},
|
||||
{
|
||||
name: "removed llm profiles",
|
||||
yaml: "version: 3\nllm_profiles: {}\n",
|
||||
want: "field llm_profiles not found",
|
||||
},
|
||||
{
|
||||
name: "pipeline field",
|
||||
yaml: "version: 3\npipelines:\n main:\n unknown: true\n",
|
||||
want: "field unknown not found",
|
||||
},
|
||||
{
|
||||
name: "lane field",
|
||||
yaml: "version: 3\npipelines:\n main:\n artifacts:\n spells:\n unknown: true\n",
|
||||
want: "field unknown not found",
|
||||
},
|
||||
{
|
||||
name: "module binding field",
|
||||
yaml: "version: 3\npipelines:\n main:\n input:\n module: seriatim\n unknown: true\n",
|
||||
want: "field unknown not found in module binding",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ParseFileConfigYAML([]byte(tt.yaml))
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error = %v, want context %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigModuleBindingsPreserveFormsAndValidatorPresence(t *testing.T) {
|
||||
cfg := applyFileConfig(t, `version: 3
|
||||
pipelines:
|
||||
main:
|
||||
input: seriatim
|
||||
chunk:
|
||||
module: generic
|
||||
llm_profile: chunk-profile
|
||||
retries: 2
|
||||
options:
|
||||
max_units: 25
|
||||
references:
|
||||
glossary: ./glossary.md
|
||||
validators: []
|
||||
artifacts:
|
||||
spells:
|
||||
extract:
|
||||
module: dnd/spells
|
||||
options:
|
||||
nested:
|
||||
enabled: true
|
||||
merge: appendorder
|
||||
normalize: noop
|
||||
`)
|
||||
profile := cfg.Pipelines["main"]
|
||||
if profile.Input.Module != "seriatim" || profile.Input.Validators.Set {
|
||||
t.Fatalf("shorthand binding = %#v", profile.Input)
|
||||
}
|
||||
if profile.Chunk.Module != "generic" || profile.Chunk.LLMProfile != "chunk-profile" || profile.Chunk.Retries != 2 ||
|
||||
!reflect.DeepEqual(profile.Chunk.Options, map[string]any{"max_units": 25}) ||
|
||||
!reflect.DeepEqual(profile.Chunk.References, map[string]string{"glossary": "./glossary.md"}) {
|
||||
t.Fatalf("object binding = %#v", profile.Chunk)
|
||||
}
|
||||
if !profile.Chunk.Validators.Set || len(profile.Chunk.Validators.Validators) != 0 {
|
||||
t.Fatalf("explicit empty validators = %#v", profile.Chunk.Validators)
|
||||
}
|
||||
if profile.Artifacts["spells"].Extract.Module != "dnd/spells" ||
|
||||
!reflect.DeepEqual(profile.Artifacts["spells"].Extract.Options, map[string]any{
|
||||
"nested": map[string]any{"enabled": true},
|
||||
}) {
|
||||
t.Fatalf("extract binding = %#v", profile.Artifacts["spells"].Extract)
|
||||
}
|
||||
if profile.Artifacts["spells"].Merge.Module != "appendorder" || profile.Artifacts["spells"].Normalize.Module != "noop" {
|
||||
t.Fatalf("stage shorthand bindings = %#v", profile.Artifacts["spells"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigReferencePrecedenceIsRetained(t *testing.T) {
|
||||
cfg := applyFileConfig(t, `version: 3
|
||||
pipelines:
|
||||
main:
|
||||
input: seriatim
|
||||
references:
|
||||
pipeline-only: ./pipeline.txt
|
||||
shared: ./pipeline-shared.txt
|
||||
chunk:
|
||||
module: generic
|
||||
references:
|
||||
chunk-only: ./chunk.txt
|
||||
artifacts:
|
||||
spells:
|
||||
references:
|
||||
lane-only: ./lane.txt
|
||||
shared: ./lane-shared.txt
|
||||
overridden: ./lane.txt
|
||||
extract:
|
||||
module: dnd/spells
|
||||
references:
|
||||
extract-only: ./extract.txt
|
||||
overridden: ./extract-overridden.txt
|
||||
merge:
|
||||
module: appendorder
|
||||
references:
|
||||
merge-only: ./merge.txt
|
||||
normalize:
|
||||
module: noop
|
||||
references:
|
||||
normalize-only: ./normalize.txt
|
||||
`)
|
||||
profile := cfg.Pipelines["main"]
|
||||
if !reflect.DeepEqual(profile.References, map[string]string{
|
||||
"pipeline-only": "./pipeline.txt",
|
||||
"shared": "./pipeline-shared.txt",
|
||||
}) {
|
||||
t.Fatalf("pipeline references = %#v", profile.References)
|
||||
}
|
||||
if !reflect.DeepEqual(profile.Chunk.References, map[string]string{"chunk-only": "./chunk.txt"}) {
|
||||
t.Fatalf("chunk references = %#v", profile.Chunk.References)
|
||||
}
|
||||
lane := profile.Artifacts["spells"]
|
||||
if !reflect.DeepEqual(lane.References, map[string]string{
|
||||
"lane-only": "./lane.txt",
|
||||
"shared": "./lane-shared.txt",
|
||||
"overridden": "./lane.txt",
|
||||
}) {
|
||||
t.Fatalf("lane compatibility references = %#v", lane.References)
|
||||
}
|
||||
if !reflect.DeepEqual(lane.Extract.References, map[string]string{
|
||||
"lane-only": "./lane.txt",
|
||||
"shared": "./lane-shared.txt",
|
||||
"overridden": "./extract-overridden.txt",
|
||||
"extract-only": "./extract.txt",
|
||||
}) {
|
||||
t.Fatalf("extract references = %#v", lane.Extract.References)
|
||||
}
|
||||
if !reflect.DeepEqual(lane.Merge.References, map[string]string{"merge-only": "./merge.txt"}) ||
|
||||
!reflect.DeepEqual(lane.Normalize.References, map[string]string{"normalize-only": "./normalize.txt"}) {
|
||||
t.Fatalf("merge/normalize references = %#v, %#v", lane.Merge.References, lane.Normalize.References)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigStageLocalValidatorsPreserveOrderAndFields(t *testing.T) {
|
||||
cfg := applyFileConfig(t, `version: 3
|
||||
pipelines:
|
||||
main:
|
||||
input: seriatim
|
||||
chunk:
|
||||
module: generic
|
||||
validators:
|
||||
- generic/always_accept
|
||||
- module: generic/valid_json
|
||||
llm_profile: validator-profile
|
||||
options:
|
||||
schema: compact
|
||||
artifacts:
|
||||
spells:
|
||||
extract:
|
||||
module: dnd/spells
|
||||
validators:
|
||||
- module: extract/dnd/spells/shape
|
||||
options:
|
||||
strict: true
|
||||
merge:
|
||||
module: appendorder
|
||||
validators:
|
||||
- generic/always_accept
|
||||
normalize:
|
||||
module: noop
|
||||
validators:
|
||||
- module: generic/valid_json
|
||||
options:
|
||||
mode: normalized
|
||||
`)
|
||||
profile := cfg.Pipelines["main"]
|
||||
chunkValidators := profile.Chunk.Validators.Validators
|
||||
if !profile.Chunk.Validators.Set || len(chunkValidators) != 2 || chunkValidators[0].Module != "generic/always_accept" ||
|
||||
chunkValidators[1].Module != "generic/valid_json" || chunkValidators[1].LLMProfile != "validator-profile" ||
|
||||
!reflect.DeepEqual(chunkValidators[1].Options, map[string]any{"schema": "compact"}) {
|
||||
t.Fatalf("chunk validators = %#v", profile.Chunk.Validators)
|
||||
}
|
||||
lane := profile.Artifacts["spells"]
|
||||
if len(lane.Extract.Validators.Validators) != 1 || lane.Extract.Validators.Validators[0].Module != "extract/dnd/spells/shape" ||
|
||||
!reflect.DeepEqual(lane.Extract.Validators.Validators[0].Options, map[string]any{"strict": true}) {
|
||||
t.Fatalf("extract validators = %#v", lane.Extract.Validators)
|
||||
}
|
||||
if len(lane.Merge.Validators.Validators) != 1 || lane.Merge.Validators.Validators[0].Module != "generic/always_accept" {
|
||||
t.Fatalf("merge validators = %#v", lane.Merge.Validators)
|
||||
}
|
||||
if len(lane.Normalize.Validators.Validators) != 1 || lane.Normalize.Validators.Validators[0].Module != "generic/valid_json" ||
|
||||
!reflect.DeepEqual(lane.Normalize.Validators.Validators[0].Options, map[string]any{"mode": "normalized"}) {
|
||||
t.Fatalf("normalize validators = %#v", lane.Normalize.Validators)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigStateSectionsApplyIndependently(t *testing.T) {
|
||||
cfg := applyFileConfig(t, `version: 3
|
||||
scriptorium:
|
||||
profile_dir: ./profiles
|
||||
concurrency:
|
||||
total_llm: 7
|
||||
output:
|
||||
directory: ./output
|
||||
cache:
|
||||
chunk_plans:
|
||||
directory: ./plans
|
||||
mode: bypass
|
||||
checkpoints:
|
||||
directory: ./checkpoints
|
||||
debug:
|
||||
directory: ./debug
|
||||
`)
|
||||
if cfg.Scriptorium.ProfileDir != "./profiles" || cfg.Scriptorium.ProfileFile != "" {
|
||||
t.Fatalf("scriptorium = %#v", cfg.Scriptorium)
|
||||
}
|
||||
if cfg.Concurrency.TotalLLM != 7 || cfg.Concurrency.StageWorkers["extract"] != 7 {
|
||||
t.Fatalf("concurrency = %#v", cfg.Concurrency)
|
||||
}
|
||||
if cfg.Output.Directory != "./output" || cfg.Cache.ChunkPlans.Directory != "plans" || cfg.Cache.ChunkPlans.Mode != pipeline.ChunkCacheBypass ||
|
||||
cfg.Cache.Checkpoints.Directory != "checkpoints" || cfg.Debug.Directory != "./debug" {
|
||||
t.Fatalf("state sections = %#v, %#v, %#v, %#v", cfg.Output, cfg.Cache, cfg.Debug, cfg.Scriptorium)
|
||||
}
|
||||
if cfg.Output.Directory == cfg.Cache.ChunkPlans.Directory || cfg.Cache.ChunkPlans.Directory == cfg.Cache.Checkpoints.Directory || cfg.Cache.Checkpoints.Directory == cfg.Debug.Directory {
|
||||
t.Fatal("state roots were coupled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileConfigRejectsTrimmedKeyCollisions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "pipeline ids",
|
||||
yaml: "version: 3\npipelines:\n main: {}\n ' main ': {}\n",
|
||||
want: "pipeline id \"main\" is duplicated after trimming",
|
||||
},
|
||||
{
|
||||
name: "lane ids",
|
||||
yaml: "version: 3\npipelines:\n main:\n artifacts:\n spells: {}\n ' spells ': {}\n",
|
||||
want: "artifact lane id \"spells\" is duplicated after trimming",
|
||||
},
|
||||
{
|
||||
name: "reference slots",
|
||||
yaml: "version: 3\npipelines:\n main:\n references:\n slot: ./one.txt\n ' slot ': ./two.txt\n",
|
||||
want: "reference slot \"slot\" is duplicated after trimming",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
file := parseFileConfig(t, tt.yaml)
|
||||
cfg := Default()
|
||||
err := cfg.ApplyFileConfig(file)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("ApplyFileConfig() error = %v, want context %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFileConfigReportsPathAndOperationContext(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missing := filepath.Join(dir, "missing.yml")
|
||||
_, err := LoadFileConfig(missing)
|
||||
if err == nil || !strings.Contains(err.Error(), "read config file") || !strings.Contains(err.Error(), missing) {
|
||||
t.Fatalf("missing-file error = %v", err)
|
||||
}
|
||||
|
||||
malformed := filepath.Join(dir, "malformed.yml")
|
||||
if err := os.WriteFile(malformed, []byte("version: [\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = LoadFileConfig(malformed)
|
||||
if err == nil || !strings.Contains(err.Error(), "parse config file") || !strings.Contains(err.Error(), malformed) {
|
||||
t.Fatalf("malformed-file error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func parseFileConfig(t *testing.T, source string) FileConfig {
|
||||
t.Helper()
|
||||
file, err := ParseFileConfigYAML([]byte(source))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFileConfigYAML() error = %v", err)
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
func applyFileConfig(t *testing.T, source string) Config {
|
||||
t.Helper()
|
||||
cfg := Default()
|
||||
if err := cfg.ApplyFileConfig(parseFileConfig(t, source)); err != nil {
|
||||
t.Fatalf("ApplyFileConfig() error = %v", err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -117,6 +117,63 @@ func TestRedactedEffectiveConfigPayloadDoesNotAliasSource(t *testing.T) {
|
||||
assertRedactionTestBindingUnchanged(t, effective.ResolvedPipeline.Input, "effective")
|
||||
}
|
||||
|
||||
func TestRedactedSummaryPayloadsCoverEveryEffectiveConfigBinding(t *testing.T) {
|
||||
bindings := map[string]pipeline.ModuleBinding{}
|
||||
for _, name := range []string{"input", "chunk", "output", "extract", "merge", "normalize", "lane-validator"} {
|
||||
bindings[name] = redactionTestBinding("summary-" + name)
|
||||
}
|
||||
effective := EffectiveConfig{
|
||||
Config: Config{Pipelines: map[string]pipeline.PipelineProfile{
|
||||
"redaction-test": {
|
||||
Input: bindings["input"],
|
||||
Chunk: bindings["chunk"],
|
||||
Output: bindings["output"],
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"safe-lane": {
|
||||
Extract: bindings["extract"],
|
||||
Merge: bindings["merge"],
|
||||
Normalize: bindings["normalize"],
|
||||
Validators: []pipeline.ModuleBinding{bindings["lane-validator"]},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
ResolvedPipeline: pipeline.ResolvedPipeline{
|
||||
Input: bindings["input"],
|
||||
Chunk: bindings["chunk"],
|
||||
Output: bindings["output"],
|
||||
ArtifactLanes: []pipeline.ResolvedArtifactLane{{
|
||||
ID: "safe-lane",
|
||||
Extract: bindings["extract"],
|
||||
Merge: bindings["merge"],
|
||||
Normalize: bindings["normalize"],
|
||||
Validators: []pipeline.ModuleBinding{bindings["lane-validator"]},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
payload, ok := effective.RedactedSummaryPayload().(EffectiveConfig)
|
||||
if !ok {
|
||||
t.Fatal("RedactedSummaryPayload() returned an unexpected type")
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(encoded)
|
||||
for name := range bindings {
|
||||
if strings.Contains(text, "summary-"+name+"-secret") || strings.Contains(text, "summary-"+name+"-nested-secret") {
|
||||
t.Fatalf("summary payload contains sensitive option for %q: %s", name, text)
|
||||
}
|
||||
if !strings.Contains(text, "summary-"+name+"-safe") {
|
||||
t.Fatalf("summary payload omitted safe option for %q: %s", name, text)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(text, "[REDACTED]") {
|
||||
t.Fatalf("summary payload contains no redaction marker: %s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactedResolvedPipelinePayloadHandlesTypedOptionContainers(t *testing.T) {
|
||||
type optionMap map[string]string
|
||||
type optionList []optionMap
|
||||
|
||||
@@ -106,11 +106,16 @@ func validatePipelineProfiles(profiles map[string]pipeline.PipelineProfile) erro
|
||||
if err := validateReferenceMap(id, "", profile.References); err != nil {
|
||||
return err
|
||||
}
|
||||
seenLanes := make(map[string]struct{}, len(profile.Artifacts))
|
||||
for rawLaneID, lane := range profile.Artifacts {
|
||||
laneID := strings.TrimSpace(rawLaneID)
|
||||
if laneID == "" {
|
||||
return fmt.Errorf("pipeline %q artifact lane id must not be empty", id)
|
||||
}
|
||||
if _, ok := seenLanes[laneID]; ok {
|
||||
return fmt.Errorf("pipeline %q artifact lane id %q is duplicated after trimming", id, laneID)
|
||||
}
|
||||
seenLanes[laneID] = struct{}{}
|
||||
if err := validateReferenceMap(id, laneID, lane.References); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
446
internal/core/config/validation_contract_test.go
Normal file
446
internal/core/config/validation_contract_test.go
Normal file
@@ -0,0 +1,446 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
)
|
||||
|
||||
func TestValidateConcurrencyRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*Config)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "non-positive total",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.TotalLLM = 0
|
||||
},
|
||||
want: "total LLM concurrency must be greater than zero",
|
||||
},
|
||||
{
|
||||
name: "worker below one",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.TotalLLM = 3
|
||||
cfg.Concurrency.StageWorkers = map[string]int{"extract": 0}
|
||||
cfg.Concurrency.extractWorkersConfigured = true
|
||||
},
|
||||
want: "stage_workers.extract must be between 1",
|
||||
},
|
||||
{
|
||||
name: "worker above total",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.TotalLLM = 3
|
||||
cfg.Concurrency.StageWorkers = map[string]int{"extract": 4}
|
||||
cfg.Concurrency.extractWorkersConfigured = true
|
||||
},
|
||||
want: "stage_workers.extract must be between 1",
|
||||
},
|
||||
{
|
||||
name: "worker lower boundary",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.TotalLLM = 3
|
||||
cfg.Concurrency.StageWorkers = map[string]int{"extract": 1}
|
||||
cfg.Concurrency.extractWorkersConfigured = true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "worker upper boundary",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.TotalLLM = 3
|
||||
cfg.Concurrency.StageWorkers = map[string]int{"extract": 3}
|
||||
cfg.Concurrency.extractWorkersConfigured = true
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown worker key",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.StageWorkers = map[string]int{"worker": 1}
|
||||
},
|
||||
want: "stage_workers key \"worker\" is not supported",
|
||||
},
|
||||
{
|
||||
name: "blank worker key",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Concurrency.StageWorkers = map[string]int{" ": 1}
|
||||
},
|
||||
want: "stage_workers key must not be empty",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tt.setup(&cfg)
|
||||
err := cfg.Validate()
|
||||
if tt.want == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("Validate() error = %v, want context %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateScriptoriumSourcesAreMutuallyExclusive(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.Scriptorium = ScriptoriumConfig{ProfileDir: "./profiles", ProfileFile: "./profile.yml"}
|
||||
assertValidationContains(t, cfg, "scriptorium profile_dir and profile_file are mutually exclusive")
|
||||
}
|
||||
|
||||
func TestValidateStateSurfaceRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*Config)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "blank output root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Output.Directory = " "
|
||||
},
|
||||
want: "output.directory must not be empty",
|
||||
},
|
||||
{
|
||||
name: "blank debug root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Debug.Directory = " "
|
||||
},
|
||||
want: "debug.directory must not be empty",
|
||||
},
|
||||
{
|
||||
name: "NUL in output root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Output.Directory = "./out\x00put"
|
||||
},
|
||||
want: "output.directory must not contain NUL",
|
||||
},
|
||||
{
|
||||
name: "NUL in chunk plan root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Cache.ChunkPlans.Directory = "./plans\x00"
|
||||
},
|
||||
want: "cache.chunk_plans.directory must not contain NUL",
|
||||
},
|
||||
{
|
||||
name: "NUL in checkpoint root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Cache.Checkpoints.Directory = "./checkpoints\x00"
|
||||
},
|
||||
want: "cache.checkpoints.directory must not contain NUL",
|
||||
},
|
||||
{
|
||||
name: "NUL in debug root",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Debug.Directory = "./debug\x00"
|
||||
},
|
||||
want: "debug.directory must not contain NUL",
|
||||
},
|
||||
{
|
||||
name: "invalid chunk plan mode",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Cache.ChunkPlans.Mode = pipeline.ChunkCacheMode("invalid")
|
||||
},
|
||||
want: "cache.chunk_plans.mode:",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tt.setup(&cfg)
|
||||
assertValidationContains(t, cfg, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateIdentifiersAfterTrimming(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*Config)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty pipeline id",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{" ": {}}
|
||||
},
|
||||
want: "pipeline id must not be empty",
|
||||
},
|
||||
{
|
||||
name: "duplicate pipeline ids",
|
||||
setup: func(cfg *Config) {
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": {}, " main ": {}}
|
||||
},
|
||||
want: "pipeline id \"main\" is duplicated after trimming",
|
||||
},
|
||||
{
|
||||
name: "empty lane id",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.Artifacts = map[string]pipeline.ArtifactLaneProfile{" ": {}}
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "artifact lane id must not be empty",
|
||||
},
|
||||
{
|
||||
name: "duplicate lane ids",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.Artifacts = map[string]pipeline.ArtifactLaneProfile{"spells": {}, " spells ": {}}
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "artifact lane id \"spells\" is duplicated after trimming",
|
||||
},
|
||||
{
|
||||
name: "empty reference slot",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.References = map[string]string{" ": "source.txt"}
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "reference slot name must not be empty",
|
||||
},
|
||||
{
|
||||
name: "duplicate reference slots",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.References = map[string]string{"slot": "one.txt", " slot ": "two.txt"}
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "reference slot \"slot\" is duplicated after trimming",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tt.setup(&cfg)
|
||||
assertValidationContains(t, cfg, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateBindingRetriesAndProfiles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*Config)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "negative retries",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.Input.Retries = -1
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "input retries must be greater than or equal to zero",
|
||||
},
|
||||
{
|
||||
name: "whitespace-only input profile",
|
||||
setup: func(cfg *Config) {
|
||||
profile := validationProfile()
|
||||
profile.Input.LLMProfile = " "
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
},
|
||||
want: "input llm_profile must not be empty when set",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := Default()
|
||||
tt.setup(&cfg)
|
||||
assertValidationContains(t, cfg, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReferencesAreUnsupportedOnInputAndOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
set func(*pipeline.PipelineProfile)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "input references",
|
||||
set: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Input.References = map[string]string{"slot": "source.txt"}
|
||||
},
|
||||
want: "input references are not supported",
|
||||
},
|
||||
{
|
||||
name: "output references",
|
||||
set: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Output.References = map[string]string{"slot": "source.txt"}
|
||||
},
|
||||
want: "output references are not supported",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := validationProfile()
|
||||
tt.set(&profile)
|
||||
cfg := Default()
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
assertValidationContains(t, cfg, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateValidatorBindingRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setup func(*pipeline.PipelineProfile)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty validator module",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{}},
|
||||
}
|
||||
},
|
||||
want: "chunk validators[0] module must not be empty",
|
||||
},
|
||||
{
|
||||
name: "validator retries",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{
|
||||
Module: "validator",
|
||||
Retries: 1,
|
||||
}},
|
||||
}
|
||||
},
|
||||
want: "chunk validators[0] retries are not supported",
|
||||
},
|
||||
{
|
||||
name: "validator references",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{
|
||||
Module: "validator",
|
||||
References: map[string]string{"slot": "source.txt"},
|
||||
}},
|
||||
}
|
||||
},
|
||||
want: "chunk validators[0] references are not supported",
|
||||
},
|
||||
{
|
||||
name: "nested validators",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Chunk.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{
|
||||
Module: "validator",
|
||||
Validators: pipeline.ValidatorOverride{Set: true},
|
||||
}},
|
||||
}
|
||||
},
|
||||
want: "chunk validators[0] nested validators are not supported",
|
||||
},
|
||||
{
|
||||
name: "input validator chain",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Input.Validators = pipeline.ValidatorOverride{Set: true}
|
||||
},
|
||||
want: "input validators are not supported",
|
||||
},
|
||||
{
|
||||
name: "output validator chain",
|
||||
setup: func(profile *pipeline.PipelineProfile) {
|
||||
profile.Output.Validators = pipeline.ValidatorOverride{Set: true}
|
||||
},
|
||||
want: "output validators are not supported",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := validationProfile()
|
||||
tt.setup(&profile)
|
||||
cfg := Default()
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
assertValidationContains(t, cfg, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateLaneValidatorCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lane func(*pipeline.ArtifactLaneProfile)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "deprecated non-empty lane validators",
|
||||
lane: func(lane *pipeline.ArtifactLaneProfile) {
|
||||
lane.Validators = []pipeline.ModuleBinding{{Module: "old-validator"}}
|
||||
},
|
||||
want: "validators are not supported at artifact lane level",
|
||||
},
|
||||
{
|
||||
name: "stage validators omitted",
|
||||
lane: func(lane *pipeline.ArtifactLaneProfile) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stage validators explicitly empty",
|
||||
lane: func(lane *pipeline.ArtifactLaneProfile) {
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{Set: true}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "stage validators configured",
|
||||
lane: func(lane *pipeline.ArtifactLaneProfile) {
|
||||
lane.Extract.Validators = pipeline.ValidatorOverride{
|
||||
Set: true,
|
||||
Validators: []pipeline.ModuleBinding{{Module: "validator"}},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
profile := validationProfile()
|
||||
lane := profile.Artifacts["lane"]
|
||||
tt.lane(&lane)
|
||||
profile.Artifacts["lane"] = lane
|
||||
cfg := Default()
|
||||
cfg.Pipelines = map[string]pipeline.PipelineProfile{"main": profile}
|
||||
err := cfg.Validate()
|
||||
if tt.want == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("Validate() error = %v, want context %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func validationProfile() pipeline.PipelineProfile {
|
||||
return pipeline.PipelineProfile{
|
||||
ID: "main",
|
||||
Input: pipeline.Binding("input"),
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"lane": {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func assertValidationContains(t *testing.T, cfg Config, want string) {
|
||||
t.Helper()
|
||||
err := cfg.Validate()
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("Validate() error = %v, want context %q", err, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user