Files
thu-maic--openmaic/tests/pbl/v2/completion-stats.test.ts
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

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();
});
});