diff --git a/tests/console_shell_unavailable_source_ui_validation.mjs b/tests/console_shell_unavailable_source_ui_validation.mjs new file mode 100644 index 00000000..0a1478e9 --- /dev/null +++ b/tests/console_shell_unavailable_source_ui_validation.mjs @@ -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" && { let inboxRead: Promise | 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; @@ -48,7 +54,7 @@ export function loadInbox(force = false): Promise { if (inboxCached && inboxCached.revision === revision && Date.now() < inboxCached.until && (!force || inboxCached.value.status === "read_failed")) return Promise.resolve(inboxCached.value); if (!inboxRead) inboxRead = getJson("/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; } diff --git a/web/strategy-switch-console/frontend/src/app/App.tsx b/web/strategy-switch-console/frontend/src/app/App.tsx index 6c2ff4b3..ab0133cb 100644 --- a/web/strategy-switch-console/frontend/src/app/App.tsx +++ b/web/strategy-switch-console/frontend/src/app/App.tsx @@ -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"; @@ -49,8 +49,10 @@ export default function App() { const [scopeOptions, setScopeOptions] = useState([]); 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(null); + const [todoStatus, setTodoStatus] = useState<"loading" | "ok" | "read_failed">("loading"); const [updatedAt, setUpdatedAt] = useState(null); const [bootEpoch, setBootEpoch] = useState(0); const routeRef = useRef(route); @@ -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 () => { @@ -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"); @@ -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; @@ -124,11 +147,19 @@ export default function App() { let config: DirectoryConfig | null = null; try { config = await getJson("/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 { @@ -229,6 +260,7 @@ export default function App() { options: scopeOptions, setScope: (scope: string) => navigate({ scope }), status: scopeStatus, + metaStatus, retry: retryScope, }; @@ -242,6 +274,7 @@ export default function App() { 0 ? {todoCount} : 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 = "") => ( @@ -95,13 +100,13 @@ export function AppShell({ 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 })} > {t(item.labelKey)} - {item.id === "decisions" && badge} + {item.id === "decisions" && (badge || (todoUnknown && ))} ); diff --git a/web/strategy-switch-console/frontend/src/app/app.css b/web/strategy-switch-console/frontend/src/app/app.css index f4b52563..f2218075 100644 --- a/web/strategy-switch-console/frontend/src/app/app.css +++ b/web/strategy-switch-console/frontend/src/app/app.css @@ -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; diff --git a/web/strategy-switch-console/frontend/src/app/context.ts b/web/strategy-switch-console/frontend/src/app/context.ts index eb85d11a..f6b89bd8 100644 --- a/web/strategy-switch-console/frontend/src/app/context.ts +++ b/web/strategy-switch-console/frontend/src/app/context.ts @@ -25,7 +25,8 @@ 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 { @@ -33,6 +34,8 @@ export interface ScopeContextValue { 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; } @@ -41,6 +44,7 @@ export const ScopeContext = createContext({ options: [], setScope: () => {}, status: "ready", + metaStatus: "ready", retry: () => {}, }); diff --git a/web/strategy-switch-console/frontend/src/components/ScopeSwitcher.tsx b/web/strategy-switch-console/frontend/src/components/ScopeSwitcher.tsx index 6da503ee..8be27a16 100644 --- a/web/strategy-switch-console/frontend/src/components/ScopeSwitcher.tsx +++ b/web/strategy-switch-console/frontend/src/components/ScopeSwitcher.tsx @@ -4,11 +4,22 @@ import { useEffect, useRef, useState } from "react"; import { useScope } from "../app/context"; import { useT } from "../i18n"; +/** Stated once for the whole panel: ten accounts with an unknown environment are one line, not ten. */ +export function ScopeMetaNotice({ onRetry }: { onRetry: () => void }) { + const t = useT(); + return ( +
+ {t("shell.scope_meta_error")} + +
+ ); +} + /** Topbar account-scope selector; grouped by platform, paper accounts in their own group. */ export function ScopeSwitcher() { const t = useT(); const names = useDisplayNames(); - const { scope, options, setScope, status, retry } = useScope(); + const { scope, options, setScope, status, metaStatus, retry } = useScope(); const [open, setOpen] = useState(false); const rootRef = useRef(null); const triggerRef = useRef(null); @@ -58,7 +69,7 @@ export function ScopeSwitcher() { const current = scope === "all" ? t("shell.scope_all") : selected ? `${names.platform(selected.platform)} · ${names.account(selected.alias, selected.platform)}` : t("common.unknown"); const platforms = [...new Set(options.map(option => option.platform))]; - const optionRow = (option: { alias: string; platform: string; paper: boolean }) => ( + const optionRow = (option: { alias: string; platform: string; paper: boolean | null }) => ( ); @@ -98,6 +109,7 @@ export function ScopeSwitcher() { {status === "error" && } )} + {status === "ready" && metaStatus === "error" && }