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
52 changes: 50 additions & 2 deletions tests/console_desk_mock_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
isAccounts, isCredentials, isOverview, isRead, isRibbon, isSession, isSwitchStrategies, isSwitchTargets,
} from "../web/strategy-switch-console/frontend/src/desk/api/guards.ts";
import { MOCK_FAILURE, loadMock, resolveMockScenario } from "../web/strategy-switch-console/frontend/src/desk/api/mock.ts";
import { DESK_SOURCES, DeskContractError, createDeskApi } from "../web/strategy-switch-console/frontend/src/desk/api/endpoints.ts";
import { DESK_PAGE_ENDPOINTS, DESK_SHELL_ENDPOINTS, DESK_SOURCES, DeskContractError, createDeskApi, fixtureExposure } from "../web/strategy-switch-console/frontend/src/desk/api/endpoints.ts";
import { HttpError } from "../web/strategy-switch-console/frontend/src/api/client.ts";

// 1. Fixtures validate against the BE-0 JSON schemas (Ajv comes from the Worker package, as in desk_contract_validation).
Expand Down Expand Up @@ -237,4 +237,52 @@ assert.equal(calls.at(-1), "/api/desk/ribbon", "a malformed date is dropped, not
assert.equal((await allLive.overview()).source, "live");
await assert.rejects(loadMock("overview", "fail", 0), HttpError);

console.log("console_desk_mock_validation: PASS (session/ribbon/overview/accounts/credentials/switch fixtures vs schemas/desk, guards, totals, gaps, mock switch; synthetic only)");
// 7. A page's fixture chip follows every endpoint it renders, not just the two
// global reads. A page mixing live reads and fixtures must never look real.
assert.deepEqual(Object.keys(DESK_PAGE_ENDPOINTS).sort(),
["accounts", "alerts", "logs", "overview", "performance", "switch"]);
for (const [page, endpoints] of Object.entries(DESK_PAGE_ENDPOINTS)) {
assert.ok(endpoints.length > 0, `${page} declares its data sources`);
for (const endpoint of endpoints) assert.ok(endpoint in DESK_SOURCES, `${page}: ${endpoint} is a real endpoint`);
}
assert.equal(fixtureExposure("accounts", null), "partial", "accounts fixture + live credentials");
assert.equal(fixtureExposure("switch", null), "partial", "live targets + fixture catalog");
for (const page of ["overview", "alerts", "logs", "performance"]) {
assert.equal(fixtureExposure(page, null), "all", `${page} is still fixture-backed`);
}
for (const scenario of ["ok", "degraded", "fail"]) {
assert.equal(fixtureExposure("accounts", scenario), "all", "?mock= forces every endpoint");
}
// The shell chrome (gateway chip, alert badge) renders the global ribbon read on
// every page, so a page only looks fully live once that read is live too.
assert.deepEqual([...DESK_SHELL_ENDPOINTS], ["ribbon"]);
const originalSources = { ...DESK_SOURCES };
DESK_SOURCES.accounts = "live";
assert.equal(fixtureExposure("accounts", null), "partial", "a fixture ribbon in the shell keeps the chip");
DESK_SOURCES.performance = "live";
assert.equal(fixtureExposure("performance", null), "partial", "single-endpoint page with a fixture shell");
// When a backend lands, flipping its source must retire the chip for that page.
DESK_SOURCES.ribbon = "live";
assert.equal(fixtureExposure("accounts", null), "none");
assert.equal(fixtureExposure("performance", null), "none");
assert.equal(fixtureExposure("overview", null), "partial", "overview fixture with a live ribbon");
Object.assign(DESK_SOURCES, originalSources);

// Each fixture-backed page names the fixtures it renders, in both dictionaries.
const pageLabels = [
["LogsPage.tsx", /mock\.badge/],
["AlertsPage.tsx", /delivery\.state === "ready"[\s\S]*mock\.badge/],
["StrategiesPage.tsx", /catalogMock[\s\S]*catalog_mock_note/],
];
for (const [file, pattern] of pageLabels) {
const source = readFileSync(new URL(`../web/strategy-switch-console/frontend/src/desk/pages/${file}`, import.meta.url), "utf8");
assert.match(source, pattern, `${file} keeps an explicit fixture label`);
}
for (const file of ["zh-CN.ts", "en.ts"]) {
const messages = readFileSync(new URL(`../web/strategy-switch-console/frontend/src/desk/i18n/${file}`, import.meta.url), "utf8");
for (const key of ["mock.badge_partial", "mock.title_partial", "strategies.catalog_mock_note"]) {
assert.ok(messages.includes(`"${key}"`), `${file} has ${key}`);
}
}

console.log("console_desk_mock_validation: PASS (session/ribbon/overview/accounts/credentials/switch fixtures vs schemas/desk, guards, totals, gaps, mock switch, per-page fixture exposure; synthetic only)");
13 changes: 13 additions & 0 deletions tests/console_desk_shell_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,19 @@ for (const language of ["en", "zh"]) {
assert.doesNotMatch(gatewayStatus(gateway, language), /Gateways reachable|网关可达/);
}
}
// 8. A page mixing live reads and fixtures shows a partial-mock chip; a fully
// live page shows none. Both the badge and its explanation are localized.
const mockChip = (language, mock, mockPartial) => renderToStaticMarkup(React.createElement(component.exports.TopBar, {
page: "overview", t: (key, values) => deskTranslate(language, key, values), language,
theme: "light", gateway: { kind: "loading" }, nowIso: "2026-11-02T14:00:00Z", serverClock: true,
mock, mockPartial, onLanguage() {}, onTheme() {},
}));
for (const language of ["en", "zh"]) {
const partial = mockChip(language, "ok", true);
assert.ok(partial.includes(deskTranslate(language, "mock.badge_partial")), `${language} names partial fixtures`);
assert.ok(partial.includes(deskTranslate(language, "mock.title_partial")), `${language} explains mixed sources`);
assert.doesNotMatch(mockChip(language, null, false), /mrd-chip-mock/, `${language} hides the chip when no fixtures are shown`);
}
console.log("desk gateway rendering validation passed");

}
10 changes: 7 additions & 3 deletions web/strategy-switch-console/frontend/src/desk/DeskApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { createRoot } from "react-dom/client";
import { invalidatePrivateSession, postJson } from "../api/client";
import { loadPreferences, resolveTheme, savePreferences, type Language, type ThemePreference } from "../lib/prefs";
import { createDeskApi, type DeskResult } from "./api/endpoints.ts";
import { createDeskApi, fixtureExposure, type DeskResult } from "./api/endpoints.ts";
import { MOCK_STORAGE_KEY, resolveMockScenario } from "./api/mock.ts";
import type { DeskSession, Ribbon } from "./contract/types";
import { clockOffsetMs, secondsBetween, serverNowIso } from "./format.ts";
Expand Down Expand Up @@ -151,8 +151,11 @@ export default function DeskApp() {

const gateway: GatewayChip = ribbon.state === "ready" ? { kind: "read", read: ribbon.result.data.console_state.gateways } : { kind: ribbon.state };
const alertCount = ribbon.state === "ready" ? ribbon.result.data.alert_badge.open_count : null;
const mockSources = [session, ribbon].filter(item => item.state === "ready" && item.result.source === "mock");
const mock = mockSources.length ? scenario ?? "ok" : null;
// A page may mix live reads and fixtures. Derive the chip from every endpoint
// that page renders, so a newly live endpoint cannot make fixtures look real.
const exposure = fixtureExposure(page, scenario);
const mock = exposure === "none" ? null : (scenario ?? "ok");
const mockPartial = exposure === "partial";
const serverClock = session.state === "ready" && offsetMs !== null;
const nowIso = serverNowIso(serverClock ? offsetMs : null, clientNow);

Expand Down Expand Up @@ -203,6 +206,7 @@ export default function DeskApp() {
nowIso={nowIso}
serverClock={serverClock}
mock={mock}
mockPartial={mockPartial}
freshness={lock ? null : freshness}
lock={lock}
onNavigate={navigate}
Expand Down
30 changes: 30 additions & 0 deletions web/strategy-switch-console/frontend/src/desk/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from "./guards.ts";
import { PERF_BENCH_API } from "../performance/benchmarks.ts";
import { loadMock, type MockEndpoint, type MockScenario } from "./mock.ts";
import type { DeskPage } from "../router.ts";

export type DeskSource = "live" | "mock";

Expand All @@ -35,6 +36,35 @@ export const DESK_SOURCES: Record<MockEndpoint, DeskSource> = {
logs: "mock",
};

/** Which endpoints each desk page actually renders. Fixture labelling follows
this map, not just the two global reads: a page with one live endpoint must
still name the fixtures it shows. Keep in sync with the pages' api.* calls. */
export const DESK_PAGE_ENDPOINTS: Record<DeskPage, readonly MockEndpoint[]> = {
overview: ["overview", "ribbon"],
accounts: ["accounts", "credentials"],
switch: ["switchTargets", "switchStrategies"],
performance: ["performance"],
alerts: ["alerts", "alertDelivery"],
logs: ["ribbon", "logs"],
};

/** Endpoints the shell renders on every page: the ribbon feeds the gateway chip
and the alert badge. (The session only gates access and the clock.) */
export const DESK_SHELL_ENDPOINTS: readonly MockEndpoint[] = ["ribbon"];

export type FixtureExposure = "none" | "all" | "partial";

/** Whether the current page (including the shell around it) shows fixtures, and
whether any of its data is live. `?mock=` forces every endpoint to a fixture,
so that is always "all". */
export function fixtureExposure(page: DeskPage, forcedScenario: MockScenario | null): FixtureExposure {
if (forcedScenario) return "all";
const endpoints = [...new Set([...(DESK_PAGE_ENDPOINTS[page] ?? []), ...DESK_SHELL_ENDPOINTS])];
const fixtures = endpoints.filter(endpoint => DESK_SOURCES[endpoint] === "mock");
if (!fixtures.length) return "none";
return fixtures.length === endpoints.length ? "all" : "partial";
}

export interface DeskResult<T> {
data: T;
source: DeskSource;
Expand Down
3 changes: 3 additions & 0 deletions web/strategy-switch-console/frontend/src/desk/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const en: Record<DeskKey, string> = {
"drawdown.breached": "Drawdown over limit",
"drawdown.unknown": "Drawdown unknown",
"mock.badge": "Mock data",
"mock.badge_partial": "Partial mock data",

"shell.brand": "QuantStrategyLab",
"shell.nav_label": "Main navigation",
Expand All @@ -80,6 +81,7 @@ export const en: Record<DeskKey, string> = {
"theme.to_light": "Switch to light",
"mock.scenario": "Mock data · {scenario}",
"mock.title": "This is example data, not your accounts.",
"mock.title_partial": "Some data on this page is example data, not your live accounts; the rest is a real read.",
"access.title": "Sign in required",
"access.body": "Sign in to open the console.",
"access.eyebrow": "Access",
Expand Down Expand Up @@ -360,6 +362,7 @@ export const en: Record<DeskKey, string> = {
"strategies.back": "Back",
"strategies.reset": "Start over",
"strategies.mock_note": "Preview data: running targets and the catalog are synthetic.",
"strategies.catalog_mock_note": "Preview data: the strategy catalog is synthetic; targets come from the configuration read.",
"strategies.target.unknown": "Target unknown",
"strategies.reason.switch_not_connected": "Switching is not connected",
"strategies.reason.blocked": "Catalog strategy is blocked",
Expand Down
3 changes: 3 additions & 0 deletions web/strategy-switch-console/frontend/src/desk/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const zh = {
"drawdown.breached": "回撤超出上限",
"drawdown.unknown": "回撤未知",
"mock.badge": "模拟数据",
"mock.badge_partial": "部分模拟数据",

// Shell.
"shell.brand": "QuantStrategyLab",
Expand All @@ -86,6 +87,7 @@ export const zh = {
"theme.to_light": "切换到浅色",
"mock.scenario": "模拟数据 · {scenario}",
"mock.title": "这些数据是示例,不是你的账户。",
"mock.title_partial": "本页部分数据为示例,不是账户实况;其余为真实读取。",
"access.title": "需要登录",
"access.body": "登录后才能查看控制台。",
"access.eyebrow": "访问权限",
Expand Down Expand Up @@ -367,6 +369,7 @@ export const zh = {
"strategies.back": "上一步",
"strategies.reset": "重新开始",
"strategies.mock_note": "预览数据:运行目标与策略目录均为合成数据。",
"strategies.catalog_mock_note": "预览数据:策略目录为合成数据;运行目标来自配置读取。",
"strategies.target.unknown": "目标未知",
"strategies.reason.switch_not_connected": "切换尚未接通",
"strategies.reason.blocked": "目录策略已阻止",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ export function AlertsPage({ api, t, language }: { api: DeskApi; t: DeskTranslat

return (
<section className="mrd-alerts-page" aria-labelledby="mrd-page-title">
{loaded?.source === "mock" ? <p className="mrd-note mrd-page-note">{t("mock.badge")}</p> : null}
{loaded?.source === "mock" || (delivery.state === "ready" && delivery.result.source === "mock")
? <p className="mrd-note mrd-page-note" title={t("mock.title")}>{t("mock.badge")}</p> : null}
{body}
</section>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export function LogsPage({ api, t }: { api: DeskApi; t: DeskTranslate; language:

return (
<section className="mrd-logs-page" aria-labelledby="mrd-page-title">
{[ribbon, logs].some(item => item.state === "ready" && item.result.source === "mock")
? <p className="mrd-note mrd-page-note" title={t("mock.title")}>{t("mock.badge")}</p> : null}
<div className="mrd-card">
<span className="mrd-label">{t("logs.eyebrow")}</span>
<h1 id="mrd-page-title" className="mrd-serif mrd-logs-title">{headline}</h1>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ export function StrategiesPage({ api, t, language, admin, initialTarget, initial
() => (targetId ? api.switchStrategies(targetId) : Promise.resolve(null)),
[api, targetId],
);
const targetsMock = targets.load.state === "ready" && targets.load.result.source === "mock";
const catalogMock = strategies.load.state === "ready" && strategies.load.result?.source === "mock";

useEffect(() => {
if (initialTarget) {
Expand Down Expand Up @@ -394,7 +396,9 @@ export function StrategiesPage({ api, t, language, admin, initialTarget, initial
</div>
</section>

{targets.load.state === "ready" && targets.load.result.source === "mock" ? <p style={P.muted}>{t("strategies.mock_note")}</p> : null}
{targetsMock && catalogMock ? <p style={P.muted}>{t("strategies.mock_note")}</p>
: catalogMock ? <p style={P.muted}>{t("strategies.catalog_mock_note")}</p>
: targetsMock ? <p style={P.muted}>{t("mock.badge")}</p> : null}
</section>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export interface DeskShellProps {
nowIso: string;
serverClock: boolean;
mock: MockScenario | null;
/** True when only some of the page's endpoints are fixtures. */
mockPartial?: boolean;
freshness?: FreshnessChipState | null;
/** Non-null for 401/403/failed access: the shell shows no runtime data at all. */
lock?: ShellLock | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ interface TopBarProps {
serverClock: boolean;
/** Non-null when any data on screen came from fixtures. */
mock: MockScenario | null;
/** True when only some of the page's endpoints are fixtures. */
mockPartial?: boolean;
/** Optional global freshness chip; omitted in contexts that do not have a read. */
freshness?: FreshnessChipState | null;
/** Set for 401/403/failed access: no gateway, alert, freshness or clock data is shown. */
Expand All @@ -47,7 +49,7 @@ function gatewayView(chip: GatewayChip): { key: DeskKey; tone: string; ageSecond
return { key: `gateway.${read.value}` as DeskKey, tone };
}

export function TopBar({ page, t, language, theme, gateway, nowIso, serverClock, mock, freshness, lock, onLanguage, onTheme }: TopBarProps) {
export function TopBar({ page, t, language, theme, gateway, nowIso, serverClock, mock, mockPartial = false, freshness, lock, onLanguage, onTheme }: TopBarProps) {
const chip = gatewayView(gateway);
const time = formatUtcHm(nowIso) ?? "--:--";
const day = formatUtcDayLabel(nowIso, language);
Expand All @@ -64,8 +66,9 @@ export function TopBar({ page, t, language, theme, gateway, nowIso, serverClock,
) : (
<>
{mock ? (
<span className="mrd-chip mrd-chip-mock" title={t("mock.title")}>
{mock === "ok" ? t("mock.badge") : t("mock.scenario", { scenario: mock })}
<span className="mrd-chip mrd-chip-mock" title={t(mockPartial ? "mock.title_partial" : "mock.title")}>
{mock !== "ok" ? t("mock.scenario", { scenario: mock })
: t(mockPartial ? "mock.badge_partial" : "mock.badge")}
</span>
) : null}
{freshness ? <FreshnessChip state={freshness} t={t} language={language} /> : null}
Expand Down
Loading
Loading