mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
* 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
371 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|