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>
280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
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/,
|
||
);
|
||
});
|
||
});
|