Files
feuerwerk/tests/core/pixel-art.test.ts
T
adminandClaude Opus 5 bbbf34109b
game-maker / gate (push) Successful in 2m20s
game-maker / suite (push) Successful in 8m25s
Lists duplicate, rename and edit values right in their rows
Name, opacity and Z each sat in a separate field below or beside
the list. You had to select a row first and then look elsewhere to
change it. Duplicating was a separate button or not possible at all.
Now the row itself carries everything.

- SortableList gets three optional callbacks. onDuplicate shows a
  copy button between the eye and the delete button. onRename opens
  a text field on double click, Enter or a click outside commits,
  Esc cancels, an empty name is ignored. value puts a NumberField
  into the row.
- Dragging on the number changes the value and never moves the row.
  A whole drag is one undo step, the canvas follows live.
- Copy and delete only show on the hovered and the selected row.
  Their space stays reserved, so nothing jumps on hover.
- Pixel layers: copy right above with the same pixels on all
  frames, rename, opacity in percent. The Name and Opacity fields
  below the list are gone.
- Pixel frames: copy right after, replaces the Duplicate button.
- Tilemap layers: copy with cells, Z and visibility, rename, Z in
  the row. The Name and Z fields in the panel are gone.
- Vector shapes: copy at the same place and select it, rename.
- New in core: DuplicateVectorShapeCommand,
  DuplicateTilemapLayerCommand, withLayerDuplicated and copyName
  ("X copy", "X copy 2"). copyName lives in src/core/util/names.ts
  because the model never imports from commands/.

Findings:

- Double clicking a shape that is not selected yet only selects it.
  The first click moves the list up, so the second click misses the
  name. Module 47 selects the row first.

Tests:

- ./check.sh: 138 files, 2055 tests, CHECK EXIT 0 (before 2052).
- GM_STEPS=47,48 (fresh, twice): ALL PASSED, 91 checks (before 86).
- GM_STEPS=39,40,47,48: module 40 green, 47 died on the rename
  check, which was a test fault and is fixed.
- Counter probes: a constant copyName fails the shape and pixel
  layer tests. A dead copy hit fails the copy check in 47.
- Check total 1272 in README and wiki, computed (1266 + 6).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 06:02:47 +02:00

599 lines
23 KiB
TypeScript

