@atom.io/template-preact-svg-editor 0.0.93 → 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.
@@ -0,0 +1,369 @@
1
+ import type {
2
+ MosaicAcceptedDomainBatchEnvelope,
3
+ MosaicDomainBatchProposal,
4
+ } from "@atom.io/realtime"
5
+ import {
6
+ createMosaicDomainBatchClient,
7
+ createMosaicDomainPresenceClient,
8
+ createMosaicDomainPresenceSocketTransport,
9
+ type MosaicDomainBatchClientTransport,
10
+ } from "@atom.io/realtime/client"
11
+ import {
12
+ bindMosaicDomainPresenceServerSocket,
13
+ createMosaicDomainBatchServer,
14
+ createMosaicDomainPresenceServer,
15
+ type MosaicDomainBatchConnection,
16
+ } from "@atom.io/realtime/server"
17
+ import {
18
+ headless,
19
+ type HeadlessRealtimeTestClient,
20
+ } from "@atom.io/realtime/testing/headless"
21
+ import type { Silo } from "atom.io"
22
+ import { afterEach, describe, expect, test } from "vitest"
23
+
24
+ import { readSvgRegister } from "./svg-convergence.ts"
25
+ import {
26
+ activateSvgDesignDomain,
27
+ createSvgDomainEditor,
28
+ type SvgDesignDomain,
29
+ } from "./svg-domain.ts"
30
+ import {
31
+ createSvgGestureClock,
32
+ dragPresenceAtoms,
33
+ nodeAtoms,
34
+ structureViolationsSelector,
35
+ svgDragPresenceKey,
36
+ type SvgDrawingFixture,
37
+ } from "./svg-editor-state.ts"
38
+
39
+ const ACCEPTED = `svg-domain:batch:accepted`
40
+ const PROPOSE = `svg-domain:batch:propose`
41
+ const RECOVER = `svg-domain:batch:recover`
42
+
43
+ const drawing: SvgDrawingFixture = {
44
+ paths: [
45
+ {
46
+ id: `path-a`,
47
+ subpaths: [
48
+ { edge: { kind: `move` }, id: `a0`, node: { x: 0, y: 0 } },
49
+ { edge: { kind: `line` }, id: `a1`, node: { x: 10, y: 10 } },
50
+ ],
51
+ },
52
+ ],
53
+ }
54
+
55
+ const teardowns: Array<() => Promise<void>> = []
56
+
57
+ afterEach(async () => {
58
+ for (const teardown of teardowns.splice(0).reverse()) await teardown()
59
+ })
60
+
61
+ function batchSocketTransport(
62
+ harness: HeadlessRealtimeTestClient,
63
+ ): MosaicDomainBatchClientTransport {
64
+ return {
65
+ propose(batch) {
66
+ if (!harness.socket.connected) return Promise.reject(new Error(`offline`))
67
+ return new Promise((resolve) => {
68
+ harness.socket.emit(PROPOSE, batch, resolve)
69
+ })
70
+ },
71
+ recover(afterRevision = 0) {
72
+ if (!harness.socket.connected) return Promise.reject(new Error(`offline`))
73
+ return new Promise((resolve) => {
74
+ harness.socket.emit(RECOVER, afterRevision, resolve)
75
+ })
76
+ },
77
+ subscribe(listener) {
78
+ harness.socket.on(ACCEPTED, listener)
79
+ return () => harness.socket.off(ACCEPTED, listener)
80
+ },
81
+ }
82
+ }
83
+
84
+ function realtimeFixture(
85
+ options: { readonly duplicateDelivery?: boolean } = {},
86
+ ) {
87
+ let denyEdges = false
88
+ let serverDomainPromise: Promise<SvgDesignDomain> | undefined
89
+ let batchServerPromise:
90
+ | Promise<ReturnType<typeof createMosaicDomainBatchServer>>
91
+ | undefined
92
+ let presenceServerPromise:
93
+ | Promise<ReturnType<typeof createMosaicDomainPresenceServer>>
94
+ | undefined
95
+ let serverSilo: Silo | undefined
96
+ const scenario = headless({
97
+ scenarioId: `svg-domain-realtime`,
98
+ server: (tools) => {
99
+ serverSilo ??= tools.silo
100
+ serverDomainPromise ??= activateSvgDesignDomain({
101
+ instance: `drawing`,
102
+ silo: tools.silo,
103
+ })
104
+ batchServerPromise ??= serverDomainPromise.then((domain) =>
105
+ createMosaicDomainBatchServer({
106
+ authorize: ({ batch }) =>
107
+ !denyEdges ||
108
+ batch.operations.every(({ address }) => address.member !== `edges`),
109
+ domain,
110
+ }),
111
+ )
112
+ presenceServerPromise ??= serverDomainPromise.then((domain) =>
113
+ createMosaicDomainPresenceServer({ domain }),
114
+ )
115
+ const batchConnection = batchServerPromise.then((server) =>
116
+ server.connect({ actor: tools.userKey, session: tools.sessionId }),
117
+ )
118
+ const presenceConnection = presenceServerPromise.then((server) =>
119
+ server.connect({ actor: tools.userKey, session: tools.sessionId }),
120
+ )
121
+ let unsubscribeBatch: () => void = () => undefined
122
+ let unbindPresence: () => Promise<void> = () => Promise.resolve()
123
+ void tools.work.track(
124
+ batchConnection.then((connection) => {
125
+ unsubscribeBatch = connection.subscribe((accepted) => {
126
+ tools.socket.emit(ACCEPTED, accepted)
127
+ if (options.duplicateDelivery === true) {
128
+ tools.socket.emit(ACCEPTED, accepted)
129
+ }
130
+ })
131
+ }),
132
+ `bind SVG batch broadcast`,
133
+ )
134
+ void tools.work.track(
135
+ presenceConnection.then((connection) => {
136
+ unbindPresence = bindMosaicDomainPresenceServerSocket(
137
+ connection,
138
+ tools.socket as never,
139
+ tools.work,
140
+ )
141
+ }),
142
+ `bind SVG presence`,
143
+ )
144
+ tools.socket.on(
145
+ PROPOSE,
146
+ (
147
+ batch: MosaicDomainBatchProposal,
148
+ respond: (
149
+ result: Awaited<ReturnType<MosaicDomainBatchConnection[`propose`]>>,
150
+ ) => void,
151
+ ) => {
152
+ void tools.work
153
+ .track(
154
+ batchConnection.then((connection) => connection.propose(batch)),
155
+ `propose SVG batch`,
156
+ )
157
+ .then(respond)
158
+ },
159
+ )
160
+ tools.socket.on(
161
+ RECOVER,
162
+ (
163
+ afterRevision: number,
164
+ respond: (recovery: {
165
+ headRevision: number
166
+ tail: readonly MosaicAcceptedDomainBatchEnvelope[]
167
+ }) => void,
168
+ ) => {
169
+ void tools.work
170
+ .track(
171
+ batchConnection.then((connection) =>
172
+ connection.recover(afterRevision),
173
+ ),
174
+ `recover SVG batches`,
175
+ )
176
+ .then(respond)
177
+ },
178
+ )
179
+ return () => {
180
+ unsubscribeBatch()
181
+ void unbindPresence()
182
+ }
183
+ },
184
+ })
185
+
186
+ async function createPeer(name: string) {
187
+ const harness = scenario.createClient({ name })
188
+ await scenario.waitForIdle()
189
+ const domain = await activateSvgDesignDomain({
190
+ instance: `drawing`,
191
+ silo: harness.silo,
192
+ })
193
+ const batch = createMosaicDomainBatchClient({
194
+ actor: harness.userKey,
195
+ domain,
196
+ session: harness.sessionId,
197
+ transport: batchSocketTransport(harness),
198
+ })
199
+ const presenceTransport = createMosaicDomainPresenceSocketTransport(
200
+ harness.socket,
201
+ {
202
+ idSource: (() => {
203
+ let sequence = 0
204
+ return () => `${harness.sessionId}:presence:${sequence++}`
205
+ })(),
206
+ },
207
+ )
208
+ const presence = createMosaicDomainPresenceClient({
209
+ domain,
210
+ session: harness.sessionId,
211
+ transport: presenceTransport,
212
+ })
213
+ await Promise.all([batch.start(), presence.start()])
214
+ const editor = createSvgDomainEditor({
215
+ batch,
216
+ domain,
217
+ presence,
218
+ state: {
219
+ getState: harness.silo.getState,
220
+ setState: harness.silo.setState,
221
+ },
222
+ })
223
+ return {
224
+ batch,
225
+ clock: createSvgGestureClock({
226
+ actor: harness.userKey,
227
+ session: harness.sessionId,
228
+ }),
229
+ domain,
230
+ editor,
231
+ harness,
232
+ presence,
233
+ presenceTransport,
234
+ }
235
+ }
236
+
237
+ const teardown = async (): Promise<void> => {
238
+ await scenario.teardown()
239
+ ;(await batchServerPromise)?.dispose()
240
+ ;(await presenceServerPromise)?.[Symbol.dispose]()
241
+ ;(await serverDomainPromise)?.[Symbol.dispose]()
242
+ }
243
+ teardowns.push(teardown)
244
+ return {
245
+ createPeer,
246
+ denyEdges: () => {
247
+ denyEdges = true
248
+ },
249
+ scenario,
250
+ server: async () => ({
251
+ batch: await batchServerPromise!,
252
+ domain: await serverDomainPromise!,
253
+ silo: serverSilo!,
254
+ }),
255
+ }
256
+ }
257
+
258
+ describe(`SVG Domain over realtime-testing`, () => {
259
+ test(`real clients tolerate duplicate delivery and deterministically settle contention`, async () => {
260
+ const room = realtimeFixture({ duplicateDelivery: true })
261
+ const [alice, bob] = await Promise.all([
262
+ room.createPeer(`alice`),
263
+ room.createPeer(`bob`),
264
+ ])
265
+ await alice.editor.replaceDrawing({ drawing, gesture: alice.clock.begin() })
266
+ await bob.batch.flush()
267
+ alice.clock.observe(10)
268
+ bob.clock.observe(10)
269
+ await Promise.all([
270
+ alice.editor.commitGeometry({
271
+ gesture: alice.clock.begin(),
272
+ point: { x: 31, y: 32 },
273
+ target: { kind: `node`, subpathId: `a0` },
274
+ }),
275
+ bob.editor.commitGeometry({
276
+ gesture: bob.clock.begin(),
277
+ point: { x: 41, y: 42 },
278
+ target: { kind: `node`, subpathId: `a0` },
279
+ }),
280
+ ])
281
+ await Promise.all([alice.batch.flush(), bob.batch.flush()])
282
+ const server = await room.server()
283
+ const values = [server.silo, alice.harness.silo, bob.harness.silo].map(
284
+ (silo) => readSvgRegister(silo.getState(nodeAtoms, `a0`)),
285
+ )
286
+ expect(values[0]).toEqual(values[1])
287
+ expect(values[1]).toEqual(values[2])
288
+ expect(server.batch.revision).toBe(3)
289
+ })
290
+
291
+ test(`a real final-member rejection exposes no partial structural state`, async () => {
292
+ const room = realtimeFixture()
293
+ const alice = await room.createPeer(`alice`)
294
+ await alice.editor.replaceDrawing({ drawing, gesture: alice.clock.begin() })
295
+ room.denyEdges()
296
+ await expect(
297
+ alice.editor.insertSubpath({
298
+ edge: { kind: `line` },
299
+ gesture: alice.clock.begin(),
300
+ index: 2,
301
+ node: { x: 20, y: 20 },
302
+ pathId: `path-a`,
303
+ subpathId: `denied`,
304
+ }),
305
+ ).rejects.toThrow(`not authorized`)
306
+ expect(alice.harness.silo.getState(structureViolationsSelector)).toEqual([])
307
+ const server = await room.server()
308
+ expect(server.silo.getState(structureViolationsSelector)).toEqual([])
309
+ expect(server.batch.revision).toBe(1)
310
+ })
311
+
312
+ test(`a disconnected real client replays one coalesced geometry batch`, async () => {
313
+ const room = realtimeFixture()
314
+ const [alice, bob] = await Promise.all([
315
+ room.createPeer(`alice`),
316
+ room.createPeer(`bob`),
317
+ ])
318
+ await alice.editor.replaceDrawing({ drawing, gesture: alice.clock.begin() })
319
+ await bob.batch.flush()
320
+ alice.harness.socket.disconnect()
321
+ await alice.editor.commitGeometry({
322
+ gesture: alice.clock.begin(),
323
+ point: { x: 70, y: 80 },
324
+ target: { kind: `node`, subpathId: `a0` },
325
+ })
326
+ expect(alice.batch.state.status).toBe(`offline`)
327
+ alice.harness.socket.connect()
328
+ await room.scenario.waitForIdle()
329
+ await alice.batch.flush()
330
+ await bob.batch.flush()
331
+ expect(readSvgRegister(bob.harness.silo.getState(nodeAtoms, `a0`))).toEqual({
332
+ x: 70,
333
+ y: 80,
334
+ })
335
+ })
336
+
337
+ test(`disconnect during drag expires only that actor-session preview`, async () => {
338
+ const room = realtimeFixture()
339
+ const [alice, bob] = await Promise.all([
340
+ room.createPeer(`alice`),
341
+ room.createPeer(`bob`),
342
+ ])
343
+ await alice.editor.replaceDrawing({ drawing, gesture: alice.clock.begin() })
344
+ await bob.batch.flush()
345
+ const gesture = alice.clock.begin()
346
+ await alice.editor.beginDrag({
347
+ element: {} as Element,
348
+ gesture,
349
+ point: { x: 15, y: 16 },
350
+ pointerId: 1,
351
+ target: { kind: `node`, subpathId: `a0` },
352
+ })
353
+ await room.scenario.waitForIdle()
354
+ await bob.presence.flush()
355
+ const key = svgDragPresenceKey(gesture)
356
+ expect(bob.harness.silo.getState(dragPresenceAtoms, key)).toMatchObject({
357
+ point: { x: 15, y: 16 },
358
+ })
359
+ alice.harness.socket.disconnect()
360
+ await bob.harness.waitForIdle()
361
+ await bob.presence.flush()
362
+ expect(bob.harness.silo.getState(dragPresenceAtoms, key)).toBeNull()
363
+ const server = await room.server()
364
+ expect(readSvgRegister(server.silo.getState(nodeAtoms, `a0`))).toEqual({
365
+ x: 0,
366
+ y: 0,
367
+ })
368
+ })
369
+ })