Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion tests/console_desk_format_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ assert.equal(deskTranslate("en", "freshness.ready", { age: "6 min" }), "All feed
const { zh, en } = DESK_MESSAGES;
assert.deepEqual(Object.keys(en).sort(), Object.keys(zh).sort());
const placeholders = text => [...text.matchAll(/\{(\w+)\}/g)].map(m => m[1]).sort();
const LANGUAGE_NEUTRAL = new Set(["read.unknown_mark", "clock.utc", "shell.brand"]);
// Log levels are protocol tokens and stay verbatim in both languages.
const LANGUAGE_NEUTRAL = new Set([
"read.unknown_mark", "clock.utc", "shell.brand",
"logs.level.error", "logs.level.warn", "logs.level.info", "logs.level.debug",
]);
for (const key of Object.keys(zh)) {
assert.deepEqual(placeholders(en[key]), placeholders(zh[key]), `placeholders ${key}`);
assert.doesNotMatch(en[key], /[㐀-鿿]/, `en ${key}`);
Expand Down
1 change: 1 addition & 0 deletions tests/console_desk_mock_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ assert.equal(resolveMockScenario("?mock=<script>", null), null);
assert.deepEqual(DESK_SOURCES, {
session: "live", ribbon: "mock", overview: "mock", accounts: "mock", credentials: "mock",
switchTargets: "mock", switchStrategies: "mock", performance: "mock",
alerts: "mock", alertDelivery: "mock", logs: "mock",
});
const calls = [];
const fetchJson = async path => { calls.push(path); return structuredClone(session.ok); };
Expand Down
110 changes: 110 additions & 0 deletions tests/console_desk_overview_validation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Desk (Meridian) overview: signed currency text, headline counts, risk meters and the
// plotting geometry. Pure modules only; every unknown stays unknown instead of becoming 0.
import assert from "node:assert/strict";
import {
curveGeometry, formatMoney, formatMultiple, formatSignedMoney, formatSignedPercent, headlineCounts, meter, sparkPaths,
} from "../web/strategy-switch-console/frontend/src/desk/overview/model.ts";
import { deskText } from "../web/strategy-switch-console/frontend/src/desk/i18n/index.ts";
import { ok as overviewOk, degraded as overviewDegraded } from "../web/strategy-switch-console/frontend/src/desk/api/fixtures/overview.ts";

// ---------------------------------------------------------------- signed figures
assert.equal(formatSignedPercent("0.0059"), "+0.59%");
assert.equal(formatSignedPercent("-0.141"), "−14.10%");
assert.equal(formatSignedPercent("-0.049", 1), "−4.9%");
assert.equal(formatSignedPercent("0"), "0.00%", "zero carries no sign");
assert.equal(formatSignedPercent(null), null, "unknown is not zero");
assert.equal(formatSignedPercent("not-a-number"), null);

assert.equal(formatMultiple("0.964"), "0.96×");
assert.equal(formatMultiple(null), null);

assert.equal(formatMoney("1189800.00", "USD", "en"), "$1,189,800");
assert.equal(formatMoney("1113000.00", "HKD", "en"), "1,113,000 HKD");
assert.equal(formatMoney(null, "USD", "en"), null);
assert.equal(formatSignedMoney("6976.00", "USD", "en"), "+$6,976");
assert.equal(formatSignedMoney("-1256.00", "USD", "zh"), "−$1,256");
assert.equal(formatSignedMoney(null, "USD", "zh"), null);

// ---------------------------------------------------------------- headline counts
// HK reports identity_unverified, so it is not running; its drift stays visible through
// needs_you instead of being folded into the sentence.
const okCounts = headlineCounts(overviewOk.book);
assert.deepEqual(okCounts, { running: 5, steady: 5, drifting: 0, configUnknown: 0, runtimeUnknown: 1, total: 6 });
// A running book whose config could not be read is not evidence that the saved plan matches:
// it is counted as unknown, never as steady.
const unreadableConfig = headlineCounts([{
...overviewOk.book[0], runtime: { status: "known", value: "running", observed_at: "2026-11-02T14:31:48Z" },
config: { state: "unknown", drift_count: 0, checked_at: null },
}]);
assert.deepEqual(unreadableConfig, { running: 1, steady: 0, drifting: 0, configUnknown: 1, runtimeUnknown: 0, total: 1 });
// No known runtime at all: the page must say the state is unconfirmed, not that nothing runs.
const allUnknown = headlineCounts(overviewDegraded.book.map(row => ({ ...row, runtime: { status: "unknown", reason: "read_failed" } })));
assert.equal(allUnknown.running, 0);
assert.equal(allUnknown.runtimeUnknown, 6, "every account is unconfirmed, not stopped");
// A book whose runtime is unknown is not counted as running.
const degradedCounts = headlineCounts(overviewDegraded.book);
assert.equal(degradedCounts.total, 6);
assert.equal(degradedCounts.drifting, 0);
assert.equal(degradedCounts.steady + degradedCounts.configUnknown, degradedCounts.running);
assert.ok(degradedCounts.running < 6, "the unreadable book drops out of the running count");

// ---------------------------------------------------------------- risk meters
// 14.1% against a 15% limit is nearly full and reads as a warning, not as safe.
assert.deepEqual(meter("-0.141", "-0.15", 10), { filled: 9, tone: "warn" });
assert.deepEqual(meter("-0.062", "-0.15", 10), { filled: 4, tone: "ok" });
assert.deepEqual(meter("-0.18", "-0.15", 10), { filled: 10, tone: "over" }, "a breach fills the meter");
assert.deepEqual(meter("0", "-0.15", 10), { filled: 0, tone: "ok" });
assert.deepEqual(meter(null, "-0.15", 10), { filled: null, tone: "unknown" }, "no ratio, no fill");
assert.deepEqual(meter("-0.141", null, 10), { filled: null, tone: "unknown" }, "no limit, no fill");
assert.deepEqual(meter("-0.141", "0", 10), { filled: null, tone: "unknown" }, "a zero limit cannot be used");

// ---------------------------------------------------------------- equity curve
const curve = curveGeometry(overviewOk.equity_curve, 720, 220);
assert.ok(curve, "the ready fixture plots");
assert.match(curve.portfolioPath, /^M\d/);
assert.match(curve.benchmarkPath, /^M\d/);
assert.match(curve.areaPath, /Z$/, "the area closes");
assert.match(curve.drawdownPath, /Z$/, "the drawdown band closes");
assert.ok(curve.min < curve.max);
assert.ok(curve.drawdownMin !== null && curve.drawdownMin <= 0, "drawdown is measured from the peak");

// No benchmark in the degraded fixture, so the line is absent rather than flat at 0.
const degradedCurve = curveGeometry(overviewDegraded.equity_curve, 720, 220);
assert.ok(degradedCurve);
assert.equal(degradedCurve.benchmarkPath, null);

// A single point cannot make a line.
const single = { ...overviewOk.equity_curve, portfolio: { dates: ["2026-11-02"], indexed: ["100"] } };
assert.equal(curveGeometry(single, 720, 220), null, "one point is not a curve");
assert.equal(curveGeometry({ ...single, portfolio: { dates: [], indexed: [] } }, 720, 220), null);

// ---------------------------------------------------------------- sparklines
assert.deepEqual(sparkPaths([], [], null, null, 120, 32), [], "no points, no path");
assert.deepEqual(sparkPaths(["100"], [], "2026-10-10", "2026-10-10", 120, 32), [], "one point is not a trend");

const solid = sparkPaths(["100", "101", "102"], [], "2026-10-10", "2026-10-12", 120, 32);
assert.equal(solid.length, 1);
assert.match(solid[0], /^M\d/);

// A gap splits the line, and both runs keep their place on one shared timeline and scale:
// 10 → x 1, 11 → x 30.5, (12 missing), 13 → x 89.5, 14 → x 119, with 100 at the bottom and
// 104 at the top. Stretching each run to the full width would put both starts near x 1.
const gapped = sparkPaths(["100", "101", "103", "104"], ["2026-10-12"], "2026-10-10", "2026-10-14", 120, 32);
assert.deepEqual(gapped, ["M1.00 31.00 L30.50 23.50", "M89.50 8.50 L119.00 1.00"]);

// When every observed run holds a single point there is nothing to draw; the page says the
// history is too short instead of joining the two points across the missing day.
assert.deepEqual(sparkPaths(["100", "104"], ["2026-10-11"], "2026-10-10", "2026-10-12", 120, 32), []);
assert.deepEqual(sparkPaths(["100", "104"], [], "2026-10-10", "2026-10-12", 120, 32).length, 1, "no gap, one run");

// A series that does not cover the window's observed days cannot be placed by date; it is drawn
// as one run by index rather than split at a guessed date.
const misaligned = sparkPaths(["100", "101", "102"], ["2026-10-11"], "2026-10-10", "2026-10-14", 120, 32);
assert.equal(misaligned.length, 1);

// Synthetic alert copy is bilingual: fixtures carry a key, live text passes through untouched.
assert.equal(deskText("zh", "alerts.mock.config_drift.title"), "港股 ETF 轮动:保存值 ≠ 平台值");
assert.match(deskText("en", "alerts.mock.config_drift.title"), /HK ETF rotation/);
assert.equal(deskText("en", "Saved value differs"), "Saved value differs", "server text is never treated as a key");

console.log("console_desk_overview_validation: PASS");
109 changes: 109 additions & 0 deletions tests/console_desk_phase8_validation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Desk (Meridian) phase 8: the alerts and logs pages, checked against the §3.17–§3.19
// schemas, their guards, the pure models behind the filters, and the source text of the
// two page components. Synthetic fixtures only; nothing here reads a real account.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";

import { alertWhen, filterCounts, sortAlerts } from "../web/strategy-switch-console/frontend/src/desk/alerts/model.ts";
import { levelCounts, runSummary, tapeStateKey } from "../web/strategy-switch-console/frontend/src/desk/logs/model.ts";
import {
isAlertDelivery, isAlerts, isLogs,
} from "../web/strategy-switch-console/frontend/src/desk/api/guards.ts";
import { DESK_SOURCES } from "../web/strategy-switch-console/frontend/src/desk/api/endpoints.ts";
import { ok as alertsOk, degraded as alertsDegraded } from "../web/strategy-switch-console/frontend/src/desk/api/fixtures/alerts.ts";
import { ok as deliveryOk, degraded as deliveryDegraded } from "../web/strategy-switch-console/frontend/src/desk/api/fixtures/alertDelivery.ts";
import { ok as logsOk, degraded as logsDegraded } from "../web/strategy-switch-console/frontend/src/desk/api/fixtures/logs.ts";
import { ok as ribbonOk } from "../web/strategy-switch-console/frontend/src/desk/api/fixtures/ribbon.ts";

const source = path => readFileSync(new URL(`../web/strategy-switch-console/frontend/src/desk/${path}`, import.meta.url), "utf8");
const clone = value => structuredClone(value);

// 1. Guards accept every shipped fixture: ok and degraded are both contract-valid.
for (const [name, fixture, guard] of [
["alerts.ok", alertsOk, isAlerts],
["alerts.degraded", alertsDegraded, isAlerts],
["alertDelivery.ok", deliveryOk, isAlertDelivery],
["alertDelivery.degraded", deliveryDegraded, isAlertDelivery],
["logs.ok", logsOk, isLogs],
["logs.degraded", logsDegraded, isLogs],
]) assert.ok(guard(fixture), name);

// 2. Guards reject shapes the UI would otherwise render with defaults.
const resolvedWithoutTime = clone(alertsOk);
resolvedWithoutTime.items.find(item => item.status === "resolved").resolved_at = null;
assert.equal(isAlerts(resolvedWithoutTime), false, "resolved without resolved_at");

const openWithTime = clone(alertsOk);
openWithTime.items.find(item => item.status === "open").resolved_at = "2026-11-02T15:00:00Z";
assert.equal(isAlerts(openWithTime), false, "open with resolved_at");

const unknownSeverity = clone(alertsOk);
unknownSeverity.items[0].severity = "emergency";
assert.equal(isAlerts(unknownSeverity), false, "unknown severity");

const shortRetention = clone(logsOk);
shortRetention.retention.days = 30;
assert.equal(isLogs(shortRetention), false, "retention is a contract constant");

const unknownLevel = clone(logsOk);
unknownLevel.items[0].level = "trace";
assert.equal(isLogs(unknownLevel), false, "unknown level");

const unredacted = clone(logsOk);
unredacted.redaction = "none";
assert.equal(isLogs(unredacted), false, "redaction must be applied");

const unknownChannel = clone(deliveryOk);
unknownChannel.channel.status = { status: "unknown", reason: "read_failed", observed_at: null };
assert.ok(isAlertDelivery(unknownChannel), "an unreadable channel is a valid unknown");
const badOutcome = clone(deliveryOk);
badOutcome.last_delivery.outcome = "skipped";
assert.equal(isAlertDelivery(badOutcome), false, "delivery outcome enum");

// 3. Ordering and counts: open before resolved, critical before warning, newest first.
const ordered = sortAlerts(alertsOk.items);
assert.deepEqual(ordered.map(item => item.status), ["open", "open", "open", "resolved", "resolved"]);
assert.deepEqual(ordered.slice(0, 3).map(item => item.severity), ["critical", "warning", "warning"]);
assert.notEqual(sortAlerts(alertsOk.items), alertsOk.items, "sort never mutates the response");

const counts = filterCounts(alertsOk.items);
assert.deepEqual(counts, { all: 5, critical: 1, warning: 2, info: 2 });
// The headline uses the response counts, so a partial read cannot overstate what is open.
assert.equal(alertsDegraded.counts.open, 1);
assert.equal(filterCounts(alertsDegraded.items).all, 1);

// 4. Time rendering: resolved shows its resolution, a single sighting shows one clock.
const resolved = alertsOk.items.find(item => item.status === "resolved");
assert.deepEqual(alertWhen(resolved), { kind: "resolved", first: "06:00", last: "06:00" });
const ranged = alertsOk.items.find(item => item.id === "alert_demo_0005");
assert.deepEqual(alertWhen(ranged), { kind: "range", first: "14:26", last: "14:31" });

// 5. Logs model: runs and drift come from the ribbon tape, levels from the tail.
assert.deepEqual(runSummary(ribbonOk.tape), { runs: 5, drift: 1 });
assert.deepEqual(runSummary([]), { runs: 0, drift: 0 });
assert.deepEqual(levelCounts(logsOk.items), { all: 11, error: 1, warn: 2, info: 7, debug: 1 });
assert.equal(tapeStateKey({ state: "done", outcome: "drift" }), "tape.outcome.drift");
assert.equal(tapeStateKey({ state: "scheduled", outcome: null }), "tape.state.scheduled");

// 6. Both pages are still mock-sourced: no backend route exists for §3.17–§3.19 yet.
assert.equal(DESK_SOURCES.alerts, "mock");
assert.equal(DESK_SOURCES.alertDelivery, "mock");
assert.equal(DESK_SOURCES.logs, "mock");

// 7. The desk routes both pages, and neither is a stub any more.
const app = source("DeskApp.tsx");
assert.match(app, /page === "alerts" \? <AlertsPage api=\{api\} t=\{t\} language=\{language\} \/>/);
assert.match(app, /page === "logs" \? <LogsPage api=\{api\} t=\{t\} language=\{language\} \/>/);
assert.match(app, /<PageStub page=\{page\} t=\{t\} \/>/, "the stub stays as the fallback for future pages");

// 8. Rendering rules: the log message is text, never HTML, and alerts only link out.
const logsPage = source("pages/LogsPage.tsx");
assert.doesNotMatch(logsPage, /dangerouslySetInnerHTML/, "log text is never injected as HTML");
assert.match(logsPage, /\{line\.message\}/, "the redacted message is rendered verbatim");
const alertsPage = source("pages/AlertsPage.tsx");
assert.match(alertsPage, /<a className="mrd-alert-action" href=\{item\.route\}/, "an alert action is a link, not a call");
for (const page of ["pages/AlertsPage.tsx", "pages/LogsPage.tsx"]) {
assert.doesNotMatch(source(page), /postJson|fetch\(|XMLHttpRequest/, `${page} never writes`);
}

console.log("console_desk_phase8_validation: PASS (alerts + logs guards, models, routes; synthetic only)");
Loading
Loading