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, 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/, ); }); });