import { describe, it, expect, vi, beforeEach } from 'vitest'; import { parseCodeOutput, generateGameCode } from './code-generator'; import type { GeneratedCodeFile, GenerateCodeParams } from './code-generator'; import type { GameDesignDocument } from './gdd-generator'; import type { AssetPack } from './asset-generator'; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // Mock callLLM // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ vi.mock('./llm-proxy', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, callLLM: vi.fn(), }; }); import { callLLM } from './llm-proxy'; beforeEach(() => { vi.mocked(callLLM).mockClear(); }); // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // 测试数据 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ const MOCK_FILES: GeneratedCodeFile[] = [ { path: 'src/main.ts', content: "import Phaser from 'phaser';\nconsole.log('hello');" }, { path: 'src/scenes/MainScene.ts', content: "export class MainScene {}" }, ]; const MOCK_JSON = JSON.stringify(MOCK_FILES); const VALID_GDD: GameDesignDocument = { architecture: { sceneKeys: ['MainScene'], levelManagerConfig: 'linear', mainEntryPoints: ['src/main.ts'] }, assetRegistry: [], gameConfig: {}, entities: [], levels: [], roadmap: [], metadata: { title: 'Test', tagline: '', archetype: 'platformer', generatedAt: '2026-01-01' }, }; const EMPTY_ASSET_PACK: AssetPack = {}; const TEMPLATE_FILES = [ { path: 'platformer/src/main.ts', content: 'import Phaser from "phaser";' }, ]; // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // parseCodeOutput 测试 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ describe('parseCodeOutput', () => { it('应正确解析标准 JSON 数组', () => { const result = parseCodeOutput(MOCK_JSON); expect(result).toHaveLength(2); expect(result[0].path).toBe('src/main.ts'); expect(result[0].content).toContain('phaser'); expect(result[1].path).toBe('src/scenes/MainScene.ts'); }); it('应正确解析带有 Markdown 代码块包裹的 JSON', () => { const wrapped = '```json\n' + MOCK_JSON + '\n```'; const result = parseCodeOutput(wrapped); expect(result).toHaveLength(2); expect(result[0].path).toBe('src/main.ts'); }); it('应正确解析带有 TypeScript 代码块包裹的 JSON', () => { const wrapped = '```typescript\n' + MOCK_JSON + '\n```'; const result = parseCodeOutput(wrapped); expect(result).toHaveLength(2); }); it('应正确处理 LLM 输出包含前后额外文本', () => { const withExtra = 'Here is the generated code:\n\n' + MOCK_JSON + '\n\nHope this helps!'; const result = parseCodeOutput(withExtra); expect(result).toHaveLength(2); expect(result[1].content).toBe('export class MainScene {}'); }); it('应处理 JSON 中包含转义字符的内容', () => { const files: GeneratedCodeFile[] = [ { path: 'src/main.ts', content: 'const x = "hello\\nworld";\nconsole.log(x);' }, ]; const result = parseCodeOutput(JSON.stringify(files)); expect(result[0].content).toContain('\\n'); }); it('应在空输出时抛出 Error', () => { expect(() => parseCodeOutput('')).toThrow('empty output'); }); it('应在完全无效 JSON 时抛出带有预览的 Error', () => { const garbage = 'this is not json at all, just random text '.repeat(20); expect(() => parseCodeOutput(garbage)).toThrow('Failed to parse'); try { parseCodeOutput(garbage); } catch (err) { expect((err as Error).message).toContain(garbage.slice(0, 100)); } }); it('应拒绝非数组的 JSON 输出', () => { expect(() => parseCodeOutput('{"path": "a.ts", "content": "b"}')).toThrow('Failed to parse'); }); it('应拒绝数组元素缺少 path 或 content 的情况', () => { expect(() => parseCodeOutput('[{"path": "a.ts"}]')).toThrow('Failed to parse'); expect(() => parseCodeOutput('[{"content": "hello"}]')).toThrow('Failed to parse'); }); it('应处理单文件数组', () => { const single = JSON.stringify([{ path: 'src/game.ts', content: 'export default {};' }]); const result = parseCodeOutput(single); expect(result).toHaveLength(1); expect(result[0].path).toBe('src/game.ts'); }); it('应处理内容中包含大括号和方括号的代码', () => { const files: GeneratedCodeFile[] = [ { path: 'src/utils.ts', content: 'const arr = [1, 2, 3];\nconst obj = { a: { b: [4] } };', }, ]; const result = parseCodeOutput(JSON.stringify(files)); expect(result[0].content).toContain('[1, 2, 3]'); expect(result[0].content).toContain('{ a: { b: [4] } }'); }); }); // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ // generateGameCode 集成测试 // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ describe('generateGameCode', () => { it('应正确组装 prompt 并调用 callLLM,返回解析后的代码', async () => { vi.mocked(callLLM).mockResolvedValueOnce(MOCK_JSON); const result = await generateGameCode({ gdd: VALID_GDD, assetPack: EMPTY_ASSET_PACK, templateFiles: TEMPLATE_FILES, archetype: 'platformer', }); expect(result.files).toHaveLength(2); expect(result.entryPoint).toBe('src/main.ts'); expect(callLLM).toHaveBeenCalledTimes(1); // 验证 system prompt 包含模板内容 const [messages, model] = vi.mocked(callLLM).mock.calls[0]; expect(messages[0].role).toBe('system'); expect(messages[0].content).toContain('platformer/src/main.ts'); expect(messages[0].content).toContain('Phaser'); expect(typeof model).toBe('string'); }); it('应将 GDD 和 AssetPack 注入 user message', async () => { vi.mocked(callLLM).mockResolvedValueOnce(MOCK_JSON); await generateGameCode({ gdd: VALID_GDD, assetPack: EMPTY_ASSET_PACK, templateFiles: TEMPLATE_FILES, archetype: 'platformer', }); const [messages] = vi.mocked(callLLM).mock.calls[0]; const userMsg = messages[1].content; expect(userMsg).toContain('Test'); // GDD title expect(userMsg).toContain('ASSET PACK'); }); it('应根据 tier 参数选择不同的模型', async () => { vi.mocked(callLLM).mockResolvedValueOnce(MOCK_JSON); await generateGameCode({ gdd: VALID_GDD, assetPack: EMPTY_ASSET_PACK, templateFiles: TEMPLATE_FILES, archetype: 'platformer', tier: 'enterprise', }); const [, model] = vi.mocked(callLLM).mock.calls[0]; expect(model).toBe('qwen/qwen3-next-80b-a3b-thinking'); }); it('未指定 tier 时应使用默认模型', async () => { vi.mocked(callLLM).mockResolvedValueOnce(MOCK_JSON); await generateGameCode({ gdd: VALID_GDD, assetPack: EMPTY_ASSET_PACK, templateFiles: TEMPLATE_FILES, archetype: 'top_down', }); const [, model] = vi.mocked(callLLM).mock.calls[0]; expect(model).toBe('qwen/qwen3-next-80b-a3b-instruct'); }); it('应将 system prompt 中标记为 "Do not invent new hooks"', async () => { vi.mocked(callLLM).mockResolvedValueOnce(MOCK_JSON); await generateGameCode({ gdd: VALID_GDD, assetPack: EMPTY_ASSET_PACK, templateFiles: TEMPLATE_FILES, archetype: 'platformer', }); const [messages] = vi.mocked(callLLM).mock.calls[0]; expect(messages[0].content).toContain('Do NOT invent new hooks'); }); it('entryPoint 应优先选择 main.ts', async () => { const files: GeneratedCodeFile[] = [ { path: 'src/scenes/Game.ts', content: 'export {}' }, { path: 'src/main.ts', content: 'import Phaser;' }, ]; vi.mocked(callLLM).mockResolvedValueOnce(JSON.stringify(files)); const result = await generateGameCode({ gdd: VALID_GDD, assetPack: EMPTY_ASSET_PACK, templateFiles: TEMPLATE_FILES, archetype: 'platformer', }); expect(result.entryPoint).toBe('src/main.ts'); }); it('LLM 返回无效输出时应抛出 Error', async () => { vi.mocked(callLLM).mockResolvedValueOnce('not valid json'); await expect( generateGameCode({ gdd: VALID_GDD, assetPack: EMPTY_ASSET_PACK, templateFiles: TEMPLATE_FILES, archetype: 'platformer', }), ).rejects.toThrow('Failed to parse'); }); });