diff --git a/lightly_studio_view/README.md b/lightly_studio_view/README.md index fd29e09e4..8724872ff 100644 --- a/lightly_studio_view/README.md +++ b/lightly_studio_view/README.md @@ -1,73 +1,1162 @@ -# LightlyStudio view +# Frontend Documentation -This is a frontend part for LightlyStudio. Powered by [`SvelteKit`](https://svelte.dev/docs/kit/introduction). -It is build as a static single page application. +This document provides a comprehensive overview of the LightlyStudio frontend architecture, tools, and coding guidelines. -## Dependencies +## Development Setup -- Please [install nvm](curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash) to select correct nodejs versions -- Select nodejs version `nvm install && nvm use` -- Install dependencies `npm ci` +### Prerequisites -## Schema and API Integration +- **Node.js v22.11.0** or higher +- **npm** package manager -We use OpenAPI for API documentation and type generation, providing type safety and automated client code generation. This ensures our frontend remains in sync with the backend API contract. +### Running Development Server -### Key Components +1. **Install dependencies:** + ```bash + npm install + ``` -#### API Schema +2. **Start development server:** + ```bash + npm run dev + ``` + + The frontend will be available at `http://localhost:5173` -Located in [src/lib/schema.d.ts](src/lib/schema.d.ts) -Contains TypeScript definitions for all API endpoints -Automatically generated from the OpenAPI specification -Provides type checking and autocompletion support +3. **Generate API client** (if backend schema changes): + ```bash + npm run generate-api + ``` -#### API Client +### Development Commands -We use a fully generated client by [`@hey-api/openapi-ts` tool](https://heyapi.dev/) for the lightly_studio_local API, you can check the [openapi-ts.config.ts](openapi-ts.config.ts). This client is generated from the OpenAPI json schema and provides type-safe methods for interacting with the API. +```bash +# Start dev server with hot reload +npm run dev -Autogenerated client stored in [src/lib/api/lightly_studio_local](/src/lib/api/) +# Build for production +npm run build -### Generating the API client +# Preview production build +npm run preview -Is done automatically before `npm run dev` and `npm run build` commands: -But you can always call it manually +# Run tests +npm run test -```bash -npm run generate-api-client +# Run tests in watch mode +npm run test:watch + +# Run linting +npm run lint + +# Format code +npm run format + +# Start Storybook +npm run storybook ``` -## Developing +## Architecture Overview -Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: +LightlyStudio frontend is built as a static single-page application (SPA) using SvelteKit. The application provides a modern web interface for data curation, annotation, and management workflows. -```bash -npm run dev +```mermaid +graph TB + subgraph "Frontend Architecture" + UI["SvelteKit UI Application"] + API["Generated API Client"] + Store["Svelte Stores"] + Hooks["Custom Hooks"] + Components["UI Components"] + end + + subgraph "Backend Services" + FastAPI["FastAPI Backend"] + DB["DuckDB Database"] + Images["Image Files"] + end -# or start the server and open the app in a new browser tab -npm run dev -- --open + subgraph "External Services" + PostHog["PostHog Analytics"] + end + + UI --> API + UI --> Store + UI --> Hooks + UI --> Components + + API --> FastAPI + FastAPI --> DB + FastAPI --> Images + + UI --> PostHog + + Store -.-> Hooks + Hooks -.-> Components + Components -.-> Store ``` -### Testing +### Key Architecture Components -To run the tests you have 2 options: +- **SvelteKit Application**: Static SPA with client-side routing and state management +- **Generated API Client**: Type-safe client generated from OpenAPI specification using @hey-api/openapi-ts +- **Component System**: Modular UI built with Shadcn/Svelte and Bits-UI components +- **State Management**: Distributed state using Svelte stores and custom hooks +- **Styling**: Tailwind CSS with custom design system -1. Run the tests in cli +## Development Tools -```bash -make test +### Core Framework & Build Tools + +- **[SvelteKit](https://svelte.dev/docs/kit/introduction)**: Frontend framework for building the SPA with routing and SSG capabilities +- **[Svelte](https://svelte.dev/docs/svelte/overview)**: Reactive component framework for building the UI +- **[Vite](https://vite.dev/)**: Fast build tool and development server +- **[TypeScript](https://www.typescriptlang.org/)**: Type-safe JavaScript for all application code + +### UI & Styling + +- **[Tailwind CSS](https://tailwindcss.com/)**: Utility-first CSS framework for styling +- **[Shadcn/Svelte](https://www.shadcn-svelte.com/)**: Pre-built component library with consistent styling +- **[Bits-UI](https://bits-ui.com/)**: Headless component library providing accessible UI primitives + +### API & Data Management + +- **[@hey-api/openapi-ts](https://heyapi.dev/)**: Generates type-safe API client from OpenAPI specification +- **[@tanstack/svelte-query](https://tanstack.com/query/latest)**: Data fetching, caching, and state management library +- **[Apache Arrow](https://arrow.apache.org/)**: Efficient data serialization for large datasets + +#### TanStack Query Integration + +LightlyStudio uses TanStack Query (formerly React Query) for efficient data fetching and state management. The integration leverages auto-generated query and mutation functions from the OpenAPI specification. + +**Key Features:** + +- **Generated Functions**: All API calls use type-safe functions generated from OpenAPI spec (e.g., `readSamples`, `updateAnnotation`) +- **Infinite Queries**: For paginated data like samples and annotations using `createInfiniteQuery` +- **Automatic Caching**: Intelligent query key structure minimizes unnecessary refetches +- **Background Updates**: Automatic data synchronization and cache invalidation +- **Error Handling**: Built-in retry logic with client error (4xx) exclusion + +**Usage Pattern:** + +
+Show code example + +```typescript +// Custom hook wrapping TanStack Query +export const useSamplesInfinite = (params: SamplesInfiniteParams) => { + const samplesOptions = createSamplesInfiniteOptions(params); + const samples = createInfiniteQuery(samplesOptions); + const client = useQueryClient(); + + const refresh = () => { + client.invalidateQueries({ queryKey: samplesOptions.queryKey }); + }; + + return { samples, refresh }; +}; + +// Component usage +const { samples: infiniteSamples } = useSamplesInfinite(filterParams); +const samples = $infiniteSamples.data?.pages.flatMap((page) => page.data) ?? []; ``` -## Building +
-To create a production version of your app: +**Query Key Strategy:** -```bash -npm run build +- Structured keys for efficient cache management: `['readSamplesInfinite', dataset_id, mode, filters, metadata]` +- Mode-aware caching for different data contexts (normal vs classifier) +- Intelligent parameter comparison to prevent unnecessary refetches + +### Data Visualization + +- **[Embedding Atlas](https://apple.github.io/embedding-atlas/)**: Apple's component for visualizing high-dimensional data +- **[D3.js](https://d3js.org/)**: Data visualization utilities (d3-selection, d3-zoom, d3-drag) +- **[PaneForge](https://paneforge.com/docs)**: Resizable layout system for complex UI arrangements + +### Testing & Quality + +- **[Vitest](https://vitest.dev/)**: Fast unit testing framework +- **[Playwright](https://playwright.dev/)**: End-to-end testing framework +- **[ESLint](https://eslint.org/docs/latest/)**: Code linting and quality enforcement +- **[Prettier](https://prettier.io/docs/)**: Code formatting + +### Development & Deployment + +- **[Node.js](https://nodejs.org/)**: Runtime environment (v22.11.0) +- **[npm](https://www.npmjs.com/)**: Package manager +- **[Storybook](https://storybook.js.org/)**: Component development and documentation + +### Analytics & Monitoring + +- **[PostHog](https://posthog.com/)**: User analytics and error tracking + +## Svelte Reactivity + +Svelte 5 introduces [runes](https://svelte.dev/docs/svelte/what-are-runes) for fine-grained reactivity. While we follow a framework-agnostic approach, runes are essential for component-level reactivity within `.svelte` files. + +### $derived - Computed Values + +Use [`$derived`](https://svelte.dev/docs/svelte/$derived) for computed values that depend on reactive state. It automatically tracks dependencies and updates when they change. + +**When to use:** + +- Computing values from props or state +- Transforming data for display +- Complex calculations that should be cached + +
+Show code example + +```typescript +// Simple derived value +const fullName = $derived(`${firstName} ${lastName}`); + +// Complex computation with caching +const filteredSamples = $derived( + samples.filter((sample) => sample.tags.some((tag) => selectedTags.includes(tag))) +); + +// Derived from TanStack Query data +const samples = $derived($infiniteSamples?.data?.pages.flatMap((page) => page.data) ?? []); +``` + +
+ +### $derived.by - Complex Derived Logic + +Use [`$derived.by`](https://svelte.dev/docs/svelte/$derived#$derived.by) when the derived computation is complex or needs explicit dependency tracking. + +
+Show code example + +```typescript +// Complex filtering logic +const samplesParams = $derived.by(() => { + return { + dataset_id, + mode: 'normal' as const, + filters: { + annotation_label_ids: $selectedAnnotationFilterIds?.length + ? $selectedAnnotationFilterIds + : undefined, + tag_ids: $tagsSelected.size > 0 ? Array.from($tagsSelected) : undefined + } + }; +}); +``` + +
+ +### $effect - Side Effects + +Use [`$effect`](https://svelte.dev/docs/svelte/$effect) for side effects that should run when reactive values change. + +**When to use:** + +- DOM manipulation +- API calls triggered by state changes +- Synchronizing external state +- Cleanup operations + +
+Show code example + +```typescript +// Synchronize filter parameters +$effect(() => { + const baseParams = samplesParams; + const currentParams = $filterParams; + + if (currentParams && _.isEqual(baseParams, currentParams)) { + return; + } + + updateFilterParams(baseParams); +}); + +// Update settings when store changes +$effect(() => { + objectFit = $gridViewSampleRenderingStore; +}); + +// Set filtered count when data loads +$effect(() => { + if ($infiniteSamples.isSuccess && $infiniteSamples.data?.pages.length > 0) { + setfilteredSampleCount($infiniteSamples.data.pages[0].total_count); + } +}); +``` + +
+ +### $effect.pre - Pre-DOM Effects + +Use [`$effect.pre`](https://svelte.dev/docs/svelte/$effect#$effect.pre) when you need effects to run before DOM updates. + +
+Show code example + +```typescript +// Run before DOM updates +$effect.pre(() => { + // Prepare data before rendering + prepareRenderData(); +}); +``` + +
+ +### Best Practices + +1. **Prefer $derived over $effect** for computed values +2. **Use $effect sparingly** - only for true side effects +3. **Return cleanup functions** from $effect when needed +4. **Avoid complex logic in $derived** - extract to functions +5. **Use $derived.by for explicit dependency tracking** + +
+Show code example + +```typescript +// Good: Simple derived value +const isSelected = $derived(selectedIds.has(item.id)); + +// Good: Effect with cleanup +$effect(() => { + const observer = new ResizeObserver(handleResize); + observer.observe(element); + + return () => observer.disconnect(); +}); + +// Avoid: Complex logic in derived +const complexResult = $derived( + // Too much logic here - extract to function + data.filter(...).map(...).reduce(...) +); + +// Better: Extract complex logic +const complexResult = $derived(processComplexData(data)); +``` + +
+ +## SvelteKit Framework Features + +SvelteKit provides powerful routing, data loading, and layout capabilities. Here's how LightlyStudio leverages these features. + +### Routing Structure + +SvelteKit uses [file-based routing](https://svelte.dev/docs/kit/routing) where the file structure defines the routes. + +
+Show code example + +``` +src/routes/ +├── +layout.svelte # Root layout +├── +layout.ts # Root layout loader +├── +page.ts # Home page loader (redirects) +├── datasets/ +│ └── [dataset_id]/ +│ ├── +layout.ts # Dataset layout loader +│ ├── samples/ +│ │ ├── +layout.svelte # Samples layout +│ │ ├── +layout.ts # Samples layout loader +│ │ ├── +page.svelte # Samples grid page +│ │ └── [sampleId]/ +│ │ └── [sampleIndex]/ +│ │ └── +page.ts # Sample details loader +│ └── annotations/ +│ ├── +layout.svelte # Annotations layout +│ └── [sampleId]/ +│ └── [annotationId]/ +│ └── [annotationIndex]/ +│ └── +page.ts # Annotation details loader +``` + +
+ +### Route Parameters + +Parameters are automatically extracted from the URL and passed to [load functions](https://svelte.dev/docs/kit/load) and components. + +
+Show code example + +```typescript +// src/routes/datasets/[dataset_id]/samples/[sampleId]/[sampleIndex]/+page.ts +export const load = async ({ params }) => { + const { dataset_id, sampleId, sampleIndex } = params; + + return { + dataset_id, + sampleId, + sampleIndex: Number(sampleIndex) + }; +}; +``` + +
+ +### Layout System + +Layouts provide shared UI and data loading across multiple pages. They create a nested hierarchy. + +
+Show code example + +```typescript +// Root layout (+layout.ts) - runs for all pages +export const load = async () => { + const globalStorage = useGlobalStorage(); + return { globalStorage }; +}; + +// Dataset layout - runs for all dataset pages +export const load = async ({ params }) => { + const { dataset_id } = params; + // Load dataset-specific data + return { dataset_id }; +}; +``` + +```svelte + + + +
+ +
+
+ {@render children()} +
+
+``` + +
+ +### Data Loading + +Load functions run before page rendering and provide data to components. + +
+Show code example + +```typescript +// Home page loader - redirects to most recent dataset +export const load = async () => { + const { data } = await readDatasets(); + + if (!data || data.length === 0) { + throw new Error('No datasets found'); + } + + const mostRecentDataset = data.toSorted( + (a, b) => new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime() + )[0]; + + redirect(307, routeHelpers.toSamples(mostRecentDataset.dataset_id)); +}; +``` + +
+ +### Navigation + +Use SvelteKit's [navigation utilities](https://svelte.dev/docs/kit/$app-navigation) for programmatic routing. + +
+Show code example + +```typescript +import { goto } from '$app/navigation'; +import { routeHelpers } from '$lib/routes'; + +// Navigate to sample details +function handleSampleClick(sampleId: string, index: number) { + goto( + routeHelpers.toSample({ + sampleId, + datasetId: dataset_id, + sampleIndex: index + }) + ); +} +``` + +
+ +### Route Helpers + +Centralized route generation ensures type safety and consistency. + +
+Show code example + +```typescript +// $lib/routes.ts +export const routes = { + dataset: { + samples: (datasetId: string) => `/datasets/${datasetId}/samples`, + sample: ({ sampleId, datasetId, sampleIndex }: SampleParams) => { + let path = `/datasets/${datasetId}/samples/${sampleId}`; + if (typeof sampleIndex !== 'undefined') { + path = `${path}/${sampleIndex}`; + } + return path; + } + } +}; + +export const routeHelpers = { + toSamples: (datasetId: string) => routes.dataset.samples(datasetId), + toSample: (params: SampleParams) => routes.dataset.sample(params) +}; +``` + +
+ +### Route Matching + +Use route matching to conditionally render UI based on current route. + +
+Show code example + +```typescript +import { page } from '$app/state'; +import { isSamplesRoute, isAnnotationsRoute } from '$lib/routes'; + +// Check current route +const isOnSamplesPage = $derived(isSamplesRoute($page.route.id)); +const isOnAnnotationsPage = $derived(isAnnotationsRoute($page.route.id)); +``` + +
+ +### Parameter Propagation + +Parameters flow from URL → load functions → page components through the data prop. + +
+Show code example + +```typescript +// Load function receives params from URL +export const load = async ({ params }) => { + return { + dataset_id: params.dataset_id, + sampleId: params.sampleId + }; +}; ``` -You can preview the production build with `npm run preview`. +```svelte + + +``` + +
+ +## Component Styling + +LightlyStudio uses [Tailwind CSS](https://tailwindcss.com/) as the primary styling solution, providing utility-first classes for rapid UI development. + +### Tailwind CSS Approach + +Use Tailwind utility classes directly in component templates for styling. This approach provides: + +- **Consistency**: Predefined design tokens ensure visual consistency +- **Performance**: Only used utilities are included in the final bundle +- **Maintainability**: Styles are co-located with components +- **Responsiveness**: Built-in responsive design utilities + +### Basic Styling Example + +
+Show code example + +```svelte + + + +
+ +
+ +
+ {sample.fileName} +
+ + +
+

+ {sample.fileName} +

+

+ {sample.dimensions.width} × {sample.dimensions.height} +

+
+ + + {#if isSelected} +
+ +
+ {/if} +
+
+ + +
+
+ + +
+ + +
+``` + +
+ +### Design System Integration + +LightlyStudio uses CSS custom properties for design tokens, integrated with Tailwind: + +
+Show code example + +```css +/* app.css - Design system tokens */ +:root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --primary: 42.1 76.2% 36.3%; + --primary-foreground: 355.7 100% 97.3%; + --border: 240 5.9% 90%; + --muted: 240 4.8% 95.9%; +} + +.dark { + --background: 20 14.3% 4.1%; + --foreground: 0 0% 95%; + --primary: 159 64% 54%; /* Lightly brand color */ + --border: 240 3.7% 15.9%; +} +``` + +
+ +### Conditional Styling + +Use Svelte's `class:` directive for dynamic styling: + +
+Show code example + +```svelte + + +
+ {#if isLoading} +
+ + Loading samples... +
+ {:else if hasError} +
+ +

Error loading data

+
+ {:else if isEmpty} +
+

No samples found

+

Try adjusting your filters

+
+ {/if} +
+``` + +
+ +### Utility Helper Function + +Use the `cn()` utility for combining classes conditionally: + +
+Show code example + +```typescript +// $lib/utils/cn.ts +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} +``` + +```svelte + + + +``` + +
+ +### Responsive Design + +Tailwind provides responsive utilities with mobile-first breakpoints: + +
+Show code example + +```svelte + +
+ +
+ + +

Dataset Overview

+ + +
+ +
+``` + +
+ +## Testing + +LightlyStudio uses [Vitest](https://vitest.dev/) for unit testing and [Testing Library](https://testing-library.com/) for component testing. We follow Test-Driven Development (TDD) principles and aim for comprehensive test coverage. + +### Testing Philosophy + +- **Test behavior, not implementation**: Focus on what the component does, not how it does it +- **User-centric testing**: Test from the user's perspective using accessible queries +- **Isolated testing**: Each test should be independent and not rely on external state +- **Comprehensive coverage**: Test happy paths, edge cases, and error scenarios + +### Unit Testing Functions + +Test pure functions and utility methods in isolation: + +
+Show code example + +```typescript +// src/lib/utils/formatters.test.ts +import { describe, it, expect } from 'vitest'; +import { formatFileSize, formatDuration, parseCoordinates } from './formatters'; + +describe('formatFileSize', () => { + it('formats bytes correctly', () => { + expect(formatFileSize(0)).toBe('0 B'); + expect(formatFileSize(1024)).toBe('1.0 KB'); + expect(formatFileSize(1048576)).toBe('1.0 MB'); + expect(formatFileSize(1073741824)).toBe('1.0 GB'); + }); + + it('handles decimal places', () => { + expect(formatFileSize(1536)).toBe('1.5 KB'); + expect(formatFileSize(2621440)).toBe('2.5 MB'); + }); + + it('handles edge cases', () => { + expect(formatFileSize(-1)).toBe('0 B'); + expect(formatFileSize(NaN)).toBe('0 B'); + expect(formatFileSize(Infinity)).toBe('0 B'); + }); +}); + +describe('parseCoordinates', () => { + it('parses valid coordinate strings', () => { + expect(parseCoordinates('10,20,100,200')).toEqual({ + x: 10, + y: 20, + width: 100, + height: 200 + }); + }); + + it('handles invalid input gracefully', () => { + expect(parseCoordinates('')).toBeNull(); + expect(parseCoordinates('invalid')).toBeNull(); + expect(parseCoordinates('1,2,3')).toBeNull(); // Missing coordinate + }); + + it('converts string numbers to integers', () => { + expect(parseCoordinates('1.5,2.7,3.9,4.1')).toEqual({ + x: 1, + y: 2, + width: 3, + height: 4 + }); + }); +}); +``` + +
+ +### Component Testing + +Test Svelte components using Testing Library with proper mocking and setup: + +
+Show code example + +```typescript +// src/lib/components/Header/Header.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/svelte'; +import { writable } from 'svelte/store'; +import Header from './Header.svelte'; +import * as appState from '$app/state'; +import '@testing-library/jest-dom'; +import type { Page } from '@sveltejs/kit'; +import type { ReversibleAction } from '$lib/hooks/useGlobalStorage'; + +describe('Header', () => { + // Setup helper function for consistent test environment + const setup = ( + props: { + isEditingMode: boolean; + reversibleActions?: ReversibleAction[]; + executeReversibleAction?: vi.Mock; + } = { isEditingMode: false } + ) => { + const setIsEditingModeSpy = vi.fn(); + const executeReversibleActionSpy = props.executeReversibleAction || vi.fn(); + + // Mock SvelteKit's page state + vi.spyOn(appState, 'page', 'get').mockReturnValue({ + data: { + globalStorage: { + isEditingMode: writable(props.isEditingMode), + setIsEditingMode: setIsEditingModeSpy, + reversibleActions: writable(props.reversibleActions || []), + executeReversibleAction: executeReversibleActionSpy + } + }, + params: { dataset_id: 'test-dataset' }, + route: { id: null, uid: null, pattern: '/datasets/[dataset_id]/samples' } + } as unknown as Page); + + return { setIsEditingModeSpy, executeReversibleActionSpy }; + }; -## Usage Tracking + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Edit Mode button', () => { + it('renders Edit Annotations button when not in editing mode', () => { + setup({ isEditingMode: false }); + render(Header); + + const editButton = screen.getByTestId('header-editing-mode-button'); + expect(editButton).toBeInTheDocument(); + expect(editButton).toHaveTextContent('Edit Annotations'); + }); + + it('renders Finish Editing button when in editing mode', () => { + setup({ isEditingMode: true }); + render(Header); + + const doneButton = screen.getByTestId('header-editing-mode-button'); + expect(doneButton).toHaveTextContent('Finish Editing'); + }); + + it('toggles editing mode when clicked', async () => { + const { setIsEditingModeSpy } = setup({ isEditingMode: false }); + render(Header); + + const editButton = screen.getByTestId('header-editing-mode-button'); + await fireEvent.click(editButton); + + expect(setIsEditingModeSpy).toHaveBeenCalledWith(true); + }); + }); + + describe('Undo functionality', () => { + it('does not render undo button when not in editing mode', () => { + setup({ isEditingMode: false }); + render(Header); + + const undoButton = screen.queryByTestId('header-reverse-action-button'); + expect(undoButton).not.toBeInTheDocument(); + }); + + it('renders disabled undo button with no actions', () => { + setup({ isEditingMode: true, reversibleActions: [] }); + render(Header); + + const undoButton = screen.getByTestId('header-reverse-action-button'); + expect(undoButton).toBeDisabled(); + expect(undoButton).toHaveAttribute('title', 'No action to undo'); + }); + + it('executes reversible action when clicked', async () => { + const mockAction = { + id: 'test-action', + description: 'Test action', + execute: vi.fn(), + timestamp: new Date() + }; + const executeReversibleActionMock = vi.fn().mockResolvedValue(undefined); + + setup({ + isEditingMode: true, + reversibleActions: [mockAction], + executeReversibleAction: executeReversibleActionMock + }); + render(Header); + + const undoButton = screen.getByTestId('header-reverse-action-button'); + await fireEvent.click(undoButton); + + expect(executeReversibleActionMock).toHaveBeenCalledWith('test-action'); + }); + }); +}); +``` + +
+ +### Testing Hooks + +Test custom hooks by creating wrapper components: + +
+Show code example + +```typescript +// src/lib/hooks/useTags/useTags.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { render } from '@testing-library/svelte'; +import { writable } from 'svelte/store'; +import { useTags } from './useTags'; + +// Test wrapper component +const TestWrapper = ` + +`; + +describe('useTags', () => { + it('initializes with empty selection', () => { + render(TestWrapper, { + props: { dataset_id: 'test-dataset', kind: ['sample'] } + }); + + const { tagsSelected } = globalThis.testHookResult; + expect(tagsSelected.size).toBe(0); + }); + + it('toggles tag selection', () => { + render(TestWrapper, { + props: { dataset_id: 'test-dataset', kind: ['sample'] } + }); + + const { tagsSelected, toggleTag } = globalThis.testHookResult; + + toggleTag('tag-1'); + expect(tagsSelected.has('tag-1')).toBe(true); + + toggleTag('tag-1'); + expect(tagsSelected.has('tag-1')).toBe(false); + }); +}); +``` + +
+ +### Testing Best Practices + +#### 1. Use Data Test IDs + +
+Show code example + +```svelte + + + + +const submitButton = screen.getByTestId('submit-button'); +``` + +
+ +#### 2. Mock External Dependencies + +
+Show code example + +```typescript +// Mock API calls +vi.mock('$lib/api/lightly_studio_local', () => ({ + readSamples: vi.fn().mockResolvedValue({ data: [] }) +})); + +// Mock SvelteKit modules +vi.mock('$app/navigation', () => ({ + goto: vi.fn() +})); +``` + +
+ +#### 3. Test User Interactions + +
+Show code example + +```typescript +// Test form submission +const input = screen.getByLabelText('Sample name'); +const submitButton = screen.getByRole('button', { name: 'Submit' }); + +await fireEvent.input(input, { target: { value: 'test-sample' } }); +await fireEvent.click(submitButton); + +expect(mockSubmitHandler).toHaveBeenCalledWith('test-sample'); +``` + +
+ +#### 4. Test Async Operations + +
+Show code example + +```typescript +// Test loading states +it('shows loading spinner during API call', async () => { + const mockApiCall = vi + .fn() + .mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100))); + + render(Component); + + expect(screen.getByTestId('loading-spinner')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument(); + }); +}); +``` + +
+ +#### 5. Test Error Scenarios + +
+Show code example + +```typescript +// Test error handling +it('displays error message when API fails', async () => { + vi.mocked(readSamples).mockRejectedValue(new Error('API Error')); + + render(Component); + + await waitFor(() => { + expect(screen.getByText('Error loading samples')).toBeInTheDocument(); + }); +}); +``` + +
+ +### Running Tests + +
+Show code example + +```bash +# Run all tests +npm run test + +# Run tests in watch mode +npm run test:watch + +# Run tests with coverage +npm run test:coverage + +# Run specific test file +npm run test Header.test.ts +``` -LightlyStudio collects anonymous usage data such as errors using PostHog to improve the application. +