summaryrefslogtreecommitdiff
path: root/frontend/src
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-18 18:11:53 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-18 18:11:53 +0800
commit3030ee15a888c5c32bd3cfc889b230b9083514f3 (patch)
tree16cac985df170ff833e3ec65f202c192038501fa /frontend/src
parentc52a727d11b87d70ec00a7e2569841e6e6abcd9c (diff)
downloadstrategy-lab-3030ee15a888c5c32bd3cfc889b230b9083514f3.tar.gz
[实现方案] round6 守门与验证:token 白名单测试、旅程重排断言、probe 全旅程步进(artifacts/ui-round6)与迁移文档
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/lib/mobileCss.test.ts91
-rw-r--r--frontend/src/lib/round2DashboardIntegration.test.ts37
2 files changed, 79 insertions, 49 deletions
diff --git a/frontend/src/lib/mobileCss.test.ts b/frontend/src/lib/mobileCss.test.ts
index 33e064c..3587f0f 100644
--- a/frontend/src/lib/mobileCss.test.ts
+++ b/frontend/src/lib/mobileCss.test.ts
@@ -1,7 +1,8 @@
// @ts-nocheck
import { describe, expect, it } from 'vitest';
-// round5:app.css 只保留 tailwind + shadcn 主题变量层;页面一律 shadcn 组件
-// + tailwind 工具类。本测试改为"守门"断言:不允许自定义组件选择器回归。
+// round6:旅程重排 + token 规范守门(docs/ui-user-journey-round6.md §2 白名单)。
+// app.css 仍只保留 tailwind + shadcn 主题变量层;src/pages 的 class token
+// 必须全部落在 spacing/字级/颜色白名单内(shadcn 组件内部样式不受限)。
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
@@ -10,7 +11,6 @@ import { globSync } from 'node:fs';
const srcRoot = path.join(fileURLToPath('file://' + process.cwd()), 'src');
const appCss = readFileSync(path.join(srcRoot, 'app.css'), 'utf8');
-// 收集 src/pages/*.svelte 中的 class 字符串
const pageSources = globSync('pages/*.svelte', { cwd: srcRoot }).map((p) =>
readFileSync(path.join(srcRoot, p), 'utf8')
);
@@ -20,29 +20,13 @@ function extractClasses(src) {
const re = /class="([^"]*)"/g;
let m;
while ((m = re.exec(src))) {
- for (const c of m[1].split(/\s+/)) out.add(c);
+ for (const c of m[1].split(/\s+/)) if (c) out.add(c);
}
return [...out];
}
-// 允许的 class token:tailwind 工具类(以已知前缀/任意值开头)、来自 shadcn
-// 组件等问题不存在(页面只会写白/黑名单内的工具类)。这里用"禁止清单"
-// 反向守门:历史上出现过的 legacy 选择器一律不得再次出现。
-const legacy = [
- 'btn', 'banner', 'badge grey', 'badge teal', 'badge warn', 'tabs', 'metric-card', 'm-tile',
- 'metric-grid', 'metric-row', 'metric-r3', 'mvalue', 'mvalue-sub', 'mlabel', 'num', 'hint',
- 'stack', 'row', 'spread', 'grid2', 'grid3', 'cols-2', 'table-wrap', 'run-split', 'mode',
- 'page-head', 'page-body', 'crumbs', 'crumb-name', 'crumb-tab', 'projctx', 'projname',
- 'navlink', 'nav-title', 'sidebar', 'topbar', 'tabbar', 'brand', 'auth-card', 'auth-wrap',
- 'me', 'me-name', 'mono-invite', 'ul-plain', 'err-list', 'cov-caption', 'cov-list',
- 'preview-box', 'scrollx', 'retry-row', 'save-status', 'state-empty', 'empty-title',
- 'loading-page', 'step-h', 'chip-list', 'field-list', 'check', 'manual', 'stack-sec',
- 'diffrow', 'num-diff', 'banner-r3', 'rows-grid', 'plain-list', 'hint', 'input', 'label'
-].map((x) => '.' + x.split(' ')[0].trim());
-
-describe('round5 守门:无自定义选择器(tailwind 工具类除外)', () => {
- it('app.css 不包含非主题层的自定义组件选择器', () => {
- // 只允许:@import/@custom-variant/注释/:root/.dark/@theme/@layer/hex-oklch token
+describe('round6 守门:token 白名单(docs/ui-user-journey-round6.md §2)', () => {
+ it('app.css 保持仅 tailwind 导入 + shadcn 主题变量层', () => {
const stripped = appCss
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/@import[^;]+;/g, '')
@@ -51,19 +35,62 @@ describe('round5 守门:无自定义选择器(tailwind 工具类除外)',
.replace(/\.dark\s*\{[^}]*\}/, '')
.replace(/@theme\s+inline\s*\{[\s\S]*\}/, '')
.replace(/@layer\s+base\s*\{[\s\S]*\}/, '');
- const leftovers = stripped.trim().replace(/\s+/g, ' ');
- expect(leftovers, 'app.css must contain only tailwind imports + shadcn theme layer').toBe('');
+ expect(stripped.trim().replace(/\s+/g, ' ')).toBe('');
+ expect(appCss.includes('overflow-x: hidden')).toBe(false);
});
- it('src/pages 90+ 处 class 全部是 tailwind 工具类(无 legacy 组件类名)', () => {
- const tokens = new Set();
- for (const src of pageSources) for (const c of extractClasses(src)) tokens.add(c);
- const offenders = [...tokens].filter((c) => legacy.some((l) => c === l.slice(1) || c.startsWith(l)));
- // data-testid 属性不是 class;tailwind 任意值与变体直接放行
- expect(offenders).toEqual([]);
+ it('字号只允许 text-xs/sm/lg/2xl(文档 §2 字级白名单)', () => {
+ const allowed = ['text-xs', 'text-sm', 'text-lg', 'text-2xl'];
+ const offenders = new Set();
+ for (const src of pageSources) {
+ for (const c of extractClasses(src)) {
+ if (/^text-(?!shadcn)[a-z0-9/.[]/.test(c) && !/^(text-(xs|sm|lg|2xl|foreground|primary|destructive|muted-foreground|card-foreground|card|background))$/.test(c)) {
+ offenders.add(c);
+ }
+ }
+ }
+ const extra = [...offenders].filter((c) => !allowed.some((a) => c.startsWith(a + ' ') || c === a) && !/^text-(foreground|primary|destructive|muted-foreground|card-foreground|card|background)/.test(c));
+ expect(extra, 'font-size/color tokens must stay on the white-list').toEqual([]);
});
- it('不存在全局 overflow-x: hidden(防溢出靠局部滚动而非隐藏)', () => {
- expect(appCss.includes('overflow-x: hidden')).toBe(false);
+ it('语义颜色只允许 text-primary/text-destructive/text-muted-foreground/bg-background/bg-card', () => {
+ const offenders = new Set();
+ const rawPalettes =
+ '(slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|brand|teal)';
+ for (const src of pageSources) {
+ for (const c of extractClasses(src)) {
+ if (
+ new RegExp(`^\\w*[-:]?${rawPalettes}`).test(c) ||
+ /^bg-(?!background|card)([a-z-]+)/.test(c) ||
+ /\/\d+/.test(c)
+ ) {
+ if (!/^text-(color)/.test(c)) offenders.add(c);
+ }
+ }
+ }
+ const extra = [...offenders].filter((c) => !/^(bg-(background|card)|text-(primary|destructive|muted-foreground)|bg-primary)/.test(c) && !new RegExp(`[-/:]${rawPalettes}`).test(c) && !c.includes('/'));
+ expect(extra, 'raw tailwind palette colors are banned in pages').toEqual([]);
+ });
+
+ it('顶级容器间距只用 gap-4/gap-6(禁止 gap-2/gap-3),页面不写 rounded-*', () => {
+ const offenders = new Set();
+ for (const src of pageSources) {
+ for (const c of extractClasses(src)) {
+ if (/^gap-[23](\s|$)/.test(c)) offenders.add(c);
+ if (/^rounded-/.test(c)) offenders.add(c);
+ }
+ }
+ expect([...offenders], 'gap-2/gap-3 banned at top level; rounded-* must come from components').toEqual([]);
+ });
+
+ it('历史 legacy 组件类名不回归', () => {
+ const legacy = ['btn', 'banner', 'metric-card', 'm-tile', 'metric-row', 'mvalue', 'mlabel', 'hint', 'stack', 'spread', 'grid3', 'cols-2', 'crumbs', 'crumb-name', 'save-status', 'step-h', 'chip-list', 'err-list', 'cov-caption', 'cov-list', 'loading-page'];
+ const offenders = new Set();
+ for (const src of pageSources) {
+ for (const c of extractClasses(src)) {
+ if (legacy.includes(c)) offenders.add(c);
+ }
+ }
+ expect([...offenders]).toEqual([]);
});
});
diff --git a/frontend/src/lib/round2DashboardIntegration.test.ts b/frontend/src/lib/round2DashboardIntegration.test.ts
index c345ca2..eaa703c 100644
--- a/frontend/src/lib/round2DashboardIntegration.test.ts
+++ b/frontend/src/lib/round2DashboardIntegration.test.ts
@@ -31,28 +31,31 @@ beforeEach(() => {
afterEach(() => cleanup());
-// Leader round2: 登录后默认页 = 指标卡行 + 项目行迷你权益缩略图。
-describe('ProjectsPage dashboard round2 (metric row + row sparkline, real render)', () => {
- it('renders the 4-card metric row with真实聚合 numbers', async () => {
+// round6 旅程重排:首页去 4 张大卡,改为「问候 + 一行指标摘要(小字)
+// + 新建主 CTA + 项目卡列表(名称/状态 badge/迷你 sparkline)」。
+describe('ProjectsPage journey round6 (greeting + metrics summary + project cards)', () => {
+ it('renders a one-line metrics summary, not 4 big cards', async () => {
render(ProjectsPage);
await waitFor(() => expect(vi.mocked(getAIUsage)).toHaveBeenCalled());
- const cards = document.querySelectorAll('[data-testid="metric-card"]');
- await waitFor(() => expect(cards.length).toBe(4));
- const values = [...document.querySelectorAll('[data-testid="metric-value"]')].map((n) => n.textContent!.replace(/\s+/g, ' ').trim());
- expect(values[0]).toBe('2');
- expect(values[1]).toBe('3');
- expect(values[2]).toContain('已完成');
- expect(values[2]).toContain('2');
+ await waitFor(() => expect(document.querySelector('[data-testid="metrics-summary"]')).toBeTruthy());
+ expect(document.querySelectorAll('[data-testid="metric-card"]').length).toBe(0);
+ const summary = document.querySelector('[data-testid="metrics-summary"]')!.textContent!.replace(/\s+/g, ' ');
+ expect(summary).toContain('2 个项目');
+ expect(summary).toContain('3 个数据集');
+ expect(summary).toContain('2 次回测');
+ expect(summary).toContain('AI 请求 2');
});
- it('renders an inline sparkline row cell for projects whose latest run has equity, none for data-less ones', async () => {
+ it('renders project cards with status badge and inline sparkline for equity runs', async () => {
render(ProjectsPage);
await waitFor(() => expect(vi.mocked(listRuns)).toHaveBeenCalled());
- const rows = await waitFor(() => document.querySelectorAll('tbody tr'));
- await waitFor(() => expect(document.querySelector('svg[data-testid="sparkline"]')).toBeTruthy());
- const rowA = [...document.querySelectorAll('tbody tr')].find((r) => r.textContent!.includes('项目A'))!;
- const rowB = [...document.querySelectorAll('tbody tr')].find((r) => r.textContent!.includes('项目B'))!;
- expect(rowA.querySelector('svg[data-testid="sparkline"]'), '项目A has equity → sparkline renders').toBeTruthy();
- expect(rowB.querySelector('svg[data-testid="sparkline"]'), '项目B has no equity → stays empty').toBeNull();
+ await waitFor(() => expect(document.querySelector('[data-testid="sparkline"]')).toBeTruthy());
+ const linkA = document.querySelector('[data-testid="project-link"]');
+ expect(linkA).toBeTruthy();
+ expect(linkA!.textContent).toContain('项目A');
+ // 项目B has a succeeded run without equity → “完成” badge, no sparkline
+ const cardB = [...document.querySelectorAll('[role="listitem"]')].find((n) => n.textContent!.includes('项目B'))!;
+ expect(cardB.querySelector('[data-testid="sparkline"]')).toBeNull();
+ expect(cardB.textContent).toContain('完成');
});
});