// @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(() => {}); } 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(); } }); });