diff --git a/crates/aura-cli/assets/chart-viewer.js b/crates/aura-cli/assets/chart-viewer.js index 083ee8b..f619745 100644 --- a/crates/aura-cli/assets/chart-viewer.js +++ b/crates/aura-cli/assets/chart-viewer.js @@ -6,51 +6,133 @@ (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) { + // wheelZoomPlugin: a pure factory (safe to construct headless — its hooks run only in + // a browser). On init it wires wheel-zoom (x, cursor-anchored), drag-pan, and + // dblclick-reset onto u.over. uPlot's default drag-box-zoom is disabled in buildCharts + // (cursor.drag off) so drag pans instead. + function wheelZoomPlugin() { + var ZOOM = 0.9; // one wheel notch shrinks/grows the visible x-range by this factor + return { hooks: { init: function (u) { + var over = u.over; + over.addEventListener("wheel", function (e) { + e.preventDefault(); + var rect = over.getBoundingClientRect(); + var xVal = u.posToVal(e.clientX - rect.left, "x"); + var lo = u.scales.x.min, hi = u.scales.x.max; + var f = e.deltaY < 0 ? ZOOM : 1 / ZOOM; // wheel up = zoom in + u.setScale("x", { min: xVal - (xVal - lo) * f, max: xVal + (hi - xVal) * f }); + }, { passive: false }); + over.addEventListener("mousedown", function (d) { + if (d.button !== 0) return; + var x0 = d.clientX, lo = u.scales.x.min, hi = u.scales.x.max; + var unitsPerPx = (hi - lo) / over.clientWidth; + function move(m) { + var dx = (m.clientX - x0) * unitsPerPx; + u.setScale("x", { min: lo - dx, max: hi - dx }); + } + function up() { + document.removeEventListener("mousemove", move); + document.removeEventListener("mouseup", up); + } + document.addEventListener("mousemove", move); + document.addEventListener("mouseup", up); + }); + over.addEventListener("dblclick", function () { + u.setScale("x", { min: u.data[0][0], max: u.data[0][u.data[0].length - 1] }); + }); + } } }; + } + + // {xs, series:[{name, y_scale_id, points}]} + mode + xMode -> array of {opts, data}. + // xMode in {"ordinal","continuous"}, default "ordinal". ordinal: data[0] is the index + // spine [0..n-1] (gaps collapse) and the x-axis/legend map an index back to the real + // xs[i]; continuous: data[0] is xs. series points (data[1..]) are untouched either way + // (null gaps preserved verbatim). overlay -> one chart; panels -> one chart per series, + // sharing a cursor.sync key so the crosshair lines up. + function buildCharts(traces, mode, xMode) { + xMode = xMode || "ordinal"; var xs = traces.xs; - var series = traces.series; + var n = xs.length; + var idx = []; + for (var k = 0; k < n; k++) idx.push(k); + var ordinal = xMode !== "continuous"; + var xData = ordinal ? idx : xs; + + // map an x-position to its real-timestamp label (total; no epoch assumption): + // ordinal -> position is an index -> xs[round(pos)]; continuous -> position IS the ts. + function xLabel(pos) { + if (!ordinal) return String(pos); + var j = Math.max(0, Math.min(n - 1, Math.round(pos))); + return String(xs[j]); + } + function xAxisCfg() { return ordinal ? { values: function (u, splits) { return splits.map(xLabel); } } : {}; } + function xSeriesCfg() { return { value: function (u, v) { return v == null ? "--" : xLabel(v); } }; } + if (mode === "panels") { - return series.map(function (s, i) { + var SYNC = "aura-x"; // shared key -> crosshair sync across panels + return traces.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 }], + series: [xSeriesCfg(), { label: s.name, scale: s.y_scale_id, stroke: PALETTE[i % PALETTE.length] }], + axes: [xAxisCfg(), { scale: s.y_scale_id }], + cursor: { sync: { key: SYNC }, drag: { x: false, y: false } }, + plugins: [wheelZoomPlugin()], }, - data: [xs, s.points], + data: [xData, s.points], }; }); } + // overlay (default) var scales = { x: { time: false } }; - var uSeries = [{}]; - var axes = [{}]; - series.forEach(function (s, i) { + var uSeries = [xSeriesCfg()]; + var axes = [xAxisCfg()]; + traces.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. + // 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; })) }]; + return [{ + opts: { scales: scales, series: uSeries, axes: axes, cursor: { drag: { x: false, y: false } }, plugins: [wheelZoomPlugin()] }, + data: [xData].concat(traces.series.map(function (s) { return s.points; })), + }]; } - // Browser-only: instantiate the vendored global uPlot per config into #stage. + // Browser-only: instantiate the vendored global uPlot per config into #stage, and wire + // the header #xmode-toggle to flip the x spine (ordinal <-> continuous) and re-render. 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); + var toggle = document.getElementById("xmode-toggle"); + var xMode = "ordinal"; + var insts = []; + // The ordinal ("collapsed") label is authored once, server-side, as the button's + // initial text (render.rs CHART_HEAD) — read it back rather than re-hardcoding the + // same string here, so the served page carries that label exactly once. + var labelOrdinal = toggle ? toggle.textContent : ""; + var labelContinuous = "x: continuous (real time)"; + function render() { + insts.forEach(function (u) { u.destroy(); }); + insts = []; + stage.innerHTML = ""; + var configs = buildCharts(traces, traces.mode, xMode); + var w = stage.clientWidth || 900; + var h = configs.length > 1 ? Math.max(160, Math.floor(440 / configs.length)) : 460; + configs.forEach(function (cfg) { + // eslint-disable-next-line no-undef + insts.push(new uPlot(Object.assign({ width: w, height: h }, cfg.opts), cfg.data, stage)); + }); + if (toggle) toggle.textContent = xMode === "ordinal" ? labelOrdinal : labelContinuous; + } + if (toggle) toggle.addEventListener("click", function () { + xMode = xMode === "ordinal" ? "continuous" : "ordinal"; + render(); }); + render(); } if (typeof window !== "undefined" && typeof document !== "undefined" && window.AURA_TRACES) { diff --git a/crates/aura-cli/src/render.rs b/crates/aura-cli/src/render.rs index f07a200..d6c3ae9 100644 --- a/crates/aura-cli/src/render.rs +++ b/crates/aura-cli/src/render.rs @@ -59,10 +59,13 @@ const GRAPH_HEAD: &str = r#"
"#; /// The chart page's `` text and `<header>` line — describes what the chart -/// view actually renders (timestamp-aligned trace series), not the graph viewer. +/// view actually renders (timestamp-aligned trace series), not the graph viewer. The +/// `#xmode-toggle` button is wired by `chart-viewer.js` (`mount`) to flip the x-axis +/// between the ordinal (gap-collapsed) default and continuous (real-time) spacing. const CHART_HEAD: &str = r#"<header> <b>aura chart</b> <span class="sub">timestamp-aligned trace series</span> + <button id="xmode-toggle" type="button">x: collapsed (gaps off)</button> </header> <div id="stage"></div> <pre id="err"></pre> @@ -76,6 +79,16 @@ 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"); +/// Chart-page-only styling (the `#xmode-toggle` button) — injected by +/// `render_chart_html` after the vendored uPlot CSS, NOT into the shared `SHELL_HEAD` +/// (the graph page has no toggle and must not inherit its rule). +const CHART_CSS: &str = r#" +#xmode-toggle { margin-left: auto; background: #1e1e2e; color: #cdd6f4; + border: 1px solid #313244; border-radius: 6px; padding: 4px 10px; + font-family: ui-monospace, monospace; font-size: 12px; cursor: pointer; } +#xmode-toggle:hover { border-color: #89b4fa; color: #f5e0dc; } +"#; + /// Assemble the self-contained interactive graph viewer page for `root`. /// /// Order is load-bearing: the Graphviz-WASM and pan-zoom globals (`Viz`, @@ -146,12 +159,13 @@ pub fn render_chart_html(data: &ChartData, mode: ChartMode) -> String { .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, + SHELL_HEAD.len() + CHART_HEAD.len() + UPLOT_JS.len() + UPLOT_CSS.len() + CHART_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(CHART_CSS); html.push_str("</style>\n<script>"); html.push_str(UPLOT_JS); html.push_str("</script>\n<script>window.AURA_TRACES = "); @@ -194,6 +208,8 @@ mod tests { html.contains("i64: \"#f9e2af\"") && html.contains("timestamp: \"#cba6f7\""), "C4 four-colour palette missing" ); + // the chart-only toggle control must not leak onto the graph page + assert!(!html.contains("xmode-toggle"), "chart toggle leaked onto the graph page"); } fn sample_chart_data() -> ChartData { @@ -229,6 +245,8 @@ mod tests { let html = render_chart_html(&sample_chart_data(), ChartMode::Overlay); assert!(html.contains("<title>aura chart"), "chart page title not chart-specific"); assert!(html.contains("aura chart"), "chart header label not chart-specific"); + // the x-axis toggle control is part of the chart chrome + assert!(html.contains("id=\"xmode-toggle\""), "x-axis toggle control missing from the chart page"); // 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"); diff --git a/crates/aura-cli/tests/chart_viewer_build.mjs b/crates/aura-cli/tests/chart_viewer_build.mjs index 71380b4..4b1f931 100644 --- a/crates/aura-cli/tests/chart_viewer_build.mjs +++ b/crates/aura-cli/tests/chart_viewer_build.mjs @@ -27,22 +27,28 @@ global.window = { AURA_TRACES: traces }; const { buildCharts } = require(join(here, "..", "assets", "chart-viewer.js")); const n = traces.series.length; +const idx = Array.from({ length: traces.xs.length }, (_, i) => i); -// overlay: ONE chart, all series superimposed on the shared xs, each on its own scale. +// overlay: ONE chart, all series superimposed, each on its own scale. Default x spine is +// ORDINAL (indices) so non-trading gaps collapse. 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"); +sameArr(overlay[0].data[0], idx, "default overlay x-axis is the ordinal index spine"); 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. +// continuous mode restores the real-timestamp x spine. +const overlayC = buildCharts(traces, "overlay", "continuous"); +sameArr(overlayC[0].data[0], traces.xs, "continuous overlay x-axis is the shared xs"); + +// panels: ONE chart per series, all positionally aligned on the SAME ordinal spine. 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)"); + sameArr(p.data[0], idx, "panel shares the same ordinal index spine (position-aligned)"); } console.log(`OK — overlay 1 chart / ${n} series, panels ${n} charts, shared xs.`); diff --git a/crates/aura-cli/tests/chart_viewer_gaps.mjs b/crates/aura-cli/tests/chart_viewer_gaps.mjs index 5f0b342..48d0cb3 100644 --- a/crates/aura-cli/tests/chart_viewer_gaps.mjs +++ b/crates/aura-cli/tests/chart_viewer_gaps.mjs @@ -31,16 +31,17 @@ 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] +const idx = Array.from({ length: traces.xs.length }, (_, i) => i); -// overlay: the data array is [xs, ...each series' points], nulls preserved per slot. +// overlay: the data array is [ordinal spine, ...each series' points], nulls preserved. const overlay = buildCharts(traces, "overlay")[0]; -sameArr(overlay.data[0], traces.xs, "overlay x is the shared union xs"); +sameArr(overlay.data[0], idx, "overlay x is the ordinal index spine (gaps collapsed)"); 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. +// panels: each panel is [ordinal spine, its own series' points] — same nulls. const panels = buildCharts(traces, "panels"); -sameArr(panels[0].data[0], traces.xs, "panel 0 shares the union xs"); +sameArr(panels[0].data[0], idx, "panel 0 shares the ordinal index spine"); 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"); diff --git a/crates/aura-cli/tests/chart_viewer_label.mjs b/crates/aura-cli/tests/chart_viewer_label.mjs new file mode 100644 index 0000000..949dc3b --- /dev/null +++ b/crates/aura-cli/tests/chart_viewer_label.mjs @@ -0,0 +1,60 @@ +// Headless guard for the trace-chart viewer (chart-viewer.js) ordinal-label honesty. +// +// Property protected: the ORDINAL x spine collapses gaps to indices [0..n-1], but the +// axis tick labels and the legend value-readout must still report the REAL timestamp +// xs[i] for each collapsed index — not the index itself. The gap-collapse is only +// honest if the axis tells the truth about which timestamp a collapsed position is. +// A regression that returned the index (String(pos)) instead of xs[round(pos)] would +// still pass "the formatter is a function" but silently mislabel every tick after a +// gap. Continuous mode (no collapse) must carry NO index->ts formatter, so its tick is +// the raw timestamp, and its legend echoes the timestamp verbatim. +// +// The fixture's timestamps [100, 200, 9000] diverge sharply from the index spine +// [0, 1, 2] across a large non-trading gap, so an index-vs-timestamp confusion fails +// loudly. Drives the exported pure buildCharts; uPlot/DOM 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", "gappy-traces.json"), "utf8")); + +const fail = (msg) => { console.error("FAIL: " + msg); process.exit(1); }; +const eq = (a, b, msg) => { if (a !== b) fail(`${msg} (got ${JSON.stringify(a)}, want ${JSON.stringify(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 -> 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 xs = traces.xs; // [100, 200, 9000] +const idx = xs.map((_, i) => i); // [0, 1, 2] + +// ordinal (default): the x AXIS formatter maps each index split back to its real ts. +const ov = buildCharts(traces, "overlay")[0]; +const axisValues = ov.opts.axes[0].values; +if (typeof axisValues !== "function") fail("ordinal x-axis lacks a values formatter"); +const labels = axisValues(null, idx); // uPlot passes the tick positions (indices) +sameArr(labels, xs.map(String), "ordinal axis must label each index with its real xs[i], not the index"); + +// ordinal: the LEGEND x value-readout maps a cursor index back to its real ts; a null +// cursor reads as the gap placeholder "--", never as a coerced number. +const legend = ov.opts.series[0].value; +if (typeof legend !== "function") fail("ordinal x series lacks a value-readout formatter"); +eq(legend(null, 0), String(xs[0]), "legend readout at index 0 must be the real ts xs[0]"); +eq(legend(null, 2), String(xs[2]), "legend readout at index 2 must be the real ts xs[2] (across the gap)"); +eq(legend(null, null), "--", "legend readout at a null cursor must be the gap placeholder --"); + +// continuous: NO index->ts formatter on the axis (uPlot's default tick = the raw ts), +// and the legend echoes the timestamp verbatim. +const ovc = buildCharts(traces, "overlay", "continuous")[0]; +eq(typeof ovc.opts.axes[0].values, "undefined", "continuous axis must carry no index->ts formatter"); +eq(ovc.opts.series[0].value(null, xs[2]), String(xs[2]), "continuous legend echoes the raw timestamp"); + +console.log("OK — ordinal labels report real timestamps (not indices); continuous echoes raw ts."); +process.exit(0); diff --git a/crates/aura-cli/tests/chart_viewer_label.rs b/crates/aura-cli/tests/chart_viewer_label.rs new file mode 100644 index 0000000..4d1ea94 --- /dev/null +++ b/crates/aura-cli/tests/chart_viewer_label.rs @@ -0,0 +1,33 @@ +//! Integration guard for the `aura chart` viewer's ordinal-label honesty: the +//! gap-collapsed ordinal x spine must still label each collapsed index with its REAL +//! timestamp (axis ticks + legend value-readout), never the index. This logic lives in +//! `chart-viewer.js` and cannot be checked from Rust; this shells out to `node`, running +//! `tests/chart_viewer_label.mjs`, which drives the pure `buildCharts`. `node` is +//! REQUIRED: if absent this test FAILS. + +use std::path::PathBuf; +use std::process::Command; + +#[test] +fn chart_viewer_ordinal_labels_report_real_timestamps() { + let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("chart_viewer_label.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 label 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 label guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}", + out.status.code() + ); +} diff --git a/crates/aura-cli/tests/chart_viewer_xmode.mjs b/crates/aura-cli/tests/chart_viewer_xmode.mjs new file mode 100644 index 0000000..a220f4b --- /dev/null +++ b/crates/aura-cli/tests/chart_viewer_xmode.mjs @@ -0,0 +1,48 @@ +// Headless guard for the trace-chart viewer's x-mode + interaction wiring. +// Properties: (1) the DEFAULT x spine is ORDINAL — data[0] is [0..n-1], not the raw +// timestamps, so non-trading gaps collapse; the x-axis carries an index->label values +// formatter. (2) "continuous" mode restores data[0] === xs verbatim. (3) panels carry a +// shared cursor.sync.key so the crosshair lines up. (4) every config attaches an +// interaction plugin with an init hook. Drives the pure buildCharts; no DOM/uPlot. +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 sameArr = (a, b, msg) => { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length || a.some((v, i) => v !== b[i])) fail(msg); +}; + +global.window = { AURA_TRACES: traces }; +const { buildCharts } = require(join(here, "..", "assets", "chart-viewer.js")); + +const n = traces.xs.length; +const idx = Array.from({ length: n }, (_, i) => i); + +// (1) default is ordinal: data[0] is the index spine, with an x-axis values formatter. +const ov = buildCharts(traces, "overlay"); +sameArr(ov[0].data[0], idx, "default overlay x is the ordinal index spine [0..n-1]"); +if (typeof ov[0].opts.axes[0].values !== "function") fail("ordinal overlay x-axis lacks a values formatter"); + +// (4) interaction wired: a plugin with an init hook. +if (!Array.isArray(ov[0].opts.plugins) || ov[0].opts.plugins.length < 1) fail("no interaction plugin attached"); +if (typeof ov[0].opts.plugins[0].hooks.init !== "function") fail("interaction plugin has no init hook"); + +// (2) continuous mode: data[0] is xs verbatim. +const ovc = buildCharts(traces, "overlay", "continuous"); +sameArr(ovc[0].data[0], traces.xs, "continuous overlay x is xs verbatim"); + +// (3) panels: cursor.sync.key present and equal across panels; default still ordinal. +const panels = buildCharts(traces, "panels"); +const keys = panels.map((p) => p.opts.cursor && p.opts.cursor.sync && p.opts.cursor.sync.key); +if (keys.some((k) => !k)) fail("a panel lacks cursor.sync.key"); +if (new Set(keys).size !== 1) fail("panels do not share one cursor.sync key"); +sameArr(panels[0].data[0], idx, "default panel x is the ordinal index spine"); + +console.log("OK — ordinal default + continuous toggle + panel cursor-sync + interaction plugin."); +process.exit(0); diff --git a/crates/aura-cli/tests/chart_viewer_xmode.rs b/crates/aura-cli/tests/chart_viewer_xmode.rs new file mode 100644 index 0000000..e8fa42f --- /dev/null +++ b/crates/aura-cli/tests/chart_viewer_xmode.rs @@ -0,0 +1,32 @@ +//! Integration guard for the `aura chart` viewer's x-mode (ordinal default + continuous +//! toggle), panel cursor-sync, and interaction-plugin wiring. These live in +//! `chart-viewer.js` and cannot be checked from Rust; this shells out to `node`, running +//! `tests/chart_viewer_xmode.mjs`, which drives the pure `buildCharts`. `node` is +//! REQUIRED: if absent this test FAILS. + +use std::path::PathBuf; +use std::process::Command; + +#[test] +fn chart_viewer_ordinal_default_continuous_toggle_and_sync() { + let script = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("chart_viewer_xmode.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 x-mode 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 x-mode guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}", + out.status.code() + ); +} diff --git a/crates/aura-cli/tests/fixtures/gappy-traces.json b/crates/aura-cli/tests/fixtures/gappy-traces.json new file mode 100644 index 0000000..e9fdfbc --- /dev/null +++ b/crates/aura-cli/tests/fixtures/gappy-traces.json @@ -0,0 +1,9 @@ +{ + "mode": "overlay", + "manifest": { "commit": "test", "params": [], "window": [100, 9000], "seed": 0, "broker": "sim-optimal(pip_size=0.0001)" }, + "taps": ["equity"], + "xs": [100, 200, 9000], + "series": [ + { "name": "equity", "y_scale_id": "y_0", "points": [0.0, 0.001, 0.004] } + ] +}