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

197 lines
8.3 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import {
appendDiscussion,
clampInsertSlot,
discussionIndex,
hasDiscussion,
insertAt,
makeAction,
makeDiscussion,
move,
moveById,
moveByIdDir,
removeAt,
removeById,
setAudioId,
setAudioIdById,
setDiscussionAgentById,
setDiscussionPromptById,
setDiscussionTopicById,
setElementId,
setElementIdById,
setSpeechText,
setSpeechTextById,
setSpeechTextClearAudioById,
} from '@/components/edit/ActionsBar/actions-edit';
import type { Action } from '@/lib/types/action';
const A = (id: string, type = 'speech'): Action => ({ id, type }) as unknown as Action;
const ids = (xs: Action[]) => xs.map((a) => a.id);
describe('makeAction', () => {
test('speech carries empty text; element cues carry empty elementId', () => {
expect(makeAction('speech', 's')).toEqual({ id: 's', type: 'speech', text: '' });
expect(makeAction('spotlight', 'p')).toEqual({ id: 'p', type: 'spotlight', elementId: '' });
expect(makeAction('laser', 'l')).toEqual({ id: 'l', type: 'laser', elementId: '' });
});
});
describe('insertAt / removeAt', () => {
const base = [A('a'), A('b'), A('c')];
test('inserts at the slot and clamps out-of-range', () => {
expect(ids(insertAt(base, 1, A('x')))).toEqual(['a', 'x', 'b', 'c']);
expect(ids(insertAt(base, 99, A('x')))).toEqual(['a', 'b', 'c', 'x']);
expect(ids(insertAt(base, -5, A('x')))).toEqual(['x', 'a', 'b', 'c']);
});
test('does not mutate the input', () => {
insertAt(base, 1, A('x'));
expect(ids(base)).toEqual(['a', 'b', 'c']);
});
test('removeAt drops the index; no-op when out of range', () => {
expect(ids(removeAt(base, 1))).toEqual(['a', 'c']);
expect(ids(removeAt(base, 9))).toEqual(['a', 'b', 'c']);
});
});
describe('move', () => {
const base = [A('a'), A('b'), A('c'), A('d')];
test('moves forward (slot is an original-array gap)', () => {
expect(ids(move(base, 0, 2))).toEqual(['b', 'a', 'c', 'd']);
expect(ids(move(base, 0, 4))).toEqual(['b', 'c', 'd', 'a']);
});
test('moves backward', () => {
expect(ids(move(base, 3, 1))).toEqual(['a', 'd', 'b', 'c']);
});
test('no-op when dropping into its own slot', () => {
expect(ids(move(base, 1, 1))).toEqual(['a', 'b', 'c', 'd']);
expect(ids(move(base, 1, 2))).toEqual(['a', 'b', 'c', 'd']);
});
});
describe('by-id ops (index-stale-safe)', () => {
const base = [A('a'), A('b'), A('c'), A('d')];
test('moveById targets the slot; missing id is a no-op', () => {
expect(ids(moveById(base, 'a', 2))).toEqual(['b', 'a', 'c', 'd']);
expect(moveById(base, 'zzz', 2)).toBe(base);
});
test('moveByIdDir nudges left/right by one and clamps at the ends', () => {
expect(ids(moveByIdDir(base, 'c', -1))).toEqual(['a', 'c', 'b', 'd']);
expect(ids(moveByIdDir(base, 'b', 1))).toEqual(['a', 'c', 'b', 'd']);
expect(ids(moveByIdDir(base, 'a', -1))).toEqual(['a', 'b', 'c', 'd']); // already first
expect(ids(moveByIdDir(base, 'd', 1))).toEqual(['a', 'b', 'c', 'd']); // already last
});
test('removeById / setSpeechTextById resolve by id', () => {
expect(ids(removeById(base, 'c'))).toEqual(['a', 'b', 'd']);
expect(removeById(base, 'zzz')).toBe(base);
const xs = [A('s', 'speech')];
expect((setSpeechTextById(xs, 's', 'hi')[0] as { text?: string }).text).toBe('hi');
expect(setSpeechTextById(xs, 'zzz', 'no')).toBe(xs);
});
});
describe('discussion ops (append-only, terminal, at most one)', () => {
const disc = (id: string, extra: Record<string, unknown> = {}): Action =>
({ id, type: 'discussion', topic: '', ...extra }) as unknown as Action;
test('makeDiscussion carries an empty topic', () => {
expect(makeDiscussion('d')).toEqual({ id: 'd', type: 'discussion', topic: '' });
});
test('appendDiscussion adds a discussion at the very end', () => {
const base = [A('a'), A('b')];
const out = appendDiscussion(base, 'd');
expect(ids(out)).toEqual(['a', 'b', 'd']);
expect(out[2]).toEqual({ id: 'd', type: 'discussion', topic: '' });
});
test('appendDiscussion is a no-op when one already exists (at most one)', () => {
const base = [A('a'), disc('d', { topic: 'x' })];
expect(appendDiscussion(base, 'd2')).toBe(base);
});
test('hasDiscussion / discussionIndex locate the discussion', () => {
const base = [A('a'), disc('d')];
expect(hasDiscussion(base)).toBe(true);
expect(discussionIndex(base)).toBe(1);
expect(hasDiscussion([A('a')])).toBe(false);
expect(discussionIndex([A('a')])).toBe(-1);
});
test('clampInsertSlot caps inserts before the discussion (keeps it terminal)', () => {
const base = [A('a'), A('b'), disc('d')];
expect(clampInsertSlot(base, 3)).toBe(2); // can't land after the discussion
expect(clampInsertSlot(base, 2)).toBe(2); // right before it is the cap
expect(clampInsertSlot(base, 1)).toBe(1); // earlier slots untouched
expect(clampInsertSlot([A('a'), A('b')], 2)).toBe(2); // no discussion → unchanged
});
test('setDiscussionTopicById sets topic; missing id / non-discussion are no-ops', () => {
const xs = [disc('d', { topic: 'old' }), A('a')];
expect((setDiscussionTopicById(xs, 'd', 'new')[0] as { topic?: string }).topic).toBe('new');
expect(setDiscussionTopicById(xs, 'zzz', 'x')).toBe(xs);
expect(setDiscussionTopicById(xs, 'a', 'x')).toBe(xs); // 'a' is not a discussion
});
test('setDiscussionPromptById sets prompt; empty string clears it', () => {
const xs = [disc('d')];
const withPrompt = setDiscussionPromptById(xs, 'd', 'guide');
expect((withPrompt[0] as { prompt?: string }).prompt).toBe('guide');
expect(
(setDiscussionPromptById(withPrompt, 'd', '')[0] as { prompt?: string }).prompt,
).toBeUndefined();
expect(setDiscussionPromptById(xs, 'missing', 'x')).toBe(xs);
});
test('setDiscussionAgentById sets agentId; empty string clears it', () => {
const xs = [disc('d')];
const withAgent = setDiscussionAgentById(xs, 'd', 'agent_1');
expect((withAgent[0] as { agentId?: string }).agentId).toBe('agent_1');
expect(
(setDiscussionAgentById(withAgent, 'd', '')[0] as { agentId?: string }).agentId,
).toBeUndefined();
expect(setDiscussionAgentById(xs, 'missing', 'x')).toBe(xs);
});
});
describe('setSpeechText / setElementId', () => {
test('setSpeechText only edits speech actions', () => {
const xs = [A('a', 'speech'), A('b', 'spotlight')];
expect((setSpeechText(xs, 0, 'hi')[0] as { text?: string }).text).toBe('hi');
expect(setSpeechText(xs, 1, 'no')).toBe(xs); // unchanged reference (no-op)
});
test('setElementId only targets element-bound cues, not speech', () => {
const xs = [A('a', 'spotlight'), A('b', 'speech')];
expect((setElementId(xs, 0, 'el_1')[0] as { elementId?: string }).elementId).toBe('el_1');
expect(setElementId(xs, 1, 'el_x')).toBe(xs); // no-op: speech is not element-bound
});
test('setElementIdById / setAudioIdById target by id (index-stale-safe)', () => {
const xs = [A('a', 'spotlight'), A('b', 'speech')];
expect((setElementIdById(xs, 'a', 'el_1')[0] as { elementId?: string }).elementId).toBe('el_1');
expect(setElementIdById(xs, 'missing', 'x')).toBe(xs);
expect((setAudioIdById(xs, 'b', 'tts_b')[1] as { audioId?: string }).audioId).toBe('tts_b');
expect(setAudioIdById(xs, 'a', 'tts_a')).toBe(xs); // 'a' is not speech → no-op
});
test('setAudioId only stamps speech actions', () => {
const xs = [A('a', 'speech'), A('b', 'spotlight')];
expect((setAudioId(xs, 0, 'tts_a')[0] as { audioId?: string }).audioId).toBe('tts_a');
expect(setAudioId(xs, 1, 'tts_b')).toBe(xs); // no-op for non-speech
});
test('setSpeechTextClearAudioById sets text and drops stale audio fields', () => {
const xs: Action[] = [
{ id: 'a', type: 'speech', text: 'old', audioId: 'tts_a', audioUrl: 'blob:x' } as Action,
A('b', 'spotlight'),
];
const out = setSpeechTextClearAudioById(xs, 'a', 'new') as Array<{
text?: string;
audioId?: string;
audioUrl?: string;
}>;
expect(out[0].text).toBe('new');
expect(out[0].audioId).toBeUndefined();
expect(out[0].audioUrl).toBeUndefined();
// index-stale-safe + type guard: missing id and non-speech are no-ops
expect(setSpeechTextClearAudioById(xs, 'missing', 'x')).toBe(xs);
expect(setSpeechTextClearAudioById(xs, 'b', 'x')).toBe(xs);
});
});