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>
113 lines
4.7 KiB
TypeScript
113 lines
4.7 KiB
TypeScript
/**
|
||
* Importing a typeface: the same two ways a picture arrives – picked from what the server already
|
||
* holds, or uploaded from a dropped file – with the same content-addressed reuse.
|
||
*/
|
||
import { describe, expect, it, vi } from 'vitest';
|
||
import type { FontAsset } from '../../src/core/model/Asset';
|
||
import { createEmptyProject } from '../../src/core/model/Project';
|
||
import type { FontStore } from '../../src/core/store/FontStore';
|
||
import { importFonts, importStoredFont, isFontFileName } from '../../src/editor/fontImport';
|
||
import { EditorState } from '../../src/editor/state';
|
||
import { importNotice } from '../../src/editor/textureImport';
|
||
|
||
const FILE = `${'a'.repeat(64)}.ttf`;
|
||
const font = { file: FILE, name: '', bytes: 380_660 };
|
||
|
||
function setup(): EditorState {
|
||
const state = new EditorState();
|
||
state.loadProject(createEmptyProject('p', 'P'), 1);
|
||
return state;
|
||
}
|
||
|
||
describe('isFontFileName', () => {
|
||
it('goes by the extension, because a dropped font often has no MIME type at all', () => {
|
||
expect(isFontFileName('DejaVuSerif.ttf')).toBe(true);
|
||
expect(isFontFileName('Inter.WOFF2')).toBe(true);
|
||
expect(isFontFileName('cover.png')).toBe(false);
|
||
expect(isFontFileName('ttf')).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('importStoredFont', () => {
|
||
const fonts = { upload: vi.fn(), list: vi.fn(), delete: vi.fn() } as unknown as FontStore;
|
||
|
||
it('makes an asset out of a typeface the server already holds', () => {
|
||
const state = setup();
|
||
const outcome = importStoredFont(font, { state, fonts });
|
||
|
||
const asset = state.project.assets.find((a): a is FontAsset => a.type === 'font');
|
||
expect(asset).toMatchObject({ file: FILE, name: 'Font' });
|
||
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 font to the project.');
|
||
state.undo();
|
||
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 });
|
||
const again = importStoredFont(font, { state, fonts });
|
||
|
||
expect(again.assetId).toBe(first.assetId);
|
||
expect(again.reused).toBe(true);
|
||
expect(state.project.assets.filter((a) => a.type === 'font')).toHaveLength(1);
|
||
expect(state.notice).toBe('That font is already in the project.');
|
||
});
|
||
});
|
||
|
||
describe('importFonts', () => {
|
||
it('uploads the file untouched and names the asset after it', async () => {
|
||
const state = setup();
|
||
const upload = vi.fn(() => Promise.resolve(font));
|
||
const fonts = { upload, list: vi.fn(), delete: vi.fn() } as unknown as FontStore;
|
||
const file = new File([new Uint8Array(8)], 'DejaVuSerif.ttf', { type: 'font/ttf' });
|
||
|
||
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.
|
||
// 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,
|
||
name: 'DejaVuSerif',
|
||
});
|
||
expect(state.notice).toBe('Imported 1 font.');
|
||
});
|
||
|
||
it('reports a file that is not a font without asking the server about it', async () => {
|
||
const state = setup();
|
||
const upload = vi.fn(() => Promise.resolve(font));
|
||
const fonts = { upload, list: vi.fn(), delete: vi.fn() } as unknown as FontStore;
|
||
|
||
const [outcome] = await importFonts([new File([new Uint8Array(8)], 'cover.png')], {
|
||
state,
|
||
fonts,
|
||
});
|
||
|
||
expect(upload).not.toHaveBeenCalled();
|
||
expect(outcome?.assetId).toBeNull();
|
||
expect(state.notice).toContain('Could not import "cover.png"');
|
||
});
|
||
});
|
||
|
||
describe('importNotice', () => {
|
||
it('says what was imported, so fonts do not report as images', () => {
|
||
const done = { name: 'a.ttf', assetId: 'id', error: null, reused: false };
|
||
expect(importNotice([done], 'font')).toBe('Imported 1 font.');
|
||
expect(importNotice([done, { ...done, name: 'b.ttf' }], 'font')).toBe('Imported 2 fonts.');
|
||
expect(importNotice([{ ...done, reused: true }], 'font')).toBe('That font is already in the project.');
|
||
});
|
||
});
|