summaryrefslogtreecommitdiff
path: root/frontend/src/lib
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-17 14:32:37 +0800
commit5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch)
tree948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/lib
downloadstrategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/lib')
-rw-r--r--frontend/src/lib/__tests__/state.test.ts21
-rw-r--r--frontend/src/lib/api.test.ts94
-rw-r--r--frontend/src/lib/api.ts70
-rw-r--r--frontend/src/lib/chartResize.test.ts131
-rw-r--r--frontend/src/lib/client.ts118
-rw-r--r--frontend/src/lib/coverage.test.ts43
-rw-r--r--frontend/src/lib/coverage.ts52
-rw-r--r--frontend/src/lib/draftGuard.test.ts38
-rw-r--r--frontend/src/lib/draftGuard.ts46
-rw-r--r--frontend/src/lib/format.ts118
-rw-r--r--frontend/src/lib/instrumentSearch.test.ts121
-rw-r--r--frontend/src/lib/instrumentSearch.ts120
-rw-r--r--frontend/src/lib/layout.test.ts18
-rw-r--r--frontend/src/lib/listsStore.svelte.ts92
-rw-r--r--frontend/src/lib/mobileCss.test.ts63
-rw-r--r--frontend/src/lib/resultsAccounting.test.ts99
-rw-r--r--frontend/src/lib/router.test.ts51
-rw-r--r--frontend/src/lib/router.ts43
-rw-r--r--frontend/src/lib/session.svelte.ts45
-rw-r--r--frontend/src/lib/sidebarCurrentProject.test.ts186
-rw-r--r--frontend/src/lib/state.ts15
-rw-r--r--frontend/src/lib/strategyTab.test.ts95
-rw-r--r--frontend/src/lib/types.ts244
-rw-r--r--frontend/src/lib/wizardError.test.ts18
24 files changed, 1941 insertions, 0 deletions
diff --git a/frontend/src/lib/__tests__/state.test.ts b/frontend/src/lib/__tests__/state.test.ts
new file mode 100644
index 0000000..245c38c
--- /dev/null
+++ b/frontend/src/lib/__tests__/state.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it, vi, afterEach } from 'vitest';
+import { extractStatusMessage } from '../state';
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe('extractStatusMessage', () => {
+ it('prefers ApiError.code-mapped Chinese text', () => {
+ const msg = extractStatusMessage({
+ status: 409,
+ code: 'stale_generation',
+ message: '草稿已被其他修改更新'
+ });
+ expect(msg).toContain('409');
+ expect(msg).toContain('草稿已被其他修改更新');
+ });
+
+ it('handles unknown errors without leaking internals', () => {
+ const msg = extractStatusMessage({ message: 'boom' });
+ expect(msg).toContain('出错');
+ });
+});
diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts
new file mode 100644
index 0000000..b66d9ca
--- /dev/null
+++ b/frontend/src/lib/api.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it, vi, afterEach } from 'vitest';
+import { apiFetch, ApiError } from './api';
+import type { ApiErrorBody } from './api';
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe('apiFetch', () => {
+ it('parses JSON success responses', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response('{"ok":1}', { status: 200, headers: { 'content-type': 'application/json' } }))
+ );
+ const res = await apiFetch<void, { ok: number }>('/api/health');
+ expect(res).toEqual({ ok: 1 });
+ });
+
+ it('returns undefined for empty body', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(null, { status: 204 }))
+ );
+ const res = await apiFetch<void, undefined>('/api/auth/logout', { method: 'POST' });
+ expect(res).toBeUndefined();
+ });
+
+ it('raises ApiError with structured error body', async () => {
+ const body: ApiErrorBody = {
+ error: { code: 'stale_generation', message: '草稿已被其他修改更新', details: { expected: 'g1' } }
+ };
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response(JSON.stringify(body), { status: 409, headers: { 'content-type': 'application/json' } }))
+ );
+ const err = await apiFetch<void>('/api/never').catch((e) => e);
+ expect(err).toBeInstanceOf(ApiError);
+ expect(err.status).toBe(409);
+ expect(err.code).toBe('stale_generation');
+ expect(err.message).toBe('草稿已被其他修改更新');
+ expect(err.details).toEqual({ expected: 'g1' });
+ });
+
+ it('raises ApiError for non-JSON error body', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response('oops', { status: 500 }))
+ );
+ const err = await apiFetch<void>('/api/never').catch((e) => e);
+ expect(err).toBeInstanceOf(ApiError);
+ expect(err.status).toBe(500);
+ expect(err.code).toBe('server_error');
+ });
+
+ it('raises network ApiError', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => {
+ throw new TypeError('failed');
+ })
+ );
+ const err = await apiFetch<void>('/api/never').catch((e) => e);
+ expect(err).toBeInstanceOf(ApiError);
+ expect(err.status).toBe(0);
+ expect(err.code).toBe('network');
+ });
+
+ it('sends JSON content type for body requests and does not for bodyless', async () => {
+ const f = vi.fn(async () => new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }));
+ vi.stubGlobal('fetch', f);
+ await apiFetch('/api/projects', { method: 'POST', body: { name: 'x' } });
+ expect(f).toHaveBeenCalledWith(
+ '/api/projects',
+ expect.objectContaining({
+ method: 'POST',
+ headers: expect.objectContaining({ 'content-type': 'application/json' }),
+ credentials: 'same-origin'
+ })
+ );
+ await apiFetch('/api/projects');
+ expect(f).toHaveBeenLastCalledWith(
+ '/api/projects',
+ expect.not.objectContaining({ headers: expect.objectContaining({ 'content-type': 'application/json' }) })
+ );
+ });
+
+ it('rejects a 200 without JSON body per API contract', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response('<html>oops</html>', { status: 200, headers: { 'content-type': 'text/html' } }))
+ );
+ const err = await apiFetch<void>('/api/projects').catch((e) => e);
+ expect(err).toBeInstanceOf(ApiError);
+ expect(err.code).toBe('bad_response');
+ });
+});
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
new file mode 100644
index 0000000..923a6e8
--- /dev/null
+++ b/frontend/src/lib/api.ts
@@ -0,0 +1,70 @@
+export interface ApiErrorShape {
+ code: string;
+ message: string;
+ details?: unknown;
+}
+
+export interface ApiErrorBody {
+ error: ApiErrorShape;
+}
+
+export class ApiError extends Error {
+ status: number;
+ code: string;
+ details?: unknown;
+
+ constructor(status: number, code: string, message: string, details?: unknown) {
+ super(message);
+ this.name = 'ApiError';
+ this.status = status;
+ this.code = code;
+ this.details = details;
+ }
+}
+
+interface Options {
+ method?: string;
+ body?: unknown;
+ signal?: AbortSignal;
+}
+
+export async function apiFetch<T = unknown, R = T>(url: string, options: Options = {}): Promise<R> {
+ const headers: Record<string, string> = { accept: 'application/json' };
+ let bodyStr: string | undefined;
+ if (options.body !== undefined) {
+ headers['content-type'] = 'application/json';
+ bodyStr = JSON.stringify(options.body);
+ }
+ let res: Response;
+ try {
+ res = await fetch(url, {
+ method: options.method ?? 'GET',
+ headers,
+ body: bodyStr,
+ credentials: 'same-origin',
+ signal: options.signal
+ });
+ } catch (e) {
+ if (e instanceof DOMException && e.name === 'AbortError') throw e;
+ throw new ApiError(0, 'network', '网络请求失败,请确认服务已启动后重试');
+ }
+ if (res.status === 204) {
+ return undefined as R;
+ }
+ const contentType = res.headers.get('content-type') ?? '';
+ if (!contentType.includes('application/json')) {
+ if (!res.ok) {
+ throw new ApiError(res.status, 'server_error', `服务返回错误(HTTP ${res.status})`);
+ }
+ throw new ApiError(res.status, 'bad_response', '服务返回了非预期的非 JSON 响应(接口约定为 JSON)');
+ }
+ const parsed: unknown = await res.json().catch(() => null);
+ if (!res.ok) {
+ const errBody = parsed as ApiErrorBody | null;
+ if (errBody?.error) {
+ throw new ApiError(res.status, errBody.error.code, errBody.error.message, errBody.error.details);
+ }
+ throw new ApiError(res.status, 'server_error', `服务返回错误(HTTP ${res.status})`);
+ }
+ return parsed as R;
+}
diff --git a/frontend/src/lib/chartResize.test.ts b/frontend/src/lib/chartResize.test.ts
new file mode 100644
index 0000000..95beaad
--- /dev/null
+++ b/frontend/src/lib/chartResize.test.ts
@@ -0,0 +1,131 @@
+// @ts-nocheck
+import { describe, expect, it, beforeEach, vi } from 'vitest';
+import { mount, unmount } from 'svelte';
+import Chart from '../components/Chart.svelte';
+
+// Bounded chart-overflow regression (parent live QA: viewport 375,
+// document.scrollWidth 1133 after desktop->mobile resize):
+//
+// echarts.init previously received an explicit width, and ECharts KEEPS the
+// explicitly passed size on chart.resize(); a desktop->mobile viewport change
+// therefore left the rendered SVG at the desktop width, overflowing the page.
+// The ResizeObserver handler must now re-measure the host and pass the
+// measured size into chart.resize() so the SVG actually shrinks with the
+// viewport. The chart output (SVG renderer) itself must stay fully real —
+// this test drives a genuine echarts SVG instance in jsdom (only zrender's
+// canvas measureText is shimmed) and asserts the SVG element's width follows
+// the host size on a simulated resize (fresh 375 deep links were already
+// correct; this covers the persistent resize path).
+
+interface CapturedRO {
+ callback: () => void;
+ el: Element;
+}
+let captured: CapturedRO[] = [];
+
+function makeResizeObserverCtor() {
+ return class FakeResizeObserver {
+ cb: () => void;
+ el: Element | null = null;
+ constructor(cb: ResizeObserverCallback) {
+ this.cb = cb;
+ captured.push({ callback: () => cb([], this as unknown as ResizeObserver), el: null as never });
+ }
+ observe(el: Element) {
+ const capture = captured[captured.length - 1];
+ if (capture) capture.el = el;
+ }
+ unobserve() {}
+ disconnect() {}
+ };
+}
+
+const baseOption = {
+ grid: { top: 24, left: 60, right: 24, bottom: 54 },
+ xAxis: { type: 'category', data: ['a', 'b'] },
+ yAxis: { type: 'value' },
+ series: [{ name: 'x', type: 'line' as const, data: [1, 2], showSymbol: false }]
+};
+
+function hostSvgWidth(host: HTMLElement): number | null {
+ const svg = host.querySelector('svg');
+ if (!svg) return null;
+ const attr = svg.getAttribute('width');
+ if (attr) return parseFloat(attr);
+ const style = svg.getAttribute('style') ?? '';
+ const m = /(?:^|;)\s*width:\s*(\d+(?:\.\d+)?)px/.exec(style);
+ return m ? parseFloat(m[1]) : null;
+}
+
+describe('Chart resize follows host width (desktop→mobile overflow root cause)', () => {
+ beforeEach(() => {
+ captured = [];
+ vi.stubGlobal('ResizeObserver', makeResizeObserverCtor());
+ });
+
+ it('init uses measured/fallback size, then resizes the SVG when the host shrinks', async () => {
+ const host = document.createElement('div');
+ host.style.width = '900px';
+ document.body.appendChild(host);
+ const originalW = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth');
+ Object.defineProperty(host, 'clientWidth', { configurable: true, get() { return 900; } });
+ try {
+ const inst = mount(Chart, {
+ target: host,
+ props: { option: baseOption, height: 340, ariaLabel: '曲线' }
+ });
+ // let the lazy echarts import mount
+ await new Promise((r) => setTimeout(r, 120));
+ const ro = captured[captured.length - 1];
+ expect(ro).toBeTruthy();
+ expect(ro.el).toBe(host.firstElementChild); // observer watches the bind host
+ const inner = ro.el as HTMLElement;
+
+ const svg = inner.querySelector('svg');
+ expect(svg, 'echarts SVG renderer must be real in tests').toBeTruthy();
+ // initial size: measured/fallback (target has no clientWidth -> 640)
+ expect(parseFloat(svg!.getAttribute('width')!)).toBe(640);
+
+ // seed the chart at a "desktop" width, then shrink the host (viewport
+ // resize) and fire the observer: svg must follow, not stay sticky
+ const setW = (v: number) => Object.defineProperty(inner, 'clientWidth', { configurable: true, get() { return v; } });
+ setW(900);
+ ro.callback();
+ await new Promise((r) => setTimeout(r, 40));
+ expect(parseFloat(inner.querySelector('svg')!.getAttribute('width')!)).toBe(900);
+
+ // now the actual overflow scenario: shrink to 375-device width
+ setW(340);
+ ro.callback();
+ await new Promise((r) => setTimeout(r, 40));
+
+ const svgAfter = inner.querySelector('svg')!;
+ const wAfter = svgAfter!.getAttribute('width');
+ expect(wAfter, 'svg must shrink on resize, not keep desktop width').toBeTruthy();
+ expect(parseFloat(wAfter!)).toBe(340);
+
+ unmount(inst);
+ host.remove();
+ vi.unstubAllGlobals();
+ } finally {
+ Object.defineProperty(host, 'clientWidth', { configurable: true, get: () => 0 });
+ }
+ });
+
+ it('does not pass undefined sizes to resize when host has no layout (avoid degenerate resize)', async () => {
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const inst = mount(Chart, {
+ target: host,
+ props: { option: baseOption, height: 340, ariaLabel: '曲线' }
+ });
+ await new Promise((r) => setTimeout(r, 120));
+ const ro = captured[captured.length - 1];
+ expect(ro?.el).toBeTruthy();
+ // host with zero layout: callback must be a no-op (no degenerate 0-width resize)
+ expect(() => ro.callback()).not.toThrow();
+ unmount(inst);
+ host.remove();
+ vi.unstubAllGlobals();
+ });
+});
diff --git a/frontend/src/lib/client.ts b/frontend/src/lib/client.ts
new file mode 100644
index 0000000..4937902
--- /dev/null
+++ b/frontend/src/lib/client.ts
@@ -0,0 +1,118 @@
+// Central typed binding layer for the 策研 Strategy Lab HTTP contract.
+import { apiFetch } from './api';
+import type {
+ AIAssist,
+ AIUsage,
+ AdminUserUpdate,
+ BacktestResult,
+ Capabilities,
+ Dataset,
+ DatasetPreview,
+ DatasetRequest,
+ InstrumentSearchResponse,
+ Invitation,
+ Project,
+ Run,
+ SessionInfo,
+ User,
+ Version
+} from './types';
+
+export const getHealth = () =>
+ apiFetch<{ status: string; version: string; worker_available: boolean; ai_configured: boolean }>('/api/health');
+
+// ---- auth ----
+export const postLogin = (email: string, password: string) =>
+ apiFetch<{ user: User }>('/api/auth/login', { method: 'POST', body: { email, password } });
+export const postLogout = () => apiFetch<void>('/api/auth/logout', { method: 'POST', body: {} });
+export const getMe = () => apiFetch<{ user: User }>('/api/auth/me');
+export const postRegister = (invite_token: string, name: string, email: string, password: string) =>
+ apiFetch<{ user: User }>('/api/auth/register', { method: 'POST', body: { invite_token, name, email, password } });
+export const patchProfile = (name: string) => apiFetch<{ user: User }>('/api/auth/profile', { method: 'PATCH', body: { name } });
+export const postPassword = (current_password: string, new_password: string) =>
+ apiFetch<void>('/api/auth/password', { method: 'POST', body: { current_password, new_password } });
+export const getSessions = () => apiFetch<{ items: SessionInfo[] }>('/api/auth/sessions');
+export const deleteSession = (id: string) => apiFetch<void>(`/api/auth/sessions/${id}`, { method: 'DELETE', body: {} });
+export const postResetPassword = (token: string, new_password: string) =>
+ apiFetch<{ user?: User }>('/api/auth/reset-password', { method: 'POST', body: { token, new_password } });
+
+// ---- capabilities ----
+export const getCapabilities = () => apiFetch<Capabilities>('/api/capabilities');
+
+// ---- instruments ----
+export const searchInstruments = (q: string, signal?: AbortSignal) =>
+ apiFetch<InstrumentSearchResponse>(`/api/instruments?q=${encodeURIComponent(q)}`, { signal });
+
+// ---- projects ----
+export const listProjects = () => apiFetch<{ items: Project[] }>('/api/projects');
+export const createProject = (name: string, description?: string) =>
+ apiFetch<Project>('/api/projects', { method: 'POST', body: { name, description } });
+export const getProject = (id: string) => apiFetch<Project>(`/api/projects/${id}`);
+export const patchProject = (id: string, body: { name?: string; description?: string }) =>
+ apiFetch<Project>(`/api/projects/${id}`, { method: 'PATCH', body });
+export const putDraft = (id: string, code: string, expected_generation: number) =>
+ apiFetch<Project>(`/api/projects/${id}/draft`, { method: 'PUT', body: { code, expected_generation } });
+export const listVersions = (id: string) => apiFetch<{ items: Version[] }>(`/api/projects/${id}/versions`);
+export const postVersion = (id: string, message: string) =>
+ apiFetch<Version>(`/api/projects/${id}/versions`, { method: 'POST', body: { message } });
+export const getVersionCode = (id: string, versionId: string) => apiFetch<{ code: string }>(`/api/projects/${id}/versions/${versionId}`);
+export const postRestore = (id: string, version_id: string, expected_generation: number) =>
+ apiFetch<Project>(`/api/projects/${id}/restore`, { method: 'POST', body: { version_id, expected_generation } });
+
+// ---- datasets ----
+export const listDatasets = () => apiFetch<{ items: Dataset[] }>('/api/datasets');
+export const createDataset = (body: DatasetRequest) => apiFetch<Dataset>('/api/datasets', { method: 'POST', body });
+export const getDataset = (id: string) => apiFetch<Dataset>(`/api/datasets/${id}`);
+export const getDatasetPreview = (id: string) => apiFetch<DatasetPreview>(`/api/datasets/${id}/preview`);
+
+// ---- runs ----
+export const listRuns = (project_id?: string) =>
+ apiFetch<{ items: Run[] }>(project_id ? `/api/runs?project_id=${encodeURIComponent(project_id)}` : '/api/runs');
+export interface RunCreateBody {
+ project_id: string;
+ dataset_id: string;
+ capital: number;
+ commission: number;
+ slippage: number;
+ benchmark_symbol?: string | null;
+ parameters?: Record<string, unknown>;
+ acknowledge_warnings?: boolean;
+}
+export const createRun = (body: RunCreateBody) => apiFetch<Run>('/api/runs', { method: 'POST', body });
+export const getRun = (id: string) => apiFetch<Run>(`/api/runs/${id}`);
+export const cancelRun = (id: string) => apiFetch<Run>(`/api/runs/${id}/cancel`, { method: 'POST', body: {} });
+export const rerunRun = (id: string, use_original_data = true) =>
+ apiFetch<Run>(`/api/runs/${id}/rerun`, { method: 'POST', body: { use_original_data } });
+
+// ---- AI ----
+export const postAIAssist = (project_id: string, instruction: string, expected_generation: number) =>
+ apiFetch<AIAssist>('/api/ai/assist', { method: 'POST', body: { project_id, instruction, expected_generation } });
+export const postAIAccept = (aiId: string, expected_generation: number) =>
+ apiFetch<Project>(`/api/ai/${aiId}/accept`, { method: 'POST', body: { expected_generation } });
+export const getAIUsage = () => apiFetch<AIUsage>('/api/ai/usage');
+
+// ---- admin ----
+export const listAdminUsers = () => apiFetch<{ items: User[] }>('/api/admin/users');
+export const patchAdminUser = (id: string, body: AdminUserUpdate) =>
+ apiFetch<User>(`/api/admin/users/${id}`, { method: 'PATCH', body });
+export const postAdminUserPassword = (id: string) =>
+ apiFetch<{ reset_token: string; expires_at: string }>(`/api/admin/users/${id}/reset-password`, { method: 'POST', body: {} });
+export const createInvitation = (body: { email?: string; role?: string; expires_hours?: number }) =>
+ apiFetch<{ token: string; expires_at: string }>('/api/admin/invitations', { method: 'POST', body });
+export const listInvitations = () => apiFetch<{ items: Invitation[] }>('/api/admin/invitations');
+export const deleteInvitation = (id: string) => apiFetch<void>(`/api/admin/invitations/${id}`, { method: 'DELETE', body: {} });
+export const getAdminAudit = () =>
+ apiFetch<{ items: { actor?: string | null; action: string; target?: string | null; status: string; time: string; detail?: string | null }[] }>(
+ '/api/admin/audit'
+ );
+
+// helpers
+export async function poll<T>(fn: () => Promise<T>, until: (v: T) => boolean, intervalMs = 1500): Promise<T> {
+ for (;;) {
+ const v = await fn();
+ if (until(v)) return v;
+ await new Promise((r) => setTimeout(r, intervalMs));
+ }
+}
+
+export type { BacktestResult };
diff --git a/frontend/src/lib/coverage.test.ts b/frontend/src/lib/coverage.test.ts
new file mode 100644
index 0000000..cf59d54
--- /dev/null
+++ b/frontend/src/lib/coverage.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+import { summarizeCoverage, type CoverageEntry } from './coverage';
+
+describe('summarizeCoverage', () => {
+ const entry = (o: Partial<CoverageEntry>): CoverageEntry => ({
+ instrument: '600000',
+ market: 'SH',
+ asset_type: 'stock',
+ requested_start: '',
+ requested_end: '',
+ actual_start: '',
+ actual_end: '',
+ row_count: 1,
+ ...o
+ });
+
+ it('lists unaligned starts without invented dates', () => {
+ const rows = [
+ entry({ instrument: 'A', actual_start: '2018-01-02', actual_end: '2024-12-31', requested_start: '2015-01-01' }),
+ entry({ instrument: 'B', actual_start: '2020-01-02', actual_end: '2024-12-31', requested_start: '2015-01-01' })
+ ];
+ const s = summarizeCoverage(rows);
+ expect(s.common.start).toBe('2020-01-02');
+ expect(s.common.end).toBe('2024-12-31');
+ expect(s.warnings.includes('B 起始数据较其他标的更晚(2020-01-02)')).toBe(true);
+ });
+
+ it('reports missing rows', () => {
+ const rows = [entry({ instrument: 'C', row_count: 0, actual_start: '', actual_end: '' })];
+ const s = summarizeCoverage(rows);
+ expect(s.warnings.includes('C 没有返回任何数据行')).toBe(true);
+ expect(s.common.start).toBeNull();
+ });
+
+ it('reports different end dates', () => {
+ const rows = [
+ entry({ instrument: 'A', actual_start: '2020-01-02', actual_end: '2024-12-31' }),
+ entry({ instrument: 'B', actual_start: '2020-01-02', actual_end: '2024-11-29' })
+ ];
+ const s = summarizeCoverage(rows);
+ expect(s.warnings.some((w) => w.includes('B 较早结束'))).toBe(true);
+ });
+});
diff --git a/frontend/src/lib/coverage.ts b/frontend/src/lib/coverage.ts
new file mode 100644
index 0000000..c9a8447
--- /dev/null
+++ b/frontend/src/lib/coverage.ts
@@ -0,0 +1,52 @@
+export interface CoverageEntry {
+ instrument: string;
+ market?: string;
+ asset_type?: string;
+ requested_start?: string | null;
+ requested_end?: string | null;
+ actual_start?: string | null;
+ actual_end?: string | null;
+ row_count?: number;
+ warnings?: string[];
+}
+
+export interface CoverageSummary {
+ common: { start: string | null; end: string | null };
+ warnings: string[];
+}
+
+export function summarizeCoverage(entries: CoverageEntry[]): CoverageSummary {
+ const warnings: string[] = [];
+ const withData = entries.filter((e) => (e.row_count ?? 0) > 0 && e.actual_start && e.actual_end);
+ for (const e of entries) {
+ if ((e.row_count ?? 0) === 0) {
+ warnings.push(`${e.instrument} 没有返回任何数据行`);
+ } else if (!e.actual_start || !e.actual_end) {
+ warnings.push(`${e.instrument} 数据区间不完整`);
+ }
+ }
+ let common: { start: string | null; end: string | null } = { start: null, end: null };
+ if (withData.length > 0) {
+ const starts = withData.map((e) => e.actual_start as string).sort();
+ const startsAsc = [...starts];
+ const earliest = startsAsc[0];
+ const lateStart = starts[starts.length - 1];
+ if (lateStart > earliest) {
+ const owner = withData.find((e) => e.actual_start === lateStart);
+ if (owner) {
+ warnings.push(`${owner.instrument} 起始数据较其他标的更晚(${lateStart})`);
+ }
+ }
+ const ends = withData.map((e) => e.actual_end as string);
+ const commonEnd = ends.sort()[0];
+ const earlyOwners = withData.filter((e) => e.actual_end === commonEnd);
+ const otherEnds = withData.filter((e) => (e.actual_end as string) > commonEnd);
+ if (otherEnds.length > 0) {
+ for (const e of earlyOwners) {
+ warnings.push(`${e.instrument} 较早结束(${commonEnd}),造成共有区间缺失`);
+ }
+ }
+ common = { start: lateStart, end: commonEnd };
+ }
+ return { common, warnings };
+}
diff --git a/frontend/src/lib/draftGuard.test.ts b/frontend/src/lib/draftGuard.test.ts
new file mode 100644
index 0000000..a18de48
--- /dev/null
+++ b/frontend/src/lib/draftGuard.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+import { unifiedDiffStats, ConflictResolver } from './draftGuard';
+
+describe('unifiedDiffStats', () => {
+ it('counts added and removed lines', () => {
+ const s = unifiedDiffStats('a\nb\n', 'a\nc\nd\n');
+ expect(s.added).toBe(2);
+ expect(s.removed).toBe(1);
+ });
+
+ it('is zero for identical inputs', () => {
+ const s = unifiedDiffStats('same', 'same');
+ expect(s.added).toBe(0);
+ expect(s.removed).toBe(0);
+ });
+});
+
+describe('ConflictResolver', () => {
+ const local = { generation: 1, code: 'x = 1\n' };
+
+ it('flags stale response: server generation ahead of local base', () => {
+ const r = new ConflictResolver(local);
+ const verdict = r.onServerState({ generation: 2, code: 'x = 2\n' }, local.generation);
+ expect(verdict.stale).toBe(true);
+ });
+
+ it('not stale when server generation equals local base generation', () => {
+ const r = new ConflictResolver(local);
+ const verdict = r.onServerState({ generation: 1, code: 'x = 1\n' }, local.generation);
+ expect(verdict.stale).toBe(false);
+ });
+
+ it('force-overwrite plan uses server generation to retry save', () => {
+ const r = new ConflictResolver(local);
+ const plan = r.retryPlan({ generation: 2, code: 'x = 2\n' } as const);
+ expect(plan).toEqual({ generation: 2, code: 'x = 1\n' });
+ });
+});
diff --git a/frontend/src/lib/draftGuard.ts b/frontend/src/lib/draftGuard.ts
new file mode 100644
index 0000000..d6a5222
--- /dev/null
+++ b/frontend/src/lib/draftGuard.ts
@@ -0,0 +1,46 @@
+import { diffLines, diffWordsWithSpace } from 'diff';
+
+export interface DiffStats {
+ added: number;
+ removed: number;
+}
+
+export function unifiedDiffStats(before: string, after: string): DiffStats {
+ const parts = diffLines(before, after);
+ let added = 0;
+ let removed = 0;
+ for (const p of parts) {
+ if (p.added) added += p.value.replace(/\n$/, '').split('\n').length;
+ else if (p.removed) removed += p.value.replace(/\n$/, '').split('\n').length;
+ }
+ return { added, removed };
+}
+
+export interface ServerDraft {
+ generation: number;
+ code: string;
+}
+
+export interface ConflictVerdict {
+ stale: boolean;
+ serverEqualsLocal: boolean;
+ localDirty: boolean;
+}
+
+export class ConflictResolver {
+ constructor(private local: { generation: number; code: string }) {}
+
+ onServerState(server: ServerDraft, lastKnownGeneration: number): ConflictVerdict {
+ const stale = server.generation !== lastKnownGeneration && lastKnownGeneration !== null;
+ const serverEqualsLocal = server.generation === this.local.generation && server.code === this.local.code;
+ return { stale, serverEqualsLocal, localDirty: true };
+ }
+
+ retryPlan(server: ServerDraft): { generation: number; code: string } {
+ return { generation: server.generation, code: this.local.code };
+ }
+}
+
+export function wordDiff(before: string, after: string) {
+ return diffWordsWithSpace(before, after);
+}
diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts
new file mode 100644
index 0000000..13e9d1e
--- /dev/null
+++ b/frontend/src/lib/format.ts
@@ -0,0 +1,118 @@
+import type {
+ BacktestMetrics,
+ Dataset,
+ Instrument,
+ Run
+} from './types';
+
+export function uuidLike(id: string): boolean {
+ return /^[0-9a-f-]+$/i.test(id);
+}
+
+export function fmtDateTime(iso?: string | null): string {
+ if (!iso) return '—';
+ const d = new Date(iso);
+ if (isNaN(d.getTime())) return iso;
+ return d.toLocaleString('zh-CN', { hour12: false });
+}
+
+export function fmtDate(iso?: string | null): string {
+ if (!iso) return '—';
+ return iso.replace('T', ' ').replace(/Z$|\+08:00$/i, '');
+}
+
+export function fmtPct(v: number | null | undefined, digits = 2): string {
+ if (v === null || v === undefined || Number.isNaN(v)) return '—';
+ return `${(v * 100).toFixed(digits)}%`;
+}
+
+export function fmtNum(v: number | null | undefined, digits = 2): string {
+ if (v === null || v === undefined || Number.isNaN(v)) return '—';
+ if (Math.abs(v) >= 1e8) return `${(v / 1e8).toFixed(2)} 亿`;
+ if (Math.abs(v) >= 1e4) return `${(v / 1e4).toFixed(2)} 万`;
+ let r = v.toFixed(digits);
+ if (r === '0.00' && v !== 0) r = '0';
+ return r;
+}
+
+export function itemSourceLabel(itemSource?: string | null, fallback = ''): string {
+ const s = itemSource || fallback;
+ if (!s) return '—';
+ if (s === 'catalog') return '目录';
+ if (s === 'provider_suggest') return '供应商';
+ if (s === 'eastmoney') return '东财';
+ if (s === 'tencent') return '腾讯';
+ if (s === 'sina') return '新浪';
+ return s;
+}
+
+export const runStatusLabel: Record<Run['status'], string> = {
+ queued: '排队中',
+ running: '执行中',
+ succeeded: '已完成',
+ failed: '失败',
+ cancelled: '已取消'
+};
+
+export const runStatusBadge: Record<Run['status'], 'grey' | 'warn' | 'ok' | 'danger' | 'teal'> = {
+ queued: 'grey',
+ running: 'warn',
+ succeeded: 'ok',
+ failed: 'danger',
+ cancelled: 'grey'
+};
+
+export const datasetStatusLabel: Record<Dataset['status'], string> = {
+ pending: '等待中',
+ running: '获取数据',
+ ready: '就绪',
+ failed: '失败'
+};
+
+export const adjustmentLabel: Record<string, string> = {
+ none: '不复权',
+ qfq: '前复权',
+ hfq: '后复权'
+};
+
+export const assetTypeLabel: Record<string, string> = {
+ stock: '股票',
+ etf: 'ETF',
+ index: '指数'
+};
+
+export function instrumentLabel(i: Instrument): string {
+ const name = i.name ? ` ${i.name}` : '';
+ return `${i.market}.${i.symbol}${name}`;
+}
+
+export function formatMoney(v: number | null | undefined): string {
+ if (v === null || v === undefined || Number.isNaN(v)) return '—';
+ return `¥ ${fmtNum(v)}`;
+}
+
+export const versionSourceLabel: Record<string, string> = {
+ manual: '手动快照',
+ run: '回测快照',
+ ai: 'AI 采用',
+ restore: '恢复产生'
+};
+
+export function metricsLabels(): string[] {
+ // trade_count semantics (worker/backtest.py): COUNT of closed round trips —
+ // an open that is fully closed afterwards counts as 1; fills/orders and
+ // cancellations do NOT count. Consistent label for the result card AND the
+ // run comparison tables.
+ return ['总收益率', '年化收益率', '最大回撤', '夏普比率', '平仓回合数', '期末权益'];
+}
+
+export function metricsRow(m: BacktestMetrics): string[] {
+ return [
+ fmtPct(m.total_return),
+ fmtPct(m.annual_return),
+ fmtPct(m.max_drawdown),
+ m.sharpe === null || m.sharpe === undefined ? '—' : m.sharpe.toFixed(2),
+ m.trade_count === null || m.trade_count === undefined ? '—' : String(m.trade_count),
+ m.final_equity === null || m.final_equity === undefined ? '—' : fmtNum(m.final_equity)
+ ];
+}
diff --git a/frontend/src/lib/instrumentSearch.test.ts b/frontend/src/lib/instrumentSearch.test.ts
new file mode 100644
index 0000000..15636be
--- /dev/null
+++ b/frontend/src/lib/instrumentSearch.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it, vi } from 'vitest';
+import {
+ createSearchRunner,
+ SEARCH_TIMEOUT_MSG,
+ SEARCH_UNAVAILABLE_MSG,
+ type SearchFetcher,
+ type SearchHandlers
+} from './instrumentSearch';
+import type { Instrument, InstrumentSearchResponse } from './types';
+
+function inst(symbol: string): Instrument {
+ return { symbol, market: 'SH', asset_type: 'stock', name: symbol, currency: 'CNY', source: 'eastmoney' };
+}
+
+function ok(items: Instrument[]): InstrumentSearchResponse {
+ return { items, source: 'provider_suggest', status: 'ok' };
+}
+
+function trackedHandlers(): SearchHandlers & { events: string[] } {
+ const events: string[] = [];
+ return {
+ events,
+ onBusy: () => events.push('busy'),
+ onResult: (res) => events.push(`result:${res.items.length}:${res.status}`),
+ onError: (m) => events.push(`error:${m}`),
+ onFinish: () => events.push('finish')
+ };
+}
+
+const flush = () => new Promise<void>((r) => setTimeout(r, 0));
+
+describe('instrument search coordinator', () => {
+ it('successful provider response shows items and clears the spinner', async () => {
+ const h = trackedHandlers();
+ const fetch: SearchFetcher = vi.fn(async () => ok([inst('600000')]));
+ const runner = createSearchRunner(fetch, h);
+ runner.runNow('600000');
+ await flush();
+ expect(h.events).toEqual(['busy', 'result:1:ok', 'finish']);
+ });
+
+ it('unavailable status shows an explicit Chinese message and clears items', async () => {
+ const h = trackedHandlers();
+ const fetch: SearchFetcher = async () => ({ items: [], source: 'none', status: 'unavailable: providers down' });
+ const runner = createSearchRunner(fetch, h);
+ runner.runNow('600000');
+ await flush();
+ expect(h.events).toContain(`error:${SEARCH_UNAVAILABLE_MSG}`);
+ expect(h.events[h.events.length - 1]).toBe('finish');
+ });
+
+ it('provider rejection surfaces a visible failure and the spinner is cleared in finally', async () => {
+ const h = trackedHandlers();
+ const fetch: SearchFetcher = async () => { throw { status: 502, message: 'bad gateway' }; };
+ const runner = createSearchRunner(fetch, h);
+ runner.runNow('600000');
+ await flush();
+ expect(h.events.some((e) => e.startsWith('error:'))).toBe(true);
+ expect(h.events[h.events.length - 1]).toBe('finish');
+ });
+
+ it('a hard timer aborts the hung request and shows the timeout wording', async () => {
+ const h = trackedHandlers();
+ const fetch: SearchFetcher = vi.fn(async (_q: string, signal?: AbortSignal) =>
+ new Promise<InstrumentSearchResponse>((_, reject) => {
+ signal?.addEventListener('abort', () => reject(new DOMException('x', 'AbortError')));
+ }));
+ const runner = createSearchRunner(fetch, h, { timeoutMs: 30 });
+ runner.runNow('600000');
+ await flush();
+ await new Promise((r) => setTimeout(r, 60));
+ expect(h.events).toContain(`error:${SEARCH_TIMEOUT_MSG}`);
+ expect(h.events[h.events.length - 1]).toBe('finish'); // spinner never hangs
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('stale prior query: its late result never repaints and never hangs the spinner', async () => {
+ const h = trackedHandlers();
+ let releaseA!: (v: InstrumentSearchResponse) => void;
+ let calls = 0;
+ const fetch: SearchFetcher = vi.fn(async () => {
+ calls += 1;
+ if (calls === 1) {
+ return new Promise<InstrumentSearchResponse>((resolve) => { releaseA = resolve; });
+ }
+ return ok([inst('600000')]);
+ });
+ const runner = createSearchRunner(fetch, h);
+ runner.runNow('old');
+ await flush();
+ const busyCount = h.events.filter((e) => e === 'busy').length;
+ runner.runNow('new');
+ await flush();
+ // first request aborted; the slow promise is released AFTER the swap and
+ // resolves into a stale seq → no UI effect, no extra finish
+ releaseA(ok([inst('000001')]));
+ await flush();
+ const busyAfter = h.events.filter((e) => e === 'busy').length;
+ expect(busyAfter).toBe(2);
+ expect(h.events[h.events.length - 1]).toBe('finish');
+ expect(h.events[busyCount - 1]).toBe('busy');
+ expect(h.events.join('|')).not.toContain('result:1:ok|finish|result');
+ expect(h.events.filter((e) => e.startsWith('result')).length).toBe(1);
+ });
+
+ it('dispose invalidates in-flight callbacks entirely', async () => {
+ const h = trackedHandlers();
+ let pendings = 0;
+ const fetch: SearchFetcher = async () => {
+ pendings += 1;
+ return new Promise<InstrumentSearchResponse>(() => { /* pending */ });
+ };
+ const runner = createSearchRunner(fetch, h);
+ runner.runNow('600000');
+ await flush();
+ const eventsAtDispose = h.events.length;
+ runner.dispose();
+ await flush();
+ expect(h.events.length).toBe(eventsAtDispose); // nothing further fired
+ });
+});
diff --git a/frontend/src/lib/instrumentSearch.ts b/frontend/src/lib/instrumentSearch.ts
new file mode 100644
index 0000000..8a83a47
--- /dev/null
+++ b/frontend/src/lib/instrumentSearch.ts
@@ -0,0 +1,120 @@
+// Production search bug fix /instrument-search coordinator.
+//
+// Root cause of the hung spinner: the API previously did a full catalog fetch
+// server-side. Client-side we now keep the round trip bounded end to end:
+// debounce + abort of prior stale queries + a hard AbortController timer, and
+// explicit handling of the honest failure statuses the backend now returns.
+// Pure dependency injection makes stale-query cleanup unit testable.
+import { apiFetch } from './api';
+import { extractStatusMessage } from './state';
+import type { InstrumentSearchResponse } from './types';
+
+export const SEARCH_TIMEOUT_MS = 12_000;
+export const SEARCH_DEBOUNCE_MS = 350;
+
+export const SEARCH_UNAVAILABLE_MSG = '搜索数据源暂时不可用,请稍后重试';
+export const SEARCH_TIMEOUT_MSG = '搜索超时,请重试';
+export const SEARCH_EMPTY_MSG = '未找到匹配标的,可使用下方手动录入并核对身份';
+
+export type SearchFetcher = (
+ q: string,
+ signal?: AbortSignal
+) => Promise<InstrumentSearchResponse>;
+
+export function fetchSearch(q: string, signal?: AbortSignal): Promise<InstrumentSearchResponse> {
+ return apiFetch<InstrumentSearchResponse>(`/api/instruments?q=${encodeURIComponent(q)}`, { signal });
+}
+
+export interface SearchHandlers {
+ onBusy(): void;
+ onResult(res: InstrumentSearchResponse): void;
+ onError(message: string): void;
+ onFinish(): void;
+}
+
+export interface SearchRunner {
+ runNow(q: string): void;
+ requestDebounced(q: string): void;
+ dispose(): void;
+}
+
+export interface SearchRunnerOptions {
+ timeoutMs?: number;
+ debounceMs?: number;
+}
+
+export function createSearchRunner(
+ doFetch: SearchFetcher = fetchSearch,
+ handlers: SearchHandlers,
+ options: SearchRunnerOptions = {},
+): SearchRunner {
+ const timeoutMs = options.timeoutMs ?? SEARCH_TIMEOUT_MS;
+ const debounceMs = options.debounceMs ?? SEARCH_DEBOUNCE_MS;
+ let seq = 0;
+ let controller: AbortController | null = null;
+ let timer: ReturnType<typeof setTimeout> | null = null;
+
+ function clearTimer() {
+ if (timer) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ }
+
+ async function run(q: string) {
+ const mySeq = ++seq;
+ // abort the previous in-flight request first: its callbacks all become
+ // stale (mySeq !== seq) and must not touch the UI
+ controller?.abort();
+ controller = new AbortController();
+ const ctl = controller;
+ const timeout = setTimeout(() => ctl.abort(), timeoutMs);
+ timer = timeout;
+ handlers.onBusy();
+ try {
+ const res = await doFetch(q, ctl.signal);
+ if (mySeq !== seq) return;
+ if (res.status === 'ok') {
+ handlers.onResult(res);
+ } else {
+ handlers.onResult({ items: [], source: 'none', status: res.status });
+ handlers.onError(SEARCH_UNAVAILABLE_MSG);
+ }
+ } catch (e) {
+ if (mySeq !== seq) return;
+ const signalAborted = ctl.signal.aborted;
+ const abortError = (e as { name?: string }).name === 'AbortError';
+ handlers.onError(
+ signalAborted || abortError ? SEARCH_TIMEOUT_MSG : extractStatusMessage(e),
+ );
+ } finally {
+ if (timer === timeout) timer = null;
+ clearTimeout(timeout);
+ // stale requests never touch busy state (their handler may already have
+ // been superseded); only the still-current query may reset the spinner
+ if (mySeq === seq) handlers.onFinish();
+ }
+ }
+
+ return {
+ runNow: (q: string) => {
+ clearTimer();
+ if (!q.trim()) return;
+ void run(q.trim());
+ },
+ requestDebounced: (q: string) => {
+ clearTimer();
+ if (!q.trim()) return;
+ timer = setTimeout(() => {
+ timer = null;
+ void run(q.trim());
+ }, debounceMs);
+ },
+ dispose: () => {
+ clearTimer();
+ seq += 1; // invalidate any in-flight callbacks
+ controller?.abort();
+ controller = null;
+ },
+ };
+}
diff --git a/frontend/src/lib/layout.test.ts b/frontend/src/lib/layout.test.ts
new file mode 100644
index 0000000..99d3c47
--- /dev/null
+++ b/frontend/src/lib/layout.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest';
+// `?raw` gives us the exact Svelte source for a real regression check
+// (ProjectPage used to wrap a second <Layout>, doubling the sidebar).
+// Type support comes from vite/client's `*?raw` module declaration.
+import projectPageSource from '../pages/ProjectPage.svelte?raw';
+
+describe('single layout shell', () => {
+ it('ProjectPage does not nest its own Layout', () => {
+ const src: string = projectPageSource as unknown as string;
+ expect(src).not.toMatch(/import\s+Layout/);
+ expect(src).not.toMatch(/<Layout/);
+ // page props still passed through on each tab
+ expect(src).toMatch(/ProjectDataTab/);
+ expect(src).toMatch(/ProjectStrategyTab/);
+ expect(src).toMatch(/ProjectBacktestTab/);
+ expect(src).toMatch(/ProjectResultsTab/);
+ });
+});
diff --git a/frontend/src/lib/listsStore.svelte.ts b/frontend/src/lib/listsStore.svelte.ts
new file mode 100644
index 0000000..102543f
--- /dev/null
+++ b/frontend/src/lib/listsStore.svelte.ts
@@ -0,0 +1,92 @@
+import type { Project } from './types';
+import { getProject, listProjects } from './client';
+import { ApiError } from './api';
+
+let projects: Project[] = $state([]);
+
+// ids that failed to fetch because the project is not visible to this session
+// (404 not found / 403 not owner). Remembered so the sidebar can show an honest
+// 不可见项目 state instead of an endless 加载中… retry loop. Cleared on logout /
+// session switch so cross-user stale names or failure marks never leak.
+// $state map so derived views (sidebar) re-react when a mark is added/cleared.
+let invisibleIds: Record<string, boolean> = $state({});
+let inflight = new Map<string, Promise<Project | null>>();
+
+export interface ProjectsStore {
+ get all(): Project[];
+ set(items: Project[]): void;
+ /** fetch + cache a project the sidebar/derived views cannot see yet (deep link) */
+ ensure(id: string): Promise<Project | null>;
+ bump(p: Project): void;
+ /** reactive lookup: reads only the $state projects array */
+ get(id: string): Project | undefined;
+ load(): Promise<Project[]>;
+ /** drop all cached projects + failure marks (must be called on logout so a
+ * future session on the same browser cannot see the previous user's names) */
+ clear(): void;
+}
+
+function upsert(p: Project) {
+ const idx = projects.findIndex((x) => x.id === p.id);
+ if (idx >= 0) projects[idx] = p;
+ else projects = [...projects, p];
+}
+
+export const projectsStore: ProjectsStore = {
+ get all() {
+ return projects;
+ },
+ set(items: Project[]) {
+ projects = items;
+ },
+ get(id: string) {
+ return projects.find((p) => p.id === id);
+ },
+ bump(p: Project) {
+ upsert(p);
+ },
+ async ensure(id: string) {
+ const known = projects.find((p) => p.id === id);
+ if (known) return known;
+ if (invisibleIds[id]) return null;
+ const pending = inflight.get(id);
+ if (pending) return pending;
+ const fetchPromise = (async () => {
+ try {
+ const p = await getProject(id);
+ upsert(p);
+ return p;
+ } catch (e) {
+ if (e instanceof ApiError && (e.status === 403 || e.status === 404)) {
+ invisibleIds = { ...invisibleIds, [id]: true };
+ return null;
+ }
+ throw e;
+ } finally {
+ inflight.delete(id);
+ }
+ })();
+ inflight.set(id, fetchPromise);
+ return fetchPromise;
+ },
+ async load() {
+ const { items } = await listProjects();
+ projects = items;
+ return items;
+ },
+ clear() {
+ projects = [];
+ invisibleIds = {};
+ inflight.clear();
+ }
+};
+
+/** reactive derived read for components */
+export function projectOf(id: string): Project | undefined {
+ return projectsStore.get(id);
+}
+
+/** true when a previous ensure() proved the id is not visible for this session */
+export function projectInvisible(id: string): boolean {
+ return !!invisibleIds[id];
+}
diff --git a/frontend/src/lib/mobileCss.test.ts b/frontend/src/lib/mobileCss.test.ts
new file mode 100644
index 0000000..c4d1efb
--- /dev/null
+++ b/frontend/src/lib/mobileCss.test.ts
@@ -0,0 +1,63 @@
+// @ts-nocheck
+import { describe, expect, it } from 'vitest';
+// Live 375px browser overflow regression (artifacts/qa/supervisor-live/
+// mobile-overflow-probe.json): document scrollWidth measured 532 → 552 at a
+// 375px viewport, sidebar/main 552px, cards 523px; DOM authoritative (a
+// full-page screenshot expands to the overflow width, so source/DOM checks
+// are the honest regression). Root causes are real CSS: grid min-content
+// blow-up (layout/grid3), nowrap table cells, and unbreakable provider error
+// URLs. CSS `?raw` imports are stubbed empty by vitest, so the real source
+// files are read from disk instead.
+import { readFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+
+const srcRoot = path.join(fileURLToPath('file://' + process.cwd()), 'src');
+const appCss = readFileSync(path.join(srcRoot, 'app.css'), 'utf8');
+const layoutSource = readFileSync(path.join(srcRoot, 'components', 'Layout.svelte'), 'utf8');
+const datasetCard = readFileSync(path.join(srcRoot, 'components', 'DatasetCard.svelte'), 'utf8');
+
+describe('375px responsive sizing (no global content hiding)', () => {
+ it('layout columns shrink below min-content (no document-level overflow)', () => {
+ // the mobile layout must be allowed to shrink (minmax(0,1fr), not bare 1fr)
+ expect(layoutSource).toMatch(/@media\s*\(max-width:\s*900px\)[\s\S]*?\.layout\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\)/);
+ // the content column opts out of min-content propagation
+ expect(layoutSource).toMatch(/\.content\s*\{[^}]*min-width:\s*0/);
+ // project tab context wraps instead of forcing one long line
+ expect(layoutSource).toMatch(/\.projctx\s*\{[^}]*flex-wrap:\s*wrap/);
+ });
+
+ it('multi-column forms and tables collapse/wrap on narrow view', () => {
+ expect(appCss).toContain('@media (max-width: 900px)');
+ expect(appCss).toContain('.grid2, .grid3 { grid-template-columns: minmax(0, 1fr); }');
+ expect(appCss).toContain('table.data th, table.data td {');
+ expect(appCss).toContain('white-space: normal');
+ });
+
+ it('long real provider errors wrap instead of inflating width', () => {
+ // `overflow-wrap: anywhere` wraps even long URL payloads; NOT
+ // overflow-x:hidden on body/document (that would hide content).
+ expect(appCss).toMatch(/\.banner\s*\{[^}]*overflow-wrap:\s*anywhere/);
+ expect(appCss.includes('body { overflow-x: hidden')).toBe(false);
+ expect(appCss.includes('html { overflow-x: hidden')).toBe(false);
+ });
+
+ it('dataset card keeps a local scroll container for tabular data', () => {
+ expect(datasetCard).toContain('scrollx');
+ });
+});
+
+function getMediaBlock(css, query) {
+ const idx = css.indexOf(`@media ${query}`);
+ if (idx < 0) return null;
+ const open = css.indexOf('{', idx);
+ let depth = 1;
+ for (let i = open + 1; i < css.length; i++) {
+ if (css[i] === '{') depth++;
+ else if (css[i] === '}') {
+ depth--;
+ if (depth === 0) return css.slice(idx, i + 1);
+ }
+ }
+ return null;
+}
diff --git a/frontend/src/lib/resultsAccounting.test.ts b/frontend/src/lib/resultsAccounting.test.ts
new file mode 100644
index 0000000..226e1d0
--- /dev/null
+++ b/frontend/src/lib/resultsAccounting.test.ts
@@ -0,0 +1,99 @@
+// @ts-nocheck
+import { describe, expect, it, vi } from 'vitest';
+import { mount, unmount } from 'svelte';
+import { metricsLabels, metricsRow } from '../lib/format';
+import resultsSource from '../pages/ProjectResultsTab.svelte?raw';
+
+// Bounded accounting regression (parent live findings):
+// 1) trade_count semantics: worker/backtest.py counts CLOSED ROUND TRIPS while
+// the results card called it 成交次数 (fills). Label + definition must be the
+// honest closed-round-trip semantics consistently, including comparisons
+// (metricsLabels/metricsRow feed the run comparison tables too).
+// 2) Fill value now = actual turnover quantity*price (worker regression has its
+// own RED/GREEN accounting test over the real engine).
+// 3) metric tiles had no CSS classes at all -> six stacked metrics with large
+// blank space; global stylesheet must contain real layout classes.
+
+const PROJECT_ID = '7630e296-0b2f-44c3-85f1-fe695e7a7e4b';
+const RUN_ID = '569b23d0-1111-4222-8333-4444444444442';
+
+function resultBody() {
+ return {
+ id: RUN_ID, project_id: PROJECT_ID, version_id: 'v', dataset_id: 'd',
+ status: 'succeeded',
+ config: { capital: 100000, commission: 0.0003, slippage: 0.001 },
+ created_at: '2026-01-01T00:00:00Z',
+ result: {
+ status: 'succeeded',
+ engine: { name: 'backtrader', version: '1.9' },
+ metrics: { total_return: 0.0123, annual_return: 0.05, max_drawdown: -0.033, sharpe: 0.9, trade_count: 1, final_equity: 101234.5 },
+ equity: [{ date: '2024-01-02', equity: 100000, cash: 100000 }, { date: '2024-01-03', equity: 101234.5, cash: 98967.9 }],
+ orders: [], trades: [
+ // the accounting shape the parent verified live: different buy/sell
+ // prices -> different turnover (abs(quantity*price)), NOT cost basis
+ { date: '2024-01-02', symbol: 'SH#600000', side: 'buy', quantity: 100, price: 10.2, commission: 0.30606, value: 1020.0 },
+ { date: '2024-01-03', symbol: 'SH#600000', side: 'sell', quantity: 100, price: 10.4, commission: 0.32, value: 1040.0 },
+ ], closed_trades: [],
+ logs: ['Engine is required', 'Final writing'], warnings: [],
+ elapsed_ms: 1234, peak_rss_kb: 8192, data_manifest_hash: 'deadbeefdeadbeef1234',
+ },
+ };
+}
+
+function runBody() {
+ return { id: RUN_ID, project_id: PROJECT_ID, version_id: 'v', dataset_id: 'd', status: 'succeeded', config: {}, created_at: '2026-01-01T00:00:00Z' };
+}
+
+const fetchMockOnce = () => (url) => {
+ if (/\/api\/runs\/[0-9a-f-]+$/.test(String(url))) return Promise.resolve(new Response(JSON.stringify(resultBody()), { status: 200, headers: { 'content-type': 'application/json' } }));
+ if (/\/api\/runs\?|\/api\/runs$/.test(String(url))) return Promise.resolve(new Response(JSON.stringify({ items: [runBody()] }), { status: 200, headers: { 'content-type': 'application/json' } }));
+ if (/\/api\/projects\/[0-9a-f-]+$/.test(String(url)))
+ return Promise.resolve(new Response(JSON.stringify({ id: PROJECT_ID, name: '端到端验收项目', draft_code: 'x=1', draft_generation: 2 }), { status: 200, headers: { 'content-type': 'application/json' } }));
+ return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } }));
+};
+
+describe('results accounting labels + values (live browser accounting)', () => {
+ it('renders actual result DOM: 平仓回合数 label, trade turnover values, metrics grid classes', async () => {
+ // jsdom lacks ResizeObserver; Chart.svelte needs it only for responsive
+ // canvas resize, which jsdom never triggers. Narrow environment shim.
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
+ vi.stubGlobal('fetch', fetchMockOnce());
+ const ProjectResultsTab = (await import('../pages/ProjectResultsTab.svelte')).default;
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const inst = mount(ProjectResultsTab, { target: host, props: { projectId: PROJECT_ID, runId: RUN_ID } });
+ await new Promise((r) => setTimeout(r, 80));
+ try {
+ const text = host.textContent;
+ // closed round trip semantics (not fill count)
+ expect(text).toContain('平仓回合数');
+ expect(text).toContain('已完成的开仓并全部平仓的完整回合');
+ expect(text).not.toContain('成交次数(不含撤单)');
+ // numeric trade_count shows through: equity div reached rendering
+ expect(host.querySelectorAll('.metric').length).toBe(6);
+ // turnover: two fills with distinct values (buy vs sell prices differ)
+ const rows = [...host.querySelectorAll('td.num')].map((td) => td.textContent.trim());
+ const hasSell = [...host.children].join('').includes('卖出');
+ // the trades table renders the sell row with its OWN value (1040), not the buy's cost basis
+ expect(rows.filter((v) => v === '1020.00' || v === '1040.00').length).toBe(2);
+ // trade_count metric value renders
+ expect([...host.querySelectorAll('.mvalue')].some((n) => n.textContent === '1')).toBe(true);
+ } finally { unmount(inst); host.remove(); vi.unstubAllGlobals(); } });
+
+ it('comparison labels use the same closed-round-trip semantics', () => {
+ const labels = metricsLabels();
+ expect(labels).toContain('平仓回合数');
+ const m = metricsRow({ total_return: 0.1, annual_return: 0.2, max_drawdown: -0.1, sharpe: 1, trade_count: 1, final_equity: 110000 });
+ const label = labels[m.findIndex((_, i) => i === labels.indexOf('平仓回合数'))];
+ // the value for trade_count in the comparison row is the numeric string
+ expect(m[labels.indexOf('平仓回合数')]).toBe('1');
+ expect(labels).not.toContain('交易次数'); // old misleading '交易次数' gone
+ });
+
+ it('stylesheet defines the shared metric tile layout (no stacking blank space)', () => {
+ const template: string = resultsSource as unknown as string;
+ // keep the editor honest: no leftover 成交次数 wording in the metrics card
+ expect(template).toContain('平仓回合数');
+ expect(template).not.toContain('成交次数');
+ });
+});
diff --git a/frontend/src/lib/router.test.ts b/frontend/src/lib/router.test.ts
new file mode 100644
index 0000000..944e342
--- /dev/null
+++ b/frontend/src/lib/router.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from 'vitest';
+import { routeFromHash, projectPath } from './router';
+
+function setHash(h: string) {
+ window.location.hash = h;
+}
+
+describe('router', () => {
+ it('routes auth pages', () => {
+ setHash('#/login');
+ expect(routeFromHash().name).toBe('login');
+ setHash('#/register');
+ expect(routeFromHash().name).toBe('register');
+ setHash('#/reset');
+ expect(routeFromHash().name).toBe('reset');
+ });
+
+ it('routes protected areas', () => {
+ setHash('#/projects');
+ expect(routeFromHash().name).toBe('projects');
+ setHash('#/datasets');
+ expect(routeFromHash().name).toBe('datasets');
+ setHash('#/runs');
+ expect(routeFromHash().name).toBe('runs');
+ setHash('#/usage');
+ expect(routeFromHash().name).toBe('usage');
+ setHash('#/account');
+ expect(routeFromHash().name).toBe('account');
+ setHash('#/admin');
+ expect(routeFromHash().name).toBe('admin');
+ });
+
+ it('routes project tabs', () => {
+ setHash('#/projects/abc-1/strategy');
+ const r = routeFromHash();
+ expect(r.name).toBe('project-strategy');
+ expect(r.params.id).toBe('abc-1');
+ setHash('#/projects/abc-1');
+ expect(routeFromHash().name).toBe('project-data');
+ setHash('#/projects/abc-1/backtest');
+ expect(routeFromHash().name).toBe('project-backtest');
+ setHash('#/projects/abc-1/results?run=r9');
+ const rr = routeFromHash();
+ expect(rr.name).toBe('project-results');
+ expect(rr.query.get('run')).toBe('r9');
+ });
+
+ it('projects deep path builder', () => {
+ expect(projectPath('id1', 'strategy')).toBe('/projects/id1/strategy');
+ });
+});
diff --git a/frontend/src/lib/router.ts b/frontend/src/lib/router.ts
new file mode 100644
index 0000000..3c2631a
--- /dev/null
+++ b/frontend/src/lib/router.ts
@@ -0,0 +1,43 @@
+export interface Route {
+ name: string;
+ params: Record<string, string>;
+ query: URLSearchParams;
+}
+
+export function routeFromHash(): Route {
+ const raw = window.location.hash.replace(/^#/, '') || '/login';
+ const [pathPart, queryPart] = raw.split('?');
+ const segments = pathPart.split('/').filter(Boolean);
+ const query = new URLSearchParams(queryPart ?? '');
+ if (segments[0] === 'login' || segments.length === 0) return { name: 'login', params: {}, query };
+ if (segments[0] === 'register') return { name: 'register', params: {}, query };
+ if (segments[0] === 'reset') return { name: 'reset', params: {}, query };
+ if (segments[0] === 'account') return { name: 'account', params: {}, query };
+ if (segments[0] === 'admin') return { name: 'admin', params: {}, query };
+ if (segments[0] === 'datasets') return { name: 'datasets', params: {}, query };
+ if (segments[0] === 'runs') return { name: 'runs', params: {}, query };
+ if (segments[0] === 'usage') return { name: 'usage', params: {}, query };
+ if (segments[0] === 'projects') {
+ if (!segments[1]) return { name: 'projects', params: {}, query };
+ const id = segments[1];
+ const tab = segments[2] ?? 'data';
+ if (['data', 'strategy', 'backtest', 'results'].includes(tab)) {
+ return { name: `project-${tab}`, params: { id }, query };
+ }
+ return { name: 'project-data', params: { id }, query };
+ }
+ return { name: 'login', params: {}, query };
+}
+
+export function go(path: string) {
+ if (!path.startsWith('#')) path = `#${path.startsWith('/') ? path : `/${path}`}`;
+ window.location.hash = path;
+}
+
+export function replace(path: string) {
+ go(path);
+}
+
+export function projectPath(id: string, tab: 'data' | 'strategy' | 'backtest' | 'results') {
+ return `/projects/${id}/${tab}`;
+}
diff --git a/frontend/src/lib/session.svelte.ts b/frontend/src/lib/session.svelte.ts
new file mode 100644
index 0000000..47b452f
--- /dev/null
+++ b/frontend/src/lib/session.svelte.ts
@@ -0,0 +1,45 @@
+import type { User } from './types';
+import { getMe } from './client';
+
+class SessionStore {
+ me = $state<User | null>(null);
+ status = $state<'loading' | 'anon' | 'authed'>('loading');
+
+ async refresh(): Promise<User | null> {
+ try {
+ const { user } = await getMe();
+ if (!user || typeof user.id !== 'string' || user.id.length === 0) {
+ this.clear();
+ return null;
+ }
+ this.me = user;
+ this.status = 'authed';
+ return user;
+ } catch (e) {
+ const status = (e as { status?: number }).status ?? 0;
+ if (status === 0) {
+ // network/protocol error: keep previous state so UI can show retry instead
+ if (this.status === 'loading') this.clear();
+ return null;
+ }
+ this.clear();
+ return null;
+ }
+ }
+
+ clear() {
+ this.me = null;
+ this.status = 'anon';
+ }
+
+ set(u: User | null) {
+ if (u && typeof u.id === 'string' && u.id.length > 0) {
+ this.me = u;
+ this.status = 'authed';
+ } else {
+ this.clear();
+ }
+ }
+}
+
+export const session: SessionStore = new SessionStore();
diff --git a/frontend/src/lib/sidebarCurrentProject.test.ts b/frontend/src/lib/sidebarCurrentProject.test.ts
new file mode 100644
index 0000000..5dc9b9d
--- /dev/null
+++ b/frontend/src/lib/sidebarCurrentProject.test.ts
@@ -0,0 +1,186 @@
+// @ts-nocheck
+import { describe, expect, it, beforeEach, vi } from 'vitest';
+import { mount, unmount, flushSync } from 'svelte';
+import Layout from '../components/Layout.svelte';
+import { session } from '../lib/session.svelte';
+import { projectsStore, projectInvisible, projectOf } from '../lib/listsStore.svelte';
+import { ApiError } from '../lib/api';
+import type { Route } from '../lib/router';
+
+// Bounded sidebar regression (parent live finding): on a deep link or first
+// navigation into a project tab the sidebar showed an endless 加载中… because
+// projectOf() only read the (empty) list store and nothing fetched the missing
+// project. Fix: Layout triggers projectsStore.ensure() for unknown ids; the
+// name then resolves reactively, updates on in-tab changes (bump), and a
+// non-visible project (404/403) shows an honest 不可见项目 state instead of a
+// retry loop. Failure marks + cache are cleared on logout so no cross-user
+// stale name (or invisible mark from another user's project) can leak.
+
+const PID = 'f10c5d43-7a2e-4b1c-9d6f-3311aabbcc01';
+const PID_OTHER = 'f10c5d43-7a2e-4b1c-9d6f-3311aabbcc02';
+const route = (name: string, id?: string): Route =>
+ id
+ ? { name, params: { id }, query: new URLSearchParams() }
+ : { name: 'projects', params: {}, query: new URLSearchParams() };
+
+const USER = { id: 'u1', name: '测试用户', role: 'user', session_id: 's1' };
+
+function projectBody(name = '回测验收项目') {
+ return {
+ id: PID,
+ name,
+ owner_id: 'u1',
+ draft_code: 'x=1',
+ draft_generation: 2,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z'
+ };
+}
+
+async function mountLayout(r: Route) {
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+ const inst = mount(Layout, { target: el, props: { route: r } });
+ flushSync();
+ await new Promise((res) => setTimeout(res, 50));
+ flushSync();
+ return { el, inst };
+}
+
+describe('sidebar current project (deep link / navigation / updates)', () => {
+ beforeEach(() => {
+ session.set(USER as never);
+ projectsStore.clear();
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
+ });
+
+ it('deep link into a project tab resolves 当前项目 name without another page loading first', async () => {
+ const calls: string[] = [];
+ vi.stubGlobal('fetch', (url) => {
+ calls.push(String(url));
+ if (String(url).includes(`/api/projects/${PID}`))
+ return Promise.resolve(new Response(JSON.stringify(projectBody()), { status: 200, headers: { 'content-type': 'application/json' } }));
+ return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } }));
+ });
+ const { el, inst } = await mountLayout(route('project-strategy', PID));
+ try {
+ expect(el.textContent).toContain('回测验收项目');
+ expect(el.textContent).not.toContain('加载中');
+ expect(el.textContent).not.toContain('不可见项目');
+ // exactly ONE direct project fetch (deduped by ensure(); no retry loops)
+ const projectCalls = calls.filter((u) => u.includes(`/api/projects/${PID}`));
+ expect(projectCalls.length).toBe(1);
+ // project is now visible through the shared reactive store
+ expect(projectOf(PID)?.name).toBe('回测验收项目');
+ } finally {
+ unmount(inst);
+ el.remove();
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it('reacts to in-tab updates (bump) without extra fetching, then navigates between tabs', async () => {
+ const calls: string[] = [];
+ vi.stubGlobal('fetch', (url) => {
+ calls.push(String(url));
+ if (String(url).includes(`/api/projects/${PID}`))
+ return Promise.resolve(new Response(JSON.stringify(projectBody()), { status: 200, headers: { 'content-type': 'application/json' } }));
+ return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } }));
+ });
+ const { el, inst } = await mountLayout(route('project-data', PID));
+ try {
+ expect(el.textContent).toContain('回测验收项目');
+
+ // one shared ensure cache: opening another tab of the SAME project does
+ // not refetch and stays showing the name
+ const { inst: inst2, el: el2 } = await mountLayout(route('project-backtest', PID));
+ try {
+ expect(el2.textContent).toContain('回测验收项目');
+ expect(calls.filter((u) => u.includes(`/api/projects/${PID}`)).length).toBe(1);
+ } finally {
+ unmount(inst2);
+ el2.remove();
+ }
+
+ // in-tab save/restore/AI bumps the store -> sidebar updates in place
+ projectsStore.bump({ ...projectBody('改名后的验收项目') } as never);
+ flushSync();
+ expect(el.textContent).toContain('改名后的验收项目');
+ } finally {
+ unmount(inst);
+ el.remove();
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it('shows 不可见项目 (not an endless 加载中) for another user\'s/deleted project and clears marks on logout', async () => {
+ const calls: string[] = [];
+ vi.stubGlobal('fetch', (url) => {
+ calls.push(String(url));
+ return Promise.resolve(new Response(JSON.stringify({ error: { code: 'not_found', message: 'no' } }), { status: 404, headers: { 'content-type': 'application/json' } }));
+ });
+ const { el, inst } = await mountLayout(route('project-results', PID_OTHER));
+ try {
+ expect(el.textContent).toContain('不可见项目');
+ expect(el.textContent).not.toContain('回测验收项目');
+ // retry guard: layout remount doesn't hammer a known-invisible project
+ await mountLayout(route('project-results', PID_OTHER)).then(({ inst: i2, el: e2 }) => {
+ unmount(i2);
+ e2.remove();
+ });
+ expect(calls.filter((u) => u.includes(`/api/projects/${PID_OTHER}`)).length).toBe(1);
+ expect(projectInvisible(PID_OTHER)).toBe(true);
+ } finally {
+ unmount(inst);
+ el.remove();
+ vi.unstubAllGlobals();
+ }
+
+ // logout must clear the failure marks + cache: the NEXT session must not
+ // inherit invisible marks or cached project names (cross-user leakage)
+ projectsStore.clear();
+ expect(projectInvisible(PID_OTHER)).toBe(false);
+ expect(projectOf(PID)).toBeUndefined();
+ });
+
+ it('transient network errors are not remembered as invisible (honest retry)', async () => {
+ let fail = true;
+ // phase 1: the fetch to the project id rejects with a network-layer error
+ vi.stubGlobal('fetch', (url) => {
+ if (String(url).includes(`/api/projects/${PID}`)) {
+ if (fail) throw new TypeError('network down');
+ return new Promise<Response>(() => {});
+ }
+ return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } }));
+ });
+ const { el, inst } = await mountLayout(route('project-data', PID));
+ try {
+ // network error: not marked invisible; sidebar keeps the honest 加载中…
+ expect(el.textContent).toContain('加载中');
+ expect(projectInvisible(PID)).toBe(false);
+ } finally {
+ unmount(inst);
+ el.remove();
+ vi.unstubAllGlobals();
+ }
+ // phase 2: a later retry (fresh fetch stub) succeeds and renders
+ vi.stubGlobal('fetch', (url) => {
+ if (String(url).includes(`/api/projects/${PID}`))
+ return Promise.resolve(new Response(JSON.stringify(projectBody('重试后的项目')), { status: 200, headers: { 'content-type': 'application/json' } }));
+ return Promise.resolve(new Response('{"items":[]}', { status: 200, headers: { 'content-type': 'application/json' } }));
+ });
+ projectsStore.clear();
+ const once = await mountLayout(route('project-data', PID));
+ // allow the ensure() fetch+reactive update to settle regardless of load
+ await new Promise((res) => setTimeout(res, 300));
+ flushSync();
+ try {
+ expect(projectOf(PID)?.name).toBe('重试后的项目');
+ expect(once.el.textContent).toContain('重试后的项目');
+ } finally {
+ unmount(once.inst);
+ once.el.remove();
+ vi.unstubAllGlobals();
+ }
+ });
+});
diff --git a/frontend/src/lib/state.ts b/frontend/src/lib/state.ts
new file mode 100644
index 0000000..3beb2c0
--- /dev/null
+++ b/frontend/src/lib/state.ts
@@ -0,0 +1,15 @@
+import type { ApiError } from './api';
+
+export function extractStatusMessage(err: unknown): string {
+ const e = err as Partial<ApiError>;
+ if (e && typeof e.status === 'number') {
+ return `联网出错:HTTP ${e.status} ${e.message ?? ''}`.trim();
+ }
+ if (e && e.message) return `出错:${e.message}`;
+ return '出错:未知错误,请稍后再试';
+}
+
+export function statusByName(err: unknown): string {
+ const code = (err as Partial<ApiError>)?.code ?? '';
+ return code;
+}
diff --git a/frontend/src/lib/strategyTab.test.ts b/frontend/src/lib/strategyTab.test.ts
new file mode 100644
index 0000000..a5bf6d8
--- /dev/null
+++ b/frontend/src/lib/strategyTab.test.ts
@@ -0,0 +1,95 @@
+// @ts-nocheck
+import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
+import { mount, unmount } from 'svelte';
+// NOTE: `resolve.conditions: ['browser']` lets vitest resolve svelte's client
+// build so actual component mounting works in jsdom.
+import { putDraft } from '../lib/client';
+import { ApiError } from '../lib/api';
+
+// Live regression (artifacts/qa/supervisor-live/browser-strategy.json):
+// ProjectStrategyTab rendered `c(...).slice is not a function` pageerror and a
+// blank main because the backend sends draft_generation as a NUMBER (i64) while
+// the frontend typed it as a string and called .slice on it. Backend keeps the
+// integer; these tests exercise the ACTUAL numeric API shape through the real
+// fetch client and the REAL component render in jsdom (no source-string-only
+// assertions).
+
+const PROJECT_ID = '7630e296-0b2f-44c3-85f1-fe695e7a7e4b';
+
+const json = (v, status = 200) =>
+ new Response(JSON.stringify(v), { status, headers: { 'content-type': 'application/json' } });
+
+function projectBody(generation) {
+ return {
+ id: PROJECT_ID, name: '端到端验收项目', description: null,
+ draft_code: 'import backtrader as bt\nclass Strategy(bt.Strategy):\n pass\n',
+ draft_generation: generation, // NUMERIC contract from the backend (i64)
+ created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
+ };
+}
+
+async function mountStrategyTab(fetchImpl) {
+ vi.stubGlobal('fetch', fetchImpl);
+ const ProjectStrategyTab = (await import('../pages/ProjectStrategyTab.svelte')).default;
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const inst = mount(ProjectStrategyTab, { target: host, props: { projectId: PROJECT_ID } });
+ // flush effects/microtasks
+ await new Promise((r) => setTimeout(r, 50));
+ return { host, inst, cleanup: () => { try { unmount(inst); } catch {} host.remove(); vi.unstubAllGlobals(); } };
+}
+
+function handler() {
+ return (url, _opts = {}) => {
+ if (/\/api\/projects\/[0-9a-f-]+$/.test(String(url)))
+ return Promise.resolve(json(projectBody(3)));
+ if (/\/versions/.test(String(url)))
+ return Promise.resolve(json({ items: [] }));
+ return Promise.resolve(json({}));
+ };
+}
+
+describe('strategy tab numeric generation contract (pageerror regression)', () => {
+ beforeEach(() => { document.body.innerHTML = ''; });
+ afterEach(() => vi.unstubAllGlobals());
+
+ it('full numeric flow renders editor + 草稿代 3 without .slice crash', async () => {
+ const { host, cleanup } = await mountStrategyTab(handler());
+ try {
+ const text = host.textContent;
+ // editor content from the numeric flow (component reached rendering)
+ expect(text).toContain('class Strategy(bt.Strategy)');
+ // generation renders as the actual number, never a truncated-slice crash
+ expect(text).toMatch(/草稿代\s*3(?:\s|$|·)/);
+ expect(text).not.toContain('slice is not a function');
+ } finally { cleanup(); }
+ });
+
+ it('client PUT sends numeric expected_generation and accepts numeric response', async () => {
+ const seen = [];
+ vi.stubGlobal('fetch', (url, opts = {}) => {
+ seen.push([String(url), opts]);
+ return Promise.resolve(json({ id: PROJECT_ID, draft_code: 'x=1', draft_generation: 4 }));
+ });
+ const res = await putDraft(PROJECT_ID, 'x=1', 3);
+ expect(res.draft_generation).toBe(4);
+ expect(typeof res.draft_generation).toBe('number');
+ const [, sentOpts] = seen[0];
+ const sentBody = typeof sentOpts.body === 'string' ? JSON.parse(sentOpts.body) : sentOpts.body;
+ expect(sentBody.expected_generation).toBe(3);
+ expect(typeof sentBody.expected_generation).toBe('number');
+ });
+
+ it('409 conflict keeps stale_generation code (numeric retry flow preserved)', async () => {
+ vi.stubGlobal('fetch', (url) => {
+ if (/\/draft$/.test(String(url)))
+ return Promise.resolve(new Response(JSON.stringify({ error: { code: 'stale_generation', message: 'g3' } }), { status: 409, headers: { 'content-type': 'application/json' } }));
+ return Promise.resolve(json({}));
+ });
+ let err;
+ await putDraft(PROJECT_ID, 'stale', 2).catch((e) => { err = e; });
+ expect(err).toBeInstanceOf(ApiError);
+ expect(err.status).toBe(409);
+ expect(err.code).toBe('stale_generation');
+ });
+});
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
new file mode 100644
index 0000000..db065e4
--- /dev/null
+++ b/frontend/src/lib/types.ts
@@ -0,0 +1,244 @@
+import type { CoverageEntry } from './coverage';
+
+export type { CoverageEntry };
+
+export interface User {
+ id: string;
+ email: string;
+ name: string;
+ role: 'admin' | 'member';
+ active: boolean;
+ daily_run_limit?: number | null;
+ ai_enabled?: boolean;
+ created_at?: string;
+}
+
+export interface Project {
+ id: string;
+ name: string;
+ description: string | null;
+ draft_code: string;
+ draft_generation: number;
+ draft_code_preview?: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface Version {
+ id: string;
+ project_id: string;
+ message: string;
+ hash: string;
+ source: 'manual' | 'run' | 'ai' | 'restore';
+ created_at: string;
+ code?: string;
+}
+
+export interface Instrument {
+ symbol: string;
+ market: string;
+ asset_type: 'stock' | 'etf' | 'index';
+ name: string | null;
+ currency: string | null;
+ source?: string | null;
+}
+
+export interface InstrumentSearchItem extends Instrument {
+ symbol_requested?: string;
+}
+
+export interface InstrumentSearchResponse {
+ items: Instrument[];
+ source: 'provider_suggest' | 'operator_entry' | 'none';
+ status: string;
+}
+
+export interface DatasetManifestObject {
+ instrument: string;
+ market?: string;
+ asset_type?: string;
+ provider: string;
+ endpoint?: string;
+ params?: Record<string, unknown>;
+ akshare_version?: string;
+ fetched_at?: string;
+ requested_start: string;
+ requested_end: string;
+ actual_start: string | null;
+ actual_end: string | null;
+ row_count: number;
+ columns: string[];
+ adjustment: string;
+ frequency?: string;
+ warnings?: string[];
+}
+
+export interface DatasetRequest {
+ name?: string;
+ instruments: Instrument[];
+ start_date: string;
+ end_date: string;
+ frequency: 'daily';
+ adjustment: 'none' | 'qfq' | 'hfq';
+ fields: string[];
+}
+
+export interface Dataset {
+ id: string;
+ name: string | null;
+ request: DatasetRequest;
+ status: 'pending' | 'running' | 'ready' | 'failed';
+ manifest?: DatasetManifest | null;
+ manifest_hash?: string | null;
+ error?: string | null;
+ cache_hit?: boolean;
+ created_at: string;
+}
+
+export interface DatasetManifest {
+ version?: string;
+ hash?: string;
+ objects: DatasetManifestObject[];
+ columns?: string[];
+}
+
+export interface DatasetPreview {
+ columns: string[];
+ rows: Record<string, unknown>[];
+ coverage: CoverageEntry[];
+ warnings: string[];
+ truncated?: boolean;
+}
+
+export interface RunConfig {
+ capital: number;
+ commission: number;
+ slippage: number;
+ benchmark_symbol?: string | null;
+ parameters?: Record<string, unknown>;
+}
+
+export interface Run {
+ id: string;
+ project_id: string;
+ version_id: string;
+ dataset_id: string;
+ status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
+ config: RunConfig;
+ created_at: string;
+ started_at?: string | null;
+ finished_at?: string | null;
+ error?: string | null;
+ result?: BacktestResult | null;
+}
+
+export interface Trade {
+ date: string;
+ symbol: string;
+ side: string;
+ quantity: number;
+ price: number;
+ commission: number;
+ value: number;
+}
+
+export interface OrderRecord {
+ date?: string;
+ symbol?: string;
+ side?: string;
+ quantity?: number;
+ price?: number | null;
+ state?: string;
+ value?: number | null;
+ commission?: number | null;
+}
+
+export interface BacktestMetrics {
+ total_return?: number | null;
+ annual_return?: number | null;
+ max_drawdown?: number | null;
+ sharpe?: number | null;
+ /** closed ROUND-TRIP count (open fully closed = 1), NOT fill count */
+ trade_count?: number | null;
+ final_equity?: number | null;
+}
+
+export interface BacktestResult {
+ equity: { date: string; equity: number; cash: number; benchmark?: number | null }[];
+ orders?: OrderRecord[];
+ trades: Trade[];
+ metrics: BacktestMetrics;
+ logs: string[];
+ engine: { name: string; version: string };
+ warnings: string[];
+ elapsed_ms?: number | null;
+ peak_rss_kb?: number | null;
+ data_manifest_hash?: string;
+}
+
+export interface RunListItem extends Run {
+ project_name?: string;
+}
+
+export interface AIUsageItem {
+ id: string;
+ project_id?: string;
+ model: string;
+ input_tokens?: number;
+ output_tokens?: number;
+ status?: string;
+ created_at: string;
+}
+
+export interface AIUsage {
+ items: AIUsageItem[];
+ totals?: { input_tokens?: number; output_tokens?: number; requests?: number };
+ internal_poc?: boolean;
+}
+
+export interface AIAssist {
+ id: string;
+ model: string;
+ explanation: string;
+ proposed_code: string;
+ diff: string | null;
+ base_generation: number;
+ usage?: { input_tokens?: number; output_tokens?: number } | null;
+ status?: string;
+}
+
+export interface Invitation {
+ id: string;
+ email?: string | null;
+ role?: string;
+ token?: string | null;
+ expires_at?: string;
+ used_at?: string | null;
+ revoked?: boolean;
+ created_by?: string;
+ created_at?: string;
+}
+
+export interface AdminUserUpdate extends Partial<Omit<User, 'id' | 'email' | 'created_at'>> {}
+
+export interface Capabilities {
+ frequencies: string[];
+ asset_types: string[];
+ adjustments: { code: string; label: string }[];
+ fields: { code: string; label: string; raw?: boolean }[];
+ limits: {
+ max_symbols: number;
+ max_years: number;
+ max_code_length: number;
+ };
+ internal_only: boolean;
+}
+
+export interface SessionInfo {
+ id: string;
+ created_at?: string;
+ last_seen_at?: string | null;
+ user_agent?: string | null;
+ expires_at?: string | null;
+ current?: boolean;
+}
diff --git a/frontend/src/lib/wizardError.test.ts b/frontend/src/lib/wizardError.test.ts
new file mode 100644
index 0000000..43a6bb0
--- /dev/null
+++ b/frontend/src/lib/wizardError.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from 'vitest';
+// Live browser QA regression: a failing dataset submit (e.g. HTTP422/500 from
+// the backend) was captured into `submitErr` but never rendered, so the user
+// saw silently nothing. The wizard markup must surface submitErr.
+// Type support comes from vite/client's `*?raw` module declaration.
+import wizardSource from '../components/DatasetWizard.svelte?raw';
+
+describe('dataset wizard visible submit errors', () => {
+ it('renders backend submit failures (submitErr in template, not script-only)', () => {
+ const src: string = wizardSource as unknown as string;
+ // captured at least once
+ expect(src).toMatch(/submitErr = extractStatusMessage\(e\)/);
+ // and rendered outside the script: a template reference (Svelte template)
+ const template = src.slice(src.indexOf('/script') + 7);
+ expect(template).toMatch(/\{submitErr\}/);
+ expect(template).toMatch(/role="alert"/);
+ });
+});