import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, expect, it } from 'vitest'; import { loadGameAssetCandidates } from '@electron/opencode/game-asset-browser'; const temporaryDirectories: string[] = []; afterEach(async () => { await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); }); async function createProject() { const projectPath = await mkdtemp(join(tmpdir(), 'niancode-assets-')); temporaryDirectories.push(projectPath); await mkdir(join(projectPath, 'assets'), { recursive: true }); return projectPath; } it('loads bounded project-relative image, audio, and bundle previews from ASSET_PLAN.md', async () => { const projectPath = await createProject(); await writeFile(join(projectPath, 'assets', 'hero.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); await writeFile(join(projectPath, 'assets', 'jump.wav'), Buffer.from('RIFF')); await writeFile(join(projectPath, 'ASSET_PLAN.md'), `# Asset Plan\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: 'selected', purpose: '跳跃反馈', source: 'Kenney', license: 'CC0', localPath: 'assets/jump.wav' }, { id: 'ui-pack', name: 'UI 包', category: 'animation', status: 'candidate', purpose: '菜单', source: 'Local', license: 'CC0', manifestPaths: ['ui/play.png', 'ui/pause.png'] }, ] })}\n\`\`\``); const assets = await loadGameAssetCandidates(projectPath); expect(assets).toHaveLength(3); expect(assets[0]).toMatchObject({ id: 'hero', mediaKind: 'image', previewDataUrl: expect.stringContaining('data:image/png;base64,') }); expect(assets[1]).toMatchObject({ id: 'jump', mediaKind: 'audio', audioDataUrl: expect.stringContaining('data:audio/wav;base64,') }); expect(assets[2]).toMatchObject({ id: 'ui-pack', mediaKind: 'bundle', manifest: ['ui/play.png', 'ui/pause.png'] }); }); it('loads a separate project-relative previewPath for visual candidates', async () => { const projectPath = await createProject(); await writeFile(join(projectPath, 'assets', 'preview.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47])); await writeFile(join(projectPath, 'ASSET_PLAN.md'), `# Asset Plan\n\n\`\`\`json\n${JSON.stringify({ assets: [ { id: 'preview-only', name: '预览图候选', category: 'visual', status: 'candidate', previewPath: 'assets/preview.png' }, ] })}\n\`\`\``); const assets = await loadGameAssetCandidates(projectPath); expect(assets).toMatchObject([{ id: 'preview-only', mediaKind: 'image', previewDataUrl: expect.stringContaining('data:image/png;base64,') }]); }); it('never reads an absolute or project-external preview path', async () => { const projectPath = await createProject(); await writeFile(join(projectPath, 'ASSET_PLAN.md'), `\`\`\`json\n${JSON.stringify({ assets: [ { id: 'outside', category: 'visual', localPath: '/tmp/private.png' }, { id: 'traversal', category: 'audio', localPath: '../private.wav' }, ] })}\n\`\`\``); const assets = await loadGameAssetCandidates(projectPath); expect(assets.every((asset) => asset.mediaKind === 'unavailable')).toBe(true); expect(assets.every((asset) => !asset.previewDataUrl && !asset.audioDataUrl)).toBe(true); }); it('falls back to asset/source/license Markdown tables when no JSON block exists', async () => { const projectPath = await createProject(); await writeFile(join(projectPath, 'ASSET_PLAN.md'), `# 素材计划 ## 方案 B | 素材 | 来源 | 许可证 | | --- | --- | --- | | 🐦 小鸟 4 帧飞行 | draganasgamesart - Bird Fly | CC0 | | 🔊 音效 | Kenney | CC0 | ## 方案 C | 素材 | 来源 | 许可证 | | --- | --- | --- | | 管子 | Ian Peter | CC0 | | 日落海景背景 | CraftPix.net | 需要署名 | `); const assets = await loadGameAssetCandidates(projectPath); expect(assets).toHaveLength(4); expect(assets[0]).toMatchObject({ name: '🐦 小鸟 4 帧飞行', source: 'draganasgamesart - Bird Fly', license: 'CC0', category: 'visual' }); expect(assets[1]).toMatchObject({ name: '🔊 音效', category: 'audio', source: 'Kenney' }); expect(assets.every((asset) => asset.status === 'candidate' && asset.id)).toBe(true); }); it('loads the candidate-status table used by the project asset plan', async () => { const projectPath = await createProject(); await writeFile(join(projectPath, 'ASSET_PLAN.md'), `# 素材计划 ## 候选状态 | ID | 素材名 | 类型 | 状态 | 方案 | |----|--------|------|------|------| | C-001 | Flappy Bird Style Sprites | 小鸟+管子 | 待审核 | A | | C-006 | Kenney Impact Sounds | 音效 | 待审核 | A/B/C | `); const assets = await loadGameAssetCandidates(projectPath); expect(assets).toMatchObject([ { id: 'C-001', name: 'Flappy Bird Style Sprites', category: 'visual', status: '待审核', purpose: '小鸟+管子' }, { id: 'C-006', name: 'Kenney Impact Sounds', category: 'audio', status: '待审核', purpose: '音效' }, ]); });