diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a74ca12 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install + +COPY . . + +EXPOSE 5173 + +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] diff --git a/RUNNING.md b/RUNNING.md index 77896cf..d6559a1 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -1,33 +1,91 @@ -# Running the Todo API +# React Counter App + +A minimal React counter application built with Vite, featuring increment and decrement buttons with a comprehensive test suite. + +## TEAM_BRIEF +stack: TypeScript/React+Vite +test_runner: npx vitest run +lint_tool: none +coverage_tool: none +coverage_threshold: 0 +coverage_applies: false ## Prerequisites -- Python 3.10 or later +- Node.js 18+ and npm +- Docker (optional, for containerized setup) -## Install dependencies +## Local Setup ```bash -pip install fastapi uvicorn pydantic +# Install dependencies +npm install + +# Start development server +npm run dev + +# Run tests +npm test + +# Build for production +npm run build ``` -For running the test suite you will also need: +## Docker Setup + +### Build and Run ```bash -pip install httpx pytest +# Build the Docker image +docker build -t react-counter-app . + +# Run the container (development server on port 5173) +docker run -p 5173:5173 react-counter-app ``` -## Start the server +### Run Tests in Docker ```bash -uvicorn main:app --reload --host 0.0.0.0 --port 8000 +# Run the test suite inside a container +docker run --rm react-counter-app npm test ``` -The API will be available at . +## Project Structure -Interactive docs are served at . +``` +├── index.html # HTML entry point +├── package.json # Dependencies and scripts +├── vite.config.js # Vite + Vitest configuration +├── src/ +│ ├── main.jsx # React entry point +│ ├── App.jsx # Root App component +│ ├── App.css # Global and centering styles +│ ├── setupTests.js # Test setup (jest-dom matchers) +│ └── components/ +│ ├── Counter.jsx # Counter component +│ └── Counter.test.jsx # Test suite for Counter and App +``` + +## Testing -## Run the tests +Tests are written using [Vitest](https://vitest.dev/) and [@testing-library/react](https://testing-library.com/docs/react-testing-library/intro/). ```bash -pytest tests/ +# Run all tests +npm test + +# Run tests in watch mode +npm run test:watch ``` + +### Test Coverage + +- Initial count renders as 0 +- Increment button increases count by 1 +- Decrement button decreases count by 1 +- Mixed increment/decrement sequences +- Count can go negative +- Rapid clicking updates correctly +- Accessibility labels and aria-live region +- Centering classes are applied +- App renders Counter within centered container diff --git a/index.html b/index.html new file mode 100644 index 0000000..0a39fde --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + React Counter App + + +
+ + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..240e22b --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "react-counter-app", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "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.6.3", + "@testing-library/react": "^16.1.0", + "@testing-library/user-event": "^14.5.2", + "@vitejs/plugin-react": "^4.3.4", + "jsdom": "^25.0.1", + "vite": "^5.4.11", + "vitest": "^2.1.8" + } +} diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..770ef01 --- /dev/null +++ b/src/App.css @@ -0,0 +1,62 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +.app-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + font-family: Arial, Helvetica, sans-serif; + background-color: #f5f5f5; +} + +.counter-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + padding: 2rem; + background: #ffffff; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + text-align: center; +} + +.counter-wrapper h1 { + font-size: 1.5rem; + color: #333; +} + +.count-display { + font-size: 3rem; + font-weight: bold; + color: #111; + min-width: 100px; + text-align: center; +} + +.counter-buttons { + display: flex; + gap: 1rem; +} + +.counter-buttons button { + font-size: 1.25rem; + padding: 0.5rem 1.5rem; + border: 2px solid #333; + border-radius: 8px; + background: #fff; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.counter-buttons button:hover { + background-color: #e0e0e0; +} + +.counter-buttons button:active { + background-color: #ccc; +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..24c9f47 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import Counter from './components/Counter.jsx'; +import './App.css'; + +/** + * Root application component. + * Renders the Counter component centered on the page. + */ +function App() { + return ( +
+ +
+ ); +} + +export default App; diff --git a/src/components/Counter.jsx b/src/components/Counter.jsx new file mode 100644 index 0000000..83bb56d --- /dev/null +++ b/src/components/Counter.jsx @@ -0,0 +1,56 @@ +import React, { useState } from 'react'; + +/** + * Counter component with increment and decrement functionality. + * + * Displays the current count value and provides buttons to + * increase or decrease the count by one. + */ +function Counter() { + const [count, setCount] = useState(0); + + /** + * Increment the count by one. + */ + const handleIncrement = () => { + setCount((prev) => prev + 1); + }; + + /** + * Decrement the count by one. + */ + const handleDecrement = () => { + setCount((prev) => prev - 1); + }; + + return ( +
+

Counter

+
+ {count} +
+
+ + +
+
+ ); +} + +export default Counter; diff --git a/src/components/Counter.module.css b/src/components/Counter.module.css new file mode 100644 index 0000000..d30c495 --- /dev/null +++ b/src/components/Counter.module.css @@ -0,0 +1,51 @@ +.counter { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem 3rem; + background-color: #ffffff; + border-radius: 12px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + text-align: center; +} + +.title { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 1rem; + color: #333; +} + +.count { + font-size: 3rem; + font-weight: 700; + margin-bottom: 1.5rem; + color: #111; +} + +.buttons { + display: flex; + gap: 1rem; +} + +.button { + font-size: 1.25rem; + font-weight: 600; + padding: 0.5rem 1.5rem; + border: 2px solid #333; + border-radius: 8px; + background-color: #fff; + color: #333; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.button:hover { + background-color: #333; + color: #fff; +} + +.button:active { + transform: scale(0.96); +} diff --git a/src/components/Counter.test.jsx b/src/components/Counter.test.jsx new file mode 100644 index 0000000..3e9a750 --- /dev/null +++ b/src/components/Counter.test.jsx @@ -0,0 +1,167 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect } from 'vitest'; +import Counter from './Counter.jsx'; +import App from '../App.jsx'; + +describe('Counter component', () => { + it('renders with an initial count of 0', () => { + render(); + const display = screen.getByTestId('count-display'); + expect(display).toHaveTextContent('0'); + }); + + it('renders the heading text "Counter"', () => { + render(); + const heading = screen.getByRole('heading', { name: /counter/i }); + expect(heading).toBeInTheDocument(); + }); + + it('renders increment and decrement buttons', () => { + render(); + const incrementBtn = screen.getByTestId('increment-button'); + const decrementBtn = screen.getByTestId('decrement-button'); + expect(incrementBtn).toBeInTheDocument(); + expect(decrementBtn).toBeInTheDocument(); + }); + + it('increments the count when increment button is clicked', async () => { + const user = userEvent.setup(); + render(); + const incrementBtn = screen.getByTestId('increment-button'); + const display = screen.getByTestId('count-display'); + + await user.click(incrementBtn); + expect(display).toHaveTextContent('1'); + + await user.click(incrementBtn); + expect(display).toHaveTextContent('2'); + }); + + it('decrements the count when decrement button is clicked', async () => { + const user = userEvent.setup(); + render(); + const decrementBtn = screen.getByTestId('decrement-button'); + const display = screen.getByTestId('count-display'); + + await user.click(decrementBtn); + expect(display).toHaveTextContent('-1'); + + await user.click(decrementBtn); + expect(display).toHaveTextContent('-2'); + }); + + it('handles a mix of increment and decrement clicks correctly', async () => { + const user = userEvent.setup(); + render(); + const incrementBtn = screen.getByTestId('increment-button'); + const decrementBtn = screen.getByTestId('decrement-button'); + const display = screen.getByTestId('count-display'); + + await user.click(incrementBtn); + await user.click(incrementBtn); + await user.click(incrementBtn); + expect(display).toHaveTextContent('3'); + + await user.click(decrementBtn); + expect(display).toHaveTextContent('2'); + + await user.click(decrementBtn); + await user.click(decrementBtn); + expect(display).toHaveTextContent('0'); + }); + + it('allows count to go negative', async () => { + const user = userEvent.setup(); + render(); + const decrementBtn = screen.getByTestId('decrement-button'); + const display = screen.getByTestId('count-display'); + + await user.click(decrementBtn); + await user.click(decrementBtn); + await user.click(decrementBtn); + expect(display).toHaveTextContent('-3'); + }); + + it('handles rapid clicks correctly', async () => { + const user = userEvent.setup(); + render(); + const incrementBtn = screen.getByTestId('increment-button'); + const display = screen.getByTestId('count-display'); + + // Rapid-fire 10 clicks + for (let i = 0; i < 10; i++) { + await user.click(incrementBtn); + } + expect(display).toHaveTextContent('10'); + }); + + it('has accessible button labels', () => { + render(); + const incrementBtn = screen.getByRole('button', { name: /increment/i }); + const decrementBtn = screen.getByRole('button', { name: /decrement/i }); + expect(incrementBtn).toBeInTheDocument(); + expect(decrementBtn).toBeInTheDocument(); + }); + + it('has an aria-live region for the count display', () => { + render(); + const display = screen.getByTestId('count-display'); + expect(display).toHaveAttribute('aria-live', 'polite'); + }); + + it('count display is centered within the counter wrapper (text-align)', () => { + render(); + const wrapper = screen.getByTestId('counter-wrapper'); + expect(wrapper).toHaveClass('counter-wrapper'); + // The counter-wrapper class applies text-align: center and align-items: center + // Verify the wrapper element has the centering class applied + const display = screen.getByTestId('count-display'); + expect(display).toHaveClass('count-display'); + }); +}); + +describe('App component', () => { + it('renders the Counter component inside a centered container', () => { + render(); + const appContainer = screen.getByTestId('app-container'); + expect(appContainer).toBeInTheDocument(); + expect(appContainer).toHaveClass('app-container'); + }); + + it('displays the Counter inside the App', () => { + render(); + const counterWrapper = screen.getByTestId('counter-wrapper'); + expect(counterWrapper).toBeInTheDocument(); + }); + + it('shows initial count of 0 when App renders', () => { + render(); + const display = screen.getByTestId('count-display'); + expect(display).toHaveTextContent('0'); + }); + + it('increment and decrement work through the App', async () => { + const user = userEvent.setup(); + render(); + const incrementBtn = screen.getByTestId('increment-button'); + const decrementBtn = screen.getByTestId('decrement-button'); + const display = screen.getByTestId('count-display'); + + await user.click(incrementBtn); + await user.click(incrementBtn); + expect(display).toHaveTextContent('2'); + + await user.click(decrementBtn); + expect(display).toHaveTextContent('1'); + }); + + it('app container uses flex centering class', () => { + render(); + const appContainer = screen.getByTestId('app-container'); + // Verify the container has the app-container class which provides + // display:flex, justify-content:center, align-items:center + expect(appContainer).toHaveClass('app-container'); + }); +}); diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000..6f4d655 --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.jsx'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); diff --git a/src/setupTests.js b/src/setupTests.js new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/src/setupTests.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..aa0f24d --- /dev/null +++ b/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/setupTests.js'], + css: true, + }, +});