/** * 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("做一个健身打卡 App,iOS 优先"); assert.ok(platforms.includes("ios")); }); it("识别为 fitness 领域", () => { const prd = generatePRD("做一个健身打卡 App,iOS 优先"); assert.equal(prd.domain, "fitness"); assert.equal(prd.projectName, "FitTracker"); }); it("技术栈推荐 iOS 原生", () => { const prd = generatePRD("做一个健身打卡 App,iOS 优先"); 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); }); });