Files
feuerwerk/tests/core/frame-commands.test.ts
T
rootandClaude Opus 5 4d84231b9f Trim needless comments and fix the picker width
Remove comments that only restate the code, keeping the section banners
and the notes that carry a reason, a measured fact or an external
contract.

ButtonOptions.width claimed to be a fixed width but only set minWidth,
so the Graph and Texture pickers grew with the name they showed. It is
now what it promises, with a separate minWidth for the buttons that have
two forms. Narrow buttons shrink their padding, otherwise a 24px icon
button had 8px left for its glyph and truncate dropped it entirely.

__gm.labelsDropped() guards that class: the overflow guard only saw text
that was too wide, never a label truncated to nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:52:05 +02:00

280 lines
11 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.
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, CreateTextureCommand } from '../../src/core/commands/assetCommands';
import { CommandError } from '../../src/core/commands/Command';
import {
AddFrameCommand,
RemoveFrameCommand,
RenameFrameCommand,
SetFrameRectCommand,
SliceFramesCommand,
} from '../../src/core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { DEFAULT_SLICE, findFrame, type FrameRect } from '../../src/core/model/texture';
import { makeProject } from '../helpers';
const FILE = `${'ab'.repeat(32)}.png`;
const GRID_2X2 = { ...DEFAULT_SLICE, columns: 2, rows: 2 };
/** A project holding one 64×64 texture, with lookups that survive undo replacing the asset. */
function makeSheet(width = 64, height = 64) {
const fixture = makeProject();
const create = new CreateTextureCommand('Sheet', { file: FILE, width, height });
fixture.stack.execute(create);
const asset = () => findAsset(fixture.project, create.asset.id) as TextureAsset;
return { ...fixture, textureId: create.asset.id, asset, frames: () => asset().frames };
}
function addFrame(sheet: ReturnType<typeof makeSheet>, rect: FrameRect, name?: string): string {
const command = new AddFrameCommand(sheet.textureId, rect, name);
sheet.stack.execute(command);
return command.frameId;
}
function snapshot(frames: readonly TextureFrame[]): TextureFrame[] {
return frames.map((f) => ({ ...f }));
}
describe('AddFrameCommand', () => {
it('appends a named frame and removes exactly that one again', () => {
const sheet = makeSheet();
const first = addFrame(sheet, { x: 0, y: 0, w: 16, h: 16 }, 'Idle');
const before = snapshot(sheet.frames());
const second = addFrame(sheet, { x: 16, y: 0, w: 16, h: 16 }, 'Run');
expect(sheet.frames()).toEqual([
{ id: first, name: 'Idle', x: 0, y: 0, w: 16, h: 16 },
{ id: second, name: 'Run', x: 16, y: 0, w: 16, h: 16 },
]);
// The id is known before execution, so the editor can select the frame it just made.
expect(findFrame(sheet.project, second)?.frame.name).toBe('Run');
sheet.stack.undo();
expect(sheet.frames()).toEqual(before);
sheet.stack.redo();
expect(sheet.frames().map((f) => f.id)).toEqual([first, second]);
});
it('names frames apart instead of repeating one', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle');
addFrame(sheet, { x: 8, y: 0, w: 8, h: 8 }, 'Idle');
addFrame(sheet, { x: 16, y: 0, w: 8, h: 8 });
addFrame(sheet, { x: 24, y: 0, w: 8, h: 8 });
// Numbered from 2, like every other generated name in a document (see `nextAssetName`).
expect(sheet.frames().map((f) => f.name)).toEqual(['Idle', 'Idle 2', 'Frame', 'Frame 2']);
});
it('stores a fractional, inverted drag as whole pixels with a positive size', () => {
const sheet = makeSheet();
// Dragging up and to the left in a zoomed view: negative extents and fractions.
addFrame(sheet, { x: 10.4, y: 5, w: -6.2, h: 9.9 });
expect(sheet.frames()[0]).toMatchObject({ x: 4, y: 5, w: 6, h: 10 });
});
it('clamps a rectangle dragged past the edge into the image', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 50, y: 50, w: 40, h: 40 });
addFrame(sheet, { x: -20, y: -5, w: 30, h: 30 });
expect(sheet.frames()[0]).toMatchObject({ x: 50, y: 50, w: 14, h: 14 });
expect(sheet.frames()[1]).toMatchObject({ x: 0, y: 0, w: 10, h: 25 });
});
it('rejects a texture that is not there, and does so in execute', () => {
const sheet = makeSheet();
const command = new AddFrameCommand('nope', { x: 0, y: 0, w: 4, h: 4 });
expect(() => sheet.stack.execute(command)).toThrow(CommandError);
expect(sheet.frames()).toHaveLength(0);
});
});
describe('SetFrameRectCommand', () => {
it('moves a frame and puts the old rectangle back', () => {
const sheet = makeSheet();
const id = addFrame(sheet, { x: 0, y: 0, w: 16, h: 16 }, 'Idle');
const before = snapshot(sheet.frames());
sheet.stack.execute(new SetFrameRectCommand(sheet.textureId, id, { x: 20.6, y: 30, w: 10, h: -8 }));
expect(sheet.frames()[0]).toMatchObject({ name: 'Idle', x: 21, y: 22, w: 10, h: 8 });
sheet.stack.undo();
expect(sheet.frames()).toEqual(before);
sheet.stack.redo();
expect(sheet.frames()[0]).toMatchObject({ x: 21, y: 22 });
});
it('records the rectangle the drag started from, which the caller restores first', () => {
const sheet = makeSheet();
const id = addFrame(sheet, { x: 0, y: 0, w: 16, h: 16 });
const frame = sheet.frames()[0]!;
// What a live preview does: hand the command the previewed rectangle, with the starting
// one put back before executing (see `SceneViewport`).
frame.x = 40;
const previewed = { x: frame.x, y: frame.y, w: frame.w, h: frame.h };
frame.x = 0;
sheet.stack.execute(new SetFrameRectCommand(sheet.textureId, id, previewed));
expect(sheet.frames()[0]).toMatchObject({ x: 40 });
sheet.stack.undo();
expect(sheet.frames()[0]).toMatchObject({ x: 0 });
});
it('rejects a frame that is gone', () => {
const sheet = makeSheet();
expect(() =>
sheet.stack.execute(new SetFrameRectCommand(sheet.textureId, 'nope', { x: 0, y: 0, w: 4, h: 4 })),
).toThrow(CommandError);
});
});
describe('RenameFrameCommand', () => {
it('renames with undo and keeps the name unique', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle');
const second = addFrame(sheet, { x: 8, y: 0, w: 8, h: 8 }, 'Run');
const before = snapshot(sheet.frames());
sheet.stack.execute(new RenameFrameCommand(sheet.textureId, second, 'Walk'));
expect(sheet.frames().map((f) => f.name)).toEqual(['Idle', 'Walk']);
sheet.stack.execute(new RenameFrameCommand(sheet.textureId, second, 'Idle'));
expect(sheet.frames().map((f) => f.name)).toEqual(['Idle', 'Idle 2']);
sheet.stack.undo();
sheet.stack.undo();
expect(sheet.frames()).toEqual(before);
});
it('leaves a frame its own name to rename to', () => {
const sheet = makeSheet();
const id = addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle');
sheet.stack.execute(new RenameFrameCommand(sheet.textureId, id, 'Idle'));
expect(sheet.frames()[0]?.name).toBe('Idle');
});
it('refuses a blank name instead of showing a nameless frame', () => {
const sheet = makeSheet();
const id = addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Idle');
expect(() => sheet.stack.execute(new RenameFrameCommand(sheet.textureId, id, ' '))).toThrow(
CommandError,
);
expect(sheet.frames()[0]?.name).toBe('Idle');
});
});
describe('RemoveFrameCommand', () => {
it('puts the frame back at its original index, not at the end', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'A');
const middle = addFrame(sheet, { x: 8, y: 0, w: 8, h: 8 }, 'B');
addFrame(sheet, { x: 16, y: 0, w: 8, h: 8 }, 'C');
const before = snapshot(sheet.frames());
sheet.stack.execute(new RemoveFrameCommand(sheet.textureId, middle));
expect(sheet.frames().map((f) => f.name)).toEqual(['A', 'C']);
sheet.stack.undo();
// The frame order is the order of the sheet, so appending it again would reshuffle it.
expect(sheet.frames()).toEqual(before);
sheet.stack.redo();
expect(sheet.frames().map((f) => f.name)).toEqual(['A', 'C']);
});
it('rejects a frame that is not in the texture', () => {
const sheet = makeSheet();
expect(() => sheet.stack.execute(new RemoveFrameCommand(sheet.textureId, 'nope'))).toThrow(CommandError);
});
});
describe('SliceFramesCommand', () => {
it('cuts a grid into equal cells in reading order', () => {
const sheet = makeSheet();
sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false));
expect(sheet.frames().map((f) => ({ x: f.x, y: f.y, w: f.w, h: f.h }))).toEqual([
{ x: 0, y: 0, w: 32, h: 32 },
{ x: 32, y: 0, w: 32, h: 32 },
{ x: 0, y: 32, w: 32, h: 32 },
{ x: 32, y: 32, w: 32, h: 32 },
]);
expect(sheet.frames().map((f) => f.name)).toEqual(['Frame 1', 'Frame 2', 'Frame 3', 'Frame 4']);
sheet.stack.undo();
expect(sheet.frames()).toEqual([]);
sheet.stack.redo();
expect(sheet.frames()).toHaveLength(4);
});
it('keeps existing frames when adding, and replaces them when told to', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 1, y: 2, w: 8, h: 8 }, 'Hand-made');
const before = snapshot(sheet.frames());
sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false));
expect(sheet.frames()).toHaveLength(5);
expect(sheet.frames()[0]).toEqual(before[0]);
sheet.stack.undo();
expect(sheet.frames()).toEqual(before);
sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, true));
expect(sheet.frames()).toHaveLength(4);
expect(sheet.frames().some((f) => f.name === 'Hand-made')).toBe(false);
sheet.stack.undo();
expect(sheet.frames()).toEqual(before);
sheet.stack.redo();
expect(sheet.frames().map((f) => f.name)).toEqual(['Frame 1', 'Frame 2', 'Frame 3', 'Frame 4']);
});
it('gives a redone slice the same frame ids, so a sprite wearing one keeps its picture', () => {
const sheet = makeSheet();
sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false));
const ids = sheet.frames().map((f) => f.id);
sheet.stack.undo();
sheet.stack.redo();
expect(sheet.frames().map((f) => f.id)).toEqual(ids);
});
it('numbers generated names around the ones already taken', () => {
const sheet = makeSheet();
addFrame(sheet, { x: 0, y: 0, w: 8, h: 8 }, 'Frame 2');
sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false));
expect(new Set(sheet.frames().map((f) => f.name)).size).toBe(5);
});
it('rejects a slice of an image whose size is unknown rather than doing nothing', () => {
// Width 0 is an unmeasured upload; slicing it would silently produce no frames at all.
const sheet = makeSheet(0, 0);
expect(() => sheet.stack.execute(new SliceFramesCommand(sheet.textureId, GRID_2X2, false))).toThrow(
CommandError,
);
expect(sheet.frames()).toEqual([]);
});
it('rejects a grid whose cells would be smaller than a pixel', () => {
const sheet = makeSheet(4, 4);
const tooFine = { ...DEFAULT_SLICE, columns: 8, rows: 8 };
expect(() => sheet.stack.execute(new SliceFramesCommand(sheet.textureId, tooFine, true))).toThrow(
CommandError,
);
expect(sheet.frames()).toEqual([]);
});
it('refuses an asset that is missing, and one that is not a texture', () => {
const sheet = makeSheet();
const sprite = new CreateAssetCommand('sprite', 'Box');
sheet.stack.execute(sprite);
expect(() => sheet.stack.execute(new SliceFramesCommand('nope', GRID_2X2, false))).toThrow(CommandError);
expect(() => sheet.stack.execute(new SliceFramesCommand(sprite.asset.id, GRID_2X2, false))).toThrow(
/not a texture/,
);
});
});