feat(trace-charts): serve persisted traces as a web chart (iter 2)
Iteration 2 (serve half) of the visual face — spec 0056 / plan 0056:
read a persisted run's traces and serve them as a self-contained uPlot
HTML chart. Completes the goal "tap data from a graph, serve it as a
chart in the browser".
- chart-viewer.js (first-party): a pure buildCharts(traces, mode) that
maps the injected window.AURA_TRACES to uPlot configs (no DOM) — overlay
= one plot, every series on its own y-scale over the shared xs; panels =
one plot per series, all on the same timestamp x. A browser-only mount()
instantiates the vendored global uPlot. Guarded by a node .mjs DOM test
(+ a sparse/gaps variant) shelling out exactly like viewer_dot.rs.
- render_chart_html + ChartMode/ChartData/Series (aura-cli/render.rs):
mirrors render_html — self-contained page, uPlot (1.6.32, vendored
d534f10) + chart-viewer.js inlined via include_str!, data injected as
window.AURA_TRACES.
- emit_chart + build_chart_data (aura-cli/main.rs): the spec-§6 3-step
union-spine alignment — xs = sorted-dedup union of every tap's ts; a
synthetic empty-payload spine + ALL taps as symmetric join_on_ts sides
(no tap privileged, no point dropped); flatten each (tap,column) to a
Series of Option<f64>. New arms `aura chart <name> [--panels]`; a
missing run is stderr + exit 2 (TraceStoreError::NotFound), never a
panic. USAGE advertises the verb in lockstep.
Verified by the orchestrator: `cargo test --workspace` green (incl. the
node guards, render_chart_html, and the chart e2e); `cargo clippy
--workspace --all-targets -D warnings` exit 0; and a real end-to-end —
`aura run --trace demo && aura chart demo` emits a 57KB self-contained
page (doctype + inlined uPlot + AURA_TRACES carrying the equity/exposure
series), `--panels` reflects the panels mode, `aura chart nope` exits 2.
Implementer deviation folded in (compiler-prescribed): serde added to
aura-cli/Cargo.toml for the Series derive (Cargo.lock updated).
refs #101
This commit is contained in:
Generated
+1
@@ -56,6 +56,7 @@ dependencies = [
|
||||
"aura-registry",
|
||||
"aura-std",
|
||||
"data-server",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ aura-ingest = { path = "../aura-ingest" }
|
||||
# data-server: the local M1 archive `aura run --real` streams from. Mirrors the
|
||||
# git line in crates/aura-ingest/Cargo.toml verbatim (same source of truth).
|
||||
data-server = { git = "http://192.168.178.103:3000/Brummel/data-server.git", branch = "main" }
|
||||
# serde_json renders the walk-forward summary line (MetricStats + stitched total);
|
||||
# serde derives Serialize on the chart-trace payload (Series) so render_chart_html
|
||||
# can embed it via serde_json::json!; admitted under the per-case dependency policy,
|
||||
# same as aura-engine.
|
||||
serde = { workspace = true }
|
||||
# serde_json renders the walk-forward summary line (MetricStats + stitched total)
|
||||
# and the injected chart traces (window.AURA_TRACES);
|
||||
# admitted under the per-case dependency policy, same as aura-engine.
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// The `aura chart` viewer: turns the injected window.AURA_TRACES (xs + per-series
|
||||
// Option<f64> points, already timestamp-union-aligned by the CLI) into uPlot charts.
|
||||
//
|
||||
// buildCharts is PURE (no DOM, no uPlot) and is the unit the headless guard drives.
|
||||
// mount() is browser-only — it instantiates the vendored global `uPlot` per config.
|
||||
(function () {
|
||||
var PALETTE = ["#89b4fa", "#f38ba8", "#a6e3a1", "#f9e2af", "#cba6f7", "#94e2d5"];
|
||||
|
||||
// {xs, series:[{name, y_scale_id, points}]} + mode -> array of {opts, data} configs.
|
||||
// overlay -> one chart, all series, each on its own auto-ranged y-scale (shared xs).
|
||||
// panels -> one chart per series, each on the SAME shared xs (timestamp-aligned).
|
||||
function buildCharts(traces, mode) {
|
||||
var xs = traces.xs;
|
||||
var series = traces.series;
|
||||
if (mode === "panels") {
|
||||
return series.map(function (s, i) {
|
||||
return {
|
||||
opts: {
|
||||
title: s.name,
|
||||
scales: { x: { time: false }, [s.y_scale_id]: {} },
|
||||
series: [{}, { label: s.name, scale: s.y_scale_id, stroke: PALETTE[i % PALETTE.length] }],
|
||||
axes: [{}, { scale: s.y_scale_id }],
|
||||
},
|
||||
data: [xs, s.points],
|
||||
};
|
||||
});
|
||||
}
|
||||
// overlay (default)
|
||||
var scales = { x: { time: false } };
|
||||
var uSeries = [{}];
|
||||
var axes = [{}];
|
||||
series.forEach(function (s, i) {
|
||||
scales[s.y_scale_id] = {};
|
||||
uSeries.push({ label: s.name, scale: s.y_scale_id, stroke: PALETTE[i % PALETTE.length] });
|
||||
// the first two series carry a drawn axis (left, then right); the rest keep
|
||||
// their own auto-scale, read via the legend.
|
||||
if (i < 2) axes.push({ scale: s.y_scale_id, side: i === 0 ? 3 : 1 });
|
||||
});
|
||||
return [{ opts: { scales: scales, series: uSeries, axes: axes }, data: [xs].concat(series.map(function (s) { return s.points; })) }];
|
||||
}
|
||||
|
||||
// Browser-only: instantiate the vendored global uPlot per config into #stage.
|
||||
function mount() {
|
||||
var traces = window.AURA_TRACES;
|
||||
var configs = buildCharts(traces, traces.mode);
|
||||
var stage = document.getElementById("stage");
|
||||
var w = stage.clientWidth || 900;
|
||||
var h = configs.length > 1 ? Math.max(160, Math.floor(440 / configs.length)) : 460;
|
||||
configs.forEach(function (cfg) {
|
||||
var opts = Object.assign({ width: w, height: h }, cfg.opts);
|
||||
// eslint-disable-next-line no-undef
|
||||
new uPlot(opts, cfg.data, stage);
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && typeof document !== "undefined" && window.AURA_TRACES) {
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", mount);
|
||||
else mount();
|
||||
}
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = { buildCharts: buildCharts };
|
||||
})();
|
||||
@@ -12,17 +12,18 @@
|
||||
//! first real-data backtest from the CLI.
|
||||
|
||||
mod render;
|
||||
use render::{ChartData, ChartMode, Series};
|
||||
|
||||
use aura_core::{zip_params, Cell, Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{
|
||||
f64_field, monte_carlo, param_stability, summarize, walk_forward, window_of, ColumnarTrace,
|
||||
Composite, Edge, FlatGraph, GraphBuilder, Harness, McAggregate, McFamily, RollMode,
|
||||
RunManifest, RunReport, SourceSpec, SweepFamily, SyntheticSpec, Target, VecSource,
|
||||
WalkForwardResult, WindowBounds, WindowRoller, WindowRun,
|
||||
f64_field, join_on_ts, monte_carlo, param_stability, summarize, walk_forward, window_of,
|
||||
ColumnarTrace, Composite, Edge, FlatGraph, GraphBuilder, Harness, JoinedRow, McAggregate,
|
||||
McFamily, RollMode, RunManifest, RunReport, SourceSpec, SweepFamily, SyntheticSpec, Target,
|
||||
VecSource, WalkForwardResult, WindowBounds, WindowRoller, WindowRun,
|
||||
};
|
||||
use aura_registry::{
|
||||
group_families, mc_member_reports, optimize, rank_by, sweep_member_reports,
|
||||
walkforward_member_reports, FamilyKind, Registry, TraceStore,
|
||||
walkforward_member_reports, FamilyKind, Registry, RunTraces, TraceStore, TraceStoreError,
|
||||
};
|
||||
use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
@@ -173,6 +174,56 @@ fn persist_traces(
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the serve-ready `ChartData` from a run's read-back traces by the spec-§6
|
||||
/// 3-step union-spine alignment — no tap privileged, no point dropped:
|
||||
/// (1) xs = the sorted, deduped union of every tap's timestamps;
|
||||
/// (2) synthesize an empty-payload spine over xs and pass ALL taps (via
|
||||
/// `ColumnarTrace::to_rows`, which yields uniformly-f64 rows) as symmetric sides
|
||||
/// of `join_on_ts`, so no side row is dropped and none occupies the privileged
|
||||
/// `JoinedRow.spine`;
|
||||
/// (3) flatten each (tap, column) to a `Series` of `Option<f64>` over xs.
|
||||
fn build_chart_data(traces: RunTraces) -> ChartData {
|
||||
let mut xs: Vec<i64> = traces.taps.iter().flat_map(|t| t.ts.iter().copied()).collect();
|
||||
xs.sort_unstable();
|
||||
xs.dedup();
|
||||
|
||||
let spine: Vec<(Timestamp, Vec<Scalar>)> = xs.iter().map(|&t| (Timestamp(t), Vec::new())).collect();
|
||||
let tap_rows: Vec<Vec<(Timestamp, Vec<Scalar>)>> = traces.taps.iter().map(|t| t.to_rows()).collect();
|
||||
let sides: Vec<&[(Timestamp, Vec<Scalar>)]> = tap_rows.iter().map(|r| r.as_slice()).collect();
|
||||
let joined: Vec<JoinedRow> = join_on_ts(&spine, &sides);
|
||||
|
||||
let mut series: Vec<Series> = Vec::new();
|
||||
for (i, tap) in traces.taps.iter().enumerate() {
|
||||
for c in 0..tap.columns.len() {
|
||||
let name = if tap.columns.len() == 1 { tap.tap.clone() } else { format!("{}[{c}]", tap.tap) };
|
||||
let y_scale_id = format!("y_{}", series.len());
|
||||
let points: Vec<Option<f64>> =
|
||||
joined.iter().map(|r| r.sides[i].as_ref().map(|row| row[c].as_f64())).collect();
|
||||
series.push(Series { name, y_scale_id, points });
|
||||
}
|
||||
}
|
||||
|
||||
ChartData { xs, series }
|
||||
}
|
||||
|
||||
/// `aura chart <name> [--panels]`: read the persisted run's traces, align them, and
|
||||
/// print the self-contained uPlot page. A missing run is a usage error (stderr +
|
||||
/// exit 2), per the CLI's convention — never a panic.
|
||||
fn emit_chart(name: &str, mode: ChartMode) {
|
||||
let traces = match TraceStore::open("runs").read(name) {
|
||||
Ok(t) => t,
|
||||
Err(TraceStoreError::NotFound(n)) => {
|
||||
eprintln!("aura: no recorded run '{n}' under runs/traces (run `aura run --trace {n}` first)");
|
||||
std::process::exit(2);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("aura: {e}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
print!("{}", render::render_chart_html(&build_chart_data(traces), mode));
|
||||
}
|
||||
|
||||
/// Run the sample harness and fold it into a `RunReport` (drain both sinks →
|
||||
/// `f64_field` → `summarize` → pair with a `RunManifest`). Pure and deterministic
|
||||
/// (C1): the same build yields the same report.
|
||||
@@ -875,7 +926,7 @@ fn run_macd(trace: Option<&str>) -> RunReport {
|
||||
}
|
||||
|
||||
const USAGE: &str =
|
||||
"usage: aura run [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura graph | aura sweep [--name <n>] | aura mc [--name <n>] | aura walkforward [--name <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
"usage: aura run [--macd] [--trace <name>] | aura run --real <SYMBOL> [--from <ms>] [--to <ms>] [--trace <name>] | aura chart <name> [--panels] | aura graph | aura sweep [--name <n>] | aura mc [--name <n>] | aura walkforward [--name <n>] | aura runs families | aura runs family <id> [rank <metric>]";
|
||||
|
||||
fn main() {
|
||||
// Collect argv and match the whole vector: every accepted form is exhaustive,
|
||||
@@ -896,6 +947,8 @@ fn main() {
|
||||
std::process::exit(2);
|
||||
}
|
||||
},
|
||||
["chart", name] => emit_chart(name, ChartMode::Overlay),
|
||||
["chart", name, "--panels"] => emit_chart(name, ChartMode::Panels),
|
||||
["graph"] => print!("{}", render::render_html(&sample_blueprint())),
|
||||
["sweep"] => run_sweep("sweep"),
|
||||
["sweep", "--name", n] => run_sweep(n),
|
||||
|
||||
@@ -8,16 +8,18 @@
|
||||
|
||||
use aura_engine::Composite;
|
||||
|
||||
/// The prototype's `<head>` shell + body skeleton (header, stage, tooltip, error
|
||||
/// pane), verbatim from `docs/design/prototypes/graph-render/index.html` lines
|
||||
/// 1-38 — minus the two `<script src="./…">` tags, since `render_html` inlines
|
||||
/// the assets instead.
|
||||
/// The shared `<head>` (charset, viewport, `<style>`) + the opening `<body>` tag,
|
||||
/// up to the per-page `<title>` slot. Derived from the prototype
|
||||
/// (`docs/design/prototypes/graph-render/index.html` lines 1-38), minus the two
|
||||
/// `<script src="./…">` tags (the assets are inlined). The `{title}` placeholder
|
||||
/// and the per-page `<header>` let one shell serve both the graph and the chart
|
||||
/// page without one borrowing the other's label.
|
||||
const SHELL_HEAD: &str = r#"<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>aura graph</title>
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: #16161a; color: #cdd6f4;
|
||||
font-family: ui-monospace, monospace; }
|
||||
@@ -42,7 +44,11 @@ const SHELL_HEAD: &str = r#"<!doctype html>
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
"#;
|
||||
|
||||
/// The graph page's `<title>` text and `<header>` line (hover/expand/drill hint),
|
||||
/// graph-specific — split out of the shared shell.
|
||||
const GRAPH_HEAD: &str = r#"<header>
|
||||
<b>aura graph</b>
|
||||
<span id="crumb"></span>
|
||||
<span class="sub">hover · [+] expand · body drill</span>
|
||||
@@ -52,10 +58,24 @@ const SHELL_HEAD: &str = r#"<!doctype html>
|
||||
<pre id="err"></pre>
|
||||
"#;
|
||||
|
||||
/// The chart page's `<title>` text and `<header>` line — describes what the chart
|
||||
/// view actually renders (timestamp-aligned trace series), not the graph viewer.
|
||||
const CHART_HEAD: &str = r#"<header>
|
||||
<b>aura chart</b>
|
||||
<span class="sub">timestamp-aligned trace series</span>
|
||||
</header>
|
||||
<div id="stage"></div>
|
||||
<pre id="err"></pre>
|
||||
"#;
|
||||
|
||||
const VIZ_JS: &str = include_str!("../assets/viz-standalone.js");
|
||||
const PANZOOM_JS: &str = include_str!("../assets/svg-pan-zoom.min.js");
|
||||
const VIEWER_JS: &str = include_str!("../assets/graph-viewer.js");
|
||||
|
||||
const UPLOT_JS: &str = include_str!("../assets/uPlot.iife.min.js");
|
||||
const UPLOT_CSS: &str = include_str!("../assets/uPlot.min.css");
|
||||
const CHART_VIEWER_JS: &str = include_str!("../assets/chart-viewer.js");
|
||||
|
||||
/// Assemble the self-contained interactive graph viewer page for `root`.
|
||||
///
|
||||
/// Order is load-bearing: the Graphviz-WASM and pan-zoom globals (`Viz`,
|
||||
@@ -64,9 +84,10 @@ const VIEWER_JS: &str = include_str!("../assets/graph-viewer.js");
|
||||
pub fn render_html(root: &Composite) -> String {
|
||||
let model = aura_engine::model_to_json(root);
|
||||
let mut html = String::with_capacity(
|
||||
SHELL_HEAD.len() + VIZ_JS.len() + PANZOOM_JS.len() + VIEWER_JS.len() + model.len() + 256,
|
||||
SHELL_HEAD.len() + GRAPH_HEAD.len() + VIZ_JS.len() + PANZOOM_JS.len() + VIEWER_JS.len() + model.len() + 256,
|
||||
);
|
||||
html.push_str(SHELL_HEAD);
|
||||
html.push_str(&SHELL_HEAD.replace("{title}", "aura graph"));
|
||||
html.push_str(GRAPH_HEAD);
|
||||
html.push_str("<script>");
|
||||
html.push_str(VIZ_JS);
|
||||
html.push_str("</script>\n<script>");
|
||||
@@ -79,6 +100,68 @@ pub fn render_html(root: &Composite) -> String {
|
||||
html
|
||||
}
|
||||
|
||||
/// Which way the chart draws the (timestamp-aligned) series.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum ChartMode {
|
||||
/// One plot, all series superimposed, each on its own y-scale.
|
||||
Overlay,
|
||||
/// One stacked plot per series, all sharing the timestamp x-axis.
|
||||
Panels,
|
||||
}
|
||||
|
||||
/// One chart series: a name, its own y-scale id (so disparate magnitudes stay
|
||||
/// legible), and one `Option<f64>` per shared-x slot (`null` = a gap where the tap
|
||||
/// did not fire at that timestamp).
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct Series {
|
||||
pub name: String,
|
||||
pub y_scale_id: String,
|
||||
pub points: Vec<Option<f64>>,
|
||||
}
|
||||
|
||||
/// The serve-ready chart payload: the shared timestamp axis and the per-(tap,column)
|
||||
/// series. These are exactly the fields `chart-viewer.js` reads; injected verbatim
|
||||
/// as `window.AURA_TRACES` (plus the `mode`). The run's manifest stays on disk in the
|
||||
/// trace store's `index.json`; it is not carried into the served page, which renders
|
||||
/// only the series, so nothing here is dead injected payload.
|
||||
pub struct ChartData {
|
||||
pub xs: Vec<i64>,
|
||||
pub series: Vec<Series>,
|
||||
}
|
||||
|
||||
/// Assemble the self-contained uPlot chart page for one run's aligned traces.
|
||||
///
|
||||
/// Order is load-bearing (mirrors `render_html`): the uPlot global and CSS, then the
|
||||
/// injected `window.AURA_TRACES`, must be defined before `chart-viewer.js` runs.
|
||||
pub fn render_chart_html(data: &ChartData, mode: ChartMode) -> String {
|
||||
let mode_str = match mode {
|
||||
ChartMode::Overlay => "overlay",
|
||||
ChartMode::Panels => "panels",
|
||||
};
|
||||
let traces = serde_json::json!({
|
||||
"mode": mode_str,
|
||||
"xs": data.xs,
|
||||
"series": data.series,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let mut html = String::with_capacity(
|
||||
SHELL_HEAD.len() + CHART_HEAD.len() + UPLOT_JS.len() + UPLOT_CSS.len() + CHART_VIEWER_JS.len() + traces.len() + 256,
|
||||
);
|
||||
html.push_str(&SHELL_HEAD.replace("{title}", "aura chart"));
|
||||
html.push_str(CHART_HEAD);
|
||||
html.push_str("<style>");
|
||||
html.push_str(UPLOT_CSS);
|
||||
html.push_str("</style>\n<script>");
|
||||
html.push_str(UPLOT_JS);
|
||||
html.push_str("</script>\n<script>window.AURA_TRACES = ");
|
||||
html.push_str(&traces);
|
||||
html.push_str(";</script>\n<script>");
|
||||
html.push_str(CHART_VIEWER_JS);
|
||||
html.push_str("</script>\n</body>\n</html>\n");
|
||||
html
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -95,6 +178,9 @@ mod tests {
|
||||
// a complete HTML document
|
||||
assert!(html.starts_with("<!doctype html>"), "not an HTML doc");
|
||||
assert!(html.trim_end().ends_with("</html>"), "HTML doc not closed");
|
||||
// the graph page wears its own label (not the chart's)
|
||||
assert!(html.contains("<title>aura graph</title>"), "graph page title missing");
|
||||
assert!(html.contains("<b>aura graph</b>"), "graph header label missing");
|
||||
// self-contained: every asset is inlined, no remote <script src>
|
||||
assert!(!html.contains("<script src"), "assets must be inlined, found a remote script tag");
|
||||
// the deterministic model is injected as the viewer's data source
|
||||
@@ -109,4 +195,57 @@ mod tests {
|
||||
"C4 four-colour palette missing"
|
||||
);
|
||||
}
|
||||
|
||||
fn sample_chart_data() -> ChartData {
|
||||
ChartData {
|
||||
xs: vec![1, 2, 3],
|
||||
series: vec![
|
||||
Series { name: "equity".into(), y_scale_id: "y_0".into(), points: vec![Some(0.0), Some(0.001), Some(0.004)] },
|
||||
Series { name: "exposure".into(), y_scale_id: "y_1".into(), points: vec![Some(1.0), Some(1.0), Some(-1.0)] },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_chart_html_is_self_contained_and_injects_traces() {
|
||||
let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay);
|
||||
assert!(html.starts_with("<!doctype html>"), "not an HTML doc");
|
||||
assert!(html.trim_end().ends_with("</html>"), "HTML doc not closed");
|
||||
// self-contained: no remote asset
|
||||
assert!(!html.contains("<script src"), "assets must be inlined");
|
||||
// data injected as the viewer's source, with the mode reflected
|
||||
assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected");
|
||||
assert!(html.contains("\"mode\":\"overlay\""), "overlay mode not reflected");
|
||||
assert!(html.contains("\"equity\""), "series name missing from injected data");
|
||||
// the vendored uPlot bundle + first-party viewer are inlined
|
||||
assert!(html.contains("uPlot=function"), "vendored uPlot bundle missing");
|
||||
assert!(html.contains("buildCharts"), "chart-viewer.js not inlined");
|
||||
}
|
||||
|
||||
/// The chart page labels itself as a chart, never borrowing the graph viewer's
|
||||
/// `<title>`/header/breadcrumb — a served chart must not claim to be a graph.
|
||||
#[test]
|
||||
fn render_chart_html_wears_its_own_chart_label() {
|
||||
let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay);
|
||||
assert!(html.contains("<title>aura chart</title>"), "chart page title not chart-specific");
|
||||
assert!(html.contains("<b>aura chart</b>"), "chart header label not chart-specific");
|
||||
// none of the graph viewer's chrome leaked in
|
||||
assert!(!html.contains("aura graph"), "graph label leaked onto the chart page");
|
||||
assert!(!html.contains("hover · [+] expand · body drill"), "graph breadcrumb leaked onto the chart page");
|
||||
}
|
||||
|
||||
/// `window.AURA_TRACES` carries only the fields `chart-viewer.js` reads
|
||||
/// (`mode`, `xs`, `series`) — no `manifest`/`taps` injected-but-unread payload.
|
||||
#[test]
|
||||
fn render_chart_html_injects_only_what_the_viewer_reads() {
|
||||
let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay);
|
||||
assert!(!html.contains("\"manifest\""), "unread manifest carried into the served chart");
|
||||
assert!(!html.contains("\"taps\""), "unread taps list carried into the served chart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_chart_html_reflects_panels_mode() {
|
||||
let html = render_chart_html(&sample_chart_data(), ChartMode::Panels);
|
||||
assert!(html.contains("\"mode\":\"panels\""), "panels mode not reflected");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Integration guard for the `aura chart` viewer's data transform.
|
||||
//!
|
||||
//! `chart-viewer.js` builds the uPlot configs in JavaScript, so the property
|
||||
//! "buildCharts maps AURA_TRACES to the right per-mode uPlot config shape" cannot
|
||||
//! be checked from Rust. This shells out to `node`, running
|
||||
//! `tests/chart_viewer_build.mjs`, which loads the real viewer module and asserts
|
||||
//! the overlay/panels config shape over a fixture — without instantiating uPlot
|
||||
//! (which needs a DOM). `node` is REQUIRED: if absent this test FAILS.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn chart_viewer_builds_overlay_and_panels_configs() {
|
||||
let script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("chart_viewer_build.mjs");
|
||||
assert!(script.exists(), "guard script missing at {}", script.display());
|
||||
|
||||
let out = match Command::new("node").arg(&script).output() {
|
||||
Ok(out) => out,
|
||||
Err(e) => panic!(
|
||||
"node is required for the chart-viewer guard but could not be run \
|
||||
({e}); install Node.js or ensure `node` is on PATH"
|
||||
),
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"chart-viewer guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}",
|
||||
out.status.code()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Headless guard for the trace-chart viewer (chart-viewer.js). Property protected:
|
||||
// buildCharts maps an AURA_TRACES payload to uPlot configs WITHOUT a DOM — overlay
|
||||
// is one chart with every series on its own y-scale over the shared xs; panels is
|
||||
// one chart per series, each on the SAME shared xs (timestamp-aligned). It loads the
|
||||
// real viewer module (a fix/regression there is observed here) and drives the
|
||||
// exported pure `buildCharts`; uPlot itself (which needs `document`) is never
|
||||
// instantiated — mount() is browser-only and not called here.
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const traces = JSON.parse(readFileSync(join(here, "fixtures", "sample-traces.json"), "utf8"));
|
||||
|
||||
const fail = (msg) => { console.error("FAIL: " + msg); process.exit(1); };
|
||||
const eq = (a, b, msg) => { if (a !== b) fail(`${msg} (got ${a}, want ${b})`); };
|
||||
const sameArr = (a, b, msg) => {
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || a.some((v, i) => v !== b[i])) fail(msg);
|
||||
};
|
||||
|
||||
// No `document` global -> chart-viewer.js's browser mount is skipped; require gives
|
||||
// us only the pure export (mirrors viewer_dot_ids.mjs stubbing window before require).
|
||||
global.window = { AURA_TRACES: traces };
|
||||
const { buildCharts } = require(join(here, "..", "assets", "chart-viewer.js"));
|
||||
|
||||
const n = traces.series.length;
|
||||
|
||||
// overlay: ONE chart, all series superimposed on the shared xs, each on its own scale.
|
||||
const overlay = buildCharts(traces, "overlay");
|
||||
eq(overlay.length, 1, "overlay is one chart");
|
||||
eq(overlay[0].data.length, 1 + n, "overlay data = xs + N series");
|
||||
eq(overlay[0].opts.series.length, 1 + n, "overlay uPlot series = x + N");
|
||||
sameArr(overlay[0].data[0], traces.xs, "overlay x-axis is the shared xs");
|
||||
const scaleIds = overlay[0].opts.series.slice(1).map((s) => s.scale);
|
||||
eq(new Set(scaleIds).size, n, "each overlay series has a distinct y-scale");
|
||||
|
||||
// panels: ONE chart per series, all timestamp-aligned on the SAME shared xs.
|
||||
const panels = buildCharts(traces, "panels");
|
||||
eq(panels.length, n, "panels = one chart per series");
|
||||
for (const p of panels) {
|
||||
eq(p.data.length, 2, "each panel = xs + its one series");
|
||||
sameArr(p.data[0], traces.xs, "panel shares the same xs (timestamp-aligned)");
|
||||
}
|
||||
|
||||
console.log(`OK — overlay 1 chart / ${n} series, panels ${n} charts, shared xs.`);
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,48 @@
|
||||
// Headless guard for the trace-chart viewer (chart-viewer.js) gap-handling.
|
||||
// Property protected: when taps fire at DIFFERENT cadences, the CLI's union-spine
|
||||
// alignment (spec 0056 §6) null-fills the timestamps a tap did NOT fire at, and
|
||||
// buildCharts must hand those nulls to uPlot VERBATIM as the data array — a null is
|
||||
// a rendered gap, never dropped, never coerced to 0. This is the observable end of
|
||||
// the "no tap's points dropped, no privileged spine" contract: a positional shift or
|
||||
// a `null -> 0` coercion would silently misalign every series past the first gap.
|
||||
//
|
||||
// It loads the real viewer module (a regression there is observed here) and drives
|
||||
// the exported pure `buildCharts` over a fixture whose two series have nulls at
|
||||
// DIFFERENT positions; uPlot itself (which needs `document`) is never instantiated.
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const traces = JSON.parse(readFileSync(join(here, "fixtures", "sparse-traces.json"), "utf8"));
|
||||
|
||||
const fail = (msg) => { console.error("FAIL: " + msg); process.exit(1); };
|
||||
const sameArr = (a, b, msg) => {
|
||||
// strict identity per slot, so a null that became 0 (or a shifted point) fails.
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || a.some((v, i) => v !== b[i])) fail(msg);
|
||||
};
|
||||
|
||||
// No `document` global -> the browser mount is skipped; we get only the pure export.
|
||||
global.window = { AURA_TRACES: traces };
|
||||
const { buildCharts } = require(join(here, "..", "assets", "chart-viewer.js"));
|
||||
|
||||
const eqPoints = traces.series[0].points; // [0.0, 0.001, null, 0.004]
|
||||
const exPoints = traces.series[1].points; // [1.0, null, null, -1.0]
|
||||
|
||||
// overlay: the data array is [xs, ...each series' points], nulls preserved per slot.
|
||||
const overlay = buildCharts(traces, "overlay")[0];
|
||||
sameArr(overlay.data[0], traces.xs, "overlay x is the shared union xs");
|
||||
sameArr(overlay.data[1], eqPoints, "overlay equity points carry their null gap verbatim");
|
||||
sameArr(overlay.data[2], exPoints, "overlay exposure points carry their null gaps verbatim");
|
||||
|
||||
// panels: each panel is [xs, its own series' points] — same nulls, same shared xs.
|
||||
const panels = buildCharts(traces, "panels");
|
||||
sameArr(panels[0].data[0], traces.xs, "panel 0 shares the union xs");
|
||||
sameArr(panels[0].data[1], eqPoints, "panel 0 carries equity's null gap verbatim");
|
||||
sameArr(panels[1].data[1], exPoints, "panel 1 carries exposure's null gaps verbatim");
|
||||
|
||||
console.log("OK — null gaps preserved verbatim across overlay + panels, shared union xs.");
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Integration guard for the `aura chart` viewer's gap handling under a
|
||||
//! multi-cadence (union-spine null-filled) trace.
|
||||
//!
|
||||
//! The CLI aligns taps that fire at different cadences onto a shared union x-axis
|
||||
//! by null-filling the timestamps a tap did not fire at (spec 0056 §6). The
|
||||
//! property "those nulls reach uPlot's data array verbatim — a gap, never dropped,
|
||||
//! never coerced to 0" lives in `chart-viewer.js`, so it cannot be checked from
|
||||
//! Rust. This shells out to `node`, running `tests/chart_viewer_gaps.mjs`, which
|
||||
//! loads the real viewer module and drives the pure `buildCharts` over a sparse
|
||||
//! fixture. `node` is REQUIRED: if absent this test FAILS.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn chart_viewer_preserves_union_spine_null_gaps() {
|
||||
let script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("chart_viewer_gaps.mjs");
|
||||
assert!(script.exists(), "guard script missing at {}", script.display());
|
||||
|
||||
let out = match Command::new("node").arg(&script).output() {
|
||||
Ok(out) => out,
|
||||
Err(e) => panic!(
|
||||
"node is required for the chart-viewer gap guard but could not be run \
|
||||
({e}); install Node.js or ensure `node` is on PATH"
|
||||
),
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"chart-viewer gap guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}",
|
||||
out.status.code()
|
||||
);
|
||||
}
|
||||
@@ -413,6 +413,60 @@ fn json_array_body<'a>(json: &'a str, marker: &str) -> &'a str {
|
||||
&rest[..end]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_emits_self_contained_html_for_a_persisted_run() {
|
||||
let cwd = temp_cwd("chart-serve");
|
||||
|
||||
// persist a run first (iteration 1), then chart it.
|
||||
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 chart = Command::new(BIN).args(["chart", "demo"]).current_dir(&cwd).output().expect("spawn chart");
|
||||
assert!(chart.status.success(), "chart exit: {:?}", chart.status);
|
||||
let html = String::from_utf8(chart.stdout).expect("utf-8 html");
|
||||
assert!(html.starts_with("<!doctype html>"), "not an HTML doc: {:?}", &html[..html.len().min(40)]);
|
||||
assert!(html.contains("window.AURA_TRACES = {"), "AURA_TRACES not injected");
|
||||
assert!(html.contains("\"equity\""), "equity series missing from the chart");
|
||||
assert!(html.contains("uPlot=function"), "vendored uPlot not inlined");
|
||||
|
||||
// a missing run is a usage error (stderr + exit 2), not a panic.
|
||||
let missing = Command::new(BIN).args(["chart", "nope"]).current_dir(&cwd).output().expect("spawn chart nope");
|
||||
assert_eq!(missing.status.code(), Some(2), "missing run must exit 2");
|
||||
assert!(!missing.status.success());
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
}
|
||||
|
||||
/// Property: the `--panels` flag is an honest CLI dispatch axis — `chart <name>`
|
||||
/// serves the overlay page and `chart <name> --panels` serves the panels page over
|
||||
/// the SAME persisted run. The mode the viewer reads (`AURA_TRACES.mode`) reflects
|
||||
/// which arm dispatched: a `--panels`→Overlay miswiring would leave every shape and
|
||||
/// render test green while silently ignoring the flag, so this pins it at the
|
||||
/// binary's observable stdout.
|
||||
#[test]
|
||||
fn chart_panels_flag_selects_panels_mode() {
|
||||
let cwd = temp_cwd("chart-panels");
|
||||
|
||||
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);
|
||||
|
||||
// default (no flag) -> overlay.
|
||||
let overlay = Command::new(BIN).args(["chart", "demo"]).current_dir(&cwd).output().expect("spawn chart");
|
||||
assert!(overlay.status.success(), "chart exit: {:?}", overlay.status);
|
||||
let overlay_html = String::from_utf8(overlay.stdout).expect("utf-8 html");
|
||||
assert!(overlay_html.contains("\"mode\":\"overlay\""), "default chart must serve overlay mode");
|
||||
assert!(!overlay_html.contains("\"mode\":\"panels\""), "default chart must not serve panels mode");
|
||||
|
||||
// --panels -> panels, over the very same persisted run.
|
||||
let panels = Command::new(BIN).args(["chart", "demo", "--panels"]).current_dir(&cwd).output().expect("spawn chart --panels");
|
||||
assert!(panels.status.success(), "chart --panels exit: {:?}", panels.status);
|
||||
let panels_html = String::from_utf8(panels.stdout).expect("utf-8 html");
|
||||
assert!(panels_html.contains("\"mode\":\"panels\""), "--panels must serve panels mode");
|
||||
assert!(!panels_html.contains("\"mode\":\"overlay\""), "--panels must not fall back to overlay");
|
||||
|
||||
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");
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mode": "overlay",
|
||||
"manifest": { "commit": "test", "params": [], "window": [1, 3], "seed": 0, "broker": "sim-optimal(pip_size=0.0001)" },
|
||||
"taps": ["equity", "exposure"],
|
||||
"xs": [1, 2, 3],
|
||||
"series": [
|
||||
{ "name": "equity", "y_scale_id": "y_0", "points": [0.0, 0.001, 0.004] },
|
||||
{ "name": "exposure", "y_scale_id": "y_1", "points": [1.0, 1.0, -1.0] }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mode": "overlay",
|
||||
"manifest": { "commit": "test", "params": [], "window": [1, 4], "seed": 0, "broker": "sim-optimal(pip_size=0.0001)" },
|
||||
"taps": ["equity", "exposure"],
|
||||
"xs": [1, 2, 3, 4],
|
||||
"series": [
|
||||
{ "name": "equity", "y_scale_id": "y_0", "points": [0.0, 0.001, null, 0.004] },
|
||||
{ "name": "exposure", "y_scale_id": "y_1", "points": [1.0, null, null, -1.0] }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user