Jelajahi Sumber

feat(02-01): add test utilities and expanded MSW handlers

- Create renderWithProviders helper with BrowserRouter wrapper
- Add mock data generators for patterns, playlists, and status
- Expand MSW handlers for all component endpoints (50+ handlers)
- Add pattern, playlist, playback, and table control endpoints
- Create MockWebSocket class for WebSocket testing
- Add browser API mocks (IntersectionObserver, ResizeObserver, canvas)
- Update test setup with browser mocks and data reset
tuanchris 1 Minggu lalu
induk
melakukan
f8f76bcad6

+ 106 - 0
frontend/src/test/mocks/browser.ts

@@ -0,0 +1,106 @@
+import { vi } from 'vitest'
+
+// Mock IntersectionObserver
+export class MockIntersectionObserver {
+  callback: IntersectionObserverCallback
+  elements: Set<Element> = new Set()
+
+  constructor(callback: IntersectionObserverCallback) {
+    this.callback = callback
+  }
+
+  observe(element: Element) {
+    this.elements.add(element)
+    // Immediately trigger as visible for testing
+    this.callback(
+      [{ target: element, isIntersecting: true, intersectionRatio: 1 } as IntersectionObserverEntry],
+      this
+    )
+  }
+
+  unobserve(element: Element) {
+    this.elements.delete(element)
+  }
+
+  disconnect() {
+    this.elements.clear()
+  }
+}
+
+// Mock matchMedia
+export function createMockMatchMedia(matches: boolean = false) {
+  return vi.fn().mockImplementation((query: string) => ({
+    matches,
+    media: query,
+    onchange: null,
+    addListener: vi.fn(),
+    removeListener: vi.fn(),
+    addEventListener: vi.fn(),
+    removeEventListener: vi.fn(),
+    dispatchEvent: vi.fn(),
+  }))
+}
+
+// Mock ResizeObserver
+export class MockResizeObserver {
+  callback: ResizeObserverCallback
+
+  constructor(callback: ResizeObserverCallback) {
+    this.callback = callback
+  }
+
+  observe() {}
+  unobserve() {}
+  disconnect() {}
+}
+
+// Setup all browser mocks
+export function setupBrowserMocks() {
+  vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
+  vi.stubGlobal('ResizeObserver', MockResizeObserver)
+  vi.stubGlobal('matchMedia', createMockMatchMedia())
+
+  // Mock canvas context
+  HTMLCanvasElement.prototype.getContext = vi.fn().mockReturnValue({
+    fillRect: vi.fn(),
+    clearRect: vi.fn(),
+    beginPath: vi.fn(),
+    moveTo: vi.fn(),
+    lineTo: vi.fn(),
+    stroke: vi.fn(),
+    fill: vi.fn(),
+    arc: vi.fn(),
+    drawImage: vi.fn(),
+    save: vi.fn(),
+    restore: vi.fn(),
+    scale: vi.fn(),
+    translate: vi.fn(),
+    rotate: vi.fn(),
+    setTransform: vi.fn(),
+    getImageData: vi.fn().mockReturnValue({ data: new Uint8ClampedArray(4) }),
+    putImageData: vi.fn(),
+    createLinearGradient: vi.fn().mockReturnValue({ addColorStop: vi.fn() }),
+    createRadialGradient: vi.fn().mockReturnValue({ addColorStop: vi.fn() }),
+    measureText: vi.fn().mockReturnValue({ width: 0 }),
+    fillText: vi.fn(),
+    strokeText: vi.fn(),
+  })
+
+  // Mock localStorage
+  const localStorageMock = {
+    store: {} as Record<string, string>,
+    getItem: vi.fn((key: string) => localStorageMock.store[key] || null),
+    setItem: vi.fn((key: string, value: string) => { localStorageMock.store[key] = value }),
+    removeItem: vi.fn((key: string) => { delete localStorageMock.store[key] }),
+    clear: vi.fn(() => { localStorageMock.store = {} }),
+    get length() { return Object.keys(localStorageMock.store).length },
+    key: vi.fn((i: number) => Object.keys(localStorageMock.store)[i] || null),
+  }
+  vi.stubGlobal('localStorage', localStorageMock)
+
+  return { localStorage: localStorageMock }
+}
+
+export function cleanupBrowserMocks() {
+  vi.unstubAllGlobals()
+}

