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

494 lines
22 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 { 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<typeof createProjectTemplateSnapshot>,
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');
}
});
});