Refactoring round one: HttpFontStore/HttpSoundStore/HttpTextureStore collapse into a generic HttpFileStore over a FileStore contract; fontImport/soundImport/textureImport share assetImport with an ImportKind per sort; ImagePicker/FontPicker/SoundPicker sit on FilePicker and describe only their row. The group/comment drag logic leaves GraphCanvas for annotationDrag.ts (with its own unit test), declaredRows.ts replaces the duplicated row build in particles.ts and shaders.ts, and dead code in font/sound/compile/emission/transform/actions is gone. README: asset list, check count 958, the two-job CI, the Scenes wiki page. Co-Authored-By: Claude Fable 5.1 <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/assetImport';
|
||
|
||
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.');
|
||
});
|
||
});
|