A graph had no way to know how big a picture is. An icon sprite with a fixed size therefore stretched every icon of another size, an 8x8 pixel-art icon in a 16x16 slot was drawn twice as big. - New data node Texture size. It takes a texture, a frame or a pixel art and hands out width and height in pixels, 0 x 0 for none. Wired into a sprite's width and height, the picture shows pixel for pixel. - GraphLookup gets textureSize. The project answers it with the same pictureSize rule a sprite uses to size itself. Tests: - ./check.sh: 2749 tests, CHECK EXIT 0. - 2 catalog cases (a picture, no picture) and one test against a real project: pixel-art sheet, single frame, missing texture. - No e2e check and no counter-check for this node. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
617 lines
24 KiB
TypeScript
617 lines
24 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 { graphLookup } from '../../src/core/model/graphLookup';
|
|
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('tells a graph its own pixel size, the sheet for the asset and the cut for a frame', () => {
|
|
const remove = registerPictureSource('pixelart', pixelArtPicture);
|
|
try {
|
|
const { project } = makeProject();
|
|
const asset = art(project);
|
|
asset.width = 8;
|
|
asset.height = 6;
|
|
asset.frames.push({ id: 'second', cels: {} });
|
|
const lookup = graphLookup(project);
|
|
expect(lookup.textureSize(asset.id)).toEqual({ w: 16, h: 6 });
|
|
expect(lookup.textureSize('second')).toEqual({ w: 8, h: 6 });
|
|
expect(lookup.textureSize('gone')).toBeNull();
|
|
} 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 });
|
|
});
|
|
});
|