This commit is contained in:
Philippe Torrel
2026-08-21 14:54:42 +02:00
parent e2da42e181
commit a5e74fd729
53 changed files with 2789 additions and 61 deletions

View File

@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:ui": "npx vitest --ui"
},
"dependencies": {
"react": "^19.2.8",
@@ -15,15 +17,22 @@
"react-router-dom": "^7.18.2"
},
"devDependencies": {
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.5",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@typescript-eslint/eslint-plugin": "^8.67.0",
"@typescript-eslint/parser": "^8.67.0",
"@vitejs/plugin-react": "^6.1.0",
"@vitest/ui": "^4.1.11",
"eslint": "^10.8.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.4",
"jsdom": "^30.0.1",
"typescript": "^6.0.3",
"vite": "^8.2.2"
"vite": "^8.2.2",
"vitest": "^4.1.11"
}
}

View File

@@ -5,11 +5,7 @@ type ImageGalleryProps = {
product: Product;
};
const ImageGallery: React.FC<ImageGalleryProps> = ({
product,
}: {
product: Product;
}) => {
const ImageGallery: React.FC<ImageGalleryProps> = ({ product }: { product: Product }) => {
const [selectedImage, setSelectedImage] = useState(product.images[0]);
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -24,8 +20,7 @@ const ImageGallery: React.FC<ImageGalleryProps> = ({
if (event.key === 'ArrowLeft') {
setSelectedIndex((prevIndex) => {
const nextIndex =
(prevIndex - 1 + product.images.length) % product.images.length;
const nextIndex = (prevIndex - 1 + product.images.length) % product.images.length;
setSelectedImage(product.images[nextIndex]);
return nextIndex;
});
@@ -55,15 +50,10 @@ const ImageGallery: React.FC<ImageGalleryProps> = ({
onClick={() => {
setSelectedImage(image);
setSelectedIndex(index);
}}
>
}}>
<span className="screen-reader-text">{image}</span>
<span className="image-selector-img-wrapper">
<img
src={image}
alt={product.title}
className="image-selector-img"
/>
<img src={image} alt={product.title} className="image-selector-img" />
</span>
</button>
))}
@@ -72,11 +62,7 @@ const ImageGallery: React.FC<ImageGalleryProps> = ({
{/* Main Image Display */}
<div className="image-selector-main-img-wrapper">
<img
src={selectedImage}
alt={product.title}
className="image-selector-main-img"
/>
<img src={selectedImage} alt={product.title} className="image-selector-main-img" />
</div>
</div>
);

View File

@@ -21,9 +21,7 @@ const ProductListCard = ({ product }: { product: Product }) => {
</h3>
<div className="product-card-desc-box">
<p className="screen-reader-text">
{Math.round(rating)} out of 5 stars
</p>
<p className="screen-reader-text">{Math.round(rating)} out of 5 stars</p>
<div className="product-card-ratings-wrapper">
{[0, 1, 2, 3, 4].map((ratingNumber) => (
@@ -56,12 +54,7 @@ export default ProductListCard;
function StarIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" {...props}>
<path
fillRule="evenodd"
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"

View File

@@ -35,5 +35,5 @@ const router = createBrowserRouter([
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
</React.StrictMode>,
);

View File

@@ -13,9 +13,7 @@ const ProductPage = () => {
const fetchProduct = async () => {
try {
const productRes = await fetch(
`https://dummyjson.com/products/${productId}`
);
const productRes = await fetch(`https://dummyjson.com/products/${productId}`);
const productData = await productRes.json();
setProduct(productData);
} catch (error) {
@@ -67,16 +65,13 @@ const ProductPage = () => {
key={rating}
className="star-icons"
style={{
fill:
product.rating > rating ? '#1e293b' : '#e5e7eb',
fill: product.rating > rating ? '#1e293b' : '#e5e7eb',
}}
aria-hidden="true"
/>
))}
</div>
<p className="screen-reader-text">
{product.rating} out of 5 stars
</p>
<p className="screen-reader-text">{product.rating} out of 5 stars</p>
</div>
</div>
@@ -89,9 +84,7 @@ const ProductPage = () => {
<div className="product-page-mt-6">
<h3 className="screen-reader-text">Description</h3>
<p className="product-page-description">
{product.description}
</p>
<p className="product-page-description">{product.description}</p>
</div>
{/* Tags */}
@@ -157,9 +150,7 @@ function Reviews({ reviews }: { reviews: Review[] }) {
<div className="product-page-reviews-text-box">
<p className="product-page-text">{review.reviewerName}</p>
<p className="product-page-text">
{new Date(review.date).toLocaleDateString()}
</p>
<p className="product-page-text">{new Date(review.date).toLocaleDateString()}</p>
</div>
<p className="product-page-reviews-comment">{review.comment}</p>
@@ -178,25 +169,15 @@ function ChevronRightIcon(props: React.SVGProps<SVGSVGElement>) {
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 19.5 8.25 12l7.5-7.5"
/>
{...props}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
);
}
function StarIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" {...props}>
<path
fillRule="evenodd"
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"

View File

@@ -0,0 +1,40 @@
import Hero from '../../src/components/Hero';
import { it, expect, describe } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
// TestSuite
describe('Hero', () => {
it('should render h1 element', () => {
render(<Hero />);
// screen.debug();
const heading = screen.getByRole('heading', { level: 1 });
expect(heading).toBeInTheDocument();
});
it('should render the company name when passed', () => {
render(<Hero companyName="My Company" />);
screen.debug(); // console Ausgabe der gerenderten Komponente (HTML-Elemente)
// Arrange
const heading = screen.getByRole('heading', { level: 1 });
// Act & Assert
expect(heading).toHaveTextContent('My Company');
});
it('should render the company name Vogue Junction as default', () => {
render(<Hero />);
screen.debug(); // console Ausgabe der gerenderten Komponente (HTML-Elemente)
// Arrange
const heading = screen.getByRole('heading', { level: 1 });
// Act & Assert
expect(heading).toHaveTextContent(/Vogue Junction/i);
});
});

View File

@@ -0,0 +1,60 @@
import { it, expect, describe } from 'vitest';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import userEvent from '@testing-library/user-event';
import ImageGallery from '../components/ImageGallery';
import { fakeProduct } from './__mocks__/product.mock';
// Dummy/Mock Daten
const product = fakeProduct;
// Arrange
const renderComponent = () => {
render(<ImageGallery product={product} />);
return {
images: screen.getAllByAltText(product.title),
buttons: screen.getAllByRole('button'),
};
};
describe('ImageGallery', () => {
it('should render three buttons', () => {
// Arrange
const { buttons } = renderComponent();
expect(buttons).toHaveLength(3);
});
it('should render four images', () => {
// Arrange
const { images } = renderComponent();
expect(images).toHaveLength(4);
});
it('should render the first image as main image', () => {
const { images } = renderComponent();
// screen.debug();
const mainImage = images[images.length - 1]; // letzte gerenderte Bild ist das erste im product Object
expect(mainImage).toBeInTheDocument();
expect(mainImage).toHaveAttribute(
'src',
'https://cdn.dummyjson.com/products/images/womens-bags/Prada%20Women%20Bag/1.png',
);
});
it('should change the main image when the last image is clicked', async () => {
const { images, buttons } = renderComponent();
const user = userEvent.setup();
// fireEvent.click(buttons[2]);
await user.click(buttons[2]); // best practice
const mainImage = images[images.length - 1];
expect(mainImage).toHaveAttribute(
'src',
'https://cdn.dummyjson.com/products/images/womens-bags/Prada%20Women%20Bag/3.png',
);
});
});

View File

@@ -0,0 +1,40 @@
import { it, expect, describe } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/vitest';
import { fakeProducts } from './__mocks__/products.mock';
import ProductList from '../components/ProductList';
const products = fakeProducts;
describe('ProductList', () => {
it('should render the correct number of products', () => {
render(
<MemoryRouter>
<ProductList data={products} />
</MemoryRouter>,
);
const headings = screen.getAllByRole('heading', { level: 3 });
expect(headings).toHaveLength(products.length);
});
it('should render the product list correctly with the title, image and price', () => {
render(
<MemoryRouter>
<ProductList data={products} />
</MemoryRouter>,
);
// screen.debug(); // gerenderte Ergebnis in der Vitest-UI-Konsole sichtbar
products.forEach((product) => {
const { title, thumbnail, price } = product;
expect(screen.getByText(title)).toBeInTheDocument(); // title
expect(screen.getByAltText(title)).toBeInTheDocument(); // image
expect(screen.getByAltText(title)).toHaveAttribute('src', thumbnail); // image src
expect(screen.getByText(`$${price}`)).toBeInTheDocument(); // price
});
});
});

View File

@@ -0,0 +1,56 @@
export const fakeProduct = {
id: 174,
title: 'Prada Women Bag',
description:
"The Prada Women Bag is an iconic designer bag that exudes elegance and luxury. Crafted with precision and featuring the Prada logo, it's a statement piece for fashion enthusiasts.",
category: 'womens-bags',
price: 599.99,
discountPercentage: 18.3,
rating: 3.52,
stock: 43,
tags: ['fashion accessories', 'designer bags'],
brand: 'Prada',
sku: 'WXX4YTJM',
weight: 4,
dimensions: { width: 23.45, height: 16.1, depth: 5.78 },
warrantyInformation: '1 week warranty',
shippingInformation: 'Ships overnight',
availabilityStatus: 'In Stock',
reviews: [
{
rating: 5,
comment: 'Excellent quality!',
date: new Date('2024-05-23T08:56:21.627Z'),
reviewerName: 'Samantha Howard',
reviewerEmail: 'samantha.howard@x.dummyjson.com',
},
{
rating: 4,
comment: 'Would buy again!',
date: new Date('2024-05-23T08:56:21.627Z'),
reviewerName: 'Henry Hill',
reviewerEmail: 'henry.hill@x.dummyjson.com',
},
{
rating: 3,
comment: 'Not worth the price!',
date: new Date('2024-05-23T08:56:21.627Z'),
reviewerName: 'Lucas Ramirez',
reviewerEmail: 'lucas.ramirez@x.dummyjson.com',
},
],
returnPolicy: '7 days return policy',
minimumOrderQuantity: 1,
meta: {
createdAt: new Date('2024-05-23T08:56:21.627Z'),
updatedAt: new Date('2024-05-23T08:56:21.627Z'),
barcode: '4590351278063',
qrCode: 'https://assets.dummyjson.com/public/qr-code.png',
},
images: [
'https://cdn.dummyjson.com/products/images/womens-bags/Prada%20Women%20Bag/1.png',
'https://cdn.dummyjson.com/products/images/womens-bags/Prada%20Women%20Bag/2.png',
'https://cdn.dummyjson.com/products/images/womens-bags/Prada%20Women%20Bag/3.png',
],
thumbnail: 'https://cdn.dummyjson.com/products/images/womens-bags/Prada%20Women%20Bag/thumbnail.png',
};

View File

@@ -0,0 +1,168 @@
export const fakeProducts = [
{
id: 174,
title: 'Prada Women Bag',
description:
"The Prada Women Bag is an iconic designer bag that exudes elegance and luxury. Crafted with precision and featuring the Prada logo, it's a statement piece for fashion enthusiasts.",
category: 'womens-bags',
price: 599.99,
discountPercentage: 18.3,
rating: 3.52,
stock: 43,
tags: ['fashion accessories', 'designer bags'],
brand: 'Prada',
sku: 'WXX4YTJM',
weight: 4,
dimensions: { width: 23.45, height: 16.1, depth: 5.78 },
warrantyInformation: '1 week warranty',
shippingInformation: 'Ships overnight',
availabilityStatus: 'In Stock',
reviews: [
{
rating: 5,
comment: 'Excellent quality!',
date: new Date('2024-05-23T08:56:21.627Z'),
reviewerName: 'Samantha Howard',
reviewerEmail: 'samantha.howard@x.dummyjson.com',
},
{
rating: 4,
comment: 'Would buy again!',
date: new Date('2024-05-23T08:56:21.627Z'),
reviewerName: 'Henry Hill',
reviewerEmail: 'henry.hill@x.dummyjson.com',
},
{
rating: 3,
comment: 'Not worth the price!',
date: new Date('2024-05-23T08:56:21.627Z'),
reviewerName: 'Lucas Ramirez',
reviewerEmail: 'lucas.ramirez@x.dummyjson.com',
},
],
returnPolicy: '7 days return policy',
minimumOrderQuantity: 1,
meta: {
createdAt: new Date('2024-05-23T08:56:21.627Z'),
updatedAt: new Date('2024-05-23T08:56:21.627Z'),
barcode: '4590351278063',
qrCode: 'https://assets.dummyjson.com/public/qr-code.png',
},
images: [
'https://cdn.dummyjson.com/products/images/womens-bags/Prada%20Women%20Bag/1.png',
'https://cdn.dummyjson.com/products/images/womens-bags/Prada%20Women%20Bag/2.png',
'https://cdn.dummyjson.com/products/images/womens-bags/Prada%20Women%20Bag/3.png',
],
thumbnail: 'https://cdn.dummyjson.com/products/images/womens-bags/Prada%20Women%20Bag/thumbnail.png',
},
{
id: 90,
title: 'Puma Future Rider Trainers',
description:
'The Puma Future Rider Trainers offer a blend of retro style and modern comfort. Perfect for casual wear, these trainers provide a fashionable and comfortable option for everyday use.',
category: 'mens-shoes',
price: 89.99,
discountPercentage: 3.64,
rating: 4.85,
stock: 10,
tags: ['footwear', 'casual shoes'],
brand: 'Puma',
sku: '64ORN32I',
weight: 8,
dimensions: { width: 14.58, height: 25.54, depth: 19.57 },
warrantyInformation: '2 year warranty',
shippingInformation: 'Ships in 1 month',
availabilityStatus: 'In Stock',
reviews: [
{
rating: 5,
comment: 'Very happy with my purchase!',
date: new Date('2024-05-23T08:56:21.627Z'),
reviewerName: 'Lucas Allen',
reviewerEmail: 'lucas.allen@x.dummyjson.com',
},
{
rating: 4,
comment: 'Awesome product!',
date: new Date('2024-05-23T08:56:21.627Z'),
reviewerName: 'Mason Pearson',
reviewerEmail: 'mason.pearson@x.dummyjson.com',
},
{
rating: 4,
comment: 'Very satisfied!',
date: new Date('2024-05-23T08:56:21.627Z'),
reviewerName: 'Hunter Gordon',
reviewerEmail: 'hunter.gordon@x.dummyjson.com',
},
],
returnPolicy: '90 days return policy',
minimumOrderQuantity: 9,
meta: {
createdAt: new Date('2024-05-23T08:56:21.627Z'),
updatedAt: new Date('2024-05-23T08:56:21.627Z'),
barcode: '3562849555769',
qrCode: 'https://assets.dummyjson.com/public/qr-code.png',
},
images: [
'https://cdn.dummyjson.com/products/images/mens-shoes/Puma%20Future%20Rider%20Trainers/1.png',
'https://cdn.dummyjson.com/products/images/mens-shoes/Puma%20Future%20Rider%20Trainers/2.png',
'https://cdn.dummyjson.com/products/images/mens-shoes/Puma%20Future%20Rider%20Trainers/3.png',
'https://cdn.dummyjson.com/products/images/mens-shoes/Puma%20Future%20Rider%20Trainers/4.png',
],
thumbnail: 'https://cdn.dummyjson.com/products/images/mens-shoes/Puma%20Future%20Rider%20Trainers/thumbnail.png',
},
{
id: 101,
title: 'Apple AirPods Max Silver',
description:
'The Apple AirPods Max in Silver are premium over-ear headphones with high-fidelity audio, adaptive EQ, and active noise cancellation. Experience immersive sound in style.',
category: 'mobile-accessories',
price: 549.99,
discountPercentage: 11.7,
rating: 3.11,
stock: 7,
tags: ['electronics', 'over-ear headphones'],
brand: 'Apple',
sku: 'HPK82VDE',
weight: 4,
dimensions: { width: 7.73, height: 18.36, depth: 17.87 },
warrantyInformation: '3 months warranty',
shippingInformation: 'Ships in 1 month',
availabilityStatus: 'In Stock',
reviews: [
{
rating: 1,
comment: 'Waste of money!',
date: new Date('2024-05-23T08:56:21.627Z'),
reviewerName: 'Harper Kelly',
reviewerEmail: 'harper.kelly@x.dummyjson.com',
},
{
rating: 3,
comment: 'Not as described!',
date: new Date('2024-05-23T08:56:21.627Z'),
reviewerName: 'Abigail Rivera',
reviewerEmail: 'abigail.rivera@x.dummyjson.com',
},
{
rating: 5,
comment: 'Excellent quality!',
date: new Date('2024-05-23T08:56:21.627Z'),
reviewerName: 'Nora Russell',
reviewerEmail: 'nora.russell@x.dummyjson.com',
},
],
returnPolicy: '90 days return policy',
minimumOrderQuantity: 2,
meta: {
createdAt: new Date('2024-05-23T08:56:21.627Z'),
updatedAt: new Date('2024-05-23T08:56:21.627Z'),
barcode: '9261269777547',
qrCode: 'https://assets.dummyjson.com/public/qr-code.png',
},
images: ['https://cdn.dummyjson.com/products/images/mobile-accessories/Apple%20AirPods%20Max%20Silver/1.png'],
thumbnail:
'https://cdn.dummyjson.com/products/images/mobile-accessories/Apple%20AirPods%20Max%20Silver/thumbnail.png',
},
];

View File

@@ -0,0 +1,12 @@
import { it, describe, expect } from 'vitest';
import { add } from '../utils/helpers';
describe('add', () => {
it('should return 3 when 2 and 1 are passed', () => {
expect(add(2, 1)).toBe(3);
});
it('should return 0.3 when 0.1 and 0.2 are passed', () => {
expect(add(0.1, 0.2)).toBe(0.3);
});
});

View File

@@ -0,0 +1,12 @@
import { it, describe, expect } from 'vitest';
// Test Suite
describe('main', () => {
// Test Case
it('should pass', () => {
// AAA - Arrange , Act , Assert
const value = true; // Arrange
// Act & Assert
expect(value).toBe(true);
});
});

View File

@@ -0,0 +1,8 @@
import { test, describe, expect } from 'vitest';
import { multiply } from '../utils/helpers';
describe('multiply', () => {
test('Multiply returns 10 when 2 and 5 are passed', () => {
expect(multiply(2, 5)).toBe(10);
});
});

View File

@@ -0,0 +1,19 @@
// src/test/setup.ts
// npm i @testing-library/jest-dom -D
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import * as matchers from '@testing-library/jest-dom/matchers';
// types für Testfunktionen
import '@testing-library/jest-dom/vitest';
// add jest-dom matchers to expect
expect.extend(matchers);
// Add cleanup function to run after each test
afterEach(() => {
cleanup();
});
// More setup code here, if needed

View File

@@ -0,0 +1,12 @@
export const multiply = (a: number, b: number): number => {
return Number(a) * Number(b);
};
export const add = (a: number, b: number): number => {
return parseFloat((Number(a) + Number(b)).toFixed(14).substring(0, 14));
};
export default {
multiply,
add,
};

View File

@@ -1,7 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'; // nicht mehr 'vite'
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/tests/setupTests.ts',
},
});