942 lines
28 KiB
TypeScript
942 lines
28 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
computeCompletionStats,
|
|
humanizeConceptSignature,
|
|
normalizeActGoals,
|
|
scenarioActGoalsScaffold,
|
|
} from '@/lib/pbl/v2/operations/completion-stats';
|
|
import type { PBLProjectV2 } from '@/lib/pbl/v2/types';
|
|
|
|
function baseProject(): PBLProjectV2 {
|
|
return {
|
|
uiPhase: 'completed',
|
|
title: 'Test Project',
|
|
description: 'A test project',
|
|
language: 'zh-CN',
|
|
proficiency: 'beginner',
|
|
tags: [],
|
|
status: 'completed',
|
|
roles: [{ id: 'r1', type: 'instructor', name: 'Teacher' }],
|
|
milestones: [],
|
|
submissions: [],
|
|
evaluations: [],
|
|
threads: [],
|
|
engagementEvents: [],
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
};
|
|
}
|
|
|
|
/** Compute stats and assert the STANDARD variant, returning it narrowed so the
|
|
* knowledge-project tests below can read standard-only fields directly. */
|
|
function standardStats(project: PBLProjectV2) {
|
|
const stats = computeCompletionStats(project);
|
|
if (stats.kind !== 'standard') throw new Error('expected a standard (non-scenario) project');
|
|
return stats;
|
|
}
|
|
|
|
describe('computeCompletionStats', () => {
|
|
it('returns a standard variant with zeros/empties for an empty project', () => {
|
|
const stats = computeCompletionStats(baseProject());
|
|
expect(stats.kind).toBe('standard');
|
|
if (stats.kind !== 'standard') throw new Error('expected standard');
|
|
expect(stats.conceptsUnlocked).toEqual([]);
|
|
expect(stats.independenceRate).toBe(0);
|
|
expect(stats.totalErrors).toBe(0);
|
|
expect(stats.totalSubmissions).toBe(0);
|
|
expect(stats.toughestMilestone).toBeNull();
|
|
expect(stats.stageDetails).toEqual([]);
|
|
expect(stats.highlights).toHaveLength(1);
|
|
expect(stats.highlights[0].kind).toBe('completion');
|
|
});
|
|
|
|
it('FIELD ISOLATION: a normal project is the standard variant — no scenario fields exist on it', () => {
|
|
const stats = computeCompletionStats(baseProject());
|
|
expect(stats.kind).toBe('standard');
|
|
// The discriminated union makes scenario-only keys absent at runtime.
|
|
expect('acts' in stats).toBe(false);
|
|
expect('goalCoverage' in stats).toBe(false);
|
|
expect('characterNames' in stats).toBe(false);
|
|
});
|
|
|
|
it('FIELD ISOLATION: a scenario project is the scenario variant — no knowledge fields exist on it', () => {
|
|
const p = baseProject();
|
|
p.scenario = {
|
|
setting: '德州扑克牌桌',
|
|
sceneVisual: { caption: '牌桌现金局' },
|
|
characters: [{ id: 'c1', name: '老周', persona: '牌手' }],
|
|
};
|
|
p.milestones = [
|
|
{
|
|
id: 'ms-rp',
|
|
title: '第一手牌',
|
|
status: 'completed',
|
|
order: 0,
|
|
scenarioStage: 'roleplay',
|
|
microtasks: [
|
|
{
|
|
id: 'b1',
|
|
title: '翻前',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
successWhen: '做出翻前决定',
|
|
engagement: { learnerTurnCount: 1, durationSeconds: 30 },
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
];
|
|
const stats = computeCompletionStats(p);
|
|
expect(stats.kind).toBe('scenario');
|
|
if (stats.kind !== 'scenario') throw new Error('expected scenario');
|
|
expect(stats.acts).toEqual({ completed: 1, total: 1 });
|
|
expect(stats.totalTurns).toBe(1);
|
|
expect(stats.sceneCaption).toBe('牌桌现金局');
|
|
expect(stats.characterNames).toEqual(['老周']);
|
|
// No knowledge-project metrics leak onto the scenario variant.
|
|
expect('conceptsUnlocked' in stats).toBe(false);
|
|
expect('stageDetails' in stats).toBe(false);
|
|
expect('highlights' in stats).toBe(false);
|
|
expect('totalSubmissions' in stats).toBe(false);
|
|
// The dead choice-decision scoreboard is gone entirely.
|
|
expect('decisions' in stats).toBe(false);
|
|
// No final eval with actGoals → no goalCoverage (graceful omit).
|
|
expect(stats.goalCoverage).toBeUndefined();
|
|
});
|
|
|
|
it('builds scenario goalCoverage from the final eval actGoals', () => {
|
|
const p = baseProject();
|
|
p.scenario = {
|
|
setting: '客户投诉处理',
|
|
characters: [{ id: 'c1', name: '王女士', persona: '客户' }],
|
|
};
|
|
p.milestones = [
|
|
{
|
|
id: 'ms-rp',
|
|
title: '安抚客户',
|
|
status: 'completed',
|
|
order: 0,
|
|
scenarioStage: 'roleplay',
|
|
microtasks: [
|
|
{
|
|
id: 'b1',
|
|
title: '倾听',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
successWhen: '对客户的情绪做出共情回应',
|
|
skillFocus: '积极倾听',
|
|
engagement: { learnerTurnCount: 2, durationSeconds: 60 },
|
|
},
|
|
{
|
|
id: 'b2',
|
|
title: '澄清',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 1,
|
|
successWhen: '问出问题的根本原因',
|
|
engagement: { learnerTurnCount: 1, durationSeconds: 30 },
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
];
|
|
p.evaluations = [
|
|
{
|
|
id: 'ev-final',
|
|
kind: 'final',
|
|
feedback: 'nice',
|
|
strengths: [],
|
|
improvements: [],
|
|
actGoals: [
|
|
{
|
|
milestoneId: 'ms-rp',
|
|
actTitle: '安抚客户',
|
|
goals: [
|
|
{ goal: '对客户的情绪做出共情回应', skillFocus: '积极倾听', status: 'achieved' },
|
|
{ goal: '问出问题的根本原因', status: 'missed' },
|
|
],
|
|
},
|
|
],
|
|
createdAt: '2026-01-01T00:00:00.000Z',
|
|
},
|
|
];
|
|
const stats = computeCompletionStats(p);
|
|
expect(stats.kind).toBe('scenario');
|
|
if (stats.kind !== 'scenario') throw new Error('expected scenario');
|
|
expect(stats.acts).toEqual({ completed: 1, total: 1 });
|
|
expect(stats.goalCoverage!.achieved).toBe(1);
|
|
expect(stats.goalCoverage!.partial).toBe(0);
|
|
expect(stats.goalCoverage!.missed).toBe(1);
|
|
expect(stats.goalCoverage!.total).toBe(2);
|
|
expect(stats.goalCoverage!.acts).toHaveLength(1);
|
|
// When the scored coverage is present, the read-only scaffold fallback
|
|
// must NOT also be set (mutually exclusive).
|
|
expect(stats.goalScaffold).toBeUndefined();
|
|
});
|
|
|
|
it('FALLBACK: older scenario project without actGoals exposes a read-only goalScaffold', () => {
|
|
const p = baseProject();
|
|
p.scenario = {
|
|
setting: '客户投诉处理',
|
|
characters: [{ id: 'c1', name: '王女士', persona: '客户' }],
|
|
};
|
|
p.milestones = [
|
|
{
|
|
id: 'ms-rp',
|
|
title: '安抚客户',
|
|
status: 'completed',
|
|
order: 0,
|
|
scenarioStage: 'roleplay',
|
|
microtasks: [
|
|
{
|
|
id: 'b1',
|
|
title: '倾听',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
successWhen: '对客户的情绪做出共情回应',
|
|
skillFocus: '积极倾听',
|
|
engagement: { learnerTurnCount: 2, durationSeconds: 60 },
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
];
|
|
// No final eval at all → no actGoals (this is exactly the pre-actGoals
|
|
// historical project shape).
|
|
const stats = computeCompletionStats(p);
|
|
expect(stats.kind).toBe('scenario');
|
|
if (stats.kind !== 'scenario') throw new Error('expected scenario');
|
|
expect(stats.goalCoverage).toBeUndefined();
|
|
// The structured review survives as an unscored scaffold.
|
|
expect(stats.goalScaffold).toEqual([
|
|
{
|
|
milestoneId: 'ms-rp',
|
|
actTitle: '安抚客户',
|
|
goals: [{ goal: '对客户的情绪做出共情回应', skillFocus: '积极倾听' }],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('collects unique concepts across milestones', () => {
|
|
const p = baseProject();
|
|
p.milestones = [
|
|
{
|
|
id: 'ms1',
|
|
title: 'S1',
|
|
status: 'completed',
|
|
order: 0,
|
|
microtasks: [
|
|
{
|
|
id: 'mt1',
|
|
title: 't1',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
engagement: { conceptsUnlocked: ['loop', 'list'] },
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
{
|
|
id: 'ms2',
|
|
title: 'S2',
|
|
status: 'completed',
|
|
order: 1,
|
|
microtasks: [
|
|
{
|
|
id: 'mt2',
|
|
title: 't2',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
engagement: { conceptsUnlocked: ['loop', 'dict'] },
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
];
|
|
const stats = standardStats(p);
|
|
expect(stats.conceptsUnlocked).toEqual(['loop', 'list', 'dict']);
|
|
});
|
|
|
|
it('computes independence rate from concept_unlocked vs closing_check', () => {
|
|
const p = baseProject();
|
|
p.milestones = [
|
|
{
|
|
id: 'ms1',
|
|
title: 'S1',
|
|
status: 'completed',
|
|
order: 0,
|
|
microtasks: [
|
|
{
|
|
id: 'mt1',
|
|
title: 't1',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
engagement: { conceptsUnlocked: ['a'] },
|
|
},
|
|
{
|
|
id: 'mt2',
|
|
title: 't2',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 1,
|
|
engagement: { conceptsUnlocked: ['b'] },
|
|
},
|
|
{
|
|
id: 'mt3',
|
|
title: 't3',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 2,
|
|
engagement: { closingQuestion: 'why?' },
|
|
},
|
|
{
|
|
id: 'mt4',
|
|
title: 't4',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 3,
|
|
engagement: {},
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
];
|
|
const stats = standardStats(p);
|
|
// 2 concept_unlocked, 1 closing_check → 2/3 = 0.667
|
|
expect(stats.independenceRate).toBeCloseTo(2 / 3, 2);
|
|
});
|
|
|
|
it('counts total errors across all microtasks', () => {
|
|
const p = baseProject();
|
|
p.milestones = [
|
|
{
|
|
id: 'ms1',
|
|
title: 'S1',
|
|
status: 'completed',
|
|
order: 0,
|
|
microtasks: [
|
|
{
|
|
id: 'mt1',
|
|
title: 't1',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
engagement: { errorCount: 3 },
|
|
},
|
|
{
|
|
id: 'mt2',
|
|
title: 't2',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 1,
|
|
engagement: { errorCount: 1 },
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
];
|
|
expect(standardStats(p).totalErrors).toBe(4);
|
|
});
|
|
|
|
it('finds the toughest milestone with ≥3 errors', () => {
|
|
const p = baseProject();
|
|
p.milestones = [
|
|
{
|
|
id: 'ms1',
|
|
title: 'Easy',
|
|
status: 'completed',
|
|
order: 0,
|
|
microtasks: [
|
|
{
|
|
id: 'mt1',
|
|
title: 't1',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
engagement: { errorCount: 1 },
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
{
|
|
id: 'ms2',
|
|
title: 'Hard',
|
|
status: 'completed',
|
|
order: 1,
|
|
microtasks: [
|
|
{
|
|
id: 'mt2',
|
|
title: 't2',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
engagement: { errorCount: 4 },
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
];
|
|
const stats = standardStats(p);
|
|
expect(stats.toughestMilestone).toEqual({ title: 'Hard', errors: 4 });
|
|
});
|
|
|
|
it('does not flag a milestone with fewer than 3 errors as toughest', () => {
|
|
const p = baseProject();
|
|
p.milestones = [
|
|
{
|
|
id: 'ms1',
|
|
title: 'Light',
|
|
status: 'completed',
|
|
order: 0,
|
|
microtasks: [
|
|
{
|
|
id: 'mt1',
|
|
title: 't1',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
engagement: { errorCount: 2 },
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
];
|
|
expect(standardStats(p).toughestMilestone).toBeNull();
|
|
});
|
|
|
|
it('produces independence highlight when rate ≥ 0.6 with ≥ 2 evidence', () => {
|
|
const p = baseProject();
|
|
p.milestones = [
|
|
{
|
|
id: 'ms1',
|
|
title: 'S1',
|
|
status: 'completed',
|
|
order: 0,
|
|
microtasks: [
|
|
{
|
|
id: 'mt1',
|
|
title: 't1',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
engagement: { conceptsUnlocked: ['a'] },
|
|
},
|
|
{
|
|
id: 'mt2',
|
|
title: 't2',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 1,
|
|
engagement: { conceptsUnlocked: ['b'] },
|
|
},
|
|
{
|
|
id: 'mt3',
|
|
title: 't3',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 2,
|
|
engagement: { conceptsUnlocked: ['c'] },
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
];
|
|
const stats = standardStats(p);
|
|
expect(stats.highlights.some((h) => h.kind === 'independence')).toBe(true);
|
|
});
|
|
|
|
it('produces resilience highlight when a tough milestone exists', () => {
|
|
const p = baseProject();
|
|
p.milestones = [
|
|
{
|
|
id: 'ms1',
|
|
title: 'Grind',
|
|
status: 'completed',
|
|
order: 0,
|
|
microtasks: [
|
|
{
|
|
id: 'mt1',
|
|
title: 't1',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
engagement: { errorCount: 5 },
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
];
|
|
const stats = standardStats(p);
|
|
expect(stats.highlights.some((h) => h.kind === 'resilience')).toBe(true);
|
|
});
|
|
|
|
it('falls back to completion highlight when nothing else qualifies', () => {
|
|
const p = baseProject();
|
|
p.milestones = [
|
|
{
|
|
id: 'ms1',
|
|
title: 'S1',
|
|
status: 'completed',
|
|
order: 0,
|
|
microtasks: [
|
|
{
|
|
id: 'mt1',
|
|
title: 't1',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
engagement: {},
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
];
|
|
const stats = standardStats(p);
|
|
expect(stats.highlights).toHaveLength(1);
|
|
expect(stats.highlights[0].kind).toBe('completion');
|
|
});
|
|
|
|
it('builds stage details with core stage and synthesis quality', () => {
|
|
const p = baseProject();
|
|
p.milestones = [
|
|
{
|
|
id: 'ms1',
|
|
title: 'Core Stage',
|
|
status: 'completed',
|
|
order: 0,
|
|
synthesisCheck: { coreConcept: 'why loops matter' },
|
|
microtasks: [
|
|
{
|
|
id: 'mt1',
|
|
title: 't1',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
engagement: { conceptsUnlocked: ['loop'] },
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
];
|
|
p.engagementEvents = [
|
|
{
|
|
id: 'e1',
|
|
kind: 'stage_synthesis_check',
|
|
milestoneId: 'ms1',
|
|
microtaskId: 'mt1',
|
|
ts: '2026-01-01T00:00:00Z',
|
|
payload: { quality: 'strong' },
|
|
},
|
|
];
|
|
p.submissions = [
|
|
{
|
|
id: 's1',
|
|
microtaskId: 'mt1',
|
|
kind: 'text',
|
|
content: 'code',
|
|
createdAt: '2026-01-01T00:00:00Z',
|
|
},
|
|
];
|
|
const stats = standardStats(p);
|
|
expect(stats.stageDetails).toHaveLength(1);
|
|
expect(stats.stageDetails[0].isCoreStage).toBe(true);
|
|
expect(stats.stageDetails[0].coreConcept).toBe('why loops matter');
|
|
expect(stats.stageDetails[0].synthesisQuality).toBe('strong');
|
|
expect(stats.stageDetails[0].conceptsInStage).toEqual(['loop']);
|
|
expect(stats.stageDetails[0].submissionsInStage).toBe(1);
|
|
});
|
|
|
|
it('does not double-count a stage_synthesis_check absorbed into closingQuestion', () => {
|
|
const p = baseProject();
|
|
p.milestones = [
|
|
{
|
|
id: 'ms1',
|
|
title: 'Core',
|
|
status: 'completed',
|
|
order: 0,
|
|
synthesisCheck: { coreConcept: 'recursion' },
|
|
microtasks: [
|
|
{
|
|
id: 'mt1',
|
|
title: 't1',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
engagement: { conceptsUnlocked: ['a'] },
|
|
},
|
|
{
|
|
id: 'mt2',
|
|
title: 't2',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 1,
|
|
engagement: { conceptsUnlocked: ['b'] },
|
|
},
|
|
{
|
|
// Last microtask: microtaskEngagement() already absorbed the
|
|
// stage_synthesis_check into closingQuestion → it is the cache's
|
|
// single record of that check.
|
|
id: 'mt3',
|
|
title: 't3',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 2,
|
|
engagement: { closingQuestion: 'why does recursion work?' },
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
];
|
|
// The SAME synthesis check also lives in the append-only ledger; it
|
|
// must not be counted a second time on top of the cache.
|
|
p.engagementEvents = [
|
|
{
|
|
id: 'e1',
|
|
kind: 'stage_synthesis_check',
|
|
milestoneId: 'ms1',
|
|
microtaskId: 'mt3',
|
|
ts: '2026-01-01T00:00:00Z',
|
|
payload: { quality: 'strong' },
|
|
},
|
|
];
|
|
const stats = standardStats(p);
|
|
// 2 concept_unlocked + exactly 1 check (the absorbed synthesis) → 2/3,
|
|
// NOT 2/4 (which is what the old ledger double-count produced).
|
|
expect(stats.independenceRate).toBeCloseTo(2 / 3, 2);
|
|
// Independence highlight should still surface (0.667 ≥ 0.6, evidence ≥ 2).
|
|
expect(stats.highlights.some((h) => h.kind === 'independence')).toBe(true);
|
|
});
|
|
|
|
it('shows human-readable, localised concept labels in the stage review', () => {
|
|
const p = baseProject();
|
|
p.milestones = [
|
|
{
|
|
id: 'ms1',
|
|
title: 'S1',
|
|
status: 'completed',
|
|
order: 0,
|
|
microtasks: [
|
|
{
|
|
id: 'mt1',
|
|
title: 't1',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
engagement: {
|
|
// signatures are machine tags; labels are what the learner sees.
|
|
conceptsUnlocked: ['set_dedup', 'print_empty_set_verified'],
|
|
conceptUnlockLabels: { set_dedup: '为什么 set 能自动去重' },
|
|
},
|
|
},
|
|
],
|
|
documents: [],
|
|
},
|
|
];
|
|
const stats = standardStats(p);
|
|
// Labelled signature → localised label; unlabelled signature → humanised
|
|
// fallback (spaced words, never the raw snake_case token).
|
|
expect(stats.stageDetails[0].conceptsInStage).toEqual([
|
|
'为什么 set 能自动去重',
|
|
'print empty set verified',
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('humanizeConceptSignature', () => {
|
|
it('turns a snake/kebab machine tag into spaced words', () => {
|
|
expect(humanizeConceptSignature('print_empty_set_verified')).toBe('print empty set verified');
|
|
expect(humanizeConceptSignature('if-else-basic')).toBe('if else basic');
|
|
});
|
|
|
|
it('collapses repeats and trims', () => {
|
|
expect(humanizeConceptSignature('__case__selection__')).toBe('case selection');
|
|
});
|
|
});
|
|
|
|
describe('scenarioActGoalsScaffold + normalizeActGoals', () => {
|
|
function rpProjectWithGoals(): PBLProjectV2 {
|
|
const p = baseProject();
|
|
p.scenario = {
|
|
setting: 's',
|
|
characters: [{ id: 'c1', name: '客户', persona: 'p' }],
|
|
};
|
|
p.milestones = [
|
|
{
|
|
id: 'ms-rp',
|
|
title: '安抚客户',
|
|
status: 'completed',
|
|
order: 0,
|
|
scenarioStage: 'roleplay',
|
|
documents: [],
|
|
microtasks: [
|
|
{
|
|
id: 'b1',
|
|
title: '倾听',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
successWhen: '共情回应',
|
|
skillFocus: '积极倾听',
|
|
},
|
|
{
|
|
id: 'b2',
|
|
title: '澄清',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 1,
|
|
successWhen: '问出根因',
|
|
},
|
|
],
|
|
},
|
|
];
|
|
return p;
|
|
}
|
|
|
|
it('scaffold pulls goal text + skillFocus straight from project data', () => {
|
|
const scaffold = scenarioActGoalsScaffold(rpProjectWithGoals());
|
|
expect(scaffold).toEqual([
|
|
{
|
|
milestoneId: 'ms-rp',
|
|
actTitle: '安抚客户',
|
|
goals: [{ goal: '共情回应', skillFocus: '积极倾听' }, { goal: '问出根因' }],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('overlays the LLM status/note onto the authored scaffold (text from data, never the model)', () => {
|
|
const p = rpProjectWithGoals();
|
|
const raw = [
|
|
{
|
|
milestoneId: 'ms-rp',
|
|
// The model tries to rewrite the goal text — it must be IGNORED.
|
|
goals: [
|
|
{ goalIndex: 0, status: 'achieved', note: '开场就共情', goal: 'HACKED' },
|
|
{ goalIndex: 1, status: 'partial' },
|
|
],
|
|
},
|
|
];
|
|
const result = normalizeActGoals(raw, p)!;
|
|
expect(result).toHaveLength(1);
|
|
expect(result[0].goals[0]).toEqual({
|
|
goal: '共情回应',
|
|
skillFocus: '积极倾听',
|
|
status: 'achieved',
|
|
note: '开场就共情',
|
|
});
|
|
expect(result[0].goals[1]).toEqual({ goal: '问出根因', status: 'partial' });
|
|
});
|
|
|
|
it('aligns verdicts by goalIndex even when the model REORDERS goals within an act', () => {
|
|
const p = rpProjectWithGoals(); // goal[0]=共情回应, goal[1]=问出根因
|
|
// Model emitted the two goals in REVERSE array order, but each carries its
|
|
// true goalIndex. Position would mis-pin (共情=missed, 根因=achieved); index
|
|
// alignment must restore the correct mapping.
|
|
const raw = [
|
|
{
|
|
milestoneId: 'ms-rp',
|
|
goals: [
|
|
{ goalIndex: 1, status: 'missed', note: '没问到根因' },
|
|
{ goalIndex: 0, status: 'achieved', note: '共情到位' },
|
|
],
|
|
},
|
|
];
|
|
const result = normalizeActGoals(raw, p)!;
|
|
expect(result[0].goals[0]).toEqual({
|
|
goal: '共情回应',
|
|
skillFocus: '积极倾听',
|
|
status: 'achieved',
|
|
note: '共情到位',
|
|
});
|
|
expect(result[0].goals[1]).toEqual({ goal: '问出根因', status: 'missed', note: '没问到根因' });
|
|
});
|
|
|
|
it('STRICT: aborts to undefined when a goal is missing its goalIndex', () => {
|
|
const p = rpProjectWithGoals();
|
|
const raw = [
|
|
{
|
|
milestoneId: 'ms-rp',
|
|
goals: [{ goalIndex: 0, status: 'achieved' }, { status: 'partial' }],
|
|
},
|
|
];
|
|
expect(normalizeActGoals(raw, p)).toBeUndefined();
|
|
});
|
|
|
|
it('STRICT: aborts to undefined on a duplicate goalIndex (and a gap it implies)', () => {
|
|
const p = rpProjectWithGoals();
|
|
// Two verdicts both claim index 0 → index 1 never covered → abort.
|
|
const raw = [
|
|
{
|
|
milestoneId: 'ms-rp',
|
|
goals: [
|
|
{ goalIndex: 0, status: 'achieved' },
|
|
{ goalIndex: 0, status: 'partial' },
|
|
],
|
|
},
|
|
];
|
|
expect(normalizeActGoals(raw, p)).toBeUndefined();
|
|
});
|
|
|
|
it('STRICT: aborts to undefined on an out-of-range goalIndex', () => {
|
|
const p = rpProjectWithGoals(); // valid indices are 0,1
|
|
const raw = [
|
|
{
|
|
milestoneId: 'ms-rp',
|
|
goals: [
|
|
{ goalIndex: 0, status: 'achieved' },
|
|
{ goalIndex: 5, status: 'partial' },
|
|
],
|
|
},
|
|
];
|
|
expect(normalizeActGoals(raw, p)).toBeUndefined();
|
|
});
|
|
|
|
it('STRICT: aborts to undefined when any goal carries an invalid/unjudged status', () => {
|
|
const p = rpProjectWithGoals();
|
|
// goal[0] valid but goal[1] has a junk status → the whole overlay is
|
|
// untrustworthy (likely a truncated/misaligned model output), so we must
|
|
// NOT silently back-fill goal[1] as `missed`. Fall back to undefined.
|
|
const raw = [
|
|
{
|
|
milestoneId: 'ms-rp',
|
|
goals: [
|
|
{ goalIndex: 0, status: 'achieved' },
|
|
{ goalIndex: 1, status: 'nonsense' },
|
|
],
|
|
},
|
|
];
|
|
expect(normalizeActGoals(raw, p)).toBeUndefined();
|
|
});
|
|
|
|
it('STRICT: aborts to undefined when an act returns the wrong goal count (truncated tail)', () => {
|
|
const p = rpProjectWithGoals(); // scaffold has 2 goals for ms-rp
|
|
// Model truncated and returned only the first goal. Back-filling goal[1]
|
|
// would tell the learner they failed a goal the model never judged → abort.
|
|
const raw = [{ milestoneId: 'ms-rp', goals: [{ goalIndex: 0, status: 'achieved' }] }];
|
|
expect(normalizeActGoals(raw, p)).toBeUndefined();
|
|
});
|
|
|
|
it('STRICT: aborts to undefined when an authored act is missing from the model output', () => {
|
|
const p = rpProjectWithGoals();
|
|
p.milestones.push({
|
|
id: 'ms-rp2',
|
|
title: '第二幕',
|
|
status: 'completed',
|
|
order: 1,
|
|
scenarioStage: 'roleplay',
|
|
documents: [],
|
|
microtasks: [
|
|
{
|
|
id: 'b3',
|
|
title: 'x',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
successWhen: '收尾',
|
|
},
|
|
],
|
|
});
|
|
// Model returned a verdict for ms-rp but dropped ms-rp2 entirely → abort,
|
|
// do not mark the whole second act as missed.
|
|
const raw = [
|
|
{
|
|
milestoneId: 'ms-rp',
|
|
goals: [
|
|
{ goalIndex: 0, status: 'achieved' },
|
|
{ goalIndex: 1, status: 'partial' },
|
|
],
|
|
},
|
|
];
|
|
expect(normalizeActGoals(raw, p)).toBeUndefined();
|
|
});
|
|
|
|
it('tolerates act-level reordering (matches by milestoneId, not array position)', () => {
|
|
const p = rpProjectWithGoals();
|
|
p.milestones.push({
|
|
id: 'ms-rp2',
|
|
title: '第二幕',
|
|
status: 'completed',
|
|
order: 1,
|
|
scenarioStage: 'roleplay',
|
|
documents: [],
|
|
microtasks: [
|
|
{
|
|
id: 'b3',
|
|
title: 'x',
|
|
status: 'completed',
|
|
assignee: 'user',
|
|
hints: [],
|
|
order: 0,
|
|
successWhen: '收尾',
|
|
},
|
|
],
|
|
});
|
|
// Model returned the two acts in REVERSE order — milestoneId lookup must
|
|
// still align each verdict to the right act.
|
|
const raw = [
|
|
{ milestoneId: 'ms-rp2', goals: [{ goalIndex: 0, status: 'missed' }] },
|
|
{
|
|
milestoneId: 'ms-rp',
|
|
goals: [
|
|
{ goalIndex: 0, status: 'achieved' },
|
|
{ goalIndex: 1, status: 'partial' },
|
|
],
|
|
},
|
|
];
|
|
const result = normalizeActGoals(raw, p)!;
|
|
expect(result.map((a) => a.milestoneId)).toEqual(['ms-rp', 'ms-rp2']);
|
|
expect(result[0].goals.map((g) => g.status)).toEqual(['achieved', 'partial']);
|
|
expect(result[1].goals.map((g) => g.status)).toEqual(['missed']);
|
|
});
|
|
|
|
it('returns undefined when the model emitted no usable verdict at all (graceful fallback)', () => {
|
|
const p = rpProjectWithGoals();
|
|
expect(normalizeActGoals(undefined, p)).toBeUndefined();
|
|
expect(normalizeActGoals([], p)).toBeUndefined();
|
|
expect(normalizeActGoals('garbage', p)).toBeUndefined();
|
|
});
|
|
|
|
it('returns undefined when the project authored no roleplay goals', () => {
|
|
expect(
|
|
normalizeActGoals(
|
|
[{ milestoneId: 'x', goals: [{ goalIndex: 0, status: 'achieved' }] }],
|
|
baseProject(),
|
|
),
|
|
).toBeUndefined();
|
|
});
|
|
});
|