import { describe, expect, it } from 'vitest'; import { builtInProjectTemplates, createProjectTemplateSnapshot, getProjectTemplateRequiredSkillIds, hasProjectCapability, type ProjectTemplateCourseStage, validateProjectTemplateSnapshot, } from '../../shared/project-template'; import { isCourseProjectTemplate } from '../../src/lib/project-templates'; const LEGACY_YOUTH_DEPLOY_STAGE: ProjectTemplateCourseStage = { id: 'deploy', order: 6, title: '发布部署', agentName: '部署工程师', agentRole: '发布部署', goal: '确认作品可以通过 Docker 构建、启动、打包并通过作品广场上传接口提交。', task: '创建或维护 Dockerfile 和 docker-compose.yml,检查 Docker 构建状态,把当前项目打成 zip 包,通过作品广场上传接口提交,并记录发布目标、发布步骤、回滚说明和作品广场提交清单。', deliverableTitle: '部署报告.md', deliverableType: 'report', deliverableSummary: 'Dockerfile、docker-compose.yml、发布目标、准备情况、Docker 执行命令、zip 包路径、作品广场上传接口结果、发布步骤、回滚步骤。', acceptanceCriteria: [ '有明确发布目标', '有 Dockerfile', '有 docker-compose.yml', '有 Docker 构建或检查结果', '有 zip 包路径', '有作品广场上传接口结果', '有发布步骤', '有回滚步骤', '没有隐私或密钥风险', ], handoffTarget: null, }; const LEGACY_GAME_RELEASE_STAGE: ProjectTemplateCourseStage = { id: 'game-release', order: 4, title: '发布打包', agentName: '游戏发布官', agentRole: '游戏发布官', goal: '准备构建结果、试玩记录和发布检查清单。', task: '记录如何运行游戏、试玩了什么,以及发布前后需要检查哪些项目。', deliverableTitle: 'RELEASE_CHECKLIST.md', deliverableType: 'report', deliverableSummary: '构建结果、试玩检查、运行说明、发布检查项和已知风险。', acceptanceCriteria: [ '包含构建命令和结果', '包含试玩记录', '包含运行说明', '包含发布检查项', '包含已知风险或问题', ], handoffTarget: 'game-showcase', }; function replacePublishStageWithLegacy( snapshot: ReturnType, legacyStage: ProjectTemplateCourseStage, ) { if (!snapshot.capabilities.course.enabled) throw new Error('Expected course capability'); snapshot.capabilities.course.stages = snapshot.capabilities.course.stages.map((stage) => ( stage.id === legacyStage.id ? structuredClone(legacyStage) : stage )); snapshot.capabilities.course.slots = snapshot.capabilities.course.slots.map((slot) => ( slot.id === legacyStage.id ? { ...slot, goal: legacyStage.goal } : slot )); } describe('project template snapshots', () => { it('creates gate-free built-in snapshots with static course metadata and Chinese display text', () => { const snapshot = createProjectTemplateSnapshot('youth-ai-course', '2026-07-01T00:00:00.000Z'); expect(snapshot.schemaVersion).toBe(1); expect(snapshot.template).toMatchObject({ id: 'youth-ai-course', version: 1, title: 'AI 伙伴课程项目', createdAt: '2026-07-01T00:00:00.000Z', }); expect(snapshot.capabilities.chat.enabled).toBe(true); expect(snapshot.capabilities.chat.defaultRoute).toBe('/opencode-chat'); expect(snapshot.capabilities.course.enabled).toBe(true); expect(snapshot.capabilities.publish.enabled).toBe(true); if (snapshot.capabilities.course.enabled) { expect(snapshot.capabilities.course.stages).toHaveLength(6); expect(snapshot.capabilities.course.slots).toHaveLength(6); expect(snapshot.capabilities.course).not.toHaveProperty('reviewer'); expect(snapshot.capabilities.course).not.toHaveProperty('partnerAgent'); expect(snapshot.capabilities.course).not.toHaveProperty('reviewPolicy'); expect(snapshot.capabilities.course).not.toHaveProperty('completionUnlock'); for (const stage of snapshot.capabilities.course.stages) { const slot = snapshot.capabilities.course.slots.find((candidate) => candidate.id === stage.id); const snapshotStage = snapshot.capabilities.course.stages.find((candidate) => candidate.id === stage.id); expect(slot).toMatchObject({ id: stage.id, title: stage.title, agentName: stage.agentName, agentRole: stage.agentRole, recommendedAgentProfile: { roleId: stage.id, displayName: stage.agentName, notes: [], }, }); expect(snapshotStage).toMatchObject({ id: stage.id, order: stage.order, title: stage.title, agentName: stage.agentName, agentRole: stage.agentRole, deliverableTitle: stage.deliverableTitle, deliverableType: stage.deliverableType, handoffTarget: stage.handoffTarget, }); } } }); it('keeps standard development projects out of course and publish capabilities', () => { const snapshot = createProjectTemplateSnapshot('standard-dev', '2026-07-01T00:00:00.000Z'); expect(snapshot.template.title).toBe('普通开发项目'); expect(hasProjectCapability(snapshot, 'chat')).toBe(true); expect(hasProjectCapability(snapshot, 'course')).toBe(false); expect(hasProjectCapability(snapshot, 'publish')).toBe(false); expect(hasProjectCapability(snapshot, 'gameDevelopment')).toBe(false); }); it('normalizes legacy youth course snapshots that still point at the retired subagents route', () => { const snapshot = createProjectTemplateSnapshot('youth-ai-course', '2026-07-01T00:00:00.000Z'); snapshot.capabilities.chat.defaultRoute = '/subagents'; const result = validateProjectTemplateSnapshot(snapshot); expect(result.ok).toBe(true); if (result.ok) { expect(result.snapshot.capabilities.chat.defaultRoute).toBe('/opencode-chat'); } }); it('normalizes the exact legacy youth deploy contract after route and scaffold compatibility', () => { const snapshot = createProjectTemplateSnapshot('youth-ai-course', '2026-07-01T00:00:00.000Z'); snapshot.capabilities.chat.defaultRoute = '/subagents'; delete snapshot.capabilities.scaffold; replacePublishStageWithLegacy(snapshot, LEGACY_YOUTH_DEPLOY_STAGE); const result = validateProjectTemplateSnapshot(snapshot); expect(result.ok).toBe(true); if (result.ok && result.snapshot.capabilities.course.enabled) { const stage = result.snapshot.capabilities.course.stages.find((item) => item.id === 'deploy'); expect(stage?.acceptanceCriteria).toContain('没有失败/阻塞或未验证的必需发布证据'); expect(result.snapshot.capabilities.chat.defaultRoute).toBe('/opencode-chat'); expect(result.snapshot.capabilities.scaffold).toEqual({ enabled: false }); expect(result.snapshot.template.createdAt).toBe('2026-07-01T00:00:00.000Z'); } }); it('normalizes the exact five-stage legacy game release delivery contract', () => { const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); replacePublishStageWithLegacy(snapshot, LEGACY_GAME_RELEASE_STAGE); const result = validateProjectTemplateSnapshot(snapshot); expect(result.ok).toBe(true); if (result.ok && result.snapshot.capabilities.course.enabled) { const stage = result.snapshot.capabilities.course.stages.find((item) => item.id === 'game-release'); expect(stage?.acceptanceCriteria).toContain('没有失败/阻塞或未验证的必需发布证据'); expect(result.snapshot.template.createdAt).toBe('2026-07-01T00:00:00.000Z'); } }); it('accepts legacy built-in snapshots without scaffold when the built-in scaffold is disabled', () => { const standardSnapshot = createProjectTemplateSnapshot('standard-dev', '2026-07-01T00:00:00.000Z'); const youthSnapshot = createProjectTemplateSnapshot('youth-ai-course', '2026-07-01T00:00:00.000Z'); delete standardSnapshot.capabilities.scaffold; delete youthSnapshot.capabilities.scaffold; const standardResult = validateProjectTemplateSnapshot(standardSnapshot); const youthResult = validateProjectTemplateSnapshot(youthSnapshot); expect(standardResult.ok).toBe(true); expect(youthResult.ok).toBe(true); if (standardResult.ok) { expect(standardResult.snapshot.capabilities.scaffold).toEqual({ enabled: false }); } if (youthResult.ok) { expect(youthResult.snapshot.capabilities.scaffold).toEqual({ enabled: false }); } }); it('accepts built-in mini-game course snapshots with non-youth stage ids', () => { const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); const result = validateProjectTemplateSnapshot(snapshot); expect(result.ok).toBe(true); }); it('normalizes legacy mini-game snapshots that predate the showcase stage', () => { const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); if (!snapshot.capabilities.course.enabled) throw new Error('Expected mini-game course capability'); snapshot.template.version = 1; snapshot.capabilities.course.stages = snapshot.capabilities.course.stages.filter((stage) => stage.id !== 'game-showcase'); snapshot.capabilities.course.slots = snapshot.capabilities.course.slots.filter((slot) => slot.id !== 'game-showcase'); const releaseStage = snapshot.capabilities.course.stages.find((stage) => stage.id === 'game-release'); if (!releaseStage) throw new Error('Expected release stage'); releaseStage.handoffTarget = null; const result = validateProjectTemplateSnapshot(snapshot); expect(result.ok).toBe(true); if (result.ok && result.snapshot.capabilities.course.enabled) { expect(result.snapshot.template.createdAt).toBe('2026-07-01T00:00:00.000Z'); expect(result.snapshot.capabilities.course.stages.map((stage) => stage.id)).toEqual([ 'gameplay', 'assets', 'game-dev', 'game-release', 'game-showcase', ]); expect(result.snapshot.capabilities.course.slots.map((slot) => slot.id)).toContain('game-showcase'); expect(result.snapshot.capabilities.course.stages.find((stage) => stage.id === 'game-release')).toMatchObject({ handoffTarget: 'game-showcase', }); } }); it('normalizes a four-stage mini-game snapshot with the frozen legacy release delivery', () => { const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); replacePublishStageWithLegacy(snapshot, { ...LEGACY_GAME_RELEASE_STAGE, handoffTarget: null }); if (!snapshot.capabilities.course.enabled) throw new Error('Expected mini-game course capability'); snapshot.capabilities.course.stages = snapshot.capabilities.course.stages.filter((stage) => stage.id !== 'game-showcase'); snapshot.capabilities.course.slots = snapshot.capabilities.course.slots.filter((slot) => slot.id !== 'game-showcase'); const result = validateProjectTemplateSnapshot(snapshot); expect(result.ok).toBe(true); if (result.ok && result.snapshot.capabilities.course.enabled) { const stage = result.snapshot.capabilities.course.stages.find((item) => item.id === 'game-release'); expect(stage?.acceptanceCriteria).toContain('没有失败/阻塞或未验证的必需发布证据'); expect(stage?.handoffTarget).toBe('game-showcase'); expect(result.snapshot.capabilities.course.stages.map((item) => item.id)).toContain('game-showcase'); expect(result.snapshot.template.createdAt).toBe('2026-07-01T00:00:00.000Z'); } }); it('rejects a near-match legacy delivery snapshot with an unrelated stage tamper', () => { const snapshot = createProjectTemplateSnapshot('youth-ai-course', '2026-07-01T00:00:00.000Z'); snapshot.capabilities.chat.defaultRoute = '/subagents'; delete snapshot.capabilities.scaffold; replacePublishStageWithLegacy(snapshot, LEGACY_YOUTH_DEPLOY_STAGE); if (!snapshot.capabilities.course.enabled) throw new Error('Expected youth course capability'); const planStage = snapshot.capabilities.course.stages.find((stage) => stage.id === 'pm'); if (!planStage) throw new Error('Expected plan stage'); planStage.title = '被篡改的阶段'; const result = validateProjectTemplateSnapshot(snapshot); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain('built-in contract'); } }); it('rejects unrelated tampering across every mini-game legacy matcher path', () => { const fiveStageLegacy = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); replacePublishStageWithLegacy(fiveStageLegacy, LEGACY_GAME_RELEASE_STAGE); const fourStageCurrent = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); if (!fourStageCurrent.capabilities.course.enabled) throw new Error('Expected mini-game course capability'); fourStageCurrent.capabilities.course.stages = fourStageCurrent.capabilities.course.stages .filter((stage) => stage.id !== 'game-showcase'); fourStageCurrent.capabilities.course.slots = fourStageCurrent.capabilities.course.slots .filter((slot) => slot.id !== 'game-showcase'); const currentRelease = fourStageCurrent.capabilities.course.stages .find((stage) => stage.id === 'game-release'); if (!currentRelease) throw new Error('Expected current release stage'); currentRelease.handoffTarget = null; const fourStageLegacy = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); replacePublishStageWithLegacy(fourStageLegacy, { ...LEGACY_GAME_RELEASE_STAGE, handoffTarget: null }); if (!fourStageLegacy.capabilities.course.enabled) throw new Error('Expected mini-game course capability'); fourStageLegacy.capabilities.course.stages = fourStageLegacy.capabilities.course.stages .filter((stage) => stage.id !== 'game-showcase'); fourStageLegacy.capabilities.course.slots = fourStageLegacy.capabilities.course.slots .filter((slot) => slot.id !== 'game-showcase'); for (const snapshot of [fiveStageLegacy, fourStageCurrent, fourStageLegacy]) { if (!snapshot.capabilities.course.enabled) throw new Error('Expected mini-game course capability'); const gameplayStage = snapshot.capabilities.course.stages.find((stage) => stage.id === 'gameplay'); if (!gameplayStage) throw new Error('Expected gameplay stage'); gameplayStage.title = '被篡改的玩法阶段'; const result = validateProjectTemplateSnapshot(snapshot); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain('built-in contract'); } } }); it('does not repair a non-string createdAt while matching a legacy delivery snapshot', () => { const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); replacePublishStageWithLegacy(snapshot, LEGACY_GAME_RELEASE_STAGE); (snapshot.template as { createdAt: unknown }).createdAt = 123; const result = validateProjectTemplateSnapshot(snapshot); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain('template.createdAt'); } }); it('rejects built-in mini-game course snapshots when scaffold is missing', () => { const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); delete snapshot.capabilities.scaffold; const result = validateProjectTemplateSnapshot(snapshot); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain('built-in contract web-mini-game-course@3'); } }); it('rejects duplicate course stage ids within a template snapshot', () => { const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); if (snapshot.capabilities.course.enabled) { snapshot.capabilities.course.slots[1] = { ...snapshot.capabilities.course.slots[1], id: snapshot.capabilities.course.slots[0].id, recommendedAgentProfile: { ...snapshot.capabilities.course.slots[1].recommendedAgentProfile, roleId: snapshot.capabilities.course.slots[0].id, }, }; } const result = validateProjectTemplateSnapshot(snapshot); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain('capabilities.course.slots.id'); expect(result.error).toContain('capabilities.course.slotStageAlignment'); } }); it('validates damaged snapshots with actionable errors', () => { const result = validateProjectTemplateSnapshot({ schemaVersion: 1, template: { id: '', version: 0, title: '', description: '' }, capabilities: { chat: { enabled: true }, course: { enabled: false }, publish: { enabled: false } }, }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain('template.id'); expect(result.error).toContain('template.version'); expect(result.error).toContain('template.title'); } }); it('rejects structurally valid snapshots that do not match the built-in semantic contract', () => { const snapshot = createProjectTemplateSnapshot('standard-dev', '2026-07-01T00:00:00.000Z'); const courseSnapshot = createProjectTemplateSnapshot('youth-ai-course', '2026-07-01T00:00:00.000Z'); snapshot.capabilities.course = structuredClone(courseSnapshot.capabilities.course); snapshot.capabilities.publish = { enabled: true, provider: 'works-square', }; const result = validateProjectTemplateSnapshot(snapshot); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain('built-in contract'); expect(result.error).toContain('standard-dev@1'); } }); it('exports the clean v3 mini-game template alongside the existing templates', () => { expect(builtInProjectTemplates.map((template) => template.template.id)).toEqual([ 'standard-dev', 'youth-ai-course', 'web-mini-game-course', ]); }); it('includes the mini-game template scaffold contract and five course stages', () => { const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); expect(snapshot.template).toMatchObject({ version: 3, title: 'Web 小游戏课程项目', description: '启用结对式 Web 小游戏课程、游戏设计任务文档和 Phaser 3 脚手架支持。', }); expect(snapshot.capabilities).toMatchObject({ scaffold: { enabled: true, kind: 'phaser-2d-casual', entryFile: 'src/main.ts', startCommand: 'pnpm dev', packageManager: 'pnpm', }, gameDevelopment: { enabled: true, engine: 'phaser3', requiredSkillIds: ['nianxxgame-skill'], requiredRoleIds: ['gameplay', 'game-dev', 'game-release'], designDocument: 'GDD.md', taskDocument: 'TASKS.md', collaborationMode: 'pair', }, }); expect(getProjectTemplateRequiredSkillIds(snapshot, 'game-dev')).toEqual(['nianxxgame-skill']); expect(getProjectTemplateRequiredSkillIds(snapshot, 'assets')).toEqual([]); if (snapshot.capabilities.course.enabled) { expect(snapshot.capabilities.course.title).toBe('Web 小游戏制作路径'); expect(snapshot.capabilities.course.stages.map((stage) => stage.id)).toEqual([ 'gameplay', 'assets', 'game-dev', 'game-release', 'game-showcase', ]); expect(snapshot.capabilities.course.stages.map((stage) => stage.title)).toEqual([ '玩法规划', '素材搜索', '游戏开发', '发布打包', '宣传展示', ]); const showcaseStage = snapshot.capabilities.course.stages.find((stage) => stage.id === 'game-showcase'); expect(showcaseStage).toMatchObject({ order: 5, deliverableTitle: 'SHOWCASE_PACKAGE.md', handoffTarget: null, }); const releaseStage = snapshot.capabilities.course.stages.find((stage) => stage.id === 'game-release'); expect(releaseStage).toMatchObject({ order: 4, handoffTarget: 'game-showcase', }); } }); it('names the complete Works Square package evidence in the game release starter prompt', () => { const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); if (!snapshot.capabilities.course.enabled) throw new Error('Expected mini-game course capability'); const releaseStage = snapshot.capabilities.course.stages.find((stage) => stage.id === 'game-release'); if (!releaseStage) throw new Error('Expected game release stage'); const starterPrompt = [ releaseStage.deliverableTitle, releaseStage.task, releaseStage.deliverableSummary, ...releaseStage.acceptanceCriteria, ].join('\n'); expect(starterPrompt).toContain('RELEASE_CHECKLIST.md'); expect(starterPrompt).toContain('Compose 配置、构建、启动、动态端口、HTTP 冒烟和清理'); expect(starterPrompt).toContain('ZIP 根目录、大小、文件数、路径安全、重复项和符号链接'); expect(starterPrompt).toContain('works-publish.json'); expect(starterPrompt).toContain('发布与回滚'); }); it('treats every course-capable template as a course project template', () => { expect(isCourseProjectTemplate('youth-ai-course')).toBe(true); expect(isCourseProjectTemplate('web-mini-game-course')).toBe(true); expect(isCourseProjectTemplate('standard-dev')).toBe(false); }); it('rejects the removed v1 mini-game contract instead of migrating it', () => { const snapshot = createProjectTemplateSnapshot('web-mini-game-course', '2026-07-01T00:00:00.000Z'); snapshot.template.version = 1; const result = validateProjectTemplateSnapshot(snapshot); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain('built-in contract web-mini-game-course@1'); } }); });