summaryrefslogtreecommitdiff
path: root/apps/web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/package.json9
-rw-r--r--apps/web/src/components/editor/timeline/__tests__/TimelineToolbar.test.tsx217
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx4
-rw-r--r--apps/web/src/components/editor/timeline/timeline-playhead.tsx2
-rw-r--r--apps/web/src/hooks/__tests__/use-keyboard-shortcuts-help.test.ts46
-rw-r--r--apps/web/src/hooks/use-keyboard-shortcuts-help.ts2
-rw-r--r--apps/web/src/stores/__tests__/keybindings-store.test.ts65
-rw-r--r--apps/web/src/stores/keybindings-store.ts2
-rw-r--r--apps/web/src/test/setup.ts8
-rw-r--r--apps/web/vitest.config.ts15
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