394 lines
15 KiB
TypeScript
394 lines
15 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest';
|
||
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||
import {
|
||
AddVectorShapeCommand,
|
||
MoveVectorShapeCommand,
|
||
RemoveVectorShapeCommand,
|
||
RenameVectorShapeCommand,
|
||
SetVectorShapeRingsCommand,
|
||
SetVectorShapeStyleCommand,
|
||
} from '../../src/core/commands/vectorArtCommands';
|
||
import type { VectorArtAsset, VectorRing } from '../../src/core/model/Asset';
|
||
import { isPlaceableType } from '../../src/core/model/AssetTypes';
|
||
import { generatedPictureOf, registerPictureSource } from '../../src/core/model/generated';
|
||
import { findAsset, type Project } from '../../src/core/model/Project';
|
||
import { resolvePicture } from '../../src/core/model/texture';
|
||
import {
|
||
handleAt,
|
||
isDrawableRing,
|
||
isDrawableShape,
|
||
SHAPE_FILL_DEFAULT,
|
||
shapeExtent,
|
||
shapeOrderTarget,
|
||
VECTOR_CANVAS_DEFAULT,
|
||
VECTOR_CANVAS_MAX,
|
||
VECTOR_HEIGHT_KEY,
|
||
VECTOR_WIDTH_KEY,
|
||
vectorArtPicture,
|
||
vectorArtRevision,
|
||
vectorCanvasSize,
|
||
} from '../../src/core/model/vectorart';
|
||
import { makeProject, roundTrip } from '../helpers';
|
||
|
||
/** A square, and the same square with one point bent into a curve. */
|
||
function square(): VectorRing {
|
||
return {
|
||
closed: true,
|
||
points: [
|
||
{ x: 10, y: 10 },
|
||
{ x: 90, y: 10 },
|
||
{ x: 90, y: 90 },
|
||
{ x: 10, y: 90 },
|
||
],
|
||
};
|
||
}
|
||
|
||
function drawing() {
|
||
const fixture = makeProject();
|
||
const create = new CreateAssetCommand('vectorart', 'Logo');
|
||
fixture.stack.execute(create);
|
||
const assetId = create.asset.id;
|
||
const art = (project: Project = fixture.project): VectorArtAsset =>
|
||
findAsset(project, assetId) as VectorArtAsset;
|
||
return { ...fixture, assetId, art };
|
||
}
|
||
|
||
describe('the vector art asset', () => {
|
||
it('is a picture, not something placed in a scene', () => {
|
||
expect(isPlaceableType('vectorart')).toBe(false);
|
||
});
|
||
|
||
it('starts empty on a canvas of its own', () => {
|
||
const f = drawing();
|
||
expect(f.art().shapes).toEqual([]);
|
||
expect(vectorCanvasSize(f.art().props)).toEqual({
|
||
width: VECTOR_CANVAS_DEFAULT,
|
||
height: VECTOR_CANVAS_DEFAULT,
|
||
});
|
||
});
|
||
|
||
it('reads a canvas that is missing, broken or absurd as one it can draw on', () => {
|
||
expect(vectorCanvasSize({})).toEqual({ width: 256, height: 256 });
|
||
expect(vectorCanvasSize({ [VECTOR_WIDTH_KEY]: 0, [VECTOR_HEIGHT_KEY]: -40 })).toEqual({
|
||
width: 256,
|
||
height: 256,
|
||
});
|
||
expect(vectorCanvasSize({ [VECTOR_WIDTH_KEY]: 99999, [VECTOR_HEIGHT_KEY]: 64.7 })).toEqual({
|
||
width: VECTOR_CANVAS_MAX,
|
||
height: 64,
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('what is worth drawing', () => {
|
||
it('needs three points closed and two open', () => {
|
||
expect(isDrawableRing({ closed: true, points: square().points.slice(0, 2) })).toBe(false);
|
||
expect(isDrawableRing({ closed: true, points: square().points.slice(0, 3) })).toBe(true);
|
||
expect(isDrawableRing({ closed: false, points: square().points.slice(0, 2) })).toBe(true);
|
||
expect(isDrawableShape({ id: 's', name: 'S', rings: [], fill: 0, opacity: 1 })).toBe(false);
|
||
});
|
||
|
||
it('measures a shape including the handles pulled off its points', () => {
|
||
const shape = {
|
||
id: 's',
|
||
name: 'S',
|
||
rings: [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: 10, y: 10, out: { x: -30, y: 5 } },
|
||
{ x: 90, y: 90 },
|
||
],
|
||
},
|
||
],
|
||
fill: 0,
|
||
opacity: 1,
|
||
};
|
||
expect(handleAt(shape.rings[0]!.points[0]!, 'out')).toEqual({ x: -20, y: 15 });
|
||
expect(handleAt(shape.rings[0]!.points[1]!, 'in')).toBeNull();
|
||
expect(shapeExtent(shape)).toEqual({ x0: -20, y0: 10, x1: 90, y1: 90 });
|
||
expect(shapeExtent({ ...shape, rings: [] })).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('the revision', () => {
|
||
it('changes with every pixel and with nothing else', () => {
|
||
const f = drawing();
|
||
const add = new AddVectorShapeCommand(f.assetId, [square()]);
|
||
f.stack.execute(add);
|
||
const first = vectorArtRevision(f.art());
|
||
|
||
f.stack.execute(new RenameVectorShapeCommand(f.assetId, add.shapeId, 'Body'));
|
||
expect(vectorArtRevision(f.art())).toBe(first);
|
||
|
||
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { fill: 0xff0000 }));
|
||
const coloured = vectorArtRevision(f.art());
|
||
expect(coloured).not.toBe(first);
|
||
|
||
const moved = square();
|
||
moved.points[0] = { x: 11, y: 10 };
|
||
f.stack.execute(new SetVectorShapeRingsCommand(f.assetId, add.shapeId, [moved]));
|
||
expect(vectorArtRevision(f.art())).not.toBe(coloured);
|
||
});
|
||
|
||
it('changes when the canvas is resized, because the picture is a different one', () => {
|
||
const f = drawing();
|
||
f.stack.execute(new AddVectorShapeCommand(f.assetId, [square()]));
|
||
const before = vectorArtRevision(f.art());
|
||
f.stack.execute(new SetAssetPropertyCommand(f.assetId, VECTOR_WIDTH_KEY, 512));
|
||
expect(vectorArtRevision(f.art())).not.toBe(before);
|
||
});
|
||
|
||
it('is the same for the same drawing, however the object was built', () => {
|
||
const asset = (ring: VectorRing): VectorArtAsset => ({
|
||
id: 'a',
|
||
type: 'vectorart',
|
||
name: 'A',
|
||
props: {},
|
||
shapes: [{ id: 's', name: 'S', rings: [ring], fill: 1, opacity: 1 }],
|
||
});
|
||
// The same ring with its two keys set the other way round – what a project file gives back.
|
||
const built = { closed: true, points: square().points };
|
||
const loaded = { points: square().points, closed: true } as VectorRing;
|
||
expect(vectorArtRevision(asset(loaded))).toBe(vectorArtRevision(asset(built)));
|
||
});
|
||
|
||
it('survives a save and load: the same drawing is the same picture', () => {
|
||
const f = drawing();
|
||
f.stack.execute(new AddVectorShapeCommand(f.assetId, [square()]));
|
||
expect(vectorArtRevision(f.art(roundTrip(f.project)))).toBe(vectorArtRevision(f.art()));
|
||
});
|
||
});
|
||
|
||
describe('the way into the picture path', () => {
|
||
let unregister: (() => void) | null = null;
|
||
afterEach(() => {
|
||
unregister?.();
|
||
unregister = null;
|
||
});
|
||
|
||
it('hands over the canvas and the revision, and only for vector art', () => {
|
||
const f = drawing();
|
||
expect(vectorArtPicture(f.art())).toEqual({
|
||
width: VECTOR_CANVAS_DEFAULT,
|
||
height: VECTOR_CANVAS_DEFAULT,
|
||
revision: vectorArtRevision(f.art()),
|
||
});
|
||
const other = new CreateAssetCommand('sprite', 'Box');
|
||
f.stack.execute(other);
|
||
expect(vectorArtPicture(other.asset)).toBeNull();
|
||
});
|
||
|
||
it('resolves to a generated picture of the canvas size once it is registered (#185)', () => {
|
||
const f = drawing();
|
||
f.stack.execute(new SetAssetPropertyCommand(f.assetId, VECTOR_HEIGHT_KEY, 128));
|
||
expect(generatedPictureOf(f.art())).toBeNull();
|
||
unregister = registerPictureSource('vectorart', vectorArtPicture);
|
||
const ref = resolvePicture(f.project, f.assetId);
|
||
expect(ref?.file).toBe(`gen:${f.assetId}@${vectorArtRevision(f.art())}`);
|
||
expect(ref?.sourceWidth).toBe(VECTOR_CANVAS_DEFAULT);
|
||
expect(ref?.sourceHeight).toBe(128);
|
||
});
|
||
});
|
||
|
||
describe('editing the shapes', () => {
|
||
it('numbers new shapes and takes them back out again', () => {
|
||
const f = drawing();
|
||
const first = new AddVectorShapeCommand(f.assetId);
|
||
const second = new AddVectorShapeCommand(f.assetId, [square()], 0x112233);
|
||
f.stack.execute(first);
|
||
f.stack.execute(second);
|
||
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
|
||
expect(f.art().shapes[1]).toMatchObject({ fill: 0x112233, opacity: 1 });
|
||
expect(f.art().shapes[0]!.fill).toBe(SHAPE_FILL_DEFAULT);
|
||
|
||
f.stack.undo();
|
||
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1']);
|
||
f.stack.redo();
|
||
expect(f.art().shapes.map((s) => s.name)).toEqual(['Shape 1', 'Shape 2']);
|
||
});
|
||
|
||
it('puts a removed shape back where it stood, unlike a tilemap keeping its last layer', () => {
|
||
const f = drawing();
|
||
const ids = [0, 1, 2].map(() => {
|
||
const add = new AddVectorShapeCommand(f.assetId, [square()]);
|
||
f.stack.execute(add);
|
||
return add.shapeId;
|
||
});
|
||
f.stack.execute(new RemoveVectorShapeCommand(f.assetId, ids[1]!));
|
||
expect(f.art().shapes.map((s) => s.id)).toEqual([ids[0], ids[2]]);
|
||
f.stack.undo();
|
||
expect(f.art().shapes.map((s) => s.id)).toEqual(ids);
|
||
|
||
// The last one may go: an empty drawing is a drawing.
|
||
for (const id of ids) f.stack.execute(new RemoveVectorShapeCommand(f.assetId, id));
|
||
expect(f.art().shapes).toEqual([]);
|
||
});
|
||
|
||
it('keeps the drawing order apart from the list order in no way at all', () => {
|
||
const f = drawing();
|
||
const ids = ['a', 'b', 'c'].map(() => {
|
||
const add = new AddVectorShapeCommand(f.assetId, [square()]);
|
||
f.stack.execute(add);
|
||
return add.shapeId;
|
||
});
|
||
// Last is on top, so "forward" is one step towards the end and "front" is the end.
|
||
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'forward')).toBe(1);
|
||
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'front')).toBe(2);
|
||
expect(shapeOrderTarget(f.art().shapes, ids[2]!, 'backward')).toBe(1);
|
||
expect(shapeOrderTarget(f.art().shapes, ids[1]!, 'back')).toBe(0);
|
||
// Already there, and not there at all.
|
||
expect(shapeOrderTarget(f.art().shapes, ids[2]!, 'front')).toBe(-1);
|
||
expect(shapeOrderTarget(f.art().shapes, ids[0]!, 'back')).toBe(-1);
|
||
expect(shapeOrderTarget(f.art().shapes, 'nobody', 'front')).toBe(-1);
|
||
|
||
f.stack.execute(new MoveVectorShapeCommand(f.assetId, 0, 2));
|
||
expect(f.art().shapes.map((s) => s.id)).toEqual([ids[1], ids[2], ids[0]]);
|
||
f.stack.undo();
|
||
expect(f.art().shapes.map((s) => s.id)).toEqual(ids);
|
||
});
|
||
|
||
it('copies the outline it is handed, so a later drag does not reach into the undo step', () => {
|
||
const f = drawing();
|
||
const add = new AddVectorShapeCommand(f.assetId, [square()]);
|
||
f.stack.execute(add);
|
||
const rings = [square()];
|
||
rings[0]!.points[0] = { x: 0, y: 0, out: { x: 5, y: 5 } };
|
||
const edit = new SetVectorShapeRingsCommand(f.assetId, add.shapeId, rings);
|
||
expect(SetVectorShapeRingsCommand.changes(f.art().shapes[0]!, rings)).toBe(true);
|
||
f.stack.execute(edit);
|
||
rings[0]!.points[1] = { x: -999, y: -999 };
|
||
expect(f.art().shapes[0]!.rings[0]!.points[0]).toEqual({ x: 0, y: 0, out: { x: 5, y: 5 } });
|
||
expect(f.art().shapes[0]!.rings[0]!.points[1]).toEqual({ x: 90, y: 10 });
|
||
|
||
f.stack.undo();
|
||
expect(f.art().shapes[0]!.rings[0]!.points[0]).toEqual({ x: 10, y: 10 });
|
||
expect(SetVectorShapeRingsCommand.changes(f.art().shapes[0]!, [square()])).toBe(false);
|
||
});
|
||
|
||
it('writes fill, opacity and outline one field at a time and takes each back', () => {
|
||
const f = drawing();
|
||
const add = new AddVectorShapeCommand(f.assetId, [square()]);
|
||
f.stack.execute(add);
|
||
f.stack.execute(
|
||
new SetVectorShapeStyleCommand(f.assetId, add.shapeId, {
|
||
fill: 0x00ff00,
|
||
opacity: 4,
|
||
stroke: { color: 0, width: 2 },
|
||
}),
|
||
);
|
||
// An opacity outside 0…1 is clamped, not refused: a slider may overshoot.
|
||
expect(f.art().shapes[0]).toMatchObject({ fill: 0x00ff00, opacity: 1 });
|
||
expect(f.art().shapes[0]!.stroke).toEqual({ color: 0, width: 2 });
|
||
|
||
f.stack.execute(new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { stroke: null }));
|
||
expect(f.art().shapes[0]!.stroke).toBeUndefined();
|
||
f.stack.undo();
|
||
expect(f.art().shapes[0]!.stroke).toEqual({ color: 0, width: 2 });
|
||
f.stack.undo();
|
||
expect(f.art().shapes[0]).toMatchObject({ fill: SHAPE_FILL_DEFAULT, opacity: 1 });
|
||
expect(f.art().shapes[0]!.stroke).toBeUndefined();
|
||
});
|
||
|
||
it('refuses an outline of no width and a shape of no name', () => {
|
||
const f = drawing();
|
||
const add = new AddVectorShapeCommand(f.assetId, [square()]);
|
||
f.stack.execute(add);
|
||
expect(() =>
|
||
f.stack.execute(
|
||
new SetVectorShapeStyleCommand(f.assetId, add.shapeId, { stroke: { color: 0, width: 0 } }),
|
||
),
|
||
).toThrow(/wider than nothing/);
|
||
expect(() => f.stack.execute(new RenameVectorShapeCommand(f.assetId, add.shapeId, ' '))).toThrow(
|
||
/needs a name/,
|
||
);
|
||
expect(() => f.stack.execute(new RenameVectorShapeCommand(f.assetId, 'nobody', 'X'))).toThrow(
|
||
/not found/,
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('what a project file may say', () => {
|
||
it('keeps a whole drawing across save and load', () => {
|
||
const f = drawing();
|
||
const add = new AddVectorShapeCommand(f.assetId, [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: 10, y: 10, out: { x: 4, y: 0 } },
|
||
{ x: 90, y: 10 },
|
||
{ x: 50, y: 80 },
|
||
],
|
||
},
|
||
{
|
||
closed: false,
|
||
points: [
|
||
{ x: 20, y: 20 },
|
||
{ x: 30, y: 30 },
|
||
],
|
||
},
|
||
]);
|
||
f.stack.execute(add);
|
||
f.stack.execute(
|
||
new SetVectorShapeStyleCommand(f.assetId, add.shapeId, {
|
||
fill: 0x334455,
|
||
opacity: 0.5,
|
||
stroke: { color: 0xffffff, width: 3 },
|
||
}),
|
||
);
|
||
expect(f.art(roundTrip(f.project)).shapes).toEqual(f.art().shapes);
|
||
});
|
||
|
||
it('drops what cannot be drawn from and keeps what someone is still drawing', () => {
|
||
const f = drawing();
|
||
const art = f.art() as unknown as Record<string, unknown>;
|
||
art.shapes = [
|
||
{
|
||
id: 'one',
|
||
name: 'One',
|
||
rings: [
|
||
{
|
||
closed: true,
|
||
points: [
|
||
{ x: 10, y: 10, in: { x: 'left', y: 2 }, out: { x: 1, y: 2 } },
|
||
{ x: 'far', y: 10 },
|
||
{ x: 30, y: 30 },
|
||
{ x: 50 },
|
||
],
|
||
},
|
||
{ points: [{ x: 0, y: 0 }] },
|
||
],
|
||
fill: 0x123456,
|
||
opacity: 7,
|
||
stroke: { color: 0xffffff, width: 0 },
|
||
},
|
||
// A doubled id would make one reference mean two shapes, as a doubled layer id does.
|
||
{ id: 'one', name: 'Twin', rings: [] },
|
||
{ name: 'Nameless', rings: [] },
|
||
'not a shape',
|
||
];
|
||
const loaded = f.art(roundTrip(f.project));
|
||
expect(loaded.shapes).toHaveLength(1);
|
||
const shape = loaded.shapes[0]!;
|
||
// The broken handle falls away and leaves a corner; the broken points fall away entirely.
|
||
expect(shape.rings[0]!.points).toEqual([
|
||
{ x: 10, y: 10, out: { x: 1, y: 2 } },
|
||
{ x: 30, y: 30 },
|
||
]);
|
||
// A ring of one point is kept – it is an outline someone is still drawing – and closed by default.
|
||
expect(shape.rings[1]).toEqual({ closed: true, points: [{ x: 0, y: 0 }] });
|
||
expect(shape).toMatchObject({ fill: 0x123456, opacity: 1 });
|
||
// An outline of no width is no outline.
|
||
expect(shape.stroke).toBeUndefined();
|
||
});
|
||
|
||
it('falls back to a fill and a name where the file says nothing usable', () => {
|
||
const f = drawing();
|
||
const art = f.art() as unknown as Record<string, unknown>;
|
||
art.shapes = [{ id: 'one', rings: [{ closed: false, points: [] }], fill: 'blue' }];
|
||
const shape = f.art(roundTrip(f.project)).shapes[0]!;
|
||
expect(shape).toMatchObject({ name: 'Shape', fill: SHAPE_FILL_DEFAULT, opacity: 1 });
|
||
expect(shape.rings).toEqual([{ closed: false, points: [] }]);
|
||
});
|
||
});
|