277 lines
9.1 KiB
TypeScript
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);
|
|
});
|