"+ → Texture" opened the machine's file dialog, so a picture that was uploaded once but lost its asset – an undone import, a deleted asset – was unreachable from the editor while still sitting on the server. Nothing collects those files, by design. It now opens a grid of every picture the server holds, with thumbnails: pick one and "Use image" makes a texture asset on that file, "Upload…" is the way in for bytes the server does not have yet and lands in the same import as a drop. A tile captioned with an asset name is one this project already wears – picking it selects that asset instead of making a second one, since two assets on one file would fight over `filter`. - `GET ?r=textures` now reports `width`/`height`; a texture asset holds the pixel size, and reading it off the GPU texture would tie the asset's correctness to load timing. - `TextureLibrary.load(file)` loads without touching `scaleMode` – a thumbnail must not restage the filter of a picture the scene is drawing with. - The grid geometry lives in `texture/imageGrid.ts` as pure functions; inside the widget no test could reach it. - The part after an upload is now one function shared by the drop import and the picker, so "one file = one asset" is stated in a single place. 12 new E2E checks; the strongest one undoes an import, leaving the file on the server with no asset naming it, and picks exactly that file back out of the dialog.
86 lines
3.9 KiB
TypeScript
86 lines
3.9 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { HttpProjectStore } from '../../src/core/store/HttpProjectStore';
|
|
import { HttpTextureStore } from '../../src/core/store/HttpTextureStore';
|
|
import { StaleRevisionError } from '../../src/core/store/ProjectStore';
|
|
import { makeProject } from '../helpers';
|
|
|
|
const FILE = `${'a'.repeat(64)}.png`;
|
|
/** What nginx answers when it refuses a request body larger than its 1 MB limit. */
|
|
const NGINX_413 = '<html><head><title>413 Request Entity Too Large</title></head><body></body></html>';
|
|
|
|
/** Replaces `fetch` with one that always answers the same way; the mock records the request. */
|
|
function stubFetch(body: BodyInit | null, init: ResponseInit) {
|
|
const mock = vi.fn<typeof fetch>(() => Promise.resolve(new Response(body, init)));
|
|
vi.stubGlobal('fetch', mock);
|
|
return mock;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe('HttpProjectStore', () => {
|
|
it('reads a JSON body and keeps the server message on failure', async () => {
|
|
stubFetch(JSON.stringify([{ id: 'p', name: 'P', revision: 1, updatedAt: 'now' }]), { status: 200 });
|
|
await expect(new HttpProjectStore('/api').list()).resolves.toHaveLength(1);
|
|
|
|
stubFetch(JSON.stringify({ error: 'Project not found' }), { status: 404 });
|
|
await expect(new HttpProjectStore('/api').load('p')).rejects.toThrow(/Project not found \(404\)/);
|
|
});
|
|
|
|
it('explains a non-JSON error body by its status instead of calling it invalid JSON', async () => {
|
|
stubFetch(NGINX_413, { status: 413, statusText: 'Request Entity Too Large' });
|
|
const { project } = makeProject();
|
|
await expect(new HttpProjectStore('/api').save(project, 1)).rejects.toThrow(/too large \(413\)/i);
|
|
});
|
|
|
|
it('still reports a successful response that is not JSON as a defect', async () => {
|
|
stubFetch('<html>hello</html>', { status: 200 });
|
|
await expect(new HttpProjectStore('/api').list()).rejects.toThrow(/invalid JSON \(200\)/);
|
|
});
|
|
|
|
it('turns a 409 into a StaleRevisionError', async () => {
|
|
stubFetch(JSON.stringify({ error: 'Project was modified elsewhere', revision: 7 }), { status: 409 });
|
|
const { project } = makeProject();
|
|
await expect(new HttpProjectStore('/api').save(project, 3)).rejects.toBeInstanceOf(StaleRevisionError);
|
|
});
|
|
});
|
|
|
|
describe('HttpTextureStore', () => {
|
|
it('uploads the raw bytes and returns what the server stored', async () => {
|
|
const mock = stubFetch(JSON.stringify({ file: FILE, width: 2, height: 3, bytes: 4 }), { status: 201 });
|
|
const bytes = new Blob([new Uint8Array([1, 2, 3, 4])]);
|
|
const stored = await new HttpTextureStore('/api').upload(bytes, 'image/png');
|
|
|
|
expect(stored).toEqual({ file: FILE, width: 2, height: 3, bytes: 4 });
|
|
const [url, init] = mock.mock.calls[0]!;
|
|
expect(url).toBe('/api?r=textures');
|
|
expect(init?.method).toBe('POST');
|
|
// The body *is* the image: no multipart wrapper, no JSON.
|
|
expect(init?.body).toBe(bytes);
|
|
expect(init?.headers).toEqual({ 'Content-Type': 'image/png' });
|
|
});
|
|
|
|
it('reports the nginx size limit as a size problem', async () => {
|
|
stubFetch(NGINX_413, { status: 413, statusText: 'Request Entity Too Large' });
|
|
const bytes = new Blob([new Uint8Array(8)]);
|
|
await expect(new HttpTextureStore('/api').upload(bytes, 'image/png')).rejects.toThrow(
|
|
/too large \(413\)/i,
|
|
);
|
|
});
|
|
|
|
it('lists and deletes', async () => {
|
|
stubFetch(JSON.stringify([{ file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' }]), {
|
|
status: 200,
|
|
});
|
|
await expect(new HttpTextureStore('/api').list()).resolves.toEqual([
|
|
{ file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' },
|
|
]);
|
|
|
|
const mock = stubFetch(null, { status: 204 });
|
|
await new HttpTextureStore('/api').delete(FILE);
|
|
expect(mock.mock.calls[0]![0]).toBe(`/api?r=${encodeURIComponent(`textures/${FILE}`)}`);
|
|
expect(mock.mock.calls[0]![1]?.method).toBe('DELETE');
|
|
});
|
|
});
|