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/sidebarCurrentProject.test.ts | |
| download | strategy-lab-5c0ba37eda80d39e6ceca59bb1d5f4942f858995.tar.gz | |
chore: establish Strategy Lab source baseline (development, not release)
Diffstat (limited to 'frontend/src/lib/sidebarCurrentProject.test.ts')
| -rw-r--r-- | frontend/src/lib/sidebarCurrentProject.test.ts | 186 |
1 files changed, 186 insertions, 0 deletions
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(); + } + }); +}); |
