Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

974 lines
35 KiB
TypeScript

/**
* PBL v2 — Progress operation tests.
*
* Pure logic, no LLM. Validates the milestone / microtask state
* machine: start, advance, milestone wrap, handover gate, skip,
* continue.
*/
import { describe, it, expect } from 'vitest';
import {
MILESTONE_DIVIDER_PREFIX,
startMicrotask,
advanceMicrotask,
continueAfterHandover,
currentMicrotask,
currentMilestone,
normalizeProjectRuntime,
normalizeScenario,
hasStartedProject,
resetProjectProgress,
completeRoleplayAct,
} from '@/lib/pbl/v2/operations/progress';
import {
appendTaskCompletionReadyMessage,
currentPendingTaskCompletion,
isCoreMilestoneFinalMicrotask,
isTaskCompletionReadyMessageContent,
setPendingTaskCompletion,
taskCompletionReadyText,
taskEvaluationCanComplete,
} from '@/lib/pbl/v2/operations/task-completion';
import { recordEvent } from '@/lib/pbl/v2/operations/engagement';
import { runtimeEventEpoch } from '@/lib/pbl/v2/operations/runtime-events';
import type { PBLProjectV2 } from '@/lib/pbl/v2/types';
function makeProject(): PBLProjectV2 {
const now = '2026-05-25T00:00:00.000Z';
return {
uiPhase: 'workspace',
title: 'Test Project',
description: 'Test',
proficiency: 'beginner',
language: 'en-US',
tags: [],
status: 'active',
roles: [
{
id: 'role-i',
type: 'instructor',
name: 'Instructor',
},
],
milestones: [
{
id: 'ms-1',
title: 'Milestone 1',
status: 'active',
order: 0,
microtasks: [
{ id: 'mt-1', title: 'Task A', status: 'todo', assignee: 'user', hints: [], order: 0 },
{ id: 'mt-2', title: 'Task B', status: 'todo', assignee: 'user', hints: [], order: 1 },
],
documents: [],
},
{
id: 'ms-2',
title: 'Milestone 2',
status: 'locked',
order: 1,
microtasks: [
{ id: 'mt-3', title: 'Task C', status: 'todo', assignee: 'user', hints: [], order: 0 },
],
documents: [],
},
],
submissions: [],
evaluations: [],
threads: [{ agentId: 'role-i', messages: [] }],
engagementEvents: [],
createdAt: now,
updatedAt: now,
};
}
describe('PBL v2 progress — current lookups', () => {
it('currentMilestone returns the active milestone', () => {
const p = makeProject();
expect(currentMilestone(p)?.id).toBe('ms-1');
});
it('currentMicrotask returns the first todo/in_progress microtask of active milestone', () => {
const p = makeProject();
expect(currentMicrotask(p)?.microtask.id).toBe('mt-1');
});
it('currentMicrotask skips completed tasks', () => {
const p = makeProject();
p.milestones[0].microtasks[0].status = 'completed';
expect(currentMicrotask(p)?.microtask.id).toBe('mt-2');
});
});
describe('PBL v2 task-level manual completion gate', () => {
it('treats task evaluation score >= 60 as passing evidence', () => {
expect(
taskEvaluationCanComplete({
id: 'eval-pass',
kind: 'task',
microtaskId: 'mt-1',
milestoneId: 'ms-1',
feedback: 'ok',
strengths: [],
improvements: [],
score: 60,
createdAt: '2026-05-25T00:00:00.000Z',
}),
).toBe(true);
expect(
taskEvaluationCanComplete({
id: 'eval-low',
kind: 'task',
microtaskId: 'mt-1',
milestoneId: 'ms-1',
feedback: 'revise',
strengths: [],
improvements: [],
score: 59,
createdAt: '2026-05-25T00:00:00.000Z',
}),
).toBe(false);
});
it('sets pending completion without advancing the current microtask', () => {
const p = makeProject();
startMicrotask(p, 'mt-1');
setPendingTaskCompletion(p, {
microtaskId: 'mt-1',
milestoneId: 'ms-1',
reason: 'learner solved it in chat',
});
expect(currentPendingTaskCompletion(p, 'mt-1')?.reason).toBe('learner solved it in chat');
expect(p.milestones[0].microtasks[0].status).toBe('in_progress');
expect(currentMicrotask(p)?.microtask.id).toBe('mt-1');
});
it('clears pending completion only when the learner confirms completion', () => {
const p = makeProject();
startMicrotask(p, 'mt-1');
setPendingTaskCompletion(p, {
microtaskId: 'mt-1',
milestoneId: 'ms-1',
reason: 'ready',
});
const result = advanceMicrotask(p, 'mt-1', 'ready', {});
expect(result.ok).toBe(true);
expect(p.pendingTaskCompletion).toBeUndefined();
expect(p.milestones[0].microtasks[0].status).toBe('completed');
expect(p.milestones[0].microtasks[1].status).toBe('in_progress');
});
it('detects the last microtask of a core milestone', () => {
const p = makeProject();
p.milestones[0].synthesisCheck = { coreConcept: 'hash lookup' };
expect(isCoreMilestoneFinalMicrotask(p.milestones[0], 'mt-1')).toBe(false);
expect(isCoreMilestoneFinalMicrotask(p.milestones[0], 'mt-2')).toBe(true);
expect(isCoreMilestoneFinalMicrotask(p.milestones[1], 'mt-3')).toBe(false);
});
it('can append the ready prompt after a task evaluation card timestamp', () => {
const p = makeProject();
const evaluationTs = '2026-05-25T00:00:10.000Z';
const message = appendTaskCompletionReadyMessage(p, 'mt-1', { afterTs: evaluationTs });
expect(message).toBeDefined();
expect(message?.content).toContain('This task is complete');
expect(message && message.ts > evaluationTs).toBe(true);
});
it('recognizes localized ready prompts for chat typewriter playback', () => {
expect(isTaskCompletionReadyMessageContent(taskCompletionReadyText('zh-CN'))).toBe(true);
expect(isTaskCompletionReadyMessageContent(taskCompletionReadyText('en-US'))).toBe(true);
expect(isTaskCompletionReadyMessageContent('普通 instructor 回复')).toBe(false);
});
});
describe('PBL v2 progress — runtime normalization', () => {
it('creates the instructor thread and opens the first task when planner output is structural only', () => {
const p = makeProject();
p.threads = [];
p.milestones[0].microtasks[0].status = 'todo';
expect(normalizeProjectRuntime(p)).toBe(true);
expect(p.threads).toEqual([{ agentId: 'role-i', messages: [] }]);
expect(p.milestones[0].status).toBe('active');
expect(p.milestones[0].microtasks[0].status).toBe('in_progress');
expect(currentMicrotask(p)?.microtask.id).toBe('mt-1');
});
it('activates the first runnable milestone when no milestone is active', () => {
const p = makeProject();
p.milestones[0].status = 'locked';
expect(normalizeProjectRuntime(p)).toBe(true);
expect(p.milestones[0].status).toBe('active');
expect(p.milestones[0].microtasks[0].status).toBe('in_progress');
});
it('emits deterministic repair ids for the same normalization transition on clones', () => {
const first = makeProject();
first.milestones[0].status = 'locked';
const second = structuredClone(first) as PBLProjectV2;
expect(normalizeProjectRuntime(first)).toBe(true);
expect(normalizeProjectRuntime(second)).toBe(true);
const firstIds = (first.runtimeEvents ?? [])
.filter((event) => event.kind === 'status_changed')
.map((event) => event.id);
const secondIds = (second.runtimeEvents ?? [])
.filter((event) => event.kind === 'status_changed')
.map((event) => event.id);
expect(firstIds).toEqual([
'norm:0:milestone:ms-1:locked:active',
'norm:0:microtask:mt-1:todo:in_progress',
]);
expect(secondIds).toEqual(firstIds);
});
it('does not open the next milestone while a handover is waiting for Continue', () => {
const p = makeProject();
p.milestones[0].status = 'completed';
p.milestones[0].microtasks.forEach((task) => {
task.status = 'completed';
});
p.pendingHandover = {
completedMilestoneId: 'ms-1',
completedMilestoneTitle: 'Milestone 1',
nextMilestoneId: 'ms-2',
nextMilestoneTitle: 'Milestone 2',
nextTaskId: 'mt-3',
nextTaskTitle: 'Task C',
consumed: false,
};
expect(normalizeProjectRuntime(p)).toBe(false);
expect(p.milestones[1].status).toBe('locked');
expect(p.milestones[1].microtasks[0].status).toBe('todo');
expect(currentMicrotask(p)).toBeUndefined();
});
});
describe('PBL v2 progress — scenario normalization (degradation safety net)', () => {
it('is a no-op for ordinary projects (no scenario, no scene)', () => {
const p = makeProject();
expect(normalizeScenario(p)).toBe(false);
expect(p.scenario).toBeUndefined();
});
it('keeps a coherent scenario (cast + full prep→roleplay→wrapup skeleton) intact', () => {
const p = makeProject();
p.scenario = {
setting: 'A cosy campus café',
characters: [{ id: 'char-1', name: '小敏', persona: '内向的同学,说话轻声细语' }],
};
p.schemaVersion = 1;
// A coherent scenario stamps EVERY milestone in the fixed skeleton. Here the
// fixture has 2 milestones, so: first = prep, last = roleplay (a minimal but
// valid scenario needs ≥1 roleplay; the repair only fixes missing/invalid
// stages, so a fully-stamped coherent skeleton must stay untouched).
p.milestones[0].scenarioStage = 'prep';
p.milestones[p.milestones.length - 1].scenarioStage = 'roleplay';
expect(normalizeScenario(p)).toBe(false);
expect(p.scenario?.characters).toHaveLength(1);
expect(p.milestones[0].scenarioStage).toBe('prep');
expect(p.milestones[p.milestones.length - 1].scenarioStage).toBe('roleplay');
expect(p.schemaVersion).toBe(1);
});
it('assigns missing character ids on a coherent scenario', () => {
const p = makeProject();
p.scenario = {
setting: 'Campus café',
characters: [{ id: '', name: '小敏', persona: '内向的同学' }],
};
p.milestones[0].scenarioStage = 'roleplay';
expect(normalizeScenario(p)).toBe(true);
expect(p.scenario?.characters[0].id).toBeTruthy();
expect(p.scenario).toBeDefined();
});
it('SKELETON REPAIR: coerces a middle milestone with missing scenarioStage to "roleplay" (the act-transition bug)', () => {
// Reproduces the real failure: the planner left a MIDDLE act's
// scenarioStage undefined, so entering it showed the Instructor (prep)
// thread instead of the live scene. The repair must position-fix it.
const p = makeProject();
// give it the 4-milestone shape the bug appeared in: prep, roleplay,
// <undefined middle>, wrapup.
p.milestones = [
{ ...p.milestones[0], id: 'ms-prep', scenarioStage: 'prep' },
{ ...p.milestones[0], id: 'ms-rp1', scenarioStage: 'roleplay' },
{ ...p.milestones[0], id: 'ms-mid', scenarioStage: undefined },
{ ...p.milestones[0], id: 'ms-wrap', scenarioStage: 'wrapup' },
] as typeof p.milestones;
p.scenario = {
setting: 's',
characters: [{ id: 'c1', name: 'X', persona: 'p' }],
};
expect(normalizeScenario(p)).toBe(true);
// first stays prep, last stays wrapup, the undefined MIDDLE → roleplay
expect(p.milestones[0].scenarioStage).toBe('prep');
expect(p.milestones[2].scenarioStage).toBe('roleplay');
expect(p.milestones[3].scenarioStage).toBe('wrapup');
// and it does NOT degrade (scenario stays intact)
expect(p.scenario).toBeDefined();
});
it('SKELETON REPAIR: a first/last milestone with missing stage becomes prep/wrapup', () => {
const p = makeProject();
p.milestones = [
{ ...p.milestones[0], id: 'ms-a', scenarioStage: undefined },
{ ...p.milestones[0], id: 'ms-b', scenarioStage: 'roleplay' },
{ ...p.milestones[0], id: 'ms-c', scenarioStage: undefined },
] as typeof p.milestones;
p.scenario = { setting: 's', characters: [{ id: 'c1', name: 'X', persona: 'p' }] };
expect(normalizeScenario(p)).toBe(true);
expect(p.milestones[0].scenarioStage).toBe('prep');
expect(p.milestones[1].scenarioStage).toBe('roleplay');
expect(p.milestones[2].scenarioStage).toBe('wrapup');
});
it('case A: clears orphan scenarioStage markers when there is no scenario (no cast)', () => {
const p = makeProject();
p.milestones[0].scenarioStage = 'roleplay'; // marker but project.scenario is undefined
expect(normalizeScenario(p)).toBe(true);
expect(p.milestones[0].scenarioStage).toBeUndefined();
expect(p.scenario).toBeUndefined();
});
it('case B: drops scenario when the cast is structurally broken (no usable character)', () => {
const p = makeProject();
p.scenario = {
setting: 'Campus café',
// missing persona → invalid character
characters: [{ id: 'char-1', name: '小敏', persona: ' ' }],
};
p.schemaVersion = 1;
p.milestones[0].scenarioStage = 'roleplay';
expect(normalizeScenario(p)).toBe(true);
expect(p.scenario).toBeUndefined();
expect(p.schemaVersion).toBeUndefined();
expect(p.milestones[0].scenarioStage).toBeUndefined();
});
it('case C: drops scenario when a valid cast has no scene stage to host it', () => {
const p = makeProject();
p.scenario = {
setting: 'Campus café',
characters: [{ id: 'char-1', name: '小敏', persona: '内向的同学' }],
};
p.schemaVersion = 1;
// no milestone has scenarioStage === 'roleplay'
expect(normalizeScenario(p)).toBe(true);
expect(p.scenario).toBeUndefined();
expect(p.schemaVersion).toBeUndefined();
});
it('is idempotent (running twice does not mutate again)', () => {
const p = makeProject();
p.scenario = {
setting: 'Campus café',
characters: [{ id: 'char-1', name: '小敏', persona: '内向的同学' }],
};
p.milestones[0].scenarioStage = 'roleplay';
normalizeScenario(p);
expect(normalizeScenario(p)).toBe(false);
});
});
describe('PBL v2 progress — start', () => {
it('startMicrotask flips todo → in_progress and records microtask_opened event', () => {
const p = makeProject();
startMicrotask(p, 'mt-1');
expect(p.milestones[0].microtasks[0].status).toBe('in_progress');
expect(p.engagementEvents.some((e) => e.kind === 'microtask_opened')).toBe(true);
});
it('startMicrotask is idempotent', () => {
const p = makeProject();
startMicrotask(p, 'mt-1');
const before = p.engagementEvents.length;
startMicrotask(p, 'mt-1');
expect(p.engagementEvents.length).toBe(before);
});
});
describe('PBL v2 progress — advance within a milestone', () => {
it('advanceMicrotask moves to next microtask in the same milestone', () => {
const p = makeProject();
startMicrotask(p, 'mt-1');
const r = advanceMicrotask(p, 'mt-1', 'done', {
problems: '',
resolution: 'n/a',
performance: 'fluent',
});
expect(r.ok).toBe(true);
if (!r.ok) throw new Error('expected ok');
expect(r.milestoneCompleted).toBe(false);
expect(r.nextMicrotaskId).toBe('mt-2');
expect(p.milestones[0].microtasks[0].status).toBe('completed');
expect(p.milestones[0].microtasks[1].status).toBe('in_progress');
});
it('advanceMicrotask refuses already-terminal microtasks', () => {
const p = makeProject();
p.milestones[0].microtasks[0].status = 'completed';
const r = advanceMicrotask(p, 'mt-1', 'x', {
problems: '',
resolution: '',
performance: '',
});
expect(r.ok).toBe(false);
});
});
describe('PBL v2 progress — milestone boundary + handover', () => {
it('completing the last microtask of a milestone stages a handover, next milestone stays LOCKED', () => {
const p = makeProject();
// Complete mt-1 first
startMicrotask(p, 'mt-1');
advanceMicrotask(p, 'mt-1', 'done', { problems: '', resolution: '', performance: '' });
// Now mt-2 is in_progress → complete it
const r = advanceMicrotask(p, 'mt-2', 'done', {
problems: '',
resolution: '',
performance: 'great',
});
expect(r.ok).toBe(true);
if (!r.ok) throw new Error('expected ok');
expect(r.milestoneCompleted).toBe(true);
expect(r.projectCompleted).toBe(false);
// Next milestone must stay LOCKED until Continue is clicked
expect(p.milestones[1].status).toBe('locked');
expect(p.pendingHandover).toBeDefined();
expect(p.pendingHandover?.nextMilestoneId).toBe('ms-2');
expect(p.pendingHandover?.consumed).toBe(false);
});
it('continueAfterHandover activates the next milestone and its first task', () => {
const p = makeProject();
p.pendingHandover = {
completedMilestoneId: 'ms-1',
completedMilestoneTitle: 'Milestone 1',
nextMilestoneId: 'ms-2',
nextMilestoneTitle: 'Milestone 2',
nextTaskId: 'mt-3',
nextTaskTitle: 'Task C',
consumed: false,
};
p.milestones[0].status = 'completed';
const r = continueAfterHandover(p);
expect(r.ok).toBe(true);
expect(r.activatedMicrotaskId).toBe('mt-3');
expect(p.milestones[1].status).toBe('active');
expect(p.milestones[1].microtasks[0].status).toBe('in_progress');
expect(p.pendingHandover?.consumed).toBe(true);
});
it('continueAfterHandover appends a milestone divider to the instructor thread', () => {
const p = makeProject();
p.pendingHandover = {
completedMilestoneId: 'ms-1',
completedMilestoneTitle: 'Milestone 1',
nextMilestoneId: 'ms-2',
nextMilestoneTitle: 'Milestone 2',
nextTaskId: 'mt-3',
nextTaskTitle: 'Task C',
consumed: false,
};
p.milestones[0].status = 'completed';
const r = continueAfterHandover(p);
expect(r.ok).toBe(true);
const dividerMessages = p.threads[0].messages.filter((m) =>
m.content.startsWith(MILESTONE_DIVIDER_PREFIX),
);
expect(dividerMessages).toHaveLength(1);
expect(dividerMessages[0].content).toContain('Stage progression');
expect(dividerMessages[0].content).toContain('Milestone 1');
expect(dividerMessages[0].content).toContain('Milestone 2');
expect(dividerMessages[0].microtaskId).toBe('mt-3');
});
it('adds NO milestone divider for ANY scenario transition — roleplay AND wrapup (roadmap labels + history block carry the structure)', () => {
const p = makeProject();
// Make it a scenario project whose ms-2 is a roleplay stage, with a
// Simulator thread present (as normalizeProjectRuntime would create).
p.scenario = {
setting: 's',
characters: [{ id: 'c1', name: '林夏', persona: 'p', situation: 'x' }],
};
p.milestones[0].scenarioStage = 'roleplay';
p.milestones[1].scenarioStage = 'roleplay';
p.threads.push({ agentId: 'simulator', messages: [] });
p.pendingHandover = {
completedMilestoneId: 'ms-1',
completedMilestoneTitle: 'Scene 1',
nextMilestoneId: 'ms-2',
nextMilestoneTitle: 'Scene 2',
nextTaskId: 'mt-3',
nextTaskTitle: 'Task C',
consumed: false,
};
p.milestones[0].status = 'completed';
continueAfterHandover(p);
const instructorThread = p.threads.find((t) => t.agentId === 'role-i');
const simThread = p.threads.find((t) => t.agentId === 'simulator');
// No divider anywhere: not in the instructor thread, not in the simulator
// thread — scenario projects never get an in-chat stage-advance marker.
expect(
instructorThread?.messages.filter((m) => m.content.startsWith(MILESTONE_DIVIDER_PREFIX)) ??
[],
).toHaveLength(0);
expect(
simThread?.messages.filter((m) => m.content.startsWith(MILESTONE_DIVIDER_PREFIX)) ?? [],
).toHaveLength(0);
expect(p.milestones[1].status).toBe('active');
// Now cross into a WRAPUP stage — still NO divider (this is the case the
// user reported: entering 复盘 used to show a "阶段推进 → 复盘" divider).
p.milestones[1].status = 'completed';
p.milestones[1].scenarioStage = 'roleplay';
p.milestones.push({
id: 'ms-wrap',
title: 'Debrief',
status: 'locked',
order: 2,
documents: [],
scenarioStage: 'wrapup',
microtasks: [
{ id: 'mt-w', title: 'W', status: 'todo', assignee: 'user', hints: [], order: 0 },
],
} as (typeof p.milestones)[number]);
p.pendingHandover = {
completedMilestoneId: 'ms-2',
completedMilestoneTitle: 'Scene 2',
nextMilestoneId: 'ms-wrap',
nextMilestoneTitle: 'Debrief',
nextTaskId: 'mt-w',
nextTaskTitle: 'W',
consumed: false,
};
continueAfterHandover(p);
expect(
instructorThread?.messages.filter((m) => m.content.startsWith(MILESTONE_DIVIDER_PREFIX)) ??
[],
).toHaveLength(0);
});
it('localizes the milestone divider label for Chinese projects', () => {
const p = makeProject();
p.language = 'zh-CN';
p.pendingHandover = {
completedMilestoneId: 'ms-1',
completedMilestoneTitle: '阶段一',
nextMilestoneId: 'ms-2',
nextMilestoneTitle: '阶段二',
nextTaskId: 'mt-3',
nextTaskTitle: 'Task C',
consumed: false,
};
p.milestones[0].status = 'completed';
continueAfterHandover(p);
const divider = p.threads[0].messages.find((m) =>
m.content.startsWith(MILESTONE_DIVIDER_PREFIX),
);
expect(divider?.content).toContain('阶段推进');
expect(divider?.content).toContain('阶段一');
expect(divider?.content).toContain('阶段二');
});
it('completing the last microtask of the LAST milestone keeps chat visible for final evaluation', () => {
const p = makeProject();
// Pre-complete ms-1
p.milestones[0].status = 'completed';
p.milestones[0].microtasks.forEach((t) => (t.status = 'completed'));
// Activate ms-2 (no handover gate involved here — we simulate that
// Continue already happened)
p.milestones[1].status = 'active';
p.milestones[1].microtasks[0].status = 'in_progress';
const r = advanceMicrotask(p, 'mt-3', 'done', {
problems: '',
resolution: '',
performance: '',
});
expect(r.ok).toBe(true);
if (!r.ok) throw new Error('expected ok');
expect(r.projectCompleted).toBe(true);
expect(p.status).toBe('completed');
expect(p.uiPhase).toBe('workspace');
});
});
describe('PBL v2 engagement — recordEvent', () => {
it('records and caps the event ledger at the soft limit', () => {
const p = makeProject();
for (let i = 0; i < 600; i++) {
recordEvent(p, 'learner_turn', { microtaskId: 'mt-1', milestoneId: 'ms-1' });
}
expect(p.engagementEvents.length).toBeLessThanOrEqual(500);
});
});
describe('PBL v2 progress — hasStartedProject', () => {
it('is false for a freshly generated project (all tasks todo, empty threads)', () => {
const p = makeProject();
p.uiPhase = 'hero';
expect(hasStartedProject(p)).toBe(false);
});
it('stays false after normalizeProjectRuntime opens the first task in_progress', () => {
// normalizeProjectRuntime opens the first microtask even on a fresh
// project, so an in_progress task alone must NOT count as "started".
const p = makeProject();
p.uiPhase = 'hero';
p.threads = [];
normalizeProjectRuntime(p);
expect(p.milestones[0].microtasks[0].status).toBe('in_progress');
expect(hasStartedProject(p)).toBe(false);
});
it('is true once the instructor thread has a message (GREETING delivered)', () => {
const p = makeProject();
p.uiPhase = 'hero';
p.threads[0].messages.push({
id: 'm1',
agentId: 'role-i',
roleType: 'instructor',
content: 'Welcome!',
ts: '2026-05-25T00:01:00.000Z',
});
expect(hasStartedProject(p)).toBe(true);
});
it('is true when there are submissions, terminal tasks, or a completed project', () => {
const withSubmission = makeProject();
withSubmission.submissions.push({
id: 's1',
microtaskId: 'mt-1',
kind: 'text',
content: 'x',
createdAt: '2026-05-25T00:01:00.000Z',
});
expect(hasStartedProject(withSubmission)).toBe(true);
const withTerminalTask = makeProject();
withTerminalTask.milestones[0].microtasks[0].status = 'completed';
expect(hasStartedProject(withTerminalTask)).toBe(true);
const completed = makeProject();
completed.status = 'completed';
expect(hasStartedProject(completed)).toBe(true);
});
});
describe('PBL v2 progress — resetProjectProgress', () => {
function makeStartedProject(): PBLProjectV2 {
const p = makeProject();
p.uiPhase = 'completed';
p.status = 'completed';
p.proficiency = 'advanced';
p.proficiencyAssessment = {
tier: 'advanced',
score: 0.6,
confidence: 0.8,
source: 'dynamic',
signals: [],
lastUpdatedAt: '2026-05-25T00:02:00.000Z',
transitions: [],
dynamicSignalsSinceRetier: 3,
turnsSinceRetier: 5,
};
p.milestones[0].status = 'completed';
p.milestones[0].internalAssessment = { performance: 'great' };
p.milestones[0].microtasks[0].status = 'completed';
p.milestones[0].microtasks[0].completionReason = 'done';
p.milestones[0].microtasks[0].internalAssessment = { performance: 'ok' };
p.milestones[0].microtasks[0].engagement = { learnerTurnCount: 4 };
p.milestones[1].status = 'active';
p.milestones[1].microtasks[0].status = 'in_progress';
p.submissions.push({
id: 's1',
microtaskId: 'mt-1',
kind: 'text',
content: 'x',
createdAt: '2026-05-25T00:01:00.000Z',
});
p.evaluations.push({
id: 'e1',
kind: 'task',
microtaskId: 'mt-1',
feedback: 'good',
strengths: [],
improvements: [],
createdAt: '2026-05-25T00:01:30.000Z',
});
p.threads[0].messages.push({
id: 'm1',
roleType: 'instructor',
content: 'hi',
ts: '2026-05-25T00:01:00.000Z',
});
p.engagementEvents.push({
id: 'ev1',
kind: 'learner_turn',
ts: '2026-05-25T00:01:00.000Z',
});
p.pendingHandover = {
completedMilestoneId: 'ms-1',
completedMilestoneTitle: 'Milestone 1',
nextMilestoneId: 'ms-2',
nextMilestoneTitle: 'Milestone 2',
consumed: true,
};
return p;
}
it('returns a hero-phase project equivalent to a brand-new one', () => {
const reset = resetProjectProgress(makeStartedProject());
expect(reset.uiPhase).toBe('hero');
expect(reset.status).toBe('active');
expect(reset.submissions).toEqual([]);
expect(reset.evaluations).toEqual([]);
expect(reset.engagementEvents).toEqual([]);
expect(reset.pendingHandover).toBeUndefined();
expect(hasStartedProject(reset)).toBe(false);
});
it('emits project_reset before the reset status events', () => {
const reset = resetProjectProgress(makeStartedProject());
const events = reset.runtimeEvents ?? [];
const firstStatusIndex = events.findIndex((event) => event.kind === 'status_changed');
expect(events[0]).toMatchObject({
kind: 'project_reset',
actorType: 'user',
});
expect(firstStatusIndex).toBeGreaterThan(0);
});
it('keeps the reset epoch after the visible project_reset marker is evicted', () => {
const firstReset = resetProjectProgress(makeStartedProject());
expect(runtimeEventEpoch(firstReset)).toBe(1);
firstReset.runtimeEvents = firstReset.runtimeEvents?.filter(
(event) => event.kind !== 'project_reset',
);
expect(firstReset.runtimeEvents?.some((event) => event.kind === 'project_reset')).toBe(false);
expect(runtimeEventEpoch(firstReset)).toBe(1);
const secondReset = resetProjectProgress(firstReset);
expect(runtimeEventEpoch(secondReset)).toBe(2);
});
it('keeps repeated deterministic status transitions after reset while deduplicating normalization echoes in one epoch', () => {
const project = makeProject();
startMicrotask(project, 'mt-1');
const firstAdvance = advanceMicrotask(project, 'mt-1', 'first pass', {});
expect(firstAdvance.ok).toBe(true);
const reset = resetProjectProgress(project);
startMicrotask(reset, 'mt-1');
const secondAdvance = advanceMicrotask(reset, 'mt-1', 'second pass', {});
expect(secondAdvance.ok).toBe(true);
const completionEvents = (reset.runtimeEvents ?? []).filter(
(event) =>
event.kind === 'status_changed' &&
event.entityType === 'microtask' &&
event.entityId === 'mt-1' &&
event.from === 'in_progress' &&
event.to === 'completed',
);
expect(completionEvents).toHaveLength(2);
expect(new Set(completionEvents.map((event) => event.id)).size).toBe(2);
const normalized = makeProject();
normalized.milestones[0].status = 'locked';
expect(normalizeProjectRuntime(normalized)).toBe(true);
normalized.milestones[0].status = 'locked';
normalized.milestones[0].microtasks[0].status = 'todo';
expect(normalizeProjectRuntime(normalized)).toBe(true);
const normalizationEvents = (normalized.runtimeEvents ?? []).filter(
(event) =>
event.kind === 'status_changed' &&
(event.id.startsWith('norm:0:milestone:') || event.id.startsWith('norm:0:microtask:')),
);
expect(normalizationEvents).toHaveLength(2);
});
it('clears every microtask back to todo and re-locks all but the first milestone', () => {
const reset = resetProjectProgress(makeStartedProject());
expect(reset.milestones[0].status).toBe('active');
expect(reset.milestones[1].status).toBe('locked');
expect(reset.milestones[0].internalAssessment).toBeUndefined();
for (const milestone of reset.milestones) {
for (const microtask of milestone.microtasks) {
expect(microtask.status).toBe('todo');
expect(microtask.completionReason).toBeUndefined();
expect(microtask.internalAssessment).toBeUndefined();
expect(microtask.engagement).toBeUndefined();
}
}
});
it('empties thread messages while keeping the thread containers', () => {
const reset = resetProjectProgress(makeStartedProject());
expect(reset.threads).toEqual([{ agentId: 'role-i', messages: [] }]);
});
it('preserves learner proficiency state (reset only clears PBL progress)', () => {
// proficiency / proficiencyAssessment are learner-model runtime, not PBL
// progress. Resetting the project must leave them untouched so a restart
// keeps the learner's calibrated tier instead of regressing to beginner.
const started = makeStartedProject();
const reset = resetProjectProgress(started);
expect(reset.proficiency).toBe('advanced');
expect(reset.proficiencyAssessment).toEqual(started.proficiencyAssessment);
});
it('does not mutate the input project', () => {
const original = makeStartedProject();
resetProjectProgress(original);
expect(original.uiPhase).toBe('completed');
expect(original.submissions).toHaveLength(1);
expect(original.milestones[0].microtasks[0].status).toBe('completed');
});
});
describe('PBL v2 — completeRoleplayAct (act model: deterministic whole-act finish)', () => {
function actProject(): PBLProjectV2 {
return {
language: 'zh-CN',
roles: [{ id: 'role-i', type: 'instructor', name: '教练' }],
threads: [
{ agentId: 'role-i', messages: [] },
{
agentId: 'simulator',
// The learner has actually played the first act (a user message tagged
// to its beat b1) — satisfies the per-act engagement gate.
messages: [
{
id: 'u1',
roleType: 'user',
content: '你好',
ts: '2026-06-08T00:00:10.000Z',
microtaskId: 'b1',
},
],
},
],
scenario: { setting: 's', characters: [{ id: 'c1', name: '林夏', persona: 'p' }] },
milestones: [
{
id: 'ms-rp1',
title: '第一幕',
status: 'active',
order: 0,
documents: [],
scenarioStage: 'roleplay',
microtasks: [
{ id: 'b1', title: 'b1', status: 'in_progress', assignee: 'user', hints: [], order: 0 },
{ id: 'b2', title: 'b2', status: 'todo', assignee: 'user', hints: [], order: 1 },
],
},
{
id: 'ms-rp2',
title: '第二幕',
status: 'locked',
order: 1,
documents: [],
scenarioStage: 'roleplay',
microtasks: [
{ id: 'b3', title: 'b3', status: 'todo', assignee: 'user', hints: [], order: 0 },
],
},
],
evaluations: [],
engagementEvents: [],
} as unknown as PBLProjectV2;
}
it('completes ALL beats of the active act AND advances to the next act in one step (no separate Continue)', () => {
const p = actProject();
const r = completeRoleplayAct(p, 'act_completed_by_learner');
expect(r).toEqual({ ok: true, milestoneCompleted: true, projectCompleted: false });
const ms1 = p.milestones[0];
expect(ms1.status).toBe('completed');
expect(ms1.microtasks.every((t) => t.status === 'completed')).toBe(true);
// One-step: the handover is staged AND immediately consumed, and the next
// act is already active — so the UI needs no second "next stage" click.
expect(p.pendingHandover?.completedMilestoneId).toBe('ms-rp1');
expect(p.pendingHandover?.nextMilestoneId).toBe('ms-rp2');
expect(p.pendingHandover?.consumed).toBe(true);
expect(p.milestones[1].status).toBe('active');
expect(p.milestones[1].microtasks[0].status).toBe('in_progress');
});
it('rejects when the active milestone is not a roleplay stage', () => {
const p = actProject();
p.milestones[0].scenarioStage = 'prep';
expect(completeRoleplayAct(p, 'x')).toEqual({ ok: false, error: 'not_in_roleplay_act' });
});
it('rejects when the act has no open beats left (already finished)', () => {
const p = actProject();
p.milestones[0].microtasks.forEach((t) => (t.status = 'completed'));
expect(completeRoleplayAct(p, 'x')).toEqual({ ok: false, error: 'already_terminal' });
});
it('rejects finishing an act the learner has NOT engaged (no user message tagged to THIS act)', () => {
const p = actProject();
// Simulate being on the SECOND act: first act done, second active, but the
// only user message belongs to the first act (b1) — the shared thread must
// NOT let the second act be finished without playing it.
p.milestones[0].status = 'completed';
p.milestones[0].microtasks.forEach((t) => (t.status = 'completed'));
p.milestones[1].status = 'active';
p.milestones[1].microtasks[0].status = 'in_progress';
expect(completeRoleplayAct(p, 'x')).toEqual({ ok: false, error: 'act_not_engaged' });
// Once the learner sends a message in the second act (beat b3), it can finish.
const sim = p.threads.find((t) => t.agentId === 'simulator')!;
sim.messages.push({
id: 'u2',
roleType: 'user',
content: '继续',
ts: '2026-06-08T00:05:00.000Z',
microtaskId: 'b3',
} as (typeof sim.messages)[number]);
const r = completeRoleplayAct(p, 'x');
expect(r.ok).toBe(true);
});
it('completing the LAST roleplay act with no next milestone completes the project', () => {
const p = actProject();
// drop the second act so the first is the last milestone
p.milestones = [p.milestones[0]];
const r = completeRoleplayAct(p, 'x');
expect(r).toEqual({ ok: true, milestoneCompleted: true, projectCompleted: true });
expect(p.status).toBe('completed');
});
});