Files
makelore/tests/unit/game-asset-review-route.test.ts
inman 80e8386fa6
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled
feat: update Makelore modules and conversations
2026-07-31 10:08:41 +08:00

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' }],
});
});
});