Files
openmaic/OpenMAIC/tests/pbl/v2/learner-state.test.ts
2026-08-16 14:58:47 +08:00

273 lines
8.6 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
applyLearnerState,
extractLearnerState,
stripToDesignTemplate,
} from '@/lib/pbl/v2/runtime/learner-state';
import { emptyAssessment } from '@/lib/pbl/v2/operations/kernel/proficiency';
import type { PBLProjectV2, PBLRuntimeEvent } from '@/lib/pbl/v2/types';
type ProjectFieldBoundary = 'learner-state' | 'design-template' | 'transient';
const PROJECT_FIELD_BOUNDARY = {
uiPhase: 'learner-state',
status: 'learner-state',
milestones: 'learner-state',
submissions: 'learner-state',
evaluations: 'learner-state',
threads: 'learner-state',
engagementEvents: 'learner-state',
proficiencyAssessment: 'learner-state',
pendingHandover: 'learner-state',
pendingTaskCompletion: 'learner-state',
runtimeResetEpoch: 'learner-state',
title: 'design-template',
description: 'design-template',
learningObjective: 'design-template',
gains: 'design-template',
proficiency: 'design-template',
language: 'design-template',
languageDirective: 'design-template',
tags: 'design-template',
scenario: 'design-template',
schemaVersion: 'design-template',
roles: 'design-template',
createdAt: 'design-template',
updatedAt: 'design-template',
runtimeEvents: 'transient',
pendingOpenTaskPriorQuizResults: 'transient',
} as const satisfies Record<keyof PBLProjectV2, ProjectFieldBoundary>;
function makeProject(overrides: Partial<PBLProjectV2> = {}): PBLProjectV2 {
return {
uiPhase: 'hero',
title: 'Design-only title',
description: 'Design-only description',
learningObjective: 'Design-only learning objective',
gains: ['Design-only gain'],
proficiency: 'intermediate',
proficiencyAssessment: emptyAssessment(),
language: 'en-US',
tags: ['design-only-tag'],
status: 'active',
roles: [{ id: 'role-i', type: 'instructor', name: 'Instructor' }],
milestones: [
{
id: 'ms-1',
title: 'Design-only milestone',
status: 'active',
order: 0,
documents: [
{ id: 'doc-1', title: 'Design doc', content: 'Reference', docType: 'markdown' },
],
briefing: 'Design briefing',
completionCriteria: 'Design criteria',
debrief: 'Design debrief',
microtasks: [
{
id: 'mt-1',
title: 'Design-only task',
description: 'Design-only task description',
status: 'todo',
assignee: 'user',
hints: ['Design-only hint'],
order: 0,
completionCriteria: 'Design-only completion criteria',
},
],
},
],
submissions: [],
evaluations: [],
threads: [{ agentId: 'role-i', messages: [] }],
engagementEvents: [],
runtimeEvents: [],
createdAt: '2026-05-29T00:00:00.000Z',
updatedAt: '2026-05-29T00:00:00.000Z',
...overrides,
};
}
function runtimeEvent(): PBLRuntimeEvent {
return {
id: 'rt-1',
kind: 'message_created',
actorType: 'user',
messageId: 'msg-1',
threadId: 'role-i',
ts: '2026-05-29T00:00:01.000Z',
microtaskId: 'mt-1',
milestoneId: 'ms-1',
};
}
describe('PBL learner state split', () => {
it('round-trips learner-owned fields over a stripped design template', () => {
const project = makeProject({
uiPhase: 'completed',
status: 'completed',
runtimeResetEpoch: 2,
pendingOpenTaskPriorQuizResults: [
{
sceneId: 'quiz-1',
sceneTitle: 'Prior quiz',
totalQuestions: 1,
correctCount: 1,
incorrectCount: 0,
unscoredCount: 0,
accuracy: 1,
},
],
runtimeEvents: [runtimeEvent()],
});
project.milestones[0]!.status = 'completed';
project.milestones[0]!.internalAssessment = { performance: 'stage complete' };
project.milestones[0]!.microtasks[0]!.status = 'completed';
project.milestones[0]!.microtasks[0]!.completionReason = 'learner finished';
project.milestones[0]!.microtasks[0]!.internalAssessment = {
problems: 'none',
resolution: 'clear',
performance: 'strong',
};
project.milestones[0]!.microtasks[0]!.engagement = {
learnerTurnCount: 3,
errorCount: 0,
repeatErrorCount: 0,
errorSignatures: [],
conceptsUnlocked: ['loop-invariant'],
struggles: [],
questionsRaised: 1,
};
project.submissions.push({
id: 'sub-1',
microtaskId: 'mt-1',
milestoneId: 'ms-1',
kind: 'text',
content: 'Learner answer',
createdAt: '2026-05-29T00:01:00.000Z',
});
project.evaluations.push({
id: 'eval-1',
kind: 'task',
microtaskId: 'mt-1',
milestoneId: 'ms-1',
feedback: 'Good work',
strengths: ['Clear'],
improvements: ['Tighter proof'],
score: 92,
createdAt: '2026-05-29T00:02:00.000Z',
});
project.threads[0]!.messages.push({
id: 'msg-1',
roleType: 'user',
content: 'Here is my answer',
ts: '2026-05-29T00:00:01.000Z',
microtaskId: 'mt-1',
});
project.engagementEvents.push({
id: 'eng-1',
kind: 'learner_turn',
microtaskId: 'mt-1',
milestoneId: 'ms-1',
ts: '2026-05-29T00:00:01.000Z',
payload: { chars: 17 },
});
project.pendingHandover = {
completedMilestoneId: 'ms-1',
completedMilestoneTitle: 'Design-only milestone',
nextMilestoneId: 'ms-2',
nextMilestoneTitle: 'Next milestone',
consumed: false,
};
project.pendingTaskCompletion = {
microtaskId: 'mt-1',
milestoneId: 'ms-1',
reason: 'ready',
createdAt: '2026-05-29T00:03:00.000Z',
assessment: { performance: 'ready' },
};
const state = extractLearnerState(project);
const template = stripToDesignTemplate(project);
const restored = applyLearnerState(template, state);
expect(extractLearnerState(restored)).toEqual(state);
expect(restored.title).toBe('Design-only title');
expect(restored.milestones[0]!.title).toBe('Design-only milestone');
expect(restored.milestones[0]!.microtasks[0]!.title).toBe('Design-only task');
expect(restored.runtimeEvents).toBeUndefined();
expect(restored.pendingOpenTaskPriorQuizResults).toBeUndefined();
expect(template.proficiencyAssessment).toBeUndefined();
});
it('treats a malformed proficiencyAssessment as absent on both ends', () => {
const project = makeProject();
Reflect.set(project, 'proficiencyAssessment', {});
const state = extractLearnerState(project);
expect(state).not.toHaveProperty('proficiencyAssessment');
const template = stripToDesignTemplate(makeProject());
const priorProficiency = template.proficiency;
const corrupted = { ...state, proficiencyAssessment: {} } as typeof state;
const restored = applyLearnerState(template, corrupted);
expect(restored.proficiencyAssessment).toBeUndefined();
expect(restored.proficiency).toBe(priorProficiency);
});
it('does not leak design-time fields into PBLLearnerState', () => {
const state = extractLearnerState(makeProject());
expect(state).not.toHaveProperty('title');
expect(state).not.toHaveProperty('description');
expect(state).not.toHaveProperty('roles');
expect(state).not.toHaveProperty('runtimeEvents');
expect(state.milestones[0]).not.toHaveProperty('title');
expect(state.milestones[0]?.microtasks[0]).not.toHaveProperty('title');
expect(state.milestones[0]?.microtasks[0]).not.toHaveProperty('hints');
});
it('classifies every top-level PBLProjectV2 field into the runtime boundary', () => {
const project = makeProject({
learningObjective: 'Understand the runtime boundary',
gains: ['Runtime confidence'],
languageDirective: 'English only',
scenario: {
setting: 'Stakeholder interview',
characters: [{ id: 'char-1', name: 'Dana', persona: 'Project stakeholder' }],
},
schemaVersion: 1,
runtimeResetEpoch: 1,
pendingOpenTaskPriorQuizResults: [
{
sceneId: 'quiz-1',
sceneTitle: 'Prior quiz',
totalQuestions: 1,
correctCount: 1,
incorrectCount: 0,
unscoredCount: 0,
accuracy: 1,
},
],
runtimeEvents: [runtimeEvent()],
});
project.pendingHandover = {
completedMilestoneId: 'ms-1',
completedMilestoneTitle: 'Design-only milestone',
nextMilestoneId: 'ms-2',
nextMilestoneTitle: 'Next milestone',
consumed: false,
};
project.pendingTaskCompletion = {
microtaskId: 'mt-1',
milestoneId: 'ms-1',
reason: 'ready',
createdAt: '2026-05-29T00:03:00.000Z',
};
expect(Object.keys(PROJECT_FIELD_BOUNDARY).sort()).toEqual(Object.keys(project).sort());
});
});