merge: canvas core stability — TLStore useRef + single-effect state machine (P0a/P0b)
This commit is contained in:
commit
8c1623256b
@ -6,8 +6,7 @@ import {
|
|||||||
useTldrawUser,
|
useTldrawUser,
|
||||||
DEFAULT_SUPPORT_VIDEO_TYPES,
|
DEFAULT_SUPPORT_VIDEO_TYPES,
|
||||||
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
DEFAULT_SUPPORTED_IMAGE_TYPES,
|
||||||
TLStore,
|
TLStore
|
||||||
TLStoreWithStatus
|
|
||||||
} from '@tldraw/tldraw';
|
} from '@tldraw/tldraw';
|
||||||
import { useTLDraw } from '../../contexts/TLDrawContext';
|
import { useTLDraw } from '../../contexts/TLDrawContext';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
@ -15,7 +14,6 @@ import { useUser } from '../../contexts/UserContext';
|
|||||||
// Tldraw services
|
// Tldraw services
|
||||||
import { localStoreService } from '../../services/tldraw/localStoreService';
|
import { localStoreService } from '../../services/tldraw/localStoreService';
|
||||||
import { PresentationService } from '../../services/tldraw/presentationService';
|
import { PresentationService } from '../../services/tldraw/presentationService';
|
||||||
import { NodeCanvasService } from '../../services/tldraw/nodeCanvasService';
|
|
||||||
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
|
import { NavigationSnapshotService } from '../../services/tldraw/snapshotService';
|
||||||
// Tldraw utils
|
// Tldraw utils
|
||||||
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
|
import { getUiOverrides, getUiComponents } from '../../utils/tldraw/ui-overrides';
|
||||||
@ -34,9 +32,6 @@ import '../../utils/tldraw/tldraw.css';
|
|||||||
// App debug
|
// App debug
|
||||||
import { logger } from '../../debugConfig';
|
import { logger } from '../../debugConfig';
|
||||||
import { CircularProgress, Alert, Snackbar } from '@mui/material';
|
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 {
|
interface LoadingState {
|
||||||
status: 'ready' | 'loading' | 'error';
|
status: 'ready' | 'loading' | 'error';
|
||||||
@ -63,13 +58,12 @@ export default function SinglePlayerPage() {
|
|||||||
const snapshotServiceRef = useRef<NavigationSnapshotService | null>(null);
|
const snapshotServiceRef = useRef<NavigationSnapshotService | null>(null);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [loadingState, setLoadingState] = useState<LoadingState>({
|
const [loadingState, setLoadingState] = useState<LoadingState>({
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
error: ''
|
error: ''
|
||||||
});
|
});
|
||||||
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
const storeRef = useRef<TLStore | null>(null);
|
||||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
const [storeReady, setStoreReady] = useState(false);
|
||||||
const [store, setStore] = useState<TLStore | TLStoreWithStatus | undefined>(undefined);
|
|
||||||
|
|
||||||
// TLDraw user preferences
|
// TLDraw user preferences
|
||||||
const tldrawUser = useTldrawUser({
|
const tldrawUser = useTldrawUser({
|
||||||
@ -85,248 +79,83 @@ export default function SinglePlayerPage() {
|
|||||||
setUserPreferences: setTldrawPreferences
|
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<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) {
|
if (!user) return;
|
||||||
logger.debug('single-player-page', '⏳ Waiting for user data');
|
let cancelled = false;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info('single-player-page', '🔄 Starting store initialization', {
|
const run = async () => {
|
||||||
isEditorReady,
|
|
||||||
hasUser: !!user,
|
|
||||||
userType: user.user_type,
|
|
||||||
username: user.username
|
|
||||||
});
|
|
||||||
|
|
||||||
const initializeStoreAndSnapshot = async () => {
|
|
||||||
try {
|
try {
|
||||||
setLoadingState({ status: 'loading', error: '' });
|
setCanvasPhase('store-init');
|
||||||
|
|
||||||
// 1. Create store
|
|
||||||
logger.debug('single-player-page', '🔄 Creating TLStore');
|
|
||||||
const newStore = localStoreService.getStore({
|
const newStore = localStoreService.getStore({
|
||||||
schema: customSchema,
|
schema: customSchema,
|
||||||
shapeUtils: allShapeUtils,
|
shapeUtils: allShapeUtils,
|
||||||
bindingUtils: allBindingUtils
|
bindingUtils: allBindingUtils
|
||||||
});
|
});
|
||||||
logger.debug('single-player-page', '✅ TLStore created');
|
|
||||||
|
|
||||||
// 2. Initialize snapshot service
|
const snapSvc = new NavigationSnapshotService(newStore, editorRef.current || undefined);
|
||||||
const snapshotService = new NavigationSnapshotService(newStore, editorRef.current || undefined);
|
if (accessToken) snapSvc.setAccessToken(accessToken);
|
||||||
if (accessToken) snapshotService.setAccessToken(accessToken);
|
snapshotServiceRef.current = snapSvc;
|
||||||
snapshotServiceRef.current = snapshotService;
|
|
||||||
logger.debug('single-player-page', '✨ Initialized NavigationSnapshotService');
|
|
||||||
|
|
||||||
// 3. Load initial snapshot if we have a node
|
|
||||||
if (context.node) {
|
if (context.node) {
|
||||||
const nodeStoragePath = getNodeStoragePath(context.node);
|
setCanvasPhase('snapshot-loading');
|
||||||
if (nodeStoragePath) {
|
const path = context.node.node_storage_path;
|
||||||
logger.debug('single-player-page', '📥 Loading snapshot from database', {
|
if (path) {
|
||||||
dbName: null,
|
|
||||||
node: context.node,
|
|
||||||
node_storage_path: nodeStoragePath,
|
|
||||||
user_type: user.user_type,
|
|
||||||
username: user.username
|
|
||||||
});
|
|
||||||
|
|
||||||
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
await NavigationSnapshotService.loadNodeSnapshotFromDatabase(
|
||||||
nodeStoragePath,
|
path,
|
||||||
accessToken || '',
|
accessToken || '',
|
||||||
newStore,
|
newStore,
|
||||||
setLoadingState,
|
setLoadingState,
|
||||||
undefined,
|
undefined,
|
||||||
editorRef.current || undefined
|
editorRef.current || undefined
|
||||||
);
|
);
|
||||||
// Wire auto-save: set the current path on the service instance
|
snapSvc.setCurrentNodePath(path);
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} 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)
|
if (cancelled) {
|
||||||
let autoSaveTimeout: ReturnType<typeof setTimeout> | 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 5. Update store state
|
|
||||||
setStore(newStore);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
newStore.dispose();
|
newStore.dispose();
|
||||||
setStore(undefined);
|
return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
let debounce: ReturnType<typeof setTimeout> | null = null;
|
||||||
setLoadingState({ status: 'ready', error: '' });
|
newStore.listen(() => {
|
||||||
} catch (error) {
|
if (!snapshotServiceRef.current?.getCurrentNodePath()) return;
|
||||||
logger.error('single-player-page', '❌ Failed to place initial node', error);
|
if (debounce) clearTimeout(debounce);
|
||||||
setLoadingState({
|
debounce = setTimeout(async () => {
|
||||||
status: 'error',
|
await snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||||
error: error instanceof Error ? error.message : 'Failed to place initial node'
|
}, 2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
storeRef.current = newStore;
|
||||||
|
setStoreReady(true);
|
||||||
|
setCanvasPhase('ready');
|
||||||
|
} catch (e) {
|
||||||
|
phaseError.current = e instanceof Error ? e.message : 'Init failed';
|
||||||
|
setCanvasPhase('error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
placeInitialNode();
|
run();
|
||||||
}, [context.node, store, isInitialLoad]);
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
// Handle navigation changes
|
snapshotServiceRef.current?.forceSaveCurrentNode().catch(() => {});
|
||||||
useEffect(() => {
|
snapshotServiceRef.current?.clearCurrentNode();
|
||||||
const handleNodeChange = async () => {
|
snapshotServiceRef.current = null;
|
||||||
if (!context.node?.id || !editorRef.current || !snapshotServiceRef.current || !store) {
|
storeRef.current?.dispose();
|
||||||
return;
|
storeRef.current = null;
|
||||||
}
|
setStoreReady(false);
|
||||||
|
setCanvasPhase('idle');
|
||||||
// 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', {
|
|
||||||
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]
|
|
||||||
: 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',
|
|
||||||
error: error instanceof Error ? error.message : 'Failed to load node data'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
}, [user?.id, context.node?.id]);
|
||||||
|
|
||||||
handleNodeChange();
|
// Handle navigation changes — save previous snapshot, load next one.
|
||||||
}, [context.node, context.history, store]);
|
// No automatic node shape placement: the canvas shows only persisted state.
|
||||||
|
|
||||||
// Inject auth and trigger initial context when token is ready
|
// Inject auth and trigger initial context when token is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -485,7 +314,7 @@ export default function SinglePlayerPage() {
|
|||||||
top: `${HEADER_HEIGHT}px`,
|
top: `${HEADER_HEIGHT}px`,
|
||||||
}}>
|
}}>
|
||||||
{/* Loading overlay - show when loading or contexts not initialized */}
|
{/* Loading overlay - show when loading or contexts not initialized */}
|
||||||
{(loadingState.status === 'loading' || !store) && (
|
{(loadingState.status === 'loading' || !storeReady) && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
@ -513,9 +342,9 @@ export default function SinglePlayerPage() {
|
|||||||
</Alert>
|
</Alert>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
|
|
||||||
{store && <Tldraw
|
{storeReady && storeRef.current && <Tldraw
|
||||||
user={tldrawUser}
|
user={tldrawUser}
|
||||||
store={store}
|
store={storeRef.current}
|
||||||
tools={singlePlayerTools}
|
tools={singlePlayerTools}
|
||||||
shapeUtils={allShapeUtils}
|
shapeUtils={allShapeUtils}
|
||||||
bindingUtils={allBindingUtils}
|
bindingUtils={allBindingUtils}
|
||||||
@ -541,18 +370,16 @@ export default function SinglePlayerPage() {
|
|||||||
|
|
||||||
editorRef.current = editor;
|
editorRef.current = editor;
|
||||||
logger.debug('single-player-page', '✅ Editor ref set');
|
logger.debug('single-player-page', '✅ Editor ref set');
|
||||||
|
|
||||||
// Update snapshot service with editor reference
|
// Update snapshot service with editor reference
|
||||||
if (snapshotServiceRef.current) {
|
if (snapshotServiceRef.current) {
|
||||||
snapshotServiceRef.current.setEditor(editor);
|
snapshotServiceRef.current.setEditor(editor);
|
||||||
if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken);
|
if (accessToken) snapshotServiceRef.current.setAccessToken(accessToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsEditorReady(true);
|
|
||||||
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
|
logger.info('single-player-page', '✅ Tldraw mounted successfully', {
|
||||||
editorId: editor.store.id,
|
editorId: editor.store.id,
|
||||||
presentationMode,
|
presentationMode,
|
||||||
isEditorReady: true
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('single-player-page', '❌ Error in onMount', error);
|
logger.error('single-player-page', '❌ Error in onMount', error);
|
||||||
@ -563,42 +390,3 @@ export default function SinglePlayerPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to safely extract node_storage_path from different node structures
|
|
||||||
const getNodeStoragePath = (node: NavigationNode): string | null => {
|
|
||||||
// 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<NodeData> => {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|||||||
@ -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