Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts, and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list, check count 958, the two-job CI, the Scenes wiki page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
100 lines
4.6 KiB
TypeScript
100 lines
4.6 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
import { HttpProjectStore } from '../../src/core/store/HttpProjectStore';
|
||
import { HttpFileStore } from '../../src/core/store/HttpFileStore';
|
||
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('HttpFileStore', () => {
|
||
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 HttpFileStore('/api', 'textures').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('sends the file name beside the route, and omits it when there is none', async () => {
|
||
const answer = JSON.stringify({ file: FILE, name: 'hero idle.png', width: 2, height: 3, bytes: 4 });
|
||
const bytes = new Blob([new Uint8Array(4)]);
|
||
|
||
let mock = stubFetch(answer, { status: 201 });
|
||
await new HttpFileStore('/api', 'textures').upload(bytes, 'image/png', 'hero idle.png');
|
||
// The body stays the raw image, so the name travels in the query – encoded, not as a path.
|
||
expect(mock.mock.calls[0]![0]).toBe('/api?r=textures&name=hero%20idle.png');
|
||
|
||
mock = stubFetch(answer, { status: 201 });
|
||
await new HttpFileStore('/api', 'textures').upload(bytes, 'image/png');
|
||
expect(mock.mock.calls[0]![0]).toBe('/api?r=textures');
|
||
});
|
||
|
||
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 HttpFileStore('/api', 'textures').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 HttpFileStore('/api', 'textures').list()).resolves.toEqual([
|
||
{ file: FILE, width: 8, height: 4, bytes: 12, updatedAt: 'now' },
|
||
]);
|
||
|
||
const mock = stubFetch(null, { status: 204 });
|
||
await new HttpFileStore('/api', 'textures').delete(FILE);
|
||
expect(mock.mock.calls[0]![0]).toBe(`/api?r=${encodeURIComponent(`textures/${FILE}`)}`);
|
||
expect(mock.mock.calls[0]![1]?.method).toBe('DELETE');
|
||
});
|
||
});
|