Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion e2e/settings.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { test, expect } from '@playwright/test'
import { createLandscapePng, uploadTestImage } from './test-helpers'

test.beforeEach(async ({ page }) => {
await page.goto('/')
Expand Down
36 changes: 27 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import JSZip from 'jszip';
import { PhotoUpload } from './components/PhotoUpload';
import { PaddingSettingsPanel } from './components/PaddingSettingsPanel';
Expand Down Expand Up @@ -67,7 +67,12 @@ export default function App() {
const [isProcessed, setIsProcessed] = useState(false);
const [progress, setProgress] = useState(0);
const [theme, setTheme] = useDarkMode();
const [settingsChangedSinceProcess, setSettingsChangedSinceProcess] = useState(false);

// Ref that always holds the latest handleProcess so the debounce timer
// never captures a stale closure.
const handleProcessRef = useRef<() => void>(() => {});
const autoProcessTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const isInitialMountRef = useRef(true);

const THEME_OPTIONS: { value: Theme; icon: typeof Sun; label: string }[] = [
{ value: 'light', icon: Sun, label: 'Light' },
Expand Down Expand Up @@ -116,7 +121,6 @@ export default function App() {
if (photos.length === 0) return;
setIsProcessing(true);
setProgress(0);
setSettingsChangedSinceProcess(false);

// Determine target aspect ratio
let target: number;
Expand All @@ -138,6 +142,26 @@ export default function App() {
setIsProcessed(true);
};

// Keep the ref pointing at the latest handleProcess so the debounce
// timer always calls the version that closes over fresh state.
useEffect(() => {
handleProcessRef.current = handleProcess;
});

// Auto-process when settings change (debounced), provided photos are loaded.
// photos.length is intentionally omitted from deps to avoid triggering on
// photo additions/removals; handleProcess already guards against empty photos.
useEffect(() => {
if (isInitialMountRef.current) {
isInitialMountRef.current = false;
return;
}
if (photos.length === 0) return;
clearTimeout(autoProcessTimerRef.current);
autoProcessTimerRef.current = setTimeout(() => handleProcessRef.current(), 400);
return () => clearTimeout(autoProcessTimerRef.current);
}, [settings]); // eslint-disable-line react-hooks/exhaustive-deps

const handleDownloadAll = async () => {
const processedPhotos = photos.filter(p => p.paddedDataUrl);
if (processedPhotos.length === 0) return;
Expand Down Expand Up @@ -281,7 +305,6 @@ export default function App() {
defaultSettings={DEFAULT_SETTINGS}
onChange={s => {
setSettings(s);
if (isProcessed) setSettingsChangedSinceProcess(true);
setIsProcessed(false);
}}
/>
Expand All @@ -305,11 +328,6 @@ export default function App() {
</>
)}
</button>
{settingsChangedSinceProcess && (
<p className="text-xs text-amber-600 dark:text-amber-400 text-center mt-2">
Settings changed — re-process to apply
</p>
)}
</div>
</div>
</div>
Expand Down
60 changes: 58 additions & 2 deletions src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('App', () => {
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})) as any;
})) as unknown as MediaQueryList;
});

afterEach(() => {
Expand Down Expand Up @@ -114,7 +114,7 @@ describe('App', () => {
setTimeout(() => this.onload?.(), 0);
}
}
global.FileReader = MockFileReader as any;
global.FileReader = MockFileReader as unknown as typeof FileReader;

return {
restore: () => { global.FileReader = origFileReader; },
Expand Down Expand Up @@ -204,4 +204,60 @@ describe('App', () => {

restore();
});

// --- Auto-process on settings change ---
it('does not show "Settings changed" warning (auto-process replaces it)', async () => {
const user = userEvent.setup();
const mockFile = await setupMocksAndUpload();
const { padImageToAspectRatio } = await import('../lib/imageUtils');
(padImageToAspectRatio as ReturnType<typeof vi.fn>).mockResolvedValue('data:image/png;base64,padded');
const { restore } = setupUploadMocks();

render(<App />);

// Upload and process
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [mockFile] } });
await waitFor(() => expect(screen.getByText(/1 photo/)).toBeInTheDocument());
await user.click(screen.getByText('Process Images').closest('button')!);
await waitFor(() => expect(screen.getByText('Download All as ZIP')).toBeInTheDocument());

// Change a setting – the old warning must never appear
const resetButton = screen.getByTitle('Reset to defaults');
await user.click(resetButton);
expect(screen.queryByText(/re-process to apply/i)).not.toBeInTheDocument();

restore();
});

