Files
makelore/tests/unit/project-config.test.ts
2026-07-29 17:22:35 +08:00

265 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/<run>/');
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('## 版本负责人权限');
});
});