@vuer-ai/vuer-rtc-server 0.1.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/.env +1 -0
- package/PHASE1_SUMMARY.md +94 -0
- package/README.md +423 -0
- package/dist/broker/InMemoryBroker.d.ts +24 -0
- package/dist/broker/InMemoryBroker.d.ts.map +1 -0
- package/dist/broker/InMemoryBroker.js +65 -0
- package/dist/broker/InMemoryBroker.js.map +1 -0
- package/dist/broker/index.d.ts +3 -0
- package/dist/broker/index.d.ts.map +1 -0
- package/dist/broker/index.js +2 -0
- package/dist/broker/index.js.map +1 -0
- package/dist/broker/types.d.ts +47 -0
- package/dist/broker/types.d.ts.map +1 -0
- package/dist/broker/types.js +9 -0
- package/dist/broker/types.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/journal/JournalRepository.d.ts +39 -0
- package/dist/journal/JournalRepository.d.ts.map +1 -0
- package/dist/journal/JournalRepository.js +102 -0
- package/dist/journal/JournalRepository.js.map +1 -0
- package/dist/journal/JournalService.d.ts +69 -0
- package/dist/journal/JournalService.d.ts.map +1 -0
- package/dist/journal/JournalService.js +224 -0
- package/dist/journal/JournalService.js.map +1 -0
- package/dist/journal/index.d.ts +6 -0
- package/dist/journal/index.d.ts.map +1 -0
- package/dist/journal/index.js +6 -0
- package/dist/journal/index.js.map +1 -0
- package/dist/persistence/DocumentRepository.d.ts +22 -0
- package/dist/persistence/DocumentRepository.d.ts.map +1 -0
- package/dist/persistence/DocumentRepository.js +66 -0
- package/dist/persistence/DocumentRepository.js.map +1 -0
- package/dist/persistence/PrismaClient.d.ts +8 -0
- package/dist/persistence/PrismaClient.d.ts.map +1 -0
- package/dist/persistence/PrismaClient.js +21 -0
- package/dist/persistence/PrismaClient.js.map +1 -0
- package/dist/persistence/SessionRepository.d.ts +22 -0
- package/dist/persistence/SessionRepository.d.ts.map +1 -0
- package/dist/persistence/SessionRepository.js +103 -0
- package/dist/persistence/SessionRepository.js.map +1 -0
- package/dist/persistence/index.d.ts +7 -0
- package/dist/persistence/index.d.ts.map +1 -0
- package/dist/persistence/index.js +7 -0
- package/dist/persistence/index.js.map +1 -0
- package/dist/serve.d.ts +18 -0
- package/dist/serve.d.ts.map +1 -0
- package/dist/serve.js +211 -0
- package/dist/serve.js.map +1 -0
- package/dist/transport/RTCServer.d.ts +92 -0
- package/dist/transport/RTCServer.d.ts.map +1 -0
- package/dist/transport/RTCServer.js +273 -0
- package/dist/transport/RTCServer.js.map +1 -0
- package/dist/transport/index.d.ts +2 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +2 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +2 -0
- package/dist/version.js.map +1 -0
- package/jest.config.js +36 -0
- package/package.json +56 -0
- package/prisma/schema.prisma +121 -0
- package/src/broker/InMemoryBroker.ts +81 -0
- package/src/broker/index.ts +2 -0
- package/src/broker/types.ts +60 -0
- package/src/index.ts +23 -0
- package/src/journal/JournalRepository.ts +119 -0
- package/src/journal/JournalService.ts +291 -0
- package/src/journal/index.ts +10 -0
- package/src/persistence/DocumentRepository.ts +76 -0
- package/src/persistence/PrismaClient.ts +24 -0
- package/src/persistence/SessionRepository.ts +114 -0
- package/src/persistence/index.ts +7 -0
- package/src/serve.ts +240 -0
- package/src/transport/RTCServer.ts +327 -0
- package/src/transport/index.ts +1 -0
- package/src/version.ts +1 -0
- package/tests/README.md +112 -0
- package/tests/demo.ts +555 -0
- package/tests/e2e/convergence.test.ts +221 -0
- package/tests/e2e/helpers/assertions.ts +158 -0
- package/tests/e2e/helpers/createTestServer.ts +220 -0
- package/tests/e2e/latency.test.ts +512 -0
- package/tests/e2e/packet-loss.test.ts +229 -0
- package/tests/e2e/relay.test.ts +255 -0
- package/tests/e2e/sync-perf.test.ts +365 -0
- package/tests/e2e/sync-reconciliation.test.ts +237 -0
- package/tests/e2e/text-sync.test.ts +199 -0
- package/tests/e2e/tombstone-convergence.test.ts +356 -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/messages.jsonl +4 -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/integration/repositories.test.ts +320 -0
- package/tests/journal/journal-service.test.ts +185 -0
- package/tests/test-data/datatypes.ts +677 -0
- package/tests/test-data/operations-example.ts +306 -0
- package/tests/test-data/scene-example.ts +247 -0
- package/tests/unit/operations.test.ts +310 -0
- package/tests/unit/vectorClock.test.ts +281 -0
- package/tsconfig.json +19 -0
- package/tsconfig.test.json +8 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
|
|
2
|
+
import type { PrismaClient } from '@prisma/client';
|
|
3
|
+
|
|
4
|
+
// Repository interfaces we'll implement
|
|
5
|
+
interface IDocumentRepository {
|
|
6
|
+
create(data: { name: string; ownerId: string }): Promise<any>;
|
|
7
|
+
findById(id: string): Promise<any | null>;
|
|
8
|
+
update(id: string, data: any): Promise<any>;
|
|
9
|
+
delete(id: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ISessionRepository {
|
|
13
|
+
create(data: {
|
|
14
|
+
documentId: string;
|
|
15
|
+
userId: string;
|
|
16
|
+
clientId: string;
|
|
17
|
+
}): Promise<any>;
|
|
18
|
+
findById(id: string): Promise<any | null>;
|
|
19
|
+
findByDocument(documentId: string): Promise<any[]>;
|
|
20
|
+
updatePresence(id: string, presence: any): Promise<any>;
|
|
21
|
+
disconnect(id: string): Promise<any>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Try to import - will fail until implemented
|
|
25
|
+
let createPrismaClient: () => PrismaClient;
|
|
26
|
+
let DocumentRepository: new (prisma: PrismaClient) => IDocumentRepository;
|
|
27
|
+
let SessionRepository: new (prisma: PrismaClient) => ISessionRepository;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const prismaModule = await import('../../src/persistence/PrismaClient.js');
|
|
31
|
+
const docModule = await import('../../src/persistence/DocumentRepository.js');
|
|
32
|
+
const sesModule = await import('../../src/persistence/SessionRepository.js');
|
|
33
|
+
|
|
34
|
+
createPrismaClient = prismaModule.createPrismaClient;
|
|
35
|
+
DocumentRepository = docModule.DocumentRepository;
|
|
36
|
+
SessionRepository = sesModule.SessionRepository;
|
|
37
|
+
} catch {
|
|
38
|
+
// Not implemented yet
|
|
39
|
+
createPrismaClient = (() => {
|
|
40
|
+
throw new Error('Not implemented');
|
|
41
|
+
}) as any;
|
|
42
|
+
DocumentRepository = class {
|
|
43
|
+
create() {
|
|
44
|
+
throw new Error('Not implemented');
|
|
45
|
+
}
|
|
46
|
+
findById() {
|
|
47
|
+
throw new Error('Not implemented');
|
|
48
|
+
}
|
|
49
|
+
update() {
|
|
50
|
+
throw new Error('Not implemented');
|
|
51
|
+
}
|
|
52
|
+
delete() {
|
|
53
|
+
throw new Error('Not implemented');
|
|
54
|
+
}
|
|
55
|
+
} as any;
|
|
56
|
+
SessionRepository = class {
|
|
57
|
+
create() {
|
|
58
|
+
throw new Error('Not implemented');
|
|
59
|
+
}
|
|
60
|
+
findById() {
|
|
61
|
+
throw new Error('Not implemented');
|
|
62
|
+
}
|
|
63
|
+
findByDocument() {
|
|
64
|
+
throw new Error('Not implemented');
|
|
65
|
+
}
|
|
66
|
+
updatePresence() {
|
|
67
|
+
throw new Error('Not implemented');
|
|
68
|
+
}
|
|
69
|
+
disconnect() {
|
|
70
|
+
throw new Error('Not implemented');
|
|
71
|
+
}
|
|
72
|
+
} as any;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('Database Repositories', () => {
|
|
76
|
+
let prisma: PrismaClient;
|
|
77
|
+
let documentRepo: IDocumentRepository;
|
|
78
|
+
let sessionRepo: ISessionRepository;
|
|
79
|
+
|
|
80
|
+
beforeAll(() => {
|
|
81
|
+
prisma = createPrismaClient();
|
|
82
|
+
documentRepo = new DocumentRepository(prisma);
|
|
83
|
+
sessionRepo = new SessionRepository(prisma);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterAll(async () => {
|
|
87
|
+
await prisma.$disconnect();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('DocumentRepository', () => {
|
|
91
|
+
let testDocId: string;
|
|
92
|
+
|
|
93
|
+
beforeEach(async () => {
|
|
94
|
+
// Clean up test data - check existence first to avoid Prisma error logs
|
|
95
|
+
if (testDocId) {
|
|
96
|
+
const exists = await documentRepo.findById(testDocId);
|
|
97
|
+
if (exists) {
|
|
98
|
+
await documentRepo.delete(testDocId);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should create a new document', async () => {
|
|
104
|
+
const doc = await documentRepo.create({
|
|
105
|
+
name: 'Test Document',
|
|
106
|
+
ownerId: 'user-123',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(doc).toBeDefined();
|
|
110
|
+
expect(doc.id).toBeDefined();
|
|
111
|
+
expect(doc.name).toBe('Test Document');
|
|
112
|
+
expect(doc.ownerId).toBe('user-123');
|
|
113
|
+
expect(doc.version).toBe(0);
|
|
114
|
+
expect(doc.createdAt).toBeDefined();
|
|
115
|
+
expect(doc.updatedAt).toBeDefined();
|
|
116
|
+
|
|
117
|
+
testDocId = doc.id;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should find document by ID', async () => {
|
|
121
|
+
const created = await documentRepo.create({
|
|
122
|
+
name: 'Find Test',
|
|
123
|
+
ownerId: 'user-456',
|
|
124
|
+
});
|
|
125
|
+
testDocId = created.id;
|
|
126
|
+
|
|
127
|
+
const found = await documentRepo.findById(created.id);
|
|
128
|
+
|
|
129
|
+
expect(found).toBeDefined();
|
|
130
|
+
expect(found!.id).toBe(created.id);
|
|
131
|
+
expect(found!.name).toBe('Find Test');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should return null for non-existent document', async () => {
|
|
135
|
+
const found = await documentRepo.findById('507f1f77bcf86cd799439011');
|
|
136
|
+
|
|
137
|
+
expect(found).toBeNull();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should update document', async () => {
|
|
141
|
+
const created = await documentRepo.create({
|
|
142
|
+
name: 'Original Name',
|
|
143
|
+
ownerId: 'user-789',
|
|
144
|
+
});
|
|
145
|
+
testDocId = created.id;
|
|
146
|
+
|
|
147
|
+
const updated = await documentRepo.update(created.id, {
|
|
148
|
+
name: 'Updated Name',
|
|
149
|
+
version: 1,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(updated.name).toBe('Updated Name');
|
|
153
|
+
expect(updated.version).toBe(1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should delete document', async () => {
|
|
157
|
+
const created = await documentRepo.create({
|
|
158
|
+
name: 'To Delete',
|
|
159
|
+
ownerId: 'user-delete',
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await documentRepo.delete(created.id);
|
|
163
|
+
|
|
164
|
+
const found = await documentRepo.findById(created.id);
|
|
165
|
+
expect(found).toBeNull();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe('SessionRepository', () => {
|
|
170
|
+
let testDocId: string;
|
|
171
|
+
let testSessionId: string;
|
|
172
|
+
|
|
173
|
+
beforeAll(async () => {
|
|
174
|
+
// Create a document for session tests
|
|
175
|
+
const doc = await documentRepo.create({
|
|
176
|
+
name: 'Session Test Doc',
|
|
177
|
+
ownerId: 'user-session',
|
|
178
|
+
});
|
|
179
|
+
testDocId = doc.id;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
afterAll(async () => {
|
|
183
|
+
if (testDocId) {
|
|
184
|
+
await documentRepo.delete(testDocId);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
beforeEach(async () => {
|
|
189
|
+
// Clean up test sessions
|
|
190
|
+
try {
|
|
191
|
+
if (testSessionId) {
|
|
192
|
+
const session = await sessionRepo.findById(testSessionId);
|
|
193
|
+
if (session) {
|
|
194
|
+
await sessionRepo.disconnect(testSessionId);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
// Ignore errors
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should create a new session', async () => {
|
|
203
|
+
const session = await sessionRepo.create({
|
|
204
|
+
documentId: testDocId,
|
|
205
|
+
userId: 'user-123',
|
|
206
|
+
clientId: 'client-abc',
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(session).toBeDefined();
|
|
210
|
+
expect(session.id).toBeDefined();
|
|
211
|
+
expect(session.documentId).toBe(testDocId);
|
|
212
|
+
expect(session.userId).toBe('user-123');
|
|
213
|
+
expect(session.clientId).toBe('client-abc');
|
|
214
|
+
expect(session.clockValue).toBe(0);
|
|
215
|
+
expect(session.connected).toBe(true);
|
|
216
|
+
expect(session.connectedAt).toBeDefined();
|
|
217
|
+
|
|
218
|
+
testSessionId = session.id;
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should find session by ID', async () => {
|
|
222
|
+
const created = await sessionRepo.create({
|
|
223
|
+
documentId: testDocId,
|
|
224
|
+
userId: 'user-456',
|
|
225
|
+
clientId: 'client-def',
|
|
226
|
+
});
|
|
227
|
+
testSessionId = created.id;
|
|
228
|
+
|
|
229
|
+
const found = await sessionRepo.findById(created.id);
|
|
230
|
+
|
|
231
|
+
expect(found).toBeDefined();
|
|
232
|
+
expect(found!.id).toBe(created.id);
|
|
233
|
+
expect(found!.userId).toBe('user-456');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should find sessions by document', async () => {
|
|
237
|
+
const session1 = await sessionRepo.create({
|
|
238
|
+
documentId: testDocId,
|
|
239
|
+
userId: 'user-1',
|
|
240
|
+
clientId: 'client-1',
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const session2 = await sessionRepo.create({
|
|
244
|
+
documentId: testDocId,
|
|
245
|
+
userId: 'user-2',
|
|
246
|
+
clientId: 'client-2',
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const sessions = await sessionRepo.findByDocument(testDocId);
|
|
250
|
+
|
|
251
|
+
expect(sessions.length).toBeGreaterThanOrEqual(2);
|
|
252
|
+
expect(sessions.some((s) => s.id === session1.id)).toBe(true);
|
|
253
|
+
expect(sessions.some((s) => s.id === session2.id)).toBe(true);
|
|
254
|
+
|
|
255
|
+
// Cleanup
|
|
256
|
+
await sessionRepo.disconnect(session1.id);
|
|
257
|
+
await sessionRepo.disconnect(session2.id);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('should update session presence', async () => {
|
|
261
|
+
const session = await sessionRepo.create({
|
|
262
|
+
documentId: testDocId,
|
|
263
|
+
userId: 'user-presence',
|
|
264
|
+
clientId: 'client-presence',
|
|
265
|
+
});
|
|
266
|
+
testSessionId = session.id;
|
|
267
|
+
|
|
268
|
+
const presence = {
|
|
269
|
+
cursor: { x: 10, y: 20, z: 5 },
|
|
270
|
+
selection: ['obj-1', 'obj-2'],
|
|
271
|
+
color: '#00ff00',
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const updated = await sessionRepo.updatePresence(session.id, presence);
|
|
275
|
+
|
|
276
|
+
expect(updated.presence).toBeDefined();
|
|
277
|
+
expect(updated.presence.cursor).toEqual({ x: 10, y: 20, z: 5 });
|
|
278
|
+
expect(updated.presence.selection).toEqual(['obj-1', 'obj-2']);
|
|
279
|
+
expect(updated.presence.color).toBe('#00ff00');
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('should disconnect session', async () => {
|
|
283
|
+
const session = await sessionRepo.create({
|
|
284
|
+
documentId: testDocId,
|
|
285
|
+
userId: 'user-disconnect',
|
|
286
|
+
clientId: 'client-disconnect',
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const disconnected = await sessionRepo.disconnect(session.id);
|
|
290
|
+
|
|
291
|
+
expect(disconnected.connected).toBe(false);
|
|
292
|
+
expect(disconnected.disconnectedAt).toBeDefined();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should filter connected sessions', async () => {
|
|
296
|
+
const session1 = await sessionRepo.create({
|
|
297
|
+
documentId: testDocId,
|
|
298
|
+
userId: 'user-active-1',
|
|
299
|
+
clientId: 'client-active-1',
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
const session2 = await sessionRepo.create({
|
|
303
|
+
documentId: testDocId,
|
|
304
|
+
userId: 'user-active-2',
|
|
305
|
+
clientId: 'client-active-2',
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
await sessionRepo.disconnect(session2.id);
|
|
309
|
+
|
|
310
|
+
const allSessions = await sessionRepo.findByDocument(testDocId);
|
|
311
|
+
const connectedSessions = allSessions.filter((s) => s.connected);
|
|
312
|
+
|
|
313
|
+
expect(connectedSessions.some((s) => s.id === session1.id)).toBe(true);
|
|
314
|
+
expect(connectedSessions.some((s) => s.id === session2.id)).toBe(false);
|
|
315
|
+
|
|
316
|
+
// Cleanup
|
|
317
|
+
await sessionRepo.disconnect(session1.id);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for JournalService
|
|
3
|
+
*
|
|
4
|
+
* Note: These are integration tests that require a MongoDB connection.
|
|
5
|
+
* For unit tests, we mock the repositories.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
|
9
|
+
import { readFileSync } from 'fs';
|
|
10
|
+
import { join, dirname } from 'path';
|
|
11
|
+
import { fileURLToPath } from 'url';
|
|
12
|
+
import type { CRDTMessage, SceneGraph } from '@vuer-ai/vuer-rtc';
|
|
13
|
+
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
interface MessageFixture {
|
|
17
|
+
name: string;
|
|
18
|
+
msg: CRDTMessage;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function loadFixtures(): MessageFixture[] {
|
|
22
|
+
const filepath = join(__dirname, '../fixtures/messages.jsonl');
|
|
23
|
+
const content = readFileSync(filepath, 'utf-8');
|
|
24
|
+
return content
|
|
25
|
+
.trim()
|
|
26
|
+
.split('\n')
|
|
27
|
+
.map((line) => JSON.parse(line));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('JournalService', () => {
|
|
31
|
+
let fixtures: MessageFixture[];
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
fixtures = loadFixtures();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe('Message Fixtures', () => {
|
|
38
|
+
it('should load fixtures correctly', () => {
|
|
39
|
+
expect(fixtures).toHaveLength(4);
|
|
40
|
+
expect(fixtures[0].name).toBe('simple_edit');
|
|
41
|
+
expect(fixtures[1].name).toBe('additive_edit');
|
|
42
|
+
expect(fixtures[2].name).toBe('undo_edit');
|
|
43
|
+
expect(fixtures[3].name).toBe('redo_edit');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should have valid CRDTMessage structure', () => {
|
|
47
|
+
for (const fixture of fixtures) {
|
|
48
|
+
const msg = fixture.msg;
|
|
49
|
+
expect(msg.id).toBeDefined();
|
|
50
|
+
expect(msg.sessionId).toBeDefined();
|
|
51
|
+
expect(msg.clock).toBeDefined();
|
|
52
|
+
expect(msg.lamportTime).toBeGreaterThanOrEqual(0);
|
|
53
|
+
expect(msg.timestamp).toBeGreaterThan(0);
|
|
54
|
+
expect(Array.isArray(msg.ops)).toBe(true);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should have valid meta.undo operation', () => {
|
|
59
|
+
const undoFixture = fixtures.find((f) => f.name === 'undo_edit');
|
|
60
|
+
expect(undoFixture).toBeDefined();
|
|
61
|
+
|
|
62
|
+
const undoOp = undoFixture!.msg.ops[0];
|
|
63
|
+
expect(undoOp.otype).toBe('meta.undo');
|
|
64
|
+
expect(undoOp.key).toBe('_meta');
|
|
65
|
+
expect((undoOp as any).targetMsgId).toBe('msg-1');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should have valid meta.redo operation', () => {
|
|
69
|
+
const redoFixture = fixtures.find((f) => f.name === 'redo_edit');
|
|
70
|
+
expect(redoFixture).toBeDefined();
|
|
71
|
+
|
|
72
|
+
const redoOp = redoFixture!.msg.ops[0];
|
|
73
|
+
expect(redoOp.otype).toBe('meta.redo');
|
|
74
|
+
expect(redoOp.key).toBe('_meta');
|
|
75
|
+
expect((redoOp as any).targetMsgId).toBe('msg-1');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('Message Validation', () => {
|
|
80
|
+
it('should validate simple edit message', async () => {
|
|
81
|
+
const { OperationValidator } = await import('@vuer-ai/vuer-rtc');
|
|
82
|
+
const validator = new OperationValidator();
|
|
83
|
+
|
|
84
|
+
const simpleEdit = fixtures.find((f) => f.name === 'simple_edit');
|
|
85
|
+
const result = validator.validateMessage(simpleEdit!.msg);
|
|
86
|
+
|
|
87
|
+
expect(result.valid).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should validate undo message', async () => {
|
|
91
|
+
const { OperationValidator } = await import('@vuer-ai/vuer-rtc');
|
|
92
|
+
const validator = new OperationValidator();
|
|
93
|
+
|
|
94
|
+
const undoEdit = fixtures.find((f) => f.name === 'undo_edit');
|
|
95
|
+
const result = validator.validateMessage(undoEdit!.msg);
|
|
96
|
+
|
|
97
|
+
expect(result.valid).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should validate redo message', async () => {
|
|
101
|
+
const { OperationValidator } = await import('@vuer-ai/vuer-rtc');
|
|
102
|
+
const validator = new OperationValidator();
|
|
103
|
+
|
|
104
|
+
const redoEdit = fixtures.find((f) => f.name === 'redo_edit');
|
|
105
|
+
const result = validator.validateMessage(redoEdit!.msg);
|
|
106
|
+
|
|
107
|
+
expect(result.valid).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('Graph Computation', () => {
|
|
112
|
+
it('should apply simple edit to empty graph', async () => {
|
|
113
|
+
const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
|
|
114
|
+
|
|
115
|
+
// First create a node (key=parent, path='children', value.key=new node)
|
|
116
|
+
const nodeMsg: CRDTMessage = {
|
|
117
|
+
id: 'node-msg',
|
|
118
|
+
sessionId: 'session-1',
|
|
119
|
+
clock: { 'session-1': 0 },
|
|
120
|
+
lamportTime: 0,
|
|
121
|
+
timestamp: Date.now() / 1000,
|
|
122
|
+
ops: [
|
|
123
|
+
{
|
|
124
|
+
key: '', // No parent for root node
|
|
125
|
+
otype: 'node.insert',
|
|
126
|
+
path: 'children',
|
|
127
|
+
value: {
|
|
128
|
+
key: 'cube-1',
|
|
129
|
+
tag: 'Mesh',
|
|
130
|
+
name: 'Cube',
|
|
131
|
+
position: [0, 0, 0],
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
let graph = createEmptyGraph();
|
|
138
|
+
graph = applyMessage(graph, nodeMsg);
|
|
139
|
+
|
|
140
|
+
expect(graph.nodes['cube-1']).toBeDefined();
|
|
141
|
+
expect(graph.nodes['cube-1'].position).toEqual([0, 0, 0]);
|
|
142
|
+
|
|
143
|
+
// Now apply the simple edit
|
|
144
|
+
const simpleEdit = fixtures.find((f) => f.name === 'simple_edit');
|
|
145
|
+
graph = applyMessage(graph, simpleEdit!.msg);
|
|
146
|
+
|
|
147
|
+
expect(graph.nodes['cube-1'].position).toEqual([1, 2, 3]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should apply additive edit', async () => {
|
|
151
|
+
const { createEmptyGraph, applyMessage } = await import('@vuer-ai/vuer-rtc');
|
|
152
|
+
|
|
153
|
+
// Create node (key=parent, path='children', value.key=new node)
|
|
154
|
+
const nodeMsg: CRDTMessage = {
|
|
155
|
+
id: 'node-msg',
|
|
156
|
+
sessionId: 'session-1',
|
|
157
|
+
clock: { 'session-1': 0 },
|
|
158
|
+
lamportTime: 0,
|
|
159
|
+
timestamp: Date.now() / 1000,
|
|
160
|
+
ops: [
|
|
161
|
+
{
|
|
162
|
+
key: '', // No parent for root node
|
|
163
|
+
otype: 'node.insert',
|
|
164
|
+
path: 'children',
|
|
165
|
+
value: {
|
|
166
|
+
key: 'cube-1',
|
|
167
|
+
tag: 'Mesh',
|
|
168
|
+
name: 'Cube',
|
|
169
|
+
position: [0, 0, 0],
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
let graph = createEmptyGraph();
|
|
176
|
+
graph = applyMessage(graph, nodeMsg);
|
|
177
|
+
|
|
178
|
+
// Apply additive edit
|
|
179
|
+
const additiveEdit = fixtures.find((f) => f.name === 'additive_edit');
|
|
180
|
+
graph = applyMessage(graph, additiveEdit!.msg);
|
|
181
|
+
|
|
182
|
+
expect(graph.nodes['cube-1'].position).toEqual([0.1, 0.2, 0.3]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|