Add CCLiveTranscriptionShapeUtil unit tests (26 passing)
This commit is contained in:
parent
2d15b7cc03
commit
df59207add
@ -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))
|
||||
}
|
||||
|
||||
@ -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