|
11 | 11 | */ |
12 | 12 |
|
13 | 13 | import { describe, it, expect } from 'vitest'; |
| 14 | +import { next as A } from '@automerge/automerge'; |
| 15 | +import type { Doc } from '@automerge/automerge'; |
| 16 | +import { encodeHeads } from '@automerge/automerge-repo'; |
| 17 | +import type { DocHandle } from '@automerge/automerge-repo'; |
14 | 18 |
|
15 | 19 | import { |
16 | 20 | buildCharToByteMap, |
| 21 | + buildRunListAttribution, |
17 | 22 | runsCharToByteOffsets, |
| 23 | + updateRunListAttribution, |
18 | 24 | type AttributionRun, |
| 25 | + type ViewableHandle, |
19 | 26 | } from './attribution-runs'; |
20 | 27 |
|
21 | 28 | describe('buildCharToByteMap', () => { |
@@ -75,3 +82,91 @@ describe('runsCharToByteOffsets', () => { |
75 | 82 | expect(out).toEqual(runs); |
76 | 83 | }); |
77 | 84 | }); |
| 85 | + |
| 86 | +// --------------------------------------------------------------------------- |
| 87 | +// Incremental ≡ from-scratch invariant |
| 88 | +// --------------------------------------------------------------------------- |
| 89 | + |
| 90 | +// Anchor for the incremental path: whatever shortcut `updateRunListAttribution` |
| 91 | +// uses to skip work, the final run list must agree character-for-character with |
| 92 | +// what `buildRunListAttribution` produces from `init()` on the same final doc. |
| 93 | +// If a future refactor breaks this — including via something subtle at the |
| 94 | +// Automerge boundary (history-traversal ordering, getChanges semantics, etc.) |
| 95 | +// — this is the test that should catch it. |
| 96 | + |
| 97 | +interface TDoc { text: string } |
| 98 | + |
| 99 | +function fakeHandle(doc: Doc<TDoc>): DocHandle<unknown> { |
| 100 | + const view: ViewableHandle = { |
| 101 | + history: () => A.topoHistoryTraversal(doc).map(h => encodeHeads([h])), |
| 102 | + metadata: () => undefined, |
| 103 | + doc: () => doc, |
| 104 | + }; |
| 105 | + return view as unknown as DocHandle<unknown>; |
| 106 | +} |
| 107 | + |
| 108 | +describe('updateRunListAttribution invariant', () => { |
| 109 | + it('matches a from-scratch rebuild after a concurrent merge', async () => { |
| 110 | + const aliceActor = 'f'.repeat(32); |
| 111 | + const bobActor = '0'.repeat(32); |
| 112 | + |
| 113 | + let alice = A.from<TDoc>({ text: '' }, { actor: aliceActor }); |
| 114 | + let bob = A.load<TDoc>(A.save(alice), { actor: bobActor }); |
| 115 | + |
| 116 | + alice = A.change(alice, d => A.splice(d, ['text'], 0, 0, 'Hello')); |
| 117 | + alice = A.change(alice, d => A.splice(d, ['text'], 5, 0, ' World')); |
| 118 | + alice = A.change(alice, d => A.splice(d, ['text'], 11, 0, '!')); |
| 119 | + |
| 120 | + const stateBefore = await buildRunListAttribution(fakeHandle(alice), 'text'); |
| 121 | + expect(stateBefore).toBeTruthy(); |
| 122 | + |
| 123 | + bob = A.change(bob, d => A.splice(d, ['text'], 0, 0, 'X')); |
| 124 | + bob = A.change(bob, d => A.splice(d, ['text'], 1, 0, 'Y')); |
| 125 | + bob = A.change(bob, d => A.splice(d, ['text'], 2, 0, 'Z')); |
| 126 | + alice = A.merge(alice, bob); |
| 127 | + |
| 128 | + const incremental = updateRunListAttribution(stateBefore!, fakeHandle(alice), 'text'); |
| 129 | + const fromScratch = await buildRunListAttribution(fakeHandle(alice), 'text'); |
| 130 | + |
| 131 | + expect(fromScratch).toBeTruthy(); |
| 132 | + expect(incremental.runs).toEqual(fromScratch!.runs); |
| 133 | + }); |
| 134 | + |
| 135 | + it('matches a from-scratch rebuild across interleaved local and merged remote edits', async () => { |
| 136 | + const aliceActor = 'f'.repeat(32); |
| 137 | + const bobActor = '0'.repeat(32); |
| 138 | + |
| 139 | + let alice = A.from<TDoc>({ text: '' }, { actor: aliceActor }); |
| 140 | + let bob = A.load<TDoc>(A.save(alice), { actor: bobActor }); |
| 141 | + |
| 142 | + alice = A.change(alice, d => A.splice(d, ['text'], 0, 0, 'A1')); |
| 143 | + const stateBefore = await buildRunListAttribution(fakeHandle(alice), 'text'); |
| 144 | + expect(stateBefore).toBeTruthy(); |
| 145 | + |
| 146 | + bob = A.change(bob, d => A.splice(d, ['text'], 0, 0, 'B1')); |
| 147 | + bob = A.change(bob, d => A.splice(d, ['text'], 2, 0, 'B2')); |
| 148 | + alice = A.merge(alice, bob); |
| 149 | + alice = A.change(alice, d => A.splice(d, ['text'], 0, 0, 'A2')); |
| 150 | + alice = A.change(alice, d => A.splice(d, ['text'], 0, 0, 'A3')); |
| 151 | + |
| 152 | + const incremental = updateRunListAttribution(stateBefore!, fakeHandle(alice), 'text'); |
| 153 | + const fromScratch = await buildRunListAttribution(fakeHandle(alice), 'text'); |
| 154 | + |
| 155 | + expect(fromScratch).toBeTruthy(); |
| 156 | + expect(incremental.runs).toEqual(fromScratch!.runs); |
| 157 | + }); |
| 158 | + |
| 159 | + it('returns state unchanged when no new changes are present', async () => { |
| 160 | + const aliceActor = 'f'.repeat(32); |
| 161 | + let alice = A.from<TDoc>({ text: '' }, { actor: aliceActor }); |
| 162 | + alice = A.change(alice, d => A.splice(d, ['text'], 0, 0, 'hello')); |
| 163 | + |
| 164 | + const stateBefore = await buildRunListAttribution(fakeHandle(alice), 'text'); |
| 165 | + expect(stateBefore).toBeTruthy(); |
| 166 | + |
| 167 | + // No edits between build and update — the incremental path should |
| 168 | + // short-circuit and return the same runs. |
| 169 | + const incremental = updateRunListAttribution(stateBefore!, fakeHandle(alice), 'text'); |
| 170 | + expect(incremental.runs).toEqual(stateBefore!.runs); |
| 171 | + }); |
| 172 | +}); |
0 commit comments