168 lines
7.7 KiB
TypeScript
168 lines
7.7 KiB
TypeScript
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' }],
|
|
});
|
|
});
|
|
});
|