Add CCLiveTranscriptionShapeUtil unit tests (26 passing)
This commit is contained in:
parent
bf592886c6
commit
b246106876
@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
x
Reference in New Issue
Block a user