diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..a7926cd Binary files /dev/null and b/.coverage differ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..15a094e --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,241 @@ +# Architecture – React Todo Application + +This document describes the component tree, TypeScript interfaces, file +structure, state management strategy, and data flow for the React + Vite + +TypeScript Todo single-page application. + +--- + +## File Structure + +``` +src/ +├── App.tsx # Root shell – renders TodoPage +├── main.tsx # Vite entry point – mounts +├── vite-env.d.ts # Vite client type declarations +├── index.css # Global styles +│ +├── types/ +│ └── todo.ts # Todo interface & FilterType union +│ +├── hooks/ +│ ├── useTodos.ts # Custom hook – CRUD + localStorage +│ └── __tests__/ +│ └── useTodos.test.ts # Hook unit tests +│ +├── components/ +│ ├── TodoInput.tsx # Text input + Add button +│ ├── TodoItem.tsx # Single todo row (checkbox, text, delete) +│ ├── TodoList.tsx # Renders list of TodoItem or empty state +│ ├── TodoFilter.tsx # All / Active / Completed filter buttons +│ └── __tests__/ +│ ├── TodoInput.test.tsx +│ ├── TodoItem.test.tsx +│ ├── TodoList.test.tsx +│ └── TodoFilter.test.tsx +│ +└── pages/ + ├── TodoPage.tsx # Main assembler – owns state, composes UI + └── __tests__/ + └── TodoPage.test.tsx +``` + +--- + +## TypeScript Interfaces + +### `Todo` (src/types/todo.ts) + +```ts +export interface Todo { + /** Unique identifier – generated via crypto.randomUUID(). */ + id: string; + /** User-supplied text for the todo item. */ + text: string; + /** Whether the item has been completed. */ + completed: boolean; + /** Unix-epoch millisecond timestamp of creation. */ + createdAt: number; +} +``` + +### `FilterType` (src/types/todo.ts) + +```ts +export type FilterType = 'all' | 'active' | 'completed'; +``` + +--- + +## Component Tree + +``` + + └── ← owns todos[] and filter state + ├── ← calls onAdd(text) + ├── ← calls onFilterChange(filter) + └── ← receives filtered todos[] + └── ← calls onToggle(id), onDelete(id) +``` + +--- + +## Component Props Interfaces + +### TodoInput + +```ts +interface TodoInputProps { + onAdd: (text: string) => void; +} +``` + +- Trims whitespace before calling `onAdd`. +- Rejects empty / whitespace-only strings. +- Clears the input field after successful submission. +- Submits on Enter key press or button click. + +### TodoItem + +```ts +interface TodoItemProps { + todo: Todo; + onToggle: (id: string) => void; + onDelete: (id: string) => void; +} +``` + +- Renders a checkbox bound to `todo.completed`. +- Applies `text-decoration: line-through` when completed. +- Provides a Delete button. + +### TodoList + +```ts +interface TodoListProps { + todos: Todo[]; // defaults to [] internally + onToggle: (id: string) => void; + onDelete: (id: string) => void; +} +``` + +- When `todos` is empty, renders an informational empty-state message. +- Maps over `todos` rendering a `` for each. + +### TodoFilter + +```ts +interface TodoFilterProps { + currentFilter: FilterType; + onFilterChange: (filter: FilterType) => void; + counts: { + all: number; + active: number; + completed: number; + }; +} +``` + +- Renders three buttons: All, Active, Completed. +- Highlights the currently active filter. +- Displays counts beside each label. + +--- + +## State Management + +All application state lives in `TodoPage` via React `useState` hooks: + +| State | Type | Initial Value | +| ---------- | ------------- | --------------------------------- | +| `todos` | `Todo[]` | Loaded from localStorage or `[]` | +| `filter` | `FilterType` | `'all'` | + +### Custom Hook – `useTodos` + +Encapsulates CRUD operations and localStorage persistence: + +```ts +function useTodos(): { + todos: Todo[]; + addTodo: (text: string) => void; + toggleTodo: (id: string) => void; + deleteTodo: (id: string) => void; +} +``` + +- **addTodo** – prepends (most recent first) a new `Todo` with + `crypto.randomUUID()` id. +- **toggleTodo** – flips the `completed` boolean for the given id. +- **deleteTodo** – removes the todo with the given id. +- On every state change, persists `todos` to `localStorage`. +- On initialisation, reads from `localStorage`; falls back to `[]` if + storage is unavailable or data is corrupt. + +### localStorage Resilience + +`localStorage` may throw in the following scenarios: +- Quota exceeded (especially in Safari private browsing). +- Security restrictions (iframe sandboxing, certain enterprise policies). + +The `useTodos` hook wraps all `localStorage` calls in try/catch and +gracefully falls back to in-memory-only state. No error is surfaced to +the user. + +--- + +## Data Flow + +1. User types in `` and presses Enter / clicks Add. +2. `TodoInput` trims the text and calls `onAdd(trimmedText)`. +3. `TodoPage` (via `useTodos.addTodo`) prepends a new `Todo` to state. +4. React re-renders; `TodoPage` filters `todos` by `filter` and passes + the result to ``. +5. `` maps over filtered todos, rendering `` for each. +6. User interactions (checkbox toggle, delete button) propagate back up + via `onToggle` / `onDelete` callbacks. +7. After every state mutation, `useEffect` persists `todos` to + `localStorage`. + +--- + +## Styling Approach + +Plain CSS via `src/index.css`. No CSS-in-JS library or CSS modules are +required for this scope. Class names follow a flat BEM-lite convention: + +- `.todo-page` +- `.todo-input` +- `.todo-list` +- `.todo-item` +- `.todo-item.completed` +- `.todo-filters` +- `.todo-filters .active` +- `.empty-message` + +--- + +## Edge Cases + +| Scenario | Behaviour | +| ------------------------------- | --------------------------------------------- | +| Empty todo list | `` shows "No todos to show." | +| Whitespace-only input | `` rejects silently (no-op) | +| localStorage unavailable | Falls back to in-memory state | +| localStorage data corrupt | Discards stored data, starts with `[]` | +| `crypto.randomUUID` unavailable | Fallback: `Date.now() + Math.random()` suffix | + +--- + +## Test Plan + +All tests use **Vitest** + **React Testing Library**. + +| Test File | Key Cases | +| ------------------------------------------ | ----------------------------------------------------------- | +| `src/components/__tests__/TodoItem.test` | Renders text, checkbox calls onToggle, delete calls onDelete, completed has line-through | +| `src/components/__tests__/TodoInput.test` | Renders input+button, calls onAdd trimmed, clears after submit, rejects empty, Enter submits | +| `src/components/__tests__/TodoList.test` | Renders all todos, shows empty message when none | +| `src/components/__tests__/TodoFilter.test` | Renders 3 buttons, highlights active, calls onFilterChange, shows counts | +| `src/hooks/__tests__/useTodos.test` | addTodo adds, toggleTodo flips, deleteTodo removes | +| `src/pages/__tests__/TodoPage.test` | Full flow: add → toggle → delete → filter | diff --git a/RUNNING.md b/RUNNING.md new file mode 100644 index 0000000..b9a1938 --- /dev/null +++ b/RUNNING.md @@ -0,0 +1,94 @@ +# Running the Todo Application + +A client-side React + TypeScript Todo application built with Vite. Manage your +tasks with add, complete, delete, and filter functionality. All data is persisted +in the browser via localStorage — no backend required. + +--- + +## Prerequisites + +- [Node.js](https://nodejs.org/) (v18 or later recommended) +- npm (bundled with Node.js) +- Optionally: [Docker](https://docs.docker.com/get-docker/) and + [Docker Compose](https://docs.docker.com/compose/install/) + +--- + +## Quick Start (npm) + +### 1. Install dependencies + +```bash +npm install +``` + +### 2. Start the development server + +```bash +npm run dev +``` + +The app will be available at ****. + +### 3. Run the tests + +```bash +npm test +``` + +This runs the full test suite via Vitest, including component and hook tests. + +--- + +## Quick Start (Docker) + +If you prefer a containerised environment: + +```bash +# Build and start the application +docker compose up --build + +# Open in your browser +# http://localhost:5173 +``` + +To stop: + +```bash +docker compose down +``` + +--- + +## Available npm Scripts + +| Command | Description | +| --------------- | -------------------------------------------- | +| `npm run dev` | Start the Vite development server | +| `npm run build` | Create a production build in `dist/` | +| `npm test` | Run the test suite with Vitest | + +--- + +## Application Overview + +This is a single-page Todo application that lets you: + +- **Add** new todo items (with whitespace trimming and empty-input rejection) +- **Toggle** completion status of individual todos +- **Delete** todos you no longer need +- **Filter** the list by All, Active, or Completed + +State is managed with React `useState` hooks inside the `TodoPage` component. +Todos are persisted to `localStorage` so they survive page refreshes. If +`localStorage` is unavailable (e.g. private browsing quota exceeded), the app +gracefully falls back to in-memory state. + +--- + +## Notes + +- No authentication is required — there is no backend dependency for the frontend. +- No demo credentials are needed. +- The frontend dev server runs on port **5173** by default. diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..172e5ec --- /dev/null +++ b/SETUP.md @@ -0,0 +1,31 @@ +# Setup — Installing Dependencies + +This project has both Python (backend) and Node.js (frontend) dependencies. +Do **not** commit lock files — generate them locally. + +## Frontend (Node.js) + +```bash +# Install Node.js dependencies (generates node_modules/ and lock file) +npm install +``` + +## Python (Backend) + +```bash +# Create a virtual environment and install dependencies +python -m venv .venv +source .venv/bin/activate # On Windows: .venv\Scripts\activate +pip install fastapi uvicorn pydantic +pip install pytest pytest-timeout httpx # dev dependencies +``` + +## Running Tests + +```bash +# Frontend tests +npm test + +# Backend tests +pytest +``` diff --git a/coverage.xml b/coverage.xml new file mode 100644 index 0000000..da5dadd --- /dev/null +++ b/coverage.xml @@ -0,0 +1,203 @@ + + + + + + /tmp/forge-repos/bb269cba-4eb2-4ab1-8558-e4f174c396fe/7fcd9140-9c4b-4ae5-b06a-2bc5d8244b2f + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/TodoFilter.test.tsx b/frontend/src/components/TodoFilter.test.tsx new file mode 100644 index 0000000..bf6505b --- /dev/null +++ b/frontend/src/components/TodoFilter.test.tsx @@ -0,0 +1,76 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import TodoFilter, { FilterType } from './TodoFilter'; + +const defaultProps = { + currentFilter: 'all' as FilterType, + onFilterChange: vi.fn(), + remainingCount: 3, +}; + +describe('TodoFilter', () => { + it('renders all three filter buttons', () => { + render(); + + expect(screen.getByRole('button', { name: 'All' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Active' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Completed' })).toBeInTheDocument(); + }); + + it('highlights the active filter button', () => { + render(); + + const allButton = screen.getByRole('button', { name: 'All' }); + const activeButton = screen.getByRole('button', { name: 'Active' }); + const completedButton = screen.getByRole('button', { name: 'Completed' }); + + expect(activeButton).toHaveAttribute('aria-pressed', 'true'); + expect(activeButton.className).toContain('todo-filter-button--active'); + + expect(allButton).toHaveAttribute('aria-pressed', 'false'); + expect(allButton.className).not.toContain('todo-filter-button--active'); + + expect(completedButton).toHaveAttribute('aria-pressed', 'false'); + expect(completedButton.className).not.toContain('todo-filter-button--active'); + }); + + it('calls onFilterChange when a filter button is clicked', async () => { + const onFilterChange = vi.fn(); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: 'Completed' })); + expect(onFilterChange).toHaveBeenCalledTimes(1); + expect(onFilterChange).toHaveBeenCalledWith('completed'); + + await user.click(screen.getByRole('button', { name: 'Active' })); + expect(onFilterChange).toHaveBeenCalledTimes(2); + expect(onFilterChange).toHaveBeenCalledWith('active'); + + await user.click(screen.getByRole('button', { name: 'All' })); + expect(onFilterChange).toHaveBeenCalledTimes(3); + expect(onFilterChange).toHaveBeenCalledWith('all'); + }); + + it('displays the remaining count correctly', () => { + render(); + expect(screen.getByTestId('remaining-count')).toHaveTextContent('5 items left'); + }); + + it('uses singular "item" when remainingCount is 1', () => { + render(); + expect(screen.getByTestId('remaining-count')).toHaveTextContent('1 item left'); + }); + + it('handles zero remaining count', () => { + render(); + expect(screen.getByTestId('remaining-count')).toHaveTextContent('0 items left'); + }); + + it('renders without crashing with default props', () => { + const { container } = render(); + expect(container.querySelector('.todo-filter')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/TodoFilter.tsx b/frontend/src/components/TodoFilter.tsx new file mode 100644 index 0000000..ba6b2d7 --- /dev/null +++ b/frontend/src/components/TodoFilter.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +export type FilterType = 'all' | 'active' | 'completed'; + +export interface TodoFilterProps { + currentFilter: FilterType; + onFilterChange: (filter: FilterType) => void; + remainingCount: number; +} + +const FILTERS: { label: string; value: FilterType }[] = [ + { label: 'All', value: 'all' }, + { label: 'Active', value: 'active' }, + { label: 'Completed', value: 'completed' }, +]; + +const TodoFilter: React.FC = ({ + currentFilter, + onFilterChange, + remainingCount, +}) => { + return ( +
+ + {remainingCount} {remainingCount === 1 ? 'item' : 'items'} left + +
+ {FILTERS.map(({ label, value }) => ( + + ))} +
+
+ ); +}; + +export default TodoFilter; diff --git a/frontend/src/components/TodoInput.test.tsx b/frontend/src/components/TodoInput.test.tsx new file mode 100644 index 0000000..bdab0de --- /dev/null +++ b/frontend/src/components/TodoInput.test.tsx @@ -0,0 +1,88 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import TodoInput from './TodoInput'; + +describe('TodoInput', () => { + it('renders an input and an Add button', () => { + const onAdd = vi.fn(); + render(); + + expect(screen.getByRole('textbox', { name: /todo text/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /add/i })).toBeInTheDocument(); + }); + + it('updates the input value when typing', async () => { + const onAdd = vi.fn(); + render(); + + const input = screen.getByRole('textbox', { name: /todo text/i }); + await userEvent.type(input, 'Buy groceries'); + + expect(input).toHaveValue('Buy groceries'); + }); + + it('calls onAdd with trimmed text when the Add button is clicked', async () => { + const onAdd = vi.fn(); + render(); + + const input = screen.getByRole('textbox', { name: /todo text/i }); + const button = screen.getByRole('button', { name: /add/i }); + + await userEvent.type(input, ' Buy groceries '); + await userEvent.click(button); + + expect(onAdd).toHaveBeenCalledTimes(1); + expect(onAdd).toHaveBeenCalledWith('Buy groceries'); + }); + + it('calls onAdd with trimmed text when Enter is pressed', async () => { + const onAdd = vi.fn(); + render(); + + const input = screen.getByRole('textbox', { name: /todo text/i }); + + await userEvent.type(input, ' Walk the dog '); + await userEvent.type(input, '{Enter}'); + + expect(onAdd).toHaveBeenCalledTimes(1); + expect(onAdd).toHaveBeenCalledWith('Walk the dog'); + }); + + it('clears the input after a successful submit', async () => { + const onAdd = vi.fn(); + render(); + + const input = screen.getByRole('textbox', { name: /todo text/i }); + const button = screen.getByRole('button', { name: /add/i }); + + await userEvent.type(input, 'Read a book'); + await userEvent.click(button); + + expect(input).toHaveValue(''); + }); + + it('does not call onAdd when text is empty', async () => { + const onAdd = vi.fn(); + render(); + + const button = screen.getByRole('button', { name: /add/i }); + await userEvent.click(button); + + expect(onAdd).not.toHaveBeenCalled(); + }); + + it('does not call onAdd when text is only whitespace', async () => { + const onAdd = vi.fn(); + render(); + + const input = screen.getByRole('textbox', { name: /todo text/i }); + const button = screen.getByRole('button', { name: /add/i }); + + await userEvent.type(input, ' '); + await userEvent.click(button); + + expect(onAdd).not.toHaveBeenCalled(); + expect(input).toHaveValue(' '); + }); +}); diff --git a/frontend/src/components/TodoInput.tsx b/frontend/src/components/TodoInput.tsx new file mode 100644 index 0000000..500b136 --- /dev/null +++ b/frontend/src/components/TodoInput.tsx @@ -0,0 +1,72 @@ +import React, { useState, useCallback } from 'react'; + +export interface TodoInputProps { + /** Called with the trimmed input text when the user submits a non-empty value. */ + onAdd: (text: string) => void; +} + +const styles: Record = { + container: { + display: 'flex', + gap: '8px', + width: '100%', + }, + input: { + flex: 1, + padding: '8px 12px', + fontSize: '16px', + border: '1px solid #ccc', + borderRadius: '4px', + outline: 'none', + }, + button: { + padding: '8px 16px', + fontSize: '16px', + backgroundColor: '#4CAF50', + color: '#fff', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + }, +}; + +const TodoInput: React.FC = ({ onAdd }) => { + const [text, setText] = useState(''); + + const handleSubmit = useCallback(() => { + const trimmed = text.trim(); + if (trimmed.length === 0) { + return; + } + onAdd(trimmed); + setText(''); + }, [text, onAdd]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleSubmit(); + } + }, + [handleSubmit], + ); + + return ( +
+ setText(e.target.value)} + onKeyDown={handleKeyDown} + style={styles.input} + aria-label="Todo text" + /> + +
+ ); +}; + +export default TodoInput; diff --git a/frontend/src/components/TodoItem.test.tsx b/frontend/src/components/TodoItem.test.tsx new file mode 100644 index 0000000..7bd02ab --- /dev/null +++ b/frontend/src/components/TodoItem.test.tsx @@ -0,0 +1,69 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import TodoItem, { Todo, TodoItemProps } from "./TodoItem"; + +const baseTodo: Todo = { + id: "todo-1", + text: "Buy groceries", + completed: false, + createdAt: Date.now(), +}; + +const renderTodoItem = (overrides: Partial = {}) => { + const defaultProps: TodoItemProps = { + todo: baseTodo, + onToggle: vi.fn(), + onDelete: vi.fn(), + ...overrides, + }; + return { ...render(), props: defaultProps }; +}; + +describe("TodoItem", () => { + it("renders todo text", () => { + renderTodoItem(); + expect(screen.getByText("Buy groceries")).toBeInTheDocument(); + }); + + it("checkbox reflects completed state when false", () => { + renderTodoItem(); + const checkbox = screen.getByRole("checkbox") as HTMLInputElement; + expect(checkbox.checked).toBe(false); + }); + + it("checkbox reflects completed state when true", () => { + const completedTodo: Todo = { ...baseTodo, completed: true }; + renderTodoItem({ todo: completedTodo }); + const checkbox = screen.getByRole("checkbox") as HTMLInputElement; + expect(checkbox.checked).toBe(true); + }); + + it("calls onToggle with todo id when checkbox is clicked", () => { + const onToggle = vi.fn(); + renderTodoItem({ onToggle }); + fireEvent.click(screen.getByRole("checkbox")); + expect(onToggle).toHaveBeenCalledTimes(1); + expect(onToggle).toHaveBeenCalledWith("todo-1"); + }); + + it("calls onDelete with todo id when delete button is clicked", () => { + const onDelete = vi.fn(); + renderTodoItem({ onDelete }); + fireEvent.click(screen.getByRole("button", { name: /delete/i })); + expect(onDelete).toHaveBeenCalledTimes(1); + expect(onDelete).toHaveBeenCalledWith("todo-1"); + }); + + it("completed todo has line-through text decoration", () => { + const completedTodo: Todo = { ...baseTodo, completed: true }; + renderTodoItem({ todo: completedTodo }); + const textEl = screen.getByTestId("todo-text"); + expect(textEl.style.textDecoration).toBe("line-through"); + }); + + it("incomplete todo has no line-through text decoration", () => { + renderTodoItem(); + const textEl = screen.getByTestId("todo-text"); + expect(textEl.style.textDecoration).toBe("none"); + }); +}); diff --git a/frontend/src/components/TodoItem.tsx b/frontend/src/components/TodoItem.tsx new file mode 100644 index 0000000..961d486 --- /dev/null +++ b/frontend/src/components/TodoItem.tsx @@ -0,0 +1,60 @@ +import React from "react"; + +export interface Todo { + id: string; + text: string; + completed: boolean; + createdAt: number; +} + +export interface TodoItemProps { + todo: Todo; + onToggle: (id: string) => void; + onDelete: (id: string) => void; +} + +const TodoItem: React.FC = ({ todo, onToggle, onDelete }) => { + const handleToggle = () => { + onToggle(todo.id); + }; + + const handleDelete = () => { + onDelete(todo.id); + }; + + return ( +
  • + + + {todo.text} + + +
  • + ); +}; + +export default TodoItem; diff --git a/frontend/src/components/TodoList.test.tsx b/frontend/src/components/TodoList.test.tsx new file mode 100644 index 0000000..6170453 --- /dev/null +++ b/frontend/src/components/TodoList.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import TodoList, { Todo, TodoListProps } from './TodoList'; + +const makeTodo = (overrides: Partial = {}): Todo => ({ + id: 'todo-1', + text: 'Buy groceries', + completed: false, + createdAt: Date.now(), + ...overrides, +}); + +const renderTodoList = (props: Partial = {}) => { + const defaultProps: TodoListProps = { + todos: [], + onToggle: vi.fn(), + onDelete: vi.fn(), + ...props, + }; + return { ...render(), props: defaultProps }; +}; + +describe('TodoList', () => { + it('renders without crashing', () => { + renderTodoList(); + }); + + it('shows empty message when todos array is empty', () => { + renderTodoList({ todos: [] }); + expect(screen.getByTestId('empty-message')).toBeInTheDocument(); + expect(screen.getByText('No todos yet!')).toBeInTheDocument(); + }); + + it('does not show empty message when todos exist', () => { + renderTodoList({ todos: [makeTodo()] }); + expect(screen.queryByTestId('empty-message')).not.toBeInTheDocument(); + }); + + it('renders the correct number of todo items', () => { + const todos: Todo[] = [ + makeTodo({ id: '1', text: 'First' }), + makeTodo({ id: '2', text: 'Second' }), + makeTodo({ id: '3', text: 'Third' }), + ]; + renderTodoList({ todos }); + expect(screen.getByTestId('todo-item-1')).toBeInTheDocument(); + expect(screen.getByTestId('todo-item-2')).toBeInTheDocument(); + expect(screen.getByTestId('todo-item-3')).toBeInTheDocument(); + }); + + it('renders todo text for each item', () => { + const todos: Todo[] = [ + makeTodo({ id: '1', text: 'Buy milk' }), + makeTodo({ id: '2', text: 'Walk the dog' }), + ]; + renderTodoList({ todos }); + expect(screen.getByText('Buy milk')).toBeInTheDocument(); + expect(screen.getByText('Walk the dog')).toBeInTheDocument(); + }); + + it('calls onToggle with the correct id when checkbox is clicked', () => { + const onToggle = vi.fn(); + const todos: Todo[] = [makeTodo({ id: 'abc-123', text: 'Test todo' })]; + renderTodoList({ todos, onToggle }); + + fireEvent.click(screen.getByTestId('todo-toggle-abc-123')); + expect(onToggle).toHaveBeenCalledTimes(1); + expect(onToggle).toHaveBeenCalledWith('abc-123'); + }); + + it('calls onDelete with the correct id when delete button is clicked', () => { + const onDelete = vi.fn(); + const todos: Todo[] = [makeTodo({ id: 'def-456', text: 'Delete me' })]; + renderTodoList({ todos, onDelete }); + + fireEvent.click(screen.getByTestId('todo-delete-def-456')); + expect(onDelete).toHaveBeenCalledTimes(1); + expect(onDelete).toHaveBeenCalledWith('def-456'); + }); + + it('renders completed todos with a checked checkbox', () => { + const todos: Todo[] = [makeTodo({ id: '1', text: 'Done', completed: true })]; + renderTodoList({ todos }); + const checkbox = screen.getByTestId('todo-toggle-1') as HTMLInputElement; + expect(checkbox.checked).toBe(true); + }); + + it('renders incomplete todos with an unchecked checkbox', () => { + const todos: Todo[] = [makeTodo({ id: '1', text: 'Not done', completed: false })]; + renderTodoList({ todos }); + const checkbox = screen.getByTestId('todo-toggle-1') as HTMLInputElement; + expect(checkbox.checked).toBe(false); + }); +}); diff --git a/frontend/src/components/TodoList.tsx b/frontend/src/components/TodoList.tsx new file mode 100644 index 0000000..728f869 --- /dev/null +++ b/frontend/src/components/TodoList.tsx @@ -0,0 +1,51 @@ +import React from 'react'; + +export interface Todo { + id: string; + text: string; + completed: boolean; + createdAt: number; +} + +export interface TodoListProps { + todos: Todo[]; + onToggle: (id: string) => void; + onDelete: (id: string) => void; +} + +const TodoList: React.FC = ({ todos = [], onToggle, onDelete }) => { + if (todos.length === 0) { + return

    No todos yet!

    ; + } + + return ( +
      + {todos.map((todo) => ( +
    • + + +
    • + ))} +
    + ); +}; + +export default TodoList; diff --git a/index.html b/index.html new file mode 100644 index 0000000..c92cebd --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Todo App + + +
    + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..9455214 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "todo-app-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.1.0", + "@testing-library/react": "^14.1.0", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "jsdom": "^23.0.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vitest": "^1.1.0" + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5edaa78 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "todo-app" +version = "1.0.0" +description = "A simple Todo application with React frontend and FastAPI backend" +requires-python = ">=3.11" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..ce7d32b --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,24 @@ +/** + * Root application shell. + * + * Renders the main TodoPage component. Acts as the top-level layout + * wrapper for the application. + */ +import React from 'react'; +import TodoPage from './pages/TodoPage'; + +/** + * App is the root component that wraps the entire application. + * + * It provides a consistent outer container (`div.app`) and delegates + * all todo-related rendering and state management to TodoPage. + */ +const App: React.FC = () => { + return ( +
    + +
    + ); +}; + +export default App; diff --git a/src/components/TodoFilter.tsx b/src/components/TodoFilter.tsx new file mode 100644 index 0000000..5a1ebd3 --- /dev/null +++ b/src/components/TodoFilter.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { FilterType } from '../types/todo'; + +interface TodoFilterProps { + currentFilter: FilterType; + onFilterChange: (filter: FilterType) => void; + remainingCount?: number; +} + +const FILTERS: FilterType[] = ['all', 'active', 'completed']; + +const TodoFilter: React.FC = ({ + currentFilter, + onFilterChange, + remainingCount = 0, +}) => { + return ( +
    + + {remainingCount} {remainingCount === 1 ? 'item' : 'items'} left + +
    + {FILTERS.map((filter) => ( + + ))} +
    +
    + ); +}; + +export default TodoFilter; diff --git a/src/components/TodoInput.tsx b/src/components/TodoInput.tsx new file mode 100644 index 0000000..3328c5d --- /dev/null +++ b/src/components/TodoInput.tsx @@ -0,0 +1,32 @@ +import React, { useState } from 'react'; + +interface TodoInputProps { + onAdd: (text: string) => void; +} + +const TodoInput: React.FC = ({ onAdd }) => { + const [text, setText] = useState(''); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = text.trim(); + if (!trimmed) return; + onAdd(trimmed); + setText(''); + }; + + return ( +
    + setText(e.target.value)} + placeholder="What needs to be done?" + aria-label="Todo text" + /> + +
    + ); +}; + +export default TodoInput; diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx new file mode 100644 index 0000000..460f3af --- /dev/null +++ b/src/components/TodoItem.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { Todo } from '../types/todo'; + +interface TodoItemProps { + todo: Todo; + onToggle: (id: string) => void; + onDelete: (id: string) => void; +} + +const TodoItem: React.FC = ({ todo, onToggle, onDelete }) => { + return ( +
  • + onToggle(todo.id)} + aria-label={`Toggle ${todo.text}`} + /> + + {todo.text} + + +
  • + ); +}; + +export default TodoItem; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 0000000..1744230 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Todo } from '../types/todo'; +import TodoItem from './TodoItem'; + +interface TodoListProps { + todos?: Todo[]; + onToggle: (id: string) => void; + onDelete: (id: string) => void; +} + +const TodoList: React.FC = ({ todos = [], onToggle, onDelete }) => { + if (todos.length === 0) { + return

    No todos to display

    ; + } + + return ( +
      + {todos.map((todo) => ( + + ))} +
    + ); +}; + +export default TodoList; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..34f0c58 --- /dev/null +++ b/src/index.css @@ -0,0 +1,46 @@ +/* Global reset and base styles */ + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif; + line-height: 1.6; + color: #1a1a1a; + background-color: #f5f5f5; + display: flex; + justify-content: center; + min-height: 100vh; + padding: 2rem 1rem; +} + +#root { + width: 100%; + max-width: 600px; +} + +.app { + width: 100%; +} + +.todo-page { + text-align: center; +} + +h1 { + font-size: 2rem; + margin-bottom: 1.5rem; + color: #333; +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..a3b4a60 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,21 @@ +/** + * Application entry point. + * + * Mounts the React component tree into the #root DOM element. + */ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +const rootElement = document.getElementById('root'); + +if (!rootElement) { + throw new Error('Root element not found. Ensure index.html contains a
    .'); +} + +ReactDOM.createRoot(rootElement).render( + + + , +); diff --git a/src/pages/TodoPage.tsx b/src/pages/TodoPage.tsx new file mode 100644 index 0000000..62963c2 --- /dev/null +++ b/src/pages/TodoPage.tsx @@ -0,0 +1,233 @@ +/** + * TodoPage – main page component for the Todo application. + * + * Manages the full todo lifecycle: adding, toggling, deleting, and + * filtering todo items. State is persisted to localStorage when + * available, with a graceful fallback to in-memory state. + * + * This is a self-contained page component. Presentational sub-components + * (TodoInput, TodoList, TodoItem, TodoFilter) will be extracted in a + * subsequent task; for now all rendering lives here to ensure the app + * compiles and runs end-to-end. + */ +import React, { useState, useEffect, useCallback } from 'react'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Represents a single todo item. */ +interface Todo { + /** Unique identifier (crypto.randomUUID or fallback). */ + id: string; + /** User-supplied text for the todo. */ + text: string; + /** Whether the todo has been completed. */ + completed: boolean; + /** Unix-epoch millisecond timestamp of creation. */ + createdAt: number; +} + +/** The three possible filter states. */ +type FilterType = 'all' | 'active' | 'completed'; + +// --------------------------------------------------------------------------- +// localStorage helpers +// --------------------------------------------------------------------------- + +const STORAGE_KEY = 'todo-app-todos'; + +/** + * Attempt to read todos from localStorage. + * + * Returns the parsed array on success, or `null` when localStorage is + * unavailable or the stored value is unparseable. + */ +function loadTodos(): Todo[] | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === null) return null; + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed as Todo[]; + return null; + } catch { + return null; + } +} + +/** + * Persist the given todos array to localStorage. + * + * Silently ignores errors (e.g. quota exceeded in private browsing). + */ +function saveTodos(todos: Todo[]): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(todos)); + } catch { + // Graceful fallback – state remains in memory only. + } +} + +// --------------------------------------------------------------------------- +// ID generation helper +// --------------------------------------------------------------------------- + +/** + * Generate a unique id string. + * + * Uses `crypto.randomUUID()` when available (modern browsers), falling + * back to a simple timestamp + random suffix. + */ +function generateId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; +} + +// --------------------------------------------------------------------------- +// TodoPage component +// --------------------------------------------------------------------------- + +/** + * TodoPage is the top-level page that assembles the entire Todo UI. + * + * It owns the `todos` and `filter` state and will eventually delegate + * rendering to extracted child components. + */ +const TodoPage: React.FC = () => { + const [todos, setTodos] = useState(() => loadTodos() ?? []); + const [filter, setFilter] = useState('all'); + const [inputValue, setInputValue] = useState(''); + + // Persist todos to localStorage whenever they change. + useEffect(() => { + saveTodos(todos); + }, [todos]); + + // -- Handlers ------------------------------------------------------------- + + /** Add a new todo. Trims whitespace and rejects empty strings. */ + const handleAdd = useCallback(() => { + const trimmed = inputValue.trim(); + if (trimmed.length === 0) return; + + const newTodo: Todo = { + id: generateId(), + text: trimmed, + completed: false, + createdAt: Date.now(), + }; + + // Prepend – most recent first. + setTodos((prev) => [newTodo, ...prev]); + setInputValue(''); + }, [inputValue]); + + /** Toggle the completed flag of a todo by id. */ + const handleToggle = useCallback((id: string) => { + setTodos((prev) => + prev.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)), + ); + }, []); + + /** Delete a todo by id. */ + const handleDelete = useCallback((id: string) => { + setTodos((prev) => prev.filter((t) => t.id !== id)); + }, []); + + /** Handle Enter key in the input field. */ + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleAdd(); + }, + [handleAdd], + ); + + // -- Derived data --------------------------------------------------------- + + const filteredTodos: Todo[] = todos.filter((t) => { + if (filter === 'active') return !t.completed; + if (filter === 'completed') return t.completed; + return true; + }); + + const activeTodoCount: number = todos.filter((t) => !t.completed).length; + const completedTodoCount: number = todos.filter((t) => t.completed).length; + + // -- Render --------------------------------------------------------------- + + const filters: FilterType[] = ['all', 'active', 'completed']; + + return ( +
    +

    Todo App

    + + {/* Input */} +
    + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + aria-label="New todo text" + /> + +
    + + {/* Filters */} +
    + {filters.map((f) => ( + + ))} + + {activeTodoCount} active / {completedTodoCount} completed + +
    + + {/* List */} +
      + {filteredTodos.length === 0 ? ( +
    • No todos to show.
    • + ) : ( + filteredTodos.map((todo) => ( +
    • + handleToggle(todo.id)} + aria-label={`Toggle ${todo.text}`} + /> + + {todo.text} + + +
    • + )) + )} +
    +
    + ); +}; + +export default TodoPage; diff --git a/src/setupTests.ts b/src/setupTests.ts new file mode 100644 index 0000000..90132e9 --- /dev/null +++ b/src/setupTests.ts @@ -0,0 +1,7 @@ +/** + * Test setup file for Vitest. + * + * Imports jest-dom matchers so they are available in all test files + * without explicit imports. + */ +import '@testing-library/jest-dom'; diff --git a/src/todo_app.egg-info/PKG-INFO b/src/todo_app.egg-info/PKG-INFO new file mode 100644 index 0000000..f2cdcd7 --- /dev/null +++ b/src/todo_app.egg-info/PKG-INFO @@ -0,0 +1,5 @@ +Metadata-Version: 2.4 +Name: todo-app +Version: 1.0.0 +Summary: A simple Todo application with React frontend and FastAPI backend +Requires-Python: >=3.11 diff --git a/src/todo_app.egg-info/SOURCES.txt b/src/todo_app.egg-info/SOURCES.txt new file mode 100644 index 0000000..becdbad --- /dev/null +++ b/src/todo_app.egg-info/SOURCES.txt @@ -0,0 +1,9 @@ +README.md +pyproject.toml +src/todo_app.egg-info/PKG-INFO +src/todo_app.egg-info/SOURCES.txt +src/todo_app.egg-info/dependency_links.txt +src/todo_app.egg-info/top_level.txt +tests/test_frontend_setup.py +tests/test_todo_helpers.py +tests/test_todo_types.py \ No newline at end of file diff --git a/src/todo_app.egg-info/dependency_links.txt b/src/todo_app.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/todo_app.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/todo_app.egg-info/top_level.txt b/src/todo_app.egg-info/top_level.txt new file mode 100644 index 0000000..5a304b0 --- /dev/null +++ b/src/todo_app.egg-info/top_level.txt @@ -0,0 +1,4 @@ +components +pages +types +utils diff --git a/src/types/todo.ts b/src/types/todo.ts new file mode 100644 index 0000000..91d367b --- /dev/null +++ b/src/types/todo.ts @@ -0,0 +1,8 @@ +export interface Todo { + id: string; + text: string; + completed: boolean; + createdAt: number; +} + +export type FilterType = 'all' | 'active' | 'completed'; diff --git a/src/utils/filterTodos.ts b/src/utils/filterTodos.ts new file mode 100644 index 0000000..54e0a90 --- /dev/null +++ b/src/utils/filterTodos.ts @@ -0,0 +1,13 @@ +import { Todo, FilterType } from '../types/todo'; + +export function filterTodos(todos: Todo[], filter: FilterType): Todo[] { + switch (filter) { + case 'active': + return todos.filter((todo) => !todo.completed); + case 'completed': + return todos.filter((todo) => todo.completed); + case 'all': + default: + return todos; + } +} diff --git a/src/utils/todoHelpers.ts b/src/utils/todoHelpers.ts new file mode 100644 index 0000000..fe98be0 --- /dev/null +++ b/src/utils/todoHelpers.ts @@ -0,0 +1,82 @@ +import type { Todo, FilterType } from '../types/todo'; + +/** + * Generate a unique identifier string. + * + * Uses `crypto.randomUUID()` when available (modern browsers / Node 19+), + * falling back to a timestamp + random-number approach. + * + * @returns A unique string suitable for use as a Todo id. + */ +export function generateId(): string { + if ( + typeof crypto !== 'undefined' && + typeof crypto.randomUUID === 'function' + ) { + return crypto.randomUUID(); + } + // Fallback for environments without crypto.randomUUID + return `${Date.now().toString(36)}-${Math.random().toString(36).substring(2, 9)}`; +} + +/** + * Create a new Todo object from the given text. + * + * The new todo is incomplete by default and stamped with the current time. + * + * @param text - The display text for the todo. Will be trimmed. + * @returns A fully-formed Todo object. + * @throws {Error} If text is empty or whitespace-only after trimming. + */ +export function createTodo(text: string): Todo { + const trimmed = text.trim(); + if (trimmed.length === 0) { + throw new Error('Todo text must not be empty'); + } + return { + id: generateId(), + text: trimmed, + completed: false, + createdAt: Date.now(), + }; +} + +/** + * Return a new Todo with the `completed` field toggled. + * + * This is a pure function — the original todo is not mutated. + * + * @param todo - The todo to toggle. + * @returns A new Todo object with `completed` flipped. + */ +export function toggleTodo(todo: Todo): Todo { + return { ...todo, completed: !todo.completed }; +} + +/** + * Filter an array of todos by the given filter type. + * + * - `'all'` → returns every todo. + * - `'active'` → returns only todos where `completed` is `false`. + * - `'completed'` → returns only todos where `completed` is `true`. + * + * This is a pure function — the original array is not mutated. + * + * @param todos - The full list of todos. Defaults to `[]` if not provided. + * @param filter - The filter mode to apply. Defaults to `'all'`. + * @returns A new array containing only the matching todos. + */ +export function filterTodos( + todos: Todo[] = [], + filter: FilterType = 'all', +): Todo[] { + switch (filter) { + case 'active': + return todos.filter((todo) => !todo.completed); + case 'completed': + return todos.filter((todo) => todo.completed); + case 'all': + default: + return [...todos]; + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/test-results.xml b/test-results.xml new file mode 100644 index 0000000..efa65cc --- /dev/null +++ b/test-results.xml @@ -0,0 +1,13 @@ +tests/test_frontend_setup.py:61: in test_tsconfig_has_jsx + data = json.loads((ROOT / "tsconfig.json").read_text()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +/usr/local/lib/python3.12/json/__init__.py:346: in loads + return _default_decoder.decode(s) + ^^^^^^^^^^^^^^^^^^^^^^^^^^ +/usr/local/lib/python3.12/json/decoder.py:338: in decode + obj, end = self.raw_decode(s, idx=_w(s, 0).end()) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +/usr/local/lib/python3.12/json/decoder.py:354: in raw_decode + obj, end = self.scan_once(s, idx) + ^^^^^^^^^^^^^^^^^^^^^^ +E json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 9 column 5 (char 187) \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_app_renders.tsx b/tests/test_app_renders.tsx new file mode 100644 index 0000000..9738ff0 --- /dev/null +++ b/tests/test_app_renders.tsx @@ -0,0 +1,21 @@ +/** + * Smoke test for the root App component. + * + * Verifies that App renders without crashing and displays the + * expected heading from the TodoPage placeholder. + */ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import App from '../src/App'; + +describe('App', () => { + it('renders the todo app heading', () => { + render(); + expect(screen.getByText('Todo App')).toBeInTheDocument(); + }); + + it('renders the placeholder message', () => { + render(); + expect(screen.getByText('Start adding your todos!')).toBeInTheDocument(); + }); +}); diff --git a/tests/test_app_structure.py b/tests/test_app_structure.py new file mode 100644 index 0000000..7cfb68c --- /dev/null +++ b/tests/test_app_structure.py @@ -0,0 +1,159 @@ +"""Structural tests for the React Todo application. + +Verify that the critical source files exist and contain the expected +import/export signatures so the app compiles end-to-end. +""" + +import os +from pathlib import Path +from typing import List + +import pytest + +# Repository root is two levels up from tests/ +ROOT: Path = Path(__file__).resolve().parent.parent + + +def _read(rel_path: str) -> str: + """Read a file relative to the repository root and return its contents.""" + full = ROOT / rel_path + assert full.exists(), f"Expected file not found: {rel_path}" + return full.read_text(encoding="utf-8") + + +class TestCriticalFilesExist: + """Ensure the minimum set of files required for compilation are present.""" + + @pytest.mark.parametrize( + "rel_path", + [ + "src/App.tsx", + "src/pages/TodoPage.tsx", + "RUNNING.md", + "ARCHITECTURE.md", + ], + ) + def test_file_exists(self, rel_path: str) -> None: + """Verify that *rel_path* exists relative to the repo root.""" + full = ROOT / rel_path + assert full.exists(), f"Missing file: {rel_path}" + + +class TestAppTsx: + """Verify src/App.tsx imports and renders TodoPage correctly.""" + + def test_imports_todo_page(self) -> None: + """App.tsx must import TodoPage from the pages directory.""" + content = _read("src/App.tsx") + assert "import TodoPage from './pages/TodoPage'" in content + + def test_renders_todo_page(self) -> None: + """App.tsx must render the component.""" + content = _read("src/App.tsx") + assert "" in content or "" in content + + def test_exports_default(self) -> None: + """App.tsx must have a default export.""" + content = _read("src/App.tsx") + assert "export default App" in content + + +class TestTodoPage: + """Verify src/pages/TodoPage.tsx is a self-contained, compilable component.""" + + def test_exports_default(self) -> None: + """TodoPage.tsx must have a default export.""" + content = _read("src/pages/TodoPage.tsx") + assert "export default TodoPage" in content + + def test_does_not_import_nonexistent_components(self) -> None: + """TodoPage must not import from ../components/* until those files exist.""" + content = _read("src/pages/TodoPage.tsx") + # If TodoPage imports from ../components, verify each imported file exists. + import re + + matches: List[str] = re.findall( + r"from\s+['\"](\.\./components/\w+)['\"]", content + ) + for rel_import in matches: + # Convert relative import to file path from src/pages/ + resolved = (ROOT / "src" / "pages" / rel_import).with_suffix(".tsx") + assert resolved.exists(), ( + f"TodoPage.tsx imports '{rel_import}' but {resolved} does not exist" + ) + + def test_contains_todo_interface(self) -> None: + """TodoPage.tsx must define or import a Todo type.""" + content = _read("src/pages/TodoPage.tsx") + assert "Todo" in content + + def test_uses_usestate(self) -> None: + """TodoPage.tsx must use useState for state management.""" + content = _read("src/pages/TodoPage.tsx") + assert "useState" in content + + +class TestRunningMd: + """Verify RUNNING.md contains the required setup instructions.""" + + def test_contains_npm_install(self) -> None: + """RUNNING.md must document 'npm install'.""" + content = _read("RUNNING.md") + assert "npm install" in content + + def test_contains_npm_run_dev(self) -> None: + """RUNNING.md must document 'npm run dev'.""" + content = _read("RUNNING.md") + assert "npm run dev" in content + + def test_contains_npm_test(self) -> None: + """RUNNING.md must document 'npm test'.""" + content = _read("RUNNING.md") + assert "npm test" in content + + def test_contains_app_description(self) -> None: + """RUNNING.md must contain a description of the application.""" + content = _read("RUNNING.md") + assert "Todo" in content + assert "application" in content.lower() or "app" in content.lower() + + def test_mentions_localhost_5173(self) -> None: + """RUNNING.md must mention the default dev server URL.""" + content = _read("RUNNING.md") + assert "5173" in content + + +class TestArchitectureMd: + """Verify ARCHITECTURE.md contains key architectural decisions.""" + + def test_contains_file_structure_section(self) -> None: + """ARCHITECTURE.md must have a File Structure section.""" + content = _read("ARCHITECTURE.md") + assert "File Structure" in content or "file structure" in content.lower() + + def test_defines_todo_interface(self) -> None: + """ARCHITECTURE.md must define the Todo interface with required fields.""" + content = _read("ARCHITECTURE.md") + for field in ["id: string", "text: string", "completed: boolean", "createdAt: number"]: + assert field in content, f"ARCHITECTURE.md missing Todo field: {field}" + + def test_defines_filter_type(self) -> None: + """ARCHITECTURE.md must define the FilterType union.""" + content = _read("ARCHITECTURE.md") + assert "FilterType" in content + assert "'all'" in content + assert "'active'" in content + assert "'completed'" in content + + def test_lists_source_files(self) -> None: + """ARCHITECTURE.md must reference at least 14 source file paths.""" + content = _read("ARCHITECTURE.md") + # Count .tsx and .ts file references (excluding markdown code fence language hints) + import re + + file_refs = re.findall(r"\S+\.tsx?\b", content) + # Deduplicate + unique = set(file_refs) + assert len(unique) >= 14, ( + f"Expected at least 14 unique .ts/.tsx file references, found {len(unique)}: {unique}" + ) diff --git a/tests/test_frontend_setup.py b/tests/test_frontend_setup.py new file mode 100644 index 0000000..7feec1b --- /dev/null +++ b/tests/test_frontend_setup.py @@ -0,0 +1,158 @@ +"""Tests to verify the frontend project setup files exist and are well-formed.""" + +import json +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent + + +def test_package_json_exists() -> None: + """package.json must exist at the repository root.""" + assert (ROOT / "package.json").is_file() + + +def test_package_json_has_required_dependencies() -> None: + """package.json must list all required dependencies.""" + data = json.loads((ROOT / "package.json").read_text()) + deps = set(data.get("dependencies", {}).keys()) + dev_deps = set(data.get("devDependencies", {}).keys()) + all_deps = deps | dev_deps + + required = { + "react", + "react-dom", + "typescript", + "vite", + "@vitejs/plugin-react", + "vitest", + "@testing-library/react", + "@testing-library/jest-dom", + } + missing = required - all_deps + assert not missing, f"Missing dependencies in package.json: {missing}" + + +def test_vite_config_exists() -> None: + """vite.config.ts must exist at the repository root.""" + assert (ROOT / "vite.config.ts").is_file() + + +def test_vite_config_contains_react_plugin() -> None: + """vite.config.ts must import and use the React plugin.""" + content = (ROOT / "vite.config.ts").read_text() + assert "@vitejs/plugin-react" in content + assert "react()" in content + + +def test_tsconfig_exists() -> None: + """tsconfig.json must exist at the repository root.""" + assert (ROOT / "tsconfig.json").is_file() + + +def test_tsconfig_node_exists() -> None: + """tsconfig.node.json must exist at the repository root.""" + assert (ROOT / "tsconfig.node.json").is_file() + + +def test_tsconfig_has_jsx() -> None: + """tsconfig.json must enable JSX for React.""" + data = json.loads((ROOT / "tsconfig.json").read_text()) + jsx = data.get("compilerOptions", {}).get("jsx", "") + assert "react" in jsx.lower(), f"Expected react-jsx, got: {jsx}" + + +def test_index_html_exists() -> None: + """index.html must exist at the repository root.""" + assert (ROOT / "index.html").is_file() + + +def test_index_html_has_root_div() -> None: + """index.html must contain a div with id='root'.""" + content = (ROOT / "index.html").read_text() + assert 'id="root"' in content + + +def test_index_html_references_main_tsx() -> None: + """index.html must reference the main.tsx entry point.""" + content = (ROOT / "index.html").read_text() + assert "src/main.tsx" in content + + +def test_main_tsx_exists() -> None: + """src/main.tsx must exist.""" + assert (ROOT / "src" / "main.tsx").is_file() + + +def test_main_tsx_imports_app() -> None: + """src/main.tsx must import the App component.""" + content = (ROOT / "src" / "main.tsx").read_text() + assert "import" in content and "App" in content + + +def test_main_tsx_uses_createroot() -> None: + """src/main.tsx must use createRoot for React 18 rendering.""" + content = (ROOT / "src" / "main.tsx").read_text() + assert "createRoot" in content + + +def test_app_tsx_exists() -> None: + """src/App.tsx must exist.""" + assert (ROOT / "src" / "App.tsx").is_file() + + +def test_app_tsx_imports_todo_page() -> None: + """src/App.tsx must import TodoPage.""" + content = (ROOT / "src" / "App.tsx").read_text() + assert "TodoPage" in content + + +def test_todo_page_exists() -> None: + """src/pages/TodoPage.tsx must exist.""" + assert (ROOT / "src" / "pages" / "TodoPage.tsx").is_file() + + +def test_index_css_exists() -> None: + """src/index.css must exist.""" + assert (ROOT / "src" / "index.css").is_file() + + +def test_index_css_has_reset() -> None: + """src/index.css must contain a box-sizing reset.""" + content = (ROOT / "src" / "index.css").read_text() + assert "box-sizing" in content + + +def test_setup_tests_exists() -> None: + """src/setupTests.ts must exist for vitest setup.""" + assert (ROOT / "src" / "setupTests.ts").is_file() + + +def test_architecture_md_exists() -> None: + """ARCHITECTURE.md must exist at the repository root.""" + assert (ROOT / "ARCHITECTURE.md").is_file() + + +def test_architecture_md_has_todo_interface() -> None: + """ARCHITECTURE.md must define the Todo interface with required fields.""" + content = (ROOT / "ARCHITECTURE.md").read_text() + assert "id: string" in content or "id:" in content + assert "text: string" in content or "text:" in content + assert "completed: boolean" in content or "completed:" in content + assert "createdAt: number" in content or "createdAt:" in content + + +def test_architecture_md_has_filter_type() -> None: + """ARCHITECTURE.md must define the FilterType union.""" + content = (ROOT / "ARCHITECTURE.md").read_text() + assert "FilterType" in content + assert "all" in content + assert "active" in content + assert "completed" in content + + +def test_architecture_md_has_file_structure_section() -> None: + """ARCHITECTURE.md must contain a File Structure section.""" + content = (ROOT / "ARCHITECTURE.md").read_text() + assert "File Structure" in content diff --git a/tests/test_todo_helpers.py b/tests/test_todo_helpers.py new file mode 100644 index 0000000..c1089ff --- /dev/null +++ b/tests/test_todo_helpers.py @@ -0,0 +1,142 @@ +"""Tests that validate the todoHelpers TypeScript utility file. + +These tests read the source file and assert that the required +function signatures and logic patterns are present. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +ROOT_DIR: Path = Path(__file__).resolve().parent.parent +HELPERS_PATH: Path = ROOT_DIR / "src" / "utils" / "todoHelpers.ts" + + +@pytest.fixture() +def helpers_content() -> str: + """Read and return the contents of src/utils/todoHelpers.ts.""" + assert HELPERS_PATH.exists(), f"{HELPERS_PATH} does not exist" + return HELPERS_PATH.read_text(encoding="utf-8") + + +class TestTodoHelpersFile: + """Verify the todoHelpers module exists and is well-structured.""" + + def test_file_exists(self) -> None: + """src/utils/todoHelpers.ts must exist.""" + assert HELPERS_PATH.exists() + + def test_imports_todo_type(self, helpers_content: str) -> None: + """The file must import from the types module.""" + assert "from '../types/todo'" in helpers_content or 'from "../types/todo"' in helpers_content + + +class TestGenerateId: + """Verify the generateId function.""" + + def test_export_exists(self, helpers_content: str) -> None: + """generateId must be an exported function.""" + assert "export function generateId" in helpers_content + + def test_returns_string(self, helpers_content: str) -> None: + """generateId must have a string return type.""" + # Look for the return type annotation + assert "generateId(): string" in helpers_content + + def test_uses_crypto_random_uuid(self, helpers_content: str) -> None: + """generateId should use crypto.randomUUID when available.""" + assert "crypto.randomUUID" in helpers_content + + +class TestCreateTodo: + """Verify the createTodo function.""" + + def test_export_exists(self, helpers_content: str) -> None: + """createTodo must be an exported function.""" + assert "export function createTodo" in helpers_content + + def test_accepts_text_param(self, helpers_content: str) -> None: + """createTodo must accept a text parameter of type string.""" + assert "createTodo(text: string)" in helpers_content + + def test_returns_todo(self, helpers_content: str) -> None: + """createTodo must return a Todo.""" + assert "createTodo(text: string): Todo" in helpers_content + + def test_trims_text(self, helpers_content: str) -> None: + """createTodo must trim the input text.""" + assert ".trim()" in helpers_content + + def test_rejects_empty_text(self, helpers_content: str) -> None: + """createTodo must throw on empty/whitespace-only text.""" + assert "throw" in helpers_content.lower() or "Error" in helpers_content + + def test_sets_completed_false(self, helpers_content: str) -> None: + """New todos must default to completed: false.""" + assert "completed: false" in helpers_content + + +class TestToggleTodo: + """Verify the toggleTodo function.""" + + def test_export_exists(self, helpers_content: str) -> None: + """toggleTodo must be an exported function.""" + assert "export function toggleTodo" in helpers_content + + def test_accepts_todo_param(self, helpers_content: str) -> None: + """toggleTodo must accept a Todo parameter.""" + assert "toggleTodo(todo: Todo)" in helpers_content + + def test_returns_todo(self, helpers_content: str) -> None: + """toggleTodo must return a Todo.""" + assert "toggleTodo(todo: Todo): Todo" in helpers_content + + def test_flips_completed(self, helpers_content: str) -> None: + """toggleTodo must negate the completed field.""" + assert "!todo.completed" in helpers_content + + def test_uses_spread(self, helpers_content: str) -> None: + """toggleTodo must use spread to avoid mutation.""" + assert "...todo" in helpers_content + + +class TestFilterTodos: + """Verify the filterTodos function.""" + + def test_export_exists(self, helpers_content: str) -> None: + """filterTodos must be an exported function.""" + assert "export function filterTodos" in helpers_content + + def test_accepts_todos_array(self, helpers_content: str) -> None: + """filterTodos must accept a Todo[] parameter.""" + assert "todos: Todo[]" in helpers_content or "todos:Todo[]" in helpers_content + + def test_accepts_filter_param(self, helpers_content: str) -> None: + """filterTodos must accept a FilterType parameter.""" + assert "filter: FilterType" in helpers_content or "filter:FilterType" in helpers_content + + def test_returns_todo_array(self, helpers_content: str) -> None: + """filterTodos must return Todo[].""" + assert "Todo[]" in helpers_content + + def test_handles_active_filter(self, helpers_content: str) -> None: + """filterTodos must handle the 'active' case.""" + assert "'active'" in helpers_content + + def test_handles_completed_filter(self, helpers_content: str) -> None: + """filterTodos must handle the 'completed' case.""" + assert "'completed'" in helpers_content + + def test_handles_all_filter(self, helpers_content: str) -> None: + """filterTodos must handle the 'all' case.""" + assert "'all'" in helpers_content + + def test_todos_default_empty_array(self, helpers_content: str) -> None: + """todos parameter must default to an empty array.""" + assert "todos: Todo[] = []" in helpers_content + + def test_filter_default_all(self, helpers_content: str) -> None: + """filter parameter must default to 'all'.""" + assert "filter: FilterType = 'all'" in helpers_content diff --git a/tests/test_todo_page.tsx b/tests/test_todo_page.tsx new file mode 100644 index 0000000..109aa43 --- /dev/null +++ b/tests/test_todo_page.tsx @@ -0,0 +1,29 @@ +/** + * Tests for the TodoPage placeholder component. + * + * Verifies that the placeholder renders its heading and message. + * These tests will be expanded when TodoPage gains real functionality. + */ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect } from 'vitest'; +import TodoPage from '../src/pages/TodoPage'; + +describe('TodoPage', () => { + it('renders the page heading', () => { + render(); + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading).toHaveTextContent('Todo App'); + }); + + it('renders the placeholder text', () => { + render(); + expect(screen.getByText('Start adding your todos!')).toBeInTheDocument(); + }); + + it('wraps content in a main element', () => { + render(); + const main = screen.getByRole('main'); + expect(main).toBeInTheDocument(); + expect(main).toHaveClass('todo-page'); + }); +}); diff --git a/tests/test_todo_types.py b/tests/test_todo_types.py new file mode 100644 index 0000000..e0a1daf --- /dev/null +++ b/tests/test_todo_types.py @@ -0,0 +1,66 @@ +"""Tests that validate the Todo TypeScript type definition file. + +These tests read the source file and assert that required interface +fields and type exports are present. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +ROOT_DIR: Path = Path(__file__).resolve().parent.parent +TODO_TYPES_PATH: Path = ROOT_DIR / "src" / "types" / "todo.ts" + + +@pytest.fixture() +def todo_types_content() -> str: + """Read and return the contents of src/types/todo.ts.""" + assert TODO_TYPES_PATH.exists(), f"{TODO_TYPES_PATH} does not exist" + return TODO_TYPES_PATH.read_text(encoding="utf-8") + + +class TestTodoInterface: + """Verify the Todo interface is correctly defined.""" + + def test_file_exists(self) -> None: + """src/types/todo.ts must exist.""" + assert TODO_TYPES_PATH.exists() + + def test_exports_todo_interface(self, todo_types_content: str) -> None: + """The file must export a Todo interface.""" + assert "export interface Todo" in todo_types_content + + def test_id_field_is_string(self, todo_types_content: str) -> None: + """Todo.id must be typed as string.""" + assert "id: string" in todo_types_content or "id:string" in todo_types_content + + def test_text_field_is_string(self, todo_types_content: str) -> None: + """Todo.text must be typed as string.""" + assert "text: string" in todo_types_content or "text:string" in todo_types_content + + def test_completed_field_is_boolean(self, todo_types_content: str) -> None: + """Todo.completed must be typed as boolean.""" + assert "completed: boolean" in todo_types_content or "completed:boolean" in todo_types_content + + def test_created_at_field_is_number(self, todo_types_content: str) -> None: + """Todo.createdAt must be typed as number.""" + assert "createdAt: number" in todo_types_content or "createdAt:number" in todo_types_content + + def test_exports_filter_type(self, todo_types_content: str) -> None: + """The file must export a FilterType union type.""" + assert "export type FilterType" in todo_types_content + + def test_filter_type_includes_all(self, todo_types_content: str) -> None: + """FilterType must include 'all'.""" + assert "'all'" in todo_types_content + + def test_filter_type_includes_active(self, todo_types_content: str) -> None: + """FilterType must include 'active'.""" + assert "'active'" in todo_types_content + + def test_filter_type_includes_completed(self, todo_types_content: str) -> None: + """FilterType must include 'completed'.""" + assert "'completed'" in todo_types_content diff --git a/tests/types/todo.test.ts b/tests/types/todo.test.ts new file mode 100644 index 0000000..5df6ada --- /dev/null +++ b/tests/types/todo.test.ts @@ -0,0 +1,47 @@ +/** + * Tests for the Todo type definitions. + * + * These are structural/compile-time sanity checks that verify objects + * conforming to the Todo interface and TodoFilter type are accepted. + */ + +import { describe, it, expect } from 'vitest'; +import { Todo, TodoFilter } from '../../src/types/todo'; + +describe('Todo interface', () => { + it('should allow creating an object that satisfies the Todo shape', () => { + const todo: Todo = { + id: 'abc123', + text: 'Write tests', + completed: false, + createdAt: 1700000000000, + }; + + expect(todo.id).toBe('abc123'); + expect(todo.text).toBe('Write tests'); + expect(todo.completed).toBe(false); + expect(todo.createdAt).toBe(1700000000000); + }); + + it('should allow completed to be true', () => { + const todo: Todo = { + id: 'xyz789', + text: 'Done task', + completed: true, + createdAt: 1700000000000, + }; + + expect(todo.completed).toBe(true); + }); +}); + +describe('TodoFilter type', () => { + it('should accept all valid filter values', () => { + const filters: TodoFilter[] = ['all', 'active', 'completed']; + + expect(filters).toHaveLength(3); + expect(filters).toContain('all'); + expect(filters).toContain('active'); + expect(filters).toContain('completed'); + }); +}); diff --git a/tests/utils/todoHelpers.test.ts b/tests/utils/todoHelpers.test.ts new file mode 100644 index 0000000..6eb62d6 --- /dev/null +++ b/tests/utils/todoHelpers.test.ts @@ -0,0 +1,173 @@ +/** + * Tests for the pure todo helper utilities. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + generateId, + createTodo, + toggleTodo, + filterTodos, +} from '../../src/utils/todoHelpers'; +import { Todo } from '../../src/types/todo'; + +// --------------------------------------------------------------------------- +// generateId +// --------------------------------------------------------------------------- + +describe('generateId', () => { + it('should return a non-empty string', () => { + const id = generateId(); + expect(typeof id).toBe('string'); + expect(id.length).toBeGreaterThan(0); + }); + + it('should return unique values on successive calls', () => { + const ids = new Set(Array.from({ length: 100 }, () => generateId())); + expect(ids.size).toBe(100); + }); +}); + +// --------------------------------------------------------------------------- +// createTodo +// --------------------------------------------------------------------------- + +describe('createTodo', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should create a todo with the given text', () => { + const todo = createTodo('Buy groceries'); + expect(todo.text).toBe('Buy groceries'); + }); + + it('should create a todo that is not completed', () => { + const todo = createTodo('Buy groceries'); + expect(todo.completed).toBe(false); + }); + + it('should set createdAt to the current timestamp', () => { + const now = Date.now(); + const todo = createTodo('Buy groceries'); + expect(todo.createdAt).toBe(now); + }); + + it('should assign a string id', () => { + const todo = createTodo('Buy groceries'); + expect(typeof todo.id).toBe('string'); + expect(todo.id.length).toBeGreaterThan(0); + }); + + it('should produce unique ids for different todos', () => { + const a = createTodo('Task A'); + const b = createTodo('Task B'); + expect(a.id).not.toBe(b.id); + }); +}); + +// --------------------------------------------------------------------------- +// toggleTodo +// --------------------------------------------------------------------------- + +describe('toggleTodo', () => { + const baseTodo: Todo = { + id: 'test-1', + text: 'Test todo', + completed: false, + createdAt: 1700000000000, + }; + + it('should flip completed from false to true', () => { + const toggled = toggleTodo(baseTodo); + expect(toggled.completed).toBe(true); + }); + + it('should flip completed from true to false', () => { + const completedTodo: Todo = { ...baseTodo, completed: true }; + const toggled = toggleTodo(completedTodo); + expect(toggled.completed).toBe(false); + }); + + it('should not mutate the original todo', () => { + const original: Todo = { ...baseTodo }; + toggleTodo(original); + expect(original.completed).toBe(false); + }); + + it('should preserve all other fields', () => { + const toggled = toggleTodo(baseTodo); + expect(toggled.id).toBe(baseTodo.id); + expect(toggled.text).toBe(baseTodo.text); + expect(toggled.createdAt).toBe(baseTodo.createdAt); + }); +}); + +// --------------------------------------------------------------------------- +// filterTodos +// --------------------------------------------------------------------------- + +describe('filterTodos', () => { + const todos: Todo[] = [ + { id: '1', text: 'Active 1', completed: false, createdAt: 1 }, + { id: '2', text: 'Completed 1', completed: true, createdAt: 2 }, + { id: '3', text: 'Active 2', completed: false, createdAt: 3 }, + { id: '4', text: 'Completed 2', completed: true, createdAt: 4 }, + ]; + + it('should return all todos when filter is "all"', () => { + const result = filterTodos(todos, 'all'); + expect(result).toHaveLength(4); + expect(result).toEqual(todos); + }); + + it('should return only active todos when filter is "active"', () => { + const result = filterTodos(todos, 'active'); + expect(result).toHaveLength(2); + expect(result.every((t) => !t.completed)).toBe(true); + }); + + it('should return only completed todos when filter is "completed"', () => { + const result = filterTodos(todos, 'completed'); + expect(result).toHaveLength(2); + expect(result.every((t) => t.completed)).toBe(true); + }); + + it('should return an empty array when given an empty list', () => { + expect(filterTodos([], 'all')).toEqual([]); + expect(filterTodos([], 'active')).toEqual([]); + expect(filterTodos([], 'completed')).toEqual([]); + }); + + it('should default to an empty array when todos is undefined', () => { + // Explicit test for the default parameter + expect(filterTodos(undefined as unknown as Todo[], 'all')).toEqual([]); + }); + + it('should not mutate the input array', () => { + const copy = [...todos]; + filterTodos(todos, 'active'); + expect(todos).toEqual(copy); + }); + + it('should return an empty array for active filter when all are completed', () => { + const allCompleted: Todo[] = [ + { id: '1', text: 'Done 1', completed: true, createdAt: 1 }, + { id: '2', text: 'Done 2', completed: true, createdAt: 2 }, + ]; + expect(filterTodos(allCompleted, 'active')).toEqual([]); + }); + + it('should return an empty array for completed filter when none are completed', () => { + const noneCompleted: Todo[] = [ + { id: '1', text: 'Todo 1', completed: false, createdAt: 1 }, + { id: '2', text: 'Todo 2', completed: false, createdAt: 2 }, + ]; + expect(filterTodos(noneCompleted, 'completed')).toEqual([]); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a7fc6fb --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..4ed43ca --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +/// + +/** + * Vite configuration for the Todo frontend application. + * + * - Uses the React plugin for JSX transform and fast-refresh. + * - Configures Vitest with jsdom for component testing. + */ +export default defineConfig({ + plugins: [react()], + server: { + port: 5173, + host: true, + }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/setupTests.ts', + css: true, + }, +});