Skip to content

Commit 3e908ac

Browse files
esmcelroyCopilot
andcommitted
test: add comprehensive unit and E2E test coverage
Unit tests (28 total): - PhotoUpload: upload zone rendering, count display, max photos, file validation - PaddingSettingsPanel: fill type switching, color picker, process button states - PhotoGrid: empty state, card rendering, widest badge, download button - imageUtils: findMaxAspectRatio edge cases - useLocalStorage: read/write, objects, malformed JSON E2E tests (13 total): - Upload flow: single/multi upload, file info display, clear all - Settings: fill type switching, color input, style buttons, disabled state - Process flow: download button after processing, progress indicator - Smoke: app load, upload zone, settings panel Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1dfe68d commit 3e908ac

7 files changed

Lines changed: 522 additions & 0 deletions

File tree

e2e/process-flow.spec.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { test, expect } from '@playwright/test'
2+
import {
3+
createLandscapePng,
4+
createPortraitPng,
5+
uploadMultipleTestImages,
6+
} from './test-helpers'
7+
8+
test.beforeEach(async ({ page }) => {
9+
await page.goto('/')
10+
})
11+
12+
test('after uploading and processing, Download All button appears', async ({
13+
page,
14+
}) => {
15+
await uploadMultipleTestImages(page, [
16+
{ name: 'img1.png', buffer: createLandscapePng() },
17+
{ name: 'img2.png', buffer: createPortraitPng() },
18+
])
19+
20+
// Process button should be enabled
21+
const processBtn = page.getByRole('button', { name: 'Process Images' })
22+
await expect(processBtn).toBeEnabled()
23+
await processBtn.click()
24+
25+
// Wait for Download All button to appear
26+
await expect(
27+
page.getByRole('button', { name: 'Download All as ZIP' }),
28+
).toBeVisible({ timeout: 15000 })
29+
})
30+
31+
test('progress indicator shows during processing', async ({ page }) => {
32+
await uploadMultipleTestImages(page, [
33+
{ name: 'p1.png', buffer: createLandscapePng() },
34+
{ name: 'p2.png', buffer: createPortraitPng() },
35+
])
36+
37+
await page.getByRole('button', { name: 'Process Images' }).click()
38+
39+
// Either the progress text or the completed state should appear
40+
// The progress bar shows "Processing images…" while active
41+
await expect(
42+
page
43+
.getByText('Processing images…')
44+
.or(page.getByRole('button', { name: 'Download All as ZIP' })),
45+
).toBeVisible({ timeout: 15000 })
46+
})

e2e/settings.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { test, expect } from '@playwright/test'
2+
import { createLandscapePng, uploadTestImage } from './test-helpers'
3+
4+
test.beforeEach(async ({ page }) => {
5+
await page.goto('/')
6+
// Clear any persisted settings from localStorage
7+
await page.evaluate(() => localStorage.removeItem('squarify-settings'))
8+
await page.reload()
9+
})
10+
11+
test('can switch between Solid Color and Background Image fill types', async ({
12+
page,
13+
}) => {
14+
const solidBtn = page.getByRole('button', { name: 'Solid Color' })
15+
const bgImgBtn = page.getByRole('button', { name: 'Background Image' })
16+
17+
// Solid Color is default (active)
18+
await expect(solidBtn).toBeVisible()
19+
await expect(bgImgBtn).toBeVisible()
20+
21+
// Switch to Background Image
22+
await bgImgBtn.click()
23+
// "Upload image" button should appear (background image UI)
24+
await expect(
25+
page.getByRole('button', { name: 'Upload image' }),
26+
).toBeVisible()
27+
28+
// Switch back to Solid Color
29+
await solidBtn.click()
30+
// Color hex input should appear
31+
await expect(page.locator('input[placeholder="#ffffff"]')).toBeVisible()
32+
})
33+
34+
test('can change the fill color using the hex input', async ({ page }) => {
35+
const hexInput = page.locator('input[placeholder="#ffffff"]')
36+
await expect(hexInput).toBeVisible()
37+
38+
// Clear and type new color
39+
await hexInput.fill('#ff0000')
40+
await expect(hexInput).toHaveValue('#ff0000')
41+
})
42+
43+
test('style buttons appear when Background Image is selected', async ({
44+
page,
45+
}) => {
46+
// Switch to Background Image
47+
await page.getByRole('button', { name: 'Background Image' }).click()
48+
49+
// Style buttons should be visible
50+
await expect(page.getByRole('button', { name: 'cover' })).toBeVisible()
51+
await expect(page.getByRole('button', { name: 'contain' })).toBeVisible()
52+
await expect(page.getByRole('button', { name: 'tile' })).toBeVisible()
53+
})
54+
55+
test('Process button is disabled when no photos are uploaded', async ({
56+
page,
57+
}) => {
58+
const processBtn = page.getByRole('button', { name: 'Process Images' })
59+
await expect(processBtn).toBeVisible()
60+
await expect(processBtn).toBeDisabled()
61+
})

