import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { createProjectConfig, projectTemplates, validateAgentNames } from '../../shared/project-config'; import { createInitialProjectConfig, normalizeProjectConfig, readProjectConfig, writeProjectConfig } from '../../electron/opencode/project-config'; describe('project-owned configuration', () => { it('exposes the replacement templates with four standard and five game Agents', () => { expect(projectTemplates.map((template) => template.id)).toEqual(['standard-development', 'game-development']); expect(projectTemplates.find((template) => template.id === 'standard-development')?.agents).toHaveLength(4); const gameAgents = projectTemplates.find((template) => template.id === 'game-development')?.agents ?? []; expect(gameAgents).toHaveLength(5); expect(gameAgents[gameAgents.length - 1]).toMatchObject({ id: 'game-promotion', roleName: '运营宣传角色' }); expect(projectTemplates.flatMap((template) => template.agents).every((agent) => agent.builtIn)).toBe(true); expect(projectTemplates.flatMap((template) => template.agents).every((agent) => agent.skillIds.includes('youth-plain-language'))).toBe(true); }); it('creates an incomplete config and validates unique required names', () => { const config = createProjectConfig('standard-development', '2026-07-11T00:00:00.000Z'); expect(config.initialized).toBe(false); expect(config.superpowersEnabled).toBe(true); expect(config.knowledgeDirectory).toBe('knowledge'); expect(validateAgentNames(config.agents)).toContain('product-planning:name-required'); config.agents.forEach((agent, index) => { agent.name = `伙伴${index + 1}`; }); expect(validateAgentNames(config.agents)).toEqual([]); config.agents[1]!.name = config.agents[0]!.name; expect(validateAgentNames(config.agents)).toContain(`${config.agents[1]!.id}:name-duplicate`); const custom = { ...structuredClone(config.agents[0]!), id: 'custom-agent', builtIn: false, name: '' }; expect(validateAgentNames([...config.agents, custom])).toContain('custom-agent:name-required'); }); it('uses template defaults for Superpowers and restores them when the field is absent', () => { const gameConfig = createProjectConfig('game-development'); expect(gameConfig.superpowersEnabled).toBe(false); expect(normalizeProjectConfig({ ...gameConfig, superpowersEnabled: undefined })).toMatchObject({ templateId: 'game-development', superpowersEnabled: false, }); const standardConfig = createProjectConfig('standard-development'); expect(standardConfig.superpowersEnabled).toBe(true); expect(normalizeProjectConfig({ ...standardConfig, superpowersEnabled: undefined })).toMatchObject({ templateId: 'standard-development', superpowersEnabled: true, }); }); it('materializes project-only runtime prompts after initialization', async () => { const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-project-config-')); const initial = await createInitialProjectConfig(projectPath, 'game-development'); initial.agents.forEach((agent, index) => { agent.name = `游戏伙伴${index + 1}`; }); initial.agents[1]!.enabled = false; expect((await readProjectConfig(projectPath)).status).toBe('valid'); const saved = await writeProjectConfig(projectPath, { ...initial, initialized: true }); expect(saved.initialized).toBe(true); expect(saved.agents.every((agent) => agent.enabled)).toBe(true); const markdown = await readFile(path.join(projectPath, '.opencode', 'agent', 'game-art.md'), 'utf8'); const worksSquareIndex = markdown.indexOf('https://square.nianxx.cn/api/assets'); const kenneyIndex = markdown.indexOf('Kenney'); expect(worksSquareIndex).toBeGreaterThanOrEqual(0); expect(kenneyIndex).toBeGreaterThan(worksSquareIndex); expect(markdown).toContain('limit'); expect(markdown).toContain('offset'); expect(markdown).toContain('# 游戏伙伴2 · 游戏素材'); expect(markdown).toContain('你的名字是「游戏伙伴2」'); expect(markdown).toContain('Kenney'); expect(markdown).toContain('OpenGameArt'); expect(markdown).toContain('ASSET_PLAN.md'); expect(markdown).toContain('ART_GUIDE.md'); expect(markdown).toContain('项目目录是所有 Agent 共享的长期上下文'); expect(markdown).toContain('聊天里的口头总结不能代替项目产物'); expect(markdown).toContain('缺少上游产物时不要把它当作阶段门禁'); expect(markdown).toContain('不依赖自动派发或跨会话消息'); expect(markdown).not.toContain('selectionStatus'); expect(markdown).not.toContain('必须停止'); expect(markdown).toContain('game-design'); expect(markdown).toContain('knowledge/'); expect(markdown).toContain('nianxxgame-skill: allow'); expect(markdown).toContain('game-assets: allow'); expect(markdown).toContain('bash: allow'); expect(markdown).toContain('youth-plain-language: allow'); expect(markdown).toContain('10 至 16 岁青少年能直接看懂'); expect(markdown).toContain('每次开始工作前读取 `VERSION.md` 和 `TASKS.md`'); expect(markdown).toContain('所有 Agent 共同维护的当前版本任务中枢'); expect(markdown).toContain('Document Revision'); expect(markdown).toContain('只能在 `TASKS.md` 提议 PATCH、MINOR 或 MAJOR'); const version = await readFile(path.join(projectPath, 'VERSION.md'), 'utf8'); const tasks = await readFile(path.join(projectPath, 'TASKS.md'), 'utf8'); const gdd = await readFile(path.join(projectPath, 'GDD.md'), 'utf8'); const assetPlan = await readFile(path.join(projectPath, 'ASSET_PLAN.md'), 'utf8'); const artGuide = await readFile(path.join(projectPath, 'ART_GUIDE.md'), 'utf8'); expect(version).toContain('Current: v0.1.0'); expect(version).toContain('Version Owner: game-design'); expect(version).toContain('Status: active'); expect(tasks).toContain('Project Version: v0.1.0'); expect(tasks).toContain('所有 Agent 共同读取和维护本文件'); expect(tasks).toContain('TASK-002 建立素材清单和接入策略'); expect(tasks).toContain('TASK-003 确认 2D/3D 渲染路径'); expect(tasks).toContain('## Version Proposals'); expect(gdd).toContain('首个可试玩结果'); expect(gdd).toContain('2D(默认)或 3D'); const techStack = await readFile(path.join(projectPath, 'TECH_STACK.md'), 'utf8'); expect(assetPlan).toContain('game-assets Skill'); expect(assetPlan).toContain('config-status'); expect(assetPlan).toContain('assets/generated/meowa//'); expect(assetPlan).toContain('generation.meta.json'); expect(assetPlan).toContain('"assets": []'); expect(assetPlan).not.toMatch(/每日.{0,30}10 次|每日额度|quota\.remaining|剩余额度|额度耗尽/); expect(assetPlan).toContain('不把 2D 生成图伪装成 3D 模型'); expect(assetPlan).toContain('GLB/glTF'); expect(techStack).toContain('Three.js + TypeScript'); expect(techStack).toContain('3D 模型不走 Meowa 生成链路'); expect(artGuide).toContain('nearest-neighbor'); expect(artGuide).toContain('GLB/glTF'); expect(artGuide).not.toMatch(/每日.{0,30}10 次|每日额度|quota\.remaining|剩余额度|额度不足/); expect(await readdir(path.join(projectPath, 'assets'))).toEqual(expect.arrayContaining(['generated', 'review-previews'])); expect(await readdir(path.join(projectPath, 'assets', 'generated'))).toContain('meowa'); expect(await readdir(path.join(projectPath, 'assets'))).toContain('models'); expect(await readdir(path.join(projectPath, 'public'))).toContain('assets'); expect(await readdir(path.join(projectPath, 'public'))).toContain('models'); const ownerMarkdown = await readFile(path.join(projectPath, '.opencode', 'agent', 'game-design.md'), 'utf8'); expect(ownerMarkdown).toContain('## 版本负责人权限'); expect(ownerMarkdown).toContain('只归档本版本变化的文档'); expect(ownerMarkdown).toContain('未完成任务带来源迁移到下一版本'); const promotionMarkdown = await readFile(path.join(projectPath, '.opencode', 'agent', 'game-promotion.md'), 'utf8'); expect(promotionMarkdown).toContain('# 游戏伙伴5 · 运营宣传角色'); expect(promotionMarkdown).toContain('PRODUCT_OVERVIEW.md'); expect(promotionMarkdown).toContain('不是投放排期、预算表或项目任务计划'); expect(promotionMarkdown).toContain('marketing-launch-story: allow'); }); it('gives every game Agent explicit shared-artifact inputs and owned outputs', () => { const agents = createProjectConfig('game-development').agents; const promptById = Object.fromEntries(agents.map((agent) => [agent.id, agent.prompt])); const releaseAgent = agents.find((agent) => agent.id === 'game-test-release'); const promotionAgent = agents.find((agent) => agent.id === 'game-promotion'); expect(releaseAgent?.skillIds).toContain('deploy-publish-check'); expect(promotionAgent?.skillIds).toEqual(expect.arrayContaining(['nianxxgame-skill', 'marketing-launch-story', 'youth-plain-language'])); expect(promptById['game-design']).toContain('每次策划工作都更新 GDD.md'); expect(promptById['game-design']).toContain('2D/3D 渲染选择'); expect(promptById['game-design']).toContain('读取 TECH_STACK.md'); expect(promptById['game-design']).toContain('不直接运行 game-assets 或请求 Meowa'); expect(promptById['game-art']).toContain('每次素材工作都更新 ASSET_PLAN.md'); expect(promptById['game-art']).toContain('game-assets'); expect(promptById['game-art']).toContain('唯一直接调用 game-assets 的 Agent'); expect(promptById['game-art']).toContain('读取 TECH_STACK.md'); expect(promptById['game-art']).toContain('3D 模型改走项目、用户或许可证明确的外部来源'); expect(promptById['game-art']).not.toMatch(/每日.{0,30}10 次|每日额度|quota\.remaining|剩余额度|额度耗尽/); expect(promptById['game-development']).toContain('每次开发工作都更新代码与 TASKS.md'); expect(promptById['game-development']).toContain('不直接运行 game-assets 或绕过素材审核'); expect(promptById['game-development']).toContain('2D Phaser 或 3D Three.js 路径'); expect(promptById['game-development']).toContain('不得把 3D 需求静默降级为 2D'); expect(promptById['game-test-release']).toContain('每次测试发布工作都更新 TEST_REPORT.md'); expect(promptById['game-test-release']).toContain('不为缺失素材自行调用 game-assets 或生成替代品'); expect(promptById['game-test-release']).toContain('按 TECH_STACK.md 检查 2D 或 3D 路径'); expect(promptById['game-promotion']).toContain('每次工作都更新 PRODUCT_OVERVIEW.md'); expect(promptById['game-promotion']).toContain('产品运营介绍'); expect(promptById['game-promotion']).toContain('不是投放排期、预算表或项目任务计划'); expect(promptById['game-promotion']).toContain('不自行宣称游戏已上线'); expect(promptById['game-promotion']).not.toContain('game-assets'); expect(promptById['game-promotion']).not.toContain('Meowa'); for (const prompt of Object.values(promptById)) { expect(prompt).toContain('项目目录是所有 Agent 共享的长期上下文'); expect(prompt).toContain('聊天里的口头总结不能代替项目产物'); } }); it('documents the Meowa handoff boundary in the bundled game Skills', async () => { const gameSkill = await readFile(path.join(process.cwd(), '.opencode', 'skills', 'nianxxgame-skill', 'SKILL.md'), 'utf8'); const assetSkill = await readFile(path.join(process.cwd(), '.opencode', 'skills', 'game-assets', 'SKILL.md'), 'utf8'); const nituSource = await readFile(path.join(process.cwd(), 'src', 'pages', 'NiTu', 'index.tsx'), 'utf8'); expect(gameSkill).toContain('game-art` is the only role that directly uses'); expect(gameSkill).toContain('game-development` integrates only assets whose project-local review state is `approved`'); expect(gameSkill).toContain('The product-operations role only describes assets'); expect(gameSkill).toContain('Three.js with TypeScript'); expect(gameSkill).toContain('references/web3d-standards.md'); expect(gameSkill).toContain('Meowa `game-assets` currently generates 2D pixel/HD raster assets only'); expect(assetSkill).toContain('`game-art` 是唯一直接调用本 Skill 的 Agent'); expect(assetSkill).toContain('`game-promotion` 不属于素材生成链路'); expect(assetSkill).toContain('当前 Meowa 适配器只生成 2D 像素/高清光栅素材,不生成 3D 模型'); expect(assetSkill).not.toMatch(/每日.{0,30}10 次|每日额度|quota\.remaining|剩余额度|额度耗尽/); expect(nituSource).not.toMatch(/每日.{0,30}10 次|每日额度|quota\.remaining|剩余额度/); }); it('runs the bundled Meowa adapter with NianCode managed Python', async () => { const assetSkill = await readFile(path.join(process.cwd(), '.opencode', 'skills', 'game-assets', 'SKILL.md'), 'utf8'); const adapterGuide = await readFile(path.join(process.cwd(), '.opencode', 'skills', 'game-assets', 'meowart_api.md'), 'utf8'); expect(assetSkill).toContain('"$NIANCODE_PYTHON_PATH" "$MEOWA_GAME_ASSETS_CLI"'); expect(assetSkill).not.toMatch(/\bpython3\s+"\$MEOWA_GAME_ASSETS_CLI"/); expect(adapterGuide).toContain('"$NIANCODE_PYTHON_PATH" meowart_api.py'); expect(adapterGuide).not.toMatch(/^python3 meowart_api\.py/gm); expect(adapterGuide).not.toMatch(/每日.{0,30}10 次|每日额度|quota\.remaining|剩余额度|额度耗尽/); }); it('does not backfill the promotion Agent into an existing saved game config', async () => { const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-project-config-no-migration-')); const initial = await createInitialProjectConfig(projectPath, 'game-development'); const legacyAgents = initial.agents.filter((agent) => agent.id !== 'game-promotion'); await writeProjectConfig(projectPath, { ...initial, agents: legacyAgents, initialized: false }); const loaded = await readProjectConfig(projectPath); expect(loaded.status).toBe('valid'); if (loaded.status !== 'valid') throw new Error('Expected a valid project config'); expect(loaded.config.agents.map((agent) => agent.id)).toEqual(['game-design', 'game-art', 'game-development', 'game-test-release']); }); it('does not backfill game starter documents when an existing project is saved', async () => { const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-project-config-no-document-migration-')); const initial = await createInitialProjectConfig(projectPath, 'game-development'); await rm(path.join(projectPath, 'ASSET_PLAN.md')); await writeProjectConfig(projectPath, { ...initial, initialized: false }); await expect(readFile(path.join(projectPath, 'ASSET_PLAN.md'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); }); it('binds the deployment check Skill to standard and game release Agents', () => { const standardRelease = createProjectConfig('standard-development').agents.find((agent) => agent.id === 'release-maintenance'); const gameRelease = createProjectConfig('game-development').agents.find((agent) => agent.id === 'game-test-release'); expect(standardRelease?.skillIds).toContain('deploy-publish-check'); expect(gameRelease?.skillIds).toContain('deploy-publish-check'); expect(standardRelease?.prompt).toContain('静态安全检查或真实执行失败时不允许云端提交'); expect(gameRelease?.prompt).toContain('真实失败输出 BLOCKED'); expect(standardRelease?.prompt).toContain('SKIPPED'); expect(gameRelease?.prompt).toContain('任何失败输出 BLOCKED'); expect(gameRelease?.prompt).toContain('SKIPPED'); }); it('refuses to initialize invalid built-in Agent names', async () => { const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-project-config-invalid-')); const initial = await createInitialProjectConfig(projectPath, 'standard-development'); await expect(writeProjectConfig(projectPath, { ...initial, initialized: true })).rejects.toThrow('invalid Agent names'); }); it('restores the required youth language Skill when saved config omits it', async () => { const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-required-language-skill-')); const initial = await createInitialProjectConfig(projectPath, 'standard-development'); initial.agents.forEach((agent, index) => { agent.name = `通俗表达伙伴${index + 1}`; agent.skillIds = []; }); const saved = await writeProjectConfig(projectPath, { ...initial, initialized: true }); expect(saved.agents.every((agent) => agent.skillIds.includes('youth-plain-language'))).toBe(true); expect(await readFile(path.join(projectPath, '.opencode', 'agent', 'development.md'), 'utf8')).toContain('youth-plain-language: allow'); }); it('uses product planning as the standard project version owner', async () => { const projectPath = await mkdtemp(path.join(tmpdir(), 'niancode-standard-version-')); const initial = await createInitialProjectConfig(projectPath, 'standard-development'); initial.agents.forEach((agent, index) => { agent.name = `标准伙伴${index + 1}`; }); await writeProjectConfig(projectPath, { ...initial, initialized: true }); expect(await readFile(path.join(projectPath, 'VERSION.md'), 'utf8')).toContain('Version Owner: product-planning'); expect(await readFile(path.join(projectPath, '.opencode', 'agent', 'product-planning.md'), 'utf8')).toContain('## 版本负责人权限'); expect(await readFile(path.join(projectPath, '.opencode', 'agent', 'development.md'), 'utf8')).not.toContain('## 版本负责人权限'); }); });