it('auto-processes after a settings change when photos are loaded', async () => {
const user = userEvent.setup();
const mockFile = await setupMocksAndUpload();
const { padImageToAspectRatio } = await import('../lib/imageUtils');
(padImageToAspectRatio as ReturnType<typeof vi.fn>).mockResolvedValue('data:image/png;base64,padded');
const { restore } = setupUploadMocks();

render(<App />);

// Upload and do the initial manual process
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
fireEvent.change(input, { target: { files: [mockFile] } });
await waitFor(() => expect(screen.getByText(/1 photo/)).toBeInTheDocument());
await user.click(screen.getByText('Process Images').closest('button')!);
await waitFor(() => expect(screen.getByText('Download All as ZIP')).toBeInTheDocument());

// Simulate a settings change by switching the output format (JPEG ≠ default PNG)
await user.click(screen.getByRole('button', { name: 'jpeg' }));

// Download All disappears immediately (isProcessed reset to false)
expect(screen.queryByText('Download All as ZIP')).not.toBeInTheDocument();

// After the debounce fires, auto-process re-runs and Download All reappears
await waitFor(
() => expect(screen.getByText('Download All as ZIP')).toBeInTheDocument(),
{ timeout: 3000 },
);

restore();
}, 5000);
});
12 changes: 6 additions & 6 deletions src/__tests__/PhotoGrid.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { PhotoGrid } from '../components/PhotoGrid';
import type { UploadedPhoto } from '../types';
Expand Down Expand Up @@ -70,7 +70,7 @@ describe('PhotoGrid', () => {
const clickSpy = vi.fn()
const createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
if (tag === 'a') {
return { href: '', download: '', click: clickSpy } as any
return { href: '', download: '', click: clickSpy } as unknown as HTMLAnchorElement
}
return originalCreateElement(tag)
})
Expand Down Expand Up @@ -116,13 +116,13 @@ describe('PhotoGrid', () => {
})
globalThis.ClipboardItem = class {
constructor(public items: Record<string, Blob>) {}
} as any
} as unknown as typeof ClipboardItem

const pngBlob = new Blob(['px'], { type: 'image/png' })
const mockFetch = vi.fn().mockResolvedValue({
blob: () => Promise.resolve(pngBlob),
})
window.fetch = mockFetch as any
window.fetch = mockFetch as unknown as typeof fetch

const photos = [
makePhoto({ id: 'p1', paddedDataUrl: 'data:image/png;base64,padded' }),
Expand Down Expand Up @@ -160,7 +160,7 @@ describe('PhotoGrid', () => {
set(v: string) { downloadFilename = v },
get() { return downloadFilename },
})
return anchor as any
return anchor as unknown as HTMLAnchorElement
}
return originalCreateElement(tag)
})
Expand All @@ -187,7 +187,7 @@ describe('PhotoGrid', () => {
set(v: string) { downloadFilename = v },
get() { return downloadFilename },
})
return anchor as any
return anchor as unknown as HTMLAnchorElement
}
return originalCreateElement(tag)
})
Expand Down
6 changes: 3 additions & 3 deletions src/__tests__/heicUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ describe('convertHeicToJpeg', () => {
}])
const mockCanvas = makeMockCanvas()
createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
if (tag === 'canvas') return mockCanvas as any
if (tag === 'canvas') return mockCanvas as unknown as HTMLCanvasElement
return originalCreateElement(tag)
})
})
Expand Down Expand Up @@ -150,7 +150,7 @@ describe('convertHeicToJpeg', () => {
createElementSpy.mockRestore()
const mockCanvas = makeMockCanvas(null)
createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
if (tag === 'canvas') return mockCanvas as any
if (tag === 'canvas') return mockCanvas as unknown as HTMLCanvasElement
return originalCreateElement(tag)
})

