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

451 lines
14 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
applyAdvanceProjectPatch,
buildAdvanceProjectPatch,
} from '@/lib/pbl/v2/operations/advance-patch';
import { advanceMicrotask, startMicrotask } from '@/lib/pbl/v2/operations/progress';
import { recordEvent } from '@/lib/pbl/v2/operations/engagement';
import { appendRuntimeEvent, MAX_RUNTIME_EVENTS } from '@/lib/pbl/v2/operations/runtime-events';
import type { PBLProjectV2, PBLRuntimeEvent } from '@/lib/pbl/v2/types';
function makeProject(): PBLProjectV2 {
return {
uiPhase: 'workspace',
title: 'Project',
description: 'Build something',
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,
documents: [],
microtasks: [
{
id: 'mt-1',
title: 'Task 1',
status: 'todo',
assignee: 'user',
hints: [],
order: 0,
},
{
id: 'mt-2',
title: 'Task 2',
status: 'todo',
assignee: 'user',
hints: [],
order: 1,
},
],
},
],
submissions: [],
evaluations: [],
threads: [],
engagementEvents: [],
createdAt: '2026-05-29T00:00:00.000Z',
updatedAt: '2026-05-29T00:00:00.000Z',
};
}
function statusChangedEvents(
project: PBLProjectV2,
): Extract<PBLRuntimeEvent, { kind: 'status_changed' }>[] {
return (project.runtimeEvents ?? []).filter(
(event): event is Extract<PBLRuntimeEvent, { kind: 'status_changed' }> =>
event.kind === 'status_changed',
);
}
function runtimeMessage(id: string): PBLRuntimeEvent {
return {
id,
kind: 'message_created',
actorType: 'user',
messageId: `msg-${id}`,
threadId: 'role-i',
ts: `2026-05-29T00:00:${id.slice(-2).padStart(2, '0')}.000Z`,
};
}
describe('PBL v2 advance checkpoint — scenario milestone-eval suppression', () => {
it('keeps shouldEvaluateMilestone for ordinary projects', () => {
const p = makeProject();
const patch = buildAdvanceProjectPatch(p, {
microtaskId: 'mt-1',
milestoneCompleted: true,
projectCompleted: false,
shouldEvaluateTask: false,
});
expect(patch.shouldEvaluateMilestone).toBe(true);
});
it('suppresses the milestone-eval card for scenario projects (no per-stage card)', () => {
const p = makeProject();
p.scenario = {
setting: 's',
characters: [{ id: 'c1', name: '林夏', persona: 'p' }],
};
const patch = buildAdvanceProjectPatch(p, {
microtaskId: 'mt-1',
milestoneCompleted: true,
projectCompleted: true,
shouldEvaluateTask: false,
});
expect(patch.shouldEvaluateMilestone).toBe(false);
// the final eval still fires (drives the completion report + CTA)
expect(patch.shouldEvaluateFinal).toBe(true);
});
});
describe('PBL v2 advance checkpoint transport', () => {
it('builds and applies a checkpoint that preserves completion evidence for evaluator prompts', () => {
const serverProject = makeProject();
startMicrotask(serverProject, 'mt-1');
recordEvent(serverProject, 'learner_turn', { microtaskId: 'mt-1', milestoneId: 'ms-1' });
recordEvent(serverProject, 'observation_error', {
microtaskId: 'mt-1',
milestoneId: 'ms-1',
payload: { signature: 'off_by_one' },
});
recordEvent(serverProject, 'observation_concept_unlocked', {
microtaskId: 'mt-1',
milestoneId: 'ms-1',
payload: { signature: 'bounds_check' },
});
const result = advanceMicrotask(serverProject, 'mt-1', 'learner fixed the loop bound', {
problems: 'off by one',
resolution: 'adjusted index condition',
performance: 'recovered quickly',
});
if (!result.ok) throw new Error('expected advance to succeed');
const patch = buildAdvanceProjectPatch(serverProject, {
microtaskId: 'mt-1',
nextMicrotaskId: result.nextMicrotaskId,
milestoneCompleted: result.milestoneCompleted,
projectCompleted: result.projectCompleted,
shouldEvaluateTask: false,
});
const clientProject = makeProject();
applyAdvanceProjectPatch(clientProject, patch);
const completed = clientProject.milestones[0].microtasks[0];
const opened = clientProject.milestones[0].microtasks[1];
expect(completed.status).toBe('completed');
expect(completed.completionReason).toBe('learner fixed the loop bound');
expect(completed.internalAssessment?.problems).toBe('off by one');
expect(completed.internalAssessment?.performance).toBe('recovered quickly');
expect(completed.engagement?.learnerTurnCount).toBe(1);
expect(completed.engagement?.errorCount).toBe(1);
expect(completed.engagement?.conceptsUnlocked).toContain('bounds_check');
expect(opened.status).toBe('in_progress');
expect(patch.engagementEvents?.map((event) => event.kind)).toEqual([
'microtask_opened',
'learner_turn',
'observation_error',
'observation_concept_unlocked',
'microtask_completed',
'microtask_opened',
]);
expect(clientProject.engagementEvents.map((event) => event.kind)).toEqual([
'microtask_opened',
'learner_turn',
'observation_error',
'observation_concept_unlocked',
'microtask_completed',
'microtask_opened',
]);
});
it('records status changes when applying a full milestone snapshot patch', () => {
const serverProject = makeProject();
const result = advanceMicrotask(serverProject, 'mt-1', 'learner finished the first task', {
problems: '',
resolution: 'Ready for task 2',
performance: 'Steady progress',
});
if (!result.ok) throw new Error('expected advance to succeed');
const patch = buildAdvanceProjectPatch(serverProject, {
microtaskId: 'mt-1',
nextMicrotaskId: result.nextMicrotaskId,
milestoneCompleted: result.milestoneCompleted,
projectCompleted: result.projectCompleted,
shouldEvaluateTask: false,
});
const clientProject = makeProject();
applyAdvanceProjectPatch(clientProject, patch);
expect(
statusChangedEvents(clientProject).map((event) => ({
entityType: event.entityType,
entityId: event.entityId,
from: event.from,
to: event.to,
})),
).toEqual([
{
entityType: 'microtask',
entityId: 'mt-1',
from: 'todo',
to: 'completed',
},
{
entityType: 'microtask',
entityId: 'mt-2',
from: 'todo',
to: 'in_progress',
},
]);
});
it('carries server-minted runtime events once and deduplicates patch replays', () => {
const serverProject = makeProject();
serverProject.milestones.push({
id: 'ms-2',
title: 'Milestone 2',
status: 'locked',
order: 1,
documents: [],
microtasks: [
{
id: 'mt-3',
title: 'Task 3',
status: 'todo',
assignee: 'user',
hints: [],
order: 0,
},
],
});
serverProject.milestones[0].microtasks = [
{
id: 'mt-1',
title: 'Task 1',
status: 'todo',
assignee: 'user',
hints: [],
order: 0,
},
];
const runtimeEventIdsBefore = new Set(
(serverProject.runtimeEvents ?? []).map((event) => event.id),
);
const result = advanceMicrotask(serverProject, 'mt-1', 'stage handover', {
problems: '',
resolution: 'Milestone complete',
performance: 'Ready to continue',
});
if (!result.ok) throw new Error('expected advance to succeed');
const patch = buildAdvanceProjectPatch(serverProject, {
microtaskId: 'mt-1',
nextMicrotaskId: result.nextMicrotaskId,
milestoneCompleted: result.milestoneCompleted,
projectCompleted: result.projectCompleted,
shouldEvaluateTask: false,
runtimeEventIdsBefore,
});
const carriedEvents = patch.runtimeEvents ?? [];
expect(carriedEvents.map((event) => event.kind)).toEqual([
'status_changed',
'status_changed',
'handover_staged',
]);
const clientProject = makeProject();
clientProject.milestones.push(structuredClone(serverProject.milestones[1]));
clientProject.milestones[0].microtasks = [
{
id: 'mt-1',
title: 'Task 1',
status: 'todo',
assignee: 'user',
hints: [],
order: 0,
},
];
applyAdvanceProjectPatch(clientProject, patch);
applyAdvanceProjectPatch(clientProject, patch);
expect(clientProject.runtimeEvents?.map((event) => event.id)).toEqual(
carriedEvents.map((event) => event.id),
);
expect(clientProject.runtimeEvents?.map((event) => event.kind)).toEqual([
'status_changed',
'status_changed',
'handover_staged',
]);
expect(
statusChangedEvents(clientProject).map((event) => ({
entityType: event.entityType,
entityId: event.entityId,
from: event.from,
to: event.to,
})),
).toEqual([
{
entityType: 'microtask',
entityId: 'mt-1',
from: 'todo',
to: 'completed',
},
{
entityType: 'milestone',
entityId: 'ms-1',
from: 'active',
to: 'completed',
},
]);
});
it('carries every newly emitted runtime event when cap eviction happens during advance', () => {
const serverProject = makeProject();
serverProject.milestones.push({
id: 'ms-2',
title: 'Milestone 2',
status: 'locked',
order: 1,
documents: [],
microtasks: [
{
id: 'mt-3',
title: 'Task 3',
status: 'todo',
assignee: 'user',
hints: [],
order: 0,
},
],
});
serverProject.milestones[0].microtasks = [
{
id: 'mt-1',
title: 'Task 1',
status: 'todo',
assignee: 'user',
hints: [],
order: 0,
},
];
for (let i = 0; i < MAX_RUNTIME_EVENTS - 2; i++) {
appendRuntimeEvent(serverProject, runtimeMessage(`prefill-${i}`));
}
const runtimeEventIdsBefore = new Set(
(serverProject.runtimeEvents ?? []).map((event) => event.id),
);
const result = advanceMicrotask(serverProject, 'mt-1', 'stage handover', {
problems: '',
resolution: 'Milestone complete',
performance: 'Ready to continue',
});
if (!result.ok) throw new Error('expected advance to succeed');
const patch = buildAdvanceProjectPatch(serverProject, {
microtaskId: 'mt-1',
nextMicrotaskId: result.nextMicrotaskId,
milestoneCompleted: result.milestoneCompleted,
projectCompleted: result.projectCompleted,
shouldEvaluateTask: false,
runtimeEventIdsBefore,
});
expect(serverProject.runtimeEvents).toHaveLength(MAX_RUNTIME_EVENTS);
expect(patch.runtimeEvents?.map((event) => event.kind)).toEqual([
'status_changed',
'status_changed',
'handover_staged',
]);
});
it('marks project completed without leaving the workspace before the final CTA', () => {
const clientProject = makeProject();
applyAdvanceProjectPatch(clientProject, {
kind: 'advance',
microtaskId: 'mt-2',
milestoneCompleted: true,
projectCompleted: true,
shouldEvaluateTask: false,
shouldEvaluateMilestone: true,
shouldEvaluateFinal: true,
});
expect(clientProject.status).toBe('completed');
expect(clientProject.uiPhase).toBe('workspace');
});
it('does not mark a milestone completed when it has zero microtasks (vacuous every guard)', () => {
const clientProject = makeProject();
// Empty out the microtasks to simulate the bug scenario.
clientProject.milestones[0].microtasks = [];
applyAdvanceProjectPatch(clientProject, {
kind: 'advance',
microtaskId: 'mt-1',
milestoneCompleted: true,
projectCompleted: false,
shouldEvaluateTask: false,
shouldEvaluateMilestone: true,
shouldEvaluateFinal: false,
});
expect(clientProject.milestones[0].status).not.toBe('completed');
});
it('does not mark a milestone completed when every microtask is skipped (no genuine completion)', () => {
const clientProject = makeProject();
clientProject.milestones[0].microtasks.forEach((task) => {
task.status = 'skipped';
});
// Use a microtaskId that does NOT match any existing task, so the
// patch application cannot accidentally set status='completed' on
// any task before the milestone gate runs.
applyAdvanceProjectPatch(clientProject, {
kind: 'advance',
microtaskId: 'mt-nonexistent',
milestoneCompleted: true,
projectCompleted: false,
shouldEvaluateTask: false,
shouldEvaluateMilestone: true,
shouldEvaluateFinal: false,
});
expect(clientProject.milestones[0].status).not.toBe('completed');
});
it('marks a milestone completed when at least one microtask is genuinely completed (mixed completed + skipped)', () => {
const clientProject = makeProject();
clientProject.milestones[0].microtasks[0].status = 'completed';
clientProject.milestones[0].microtasks[1].status = 'skipped';
applyAdvanceProjectPatch(clientProject, {
kind: 'advance',
microtaskId: 'mt-1',
milestoneCompleted: true,
projectCompleted: false,
shouldEvaluateTask: false,
shouldEvaluateMilestone: true,
shouldEvaluateFinal: false,
});
expect(clientProject.milestones[0].status).toBe('completed');
});
});