diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts index ad4f4a6..a561178 100644 --- a/e2e/settings.spec.ts +++ b/e2e/settings.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test' -import { createLandscapePng, uploadTestImage } from './test-helpers' test.beforeEach(async ({ page }) => { await page.goto('/') diff --git a/src/App.tsx b/src/App.tsx index d380a9d..472a0df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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'; @@ -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 | undefined>(undefined); + const isInitialMountRef = useRef(true); const THEME_OPTIONS: { value: Theme; icon: typeof Sun; label: string }[] = [ { value: 'light', icon: Sun, label: 'Light' }, @@ -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; @@ -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; @@ -281,7 +305,6 @@ export default function App() { defaultSettings={DEFAULT_SETTINGS} onChange={s => { setSettings(s); - if (isProcessed) setSettingsChangedSinceProcess(true); setIsProcessed(false); }} /> @@ -305,11 +328,6 @@ export default function App() { )} - {settingsChangedSinceProcess && ( -

- Settings changed — re-process to apply -

- )} diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index bcfa3a8..b5ca1cc 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -39,7 +39,7 @@ describe('App', () => { addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), - })) as any; + })) as unknown as MediaQueryList; }); afterEach(() => { @@ -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; }, @@ -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).mockResolvedValue('data:image/png;base64,padded'); + const { restore } = setupUploadMocks(); + + render(); + + // 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).mockResolvedValue('data:image/png;base64,padded'); + const { restore } = setupUploadMocks(); + + render(); + + // 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); }); diff --git a/src/__tests__/PhotoGrid.test.tsx b/src/__tests__/PhotoGrid.test.tsx index 0682968..d29e372 100644 --- a/src/__tests__/PhotoGrid.test.tsx +++ b/src/__tests__/PhotoGrid.test.tsx @@ -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'; @@ -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) }) @@ -116,13 +116,13 @@ describe('PhotoGrid', () => { }) globalThis.ClipboardItem = class { constructor(public items: Record) {} - } 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' }), @@ -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) }) @@ -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) }) diff --git a/src/__tests__/heicUtils.test.ts b/src/__tests__/heicUtils.test.ts index 95e23a1..d799b26 100644 --- a/src/__tests__/heicUtils.test.ts +++ b/src/__tests__/heicUtils.test.ts @@ -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) }) }) @@ -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) }) @@ -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) }) }) diff --git a/src/__tests__/imageUtils.test.ts b/src/__tests__/imageUtils.test.ts index fc5de1d..266566e 100644 --- a/src/__tests__/imageUtils.test.ts +++ b/src/__tests__/imageUtils.test.ts @@ -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 { return { @@ -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` ) } @@ -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?.()) }, }) } @@ -288,7 +287,7 @@ 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) }) @@ -296,7 +295,7 @@ describe('padImageToAspectRatio', () => { 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) }) diff --git a/src/__tests__/useDarkMode.test.ts b/src/__tests__/useDarkMode.test.ts index ceaaea0..ea0e482 100644 --- a/src/__tests__/useDarkMode.test.ts +++ b/src/__tests__/useDarkMode.test.ts @@ -34,12 +34,12 @@ describe('useDarkMode', () => { mockListeners.push(...listeners); // Keep reference so listeners added later are captured const origAddEventListener = mql.addEventListener as ReturnType; - (mql as any).addEventListener = vi.fn((_event: string, handler: (e: { matches: boolean }) => void) => { + (mql as unknown as Record).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(() => { @@ -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);