feat: complete strategy and market data improvements

This commit is contained in:
leefer
2026-07-29 16:50:40 +08:00
parent c30d2107b3
commit 0030bb8cc1
18 changed files with 1622 additions and 162 deletions
+136 -21
View File
@@ -135,12 +135,20 @@ async function mockApplication(page, authSession = session(), options = {}) {
if (url.pathname === "/api/auth/me") payload = authSession;
else if (url.pathname === "/api/dashboard") {
options.dashboardRequests = (options.dashboardRequests || 0) + 1;
options.dashboardTradeDates ||= [];
options.dashboardTradeDates.push(url.searchParams.get("trade_date"));
if (options.dashboardDelay) {
await new Promise((resolve) => setTimeout(resolve, options.dashboardDelay));
}
payload = dashboard;
if (options.echoDashboardDate) {
const requestedDate = url.searchParams.get("trade_date");
payload = { ...dashboard, meta: { ...dashboard.meta, trade_date: requestedDate, requested_date: requestedDate } };
} else payload = dashboard;
}
else if (url.pathname === "/api/stock/002141/preview") {
if (options.previewDelay) {
await new Promise((resolve) => setTimeout(resolve, options.previewDelay));
}
payload = {
meta: { trade_date: "2026-07-23", intraday_trade_date: "2026-07-24", realtime: true, intraday_notice: "" },
stock: { code: "002141", name: "Test Stock", industry: "Test Sector", price: 10.8, change: 2.4 },
@@ -168,7 +176,16 @@ async function mockApplication(page, authSession = session(), options = {}) {
notes: [],
};
} else if (url.pathname === "/api/search/detail") {
payload = {
const theme = url.searchParams.get("type") === "theme";
payload = theme ? {
meta: { trade_date: "2026-07-23", realtime: false },
entity: { id: "885728.TI", code: "885728.TI", name: "人工智能", type: "theme", type_label: "题材", value: 1280, change: 2.2 },
series: [
{ trade_date: "2026-07-22", open: 1220, high: 1260, low: 1210, close: 1250, volume: 1000 },
{ trade_date: "2026-07-23", open: 1255, high: 1290, low: 1248, close: 1280, volume: 1200 },
],
metrics: [],
} : {
meta: { trade_date: "2026-07-23", realtime: false },
entity: { id: "000001.SH", code: "000001.SH", name: "上证指数", type: "index", type_label: "指数", value: 3800, change: 0.5 },
series: [
@@ -486,6 +503,27 @@ test("admin shell opens every primary workspace and global search", async ({ pag
await expect(page.locator("#globalSearchInput")).toBeFocused();
});
test("fresh visits default to the latest date and sentiment cycle", async ({ page }) => {
const options = { echoDashboardDate: true };
await mockApplication(page, session("admin", true), options);
await page.goto("/index.html?date=2026-07-28");
const today = await page.evaluate(() => todayString());
await expect(page.locator("#tradeDate")).toHaveValue(today);
await expect(page.locator("#sentimentCycleView")).toHaveClass(/active-view/);
await expect(page.locator('[data-view="sentimentCycleView"]')).toHaveClass(/active/);
expect(options.dashboardTradeDates.at(-1)).toBe(today);
expect(new URL(page.url()).searchParams.has("date")).toBe(false);
await page.evaluate(() => {
const input = document.querySelector("#tradeDate");
input.value = "2026-07-28";
input.dispatchEvent(new Event("change", { bubbles: true }));
});
await expect.poll(() => options.dashboardTradeDates.at(-1)).toBe("2026-07-28");
expect(new URL(page.url()).searchParams.has("date")).toBe(false);
});
test("every primary workspace shares the canonical desktop shell geometry", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("admin", true));
@@ -564,7 +602,7 @@ test("night mode covers the application shell and persists across reloads", asyn
await expect(page.locator("#themeToggle")).toHaveAttribute("aria-label", "切换到夜间模式");
});
test("collapsed overview and sentiment decision layout keep a single current reading", async ({ page }) => {
test("collapsed overview and sentiment layout keep a single current reading", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await mockApplication(page, session("user", true));
await page.goto("/index.html");
@@ -591,49 +629,37 @@ test("collapsed overview and sentiment decision layout keep a single current rea
});
await page.locator('[data-view="sentimentCycleView"]').first().click();
await expect(page.locator("#sentimentStageGuideTitle")).toHaveText("判定口径");
await expect(page.locator('[data-sentiment-stage]:visible')).toHaveCount(1);
await expect(page.locator('[data-sentiment-stage="退潮"]')).toBeVisible();
await expect(page.locator(".sentiment-stage-guide, [data-sentiment-stage]")).toHaveCount(0);
await expect(page.locator("#sentimentPhaseAdvice")).toHaveText("情绪指标继续走弱。");
const alignment = await page.evaluate(() => {
const guide = document.querySelector(".sentiment-stage-guide").getBoundingClientRect();
const components = document.querySelector(".sentiment-components-panel").getBoundingClientRect();
const trend = document.querySelector(".sentiment-trend-panel").getBoundingClientRect();
const summary = document.querySelector(".sentiment-cycle-summary").getBoundingClientRect();
const chart = document.querySelector(".sentiment-chart-shell").getBoundingClientRect();
const currentGuide = document.querySelector("[data-sentiment-stage].current");
const detail = document.querySelector(".sentiment-detail-toolbar").getBoundingClientRect();
const label = document.querySelector(".sentiment-block .metric-label");
const status = document.querySelector(".sentiment-block .sentiment-text");
const rangeHeader = document.querySelector(".sentiment-stage-guide-head > span:nth-child(3)").getBoundingClientRect();
const rangeValue = currentGuide.querySelector(".stage-range").getBoundingClientRect();
const labelStyle = getComputedStyle(document.querySelector(".sentiment-block .metric-label"));
const statusStyle = getComputedStyle(document.querySelector(".sentiment-block .sentiment-text"));
return {
mainAligned: Math.abs(guide.x - trend.x) < 1 && Math.abs(guide.width - trend.width) < 1 && guide.top > trend.bottom,
columnsAligned: Math.abs(trend.top - summary.top) < 1,
railAligned: Math.abs(summary.x - components.x) < 1 && Math.abs(summary.width - components.width) < 1 && components.top > summary.bottom,
detailVisible: detail.top < innerHeight,
detailAfterAnalysis: detail.top > Math.max(trend.bottom, components.bottom),
chartHeight: chart.height,
guideRowHeight: currentGuide.getBoundingClientRect().height,
guideIsWhite: getComputedStyle(currentGuide).backgroundColor === "rgb(255, 255, 255)",
sameType: labelStyle.fontSize === statusStyle.fontSize
&& labelStyle.fontWeight === statusStyle.fontWeight
&& labelStyle.lineHeight === statusStyle.lineHeight,
sameBaseline: Math.abs(label.getBoundingClientRect().y - status.getBoundingClientRect().y) < 0.1,
noStatusOffset: statusStyle.marginTop === "0px",
rangeAligned: Math.abs(rangeHeader.right - rangeValue.right) < 1,
};
});
expect(alignment.mainAligned).toBe(true);
expect(alignment.columnsAligned).toBe(true);
expect(alignment.railAligned).toBe(true);
expect(alignment.detailVisible).toBe(true);
expect(alignment.detailAfterAnalysis).toBe(true);
expect(alignment.chartHeight).toBeGreaterThanOrEqual(340);
expect(alignment.guideRowHeight).toBeLessThanOrEqual(52);
expect(alignment.guideIsWhite).toBe(true);
expect(alignment.sameType).toBe(true);
expect(alignment.sameBaseline).toBe(true);
expect(alignment.noStatusOffset).toBe(true);
expect(alignment.rangeAligned).toBe(true);
const pageFrames = {};
for (const [view, headSelector] of [
@@ -946,6 +972,29 @@ test("market ladder transfers tier bands, sorting and structural insights", asyn
await page.locator('[data-ladder-level="2"]').click();
await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-stock")).toHaveCount(8);
await expect(page.locator("#ladderBoard .market-ladder-tier").nth(3).locator(".market-ladder-more")).toContainText("展开剩余 1 只");
const ladderOverflow = await page.evaluate(() => {
const group = state.dashboard.ladders.find((item) => item.level === 2);
group.stocks = Array.from({ length: 48 }, (_, index) => ({
code: `001${String(index).padStart(3, "0")}`,
name: `二板扩展${index + 1}`,
sector: "电网设备",
first_time: "09:30:00",
open_times: index % 4,
amount_billion: 1.2,
}));
group.count = group.stocks.length;
state.expandedLadderLevels.add(2);
renderLadderBoard(state.dashboard.ladders);
const main = document.querySelector(".app-main");
return {
clientHeight: main.clientHeight,
scrollHeight: main.scrollHeight,
overflowY: getComputedStyle(main).overflowY,
};
});
expect(ladderOverflow.overflowY).toBe("auto");
expect(ladderOverflow.scrollHeight).toBeGreaterThan(ladderOverflow.clientHeight);
});
test("sector rotation transfers the nine-day matrix, tracking and sortable detail", async ({ page }) => {
@@ -1159,7 +1208,20 @@ test("theme library preserves the full master-detail workflow in its redesigned
await expect(page.locator("#themeDetailMetrics > div")).toHaveCount(5);
await expect(page.locator("#themeMemberCount")).toHaveText("有行情 1 / 1");
await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveAttribute("aria-pressed", "true");
expect(await page.locator("#themeDetailChart").evaluate((canvas) => canvas.toDataURL().length)).toBeGreaterThan(100);
await expect(page.locator("#themeDetailChart")).toHaveCount(0);
const themePreviewRequest = page.waitForRequest((request) => request.url().includes("/api/search/detail?") && request.url().includes("type=theme"));
await page.locator(".market-preview-trigger").hover();
await themePreviewRequest;
await expect(page.locator("#stockPreview")).toBeVisible();
await expect(page.locator("#stockPreviewName")).toHaveText("人工智能");
await expect(page.locator("#stockPreviewSource")).toHaveText("日 K 行情 · 2 个交易日");
const intradayRequest = page.waitForRequest((request) => request.url().includes("/api/chart/intraday?") && request.url().includes("type=theme"));
await page.locator('[data-preview-chart="intraday"]').click();
const requestedIntraday = new URL((await intradayRequest).url());
expect(requestedIntraday.searchParams.get("id")).toBe("885728.TI");
await expect(page.locator("#stockPreviewSource")).toHaveText("最新分时 · 1分钟");
await page.locator("#closeStockPreview").click();
await page.locator("#themeSearch").fill("不存在的题材");
await expect(page.locator("#themeDirectory [data-theme-code]")).toHaveCount(0);
@@ -1507,6 +1569,28 @@ test("stock hover preview ignores the selected historical date", async ({ page }
expect(canvasColors).toBeGreaterThan(4);
});
test("stock hover preview loading state follows the dark chart theme", async ({ page }) => {
await mockApplication(page, session("user", true), { previewDelay: 500 });
await page.goto("/index.html");
await page.evaluate(() => {
document.documentElement.dataset.theme = "dark";
showStockPreview("002141", document.querySelector("#globalSearchButton"));
});
const loading = page.locator("#stockPreviewLoading");
await expect(loading).toBeVisible();
await expect(page.locator('[data-preview-chart="daily"]')).toHaveClass(/active/);
const colors = await page.evaluate(() => ({
overlay: getComputedStyle(document.querySelector("#stockPreviewLoading")).backgroundColor,
chart: getComputedStyle(document.documentElement).getPropertyValue("--chart-background").trim(),
pixel: Array.from(
document.querySelector("#stockPreviewChart").getContext("2d").getImageData(10, 10, 1, 1).data,
),
}));
expect(colors.overlay).not.toBe("rgb(255, 255, 255)");
expect(colors.chart).toBe("#181b1e");
expect(colors.pixel.slice(0, 3)).toEqual([24, 27, 30]);
});
test("rising candle body stays hollow and its wick stops at both edges", async ({ page }) => {
await mockApplication(page, session("user", true));
await page.goto("/index.html");
@@ -1726,6 +1810,7 @@ test("mobile shell stays within the viewport", async ({ page }) => {
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(1);
await expect(page.locator("#globalSearchButton")).toBeVisible();
await expect(page.locator('[data-view="limitPool"]')).toHaveClass(/mobile-active/);
const mobileShell = await page.evaluate(() => {
const header = document.querySelector(".topbar").getBoundingClientRect();
const main = document.querySelector(".app-main").getBoundingClientRect();
@@ -1848,6 +1933,24 @@ test("new review workflows render account-scoped records", async ({ page }) => {
await page.locator("#reviewHistoryToggle").click();
await expect(page.locator("#reviewHistoryPanel")).toBeVisible();
await expect(page.locator("#reviewHistoryToggle")).toHaveAttribute("aria-expanded", "true");
const reviewHistoryOverflow = await page.evaluate(() => {
const history = document.querySelector("#notesHistory");
const seed = history.querySelector(".note-row");
for (let index = 0; index < 18; index += 1) history.appendChild(seed.cloneNode(true));
const main = document.querySelector(".app-main");
return {
mainClientHeight: main.clientHeight,
mainScrollHeight: main.scrollHeight,
mainOverflowY: getComputedStyle(main).overflowY,
historyClientHeight: history.clientHeight,
historyScrollHeight: history.scrollHeight,
historyOverflowY: getComputedStyle(history).overflowY,
};
});
expect(reviewHistoryOverflow.mainOverflowY).toBe("auto");
expect(reviewHistoryOverflow.mainScrollHeight).toBeGreaterThan(reviewHistoryOverflow.mainClientHeight);
expect(reviewHistoryOverflow.historyOverflowY).toBe("auto");
expect(reviewHistoryOverflow.historyScrollHeight).toBeGreaterThan(reviewHistoryOverflow.historyClientHeight);
await expect(page.locator("#tradeLogTableBody tr")).toHaveCount(1);
const tradeScroll = await page.evaluate(() => {
const seed = state.tradeEntries[0];
@@ -2447,15 +2550,27 @@ test("mentor pins, custom order and streamed replies work together", async ({ pa
await page.locator("#themeToggle").click();
const darkMessageStyle = await answer.evaluate((element) => {
const style = getComputedStyle(element);
const content = element.querySelector(".mentor-message-content");
const heading = element.querySelector(".mentor-answer-heading");
const label = element.querySelector(".mentor-message-label");
const meta = element.querySelector("small");
return {
background: style.backgroundColor,
border: style.borderTopColor,
shadow: style.boxShadow,
contentColor: getComputedStyle(content).color,
headingColor: getComputedStyle(heading).color,
labelColor: getComputedStyle(label).color,
metaColor: getComputedStyle(meta).color,
};
});
expect(darkMessageStyle.background).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.border).not.toBe("rgb(255, 255, 255)");
expect(darkMessageStyle.shadow).toBe("none");
expect(darkMessageStyle.contentColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.headingColor).toBe("rgb(232, 234, 237)");
expect(darkMessageStyle.labelColor).toBe("rgb(127, 137, 147)");
expect(darkMessageStyle.metaColor).toBe("rgb(127, 137, 147)");
});
test("mobile mentor directory opens as a searchable selector and hides private mentors", async ({ page }) => {