+ 294 - 24
frontend/src/test/mocks/handlers.ts

@@ -1,47 +1,317 @@
 import { http, HttpResponse } from 'msw'
+import { PatternMetadata, PreviewData } from '@/lib/types'
 
-// Default mock data
-const mockPatterns = [
-  { name: 'star.thr', path: 'patterns/star.thr' },
-  { name: 'spiral.thr', path: 'patterns/spiral.thr' },
-]
+// ============================================
+// Mock Data Store (mutable for test scenarios)
+// ============================================
+
+export const mockData = {
+  patterns: [
+    { path: 'patterns/star.thr', name: 'star.thr', category: 'geometric', date_modified: Date.now(), coordinates_count: 150 },
+    { path: 'patterns/spiral.thr', name: 'spiral.thr', category: 'organic', date_modified: Date.now() - 86400000, coordinates_count: 200 },
+    { path: 'patterns/wave.thr', name: 'wave.thr', category: 'organic', date_modified: Date.now() - 172800000, coordinates_count: 175 },
+    { path: 'patterns/custom/my_design.thr', name: 'my_design.thr', category: 'custom', date_modified: Date.now() - 259200000, coordinates_count: 100 },
+  ] as PatternMetadata[],
 
-const mockPlaylists = ['default', 'favorites']
+  playlists: {
+    default: ['patterns/star.thr', 'patterns/spiral.thr'],
+    favorites: ['patterns/star.thr'],
+    geometric: ['patterns/star.thr', 'patterns/wave.thr'],
+  } as Record<string, string[]>,
+
+  status: {
+    is_running: false,
+    is_paused: false,
+    current_file: null as string | null,
+    speed: 100,
+    progress: 0,
+    playlist_mode: false,
+    playlist_name: null as string | null,
+    queue: [] as string[],
+    connection_status: 'connected',
+    theta: 0,
+    rho: 0.5,
+  },
+}
 
