diff --git a/source/server/utils/merge/apply.test.ts b/source/server/utils/merge/apply.test.ts new file mode 100644 index 00000000..5c0ead24 --- /dev/null +++ b/source/server/utils/merge/apply.test.ts @@ -0,0 +1,25 @@ + +import {DELETE_KEY} from "./types.js"; +import apply from "./apply.js"; + +describe("merge.apply()", function(){ + it("merges a no-op", function(){ + const a = {a:1}; + expect(apply(a, {})).to.deep.equal(a); + }); + + it("merges a deleted key", function(){ + expect(apply({a:1, b:2}, {b:DELETE_KEY})).to.deep.equal({a:1}); + }); + + it("merges an updated key", function(){ + expect(apply({a:1, b:2}, {b:3})).to.deep.equal({a:1, b:3}); + }); + it("merges nested objects", function(){ + expect(apply({a:{b:1}, c:3}, {a:{b:2}})).to.deep.equal({a:{b:2}, c:3}); + }); + it("merges updated arrays", function(){ + expect(apply({a:[1,2]}, {a:{0:1, 1:3}})).to.deep.equal({a:[1,3]}); + }); + +}); \ No newline at end of file diff --git a/source/server/utils/merge/diff.test.ts b/source/server/utils/merge/diff.test.ts new file mode 100644 index 00000000..25428539 --- /dev/null +++ b/source/server/utils/merge/diff.test.ts @@ -0,0 +1,78 @@ + +import {DELETE_KEY} from "./types.js"; + +import diff from "./diff.js"; + +describe("merge.diff()", function(){ + describe("handle native data types ", function(){ + [ //All JSON-serializable data types with possible bounday values + 1, + 0, + "1", + "", + true, + false, + null, + {}, + {a:1}, + [], + ].forEach(v=>{ + const v_clone :typeof v = JSON.parse(JSON.stringify(v)); + it(`${JSON.stringify(v)} deep equals itself`, function(){ + expect(diff({v}, {v:v_clone})).to.deep.equal({}); + }); + it(`${JSON.stringify({v})} compares from {}`, function(){ + expect(diff({}, {v})).to.deep.equal({v:v_clone}); + }); + it(`${JSON.stringify({v})} compares from {}`, function(){ + expect(diff({v}, {})).to.deep.equal({v:DELETE_KEY}); + }); + }); + it("special-case for null recursion", function(){ + expect(diff({ + v: {a:1}, + }, {v: null})) + }) + }); + + describe("arrays", function(){ + //Arrays behave like objects with numeric keys, with some specific handling + + it("handles an array equality", function(){ + expect(diff({a:["foo"]}, {a:["foo"]})).to.deep.equal({}); + }); + + it("handles nested objects equality", function(){ + expect(diff({a:[{v: "foo"}]}, {a:[{v:"foo"}]})).to.deep.equal({}); + }); + + it("handle array.push", function(){ + expect(diff({a:[]}, {a:[1]})).to.deep.equal({a:{0:1}}); + expect(diff({a:[1]}, {a:[1, 2]})).to.deep.equal({a:{1:2}}); + }); + + it("handle Array.pop", function(){ + expect(diff({a:[1]}, {a:[]})).to.deep.equal({a:{0: DELETE_KEY}}); + expect(diff({a:[1, 2]}, {a:[1]})).to.deep.equal({a:{1: DELETE_KEY}}); + }); + + it("handle Array.replace", function(){ + expect(diff({a:["foo", "bar"]}, {a:["foo", "baz"]})).to.deep.equal({a:{1: "baz"}}); + }); + + it("handle Array.splice", function(){ + // Might be improved to detect a splice and use special syntax to represent it + expect(diff({a:["foo", "bar", "baz"]}, {a:["foo", "baz"]})).to.deep.equal({a:{1: "baz", 2: DELETE_KEY}}); + }); + + it("handle deep changes", function(){ + expect(diff({a:[{v:"foo"}, {v:"bar"}]}, {a:[{v:"foo"}, {v:"baz"}]})).to.deep.equal({a:{1:{v:"baz"}}}); + }); + + it("throws when diffing an array with an object", function(){ + expect(()=>diff({a:[]}, {a:{}})).to.throw("Can't diff an array with an object"); + }); + + + }); +}); diff --git a/source/server/utils/merge/merge.test.ts b/source/server/utils/merge/merge.test.ts index ffb4f4ac..4c29f538 100644 --- a/source/server/utils/merge/merge.test.ts +++ b/source/server/utils/merge/merge.test.ts @@ -10,104 +10,6 @@ import { IDocument, INode } from "../schema/document.js"; import {DELETE_KEY, apply, applyDoc, diff, diffDoc} from "./index.js"; - -describe("merge.diff()", function(){ - describe("handle native data types ", function(){ - [ //All JSON-serializable data types with possible bounday values - 1, - 0, - "1", - "", - true, - false, - null, - {}, - {a:1}, - [], - ].forEach(v=>{ - const v_clone :typeof v = JSON.parse(JSON.stringify(v)); - it(`${JSON.stringify(v)} deep equals itself`, function(){ - expect(diff({v}, {v:v_clone})).to.deep.equal({}); - }); - it(`${JSON.stringify({v})} compares from {}`, function(){ - expect(diff({}, {v})).to.deep.equal({v:v_clone}); - }); - it(`${JSON.stringify({v})} compares from {}`, function(){ - expect(diff({v}, {})).to.deep.equal({v:DELETE_KEY}); - }); - }); - }); - - describe("arrays", function(){ - //Arrays behave like objects with numeric keys, with some specific handling - - it("handles an array equality", function(){ - expect(diff({a:["foo"]}, {a:["foo"]})).to.deep.equal({}); - }); - - it("handles nested objects equality", function(){ - expect(diff({a:[{v: "foo"}]}, {a:[{v:"foo"}]})).to.deep.equal({}); - }); - - it("handle array.push", function(){ - expect(diff({a:[]}, {a:[1]})).to.deep.equal({a:{0:1}}); - expect(diff({a:[1]}, {a:[1, 2]})).to.deep.equal({a:{1:2}}); - }); - - it("handle Array.pop", function(){ - expect(diff({a:[1]}, {a:[]})).to.deep.equal({a:{0: DELETE_KEY}}); - expect(diff({a:[1, 2]}, {a:[1]})).to.deep.equal({a:{1: DELETE_KEY}}); - }); - - it("handle Array.replace", function(){ - expect(diff({a:["foo", "bar"]}, {a:["foo", "baz"]})).to.deep.equal({a:{1: "baz"}}); - }); - - it("handle Array.splice", function(){ - // Might be improved to detect a splice and use special syntax to represent it - expect(diff({a:["foo", "bar", "baz"]}, {a:["foo", "baz"]})).to.deep.equal({a:{1: "baz", 2: DELETE_KEY}}); - }); - - it("handle deep changes", function(){ - expect(diff({a:[{v:"foo"}, {v:"bar"}]}, {a:[{v:"foo"}, {v:"baz"}]})).to.deep.equal({a:{1:{v:"baz"}}}); - }); - - it("throws when diffing an array with an object", function(){ - expect(()=>diff({a:[]}, {a:{}})).to.throw("Can't diff an array with an object"); - }); - - - }); -}); - - -describe("merge.apply()", function(){ - it("merges a no-op", function(){ - const a = {a:1}; - expect(apply(a, {})).to.deep.equal(a); - }); - - it("merges a deleted key", function(){ - expect(apply({a:1, b:2}, {b:DELETE_KEY})).to.deep.equal({a:1}); - }); - - it("merges an updated key", function(){ - expect(apply({a:1, b:2}, {b:3})).to.deep.equal({a:1, b:3}); - }); - it("merges nested objects", function(){ - expect(apply({a:{b:1}, c:3}, {a:{b:2}})).to.deep.equal({a:{b:2}, c:3}); - }); - it("merges updated arrays", function(){ - expect(apply({a:[1,2]}, {a:{0:1, 1:3}})).to.deep.equal({a:[1,3]}); - }); - -}); - -describe("merge.diffDoc()", function(){ - -}) - - describe("three-way merge", function(){ /* * For consistency, tests should follow the following naming scheme: diff --git a/source/server/utils/merge/pointers.test.ts b/source/server/utils/merge/pointers.test.ts index 8ee0b830..c2149d7c 100644 --- a/source/server/utils/merge/pointers.test.ts +++ b/source/server/utils/merge/pointers.test.ts @@ -129,10 +129,62 @@ describe("(de)reference pointers", function(){ "nodes": [], }); }); + + describe("throws", function(){ + it("if doc has no scene", function(){ + const doc = {...baseDoc, scenes: []}; + expect(()=> toPointers(doc)).to.throw("Document has no valid scene"); + }); + + it("if doc has an invalid scene index", function(){ + const doc = {...baseDoc, scene: 1}; + expect(()=> toPointers(doc as any)).to.throw("Document's scene #1 is invalid"); + }); + + it("if scene has invalid node index", function(){ + const doc = {...baseDoc, scenes:[{nodes: [1]}], nodes:[{name: "Camera"}]}; + expect(()=> toPointers(doc as any)).to.throw(`Invalid node index 1 in scene #0`); + }); + + it("if node has invalid camera/light/model/meta index", function(){ + "camera/light/model/meta".split("/").forEach(key=>{ + const doc = {...baseDoc, scenes:[{nodes:[0]}], nodes:[{name: key.toUpperCase(), [key]: 0}]}; + expect(()=> toPointers(doc as any)).to.throw(`Invalid ${key} index 0 in node #0`); + }) + }); + + it("if scene has invalid setup index", function(){ + const doc = {...baseDoc, scenes:[{setup: 0}]}; + expect(()=> toPointers(doc as any)).to.throw(`Invalid setup #0 in scene #0`); + }); + + it("if scene has invalid meta index", function(){ + const doc = {...baseDoc, scenes:[{meta: 0}]}; + expect(()=> toPointers(doc as any)).to.throw(`Invalid meta #0 in scene #0`); + }); + }); }); describe("fromPointers()", function(){ + it("copies a scene's name and units", function(){ + const deref = {asset: baseDoc.asset, scene:{ + name: "My Scene", + units: "km", + }}; + expect(fromPointers(deref as any).scene).to.deep.equal({ + name: "My Scene", + units: "km" + }); + }); + + it("auto-fills a scene's units", function(){ + const deref = {asset: baseDoc.asset, scene:{ + name: "My Scene", + }}; + expect(fromPointers(deref as any).scene).to.have.property("units", "cm"); + }); + it("builds nodes reference arrays", function(){ const deref = { asset: baseDoc.asset, @@ -166,6 +218,37 @@ describe("(de)reference pointers", function(){ }); }); + it("copies nodes matrix/translation/rotation/scale properties", function(){ + const model = { + name: "Model", + model: {uri: "model.gltf"}, + matrix:[0,0,0,0, 0,0,0,0, 0,0,0,0, 0,0,0,0], + translation:[0,0,0], + rotation: [0, 0, 0, 0], + scale: [1, 1, 1], + }; + + const deref = { + asset: baseDoc.asset, + scene: { + units: "m", + nodes:[model], + } + }; + + const doc:IDocument = fromPointers(deref as any); + //use JSON.parse(JSON.stringify()) to remove undefined values + expect(doc).to.deep.equal({ + ...baseDoc, + nodes: [ + {name: "Model", model: 0}, + ], + models:[model], + scenes:[{ nodes: [0], units: "m" }], + }); + }); + + it("builds metas from scene", function(){ const deref = { asset: baseDoc.asset, diff --git a/source/server/utils/merge/pointers.ts b/source/server/utils/merge/pointers.ts index d2ababe5..58098a42 100644 --- a/source/server/utils/merge/pointers.ts +++ b/source/server/utils/merge/pointers.ts @@ -9,7 +9,7 @@ type DocumentCollections = keyof Omit; /** * Dereference all indexed values into pointers in a SVX document - * It effectively forgets about all unreachable nodes (scenes, setups, etc...). + * It unlinks all unreachable nodes (scenes, setups, etc...). * * It's something of an emulation of what DPO-voyager does when building a scene graph from a document. * See CVScene.ts and CVNode.ts in voyager. @@ -43,40 +43,47 @@ export function toPointers(src :IDocument) :DerefDocument { node.children = indices.map(idx=>nodes[idx]); }); - const scenes = src.scenes?.map((iScene, sceneIndex)=>{ - let scene :DerefScene = { - name: iScene.name, - units: iScene.units, - nodes: [], - } - if(iScene.nodes?.length){ - scene.nodes = iScene.nodes.map(idx=>{ - if(!nodes || !nodes[idx]) throw new Error(`Invalid node index ${idx} in scene ${iScene.name}(${sceneIndex})`); - return nodes[idx]; - }); - } - if(typeof iScene.setup == "number"){ - if(!src.setups || !src.setups[iScene.setup]) throw new Error(`Invalid setup index ${iScene.setup} in scene ${iScene.name}(${sceneIndex})`) - scene.setup = src.setups[iScene.setup]; - } - if(typeof iScene.meta == "number"){ - if(!src.metas || !src.metas[iScene.meta]) throw new Error(`Invalid meta index ${iScene.meta} in scene ${iScene.name}(${sceneIndex})`) - scene.meta = src.metas[iScene.meta]; - } - return scene; - }); - if(!scenes || !scenes[src.scene!]) throw new Error("Document has no valid scene"); + if(!Array.isArray(src.scenes) || !src.scenes.length) throw new Error("Document has no valid scene"); + if(typeof src.scene != "number"|| !src.scenes[src.scene]) throw new Error(`Document's scene #${src.scene} is invalid`); + const iScene = src.scenes[src.scene]; + + const scene :DerefScene = { + name: iScene.name, + units: iScene.units, + nodes: [], + } + + if(iScene.nodes?.length){ + scene.nodes = iScene.nodes.map(idx=>{ + if(!nodes || !nodes[idx]) throw new Error(`Invalid node index ${idx} in scene #${src.scene}`); + return nodes[idx]; + }); + } + if(typeof iScene.setup == "number"){ + if(!src.setups || !src.setups[iScene.setup]) throw new Error(`Invalid setup #${iScene.setup} in scene #${src.scene}`) + scene.setup = src.setups[iScene.setup]; + } + if(typeof iScene.meta == "number"){ + if(!src.metas || !src.metas[iScene.meta]) throw new Error(`Invalid meta #${iScene.meta} in scene #${src.scene}`) + scene.meta = src.metas[iScene.meta]; + } + return { asset: src.asset, - scene: scenes[src.scene!], + scene, }; } - +/** + * Takes a dereferenced document and computes back the source svx.json document. + * It's akin to the `toDocument()`methods seen in DPO-voyager, in particular in `CVScene.ts`. + * @see toPointers for the inverse operation + */ export function fromPointers({asset, scene} :DerefDocument): IDocument{ - //The output document + //The output document. + // All fields are initially created to later filter unused ones let document :Required = { asset, scene: -1, @@ -92,7 +99,7 @@ export function fromPointers({asset, scene} :DerefDocument): IDocument{ function appendScene(scene :DerefScene){ let iScene :IScene = { - units: scene.units ?? "cm", + units: scene.units ?? "cm", //This is the default unit as per CVScene.ts }; if(scene.name) iScene.name = scene.name; if(scene.nodes?.length){