Files
feuerwerk/tests/core/http-store.test.ts
T
rootandClaude Opus 5 5a6af74e44 Add textures for sprites
Images are uploaded to a content-addressed blob endpoint and stored as
data/textures/<sha256>.<ext>; the project only keeps the file name. The
sprite points at a texture asset through an ordinary property of the new
kind `asset`, so instance overrides, "Apply to container", "Reset to
asset", undo and `Set property` work without any extra code. The new kind
also replaces the ad-hoc asset picker the graph nodes faked as an enum.

Import goes through + -> Texture, drag & drop onto the canvas or Ctrl+V.
Images wider than 2048px are downscaled client-side and re-encoded to
WebP if needed, because nginx caps uploads at 1MB; an image that already
fits is uploaded byte for byte so pixel art is never resampled.

The sprite colour default changes from red to white, since colour tints
the texture multiplicatively and a tint of white shows an image as it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:43:08 +02:00

84 lines
3.8 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, bytes: 12, updatedAt: 'now' }]), { status: 200 });
await expect(new HttpTextureStore('/api').list()).resolves.toEqual([
{ file: FILE, 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');
});
});