summaryrefslogtreecommitdiff
path: root/frontend/src/lib/sidebarCurrentProject.test.ts
blob: 5dc9b9de95d29775a0399fea960363e5ac6b33b5 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
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();
    }
  });
});