Files
xiaobaifupan/tests/test_frontend_contract.py
T

192 lines
10 KiB
Python

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.ui_core = (STATIC_DIR / "ui-core.js").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))
navigation = set(re.findall(r'data-view="([A-Za-z][A-Za-z0-9_-]*)"', self.html))
self.assertEqual(views, navigation)
self.assertEqual(len(views), 16)
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_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", "onePrice", "watchlist", "all"):
self.assertIn(f'data-auction-dataset="{dataset}"', self.html)
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.assertIn('id="auctionNewsTitle"', self.html)
self.assertIn('id="auctionWorkspaceTitle"', self.html)
self.assertIn('id="auctionExpectationFilterbar"', self.html)
self.assertIn('class="auction-news-entry"', self.html)
self.assertIn('class="disabled-status">暂不可用', self.html)
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('<script src="/ui-core.js"'),
self.html.index('<script src="/app.js"'),
)
for function_name in (
"number", "clamp", "escapeHtml", "formatNumber", "formatTimestamp",
"displayCompactDate", "todayString", "localDateString", "parseLocalDate",
):
self.assertIn(f"function {function_name}(", self.ui_core)
def test_business_views_do_not_expose_engineering_source_labels(self):
prohibited = (
"Tushare 实时行情",
"Tushare 日K",
"SQLite 缓存",
"演示日K",
"rt_k 实时截面",
)
for label in prohibited:
self.assertNotIn(label, self.script)
def test_stock_hover_preview_always_uses_latest_market_context(self):
start = self.script.index("async function showStockPreview")
end = self.script.index("function renderStockPreviewLoading", start)
preview_loader = self.script[start:end]
self.assertIn('const cacheKey = `${code}:latest`;', preview_loader)
self.assertIn('/preview`', preview_loader)
self.assertNotIn("trade_date", preview_loader)
self.assertNotIn("elements.tradeDate.value", preview_loader)
def test_daily_rising_candles_are_fully_hollow_without_crossing_wicks(self):
start = self.script.index("function drawCandlestick")
end = self.script.index("function drawPriceChart", start)
candle = self.script[start:end]
self.assertIn("context.lineTo(x, bodyTop);", candle)
self.assertIn("context.moveTo(x, bodyBottom);", candle)
self.assertIn("context.lineTo(x, lowY);", candle)
self.assertIn("context.fillStyle = CHART_BACKGROUND;", candle)
self.assertIn("context.strokeRect(bodyLeft, bodyTop, candleWidth, bodyHeight);", candle)
self.assertNotIn("context.lineTo(x, lowY);\n context.stroke();\n const openY", candle)
def test_stock_hover_intraday_draws_average_without_source_label(self):
start = self.script.index("function drawIntradayCanvas")
end = self.script.index("function drawDailyPreviewChart", start)
chart = self.script[start:end]
self.assertIn("point.average", chart)
self.assertIn('context.strokeStyle = "#b7791f";', chart)
self.assertIn('intraday_trade_date || payload.meta?.trade_date', self.script)
self.assertIn('(payload.intraday || []).length ? "最新分时 · 1分钟"', self.script)
def test_hover_prefers_daily_and_intraday_uses_centered_zero_axis(self):
self.assertIn('stockPreviewChart: "daily"', self.script)
self.assertIn('state.stockPreviewChart = "daily";', self.script)
self.assertIn('selectStockPreviewChart("daily");', self.script)
start = self.script.index("function drawIntradayCanvas")
end = self.script.index("function drawIntradayPreviewChart", start)
intraday = self.script[start:end]
self.assertIn("Math.abs(maximum - previousClose)", intraday)
self.assertIn("Math.abs(previousClose - minimum)", intraday)
self.assertIn('context.fillText("0.00%"', intraday)
self.assertIn('label: "09:30"', intraday)
self.assertIn('label: "11:30 / 13:00"', intraday)
self.assertIn('label: "15:00"', intraday)
self.assertIn("intradayMinuteOffset(points[index]?.time) / 240", intraday)
def test_detail_dialogs_offer_lazy_daily_and_intraday_modes(self):
self.assertIn('data-stock-detail-chart="daily"', self.html)
self.assertIn('data-stock-detail-chart="intraday"', self.html)
self.assertIn('data-entity-detail-chart="daily"', self.html)
self.assertIn('data-entity-detail-chart="intraday"', self.html)
self.assertIn('/api/chart/intraday?', self.script)
self.assertIn('drawIntradayCanvas(elements.priceChart', self.script)
self.assertIn('drawIntradayCanvas(elements.entityDetailChart', self.script)
self.assertIn('state.stockDetailChartMode === "intraday"', self.script)
self.assertIn('state.entityDetailChartMode === "intraday"', self.script)
def test_membership_copy_includes_review_assistant_access(self):
self.assertIn("复盘助手仅对会员开放", self.html)
self.assertIn("智能选股、问师、问天、复盘助手等智能功能", self.html)
self.assertIn("自选股、复盘记录与交易日志", self.html)
self.assertIn("每日智能分析额度", self.html)
def test_review_assistant_uses_the_same_member_gate_pattern(self):
self.assertIn('id="assistantMemberGate" class="member-gate assistant-member-gate"', self.html)
self.assertIn('id="assistantMemberContent" class="assistant-member-content"', self.html)
self.assertIn('elements.assistantDialog.classList.toggle("member-locked", !unlocked);', self.script)
self.assertIn('button.disabled = !unlocked || state.assistantLoading;', self.script)
def test_trade_log_editor_is_dialog_based(self):
self.assertIn('id="openTradeLogDialog"', self.html)
self.assertIn('id="tradeLogDialog" class="settings-dialog trade-log-dialog"', self.html)
self.assertIn('elements.tradeLogDialog.showModal()', self.script)
self.assertIn('renderTradeLog();\n closeTradeLogDialog();', self.script)
def test_heaven_interpretations_use_one_dialog_and_history_tabs(self):
self.assertIn('id="heavenReadingDialog"', self.html)
self.assertIn('data-heaven-reading-tab="current"', self.html)
self.assertIn('data-heaven-reading-tab="history"', self.html)
for button_id in ("historyTrendButton", "historyFortuneButton", "historyHeartButton"):
self.assertIn(f'id="{button_id}"', self.html)
self.assertIn('state.heavenInterpretations.fortune = payload.daily_fortune_reading || "";', self.script)
self.assertIn('if (existing) {\n openHeavenReading(mode, { loading: false });', self.script)
if __name__ == "__main__":
unittest.main()