@vuer-ai/vuer-rtc 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/EditBuffer.d.ts +43 -0
- package/dist/client/EditBuffer.d.ts.map +1 -0
- package/dist/client/EditBuffer.js +96 -0
- package/dist/client/EditBuffer.js.map +1 -0
- package/dist/client/actions.d.ts +66 -0
- package/dist/client/actions.d.ts.map +1 -0
- package/dist/client/actions.js +345 -0
- package/dist/client/actions.js.map +1 -0
- package/dist/client/createGraph.d.ts +30 -0
- package/dist/client/createGraph.d.ts.map +1 -0
- package/dist/client/createGraph.js +91 -0
- package/dist/client/createGraph.js.map +1 -0
- package/dist/client/hooks.d.ts +81 -0
- package/dist/client/hooks.d.ts.map +1 -0
- package/dist/client/hooks.js +161 -0
- package/dist/client/hooks.js.map +1 -0
- package/dist/client/index.d.ts +8 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +10 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/types.d.ts +74 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +11 -0
- package/dist/client/types.js.map +1 -0
- package/dist/hooks.d.ts +8 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +7 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/operations/OperationTypes.d.ts +239 -0
- package/dist/operations/OperationTypes.d.ts.map +1 -0
- package/dist/operations/OperationTypes.js +10 -0
- package/dist/operations/OperationTypes.js.map +1 -0
- package/dist/operations/OperationValidator.d.ts +32 -0
- package/dist/operations/OperationValidator.d.ts.map +1 -0
- package/dist/operations/OperationValidator.js +208 -0
- package/dist/operations/OperationValidator.js.map +1 -0
- package/dist/operations/apply/array.d.ts +22 -0
- package/dist/operations/apply/array.d.ts.map +1 -0
- package/dist/operations/apply/array.js +64 -0
- package/dist/operations/apply/array.js.map +1 -0
- package/dist/operations/apply/boolean.d.ts +18 -0
- package/dist/operations/apply/boolean.d.ts.map +1 -0
- package/dist/operations/apply/boolean.js +34 -0
- package/dist/operations/apply/boolean.js.map +1 -0
- package/dist/operations/apply/color.d.ts +14 -0
- package/dist/operations/apply/color.d.ts.map +1 -0
- package/dist/operations/apply/color.js +46 -0
- package/dist/operations/apply/color.js.map +1 -0
- package/dist/operations/apply/index.d.ts +18 -0
- package/dist/operations/apply/index.d.ts.map +1 -0
- package/dist/operations/apply/index.js +26 -0
- package/dist/operations/apply/index.js.map +1 -0
- package/dist/operations/apply/node.d.ts +24 -0
- package/dist/operations/apply/node.d.ts.map +1 -0
- package/dist/operations/apply/node.js +77 -0
- package/dist/operations/apply/node.js.map +1 -0
- package/dist/operations/apply/number.d.ts +26 -0
- package/dist/operations/apply/number.d.ts.map +1 -0
- package/dist/operations/apply/number.js +54 -0
- package/dist/operations/apply/number.js.map +1 -0
- package/dist/operations/apply/object.d.ts +14 -0
- package/dist/operations/apply/object.d.ts.map +1 -0
- package/dist/operations/apply/object.js +47 -0
- package/dist/operations/apply/object.js.map +1 -0
- package/dist/operations/apply/quaternion.d.ts +15 -0
- package/dist/operations/apply/quaternion.d.ts.map +1 -0
- package/dist/operations/apply/quaternion.js +33 -0
- package/dist/operations/apply/quaternion.js.map +1 -0
- package/dist/operations/apply/string.d.ts +14 -0
- package/dist/operations/apply/string.d.ts.map +1 -0
- package/dist/operations/apply/string.js +26 -0
- package/dist/operations/apply/string.js.map +1 -0
- package/dist/operations/apply/types.d.ts +34 -0
- package/dist/operations/apply/types.d.ts.map +1 -0
- package/dist/operations/apply/types.js +32 -0
- package/dist/operations/apply/types.js.map +1 -0
- package/dist/operations/apply/vector3.d.ts +18 -0
- package/dist/operations/apply/vector3.d.ts.map +1 -0
- package/dist/operations/apply/vector3.js +44 -0
- package/dist/operations/apply/vector3.js.map +1 -0
- package/dist/operations/dispatcher.d.ts +35 -0
- package/dist/operations/dispatcher.d.ts.map +1 -0
- package/dist/operations/dispatcher.js +107 -0
- package/dist/operations/dispatcher.js.map +1 -0
- package/dist/operations/index.d.ts +10 -0
- package/dist/operations/index.d.ts.map +1 -0
- package/dist/operations/index.js +17 -0
- package/dist/operations/index.js.map +1 -0
- package/dist/state/ConflictResolver.d.ts +36 -0
- package/dist/state/ConflictResolver.d.ts.map +1 -0
- package/dist/state/ConflictResolver.js +167 -0
- package/dist/state/ConflictResolver.js.map +1 -0
- package/dist/state/DType.d.ts +160 -0
- package/dist/state/DType.d.ts.map +1 -0
- package/dist/state/DType.js +282 -0
- package/dist/state/DType.js.map +1 -0
- package/dist/state/Schema.d.ts +32 -0
- package/dist/state/Schema.d.ts.map +1 -0
- package/dist/state/Schema.js +175 -0
- package/dist/state/Schema.js.map +1 -0
- package/dist/state/VectorClock.d.ts +42 -0
- package/dist/state/VectorClock.d.ts.map +1 -0
- package/dist/state/VectorClock.js +84 -0
- package/dist/state/VectorClock.js.map +1 -0
- package/dist/state/index.d.ts +11 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +13 -0
- package/dist/state/index.js.map +1 -0
- package/docs/OPERATION_HINTS.md +222 -0
- package/docs/SCENE_GRAPH.md +373 -0
- package/docs/TYPE_BEHAVIORS.md +348 -0
- package/examples/01-basic-usage.ts +139 -0
- package/examples/02-concurrent-edits.ts +208 -0
- package/examples/03-scene-building.ts +258 -0
- package/examples/04-conflict-resolution.ts +339 -0
- package/examples/README.md +86 -0
- package/jest.config.js +19 -0
- package/package.json +57 -0
- package/src/client/EditBuffer.ts +105 -0
- package/src/client/actions.ts +397 -0
- package/src/client/createGraph.ts +132 -0
- package/src/client/hooks.tsx +249 -0
- package/src/client/index.ts +35 -0
- package/src/client/types.ts +94 -0
- package/src/hooks.ts +20 -0
- package/src/index.ts +14 -0
- package/src/operations/OperationTypes.ts +340 -0
- package/src/operations/OperationValidator.ts +260 -0
- package/src/operations/apply/array.ts +84 -0
- package/src/operations/apply/boolean.ts +48 -0
- package/src/operations/apply/color.ts +65 -0
- package/src/operations/apply/index.ts +37 -0
- package/src/operations/apply/node.ts +98 -0
- package/src/operations/apply/number.ts +76 -0
- package/src/operations/apply/object.ts +63 -0
- package/src/operations/apply/quaternion.ts +47 -0
- package/src/operations/apply/string.ts +36 -0
- package/src/operations/apply/types.ts +66 -0
- package/src/operations/apply/vector3.ts +60 -0
- package/src/operations/dispatcher.ts +127 -0
- package/src/operations/index.ts +80 -0
- package/src/state/ConflictResolver.ts +205 -0
- package/src/state/DType.ts +333 -0
- package/src/state/Schema.ts +236 -0
- package/src/state/VectorClock.ts +98 -0
- package/src/state/index.ts +14 -0
- package/tests/client/actions.test.ts +371 -0
- package/tests/client/edit-buffer.test.ts +117 -0
- package/tests/fixtures/array-ops.jsonl +6 -0
- package/tests/fixtures/boolean-ops.jsonl +6 -0
- package/tests/fixtures/color-ops.jsonl +4 -0
- package/tests/fixtures/edit-buffer.jsonl +3 -0
- package/tests/fixtures/node-ops.jsonl +6 -0
- package/tests/fixtures/number-ops.jsonl +7 -0
- package/tests/fixtures/object-ops.jsonl +4 -0
- package/tests/fixtures/operations.jsonl +7 -0
- package/tests/fixtures/string-ops.jsonl +4 -0
- package/tests/fixtures/undo-redo.jsonl +3 -0
- package/tests/fixtures/vector-ops.jsonl +9 -0
- package/tests/operations/collections.test.ts +193 -0
- package/tests/operations/nodes.test.ts +228 -0
- package/tests/operations/primitives.test.ts +222 -0
- package/tests/operations/vectors.test.ts +150 -0
- package/tsconfig.json +21 -0
- package/tsconfig.test.json +9 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for collection operations: array, object, color
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach } from '@jest/globals';
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
|
+
import { join, dirname } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { createEmptyGraph, applyMessage } from '../../src/index.js';
|
|
10
|
+
import type { SceneGraph, CRDTMessage, Operation } from '../../src/operations/OperationTypes.js';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
interface OpFixture {
|
|
15
|
+
name: string;
|
|
16
|
+
op: Operation;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function loadOps(filename: string): OpFixture[] {
|
|
20
|
+
const filepath = join(__dirname, '../fixtures', filename);
|
|
21
|
+
const content = readFileSync(filepath, 'utf-8');
|
|
22
|
+
return content
|
|
23
|
+
.trim()
|
|
24
|
+
.split('\n')
|
|
25
|
+
.map((line) => JSON.parse(line));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createMsg(op: Operation, lamport = 1): CRDTMessage {
|
|
29
|
+
return {
|
|
30
|
+
id: `msg-${Math.random().toString(36).slice(2)}`,
|
|
31
|
+
sessionId: 'test-session',
|
|
32
|
+
clock: { 'test-session': lamport },
|
|
33
|
+
lamportTime: lamport,
|
|
34
|
+
timestamp: Date.now(),
|
|
35
|
+
ops: [op],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createNodeWithProps(
|
|
40
|
+
graph: SceneGraph,
|
|
41
|
+
key: string,
|
|
42
|
+
props: Record<string, unknown>
|
|
43
|
+
): SceneGraph {
|
|
44
|
+
const nodeOp: Operation = {
|
|
45
|
+
key: '',
|
|
46
|
+
otype: 'node.insert',
|
|
47
|
+
path: 'children',
|
|
48
|
+
value: {
|
|
49
|
+
key,
|
|
50
|
+
id: `uuid-${key}`,
|
|
51
|
+
tag: 'Mesh',
|
|
52
|
+
name: key,
|
|
53
|
+
...props,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
return applyMessage(graph, createMsg(nodeOp, 0));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('Array Operations', () => {
|
|
60
|
+
let arrayOps: OpFixture[];
|
|
61
|
+
let graph: SceneGraph;
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
arrayOps = loadOps('array-ops.jsonl');
|
|
65
|
+
graph = createEmptyGraph();
|
|
66
|
+
graph = createNodeWithProps(graph, 'node-1', {
|
|
67
|
+
tags: ['initial'],
|
|
68
|
+
scores: [10, 20],
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('array.set should replace array', () => {
|
|
73
|
+
const op = arrayOps.find((f) => f.name === 'array_set')!.op;
|
|
74
|
+
const result = applyMessage(graph, createMsg(op));
|
|
75
|
+
expect(result.nodes['node-1'].tags).toEqual(['enemy', 'active']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('array.set with empty should clear', () => {
|
|
79
|
+
const op = arrayOps.find((f) => f.name === 'array_set_empty')!.op;
|
|
80
|
+
const result = applyMessage(graph, createMsg(op));
|
|
81
|
+
expect(result.nodes['node-1'].tags).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('array.push should append element', () => {
|
|
85
|
+
const op = arrayOps.find((f) => f.name === 'array_push')!.op;
|
|
86
|
+
const result = applyMessage(graph, createMsg(op));
|
|
87
|
+
expect(result.nodes['node-1'].tags).toEqual(['initial', 'new-tag']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('array.push should work with numbers', () => {
|
|
91
|
+
const op = arrayOps.find((f) => f.name === 'array_push_number')!.op;
|
|
92
|
+
const result = applyMessage(graph, createMsg(op));
|
|
93
|
+
expect(result.nodes['node-1'].scores).toEqual([10, 20, 100]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('array.union should add unique elements', () => {
|
|
97
|
+
const op = arrayOps.find((f) => f.name === 'array_union')!.op;
|
|
98
|
+
const result = applyMessage(graph, createMsg(op));
|
|
99
|
+
expect(result.nodes['node-1'].tags).toContain('initial');
|
|
100
|
+
expect(result.nodes['node-1'].tags).toContain('tag-a');
|
|
101
|
+
expect(result.nodes['node-1'].tags).toContain('tag-b');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('array.remove should remove element', () => {
|
|
105
|
+
graph = createNodeWithProps(createEmptyGraph(), 'node-1', { tags: ['friend', 'enemy', 'active'] });
|
|
106
|
+
const op = arrayOps.find((f) => f.name === 'array_remove')!.op;
|
|
107
|
+
const result = applyMessage(graph, createMsg(op));
|
|
108
|
+
expect(result.nodes['node-1'].tags).toEqual(['friend', 'active']);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('array.remove on missing element should be no-op', () => {
|
|
112
|
+
const op = arrayOps.find((f) => f.name === 'array_remove')!.op;
|
|
113
|
+
const result = applyMessage(graph, createMsg(op));
|
|
114
|
+
expect(result.nodes['node-1'].tags).toEqual(['initial']);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('Object Operations', () => {
|
|
119
|
+
let objectOps: OpFixture[];
|
|
120
|
+
let graph: SceneGraph;
|
|
121
|
+
|
|
122
|
+
beforeEach(() => {
|
|
123
|
+
objectOps = loadOps('object-ops.jsonl');
|
|
124
|
+
graph = createEmptyGraph();
|
|
125
|
+
graph = createNodeWithProps(graph, 'node-1', {
|
|
126
|
+
metadata: { existing: 'value' },
|
|
127
|
+
config: { debug: true },
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('object.set should replace object', () => {
|
|
132
|
+
const op = objectOps.find((f) => f.name === 'object_set')!.op;
|
|
133
|
+
const result = applyMessage(graph, createMsg(op));
|
|
134
|
+
expect(result.nodes['node-1'].metadata).toEqual({ author: 'user-1', version: 1 });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('object.set with empty should clear', () => {
|
|
138
|
+
const op = objectOps.find((f) => f.name === 'object_set_empty')!.op;
|
|
139
|
+
const result = applyMessage(graph, createMsg(op));
|
|
140
|
+
expect(result.nodes['node-1'].metadata).toEqual({});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('object.merge should merge properties', () => {
|
|
144
|
+
const op = objectOps.find((f) => f.name === 'object_merge')!.op;
|
|
145
|
+
const result = applyMessage(graph, createMsg(op));
|
|
146
|
+
expect(result.nodes['node-1'].metadata).toEqual({
|
|
147
|
+
existing: 'value',
|
|
148
|
+
tags: ['a', 'b'],
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('object.merge with nested should add nested', () => {
|
|
153
|
+
const op = objectOps.find((f) => f.name === 'object_merge_nested')!.op;
|
|
154
|
+
const result = applyMessage(graph, createMsg(op));
|
|
155
|
+
expect(result.nodes['node-1'].config).toEqual({
|
|
156
|
+
debug: true,
|
|
157
|
+
settings: { enabled: true },
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('Color Operations', () => {
|
|
163
|
+
let colorOps: OpFixture[];
|
|
164
|
+
let graph: SceneGraph;
|
|
165
|
+
|
|
166
|
+
beforeEach(() => {
|
|
167
|
+
colorOps = loadOps('color-ops.jsonl');
|
|
168
|
+
graph = createEmptyGraph();
|
|
169
|
+
graph = createNodeWithProps(graph, 'node-1', {
|
|
170
|
+
color: '#000000',
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('color.set should replace color', () => {
|
|
175
|
+
const op = colorOps.find((f) => f.name === 'color_set_red')!.op;
|
|
176
|
+
const result = applyMessage(graph, createMsg(op));
|
|
177
|
+
expect(result.nodes['node-1'].color).toBe('#ff0000');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('color.set with different colors should work', () => {
|
|
181
|
+
const op = colorOps.find((f) => f.name === 'color_set_rgb')!.op;
|
|
182
|
+
const result = applyMessage(graph, createMsg(op));
|
|
183
|
+
expect(result.nodes['node-1'].color).toBe('#336699');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('color.blend should blend colors', () => {
|
|
187
|
+
graph = createNodeWithProps(createEmptyGraph(), 'node-1', { color: '#000000' });
|
|
188
|
+
const op = colorOps.find((f) => f.name === 'color_blend')!.op;
|
|
189
|
+
const result = applyMessage(graph, createMsg(op));
|
|
190
|
+
// Color blend result depends on implementation
|
|
191
|
+
expect(result.nodes['node-1'].color).toBeDefined();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for node operations: node.insert, node.remove
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach } from '@jest/globals';
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
|
+
import { join, dirname } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { createEmptyGraph, applyMessage } from '../../src/index.js';
|
|
10
|
+
import type { SceneGraph, CRDTMessage, Operation } from '../../src/operations/OperationTypes.js';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
interface OpFixture {
|
|
15
|
+
name: string;
|
|
16
|
+
op: Operation;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function loadOps(filename: string): OpFixture[] {
|
|
20
|
+
const filepath = join(__dirname, '../fixtures', filename);
|
|
21
|
+
const content = readFileSync(filepath, 'utf-8');
|
|
22
|
+
return content
|
|
23
|
+
.trim()
|
|
24
|
+
.split('\n')
|
|
25
|
+
.map((line) => JSON.parse(line));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createMsg(op: Operation, lamport = 1): CRDTMessage {
|
|
29
|
+
return {
|
|
30
|
+
id: `msg-${Math.random().toString(36).slice(2)}`,
|
|
31
|
+
sessionId: 'test-session',
|
|
32
|
+
clock: { 'test-session': lamport },
|
|
33
|
+
lamportTime: lamport,
|
|
34
|
+
timestamp: Date.now(),
|
|
35
|
+
ops: [op],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('Node Operations', () => {
|
|
40
|
+
let nodeOps: OpFixture[];
|
|
41
|
+
let graph: SceneGraph;
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
nodeOps = loadOps('node-ops.jsonl');
|
|
45
|
+
graph = createEmptyGraph();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('node.insert', () => {
|
|
49
|
+
it('should insert root node', () => {
|
|
50
|
+
const op = nodeOps.find((f) => f.name === 'node_insert_root')!.op;
|
|
51
|
+
const result = applyMessage(graph, createMsg(op));
|
|
52
|
+
|
|
53
|
+
expect(result.rootKey).toBe('scene');
|
|
54
|
+
expect(result.nodes['scene']).toBeDefined();
|
|
55
|
+
expect(result.nodes['scene'].tag).toBe('Scene');
|
|
56
|
+
expect(result.nodes['scene'].name).toBe('Root Scene');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should insert child node under parent', () => {
|
|
60
|
+
// First insert root
|
|
61
|
+
const rootOp = nodeOps.find((f) => f.name === 'node_insert_root')!.op;
|
|
62
|
+
graph = applyMessage(graph, createMsg(rootOp, 1));
|
|
63
|
+
|
|
64
|
+
// Then insert mesh
|
|
65
|
+
const meshOp = nodeOps.find((f) => f.name === 'node_insert_mesh')!.op;
|
|
66
|
+
const result = applyMessage(graph, createMsg(meshOp, 2));
|
|
67
|
+
|
|
68
|
+
expect(result.nodes['cube-1']).toBeDefined();
|
|
69
|
+
expect(result.nodes['cube-1'].tag).toBe('Mesh');
|
|
70
|
+
expect(result.nodes['scene'].children).toContain('cube-1');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should insert group node', () => {
|
|
74
|
+
const rootOp = nodeOps.find((f) => f.name === 'node_insert_root')!.op;
|
|
75
|
+
graph = applyMessage(graph, createMsg(rootOp, 1));
|
|
76
|
+
|
|
77
|
+
const groupOp = nodeOps.find((f) => f.name === 'node_insert_group')!.op;
|
|
78
|
+
const result = applyMessage(graph, createMsg(groupOp, 2));
|
|
79
|
+
|
|
80
|
+
expect(result.nodes['group-1']).toBeDefined();
|
|
81
|
+
expect(result.nodes['group-1'].tag).toBe('Group');
|
|
82
|
+
expect(result.nodes['scene'].children).toContain('group-1');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should insert nested node under group', () => {
|
|
86
|
+
// Build hierarchy: scene -> group-1 -> sphere-1
|
|
87
|
+
const rootOp = nodeOps.find((f) => f.name === 'node_insert_root')!.op;
|
|
88
|
+
graph = applyMessage(graph, createMsg(rootOp, 1));
|
|
89
|
+
|
|
90
|
+
const groupOp = nodeOps.find((f) => f.name === 'node_insert_group')!.op;
|
|
91
|
+
graph = applyMessage(graph, createMsg(groupOp, 2));
|
|
92
|
+
|
|
93
|
+
const nestedOp = nodeOps.find((f) => f.name === 'node_insert_nested')!.op;
|
|
94
|
+
const result = applyMessage(graph, createMsg(nestedOp, 3));
|
|
95
|
+
|
|
96
|
+
expect(result.nodes['sphere-1']).toBeDefined();
|
|
97
|
+
expect(result.nodes['group-1'].children).toContain('sphere-1');
|
|
98
|
+
expect(result.nodes['scene'].children).not.toContain('sphere-1');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should preserve initial properties', () => {
|
|
102
|
+
const rootOp = nodeOps.find((f) => f.name === 'node_insert_root')!.op;
|
|
103
|
+
graph = applyMessage(graph, createMsg(rootOp, 1));
|
|
104
|
+
|
|
105
|
+
const meshOp = nodeOps.find((f) => f.name === 'node_insert_mesh')!.op;
|
|
106
|
+
const result = applyMessage(graph, createMsg(meshOp, 2));
|
|
107
|
+
|
|
108
|
+
expect(result.nodes['cube-1'].position).toEqual([0, 0, 0]);
|
|
109
|
+
expect(result.nodes['cube-1'].scale).toEqual([1, 1, 1]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should be idempotent (inserting same key twice is no-op)', () => {
|
|
113
|
+
const rootOp = nodeOps.find((f) => f.name === 'node_insert_root')!.op;
|
|
114
|
+
graph = applyMessage(graph, createMsg(rootOp, 1));
|
|
115
|
+
|
|
116
|
+
const meshOp = nodeOps.find((f) => f.name === 'node_insert_mesh')!.op;
|
|
117
|
+
graph = applyMessage(graph, createMsg(meshOp, 2));
|
|
118
|
+
|
|
119
|
+
// Insert same mesh again with different name
|
|
120
|
+
const duplicateOp: Operation = {
|
|
121
|
+
key: 'scene',
|
|
122
|
+
otype: 'node.insert',
|
|
123
|
+
path: 'children',
|
|
124
|
+
value: {
|
|
125
|
+
key: 'cube-1',
|
|
126
|
+
id: 'uuid-cube-1-dup',
|
|
127
|
+
tag: 'Mesh',
|
|
128
|
+
name: 'Cube Duplicate',
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
const result = applyMessage(graph, createMsg(duplicateOp, 3));
|
|
132
|
+
|
|
133
|
+
// Should not change
|
|
134
|
+
expect(result.nodes['cube-1'].name).toBe('Cube');
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('node.remove', () => {
|
|
139
|
+
it('should remove node from parent', () => {
|
|
140
|
+
// Setup: scene -> cube-1
|
|
141
|
+
const rootOp = nodeOps.find((f) => f.name === 'node_insert_root')!.op;
|
|
142
|
+
graph = applyMessage(graph, createMsg(rootOp, 1));
|
|
143
|
+
|
|
144
|
+
const meshOp = nodeOps.find((f) => f.name === 'node_insert_mesh')!.op;
|
|
145
|
+
graph = applyMessage(graph, createMsg(meshOp, 2));
|
|
146
|
+
|
|
147
|
+
expect(graph.nodes['scene'].children).toContain('cube-1');
|
|
148
|
+
|
|
149
|
+
// Remove
|
|
150
|
+
const removeOp = nodeOps.find((f) => f.name === 'node_remove')!.op;
|
|
151
|
+
const result = applyMessage(graph, createMsg(removeOp, 3));
|
|
152
|
+
|
|
153
|
+
expect(result.nodes['scene'].children).not.toContain('cube-1');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should tombstone node (soft delete)', () => {
|
|
157
|
+
const rootOp = nodeOps.find((f) => f.name === 'node_insert_root')!.op;
|
|
158
|
+
graph = applyMessage(graph, createMsg(rootOp, 1));
|
|
159
|
+
|
|
160
|
+
const meshOp = nodeOps.find((f) => f.name === 'node_insert_mesh')!.op;
|
|
161
|
+
graph = applyMessage(graph, createMsg(meshOp, 2));
|
|
162
|
+
|
|
163
|
+
const removeOp = nodeOps.find((f) => f.name === 'node_remove')!.op;
|
|
164
|
+
const result = applyMessage(graph, createMsg(removeOp, 3));
|
|
165
|
+
|
|
166
|
+
// Node still exists but has deletedAt
|
|
167
|
+
expect(result.nodes['cube-1']).toBeDefined();
|
|
168
|
+
expect(result.nodes['cube-1'].deletedAt).toBeDefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('should remove nested node from group', () => {
|
|
172
|
+
// Build: scene -> group-1 -> sphere-1
|
|
173
|
+
const rootOp = nodeOps.find((f) => f.name === 'node_insert_root')!.op;
|
|
174
|
+
graph = applyMessage(graph, createMsg(rootOp, 1));
|
|
175
|
+
|
|
176
|
+
const groupOp = nodeOps.find((f) => f.name === 'node_insert_group')!.op;
|
|
177
|
+
graph = applyMessage(graph, createMsg(groupOp, 2));
|
|
178
|
+
|
|
179
|
+
const nestedOp = nodeOps.find((f) => f.name === 'node_insert_nested')!.op;
|
|
180
|
+
graph = applyMessage(graph, createMsg(nestedOp, 3));
|
|
181
|
+
|
|
182
|
+
// Remove nested
|
|
183
|
+
const removeNestedOp = nodeOps.find((f) => f.name === 'node_remove_nested')!.op;
|
|
184
|
+
const result = applyMessage(graph, createMsg(removeNestedOp, 4));
|
|
185
|
+
|
|
186
|
+
expect(result.nodes['group-1'].children).not.toContain('sphere-1');
|
|
187
|
+
expect(result.nodes['sphere-1'].deletedAt).toBeDefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should be no-op if node does not exist', () => {
|
|
191
|
+
const rootOp = nodeOps.find((f) => f.name === 'node_insert_root')!.op;
|
|
192
|
+
graph = applyMessage(graph, createMsg(rootOp, 1));
|
|
193
|
+
|
|
194
|
+
const removeOp: Operation = {
|
|
195
|
+
key: 'scene',
|
|
196
|
+
otype: 'node.remove',
|
|
197
|
+
path: 'children',
|
|
198
|
+
value: 'non-existent',
|
|
199
|
+
};
|
|
200
|
+
const result = applyMessage(graph, createMsg(removeOp, 2));
|
|
201
|
+
|
|
202
|
+
// Should not throw, just no-op
|
|
203
|
+
expect(result.nodes['scene'].children).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('complex hierarchies', () => {
|
|
208
|
+
it('should build multi-level hierarchy', () => {
|
|
209
|
+
// scene -> group-1 -> [sphere-1], cube-1
|
|
210
|
+
const rootOp = nodeOps.find((f) => f.name === 'node_insert_root')!.op;
|
|
211
|
+
graph = applyMessage(graph, createMsg(rootOp, 1));
|
|
212
|
+
|
|
213
|
+
const groupOp = nodeOps.find((f) => f.name === 'node_insert_group')!.op;
|
|
214
|
+
graph = applyMessage(graph, createMsg(groupOp, 2));
|
|
215
|
+
|
|
216
|
+
const meshOp = nodeOps.find((f) => f.name === 'node_insert_mesh')!.op;
|
|
217
|
+
graph = applyMessage(graph, createMsg(meshOp, 3));
|
|
218
|
+
|
|
219
|
+
const nestedOp = nodeOps.find((f) => f.name === 'node_insert_nested')!.op;
|
|
220
|
+
graph = applyMessage(graph, createMsg(nestedOp, 4));
|
|
221
|
+
|
|
222
|
+
expect(graph.nodes['scene'].children).toContain('group-1');
|
|
223
|
+
expect(graph.nodes['scene'].children).toContain('cube-1');
|
|
224
|
+
expect(graph.nodes['group-1'].children).toContain('sphere-1');
|
|
225
|
+
expect(Object.keys(graph.nodes)).toHaveLength(4);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for primitive operation types: number, string, boolean
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach } from '@jest/globals';
|
|
6
|
+
import { readFileSync } from 'fs';
|
|
7
|
+
import { join, dirname } from 'path';
|
|
8
|
+
import { fileURLToPath } from 'url';
|
|
9
|
+
import { createEmptyGraph, applyMessage } from '../../src/index.js';
|
|
10
|
+
import type { SceneGraph, CRDTMessage, Operation } from '../../src/operations/OperationTypes.js';
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
|
|
14
|
+
interface OpFixture {
|
|
15
|
+
name: string;
|
|
16
|
+
op: Operation;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function loadOps(filename: string): OpFixture[] {
|
|
20
|
+
const filepath = join(__dirname, '../fixtures', filename);
|
|
21
|
+
const content = readFileSync(filepath, 'utf-8');
|
|
22
|
+
return content
|
|
23
|
+
.trim()
|
|
24
|
+
.split('\n')
|
|
25
|
+
.map((line) => JSON.parse(line));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createMsg(op: Operation, lamport = 1): CRDTMessage {
|
|
29
|
+
return {
|
|
30
|
+
id: `msg-${Math.random().toString(36).slice(2)}`,
|
|
31
|
+
sessionId: 'test-session',
|
|
32
|
+
clock: { 'test-session': lamport },
|
|
33
|
+
lamportTime: lamport,
|
|
34
|
+
timestamp: Date.now(),
|
|
35
|
+
ops: [op],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function createNodeWithProps(
|
|
40
|
+
graph: SceneGraph,
|
|
41
|
+
key: string,
|
|
42
|
+
props: Record<string, unknown>
|
|
43
|
+
): SceneGraph {
|
|
44
|
+
const nodeOp: Operation = {
|
|
45
|
+
key: '',
|
|
46
|
+
otype: 'node.insert',
|
|
47
|
+
path: 'children',
|
|
48
|
+
value: {
|
|
49
|
+
key,
|
|
50
|
+
id: `uuid-${key}`,
|
|
51
|
+
tag: 'Mesh',
|
|
52
|
+
name: key,
|
|
53
|
+
...props,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
return applyMessage(graph, createMsg(nodeOp, 0));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('Number Operations', () => {
|
|
60
|
+
let numberOps: OpFixture[];
|
|
61
|
+
let graph: SceneGraph;
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
numberOps = loadOps('number-ops.jsonl');
|
|
65
|
+
graph = createEmptyGraph();
|
|
66
|
+
graph = createNodeWithProps(graph, 'node-1', {
|
|
67
|
+
opacity: 1,
|
|
68
|
+
score: 0,
|
|
69
|
+
scale: 1,
|
|
70
|
+
health: 100,
|
|
71
|
+
damage: 10,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('number.set should replace value', () => {
|
|
76
|
+
const op = numberOps.find((f) => f.name === 'number_set')!.op;
|
|
77
|
+
const result = applyMessage(graph, createMsg(op));
|
|
78
|
+
expect(result.nodes['node-1'].opacity).toBe(0.5);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('number.add should add to existing value', () => {
|
|
82
|
+
const op = numberOps.find((f) => f.name === 'number_add')!.op;
|
|
83
|
+
const result = applyMessage(graph, createMsg(op));
|
|
84
|
+
expect(result.nodes['node-1'].score).toBe(10);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('number.add with negative should subtract', () => {
|
|
88
|
+
graph = createNodeWithProps(createEmptyGraph(), 'node-1', { score: 100 });
|
|
89
|
+
const op = numberOps.find((f) => f.name === 'number_add_negative')!.op;
|
|
90
|
+
const result = applyMessage(graph, createMsg(op));
|
|
91
|
+
expect(result.nodes['node-1'].score).toBe(95);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('number.multiply should multiply value', () => {
|
|
95
|
+
const op = numberOps.find((f) => f.name === 'number_multiply')!.op;
|
|
96
|
+
const result = applyMessage(graph, createMsg(op));
|
|
97
|
+
expect(result.nodes['node-1'].scale).toBe(2);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('number.multiply with fraction should scale down', () => {
|
|
101
|
+
graph = createNodeWithProps(createEmptyGraph(), 'node-1', { scale: 4 });
|
|
102
|
+
const op = numberOps.find((f) => f.name === 'number_multiply_fraction')!.op;
|
|
103
|
+
const result = applyMessage(graph, createMsg(op));
|
|
104
|
+
expect(result.nodes['node-1'].scale).toBe(2);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('number.min should take minimum', () => {
|
|
108
|
+
const op = numberOps.find((f) => f.name === 'number_min')!.op;
|
|
109
|
+
const result = applyMessage(graph, createMsg(op));
|
|
110
|
+
expect(result.nodes['node-1'].health).toBe(50);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('number.min should not change if current is lower', () => {
|
|
114
|
+
graph = createNodeWithProps(createEmptyGraph(), 'node-1', { health: 25 });
|
|
115
|
+
const op = numberOps.find((f) => f.name === 'number_min')!.op;
|
|
116
|
+
const result = applyMessage(graph, createMsg(op));
|
|
117
|
+
expect(result.nodes['node-1'].health).toBe(25);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('number.max should take maximum', () => {
|
|
121
|
+
const op = numberOps.find((f) => f.name === 'number_max')!.op;
|
|
122
|
+
const result = applyMessage(graph, createMsg(op));
|
|
123
|
+
expect(result.nodes['node-1'].damage).toBe(25);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('number.max should not change if current is higher', () => {
|
|
127
|
+
graph = createNodeWithProps(createEmptyGraph(), 'node-1', { damage: 50 });
|
|
128
|
+
const op = numberOps.find((f) => f.name === 'number_max')!.op;
|
|
129
|
+
const result = applyMessage(graph, createMsg(op));
|
|
130
|
+
expect(result.nodes['node-1'].damage).toBe(50);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('String Operations', () => {
|
|
135
|
+
let stringOps: OpFixture[];
|
|
136
|
+
let graph: SceneGraph;
|
|
137
|
+
|
|
138
|
+
beforeEach(() => {
|
|
139
|
+
stringOps = loadOps('string-ops.jsonl');
|
|
140
|
+
graph = createEmptyGraph();
|
|
141
|
+
graph = createNodeWithProps(graph, 'node-1', {
|
|
142
|
+
name: 'default',
|
|
143
|
+
tags: 'initial',
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('string.set should replace value', () => {
|
|
148
|
+
const op = stringOps.find((f) => f.name === 'string_set')!.op;
|
|
149
|
+
const result = applyMessage(graph, createMsg(op));
|
|
150
|
+
expect(result.nodes['node-1'].name).toBe('Player');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('string.set with empty string should clear', () => {
|
|
154
|
+
const op = stringOps.find((f) => f.name === 'string_set_empty')!.op;
|
|
155
|
+
const result = applyMessage(graph, createMsg(op));
|
|
156
|
+
expect(result.nodes['node-1'].name).toBe('');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('string.concat should append', () => {
|
|
160
|
+
const op = stringOps.find((f) => f.name === 'string_concat')!.op;
|
|
161
|
+
const result = applyMessage(graph, createMsg(op));
|
|
162
|
+
expect(result.nodes['node-1'].tags).toBe('initialactive');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('string.concat with separator should use separator', () => {
|
|
166
|
+
const op = stringOps.find((f) => f.name === 'string_concat_separator')!.op;
|
|
167
|
+
const result = applyMessage(graph, createMsg(op));
|
|
168
|
+
expect(result.nodes['node-1'].tags).toBe('initial,enemy');
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('Boolean Operations', () => {
|
|
173
|
+
let boolOps: OpFixture[];
|
|
174
|
+
let graph: SceneGraph;
|
|
175
|
+
|
|
176
|
+
beforeEach(() => {
|
|
177
|
+
boolOps = loadOps('boolean-ops.jsonl');
|
|
178
|
+
graph = createEmptyGraph();
|
|
179
|
+
graph = createNodeWithProps(graph, 'node-1', {
|
|
180
|
+
visible: true,
|
|
181
|
+
enabled: false,
|
|
182
|
+
active: true,
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('boolean.set true should set to true', () => {
|
|
187
|
+
graph = createNodeWithProps(createEmptyGraph(), 'node-1', { visible: false });
|
|
188
|
+
const op = boolOps.find((f) => f.name === 'boolean_set_true')!.op;
|
|
189
|
+
const result = applyMessage(graph, createMsg(op));
|
|
190
|
+
expect(result.nodes['node-1'].visible).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('boolean.set false should set to false', () => {
|
|
194
|
+
const op = boolOps.find((f) => f.name === 'boolean_set_false')!.op;
|
|
195
|
+
const result = applyMessage(graph, createMsg(op));
|
|
196
|
+
expect(result.nodes['node-1'].visible).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('boolean.or with true should become true', () => {
|
|
200
|
+
const op = boolOps.find((f) => f.name === 'boolean_or_true')!.op;
|
|
201
|
+
const result = applyMessage(graph, createMsg(op));
|
|
202
|
+
expect(result.nodes['node-1'].enabled).toBe(true);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('boolean.or with false on false should stay false', () => {
|
|
206
|
+
const op = boolOps.find((f) => f.name === 'boolean_or_false')!.op;
|
|
207
|
+
const result = applyMessage(graph, createMsg(op));
|
|
208
|
+
expect(result.nodes['node-1'].enabled).toBe(false);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('boolean.and with true on true should stay true', () => {
|
|
212
|
+
const op = boolOps.find((f) => f.name === 'boolean_and_true')!.op;
|
|
213
|
+
const result = applyMessage(graph, createMsg(op));
|
|
214
|
+
expect(result.nodes['node-1'].active).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('boolean.and with false should become false', () => {
|
|
218
|
+
const op = boolOps.find((f) => f.name === 'boolean_and_false')!.op;
|
|
219
|
+
const result = applyMessage(graph, createMsg(op));
|
|
220
|
+
expect(result.nodes['node-1'].active).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
});
|