From 8d617cfa1702386231cc5375c215f4602b57e07d Mon Sep 17 00:00:00 2001 From: leefer Date: Wed, 29 Jul 2026 21:38:38 +0800 Subject: [PATCH] refactor: establish shared frontend shell --- docs/governance/architecture-inventory.json | 8 +- docs/governance/stage-15-frontend-shell.md | 42 +++++ static/app.js | 191 +++---------------- static/index.html | 4 +- static/pages.config.js | 69 +++++++ static/shared/shell.js | 194 ++++++++++++++++++++ tests/test_frontend_boundaries.py | 42 ++++- tests/test_frontend_contract.py | 3 +- 8 files changed, 380 insertions(+), 173 deletions(-) create mode 100644 docs/governance/stage-15-frontend-shell.md create mode 100644 static/pages.config.js create mode 100644 static/shared/shell.js diff --git a/docs/governance/architecture-inventory.json b/docs/governance/architecture-inventory.json index cf550ca..a8cc486 100644 --- a/docs/governance/architecture-inventory.json +++ b/docs/governance/architecture-inventory.json @@ -254,8 +254,8 @@ "code_hotspots": [ { "path": "static/app.js", - "bytes": 447568, - "lines": 9403 + "bytes": 441609, + "lines": 9262 }, { "path": "static/styles.css", @@ -274,8 +274,8 @@ }, { "path": "static/index.html", - "bytes": 133691, - "lines": 1873 + "bytes": 133815, + "lines": 1875 }, { "path": "database.py", diff --git a/docs/governance/stage-15-frontend-shell.md b/docs/governance/stage-15-frontend-shell.md new file mode 100644 index 0000000..1f77f2f --- /dev/null +++ b/docs/governance/stage-15-frontend-shell.md @@ -0,0 +1,42 @@ +# Stage 15: Shared Frontend Shell and Page Registry + +## Runtime Page Registry + +`static/pages.config.js` is the build-free runtime representation of +`config/pages.config.json`. It registers every primary workspace plus the internal strategy +tracking workspace. Automated parity checks prevent titles, feature ownership, access labels, +groups, default-page selection, and layout metadata from drifting between the two registries. + +Legacy route aliases are resolved by the registry instead of page business code. The registry +describes navigation and presentation metadata only; backend authorization remains +authoritative. + +## Shared Shell + +`static/shared/shell.js` now owns: + +- sidebar initialization, persistence, collapse state, and responsive control labels; +- primary and mobile navigation binding and active-state synchronization; +- workspace mounting, entry animation, URL state, and scroll reset; +- header command-menu lifecycle; +- market-summary expansion; +- status-bar page titles and data dates; +- the single-open-dialog lifecycle used by global application dialogs. + +`static/app.js` retains feature-specific enter and leave behavior, such as stopping Wentian +animations or loading auction data. It asks the shell to mount a registered page and no longer +mutates global page geometry directly. + +## Compatibility + +- Existing DOM IDs, CSS classes, query parameters, page animations, and mobile navigation are + unchanged. +- `screenerTrackingView` continues to highlight the Intelligent Screener navigation item. +- Existing function entry points remain as thin compatibility facades where feature code still + calls status, dialog, or command-menu services. + +## Residual Risk + +Feature renderers and feature event binding still share `static/app.js`. Their state ownership +is now explicit and their shell dependencies are removed, so later page-module extraction can +be performed one feature at a time instead of as a single rewrite. diff --git a/static/app.js b/static/app.js index 1629ef4..30cbc63 100644 --- a/static/app.js +++ b/static/app.js @@ -201,6 +201,19 @@ window.XiaobaiAPI.configure({ onUnauthorized: () => showAuthGate("登录状态已失效,请重新登录。"), }); +const applicationShell = window.XiaobaiShell.create({ + state, + pages: window.XiaobaiPages, + motionEnabled, + animateRows, + refreshIcons, + tradeDate: () => displayCompactDate( + state.dashboard?.meta?.trade_date || document.querySelector("#tradeDate")?.value || "", + ), + onNavigate: (viewId) => openView(viewId), + onNavigationSync: () => toggleAccountDropdown(false), +}); + const elements = { tradeDate: document.querySelector("#tradeDate"), loading: document.querySelector("#loadingOverlay"), @@ -225,11 +238,7 @@ const elements = { }; function openModalDialog(dialog) { - if (!(dialog instanceof HTMLDialogElement)) return; - document.querySelectorAll("dialog[open]").forEach((openDialog) => { - if (openDialog !== dialog) openDialog.close(); - }); - if (!dialog.open) dialog.showModal(); + applicationShell.openModalDialog(dialog); } const metricAnimationFrames = new WeakMap(); @@ -250,20 +259,6 @@ let heartDustAnimationFrame = 0; let heartDustParticles = []; let heartIncenseAnimation = null; const heartCoinRotations = [0, 0, 0]; -const MARKET_VIEWS = new Set([ - "limitPool", - "brokenView", - "downView", - "yesterdayView", - "performanceView", - "sentimentCycleView", - "auctionView", - "ladderView", - "rotationView", - "themeLibraryView", - "popularityView", - "dragonView", -]); let rowAnimationObserver = null; let stockPreviewOpenTimer = null; let stockPreviewCloseTimer = null; @@ -446,7 +441,7 @@ function toggleTheme() { async function initialize() { syncThemeControl(); refreshIcons(); - initializeApplicationShell(); + applicationShell.initialize(); elements.tradeDate.value = todayString(); const initialUrl = new URL(window.location.href); if (initialUrl.searchParams.has("date")) { @@ -488,9 +483,12 @@ async function startAuthenticatedApp() { if (["trend", "fortune", "heart"].includes(requestedHeavenPanel)) { state.heavenPanel = requestedHeavenPanel; } - const legacyViewAliases = { sectorView: "rotationView", breadthView: "limitPool" }; - const requestedView = legacyViewAliases[searchParams.get("view")] || searchParams.get("view"); - if (requestedView && document.getElementById(requestedView)?.classList.contains("workspace-view")) { + const requestedView = window.XiaobaiPages.resolve(searchParams.get("view")); + if ( + requestedView + && window.XiaobaiPages.has(requestedView) + && document.getElementById(requestedView)?.classList.contains("workspace-view") + ) { openView(requestedView, false); if (requestedView !== searchParams.get("view")) { const url = new URL(window.location.href); @@ -626,17 +624,6 @@ function bindEvents() { }); }); - document.querySelectorAll(".module-tab").forEach((button) => { - button.addEventListener("click", () => openView(button.dataset.view)); - }); - document.querySelector("#mobileMarketViewSelect").addEventListener("change", (event) => { - openView(event.target.value); - }); - document.querySelector("#sidebarCollapseButton").addEventListener("click", toggleSidebar); - document.querySelector("#headerMenuButton").addEventListener("click", (event) => { - event.stopPropagation(); - toggleHeaderCommandMenu(); - }); document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch); document.querySelector("#themeToggle").addEventListener("click", toggleTheme); document.querySelector("#alertButton").addEventListener("click", openAlerts); @@ -669,33 +656,22 @@ function bindEvents() { const result = event.target.closest("[data-search-result-index]"); if (result) openGlobalSearchResult(number(result.dataset.searchResultIndex)); }); - document.querySelector("#headerCommandGroup").addEventListener("click", (event) => { - if (event.target.closest("button") && !event.target.closest(".account-menu-shell")) toggleHeaderCommandMenu(false); - }); document.addEventListener("click", (event) => { - if (!event.target.closest(".header-actions")) toggleHeaderCommandMenu(false); if (!event.target.closest(".account-menu-shell")) toggleAccountDropdown(false); }); window.addEventListener("keydown", handleGlobalSearchShortcut); document.addEventListener("keydown", (event) => { if (event.key === "Escape") { - toggleHeaderCommandMenu(false); toggleAccountDropdown(false, true); toggleMentorDirectory(false); } handleAccountMenuKeydown(event); }); window.addEventListener("resize", () => { - if (window.innerWidth > 720) toggleHeaderCommandMenu(false); if (window.innerWidth > 720) toggleMentorDirectory(false); if (!elements.stockPreview.hidden) closeStockPreview(); - updateSidebarControl(); - syncNavigationState(state.activeView); if (state.activeView === "dragonView") layoutDragonCards(); }); - document.querySelectorAll("[data-open-view]").forEach((button) => { - button.addEventListener("click", () => openView(button.dataset.openView)); - }); document.querySelectorAll("[data-open-account]").forEach((button) => { button.addEventListener("click", () => openSettings("membership")); }); @@ -754,17 +730,6 @@ function bindEvents() { renderRotationHistory(); }); }); - document.querySelector("#overviewToggle").addEventListener("click", () => { - const overview = document.querySelector(".overview-strip"); - const expanded = overview.dataset.overviewExpanded !== "true"; - overview.dataset.overviewExpanded = String(expanded); - const toggle = document.querySelector("#overviewToggle"); - toggle.setAttribute("aria-expanded", String(expanded)); - toggle.title = expanded ? "收起市场详情" : "展开市场详情"; - toggle.querySelector("span").textContent = expanded ? "收起详情" : "展开详情"; - toggle.querySelector("i").setAttribute("data-lucide", expanded ? "chevron-up" : "chevron-down"); - refreshIcons(); - }); document.querySelector("#sentimentExportButton").addEventListener("click", exportSentimentHistory); document.querySelectorAll("[data-sentiment-range]").forEach((button) => { button.addEventListener("click", () => { @@ -8456,35 +8421,15 @@ function applyMembershipAccess() { } function openView(viewId, updateHash = true) { + if (!applicationShell.page(viewId)) return; closeStockPreview(); - state.activeView = viewId; if (viewId !== "auctionView") clearAuctionTimer(); if (viewId !== "heavenView") { stopQiFieldCanvas(); stopHeartDust(); cancelHeavenPerformance(); } - document.querySelectorAll(".workspace-view").forEach((view) => { - const active = view.id === viewId; - view.classList.toggle("active-view", active); - view.classList.remove("view-entering"); - if (active && motionEnabled()) { - void view.offsetWidth; - view.classList.add("view-entering"); - view.addEventListener("animationend", () => view.classList.remove("view-entering"), { once: true }); - const body = view.querySelector("tbody"); - if (body) animateRows(body); - } - }); - syncNavigationState(viewId); - setPageStatus(viewId); - if (updateHash) { - const url = new URL(window.location.href); - url.searchParams.set("view", viewId); - url.hash = ""; - history.replaceState(null, "", url); - } - window.scrollTo({ top: 0, behavior: "auto" }); + if (!applicationShell.mount(viewId, { updateUrl: updateHash })) return; applyMembershipAccess(); if (viewId === "dragonView") loadDragonTiger(); if (viewId === "reviewWorkspaceView") loadReviewWorkspace(); @@ -9153,32 +9098,7 @@ function setLoading(loading, text = "正在加载复盘数据", context = "defau } function setStatus(text) { - setText("statusText", text); -} - -function setPageStatus(viewId) { - const labels = { - sentimentCycleView: "情绪周期", - limitPool: "涨停池", - brokenView: "炸板池", - downView: "跌停板", - yesterdayView: "昨日涨停", - performanceView: "涨停表现", - ladderView: "市场天梯", - rotationView: "板块轮动", - auctionView: "集合竞价", - themeLibraryView: "题材库", - popularityView: "人气热榜", - dragonView: "龙虎榜", - screenerView: "智能选股", - screenerTrackingView: "策略持续跟踪", - mentorView: "问师", - heavenView: "问天", - reviewWorkspaceView: "我的复盘", - }; - const label = labels[viewId] || "小白复盘"; - const tradeDate = displayCompactDate(state.dashboard?.meta?.trade_date || elements.tradeDate?.value || ""); - setStatus(tradeDate && tradeDate !== "--" ? `${label} · 数据日期 ${tradeDate}` : `${label} · 等待数据`); + applicationShell.setStatus(text); } let toastTimer; @@ -9203,47 +9123,8 @@ function refreshIcons() { window.lucide.createIcons({ attrs: { "aria-hidden": "true" } }); } -function initializeApplicationShell() { - let collapsed = false; - try { - collapsed = window.localStorage.getItem("xiaobai-sidebar-collapsed") === "1"; - } catch (_error) { - collapsed = false; - } - document.body.classList.toggle("sidebar-collapsed", collapsed); - updateSidebarControl(); - syncNavigationState(state.activeView); -} - -function toggleSidebar() { - const collapsed = document.body.classList.toggle("sidebar-collapsed"); - try { - window.localStorage.setItem("xiaobai-sidebar-collapsed", collapsed ? "1" : "0"); - } catch (_error) { - // The visual state still works when storage is unavailable. - } - updateSidebarControl(); -} - -function updateSidebarControl() { - const button = document.querySelector("#sidebarCollapseButton"); - if (!button) return; - const automaticallyCollapsed = window.innerWidth <= 1023 && window.innerWidth > 720; - const collapsed = document.body.classList.contains("sidebar-collapsed") || automaticallyCollapsed; - button.setAttribute("aria-expanded", String(!collapsed)); - button.setAttribute("aria-label", collapsed ? "展开侧栏" : "收起侧栏"); - button.title = collapsed ? "展开侧栏" : "收起侧栏"; - const label = button.querySelector("span"); - if (label) label.textContent = collapsed ? "展开侧栏" : "收起侧栏"; -} - function toggleHeaderCommandMenu(force) { - const menu = document.querySelector("#headerCommandGroup"); - const button = document.querySelector("#headerMenuButton"); - if (!menu || !button) return; - const open = typeof force === "boolean" ? force : !menu.classList.contains("is-open"); - menu.classList.toggle("is-open", open); - button.setAttribute("aria-expanded", String(open)); + applicationShell.toggleHeaderCommandMenu(force); } function toggleAccountDropdown(force, returnFocus = false) { @@ -9287,28 +9168,6 @@ function handleAccountMenuKeydown(event) { } } -function syncNavigationState(viewId) { - const marketView = MARKET_VIEWS.has(viewId); - document.body.dataset.activeView = viewId; - document.querySelectorAll(".module-tab").forEach((button) => { - button.classList.toggle( - "active", - button.dataset.view === viewId - || (viewId === "screenerTrackingView" && button.dataset.view === "screenerView"), - ); - button.classList.toggle( - "mobile-active", - window.innerWidth <= 720 && marketView && button.dataset.view === "limitPool" && viewId !== "limitPool", - ); - }); - const selector = document.querySelector("#mobileMarketSelector"); - const select = document.querySelector("#mobileMarketViewSelect"); - if (selector) selector.hidden = !marketView; - if (select && marketView) select.value = viewId; - toggleHeaderCommandMenu(false); - toggleAccountDropdown(false); -} - function updateSentimentGauge(rawScore) { const gauge = document.querySelector("#sentimentGauge"); if (!gauge) return; diff --git a/static/index.html b/static/index.html index 516d6b8..ecfdf8c 100644 --- a/static/index.html +++ b/static/index.html @@ -1865,9 +1865,11 @@ + + - + diff --git a/static/pages.config.js b/static/pages.config.js new file mode 100644 index 0000000..a05fd9d --- /dev/null +++ b/static/pages.config.js @@ -0,0 +1,69 @@ +(function exposePageRegistry(global) { + "use strict"; + + const pages = [ + ["sentimentCycleView", "情绪周期", "sentiment", "market", "authenticated", true], + ["limitPool", "涨停池", "pools", "market", "authenticated", false], + ["brokenView", "炸板池", "pools", "market", "authenticated", false], + ["downView", "跌停板", "pools", "market", "authenticated", false], + ["yesterdayView", "昨日涨停", "pools", "market", "authenticated", false], + ["performanceView", "涨停表现", "pools", "market", "authenticated", false], + ["ladderView", "市场天梯", "ladder", "market", "authenticated", false], + ["rotationView", "板块轮动", "rotation", "market", "authenticated", false], + ["auctionView", "集合竞价", "auction", "market", "authenticated", false], + ["themeLibraryView", "题材库", "themes", "market", "authenticated", false], + ["popularityView", "人气热榜", "popularity", "market", "authenticated", false], + ["dragonView", "龙虎榜", "dragon_tiger", "market", "authenticated", false], + ["screenerView", "智能选股", "screener", "intelligence", "member", false], + ["mentorView", "问师", "mentor", "intelligence", "member", false], + ["heavenView", "问天", "heaven", "intelligence", "member", false], + ["reviewWorkspaceView", "我的复盘", "review", "personal", "authenticated", false], + ].map(([id, title, feature, group, access, isDefault]) => Object.freeze({ + id, + title, + feature, + group, + access, + default: isDefault, + desktop_scroll: "page", + mobile_layout: "dedicated", + })); + + const internalPages = [ + Object.freeze({ + id: "screenerTrackingView", + title: "策略持续跟踪", + feature: "screener", + group: "intelligence", + access: "member", + internal: true, + navigation_alias: "screenerView", + }), + ]; + + const all = [...pages, ...internalPages]; + const byId = new Map(all.map((page) => [page.id, page])); + const defaultPage = pages.find((page) => page.default); + const aliases = Object.freeze({ sectorView: "rotationView", breadthView: "limitPool" }); + + global.XiaobaiPages = Object.freeze({ + schemaVersion: 1, + pages: Object.freeze(pages), + internalPages: Object.freeze(internalPages), + all: Object.freeze(all), + defaultPage, + aliases, + resolve(id) { + return aliases[id] || id; + }, + get(id) { + return byId.get(id) || null; + }, + has(id) { + return byId.has(id); + }, + inGroup(id, group) { + return byId.get(id)?.group === group; + }, + }); +})(window); diff --git a/static/shared/shell.js b/static/shared/shell.js new file mode 100644 index 0000000..ea6622c --- /dev/null +++ b/static/shared/shell.js @@ -0,0 +1,194 @@ +(function exposeApplicationShell(global) { + "use strict"; + + const SIDEBAR_STORAGE_KEY = "xiaobai-sidebar-collapsed"; + + function create(options) { + const state = options.state; + const registry = options.pages; + let initialized = false; + + function toggleHeaderCommandMenu(force) { + const menu = document.querySelector("#headerCommandGroup"); + const button = document.querySelector("#headerMenuButton"); + if (!menu || !button) return; + const open = typeof force === "boolean" ? force : !menu.classList.contains("is-open"); + menu.classList.toggle("is-open", open); + button.setAttribute("aria-expanded", String(open)); + } + + function updateSidebarControl() { + const button = document.querySelector("#sidebarCollapseButton"); + if (!button) return; + const automaticallyCollapsed = global.innerWidth <= 1023 && global.innerWidth > 720; + const collapsed = document.body.classList.contains("sidebar-collapsed") || automaticallyCollapsed; + button.setAttribute("aria-expanded", String(!collapsed)); + button.setAttribute("aria-label", collapsed ? "展开侧栏" : "收起侧栏"); + button.title = collapsed ? "展开侧栏" : "收起侧栏"; + const label = button.querySelector("span"); + if (label) label.textContent = collapsed ? "展开侧栏" : "收起侧栏"; + } + + function toggleSidebar() { + const collapsed = document.body.classList.toggle("sidebar-collapsed"); + try { + global.localStorage.setItem(SIDEBAR_STORAGE_KEY, collapsed ? "1" : "0"); + } catch (_error) { + // The shell remains usable when storage is unavailable. + } + updateSidebarControl(); + } + + function syncNavigation(viewId) { + const page = registry.get(viewId); + const navigationId = page?.navigation_alias || viewId; + const marketView = page?.group === "market"; + document.body.dataset.activeView = viewId; + document.querySelectorAll(".module-tab").forEach((button) => { + button.classList.toggle("active", button.dataset.view === navigationId); + button.classList.toggle( + "mobile-active", + global.innerWidth <= 720 + && marketView + && button.dataset.view === "limitPool" + && viewId !== "limitPool", + ); + }); + const selector = document.querySelector("#mobileMarketSelector"); + const select = document.querySelector("#mobileMarketViewSelect"); + if (selector) selector.hidden = !marketView; + if (select && marketView) select.value = viewId; + toggleHeaderCommandMenu(false); + options.onNavigationSync?.(viewId); + } + + function setStatus(text) { + const status = document.querySelector("#statusText"); + if (status) status.textContent = text; + } + + function setPageStatus(viewId, tradeDate = "") { + const page = registry.get(viewId); + const label = page?.title || "小白复盘"; + setStatus(tradeDate && tradeDate !== "--" ? `${label} · 数据日期 ${tradeDate}` : `${label} · 等待数据`); + } + + function openModalDialog(dialog) { + if (!(dialog instanceof HTMLDialogElement)) return; + document.querySelectorAll("dialog[open]").forEach((openDialog) => { + if (openDialog !== dialog) openDialog.close(); + }); + if (!dialog.open) dialog.showModal(); + } + + function mount(viewId, mountOptions = {}) { + const page = registry.get(viewId); + const view = document.getElementById(viewId); + if (!page || !view?.classList.contains("workspace-view")) return false; + const previousView = state.activeView; + options.onBeforeMount?.(viewId, previousView); + state.activeView = viewId; + document.querySelectorAll(".workspace-view").forEach((candidate) => { + const active = candidate.id === viewId; + candidate.classList.toggle("active-view", active); + candidate.classList.remove("view-entering"); + if (active && options.motionEnabled?.()) { + void candidate.offsetWidth; + candidate.classList.add("view-entering"); + candidate.addEventListener( + "animationend", + () => candidate.classList.remove("view-entering"), + { once: true }, + ); + const body = candidate.querySelector("tbody"); + if (body) options.animateRows?.(body); + } + }); + syncNavigation(viewId); + setPageStatus(viewId, options.tradeDate?.() || ""); + if (mountOptions.updateUrl !== false) { + const url = new URL(global.location.href); + url.searchParams.set("view", viewId); + url.hash = ""; + global.history.replaceState(null, "", url); + } + global.scrollTo({ top: 0, behavior: "auto" }); + options.onAfterMount?.(viewId, previousView); + return true; + } + + function initialize() { + if (initialized) return; + initialized = true; + let collapsed = false; + try { + collapsed = global.localStorage.getItem(SIDEBAR_STORAGE_KEY) === "1"; + } catch (_error) { + collapsed = false; + } + document.body.classList.toggle("sidebar-collapsed", collapsed); + updateSidebarControl(); + syncNavigation(state.activeView); + document.querySelectorAll(".module-tab").forEach((button) => { + button.addEventListener("click", () => options.onNavigate?.(button.dataset.view)); + }); + document.querySelectorAll("[data-open-view]").forEach((button) => { + button.addEventListener("click", () => options.onNavigate?.(button.dataset.openView)); + }); + document.querySelector("#mobileMarketViewSelect")?.addEventListener("change", (event) => { + options.onNavigate?.(event.target.value); + }); + document.querySelector("#sidebarCollapseButton")?.addEventListener("click", toggleSidebar); + document.querySelector("#headerMenuButton")?.addEventListener("click", (event) => { + event.stopPropagation(); + toggleHeaderCommandMenu(); + }); + document.querySelector("#headerCommandGroup")?.addEventListener("click", (event) => { + if (event.target.closest("button") && !event.target.closest(".account-menu-shell")) { + toggleHeaderCommandMenu(false); + } + }); + document.querySelector("#overviewToggle")?.addEventListener("click", (event) => { + const overview = document.querySelector(".overview-strip"); + if (!overview) return; + const expanded = overview.dataset.overviewExpanded !== "true"; + overview.dataset.overviewExpanded = String(expanded); + event.currentTarget.setAttribute("aria-expanded", String(expanded)); + event.currentTarget.title = expanded ? "收起市场详情" : "展开市场详情"; + const label = event.currentTarget.querySelector("span"); + if (label) label.textContent = expanded ? "收起详情" : "展开详情"; + event.currentTarget.querySelector("i")?.setAttribute( + "data-lucide", + expanded ? "chevron-up" : "chevron-down", + ); + options.refreshIcons?.(); + }); + document.addEventListener("click", (event) => { + if (!event.target.closest(".header-actions")) toggleHeaderCommandMenu(false); + }); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") toggleHeaderCommandMenu(false); + }); + global.addEventListener("resize", () => { + if (global.innerWidth > 720) toggleHeaderCommandMenu(false); + updateSidebarControl(); + syncNavigation(state.activeView); + }); + } + + return Object.freeze({ + initialize, + mount, + openModalDialog, + page: (viewId) => registry.get(viewId), + setPageStatus, + setStatus, + syncNavigation, + toggleHeaderCommandMenu, + toggleSidebar, + updateSidebarControl, + }); + } + + global.XiaobaiShell = Object.freeze({ create }); +})(window); diff --git a/tests/test_frontend_boundaries.py b/tests/test_frontend_boundaries.py index d185acb..4396998 100644 --- a/tests/test_frontend_boundaries.py +++ b/tests/test_frontend_boundaries.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import re import unittest from pathlib import Path @@ -21,17 +22,56 @@ class FrontendBoundaryTests(unittest.TestCase): def test_shared_dependencies_load_before_application(self) -> None: html = (STATIC / "index.html").read_text(encoding="utf-8") + pages_position = html.index('/pages.config.js') state_position = html.index('/shared/state.js') api_position = html.index('/shared/api.js') + shell_position = html.index('/shared/shell.js') app_position = html.index('/app.js') + self.assertLess(pages_position, state_position) self.assertLess(state_position, api_position) - self.assertLess(api_position, app_position) + self.assertLess(api_position, shell_position) + self.assertLess(shell_position, app_position) def test_application_state_is_created_through_shared_boundary(self) -> None: app = (STATIC / "app.js").read_text(encoding="utf-8") self.assertIn("const state = window.XiaobaiState.create({", app) self.assertNotIn("const state = {", app) + def test_runtime_page_registry_matches_governance_registry(self) -> None: + expected = json.loads( + (ROOT / "config" / "pages.config.json").read_text(encoding="utf-8") + )["pages"] + runtime = (STATIC / "pages.config.js").read_text(encoding="utf-8") + rows = re.findall( + r'^\s*\["([^"]+)", "([^"]+)", "([^"]+)", "([^"]+)", "([^"]+)", (true|false)\],$', + runtime, + re.MULTILINE, + ) + actual = [ + { + "id": row[0], + "title": row[1], + "feature": row[2], + "group": row[3], + "access": row[4], + "default": row[5] == "true", + "desktop_scroll": "page", + "mobile_layout": "dedicated", + } + for row in rows + ] + self.assertEqual(actual, expected) + + def test_shell_owns_navigation_and_page_mounting(self) -> None: + app = (STATIC / "app.js").read_text(encoding="utf-8") + shell = (STATIC / "shared" / "shell.js").read_text(encoding="utf-8") + self.assertNotIn("function syncNavigationState", app) + self.assertNotIn("function initializeApplicationShell", app) + self.assertIn("function syncNavigation(viewId)", shell) + self.assertIn("function mount(viewId, mountOptions = {})", shell) + self.assertIn("function openModalDialog(dialog)", shell) + self.assertNotIn('document.querySelectorAll(".module-tab").forEach', app) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_frontend_contract.py b/tests/test_frontend_contract.py index 35abbf1..605507f 100644 --- a/tests/test_frontend_contract.py +++ b/tests/test_frontend_contract.py @@ -23,6 +23,7 @@ class FrontendContractTests(unittest.TestCase): def setUpClass(cls) -> None: cls.html = (STATIC_DIR / "index.html").read_text(encoding="utf-8") cls.script = (STATIC_DIR / "app.js").read_text(encoding="utf-8") + cls.shell = (STATIC_DIR / "shared" / "shell.js").read_text(encoding="utf-8") cls.ui_core = (STATIC_DIR / "ui-core.js").read_text(encoding="utf-8") cls.design_system = (STATIC_DIR / "design-system.css").read_text(encoding="utf-8") cls.theme = (STATIC_DIR / "theme.css").read_text(encoding="utf-8") @@ -297,7 +298,7 @@ class FrontendContractTests(unittest.TestCase): self.assertIn('id="openTradeLogDialog"', self.html) self.assertIn('id="tradeLogDialog" class="settings-dialog trade-log-dialog"', self.html) self.assertIn('openModalDialog(elements.tradeLogDialog)', self.script) - self.assertIn('document.querySelectorAll("dialog[open]")', self.script) + self.assertIn('document.querySelectorAll("dialog[open]")', self.shell) self.assertIn('renderTradeLog();\n closeTradeLogDialog();', self.script) def test_review_workspace_exposes_complete_watchlist_and_three_part_journal(self):