Files
feuerwerk/tests/editor/font-import.test.ts
T
rootandClaude Opus 5 86b5363a01
game-maker / suite (push) Successful in 9m10s
Fonts: a new asset type, picked from the server or dropped in, worn by text objects (#74)
A font asset holds a file and nothing else, the twin of a texture: the server keeps it
content-addressed under data/fonts/{sha256}.{ext}, with the format read from the file's own
four-byte signature (finfo reports fonts inconsistently across libmagic versions). A text
wears one through its ordinary Font property, so overrides, reset, undo and the Properties
node come for free; no font means the editor's own typeface.

The picker lists everything the server holds and renders each row in the face it offers.
The family name is gm-font-<hash16>, never the name inside the file, so two "Regular" cannot
collide. WorldViewport.syncText is the single place that draws it, which covers scene, game
preview and play at once.

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

102 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, 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('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.
expect(upload).toHaveBeenCalledWith(file, 'font/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.');
});
});