/** * PBL v2 — Planner schema-level tests. * * Full agentic-loop tests (mocking the LLM via `MockLanguageModelV3`) * are deferred to PR 5 so they can be written alongside the Instructor * force-advance / closing-check tests, which already need that same * machinery. PR 2 covers what we can test *without* the LLM: * * 1. Public API surface (the names the rest of the codebase imports) * 2. Error-path guarantees (missing `pblConfig` → `PlannerV2Error`, * not a silent corrupt project) * 3. The `legacyStubFromV2` helper output shape (so the generated * v1 `projectConfig` stub stays valid against * `PBLProjectConfig` even after v2 schema changes) */ import { describe, it, expect } from 'vitest'; import { generatePBLV2Project, PlannerV2Error, plannerCompletionGaps, plannerStepHasAcceptedCompletion, normalizeSynthesisChecks, buildScenarioDesignBlock, MAX_SYNTHESIS_STAGES, type PlannerV2Callbacks, type PlannerV2ProgressEvent, } from '@/lib/pbl/v2/agents/planner'; import type { StepResult, ToolSet } from 'ai'; describe('PBL v2 — scenario design block (free-first dialogue)', () => { const pblConfig = { projectTopic: '恋爱沟通', projectDescription: '练习', targetSkills: ['倾听'], }; it('returns empty for non-roleplay outlines (ordinary PBL prompt unchanged)', () => { expect(buildScenarioDesignBlock(pblConfig, false)).toBe(''); }); it('defaults scene beats to free dialogue (no scripted options)', () => { const block = buildScenarioDesignBlock(pblConfig, true); expect(block).toMatch(/FREE-FIRST/); expect(block).toMatch(/the learner always (speaks\/types|types) their OWN response/i); }); it('routes hidden analysis to characterObjective / hints, never into the scene', () => { const block = buildScenarioDesignBlock(pblConfig, true); // hints guide thinking, never hand over a copy-paste line expect(block).toMatch(/`hints`/); expect(block).toMatch(/NEVER spoken by the character/i); }); it('forbids authoring the roleplay character as a coach and routes out-of-scene info to its own channels', () => { const block = buildScenarioDesignBlock(pblConfig, true); expect(block).toMatch(/pure in-world participant, NEVER a coach/i); expect(block).toMatch(/Out-of-scene content has its own channels/i); expect(block).toMatch(/`narration`/); expect(block).toMatch(/`hints`/); }); it('forbids spoilers: learner-visible text must not reveal what a beat is meant to make them discover; hidden facts go in characterObjective', () => { const block = buildScenarioDesignBlock(pblConfig, true); // The general anti-spoiler rule must be present... expect(block).toMatch(/No spoilers/i); expect(block).toMatch(/learner can see up front/i); expect(block).toMatch(/MUST NOT appear in any learner-visible field/i); // ...and route the to-be-discovered fact to the private per-beat channel. expect(block).toMatch(/Put it ONLY in that beat's private `characterObjective`/); // situation is explicitly flagged as learner-visible (briefing) → no spoilers expect(block).toMatch(/always-visible scenario briefing/i); // characterObjective is documented as the home for what the learner uncovers expect(block).toMatch(/fact the learner is meant to UNCOVER this beat/i); }); it('directs the Planner to author ONE project-wide scene visual from all roleplay stages', () => { const block = buildScenarioDesignBlock(pblConfig, true); expect(block).toMatch(/set_scene_visual/); expect(block).toMatch(/project-wide scene visual/i); expect(block).toMatch(/motifs/); // it must be derived from the actual stages, not a guessed category expect(block).toMatch(/specific to THIS project/i); }); it('B1′: requires an observable successWhen ("deliverable") per beat + drama arc + skill/motivation tags', () => { const block = buildScenarioDesignBlock(pblConfig, true); // observable success condition is the scenario "deliverable" that gates advance expect(block).toMatch(/`successWhen`/); expect(block).toMatch(/REQUIRED for every roleplay beat/i); expect(block).toMatch(/off-topic \/ small-talk turns from advancing/i); // beats are a dramatic arc of meaningful decisions, not flat filler expect(block).toMatch(/DRAMATIC ARC/i); expect(block).toMatch(/MEANINGFUL decision\/action unit/i); // per-beat motivation + skill tags expect(block).toMatch(/`characterObjective`/); expect(block).toMatch(/`skillFocus`/); }); }); import type { SceneOutline } from '@/lib/types/generation'; import type { PBLMilestone } from '@/lib/pbl/v2/types'; import type { PBLPlannerV2Input, PBLProjectV2 } from '@/lib/pbl/v2/types'; // A minimal outline that mimics what the OpenMAIC outline-generator // produces for a PBL scene. Only fields the Planner reads are filled // in — `pblConfig` is the contract surface. function pblOutline(overrides?: Partial): SceneOutline { return { id: 'outline-pbl-1', type: 'pbl', title: 'CSV Data Analyzer', description: 'Build a small CSV → chart → report tool.', keyPoints: ['DataFrame', 'File IO', 'Visualization'], teachingObjective: 'Get comfortable with end-to-end data analysis.', order: 1, pblConfig: { projectTopic: 'CSV Data Analyzer', projectDescription: 'Build a small CSV → chart → report tool.', targetSkills: ['DataFrame', 'File IO', 'Visualization'], issueCount: 3, }, ...overrides, }; } function plannerInput(overrides?: Partial): PBLPlannerV2Input { const outline = pblOutline(); return { outline, courseContext: { allOutlines: [outline], languageDirective: 'Reply in English.', }, ...overrides, }; } function minimalPlannerProject(overrides?: Partial): PBLProjectV2 { return { uiPhase: 'hero', title: 'CSV Data Analyzer', description: 'Build a small CSV analyzer.', learningObjective: 'Practice file IO and simple charting.', proficiency: 'beginner', language: 'en-US', tags: [], status: 'designing', roles: [ { id: 'role-instructor', type: 'instructor', name: 'Instructor', }, ], milestones: [ { id: 'milestone-1', title: 'Load CSV data', status: 'locked', order: 0, microtasks: [ { id: 'microtask-1', title: 'Pick a CSV sample', status: 'todo', assignee: 'user', hints: [], order: 0, }, ], documents: [], }, ], submissions: [], evaluations: [], threads: [], engagementEvents: [], createdAt: '2026-05-25T08:00:00.000Z', updatedAt: '2026-05-25T08:00:00.000Z', ...overrides, }; } describe('PBL v2 Planner — public API surface', () => { it('exports the entry point and error class', () => { expect(typeof generatePBLV2Project).toBe('function'); expect(PlannerV2Error.prototype).toBeInstanceOf(Error); }); it('PlannerV2Error carries a `partial` project for graceful fallback', () => { const err = new PlannerV2Error('test', { uiPhase: 'hero', title: '', description: '', proficiency: '', language: 'en-US', tags: [], status: 'designing', roles: [], milestones: [], submissions: [], evaluations: [], threads: [], engagementEvents: [], createdAt: '2026-05-25T08:00:00.000Z', updatedAt: '2026-05-25T08:00:00.000Z', }); expect(err.name).toBe('PlannerV2Error'); expect(err.partial.milestones).toEqual([]); }); }); describe('PBL v2 Planner — error paths (no LLM needed)', () => { it('throws PlannerV2Error when outline.pblConfig is missing', async () => { const input = plannerInput({ outline: pblOutline({ pblConfig: undefined }), }); // We pass `undefined as never` for the language model because the // function fails before any LLM call — the `pblConfig` check is // the first thing it does. Using `as never` keeps the test // payload honest (we are deliberately violating the contract to // observe the guard). await expect(generatePBLV2Project(input, undefined as never)).rejects.toBeInstanceOf( PlannerV2Error, ); }); }); describe('PBL v2 Planner — completion gate', () => { it('does not accept design completion while a milestone has no microtasks', () => { const project = minimalPlannerProject({ milestones: [ { id: 'milestone-empty', title: 'Prepare analysis plan', status: 'locked', order: 0, microtasks: [], documents: [], }, ], }); expect(plannerCompletionGaps(project)).toContain( 'milestone "Prepare analysis plan" has no microtasks', ); }); it('accepts design completion only after all required project structure exists', () => { expect(plannerCompletionGaps(minimalPlannerProject())).toEqual([]); }); it('rejects ordinary PBL hidden documents because the workspace does not render them', () => { const project = minimalPlannerProject(); project.milestones[0].documents = [ { id: 'doc-hidden', title: 'Hidden primer', content: 'This would not be visible in the ordinary PBL workspace.', docType: 'reference', }, ]; expect(plannerCompletionGaps(project).some((g) => g.includes('hidden documents'))).toBe(true); }); it('ordinary projects are unaffected by the scenario flag being off (no scenario gaps)', () => { // No opts → scenario checks never run; byte-identical to before. expect(plannerCompletionGaps(minimalPlannerProject())).toEqual([]); // Explicit false behaves the same. expect(plannerCompletionGaps(minimalPlannerProject(), { scenarioRoleplay: false })).toEqual([]); }); it('only stops the planner loop after mark_design_complete returns ok=true', () => { const rejectedStep = { toolResults: [ { toolName: 'mark_design_complete', output: { ok: false, gaps: ['milestone "Prepare analysis plan" has no microtasks'], nextAction: 'Call add_microtask for milestoneId="milestone-empty".', }, }, ], } as StepResult; const acceptedStep = { toolResults: [ { toolName: 'mark_design_complete', output: { ok: true }, }, ], } as StepResult; expect(plannerStepHasAcceptedCompletion(rejectedStep)).toBe(false); expect(plannerStepHasAcceptedCompletion(acceptedStep)).toBe(true); }); }); describe('PBL v2 Planner — scenario completion gate (no LLM needed)', () => { function lightMicrotask(id: string, title: string) { return { id, title, status: 'todo' as const, assignee: 'user' as const, hints: [], order: 0 }; } // A coherent role-play scenario project: full three-stage skeleton // (prep → scene → wrapup) + a cast with name/persona/situation. function scenarioProject(overrides?: Partial): PBLProjectV2 { return minimalPlannerProject({ scenario: { setting: '校园咖啡馆的午后', sceneVisual: { caption: '校园咖啡馆的安静午后', bg1: '#3a2740', bg2: '#2c1f30', accent: '#ffb38a', motifs: ['☕', '📚'], }, characters: [ { id: 'char-1', name: '小敏', persona: '内向的同学,说话轻声细语', situation: '这周被一门考试压得喘不过气,情绪低落', }, ], }, schemaVersion: 1, milestones: [ { id: 'ms-prep', title: '准备', status: 'active', order: 0, scenarioStage: 'prep', microtasks: [lightMicrotask('mt-prep', '了解背景')], documents: [], }, { id: 'ms-scene', title: '初次搭话', status: 'locked', order: 1, scenarioStage: 'roleplay', microtasks: [ { ...lightMicrotask('beat-1', '打招呼'), completionCriteria: '学习者向小敏打了招呼并对她的回应做出回复', }, ], documents: [], }, { id: 'ms-wrapup', title: '收尾', status: 'locked', order: 2, scenarioStage: 'wrapup', microtasks: [lightMicrotask('mt-wrap', '听取反馈')], documents: [], }, ], ...overrides, }); } it('accepts a coherent scenario project (cast + prep/scene/wrapup skeleton)', () => { expect(plannerCompletionGaps(scenarioProject(), { scenarioRoleplay: true })).toEqual([]); }); it('flags a scenario project that never called set_scenario', () => { const p = scenarioProject({ scenario: undefined }); const gaps = plannerCompletionGaps(p, { scenarioRoleplay: true }); expect(gaps.some((g) => g.includes('set_scenario was never called'))).toBe(true); }); it('flags a character missing name, persona, or situation', () => { const p = scenarioProject(); p.scenario!.characters = [{ id: 'char-1', name: '小敏', persona: '内向', situation: ' ' }]; const gaps = plannerCompletionGaps(p, { scenarioRoleplay: true }); expect(gaps.some((g) => g.includes('missing name, persona, or situation'))).toBe(true); }); it('flags a scenario project with no scene stage', () => { const p = scenarioProject(); // turn the scene stage into a plain prep so there is no 'roleplay' p.milestones[1].scenarioStage = 'prep'; const gaps = plannerCompletionGaps(p, { scenarioRoleplay: true }); expect(gaps.some((g) => g.includes('scenarioStage:"roleplay"'))).toBe(true); }); it('flags when the first milestone is not prep / last is not wrapup', () => { const p = scenarioProject(); p.milestones[0].scenarioStage = 'roleplay'; // first no longer prep const gaps = plannerCompletionGaps(p, { scenarioRoleplay: true }); expect(gaps.some((g) => g.includes('FIRST milestone must be scenarioStage:"prep"'))).toBe(true); }); it('flags a scenario project that never authored a scene visual (set_scene_visual)', () => { const p = scenarioProject(); p.scenario!.sceneVisual = undefined; const gaps = plannerCompletionGaps(p, { scenarioRoleplay: true }); expect(gaps.some((g) => g.includes('set_scene_visual'))).toBe(true); }); it('flags a scene visual with a caption but no emoji motifs', () => { const p = scenarioProject(); p.scenario!.sceneVisual = { caption: '某处', motifs: [] }; const gaps = plannerCompletionGaps(p, { scenarioRoleplay: true }); expect(gaps.some((g) => g.includes('set_scene_visual'))).toBe(true); }); it('does NOT apply scenario checks when the flag is off, even if scenario data is incomplete', () => { // Defensive: an ordinary project must never be blocked by scenario // gaps, regardless of any stray fields. const p = scenarioProject({ scenario: undefined, milestones: minimalPlannerProject().milestones, }); expect(plannerCompletionGaps(p)).toEqual([]); }); it('packaged scenario survives a JSON serialization round-trip unchanged', () => { const p = scenarioProject(); const roundTripped = JSON.parse(JSON.stringify(p)) as PBLProjectV2; expect(roundTripped).toEqual(p); // And it is still recognised as a valid v2 project + still coherent. expect(plannerCompletionGaps(roundTripped, { scenarioRoleplay: true })).toEqual([]); }); }); describe('PBL v2 Planner — targetLanguage overrides detection (UI locale path)', () => { it('Planner uses targetLanguage when present, ignoring outline content', async () => { // Outline content is in English (LeetCode/CSV style) but the // user explicitly chose zh-CN in the UI switcher. The Planner // MUST honour the UI choice — this is the regression we fixed. const outline: SceneOutline = { id: 'o', type: 'pbl', title: 'Personal Finance Dashboard', description: 'Build a React dashboard for personal finances.', keyPoints: ['React', 'Charts', 'State management'], order: 1, pblConfig: { projectTopic: 'Personal Finance Dashboard', projectDescription: 'Build a React dashboard for personal finances.', targetSkills: ['React'], issueCount: 3, }, }; const input: PBLPlannerV2Input = { outline, courseContext: { allOutlines: [outline], languageDirective: 'Reply in English.', }, targetLanguage: 'zh-CN', }; // We can't run the full agentic loop without an LLM, but the // pblConfig guard fires first; the resulting `partial` project // still went through `emptyProject` so we can read its language. try { await generatePBLV2Project( { ...input, outline: { ...outline, pblConfig: undefined } }, undefined as never, ); } catch (err) { // `partial` was built via emptyProject(input), which now reads // targetLanguage first. Confirm the override is respected. const partial = (err as PlannerV2Error).partial; expect(partial.language).toBe('zh-CN'); } }); it('leaves language blank when no targetLanguage is given (Hero locale-sync fills it)', async () => { const outline: SceneOutline = { id: 'o', type: 'pbl', title: 'Personal Finance Dashboard', description: 'Build a React dashboard.', keyPoints: ['React'], order: 1, pblConfig: { projectTopic: 'Personal Finance Dashboard', projectDescription: 'Build a React dashboard.', targetSkills: ['React'], issueCount: 3, }, }; const input: PBLPlannerV2Input = { outline: { ...outline, pblConfig: undefined }, courseContext: { allOutlines: [outline], languageDirective: 'Reply in English.', }, // No targetLanguage → language stays '' (no content-based locale guessing). }; try { await generatePBLV2Project(input, undefined as never); } catch (err) { const partial = (err as PlannerV2Error).partial; expect(partial.language).toBe(''); } }); it('whitespace-only targetLanguage leaves language blank (no content guessing)', async () => { const outline: SceneOutline = pblOutline({ description: '通过 Java 实现一个 HashMap 词频统计程序', pblConfig: { projectTopic: '用 HashMap 统计词频', projectDescription: '通过 Java 实现一个 HashMap 词频统计程序', targetSkills: ['HashMap'], issueCount: 3, }, }); const input: PBLPlannerV2Input = { outline: { ...outline, pblConfig: undefined }, courseContext: { allOutlines: [outline], languageDirective: '', }, targetLanguage: ' ', }; try { await generatePBLV2Project(input, undefined as never); } catch (err) { const partial = (err as PlannerV2Error).partial; // whitespace targetLanguage → '' (content is NOT scanned for a locale) expect(partial.language).toBe(''); } }); }); describe('PBL v2 Planner — normalizeSynthesisChecks (P1 deterministic cap/fallback)', () => { function ms(id: string, order: number, extra: Partial = {}): PBLMilestone { return { id, title: `Stage ${order}`, description: '', status: order === 0 ? 'active' : 'locked', order, microtasks: [ { id: `${id}-mt`, title: 't', status: 'todo', assignee: 'user', hints: [], order: 0 }, ], documents: [], ...extra, }; } function project(milestones: PBLMilestone[], learningObjective = ''): PBLProjectV2 { return { uiPhase: 'workspace', title: 'Build a thing', description: 'desc', learningObjective, proficiency: 'beginner', language: 'zh-CN', tags: [], status: 'active', roles: [{ id: 'r', type: 'instructor', name: 'I' }], milestones, submissions: [], evaluations: [], threads: [], engagementEvents: [], createdAt: 'x', updatedAt: 'x', }; } it('caps to MAX_SYNTHESIS_STAGES when the planner over-flags, keeping the most relevant', () => { const p = project( [ ms('m0', 0, { title: '循环与迭代', synthesisCheck: { coreConcept: '循环' } }), ms('m1', 1, { title: '环境设置', synthesisCheck: { coreConcept: '安装' } }), ms('m2', 2, { title: '收尾打包', synthesisCheck: { coreConcept: '打包' } }), ], '学会用循环处理列表', ); normalizeSynthesisChecks(p); const flagged = p.milestones.filter((m) => m.synthesisCheck); expect(flagged.length).toBe(MAX_SYNTHESIS_STAGES); // The loop-related stage is most relevant to the objective and must survive. expect(p.milestones.find((m) => m.id === 'm0')?.synthesisCheck).toBeTruthy(); }); it('adds exactly one synthesisCheck when the planner flagged none', () => { const p = project([ms('m0', 0), ms('m1', 1), ms('m2', 2)], '学会用循环处理列表'); normalizeSynthesisChecks(p); const flagged = p.milestones.filter((m) => m.synthesisCheck); expect(flagged.length).toBe(1); expect(flagged[0].synthesisCheck?.coreConcept).toBeTruthy(); }); it('leaves a valid 1-2 flagged set untouched', () => { const p = project([ ms('m0', 0, { synthesisCheck: { coreConcept: 'A' } }), ms('m1', 1), ms('m2', 2), ]); normalizeSynthesisChecks(p); expect(p.milestones.filter((m) => m.synthesisCheck).length).toBe(1); expect(p.milestones[0].synthesisCheck?.coreConcept).toBe('A'); }); it('picks the median stage as fallback when nothing aligns', () => { const p = project([ms('m0', 0), ms('m1', 1), ms('m2', 2)], 'zzz-unrelated-objective'); normalizeSynthesisChecks(p); // 3 stages, all-zero overlap → median index 1. expect(p.milestones.find((m) => m.id === 'm1')?.synthesisCheck).toBeTruthy(); expect(p.milestones.filter((m) => m.synthesisCheck).length).toBe(1); }); }); describe('PBL v2 Planner — progress event type', () => { it('PlannerV2Callbacks.onProgress accepts all known event kinds (compile check)', () => { // This is a pure type-check assertion: it ensures // `PlannerV2ProgressEvent` covers all the variants the Generating // page needs to render. If a new event kind is added without // updating the discriminated union, this won't compile. const events: PlannerV2ProgressEvent[] = [ { kind: 'project_info', title: 't' }, { kind: 'role', roleType: 'instructor', name: 'Instructor' }, { kind: 'milestone', title: 'm', index: 0 }, { kind: 'microtask', milestoneTitle: 'm', title: 'mt', index: 0 }, { kind: 'complete', milestoneCount: 3, microtaskCount: 9 }, ]; expect(events).toHaveLength(5); // And the callback signature compiles const cb: PlannerV2Callbacks = { onProgress: (e) => { // exhaustiveness check via never switch (e.kind) { case 'project_info': case 'role': case 'milestone': case 'microtask': case 'complete': return; default: { const _exhaustive: never = e; return _exhaustive; } } }, }; expect(cb.onProgress).toBeDefined(); }); });