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
101 changes: 101 additions & 0 deletions tests/console_shell_unavailable_source_ui_validation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { build } from "../web/strategy-switch-console/frontend/node_modules/esbuild/lib/main.js";

// An unreadable auxiliary source must never look like a clean empty state.
const root = fileURLToPath(new URL("../web/strategy-switch-console/frontend/", import.meta.url));
const directory = await mkdtemp(join(tmpdir(), "shell-unavailable-source-"));
try {
const outfile = join(directory, "shell.cjs");
await build({ stdin: { resolveDir: root, contents: `
import { createElement as h } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from './src/i18n';
import { AccountDirectoryContext, ScopeContext } from './src/app/context';
import { AppShell } from './src/app/AppShell';
import { ScopeMetaNotice } from './src/components/ScopeSwitcher';
import { benchmarkPolicy } from './src/pages/performance/benchmarkPolicy';
import { loadInbox, onInboxRead } from './src/api/endpoints';
export { benchmarkPolicy, loadInbox, onInboxRead };
const scope = (overrides) => ({ scope: 'all', options: [], setScope: () => {}, status: 'ready', metaStatus: 'ready', retry: () => {}, ...overrides });
export const shell = (language, todoCount, todoStatus) => renderToStaticMarkup(h(I18nProvider, { language },
h(AccountDirectoryContext.Provider, { value: null },
h(ScopeContext.Provider, { value: scope({}) },
h(AppShell, { page: 'overview', todoCount, todoStatus, synthetic: false, login: null, updatedAt: null, onLogout: () => {}, children: 'x' })))));
export const metaNotice = (language) => renderToStaticMarkup(h(I18nProvider, { language },
h(ScopeMetaNotice, { onRetry: () => {} })));
` }, bundle: true, platform: "node", format: "cjs", outfile, jsx: "automatic", loader: { ".css": "empty" }, define: { "import.meta.env": "{}" }, logLevel: "silent" });
const ui = createRequire(import.meta.url)(outfile);

// --- To-do badge ----------------------------------------------------------
const unknownEn = ui.shell("en", null, "read_failed");
assert.match(unknownEn, /nav-badge-unknown/, "an unreadable inbox marks the to-do item");
assert.match(unknownEn, /aria-label="To-dos · To-do count unavailable"/,
"the unknown marker is announced, because the button label replaces its contents");
assert.match(ui.shell("zh", null, "read_failed"), /待办数量不可读/, "zh: the unknown marker is localized");
assert.doesNotMatch(ui.shell("en", null, "ok"), /nav-badge/, "a readable inbox with nothing pending stays blank");
assert.doesNotMatch(ui.shell("en", null, "loading"), /nav-badge-unknown/, "a count still in flight is not called unreadable");
const counted = ui.shell("en", 3, "read_failed");
assert.match(counted, /class="nav-badge">3/, "a known count outranks the unknown marker");
assert.doesNotMatch(counted, /nav-badge-unknown/, "both markers never render at once");

// --- Scope metadata notice ------------------------------------------------
assert.match(ui.metaNotice("en"), /Account names and paper\/live labels are unavailable/);
assert.match(ui.metaNotice("zh"), /无法读取账户名称与模拟\/实盘标记/);
assert.match(ui.metaNotice("en"), /role="status"/, "the notice is announced once, not per account");
assert.match(ui.metaNotice("zh"), /Retry|重试/, "the notice offers the existing retry");

// --- Benchmark basis ------------------------------------------------------
// A directory read failure leaves every environment unknown; that is not a licence
// to treat paper accounts as live, so only declared live accounts form the basis.
const unknownEnvironment = ui.benchmarkPolicy("all", undefined, [
{ alias: "tqqq", platform: "ibkr", paper: null },
{ alias: "live", platform: "binance", paper: false },
]);
assert.equal(unknownEnvironment.reason, "crypto", "an unknown environment was counted as live");
assert.equal(ui.benchmarkPolicy("all", undefined, [{ alias: "tqqq", platform: "ibkr", paper: null }]).enabled, true,
"with no known live account the whole cohort still forms the basis");

// --- One inbox read, every reader ---------------------------------------
const notified = [];
const unsubscribe = ui.onInboxRead(() => notified.push("read"));
const inbox = (status) => new Response(JSON.stringify({ status, items: status === "read_failed" ? [] : [{ status: "pending" }] }),
{ status: 200, headers: { "Content-Type": "application/json" } });
let fetches = 0;
globalThis.fetch = async () => { fetches += 1; return inbox("ok"); };
await ui.loadInbox();
assert.equal(fetches, 1);
assert.deepEqual(notified, ["read"], "a fresh inbox read is announced to the shell");
await ui.loadInbox();
assert.equal(fetches, 1, "a successful read stays cached");
assert.equal(notified.length, 1, "a cache hit never re-announces, so the shell cannot loop into a new request");
await ui.loadInbox(true);
assert.equal(fetches, 2, "an explicit refresh still reads");
assert.equal(notified.length, 2);
globalThis.fetch = async () => { fetches += 1; return inbox("read_failed"); };
const failed = await ui.loadInbox(true);
assert.equal(failed.status, "read_failed");
assert.equal(notified.length, 3, "a failed read is announced too, so the badge can say unknown");
unsubscribe();
await ui.loadInbox(true);
assert.equal(notified.length, 3, "an unmounted shell stops hearing reads");

// --- Boot wiring ----------------------------------------------------------
const app = await readFile(join(root, "src/app/App.tsx"), "utf8");
assert.match(app, /const unsubscribe = onInboxRead\(sync\)/,
"the shell badge follows every inbox read, not only the boot one");
assert.match(app, /setTodoStatus\("read_failed"\);/, "an inbox read that rejects is recorded as unreadable");
assert.match(app, /setMetaStatus\(config \? "ready" : "error"\)/, "a failed /api/config read is recorded");
assert.match(app, /paper: config[\s\S]{0,220}: null,/, "an account with no directory has an unknown environment");
const switcher = await readFile(join(root, "src/components/ScopeSwitcher.tsx"), "utf8");
assert.match(switcher, /metaStatus === "error" && <ScopeMetaNotice/, "the scope panel states that metadata is unavailable");
assert.match(switcher, /option\.paper === true/, "only a declared paper account gets the paper badge");
console.log("console_shell_unavailable_source_ui_validation: PASS");
} finally {
delete globalThis.fetch;
await rm(directory, { recursive: true, force: true });
}
2 changes: 1 addition & 1 deletion web/strategy-switch-console/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"build": "tsc --noEmit && vite build && node scripts/check-budget.mjs",
"sync:v2-assets": "node scripts/sync-v2-asset-map.mjs",
"test:operations": "node --experimental-strip-types ../../../tests/console_v2_operations_validation.mjs && node --experimental-strip-types ../../../tests/console_v2_regressions_validation.mjs && node --experimental-strip-types ../../../tests/console_v2_preferences_accounts_validation.mjs && node ../../../tests/console_runtime_control_result_ui_validation.mjs && node ../../../tests/console_page_load_boundary_validation.mjs && node --test ../../../tests/fe8b_fix03_ui_validation.mjs ../../../tests/console_notification_receipts_ui_validation.mjs && npm run test:performance && node --test ../../../tests/console_execution_evidence_validation.mjs && node ../../../tests/console_strategy_execution_validation.mjs && node --experimental-strip-types ../../../tests/console_binance_wallet_cards_validation.mjs && node ../../../tests/console_tabs_scope_sources_ui_validation.mjs",
"test:operations": "node --experimental-strip-types ../../../tests/console_v2_operations_validation.mjs && node --experimental-strip-types ../../../tests/console_v2_regressions_validation.mjs && node --experimental-strip-types ../../../tests/console_v2_preferences_accounts_validation.mjs && node ../../../tests/console_runtime_control_result_ui_validation.mjs && node ../../../tests/console_page_load_boundary_validation.mjs && node --test ../../../tests/fe8b_fix03_ui_validation.mjs ../../../tests/console_notification_receipts_ui_validation.mjs && npm run test:performance && node --test ../../../tests/console_execution_evidence_validation.mjs && node ../../../tests/console_strategy_execution_validation.mjs && node --experimental-strip-types ../../../tests/console_binance_wallet_cards_validation.mjs && node ../../../tests/console_tabs_scope_sources_ui_validation.mjs && node ../../../tests/console_shell_unavailable_source_ui_validation.mjs",
"dev:synthetic": "node scripts/dev-synthetic.mjs",
"test:performance": "node --experimental-strip-types ../../../tests/console_performance_math_validation.mjs && node --experimental-strip-types ../../../tests/console_performance_data_validation.mjs && node --experimental-strip-types ../../../tests/console_calendar_pending_ui_validation.mjs && node ../../../tests/website_currency_ranking_validation.mjs && node ../../../tests/website_summary_request_validation.mjs && node ../../../tests/console_compact_summary_validation.mjs && node ../../../tests/native_daily_gap_ui_validation.mjs"
},
Expand Down
8 changes: 7 additions & 1 deletion web/strategy-switch-console/frontend/src/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export function loadSession(): Promise<Session> {

let inboxRead: Promise<InboxResponse> | null = null;
let inboxCached: { value: InboxResponse; until: number; revision: number } | null = null;
const inboxListeners = new Set<() => void>();
/** Subscribe to every fresh inbox read, so the shell badge and the pages share one request. */
export function onInboxRead(listener: () => void): () => void {
inboxListeners.add(listener);
return () => { inboxListeners.delete(listener); };
}
function inboxTtl(failed: boolean): number {
const configured = Number(failed ? import.meta.env?.VITE_INBOX_FAILURE_BACKOFF_MS : import.meta.env?.VITE_INBOX_CACHE_MS);
return Number.isFinite(configured) && configured >= 1000 && configured <= 300_000 ? configured : failed ? 60_000 : 15_000;
Expand All @@ -48,7 +54,7 @@ export function loadInbox(force = false): Promise<InboxResponse> {
if (inboxCached && inboxCached.revision === revision && Date.now() < inboxCached.until
&& (!force || inboxCached.value.status === "read_failed")) return Promise.resolve(inboxCached.value);
if (!inboxRead) inboxRead = getJson<InboxResponse>("/api/decisions-inbox")
.then(value => { inboxCached = { value, until: Date.now() + inboxTtl(value.status === "read_failed"), revision }; return value; })
.then(value => { inboxCached = { value, until: Date.now() + inboxTtl(value.status === "read_failed"), revision }; for (const listener of [...inboxListeners]) listener(); return value; })
.finally(() => { inboxRead = null; });
return inboxRead;
}
Expand Down
57 changes: 45 additions & 12 deletions web/strategy-switch-console/frontend/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { AccountDirectoryContext, NavigateContext, PrefsContext, ScopeContext, t
import { loadPreferences, resolveTheme, savePreferences, type Preferences } from "../lib/prefs";
import { getNavigationGuard, parseRoute, pushRoute, type Route } from "./router";
import { AccessError, getJson, invalidatePrivateSession, postJson } from "../api/client";
import { loadAccounts, loadInbox, loadSession, type Session } from "../api/endpoints";
import { loadAccounts, loadInbox, loadSession, onInboxRead, type Session } from "../api/endpoints";
import type { DirectoryConfig } from "../lib/accountDirectory";
import { AppShell } from "./AppShell";
import { ToastProvider } from "../components/Toast";
Expand Down Expand Up @@ -49,8 +49,10 @@ export default function App() {
const [scopeOptions, setScopeOptions] = useState<ScopeOption[]>([]);
const [scopeStatus, setScopeStatus] = useState<"loading" | "ready" | "error">("loading");
const [scopeEpoch, setScopeEpoch] = useState(0);
const [metaStatus, setMetaStatus] = useState<"ready" | "error">("ready");
const [persistFailed, setPersistFailed] = useState(false);
const [todoCount, setTodoCount] = useState<number | null>(null);
const [todoStatus, setTodoStatus] = useState<"loading" | "ok" | "read_failed">("loading");
const [updatedAt, setUpdatedAt] = useState<string | null>(null);
const [bootEpoch, setBootEpoch] = useState(0);
const routeRef = useRef(route);
Expand Down Expand Up @@ -80,7 +82,7 @@ export default function App() {
return () => mq.removeEventListener?.("change", update);
}, []);

// Boot: session, then the todo badge. Auxiliary read failures never block the shell.
// Boot reads the session only; every auxiliary read is a separate effect the shell can fail into.
useEffect(() => {
let cancelled = false;
(async () => {
Expand All @@ -94,13 +96,6 @@ export default function App() {
}
setSession(next);
setBoot("ready");
try {
const inbox = await loadInbox();
if (cancelled) return;
setTodoCount(inbox.status === "read_failed" ? null : inbox.items.filter(item => item.status === "pending").length);
} catch {
if (!cancelled) setTodoCount(null);
}
} catch (error) {
if (cancelled) return;
if (error instanceof AccessError && error.status === 401) setBoot("denied");
Expand All @@ -112,6 +107,34 @@ export default function App() {
};
}, [bootEpoch]);

// The to-do badge follows every inbox read, not just the boot one: the To-dos and
// Overview pages retry the same cached request, and a badge stuck on the boot
// outcome would keep reporting an outage the pages have already recovered from.
useEffect(() => {
if (boot !== "ready") return;
let cancelled = false;
const sync = () => {
void loadInbox().then(inbox => {
if (cancelled) return;
const unreadable = inbox.status === "read_failed";
setTodoStatus(unreadable ? "read_failed" : "ok");
setTodoCount(unreadable ? null : inbox.items.filter(item => item.status === "pending").length);
}).catch(() => {
if (cancelled) return;
setTodoStatus("read_failed");
setTodoCount(null);
});
};
setTodoStatus("loading");
setTodoCount(null);
sync();
const unsubscribe = onInboxRead(sync);
return () => {
cancelled = true;
unsubscribe();
};
}, [boot]);

// Scope options load on their own so a failure can be retried without re-reading the session or inbox.
useEffect(() => {
if (boot !== "ready") return;
Expand All @@ -124,11 +147,19 @@ export default function App() {
let config: DirectoryConfig | null = null;
try {
config = await getJson<DirectoryConfig>("/api/config");
} catch { /* The list remains usable when optional account metadata is unavailable. */ }
} catch { /* The list stays usable; only the metadata below is unknown. */ }
if (cancelled) return;
setDirectory(config);
setScopeOptions(accounts.accounts.map(account => ({ alias: account.account_alias, platform: account.platform,
paper: config?.accountOptions?.[account.platform]?.find(option => option.key === account.account_alias)?.broker_environment === "paper" })));
setMetaStatus(config ? "ready" : "error");
setScopeOptions(accounts.accounts.map(account => ({
alias: account.account_alias,
platform: account.platform,
// Without the directory, an account has no declared environment: neither
// "paper" nor "live" is known, so no paper badge and no live totals.
paper: config
? config.accountOptions?.[account.platform]?.find(option => option.key === account.account_alias)?.broker_environment === "paper"
: null,
})));
setUpdatedAt(new Date().toISOString());
setScopeStatus("ready");
} catch {
Expand Down Expand Up @@ -229,6 +260,7 @@ export default function App() {
options: scopeOptions,
setScope: (scope: string) => navigate({ scope }),
status: scopeStatus,
metaStatus,
retry: retryScope,
};

Expand All @@ -242,6 +274,7 @@ export default function App() {
<AppShell
page={route.page}
todoCount={todoCount}
todoStatus={todoStatus}
synthetic={session?.synthetic === true}
login={session?.login || null}
updatedAt={updatedAt}
Expand Down
9 changes: 7 additions & 2 deletions web/strategy-switch-console/frontend/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function NavIcon({ path }: { path: string }) {
export function AppShell({
page,
todoCount,
todoStatus = "ok",
synthetic,
login,
updatedAt,
Expand All @@ -39,6 +40,7 @@ export function AppShell({
}: {
page: PageId;
todoCount: number | null;
todoStatus?: "loading" | "ok" | "read_failed";
synthetic: boolean;
login: string | null;
updatedAt: string | null;
Expand Down Expand Up @@ -88,20 +90,23 @@ export function AppShell({
}, []);

const badge = todoCount !== null && todoCount > 0 ? <span className="nav-badge">{todoCount}</span> : null;
// An unreadable inbox has no count to show, and showing nothing would read as
// "nothing to do", so the item carries an explicit unknown marker instead.
const todoUnknown = todoStatus === "read_failed";
const updated = updatedAt ? formatDateTime(updatedAt, language) : null;

const navButton = (item: (typeof NAV_ITEMS)[number], extraClass = "") => (
<button
key={item.id}
type="button"
className={`${extraClass}${page === item.id ? " active" : ""}`}
aria-label={t(item.labelKey)}
aria-label={item.id === "decisions" && todoUnknown ? `${t(item.labelKey)} · ${t("nav.todo_unavailable")}` : t(item.labelKey)}
aria-current={page === item.id ? "page" : undefined}
onClick={() => navigate({ page: item.id, account: null, tab: null })}
>
<NavIcon path={item.icon} />
<span className="nav-text">{t(item.labelKey)}</span>
{item.id === "decisions" && badge}
{item.id === "decisions" && (badge || (todoUnknown && <span className="nav-badge nav-badge-unknown" aria-hidden="true">?</span>))}
</button>
);

Expand Down
2 changes: 2 additions & 0 deletions web/strategy-switch-console/frontend/src/app/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
text-align: center;
line-height: 18px;
}
/* A count that could not be read is marked, never left blank: blank reads as "no to-dos". */
.nav-badge-unknown { background: var(--surface-2); color: var(--muted); box-shadow: inset 0 0 0 1px var(--line); }
.sidenav-collapse {
margin-top: auto;
padding: 8px 12px;
Expand Down
6 changes: 5 additions & 1 deletion web/strategy-switch-console/frontend/src/app/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,17 @@ export function usePrefs(): PrefsContextValue {
export type ScopeOption = {
alias: string;
platform: string;
paper: boolean;
/** `null` means the account directory was unreadable, so the environment is unknown. */
paper: boolean | null;
};

export interface ScopeContextValue {
scope: string;
options: ScopeOption[];
setScope: (scope: string) => void;
status: "loading" | "ready" | "error";
/** Whether the account metadata behind names and paper/live labels was read. */
metaStatus: "ready" | "error";
retry: () => void;
}

Expand All @@ -41,6 +44,7 @@ export const ScopeContext = createContext<ScopeContextValue>({
options: [],
setScope: () => {},
status: "ready",
metaStatus: "ready",
retry: () => {},
});

Expand Down
Loading
Loading