Files
feuerwerk/tests/core/frame-commands.test.ts
T
adminandClaude Opus 5.5 3e05cbbed5 Let pixel art and vector art be nine-sliced and sorted (#308)
Nine-slice and the sort line hung on the texture frame alone. Drawing
and sorting already read both off the TextureRef every picture hands
out, but the generated pictures always said "no slice, no line". So
the feature moves from the frame to the picture, without a second
copy of any rule.

- PictureCut (border, fill, sortLine) is one type. TextureFrame
  extends it, PixelArtAsset and VectorArtAsset carry it as a whole.
  The texture file format does not change, and no migration is needed.
- The rules in texture.ts take a cut and a size: cutSortLine,
  setCutSlice, rawCutSlice and the rest replace the frame* versions.
  serialize.ts reads all three through one readCut.
- cutTarget.ts resolves a target to cut and size: a texture frame, a
  pixel frame's size or the vector canvas. It lives apart from
  texture.ts because vectorart.ts reaches texture.ts itself.
- SetSliceCommand and SetSortLineCommand take that target instead of
  (assetId, frameId). They refuse an asset that draws no picture.
- generatedRef fills slice and sortLine from the drawing. The whole
  strip of a pixel art asset with several frames gets none, it is no
  picture of that size. With one frame asset and frame are the same.
- Nine-slice scale is one shared schema row, now also on pixel art and
  vector art.
- cutSection.ts holds the NINE-SLICE and SORTING rows once. The frame
  inspector and the drawing inspector both build them.
- SheetCanvas draws a drawing's lines that are set, dashed as in the
  Texture tab. They are not dragged there, the painting tools own the
  canvas (as proposed in the issue).
- drawOrder falls back to the picture's height when a ref has no
  rect, or a sort line on vector art would have been ignored.

Tests:
- Gate: 232 files, 3542 tests (+8: drawing-cut.test.ts, and vector
  art in sort-by-y.test.ts). Three counter-proofs bite: no cut on a
  drawing, a cut on the whole strip, no height fallback.
- e2e 48: 44 checks (+1, sum 1732). 48l-ii reads red and blue where
  12-pixel borders at scale 2 put them. Without borders both spots
  read green.
- Looked at: the Pixel art tab with the lines, and the sliced sprite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:53:51 +02:00

339 lines
13 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,
SetSliceCommand,
SliceFramesCommand,
} from '../../src/core/commands/frameCommands';
import type { TextureAsset, TextureFrame } from '../../src/core/model/Asset';
import { findAsset } from '../../src/core/model/Project';
import { cutFill, 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('slices a frame, and undo takes the keys away again rather than zeroing them', () => {
const sheet = makeSheet();
const frameId = addFrame(sheet, { x: 0, y: 0, w: 24, h: 24 });
const frame = () => sheet.frames().find((f) => f.id === frameId)!;
sheet.stack.execute(
new SetSliceCommand(
{ assetId: sheet.textureId, frameId },
{
border: { left: 8, top: 8, right: 8, bottom: 8 },
fill: 'stretch',
},
),
);
expect(frame().border).toEqual({ left: 8, top: 8, right: 8, bottom: 8 });
expect(frame().fill).toBe('stretch');
sheet.stack.undo();
// Absent, not zero: a frame nobody sliced saves exactly as it did before #41.
expect(frame().border).toBeUndefined();
expect(frame().fill).toBeUndefined();
});
it('repeats unless told to stretch, and writes the fill only when it is not the default', () => {
const sheet = makeSheet();
const frameId = addFrame(sheet, { x: 0, y: 0, w: 24, h: 24 });
const frame = () => sheet.frames().find((f) => f.id === frameId)!;
expect(cutFill(frame())).toBe('repeat');
sheet.stack.execute(
new SetSliceCommand(
{ assetId: sheet.textureId, frameId },
{
border: { left: 8, top: 8, right: 8, bottom: 8 },
fill: 'repeat',
},
),
);
expect(frame().fill).toBeUndefined();
expect(cutFill(frame())).toBe('repeat');
});
it('keeps two opposite borders from eating the whole frame', () => {
const sheet = makeSheet();
const frameId = addFrame(sheet, { x: 0, y: 0, w: 24, h: 24 });
sheet.stack.execute(
new SetSliceCommand(
{ assetId: sheet.textureId, frameId },
{
border: { left: 40, top: -5, right: 40, bottom: 2.6 },
fill: 'stretch',
},
),
);
// Scaled down together to leave one pixel between them; a negative one is none.
expect(sheet.frames()[0]?.border).toEqual({ left: 12, top: 0, right: 11, bottom: 3 });
});
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/,
);
});
});