import { EventEmitter } from 'node:events'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { afterEach, describe, expect, it } from 'vitest'; import { handleFileRoutes } from '@electron/api/routes/files'; import { createProjectConfig } from '../../shared/project-config'; const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); function createResponse() { const chunks: string[] = []; const res = { statusCode: 0, setHeader: () => undefined, end: (chunk?: string) => { if (chunk) chunks.push(chunk); }, } as unknown as ServerResponse; return { res, json: () => JSON.parse(chunks.join('')) as unknown }; } function createRequest(method: string, body?: unknown): IncomingMessage { const req = new EventEmitter(); Object.assign(req, { method, headers: body === undefined ? {} : { 'content-type': 'application/json' }, [Symbol.asyncIterator]: async function* () { if (body !== undefined) yield Buffer.from(JSON.stringify(body)); }, }); return req as IncomingMessage; } async function createGameProject() { const projectPath = await mkdtemp(join(tmpdir(), 'niancode-asset-review-route-')); temporaryDirectories.push(projectPath); await mkdir(join(projectPath, '.niancode'), { recursive: true }); await mkdir(join(projectPath, 'assets'), { recursive: true }); const config = createProjectConfig('2026-07-12T00:00:00.000Z'); config.initialized = true; config.agents.forEach((agent, index) => { agent.name = `测试伙伴${index + 1}`; }); await writeFile(join(projectPath, '.niancode', 'project.json'), `${JSON.stringify(config, null, 2)}\n`, 'utf8'); await writeFile(join(projectPath, 'assets', 'hero.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); await writeFile(join(projectPath, 'ASSET_PLAN.md'), `# 素材计划\n\n\`\`\`json\n${JSON.stringify({ assets: [ { id: 'hero', name: '主角', category: 'visual', status: 'candidate', purpose: '玩家角色', source: 'Kenney', license: 'CC0', localPath: 'assets/hero.png' }, { id: 'jump', name: '跳跃音效', category: 'audio', status: 'candidate', purpose: '跳跃反馈', source: 'Kenney', license: 'CC0', localPath: 'assets/jump.wav' }, ] })}\n\`\`\``); return projectPath; } describe('game asset review host route', () => { it('projects the Agent submission and persists approval across a later invocation', async () => { const projectPath = await createGameProject(); const ctx = { opencodeProjectStore: { getActiveProject: async () => ({ path: projectPath }) } } as never; const candidateIds = encodeURIComponent(JSON.stringify(['hero', 'jump'])); const initialResponse = createResponse(); await handleFileRoutes(createRequest('GET'), initialResponse.res, new URL(`http://127.0.0.1/api/files/game-asset-review?invocationId=review-1&candidateIds=${candidateIds}`), ctx); expect(initialResponse.res.statusCode).toBe(200); expect(initialResponse.json()).toMatchObject({ review: { status: 'pending', pendingAssetIds: ['hero', 'jump'] }, assets: [{ id: 'hero' }, { id: 'jump' }] }); const actionResponse = createResponse(); await handleFileRoutes(createRequest('POST', { invocationId: 'review-1', candidateIds: ['hero', 'jump'], assetId: 'hero', action: 'approve', }), actionResponse.res, new URL('http://127.0.0.1/api/files/game-asset-review'), ctx); expect(actionResponse.res.statusCode).toBe(200); expect(actionResponse.json()).toMatchObject({ review: { approvedAssetIds: ['hero'], pendingAssetIds: ['jump'] } }); const reopenedResponse = createResponse(); await handleFileRoutes(createRequest('GET'), reopenedResponse.res, new URL(`http://127.0.0.1/api/files/game-asset-review?invocationId=review-2&candidateIds=${encodeURIComponent(JSON.stringify(['hero']))}`), ctx); expect(reopenedResponse.json()).toMatchObject({ review: { pendingAssetIds: [] }, assets: [] }); }); it('accepts all card decisions in one atomic batch', async () => { const projectPath = await createGameProject(); const ctx = { opencodeProjectStore: { getActiveProject: async () => ({ path: projectPath }) } } as never; const response = createResponse(); await handleFileRoutes(createRequest('POST', { invocationId: 'review-batch', candidateIds: ['hero', 'jump'], decisions: [ { assetId: 'hero', action: 'approve' }, { assetId: 'jump', action: 'replace' }, ], }), response.res, new URL('http://127.0.0.1/api/files/game-asset-review'), ctx); expect(response.res.statusCode).toBe(200); expect(response.json()).toMatchObject({ success: true, review: { status: 'resolved', pendingAssetIds: [], approvedAssetIds: ['hero'], discardedAssetIds: ['jump'], }, }); }); it('uses the current plan when a legacy or empty marker omits candidate ids', async () => { const projectPath = await createGameProject(); await writeFile(join(projectPath, 'ASSET_PLAN.md'), `# 素材计划 | 素材 | 来源 | 许可证 | | --- | --- | --- | | 主角 | Kenney | CC0 | | 音效 | Kenney | CC0 | `); const ctx = { opencodeProjectStore: { getActiveProject: async () => ({ path: projectPath }) } } as never; const response = createResponse(); await handleFileRoutes(createRequest('GET'), response.res, new URL('http://127.0.0.1/api/files/game-asset-review?invocationId=legacy-review&candidateIds=%5B%5D'), ctx); expect(response.res.statusCode).toBe(200); expect(response.json()).toMatchObject({ review: { status: 'pending', pendingAssetIds: expect.arrayContaining([expect.any(String), expect.any(String)]) }, assets: [{ name: '主角' }, { name: '音效' }], }); }); it('uses the current plan when legacy marker ids cannot match table-generated ids', async () => { const projectPath = await createGameProject(); await writeFile(join(projectPath, 'ASSET_PLAN.md'), `# 素材计划 | 素材 | 来源 | 许可证 | | --- | --- | --- | | 主角 | Kenney | CC0 | `); const ctx = { opencodeProjectStore: { getActiveProject: async () => ({ path: projectPath }) } } as never; const response = createResponse(); await handleFileRoutes(createRequest('GET'), response.res, new URL('http://127.0.0.1/api/files/game-asset-review?invocationId=legacy-unmatched&candidateIds=%5B%22hero%22%5D'), ctx); expect(response.res.statusCode).toBe(200); expect(response.json()).toMatchObject({ review: { pendingAssetIds: [expect.any(String)] }, assets: [{ name: '主角' }] }); }); it('projects the actual candidate-status plan table into the review tool', async () => { const projectPath = await createGameProject(); await writeFile(join(projectPath, 'ASSET_PLAN.md'), `# 素材计划 ## 候选状态 | ID | 素材名 | 类型 | 状态 | 方案 | |----|--------|------|------|------| | C-001 | Flappy Bird Style Sprites | 小鸟+管子 | 待审核 | A | | C-006 | Kenney Impact Sounds | 音效 | 待审核 | A/B/C | `); const ctx = { opencodeProjectStore: { getActiveProject: async () => ({ path: projectPath }) } } as never; const response = createResponse(); await handleFileRoutes(createRequest('GET'), response.res, new URL('http://127.0.0.1/api/files/game-asset-review?invocationId=actual-plan&candidateIds=%5B%5D'), ctx); expect(response.res.statusCode).toBe(200); expect(response.json()).toMatchObject({ review: { candidateIds: ['C-001', 'C-006'], pendingAssetIds: ['C-001', 'C-006'] }, assets: [{ id: 'C-001', name: 'Flappy Bird Style Sprites' }, { id: 'C-006', name: 'Kenney Impact Sounds' }], }); }); });