e2e/test-helpers.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import type { Page } from '@playwright/test'
2+
3+
// Minimal valid 10x5 PNG (landscape)
4+
export function createLandscapePng(): Buffer {
5+
return createPngBuffer(10, 5, [255, 0, 0]) // red
6+
}
7+
8+
// Minimal valid 5x10 PNG (portrait)
9+
export function createPortraitPng(): Buffer {
10+
return createPngBuffer(5, 10, [0, 0, 255]) // blue
11+
}
12+
13+
// Creates a minimal valid PNG with given dimensions and solid color
14+
function createPngBuffer(
15+
width: number,
16+
height: number,
17+
rgb: [number, number, number],
18+
): Buffer {
19+
// Build raw scanlines: each row = filter byte (0) + RGB pixels
20+
const rawData: number[] = []
21+
for (let y = 0; y < height; y++) {
22+
rawData.push(0) // filter: none
23+
for (let x = 0; x < width; x++) {
24+
rawData.push(rgb[0], rgb[1], rgb[2])
25+
}
26+
}
27+
28+
const deflated = deflateRaw(Buffer.from(rawData))
29+
30+
const chunks: Buffer[] = []
31+
// Signature
32+
chunks.push(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
33+
// IHDR
34+
chunks.push(createChunk('IHDR', ihdrData(width, height)))
35+
// IDAT
36+
chunks.push(createChunk('IDAT', deflated))
37+
// IEND
38+
chunks.push(createChunk('IEND', Buffer.alloc(0)))
39+
40+
return Buffer.concat(chunks)
41+
}
42+
43+
function ihdrData(width: number, height: number): Buffer {
44+
const buf = Buffer.alloc(13)
45+
buf.writeUInt32BE(width, 0)
46+
buf.writeUInt32BE(height, 4)
47+
buf[8] = 8 // bit depth
48+
buf[9] = 2 // color type: RGB
49+
buf[10] = 0 // compression
50+
buf[11] = 0 // filter
51+
buf[12] = 0 // interlace
52+
return buf
53+
}
54+
55+
function createChunk(type: string, data: Buffer): Buffer {
56+
const length = Buffer.alloc(4)
57+
length.writeUInt32BE(data.length, 0)
58+
const typeBytes = Buffer.from(type, 'ascii')
59+
const crcInput = Buffer.concat([typeBytes, data])
60+
const crc = Buffer.alloc(4)
61+
crc.writeUInt32BE(crc32(crcInput), 0)
62+
return Buffer.concat([length, typeBytes, data, crc])
63+
}
64+
65+
function crc32(buf: Buffer): number {
66+
let crc = 0xffffffff
67+
for (let i = 0; i < buf.length; i++) {
68+
crc ^= buf[i]
69+
for (let j = 0; j < 8; j++) {
70+
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1
71+
}
72+
}
73+
return (crc ^ 0xffffffff) >>> 0
74+
}
75+
76+
// Minimal zlib deflate: stored blocks (no compression)
77+
function deflateRaw(data: Buffer): Buffer {
78+
const chunks: Buffer[] = []
79+
// zlib header
80+
chunks.push(Buffer.from([0x78, 0x01]))
81+
82+
const maxBlock = 65535
83+
let offset = 0
84+
while (offset < data.length) {
85+
const remaining = data.length - offset
86+
const blockSize = Math.min(remaining, maxBlock)
87+
const isLast = offset + blockSize >= data.length
88+
const header = Buffer.alloc(5)
89+
header[0] = isLast ? 0x01 : 0x00
90+
header.writeUInt16LE(blockSize, 1)
91+
header.writeUInt16LE(blockSize ^ 0xffff, 3)
92+
chunks.push(header)
93+
chunks.push(data.subarray(offset, offset + blockSize))
94+
offset += blockSize
95+
}
96+
97+
// Adler-32 checksum
98+
let a = 1,
99+
b = 0
100+
for (let i = 0; i < data.length; i++) {
101+
a = (a + data[i]) % 65521
102+
b = (b + a) % 65521
103+
}
104+
const adler = Buffer.alloc(4)
105+
adler.writeUInt32BE((b << 16) | a, 0)
106+
chunks.push(adler)
107+
108+
return Buffer.concat(chunks)
109+
}
110+
111+
export async function uploadTestImage(
112+
page: Page,
113+
name: string,
114+
buffer: Buffer,
115+
) {
116+
const fileInput = page.locator('input[type="file"]').first()
117+
await fileInput.setInputFiles({
118+
name,
119+
mimeType: 'image/png',
120+
buffer,
121+
})
122+
}
123+
124+
export async function uploadMultipleTestImages(
125+
page: Page,
126+
files: Array<{ name: string; buffer: Buffer }>,
127+
) {
128+
const fileInput = page.locator('input[type="file"]').first()
129+
await fileInput.setInputFiles(
130+
files.map((f) => ({
131+
name: f.name,
132+
mimeType: 'image/png',
133+
buffer: f.buffer,
134+
})),
135+
)
136+
}

e2e/upload-flow.spec.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { test, expect } from '@playwright/test'
2+
import {
3+
createLandscapePng,
4+
createPortraitPng,
5+
uploadTestImage,
6+
uploadMultipleTestImages,
7+
} from './test-helpers'
8+
9+
test.beforeEach(async ({ page }) => {
10+
await page.goto('/')
11+
})
12+
13+
test('can upload a single image and see it in the grid', async ({ page }) => {
14+
await uploadTestImage(page, 'landscape.png', createLandscapePng())
15+
16+
// Photo should appear in grid with its filename
17+
await expect(page.getByText('landscape.png')).toBeVisible()
18+
})
19+
20+
test('can upload multiple images and see correct count in stats bar', async ({
21+
page,
22+
}) => {
23+
await uploadMultipleTestImages(page, [
24+
{ name: 'photo1.png', buffer: createLandscapePng() },
25+
{ name: 'photo2.png', buffer: createPortraitPng() },
26+
{ name: 'photo3.png', buffer: createLandscapePng() },
27+
])
28+
29+
// Stats bar should show "3 photos"
30+
await expect(page.getByText('3 photos')).toBeVisible()
31+
})
32+
33+
test('shows file name and dimensions for uploaded photo', async ({ page }) => {
34+
await uploadTestImage(page, 'test-shot.png', createLandscapePng())
35+
36+
await expect(page.getByText('test-shot.png')).toBeVisible()
37+
// Landscape PNG is 10x5
38+
await expect(page.getByText('10 × 5')).toBeVisible()
39+
})
40+
41+
test('Clear All button removes all photos', async ({ page }) => {
42+
await uploadMultipleTestImages(page, [
43+
{ name: 'a.png', buffer: createLandscapePng() },
44+
{ name: 'b.png', buffer: createPortraitPng() },
45+
])
46+
47+
// Verify photos are present
48+
await expect(page.getByText('2 photos')).toBeVisible()
49+
50+
// Click Clear all
51+
await page.getByText('Clear all').click()
52+
53+
// Photos should be gone; upload zone should reappear
54+
await expect(page.getByText('2 photos')).not.toBeVisible()
55+
await expect(page.getByText('a.png')).not.toBeVisible()
56+
await expect(
57+
page.getByText('Drop photos here or click to browse'),
58+
).toBeVisible()
59+
})
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { PaddingSettingsPanel } from '../components/PaddingSettingsPanel';
5+
import type { PaddingSettings } from '../types';
6+
7+
const defaultSettings: PaddingSettings = {
8+
fillType: 'color',
9+
fillColor: '#ffffff',
10+
fillImageDataUrl: null,
11+
fillImageStyle: 'cover',
12+
};
13+
14+
function renderPanel(overrides: Partial<Parameters<typeof PaddingSettingsPanel>[0]> = {}) {
15+
const props = {
16+
settings: defaultSettings,
17+
onChange: vi.fn(),
18+
onProcess: vi.fn(),
19+
isProcessing: false,
20+
hasPhotos: true,
21+
...overrides,
22+
};
23+
const result = render(<PaddingSettingsPanel {...props} />);
24+
return { ...result, ...props };
25+
}
26+
27+
describe('PaddingSettingsPanel', () => {
28+
it('renders with default settings (Solid Color selected)', () => {
29+
renderPanel();
30+
expect(screen.getByText('Solid Color')).toBeInTheDocument();
31+
expect(screen.getByText('Background Image')).toBeInTheDocument();
32+
expect(screen.getByText('Color')).toBeInTheDocument();
33+
});
34+
35+
it('switches to Background Image fill type', async () => {
36+
const user = userEvent.setup();
37+
const { onChange } = renderPanel();
38+
39+
await user.click(screen.getByText('Background Image'));
40+
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ fillType: 'image' }));
41+
});
42+
43+
it('switches to Solid Color fill type', async () => {
44+
const user = userEvent.setup();
45+
const { onChange } = renderPanel({
46+
settings: { ...defaultSettings, fillType: 'image' },
47+
});
48+
49+
await user.click(screen.getByText('Solid Color'));
50+
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ fillType: 'color' }));
51+
});
52+
53+
it('shows color picker when fillType is color', () => {
54+
renderPanel();
55+
expect(screen.getByText('Color')).toBeInTheDocument();
56+
const colorInput = document.querySelector('input[type="color"]');
57+
expect(colorInput).toBeInTheDocument();
58+
});
59+
60+
it('shows image upload when fillType is image', () => {
61+
renderPanel({
62+
settings: { ...defaultSettings, fillType: 'image' },
63+
});
64+
expect(screen.getByText('Upload image')).toBeInTheDocument();
65+
expect(screen.getByText('Image')).toBeInTheDocument();
66+
});
67+
68+
it('process button is disabled when hasPhotos is false', () => {
69+
renderPanel({ hasPhotos: false });
70+
const btn = screen.getByRole('button', { name: /process images/i });
71+
expect(btn).toBeDisabled();
72+
});
73+
74+
it('process button shows spinner when isProcessing is true', () => {
75+
renderPanel({ isProcessing: true });
76+
expect(screen.getByText('Processing…')).toBeInTheDocument();
77+
});
78+
79+
it('calls onProcess when Process Images button is clicked', async () => {
80+
const user = userEvent.setup();
81+
const { onProcess } = renderPanel();
82+
83+
await user.click(screen.getByRole('button', { name: /process images/i }));
84+
expect(onProcess).toHaveBeenCalledOnce();
85+
});
86+
});

0 commit comments

Comments
 (0)