Files
feuerwerk/tests/core/fonts.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.1 KiB
TypeScript

/**
* A font is an asset holding an uploaded typeface, worn by a text object through its `font`
* property the way a sprite wears a texture. Empty, or dangling, means the editor's own typeface.
*/
import { describe, expect, it } from 'vitest';
import {
buildAsset,
CreateAssetCommand,
CreateFontCommand,
SetAssetPropertyCommand,
} from '../../src/core/commands/assetCommands';
import type { FontAsset } from '../../src/core/model/Asset';
import { isPlaceableType } from '../../src/core/model/AssetTypes';
import { fontFamilyOf, fontFileOf, isFontFile } from '../../src/core/model/font';
import { assetRefChoices } from '../../src/core/properties/assetRef';
import { FONT_KEY, fontOf } from '../../src/core/properties/fonts';
import { schemaFor } from '../../src/core/properties/PropertySchema';
import { deserializeProject } from '../../src/core/store/serialize';
import { makeProject, roundTrip } from '../helpers';
const FILE = `${'a1'.repeat(32)}.ttf`;
function makeFont(name = 'Serif', file = FILE) {
const fixture = makeProject();
const command = new CreateFontCommand(name, file);
fixture.stack.execute(command);
return { ...fixture, font: command.asset };
}
describe('font files', () => {
it('accepts only a content-addressed name in a supported format', () => {
expect(isFontFile(FILE)).toBe(true);
expect(isFontFile(`${'b3'.repeat(32)}.woff2`)).toBe(true);
expect(isFontFile(`${'a1'.repeat(32)}.png`)).toBe(false);
expect(isFontFile(`${'a1'.repeat(32)}.TTF`)).toBe(false);
expect(isFontFile('DejaVuSerif.ttf')).toBe(false);
expect(isFontFile('')).toBe(false);
});
it('names one family per file, and never two for the same bytes', () => {
expect(fontFamilyOf(FILE)).toBe(fontFamilyOf(FILE));
expect(fontFamilyOf(FILE)).not.toBe(fontFamilyOf(`${'b3'.repeat(32)}.ttf`));
// A CSS family, so it has to survive being written into a font shorthand unquoted.
expect(fontFamilyOf(FILE)).toMatch(/^[A-Za-z][\w-]*$/);
});
});
describe('the font asset', () => {
it('is not placeable and starts without a file', () => {
expect(isPlaceableType('font')).toBe(false);
expect(buildAsset('font', 'Font')).toMatchObject({ type: 'font', file: '' });
});
it('refuses a file name the server could not have produced', () => {
const fixture = makeProject();
// The stack lets the error through; the editor turns it into the notice the importer reports.
expect(() => fixture.stack.execute(new CreateFontCommand('Bad', 'DejaVuSerif.ttf'))).toThrow(
'is not a stored font file',
);
expect(fixture.project.assets).toHaveLength(0);
});
it('survives a round trip and drops a file name it cannot trust', () => {
const f = makeFont();
expect(roundTrip(f.project)).toEqual(f.project);
const repaired = deserializeProject({
name: 'P',
assets: [
{ id: 'f1', type: 'font', name: 'Local', file: '../../etc/passwd' },
{ id: 'f2', type: 'font', name: 'Gone' },
],
});
expect(repaired.assets.map((a) => (a as FontAsset).file)).toEqual(['', '']);
});
});
describe('the Font row on a text', () => {
it('offers font assets and nothing else', () => {
const f = makeFont();
f.stack.execute(new CreateAssetCommand('text', 'Title'));
const row = schemaFor('text').find((def) => def.key === FONT_KEY);
expect(row).toMatchObject({ kind: 'asset', default: '', assetTypes: ['font'] });
expect(assetRefChoices(f.project, ['font']).map((a) => a.id)).toEqual([f.font.id]);
});
it('resolves to a file, and to none when the id is unset or dangling', () => {
const f = makeFont();
const text = new CreateAssetCommand('text', 'Title');
f.stack.execute(text);
expect(fontOf(text.asset.props)).toBe('');
expect(fontFileOf(f.project, '')).toBe('');
f.stack.execute(new SetAssetPropertyCommand(text.asset.id, FONT_KEY, f.font.id));
expect(fontFileOf(f.project, fontOf(text.asset.props))).toBe(FILE);
expect(fontFileOf(f.project, 'no-such-asset')).toBe('');
// A text asset is not a font asset: an id pointing at the wrong type wears nothing.
expect(fontFileOf(f.project, text.asset.id)).toBe('');
});
});