Files
forma-engine/tests/store.test.ts
T

277 lines
9.1 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { ProjectStore } from "../engine/store.ts";
import { validateProject } from "../engine/schema.ts";
import { project, node, scene, findNode } from "./fixtures.ts";
const add = (
id: string,
parentId: string | null = null,
components: Record<string, any> = {},
) => ({ op: "node.create", args: { entity: node(id, parentId, components) } });
const rename = (name: string) => ({ op: "project.rename", args: { name } });
test("a failed multi-command transaction rolls back every preceding mutation", () => {
const store = new ProjectStore(project([node("existing")]));
const before = structuredClone(store.project);
assert.throws(() =>
store.transaction({
commands: [
rename("Should not persist"),
add("new_object"),
{ op: "node.reparent", args: { id: "existing", parentId: "missing" } },
],
expectedRevision: 0,
}),
);
assert.deepEqual(store.project, before);
});
test("stale revisions cannot overwrite a newer editor change", () => {
const store = new ProjectStore(project());
store.transaction({ commands: [rename("Current")], expectedRevision: 0 });
assert.equal(store.project.revision, 1);
assert.throws(() =>
store.transaction({ commands: [rename("Stale")], expectedRevision: 0 }),
);
assert.equal(store.project.name, "Current");
assert.equal(store.project.revision, 1);
});
test("undo and redo restore complete edits with monotonic revisions", () => {
const store = new ProjectStore(project());
store.transaction({
commands: [add("root"), add("child", "root")],
expectedRevision: 0,
});
assert.equal(scene(store.project).entities.length, 2);
const revision = store.project.revision;
store.undo();
assert.equal(scene(store.project).entities.length, 0);
assert.ok(store.project.revision > revision);
const undoneRevision = store.project.revision;
store.redo();
assert.equal(findNode(store.project, "child").parentId, "root");
assert.ok(store.project.revision > undoneRevision);
validateProject(store.project);
});
test("a retry of an already accepted request cannot duplicate objects", () => {
const store = new ProjectStore(project());
const tx = {
commands: [add("only_once")],
expectedRevision: 0,
requestId: "request_123",
};
store.transaction(tx);
store.transaction(tx);
assert.equal(store.project.revision, 1);
assert.equal(scene(store.project).entities.length, 1);
});
test("a fresh edit after undo discards redo history", () => {
const store = new ProjectStore(project());
store.transaction({ commands: [rename("First")] });
store.transaction({ commands: [rename("Second")] });
store.undo();
store.transaction({ commands: [rename("Branch")] });
const before = structuredClone(store.project);
try {
store.redo();
} catch {}
assert.deepEqual(store.project, before);
});
test("hierarchy cycles are rejected atomically, including indirect cycles", () => {
const store = new ProjectStore(
project([node("a"), node("b", "a"), node("c", "b")]),
);
const before = structuredClone(store.project);
assert.throws(() =>
store.transaction({
commands: [{ op: "node.reparent", args: { id: "a", parentId: "c" } }],
}),
);
assert.deepEqual(store.project, before);
assert.throws(() =>
store.transaction({
commands: [{ op: "node.reparent", args: { id: "b", parentId: "b" } }],
}),
);
});
test("deleting a hierarchy root removes its full subtree but preserves siblings", () => {
const store = new ProjectStore(
project([node("a"), node("b", "a"), node("c", "b"), node("survivor")]),
);
store.transaction({ commands: [{ op: "node.delete", args: { id: "a" } }] });
assert.deepEqual(
scene(store.project).entities.map((e: any) => e.id),
["survivor"],
);
store.undo();
assert.equal(scene(store.project).entities.length, 4);
});
test("duplicate remaps internal hierarchy, camera and entity properties only", () => {
const p = project([
node("root"),
node("child", "root"),
node("outside"),
node("camera", "root", {
camera: { targetId: "child", offset: [0, 10, -8] },
}),
node("behavior", "root", {
script: {
scriptId: "script_refs",
params: { target: "child", external: "outside", literal: "child" },
},
}),
]);
p.scripts = [
{
id: "script_refs",
name: "References",
source: "({ update() {} })",
fields: {
target: { type: "entity", default: "" },
external: { type: "entity", default: "" },
literal: { type: "string", default: "" },
},
},
];
const store = new ProjectStore(p);
const oldIds = new Set(scene(store.project).entities.map((e: any) => e.id));
store.transaction({
commands: [{ op: "node.duplicate", args: { id: "root" } }],
});
const copies = scene(store.project).entities.filter(
(e: any) => !oldIds.has(e.id),
);
assert.equal(copies.length, 4);
const copyRoot = copies.find((e: any) => e.parentId === null);
assert.ok(copyRoot);
const copyCamera = copies.find((e: any) => e.components.camera),
copyBehavior = copies.find((e: any) => e.components.script);
const copyChild = copies.find(
(e: any) => e !== copyRoot && !e.components.camera && !e.components.script,
);
assert.ok(copyChild);
for (const e of copies.filter((e: any) => e !== copyRoot))
assert.equal(e.parentId, copyRoot.id);
assert.equal(copyCamera.components.camera.targetId, copyChild.id);
assert.equal(copyBehavior.components.script.params.target, copyChild.id);
assert.equal(copyBehavior.components.script.params.external, "outside");
assert.equal(copyBehavior.components.script.params.literal, "child");
validateProject(store.project);
});
test("schema rejects duplicate node IDs, dangling parents and prototype keys", () => {
assert.throws(() => validateProject(project([node("same"), node("same")])));
assert.throws(() => validateProject(project([node("child", "missing")])));
const p = project([node("a")]);
p.scenes[0].entities[0].components = JSON.parse(
'{"data":{"__proto__":{"admin":true}}}',
);
assert.throws(() => validateProject(p));
assert.equal(({} as any).admin, undefined);
});
test("component type cannot alter the components object prototype", () => {
const store = new ProjectStore(project([node("safe")]));
const before = structuredClone(store.project);
assert.throws(() =>
store.transaction({
commands: [
{
op: "component.set",
args: {
id: "safe",
type: "__proto__",
value: { mesh: { type: "box", size: [1, 1, 1] } },
},
},
],
}),
);
assert.deepEqual(store.project, before);
assert.equal(
Object.getPrototypeOf(findNode(store.project, "safe").components),
Object.prototype,
);
});
test("prefab instances remap internal references independently from their source", () => {
const p = project([
node("root"),
node("child", "root"),
node("camera", "root", {
camera: { targetId: "child", offset: [0, 10, -8] },
}),
node("behavior", "root", {
script: { scriptId: "target_default", params: {} },
}),
]);
p.scripts = [
{
id: "target_default",
name: "Default target",
source: "({ update() {} })",
fields: { target: { type: "entity", default: "child" } },
},
];
const store = new ProjectStore(p);
store.transaction({
commands: [
{ op: "prefab.create", args: { id: "root", assetId: "prefab_test" } },
],
});
const originalIds = new Set(
scene(store.project).entities.map((e: any) => e.id),
);
store.transaction({
commands: [
{
op: "prefab.instantiate",
args: { assetId: "prefab_test", position: [4, 0, 2] },
},
],
});
const copies = scene(store.project).entities.filter(
(e: any) => !originalIds.has(e.id),
);
assert.equal(copies.length, 4);
const root = copies.find((e: any) => e.parentId === null),
child = copies.find(
(e: any) =>
e.parentId !== null && !e.components.camera && !e.components.script,
);
assert.deepEqual(root.transform.position, [4, 0, 2]);
assert.equal(
copies.find((e: any) => e.components.camera).components.camera.targetId,
child.id,
);
assert.equal(
copies.find((e: any) => e.components.script).components.script.params
.target,
child.id,
);
assert.equal(
findNode(store.project, "camera").components.camera.targetId,
"child",
);
assert.equal(store.project.scripts[0].fields.target.default, "child");
validateProject(store.project);
});
test("deleting an in-use asset cannot leave broken scene references", () => {
const p = project([node("model", null, { mesh: { assetId: "triangle" } })]);
p.assets = [
{
id: "triangle",
name: "Triangle",
kind: "geometry",
geometry: { positions: [0, 0, 0, 1, 0, 0, 0, 1, 0], indices: [0, 1, 2] },
},
];
const store = new ProjectStore(p),
before = structuredClone(store.project);
assert.throws(() =>
store.transaction({
commands: [{ op: "asset.delete", args: { id: "triangle" } }],
}),
);
assert.deepEqual(store.project, before);
});