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:
1 parent
86b5363a01
commit
99c5d2ad78
21 files changed
+347
-61
No files matched your search
@@ -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)]);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user