265 lines
18 KiB
TypeScript
265 lines
18 KiB
TypeScript
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('## 版本负责人权限');
|
||
});
|
||
});
|