-const mockStatus = {
-  is_running: false,
-  is_paused: false,
-  current_file: null,
-  speed: 100,
-  connection_status: 'connected',
+// Reset mock data between tests
+export function resetMockData() {
+  mockData.patterns = [
+    { path: 'patterns/star.thr', name: 'star.thr', category: 'geometric', date_modified: Date.now(), coordinates_count: 150 },
+    { path: 'patterns/spiral.thr', name: 'spiral.thr', category: 'organic', date_modified: Date.now() - 86400000, coordinates_count: 200 },
+    { path: 'patterns/wave.thr', name: 'wave.thr', category: 'organic', date_modified: Date.now() - 172800000, coordinates_count: 175 },
+    { path: 'patterns/custom/my_design.thr', name: 'my_design.thr', category: 'custom', date_modified: Date.now() - 259200000, coordinates_count: 100 },
+  ]
+  mockData.playlists = {
+    default: ['patterns/star.thr', 'patterns/spiral.thr'],
+    favorites: ['patterns/star.thr'],
+    geometric: ['patterns/star.thr', 'patterns/wave.thr'],
+  }
+  mockData.status = {
+    is_running: false,
+    is_paused: false,
+    current_file: null,
+    speed: 100,
+    progress: 0,
+    playlist_mode: false,
+    playlist_name: null,
+    queue: [],
+    connection_status: 'connected',
+    theta: 0,
+    rho: 0.5,
+  }
 }
 
+// ============================================
+// Handlers
+// ============================================
+
 export const handlers = [
-  // Pattern endpoints
+  // ----------------
+  // Pattern Endpoints
+  // ----------------
   http.get('/list_theta_rho_files', () => {
-    return HttpResponse.json(mockPatterns)
+    return HttpResponse.json(mockData.patterns.map(p => ({ name: p.name, path: p.path })))
   }),
 
   http.get('/list_theta_rho_files_with_metadata', () => {
-    return HttpResponse.json(mockPatterns.map(p => ({ ...p, metadata: {} })))
+    return HttpResponse.json(mockData.patterns)
+  }),
+
+  http.post('/preview_thr_batch', async ({ request }) => {
+    const body = await request.json() as { files: string[] }
+    const previews: Record<string, PreviewData> = {}
+    for (const file of body.files) {
+      previews[file] = {
+        image_data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
+        first_coordinate: { x: 0, y: 0 },
+        last_coordinate: { x: 100, y: 100 },
+      }
+    }
+    return HttpResponse.json(previews)
+  }),
+
+  http.post('/get_theta_rho_coordinates', async () => {
+    // Return mock coordinates for pattern preview
+    return HttpResponse.json({
+      coordinates: Array.from({ length: 50 }, (_, i) => ({
+        theta: i * 7.2,
+        rho: 0.5 + Math.sin(i * 0.2) * 0.3,
+      })),
+    })
+  }),
+
+  http.post('/run_theta_rho', async ({ request }) => {
+    const body = await request.json() as { file_name?: string; file?: string; pre_execution?: string }
+    const file = body.file_name || body.file
+    mockData.status.is_running = true
+    mockData.status.current_file = file || null
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/delete_theta_rho_file', async ({ request }) => {
+    const body = await request.json() as { file_path: string }
+    mockData.patterns = mockData.patterns.filter(p => p.path !== body.file_path)
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/upload_theta_rho', async () => {
+    return HttpResponse.json({ success: true, path: 'patterns/custom/uploaded.thr' })
+  }),
+
+  http.get('/api/pattern_history_all', () => {
+    return HttpResponse.json({})
+  }),
+
+  http.get('/api/pattern_history/:path', () => {
+    return HttpResponse.json({ executions: [] })
   }),
 
-  // Playlist endpoints
+  // ----------------
+  // Playlist Endpoints
+  // ----------------
   http.get('/list_all_playlists', () => {
-    return HttpResponse.json(mockPlaylists)
+    return HttpResponse.json(Object.keys(mockData.playlists))
+  }),
+
+  http.get('/get_playlist', ({ request }) => {
+    const url = new URL(request.url)
+    const name = url.searchParams.get('name')
+    if (name && mockData.playlists[name]) {
+      return HttpResponse.json({ name, files: mockData.playlists[name] })
+    }
+    return HttpResponse.json({ name: name || '', files: [] })
+  }),
+
+  http.post('/create_playlist', async ({ request }) => {
+    const body = await request.json() as { name: string; files?: string[] }
+    mockData.playlists[body.name] = body.files || []
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/modify_playlist', async ({ request }) => {
+    const body = await request.json() as { name: string; files: string[] }
+    if (mockData.playlists[body.name]) {
+      mockData.playlists[body.name] = body.files
+    }
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/rename_playlist', async ({ request }) => {
+    const body = await request.json() as { old_name: string; new_name: string }
+    if (mockData.playlists[body.old_name]) {
+      mockData.playlists[body.new_name] = mockData.playlists[body.old_name]
+      delete mockData.playlists[body.old_name]
+    }
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.delete('/delete_playlist', async ({ request }) => {
+    const body = await request.json() as { name: string }
+    delete mockData.playlists[body.name]
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/run_playlist', async ({ request }) => {
+    const body = await request.json() as { name: string }
+    const playlist = mockData.playlists[body.name]
+    if (playlist && playlist.length > 0) {
+      mockData.status.is_running = true
+      mockData.status.playlist_mode = true
+      mockData.status.playlist_name = body.name
+      mockData.status.current_file = playlist[0]
+      mockData.status.queue = playlist.slice(1)
+    }
+    return HttpResponse.json({ success: true })
   }),
 
-  // Status endpoint
+  http.post('/reorder_playlist', async ({ request }) => {
+    const body = await request.json() as { from_index: number; to_index: number }
+    // Reorder the current queue
+    const queue = [...mockData.status.queue]
+    const [item] = queue.splice(body.from_index, 1)
+    queue.splice(body.to_index, 0, item)
+    mockData.status.queue = queue
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/add_to_playlist', async ({ request }) => {
+    const body = await request.json() as { playlist_name: string; file_path: string }
+    if (!mockData.playlists[body.playlist_name]) {
+      mockData.playlists[body.playlist_name] = []
+    }
+    mockData.playlists[body.playlist_name].push(body.file_path)
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/add_to_queue', async ({ request }) => {
+    const body = await request.json() as { file: string; position?: 'next' | 'end' }
+    if (body.position === 'next') {
+      mockData.status.queue.unshift(body.file)
+    } else {
+      mockData.status.queue.push(body.file)
+    }
+    return HttpResponse.json({ success: true })
+  }),
+
+  // ----------------
+  // Playback Control Endpoints
+  // ----------------
+  http.post('/pause_execution', () => {
+    mockData.status.is_paused = true
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/resume_execution', () => {
+    mockData.status.is_paused = false
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/stop_execution', () => {
+    mockData.status.is_running = false
+    mockData.status.is_paused = false
+    mockData.status.current_file = null
+    mockData.status.playlist_mode = false
+    mockData.status.queue = []
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/force_stop', () => {
+    mockData.status.is_running = false
+    mockData.status.is_paused = false
+    mockData.status.current_file = null
+    mockData.status.playlist_mode = false
+    mockData.status.queue = []
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/skip_pattern', () => {
+    if (mockData.status.queue.length > 0) {
+      mockData.status.current_file = mockData.status.queue.shift() || null
+    } else {
+      mockData.status.is_running = false
+      mockData.status.current_file = null
+    }
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/set_speed', async ({ request }) => {
+    const body = await request.json() as { speed: number }
+    mockData.status.speed = body.speed
+    return HttpResponse.json({ success: true })
+  }),
+
+  // ----------------
+  // Table Control Endpoints
+  // ----------------
+  http.post('/send_home', () => {
+    mockData.status.theta = 0
+    mockData.status.rho = 0
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/soft_reset', () => {
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/move_to_center', () => {
+    mockData.status.rho = 0
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/move_to_perimeter', () => {
+    mockData.status.rho = 1
+    return HttpResponse.json({ success: true })
+  }),
+
+  http.post('/send_coordinate', async ({ request }) => {
+    const body = await request.json() as { theta: number; rho: number }
+    mockData.status.theta = body.theta
+    mockData.status.rho = body.rho
+    return HttpResponse.json({ success: true })
+  }),
+
+  // ----------------
+  // Status Endpoints
+  // ----------------
   http.get('/serial_status', () => {
-    return HttpResponse.json(mockStatus)
+    return HttpResponse.json(mockData.status)
+  }),
+
+  http.get('/list_serial_ports', () => {
+    return HttpResponse.json(['/dev/ttyUSB0', '/dev/ttyUSB1'])
+  }),
+
+  // Debug serial endpoints
+  http.post('/api/debug-serial/open', () => {
+    return HttpResponse.json({ success: true })
   }),
 
-  // API prefix versions
-  http.get('/api/patterns', () => {
-    return HttpResponse.json(mockPatterns)
+  http.post('/api/debug-serial/close', () => {
+    return HttpResponse.json({ success: true })
   }),
 
-  http.get('/api/status', () => {
-    return HttpResponse.json(mockStatus)
+  http.post('/api/debug-serial/send', () => {
+    return HttpResponse.json({ success: true, response: 'OK' })
   }),
 ]

+ 74 - 0
frontend/src/test/mocks/websocket.ts

@@ -0,0 +1,74 @@
+import { vi } from 'vitest'
+
+type MessageHandler = (event: MessageEvent) => void
+
+export class MockWebSocket {
+  static instances: MockWebSocket[] = []
+
+  url: string
+  readyState: number = WebSocket.CONNECTING
+  onopen: (() => void) | null = null
+  onclose: (() => void) | null = null
+  onmessage: MessageHandler | null = null
+  onerror: ((error: Event) => void) | null = null
+
+  constructor(url: string) {
+    this.url = url
+    MockWebSocket.instances.push(this)
+
+    // Simulate connection after microtask
+    setTimeout(() => {
+      this.readyState = WebSocket.OPEN
+      this.onopen?.()
+    }, 0)
+  }
+
+  send(_data: string) {
+    // Mock implementation - can be extended to handle specific messages
+  }
+
+  close() {
+    this.readyState = WebSocket.CLOSED
+    this.onclose?.()
+  }
+
+  // Helper to simulate receiving a message
+  simulateMessage(data: unknown) {
+    if (this.onmessage) {
+      const event = new MessageEvent('message', {
+        data: JSON.stringify(data),
+      })
+      this.onmessage(event)
+    }
+  }
+
+  // Helper to simulate connection error
+  simulateError() {
+    if (this.onerror) {
+      this.onerror(new Event('error'))
+    }
+  }
+
+  static CONNECTING = 0
+  static OPEN = 1
+  static CLOSING = 2
+  static CLOSED = 3
+}
+
+// Install mock WebSocket globally
+export function setupMockWebSocket() {
+  MockWebSocket.instances = []
+  vi.stubGlobal('WebSocket', MockWebSocket)
+}
+
+// Get the most recent WebSocket instance
+export function getLastWebSocket(): MockWebSocket | undefined {
+  return MockWebSocket.instances[MockWebSocket.instances.length - 1]
+}
+
+// Clean up mock WebSocket
+export function cleanupMockWebSocket() {
+  MockWebSocket.instances.forEach(ws => ws.close())
+  MockWebSocket.instances = []
+  vi.unstubAllGlobals()
+}

+ 22 - 6
frontend/src/test/setup.ts

@@ -1,16 +1,32 @@
 import '@testing-library/jest-dom/vitest'
 import { cleanup } from '@testing-library/react'
-import { afterAll, afterEach, beforeAll } from 'vitest'
+import { afterAll, afterEach, beforeAll, beforeEach } from 'vitest'
 import { server } from './mocks/server'
+import { resetMockData } from './mocks/handlers'
+import { setupBrowserMocks, cleanupBrowserMocks } from './mocks/browser'
+import { setupMockWebSocket, cleanupMockWebSocket } from './mocks/websocket'
 
-// Start MSW server before tests
-beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
+// Setup browser mocks
+beforeAll(() => {
+  setupBrowserMocks()
+  setupMockWebSocket()
+  server.listen({ onUnhandledRequest: 'error' })
+})
+
+// Reset state between tests
+beforeEach(() => {
+  resetMockData()
+})
 
-// Reset handlers after each test
+// Cleanup after each test
 afterEach(() => {
   cleanup()
   server.resetHandlers()
 })
 
-// Close server after all tests
-afterAll(() => server.close())
+// Teardown after all tests
+afterAll(() => {
+  server.close()
+  cleanupMockWebSocket()
+  cleanupBrowserMocks()
+})

+ 69 - 0
frontend/src/test/utils.tsx

@@ -0,0 +1,69 @@
+import { render, RenderOptions } from '@testing-library/react'
+import { BrowserRouter } from 'react-router'
+import { ReactElement, ReactNode } from 'react'
+import { PatternMetadata, PreviewData } from '@/lib/types'
+
+// Wrapper component with required providers
+function AllProviders({ children }: { children: ReactNode }) {
+  return <BrowserRouter>{children}</BrowserRouter>
+}
+
+// Custom render that includes providers
+export function renderWithProviders(
+  ui: ReactElement,
+  options?: Omit<RenderOptions, 'wrapper'>
+) {
+  return render(ui, { wrapper: AllProviders, ...options })
+}
+
+// Mock data generators
+export function createMockPatterns(count: number = 5): PatternMetadata[] {
+  return Array.from({ length: count }, (_, i) => ({
+    path: `patterns/pattern${i + 1}.thr`,
+    name: `pattern${i + 1}.thr`,
+    category: i % 2 === 0 ? 'geometric' : 'organic',
+    date_modified: Date.now() - i * 86400000, // Each day older
+    coordinates_count: 100 + i * 50,
+  }))
+}
+
+export function createMockPreview(): PreviewData {
+  // 1x1 transparent PNG as base64
+  return {
+    image_data: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
+    first_coordinate: { x: 0, y: 0 },
+    last_coordinate: { x: 100, y: 100 },
+  }
+}
+
+export function createMockStatus(overrides: Partial<{
+  is_running: boolean
+  is_paused: boolean
+  current_file: string | null
+  speed: number
+  progress: number
+  playlist_mode: boolean
+  playlist_name: string | null
+  queue: string[]
+}> = {}) {
+  return {
+    is_running: false,
+    is_paused: false,
+    current_file: null,
+    speed: 100,
+    progress: 0,
+    playlist_mode: false,
+    playlist_name: null,
+    queue: [],
+    connection_status: 'connected',
+    ...overrides,
+  }
+}
+
+export function createMockPlaylists(): string[] {
+  return ['default', 'favorites', 'geometric', 'relaxing']
+}
+
+// Re-export everything from testing-library
+export * from '@testing-library/react'
+export { default as userEvent } from '@testing-library/user-event'