Uploads carry the name the file came with (#83)

An upload sends its file name next to the raw bytes, and the server keeps
it in a sidecar next to the content-addressed file. Both pickers caption
every tile and row with it, and an asset made there is named after the
file instead of "Texture" / "Font". Last upload of the same content wins.

The API's own set_error_handler ignores error_reporting(), so @-silenced
warnings still throw: a missing sidecar took the whole listing down with a
500. Existence is checked instead of suppressed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DeveloperandClaude Opus 5 committed 2026-09-13 20:32:45 +02:00
1 parent 86b5363a01
commit 99c5d2ad78
21 files changed
+347 -61

No files matched your search

+14
View File
@@ -61,6 +61,20 @@ describe('HttpTextureStore', () => {
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 HttpTextureStore('/api').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 HttpTextureStore('/api').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)]);
+13
View File
@@ -24,6 +24,19 @@ describe('CreateTextureCommand', () => {
expect(findAsset(project, command.asset.id)?.name).toBe('Hero');
});
it('keeps the caller’s name even though the upload result carries one of its own', () => {
const { project, stack } = makeProject();
// What `TextureStore.upload` actually answers: the file's original name and its byte count
// ride along. Copied wholesale they would land in the asset and rename it behind the caller.
const upload = { file: FILE, name: 'hero-idle.png', width: 64, height: 32, bytes: 900 };
const command = new CreateTextureCommand('Hero', upload);
stack.execute(command);
const asset = findAsset(project, command.asset.id);
expect(asset?.name).toBe('Hero');
expect(asset).not.toHaveProperty('bytes');
});
it('rejects a file name the server cannot have produced – in execute, not in the constructor', () => {
const { project, stack } = makeProject();
const command = new CreateTextureCommand('Hero', { file: '../secrets.png', width: 1, height: 1 });
+13 -2
View File
@@ -11,7 +11,7 @@ import { EditorState } from '../../src/editor/state';
import { importNotice } from '../../src/editor/textureImport';
const FILE = `${'a'.repeat(64)}.ttf`;
const font = { file: FILE, bytes: 380_660 };
const font = { file: FILE, name: '', bytes: 380_660 };
function setup(): EditorState {
const state = new EditorState();
@@ -44,6 +44,16 @@ describe('importStoredFont', () => {
expect(state.project.assets.filter((a) => a.type === 'font')).toEqual([]);
});
it('names the asset after the file it was uploaded as, not "Font"', () => {
const state = setup();
importStoredFont({ ...font, name: 'DejaVuSerif.ttf' }, { state, fonts });
expect(state.project.assets.find((a): a is FontAsset => a.type === 'font')).toMatchObject({
file: FILE,
name: 'DejaVuSerif',
});
});
it('reuses the asset that already names the file instead of making a second one', () => {
const state = setup();
const first = importStoredFont(font, { state, fonts });
@@ -66,7 +76,8 @@ describe('importFonts', () => {
const [outcome] = await importFonts([file], { state, fonts });
// The blob goes up as it came in: a typeface cannot be re-encoded smaller on the way.
expect(upload).toHaveBeenCalledWith(file, 'font/ttf');
// The name travels with the bytes: it is all the server can caption the hash with.
expect(upload).toHaveBeenCalledWith(file, 'font/ttf', 'DejaVuSerif.ttf');
expect(outcome).toMatchObject({ error: null, reused: false });
expect(state.project.assets.find((a): a is FontAsset => a.type === 'font')).toMatchObject({
file: FILE,
+24 -1
View File
@@ -4,6 +4,7 @@ import { createEmptyProject } from '../../src/core/model/Project';
import type { TextureStore } from '../../src/core/store/TextureStore';
import { EditorState } from '../../src/editor/state';
import {
assetNameForFile,
encodeWithinBudget,
type ImportOutcome,
importNotice,
@@ -27,6 +28,18 @@ function reused(name: string): ImportOutcome {
return { name, assetId: `id-${name}`, error: null, reused: true };
}
describe('assetNameForFile', () => {
it('drops the extension and falls back when nothing readable is left', () => {
expect(assetNameForFile('hero-idle.png', 'Texture')).toBe('hero-idle');
// Only the last extension goes: a name with dots in it keeps them.
expect(assetNameForFile('walk.cycle.left.png', 'Texture')).toBe('walk.cycle.left');
expect(assetNameForFile('README', 'Texture')).toBe('README');
expect(assetNameForFile('.png', 'Texture')).toBe('Texture');
// What a file the server knows no name for looks like.
expect(assetNameForFile('', 'Font')).toBe('Font');
});
});
describe('planImageFit', () => {
it('leaves an image alone that fits in both senses', () => {
expect(planImageFit(32, 32, 900)).toEqual({ kind: 'keep' });
@@ -114,7 +127,7 @@ describe('importNotice', () => {
describe('importStoredImage', () => {
const FILE = `${'a'.repeat(64)}.png`;
const image = { file: FILE, width: 64, height: 48, bytes: 900 };
const image = { file: FILE, name: '', width: 64, height: 48, bytes: 900 };
/** The picker takes the image from the list; nothing here ever uploads. */
const textures = { upload: vi.fn(), list: vi.fn(), delete: vi.fn() } as unknown as TextureStore;
@@ -138,6 +151,16 @@ describe('importStoredImage', () => {
expect(state.project.assets.filter((a) => a.type === 'texture')).toEqual([]);
});
it('names the asset after the file the picture was uploaded as, not "Texture"', () => {
const state = setup();
importStoredImage({ ...image, name: 'hero-idle.png' }, { state, textures });
expect(state.project.assets.find((a): a is TextureAsset => a.type === 'texture')).toMatchObject({
file: FILE,
name: 'hero-idle',
});
});
it('reuses the asset that already names the file instead of making a second one', () => {
const state = setup();
const first = importStoredImage(image, { state, textures });