From 3a56f564a9f058886f3dfa435cb6f8a7d8918e40 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 1 Nov 2026 03:44:54 +0800 Subject: [PATCH 1/2] fix(console): surface unreadable to-do count and account metadata --- ...shell_unavailable_source_ui_validation.mjs | 65 +++++++++++++++++++ .../frontend/package.json | 2 +- .../frontend/src/app/App.tsx | 27 ++++++-- .../frontend/src/app/AppShell.tsx | 9 ++- .../frontend/src/app/app.css | 2 + .../frontend/src/app/context.ts | 6 +- .../frontend/src/components/ScopeSwitcher.tsx | 12 +++- .../frontend/src/i18n/en.ts | 2 + .../frontend/src/i18n/zh-CN.ts | 2 + .../src/pages/performance/benchmarkPolicy.ts | 4 +- web/strategy-switch-console/v2_asset_map.js | 36 +++++----- 11 files changed, 136 insertions(+), 31 deletions(-) create mode 100644 tests/console_shell_unavailable_source_ui_validation.mjs 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..2e7657f8 --- /dev/null +++ b/tests/console_shell_unavailable_source_ui_validation.mjs @@ -0,0 +1,65 @@ +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 { 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 = new URL("../web/strategy-switch-console/frontend/", import.meta.url).pathname; +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 { benchmarkPolicy } from './src/pages/performance/benchmarkPolicy'; + export { benchmarkPolicy }; + 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' }))))); + ` }, 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"); + + // --- 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"); + + // --- Boot reads ----------------------------------------------------------- + const app = await readFile(join(root, "src/app/App.tsx"), "utf8"); + assert.match(app, /catch \{\s*if \(cancelled\) return;\s*setTodoStatus\("read_failed"\);/, + "a thrown inbox read is recorded as unreadable, not swallowed"); + 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"/, "the scope panel states that metadata is unavailable"); + assert.match(switcher, /t\("shell\.scope_meta_error"\)/, "the unavailable metadata copy is localized"); + 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 { + await rm(directory, { recursive: true, force: true }); +} diff --git a/web/strategy-switch-console/frontend/package.json b/web/strategy-switch-console/frontend/package.json index 019e4c9d..eaa4ba39 100644 --- a/web/strategy-switch-console/frontend/package.json +++ b/web/strategy-switch-console/frontend/package.json @@ -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" }, diff --git a/web/strategy-switch-console/frontend/src/app/App.tsx b/web/strategy-switch-console/frontend/src/app/App.tsx index 6c2ff4b3..eb136ded 100644 --- a/web/strategy-switch-console/frontend/src/app/App.tsx +++ b/web/strategy-switch-console/frontend/src/app/App.tsx @@ -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); @@ -94,12 +96,17 @@ export default function App() { } setSession(next); setBoot("ready"); + setTodoStatus("loading"); try { const inbox = await loadInbox(); if (cancelled) return; - setTodoCount(inbox.status === "read_failed" ? null : inbox.items.filter(item => item.status === "pending").length); + 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) setTodoCount(null); + if (cancelled) return; + setTodoStatus("read_failed"); + setTodoCount(null); } } catch (error) { if (cancelled) return; @@ -124,11 +131,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 +244,7 @@ export default function App() { options: scopeOptions, setScope: (scope: string) => navigate({ scope }), status: scopeStatus, + metaStatus, retry: retryScope, }; @@ -242,6 +258,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..c2e99eb7 100644 --- a/web/strategy-switch-console/frontend/src/components/ScopeSwitcher.tsx +++ b/web/strategy-switch-console/frontend/src/components/ScopeSwitcher.tsx @@ -8,7 +8,7 @@ import { useT } from "../i18n"; 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 +58,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 +98,12 @@ export function ScopeSwitcher() { {status === "error" && } )} + {status === "ready" && metaStatus === "error" && ( +
+ {t("shell.scope_meta_error")} + +
+ )} + + ); +} + /** Topbar account-scope selector; grouped by platform, paper accounts in their own group. */ export function ScopeSwitcher() { const t = useT(); @@ -98,12 +109,7 @@ export function ScopeSwitcher() { {status === "error" && } )} - {status === "ready" && metaStatus === "error" && ( -
- {t("shell.scope_meta_error")} - -
- )} + {status === "ready" && metaStatus === "error" && }