import * as React from 'react'; import { createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import OekakiDrawingControls from '../oekaki-drawing-controls'; import { OEKAKI_MOBILE_PORTRAIT_MESSAGE, OEKAKI_WEB_DOWNLOAD_MESSAGE } from '../../../lib/oekaki/oekaki-copy'; import type { UploadedFileResult } from '../../../hooks/use-file-upload'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; const testState = vi.hoisted(() => ({ runtime: 'web' as 'web' | 'electron' | 'android', loadTegakiMock: vi.fn(), openMock: vi.fn(), flattenMock: vi.fn(), destroyMock: vi.fn(), onOpenImageLoadedMock: vi.fn(), })); vi.mock('../../../lib/media-hosting/show-upload-controls', () => ({ getMediaHostingRuntime: () => testState.runtime, })); vi.mock('../../../lib/oekaki/tegaki-loader', () => ({ TEGAKI_DRAWING_FILE_NAME: 'tegaki.png', loadTegaki: testState.loadTegakiMock, })); let container: HTMLDivElement; let root: Root; const OriginalImage = globalThis.Image; interface MockTegakiOpenOptions { width: number; height: number; saveReplay: boolean; onDone: () => void; onCancel: () => void; } const setViewport = (width: number, height: number) => { Object.defineProperty(window, 'innerWidth', { configurable: true, value: width }); Object.defineProperty(window, 'innerHeight', { configurable: true, value: height }); Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn().mockImplementation((query: string) => ({ matches: query === '(max-width: 640px) and (orientation: portrait)' && width <= 640 && height > width, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), })), }); }; const createFinishedCanvas = () => { const canvas = document.createElement('canvas'); Object.defineProperty(canvas, 'toBlob', { configurable: true, value: (callback: BlobCallback) => callback(new Blob(['png'], { type: 'image/png' })), }); return canvas; }; class MockImage { onload: (() => void) | null = null; onerror: (() => void) | null = null; set src(_value: string) { queueMicrotask(() => this.onload?.()); } } const renderControls = async ({ uploadFile = vi.fn<(file: File) => Promise>().mockResolvedValue(null), onClearUploadedUrl = vi.fn(), }: { uploadFile?: (file: File) => Promise; onClearUploadedUrl?: (url: string) => void; } = {}) => { await act(async () => { root.render(createElement(OekakiDrawingControls, { uploadFile, onClearUploadedUrl })); }); return { uploadFile, onClearUploadedUrl }; }; const getButton = (label: string): HTMLButtonElement => { const button = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === label); if (!(button instanceof HTMLButtonElement)) { throw new Error(`Button ${label} not found`); } return button; }; const clickButton = async (label: string) => { const button = getButton(label); await act(async () => { button.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); }; const triggerTegakiDone = async () => { const openOptions = testState.openMock.mock.calls.at(-1)?.[0] as MockTegakiOpenOptions | undefined; if (!openOptions) { throw new Error('Tegaki open options not captured'); } await act(async () => { openOptions.onDone(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); }; describe('OekakiDrawingControls', () => { beforeEach(() => { vi.clearAllMocks(); testState.runtime = 'web'; testState.openMock.mockReset(); testState.flattenMock.mockReset(); testState.destroyMock.mockReset(); testState.onOpenImageLoadedMock.mockReset(); testState.flattenMock.mockReturnValue(createFinishedCanvas()); const tegaki = { bg: null as HTMLElement | null, open: testState.openMock, flatten: testState.flattenMock, destroy: testState.destroyMock, onOpenImageLoaded: testState.onOpenImageLoadedMock, }; testState.openMock.mockImplementation(() => { tegaki.bg = document.createElement('div'); }); testState.destroyMock.mockImplementation(() => { tegaki.bg = null; }); testState.loadTegakiMock.mockResolvedValue(tegaki); Object.defineProperty(globalThis, 'Image', { configurable: true, value: MockImage, }); Object.defineProperty(globalThis, 'alert', { configurable: true, value: vi.fn(), writable: true, }); Object.defineProperty(globalThis, 'confirm', { configurable: true, value: vi.fn(() => true), writable: true, }); Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:tegaki'), }); Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn(), }); vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined); setViewport(1024, 768); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); Object.defineProperty(globalThis, 'Image', { configurable: true, value: OriginalImage, }); vi.restoreAllMocks(); }); it('alerts instead of opening Tegaki on a portrait phone viewport', async () => { setViewport(390, 844); await renderControls(); await clickButton('Draw'); expect(globalThis.alert).toHaveBeenCalledWith(OEKAKI_MOBILE_PORTRAIT_MESSAGE); expect(testState.loadTegakiMock).not.toHaveBeenCalled(); }); it('opens Tegaki on a landscape phone viewport', async () => { setViewport(844, 390); await renderControls(); await clickButton('Draw'); expect(globalThis.alert).not.toHaveBeenCalled(); expect(testState.loadTegakiMock).toHaveBeenCalledTimes(1); expect(testState.openMock).toHaveBeenCalledWith(expect.objectContaining({ width: 400, height: 400, saveReplay: true })); }); it('keeps Draw disabled while Tegaki is open', async () => { await renderControls(); await clickButton('Draw'); expect(getButton('Draw').disabled).toBe(true); await clickButton('Draw'); expect(testState.loadTegakiMock).toHaveBeenCalledTimes(1); const openOptions = testState.openMock.mock.calls.at(-1)?.[0] as MockTegakiOpenOptions; await act(async () => { openOptions.onCancel(); }); expect(testState.destroyMock).toHaveBeenCalledTimes(1); expect(getButton('Draw').disabled).toBe(false); }); it('downloads the web drawing only after confirmation', async () => { await renderControls(); await clickButton('Draw'); await triggerTegakiDone(); expect(globalThis.confirm).toHaveBeenCalledWith(OEKAKI_WEB_DOWNLOAD_MESSAGE); expect(testState.destroyMock).toHaveBeenCalledTimes(1); expect(HTMLAnchorElement.prototype.click).toHaveBeenCalledTimes(1); expect(URL.createObjectURL).toHaveBeenCalledOnce(); }); it('keeps Draw disabled while a finished drawing is still exporting', async () => { let finishExport: BlobCallback | null = null; const canvas = document.createElement('canvas'); Object.defineProperty(canvas, 'toBlob', { configurable: true, value: (callback: BlobCallback) => { finishExport = callback; }, }); testState.flattenMock.mockReturnValue(canvas); await renderControls(); await clickButton('Draw'); const openOptions = testState.openMock.mock.calls.at(-1)?.[0] as MockTegakiOpenOptions; await act(async () => { openOptions.onDone(); await Promise.resolve(); }); expect(getButton('Draw').disabled).toBe(true); expect(testState.openMock).toHaveBeenCalledTimes(1); await act(async () => { finishExport?.(new Blob(['png'], { type: 'image/png' })); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); expect(getButton('Edit').disabled).toBe(false); }); it('unlocks the controls when exporting the finished drawing fails', async () => { const canvas = document.createElement('canvas'); Object.defineProperty(canvas, 'toBlob', { configurable: true, value: (callback: BlobCallback) => callback(null), }); testState.flattenMock.mockReturnValue(canvas); await renderControls(); await clickButton('Draw'); const openOptions = testState.openMock.mock.calls.at(-1)?.[0] as MockTegakiOpenOptions; await act(async () => { openOptions.onDone(); await Promise.resolve(); await Promise.resolve(); }); expect(globalThis.alert).toHaveBeenCalledWith('Could not export drawing'); expect(testState.destroyMock).toHaveBeenCalledTimes(1); expect(getButton('Draw').disabled).toBe(false); }); it('does not download the web drawing when confirmation is cancelled', async () => { Object.defineProperty(globalThis, 'confirm', { configurable: true, value: vi.fn(() => false), }); await renderControls(); await clickButton('Draw'); await triggerTegakiDone(); expect(globalThis.confirm).toHaveBeenCalledWith(OEKAKI_WEB_DOWNLOAD_MESSAGE); expect(HTMLAnchorElement.prototype.click).not.toHaveBeenCalled(); expect(URL.createObjectURL).not.toHaveBeenCalled(); }); it('clears the uploaded drawing URL when Clear is clicked', async () => { testState.runtime = 'electron'; const uploadFile = vi.fn<(file: File) => Promise>().mockResolvedValue({ url: 'https://files.example/tegaki.png', fileName: 'tegaki.png', }); const onClearUploadedUrl = vi.fn(); await renderControls({ uploadFile, onClearUploadedUrl }); await clickButton('Draw'); await triggerTegakiDone(); await clickButton('Clear'); expect(onClearUploadedUrl).toHaveBeenCalledWith('https://files.example/tegaki.png'); }); it('clears a stale uploaded URL when re-uploading an edited drawing fails', async () => { testState.runtime = 'electron'; const uploadFile = vi .fn<(file: File) => Promise>() .mockResolvedValueOnce({ url: 'https://files.example/first.png', fileName: 'tegaki.png' }) .mockResolvedValueOnce(null); const onClearUploadedUrl = vi.fn(); await renderControls({ uploadFile, onClearUploadedUrl }); await clickButton('Draw'); await triggerTegakiDone(); await clickButton('Edit'); await triggerTegakiDone(); expect(uploadFile).toHaveBeenCalledTimes(2); expect(onClearUploadedUrl).toHaveBeenCalledWith('https://files.example/first.png'); }); it('starts edited drawings from a fresh Tegaki session with the saved image loaded', async () => { testState.runtime = 'electron'; const uploadFile = vi.fn<(file: File) => Promise>().mockResolvedValue({ url: 'https://files.example/tegaki.png', fileName: 'tegaki.png', }); await renderControls({ uploadFile }); await clickButton('Draw'); await triggerTegakiDone(); await clickButton('Edit'); expect(testState.openMock).toHaveBeenCalledTimes(2); expect(testState.destroyMock).toHaveBeenCalledTimes(1); expect(testState.onOpenImageLoadedMock).toHaveBeenCalledTimes(1); expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(File)); }); it('destroys Tegaki when an edited drawing cannot be loaded', async () => { testState.runtime = 'electron'; const uploadFile = vi.fn<(file: File) => Promise>().mockResolvedValue({ url: 'https://files.example/tegaki.png', fileName: 'tegaki.png', }); await renderControls({ uploadFile }); await clickButton('Draw'); await triggerTegakiDone(); testState.onOpenImageLoadedMock.mockImplementationOnce(() => { throw new Error('Could not restore drawing'); }); await clickButton('Edit'); expect(testState.destroyMock).toHaveBeenCalledTimes(2); expect(globalThis.alert).toHaveBeenCalledWith('Could not restore drawing'); expect(getButton('Edit').disabled).toBe(false); }); });