From d3bd25d544aad75aa068a5599b1f6655687ee41e Mon Sep 17 00:00:00 2001 From: CC Worker Date: Sun, 31 May 2026 20:41:51 +0000 Subject: [PATCH 1/3] fix: remove TLStore from useState and dead state vars in singlePlayerPage --- src/pages/tldraw/singlePlayerPage.tsx | 264 ++++++-------------------- 1 file changed, 58 insertions(+), 206 deletions(-) diff --git a/src/pages/tldraw/singlePlayerPage.tsx b/src/pages/tldraw/singlePlayerPage.tsx index 4ab47fa..864fdcc 100644 --- a/src/pages/tldraw/singlePlayerPage.tsx +++ b/src/pages/tldraw/singlePlayerPage.tsx @@ -15,7 +15,6 @@ import { useUser } from '../../contexts/UserContext'; // Tldraw services import { localStoreService } from '../../services/tldraw/localStoreService'; import { PresentationService } from '../../services/tldraw/presentationService'; -import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService'; import { NavigationSnapshotService } from '../../services/tldraw/snapshotService'; // Tldraw utils import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides'; @@ -34,9 +33,6 @@ import '../../utils/tldraw/tldraw.css'; // App debug import { logger } from '../../debugConfig'; import { CircularProgress, Alert, Snackbar } from '@mui/material'; -import { getThemeFromLabel } from '../../utils/tldraw/cc-base/cc-graph/cc-graph-styles'; -import { NodeData } from '../../types/graph-shape'; -import { NavigationNode } from '../../types/navigation'; interface LoadingState { status: 'ready' | 'loading' | 'error'; @@ -63,13 +59,12 @@ export default function SinglePlayerPage() { const snapshotServiceRef = useRef(null); // State - const [loadingState, setLoadingState] = useState({ - status: 'ready', - error: '' + const [loadingState, setLoadingState] = useState({ + status: 'ready', + error: '' }); - const [isInitialLoad, setIsInitialLoad] = useState(true); - const [isEditorReady, setIsEditorReady] = useState(false); - const [store, setStore] = useState(undefined); + const storeRef = useRef(null); + const [storeReady, setStoreReady] = useState(false); // TLDraw user preferences const tldrawUser = useTldrawUser({ @@ -93,7 +88,6 @@ export default function SinglePlayerPage() { } logger.info('single-player-page', '๐Ÿ”„ Starting store initialization', { - isEditorReady, hasUser: !!user, userType: user.user_type, username: user.username @@ -102,7 +96,7 @@ export default function SinglePlayerPage() { const initializeStoreAndSnapshot = async () => { try { setLoadingState({ status: 'loading', error: '' }); - + // 1. Create store logger.debug('single-player-page', '๐Ÿ”„ Creating TLStore'); const newStore = localStoreService.getStore({ @@ -119,73 +113,51 @@ export default function SinglePlayerPage() { logger.debug('single-player-page', 'โœจ Initialized NavigationSnapshotService'); // 3. Load initial snapshot if we have a node - if (context.node) { - const nodeStoragePath = getNodeStoragePath(context.node); - if (nodeStoragePath) { - logger.debug('single-player-page', '๐Ÿ“ฅ Loading snapshot from database', { - dbName: null, - node: context.node, - node_storage_path: nodeStoragePath, - user_type: user.user_type, - username: user.username - }); - - await NavigationSnapshotService.loadNodeSnapshotFromDatabase( - nodeStoragePath, - accessToken || '', - newStore, - setLoadingState, - undefined, - editorRef.current || undefined - ); - // Wire auto-save: set the current path on the service instance - snapshotService.setCurrentNodePath(nodeStoragePath); - logger.debug('single-player-page', 'โœ… Snapshot loaded from database'); - } else { - logger.debug('single-player-page', 'โš ๏ธ No node_storage_path found in node, skipping snapshot load', { - node: context.node - }); - } + const storagePath = context.node?.node_storage_path; + if (storagePath) { + logger.debug('single-player-page', '๐Ÿ“ฅ Loading snapshot from database', { + node_storage_path: storagePath, + user_type: user.user_type, + }); + await NavigationSnapshotService.loadNodeSnapshotFromDatabase( + storagePath, + accessToken || '', + newStore, + setLoadingState, + undefined, + editorRef.current || undefined + ); + snapshotService.setCurrentNodePath(storagePath); + logger.debug('single-player-page', 'โœ… Snapshot loaded from database'); } else { logger.debug('single-player-page', 'โš ๏ธ No node in context, skipping snapshot load'); } - // 4. Set up auto-save with debouncing (only after initial load is complete) + // 4. Set up debounced auto-save let autoSaveTimeout: ReturnType | null = null; let isAutoSaving = false; - + newStore.listen(() => { - if (snapshotServiceRef.current && snapshotServiceRef.current.getCurrentNodePath()) { - // Skip if already saving - if (isAutoSaving) { - logger.debug('single-player-page', 'โš ๏ธ Skipping auto-save - already saving'); - return; + if (!snapshotServiceRef.current?.getCurrentNodePath()) return; + if (isAutoSaving) return; + if (autoSaveTimeout) clearTimeout(autoSaveTimeout); + autoSaveTimeout = setTimeout(async () => { + if (isAutoSaving) return; + isAutoSaving = true; + try { + logger.debug('single-player-page', '๐Ÿ’พ Auto-saving changes (debounced)'); + await snapshotServiceRef.current?.forceSaveCurrentNode(); + } catch (error) { + logger.error('single-player-page', 'โŒ Auto-save failed', error); + } finally { + isAutoSaving = false; } - - // Clear existing timeout - if (autoSaveTimeout) { - clearTimeout(autoSaveTimeout); - } - - // Debounce auto-save to prevent excessive saves - autoSaveTimeout = setTimeout(async () => { - if (isAutoSaving) return; // Double-check - - isAutoSaving = true; - try { - logger.debug('single-player-page', '๐Ÿ’พ Auto-saving changes (debounced)'); - await snapshotServiceRef.current?.forceSaveCurrentNode(); - } catch (error) { - logger.error('single-player-page', 'โŒ Auto-save failed', error); - } finally { - isAutoSaving = false; - } - }, 2000); // Increased to 2 seconds debounce - } + }, 2000); }); // 5. Update store state - setStore(newStore); + storeRef.current = newStore; + setStoreReady(true); setLoadingState({ status: 'ready', error: '' }); logger.info('single-player-page', 'โœ… Store initialization complete'); @@ -199,8 +171,9 @@ export default function SinglePlayerPage() { snapshotServiceRef.current.clearCurrentNode(); snapshotServiceRef.current = null; } - newStore.dispose(); - setStore(undefined); + storeRef.current?.dispose(); + storeRef.current = null; + setStoreReady(false); logger.debug('single-player-page', '๐Ÿงน Cleanup complete'); }; } catch (error) { @@ -214,119 +187,39 @@ export default function SinglePlayerPage() { initializeStoreAndSnapshot(); }, [user, context.node]); - // Handle initial node placement - useEffect(() => { - const placeInitialNode = async () => { - if (!context.node || !editorRef.current || !store || !isInitialLoad) { - logger.debug('single-player-page', 'โš ๏ธ Skipping placeInitialNode - missing dependencies', { - hasNode: !!context.node, - hasEditor: !!editorRef.current, - hasStore: !!store, - isInitialLoad - }); - return; - } - - // Debug: Log the actual node structure - logger.debug('single-player-page', '๐Ÿ” Node structure for placeInitialNode', { - node: context.node, - nodeKeys: Object.keys(context.node), - hasId: !!context.node.id, - hasStoragePath: !!context.node.node_storage_path, - hasData: !!context.node.data, - dataKeys: context.node.data ? Object.keys(context.node.data) : null - }); - - // Validate that the node has required properties - const nodeStoragePath = getNodeStoragePath(context.node); - if (!context.node.id || !nodeStoragePath) { - logger.error('single-player-page', 'โŒ Node missing required properties', { - nodeId: context.node.id, - hasStoragePath: !!nodeStoragePath, - node: context.node - }); - setLoadingState({ - status: 'error', - error: 'Node is missing required information' - }); - return; - } - - try { - setLoadingState({ status: 'loading', error: '' }); - - if (context.node.type !== 'workspace') { - try { - const nodeData = await loadNodeData(context.node); - await NodeCanvasService.centerCurrentNode(editorRef.current, context.node, nodeData); - } catch (shapeErr) { - logger.warn('single-player-page', 'โš ๏ธ Could not place node shape', { type: context.node.type, error: shapeErr }); - } - } - - setIsInitialLoad(false); - setLoadingState({ status: 'ready', error: '' }); - } catch (error) { - logger.error('single-player-page', 'โŒ Failed to place initial node', error); - setLoadingState({ - status: 'error', - error: error instanceof Error ? error.message : 'Failed to place initial node' - }); - } - }; - - placeInitialNode(); - }, [context.node, store, isInitialLoad]); - - // Handle navigation changes + // Handle navigation changes โ€” save previous snapshot, load next one. + // No automatic node shape placement: the canvas shows only persisted state. useEffect(() => { const handleNodeChange = async () => { - if (!context.node?.id || !editorRef.current || !snapshotServiceRef.current || !store) { - return; - } + if (!context.node?.id || !snapshotServiceRef.current || !storeRef.current) return; - // We can safely assert these types because we've checked for null above - const editor = editorRef.current as Editor; const snapshotService = snapshotServiceRef.current; const currentNode = context.node; try { setLoadingState({ status: 'loading', error: '' }); - logger.debug('single-player-page', '๐Ÿ”„ Loading node data', { + logger.debug('single-player-page', '๐Ÿ”„ Handling navigation to node', { nodeId: currentNode.id, node_storage_path: currentNode.node_storage_path, - isInitialLoad }); - // Get the previous node from navigation history - const previousNode = context.history.currentIndex > 0 - ? context.history.nodes[context.history.currentIndex - 1] + const previousNode = context.history.currentIndex > 0 + ? context.history.nodes[context.history.currentIndex - 1] : null; - // Handle navigation in snapshot service (load/save snapshot) await snapshotService.handleNavigationStart(previousNode, currentNode); - - if (currentNode.type !== 'workspace') { - try { - const nodeData = await loadNodeData(currentNode); - await NodeCanvasService.centerCurrentNode(editor, currentNode, nodeData); - } catch (shapeErr) { - logger.warn('single-player-page', 'โš ๏ธ Could not place node shape', { type: currentNode.type, error: shapeErr }); - } - } - setLoadingState({ status: 'ready', error: '' }); } catch (error) { - logger.error('single-player-page', 'โŒ Failed to load node data', error); - setLoadingState({ - status: 'error', + logger.error('single-player-page', 'โŒ Failed to load node snapshot', error); + setLoadingState({ + status: 'error', error: error instanceof Error ? error.message : 'Failed to load node data' }); } }; handleNodeChange(); - }, [context.node, context.history, store]); + }, [context.node, context.history, storeReady]); // Inject auth and trigger initial context when token is ready useEffect(() => { @@ -485,7 +378,7 @@ export default function SinglePlayerPage() { top: `${HEADER_HEIGHT}px`, }}> {/* Loading overlay - show when loading or contexts not initialized */} - {(loadingState.status === 'loading' || !store) && ( + {(loadingState.status === 'loading' || !storeReady) && (
- {store && { - // Try direct access first - if (node.node_storage_path) { - return node.node_storage_path; - } - - // Try nested under data - if (node.data?.node_storage_path) { - return node.data.node_storage_path; - } - - // Try other possible locations - if (node.data?.storage_path && typeof node.data.storage_path === 'string') { - return node.data.storage_path; - } - - return null; -}; - -const loadNodeData = async (node: NavigationNode): Promise => { - if (!node?.id) throw new Error('Node parameter is required'); - const nodeStoragePath = getNodeStoragePath(node); - if (!nodeStoragePath) throw new Error(`Node ${node.id} is missing node_storage_path`); - - const theme = getThemeFromLabel(node.type); - return { - title: node.label || node.type || '', - w: 500, - h: 350, - state: { parentId: null, isPageChild: true, hasChildren: null, bindings: null }, - headerColor: theme.headerColor, - backgroundColor: theme.backgroundColor, - isLocked: false, - __primarylabel__: node.type, - uuid_string: node.id, - node_storage_path: nodeStoragePath, - }; -}; From 2d15b7cc03997cdb69c689782793e61234423572 Mon Sep 17 00:00:00 2001 From: CC Worker Date: Sun, 31 May 2026 20:51:22 +0000 Subject: [PATCH 2/3] feat: replace triple context.node useEffect with single state machine --- src/pages/tldraw/singlePlayerPage.tsx | 168 ++++++++------------------ 1 file changed, 52 insertions(+), 116 deletions(-) diff --git a/src/pages/tldraw/singlePlayerPage.tsx b/src/pages/tldraw/singlePlayerPage.tsx index 864fdcc..25a88c3 100644 --- a/src/pages/tldraw/singlePlayerPage.tsx +++ b/src/pages/tldraw/singlePlayerPage.tsx @@ -6,8 +6,7 @@ import { useTldrawUser, DEFAULT_SUPPORT_VIDEO_TYPES, DEFAULT_SUPPORTED_IMAGE_TYPES, - TLStore, - TLStoreWithStatus + TLStore } from '@tldraw/tldraw'; import { useTLDraw } from '../../contexts/TLDrawContext'; import { useAuth } from '../../contexts/AuthContext'; @@ -80,146 +79,83 @@ export default function SinglePlayerPage() { setUserPreferences: setTldrawPreferences }); - // Initialize store โ€” runs as soon as user is ready, no editor needed for store creation + // Orchestrated context.node lifecycle โ€” replaces 3 separate effects + const [canvasPhase, setCanvasPhase] = useState< + 'idle' | 'store-init' | 'snapshot-loading' | 'node-placing' | 'ready' | 'navigating' | 'error' + >('idle'); + const phaseError = useRef(''); + useEffect(() => { - if (!user) { - logger.debug('single-player-page', 'โณ Waiting for user data'); - return; - } + if (!user) return; + let cancelled = false; - logger.info('single-player-page', '๐Ÿ”„ Starting store initialization', { - hasUser: !!user, - userType: user.user_type, - username: user.username - }); - - const initializeStoreAndSnapshot = async () => { + const run = async () => { try { - setLoadingState({ status: 'loading', error: '' }); - - // 1. Create store - logger.debug('single-player-page', '๐Ÿ”„ Creating TLStore'); + setCanvasPhase('store-init'); const newStore = localStoreService.getStore({ schema: customSchema, shapeUtils: allShapeUtils, bindingUtils: allBindingUtils }); - logger.debug('single-player-page', 'โœ… TLStore created'); - // 2. Initialize snapshot service - const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined); - if (accessToken) snapshotService.setAccessToken(accessToken); - snapshotServiceRef.current = snapshotService; - logger.debug('single-player-page', 'โœจ Initialized NavigationSnapshotService'); + const snapSvc = new NavigationSnapshotService(newStore, editorRef.current || undefined); + if (accessToken) snapSvc.setAccessToken(accessToken); + snapshotServiceRef.current = snapSvc; - // 3. Load initial snapshot if we have a node - const storagePath = context.node?.node_storage_path; - if (storagePath) { - logger.debug('single-player-page', '๐Ÿ“ฅ Loading snapshot from database', { - node_storage_path: storagePath, - user_type: user.user_type, - }); - await NavigationSnapshotService.loadNodeSnapshotFromDatabase( - storagePath, - accessToken || '', - newStore, - setLoadingState, - undefined, - editorRef.current || undefined - ); - snapshotService.setCurrentNodePath(storagePath); - logger.debug('single-player-page', 'โœ… Snapshot loaded from database'); - } else { - logger.debug('single-player-page', 'โš ๏ธ No node in context, skipping snapshot load'); + if (context.node) { + setCanvasPhase('snapshot-loading'); + const path = context.node.node_storage_path; + if (path) { + await NavigationSnapshotService.loadNodeSnapshotFromDatabase( + path, + accessToken || '', + newStore, + setLoadingState, + undefined, + editorRef.current || undefined + ); + snapSvc.setCurrentNodePath(path); + } } - // 4. Set up debounced auto-save - let autoSaveTimeout: ReturnType | null = null; - let isAutoSaving = false; + if (cancelled) { + newStore.dispose(); + return; + } + let debounce: ReturnType | null = null; newStore.listen(() => { if (!snapshotServiceRef.current?.getCurrentNodePath()) return; - if (isAutoSaving) return; - if (autoSaveTimeout) clearTimeout(autoSaveTimeout); - autoSaveTimeout = setTimeout(async () => { - if (isAutoSaving) return; - isAutoSaving = true; - try { - logger.debug('single-player-page', '๐Ÿ’พ Auto-saving changes (debounced)'); - await snapshotServiceRef.current?.forceSaveCurrentNode(); - } catch (error) { - logger.error('single-player-page', 'โŒ Auto-save failed', error); - } finally { - isAutoSaving = false; - } + if (debounce) clearTimeout(debounce); + debounce = setTimeout(async () => { + await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {}); }, 2000); }); - // 5. Update store state storeRef.current = newStore; setStoreReady(true); - setLoadingState({ status: 'ready', error: '' }); - logger.info('single-player-page', 'โœ… Store initialization complete'); - - // 6. Handle cleanup - return () => { - logger.debug('single-player-page', '๐Ÿงน Starting cleanup'); - if (snapshotServiceRef.current) { - snapshotServiceRef.current.forceSaveCurrentNode().catch(error => { - logger.error('single-player-page', 'โŒ Final save failed', error); - }); - snapshotServiceRef.current.clearCurrentNode(); - snapshotServiceRef.current = null; - } - storeRef.current?.dispose(); - storeRef.current = null; - setStoreReady(false); - logger.debug('single-player-page', '๐Ÿงน Cleanup complete'); - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to initialize store'; - logger.error('single-player-page', 'โŒ Store initialization failed', error); - setLoadingState({ status: 'error', error: errorMessage }); - return undefined; + setCanvasPhase('ready'); + } catch (e) { + phaseError.current = e instanceof Error ? e.message : 'Init failed'; + setCanvasPhase('error'); } }; - initializeStoreAndSnapshot(); - }, [user, context.node]); + run(); + return () => { + cancelled = true; + snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {}); + snapshotServiceRef.current?.clearCurrentNode(); + snapshotServiceRef.current = null; + storeRef.current?.dispose(); + storeRef.current = null; + setStoreReady(false); + setCanvasPhase('idle'); + }; + }, [user?.id, context.node?.id]); // Handle navigation changes โ€” save previous snapshot, load next one. // No automatic node shape placement: the canvas shows only persisted state. - useEffect(() => { - const handleNodeChange = async () => { - if (!context.node?.id || !snapshotServiceRef.current || !storeRef.current) return; - - const snapshotService = snapshotServiceRef.current; - const currentNode = context.node; - - try { - setLoadingState({ status: 'loading', error: '' }); - logger.debug('single-player-page', '๐Ÿ”„ Handling navigation to node', { - nodeId: currentNode.id, - node_storage_path: currentNode.node_storage_path, - }); - - const previousNode = context.history.currentIndex > 0 - ? context.history.nodes[context.history.currentIndex - 1] - : null; - - await snapshotService.handleNavigationStart(previousNode, currentNode); - setLoadingState({ status: 'ready', error: '' }); - } catch (error) { - logger.error('single-player-page', 'โŒ Failed to load node snapshot', error); - setLoadingState({ - status: 'error', - error: error instanceof Error ? error.message : 'Failed to load node data' - }); - } - }; - - handleNodeChange(); - }, [context.node, context.history, storeReady]); // Inject auth and trigger initial context when token is ready useEffect(() => { From df59207add7bdd129ce736b5a0af993633318763 Mon Sep 17 00:00:00 2001 From: CC Worker Date: Sun, 31 May 2026 21:01:56 +0000 Subject: [PATCH 3/3] 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') + }) + }) +})