Files
5chan/src/components/oekaki-drawing-controls/__tests__/oekaki-drawing-controls.test.tsx
T
Tommaso CasaburiandGitHub 56894700c1 feat(oekaki): add drawing flow for /i/ (#1144)
* feat(oekaki): add drawing flow for /i/

* fix(oekaki): address review feedback

* fix(oekaki): reset Tegaki edit sessions

* fix(oekaki): block drawing during export

* fix(oekaki): destroy Tegaki on preload errors

* fix(oekaki): preserve drawing on export failure

* fix(oekaki): unlock controls after export failure
2026-05-30 15:06:47 +07:00

371 lines
12 KiB
TypeScript

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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
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<UploadedFileResult | null>>().mockResolvedValue(null),
onClearUploadedUrl = vi.fn(),
}: {
uploadFile?: (file: File) => Promise<UploadedFileResult | null>;
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<UploadedFileResult | null>>().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<UploadedFileResult | null>>()
.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<UploadedFileResult | null>>().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<UploadedFileResult | null>>().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);
});
});