@atom.io/template-preact-svg-editor 0.1.0 → 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/CHANGELOG.md +14 -0
- package/COLLABORATION.md +132 -0
- package/README.md +43 -10
- package/eslint.config.ts +1 -1
- package/package.json +30 -16
- package/src/BezierPlayground.tsx +179 -320
- package/src/preact-logo.ts +155 -0
- package/src/svg-contour.test.ts +89 -0
- package/src/svg-contour.ts +579 -0
- package/src/svg-convergence.test.ts +264 -0
- package/src/svg-convergence.ts +566 -0
- package/src/svg-domain-realtime.test.ts +369 -0
- package/src/svg-domain.test.ts +618 -0
- package/src/svg-domain.ts +939 -0
- package/src/svg-editor-state.test.ts +401 -0
- package/src/svg-editor-state.ts +1102 -0
- package/vite.config.ts +1 -0
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createMosaicDomainBatchClient,
|
|
3
|
+
type MosaicDomainBatchClientTransport,
|
|
4
|
+
} from "@atom.io/realtime/client"
|
|
5
|
+
import {
|
|
6
|
+
createMosaicDomainBatchServer,
|
|
7
|
+
createMosaicDomainHistoryCoordinator,
|
|
8
|
+
type MosaicDomainHistoryConnection,
|
|
9
|
+
} from "@atom.io/realtime/server"
|
|
10
|
+
import { Silo } from "atom.io"
|
|
11
|
+
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
12
|
+
|
|
13
|
+
import { materializeSvgOrder, readSvgRegister } from "./svg-convergence.ts"
|
|
14
|
+
import {
|
|
15
|
+
activateSvgDesignDomain,
|
|
16
|
+
createSvgDomainEditor,
|
|
17
|
+
type SvgDomainState,
|
|
18
|
+
} from "./svg-domain.ts"
|
|
19
|
+
import {
|
|
20
|
+
activeDragAtom,
|
|
21
|
+
createSvgGestureClock,
|
|
22
|
+
nodeAtoms,
|
|
23
|
+
pathDrawSelectors,
|
|
24
|
+
structureViolationsSelector,
|
|
25
|
+
subpathOrderAtoms,
|
|
26
|
+
type SvgDrawingFixture,
|
|
27
|
+
svgOperationId,
|
|
28
|
+
} from "./svg-editor-state.ts"
|
|
29
|
+
|
|
30
|
+
const drawing: SvgDrawingFixture = {
|
|
31
|
+
paths: [
|
|
32
|
+
{
|
|
33
|
+
id: `path-a`,
|
|
34
|
+
subpaths: [
|
|
35
|
+
{ edge: { kind: `move` }, id: `a0`, node: { x: 0, y: 0 } },
|
|
36
|
+
{ edge: { kind: `line` }, id: `a1`, node: { x: 10, y: 10 } },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const disposals: Array<() => void> = []
|
|
43
|
+
|
|
44
|
+
function historyAdapter(
|
|
45
|
+
connection: MosaicDomainHistoryConnection,
|
|
46
|
+
session: string,
|
|
47
|
+
) {
|
|
48
|
+
let sequence = 0
|
|
49
|
+
const request = async (mode: `redo` | `undo`) => {
|
|
50
|
+
const snapshot = await connection.snapshot()
|
|
51
|
+
const next = sequence + 1
|
|
52
|
+
const result = await connection.request({
|
|
53
|
+
cursor: snapshot.cursor,
|
|
54
|
+
id: `${session}:history:${next.toString()}`,
|
|
55
|
+
mode,
|
|
56
|
+
sequence: next,
|
|
57
|
+
session,
|
|
58
|
+
})
|
|
59
|
+
if (result.status === `accepted`) sequence = next
|
|
60
|
+
return result
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
redo: () => request(`redo`),
|
|
64
|
+
undo: () => request(`undo`),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
for (const dispose of disposals.splice(0).reverse()) dispose()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
async function distributedFixture(
|
|
73
|
+
authorize?: Parameters<typeof createMosaicDomainBatchServer>[0][`authorize`],
|
|
74
|
+
) {
|
|
75
|
+
const serverSilo = new Silo({
|
|
76
|
+
isProduction: false,
|
|
77
|
+
lifespan: `ephemeral`,
|
|
78
|
+
name: `svg-domain-server`,
|
|
79
|
+
})
|
|
80
|
+
const aliceSilo = new Silo({
|
|
81
|
+
isProduction: false,
|
|
82
|
+
lifespan: `ephemeral`,
|
|
83
|
+
name: `svg-domain-alice`,
|
|
84
|
+
})
|
|
85
|
+
const bobSilo = new Silo({
|
|
86
|
+
isProduction: false,
|
|
87
|
+
lifespan: `ephemeral`,
|
|
88
|
+
name: `svg-domain-bob`,
|
|
89
|
+
})
|
|
90
|
+
const [serverDomain, aliceDomain, bobDomain] = await Promise.all([
|
|
91
|
+
activateSvgDesignDomain({ instance: `drawing`, silo: serverSilo }),
|
|
92
|
+
activateSvgDesignDomain({ instance: `drawing`, silo: aliceSilo }),
|
|
93
|
+
activateSvgDesignDomain({ instance: `drawing`, silo: bobSilo }),
|
|
94
|
+
])
|
|
95
|
+
const server = createMosaicDomainBatchServer({
|
|
96
|
+
domain: serverDomain,
|
|
97
|
+
...(authorize === undefined ? {} : { authorize }),
|
|
98
|
+
})
|
|
99
|
+
const history = createMosaicDomainHistoryCoordinator({
|
|
100
|
+
batches: server,
|
|
101
|
+
domain: serverDomain,
|
|
102
|
+
})
|
|
103
|
+
const aliceConnection = server.connect({
|
|
104
|
+
actor: `alice`,
|
|
105
|
+
session: `alice-tab`,
|
|
106
|
+
})
|
|
107
|
+
const bobConnection = server.connect({ actor: `bob`, session: `bob-tab` })
|
|
108
|
+
const alice = createMosaicDomainBatchClient({
|
|
109
|
+
actor: `alice`,
|
|
110
|
+
domain: aliceDomain,
|
|
111
|
+
session: `alice-tab`,
|
|
112
|
+
transport: aliceConnection,
|
|
113
|
+
})
|
|
114
|
+
const bob = createMosaicDomainBatchClient({
|
|
115
|
+
actor: `bob`,
|
|
116
|
+
domain: bobDomain,
|
|
117
|
+
session: `bob-tab`,
|
|
118
|
+
transport: bobConnection,
|
|
119
|
+
})
|
|
120
|
+
await Promise.all([alice.start(), bob.start()])
|
|
121
|
+
const state = (silo: Silo): SvgDomainState => ({
|
|
122
|
+
getState: silo.getState,
|
|
123
|
+
setState: silo.setState,
|
|
124
|
+
})
|
|
125
|
+
const aliceEditor = createSvgDomainEditor({
|
|
126
|
+
batch: alice,
|
|
127
|
+
domain: aliceDomain,
|
|
128
|
+
history: historyAdapter(
|
|
129
|
+
history.connect({ actor: `alice`, session: `alice-tab` }),
|
|
130
|
+
`alice-tab`,
|
|
131
|
+
),
|
|
132
|
+
state: state(aliceSilo),
|
|
133
|
+
})
|
|
134
|
+
const bobEditor = createSvgDomainEditor({
|
|
135
|
+
batch: bob,
|
|
136
|
+
domain: bobDomain,
|
|
137
|
+
history: historyAdapter(
|
|
138
|
+
history.connect({ actor: `bob`, session: `bob-tab` }),
|
|
139
|
+
`bob-tab`,
|
|
140
|
+
),
|
|
141
|
+
state: state(bobSilo),
|
|
142
|
+
})
|
|
143
|
+
disposals.push(
|
|
144
|
+
() => {
|
|
145
|
+
history[Symbol.dispose]()
|
|
146
|
+
},
|
|
147
|
+
() => {
|
|
148
|
+
server.dispose()
|
|
149
|
+
},
|
|
150
|
+
() => {
|
|
151
|
+
alice[Symbol.dispose]()
|
|
152
|
+
},
|
|
153
|
+
() => {
|
|
154
|
+
bob[Symbol.dispose]()
|
|
155
|
+
},
|
|
156
|
+
() => {
|
|
157
|
+
serverDomain[Symbol.dispose]()
|
|
158
|
+
},
|
|
159
|
+
() => {
|
|
160
|
+
aliceDomain[Symbol.dispose]()
|
|
161
|
+
},
|
|
162
|
+
() => {
|
|
163
|
+
bobDomain[Symbol.dispose]()
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
return {
|
|
167
|
+
alice,
|
|
168
|
+
aliceDomain,
|
|
169
|
+
aliceEditor,
|
|
170
|
+
aliceSilo,
|
|
171
|
+
bob,
|
|
172
|
+
bobDomain,
|
|
173
|
+
bobEditor,
|
|
174
|
+
bobSilo,
|
|
175
|
+
server,
|
|
176
|
+
serverSilo,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
describe(`SVG Mosaic Domain`, () => {
|
|
181
|
+
test(`heterogeneous reset, split, reorder, and delete batches expose no holes`, async () => {
|
|
182
|
+
const fixture = await distributedFixture()
|
|
183
|
+
const clock = createSvgGestureClock({ actor: `alice`, session: `alice-tab` })
|
|
184
|
+
const observations: unknown[] = []
|
|
185
|
+
const unsubscribe = fixture.aliceSilo.subscribe(
|
|
186
|
+
structureViolationsSelector,
|
|
187
|
+
({ newValue }) => observations.push(newValue),
|
|
188
|
+
`svg-domain-invariants`,
|
|
189
|
+
)
|
|
190
|
+
disposals.push(unsubscribe)
|
|
191
|
+
await fixture.aliceEditor.replaceDrawing({ drawing, gesture: clock.begin() })
|
|
192
|
+
await fixture.aliceEditor.splitSubpath({
|
|
193
|
+
continuationEdge: { kind: `line` },
|
|
194
|
+
gesture: clock.begin(),
|
|
195
|
+
inserted: {
|
|
196
|
+
edge: { kind: `line` },
|
|
197
|
+
node: { x: 5, y: 5 },
|
|
198
|
+
subpathId: `split`,
|
|
199
|
+
},
|
|
200
|
+
pathId: `path-a`,
|
|
201
|
+
targetSubpathId: `a1`,
|
|
202
|
+
})
|
|
203
|
+
await fixture.aliceEditor.reorderSubpath({
|
|
204
|
+
gesture: clock.begin(),
|
|
205
|
+
index: 2,
|
|
206
|
+
pathId: `path-a`,
|
|
207
|
+
subpathId: `split`,
|
|
208
|
+
})
|
|
209
|
+
await fixture.aliceEditor.deleteSubpath({
|
|
210
|
+
gesture: clock.begin(),
|
|
211
|
+
pathId: `path-a`,
|
|
212
|
+
subpathId: `a1`,
|
|
213
|
+
})
|
|
214
|
+
await fixture.bob.flush()
|
|
215
|
+
|
|
216
|
+
for (const silo of [
|
|
217
|
+
fixture.serverSilo,
|
|
218
|
+
fixture.aliceSilo,
|
|
219
|
+
fixture.bobSilo,
|
|
220
|
+
]) {
|
|
221
|
+
expect(silo.getState(structureViolationsSelector)).toEqual([])
|
|
222
|
+
expect(silo.getState(pathDrawSelectors, `path-a`)).toBe(`M 0 0 L 5 5`)
|
|
223
|
+
}
|
|
224
|
+
expect(observations.every((value) => JSON.stringify(value) === `[]`)).toBe(
|
|
225
|
+
true,
|
|
226
|
+
)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
test(`independent nodes settle independently and same-node contention converges`, async () => {
|
|
230
|
+
const fixture = await distributedFixture()
|
|
231
|
+
const aliceClock = createSvgGestureClock({
|
|
232
|
+
actor: `alice`,
|
|
233
|
+
initialLogicalTime: 100,
|
|
234
|
+
session: `alice-tab`,
|
|
235
|
+
})
|
|
236
|
+
const bobClock = createSvgGestureClock({
|
|
237
|
+
actor: `bob`,
|
|
238
|
+
initialLogicalTime: 100,
|
|
239
|
+
session: `bob-tab`,
|
|
240
|
+
})
|
|
241
|
+
await fixture.aliceEditor.replaceDrawing({
|
|
242
|
+
drawing,
|
|
243
|
+
gesture: aliceClock.begin(),
|
|
244
|
+
})
|
|
245
|
+
await fixture.bob.flush()
|
|
246
|
+
await Promise.all([
|
|
247
|
+
fixture.aliceEditor.commitGeometry({
|
|
248
|
+
gesture: aliceClock.begin(),
|
|
249
|
+
point: { x: 11, y: 12 },
|
|
250
|
+
target: { kind: `node`, subpathId: `a0` },
|
|
251
|
+
}),
|
|
252
|
+
fixture.bobEditor.commitGeometry({
|
|
253
|
+
gesture: bobClock.begin(),
|
|
254
|
+
point: { x: 21, y: 22 },
|
|
255
|
+
target: { kind: `node`, subpathId: `a1` },
|
|
256
|
+
}),
|
|
257
|
+
])
|
|
258
|
+
await Promise.all([fixture.alice.flush(), fixture.bob.flush()])
|
|
259
|
+
bobClock.observe(102)
|
|
260
|
+
await Promise.all([
|
|
261
|
+
fixture.aliceEditor.commitGeometry({
|
|
262
|
+
gesture: aliceClock.begin(),
|
|
263
|
+
point: { x: 30, y: 30 },
|
|
264
|
+
target: { kind: `node`, subpathId: `a0` },
|
|
265
|
+
}),
|
|
266
|
+
fixture.bobEditor.commitGeometry({
|
|
267
|
+
gesture: bobClock.begin(),
|
|
268
|
+
point: { x: 40, y: 40 },
|
|
269
|
+
target: { kind: `node`, subpathId: `a0` },
|
|
270
|
+
}),
|
|
271
|
+
])
|
|
272
|
+
await Promise.all([fixture.alice.flush(), fixture.bob.flush()])
|
|
273
|
+
|
|
274
|
+
for (const silo of [
|
|
275
|
+
fixture.serverSilo,
|
|
276
|
+
fixture.aliceSilo,
|
|
277
|
+
fixture.bobSilo,
|
|
278
|
+
]) {
|
|
279
|
+
expect(readSvgRegister(silo.getState(nodeAtoms, `a0`))).toEqual({
|
|
280
|
+
x: 40,
|
|
281
|
+
y: 40,
|
|
282
|
+
})
|
|
283
|
+
expect(readSvgRegister(silo.getState(nodeAtoms, `a1`))).toEqual({
|
|
284
|
+
x: 21,
|
|
285
|
+
y: 22,
|
|
286
|
+
})
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
test(`actor-selective compensation preserves later foreign geometry`, async () => {
|
|
291
|
+
const fixture = await distributedFixture()
|
|
292
|
+
const aliceClock = createSvgGestureClock({
|
|
293
|
+
actor: `alice`,
|
|
294
|
+
session: `alice-tab`,
|
|
295
|
+
})
|
|
296
|
+
const bobClock = createSvgGestureClock({ actor: `bob`, session: `bob-tab` })
|
|
297
|
+
await fixture.aliceEditor.replaceDrawing({
|
|
298
|
+
drawing,
|
|
299
|
+
gesture: aliceClock.begin(),
|
|
300
|
+
})
|
|
301
|
+
await fixture.bob.flush()
|
|
302
|
+
await fixture.aliceEditor.commitGeometry({
|
|
303
|
+
gesture: aliceClock.begin(),
|
|
304
|
+
point: { x: 50, y: 50 },
|
|
305
|
+
target: { kind: `node`, subpathId: `a0` },
|
|
306
|
+
})
|
|
307
|
+
await fixture.bob.flush()
|
|
308
|
+
const aliceOperation = Object.values(
|
|
309
|
+
fixture.bobSilo.getState(nodeAtoms, `a0`).operations,
|
|
310
|
+
).find(({ actor }) => actor === `alice`)!
|
|
311
|
+
const malicious = bobClock.begin()
|
|
312
|
+
await expect(
|
|
313
|
+
fixture.bob.submit(
|
|
314
|
+
{
|
|
315
|
+
address: fixture.bobDomain.address(`nodes`, `a0`),
|
|
316
|
+
id: svgOperationId(malicious, 0),
|
|
317
|
+
operation: {
|
|
318
|
+
actor: `bob`,
|
|
319
|
+
id: svgOperationId(malicious, 0),
|
|
320
|
+
mode: `undo`,
|
|
321
|
+
targetOperationIds: [aliceOperation.id],
|
|
322
|
+
type: `history`,
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
malicious.id,
|
|
326
|
+
),
|
|
327
|
+
).rejects.toThrow(`only the authenticated actor's operations`)
|
|
328
|
+
bobClock.observe(10)
|
|
329
|
+
await fixture.bobEditor.commitGeometry({
|
|
330
|
+
gesture: bobClock.begin(),
|
|
331
|
+
point: { x: 60, y: 60 },
|
|
332
|
+
target: { kind: `node`, subpathId: `a0` },
|
|
333
|
+
})
|
|
334
|
+
await fixture.alice.flush()
|
|
335
|
+
expect(await fixture.aliceEditor.undo(aliceClock.begin())).toBe(true)
|
|
336
|
+
await fixture.bob.flush()
|
|
337
|
+
|
|
338
|
+
for (const silo of [
|
|
339
|
+
fixture.serverSilo,
|
|
340
|
+
fixture.aliceSilo,
|
|
341
|
+
fixture.bobSilo,
|
|
342
|
+
]) {
|
|
343
|
+
expect(readSvgRegister(silo.getState(nodeAtoms, `a0`))).toEqual({
|
|
344
|
+
x: 60,
|
|
345
|
+
y: 60,
|
|
346
|
+
})
|
|
347
|
+
}
|
|
348
|
+
expect(await fixture.aliceEditor.redo(aliceClock.begin())).toBe(true)
|
|
349
|
+
await fixture.bob.flush()
|
|
350
|
+
for (const silo of [
|
|
351
|
+
fixture.serverSilo,
|
|
352
|
+
fixture.aliceSilo,
|
|
353
|
+
fixture.bobSilo,
|
|
354
|
+
]) {
|
|
355
|
+
expect(readSvgRegister(silo.getState(nodeAtoms, `a0`))).toEqual({
|
|
356
|
+
x: 60,
|
|
357
|
+
y: 60,
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
test(`redo follows undo order and a new gesture clears the redo suffix`, async () => {
|
|
363
|
+
const fixture = await distributedFixture()
|
|
364
|
+
const clock = createSvgGestureClock({ actor: `alice`, session: `alice-tab` })
|
|
365
|
+
await fixture.aliceEditor.replaceDrawing({ drawing, gesture: clock.begin() })
|
|
366
|
+
await fixture.aliceEditor.commitGeometry({
|
|
367
|
+
gesture: clock.begin(),
|
|
368
|
+
point: { x: 50, y: 50 },
|
|
369
|
+
target: { kind: `node`, subpathId: `a0` },
|
|
370
|
+
})
|
|
371
|
+
await fixture.aliceEditor.commitGeometry({
|
|
372
|
+
gesture: clock.begin(),
|
|
373
|
+
point: { x: 70, y: 70 },
|
|
374
|
+
target: { kind: `node`, subpathId: `a1` },
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
expect(await fixture.aliceEditor.undo(clock.begin())).toBe(true)
|
|
378
|
+
expect(await fixture.aliceEditor.undo(clock.begin())).toBe(true)
|
|
379
|
+
expect(readSvgRegister(fixture.aliceSilo.getState(nodeAtoms, `a0`))).toEqual(
|
|
380
|
+
{ x: 0, y: 0 },
|
|
381
|
+
)
|
|
382
|
+
expect(readSvgRegister(fixture.aliceSilo.getState(nodeAtoms, `a1`))).toEqual(
|
|
383
|
+
{ x: 10, y: 10 },
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
expect(await fixture.aliceEditor.redo(clock.begin())).toBe(true)
|
|
387
|
+
expect(readSvgRegister(fixture.aliceSilo.getState(nodeAtoms, `a0`))).toEqual(
|
|
388
|
+
{ x: 50, y: 50 },
|
|
389
|
+
)
|
|
390
|
+
expect(readSvgRegister(fixture.aliceSilo.getState(nodeAtoms, `a1`))).toEqual(
|
|
391
|
+
{ x: 10, y: 10 },
|
|
392
|
+
)
|
|
393
|
+
expect(await fixture.aliceEditor.redo(clock.begin())).toBe(true)
|
|
394
|
+
expect(readSvgRegister(fixture.aliceSilo.getState(nodeAtoms, `a1`))).toEqual(
|
|
395
|
+
{ x: 70, y: 70 },
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
expect(await fixture.aliceEditor.undo(clock.begin())).toBe(true)
|
|
399
|
+
await fixture.aliceEditor.commitGeometry({
|
|
400
|
+
gesture: clock.begin(),
|
|
401
|
+
point: { x: 80, y: 80 },
|
|
402
|
+
target: { kind: `node`, subpathId: `a0` },
|
|
403
|
+
})
|
|
404
|
+
expect(await fixture.aliceEditor.redo(clock.begin())).toBe(false)
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
test(`foreign participation cannot keep topology alive after its creator undoes insertion`, async () => {
|
|
408
|
+
const fixture = await distributedFixture()
|
|
409
|
+
const aliceClock = createSvgGestureClock({
|
|
410
|
+
actor: `alice`,
|
|
411
|
+
session: `alice-tab`,
|
|
412
|
+
})
|
|
413
|
+
const bobClock = createSvgGestureClock({ actor: `bob`, session: `bob-tab` })
|
|
414
|
+
await fixture.aliceEditor.replaceDrawing({
|
|
415
|
+
drawing,
|
|
416
|
+
gesture: aliceClock.begin(),
|
|
417
|
+
})
|
|
418
|
+
await fixture.aliceEditor.insertSubpath({
|
|
419
|
+
edge: { kind: `line` },
|
|
420
|
+
gesture: aliceClock.begin(),
|
|
421
|
+
index: 1,
|
|
422
|
+
node: { x: 5, y: 5 },
|
|
423
|
+
pathId: `path-a`,
|
|
424
|
+
subpathId: `shared`,
|
|
425
|
+
})
|
|
426
|
+
await fixture.bob.flush()
|
|
427
|
+
await fixture.bobEditor.reorderSubpath({
|
|
428
|
+
gesture: bobClock.begin(),
|
|
429
|
+
index: 2,
|
|
430
|
+
pathId: `path-a`,
|
|
431
|
+
subpathId: `shared`,
|
|
432
|
+
})
|
|
433
|
+
await fixture.alice.flush()
|
|
434
|
+
|
|
435
|
+
expect(await fixture.aliceEditor.undo(aliceClock.begin())).toBe(true)
|
|
436
|
+
await fixture.bob.flush()
|
|
437
|
+
|
|
438
|
+
for (const silo of [
|
|
439
|
+
fixture.serverSilo,
|
|
440
|
+
fixture.aliceSilo,
|
|
441
|
+
fixture.bobSilo,
|
|
442
|
+
]) {
|
|
443
|
+
expect(silo.getState(structureViolationsSelector)).toEqual([])
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test(`final-member rejection rolls back the complete structural gesture`, async () => {
|
|
448
|
+
let denyEdges = false
|
|
449
|
+
const fixture = await distributedFixture(
|
|
450
|
+
({ batch }) =>
|
|
451
|
+
!denyEdges ||
|
|
452
|
+
batch.operations.every(({ address }) => address.member !== `edges`),
|
|
453
|
+
)
|
|
454
|
+
const clock = createSvgGestureClock({ actor: `alice`, session: `alice-tab` })
|
|
455
|
+
await fixture.aliceEditor.replaceDrawing({ drawing, gesture: clock.begin() })
|
|
456
|
+
const before = fixture.aliceSilo.getState(pathDrawSelectors, `path-a`)
|
|
457
|
+
denyEdges = true
|
|
458
|
+
await expect(
|
|
459
|
+
fixture.aliceEditor.insertSubpath({
|
|
460
|
+
edge: { kind: `line` },
|
|
461
|
+
gesture: clock.begin(),
|
|
462
|
+
index: 2,
|
|
463
|
+
node: { x: 20, y: 20 },
|
|
464
|
+
pathId: `path-a`,
|
|
465
|
+
subpathId: `rejected`,
|
|
466
|
+
}),
|
|
467
|
+
).rejects.toThrow(`not authorized`)
|
|
468
|
+
expect(fixture.aliceSilo.getState(pathDrawSelectors, `path-a`)).toBe(before)
|
|
469
|
+
expect(fixture.aliceSilo.getState(structureViolationsSelector)).toEqual([])
|
|
470
|
+
expect(fixture.server.revision).toBe(1)
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
test(`offline explicit batches replay without losing the coalesced geometry`, async () => {
|
|
474
|
+
const fixture = await distributedFixture()
|
|
475
|
+
const clock = createSvgGestureClock({ actor: `alice`, session: `alice-tab` })
|
|
476
|
+
await fixture.aliceEditor.replaceDrawing({ drawing, gesture: clock.begin() })
|
|
477
|
+
await fixture.bob.flush()
|
|
478
|
+
const connection = fixture.server.connect({
|
|
479
|
+
actor: `offline`,
|
|
480
|
+
session: `offline-tab`,
|
|
481
|
+
})
|
|
482
|
+
const offlineSilo = new Silo({
|
|
483
|
+
isProduction: false,
|
|
484
|
+
lifespan: `ephemeral`,
|
|
485
|
+
name: `svg-domain-offline`,
|
|
486
|
+
})
|
|
487
|
+
const offlineDomain = await activateSvgDesignDomain({
|
|
488
|
+
instance: `drawing`,
|
|
489
|
+
silo: offlineSilo,
|
|
490
|
+
})
|
|
491
|
+
let online = true
|
|
492
|
+
const transport: MosaicDomainBatchClientTransport = {
|
|
493
|
+
propose: (proposal) =>
|
|
494
|
+
online
|
|
495
|
+
? connection.propose(proposal)
|
|
496
|
+
: Promise.reject(new Error(`offline`)),
|
|
497
|
+
recover: (revision) => connection.recover(revision),
|
|
498
|
+
subscribe: (listener) => connection.subscribe(listener),
|
|
499
|
+
}
|
|
500
|
+
const client = createMosaicDomainBatchClient({
|
|
501
|
+
actor: `offline`,
|
|
502
|
+
domain: offlineDomain,
|
|
503
|
+
session: `offline-tab`,
|
|
504
|
+
transport,
|
|
505
|
+
})
|
|
506
|
+
await client.start()
|
|
507
|
+
const editor = createSvgDomainEditor({
|
|
508
|
+
batch: client,
|
|
509
|
+
domain: offlineDomain,
|
|
510
|
+
state: { getState: offlineSilo.getState, setState: offlineSilo.setState },
|
|
511
|
+
})
|
|
512
|
+
online = false
|
|
513
|
+
await editor.commitGeometry({
|
|
514
|
+
gesture: createSvgGestureClock({
|
|
515
|
+
actor: `offline`,
|
|
516
|
+
session: `offline-tab`,
|
|
517
|
+
}).begin(),
|
|
518
|
+
point: { x: 77, y: 88 },
|
|
519
|
+
target: { kind: `node`, subpathId: `a0` },
|
|
520
|
+
})
|
|
521
|
+
expect(client.state.status).toBe(`offline`)
|
|
522
|
+
expect(readSvgRegister(offlineSilo.getState(nodeAtoms, `a0`))).toEqual({
|
|
523
|
+
x: 77,
|
|
524
|
+
y: 88,
|
|
525
|
+
})
|
|
526
|
+
online = true
|
|
527
|
+
await client.flush()
|
|
528
|
+
await fixture.bob.flush()
|
|
529
|
+
expect(readSvgRegister(fixture.bobSilo.getState(nodeAtoms, `a0`))).toEqual({
|
|
530
|
+
x: 77,
|
|
531
|
+
y: 88,
|
|
532
|
+
})
|
|
533
|
+
disposals.push(
|
|
534
|
+
() => {
|
|
535
|
+
client[Symbol.dispose]()
|
|
536
|
+
},
|
|
537
|
+
() => {
|
|
538
|
+
offlineDomain[Symbol.dispose]()
|
|
539
|
+
},
|
|
540
|
+
)
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
test(`reset/import ranks only replacement subpaths after planned removals`, async () => {
|
|
544
|
+
const fixture = await distributedFixture()
|
|
545
|
+
const clock = createSvgGestureClock({ actor: `alice`, session: `alice-tab` })
|
|
546
|
+
await fixture.aliceEditor.replaceDrawing({
|
|
547
|
+
drawing,
|
|
548
|
+
gesture: clock.begin(),
|
|
549
|
+
})
|
|
550
|
+
const replacement: SvgDrawingFixture = {
|
|
551
|
+
paths: [
|
|
552
|
+
{
|
|
553
|
+
id: `path-a`,
|
|
554
|
+
subpaths: [
|
|
555
|
+
{ edge: { kind: `move` }, id: `b0`, node: { x: 1, y: 1 } },
|
|
556
|
+
{ edge: { kind: `line` }, id: `b1`, node: { x: 2, y: 2 } },
|
|
557
|
+
{ edge: { kind: `line` }, id: `b2`, node: { x: 3, y: 3 } },
|
|
558
|
+
],
|
|
559
|
+
},
|
|
560
|
+
],
|
|
561
|
+
}
|
|
562
|
+
await fixture.aliceEditor.replaceDrawing({
|
|
563
|
+
drawing: replacement,
|
|
564
|
+
gesture: clock.begin(),
|
|
565
|
+
})
|
|
566
|
+
await fixture.bob.flush()
|
|
567
|
+
for (const silo of [
|
|
568
|
+
fixture.serverSilo,
|
|
569
|
+
fixture.aliceSilo,
|
|
570
|
+
fixture.bobSilo,
|
|
571
|
+
]) {
|
|
572
|
+
expect(
|
|
573
|
+
materializeSvgOrder(silo.getState(subpathOrderAtoms, `path-a`)).map(
|
|
574
|
+
({ value }) => value,
|
|
575
|
+
),
|
|
576
|
+
).toEqual([`b0`, `b1`, `b2`])
|
|
577
|
+
expect(silo.getState(structureViolationsSelector)).toEqual([])
|
|
578
|
+
}
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
test(`presence failures never abort local drag or its durable commit`, async () => {
|
|
582
|
+
const fixture = await distributedFixture()
|
|
583
|
+
const clock = createSvgGestureClock({ actor: `alice`, session: `alice-tab` })
|
|
584
|
+
await fixture.aliceEditor.replaceDrawing({
|
|
585
|
+
drawing,
|
|
586
|
+
gesture: clock.begin(),
|
|
587
|
+
})
|
|
588
|
+
const publish = vi.fn(() => Promise.reject(new Error(`presence offline`)))
|
|
589
|
+
const clear = vi.fn(() => Promise.reject(new Error(`presence offline`)))
|
|
590
|
+
const editor = createSvgDomainEditor({
|
|
591
|
+
batch: fixture.alice,
|
|
592
|
+
domain: fixture.aliceDomain,
|
|
593
|
+
presence: { clear, publish } as never,
|
|
594
|
+
state: {
|
|
595
|
+
getState: fixture.aliceSilo.getState,
|
|
596
|
+
setState: fixture.aliceSilo.setState,
|
|
597
|
+
},
|
|
598
|
+
})
|
|
599
|
+
const gesture = clock.begin()
|
|
600
|
+
await expect(
|
|
601
|
+
editor.beginDrag({
|
|
602
|
+
element: {} as Element,
|
|
603
|
+
gesture,
|
|
604
|
+
point: { x: 4, y: 5 },
|
|
605
|
+
pointerId: 1,
|
|
606
|
+
target: { kind: `node`, subpathId: `a0` },
|
|
607
|
+
}),
|
|
608
|
+
).resolves.toBeUndefined()
|
|
609
|
+
await expect(editor.previewDrag({ x: 8, y: 9 })).resolves.toBeUndefined()
|
|
610
|
+
await expect(editor.finishDrag({ commit: true })).resolves.toBeUndefined()
|
|
611
|
+
expect(fixture.aliceSilo.getState(activeDragAtom)).toBeNull()
|
|
612
|
+
expect(readSvgRegister(fixture.aliceSilo.getState(nodeAtoms, `a0`))).toEqual(
|
|
613
|
+
{ x: 8, y: 9 },
|
|
614
|
+
)
|
|
615
|
+
expect(publish).toHaveBeenCalledTimes(2)
|
|
616
|
+
expect(clear).toHaveBeenCalledOnce()
|
|
617
|
+
})
|
|
618
|
+
})
|