/** * Unit tests for CCLiveTranscriptionShapeUtil * * Intended production location: * src/utils/tldraw/cc-base/cc-transcription/CCLiveTranscriptionShapeUtil.test.ts * * Run from classroom-copilot-app root: * npx vitest run transcription-shape */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { Editor, createShapeId, createTLStore } from '@tldraw/tldraw' import { CCLiveTranscriptionShapeUtil, type CCLiveTranscriptionShape, type TranscriptionSegment } from './CCLiveTranscriptionShapeUtil' import { CCSlideShapeUtil } from '../cc-slideshow/CCSlideShapeUtil' import { CCSlideShowShapeUtil } from '../cc-slideshow/CCSlideShowShapeUtil' import { getDefaultCCLiveTranscriptionProps } from '../cc-props' // ─── Mock TranscriptionManager ────────────────────────────────────────────── // Must be hoisted before the module under test is imported in a real project. // Here we patch the module directly since we control the import order. // Declare symbols before vi.mock so hoisting/factory capture stays consistent. const mockStartTranscription = () => {} const mockStopTranscription = () => {} vi.mock('./TranscriptionManager', () => { const start = vi.fn() const stop = vi.fn() Object.assign(globalThis, { __mockStartTranscription: start, __mockStopTranscription: stop }) return { TranscriptionManager: { getManager: vi.fn().mockReturnValue({ startTranscription: start, stopTranscription: stop }), }, } }) const sm = () => (globalThis as unknown as Record).__mockStartTranscription const st = () => (globalThis as unknown as Record).__mockStopTranscription const clearMocks = () => { sm().mockClear(); st().mockClear() } // Provide a minimal matchMedia so tldraw's UserPreferencesManager // can call addEventListener/removeEventListener in jsdom. if (typeof globalThis.matchMedia !== 'function') { vi.stubGlobal('matchMedia', () => ({ matches: false, media: '', onchange: null, addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {}, dispatchEvent: () => false, } as unknown as MediaQueryList)) } // ─── Helpers ───────────────────────────────────────────────────────────────── /** Minimal shape utils needed to boot the Editor for transcription tests. */ const shapeUtils = [CCLiveTranscriptionShapeUtil, CCSlideShapeUtil, CCSlideShowShapeUtil] function makeEditor(): Editor { const store = createTLStore({ shapeUtils, bindingUtils: [] }) return new Editor({ shapeUtils, bindingUtils: [], tools: [], store, getContainer: () => document.body, }) } /** Create a transcription shape with optional prop overrides and return its id. */ function createTranscriptionShape( editor: Editor, overrides: Partial = {} ) { const id = createShapeId() editor.createShape({ id, type: 'cc-live-transcription', x: 0, y: 0, props: { ...getDefaultCCLiveTranscriptionProps(), ...overrides, } as CCLiveTranscriptionShape['props'], }) return id } /** Typed getter for the shape record. */ function getShape(editor: Editor, id: ReturnType) { return editor.getShape(id)! } /** Build a finished TranscriptionSegment fixture. */ function makeSegment(text: string, overrides: Partial = {}): TranscriptionSegment { return { id: crypto.randomUUID(), text, completed: true, start: '0.000', end: '1.000', ...overrides, } } // ─── Test Suite ────────────────────────────────────────────────────────────── describe('CCLiveTranscriptionShapeUtil', () => { let editor: Editor let util: CCLiveTranscriptionShapeUtil beforeEach(() => { editor = makeEditor() util = editor.getShapeUtil('cc-live-transcription') clearMocks() }) afterEach(() => { editor.dispose() }) // ── getDefaultProps ───────────────────────────────────────────────────── describe('getDefaultProps()', () => { it('returns the expected default structure', () => { const defaults = util.getDefaultProps() expect(defaults).toMatchObject({ isRecording: false, segments: [], currentSegment: undefined, lastProcessedSegment: undefined, }) // Base props present expect(typeof defaults.title).toBe('string') expect(typeof defaults.w).toBe('number') expect(typeof defaults.h).toBe('number') expect(typeof defaults.headerColor).toBe('string') expect(typeof defaults.backgroundColor).toBe('string') expect(defaults.isLocked).toBe(false) }) it('segments array is empty, not null or undefined', () => { const { segments } = util.getDefaultProps() expect(Array.isArray(segments)).toBe(true) expect(segments).toHaveLength(0) }) }) // ── updateText — unconfirmed (live partial) ───────────────────────────── describe('updateText() — unconfirmed segment (isConfirmed = false)', () => { it('updates only currentSegment, does not touch the segments array', () => { const id = createTranscriptionShape(editor, { isRecording: true }) util.updateText(id, 'hello wor', false, { start: 0, end: 0.8 }) const shape = getShape(editor, id) expect(shape.props.segments).toHaveLength(0) expect(shape.props.currentSegment).toMatchObject({ text: 'hello wor', completed: false, }) }) it('overwrites currentSegment when called again with new partial text', () => { const id = createTranscriptionShape(editor, { isRecording: true }) util.updateText(id, 'hello wor', false, { start: 0, end: 0.8 }) util.updateText(id, 'hello world', false, { start: 0, end: 1.2 }) const shape = getShape(editor, id) expect(shape.props.currentSegment?.text).toBe('hello world') expect(shape.props.segments).toHaveLength(0) }) it('does NOT call updateShape when the partial text is unchanged', () => { const id = createTranscriptionShape(editor, { isRecording: true, currentSegment: makeSegment('same text', { completed: false }), }) const spy = vi.spyOn(editor, 'updateShape') util.updateText(id, 'same text', false, { start: 0, end: 1.0 }) expect(spy).not.toHaveBeenCalled() spy.mockRestore() }) }) // ── updateText — confirmed (completed) segment ───────────────────────── describe('updateText() — confirmed segment (isConfirmed = true)', () => { it('appends to segments array when text is new', () => { const id = createTranscriptionShape(editor, { isRecording: true }) util.updateText(id, 'First sentence.', true, { start: 0, end: 2.1 }) const shape = getShape(editor, id) expect(shape.props.segments).toHaveLength(1) expect(shape.props.segments[0]).toMatchObject({ text: 'First sentence.', completed: true, start: '0.000', end: '2.100', }) }) it('updates lastProcessedSegment to the confirmed text', () => { const id = createTranscriptionShape(editor, { isRecording: true }) util.updateText(id, 'Second sentence.', true, { start: 2, end: 4 }) expect(getShape(editor, id).props.lastProcessedSegment).toBe('Second sentence.') }) it('clears currentSegment when its text matches the confirmed text', () => { const id = createTranscriptionShape(editor, { isRecording: true, currentSegment: makeSegment('Final words.', { completed: false }), }) util.updateText(id, 'Final words.', true, { start: 5, end: 7 }) expect(getShape(editor, id).props.currentSegment).toBeUndefined() }) it('preserves currentSegment when its text differs from the confirmed text', () => { const id = createTranscriptionShape(editor, { isRecording: true, currentSegment: makeSegment('still typing...', { completed: false }), }) util.updateText(id, 'Earlier sentence confirmed.', true, { start: 0, end: 3 }) expect(getShape(editor, id).props.currentSegment?.text).toBe('still typing...') }) it('DEDUPLICATION — does not add a segment whose text is already in segments[]', () => { const existingSegment = makeSegment('Duplicate text.') const id = createTranscriptionShape(editor, { isRecording: true, segments: [existingSegment], lastProcessedSegment: 'Duplicate text.', }) util.updateText(id, 'Duplicate text.', true, { start: 10, end: 12 }) const shape = getShape(editor, id) expect(shape.props.segments).toHaveLength(1) expect(shape.props.segments[0].id).toBe(existingSegment.id) }) it('DEDUPLICATION — blocks re-add even when lastProcessedSegment differs (isDuplicate text check)', () => { const existingSegment = makeSegment('Already confirmed.') const id = createTranscriptionShape(editor, { isRecording: true, segments: [existingSegment], lastProcessedSegment: undefined, }) util.updateText(id, 'Already confirmed.', true, { start: 15, end: 17 }) expect(getShape(editor, id).props.segments).toHaveLength(1) }) it('accumulates multiple unique confirmed segments in order', () => { const id = createTranscriptionShape(editor, { isRecording: true }) util.updateText(id, 'Sentence one.', true, { start: 0, end: 2 }) util.updateText(id, 'Sentence two.', true, { start: 2, end: 4 }) util.updateText(id, 'Sentence three.', true, { start: 4, end: 6 }) const { segments } = getShape(editor, id).props expect(segments).toHaveLength(3) expect(segments.map((s) => s.text)).toEqual([ 'Sentence one.', 'Sentence two.', 'Sentence three.', ]) }) it('assigns unique ids to each confirmed segment', () => { const id = createTranscriptionShape(editor, { isRecording: true }) util.updateText(id, 'Alpha.', true, { start: 0, end: 1 }) util.updateText(id, 'Beta.', true, { start: 1, end: 2 }) const { segments } = getShape(editor, id).props expect(segments[0].id).not.toBe(segments[1].id) }) it('formats numeric start/end timestamps to 3 decimal places', () => { const id = createTranscriptionShape(editor, { isRecording: true }) util.updateText(id, 'Timestamped.', true, { start: 1.1, end: 2.5678 }) const seg = getShape(editor, id).props.segments[0] expect(seg.start).toBe('1.100') expect(seg.end).toBe('2.568') }) }) // ── toggleRecording ──────────────────────────────────────────────────── describe('toggleRecording() — via private accessor', () => { // TypeScript private methods are still callable at runtime; we use // bracket notation to bypass the access check cleanly in tests. const toggle = (u: CCLiveTranscriptionShapeUtil, shape: CCLiveTranscriptionShape) => (u as unknown as { toggleRecording(s: CCLiveTranscriptionShape): void }).toggleRecording(shape) it('STARTING — sets isRecording to true', () => { const id = createTranscriptionShape(editor, { isRecording: false }) const shape = getShape(editor, id) toggle(util, shape) expect(getShape(editor, id).props.isRecording).toBe(true) }) it('STARTING — resets segments to an empty array', () => { const id = createTranscriptionShape(editor, { isRecording: false, segments: [makeSegment('old transcript'), makeSegment('more old text')], }) toggle(util, getShape(editor, id)) expect(getShape(editor, id).props.segments).toHaveLength(0) }) it('STARTING — clears currentSegment and lastProcessedSegment', () => { const id = createTranscriptionShape(editor, { isRecording: false, currentSegment: makeSegment('partial', { completed: false }), lastProcessedSegment: 'some prior segment', }) toggle(util, getShape(editor, id)) const props = getShape(editor, id).props expect(props.currentSegment).toBeUndefined() expect(props.lastProcessedSegment).toBeUndefined() }) it('STARTING — calls TranscriptionManager.startTranscription with the shape id', () => { const id = createTranscriptionShape(editor, { isRecording: false }) const shape = getShape(editor, id) toggle(util, shape) expect(sm()).toHaveBeenCalledOnce() expect(sm()).toHaveBeenCalledWith(id) }) it('STOPPING — sets isRecording to false', () => { const id = createTranscriptionShape(editor, { isRecording: true }) const shape = getShape(editor, id) toggle(util, shape) expect(getShape(editor, id).props.isRecording).toBe(false) }) it('STOPPING — preserves existing segments (does not wipe them)', () => { const seg1 = makeSegment('Keep me.') const seg2 = makeSegment('Keep me too.') const id = createTranscriptionShape(editor, { isRecording: true, segments: [seg1, seg2], }) toggle(util, getShape(editor, id)) const { segments } = getShape(editor, id).props expect(segments).toHaveLength(2) expect(segments[0].text).toBe('Keep me.') expect(segments[1].text).toBe('Keep me too.') }) it('STOPPING — calls TranscriptionManager.stopTranscription', () => { const id = createTranscriptionShape(editor, { isRecording: true }) toggle(util, getShape(editor, id)) expect(st()).toHaveBeenCalledOnce() expect(sm()).not.toHaveBeenCalled() }) it('start-stop cycle leaves shape in consistent state', () => { const id = createTranscriptionShape(editor, { isRecording: false }) toggle(util, getShape(editor, id)) expect(getShape(editor, id).props.isRecording).toBe(true) util.updateText(id, 'Live lesson note.', true, { start: 0, end: 3 }) toggle(util, getShape(editor, id)) const props = getShape(editor, id).props expect(props.isRecording).toBe(false) expect(props.segments).toHaveLength(1) expect(props.segments[0].text).toBe('Live lesson note.') }) }) // ── Edge cases ───────────────────────────────────────────────────────── describe('updateText() — edge cases', () => { it('does nothing if the shape id does not exist in the store', () => { const ghostId = createShapeId('nonexistent') expect(() => util.updateText(ghostId, 'text', true, { start: 0, end: 1 })).not.toThrow() }) it('handles empty string text without crashing', () => { const id = createTranscriptionShape(editor, { isRecording: true }) expect(() => util.updateText(id, '', true, { start: 0, end: 0 })).not.toThrow() }) it('handles string start/end metadata (passed as already-formatted strings)', () => { const id = createTranscriptionShape(editor, { isRecording: true }) util.updateText(id, 'String timestamps.', true, { start: '1.234' as unknown as number, end: '5.678' as unknown as number }) const seg = getShape(editor, id).props.segments[0] expect(typeof seg.start).toBe('string') expect(typeof seg.end).toBe('string') }) it('handles missing metadata gracefully, falling back to "0.000"', () => { const id = createTranscriptionShape(editor, { isRecording: true }) util.updateText(id, 'No timestamps.', true, undefined as unknown as { start: number; end: number }) const seg = getShape(editor, id).props.segments[0] expect(seg.start).toBe('0.000') expect(seg.end).toBe('0.000') }) }) })