Files
openmaic/OpenMAIC/tests/video-export/visuals.test.ts
2026-08-16 14:58:47 +08:00

604 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest';
import type { Action } from '@openmaic/dsl';
import { upgradeLegacyPBLConfigToProjectV2, type PBLProjectConfig } from '@/lib/pbl/legacy/read';
import type { PBLProjectV2 } from '@/lib/pbl/v2/types';
import {
compileVideoTimeline,
emitManifest,
type CompilerScene,
type PblCoverVisual,
} from '@/lib/video-export';
import { NO_ASSETS, stubProbe } from './helpers';
const speech = (id: string, text = 'Narration'): Action => ({ id, type: 'speech', text }) as Action;
function quizScene(content: Record<string, unknown>, title = 'Quiz <Checkpoint>'): CompilerScene {
return {
id: 'quiz-1',
stageId: 'stage',
title,
order: 0,
type: 'quiz',
content: { type: 'quiz', ...content },
actions: [speech('quiz-speech')],
} as CompilerScene;
}
function pblScene(content: Record<string, unknown>): CompilerScene {
return {
id: 'pbl-1',
stageId: 'stage',
title: 'Scene fallback',
order: 0,
type: 'pbl',
content: { type: 'pbl', ...content },
actions: [speech('pbl-speech')],
} as CompilerScene;
}
function compile(scene: CompilerScene) {
return compileVideoTimeline(
{ stage: { id: 'stage', name: 'Visual cards' }, scenes: [scene] },
{ timing: stubProbe({ 'quiz-speech': 2400, 'pbl-speech': 3600 }), assets: NO_ASSETS },
);
}
const nonRunnableV2Mutations: Array<[string, (project: PBLProjectV2) => void]> = [
[
'all milestones have empty microtasks',
(project) => {
project.milestones.forEach((milestone) => {
milestone.microtasks = [];
});
},
],
[
'one milestone has no microtasks',
(project) => {
project.milestones.at(-1)!.microtasks = [];
},
],
[
'there is no Instructor role',
(project) => {
project.roles.forEach((role) => {
role.type = 'mentor';
});
},
],
[
'the Instructor has no id',
(project) => {
Reflect.deleteProperty(project.roles.find((role) => role.type === 'instructor')!, 'id');
},
],
[
'the Instructor has no name',
(project) => {
Reflect.deleteProperty(project.roles.find((role) => role.type === 'instructor')!, 'name');
},
],
[
'a microtask has no id',
(project) => {
Reflect.deleteProperty(project.milestones.at(-1)!.microtasks.at(-1)!, 'id');
},
],
[
'a microtask has no title',
(project) => {
Reflect.deleteProperty(project.milestones.at(-1)!.microtasks.at(-1)!, 'title');
},
],
];
describe('video-export cover visual pass', () => {
it('builds a whole-scene Quiz cover from authored questions and default points', () => {
const ir = compile(
quizScene({
questions: [
{
id: 'q1',
type: 'single',
question: 'Never exported',
answer: ['SECRET_ANSWER'],
analysis: 'SECRET_ANALYSIS',
points: 4,
},
{ id: 'q2', type: 'short_answer', question: 'Also hidden' },
],
runtimeAnswers: { q1: 'SECRET_RUNTIME_ANSWER' },
}),
);
expect(ir.version).toBe(4);
expect(ir.scenes[0]).toMatchObject({
supported: true,
base: { kind: 'visual-segments' },
visuals: [
{
kind: 'quiz-cover',
startMs: 0,
durationMs: 2400,
title: 'Quiz <Checkpoint>',
questionCount: 2,
totalPoints: 5,
},
],
});
expect(JSON.stringify(ir)).not.toMatch(/SECRET_(ANSWER|ANALYSIS|RUNTIME_ANSWER)/);
expect(() => emitManifest(ir)).not.toThrow();
});
it('preserves a long Quiz title and emits zero counts for an empty question list', () => {
const title =
'Quiz这是一个用于验证视觉 IR 不截断源文本的超长标题 SupercalifragilisticexpialidociousWithoutBreaks';
const visual = compile(quizScene({ questions: [] }, title)).scenes[0].visuals[0];
expect(visual).toEqual({
kind: 'quiz-cover',
startMs: 0,
durationMs: 2400,
title,
questionCount: 0,
totalPoints: 0,
});
});
it('uses PBL v2 design fields, gains fallback rules, and learner-safe people summaries', () => {
const ir = compile(
pblScene({
projectV2: {
title: 'Build <Safe> Systems',
description: 'A deterministic project',
learningObjective: 'Fallback objective',
gains: [' Model threats ', '', 'Escape output'],
roles: [
{
id: 'instructor',
type: 'instructor',
name: 'Dr. Guide',
description: 'Learner-facing summary',
systemPrompt: 'SECRET_PERSONA',
},
],
milestones: [
{ id: 'm1', microtasks: [{ id: 't1' }, { id: 't2' }] },
{ id: 'm2', microtasks: [{ id: 't3' }] },
],
scenario: {
setting: 'SECRET_SITUATION',
characters: [
{
id: 'character',
name: 'Alex & Sam',
persona: 'SECRET_CHARACTER_PERSONA',
situation: 'SECRET_CHARACTER_SITUATION',
},
],
},
threads: [{ messages: [{ content: 'SECRET_CHAT' }] }],
submissions: [{ content: 'SECRET_SUBMISSION' }],
evaluations: [],
engagementEvents: [],
},
projectConfig: {
projectInfo: { title: 'Legacy title', description: 'Legacy description' },
issueboard: { issues: [] },
agents: [],
chat: { messages: [{ message: 'SECRET_LEGACY_CHAT' }] },
},
}),
);
expect(ir.scenes[0].visuals).toEqual([
{
kind: 'pbl-cover',
startMs: 0,
durationMs: 3600,
title: 'Build <Safe> Systems',
description: 'A deterministic project',
gains: ['Model threats', 'Escape output'],
stageCount: 2,
taskCount: 3,
instructorName: 'Dr. Guide',
instructorDescription: 'Learner-facing summary',
scenarioCharacterName: 'Alex & Sam',
},
]);
expect(JSON.stringify(ir)).not.toMatch(
/SECRET_(PERSONA|SITUATION|CHARACTER_PERSONA|CHARACTER_SITUATION|CHAT|SUBMISSION|LEGACY_CHAT)/,
);
});
it('falls back from missing v2 gains to learningObjective', () => {
const visual = compile(
pblScene({
projectV2: {
title: 'Legacy v2',
description: '',
learningObjective: 'Understand deterministic exports',
roles: [],
milestones: [],
},
}),
).scenes[0].visuals[0] as PblCoverVisual;
expect(visual.gains).toEqual(['Understand deterministic exports']);
});
it('selects an authored instructor after the reserved compatibility role', () => {
const visual = compile(
pblScene({
projectV2: {
title: 'Authored project',
description: '',
roles: [
{
id: 'role-compat-instructor',
type: 'instructor',
name: 'Synthetic Instructor',
},
{
id: 'role-authored-instructor',
type: 'instructor',
name: 'Dr. Guide',
description: 'Learner-facing summary',
},
],
milestones: [],
},
}),
).scenes[0].visuals[0] as PblCoverVisual;
expect(visual.instructorName).toBe('Dr. Guide');
expect(visual.instructorDescription).toBe('Learner-facing summary');
expect(JSON.stringify(visual)).not.toContain('Synthetic Instructor');
});
it('uses scene-safe defaults when PBL design fields are missing', () => {
const visual = compile(pblScene({})).scenes[0].visuals[0];
expect(visual).toEqual({
kind: 'pbl-cover',
startMs: 0,
durationMs: 3600,
title: 'Scene fallback',
description: '',
gains: [],
stageCount: 0,
taskCount: 0,
});
});
/**
* Shaped like the real v1 generator: the design prompt asks for 24
* development roles the learner picks between, `createAgent` always writes
* `is_user_role: false` (the learner is tracked by `selectedRole`), and the
* issueboard appends a Question/Judge agent per issue.
*/
const legacyProjectConfig = (progress: {
activeIssue: 'root' | 'child';
chat: string;
}): PBLProjectConfig => ({
projectInfo: { title: 'Legacy Project', description: 'Legacy description' },
agents: [
{
name: 'Data Analyst',
actor_role: '数据分析师',
role_division: 'development',
is_user_role: false,
is_system_agent: false,
system_prompt: 'SECRET_AGENT_PROMPT',
default_mode: 'idle',
delay_time: 0,
env: {},
is_active: true,
},
{
name: 'Frontend Developer',
actor_role: '前端开发',
role_division: 'development',
is_user_role: false,
is_system_agent: false,
system_prompt: '',
default_mode: 'idle',
delay_time: 0,
env: {},
is_active: true,
},
{
name: 'Question Agent - root',
actor_role: 'Question Agent',
role_division: 'management',
is_user_role: false,
is_system_agent: true,
system_prompt: '',
default_mode: 'idle',
delay_time: 0,
env: {},
is_active: true,
},
{
name: 'Judge Agent - root',
actor_role: 'Judge Agent',
role_division: 'management',
is_user_role: false,
is_system_agent: true,
system_prompt: '',
default_mode: 'idle',
delay_time: 0,
env: {},
is_active: true,
},
{
name: 'Question Agent - child',
actor_role: 'Question Agent',
role_division: 'management',
is_user_role: false,
is_system_agent: true,
system_prompt: '',
default_mode: 'idle',
delay_time: 0,
env: {},
is_active: true,
},
],
issueboard: {
agent_ids: ['Question Agent - root', 'Judge Agent - root', 'Question Agent - child'],
current_issue_id: progress.activeIssue,
issues: [
{
id: 'root',
title: 'Root task',
description: 'Root description',
person_in_charge: 'Data Analyst',
participants: ['Question Agent - root'],
notes: '',
parent_issue: null,
index: 0,
is_done: progress.activeIssue !== 'root',
is_active: progress.activeIssue === 'root',
generated_questions: 'Root question',
question_agent_name: 'Question Agent - root',
judge_agent_name: 'Judge Agent - root',
},
{
id: 'child',
title: 'Child task',
description: 'Child description',
person_in_charge: 'Data Analyst',
participants: ['Question Agent - child'],
notes: '',
parent_issue: 'root',
index: 1,
is_done: false,
is_active: progress.activeIssue === 'child',
generated_questions: 'Child question',
question_agent_name: 'Question Agent - child',
judge_agent_name: 'Judge Agent - root',
},
],
},
chat: {
messages: [
{
id: 'message',
agent_name: 'Question Agent - root',
message: progress.chat,
timestamp: 1780272000000,
read_by: [],
},
],
},
selectedRole: progress.activeIssue === 'child' ? 'Data Analyst' : null,
});
it('builds a stable legacy PBL cover without reading chat or progress state', () => {
const first = compile(
pblScene({ projectConfig: legacyProjectConfig({ activeIssue: 'root', chat: 'SECRET_A' }) }),
).scenes[0].visuals;
const second = compile(
pblScene({ projectConfig: legacyProjectConfig({ activeIssue: 'child', chat: 'SECRET_B' }) }),
).scenes[0].visuals;
expect(first).toEqual(second);
expect(first).toEqual([
{
kind: 'pbl-cover',
startMs: 0,
durationMs: 3600,
title: 'Legacy Project',
description: 'Legacy description',
gains: [],
stageCount: 1,
taskCount: 2,
},
]);
});
it('uses legacy cover fields when a hybrid scene has malformed projectV2', () => {
const visual = compile(
pblScene({
projectConfig: legacyProjectConfig({ activeIssue: 'root', chat: '' }),
projectV2: { title: 'broken' },
}),
).scenes[0].visuals[0];
expect(visual).toEqual({
kind: 'pbl-cover',
startMs: 0,
durationMs: 3600,
title: 'Legacy Project',
description: 'Legacy description',
gains: [],
stageCount: 1,
taskCount: 2,
});
});
it('uses legacy cover fields when a hybrid v2 project has no milestones', () => {
const projectConfig = legacyProjectConfig({ activeIssue: 'root', chat: '' });
const projectV2 = upgradeLegacyPBLConfigToProjectV2(projectConfig);
projectV2.title = 'Empty v2 title';
projectV2.milestones = [];
const visual = compile(pblScene({ projectConfig, projectV2 })).scenes[0].visuals[0];
expect(visual).toMatchObject({
kind: 'pbl-cover',
title: 'Legacy Project',
stageCount: 1,
taskCount: 2,
});
});
it.each(nonRunnableV2Mutations)(
'uses legacy cover fields when a hybrid v2 project is non-runnable because %s',
(_label, makeNonRunnable) => {
const projectConfig = legacyProjectConfig({ activeIssue: 'root', chat: '' });
const projectV2 = upgradeLegacyPBLConfigToProjectV2(projectConfig);
projectV2.title = 'Non-runnable v2 title';
makeNonRunnable(projectV2);
const visual = compile(pblScene({ projectConfig, projectV2 })).scenes[0].visuals[0];
expect(visual).toMatchObject({
kind: 'pbl-cover',
title: 'Legacy Project',
stageCount: 1,
taskCount: 2,
});
},
);
it('keeps a partial projectV2 cover when the legacy config is an empty stub', () => {
const visual = compile(
pblScene({
projectConfig: {},
projectV2: { title: 'Recoverable v2 title' },
}),
).scenes[0].visuals[0];
expect(visual).toMatchObject({
kind: 'pbl-cover',
title: 'Recoverable v2 title',
});
});
it.each(nonRunnableV2Mutations)(
'keeps the permissive v2-only cover when %s',
(_label, makeNonRunnable) => {
const projectV2 = upgradeLegacyPBLConfigToProjectV2(
legacyProjectConfig({ activeIssue: 'root', chat: '' }),
);
projectV2.title = 'Recoverable non-runnable v2';
makeNonRunnable(projectV2);
const visual = compile(pblScene({ projectV2 })).scenes[0].visuals[0];
expect(visual).toMatchObject({
kind: 'pbl-cover',
title: 'Recoverable non-runnable v2',
});
},
);
it('keeps the permissive v2 cover when a damaged hybrid legacy shell has no issues', () => {
const projectConfig = legacyProjectConfig({ activeIssue: 'root', chat: '' });
projectConfig.issueboard.issues = [];
const visual = compile(
pblScene({ projectConfig, projectV2: { title: 'Recoverable damaged v2' } }),
).scenes[0].visuals[0];
expect(visual).toMatchObject({
kind: 'pbl-cover',
title: 'Recoverable damaged v2',
stageCount: 0,
taskCount: 0,
});
});
/**
* A v1 roster holds student roles and issueboard bots, never a tutor, so no
* agent on it may be promoted onto the cover as one.
*/
it('never names an instructor for a legacy project', () => {
const visual = compile(
pblScene({ projectConfig: legacyProjectConfig({ activeIssue: 'root', chat: '' }) }),
).scenes[0].visuals[0] as PblCoverVisual;
expect(visual.instructorName).toBeUndefined();
expect(JSON.stringify(visual)).not.toMatch(/Data Analyst|Frontend Developer|Question Agent/);
});
/**
* Upgrading flattens the issue tree, so a migrated project reports one stage
* per issue instead of the single authored root the legacy path sees above.
* The cover reports whatever the stored project holds; restoring hierarchy is
* a compat concern tracked on its own. What this pass owns is that neither the
* counts nor the people move with learner progress -- the compat roster names
* its instructor after the active issue, so the cover must drop that role
* rather than repeat a progress-derived name.
*/
it('keeps migrated legacy cover counts and people stable across progress', () => {
const compileMigrated = (activeIssue: 'root' | 'child', chat: string) =>
compile(
pblScene({
projectV2: upgradeLegacyPBLConfigToProjectV2(legacyProjectConfig({ activeIssue, chat })),
}),
).scenes[0].visuals;
const first = compileMigrated('root', 'SECRET_A');
const second = compileMigrated('child', 'SECRET_B');
expect(first).toEqual(second);
expect(first).toEqual([
{
kind: 'pbl-cover',
startMs: 0,
durationMs: 3600,
title: 'Legacy Project',
description: 'Legacy description',
gains: [],
stageCount: 2,
taskCount: 2,
},
]);
expect(JSON.stringify(first)).not.toMatch(/Question Agent|Instructor/);
});
it('keeps unprepared interactive scenes unsupported while Quiz and PBL get informational diagnostics', () => {
const interactive = {
id: 'interactive',
stageId: 'stage',
title: 'Runtime widget',
order: 2,
type: 'interactive',
content: { type: 'interactive', html: '<p>runtime</p>' },
actions: [],
} as CompilerScene;
const scenes = [quizScene({ questions: [] }), { ...pblScene({}), order: 1 }, interactive];
const ir = compileVideoTimeline(
{ stage: { id: 'stage', name: 'Mixed' }, scenes },
{ timing: stubProbe(), assets: NO_ASSETS },
);
expect(ir.diagnostics.filter((d) => d.code === 'cover-card').map((d) => d.sceneId)).toEqual([
'quiz-1',
'pbl-1',
]);
expect(
ir.diagnostics.filter((d) => d.code === 'unsupported-scene').map((d) => d.sceneId),
).toEqual(['interactive']);
expect(ir.scenes[2]).toMatchObject({
supported: false,
base: { kind: 'placeholder' },
});
expect(ir.scenes[2].markers).toContainEqual(
expect.objectContaining({ kind: 'unsupported-scene' }),
);
});
});