backfillData() only refreshed system settings after a successful backfill, leaving state.sentimentHistory/sentimentHistoryKey populated with the stale pre-backfill cache, so an already-open sentiment cycle page kept showing the old one-day history. After a successful backfill, clear both cache fields and, when the sentiment cycle view is active, force loadSentimentHistory(true) so the history renders immediately. Add a frontend contract test covering the refresh path. Co-authored-by: multica-agent <github@multica.ai>
449 lines
25 KiB
Python
449 lines
25 KiB
Python
from __future__ import annotations
|
|
|
|
import re
|
|
import subprocess
|
|
import unittest
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
|
|
from tests.frontend_test_helpers import (
|
|
assembled_frontend_runtime,
|
|
assembled_frontend_document,
|
|
registered_frontend_runtime_scripts,
|
|
)
|
|
|
|
|
|
STATIC_DIR = Path(__file__).resolve().parents[1] / "frontend"
|
|
|
|
|
|
def uncommitted_runtime_files() -> set[str]:
|
|
"""Relative frontend paths with pending working-tree edits.
|
|
|
|
The id-reference contract is validated against committed sources only.
|
|
Parallel agents may temporarily reference ids that their own (uncommitted)
|
|
HTML change reintroduces; those are resolved by the owning commit.
|
|
"""
|
|
result = subprocess.run(
|
|
["git", "-C", str(STATIC_DIR.parent), "diff", "--name-only", "HEAD"],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
)
|
|
if result.returncode != 0:
|
|
return set()
|
|
return {
|
|
line.strip().removeprefix("app/frontend/")
|
|
for line in result.stdout.splitlines()
|
|
if line.strip().startswith("app/frontend/")
|
|
}
|
|
|
|
|
|
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 = assembled_frontend_document()
|
|
cls.registry = (STATIC_DIR / "pages.config.js").read_text(encoding="utf-8")
|
|
cls.script = assembled_frontend_runtime()
|
|
cls.shell = (STATIC_DIR / "shared" / "shell.js").read_text(encoding="utf-8")
|
|
cls.ui_core = (STATIC_DIR / "shared" / "ui-core.js").read_text(encoding="utf-8")
|
|
cls.tokens = (STATIC_DIR / "shared" / "tokens.css").read_text(encoding="utf-8")
|
|
cls.auth_styles = (STATIC_DIR / "shared" / "auth.css").read_text(encoding="utf-8")
|
|
cls.feedback_styles = (STATIC_DIR / "shared" / "components" / "feedback.css").read_text(encoding="utf-8")
|
|
cls.sentiment_styles = (STATIC_DIR / "pages" / "sentiment" / "foundation.css").read_text(encoding="utf-8")
|
|
cls.screener_styles = (STATIC_DIR / "pages" / "screener" / "foundation.css").read_text(encoding="utf-8")
|
|
cls.mentor_styles = (STATIC_DIR / "pages" / "mentor" / "foundation.css").read_text(encoding="utf-8")
|
|
cls.heaven_styles = (STATIC_DIR / "pages" / "heaven" / "foundation.css").read_text(encoding="utf-8")
|
|
cls.review_styles = (STATIC_DIR / "pages" / "review" / "foundation.css").read_text(encoding="utf-8")
|
|
cls.theme_library_styles = (STATIC_DIR / "pages" / "themes" / "foundation.css").read_text(encoding="utf-8")
|
|
cls.base_styles = (STATIC_DIR / "shared" / "base.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):
|
|
pending = uncommitted_runtime_files()
|
|
dangling: list[str] = []
|
|
for url in registered_frontend_runtime_scripts():
|
|
relative = url.split("?", 1)[0].lstrip("/")
|
|
if relative.startswith("vendor/"):
|
|
continue
|
|
if relative in pending:
|
|
continue
|
|
source = (STATIC_DIR / relative).read_text(encoding="utf-8")
|
|
selectors = set(re.findall(r'querySelector\("#([A-Za-z][A-Za-z0-9_-]*)"\)', source))
|
|
selectors.update(re.findall(r'getElementById\("([A-Za-z][A-Za-z0-9_-]*)"\)', source))
|
|
selectors.update(re.findall(r'setText\("([A-Za-z][A-Za-z0-9_-]*)"', source))
|
|
dangling.extend(
|
|
f"{relative}:{selector}"
|
|
for selector in sorted(selectors - set(self.ids))
|
|
)
|
|
self.assertEqual(dangling, [])
|
|
|
|
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'<section id="([A-Za-z][A-Za-z0-9_-]*View)" class="workspace-view[^"]*"[^>]*\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)
|
|
screener_sources = "\n".join(
|
|
(
|
|
STATIC_DIR.parent
|
|
/ "backend"
|
|
/ "features"
|
|
/ "screener"
|
|
/ filename
|
|
).read_text(encoding="utf-8")
|
|
for filename in ("catalog.py", "factors.py")
|
|
)
|
|
for field in (
|
|
"auction_change", "auction_amount_million",
|
|
"auction_turnover_rate", "auction_volume_ratio",
|
|
):
|
|
self.assertIn(field, screener_sources)
|
|
|
|
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_backfill_clears_sentiment_cache_and_reloads_sentiment_view(self):
|
|
start = self.script.index("async function backfillData")
|
|
end = self.script.index("async function openAdminSettings", start)
|
|
backfill = self.script[start:end]
|
|
self.assertIn("state.sentimentHistory = null;", backfill)
|
|
self.assertIn('state.sentimentHistoryKey = "";', backfill)
|
|
self.assertIn('if (state.activeView === "sentimentCycleView")', backfill)
|
|
self.assertIn("await loadSentimentHistory(true);", backfill)
|
|
|
|
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):
|
|
self.assertIn(".settings-dialog:not(.heaven-reading-dialog)[open] {", self.auth_styles)
|
|
self.assertIn("margin: auto;", self.auth_styles)
|
|
self.assertIn("max-height: min(760px, -28px + 100dvh);", self.auth_styles)
|
|
self.assertIn('#reviewWorkspaceView .data-table tbody tr:hover td', self.review_styles)
|
|
self.assertIn('#reviewWorkspaceView .data-table tbody td', self.review_styles)
|
|
self.assertIn('#screenerView .screener-result-frame tbody tr:hover td:last-child', self.screener_styles)
|
|
|
|
def test_global_toast_has_one_owner_and_cannot_stretch_between_insets(self):
|
|
self.assertIn("#toast.toast {", self.feedback_styles)
|
|
self.assertIn("inset: auto 18px 48px auto;", self.feedback_styles)
|
|
self.assertIn("width: max-content;", self.feedback_styles)
|
|
self.assertIn("height: auto;", self.feedback_styles)
|
|
self.assertIn("#toast.toast[hidden] {", self.feedback_styles)
|
|
self.assertIn("display: none;", self.feedback_styles)
|
|
self.assertNotRegex(self.heaven_styles, 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.registry.index('"/shared/ui-core.js"'),
|
|
self.registry.index('"/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("async function showEntityPreview", 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 = palette.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 = palette.average;", 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_entity_daily_chart_uses_runtime_theme_palette(self):
|
|
start = self.script.index("function drawEntityDetailChart")
|
|
end = self.script.index("function clearEntityDetailChart", start)
|
|
chart = self.script[start:end]
|
|
self.assertIn("const palette = currentChartPalette();", chart)
|
|
self.assertIn("context.fillStyle = palette.background;", chart)
|
|
self.assertIn("context.fillStyle = palette.axis;", chart)
|
|
self.assertNotIn('context.fillStyle = "#6c7983";', chart)
|
|
|
|
def test_dark_mentor_tokens_and_sentiment_bottom_clearance_are_defined(self):
|
|
self.assertIn(":root[data-theme=\"dark\"] #mentorView.workspace-view {", self.mentor_styles)
|
|
self.assertIn("--qp-bg-chat: #232529;", self.mentor_styles)
|
|
self.assertIn("--qp-bg-bubble-self: #35598C;", self.mentor_styles)
|
|
self.assertIn("--qp-bg-selected: #2B3B58;", self.mentor_styles)
|
|
self.assertIn("--qp-link: #316FEF;", self.mentor_styles)
|
|
self.assertIn("--qp-accent-soft: #5B8DEF;", self.mentor_styles)
|
|
self.assertIn("--mentor-directory-width: 300px;", self.tokens)
|
|
self.assertIn("--mentor-composer-min-height: 85px;", self.tokens)
|
|
self.assertIn("#mentorView .mentor-message.user .mentor-message-content {", self.mentor_styles)
|
|
self.assertIn("background: var(--qp-bg-bubble-self);", self.mentor_styles)
|
|
self.assertIn("#mentorView .mentor-quick-prompts button {", self.mentor_styles)
|
|
self.assertIn("#mentorView .mentor-composer-hint {", self.mentor_styles)
|
|
self.assertIn("#sentimentCycleView .sentiment-history-frame {", self.sentiment_styles)
|
|
self.assertIn("margin-bottom: var(--card-gap);", self.sentiment_styles)
|
|
self.assertIn("padding-bottom: var(--card-gap);", self.sentiment_styles)
|
|
self.assertIn("--sentiment-history-max-height: 510px;", self.tokens)
|
|
self.assertIn("max-height: var(--sentiment-history-max-height);", self.sentiment_styles)
|
|
self.assertIn("overflow: auto;", self.sentiment_styles)
|
|
|
|
def test_mentor_final_visual_fix_contract(self):
|
|
shell_styles = (STATIC_DIR / "shared" / "shell.css").read_text(encoding="utf-8")
|
|
mentor_html = (STATIC_DIR / "pages" / "mentor" / "page.html").read_text(encoding="utf-8")
|
|
|
|
self.assertNotIn('body[data-active-view="mentorView"]', shell_styles)
|
|
self.assertNotIn("body[data-active-view=mentorView]", shell_styles)
|
|
self.assertIn('id="mentorPageSubtitle"', mentor_html)
|
|
self.assertIn('class="mentor-page-header"', mentor_html)
|
|
self.assertIn("#mentorView .mentor-page-header", self.mentor_styles)
|
|
for tone in ("violet", "blue", "green", "orange", "red", "teal", "purple", "yellow"):
|
|
self.assertIn(f".mentor-avatar-tone-{tone} {{", self.mentor_styles, tone)
|
|
self.assertIn(
|
|
f':root[data-theme="dark"] #mentorView .mentor-avatar-tone-{tone} {{',
|
|
self.mentor_styles,
|
|
tone,
|
|
)
|
|
self.assertIn("kobe92-perspective", self.script)
|
|
self.assertIn('"beijingchaojia-perspective": "green"', self.script)
|
|
self.assertIn("`mentor-avatar-tone-${mentorAvatarTone(mentor)}`", self.script)
|
|
self.assertIn('${escapeHtml(grade)}级', self.script)
|
|
self.assertIn("置顶", self.script)
|
|
self.assertNotIn("activeMentorBadges", self.script)
|
|
self.assertNotIn("activeMentorBadges", mentor_html)
|
|
self.assertIn("#mentorView .mentor-message.assistant .mentor-message-body {", self.mentor_styles)
|
|
self.assertIn("max-width: 900px;", self.mentor_styles)
|
|
self.assertIn("#mentorView .mentor-follow-ups {", self.mentor_styles)
|
|
self.assertIn("width: 382px;", self.mentor_styles)
|
|
self.assertIn("data-lucide=\"filter\"", mentor_html)
|
|
self.assertNotIn("chevron-down", mentor_html)
|
|
self.assertNotIn("· 回答完成", self.script)
|
|
self.assertIn('state.mentorLoading ? "正在生成回答..." : (selected?.tagline', self.script)
|
|
|
|
def test_theme_switch_is_atomic_and_theme_library_loading_surface_is_dark_safe(self):
|
|
self.assertIn('typeof document.startViewTransition === "function"', self.script)
|
|
self.assertIn('root.classList.add("theme-switching")', self.script)
|
|
self.assertIn('root.classList.remove("theme-switching")', self.script)
|
|
self.assertIn("clearThemeTransitionEffects();", self.script)
|
|
self.assertIn("redrawThemeSensitiveVisuals();", self.script)
|
|
self.assertIn(":root.theme-switching *", self.theme_library_styles)
|
|
self.assertIn("::view-transition-old(root)", self.base_styles)
|
|
self.assertIn(".theme-detail-empty-v2", self.theme_library_styles)
|
|
|
|
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('openModalDialog(elements.tradeLogDialog)', 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):
|
|
for label in (
|
|
"今日涨幅", "5日涨幅", "竞价关注(分)", "跟踪备注", "添加自选",
|
|
"今日盘面一句话", "今日做对了什么 / 做错了什么", "明日策略",
|
|
):
|
|
self.assertIn(label, self.html)
|
|
for element_id in (
|
|
"watchlistDialog", "watchlistSearchInput", "watchlistRemark",
|
|
"journalSummary", "journalContent", "journalPlan",
|
|
):
|
|
self.assertIn(f'id="{element_id}"', self.html)
|
|
self.assertIn('summary: document.querySelector("#journalSummary").value', self.script)
|
|
self.assertIn('return_5d', self.script)
|
|
self.assertIn('attention_score', 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)
|
|
|
|
def test_heart_question_presets_lock_before_casting_and_reach_backend(self):
|
|
for preset, label in (("trade", "交易"), ("mind", "心境"), ("unthemed", "无题")):
|
|
self.assertIn(f'data-heart-question-preset="{preset}"', self.html)
|
|
self.assertIn(f'>{label}</button>', self.html)
|
|
self.assertIn('id="heartQuestionInput"', self.html)
|
|
self.assertIn("Promise.all([climateSequence(), qiSequence(), personalSequence()])", self.script)
|
|
self.assertIn("function formatHeavenAnswer(content)", self.script)
|
|
self.assertIn('data-lucide="${phaseIcon(dayMasterElement)}"', self.script)
|
|
self.assertIn('function phaseIcon(element)', self.script)
|
|
self.assertIn('setHeartQuestionLocked(true);', self.script)
|
|
self.assertIn('payload.question = state.heartQuestion;', self.script)
|
|
self.assertIn('payload.question_preset = state.heartQuestionPreset;', self.script)
|
|
self.assertIn('payload.cast_at = state.heartCastAt;', self.script)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|