diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-17 14:32:37 +0800 |
| commit | 5c0ba37eda80d39e6ceca59bb1d5f4942f858995 (patch) | |
| tree | 948723f9cedf7ccb0707fa6ee516bd30fe20fd10 /frontend/src/lib | |
| download | strategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz | |
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/lib')
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"/); + }); +}); |
