feat(chart): aura chart <family> overlays a family's members; --tap + write-guard
`aura chart <name>` now branches on the name's on-disk kind: a single run charts
its taps as before; a FAMILY overlays one tap (default equity) across all its
members in one self-contained uPlot page, consistent across sweep / MC /
walk-forward. Members share ONE y-scale (they measure one quantity — that is what
makes the overlay comparable, unlike the single-run overlay of different taps),
and align on the union-ts spine via the existing join_on_ts: sweep/MC members
share the window (true overlay), walk-forward members are disjoint OOS windows
(null-complementary -> the stitched curve). One mechanism, three correct readings.
- build_comparison_chart_data (one labelled Series per member, shared y_scale_id);
filter_to_tap (single-run --tap restricts to one tap; no --tap = all taps,
unchanged); parse_chart_args (`<name> [--tap <t>] [--panels]`, any order);
emit_chart name-kind branch; USAGE updated.
- Write-guard: ensure_name_free is now called once per tracing command (run /
run --macd / run --real -> Run; sweep / mc / walkforward -> Family) so a name
cannot be reused across kinds — name resolution stays a total function.
Refuse-don't-guess throughout (exit 2, never panic). Engine untouched (C9/C14).
Tasks 2-3 of plan 0061; Task 1 (registry) landed in 9b2adcb.
Verified: cargo test --workspace green (no failures; new unit + integration tests
incl. family-overlay, single-run --tap filter, cross-kind collision RED-then-green),
cargo clippy --workspace --all-targets -D warnings clean, cargo build --workspace
clean. Benign clippy-forced deltas vs the literal plan (no behaviour change): a
local MemberRows type alias (type_complexity), let-chains in the six guards
(collapsible_if), and dropping the now-dead TraceStoreError import.
closes #107
This commit is contained in:
@@ -467,6 +467,55 @@ fn chart_panels_flag_selects_panels_mode() {
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: `--tap <nonexistent>` on a single-run chart is a CLEAN refusal at the
|
||||
/// CLI seam — refuse-don't-guess (C10). The builder's no-tap `Err` is unit-tested,
|
||||
/// but this pins the user-facing wiring: `filter_to_tap` returning `Err` must reach
|
||||
/// `eprintln!` + `exit(2)`, never a panic and never a chart of zero series. A
|
||||
/// regression that swallowed the `Err` (e.g. charting an empty `ChartData`) would
|
||||
/// exit 0 with an empty page; this fails it. Archive-local (persists its own run).
|
||||
#[test]
|
||||
fn chart_tap_nonexistent_on_run_refuses_with_exit_2() {
|
||||
let cwd = temp_cwd("chart-tap-missing");
|
||||
|
||||
// persist a single run; its taps are `equity` / `exposure`, never `nope`.
|
||||
let traced = Command::new(BIN).args(["run", "--trace", "demo"]).current_dir(&cwd).output().expect("spawn run --trace");
|
||||
assert!(traced.status.success(), "run --trace exit: {:?}", traced.status);
|
||||
|
||||
let out = Command::new(BIN).args(["chart", "demo", "--tap", "nope"]).current_dir(&cwd).output().expect("spawn chart --tap");
|
||||
assert_eq!(out.status.code(), Some(2), "nonexistent tap must exit 2: {:?}", out.status);
|
||||
assert!(out.stdout.is_empty(), "the refusal must not leak a chart page to stdout, got: {:?}", String::from_utf8_lossy(&out.stdout));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("run has no tap named 'nope'"),
|
||||
"expected the no-such-tap refusal message, got: {stderr}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: an unknown `chart <name>` is a usage error pinned at BOTH the exit code
|
||||
/// and the user-facing message — exit 2 (never a panic) AND the NotFound branch's
|
||||
/// "no recorded run or family '<name>'" wording, which now covers families too. A
|
||||
/// regression that drifted the message (e.g. back to the run-only phrasing) or
|
||||
/// downgraded the exit would slip past the bundled exit-only check; this pins the
|
||||
/// message/exit contract. Archive-local: charts into an empty cwd so the name is
|
||||
/// genuinely absent.
|
||||
#[test]
|
||||
fn chart_unknown_name_refuses_with_exit_2_and_message() {
|
||||
let cwd = temp_cwd("chart-unknown");
|
||||
|
||||
let out = Command::new(BIN).args(["chart", "ghost"]).current_dir(&cwd).output().expect("spawn chart ghost");
|
||||
assert_eq!(out.status.code(), Some(2), "unknown name must exit 2: {:?}", out.status);
|
||||
assert!(out.stdout.is_empty(), "the refusal must not leak a chart page to stdout, got: {:?}", String::from_utf8_lossy(&out.stdout));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("no recorded run or family 'ghost'"),
|
||||
"expected the unknown-name (run-or-family) refusal message, got: {stderr}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_args_prints_usage_and_exits_two() {
|
||||
let out = Command::new(BIN).output().expect("spawn aura");
|
||||
@@ -1199,3 +1248,132 @@ fn family_window_flag_without_real_refuses_with_usage_exit_2() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Property: `aura chart <family>` overlays one series per family member, labelled
|
||||
/// by member key. The built-in sweep grid is fast∈{2,3} × slow∈{4,5} = 4 members;
|
||||
/// each member key appears as a series name in the injected AURA_TRACES.
|
||||
#[test]
|
||||
fn chart_of_a_family_overlays_one_series_per_member() {
|
||||
let cwd = temp_cwd("chart-family");
|
||||
let swept = Command::new(BIN)
|
||||
.args(["sweep", "--trace", "fam"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn sweep --trace");
|
||||
assert!(swept.status.success(), "sweep --trace exit: {:?}", swept.status);
|
||||
|
||||
let chart = Command::new(BIN).args(["chart", "fam"]).current_dir(&cwd).output().expect("spawn chart fam");
|
||||
assert!(chart.status.success(), "chart family exit: {:?}", chart.status);
|
||||
let html = String::from_utf8(chart.stdout).expect("utf-8 html");
|
||||
assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected");
|
||||
for key in [
|
||||
"signals.trend.fast.length-2_signals.trend.slow.length-4",
|
||||
"signals.trend.fast.length-2_signals.trend.slow.length-5",
|
||||
"signals.trend.fast.length-3_signals.trend.slow.length-4",
|
||||
"signals.trend.fast.length-3_signals.trend.slow.length-5",
|
||||
] {
|
||||
assert!(html.contains(key), "member series '{key}' missing from the family chart");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: the single-run chart path is unchanged — no `--tap` shows ALL taps
|
||||
/// (equity + exposure); `--tap equity` filters to one. A regression net for the
|
||||
/// "single-run byte-unchanged when no --tap" acceptance bullet.
|
||||
#[test]
|
||||
fn chart_single_run_tap_filter_keeps_only_the_named_tap() {
|
||||
let cwd = temp_cwd("chart-tap");
|
||||
let traced = Command::new(BIN).args(["run", "--trace", "solo"]).current_dir(&cwd).output().expect("spawn run --trace");
|
||||
assert!(traced.status.success(), "run --trace exit: {:?}", traced.status);
|
||||
|
||||
// no --tap: both taps present (unchanged behaviour).
|
||||
let all = Command::new(BIN).args(["chart", "solo"]).current_dir(&cwd).output().expect("spawn chart");
|
||||
assert!(all.status.success());
|
||||
let all_html = String::from_utf8(all.stdout).expect("utf-8");
|
||||
assert!(all_html.contains("\"equity\""), "equity series missing");
|
||||
assert!(all_html.contains("\"exposure\""), "exposure series missing");
|
||||
|
||||
// --tap equity: only equity remains.
|
||||
let one = Command::new(BIN).args(["chart", "solo", "--tap", "equity"]).current_dir(&cwd).output().expect("spawn chart --tap");
|
||||
assert!(one.status.success(), "chart --tap exit: {:?}", one.status);
|
||||
let one_html = String::from_utf8(one.stdout).expect("utf-8");
|
||||
assert!(one_html.contains("\"equity\""), "equity series missing under --tap");
|
||||
assert!(!one_html.contains("\"exposure\""), "--tap equity must drop exposure");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: a trace name cannot be reused across kinds — the write-guard refuses
|
||||
/// it (exit 2), keeping `aura chart <name>` resolution a total function.
|
||||
#[test]
|
||||
fn trace_name_collision_across_kinds_is_refused() {
|
||||
let cwd = temp_cwd("collision");
|
||||
|
||||
// run then sweep on the same name -> the sweep is refused.
|
||||
let run = Command::new(BIN).args(["run", "--trace", "t"]).current_dir(&cwd).output().expect("spawn run --trace");
|
||||
assert!(run.status.success(), "run --trace exit: {:?}", run.status);
|
||||
let sweep = Command::new(BIN).args(["sweep", "--trace", "t"]).current_dir(&cwd).output().expect("spawn sweep --trace");
|
||||
assert_eq!(sweep.status.code(), Some(2), "sweep onto a run name must exit 2");
|
||||
|
||||
// reverse: sweep then run on a fresh name -> the run is refused.
|
||||
let sweep2 = Command::new(BIN).args(["sweep", "--trace", "u"]).current_dir(&cwd).output().expect("spawn sweep --trace u");
|
||||
assert!(sweep2.status.success(), "sweep --trace u exit: {:?}", sweep2.status);
|
||||
let run2 = Command::new(BIN).args(["run", "--trace", "u"]).current_dir(&cwd).output().expect("spawn run --trace u");
|
||||
assert_eq!(run2.status.code(), Some(2), "run onto a family name must exit 2");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: the write-guard refuses ONLY cross-kind name reuse — a SAME-kind
|
||||
/// overwrite stays Ok. Re-running `aura sweep --trace <name>` onto a name already
|
||||
/// held by a family succeeds (exit 0), so the guard does not over-broadly forbid
|
||||
/// all reuse. Without this the collision test alone would pass even if the guard
|
||||
/// degenerated into "refuse every existing name", silently breaking re-tracing.
|
||||
#[test]
|
||||
fn same_kind_overwrite_is_allowed_by_the_write_guard() {
|
||||
let cwd = temp_cwd("same-kind-overwrite");
|
||||
|
||||
let first = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace fam");
|
||||
assert!(first.status.success(), "first sweep --trace exit: {:?}", first.status);
|
||||
|
||||
// Same name, same kind (family -> family): the guard must NOT refuse it.
|
||||
let again = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace fam again");
|
||||
assert_eq!(again.status.code(), Some(0), "same-kind family overwrite must stay Ok, got: {:?}; stderr: {}", again.status, String::from_utf8_lossy(&again.stderr));
|
||||
|
||||
// And the re-traced family is still chartable (name resolution unbroken).
|
||||
let chart = Command::new(BIN).args(["chart", "fam"]).current_dir(&cwd).output().expect("spawn chart fam");
|
||||
assert!(chart.status.success(), "chart after overwrite exit: {:?}", chart.status);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: the family overlay honours a non-default `--tap` — `chart <family>
|
||||
/// --tap exposure` overlays the exposure tap of every member, not the default
|
||||
/// equity. Pins the family branch's `tap.unwrap_or("equity")` selection: a
|
||||
/// regression that ignored `--tap` on families (always equity) would still pass
|
||||
/// the default-equity family test, so the explicit non-default tap is its own net.
|
||||
#[test]
|
||||
fn chart_family_with_tap_overlays_the_named_tap_per_member() {
|
||||
let cwd = temp_cwd("chart-family-tap");
|
||||
|
||||
let swept = Command::new(BIN).args(["sweep", "--trace", "fam"]).current_dir(&cwd).output().expect("spawn sweep --trace");
|
||||
assert!(swept.status.success(), "sweep --trace exit: {:?}", swept.status);
|
||||
|
||||
let chart = Command::new(BIN).args(["chart", "fam", "--tap", "exposure"]).current_dir(&cwd).output().expect("spawn chart fam --tap exposure");
|
||||
assert!(chart.status.success(), "chart family --tap exposure exit: {:?}", chart.status);
|
||||
let html = String::from_utf8(chart.stdout).expect("utf-8 html");
|
||||
assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected");
|
||||
// One series per member, keyed by member key — the exposure overlay still
|
||||
// spans all four grid points (the tap selection changes the column, not the
|
||||
// member set).
|
||||
for key in [
|
||||
"signals.trend.fast.length-2_signals.trend.slow.length-4",
|
||||
"signals.trend.fast.length-2_signals.trend.slow.length-5",
|
||||
"signals.trend.fast.length-3_signals.trend.slow.length-4",
|
||||
"signals.trend.fast.length-3_signals.trend.slow.length-5",
|
||||
] {
|
||||
assert!(html.contains(key), "member series '{key}' missing from the --tap exposure family chart");
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user