test: add regression and browser contracts
This commit is contained in:
Generated
+76
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"name": "xiaobai-review-web",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "xiaobai-review-web",
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.54.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.61.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.61.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
|
||||||
|
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "xiaobai-review-web",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test:e2e": "playwright test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.54.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const { defineConfig } = require("@playwright/test");
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
timeout: 30_000,
|
||||||
|
fullyParallel: false,
|
||||||
|
reporter: "line",
|
||||||
|
use: {
|
||||||
|
baseURL: "http://127.0.0.1:8876",
|
||||||
|
channel: "msedge",
|
||||||
|
headless: true,
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "python -m http.server 8876 --bind 127.0.0.1 --directory static",
|
||||||
|
url: "http://127.0.0.1:8876/index.html",
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 15_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
const { test, expect } = require("@playwright/test");
|
||||||
|
|
||||||
|
const dashboard = {
|
||||||
|
meta: {
|
||||||
|
trade_date: "2026-07-22",
|
||||||
|
requested_date: "2026-07-22",
|
||||||
|
source: "tushare",
|
||||||
|
realtime: false,
|
||||||
|
cached: true,
|
||||||
|
market_status: "closed",
|
||||||
|
updated_at: "2026-07-22T15:00:00+08:00",
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
up_count: 2100,
|
||||||
|
down_count: 2800,
|
||||||
|
limit_up_count: 42,
|
||||||
|
limit_down_count: 8,
|
||||||
|
broken_count: 17,
|
||||||
|
seal_rate: 71.2,
|
||||||
|
amount_billion: 12600,
|
||||||
|
sentiment_score: 48,
|
||||||
|
},
|
||||||
|
limits: [],
|
||||||
|
broken: [],
|
||||||
|
down_limits: [],
|
||||||
|
yesterday_limits: [],
|
||||||
|
limit_performance: [],
|
||||||
|
ladders: [],
|
||||||
|
sectors: [],
|
||||||
|
sector_rotation: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
function session(role = "admin", subscribed = true) {
|
||||||
|
return {
|
||||||
|
authenticated: true,
|
||||||
|
csrf_token: "test-csrf",
|
||||||
|
user: {
|
||||||
|
id: role === "admin" ? 1 : 2,
|
||||||
|
username: role === "admin" ? "admin_user" : "normal_user",
|
||||||
|
role,
|
||||||
|
membership: {
|
||||||
|
active: role === "admin" || subscribed,
|
||||||
|
subscribed,
|
||||||
|
is_admin: role === "admin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mockApplication(page, authSession = session()) {
|
||||||
|
await page.route("**/api/**", async (route) => {
|
||||||
|
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/watchlist" || url.pathname === "/api/notes") payload = { items: [] };
|
||||||
|
else if (url.pathname === "/api/search") payload = { groups: { stocks: [], sectors: [], themes: [], indices: [] } };
|
||||||
|
else if (url.pathname === "/api/dragon-tiger") {
|
||||||
|
payload = {
|
||||||
|
meta: { trade_date: "2026-07-22", requested_date: "2026-07-22", status: "empty", source: "tushare" },
|
||||||
|
summary: {},
|
||||||
|
traders: [],
|
||||||
|
unclassified_seats: [],
|
||||||
|
};
|
||||||
|
} else if (url.pathname === "/api/sentiment/history") payload = { rows: [], components: [] };
|
||||||
|
else if (url.pathname === "/api/rotation/history") payload = { days: [], rows: [], sectors: [] };
|
||||||
|
else if (url.pathname === "/api/screener/setup") {
|
||||||
|
payload = {
|
||||||
|
trade_date: "20260722",
|
||||||
|
regime: { id: "repair", label: "修复", confidence: 70, reason: "测试", evidence: [] },
|
||||||
|
regimes: [{ id: "repair", label: "修复" }],
|
||||||
|
factor_data: { ready: false, date_count: 0 },
|
||||||
|
llm: { configured: true },
|
||||||
|
strategies: [],
|
||||||
|
};
|
||||||
|
} else if (url.pathname === "/api/mentors/setup") payload = { trade_date: "20260722", mentors: [] };
|
||||||
|
else if (url.pathname === "/api/heaven/setup") {
|
||||||
|
await route.fulfill({ status: 503, contentType: "application/json", body: JSON.stringify({ error: "测试环境不加载问天数据" }) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(payload) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("admin shell opens every primary workspace and global search", async ({ page }) => {
|
||||||
|
await mockApplication(page);
|
||||||
|
await page.goto("/index.html");
|
||||||
|
await expect(page.locator("#authGate")).toBeHidden();
|
||||||
|
await expect(page.locator("#settingsButton")).toBeVisible();
|
||||||
|
await expect(page.locator("#syncButton")).toBeVisible();
|
||||||
|
|
||||||
|
const views = [
|
||||||
|
"sentimentCycleView", "limitPool", "brokenView", "downView", "yesterdayView",
|
||||||
|
"performanceView", "ladderView", "rotationView", "dragonView", "screenerView",
|
||||||
|
"mentorView", "heavenView", "reviewWorkspaceView",
|
||||||
|
];
|
||||||
|
for (const view of views) {
|
||||||
|
await page.locator(`[data-view="${view}"]`).first().click();
|
||||||
|
await expect(page.locator(`#${view}`)).toHaveClass(/active-view/);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+K");
|
||||||
|
await expect(page.locator("#globalSearchDialog")).toBeVisible();
|
||||||
|
await expect(page.locator("#globalSearchInput")).toBeFocused();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("regular account cannot see admin controls and member features are gated", async ({ page }) => {
|
||||||
|
await mockApplication(page, session("user", false));
|
||||||
|
await page.goto("/index.html");
|
||||||
|
await expect(page.locator("#settingsButton")).toBeHidden();
|
||||||
|
await expect(page.locator("#syncButton")).toBeHidden();
|
||||||
|
await expect(page.locator("#accountVipLabel")).toHaveText("非会员");
|
||||||
|
await page.locator('[data-view="screenerView"]').first().click();
|
||||||
|
await expect(page.locator("#screenerView .member-gate")).toBeVisible();
|
||||||
|
await expect(page.locator("#screenerRunButton")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mobile shell stays within the viewport", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 375, height: 812 });
|
||||||
|
await mockApplication(page, session("user", true));
|
||||||
|
await page.goto("/index.html");
|
||||||
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||||
|
expect(overflow).toBeLessThanOrEqual(1);
|
||||||
|
await expect(page.locator("#globalSearchButton")).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).resolve().parents[1] / "static"
|
||||||
|
|
||||||
|
|
||||||
|
class IdCollector(HTMLParser):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.ids: list[str] = []
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
self.ids.extend(value for key, value in attrs if key == "id" and value)
|
||||||
|
|
||||||
|
|
||||||
|
class FrontendContractTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls.html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
||||||
|
cls.script = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
|
||||||
|
collector = IdCollector()
|
||||||
|
collector.feed(cls.html)
|
||||||
|
cls.ids = collector.ids
|
||||||
|
|
||||||
|
def test_html_ids_are_unique(self):
|
||||||
|
duplicates = sorted({item for item in self.ids if self.ids.count(item) > 1})
|
||||||
|
self.assertEqual(duplicates, [])
|
||||||
|
|
||||||
|
def test_literal_id_selectors_exist_in_html(self):
|
||||||
|
selectors = set(re.findall(r'querySelector\("#([A-Za-z][A-Za-z0-9_-]*)"\)', self.script))
|
||||||
|
selectors.update(re.findall(r'getElementById\("([A-Za-z][A-Za-z0-9_-]*)"\)', self.script))
|
||||||
|
selectors.update(re.findall(r'setText\("([A-Za-z][A-Za-z0-9_-]*)"', self.script))
|
||||||
|
missing = sorted(selectors - set(self.ids))
|
||||||
|
self.assertEqual(missing, [])
|
||||||
|
|
||||||
|
def test_all_primary_views_have_navigation_entries(self):
|
||||||
|
views = set(re.findall(r'id="([A-Za-z][A-Za-z0-9_-]*View|limitPool)" class="workspace-view', self.html))
|
||||||
|
navigation = set(re.findall(r'data-view="([A-Za-z][A-Za-z0-9_-]*)"', self.html))
|
||||||
|
self.assertEqual(views, navigation)
|
||||||
|
self.assertEqual(len(views), 13)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user