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>
176 lines
7.4 KiB
TypeScript
176 lines
7.4 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
||
import type { TextureAsset } from '../../src/core/model/Asset';
|
||
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,
|
||
importStoredImage,
|
||
MAX_TEXTURE_SIDE,
|
||
MAX_UPLOAD_BYTES,
|
||
planImageFit,
|
||
} from '../../src/editor/textureImport';
|
||
|
||
/** A blob of exactly `size` bytes – `encodeWithinBudget` only ever looks at the size. */
|
||
function blobOf(size: number): Blob {
|
||
return new Blob([new Uint8Array(size)]);
|
||
}
|
||
|
||
function outcome(name: string, error: string | null = null): ImportOutcome {
|
||
return { name, assetId: error ? null : `id-${name}`, error, reused: false };
|
||
}
|
||
|
||
/** A file whose picture the project already had, so no asset was created for it. */
|
||
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' });
|
||
expect(planImageFit(MAX_TEXTURE_SIDE, 1024, MAX_UPLOAD_BYTES)).toEqual({ kind: 'keep' });
|
||
});
|
||
|
||
it('caps the longest side and keeps the aspect ratio', () => {
|
||
expect(planImageFit(4000, 3000, 3_000_000)).toEqual({ kind: 'reencode', width: 2048, height: 1536 });
|
||
expect(planImageFit(1000, 4096, 100)).toEqual({ kind: 'reencode', width: 500, height: 2048 });
|
||
});
|
||
|
||
it('re-encodes at the original size when only the byte count is over budget', () => {
|
||
expect(planImageFit(800, 600, MAX_UPLOAD_BYTES + 1)).toEqual({
|
||
kind: 'reencode',
|
||
width: 800,
|
||
height: 600,
|
||
});
|
||
});
|
||
|
||
it('never scales a picture away to nothing', () => {
|
||
expect(planImageFit(20000, 3, 9_000_000)).toEqual({ kind: 'reencode', width: 2048, height: 1 });
|
||
});
|
||
});
|
||
|
||
describe('encodeWithinBudget', () => {
|
||
it('stops at the first quality that fits', async () => {
|
||
const encode = vi.fn(() => Promise.resolve(blobOf(MAX_UPLOAD_BYTES - 1)));
|
||
const blob = await encodeWithinBudget(encode);
|
||
expect(blob.size).toBe(MAX_UPLOAD_BYTES - 1);
|
||
expect(encode).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('steps the quality down until the result fits', async () => {
|
||
const sizes = [MAX_UPLOAD_BYTES + 1000, MAX_UPLOAD_BYTES - 1000];
|
||
const encode = vi.fn((_quality: number) => Promise.resolve(blobOf(sizes.shift() ?? 0)));
|
||
const blob = await encodeWithinBudget(encode);
|
||
expect(blob.size).toBe(MAX_UPLOAD_BYTES - 1000);
|
||
expect(encode).toHaveBeenCalledTimes(2);
|
||
// Decreasing quality, so a later attempt is never larger than an earlier one.
|
||
const qualities = encode.mock.calls.map(([quality]) => quality);
|
||
expect(qualities).toEqual([...qualities].sort((a, b) => b - a));
|
||
});
|
||
|
||
it('returns the last attempt when nothing fits, so the caller can say how far off it was', async () => {
|
||
const encode = vi.fn(() => Promise.resolve(blobOf(MAX_UPLOAD_BYTES + 5)));
|
||
const blob = await encodeWithinBudget(encode);
|
||
expect(blob.size).toBe(MAX_UPLOAD_BYTES + 5);
|
||
expect(encode.mock.calls.length).toBeGreaterThan(1);
|
||
});
|
||
});
|
||
|
||
describe('importNotice', () => {
|
||
it('counts what arrived', () => {
|
||
expect(importNotice([outcome('a.png')])).toBe('Imported 1 image.');
|
||
expect(importNotice([outcome('a.png'), outcome('b.png')])).toBe('Imported 2 images.');
|
||
});
|
||
|
||
it('names the first casualty and how many followed', () => {
|
||
expect(importNotice([outcome('a.png', 'too large')])).toBe('Could not import "a.png": too large');
|
||
expect(importNotice([outcome('a.png', 'too large'), outcome('b.png', 'broken')])).toBe(
|
||
'Could not import "a.png": too large (and 1 more)',
|
||
);
|
||
});
|
||
|
||
it('reports a partial import as both', () => {
|
||
expect(importNotice([outcome('a.png'), outcome('b.png', 'too large')])).toBe(
|
||
'Imported 1 of 2. Could not import "b.png": too large',
|
||
);
|
||
});
|
||
|
||
it('says so when the picture was already in the project', () => {
|
||
// "Imported 1 image" would send the user looking for an asset that was never created.
|
||
expect(importNotice([reused('a.png')])).toBe('That image is already in the project.');
|
||
expect(importNotice([reused('a.png'), reused('b.png')])).toBe(
|
||
'Those 2 images are already in the project.',
|
||
);
|
||
expect(importNotice([outcome('a.png'), reused('b.png')])).toBe(
|
||
'Imported 1 image. One was already in the project.',
|
||
);
|
||
expect(importNotice([outcome('a.png'), reused('b.png'), outcome('c.png', 'broken')])).toBe(
|
||
'Imported 2 of 3. One was already in the project. Could not import "c.png": broken',
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('importStoredImage', () => {
|
||
const FILE = `${'a'.repeat(64)}.png`;
|
||
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;
|
||
|
||
function setup(): EditorState {
|
||
const state = new EditorState();
|
||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||
return state;
|
||
}
|
||
|
||
it('makes an asset out of a picture the server already holds', () => {
|
||
const state = setup();
|
||
const outcome = importStoredImage(image, { state, textures });
|
||
|
||
const asset = state.project.assets.find((a): a is TextureAsset => a.type === 'texture');
|
||
expect(asset).toMatchObject({ file: FILE, width: 64, height: 48, name: 'Texture' });
|
||
expect(outcome).toMatchObject({ assetId: asset?.id, error: null, reused: false });
|
||
expect(state.selection).toEqual({ kind: 'asset', assetId: asset?.id });
|
||
expect(state.notice).toBe('Added the image to the project.');
|
||
// Adopting is a command like any other, so it can be taken back.
|
||
state.undo();
|
||
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 });
|
||
const again = importStoredImage(image, { state, textures });
|
||
|
||
expect(again.assetId).toBe(first.assetId);
|
||
expect(again.reused).toBe(true);
|
||
// Two assets on one file would disagree about `filter`, which is a property of the picture.
|
||
expect(state.project.assets.filter((a) => a.type === 'texture')).toHaveLength(1);
|
||
expect(state.notice).toBe('That image is already in the project.');
|
||
});
|
||
});
|