feat: integrate iFinD data and refine intelligent workspaces

This commit is contained in:
leefer
2026-07-28 16:38:56 +08:00
parent 6adeb54458
commit f4b2d7152a
22 changed files with 6481 additions and 389 deletions
+219 -7
View File
@@ -133,7 +133,13 @@ async function mockApplication(page, authSession = session(), options = {}) {
const url = new URL(route.request().url());
let payload = { ok: true };
if (url.pathname === "/api/auth/me") payload = authSession;
else if (url.pathname === "/api/dashboard") payload = dashboard;
else if (url.pathname === "/api/dashboard") {
options.dashboardRequests = (options.dashboardRequests || 0) + 1;
if (options.dashboardDelay) {
await new Promise((resolve) => setTimeout(resolve, options.dashboardDelay));
}
payload = dashboard;
}
else if (url.pathname === "/api/stock/002141/preview") {
payload = {
meta: { trade_date: "2026-07-23", intraday_trade_date: "2026-07-24", realtime: true, intraday_notice: "" },
@@ -233,6 +239,17 @@ async function mockApplication(page, authSession = session(), options = {}) {
items: [{ role: "assistant", content: "Review evidence before forming a conclusion.", context_date: "20260722" }],
};
} else if (url.pathname === "/api/search") payload = { groups: { stocks: [{ id: "002141", code: "002141", name: "Test Stock", type: "stock", industry: "Test Sector" }], sectors: [], themes: [], indices: [] } };
else if (url.pathname === "/api/dragon-tiger/profiles") {
payload = {
meta: { status: "success", source: "tushare", cached: true },
summary: { profile_count: 3, described_count: 2, organization_count: 4 },
profiles: [
{ id: "hot-money-profile-1", name: "赵老哥", description: "聚焦市场核心标的。", organizations: ["华泰证券浙江分公司", "银河证券绍兴"], organization_count: 2 },
{ id: "hot-money-profile-2", name: "炒股养家", description: "重视情绪与风险收益比。", organizations: ["华鑫证券上海宛平南路"], organization_count: 1 },
{ id: "hot-money-profile-3", name: "作手新一", description: "", organizations: ["国泰海通证券南京太平南路"], organization_count: 1 },
],
};
}
else if (url.pathname === "/api/dragon-tiger") {
payload = {
meta: { trade_date: "2026-07-22", requested_date: "2026-07-22", status: "empty", source: "tushare" },
@@ -336,15 +353,25 @@ async function mockApplication(page, authSession = session(), options = {}) {
},
],
};
if (options.additionalScreenerRegimes) {
payload.regimes.push(...options.additionalScreenerRegimes);
}
if (options.additionalScreenerStrategies) {
payload.strategies.push(...options.additionalScreenerStrategies);
}
if (options.latestScreenerResults) {
payload.latest_results = options.latestScreenerResults;
payload.latest_result = options.latestScreenerResults.smart || null;
}
if (options.recentScreenerResults) {
payload.recent_results = options.recentScreenerResults;
}
} else if (url.pathname === "/api/screener/run") {
const body = route.request().postDataJSON();
options.screenerRunBodies = [...(options.screenerRunBodies || []), body];
const runResult = options.screenerRunResult?.(body);
payload = {
result: options.latestScreenerResults?.[body.mode] || {
result: runResult || options.latestScreenerResults?.[body.mode] || {
meta: {
run_id: 99,
trade_date: "20260722",
@@ -357,6 +384,9 @@ async function mockApplication(page, authSession = session(), options = {}) {
backtest: null,
},
};
if (options.recentScreenerResults) {
options.recentScreenerResults.unshift(payload.result);
}
} else if (url.pathname === "/api/screener/tracking") {
if (route.request().method() === "POST") {
const body = route.request().postDataJSON();
@@ -446,6 +476,61 @@ test("admin shell opens every primary workspace and global search", async ({ pag
await expect(page.locator("#globalSearchInput")).toBeFocused();
});
test("manual refresh stays in place without reopening the full-page loader", async ({ page }) => {
const options = { dashboardDelay: 350 };
await mockApplication(page, session(), options);
await page.goto("/index.html");
await expect(page.locator("#loadingOverlay")).toBeHidden();
await page.locator("#refreshButton").click();
await expect(page.locator("#refreshButton")).toBeDisabled();
await expect(page.locator("#loadingOverlay")).toBeHidden();
await expect(page.locator("#statusText")).toContainText("刷新");
await expect(page.locator("#refreshButton")).toBeEnabled();
expect(options.dashboardRequests).toBe(2);
});
test("night mode covers the application shell and persists across reloads", async ({ page }) => {
await mockApplication(page, session("admin", true));
await page.addInitScript(() => {
if (sessionStorage.getItem("themeTestReady")) return;
localStorage.removeItem("xiaobaiTheme");
sessionStorage.setItem("themeTestReady", "1");
});
await page.goto("/index.html");
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "false");
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到日间模式");
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "true");
const darkSurfaces = await page.evaluate(() => {
const color = (selector) => getComputedStyle(document.querySelector(selector)).backgroundColor;
return {
body: color("body"),
sidebar: color(".sidebar"),
topbar: color(".topbar"),
tableHead: color("#limitTable thead th"),
};
});
expect(new Set(Object.values(darkSurfaces)).has("rgb(255, 255, 255)")).toBe(false);
await page.reload();
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-pressed", "true");
await page.keyboard.press("Control+K");
await expect(page.locator("#globalSearchDialog")).toBeVisible();
expect(await page.locator("#globalSearchDialog").evaluate((dialog) => getComputedStyle(dialog).backgroundColor)).not.toBe("rgb(255, 255, 255)");
await page.locator("#closeGlobalSearch").click();
await page.locator("#themeToggle").click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到夜间模式");
});
test("collapsed overview and sentiment decision layout keep a single current reading", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true));
@@ -1241,7 +1326,32 @@ test("dragon-tiger redesign keeps the merged empty state and independent card hi
descriptionSize: 13,
});
await page.locator("#dragonProfilesButton").click();
await expect(page.locator("#dragonProfilesContent")).toBeVisible();
await expect(page.locator("#dragonDailyContent")).toBeHidden();
await expect(page.locator("#hotMoneyProfileSummary > span")).toHaveCount(3);
await expect(page.locator("#hotMoneyProfileList .hot-money-profile-row-v2")).toHaveCount(3);
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("赵老哥");
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("华泰证券浙江分公司");
await page.locator("#hotMoneyProfileSearch").fill("宛平南路");
await expect(page.locator("#hotMoneyProfileList .hot-money-profile-row-v2")).toHaveCount(1);
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("炒股养家");
await page.locator("#hotMoneyProfileSearch").fill("");
await page.locator('[data-hot-money-profile="hot-money-profile-3"]').click();
await expect(page.locator("#hotMoneyProfileDetail")).toContainText("名录暂未收录该游资的公开简介");
await page.setViewportSize({ width: 390, height: 844 });
const profileMobile = await page.evaluate(() => {
const list = document.querySelector("#hotMoneyProfileList").getBoundingClientRect();
const detail = document.querySelector("#hotMoneyProfileDetail").getBoundingClientRect();
return {
pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1,
detailBelowList: detail.top >= list.bottom - 1,
};
});
expect(profileMobile).toEqual({ pageFits: true, detailBelowList: true });
await page.locator("#dragonDailyButton").click();
const mobile = await page.evaluate(() => ({
pageFits: document.documentElement.scrollWidth <= window.innerWidth + 1,
operationsScroll: document.querySelector("#dragonTraderDetail .trader-operations").scrollWidth > document.querySelector("#dragonTraderDetail .trader-operations").clientWidth + 1,
@@ -1954,6 +2064,93 @@ test("screener stage completion follows its execution context and mode results s
await expect(page.locator("#screenerTableBody")).not.toContainText("量化结果");
});
test("screener keeps results for each stage and curated strategy across switching and reload", async ({ page }) => {
const formula = {
meta: { library: "smart" }, universe: {}, filters: [],
score: [{ field: "relative_strength", weight: 1, direction: "desc" }],
limit: 10, min_score: 0.5,
};
const options = {
recentScreenerResults: [],
additionalScreenerRegimes: [{ id: "retreat", label: "Retreat" }],
additionalScreenerStrategies: [
{
id: 3, name: "Retreat Defense", description: "Retreat-stage strategy",
regimes: ["retreat"], builtin: true, data_ready: true, missing_data: [], formula,
},
{
id: 4, name: "Quality B", description: "Second curated strategy",
regimes: ["repair"], builtin: true, data_ready: true, missing_data: [],
formula: {
...formula,
meta: { library: "curated", category: "Quality", quality: "A", frequency: "Monthly", risk: "Low" },
},
},
],
};
options.screenerRunResult = (body) => {
const candidateName = body.mode === "smart"
? body.regime === "retreat" ? "Smart Retreat" : "Smart Repair"
: body.strategy_name === "Quality B" ? "Curated B" : "Curated A";
return {
meta: {
run_id: 100 + options.recentScreenerResults.length,
trade_date: "20260722",
regime: body.regime,
strategy_name: body.strategy_name,
mode: body.mode,
},
candidates: [{
code: `60000${options.recentScreenerResults.length + 1}`,
name: candidateName,
sector: "Test Sector",
score_display: 80,
historical_probability: 50,
probability_samples: 20,
pct_chg: 1,
return_5d: 2,
volume_ratio_5d: 1.2,
sector_strength: 70,
reason: "Context result",
risk_flags: [],
}],
disclaimer: "Historical statistics do not predict future returns.",
backtest: null,
};
};
await mockApplication(page, session("user", true), options);
await page.goto("/index.html?view=screenerView");
await page.locator("#screenerRunButton").click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
await page.locator('[data-regime="retreat"]').click();
await page.locator("#screenerRunButton").click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat");
await page.locator('[data-regime="repair"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
await page.locator('[data-regime="retreat"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat");
await page.locator('[data-screener-mode="curated"]').click();
await page.locator('[data-curated-run="2"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
await page.locator('[data-curated-run="4"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
await page.locator('[data-curated-strategy="2"] .curated-card-description').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
await page.reload();
await page.locator('[data-screener-mode="smart"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Repair");
await page.locator('[data-regime="retreat"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Smart Retreat");
await page.locator('[data-screener-mode="curated"]').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated A");
await page.locator('[data-curated-strategy="4"] .curated-card-description').click();
await expect(page.locator("#screenerTableBody")).toContainText("Curated B");
});
test("screener tracking is an internal page populated only by manual candidate actions", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true));
@@ -2048,8 +2245,8 @@ test("heaven workspace actions remain compact and do not overlap", async ({ page
await page.goto("/index.html");
await page.locator('[data-view="heavenView"]').first().click();
const titleSize = Number.parseFloat(await page.locator("#heavenView .heaven-toolbar h2").evaluate((element) => getComputedStyle(element).fontSize));
expect(titleSize).toBeLessThanOrEqual(22);
const titleSize = Number.parseFloat(await page.locator("#heavenView .wt-title-line h1").evaluate((element) => getComputedStyle(element).fontSize));
expect(titleSize).toBeLessThanOrEqual(32);
const trendButtons = page.locator(".heaven-trend-actions .button");
await expect(trendButtons).toHaveCount(3);
@@ -2075,7 +2272,8 @@ test("heaven workspace actions remain compact and do not overlap", async ({ page
openHeavenReading("fortune", { loading: false });
});
const readingDialog = await page.locator("#heavenReadingDialog").boundingBox();
expect(readingDialog.width).toBeLessThanOrEqual(920);
expect(readingDialog.width).toBeLessThanOrEqual(1120);
expect(readingDialog.width / readingDialog.height).toBeGreaterThan(1.4);
expect(readingDialog.height).toBeLessThanOrEqual(820);
const readingHeader = await page.locator("#heavenReadingDialog .dialog-header").boundingBox();
const readingTabs = await page.locator("#heavenReadingDialog .heaven-reading-tabs").boundingBox();
@@ -2113,7 +2311,9 @@ test("heaven workspace controls fit a narrow viewport", async ({ page }) => {
expect(Math.abs(firstFortuneButton.y - secondFortuneButton.y)).toBeLessThanOrEqual(1);
await page.locator('[data-heaven-panel="heart"]').click();
const heartControls = await page.locator(".heart-toolbar-controls").boundingBox();
expect(heartControls.width).toBeLessThanOrEqual(80);
const heartPanel = await page.locator("#heavenHeartPanel").boundingBox();
expect(heartControls.width).toBeLessThanOrEqual(heartPanel.width);
expect(heartControls.x).toBeGreaterThanOrEqual(heartPanel.x - 1);
expect(heartControls.x + heartControls.width).toBeLessThanOrEqual(375);
expect(await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(1);
});
@@ -2128,7 +2328,7 @@ test("mentor directory exposes evidence filters and private owner metadata", asy
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(22);
await expect(page.locator('#mentorList [data-mentor-id="private-owner"] .mentor-badge.private')).toContainText("仅自己");
await expect(page.locator('#mentorList [data-mentor-id="source-a"] .mentor-badge.grade-a')).toHaveText("A");
await expect(page.locator('#mentorList [data-mentor-id="source-c"] .mentor-badge.quality')).toHaveText("5/6");
await expect(page.locator('#mentorList [data-mentor-id="source-c"] .mentor-badge.quality')).toHaveCount(0);
await page.locator("#mentorSearchInput").fill("行为推演");
await expect(page.locator("#mentorList .mentor-option")).toHaveCount(1);
@@ -2183,6 +2383,18 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
await expect(answer.locator(".mentor-answer-list li")).toHaveCount(2);
await expect(answer.locator("br")).toHaveCount(0);
await expect(page.locator("#mentorMessages .assistant-stream-caret")).toHaveCount(0);
await page.locator("#themeToggle").click();
const darkMessageStyle = await answer.evaluate((element) => {
const style = getComputedStyle(element);
return {
background: style.backgroundColor,
border: style.borderTopColor,
shadow: style.boxShadow,
};
});
expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.border).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.shadow).toBe("none");
});
test("mobile mentor directory opens as a searchable selector and hides private mentors", async ({ page }) => {
+42
View File
@@ -79,6 +79,48 @@ class AccountDataBoundaryTests(unittest.TestCase):
self.database.latest_screener_runs(self.second["id"], "20260722"), {}
)
def test_latest_screener_context_runs_keep_each_stage_and_strategy(self):
runs = [
("smart", "repair", "Repair", "600001"),
("smart", "repair", "Repair", "600002"),
("smart", "retreat", "Retreat", "600003"),
("curated", "repair", "Dividend", "600004"),
("curated", "repair", "Momentum", "600005"),
("quant", "repair", "Custom quant", "600006"),
("quant", "repair", "Custom quant", "600007"),
]
for mode, regime, strategy, code in runs:
self.database.save_screener_run(
self.first["id"], "20260722", regime, strategy, FORMULA,
{"candidates": [{"code": code}], "meta": {}}, mode,
)
results = self.database.latest_screener_context_runs(
self.first["id"], "20260722"
)
by_context = {
(
item["meta"]["mode"],
item["meta"]["regime"] if item["meta"]["mode"] == "smart" else "",
item["meta"]["strategy_name"] if item["meta"]["mode"] != "quant" else "",
): item["candidates"][0]["code"]
for item in results
}
self.assertEqual(by_context, {
("smart", "repair", "Repair"): "600002",
("smart", "retreat", "Retreat"): "600003",
("curated", "", "Dividend"): "600004",
("curated", "", "Momentum"): "600005",
("quant", "", ""): "600007",
})
self.assertEqual(
self.database.latest_screener_context_runs(
self.second["id"], "20260722"
),
[],
)
def test_mentor_messages_are_scoped_by_user_mentor_and_date(self):
self.database.save_mentor_exchange(
self.first["id"], "mentor-a", "20260721", "怎么看?", "先看承接。", "20260721"
+111
View File
@@ -0,0 +1,111 @@
from __future__ import annotations
import copy
import unittest
from server import DashboardService
class SnapshotDatabase:
def __init__(self, snapshot, latest=None):
self.snapshot = snapshot
self.latest = latest
self.aliases = {}
def get_snapshot(self, _trade_date):
return copy.deepcopy(self.snapshot)
def reason_overrides(self, _trade_date):
return {}
def get_data_snapshot(self, kind, cache_key):
return copy.deepcopy(self.aliases.get((kind, cache_key)))
def save_data_snapshot(self, kind, cache_key, _source, payload):
self.aliases[(kind, cache_key)] = copy.deepcopy(payload)
def get_latest_real_snapshot(self, _trade_date, strictly_before=False):
return copy.deepcopy(self.latest)
class DashboardCacheTests(unittest.TestCase):
def service(self, snapshot):
service = object.__new__(DashboardService)
service.database = SnapshotDatabase(snapshot)
return service
def test_cached_dashboard_skips_sentiment_rebuild_when_fields_are_complete(self):
snapshot = {
"meta": {"source": "tushare", "trade_date": "2026-07-22"},
"overview": {
"sentiment_score": 32,
"sentiment_label": "weak",
"sentiment_phase": "retreat",
"sentiment_direction": "cooling",
"sentiment_components": {},
},
}
service = self.service(snapshot)
service._enrich_dashboard_sentiment = lambda *_args: self.fail(
"complete cached sentiment must not be rebuilt"
)
payload = service.get_dashboard("2026-07-22")
self.assertTrue(payload["meta"]["cached"])
self.assertEqual(payload["overview"]["sentiment_score"], 32)
def test_cached_dashboard_rebuilds_legacy_snapshot_missing_sentiment(self):
snapshot = {
"meta": {"source": "tushare", "trade_date": "2026-07-22"},
"overview": {"limit_up_count": 20},
}
service = self.service(snapshot)
calls = []
def enrich(payload, trade_date):
calls.append(trade_date)
payload["overview"].update({
"sentiment_score": 20,
"sentiment_label": "weak",
"sentiment_phase": "ice",
"sentiment_direction": "cooling",
"sentiment_components": {},
})
return payload
service._enrich_dashboard_sentiment = enrich
payload = service.get_dashboard("2026-07-22")
self.assertEqual(calls, ["20260722"])
self.assertEqual(payload["overview"]["sentiment_phase"], "ice")
def test_weekend_dashboard_reuses_latest_close_without_external_sync(self):
latest = {
"meta": {"source": "tushare", "trade_date": "2026-07-24"},
"overview": {
"sentiment_score": 32,
"sentiment_label": "weak",
"sentiment_phase": "retreat",
"sentiment_direction": "cooling",
"sentiment_components": {},
},
}
service = object.__new__(DashboardService)
service.database = SnapshotDatabase(None, latest)
service.sync_dashboard = lambda *_args: self.fail(
"weekend refresh must not call the external synchronization path"
)
first = service.get_dashboard("2026-07-25")
service.database.latest = None
second = service.get_dashboard("2026-07-25")
self.assertTrue(first["meta"]["carried_forward"])
self.assertEqual(first["meta"]["trade_date"], "2026-07-24")
self.assertEqual(second["meta"]["requested_date"], "2026-07-25")
if __name__ == "__main__":
unittest.main()
+46 -3
View File
@@ -24,6 +24,8 @@ class FrontendContractTests(unittest.TestCase):
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")
cls.design_system = (STATIC_DIR / "design-system.css").read_text(encoding="utf-8")
cls.theme = (STATIC_DIR / "theme.css").read_text(encoding="utf-8")
collector = IdCollector()
collector.feed(cls.html)
cls.ids = collector.ids
@@ -62,6 +64,13 @@ class FrontendContractTests(unittest.TestCase):
):
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"')
@@ -123,7 +132,7 @@ class FrontendContractTests(unittest.TestCase):
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"'),
self.html.index('<script src="/app.js'),
)
for function_name in (
"number", "clamp", "escapeHtml", "formatNumber", "formatTimestamp",
@@ -158,7 +167,7 @@ class FrontendContractTests(unittest.TestCase):
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.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)
@@ -167,7 +176,7 @@ class FrontendContractTests(unittest.TestCase):
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("context.strokeStyle = palette.average;", chart)
self.assertIn('intraday_trade_date || payload.meta?.trade_date', self.script)
self.assertIn('(payload.intraday || []).length ? "最新分时 · 1分钟"', self.script)
@@ -197,6 +206,40 @@ class FrontendContractTests(unittest.TestCase):
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 {", self.theme)
self.assertIn("--mentor-ink: var(--text-primary);", self.theme)
self.assertIn("--mentor-sub: var(--text-secondary);", self.theme)
self.assertIn(":root[data-theme=\"dark\"] #mentorView .mentor-message {", self.theme)
self.assertIn("border-color: var(--line-soft);", self.theme)
self.assertIn("background: var(--surface-subtle);", self.theme)
self.assertIn("box-shadow: none;", self.theme)
self.assertIn("#sentimentCycleView .sentiment-history-frame {", self.theme)
self.assertIn("margin-bottom: var(--card-gap);", self.theme)
self.assertIn("padding-bottom: var(--card-gap);", self.theme)
self.assertIn("--sentiment-history-max-height:510px;", self.design_system)
self.assertIn("max-height:var(--sentiment-history-max-height);", self.design_system)
self.assertIn("overflow:auto;", self.design_system)
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)
self.assertIn("::view-transition-old(root)", self.theme)
self.assertIn(".theme-detail-empty-v2,", self.theme)
def test_membership_copy_includes_review_assistant_access(self):
self.assertIn("复盘助手仅对会员开放", self.html)
self.assertIn("智能选股、问师、问天、复盘助手等智能功能", self.html)
+73
View File
@@ -0,0 +1,73 @@
from __future__ import annotations
import unittest
from tushare_client import TushareClient
class HotMoneyProfileClient(TushareClient):
def query(self, api_name, params=None, fields=""):
self.last_request = (api_name, params or {}, fields)
if api_name != "hm_list":
raise AssertionError(f"unexpected api: {api_name}")
return [
{
"name": "赵老哥",
"desc": "聚焦市场核心标的。",
"orgs": "华泰证券浙江分公司;银河证券绍兴",
},
{
"name": "炒股养家",
"desc": "",
"orgs": "华鑫证券上海宛平南路, 华鑫证券上海分公司",
},
{
"name": "赵老哥",
"desc": "重复记录不应覆盖首条档案。",
"orgs": "重复席位",
},
{"name": "", "desc": "无效记录", "orgs": ""},
]
class HotMoneyProfileTests(unittest.TestCase):
def test_directory_normalizes_profiles_and_organizations(self):
client = HotMoneyProfileClient("token")
payload = client.hot_money_profiles()
self.assertEqual(client.last_request[0], "hm_list")
self.assertEqual(client.last_request[2], "name,desc,orgs")
self.assertEqual(payload["meta"]["status"], "success")
self.assertEqual(payload["summary"], {
"profile_count": 2,
"described_count": 1,
"organization_count": 4,
})
self.assertEqual(
payload["profiles"][0]["organizations"],
["华泰证券浙江分公司", "银河证券绍兴"],
)
self.assertEqual(payload["profiles"][1]["organization_count"], 2)
self.assertEqual(
[item["id"] for item in payload["profiles"]],
["hot-money-profile-1", "hot-money-profile-2"],
)
def test_directory_parses_json_encoded_organization_lists(self):
client = TushareClient("token")
client.query = lambda *_args, **_kwargs: [
{
"name": "Profile",
"desc": "",
"orgs": '["Seat A", "Seat B", "Seat A"]',
}
]
payload = client.hot_money_profiles()
self.assertEqual(payload["profiles"][0]["organizations"], ["Seat A", "Seat B"])
self.assertEqual(payload["summary"]["organization_count"], 2)
if __name__ == "__main__":
unittest.main()
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
import unittest
from ifind_client import IfindError, IfindHttpClient
class IfindClientTests(unittest.TestCase):
def test_table_rows_normalizes_single_table_payload(self):
rows = IfindHttpClient._table_rows(
{
"tables": {
"thscode": "300033.SZ",
"time": ["2026-07-28 09:30", "2026-07-28 09:31"],
"table": {"close": [10.1, 10.2], "amount": [100, 200]},
}
}
)
self.assertEqual(len(rows), 2)
self.assertEqual(rows[0]["thscode"], "300033.SZ")
self.assertEqual(rows[1]["time"], "2026-07-28 09:31")
self.assertEqual(rows[1]["close"], 10.2)
def test_table_rows_normalizes_wencai_list_payload(self):
rows = IfindHttpClient._table_rows(
{
"tables": [
{
"table": {
"股票代码": ["000001.SZ", "600000.SH"],
"股票简称": ["平安银行", "浦发银行"],
}
}
]
}
)
self.assertEqual([row["股票代码"] for row in rows], ["000001.SZ", "600000.SH"])
def test_display_date_rejects_invalid_values(self):
self.assertEqual(IfindHttpClient._display_date("20260728"), "2026-07-28")
with self.assertRaises(IfindError):
IfindHttpClient._display_date("2026-7-28")
def test_client_requires_credentials_before_request(self):
client = IfindHttpClient()
self.assertFalse(client.configured)
with self.assertRaises(IfindError):
client.real_time("000001.SH", ["latest"])
if __name__ == "__main__":
unittest.main()
+154
View File
@@ -0,0 +1,154 @@
from __future__ import annotations
import tempfile
import unittest
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from chart_data_provider import EastmoneyChartClient, MarketChartClient
from database import ReviewDatabase
from market_insights import MarketInsightsService
from server import DashboardService
class FakeIfind:
configured = True
def history(self, codes, indicators, start_date, end_date, cache_ttl=0):
return [
{
"time": "2026-07-27",
"thscode": "000001.SZ",
"open": 10,
"high": 10.5,
"low": 9.8,
"close": 10.2,
"volume": 100,
"amount": 1_000_000,
},
{
"time": "2026-07-28",
"thscode": "000001.SZ",
"open": 10.2,
"high": 10.8,
"low": 10.1,
"close": 10.5,
"volume": 120,
"amount": 1_200_000,
},
]
def real_time(self, codes, indicators, cache_ttl=0):
return []
class FakeIfindSnapshots:
configured = True
def __init__(self):
self.calls = []
def snapshots(self, codes, indicators, start_time, end_time, cache_ttl=0):
self.calls.append(
{
"codes": codes,
"indicators": indicators,
"start_time": start_time,
"end_time": end_time,
"cache_ttl": cache_ttl,
}
)
return [
{
"time": "2026-07-28 09:21:00",
"thscode": "000001.SZ",
"latest": 10.5,
"preClose": 10,
"volume": 2000,
"amount": 21000,
"bidSize1": 1200,
"askSize1": 800,
}
]
class FakeTushare:
pass
class IfindFeatureTests(unittest.TestCase):
def test_wencai_saved_queries_are_isolated_by_user(self):
with tempfile.TemporaryDirectory() as temporary:
database = ReviewDatabase(Path(temporary) / "review.db")
first = database.create_user("first-user", "salt", "hash")
second = database.create_user("second-user", "salt", "hash")
database.save_wencai_query(first["id"], "高质量", "ROE大于15%", "stock")
self.assertEqual(len(database.list_wencai_saved_queries(first["id"])), 1)
self.assertEqual(database.list_wencai_saved_queries(second["id"]), [])
def test_ifind_daily_chart_normalizes_change(self):
client = MarketChartClient(FakeIfind(), EastmoneyChartClient())
rows = client.stock_daily("000001", "20260728")
self.assertEqual(rows[-1]["trade_date"], "2026-07-28")
self.assertAlmostEqual(rows[-1]["change"], 2.9412, places=4)
def test_event_enrichment_keeps_blank_broken_reason_blank(self):
dashboard = {"broken": [{"code": "000001", "reason": "原原因"}]}
DashboardService._merge_ifind_event_enrichment(
dashboard,
{
"broken": {
"000001": {
"reason": "",
"first_time": "09:42:00",
"last_time": "",
"open_times": 3,
}
}
},
)
self.assertEqual(dashboard["broken"][0]["reason"], "原原因")
self.assertEqual(dashboard["broken"][0]["open_times"], 3)
def test_dynamic_auction_uses_ifind_snapshot_window_and_normalizes_rows(self):
with tempfile.TemporaryDirectory() as temporary:
database = ReviewDatabase(Path(temporary) / "review.db")
database.upsert_stock_master(
[
{
"ts_code": "000001.SZ",
"name": "Ping An Bank",
"industry": "Bank",
"market": "MainBoard",
"list_date": "19910403",
}
]
)
ifind = FakeIfindSnapshots()
service = MarketInsightsService(
database,
FakeTushare(),
now_provider=lambda: datetime(
2026, 7, 28, 9, 22, tzinfo=timezone(timedelta(hours=8))
),
ifind=ifind,
)
service._auction_candidates = lambda rows, baseline: (
[{"ts_code": "000001.SZ"}],
{},
[],
)
rows = service._dynamic_auction_rows("20260728", "20260727", 0)
self.assertEqual(ifind.calls[0]["start_time"], "2026-07-28 09:15:00")
self.assertEqual(ifind.calls[0]["end_time"], "2026-07-28 09:22:00")
self.assertEqual(rows[0]["ts_code"], "000001.SZ")
self.assertEqual(rows[0]["price"], 10.5)
self.assertEqual(rows[0]["snapshot_time"], "2026-07-28 09:21:00")
self.assertTrue(rows[0]["dynamic"])
if __name__ == "__main__":
unittest.main()