mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user