diff options
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/package.json | 9 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline/__tests__/TimelineToolbar.test.tsx | 217 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline/index.tsx | 4 | ||||
| -rw-r--r-- | apps/web/src/components/editor/timeline/timeline-playhead.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/hooks/__tests__/use-keyboard-shortcuts-help.test.ts | 46 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-keyboard-shortcuts-help.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/stores/__tests__/keybindings-store.test.ts | 65 | ||||
| -rw-r--r-- | apps/web/src/stores/keybindings-store.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/test/setup.ts | 8 | ||||
| -rw-r--r-- | apps/web/vitest.config.ts | 15 |
10 files changed, 365 insertions, 5 deletions
diff --git a/apps/web/package.json b/apps/web/package.json index 139c6529..54f0c31f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,8 @@ "dev": "next dev", "build": "next build", "start": "next start", + "test": "vitest", + "test:run": "vitest run", "lint": "biome check src/", "lint:fix": "biome check src/ --write", "format": "biome format src/ --write", @@ -71,6 +73,9 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.16", + "@testing-library/jest-dom": "^6.4.0", + "@testing-library/react": "^15.0.7", + "@testing-library/user-event": "^14.5.2", "@types/bun": "latest", "@types/node": "^24.1.0", "@types/pg": "^8.15.4", @@ -78,9 +83,11 @@ "@types/react-dom": "^18.2.18", "cross-env": "^7.0.3", "drizzle-kit": "^0.31.4", + "jsdom": "^26.0.0", "postcss": "^8", "tailwindcss": "^3.4.1", "tsx": "^4.7.1", - "typescript": "^5.8.3" + "typescript": "^5.8.3", + "vitest": "^2.1.8" } } diff --git a/apps/web/src/components/editor/timeline/__tests__/TimelineToolbar.test.tsx b/apps/web/src/components/editor/timeline/__tests__/TimelineToolbar.test.tsx new file mode 100644 index 00000000..9a8ef666 --- /dev/null +++ b/apps/web/src/components/editor/timeline/__tests__/TimelineToolbar.test.tsx @@ -0,0 +1,217 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { Timeline } from '../index' + +// Mock the stores and hooks +vi.mock('@/stores/timeline-store', () => ({ + useTimelineStore: () => ({ + tracks: [], + addTrack: vi.fn(), + addElementToTrack: vi.fn(), + removeElementFromTrack: vi.fn(), + removeElementFromTrackWithRipple: vi.fn(), + selectedElements: [], + clearSelectedElements: vi.fn(), + splitElement: vi.fn(), + splitAndKeepLeft: vi.fn(), + splitAndKeepRight: vi.fn(), + separateAudio: vi.fn(), + snappingEnabled: false, + toggleSnapping: vi.fn(), + deleteElements: vi.fn(), + duplicateElement: vi.fn(), + selectAll: vi.fn(), + zoomLevel: 1, + setZoomLevel: vi.fn(), + dragState: { isDragging: false, draggedElement: null, dragOffset: { x: 0, y: 0 } }, + getTotalDuration: () => 100, + setSelectedElements: vi.fn(), + toggleTrackMute: vi.fn(), + }) +})) + +vi.mock('@/stores/media-store', () => ({ + useMediaStore: () => ({ + mediaItems: [], + addMediaItem: vi.fn(), + }) +})) + +vi.mock('@/stores/project-store', () => ({ + useProjectStore: () => ({ + activeProject: null, + }) +})) + +const mockSeek = vi.fn() + +vi.mock('@/stores/playback-store', () => ({ + usePlaybackStore: () => ({ + currentTime: 0, + duration: 100, + seek: mockSeek, + setDuration: vi.fn(), + isPlaying: false, + toggle: vi.fn(), + }), +})) + +// Mock the timeline playhead hook +vi.mock('@/hooks/use-timeline-playhead', () => ({ + useTimelinePlayhead: () => ({ + playheadRef: { current: null }, + updatePlayheadPosition: vi.fn(), + onPlayheadDrag: vi.fn(), + }) +})) + +vi.mock('@/hooks/use-editor-actions', () => ({ + useEditorActions: () => ({}) +})) + +vi.mock('@/hooks/use-timeline-context-menu', () => ({ + useTimelineContextMenu: () => ({ + contextMenu: null, + handleContextMenu: vi.fn(), + closeContextMenu: vi.fn(), + }) +})) + +vi.mock('@/hooks/use-timeline-selection', () => ({ + useTimelineSelection: () => ({ + isSelecting: false, + selectionBox: null, + handleMouseDown: vi.fn(), + }) +})) + +vi.mock('@/hooks/use-timeline-drag-drop', () => ({ + useTimelineDragDrop: () => ({ + dragProps: {}, + }) +})) + +// Mock UI components to avoid dependency issues +vi.mock('../../ui/tooltip', () => ({ + Tooltip: ({ children }: any) => children, + TooltipTrigger: ({ children }: any) => children, + TooltipContent: ({ children }: any) => <div>{children}</div>, + TooltipProvider: ({ children }: any) => children, +})) + +vi.mock('../../ui/button', () => ({ + Button: ({ children, onClick, ...props }: any) => ( + <button onClick={onClick} {...props} aria-label={props['aria-label']}> + {children} + </button> + ), +})) + +vi.mock('../../ui/scroll-area', () => ({ + ScrollArea: ({ children }: any) => <div>{children}</div>, +})) + +vi.mock('lucide-react', () => ({ + SkipBack: () => <svg data-testid="skip-back-icon" />, + Play: () => <svg data-testid="play-icon" />, + Pause: () => <svg data-testid="pause-icon" />, + Scissors: () => <svg />, + ArrowLeftToLine: () => <svg />, + ArrowRightToLine: () => <svg />, + Trash2: () => <svg />, + Snowflake: () => <svg />, + Copy: () => <svg />, + SplitSquareHorizontal: () => <svg />, + Video: () => <svg />, + Music: () => <svg />, + TypeIcon: () => <svg />, + Magnet: () => <svg />, + Link: () => <svg />, + ZoomIn: () => <svg />, + ZoomOut: () => <svg />, +})) + +vi.mock('@/constants/timeline', () => ({ + TIMELINE_CONSTANTS: { + DEFAULT_TEXT_DURATION: 5, + } +})) + +vi.mock('@/lib/utils', () => ({ + cn: (...args: any[]) => args.join(' '), + snapTimeToFrame: (time: number) => time, +})) + +// Mock timeline utilities +vi.mock('@/lib/timeline-utils', () => ({ + getTotalDuration: () => 100, + getCurrentSnapPoint: () => null, +})) + +// Mock context menu components +vi.mock('../../ui/context-menu', () => ({ + ContextMenu: ({ children }: any) => children, + ContextMenuTrigger: ({ children }: any) => children, + ContextMenuContent: ({ children }: any) => <div>{children}</div>, + ContextMenuItem: ({ children }: any) => <div>{children}</div>, + ContextMenuSeparator: () => <div />, +})) + +// Mock dialog components +vi.mock('../../ui/dialog', () => ({ + Dialog: ({ children }: any) => children, + DialogContent: ({ children }: any) => <div>{children}</div>, + DialogHeader: ({ children }: any) => <div>{children}</div>, + DialogTitle: ({ children }: any) => <div>{children}</div>, +})) + +describe('Timeline - Return to Start Button', () => { + beforeEach(() => { + mockSeek.mockClear() + }) + + it('should render Return to Start button', () => { + render(<Timeline />) + + // Look for the SkipBack icon which indicates our button + const skipBackIcon = screen.getByTestId('skip-back-icon') + expect(skipBackIcon).toBeInTheDocument() + }) + + it('should call seek(0) when Return to Start button is clicked', () => { + render(<Timeline />) + + // Find the button that contains the SkipBack icon + const skipBackIcon = screen.getByTestId('skip-back-icon') + const button = skipBackIcon.closest('button') + + expect(button).toBeInTheDocument() + fireEvent.click(button!) + + expect(mockSeek).toHaveBeenCalledWith(0) + }) + + it('should have tooltip structure for Return to Start button', () => { + render(<Timeline />) + + // Find the skip back button and verify it's wrapped in tooltip components + const skipBackIcon = screen.getByTestId('skip-back-icon') + const button = skipBackIcon.closest('button') + + expect(button).toBeInTheDocument() + // The button should exist and be clickable, which means the tooltip structure is in place + expect(button).toHaveAttribute('data-state', 'closed') // Tooltip trigger state + }) + + it('should have SkipBack icon in Return to Start button', () => { + render(<Timeline />) + + const skipBackIcon = screen.getByTestId('skip-back-icon') + expect(skipBackIcon).toBeInTheDocument() + + // Verify it's inside a button + const button = skipBackIcon.closest('button') + expect(button).toBeInTheDocument() + }) +})
\ No newline at end of file diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index cf5fd524..0d9660bb 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -1,5 +1,6 @@ "use client"; +import React from "react"; import { ScrollArea } from "../../ui/scroll-area"; import { Button } from "../../ui/button"; import { @@ -984,12 +985,13 @@ function TimelineToolbar({ {isPlaying ? "Pause (Space)" : "Play (Space)"} </TooltipContent> </Tooltip> + {/* Return to Start Button - Seeks timeline to 00:00:00 */} <Tooltip> <TooltipTrigger asChild> <Button variant="text" size="icon" - onClick={() => seek(0)} + onClick={() => seek(0)} // Seek to timeline start position className="mr-2" > <SkipBack className="h-4 w-4" /> diff --git a/apps/web/src/components/editor/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/timeline/timeline-playhead.tsx index b33166c0..dbbd18df 100644 --- a/apps/web/src/components/editor/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/timeline/timeline-playhead.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRef, useState, useEffect } from "react"; +import React, { useRef, useState, useEffect } from "react"; import { TimelineTrack } from "@/types/timeline"; import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; import { useTimelinePlayhead } from "@/hooks/use-timeline-playhead"; diff --git a/apps/web/src/hooks/__tests__/use-keyboard-shortcuts-help.test.ts b/apps/web/src/hooks/__tests__/use-keyboard-shortcuts-help.test.ts new file mode 100644 index 00000000..52c7dcd6 --- /dev/null +++ b/apps/web/src/hooks/__tests__/use-keyboard-shortcuts-help.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { renderHook } from '@testing-library/react' +import { useKeyboardShortcutsHelp } from '../use-keyboard-shortcuts-help' +import { useKeybindingsStore } from '../../stores/keybindings-store' + +describe('useKeyboardShortcutsHelp', () => { + beforeEach(() => { + // Reset store to default state before each test + useKeybindingsStore.getState().resetToDefaults() + }) + + it('should format Enter key correctly in shortcuts', () => { + const { result } = renderHook(() => useKeyboardShortcutsHelp()) + + // Find the goto-start action in shortcuts + const gotoStartShortcut = result.current.shortcuts.find( + shortcut => shortcut.action === 'goto-start' + ) + + expect(gotoStartShortcut).toBeDefined() + expect(gotoStartShortcut?.keys).toContain('Enter') + expect(gotoStartShortcut?.keys).toContain('Home') + }) + + it('should include goto-start action with correct description', () => { + const { result } = renderHook(() => useKeyboardShortcutsHelp()) + + const gotoStartShortcut = result.current.shortcuts.find( + shortcut => shortcut.action === 'goto-start' + ) + + expect(gotoStartShortcut).toBeDefined() + expect(gotoStartShortcut?.description).toBe('Go to timeline start') + expect(gotoStartShortcut?.category).toBe('Navigation') + }) + + it('should have multiple keys for goto-start action', () => { + const { result } = renderHook(() => useKeyboardShortcutsHelp()) + + const gotoStartShortcut = result.current.shortcuts.find( + shortcut => shortcut.action === 'goto-start' + ) + + expect(gotoStartShortcut?.keys.length).toBeGreaterThanOrEqual(2) + }) +})
\ No newline at end of file diff --git a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts index e2cde3b4..98810cb1 100644 --- a/apps/web/src/hooks/use-keyboard-shortcuts-help.ts +++ b/apps/web/src/hooks/use-keyboard-shortcuts-help.ts @@ -77,7 +77,7 @@ const formatKey = (key: string): string => { .replace("down", "↓") .replace("space", "Space") .replace("home", "Home") - .replace("enter", "Enter") + .replace("enter", "Enter") // Format Enter key for display in help dialog .replace("end", "End") .replace("delete", "Delete") .replace("backspace", "Backspace") diff --git a/apps/web/src/stores/__tests__/keybindings-store.test.ts b/apps/web/src/stores/__tests__/keybindings-store.test.ts new file mode 100644 index 00000000..8aa5b94f --- /dev/null +++ b/apps/web/src/stores/__tests__/keybindings-store.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useKeybindingsStore } from '../keybindings-store' + +describe('Keybindings Store', () => { + beforeEach(() => { + // Reset store to default state before each test + useKeybindingsStore.getState().resetToDefaults() + }) + + describe('Default Keybindings', () => { + it('should include Enter key mapping to goto-start action', () => { + const { keybindings } = useKeybindingsStore.getState() + expect(keybindings.enter).toBe('goto-start') + }) + + it('should include Home key mapping to goto-start action', () => { + const { keybindings } = useKeybindingsStore.getState() + expect(keybindings.home).toBe('goto-start') + }) + + it('should have both Enter and Home keys for the same action', () => { + const { keybindings } = useKeybindingsStore.getState() + expect(keybindings.enter).toBe(keybindings.home) + }) + }) + + describe('getKeybindingsForAction', () => { + it('should return both Enter and Home keys for goto-start action', () => { + const { getKeybindingsForAction } = useKeybindingsStore.getState() + const keys = getKeybindingsForAction('goto-start') + + expect(keys).toContain('enter') + expect(keys).toContain('home') + expect(keys.length).toBeGreaterThanOrEqual(2) + }) + }) + + describe('Keyboard Event Parsing', () => { + it('should generate correct keybinding string for Enter key', () => { + const { getKeybindingString } = useKeybindingsStore.getState() + + // Mock KeyboardEvent for Enter key + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', + code: 'Enter' + }) + + const result = getKeybindingString(enterEvent) + expect(result).toBe('enter') + }) + + it('should generate correct keybinding string for Home key', () => { + const { getKeybindingString } = useKeybindingsStore.getState() + + // Mock KeyboardEvent for Home key + const homeEvent = new KeyboardEvent('keydown', { + key: 'Home', + code: 'Home' + }) + + const result = getKeybindingString(homeEvent) + expect(result).toBe('home') + }) + }) +})
\ No newline at end of file diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index 4c11cfde..740a5855 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -17,7 +17,7 @@ export const defaultKeybindings: KeybindingConfig = { "shift+left": "jump-backward", "shift+right": "jump-forward", home: "goto-start", - enter: "goto-start", + enter: "goto-start", // More intuitive alternative to Home key for timeline navigation end: "goto-end", s: "split-element", n: "toggle-snapping", diff --git a/apps/web/src/test/setup.ts b/apps/web/src/test/setup.ts new file mode 100644 index 00000000..e53f09dd --- /dev/null +++ b/apps/web/src/test/setup.ts @@ -0,0 +1,8 @@ +import '@testing-library/jest-dom' + +// Mock ResizeObserver +global.ResizeObserver = class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +}
\ No newline at end of file diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 00000000..cb1133fb --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config' +import { resolve } from 'path' + +export default defineConfig({ + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + globals: true, + }, + resolve: { + alias: { + '@': resolve(__dirname, './src'), + }, + }, +})
\ No newline at end of file |
