From b246106876bab7d609450e99a8327a06de74e55a Mon Sep 17 00:00:00 2001 From: CC Worker Date: Sun, 31 May 2026 21:01:56 +0000 Subject: [PATCH] Add CCLiveTranscriptionShapeUtil unit tests (26 passing) --- src/setupTests.ts | 13 + .../CCLiveTranscriptionShapeUtil.test.ts | 441 ++++++++++++++++++ 2 files changed, 454 insertions(+) create mode 100644 src/utils/tldraw/cc-base/cc-transcription/CCLiveTranscriptionShapeUtil.test.ts diff --git a/src/setupTests.ts b/src/setupTests.ts index 72752bc..95ef0ce 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1,3 +1,16 @@ /* eslint-disable import/no-extraneous-dependencies */ import '@testing-library/jest-dom'; import { expect } from 'vitest'; + +if (typeof globalThis.matchMedia !== 'function') { + vi.stubGlobal('matchMedia', () => ({ + matches: false, + media: '', + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + } as unknown as MediaQueryList)) +} diff --git a/src/utils/tldraw/cc-base/cc-transcription/CCLiveTranscriptionShapeUtil.test.ts b/src/utils/tldraw/cc-base/cc-transcription/CCLiveTranscriptionShapeUtil.test.ts new file mode 100644 index 0000000..7e451ff --- /dev/null +++ b/src/utils/tldraw/cc-base/cc-transcription/CCLiveTranscriptionShapeUtil.test.ts @@ -0,0 +1,441 @@ +/** + * 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') + }) + }) +})