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

384 lines
16 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
resolveActionTimeline,
IMPLICIT_WB_OPEN,
EFFECT_AUTO_CLEAR_MS,
DISCUSSION_TRIGGER_DELAY_MS,
DISCUSSION_AUTO_SKIP_MS,
WB_OPEN_MS,
WB_DRAW_MS,
WB_EDIT_MS,
wbDrawCodeMs,
wbClearMs,
estimateSpeechDurationMs,
} from '@/lib/choreography';
import type { Action } from '@/lib/types/action';
import type { Scene } from '@/lib/types/stage';
const act = (a: Partial<Action> & { type: string }): Action => a as unknown as Action;
const speech = (id: string, text: string): Action => act({ id, type: 'speech', text });
const sc = (id: string, actions: Action[]): Scene =>
({
id,
stageId: 's',
type: 'slide',
title: id,
order: 1,
content: { type: 'slide', canvas: {} },
actions,
}) as unknown as Scene;
describe('resolveActionTimeline — blocking actions advance the cursor', () => {
it('speech falls back to the deterministic estimate and accumulates startMs', () => {
const scenes = [sc('S0', [speech('a', '中'.repeat(20)), speech('b', 'hello world')])];
const tl = resolveActionTimeline(scenes);
const d0 = estimateSpeechDurationMs('中'.repeat(20)); // 3000
const d1 = estimateSpeechDurationMs('hello world'); // 2000 floor
expect(tl).toHaveLength(2);
expect(tl[0]).toMatchObject({
sceneId: 'S0',
sceneIndex: 0,
actionIndex: 0,
startMs: 0,
durationMs: d0,
advancesCursorMs: d0,
blocking: true,
});
expect(tl[1]).toMatchObject({ startMs: d0, durationMs: d1, advancesCursorMs: d1 });
});
it('a supplied audio-duration resolver overrides the estimate', () => {
const scenes = [sc('S0', [speech('a', 'anything')])];
const tl = resolveActionTimeline(scenes, { getAudioDurationMs: () => 7777 });
expect(tl[0]).toMatchObject({ durationMs: 7777, advancesCursorMs: 7777 });
});
it('playback speed scales both the estimate and supplied audio', () => {
const scenes = [sc('S0', [speech('a', '中'.repeat(20))])];
const tl = resolveActionTimeline(scenes, { playbackSpeed: 2 });
expect(tl[0].durationMs).toBe(estimateSpeechDurationMs('中'.repeat(20)) / 2);
// Real audio is scaled the same way (the live path sets AudioPlayer playbackRate).
const withAudio = resolveActionTimeline(scenes, {
playbackSpeed: 2,
getAudioDurationMs: () => 10_000,
});
expect(withAudio[0]).toMatchObject({ durationMs: 5000, advancesCursorMs: 5000 });
});
});
describe('resolveActionTimeline — fire-and-forget effects do not advance the cursor', () => {
it('effects start at the cursor but advance it by 0 (blocking:false)', () => {
const scenes = [
sc('S0', [
act({ id: 'sp', type: 'spotlight', elementId: 'e1' }),
act({ id: 'la', type: 'laser', elementId: 'e2' }),
speech('s', 'hi there'),
]),
];
const tl = resolveActionTimeline(scenes);
// Both effects and the speech start at 0 — the effects didn't move the clock.
expect(tl[0]).toMatchObject({ startMs: 0, advancesCursorMs: 0, blocking: false });
expect(tl[1]).toMatchObject({ startMs: 0, advancesCursorMs: 0, blocking: false });
expect(tl[2]).toMatchObject({ startMs: 0, blocking: true });
});
it('an effect is cleared at playback completion, not a flat 5s later', () => {
// spotlight fires at 0; the only speech ends at 2000, then processNext hits
// completion → clearEffects. So the effect lives 2000ms, not EFFECT_AUTO_CLEAR_MS.
const scenes = [
sc('S0', [act({ id: 'sp', type: 'spotlight', elementId: 'e1' }), speech('s', 'hi there')]),
];
const tl = resolveActionTimeline(scenes);
expect(tl[0]).toMatchObject({ startMs: 0, durationMs: 2000, blocking: false });
});
it('an effect is cleared at the next scene boundary', () => {
const scenes = [
sc('S0', [act({ id: 'sp', type: 'spotlight', elementId: 'e1' }), speech('a', 'hi there')]), // 0..2000
sc('S1', [speech('b', 'more')]),
];
// clearEffects fires at the start of S1 (2000), cutting the spotlight there.
expect(resolveActionTimeline(scenes)[0].durationMs).toBe(2000);
});
it('lives its full EFFECT_AUTO_CLEAR_MS when the scene runs long enough', () => {
// speech dwell 6000 > 5000, so the auto-clear timer fires before the boundary.
const scenes = [
sc('S0', [
act({ id: 'sp', type: 'spotlight', elementId: 'e1' }),
speech('s', '中'.repeat(40)),
]),
];
expect(resolveActionTimeline(scenes)[0].durationMs).toBe(EFFECT_AUTO_CLEAR_MS);
});
it('a later effect resets the shared clear timer, extending earlier effects', () => {
// spotlight@0, then speech(3000), then laser@3000 (resets the shared timer),
// then speech(6000) → completion 9000. The shared timer last fires at
// 3000+5000=8000, so BOTH effects clear together at 8000: spotlight lives
// 8000ms (extended past its own 5000), laser lives 5000ms.
const scenes = [
sc('S0', [
act({ id: 'sp', type: 'spotlight', elementId: 'e1' }),
speech('a', '中'.repeat(20)), // 3000
act({ id: 'la', type: 'laser', elementId: 'e2' }),
speech('b', '中'.repeat(40)), // 6000
]),
];
const tl = resolveActionTimeline(scenes);
expect(tl[0]).toMatchObject({ startMs: 0, durationMs: 8000, blocking: false }); // spotlight
expect(tl[2]).toMatchObject({
startMs: 3000,
durationMs: EFFECT_AUTO_CLEAR_MS,
blocking: false,
}); // laser
});
it('breaks the chain when the gap between effects exceeds EFFECT_AUTO_CLEAR_MS', () => {
// spotlight@0; speech(6000) > 5000, so the shared timer for the spotlight
// already fired at 5000 before the laser@6000 arrives. The laser starts a
// fresh 5000 window. Long trailing speech keeps both within their own scene.
const scenes = [
sc('S0', [
act({ id: 'sp', type: 'spotlight', elementId: 'e1' }),
speech('a', '中'.repeat(40)), // 6000
act({ id: 'la', type: 'laser', elementId: 'e2' }),
speech('b', '中'.repeat(40)), // 6000 → completion 12000
]),
];
const tl = resolveActionTimeline(scenes);
expect(tl[0]).toMatchObject({ startMs: 0, durationMs: EFFECT_AUTO_CLEAR_MS }); // spotlight: own 5000
expect(tl[2]).toMatchObject({ startMs: 6000, durationMs: EFFECT_AUTO_CLEAR_MS }); // laser: own 5000
});
it('breaks the chain at an EXACT 5s boundary (earlier clear timer fires first)', () => {
// spotlight@0; speech of exactly 5000ms; laser@5000. The spotlight's clear
// timer was queued first (same 5000ms delay), so it fires before the laser
// resets it — the spotlight lives exactly 5000, not 10000.
const scenes = [
sc('S0', [
act({ id: 'sp', type: 'spotlight', elementId: 'e1' }),
speech('a', 'anything'), // 5000 via audio resolver
act({ id: 'la', type: 'laser', elementId: 'e2' }),
speech('b', 'anything'), // 5000 → completion 10000
]),
];
const tl = resolveActionTimeline(scenes, { getAudioDurationMs: () => 5000 });
expect(tl[0]).toMatchObject({ startMs: 0, durationMs: EFFECT_AUTO_CLEAR_MS }); // spotlight: exactly 5000
expect(tl[2]).toMatchObject({ startMs: 5000, durationMs: EFFECT_AUTO_CLEAR_MS }); // laser: own 5000
});
it('a later scene never extends an earlier scene effect (boundary clears first)', () => {
const scenes = [
sc('S0', [act({ id: 'sp', type: 'spotlight', elementId: 'e1' }), speech('a', 'hi there')]), // 0..2000
sc('S1', [act({ id: 'la', type: 'laser', elementId: 'e2' }), speech('b', 'more')]),
];
// S0's spotlight is cut at S1's start (2000), NOT extended by S1's laser@2000.
expect(resolveActionTimeline(scenes)[0].durationMs).toBe(2000);
});
it('an effect as the last action of a scene has 0ms visual duration', () => {
// The engine fires the effect, queueMicrotask(processNext) hits completion,
// and clearEffects runs before real time elapses → 0ms.
const scenes = [
sc('S0', [speech('a', 'hi there'), act({ id: 'sp', type: 'spotlight', elementId: 'e1' })]),
];
const tl = resolveActionTimeline(scenes);
expect(tl[1]).toMatchObject({ startMs: 2000, durationMs: 0, blocking: false });
});
});
describe('resolveActionTimeline — per-action durations', () => {
it('wb_draw_code uses the line-count formula', () => {
const code = 'a\nb\nc\nd'; // 4 lines
const scenes = [sc('S0', [act({ id: 'c', type: 'wb_draw_code', code })])];
const tl = resolveActionTimeline(scenes, { whiteboardOpen: true });
expect(tl[0].durationMs).toBe(wbDrawCodeMs(4));
});
it('wb_clear is 0ms when empty and scales with the supplied live element count', () => {
const scenes = [
sc('S0', [act({ id: 'o', type: 'wb_open' }), act({ id: 'cl', type: 'wb_clear' })]),
];
// Empty board → engine early-returns with no delay, so 0ms (not wbClearMs(0)).
expect(resolveActionTimeline(scenes)[1].durationMs).toBe(0);
expect(resolveActionTimeline(scenes, { getClearElementCount: () => 10 })[1].durationMs).toBe(
wbClearMs(10),
);
});
it('wb_draw_* share WB_DRAW_MS', () => {
const scenes = [sc('S0', [act({ id: 't', type: 'wb_draw_text', content: 'hi' })])];
expect(resolveActionTimeline(scenes, { whiteboardOpen: true })[0].durationMs).toBe(WB_DRAW_MS);
});
it('no-op whiteboard draws cost 0ms (engine early-returns)', () => {
// wb_draw_text with empty content → executeWbDrawText returns before delay.
const emptyText = [sc('S0', [act({ id: 't', type: 'wb_draw_text', content: '' })])];
expect(resolveActionTimeline(emptyText, { whiteboardOpen: true })[0].durationMs).toBe(0);
// wb_draw_table with no rows → executeWbDrawTable returns early.
const emptyTable = [sc('S0', [act({ id: 'tb', type: 'wb_draw_table', data: [] })])];
expect(resolveActionTimeline(emptyTable, { whiteboardOpen: true })[0].durationMs).toBe(0);
// A populated table still costs WB_DRAW_MS.
const table = [sc('S0', [act({ id: 'tb2', type: 'wb_draw_table', data: [['a', 'b']] })])];
expect(resolveActionTimeline(table, { whiteboardOpen: true })[0].durationMs).toBe(WB_DRAW_MS);
});
it('discussion dwells for trigger delay + card auto-skip (unattended playback)', () => {
const scenes = [sc('S0', [act({ id: 'd', type: 'discussion', topic: 't' })])];
// Not skipped: the 3s trigger delay, then the ProactiveCard's 5s auto-skip
// countdown before playback continues.
expect(resolveActionTimeline(scenes)[0]).toMatchObject({
durationMs: DISCUSSION_TRIGGER_DELAY_MS + DISCUSSION_AUTO_SKIP_MS,
blocking: true,
});
});
it('a skipped discussion (consumed / agent not selected) contributes no dwell', () => {
const scenes = [sc('S0', [act({ id: 'd', type: 'discussion', topic: 't' })])];
// Engine skips it (processNext recurses with no timer) → 0ms.
expect(resolveActionTimeline(scenes, { isDiscussionSkipped: () => true })[0].durationMs).toBe(
0,
);
// Not skipped → trigger delay + card auto-skip, same as the default.
expect(resolveActionTimeline(scenes, { isDiscussionSkipped: () => false })[0].durationMs).toBe(
DISCUSSION_TRIGGER_DELAY_MS + DISCUSSION_AUTO_SKIP_MS,
);
});
it('wb_edit_code is WB_EDIT_MS by default and 0ms when the caller flags a no-op', () => {
const scenes = [
sc('S0', [
act({ id: 'e', type: 'wb_edit_code', elementId: 'c1', operation: 'delete_lines' }),
]),
];
expect(resolveActionTimeline(scenes, { whiteboardOpen: true })[0].durationMs).toBe(WB_EDIT_MS);
// Stale target / non-code element → executeWbEditCode returns before its delay.
expect(
resolveActionTimeline(scenes, { whiteboardOpen: true, isEditCodeNoop: () => true })[0]
.durationMs,
).toBe(0);
});
it('play_video uses the supplied duration (capped); unresolved is explicit, not silent 0', () => {
const scenes = [sc('S0', [act({ id: 'v', type: 'play_video', elementId: 'v1' })])];
// Supplied duration is used, capped at MAX_VIDEO_WAIT_MS (5min).
expect(resolveActionTimeline(scenes, { getVideoDurationMs: () => 12_345 })[0].durationMs).toBe(
12_345,
);
expect(
resolveActionTimeline(scenes, { getVideoDurationMs: () => 60 * 60 * 1000 })[0].durationMs,
).toBe(5 * 60 * 1000);
// Unresolved: default policy THROWS rather than silently shifting later actions early.
expect(() => resolveActionTimeline(scenes)).toThrow(/play_video/);
// Opt-in policies: 'cap' → max wait, 'zero' → explicit no-dwell.
expect(resolveActionTimeline(scenes, { onUnresolvedVideoDuration: 'cap' })[0].durationMs).toBe(
5 * 60 * 1000,
);
expect(resolveActionTimeline(scenes, { onUnresolvedVideoDuration: 'zero' })[0].durationMs).toBe(
0,
);
});
});
describe('resolveActionTimeline — scene boundaries', () => {
it('an empty scene yields one EMPTY_SCENE_DWELL beat', () => {
const scenes = [sc('S0', [])];
const tl = resolveActionTimeline(scenes);
expect(tl).toHaveLength(1);
expect(tl[0]).toMatchObject({
sceneId: 'S0',
actionIndex: 0,
// empty-text speech → 2000ms floor
durationMs: estimateSpeechDurationMs(''),
blocking: true,
});
expect(tl[0].action).toMatchObject({ type: 'speech', text: '' });
});
it('startMs accumulates across scenes in play order', () => {
const scenes = [
sc('S0', [speech('a', 'hello world')]), // 2000
sc('S1', []), // empty dwell 2000
sc('S2', [speech('c', '中'.repeat(20))]), // 3000
];
const tl = resolveActionTimeline(scenes);
expect(tl.map((s) => [s.sceneId, s.startMs])).toEqual([
['S0', 0],
['S1', 2000],
['S2', 4000],
]);
});
});
describe('resolveActionTimeline — implicit whiteboard auto-open', () => {
it('prepends a WB_OPEN_MS beat before the first wb_* mutation on a closed board', () => {
const scenes = [sc('S0', [act({ id: 'd', type: 'wb_draw_text', content: 'hi' })])];
const tl = resolveActionTimeline(scenes); // whiteboardOpen defaults to false
expect(tl).toHaveLength(2);
expect(tl[0]).toMatchObject({
action: IMPLICIT_WB_OPEN,
startMs: 0,
durationMs: WB_OPEN_MS,
advancesCursorMs: WB_OPEN_MS,
blocking: true,
});
// The real draw starts after the open animation.
expect(tl[1]).toMatchObject({ startMs: WB_OPEN_MS, durationMs: WB_DRAW_MS });
});
it('does not prepend when the board is already open (seeded or via wb_open)', () => {
const drawOnly = [sc('S0', [act({ id: 'd', type: 'wb_draw_text' })])];
expect(resolveActionTimeline(drawOnly, { whiteboardOpen: true })).toHaveLength(1);
const explicitOpen = [
sc('S0', [act({ id: 'o', type: 'wb_open' }), act({ id: 'd', type: 'wb_draw_text' })]),
];
const tl = resolveActionTimeline(explicitOpen);
// Only the authored wb_open + the draw — no synthetic beat.
expect(tl.map((s) => s.action.type)).toEqual(['wb_open', 'wb_draw_text']);
expect(tl[0].action).not.toBe(IMPLICIT_WB_OPEN);
});
it('re-opens after a wb_close (auto-open state carries across the sequence)', () => {
const scenes = [
sc('S0', [
act({ id: 'd1', type: 'wb_draw_text' }), // implicit open here
act({ id: 'x', type: 'wb_close' }),
act({ id: 'd2', type: 'wb_draw_shape' }), // implicit open again
]),
];
const tl = resolveActionTimeline(scenes);
expect(tl.map((s) => s.action.type)).toEqual([
'wb_open', // implicit (before d1)
'wb_draw_text',
'wb_close',
'wb_open', // implicit (before d2, board was closed again)
'wb_draw_shape',
]);
expect(tl.filter((s) => s.action === IMPLICIT_WB_OPEN)).toHaveLength(2);
});
it('carries open state across scene boundaries', () => {
const scenes = [
sc('S0', [act({ id: 'd1', type: 'wb_draw_text' })]), // implicit open
sc('S1', [act({ id: 'd2', type: 'wb_draw_shape' })]), // still open — no new open
];
const tl = resolveActionTimeline(scenes);
expect(tl.map((s) => s.action.type)).toEqual(['wb_open', 'wb_draw_text', 'wb_draw_shape']);
});
it('wb_clear/wb_delete on a closed board also trigger the implicit open', () => {
const scenes = [sc('S0', [act({ id: 'cl', type: 'wb_clear' })])];
const tl = resolveActionTimeline(scenes);
expect(tl[0].action).toBe(IMPLICIT_WB_OPEN);
// wb_clear on an (implicitly) empty board is still 0ms.
expect(tl[1]).toMatchObject({ startMs: WB_OPEN_MS, durationMs: 0 });
});
});