summaryrefslogtreecommitdiff
path: root/frontend/src/lib/sidebarCurrentProject.test.ts
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/sidebarCurrentProject.test.ts
downloadstrategy-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.ts186
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();
+ }
+ });
+});