/**
* Pixel art (#130): the cel format and its name, the drawing rules, how layers make a frame, the
* edits and the one command that takes them back, the project file, and the picture path.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { CommandError } from '../../src/core/commands/Command';
import { EditPixelArtCommand, SetPaletteColorsCommand } from '../../src/core/commands/pixelArtCommands';
import type { PaletteAsset, PixelArtAsset } from '../../src/core/model/Asset';
import { registerPictureSource } from '../../src/core/model/generated';
import {
celFilesOf,
colorAt,
composeFrame,
composeSheet,
DEFAULT_PALETTE,
findPixelFrame,
PALETTE_KEY,
paletteColors,
pixelArtPicture,
pixelArtRevision,
pixelArtWornId,
} from '../../src/core/model/pixelart';
import {
type CelPixels,
celFileName,
decodeCel,
emptyCel,
encodeCel,
isCelFile,
sha256Hex,
} from '../../src/core/model/pixelCel';
import {
anchorOffset,
ellipsePoints,
floodFill,
linePoints,
mergeCels,
rectPoints,
resizeCel,
stamp,
} from '../../src/core/model/pixelDraw';
import {
pixelArtState,
withCanvasSize,
withCels,
withFrameAdded,
withFrameMoved,
withFrameRemoved,
withLayerAdded,
withLayerChanged,
withLayerDuplicated,
withLayerMergedDown,
withLayerMoved,
withLayerRemoved,
} from '../../src/core/model/pixelEdit';
import { findAsset, type Project } from '../../src/core/model/Project';
import { resolvePicture } from '../../src/core/model/texture';
import { pictureChoices, textureRefLabel } from '../../src/core/properties/textureRef';
import type { ProjectStore } from '../../src/core/store/ProjectStore';
import { CelUploadStore } from '../../src/editor/pixelart/celUploadStore';
import { PixelCels } from '../../src/viewport/PixelCels';
import { fitZoom, pixelUnder, stepZoom } from '../../src/editor/pixelart/pixelView';
import { makeProject, roundTrip } from '../helpers';
const FILE_A = `${'a'.repeat(64)}.px`;
const FILE_B = `${'b'.repeat(64)}.px`;
function cel(width: number, height: number, rows: number[][]): CelPixels {
return { width, height, data: Uint8Array.from(rows.flat()) };
}
function art(project: Project): PixelArtAsset {
const create = new CreateAssetCommand('pixelart', 'Art');
create.execute(project);
return create.asset as PixelArtAsset;
}
describe('cel files', () => {
it('hashes like SHA-256', () => {
expect(sha256Hex(new Uint8Array())).toBe(
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
);
expect(sha256Hex(new TextEncoder().encode('abc'))).toBe(
'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
);
// Two blocks: the padding has to spill into a second one.
expect(sha256Hex(new TextEncoder().encode('a'.repeat(64)))).toBe(
'ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb',
);
});
it('writes a header the server checks and reads it back', () => {
const pixels = cel(3, 2, [
[1, 0, 2],
[0, 3, 0],
]);
const bytes = encodeCel(pixels);
expect([...bytes.slice(0, 10)]).toEqual([0x47, 0x4d, 0x50, 0x58, 1, 0, 3, 0, 2, 0]);
expect(decodeCel(bytes)).toEqual(pixels);
expect(isCelFile(celFileName(bytes))).toBe(true);
});
it('refuses a cel of the wrong length, version or size', () => {
const bytes = encodeCel(emptyCel(2, 2));
expect(decodeCel(bytes.slice(0, -1))).toBeNull();
const version = bytes.slice();
version[4] = 2;
expect(decodeCel(version)).toBeNull();
const huge = encodeCel(emptyCel(2, 2));
huge[6] = 0x01;
huge[7] = 0x02; // 513 wide
expect(decodeCel(huge)).toBeNull();
});
});
describe('drawing', () => {
it('draws a line pixel by pixel, both ends included', () => {
expect(linePoints({ x: 0, y: 0 }, { x: 3, y: 1 })).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 2, y: 1 },
{ x: 3, y: 1 },
]);
expect(linePoints({ x: 2, y: 2 }, { x: 2, y: 2 })).toEqual([{ x: 2, y: 2 }]);
});
it('draws the border of a rectangle, or all of it', () => {
expect(rectPoints({ x: 3, y: 3 }, { x: 0, y: 0 }, false)).toHaveLength(12);
expect(rectPoints({ x: 0, y: 0 }, { x: 3, y: 3 }, true)).toHaveLength(16);
});
it('draws an ellipse whose outline has no gaps and whose fill leaves the corners', () => {
const filled = ellipsePoints({ x: 0, y: 0 }, { x: 7, y: 7 }, true);
const outline = ellipsePoints({ x: 0, y: 0 }, { x: 7, y: 7 }, false);
const has = (list: { x: number; y: number }[], x: number, y: number) =>
list.some((p) => p.x === x && p.y === y);
expect(has(filled, 0, 0)).toBe(false);
expect(has(filled, 3, 3)).toBe(true);
expect(has(outline, 3, 3)).toBe(false);
expect(has(outline, 0, 3)).toBe(true);
// Every outline pixel touches another one, so the ring is closed.
for (const p of outline)
expect(outline.some((q) => q !== p && Math.abs(q.x - p.x) <= 1 && Math.abs(q.y - p.y) <= 1)).toBe(true);
// Symmetric in both axes.
for (const p of filled) expect(has(filled, 7 - p.x, p.y) && has(filled, p.x, 7 - p.y)).toBe(true);
});
it('stamps a brush centred on the point, an even one reaching right and down', () => {
const pixels = emptyCel(6, 6);
stamp(pixels, { x: 2, y: 2 }, 2, 5);
expect(pixels.data.reduce((n, i) => n + (i === 5 ? 1 : 0), 0)).toBe(4);
expect(pixels.data[2 * 6 + 2]).toBe(5);
expect(pixels.data[3 * 6 + 3]).toBe(5);
expect(pixels.data[1 * 6 + 1]).toBe(0);
});
it('fills the area joined by edges, or every pixel of that colour', () => {
const rows = [
[0, 1, 0],
[1, 0, 0],
[0, 0, 0],
];
const joined = cel(3, 3, rows);
floodFill(joined, { x: 2, y: 2 }, 7, true);
// The corner pixel is cut off by two edges and only touches the area diagonally.
expect([...joined.data]).toEqual([0, 1, 7, 1, 7, 7, 7, 7, 7]);
const every = cel(3, 3, rows);
floodFill(every, { x: 2, y: 2 }, 7, false);
expect([...every.data]).toEqual([7, 1, 7, 1, 7, 7, 7, 7, 7]);
});
it('resizes about an anchor and cuts what sticks out', () => {
const pixels = cel(2, 2, [
[1, 2],
[3, 4],
]);
expect([...resizeCel(pixels, 4, 2, { x: 0.5, y: 0 }).data]).toEqual([0, 1, 2, 0, 0, 3, 4, 0]);
expect([...resizeCel(pixels, 1, 1, { x: 1, y: 1 }).data]).toEqual([4]);
expect(anchorOffset(32, 40, 0.5)).toBe(4);
});
it('merges a layer down: what the upper one paints covers the lower one', () => {
const merged = mergeCels(cel(2, 1, [[1, 1]]), cel(2, 1, [[0, 2]]));
expect([...merged.data]).toEqual([1, 2]);
});
});
describe('composing', () => {
const colors = [0xff0000, 0x0000ff];
it('lays the layers over each other in list order, the last on top', () => {
const { project } = makeProject();
const asset = art(project);
asset.width = 2;
asset.height = 1;
asset.layers = [
{ id: 'low', name: 'Low', opacity: 1 },
{ id: 'high', name: 'High', opacity: 1 },
];
asset.frames = [{ id: 'f', cels: { low: FILE_A, high: FILE_B } }];
const read = (file: string) =>
file === FILE_A ? cel(2, 1, [[1, 1]]) : file === FILE_B ? cel(2, 1, [[0, 2]]) : null;
const rgba = composeFrame(asset, colors, asset.frames[0]!, read);
expect([...rgba!]).toEqual([255, 0, 0, 255, 0, 0, 255, 255]);
});
it('mixes a layer by its opacity and leaves a hidden one out', () => {
const { project } = makeProject();
const asset = art(project);
asset.width = 1;
asset.height = 1;
asset.layers = [
{ id: 'low', name: 'Low', opacity: 1 },
{ id: 'high', name: 'High', opacity: 0.5 },
];
asset.frames = [{ id: 'f', cels: { low: FILE_A, high: FILE_B } }];
const read = (file: string) => (file === FILE_A ? cel(1, 1, [[1]]) : cel(1, 1, [[2]]));
expect([...composeFrame(asset, colors, asset.frames[0]!, read)!]).toEqual([128, 0, 128, 255]);
asset.layers[1]!.hidden = true;
expect([...composeFrame(asset, colors, asset.frames[0]!, read)!]).toEqual([255, 0, 0, 255]);
});
it('is transparent where nothing paints, and waits while a cel is loading', () => {
const { project } = makeProject();
const asset = art(project);
asset.width = 1;
asset.height = 1;
const layer = asset.layers[0]!.id;
asset.frames[0]!.cels[layer] = FILE_A;
expect([...composeFrame(asset, colors, asset.frames[0]!, () => cel(1, 1, [[0]]))!]).toEqual([0, 0, 0, 0]);
// An index past the palette's end is transparent too.
expect([...composeFrame(asset, colors, asset.frames[0]!, () => cel(1, 1, [[9]]))!]).toEqual([0, 0, 0, 0]);
expect(composeFrame(asset, colors, asset.frames[0]!, () => undefined)).toBeNull();
});
it('puts the frames side by side into one sheet', () => {
const { project } = makeProject();
const asset = art(project);
asset.width = 1;
asset.height = 1;
const layer = asset.layers[0]!.id;
asset.frames = [
{ id: 'f1', cels: { [layer]: FILE_A } },
{ id: 'f2', cels: { [layer]: FILE_B } },
];
const sheet = composeSheet(asset, colors, (file) =>
file === FILE_A ? cel(1, 1, [[1]]) : cel(1, 1, [[2]]),
);
expect([...sheet!]).toEqual([255, 0, 0, 255, 0, 0, 255, 255]);
});
it('reads colours by index, 0 being transparent', () => {
expect(colorAt(colors, 0)).toBeNull();
expect(colorAt(colors, 2)).toBe(0x0000ff);
expect(colorAt(colors, 3)).toBeNull();
});
});
describe('the picture', () => {
it('changes its revision with the pixels, the palette and the layers, not with a name', () => {
const { project } = makeProject();
const asset = art(project);
const layer = asset.layers[0]!.id;
const before = pixelArtRevision(asset, DEFAULT_PALETTE);
asset.layers[0]!.name = 'Renamed';
expect(pixelArtRevision(asset, DEFAULT_PALETTE)).toBe(before);
asset.frames[0]!.cels[layer] = FILE_A;
const painted = pixelArtRevision(asset, DEFAULT_PALETTE);
expect(painted).not.toBe(before);
expect(pixelArtRevision(asset, [0x123456])).not.toBe(painted);
asset.layers[0]!.opacity = 0.5;
expect(pixelArtRevision(asset, DEFAULT_PALETTE)).not.toBe(painted);
});
it('is a sheet of all frames, and a frame id reads as a cut of it', () => {
const remove = registerPictureSource('pixelart', pixelArtPicture);
try {
const { project } = makeProject();
const asset = art(project);
asset.width = 16;
asset.height = 8;
asset.frames.push({ id: 'second', cels: {} });
const whole = resolvePicture(project, asset.id);
expect(whole).toMatchObject({ rect: null, sourceWidth: 32, sourceHeight: 8, smooth: false });
expect(whole!.file.startsWith(`gen:${asset.id}@`)).toBe(true);
const second = resolvePicture(project, 'second');
expect(second).toMatchObject({ file: whole!.file, rect: { x: 16, y: 0, w: 16, h: 8 } });
expect(findPixelFrame(project, 'second')?.index).toBe(1);
expect(textureRefLabel(project, 'second')).toBe('Art / Frame 2');
expect(pictureChoices(project).map((c) => c.label)).toEqual(['Art', 'Art / Frame 1', 'Art / Frame 2']);
expect(pixelArtWornId(asset)).toBe(asset.frames[0]!.id);
} finally {
remove();
}
});
it('with one frame is offered as the picture alone', () => {
const remove = registerPictureSource('pixelart', pixelArtPicture);
try {
const { project } = makeProject();
const asset = art(project);
expect(pictureChoices(project).map((c) => c.label)).toEqual(['Art']);
expect(pixelArtWornId(asset)).toBe(asset.id);
} finally {
remove();
}
});
it('is painted in the palette its row names, else in the default colours', () => {
const { project } = makeProject();
const asset = art(project);
expect(paletteColors(project, asset)).toBe(DEFAULT_PALETTE);
const palette = new CreateAssetCommand('palette', 'Palette');
palette.execute(project);
(palette.asset as PaletteAsset).colors = [0x112233];
asset.props[PALETTE_KEY] = palette.asset.id;
expect(paletteColors(project, asset)).toEqual([0x112233]);
});
});
describe('edits', () => {
const start = () => {
const { project, stack } = makeProject();
const asset = art(project);
return { project, stack, asset, layer: asset.layers[0]!.id, frame: asset.frames[0]!.id };
};
it('sets cels and empties one with the empty name', () => {
const { asset, layer, frame } = start();
const painted = withCels(pixelArtState(asset), [{ frameId: frame, layerId: layer, file: FILE_A }]);
expect(painted.frames[0]!.cels[layer]).toBe(FILE_A);
expect(withCels(painted, [{ frameId: frame, layerId: layer, file: '' }]).frames[0]!.cels).toEqual({});
// The state it was given is left alone.
expect(asset.frames[0]!.cels).toEqual({});
});
it('adds, renames, hides, moves and removes layers, and keeps the last one', () => {
const { asset, layer } = start();
let s = withLayerAdded(pixelArtState(asset), 'second');
expect(s.layers.map((l) => [l.id, l.name])).toEqual([
[layer, 'Layer 1'],
['second', 'Layer 2'],
]);
s = withLayerChanged(s, 'second', { name: ' Ink ', hidden: true, opacity: 3 });
expect(s.layers[1]).toEqual({ id: 'second', name: 'Ink', hidden: true, opacity: 1 });
expect(withLayerChanged(s, 'second', { hidden: false }).layers[1]).not.toHaveProperty('hidden');
s = withLayerMoved(s, 'second', 0);
expect(s.layers[0]!.id).toBe('second');
s = withLayerRemoved(s, 'second');
expect(() => withLayerRemoved(s, layer)).toThrow('at least one layer');
expect(() => withLayerChanged(s, layer, { name: ' ' })).toThrow('needs a name');
});
it('merges a layer down into the cels it is handed', () => {
const { asset, layer, frame } = start();
let s = withLayerAdded(pixelArtState(asset), 'top');
s = withCels(s, [{ frameId: frame, layerId: 'top', file: FILE_B }]);
s = withLayerMergedDown(s, 'top', { [frame]: FILE_A });
expect(s.layers.map((l) => l.id)).toEqual([layer]);
expect(s.frames[0]!.cels).toEqual({ [layer]: FILE_A });
expect(() => withLayerMergedDown(s, layer, {})).toThrow('no layer below');
});
it('duplicates a layer right above it, naming the same cels on every frame', () => {
const { asset, layer, frame } = start();
let s = withLayerAdded(pixelArtState(asset), 'top');
s = withLayerChanged(s, layer, { opacity: 0.5, hidden: true });
s = withCels(s, [{ frameId: frame, layerId: layer, file: FILE_A }]);
s = withLayerDuplicated(s, 'copy', layer);
s = withLayerDuplicated(s, 'again', layer);
expect(s.layers.map((l) => [l.id, l.name])).toEqual([
[layer, 'Layer 1'],
['again', 'Layer 1 copy 2'],
['copy', 'Layer 1 copy'],
['top', 'Layer 2'],
]);
expect(s.layers[2]).toMatchObject({ opacity: 0.5, hidden: true });
expect(s.frames[0]!.cels).toMatchObject({ [layer]: FILE_A, copy: FILE_A, again: FILE_A });
expect(s.frames[0]!.cels).not.toHaveProperty('top');
expect(() => withLayerDuplicated(s, 'x', 'nobody')).toThrow('not found');
});
it('adds a frame empty or as a copy, moves it and keeps the last one', () => {
const { asset, layer, frame } = start();
let s = withCels(pixelArtState(asset), [{ frameId: frame, layerId: layer, file: FILE_A }]);
s = withFrameAdded(s, 'copy', frame, frame);
s = withFrameAdded(s, 'blank', frame);
expect(s.frames.map((f) => [f.id, f.cels[layer] ?? ''])).toEqual([
[frame, FILE_A],
['blank', ''],
['copy', FILE_A],
]);
s = withFrameMoved(s, 'copy', 0);
expect(s.frames[0]!.id).toBe('copy');
s = withFrameRemoved(withFrameRemoved(s, 'copy'), 'blank');
expect(() => withFrameRemoved(s, frame)).toThrow('at least one frame');
});
it('resizes by swapping every cel for its counterpart', () => {
const { asset, layer, frame } = start();
const s = withCels(pixelArtState(asset), [{ frameId: frame, layerId: layer, file: FILE_A }]);
const resized = withCanvasSize(s, 40, 16, { [FILE_A]: FILE_B });
expect(resized).toMatchObject({ width: 40, height: 16 });
expect(resized.frames[0]!.cels[layer]).toBe(FILE_B);
expect(() => withCanvasSize(s, 40, 16, {})).toThrow('no counterpart');
expect(() => withCanvasSize(s, 513, 16, { [FILE_A]: FILE_B })).toThrow('1 to 512');
});
it('is one undo step per command, and a refused edit changes nothing', () => {
const { project, stack, asset, layer, frame } = start();
stack.execute(
new EditPixelArtCommand(asset.id, 'Pencil', (s) =>
withCels(s, [{ frameId: frame, layerId: layer, file: FILE_A }]),
),
);
stack.execute(new EditPixelArtCommand(asset.id, 'Add layer', (s) => withLayerAdded(s, 'top')));
const current = findAsset(project, asset.id) as PixelArtAsset;
expect(current.layers).toHaveLength(2);
stack.undo();
expect(current.layers).toHaveLength(1);
stack.undo();
expect(current.frames[0]!.cels).toEqual({});
stack.redo();
expect(current.frames[0]!.cels[layer]).toBe(FILE_A);
const refused = new EditPixelArtCommand(asset.id, 'Delete layer', (s) => withLayerRemoved(s, layer));
expect(() => refused.execute(project)).toThrow(CommandError);
expect(current.layers).toHaveLength(1);
});
it('sets the colours of a palette and takes them back', () => {
const { project, stack } = makeProject();
const create = new CreateAssetCommand('palette', 'Palette');
stack.execute(create);
const palette = create.asset as PaletteAsset;
expect(palette.colors).toEqual(DEFAULT_PALETTE);
stack.execute(new SetPaletteColorsCommand(palette.id, 'Palette colour', [0x010203]));
expect(palette.colors).toEqual([0x010203]);
stack.undo();
expect(palette.colors).toEqual(DEFAULT_PALETTE);
expect(() => new SetPaletteColorsCommand(palette.id, 'x', [0x1000000]).execute(project)).toThrow(
CommandError,
);
});
});
describe('the project file', () => {
it('brings pixel art and palettes back as they were', () => {
const { project } = makeProject();
const asset = art(project);
const layer = asset.layers[0]!.id;
asset.width = 48;
asset.layers.push({ id: 'ink', name: 'Ink', opacity: 0.25, hidden: true });
asset.frames[0]!.cels = { [layer]: FILE_A, ink: FILE_B };
asset.frames.push({ id: 'second', cels: {} });
const palette = new CreateAssetCommand('palette', 'Palette');
palette.execute(project);
const loaded = roundTrip(project);
expect(findAsset(loaded, asset.id)).toEqual(asset);
expect(findAsset(loaded, palette.asset.id)).toEqual(palette.asset);
});
it('drops what it cannot trust and keeps something to paint on', () => {
const { project } = makeProject();
const raw = JSON.parse(JSON.stringify(project)) as { assets: unknown[] };
raw.assets.push(
{
id: 'bad',
type: 'pixelart',
name: 'Bad',
props: {},
width: 9999,
height: 'x',
layers: [{ id: 'l', name: 'L', opacity: 7 }, { id: 'l' }, { name: 'no id' }],
frames: [
{ id: 'f', cels: { l: FILE_A, gone: FILE_B, l2: 'not-a-file' } },
{ id: 'f' },
{ id: 'bad' },
],
},
{ id: 'empty', type: 'pixelart', name: 'Empty', props: {} },
{ id: 'pal', type: 'palette', name: 'Pal', props: {}, colors: [1, 'x', 0x1ffffff, 2.7] },
);
const loaded = roundTrip(raw as unknown as Project);
const bad = findAsset(loaded, 'bad') as PixelArtAsset;
expect(bad).toMatchObject({ width: 512, height: 32 });
expect(bad.layers).toEqual([{ id: 'l', name: 'L', opacity: 1 }]);
// A frame may not reuse its own asset's id, nor another frame's.
expect(bad.frames).toEqual([{ id: 'f', cels: { l: FILE_A } }]);
const empty = findAsset(loaded, 'empty') as PixelArtAsset;
expect(empty.layers).toHaveLength(1);
expect(empty.frames).toHaveLength(1);
expect((findAsset(loaded, 'pal') as PaletteAsset).colors).toEqual([1, 0xffffff, 2]);
expect(celFilesOf(bad)).toEqual([FILE_A]);
});
});
describe('cels in the browser', () => {
it('names painted pixels at once, keeps them, and uploads what the project names', async () => {
const cels = new PixelCels();
const pixels = cel(2, 1, [[1, 0]]);
const file = cels.put(pixels);
expect(file).toBe(celFileName(encodeCel(pixels)));
expect(cels.put(emptyCel(2, 1))).toBe('');
// A copy is kept: painting on on the array handed in changes nothing stored.
pixels.data[0] = 9;
expect([...cels.read(file)!.data]).toEqual([1, 0]);
const { project } = makeProject();
const asset = art(project);
const unnamed = cels.put(cel(1, 1, [[3]]));
asset.frames[0]!.cels[asset.layers[0]!.id] = file;
expect(cels.pending(project)).toEqual([file]);
const sent: string[] = [];
await cels.upload(project, {
upload: (bytes) => {
const name = celFileName(new Uint8Array(bytes));
sent.push(name);
return Promise.resolve({ file: name });
},
});
expect(sent).toEqual([file]);
expect(cels.pending(project)).toEqual([]);
expect(unnamed).not.toBe(file);
});
it('fails the upload when the server names the bytes differently', async () => {
const cels = new PixelCels();
const { project } = makeProject();
const asset = art(project);
asset.frames[0]!.cels[asset.layers[0]!.id] = cels.put(cel(1, 1, [[1]]));
await expect(cels.upload(project, { upload: () => Promise.resolve({ file: FILE_A }) })).rejects.toThrow(
'The server stored pixels as',
);
expect(cels.pending(project)).toHaveLength(1);
});
});
describe('saving', () => {
const inner = (log: string[]): ProjectStore => ({
list: () => Promise.resolve([]),
load: () => Promise.reject(new Error('unused')),
save: () => {
log.push('save');
return Promise.resolve(2);
},
create: () => Promise.reject(new Error('unused')),
delete: () => Promise.resolve(),
});
it('uploads the painted cels before the project that names them', async () => {
const log: string[] = [];
const cels = new PixelCels();
const { project } = makeProject();
const asset = art(project);
asset.frames[0]!.cels[asset.layers[0]!.id] = cels.put(cel(1, 1, [[1]]));
const store = new CelUploadStore(inner(log), cels, {
upload: (bytes) => {
log.push('upload');
return Promise.resolve({ file: celFileName(new Uint8Array(bytes)) });
},
});
expect(await store.save(project, 1)).toBe(2);
expect(log).toEqual(['upload', 'save']);
});
it('does not save a project whose cels could not be uploaded', async () => {
const log: string[] = [];
const cels = new PixelCels();
const { project } = makeProject();
const asset = art(project);
asset.frames[0]!.cels[asset.layers[0]!.id] = cels.put(cel(1, 1, [[1]]));
const store = new CelUploadStore(inner(log), cels, {
upload: () => Promise.reject(new Error('offline')),
});
await expect(store.save(project, 1)).rejects.toThrow('offline');
expect(log).toEqual([]);
});
});
describe('the view', () => {
it('zooms in whole steps and fits the largest one', () => {
expect(fitZoom(32, 32, 500, 400)).toBe(8);
expect(fitZoom(512, 512, 300, 300)).toBe(1);
expect(stepZoom(8, 1)).toBe(12);
expect(stepZoom(8, -1)).toBe(6);
expect(stepZoom(32, 1)).toBe(32);
expect(stepZoom(1, -1)).toBe(1);
});
it('finds the pixel under the pointer, outside the canvas too', () => {
const view = { panX: 10, panY: 20, zoom: 8 };
expect(pixelUnder(view, 10, 20)).toEqual({ x: 0, y: 0 });
expect(pixelUnder(view, 17.9, 27.9)).toEqual({ x: 0, y: 0 });
expect(pixelUnder(view, 9, 20)).toEqual({ x: -1, y: 0 });
});
});