feat: integrate iFinD data and refine intelligent workspaces
This commit is contained in:
+219
-7
@@ -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 }) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user