diff --git a/__tests__/mcp-daemon.test.ts b/__tests__/mcp-daemon.test.ts index eac53516f7..bcaeb63ab1 100644 --- a/__tests__/mcp-daemon.test.ts +++ b/__tests__/mcp-daemon.test.ts @@ -224,6 +224,9 @@ describe('Shared MCP daemon (issue #411)', () => { servers.push(server); sendInitialize(server.child, `file://${tempDir}`, 1); await waitFor(() => findResponse(server.stdout, 1), 10000); + // The proxy answers initialize locally and the pidfile precedes listen. + // Wait for an actual daemon handshake before opening the raw client. + await waitFor(() => server.stderr.some((l) => l.includes('Attached to shared daemon')), 10000); const pid = await waitFor(() => readLockPid(realRoot), 10000); const raw = net.connect(getDaemonSocketPath(realRoot)); try { diff --git a/__tests__/migrations-pinable-compat.test.ts b/__tests__/migrations-pinable-compat.test.ts new file mode 100644 index 0000000000..0dd54821a5 --- /dev/null +++ b/__tests__/migrations-pinable-compat.test.ts @@ -0,0 +1,129 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { DatabaseConnection } from '../src/db'; +import { createDatabase, type SqliteDatabase } from '../src/db/sqlite-adapter'; +import { + CURRENT_SCHEMA_VERSION, getCurrentVersion, getMigrationHistory, + getPendingMigrations, runMigrations, +} from '../src/db/migrations'; + +const cursorIndexes = { + idx_edges_source_id: ['source', 'id'], + idx_edges_target_id: ['target', 'id'], + idx_nodes_file_id: ['file_path', 'id'], +}; +type Legacy = 'v9' | 'pinable-v10' | 'upstream-v10' | 'upstream-v11'; + +describe('Pinable/upstream schema-version collision (#2060)', () => { + let dir: string | undefined; + let connection: DatabaseConnection | undefined; + + afterEach(() => { + connection?.close(); + connection = undefined; + if (dir) fs.rmSync(dir, { recursive: true, force: true }); + dir = undefined; + }); + + function fixture(origin: Legacy): { dbPath: string; version: number; ordinaryMetadata: string } { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-pinable-migration-')); + const dbPath = path.join(dir, 'index.db'); + const raw = createDatabase(dbPath).db; + const version = origin === 'v9' ? 9 : origin === 'upstream-v11' ? 11 : 10; + const legacySynthesis = origin === 'v9' || origin === 'pinable-v10'; + const ordinaryMetadata = origin === 'upstream-v10' ? '{"keep":true}' : 'malformed ordinary metadata'; + try { + // Keep the full real SQLite schema, then remove only the features that + // these historical versions did not contain. No database mocking. + raw.exec(fs.readFileSync(path.join(__dirname, '../src/db/schema.sql'), 'utf8')); + raw.exec('DELETE FROM schema_versions'); + raw.prepare('INSERT INTO schema_versions VALUES (?, 0, ?)').run(version, origin); + if (origin !== 'pinable-v10') { + for (const index of Object.keys(cursorIndexes)) raw.exec(`DROP INDEX ${index}`); + } + if (legacySynthesis) { + raw.exec(`DROP TABLE synthesis_inputs; + DROP INDEX idx_edges_synthesis_site; + DROP INDEX idx_nodes_kind; + CREATE INDEX idx_nodes_kind ON nodes(kind);`); + } else { + raw.prepare('INSERT INTO project_metadata VALUES (?, ?, ?)').run('synthesis_pending', '0', 7); + if (origin === 'upstream-v10') { + raw.exec(`DROP INDEX idx_edges_synthesis_site; + CREATE INDEX idx_edges_synthesis_site ON edges(json_extract(metadata, '$.registeredAt')) + WHERE json_extract(metadata, '$.synthesizedBy') IS NOT NULL;`); + } + } + raw.prepare('INSERT INTO project_metadata VALUES (?, ?, ?)').run('user_marker', 'unchanged', 7); + for (const [id, kind, file] of [['owner', 'struct', 'types.go'], ['method', 'method', 'methods.go']]) { + raw.prepare(`INSERT INTO files(path, content_hash, language, size, modified_at, indexed_at) + VALUES (?, 'hash', 'go', 100, 0, 0)`).run(file); + raw.prepare(`INSERT INTO nodes(id, kind, name, qualified_name, file_path, language, + start_line, end_line, start_column, end_column, updated_at) + VALUES (?, ?, ?, ?, ?, 'go', 1, 2, 0, 1, 0)`).run(id, kind, id, id, file); + } + const containment = legacySynthesis ? { keep: true } : { keep: true, synthesizedBy: 'go-method-contains' }; + raw.prepare("INSERT INTO edges(source, target, kind, metadata) VALUES ('owner', 'method', 'contains', ?)") + .run(JSON.stringify(containment)); + raw.prepare("INSERT INTO edges(source, target, kind, metadata) VALUES ('owner', 'method', 'calls', ?)") + .run(ordinaryMetadata); + } finally { raw.close(); } + return { dbPath, version, ordinaryMetadata }; + } + + function assertSchema(db: SqliteDatabase): void { + expect(getCurrentVersion(db)).toBe(CURRENT_SCHEMA_VERSION); + expect(CURRENT_SCHEMA_VERSION).toBe(12); + expect(getPendingMigrations(db)).toEqual([]); + expect(() => db.prepare('SELECT file_path FROM synthesis_inputs').all()).not.toThrow(); + for (const [index, columns] of Object.entries({ + ...cursorIndexes, idx_nodes_kind: ['kind', 'file_path', 'start_line', 'id'], + })) { + expect(db.prepare(`PRAGMA index_info(${index})`).all().map(row => row.name)).toEqual(columns); + } + const sql = db.prepare("SELECT sql FROM sqlite_master WHERE name = 'idx_edges_synthesis_site'").get().sql; + expect(sql).toContain('json_valid(metadata)'); + } + + it.each(['v9', 'pinable-v10', 'upstream-v10', 'upstream-v11']) + ('opens %s without losing graph data, synthesis state or pagination indexes', origin => { + const { dbPath, version, ordinaryMetadata } = fixture(origin); + connection = DatabaseConnection.open(dbPath); + const db = connection.getDb(); + assertSchema(db); + expect(db.prepare('SELECT id FROM nodes ORDER BY id').all()).toEqual([{ id: 'method' }, { id: 'owner' }]); + expect(db.prepare('SELECT COUNT(*) AS n FROM edges').get().n).toBe(2); + expect(db.prepare('SELECT COUNT(*) AS n FROM files').get().n).toBe(2); + expect(db.prepare("SELECT value FROM project_metadata WHERE key = 'user_marker'").get().value).toBe('unchanged'); + expect(db.prepare("SELECT metadata FROM edges WHERE kind = 'calls'").get().metadata).toBe(ordinaryMetadata); + expect(JSON.parse(db.prepare("SELECT metadata FROM edges WHERE kind = 'contains'").get().metadata)) + .toEqual({ keep: true, synthesizedBy: 'go-method-contains' }); + expect(db.prepare("SELECT value FROM project_metadata WHERE key = 'synthesis_pending'").get().value) + .toBe(origin === 'v9' || origin === 'pinable-v10' ? '1' : '0'); + expect(db.prepare('SELECT description FROM schema_versions WHERE version = ?').get(version).description).toBe(origin); + + // After synthesis completes, a no-op reopen and a replay of the new + // additive migration must not dirty the graph or request synthesis again. + db.exec("UPDATE project_metadata SET value = '0' WHERE key = 'synthesis_pending'"); + const rows = db.prepare('SELECT * FROM edges ORDER BY id').all(); + const history = getMigrationHistory(db); + runMigrations(db, getCurrentVersion(db)); + expect(getMigrationHistory(db)).toEqual(history); + connection.close(); + connection = DatabaseConnection.open(dbPath); + expect(getMigrationHistory(connection.getDb())).toEqual(history); + connection.getDb().exec('DELETE FROM schema_versions WHERE version = 12'); + runMigrations(connection.getDb(), 11); + assertSchema(connection.getDb()); + expect(connection.getDb().prepare('SELECT * FROM edges ORDER BY id').all()).toEqual(rows); + expect(connection.getDb().prepare("SELECT value FROM project_metadata WHERE key = 'synthesis_pending'").get().value).toBe('0'); + }); + + it('gives a fresh database the same synthesis and cursor schema', () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-pinable-fresh-')); + connection = DatabaseConnection.initialize(path.join(dir, 'index.db')); + assertSchema(connection.getDb()); + }); +}); diff --git a/__tests__/ui-package.test.ts b/__tests__/ui-package.test.ts index 901d03ceb0..1bb15d3877 100644 --- a/__tests__/ui-package.test.ts +++ b/__tests__/ui-package.test.ts @@ -17,6 +17,7 @@ * component reaches past the adapter for anything. */ +import type { WireNodeRefs } from '../ui/src/lib/wire'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { flushSync, mount, unmount } from 'svelte'; @@ -836,6 +837,652 @@ describe('@colbymchenry/codegraph-ui — the published shape', () => { }); }); +describe('工作区路由与请求隔离', () => { + it('解析项目路径并继续兼容旧链接', async () => { + const { parseHash } = await import('../ui/src/lib/router.svelte'); + expect(parseHash('#/p/alpha/s/function%3Ax?hl=3')).toMatchObject({ projectId: 'alpha', route: { view: 'symbol', id: 'function:x', line: 3 } }); + expect(parseHash('#/s/function%3Ax').projectId).toBeNull(); + expect(parseHash('#/workspace').route.view).toBe('workspace'); + }); + it('项目切换后的迟到统计不能覆盖新项目', async () => { + const { project } = await import('../ui/src/lib/project.svelte'); + const { setGraphAdapter } = await import('../ui/src/lib/adapter'); + let resolveOld!: (value: unknown) => void; + setGraphAdapter({ stats: () => new Promise(resolve => { resolveOld = resolve; }) } as never); + project.resetProject(); + const old = project.ensure(); + project.resetProject(); + setGraphAdapter({ stats: async () => ({ project: { name: '新项目' } }) } as never); + await project.ensure(); + resolveOld({ project: { name: '旧项目' } }); + await old; + expect(project.name).toBe('新项目'); + project.resetProject(); + setGraphAdapter(null); + }); + it('项目切换复用工作区摘要,不重复请求完整统计', async () => { + const { project } = await import('../ui/src/lib/project.svelte'); + const { setGraphAdapter } = await import('../ui/src/lib/adapter'); + const stats = { project: { name: '大型项目' }, graph: { nodes: 1_632_270, edges: 3_124_664, files: 65_791 } } as never; + const fetch = vi.fn(); + setGraphAdapter({ stats: fetch } as never); + project.resetProject(stats); + await project.ensure(); + expect(project.name).toBe('大型项目'); + expect(project.stats).toEqual(stats); + expect(fetch).not.toHaveBeenCalled(); + project.resetProject(); + setGraphAdapter(null); + }); +}); + +it('搜索返回恢复查询、项目范围和结果滚动', async () => { + const { default: WorkspaceSearch } = await import('../ui/src/components/WorkspaceSearch.svelte'); + const { workspace } = await import('../ui/src/lib/workspace.svelte'); + const projects = vi.spyOn(workspace, 'projects', 'get').mockReturnValue([{ id: 'beta', name: 'Beta', available: true }]); + const search = vi.spyOn(workspace, 'search').mockResolvedValue({ results: [{ projectId: 'beta', projectName: 'Beta', node: { id: 'function:x', name: 'target', kind: 'function', file: 'src/x.ts', line: 1, matchKind: 'exact' } as never }], incomplete: [], limited: false }); + mounted = mount(WorkspaceSearch, { target: host }); + const input = host.querySelector('input')!; + input.value = 'target path:src'; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new FocusEvent('focus')); + flushSync(); + await new Promise(resolve => setTimeout(resolve, 220)); flushSync(); + expect(search).toHaveBeenLastCalledWith('target path:src', '', expect.any(AbortSignal)); + const scope = host.querySelector('select')!; scope.value = 'beta'; scope.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); + await new Promise(resolve => setTimeout(resolve, 220)); flushSync(); + const panel = host.querySelector('.results') as HTMLElement; + panel.scrollTop = 120; panel.dispatchEvent(new Event('scroll')); + const link = panel.querySelector('a')!; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await Promise.resolve(); + expect(document.activeElement).toBe(link); + expect(link.getAttribute('href')).toBe('#/p/beta/s/function%3Ax'); + link.dispatchEvent(new MouseEvent('click', { bubbles: true })); flushSync(); + expect(host.querySelector('.results')).toBeNull(); + window.dispatchEvent(new PopStateEvent('popstate')); flushSync(); + await Promise.resolve(); + expect(input.value).toBe('target path:src'); + expect((host.querySelector('select') as HTMLSelectElement).value).toBe('beta'); + expect((host.querySelector('.results') as HTMLElement).scrollTop).toBe(120); + expect(search).toHaveBeenLastCalledWith('target path:src', 'beta', expect.any(AbortSignal)); + search.mockRestore(); projects.mockRestore(); +}); + +it('项目切换隔离迟到入口、路径名称、保存路径和实时事件', async () => { + const { palette } = await import('../ui/src/lib/palette.svelte'); + const { trails } = await import('../ui/src/lib/trails.svelte'); + const { resolveTrailNames } = await import('../ui/src/lib/trail.svelte'); + let entryResolve!: (value: unknown) => void; + let trailResolve!: (value: unknown) => void; + let refsResolve!: (value: unknown) => void; + let handlers: import('../ui/src/lib/adapter').LiveHandlers | undefined; + let closed = 0; + setGraphAdapter({ + entryPoints: () => new Promise(resolve => { entryResolve = resolve; }), + trails: () => new Promise(resolve => { trailResolve = resolve; }), + nodes: () => new Promise(resolve => { refsResolve = resolve; }), + events: (next: import('../ui/src/lib/adapter').LiveHandlers) => { handlers = next; return () => { closed++; }; }, + } as never); + palette.resetProject(); trails.resetProject(); trail.resetProject(); live.stop(); + const entriesRequest = palette.ensureEntries(); + const trailsRequest = trails.reload(); + trail.push({ id: 'same-id' }); + const namesRequest = resolveTrailNames(); + live.start(); + palette.resetProject(); trails.resetProject(); trail.resetProject(); live.stop(); + trail.push({ id: 'same-id', name: '新名称' }); + entryResolve({ entries: ['旧入口'] }); trailResolve({ trails: [{ id: 'old' }] }); + refsResolve({ items: [{ id: 'same-id', name: '旧名称', kind: 'function' }], missing: [] }); + handlers?.index?.({ type: 'index', at: 1 }); + await Promise.all([entriesRequest, trailsRequest, namesRequest]); + expect(palette.entries).toBeNull(); expect(palette.entriesSettled).toBe(false); + expect(trails.list).toEqual([]); expect(trails.settled).toBe(false); + expect(trail.current?.name).toBe('新名称'); + expect(live.indexTick).toBe(0); expect(closed).toBe(1); + trail.resetProject(); setGraphAdapter(null); +}); + +it('一万条关系逐页浏览,保留调用位置并限制当前DOM为50条', async () => { + const { default: SymbolRelationships } = await import('../ui/src/components/SymbolRelationships.svelte'); + const requests: number[] = []; + setGraphAdapter({ neighbors: async (request: { cursor?: string }) => { + const start = Number(request.cursor ?? 0); requests.push(start); + return { items: Array.from({ length: 50 }, (_, i) => ({ edge: { id: `e${start + i}`, kind: 'calls', source: 'hub', target: 'target', file: 'src/hub.ts', line: start + i + 1 }, node: { id: 'target', kind: 'function', name: 'target', file: 'src/hub.ts', line: 5 } })), nextCursor: start + 50 < 10000 ? String(start + 50) : null, revision: 'r1' }; + } } as never); + mounted = mount(SymbolRelationships, { target: host, props: { id: 'hub', file: 'src/hub.ts' } }); flushSync(); + (host.querySelector('[data-open]') as HTMLButtonElement).click(); flushSync(); + await settle(); + expect(host.querySelectorAll('[data-relationship]')).toHaveLength(50); + expect(host.textContent).toContain('src/hub.ts:1'); + (host.querySelector('[data-next]') as HTMLButtonElement).click(); await settle(); + expect(requests).toEqual([0, 50]); + expect(host.querySelectorAll('[data-relationship]')).toHaveLength(50); + expect(host.textContent).toContain('src/hub.ts:51'); + expect(host.querySelector('[data-site]')?.getAttribute('href')).toBe('#/file/src/hub.ts?src=1&hl=51'); + for (let page = 2; page < 200; page++) { (host.querySelector('[data-next]') as HTMLButtonElement).click(); await settle(); } + expect(requests).toHaveLength(200); + expect(host.querySelectorAll('[data-relationship]')).toHaveLength(50); + expect(host.textContent).toContain('src/hub.ts:10000'); + expect((host.querySelector('[data-next]') as HTMLButtonElement).disabled).toBe(true); +}, 15000); + +async function settle() { for (let i = 0; i < 3; i++) { await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); } } + +it('关系方向变更取消旧页并隔离迟到响应,失效游标可从第一页恢复', async () => { + const { default: SymbolRelationships } = await import('../ui/src/components/SymbolRelationships.svelte'); + const pending: Array<{ request: { direction: string; cursor?: string }; signal: AbortSignal; resolve: (value: unknown) => void; reject: (error: Error) => void }> = []; + setGraphAdapter({ neighbors: (request: never, signal: AbortSignal) => new Promise((resolve, reject) => pending.push({ request, signal, resolve, reject })) } as never); + mounted = mount(SymbolRelationships, { target: host, props: { id: 'hub', file: 'src/hub.ts' } }); flushSync(); + (host.querySelector('[data-open]') as HTMLButtonElement).click(); flushSync(); + const select = host.querySelector('select')!; select.value = 'out'; select.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); + expect(pending[0].signal.aborted).toBe(true); + expect(pending[1].request.direction).toBe('out'); + const answer = (name: string, cursor: string | null) => ({ items: [{ edge: { kind: 'calls', line: 12 }, node: { id: name, name, file: 'src/target.ts', line: 3 } }], nextCursor: cursor, revision: 'r1' }); + pending[1].resolve(answer('当前结果', 'old-cursor')); await settle(); + pending[0].resolve(answer('迟到结果', null)); await settle(); + expect(host.textContent).toContain('当前结果'); expect(host.textContent).not.toContain('迟到结果'); + (host.querySelector('[data-next]') as HTMLButtonElement).click(); flushSync(); + expect(pending[2].request.cursor).toBe('old-cursor'); + pending[2].reject(new Error('索引已经更新,请重新加载列表。')); await settle(); + (host.querySelector('[data-retry]') as HTMLButtonElement).click(); flushSync(); + expect(pending[3].request.cursor).toBeUndefined(); + (host.querySelector('[data-open]') as HTMLButtonElement).click(); flushSync(); + expect(pending[3].signal.aborted).toBe(true); + pending[3].resolve(answer('关闭后结果', null)); await settle(); + expect(host.querySelectorAll('[data-relationship]')).toHaveLength(0); +}); + +it('旧适配器不显示全部关系入口', async () => { + const { default: SymbolRelationships } = await import('../ui/src/components/SymbolRelationships.svelte'); + setGraphAdapter({} as never); + mounted = mount(SymbolRelationships, { target: host, props: { id: 'hub', file: 'src/hub.ts' } }); flushSync(); + expect(host.querySelector('[data-open]')).toBeNull(); +}); + +it('工作区重新加载恢复离线项目且保留其他项目阅读上下文', async () => { + const { workspace } = await import('../ui/src/lib/workspace.svelte'); + const { default: WorkspaceOverview } = await import('../ui/src/components/WorkspaceOverview.svelte'); + let online = false; + const fetcher = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => new Response(JSON.stringify({ name: '恢复测试', projects: [{ id: 'recover', name: '可恢复项目', available: online, error: online ? undefined : '索引离线' }, { id: 'other', name: '其他项目', available: true }] }))); + workspace.remember('other', '#/p/other/map?depth=3'); + await workspace.ensure(); + mounted = mount(WorkspaceOverview, { target: host }); flushSync(); + expect(host.textContent).toContain('索引离线'); + const retry = host.querySelector('[data-workspace-retry]') as HTMLButtonElement; + expect(retry).not.toBeNull(); + online = true; retry.click(); await settle(); + expect(workspace.projects.find(item => item.id === 'recover')?.available).toBe(true); + expect(workspace.href('other')).toBe('#/p/other/map?depth=3'); + expect(fetcher).toHaveBeenCalledTimes(2); + fetcher.mockRestore(); +}); + +it('当前离线项目可重试恢复,搜索与其他项目上下文不被清除', async () => { + const { workspace } = await import('../ui/src/lib/workspace.svelte'); + let online = false; + let reloadResolve: ((value: Response) => void) | undefined; + const overview = () => ({ name: '恢复工作台', projects: [{ id: 'recover', name: 'Recover', available: online, error: online ? undefined : '索引离线' }, { id: 'other', name: 'Other', available: true }] }); + const fetcher = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => { + const url = String(input); + if (url === 'api/workspace') { + if (online) return new Promise(resolve => { reloadResolve = resolve; }); + return new Response(JSON.stringify(overview())); + } + if (url.includes('workspace/search')) return new Response(JSON.stringify({ results: [], incomplete: [], limited: false })); + if (url.endsWith('/stats')) return new Response(JSON.stringify(STATS)); + return new Response(JSON.stringify({ error: 'fixture' }), { status: 404 }); + }); + await workspace.reload(); + workspace.remember('other', '#/p/other/map?depth=3'); + workspace.remember('recover', '#/p/recover/map?root=src&depth=3'); + workspace.remember('recover', '#/p/recover/steps?anchor=function%3Ahandler'); + hashNavigation.navigate('#/p/recover/'); + const { default: App } = await import('../ui/src/App.svelte'); + mounted = mount(App, { target: host }); await settle(); + expect(host.textContent).toContain('索引离线'); + const input = host.querySelector('input[aria-label="全局搜索"]')!; + input.value = 'target path:src'; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new FocusEvent('focus')); flushSync(); + await new Promise(resolve => setTimeout(resolve, 210)); flushSync(); + online = true; + const retry = host.querySelector('[data-workspace-retry]')!; + retry.click(); flushSync(); + expect(retry.disabled).toBe(true); + const inFlight = workspace.reload(); + reloadResolve!(new Response(JSON.stringify(overview()))); + await inFlight; await settle(); + expect(workspace.activeId).toBe('recover'); + expect(host.querySelector('aside a[title="架构依赖"]')?.getAttribute('href')).toBe('#/p/recover/map?root=src&depth=3'); + expect(host.querySelector('aside a[title="执行链路"]')?.getAttribute('href')).toBe('#/p/recover/steps?anchor=function%3Ahandler'); + expect(host.querySelector('footer')?.textContent).toContain('索引总量'); + expect(host.querySelector('footer')?.textContent).not.toContain('当前显示'); + expect(host.textContent).not.toContain('索引离线'); + expect(input.value).toBe('target path:src'); + expect(host.querySelector('.results')).not.toBeNull(); + expect(workspace.href('other')).toBe('#/p/other/map?depth=3'); + expect(fetcher.mock.calls.filter(([url]) => String(url) === 'api/workspace')).toHaveLength(2); + fetcher.mockRestore(); +}); + +it.each(['map', 'screens', 'steps', 'flow'] as const)('%s 忽略取消的迟到请求不能覆盖最新索引响应', async (kind) => { + const pending: Array<(value: unknown) => void> = []; + const { adapter } = mockAdapter(); + setGraphAdapter({ ...adapter, [kind]: () => new Promise(resolve => pending.push(resolve)) } as GraphAdapter); + const components = { + map: ArchitectureMap, + screens: (await import('../ui/src/views/ScreensView.svelte')).default, + steps: (await import('../ui/src/views/StepsView.svelte')).default, + flow: FlowStrip, + }; + const props = kind === 'map' ? { root: null, depth: 2, tests: false } + : kind === 'steps' ? { anchor: 'anchor', symbol: null, depth: 4, through: false, reading: 'tree' } + : kind === 'flow' ? { from: 'from', to: 'to', symbols: null, trailParam: null } : {}; + await render(components[kind], props); + live.signal('index'); flushSync(); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + expect(pending).toHaveLength(2); + const response = (latest: boolean) => { + const marker = latest ? 'CURRENT_RESPONSE' : 'STALE_RESPONSE'; + const budget = { exceeded: true, nodes: latest ? 410002 : 410001, edges: 0, maxNodes: 400, maxEdges: 2000 }; + if (kind === 'map') return { ...MAP, roots: [{ root: 'src', label: marker, files: 2 }] }; + if (kind === 'flow') return { ...FLOW, flows: FLOW.flows.map(flow => ({ ...flow, label: marker })) }; + if (kind === 'steps') return { anchor: { ...SYMBOL.node, name: marker }, steps: [], links: [], program: null, defaultView: 'tree', budget }; + return { routed: true, screens: [], origins: [], links: [], budget }; + }; + pending[1]!(response(true)); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + const marker = kind === 'screens' ? '410002' : 'CURRENT_RESPONSE'; + expect(host.textContent).toContain(marker); + pending[0]!(response(false)); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + expect(host.textContent).toContain(marker); + expect(host.textContent).not.toContain(kind === 'screens' ? '410001' : 'STALE_RESPONSE'); +}); + +it('Steps入口列表忽略取消后迟到的routes响应', async () => { + const pending: Array<(value: unknown) => void> = []; + const { adapter } = mockAdapter(); + setGraphAdapter({ ...adapter, steps: async () => ({}), screens: async () => ({ routed: false, screens: [] }), routes: () => new Promise(resolve => pending.push(resolve)) } as unknown as GraphAdapter); + const Steps = (await import('../ui/src/views/StepsView.svelte')).default; + await render(Steps, { anchor: null, symbol: null, depth: 4, through: false, reading: 'tree' }); + live.signal('index'); flushSync(); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + const response = (name: string) => ({ routed: true, entries: [{ routeId: name, routeFile: 'src/routes.ts', routeLine: 1, url: name, handler: name }] }); + pending[1]!(response('CURRENT_ROUTE')); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + pending[0]!(response('STALE_ROUTE')); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + expect(host.textContent).toContain('CURRENT_ROUTE'); + expect(host.textContent).not.toContain('STALE_ROUTE'); +}); + +it('目录游标失效后可以重新加载第一页并丢弃旧游标', async () => { + const DirectoryBrowser = (await import('../ui/src/components/graph/DirectoryBrowser.svelte')).default; + const requests: Array<{ kind: string; cursor?: string }> = []; + let refreshed = false; + const { adapter } = mockAdapter(); + setGraphAdapter({ ...adapter, browse: async request => { + requests.push(request); + if (request.cursor) throw new Error('stale cursor'); + return { items: request.kind === 'files' ? [{ kind: 'file', path: refreshed ? 'src/new.ts' : 'src/old.ts' }] : [], nextCursor: request.kind === 'files' && !refreshed ? 'expired' : null, revision: refreshed ? 'v2' : 'v1' }; + } }); + await render(DirectoryBrowser, { root: 'src', onOpen: () => {} }); + const more = [...host.querySelectorAll('button')].find(button => /加载更多文件|Load more files/.test(button.textContent ?? ''))!; + more.click(); flushSync(); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + expect(host.textContent).toContain('stale cursor'); + refreshed = true; + const reload = [...host.querySelectorAll('button')].find(button => /重新加载第一页|Reload first page/.test(button.textContent ?? ''))!; + reload.click(); flushSync(); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + expect(host.textContent).toContain('src/new.ts'); + expect(host.textContent).not.toContain('src/old.ts'); + expect(host.textContent).not.toContain('stale cursor'); + expect(requests.filter(request => request.cursor)).toHaveLength(1); +}); + +it('项目阅读上下文分别恢复架构范围和执行入口', async () => { + const { workspace } = await import('../ui/src/lib/workspace.svelte'); + workspace.remember('context-a', '#/p/context-a/map?root=src&depth=3'); + workspace.remember('context-a', '#/p/context-a/steps?anchor=function%3Ahandler&depth=4'); + workspace.remember('context-b', '#/p/context-b/map?root=lib&depth=2'); + expect(workspace.href('context-a')).toBe('#/p/context-a/steps?anchor=function%3Ahandler&depth=4'); + expect(workspace.href('context-a', 'map')).toBe('#/p/context-a/map?root=src&depth=3'); + expect(workspace.href('context-a', 'steps')).toBe('#/p/context-a/steps?anchor=function%3Ahandler&depth=4'); + expect(workspace.href('context-a', 'execution')).toBe('#/p/context-a/steps?anchor=function%3Ahandler&depth=4'); + expect(workspace.href('context-b', 'map')).toBe('#/p/context-b/map?root=lib&depth=2'); + expect(workspace.href('context-a', 'flow')).toBe('#/p/context-a/flow'); +}); + +it('画布状态销毁与项目重置不能清除新画布状态', async () => { + const { graphStatus } = await import('../ui/src/lib/graph-status.svelte'); + const old = graphStatus.set({ nodes: 10, edges: 20 }); + const next = graphStatus.set({ nodes: 30, edges: 40, scope: 'src' }); + old(); expect(graphStatus.current?.nodes).toBe(30); + graphStatus.resetProject(); expect(graphStatus.current).toBeNull(); + const current = graphStatus.set({ nodes: 1, edges: 2 }); + next(); expect(graphStatus.current?.nodes).toBe(1); + current(); expect(graphStatus.current).toBeNull(); +}); + +it('Flow已返回路径而Worker尚未完成时显示布局中,不误称没有路径', async () => { + class PausedWorker { + static instances: PausedWorker[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: ErrorEvent) => void) | null = null; + constructor() { PausedWorker.instances.push(this); } + postMessage() {} + terminate() {} + } + vi.stubGlobal('Worker', PausedWorker); + try { + setGraphAdapter(mockAdapter().adapter); + await render(FlowStrip, { from: 'from', to: 'to', symbols: null, trailParam: null }); + expect(host.textContent).toMatch(/正在布局已找到的路径|Laying out the returned paths/); + expect(host.textContent).not.toContain('No path between them'); + const { buildFlowLayout } = await import('../ui/src/lib/flow-model'); + PausedWorker.instances[0]!.onmessage!({ data: { result: buildFlowLayout(FLOW.flows, FLOW.flows[0]!.id) } } as MessageEvent); + flushSync(); + expect(host.textContent).toContain('parseToken'); + expect(host.textContent).not.toMatch(/正在布局已找到的路径|Laying out the returned paths/); + } finally { vi.unstubAllGlobals(); } +}); + +it('Steps入口忽略取消后迟到的screens响应', async () => { + const pending: Array<(value: unknown) => void> = []; + setGraphAdapter({ ...mockAdapter().adapter, steps: async () => ({}), screens: () => new Promise(resolve => pending.push(resolve)) } as unknown as GraphAdapter); + const Steps = (await import('../ui/src/views/StepsView.svelte')).default; + await render(Steps, { anchor: null, symbol: null, depth: 4, through: false, reading: 'tree' }); + live.signal('index'); flushSync(); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + pending[1]!({ routed: true, screens: [{ id: 'current', path: 'CURRENT_SCREEN', component: null, file: 'new.ts', incoming: 0, outgoing: 0 }] }); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + pending[0]!({ routed: false, screens: [] }); + await new Promise(resolve => setTimeout(resolve, 0)); flushSync(); + expect(host.textContent).toContain('CURRENT_SCREEN'); +}); + +it('Map通过G6场景保留模块标签和键盘选择入口', async () => { + setGraphAdapter(mockAdapter().adapter); + await render(ArchitectureMap, { root: null, depth: 2, tests: false }); + await vi.waitFor(() => expect(host.querySelector('[data-graph-engine="g6"]')).not.toBeNull()); + expect(host.querySelectorAll('.accessible button').length).toBeGreaterThan(0); + expect(g6Harness.scene.nodes.every((n: any) => n.width > 0 && n.height > 0)).toBe(true); + }); + +it('同项目乱序刷新只采用最后一次统计、入口和路径列表', async () => { + const { project } = await import('../ui/src/lib/project.svelte'); + const { palette } = await import('../ui/src/lib/palette.svelte'); + const { trails } = await import('../ui/src/lib/trails.svelte'); + const pending: Record void>> = { stats: [], entries: [], trails: [] }; + const queue = (key: string) => () => new Promise(resolve => pending[key]!.push(resolve)); + setGraphAdapter({ stats: queue('stats'), entryPoints: queue('entries'), trails: queue('trails') } as never); + project.resetProject(); palette.resetProject(); trails.resetProject(); + const old = [project.ensure(), palette.ensureEntries(), trails.ensure()]; + const latest = [project.reload(), palette.reloadEntries(), trails.reload()]; + pending.stats[1]!({ project: { name: '最新项目统计' } }); + pending.entries[1]!({ marker: '最新入口' }); pending.trails[1]!({ trails: [{ id: 'latest' }] }); + await Promise.all(latest); + pending.stats[0]!({ project: { name: '旧项目统计' } }); + pending.entries[0]!({ marker: '旧入口' }); pending.trails[0]!({ trails: [{ id: 'old' }] }); + await Promise.all(old); + expect(project.name).toBe('最新项目统计'); + expect(palette.entries).toEqual({ marker: '最新入口' }); + expect(trails.list.map(item => item.id)).toEqual(['latest']); + project.resetProject(); palette.resetProject(); trails.resetProject(); setGraphAdapter(null); +}); + +it('切换搜索范围失败时不保留上一范围结果,清空输入清除旧提示', async () => { + const { workspace } = await import('../ui/src/lib/workspace.svelte'); + const { default: WorkspaceSearch } = await import('../ui/src/components/WorkspaceSearch.svelte'); + const projects = vi.spyOn(workspace, 'projects', 'get').mockReturnValue([{ id: 'a', name: 'A', available: true }, { id: 'b', name: 'B', available: true }]); + const search = vi.spyOn(workspace, 'search').mockResolvedValueOnce({ results: [{ projectId: 'a', projectName: 'A', node: { id: 'old', name: '旧范围结果', file: 'a.ts', kind: 'function' } as never }], limited: true, incomplete: [{ projectId: 'b', reason: '旧提示' }] }).mockRejectedValue(new Error('B搜索失败')); + try { + mounted = mount(WorkspaceSearch, { target: host }); flushSync(); + const input = host.querySelector('input')!; input.value = 'target'; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new FocusEvent('focus')); flushSync(); + await new Promise(resolve => setTimeout(resolve, 210)); flushSync(); + expect(host.textContent).toContain('旧范围结果'); + const scope = host.querySelector('select')!; scope.value = 'b'; scope.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); + expect(host.textContent).not.toContain('旧范围结果'); + await new Promise(resolve => setTimeout(resolve, 210)); flushSync(); + expect(host.textContent).toContain('B搜索失败'); + expect(host.querySelector('.results a')).toBeNull(); + expect(host.textContent).not.toContain('结果已截断'); + input.value = ''; input.dispatchEvent(new Event('input', { bubbles: true })); flushSync(); + expect(host.textContent).not.toContain('B搜索失败'); + expect(host.textContent).not.toContain('旧提示'); + } finally { search.mockRestore(); projects.mockRestore(); } +}); + +it('旧刷新失败不会写入错误或提前结束最新读取,刷新不使保存失效', async () => { + const { project } = await import('../ui/src/lib/project.svelte'); + const { palette } = await import('../ui/src/lib/palette.svelte'); + const { trails } = await import('../ui/src/lib/trails.svelte'); + type Deferred = { resolve: (value: unknown) => void; reject: (error: Error) => void }; + const pending: Record = { stats: [], entries: [], trails: [], save: [] }; + const queue = (key: string) => () => new Promise((resolve, reject) => pending[key]!.push({ resolve, reject })); + setGraphAdapter({ stats: queue('stats'), entryPoints: queue('entries'), trails: queue('trails'), saveTrail: queue('save') } as never); + project.resetProject(); palette.resetProject(); trails.resetProject(); + try { + const old = [project.ensure(), palette.ensureEntries(), trails.ensure()]; + const save = trails.save('保留保存', '', []); + const latest = [project.reload(), palette.reloadEntries(), trails.reload()]; + for (const key of ['stats', 'entries', 'trails']) pending[key]![0]!.reject(new Error('旧读取失败')); + await Promise.all(old); + expect(project.error).toBeNull(); expect(palette.entriesFailure).toBeNull(); expect(trails.failure).toBeNull(); + expect(palette.entriesSettled).toBe(false); expect(trails.settled).toBe(false); + pending.stats[1]!.resolve({ project: { name: '最新统计' } }); + pending.entries[1]!.resolve({ marker: '最新入口' }); pending.trails[1]!.resolve({ trails: [] }); + await Promise.all(latest); + pending.save[0]!.resolve({ trails: [{ id: 'saved' }], saved: 'saved' }); + expect(await save).toBe('saved'); + expect(trails.list.map(item => item.id)).toEqual(['saved']); + expect(trails.busy).toBe(false); + } finally { project.resetProject(); palette.resetProject(); trails.resetProject(); setGraphAdapter(null); } +}); + +it('G6场景合并双向展示边但保留真实有向关系', async () => { + setGraphAdapter(mockAdapter().adapter); + await render(ArchitectureMap, { root: 'src', depth: 1, tests: false }); + await vi.waitFor(() => expect(g6Harness.scene?.kind).toBe('map')); + expect(g6Harness.scene.relations.length).toBeGreaterThan(0); + expect(g6Harness.scene.edges.every((e: any) => e.originalIds.length && e.width <= 2)).toBe(true); + }); + +it('G6节点移动保存位置,流向播放和恢复布局保持可用', async () => { + const { saveGraphHistory, readGraphHistory } = await import('../ui/src/lib/graph-history'); + saveGraphHistory(location.href, { positions: {}, selected: null, flowPlaying: false }); + setGraphAdapter(mockAdapter().adapter); + await render(ArchitectureMap, { root: 'src', depth: 1, tests: false }); + await vi.waitFor(() => expect(g6Harness.scene?.nodes.length).toBeGreaterThan(0)); + const n = g6Harness.scene.nodes[0]; g6Harness.events.move(n.id, n.x + 10, n.y); flushSync(); + expect(readGraphHistory(location.href).positions?.[n.id]?.x).toBe(n.x + 10); + const play = [...host.querySelectorAll('button')].find(b => /播放流向|Play flow/.test(b.textContent ?? ''))!; + play.click(); flushSync(); await vi.waitFor(() => expect(g6Harness.scene.edges.some((e: any) => e.flowing)).toBe(true)); + play.click(); flushSync(); expect(readGraphHistory(location.href).flowPlaying).toBe(false); + const reset = [...host.querySelectorAll('button')].find(b => /恢复自动布局|Reset layout/.test(b.textContent ?? ''))!; + expect(reset.disabled).toBe(false); reset.click(); flushSync(); expect(readGraphHistory(location.href).positions).toEqual({}); + }); + +it('旧直角折线视图回退为直线,选择器只保留曲线和直线', async () => { + const { saveGraphHistory, readGraphHistory } = await import('../ui/src/lib/graph-history'); + saveGraphHistory(location.href, { edgeStyle: 'orthogonal' as unknown as import('../ui/src/lib/map-edge-path').MapEdgeStyle }); + setGraphAdapter(mockAdapter().adapter); + await render(ArchitectureMap, { root: 'src', depth: 1, tests: false }); + const style = host.querySelector('option[value="straight"]')!.parentElement as HTMLSelectElement; + expect([...style.options].map(option => option.value)).toEqual(['curve', 'straight']); + expect(style.value).toBe('straight'); + expect(readGraphHistory(location.href).edgeStyle).toBe('straight'); + expect(g6Harness.scene.edges.every((e: any) => !e.path || /^M[^C]+ L/.test(e.path))).toBe(true); +}); + +it('连线切换保留位置,紧凑排列将聚焦节点移近并保存当前线型', async () => { + const { saveGraphHistory, readGraphHistory } = await import('../ui/src/lib/graph-history'); + saveGraphHistory(location.href, { positions: { 'src/http': { x: 50000, y: 50000 } }, selected: 'src/auth', focusOnly: true, focusDirection: 'both', edgeStyle: 'curve', viewport: { x: 0, y: 0, zoom: 1 } }); + setGraphAdapter(mockAdapter().adapter); + await render(ArchitectureMap, { root: 'src', depth: 1, tests: false }); + const style = host.querySelector('option[value="straight"]')!.parentElement as HTMLSelectElement; + style.value = 'straight'; style.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); + expect(readGraphHistory(location.href).edgeStyle).toBe('straight'); + expect(readGraphHistory(location.href).positions?.['src/http']).toEqual({ x: 50000, y: 50000 }); + const compact = [...host.querySelectorAll('button')].find(button => /紧凑排列|Compact layout/.test(button.textContent ?? ''))!; + expect(compact.disabled).toBe(false); + compact.click(); flushSync(); + await vi.waitFor(() => expect(readGraphHistory(location.href).positions?.['src/http']?.x).toBeLessThan(1000)); + expect(Object.keys(readGraphHistory(location.href).positions ?? {}).sort()).toEqual(['src/auth', 'src/http']); + expect(g6Harness.scene.edges.every((e: any) => !e.path || /^M[^C]+ L/.test(e.path))).toBe(true); + style.value = 'curve'; style.dispatchEvent(new Event('change', { bubbles: true })); flushSync(); + expect(g6Harness.scene.edges.every((e: any) => !e.path)).toBe(true); + expect(readGraphHistory(location.href).edgeStyle).toBe('curve'); +}); + +it('紧凑排列期间切换选中对象,迟到Worker结果不能覆盖位置', async () => { + const { saveGraphHistory, readGraphHistory } = await import('../ui/src/lib/graph-history'); + const { calculateLayout } = await import('../ui/src/lib/graph-layout-runner'); + const pending: ControlledWorker[] = []; + class ControlledWorker { + onmessage: ((event: { data: { result: unknown } }) => void) | null = null; + onerror = null; stopped = false; + postMessage(job: { kind: string; payload: unknown; options: Record }) { + if (job.kind === 'map-compact') pending.push(this); + else queueMicrotask(() => this.onmessage?.({ data: { result: calculateLayout(job.kind, job.payload, job.options) } })); + } + terminate() { this.stopped = true; } + } + const original = globalThis.Worker; + globalThis.Worker = ControlledWorker as unknown as typeof Worker; + try { + saveGraphHistory(location.href, { positions: {}, selected: 'src/auth', focusOnly: true, edgeStyle: 'curve', viewport: { x: 0, y: 0, zoom: 1 } }); + setGraphAdapter(mockAdapter().adapter); + await render(ArchitectureMap, { root: 'src', depth: 1, tests: false }); + [...host.querySelectorAll('button')].find(button => /紧凑排列|Compact layout/.test(button.textContent ?? ''))!.click(); flushSync(); + expect(pending).toHaveLength(1); + [...host.querySelectorAll('.accessible button')].find(button => button.textContent?.includes('http'))!.click(); flushSync(); + expect(pending[0]!.stopped).toBe(true); + pending[0]!.onmessage?.({ data: { result: { 'src/auth': { x: 9999, y: 9999 } } } }); flushSync(); + expect(readGraphHistory(location.href).positions).toEqual({}); + expect(readGraphHistory(location.href).selected).toBe('src/http'); + } finally { globalThis.Worker = original; } +}); + +it('关系布局切换保留真实边,选择不重排,手动位置按布局隔离并恢复历史', async () => { + const { default: GraphCanvas } = await import('../ui/src/components/graph/GraphCanvas.svelte'); + const { saveGraphHistory, readGraphHistory } = await import('../ui/src/lib/graph-history'); + const scene: import('../ui/src/lib/graph-scene').GraphScene = {kind:'map',groups:[], + nodes:['a','b','c','d'].map((id,i)=>({id,label:id,sub:'',kind:'module',x:i*240,y:0,width:180,height:48,draggable:true})), + relations:[{id:'ab',source:'a',target:'b'}],edges:[{id:'ab',source:'a',target:'b',label:'1',width:1,originalIds:['ab'],path:'M180,24 L240,24',straight:true}]}; + const originalPositions=scene.nodes.map(n=>[n.x,n.y]); + // Vitest omits component styles; load the compiled CSS to exercise pointer hit testing rules. + const { compile } = await import('svelte/compiler'); + const canvasCss = compile(readFileSync(join(process.cwd(), 'ui/src/components/graph/GraphCanvas.svelte'), 'utf8'), { css: 'external' }).css!.code; + const stylesheet = document.createElement('style'); + // Match the mounted component's scope (Vite supplies its own filename to the compiler). + const compiledScope = canvasCss.match(/svelte-[\w-]+/)![0]; + const selectMode=(mode:string)=>{const select=host.querySelector('select[aria-label="图布局"]')!;select.value=mode;select.dispatchEvent(new Event('change',{bubbles:true}));flushSync();}; + const ready=()=>vi.waitFor(()=>expect(host.querySelector('[data-graph-engine]')?.getAttribute('aria-busy')).toBe('false')); + saveGraphHistory(location.href,{relationshipLayout:'default',layoutPositions:{},collapsedGroups:[],selected:null,analysisFocus:false}); + try { + await render(GraphCanvas,{scene}); + const toolbar = host.querySelector('.toolbar')!; + const mountedScope = [...toolbar.classList].find(name => name.startsWith('svelte-'))!; + stylesheet.textContent = canvasCss.replaceAll(compiledScope, mountedScope); + document.head.append(stylesheet); + const layoutTrigger = host.querySelector('button[aria-label="图布局"]')!; + expect(getComputedStyle(toolbar).pointerEvents).toBe('none'); + expect(getComputedStyle(layoutTrigger.parentElement!).pointerEvents).toBe('auto'); + layoutTrigger.click(); + const layoutMenu = layoutTrigger.parentElement!.querySelector('[role="listbox"]')!; + expect(layoutMenu.hidden).toBe(false); + [...layoutMenu.querySelectorAll('[role="option"]')].find(option => option.textContent === '环形')!.click(); + flushSync(); await ready(); + expect(host.querySelector('[data-graph-engine]')?.getAttribute('data-layout')).toBe('circular'); + expect(layoutMenu.hidden).toBe(true); + expect(g6Harness.scene.nodes.map((n:any)=>[n.x,n.y])).not.toEqual(originalPositions); + expect(g6Harness.scene.relations).toEqual(scene.relations); + expect(g6Harness.scene.edges[0]).toMatchObject({path:undefined,straight:true,originalIds:['ab']}); + const arranged=g6Harness.scene.nodes.map((n:any)=>[n.id,n.x,n.y]); + g6Harness.events.select('a');flushSync(); + expect(g6Harness.scene.nodes.map((n:any)=>[n.id,n.x,n.y])).toEqual(arranged); + g6Harness.events.move('a',800,900);flushSync(); + expect(readGraphHistory(location.href).layoutPositions?.circular?.a).toEqual({x:800,y:900}); + selectMode('concentric');await ready(); + expect(g6Harness.scene.nodes.find((n:any)=>n.id==='a').x).not.toBe(800); + selectMode('circular');await ready(); + expect(g6Harness.scene.nodes.find((n:any)=>n.id==='a')).toMatchObject({x:800,y:900}); + await unmount(mounted!);mounted=null;await render(GraphCanvas,{scene});await ready(); + expect(host.querySelector('select[aria-label="图布局"]')!.value).toBe('circular'); + expect(g6Harness.scene.nodes.find((n:any)=>n.id==='a')).toMatchObject({x:800,y:900}); + g6Harness.events.viewport({x:15,y:25,zoom:1});flushSync(); + expect(host.querySelector('.graph-canvas')!.style.backgroundPosition).toBe('15px 25px'); + expect(host.querySelector('.graph-canvas')!.style.backgroundSize).toBe('30px 30px'); + [...host.querySelectorAll('button')].find(b=>b.textContent==='恢复布局')!.click();flushSync();await ready(); + expect(g6Harness.scene.nodes.map((n:any)=>[n.x,n.y])).toEqual(originalPositions); + expect(readGraphHistory(location.href).layoutPositions).toEqual({}); + } finally { stylesheet.remove(); saveGraphHistory(location.href,{relationshipLayout:'default',layoutPositions:{},selected:null}); } +}); + +it('架构图最近查看最多十个,去重置顶,重开保留,点击展开并定位,跨范围隔离',async()=>{ + const { default: GraphCanvas } = await import('../ui/src/components/graph/GraphCanvas.svelte'); + const { saveGraphHistory, readGraphHistory } = await import('../ui/src/lib/graph-history'); + const key=location.href; + const scene:import('../ui/src/lib/graph-scene').GraphScene={kind:'map',groups:[],relations:[],edges:[], + nodes:Array.from({length:12},(_,i)=>({id:`src/n${i}`,label:`节点 ${i}`,sub:'',kind:'module',x:i*220,y:0,width:180,height:48}))}; + const recent=()=>[...host.querySelectorAll('[data-recent-node]')]; + saveGraphHistory(key,{relationshipLayout:'default',recentNodes:[],selected:null,collapsedGroups:[],analysisFocus:false}); + try { + await render(GraphCanvas,{scene});expect(host.textContent).toContain('暂无记录'); + for(const node of scene.nodes){g6Harness.events.select(node.id);flushSync();} + expect(recent().map(b=>b.dataset.recentNode)).toEqual(Array.from({length:10},(_,i)=>`src/n${11-i}`)); + g6Harness.events.select('src/n5');flushSync(); + expect(recent()).toHaveLength(10);expect(recent()[0]!.dataset.recentNode).toBe('src/n5'); + g6Harness.events.select(null);flushSync();expect(recent()).toHaveLength(10); + await unmount(mounted!);mounted=null; + saveGraphHistory(key,{collapsedGroups:['directory:src']}); + await render(GraphCanvas,{scene}); + expect(recent()[0]!.dataset.recentNode).toBe('src/n5'); + recent().find(b=>b.dataset.recentNode==='src/n2')!.click();flushSync(); + await vi.waitFor(()=>expect(g6Harness.focused).toEqual(['src/n2'])); + expect(readGraphHistory(key).selected).toBe('src/n2'); + expect(readGraphHistory(key).collapsedGroups).toEqual([]); + expect(recent()[0]!.dataset.recentNode).toBe('src/n2'); + await unmount(mounted!);mounted=null; + await render(GraphCanvas,{scene:{...scene,nodes:scene.nodes.filter(n=>n.id!=='src/n2')}}); + expect(recent()[0]!.disabled).toBe(true); + await unmount(mounted!);mounted=null; + window.history.replaceState(null,'','#/p/recent-other/map'); + await render(GraphCanvas,{scene});expect(recent()).toHaveLength(0); + } finally { + if(mounted){await unmount(mounted);mounted=null;} + window.history.replaceState(null,'',key);saveGraphHistory(key,{recentNodes:[],selected:null,collapsedGroups:[]}); + } +}); + +it('切换关系布局取消旧Worker,折叠与选择不重排,卸载取消尚未完成的计算',async()=>{ + const { default: GraphCanvas } = await import('../ui/src/components/graph/GraphCanvas.svelte'); + const { saveGraphHistory } = await import('../ui/src/lib/graph-history'); + const pending:ControlledWorker[]=[]; + class ControlledWorker { + onmessage:((event:{data:{result:unknown}})=>void)|null=null;onerror=null;stopped=false; + postMessage(){pending.push(this);}terminate(){this.stopped=true;} + } + const original=globalThis.Worker;globalThis.Worker=ControlledWorker as unknown as typeof Worker; + const scene:import('../ui/src/lib/graph-scene').GraphScene={kind:'map',groups:[],relations:[],edges:[], + nodes:['src/a','src/b'].map(id=>({id,label:id,sub:'',kind:'module',x:0,y:0,width:180,height:48}))}; + const selectMode=(mode:string)=>{const select=host.querySelector('select[aria-label="图布局"]')!;select.value=mode;select.dispatchEvent(new Event('change',{bubbles:true}));flushSync();}; + saveGraphHistory(location.href,{relationshipLayout:'default',layoutPositions:{},collapsedGroups:[],selected:null,analysisFocus:false}); + try { + await render(GraphCanvas,{scene}); + selectMode('force');expect(pending).toHaveLength(1); + selectMode('circular');expect(pending[0]!.stopped).toBe(true);expect(pending).toHaveLength(2); + pending[0]!.onmessage?.({data:{result:{'src/a':{x:9999,y:9999},'src/b':{x:9999,y:9999}}}});flushSync(); + expect(host.querySelector('[data-graph-engine]')?.getAttribute('aria-busy')).toBe('true'); + pending[1]!.onmessage?.({data:{result:{'src/a':{x:100,y:100},'src/b':{x:400,y:100}}}});flushSync(); + expect(g6Harness.scene.nodes[0].x).toBe(100); + g6Harness.events.select('src/a');flushSync();expect(pending).toHaveLength(2); + [...host.querySelectorAll('button')].find(b=>b.textContent?.includes('src · 2'))!.click();flushSync(); + expect(pending).toHaveLength(2); + selectMode('force');expect(pending).toHaveLength(3); + await unmount(mounted!);mounted=null;expect(pending[2]!.stopped).toBe(true); + } finally {globalThis.Worker=original;saveGraphHistory(location.href,{relationshipLayout:'default',layoutPositions:{},collapsedGroups:[],selected:null});} +}); describe('a long trail (#1976)', () => { it('keeps at most the 64 hops the store saves and /api/flow reads, dropping the oldest', () => { for (let i = 0; i < 70; i++) trail.push({ id: `function:h${i}` }); @@ -871,3 +1518,27 @@ describe('a long trail (#1976)', () => { expect(trail.hops.every((hop) => hop.name)).toBe(true); }); }); + + +it('keeps batched trail name requests scoped to the originating project', async () => { + const { fetchNodeRefs } = await import('../ui/src/lib/api'); + const ids = Array.from({ length: 64 }, (_, i) => `function:project-batch-${i}`); + let releaseFirst!: (result: WireNodeRefs) => void; + const previous = mockAdapter().adapter; + const next = mockAdapter().adapter; + const previousNodes = vi.fn() + .mockImplementationOnce(() => new Promise(resolve => { releaseFirst = resolve; })) + .mockResolvedValue({ items: [], missing: ids.slice(60) }); + const nextNodes = vi.fn().mockResolvedValue({ items: [], missing: [] }); + previous.nodes = previousNodes; + next.nodes = nextNodes; + setGraphAdapter(previous); + const signal = new AbortController().signal; + const pending = fetchNodeRefs(ids, signal); + setGraphAdapter(next); + releaseFirst({ items: [], missing: ids.slice(0, 60) }); + expect(await pending).toEqual({ items: [], missing: ids }); + expect(previousNodes.mock.calls.map(([batch]) => batch.length)).toEqual([60, 4]); + expect(previousNodes.mock.calls.every(([, passedSignal]) => passedSignal === signal)).toBe(true); + expect(nextNodes).not.toHaveBeenCalled(); +}); diff --git a/src/db/migrations.ts b/src/db/migrations.ts index bb7b84bf53..8cb733e1e2 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter'; /** * Current schema version */ -export const CURRENT_SCHEMA_VERSION = 11; +export const CURRENT_SCHEMA_VERSION = 12; /** * Migration definition @@ -20,6 +20,28 @@ interface Migration { up: (db: SqliteDatabase) => void; } +/** Shared repair for upstream v10 and the fork that already used that version. */ +function migrateSynthesisInputs(db: SqliteDatabase): void { + db.exec(` + DROP INDEX IF EXISTS idx_nodes_kind; + CREATE INDEX idx_nodes_kind ON nodes(kind, file_path, start_line, id); + CREATE TABLE IF NOT EXISTS synthesis_inputs ( + file_path TEXT PRIMARY KEY REFERENCES files(path) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_edges_synthesis_site ON edges(CASE WHEN json_valid(metadata) THEN json_extract(metadata, '$.registeredAt') END) + WHERE CASE WHEN json_valid(metadata) THEN json_extract(metadata, '$.synthesizedBy') END IS NOT NULL; + UPDATE edges SET metadata = json_set(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy', 'go-method-contains') + WHERE kind = 'contains' AND provenance IS NULL AND EXISTS ( + SELECT 1 FROM nodes s JOIN nodes t ON t.id = edges.target + WHERE s.id = edges.source AND s.language = 'go' AND t.language = 'go' + AND s.kind IN ('struct', 'class', 'interface', 'enum', 'type_alias') AND t.kind = 'method' + AND s.file_path != t.file_path + ); + INSERT OR REPLACE INTO project_metadata(key, value, updated_at) + VALUES ('synthesis_pending', '1', 0); + `); +} + /** * All migrations in order * @@ -180,26 +202,7 @@ const migrations: Migration[] = [ { version: 10, description: 'Track synthesis inputs and stabilize synthesis traversal for incremental refresh (#1988)', - up: (db) => { - db.exec(` - DROP INDEX IF EXISTS idx_nodes_kind; - CREATE INDEX idx_nodes_kind ON nodes(kind, file_path, start_line, id); - CREATE TABLE IF NOT EXISTS synthesis_inputs ( - file_path TEXT PRIMARY KEY REFERENCES files(path) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_edges_synthesis_site ON edges(CASE WHEN json_valid(metadata) THEN json_extract(metadata, '$.registeredAt') END) - WHERE CASE WHEN json_valid(metadata) THEN json_extract(metadata, '$.synthesizedBy') END IS NOT NULL; - UPDATE edges SET metadata = json_set(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy', 'go-method-contains') - WHERE kind = 'contains' AND provenance IS NULL AND EXISTS ( - SELECT 1 FROM nodes s JOIN nodes t ON t.id = edges.target - WHERE s.id = edges.source AND s.language = 'go' AND t.language = 'go' - AND s.kind IN ('struct', 'class', 'interface', 'enum', 'type_alias') AND t.kind = 'method' - AND s.file_path != t.file_path - ); - INSERT OR REPLACE INTO project_metadata(key, value, updated_at) - VALUES ('synthesis_pending', '1', 0); - `); - }, + up: migrateSynthesisInputs, }, { version: 11, @@ -216,6 +219,24 @@ const migrations: Migration[] = [ `); }, }, + { + version: 12, + description: 'Reconcile legacy Pinable v10 and retain workbench cursor indexes', + up: (db) => { + // Pinable used v10 for pagination before upstream assigned v10 to + // synthesis. Its recorded version therefore skips upstream's v10. + // Inspect the schema rather than changing an already-applied history row. + const hasSynthesisInputs = db.prepare( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'synthesis_inputs'" + ).get(); + if (!hasSynthesisInputs) migrateSynthesisInputs(db); + db.exec(` + CREATE INDEX IF NOT EXISTS idx_edges_source_id ON edges(source, id); + CREATE INDEX IF NOT EXISTS idx_edges_target_id ON edges(target, id); + CREATE INDEX IF NOT EXISTS idx_nodes_file_id ON nodes(file_path, id); + `); + }, + }, ]; /** diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index d62a399592..4875f6f509 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -73,10 +73,12 @@ const NODE_REFS_PER_REQUEST = 60; * answers, so the ids go out in batches and the answers are merged (#1976). */ export async function fetchNodeRefs(ids: readonly string[], signal?: AbortSignal): Promise { - if (ids.length <= NODE_REFS_PER_REQUEST) return getGraphAdapter().nodes(ids, signal); + // A workspace may switch while a batch is in flight; do not mix projects. + const adapter = getGraphAdapter(); + if (ids.length <= NODE_REFS_PER_REQUEST) return adapter.nodes(ids, signal); const merged: WireNodeRefs = { items: [], missing: [] }; for (let i = 0; i < ids.length; i += NODE_REFS_PER_REQUEST) { - const batch = await getGraphAdapter().nodes(ids.slice(i, i + NODE_REFS_PER_REQUEST), signal); + const batch = await adapter.nodes(ids.slice(i, i + NODE_REFS_PER_REQUEST), signal); merged.items.push(...batch.items); merged.missing.push(...batch.missing); }