Expand All @@ -173,7 +173,7 @@ describe('processFilesForHeic', () => {
}])
const mockCanvas = makeMockCanvas()
createElementSpy = vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
if (tag === 'canvas') return mockCanvas as any
if (tag === 'canvas') return mockCanvas as unknown as HTMLCanvasElement
return originalCreateElement(tag)
})
})
Expand Down
17 changes: 8 additions & 9 deletions src/__tests__/imageUtils.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { findMaxAspectRatio, getImageDimensions, padImageToAspectRatio, drawPatternFill, drawWatermark, getWatermarkPosition } from '../lib/imageUtils'
import type { UploadedPhoto, PaddingSettings, PatternSettings, WatermarkSettings, WatermarkPosition } from '../types'
import type { UploadedPhoto, PaddingSettings, PatternSettings, WatermarkSettings } from '../types'

function makePhoto(width: number, height: number, overrides?: Partial<UploadedPhoto>): UploadedPhoto {
return {
Expand Down Expand Up @@ -92,7 +92,7 @@ function installCanvasMocks() {

HTMLCanvasElement.prototype.getContext = vi.fn(() => mockCtx) as unknown as typeof HTMLCanvasElement.prototype.getContext
HTMLCanvasElement.prototype.toDataURL = vi.fn(
(type?: string, _quality?: unknown) => `data:${type ?? 'image/png'};base64,MOCK`
(type?: string) => `data:${type ?? 'image/png'};base64,MOCK`
)
}

Expand All @@ -108,13 +108,12 @@ function installImageMock(naturalWidth = 800, naturalHeight = 600) {
onerror: ((err: unknown) => void) | null = null

constructor() {
const self = this
// Auto-fire onload on next microtask after src is set
Object.defineProperty(this, 'src', {
get: () => self._src,
set(value: string) {
self._src = value
queueMicrotask(() => self.onload?.())
get: () => this._src,
set: (value: string) => {
this._src = value
queueMicrotask(() => this.onload?.())
},
})
}
Expand Down Expand Up @@ -288,15 +287,15 @@ describe('padImageToAspectRatio', () => {
it('outputs webp format with quality', async () => {
const photo = makePhoto(600, 600, { dataUrl: 'data:img' })
const settings = defaultSettings({ outputFormat: 'webp', outputQuality: 0.9 })
const result = await padImageToAspectRatio(photo, 2, settings)
await padImageToAspectRatio(photo, 2, settings)

expect(HTMLCanvasElement.prototype.toDataURL).toHaveBeenCalledWith('image/webp', 0.9)
})

it('outputs png format without quality parameter', async () => {
const photo = makePhoto(600, 600, { dataUrl: 'data:img' })
const settings = defaultSettings({ outputFormat: 'png' })
const result = await padImageToAspectRatio(photo, 2, settings)
await padImageToAspectRatio(photo, 2, settings)

expect(HTMLCanvasElement.prototype.toDataURL).toHaveBeenCalledWith('image/png', undefined)
})
Expand Down
6 changes: 3 additions & 3 deletions src/__tests__/useDarkMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,12 @@ describe('useDarkMode', () => {
mockListeners.push(...listeners);
// Keep reference so listeners added later are captured
const origAddEventListener = mql.addEventListener as ReturnType<typeof vi.fn>;
(mql as any).addEventListener = vi.fn((_event: string, handler: (e: { matches: boolean }) => void) => {
(mql as unknown as Record<string, unknown>).addEventListener = vi.fn((_event: string, handler: (e: { matches: boolean }) => void) => {
mockListeners.push(handler);
origAddEventListener(_event, handler);
});
return mql;
}) as any;
}) as unknown as typeof window.matchMedia;
});

afterEach(() => {
Expand Down Expand Up @@ -104,7 +104,7 @@ describe('useDarkMode', () => {
window.matchMedia = vi.fn(() => {
const { mql } = createMatchMedia(true);
return mql;
}) as any;
}) as unknown as typeof window.matchMedia;
const { result } = renderHook(() => useDarkMode());
expect(result.current[0]).toBe('system');
expect(result.current[2]).toBe(true);
Expand Down
Loading