Files
16gagent/test/project-intake-agent.test.mjs
2026-06-06 10:40:48 +08:00

623 lines
26 KiB
JavaScript
Raw Permalink 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.
/**
* Project Intake & PRD Agent Test — SF-01
*
* 验证场景:
* 1. 宠物管理 App → 正确识别领域
* 2. 电商小程序 → 平台检测 + 物流/支付
* 3. 教育平台 → 课程相关 PRD
* 4. 企业 OA → 审批流/考勤
* 5. 极简输入 → 笔记应用
* 6. 健身打卡 iOS → 平台偏好
* 7. 空输入 → 错误处理
* 8. 通用/不识别输入 → default domain
* 9. PRD 输出结构完整性
* 10. 任务拆解
*/
import { describe, it, before } from "node:test";
import assert from "node:assert/strict";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync, rmSync, readFileSync } from "node:fs";
const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
const WORKSPACE = resolve(__dirname, "..");
const FIXTURES_DIR = resolve(WORKSPACE, "test/fixtures/project-intake");
let generatePRD, matchDomain, detectPlatforms, detectExtraFeatures;
let generateUserStories, determineTechConstraints, generateDevTasks;
let loadInput, writeOutput;
before(async () => {
const mod = await import("../scripts/project-intake-agent.mjs");
generatePRD = mod.generatePRD;
matchDomain = mod.matchDomain;
detectPlatforms = mod.detectPlatforms;
detectExtraFeatures = mod.detectExtraFeatures;
generateUserStories = mod.generateUserStories;
determineTechConstraints = mod.determineTechConstraints;
generateDevTasks = mod.generateDevTasks;
loadInput = mod.loadInput;
writeOutput = mod.writeOutput;
});
// ═══════════════════════════════════════════════════════
// 1 — 宠物管理 App
// ═══════════════════════════════════════════════════════
describe("1 — 宠物管理 App", () => {
it("识别为 pet 领域", () => {
const { data } = loadInput(resolve(FIXTURES_DIR, "pet-management.json"));
const prd = generatePRD(data.input);
assert.equal(prd.domain, "pet");
assert.equal(prd.matchConfidence, "medium"); // matchCount=1 but no keywords extracted from user input
assert.equal(prd.projectName, "PetCare");
assert.ok(prd.summary.includes("宠物"));
});
it("包含宠物相关特征", () => {
const prd = generatePRD("做一个宠物管理 App");
assert.ok(prd.features.some(f => f.name.includes("宠物")));
assert.ok(prd.features.some(f => f.name.includes("健康")));
assert.ok(prd.pages.some(p => p.name.includes("宠物")));
assert.ok(prd.apiRequirements.some(a => a.path.includes("pet")));
});
it("生成用户故事", () => {
const prd = generatePRD("做一个宠物管理 App");
assert.ok(prd.userStories.length >= 2, `Expected >= 2 stories, got ${prd.userStories.length}`);
for (const story of prd.userStories) {
assert.ok(story.id.startsWith("US-"));
assert.ok(story.as);
assert.ok(story.want);
assert.ok(story.soThat);
assert.ok(story.priority);
}
});
it("生成 MVP 范围", () => {
const prd = generatePRD("做一个宠物管理 App");
assert.ok(prd.mvpScope);
assert.ok(prd.mvpScope.features.length > 0);
assert.ok(prd.mvpScope.estimatedWeeks > 0);
assert.ok(prd.mvpScope.description);
});
});
// ═══════════════════════════════════════════════════════
// 2 — 电商小程序
// ═══════════════════════════════════════════════════════
describe("2 — 电商小程序(支付+物流)", () => {
it("识别为 ecommerce 领域", () => {
const { data } = loadInput(resolve(FIXTURES_DIR, "ecommerce-miniapp.json"));
const prd = generatePRD(data.input);
assert.equal(prd.domain, "ecommerce");
assert.equal(prd.projectName, "ShopApp");
});
it("检测到小程序平台", () => {
const platforms = detectPlatforms("做一个电商小程序,需要支持微信支付和物流追踪");
assert.ok(platforms.includes("miniapp") || platforms.includes("wechat-miniapp"));
});
it("检测到支付和物流特性", () => {
const features = detectExtraFeatures("做一个电商小程序,需要支持微信支付和物流追踪");
assert.ok(features.includes("wechat-pay"));
assert.ok(features.includes("logistics-tracking"));
});
it("技术约束包含微信支付和物流", () => {
const prd = generatePRD("做一个电商小程序,需要支持微信支付和物流追踪");
const considerations = prd.techConstraints.considerations;
assert.ok(considerations.some(c => c.includes("微信支付")));
assert.ok(considerations.some(c => c.includes("物流")));
});
it("包含电商 API 端点(domain template fallback", () => {
const prd = generatePRD("做一个电商小程序,需要支持微信支付和物流追踪");
// Domain template APIs used (no explicit API/接口 mentions in input)
assert.ok(prd.apiRequirements.some(a => a.path.includes("products")));
assert.ok(prd.apiRequirements.some(a => a.path.includes("orders")));
assert.ok(prd.apiRequirements.some(a => a.path.includes("payments")));
assert.ok(prd.apiRequirements.some(a => a.path.includes("logistics")));
// Verify extra features are still detected
assert.ok(prd.techConstraints.considerations.some(c => c.includes("微信支付")));
assert.ok(prd.techConstraints.considerations.some(c => c.includes("物流")));
});
});
// ═══════════════════════════════════════════════════════
// 3 — 在线教育平台
// ═══════════════════════════════════════════════════════
describe("3 — 在线教育平台", () => {
it("识别为 education 领域", () => {
const { data } = loadInput(resolve(FIXTURES_DIR, "edu-platform.json"));
const prd = generatePRD(data.input);
assert.equal(prd.domain, "education");
assert.equal(prd.projectName, "EduPlatform");
});
it("包含课程和题库功能", () => {
const prd = generatePRD("做一个在线教育平台");
assert.ok(prd.features.some(f => f.name.includes("课程")));
assert.ok(prd.features.some(f => f.name.includes("题库")));
// Domain template pages used (no explicit page mentions in input)
assert.ok(prd.pages.some(p => p.route.includes("course")));
assert.ok(prd.pages.some(p => p.route.includes("learn")));
assert.ok(prd.pages.some(p => p.route.includes("exercises")));
});
it("API 包含课程和进度端点", () => {
const prd = generatePRD("做一个在线教育平台");
assert.ok(prd.apiRequirements.some(a => a.path.includes("courses")));
assert.ok(prd.apiRequirements.some(a => a.path.includes("progress")));
});
});
// ═══════════════════════════════════════════════════════
// 4 — 企业 OA
// ═══════════════════════════════════════════════════════
describe("4 — 企业 OA 系统", () => {
it("识别为 enterprise 领域", () => {
const { data } = loadInput(resolve(FIXTURES_DIR, "enterprise-oa.json"));
const prd = generatePRD(data.input);
assert.equal(prd.domain, "enterprise");
assert.equal(prd.projectName, "OAFlow");
});
it("包含审批流和考勤功能", () => {
const prd = generatePRD("做一个企业办公自动化系统,包含审批流、考勤打卡、部门管理");
assert.ok(prd.features.some(f => f.name.includes("审批")));
assert.ok(prd.features.some(f => f.name.includes("考勤")));
assert.ok(prd.features.some(f => f.name.includes("部门")));
});
it("技术约束包含 RBAC", () => {
const prd = generatePRD("做一个企业办公自动化系统,包含审批流、考勤打卡、部门管理");
assert.ok(prd.techConstraints.considerations.some(c => c.includes("RBAC")));
});
it("API 包含审批端点", () => {
const prd = generatePRD("做一个企业办公自动化系统,包含审批流、考勤打卡、部门管理");
assert.ok(prd.apiRequirements.some(a => a.path.includes("approvals")));
assert.ok(prd.apiRequirements.some(a => a.path.includes("attendance")));
});
it("包含三个用户画像", () => {
const prd = generatePRD("做一个企业办公自动化系统");
assert.ok(prd.personas.length >= 3, `Expected >= 3 personas, got ${prd.personas.length}`);
});
});
// ═══════════════════════════════════════════════════════
// 5 — 极简输入
// ═══════════════════════════════════════════════════════
describe("5 — 极简输入", () => {
it("两个词的输入也能生成 PRD", () => {
const { data } = loadInput(resolve(FIXTURES_DIR, "minimal.json"));
const prd = generatePRD(data.input);
assert.ok(prd);
assert.ok(!prd.error);
assert.equal(prd.projectName, "NoteApp");
assert.equal(prd.domain, "note");
});
it("极简输入有合理的结构", () => {
const prd = generatePRD("笔记应用");
assert.ok(prd.features.length >= 3);
assert.ok(prd.pages.length >= 2);
assert.ok(prd.apiRequirements.length >= 3);
assert.ok(prd.userStories.length >= 2);
});
});
// ═══════════════════════════════════════════════════════
// 6 — 健身打卡 iOS
// ═══════════════════════════════════════════════════════
describe("6 — 健身打卡 + 平台偏好", () => {
it("检测到 iOS 平台偏好", () => {
const platforms = detectPlatforms("做一个健身打卡 AppiOS 优先");
assert.ok(platforms.includes("ios"));
});
it("识别为 fitness 领域", () => {
const prd = generatePRD("做一个健身打卡 AppiOS 优先");
assert.equal(prd.domain, "fitness");
assert.equal(prd.projectName, "FitTracker");
});
it("技术栈推荐 iOS 原生", () => {
const prd = generatePRD("做一个健身打卡 AppiOS 优先");
assert.ok(prd.techConstraints.recommendedStack.includes("SwiftUI"));
});
it("包含打卡和统计功能", () => {
const prd = generatePRD("做一个健身打卡 App");
assert.ok(prd.features.some(f => f.name.includes("打卡")));
assert.ok(prd.features.some(f => f.name.includes("统计")));
assert.ok(prd.apiRequirements.some(a => a.path.includes("checkins")));
});
});
// ═══════════════════════════════════════════════════════
// 7 — 空输入 / 错误处理
// ═══════════════════════════════════════════════════════
describe("7 — 空输入与错误处理", () => {
it("空字符串返回错误", () => {
const prd = generatePRD("");
assert.equal(prd.error, "EMPTY_INPUT");
assert.ok(prd.message);
});
it("null 输入返回错误", () => {
const prd = generatePRD(null);
assert.equal(prd.error, "EMPTY_INPUT");
});
it("仅空白字符返回错误", () => {
const prd = generatePRD(" ");
assert.equal(prd.error, "EMPTY_INPUT");
});
});
// ═══════════════════════════════════════════════════════
// 8 — 通用/不匹配输入
// ═══════════════════════════════════════════════════════
describe("8 — 通用领域降级", () => {
it("不匹配任何领域时返回 EXTRACTION_FAILED 但包含基础字段", () => {
const prd = generatePRD("做一个区块链数据分析平台");
assert.equal(prd.error, "EXTRACTION_FAILED");
assert.ok(prd.message.includes("no features/pages/apis detected"));
assert.equal(prd.domain, "generic");
assert.equal(prd.projectName, "MyProject");
assert.equal(prd.features.length, 0);
assert.equal(prd.pages.length, 0);
});
it("无法提取特征时返回错误而非虚空 PRD", () => {
const prd = generatePRD("做一个很酷的东西");
// P0 硬化后:无法提取特征时不会生成虚空 PRD
assert.equal(prd.error, "EXTRACTION_FAILED");
assert.ok(prd.projectName);
assert.equal(prd.domain, "generic");
});
it("有领域匹配时生成完整 PRD(正常分支)", () => {
const prd = generatePRD("做一个宠物管理 App");
assert.equal(prd.error, undefined);
assert.ok(prd.projectName);
assert.ok(prd.summary);
assert.ok(prd.personas.length > 0);
assert.ok(prd.userStories.length > 0);
});
});
// ═══════════════════════════════════════════════════════
// 9 — PRD 结构完整性
// ═══════════════════════════════════════════════════════
describe("9 — PRD 结构完整性", () => {
it("PRD 包含所有必需字段", () => {
const prd = generatePRD("做一个宠物管理 App");
const requiredFields = [
"projectName", "chineseName", "domain", "matchConfidence",
"summary", "personas", "userStories", "mvpScope",
"features", "pages", "apiRequirements",
"techConstraints", "extraFeatures", "devTasks", "meta",
];
for (const field of requiredFields) {
assert.ok(prd[field] !== undefined, `Missing required field: ${field}`);
}
});
it("features 包含 priority 字段", () => {
const prd = generatePRD("做一个宠物管理 App");
for (const f of prd.features) {
assert.ok(f.name);
assert.ok(f.description);
assert.ok(f.priority);
assert.ok(["P0", "P1", "P2"].includes(f.priority));
}
});
it("pages 包含 route 字段", () => {
const prd = generatePRD("做一个宠物管理 App");
for (const p of prd.pages) {
assert.ok(p.name);
assert.ok(p.route);
assert.ok(p.description);
}
});
it("apiRequirements 包含 method 和 path", () => {
const prd = generatePRD("做一个宠物管理 App");
for (const a of prd.apiRequirements) {
assert.ok(a.method);
assert.ok(a.path);
assert.ok(a.description);
assert.ok(["GET", "POST", "PUT", "DELETE"].includes(a.method));
}
});
it("devTasks 包含所有必需字段", () => {
const prd = generatePRD("做一个宠物管理 App");
for (const t of prd.devTasks) {
assert.ok(t.id.startsWith("T-"));
assert.ok(t.title);
assert.ok(t.description);
assert.ok(t.phase);
assert.ok(typeof t.estimatedHours === "number");
assert.ok(t.estimatedHours > 0);
assert.ok(t.priority);
}
});
it("MVP scope 仅包含 P0 功能", () => {
const prd = generatePRD("做一个电商小程序");
const p0Features = prd.features.filter(f => f.priority === "P0").map(f => f.name);
for (const f of prd.mvpScope.features) {
assert.ok(p0Features.includes(f), `${f} should be P0`);
}
});
it("meta 包含时间戳和输入长度", () => {
const prd = generatePRD("做一个宠物管理 App");
assert.ok(prd.meta.generatedAt);
assert.ok(typeof prd.meta.inputLength === "number");
assert.ok(typeof prd.meta.domainMatchCount === "number");
});
});
// ═══════════════════════════════════════════════════════
// 10 — 任务拆解
// ═══════════════════════════════════════════════════════
describe("10 — 任务拆解", () => {
it("任务包含 Foundation 阶段", () => {
const prd = generatePRD("做一个宠物管理 App");
const foundationTasks = prd.devTasks.filter(t => t.phase === "Foundation");
assert.ok(foundationTasks.length >= 2);
});
it("任务包含 UI Development 和 Backend Development", () => {
const prd = generatePRD("做一个电商小程序");
const phases = new Set(prd.devTasks.map(t => t.phase));
assert.ok(phases.has("UI Development"));
assert.ok(phases.has("Backend Development"));
});
it("任务包含 Integration 和 Testing", () => {
const prd = generatePRD("做一个在线教育平台");
const phases = new Set(prd.devTasks.map(t => t.phase));
assert.ok(phases.has("Integration"));
assert.ok(phases.has("Testing"));
});
it("总工时计算合理", () => {
const prd = generatePRD("做一个企业办公自动化系统");
const totalHours = prd.devTasks.reduce((s, t) => s + t.estimatedHours, 0);
assert.ok(totalHours > 40, `Expected > 40 hours, got ${totalHours}`);
assert.ok(totalHours < 500, `Expected < 500 hours, got ${totalHours}`);
});
it("所有领域的任务都有合理的工时", () => {
const inputs = [
"做一个宠物管理 App",
"做一个电商小程序",
"做一个在线教育平台",
"笔记应用",
"做一个企业办公自动化系统",
"做一个健身打卡 App",
];
for (const input of inputs) {
const prd = generatePRD(input);
// Just verify no zero-hour tasks
for (const t of prd.devTasks) {
assert.ok(t.estimatedHours > 0, `${input}: task ${t.id} has 0 hours`);
}
}
});
});
// ═══════════════════════════════════════════════════════
// 11 — Output File I/O
// ═══════════════════════════════════════════════════════
describe("11 — File I/O", () => {
it("loadInput 读取正确的 input 字段", () => {
const { data } = loadInput(resolve(FIXTURES_DIR, "pet-management.json"));
assert.ok(data.input);
assert.equal(data.input, "做一个宠物管理 App");
});
it("loadInput 对不存在的文件返回 error", () => {
const { data, error } = loadInput("/nonexistent/path.json");
assert.equal(data, null);
assert.ok(error);
});
it("writeOutput 写入文件", () => {
const prd = generatePRD("做一个宠物管理 App");
const tmpPath = resolve(WORKSPACE, ".tmp-prd-test-output.json");
writeOutput(prd, tmpPath, true);
assert.ok(existsSync(tmpPath));
// Clean up
rmSync(tmpPath);
});
});
// ═══════════════════════════════════════════════════════
// 12 — 领域匹配引擎
// ═══════════════════════════════════════════════════════
describe("12 — 领域匹配引擎", () => {
it("matchDomain 正确识别各领域", () => {
const cases = [
{ input: "宠物", expected: "pet" },
{ input: "电商商城购物", expected: "ecommerce" },
{ input: "在线课程教学", expected: "education" },
{ input: "企业OA审批", expected: "enterprise" },
{ input: "健身运动打卡", expected: "fitness" },
{ input: "笔记备忘录", expected: "note" },
{ input: "社交朋友圈", expected: "social" },
{ input: "外卖点餐", expected: "food" },
{ input: "旅游酒店机票", expected: "travel" },
];
for (const { input, expected } of cases) {
const { domainKey } = matchDomain(input);
assert.equal(domainKey, expected, `"${input}" → expected ${expected}, got ${domainKey}`);
}
});
it("多领域关键词选最多匹配的", () => {
// "宠物电商" → pet keywords: [宠物] = 1, ecommerce keywords: [电商] = 1 → tie, first match wins (pet)
const { domainKey } = matchDomain("宠物电商平台");
// pet has 1 match, ecommerce also 1 match but pet comes first
assert.ok(["pet", "ecommerce"].includes(domainKey));
});
it("无匹配回退到 generic", () => {
const { domainKey, matchCount } = matchDomain("量子计算区块链");
assert.equal(domainKey, "generic");
assert.equal(matchCount, 0);
});
});
// ═══════════════════════════════════════════════════════
// 13 — 平台检测
// ═══════════════════════════════════════════════════════
describe("13 — 平台检测", () => {
it("默认返回 mobile + web", () => {
const platforms = detectPlatforms("做一个任意应用");
assert.deepEqual(platforms, ["mobile", "web"]);
});
it("检测所有已知平台", () => {
const cases = [
{ input: "小程序", expected: "miniapp" },
{ input: "iOS App", expected: "ios" },
{ input: "安卓应用", expected: "android" },
{ input: "Web 网页", expected: "web" },
{ input: "桌面端PC", expected: "desktop" },
];
for (const { input, expected } of cases) {
const platforms = detectPlatforms(input);
assert.ok(platforms.includes(expected), `"${input}" should include ${expected}`);
}
});
it("检测多个平台", () => {
const platforms = detectPlatforms("做一个 iOS 和 Android 应用");
assert.ok(platforms.includes("ios"));
assert.ok(platforms.includes("android"));
});
});
// ═══════════════════════════════════════════════════════
// 14 — 额外特性检测
// ═══════════════════════════════════════════════════════
describe("14 — 额外特性检测", () => {
it("检测到支付特性", () => {
const features = detectExtraFeatures("需要微信支付和支付宝");
assert.ok(features.includes("wechat-pay"));
assert.ok(features.includes("alipay"));
});
it("检测到 AI 和推送", () => {
const features = detectExtraFeatures("AI 智能推荐 + 推送通知");
assert.ok(features.includes("ai-powered"));
assert.ok(features.includes("smart-recommendation"));
assert.ok(features.includes("push-notification"));
});
it("检测到离线模式和多语言", () => {
const features = detectExtraFeatures("需要支持离线模式和多语言国际化");
assert.ok(features.includes("offline-mode"));
assert.ok(features.includes("i18n"));
});
it("无特性时返回空数组", () => {
const features = detectExtraFeatures("做一个普通应用");
assert.deepEqual(features, []);
});
});
// ═══════════════════════════════════════════════════════
// 15 — CLI 端到端
// ═══════════════════════════════════════════════════════
describe("15 — CLI 端到端", () => {
it("通过 --input 直接传入需求", async () => {
const { execSync } = await import("node:child_process");
const result = execSync(
`node scripts/project-intake-agent.mjs --input "做一个宠物管理 App" --pretty`,
{ cwd: WORKSPACE, encoding: "utf-8" }
);
const parsed = JSON.parse(result);
assert.equal(parsed.projectName, "PetCare");
assert.equal(parsed.domain, "pet");
});
it("通过 --input-file 传入需求", async () => {
const { execSync } = await import("node:child_process");
const result = execSync(
`node scripts/project-intake-agent.mjs --input-file test/fixtures/project-intake/edu-platform.json --pretty`,
{ cwd: WORKSPACE, encoding: "utf-8" }
);
const parsed = JSON.parse(result);
assert.equal(parsed.projectName, "EduPlatform");
});
it("--help 显示帮助", async () => {
const { execSync } = await import("node:child_process");
const result = execSync(
`node scripts/project-intake-agent.mjs --help`,
{ cwd: WORKSPACE, encoding: "utf-8" }
);
assert.ok(result.includes("Usage"));
assert.ok(result.includes("project-intake-agent"));
});
it("缺少 input 时返回错误", async () => {
const { execSync } = await import("node:child_process");
try {
execSync(`node scripts/project-intake-agent.mjs`, { cwd: WORKSPACE, encoding: "utf-8" });
assert.fail("Should have thrown");
} catch (e) {
assert.ok(e.stderr.includes("required") || e.status !== 0);
}
});
it("--output 写入文件", async () => {
const { execSync } = await import("node:child_process");
const tmpPath = resolve(WORKSPACE, ".tmp-prd-e2e-output.json");
execSync(
`node scripts/project-intake-agent.mjs --input "笔记应用" --output ${tmpPath} --pretty`,
{ cwd: WORKSPACE, encoding: "utf-8" }
);
assert.ok(existsSync(tmpPath));
const content = JSON.parse(readFileSync(tmpPath, "utf-8"));
assert.equal(content.projectName, "NoteApp");
rmSync(tmpPath);
});
});