from __future__ import annotations import re import unittest from html.parser import HTMLParser from pathlib import Path STATIC_DIR = Path(__file__).resolve().parents[1] / "static" class IdCollector(HTMLParser): def __init__(self) -> None: super().__init__() self.ids: list[str] = [] def handle_starttag(self, tag, attrs): self.ids.extend(value for key, value in attrs if key == "id" and value) class FrontendContractTests(unittest.TestCase): @classmethod 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") cls.tokens = (STATIC_DIR / "shared" / "tokens.css").read_text(encoding="utf-8") collector = IdCollector() collector.feed(cls.html) cls.ids = collector.ids def test_html_ids_are_unique(self): duplicates = sorted({item for item in self.ids if self.ids.count(item) > 1}) self.assertEqual(duplicates, []) def test_literal_id_selectors_exist_in_html(self): selectors = set(re.findall(r'querySelector\("#([A-Za-z][A-Za-z0-9_-]*)"\)', self.script)) selectors.update(re.findall(r'getElementById\("([A-Za-z][A-Za-z0-9_-]*)"\)', self.script)) selectors.update(re.findall(r'setText\("([A-Za-z][A-Za-z0-9_-]*)"', self.script)) missing = sorted(selectors - set(self.ids)) self.assertEqual(missing, []) def test_all_primary_views_have_navigation_entries(self): views = set(re.findall(r'id="([A-Za-z][A-Za-z0-9_-]*View|limitPool)" class="workspace-view', self.html)) internal_views = set(re.findall( r'
]*\bdata-internal-view\b', self.html, )) navigation = set(re.findall(r'data-view="([A-Za-z][A-Za-z0-9_-]*)"', self.html)) self.assertEqual(views - internal_views, navigation) self.assertEqual(len(views - internal_views), 16) self.assertEqual(internal_views, {"screenerTrackingView"}) def test_market_discovery_views_are_wired_end_to_end(self): for view_id in ("auctionView", "themeLibraryView", "popularityView"): self.assertIn(f'id="{view_id}"', self.html) self.assertIn(f'data-view="{view_id}"', self.html) for endpoint in ("/api/auction?", "/api/themes?", "/api/themes/detail?", "/api/popularity?"): self.assertIn(endpoint, self.script) for field in ( "auction_change", "auction_amount_million", "auction_turnover_rate", "auction_volume_ratio", ): self.assertIn(field, (STATIC_DIR.parent / "screener.py").read_text(encoding="utf-8")) def test_wencai_workspace_is_not_exposed_and_mentor_hides_internal_quality_score(self): self.assertNotIn('id="wencaiView"', self.html) self.assertNotIn('data-view="wencaiView"', self.html) for endpoint in ("/api/wencai", "/api/wencai/query", "/api/wencai/saved"): self.assertNotIn(endpoint, self.script) self.assertNotIn("${score}/${total}", self.script) def test_auction_navigation_and_frontend_pools_follow_product_order(self): rotation = self.html.index('data-view="rotationView"') auction = self.html.index('data-view="auctionView"') themes = self.html.index('data-view="themeLibraryView"') self.assertLess(rotation, auction) self.assertLess(auction, themes) for dataset in ("focus", "watchlist", "all", "onePrice"): self.assertIn(f'data-auction-dataset="{dataset}"', self.html) dataset_positions = [self.html.index(f'data-auction-dataset="{dataset}"') for dataset in ("focus", "watchlist", "all", "onePrice")] self.assertEqual(dataset_positions, sorted(dataset_positions)) for filter_name in ("all", "above", "matched", "below"): self.assertIn(f'data-auction-filter="{filter_name}"', self.html) self.assertNotIn('data-auction-filter="strong"', self.html) self.assertNotIn('data-auction-filter="limit"', self.html) self.assertIn('id="auctionThemeCarry"', self.html) self.assertIn('id="auctionAmountTrend"', self.html) self.assertNotIn('id="auctionNewsTitle"', self.html) self.assertIn('id="auctionWorkspaceTitle"', self.html) self.assertIn('id="auctionExpectationFilterbar"', self.html) self.assertIn('id="auctionExpectationControls"', self.html) self.assertNotIn('id="auctionAboveCount"', self.html) self.assertNotIn('id="auctionMatchedCount"', self.html) self.assertNotIn('id="auctionBelowCount"', self.html) self.assertNotIn('class="auction-news-entry"', self.html) def test_visual_renovation_keeps_required_product_controls(self): for order in ("oldest", "latest"): self.assertIn(f'data-rotation-order="{order}"', self.html) self.assertIn('id="dragonProfilesButton"', self.html) self.assertIn('id="sentimentHistoryBody"', self.html) self.assertIn('id="sentimentPreviousPositive"', self.html) self.assertIn('id="accountDropdown"', self.html) self.assertIn('id="settingsButton"', self.html) def test_screener_uses_progressive_strategy_editor(self): for step in ("regime", "strategy", "run", "result"): self.assertIn(f'data-screener-step="{step}"', self.html) def test_screener_exposes_curated_and_quant_workspaces(self): for mode in ("smart", "curated", "quant"): self.assertIn(f'data-screener-mode="{mode}"', self.html) self.assertIn(f'data-screener-panel="{mode}"', self.html) for element_id in ( "curatedStrategyList", "quantFilterRows", "quantScoreRows", "quantRunButton", "quantSaveButton", ): self.assertIn(f'id="{element_id}"', self.html) for removed_id in ( "curatedRunButton", "factorSyncButton", "screenerRunButton", "changeStrategyButton", ): self.assertNotIn(f'id="{removed_id}"', self.html) self.assertIn("盘后自动候选池", self.html) self.assertIn("自定义选股", self.html) self.assertIn('id="strategyDrawer" class="strategy-drawer"', self.html) self.assertIn('id="openStrategyDrawerButton"', self.html) self.assertIn('id="closeStrategyDrawerButton"', self.html) self.assertIn('id="activeStrategyDescription"', self.html) self.assertIn('openStrategyDrawer("editor")', self.script) for element_id in ("curatedSuitableEnvironment", "curatedFailureRisk"): self.assertIn(f'id="{element_id}"', self.html) self.assertIn("meta.suitable_environment", self.script) self.assertIn("meta.failure_risk", self.script) self.assertIn('mode === "curated" ? "暂无符合条件个股"', self.script) def test_curated_library_explains_empty_signals_and_supports_school_views(self): for element_id in ("curatedSchoolFilters", "curatedStrategyList"): self.assertIn(f'id="{element_id}"', self.html) for view in ("list", "grid"): self.assertIn(f'data-curated-view="{view}"', self.html) for school in ("基本面", "趋势", "短线", "动量"): self.assertIn(school, self.script) self.assertIn("curatedStrategyRunState", self.script) self.assertIn("必需数据已完整,本日没有股票同时满足", self.script) def test_dialogs_and_dark_table_hover_have_shared_safety_constraints(self): redesign = (STATIC_DIR / "redesign-v2.css").read_text(encoding="utf-8") self.assertIn(".settings-dialog:not(.heaven-reading-dialog)[open] { margin: auto; }", redesign) self.assertIn("max-height: min(760px, calc(100dvh - 28px));", redesign) self.assertIn('#reviewWorkspaceView .data-table tbody tr:hover td', self.theme) self.assertIn('#reviewWorkspaceView .data-table tbody td', self.theme) self.assertIn('#screenerView .screener-result-frame tbody tr:hover td:last-child', self.theme) def test_global_toast_has_one_owner_and_cannot_stretch_between_insets(self): styles = (STATIC_DIR / "styles.css").read_text(encoding="utf-8") wentian = (STATIC_DIR / "wentian-v2.css").read_text(encoding="utf-8") self.assertIn("#toast.toast {", styles) self.assertIn("top: auto;", styles) self.assertIn("left: auto;", styles) self.assertIn("height: auto;", styles) self.assertIn("#toast.toast[hidden] { display: none; }", styles) self.assertNotIn(".toast{position:fixed", self.design_system) self.assertNotRegex(wentian, r"(?m)^\.toast\s*\{") def test_public_knowledge_editors_are_hidden_for_non_admins(self): self.assertIn('document.querySelector("#reasonForm").hidden = !isAdmin;', self.script) self.assertIn('document.querySelector("#sectorPhaseManager").hidden = !isAdmin;', self.script) self.assertIn('const canManage = state.user?.role === "admin";', self.script) def test_shared_ui_core_loads_before_application(self): self.assertLess( self.html.index('