Files
feuerwerk/tests/core/pixel-art.test.ts
T
adminandClaude Opus 5 66d6aafbb4
game-maker / gate (push) Successful in 2m24s
game-maker / suite (push) Failing after 7m40s
Pixel art can be drawn in its own tab with layers and frames (#130)
Textures are uploaded files named by their content, so they cannot
be edited in place. Pixel art is therefore its own asset type, like
vector art. Its pixels live in the project and the tab rasters them
into a texture that sprites, tiles and animations can use.

- New asset types Pixel art and Palette. The first pixel art of a
  project gets a palette with the 16 PICO-8 colours in the same undo
  step. Every pixel is a palette index, 0 is transparent.
- Each layer on each frame is its own file data/pixels/<sha256>.px.
  The project only holds the names, so undo swaps names. The browser
  computes the hash and uploads new cels before the project is saved.
- New route POST ?r=pixels in api/lib/PixelStore.php. It answers 201
  and 422 for broken data. data/pixels/ is git-ignored.
- Tools: pencil, eraser, fill (contiguous or all pixels of a colour),
  eyedropper, line, rectangle and ellipse (outline or filled), brush
  size 1 to 8.
- Layers can be added, deleted, renamed, reordered, hidden, faded and
  merged down. Frames can be added, duplicated, deleted and reordered,
  with onion skin and a small playing preview.
- Zoom 1x to 32x in whole steps, grid from 8x, a 3x3 tile preview and
  Resize... with a 3x3 anchor.
- All frames sit side by side in one image. Each frame is a region
  like a cut texture frame, so texture rows offer "Asset / Frame 2"
  and the context menu has Create animation from frames.

Decisions:
- Merging down drops the opacity of the upper layer. Indexed colours
  know no blends.
- Only the last palette colour can be removed. Otherwise the indices
  of every image using the palette would shift.
- Undo stores the whole layer per stroke instead of pixel diffs.
- Alt+click picks a colour. It is a mouse gesture, not a shortcut.

Follow-up issues: #197 import from texture, #198 selection and
flipping, #199 PNG export, #200 more tools.

Tests:
- ./check.sh: 136 files, 2049 tests (before 135 / 2011). 34 new in
  tests/core/pixel-art.test.ts, 4 from the icon tables.
- e2e module 48 fresh: ALL PASSED, 29 checks, console empty.
- Counter-probes bite: five on the unit tests, upload order (2 tests
  fail) and cels not uploaded in the browser (1 check fails).
- Check total 1259 in README is computed (1232 measured in CI + 27).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 17:33:44 +02:00

579 lines
22 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,
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('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 });
});
});