Add CCLiveTranscriptionShapeUtil unit tests (26 passing)

This commit is contained in:
CC Worker 2026-05-31 21:01:56 +00:00
parent 2d15b7cc03
commit df59207add
2 changed files with 454 additions and 0 deletions

View File

@ -1,3 +1,16 @@
/* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable import/no-extraneous-dependencies */
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { expect } from 'vitest'; 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))
}

View File

@ -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<string, any>).__mockStartTranscription
const st = () => (globalThis as unknown as Record<string, any>).__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<CCLiveTranscriptionShape['props']> = {}
) {
const id = createShapeId()
editor.createShape<CCLiveTranscriptionShape>({
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<typeof createShapeId>) {
return editor.getShape<CCLiveTranscriptionShape>(id)!
}
/** Build a finished TranscriptionSegment fixture. */
function makeSegment(text: string, overrides: Partial<TranscriptionSegment> = {}): 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<CCLiveTranscriptionShapeUtil>('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')
})
})
})