@startsimpli/ui 0.4.142 โ 0.4.143
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/package.json +36 -10
- package/src/components/track-changes/TrackedTextEditor.tsx +595 -0
- package/src/components/track-changes/__tests__/extension.test.ts +350 -0
- package/src/components/track-changes/__tests__/model.test.ts +461 -0
- package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +476 -0
- package/src/components/track-changes/describe.ts +76 -0
- package/src/components/track-changes/extension.ts +724 -0
- package/src/components/track-changes/index.ts +74 -0
- package/src/components/track-changes/normalize.ts +364 -0
- package/src/components/track-changes/types.ts +373 -0
- package/src/components/track-changes/useTrackChanges.ts +214 -0
- package/src/components/track-changes/word-diff.ts +97 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure layer under the track-changes editor (bd startsim-sjuai).
|
|
3
|
+
*
|
|
4
|
+
* The assertions that matter: a server offset lands on the same CHARACTER in
|
|
5
|
+
* the editor even with emoji before it; an anchor whose quote no longer
|
|
6
|
+
* matches is never painted at a guessed place; and the minimal change between
|
|
7
|
+
* two values is word-sized and exact, so an external update moves decorations
|
|
8
|
+
* instead of collapsing them.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
anchorFor,
|
|
12
|
+
blameNotice,
|
|
13
|
+
codePointToUtf16,
|
|
14
|
+
normalizeBlame,
|
|
15
|
+
normalizeSuggestions,
|
|
16
|
+
normalizeThreads,
|
|
17
|
+
toServerOffsets,
|
|
18
|
+
utf16ToCodePoint,
|
|
19
|
+
} from '../normalize'
|
|
20
|
+
import { applyTextChanges, tokenize, wordChanges } from '../word-diff'
|
|
21
|
+
|
|
22
|
+
describe('offset units', () => {
|
|
23
|
+
it('converts code points to UTF-16 across an emoji', () => {
|
|
24
|
+
const text = '๐ hi there'
|
|
25
|
+
// Python: len('๐ ') == 2; JS: '๐ '.length == 3
|
|
26
|
+
expect(codePointToUtf16(text, 2)).toBe(3)
|
|
27
|
+
expect(text.slice(codePointToUtf16(text, 2), codePointToUtf16(text, 4))).toBe('hi')
|
|
28
|
+
expect(utf16ToCodePoint(text, 3)).toBe(2)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('is the identity on text with no astral characters (CJK is BMP)', () => {
|
|
32
|
+
const text = 'ไธญๆๅๅฎข blog'
|
|
33
|
+
for (let i = 0; i <= text.length; i++) {
|
|
34
|
+
expect(codePointToUtf16(text, i)).toBe(i)
|
|
35
|
+
expect(utf16ToCodePoint(text, i)).toBe(i)
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('clamps out-of-range offsets instead of throwing', () => {
|
|
40
|
+
expect(codePointToUtf16('ab', 9)).toBe(2)
|
|
41
|
+
expect(codePointToUtf16('ab', -1)).toBe(0)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('round-trips an editor anchor to server offsets', () => {
|
|
45
|
+
const text = 'Launch ๐๐ day: big news'
|
|
46
|
+
const from = text.indexOf('big')
|
|
47
|
+
const anchor = anchorFor(text, from, from + 3)
|
|
48
|
+
expect(anchor.quote).toBe('big')
|
|
49
|
+
const server = toServerOffsets(text, anchor)
|
|
50
|
+
expect(server.from).toBe(Array.from(text.slice(0, from)).length)
|
|
51
|
+
expect(server.to - server.from).toBe(3)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('anchorFor', () => {
|
|
56
|
+
it('captures 32 characters of context on each side', () => {
|
|
57
|
+
const text = 'a'.repeat(50) + 'QUOTE' + 'b'.repeat(50)
|
|
58
|
+
const a = anchorFor(text, 50, 55)
|
|
59
|
+
expect(a).toEqual({ from: 50, to: 55, quote: 'QUOTE', prefix: 'a'.repeat(32), suffix: 'b'.repeat(32) })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('normalizes a backwards range and clips at the edges', () => {
|
|
63
|
+
const a = anchorFor('hello world', 5, 0)
|
|
64
|
+
expect(a.from).toBe(0)
|
|
65
|
+
expect(a.quote).toBe('hello')
|
|
66
|
+
expect(a.prefix).toBe('')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('normalizeBlame', () => {
|
|
71
|
+
const text = '๐ Ada wrote this. Machine wrote that.'
|
|
72
|
+
|
|
73
|
+
it('converts code-point spans and reads both spellings', () => {
|
|
74
|
+
const spans = normalizeBlame(
|
|
75
|
+
{
|
|
76
|
+
version: 3,
|
|
77
|
+
spans: [
|
|
78
|
+
{ from: 0, to: 17, actor_label: 'ada@x.io', actor_kind: 'person', actor_kind_source: 'claim', via: 'edit', version: 2 },
|
|
79
|
+
{ from: 17, to: 37, actorLabel: 'svc:n8n', actorKind: 'machine', actorKindSource: 'claim', version: 3 },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
text,
|
|
83
|
+
)
|
|
84
|
+
expect(spans).toHaveLength(2)
|
|
85
|
+
expect(text.slice(spans[0].from, spans[0].to)).toBe('๐ Ada wrote this.')
|
|
86
|
+
expect(text.slice(spans[1].from, spans[1].to)).toBe(' Machine wrote that.')
|
|
87
|
+
expect(spans[1].author).toMatchObject({ label: 'svc:n8n', kind: 'machine' })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('keeps an unknown span with its reason, and never invents an author', () => {
|
|
91
|
+
const [s] = normalizeBlame(
|
|
92
|
+
{ spans: [{ from: 0, to: 5, reason: 'before history began 2026-10-06' }] },
|
|
93
|
+
'hello',
|
|
94
|
+
)
|
|
95
|
+
expect(s.author).toBeNull()
|
|
96
|
+
expect(s.unknownReason).toBe('before history began 2026-10-06')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('treats a span with a reason as unknown even if actor columns are set', () => {
|
|
100
|
+
const [s] = normalizeBlame(
|
|
101
|
+
{ spans: [{ from: 0, to: 5, actor_label: 'x', reason: 'value truncated in revision 4' }] },
|
|
102
|
+
'hello',
|
|
103
|
+
)
|
|
104
|
+
expect(s.author).toBeNull()
|
|
105
|
+
expect(s.unknownReason).toBe('value truncated in revision 4')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('credits an accepted suggestion to its author with the acceptor beside it', () => {
|
|
109
|
+
const [s] = normalizeBlame(
|
|
110
|
+
{
|
|
111
|
+
spans: [
|
|
112
|
+
{
|
|
113
|
+
from: 0,
|
|
114
|
+
to: 5,
|
|
115
|
+
actor_label: 'author@x',
|
|
116
|
+
actor_kind: 'person',
|
|
117
|
+
via: 'accepted_suggestion',
|
|
118
|
+
accepted_by: { actor_label: 'acceptor@x', actor_kind: 'person' },
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
'hello',
|
|
123
|
+
)
|
|
124
|
+
expect(s.via).toBe('accepted_suggestion')
|
|
125
|
+
expect(s.author?.label).toBe('author@x')
|
|
126
|
+
expect(s.acceptedBy?.label).toBe('acceptor@x')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('drops spans outside the text rather than painting past the end', () => {
|
|
130
|
+
const spans = normalizeBlame({ spans: [{ from: 3, to: 99, actor_label: 'x' }] }, 'hello')
|
|
131
|
+
expect(spans[0]).toMatchObject({ from: 3, to: 5 })
|
|
132
|
+
expect(normalizeBlame({ spans: [{ from: 9, to: 12, actor_label: 'x' }] }, 'hello')).toHaveLength(0)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('honours offset_unit utf16', () => {
|
|
136
|
+
const t = '๐ab'
|
|
137
|
+
const [s] = normalizeBlame({ offset_unit: 'utf16', spans: [{ from: 2, to: 4, actor_label: 'x' }] }, t)
|
|
138
|
+
expect(t.slice(s.from, s.to)).toBe('ab')
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
describe('normalizeSuggestions', () => {
|
|
143
|
+
const text = 'Hello ๐ world, this is the draft.'
|
|
144
|
+
|
|
145
|
+
it('reads a page or a bare array, both spellings, and converts offsets', () => {
|
|
146
|
+
const cpFrom = Array.from(text.slice(0, text.indexOf('world'))).length
|
|
147
|
+
const page = {
|
|
148
|
+
results: [
|
|
149
|
+
{
|
|
150
|
+
id: 's1',
|
|
151
|
+
set_id: 'set1',
|
|
152
|
+
anchor: { from: cpFrom, to: cpFrom + 5, quote: 'world' },
|
|
153
|
+
replacement: 'planet',
|
|
154
|
+
actor_label: 'bo@x',
|
|
155
|
+
actor_kind: 'person',
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
}
|
|
159
|
+
const [s] = normalizeSuggestions(page, text)
|
|
160
|
+
expect(text.slice(s.from, s.to)).toBe('world')
|
|
161
|
+
expect(s).toMatchObject({ id: 's1', setId: 'set1', replacement: 'planet', status: 'open', anchorState: 'mapped' })
|
|
162
|
+
expect(normalizeSuggestions([page.results[0]], text)).toHaveLength(1)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('prefers the mapped anchor over the proposed one', () => {
|
|
166
|
+
const [s] = normalizeSuggestions(
|
|
167
|
+
[{ id: 's', anchor: { from: 0, to: 5, quote: 'Hello' }, mapped_anchor: { from: 0, to: 5, quote: 'Hello' }, replacement: 'Hi', anchor_status: 'reanchored' }],
|
|
168
|
+
text,
|
|
169
|
+
)
|
|
170
|
+
expect(s.anchorState).toBe('reanchored')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('orphans a suggestion whose quote does not match the text at its offsets', () => {
|
|
174
|
+
const [s] = normalizeSuggestions(
|
|
175
|
+
[{ id: 's', anchor: { from: 0, to: 5, quote: 'Howdy' }, replacement: 'Hi' }],
|
|
176
|
+
text,
|
|
177
|
+
)
|
|
178
|
+
expect(s.anchorState).toBe('orphaned')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('keeps an orphaned suggestion (never silently dropped)', () => {
|
|
182
|
+
const list = normalizeSuggestions(
|
|
183
|
+
[{ id: 's', anchor: { from: 0, to: 0, quote: 'gone' }, replacement: 'x', anchor_status: 'orphaned' }],
|
|
184
|
+
text,
|
|
185
|
+
)
|
|
186
|
+
expect(list).toHaveLength(1)
|
|
187
|
+
expect(list[0].anchorState).toBe('orphaned')
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
describe('normalizeThreads', () => {
|
|
192
|
+
it('reads anchored, field-level and tombstoned comments', () => {
|
|
193
|
+
const text = 'One two three'
|
|
194
|
+
const threads = normalizeThreads(
|
|
195
|
+
{
|
|
196
|
+
results: [
|
|
197
|
+
{
|
|
198
|
+
thread_id: 't1',
|
|
199
|
+
anchor: { from: 4, to: 7, quote: 'two' },
|
|
200
|
+
comments: [
|
|
201
|
+
{ id: 'c1', body: 'why two?', actor_label: 'a@x', actor_kind: 'person' },
|
|
202
|
+
{ id: 'c2', body: null, deleted: true, actor_label: 'b@x' },
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
{ id: 't2', anchor: null, resolved: true, comments: [{ id: 'c3', body: 'ok' }] },
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
text,
|
|
209
|
+
)
|
|
210
|
+
expect(threads[0]).toMatchObject({ id: 't1', from: 4, to: 7, quote: 'two', resolved: false })
|
|
211
|
+
expect(threads[0].comments[1]).toMatchObject({ deleted: true, body: null })
|
|
212
|
+
expect(threads[1]).toMatchObject({ id: 't2', from: null, to: null, resolved: true })
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
describe('word diff', () => {
|
|
217
|
+
it('tokenizes words, whitespace, punctuation and each CJK character', () => {
|
|
218
|
+
expect(tokenize('Hi, you there')).toEqual(['Hi', ',', ' ', 'you', ' ', 'there'])
|
|
219
|
+
expect(tokenize('ไธญๆ ok')).toEqual(['ไธญ', 'ๆ', ' ', 'ok'])
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('a one-word edit in a long single-line paragraph changes one word, not the line', () => {
|
|
223
|
+
const para = 'word '.repeat(100) + 'target ' + 'word '.repeat(100)
|
|
224
|
+
const next = para.replace('target', 'changed')
|
|
225
|
+
const changes = wordChanges(para, next)
|
|
226
|
+
expect(changes).toHaveLength(1)
|
|
227
|
+
expect(changes[0].to - changes[0].from).toBeLessThanOrEqual('target'.length)
|
|
228
|
+
expect(applyTextChanges(para, changes)).toBe(next)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('is exact on random edits (apply(diff(a,b), a) === b)', () => {
|
|
232
|
+
let seed = 7
|
|
233
|
+
const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff)
|
|
234
|
+
const words = ['alpha', 'beta', ' ', '\n', ',', 'ไธญ', '๐', 'gamma', '\r\n', ' ']
|
|
235
|
+
const gen = (n: number) => Array.from({ length: n }, () => words[Math.floor(rnd() * words.length)]).join('')
|
|
236
|
+
for (let i = 0; i < 200; i++) {
|
|
237
|
+
const a = gen(Math.floor(rnd() * 40))
|
|
238
|
+
const b = rnd() < 0.5 ? gen(Math.floor(rnd() * 40)) : a.slice(0, Math.floor(rnd() * a.length)) + gen(3) + a.slice(Math.floor(rnd() * a.length))
|
|
239
|
+
expect(applyTextChanges(a, wordChanges(a, b))).toBe(b)
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('returns no changes for identical text', () => {
|
|
244
|
+
expect(wordChanges('same', 'same')).toEqual([])
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
describe('the shapes on the RED branches (start-simpli-api feat/tc-suggestions-comments, 2026-10-07)', () => {
|
|
249
|
+
const BLOG = 'The Gulf is calm this week. Shipping rates fell sharply on Monday. Analysts expect a rebound by spring.'
|
|
250
|
+
const at = (q: string) => BLOG.indexOf(q)
|
|
251
|
+
|
|
252
|
+
it('reads a suggestion with a nested author and a position object', () => {
|
|
253
|
+
const [s] = normalizeSuggestions(
|
|
254
|
+
{
|
|
255
|
+
next: null,
|
|
256
|
+
next_cursor: null,
|
|
257
|
+
results: [
|
|
258
|
+
{
|
|
259
|
+
id: 's1',
|
|
260
|
+
set_id: 'set1',
|
|
261
|
+
field: 'blog',
|
|
262
|
+
base_version: 1,
|
|
263
|
+
status: 'open',
|
|
264
|
+
anchor: { from: at('fell sharply'), to: at('fell sharply') + 12, quote: 'fell sharply', prefix: 'rates ', suffix: ' on' },
|
|
265
|
+
position: { from: at('fell sharply'), to: at('fell sharply') + 12, state: 'mapped' },
|
|
266
|
+
replacement: 'dropped',
|
|
267
|
+
author: { sub: 'rev', label: 'rev@x.io', kind: 'person', kind_source: 'claim' },
|
|
268
|
+
},
|
|
269
|
+
],
|
|
270
|
+
},
|
|
271
|
+
BLOG,
|
|
272
|
+
)
|
|
273
|
+
expect(s).toMatchObject({ id: 's1', setId: 'set1', quote: 'fell sharply', replacement: 'dropped', anchorState: 'mapped' })
|
|
274
|
+
expect(s.author).toEqual({ sub: 'rev', label: 'rev@x.io', kind: 'person', kindSource: 'claim' })
|
|
275
|
+
expect(BLOG.slice(s.from, s.to)).toBe('fell sharply')
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('uses the position, not the proposed anchor, after the text moved', () => {
|
|
279
|
+
const moved = 'Overall, the' + BLOG.slice(3)
|
|
280
|
+
const p = moved.indexOf('fell sharply')
|
|
281
|
+
const [s] = normalizeSuggestions(
|
|
282
|
+
[{ id: 's', anchor: { from: at('fell sharply'), to: at('fell sharply') + 12, quote: 'fell sharply' }, position: { from: p, to: p + 12, state: 'reanchored' }, replacement: 'x' }],
|
|
283
|
+
moved,
|
|
284
|
+
)
|
|
285
|
+
expect(moved.slice(s.from, s.to)).toBe('fell sharply')
|
|
286
|
+
expect(s.anchorState).toBe('reanchored')
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it('reads an orphaned position (from null) as orphaned, keeping the quote', () => {
|
|
290
|
+
const [s] = normalizeSuggestions(
|
|
291
|
+
[{ id: 's', anchor: { from: 0, to: 3, quote: 'gone text' }, position: { from: null, to: null, state: 'orphaned' }, replacement: 'x' }],
|
|
292
|
+
BLOG,
|
|
293
|
+
)
|
|
294
|
+
expect(s.anchorState).toBe('orphaned')
|
|
295
|
+
expect(s.quote).toBe('gone text')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('reads a thread with status, position and nested comment authors', () => {
|
|
299
|
+
const [t] = normalizeThreads(
|
|
300
|
+
{
|
|
301
|
+
results: [
|
|
302
|
+
{
|
|
303
|
+
id: 't1',
|
|
304
|
+
field: 'blog',
|
|
305
|
+
status: 'resolved',
|
|
306
|
+
anchor: { from: at('fell sharply'), to: at('fell sharply') + 12, quote: 'fell sharply' },
|
|
307
|
+
position: { from: at('fell sharply'), to: at('fell sharply') + 12, state: 'mapped' },
|
|
308
|
+
comments: [{ id: 'c1', body: 'Is this still true?', deleted: false, author: { sub: 'rev', label: 'rev@x.io', kind: 'person' }, edited_at: null, mentions: [] }],
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
},
|
|
312
|
+
BLOG,
|
|
313
|
+
)
|
|
314
|
+
expect(t).toMatchObject({ id: 't1', resolved: true, quote: 'fell sharply', anchorState: 'mapped' })
|
|
315
|
+
expect(t.comments[0].author.label).toBe('rev@x.io')
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
describe('a pure insert has no quote to check, so its context is checked', () => {
|
|
320
|
+
const text = 'Rates fell sharply on Monday.'
|
|
321
|
+
const at = text.indexOf(' on Monday')
|
|
322
|
+
|
|
323
|
+
it('places an insert whose neighbouring text still matches', () => {
|
|
324
|
+
const [s] = normalizeSuggestions(
|
|
325
|
+
[{ id: 's', anchor: { from: at, to: at, quote: '', prefix: 'Rates fell sharply', suffix: ' on Monday.' }, position: { from: at, to: at, state: 'mapped' }, replacement: ' and fast' }],
|
|
326
|
+
text,
|
|
327
|
+
)
|
|
328
|
+
expect(s.anchorState).toBe('mapped')
|
|
329
|
+
expect(s.from).toBe(at)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('orphans an insert whose position lands between different words on both sides', () => {
|
|
333
|
+
const [s] = normalizeSuggestions(
|
|
334
|
+
[{ id: 's', anchor: { from: 3, to: 3, quote: '', prefix: 'Rates fell sharply', suffix: ' on Monday.' }, position: { from: 3, to: 3, state: 'mapped' }, replacement: 'x' }],
|
|
335
|
+
text,
|
|
336
|
+
)
|
|
337
|
+
expect(s.anchorState).toBe('orphaned')
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
it('keeps an insert when only text further away changed (one side still matches)', () => {
|
|
341
|
+
const moved = 'Prices fell sharply on Monday.'
|
|
342
|
+
const p = moved.indexOf(' on Monday')
|
|
343
|
+
const [s] = normalizeSuggestions(
|
|
344
|
+
[{ id: 's', anchor: { from: at, to: at, quote: '', prefix: 'Rates fell sharply', suffix: ' on Monday.' }, position: { from: p, to: p, state: 'mapped' }, replacement: 'x' }],
|
|
345
|
+
moved,
|
|
346
|
+
)
|
|
347
|
+
expect(s.anchorState).toBe('mapped')
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
describe('the final blame contract (start-simpli-api #507, startsim-gulrr)', () => {
|
|
352
|
+
const text = '๐ Old intro. Ada wrote this. Bo suggested that.'
|
|
353
|
+
const u = (s: string) => text.indexOf(s) // UTF-16, offset_unit is always utf16
|
|
354
|
+
const response = {
|
|
355
|
+
entity_id: 'e1',
|
|
356
|
+
field: 'blog',
|
|
357
|
+
version: 4,
|
|
358
|
+
history: { enabled: true, declared_by: 'type' },
|
|
359
|
+
offset_unit: 'utf16' as const,
|
|
360
|
+
length: text.length,
|
|
361
|
+
recorded: true,
|
|
362
|
+
trail_starts: { version: 2, at: '2026-10-06T06:00:00Z', from_creation: false },
|
|
363
|
+
spans: [
|
|
364
|
+
{ from: 0, to: u(' Ada'), via: 'unknown', reason: 'before_history', version: null, detail: 'Written before history began on 2026-10-06.' },
|
|
365
|
+
{ from: u(' Ada'), to: u(' Bo'), via: 'edit', version: 3, at: '2026-10-06T09:00:00Z', actor_sub: 'ada', actor_label: 'ada@x.io', actor_kind: 'person', actor_kind_source: 'claim' },
|
|
366
|
+
{
|
|
367
|
+
from: u(' Bo'), to: text.length, via: 'accepted_suggestion', version: 4, at: '2026-10-07T08:00:00Z',
|
|
368
|
+
actor_sub: 'bo', actor_label: 'bo@x.io', actor_kind: 'person', actor_kind_source: 'claim',
|
|
369
|
+
accepted_by: { actor_sub: 'ada', actor_label: 'ada@x.io', actor_kind: 'person', actor_kind_source: 'claim' },
|
|
370
|
+
suggestion_id: 's1', set_id: 'set1',
|
|
371
|
+
},
|
|
372
|
+
],
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
it('(1) reads via "unknown" as an unknown span with no author', () => {
|
|
376
|
+
const [s] = normalizeBlame(response, text)
|
|
377
|
+
expect(s.author).toBeNull()
|
|
378
|
+
expect(s.via).toBe('unknown')
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('(2) reads the reason code, and shows the serverโs sentence', () => {
|
|
382
|
+
const [s] = normalizeBlame(response, text)
|
|
383
|
+
expect(s.reason).toBe('before_history')
|
|
384
|
+
expect(s.unknownReason).toBe('Written before history began on 2026-10-06.')
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it.each([
|
|
388
|
+
['before_history', 'written before the history began'],
|
|
389
|
+
['truncated', 'a revision was too large to store in full'],
|
|
390
|
+
['values_omitted', 'a revision was too large to store its values'],
|
|
391
|
+
['chain_break', 'the history has a gap here'],
|
|
392
|
+
['unrecorded_change', 'changed without a recorded revision'],
|
|
393
|
+
])('(2) says what %s means when the server sends no detail', (reason, words) => {
|
|
394
|
+
const [s] = normalizeBlame({ offset_unit: 'utf16', spans: [{ from: 0, to: 3, via: 'unknown', reason, version: null }] }, 'abc')
|
|
395
|
+
expect(s.unknownReason).toBe(words)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('(3) reads accepted_by as an object with actor_* keys', () => {
|
|
399
|
+
const s = normalizeBlame(response, text)[2]
|
|
400
|
+
expect(s.via).toBe('accepted_suggestion')
|
|
401
|
+
expect(s.author).toMatchObject({ sub: 'bo', label: 'bo@x.io', kind: 'person' })
|
|
402
|
+
expect(s.acceptedBy).toEqual({ sub: 'ada', label: 'ada@x.io', kind: 'person', kindSource: 'claim' })
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
it('(4) reads the author from flat columns on the span', () => {
|
|
406
|
+
const s = normalizeBlame(response, text)[1]
|
|
407
|
+
expect(s.author).toEqual({ sub: 'ada', label: 'ada@x.io', kind: 'person', kindSource: 'claim' })
|
|
408
|
+
expect(text.slice(s.from, s.to)).toBe(' Ada wrote this.')
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it('(5) reads spans null with recorded false as nothing to paint, and says why', () => {
|
|
412
|
+
const off = { offset_unit: 'utf16' as const, recorded: false, spans: null, detail: 'History is turned off for this type.' }
|
|
413
|
+
expect(normalizeBlame(off, text)).toEqual([])
|
|
414
|
+
expect(blameNotice(off)).toBe('Who wrote each passage is not recorded: History is turned off for this type.')
|
|
415
|
+
expect(blameNotice(response)).toBeNull()
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
it('(6) keeps an unknown span whose version is null', () => {
|
|
419
|
+
const [s] = normalizeBlame(response, text)
|
|
420
|
+
expect(s.version).toBeNull()
|
|
421
|
+
expect(text.slice(s.from, s.to)).toBe('๐ Old intro.')
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
it('offset_unit utf16: an emoji before a span does not shift it', () => {
|
|
425
|
+
const spans = normalizeBlame(response, text)
|
|
426
|
+
expect(spans.map((s) => text.slice(s.from, s.to))).toEqual(['๐ Old intro.', ' Ada wrote this.', ' Bo suggested that.'])
|
|
427
|
+
})
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
describe('the final suggestion and comment contract (start-simpli-api #508)', () => {
|
|
431
|
+
const BLOG = 'Rates ๐ fell sharply on Monday.'
|
|
432
|
+
const f = BLOG.indexOf('fell') // UTF-16, as the wire now is
|
|
433
|
+
|
|
434
|
+
it('reads utf16 positions without converting them as code points', () => {
|
|
435
|
+
const [s] = normalizeSuggestions(
|
|
436
|
+
{
|
|
437
|
+
offset_unit: 'utf16', version: 3, next: null, next_cursor: null,
|
|
438
|
+
results: [{
|
|
439
|
+
id: 's1', set_id: 'x', field: 'blog', status: 'open', base_version: 2,
|
|
440
|
+
anchor: { from: f, to: f + 4, quote: 'fell', prefix: 'Rates ๐ ', suffix: ' sharply' },
|
|
441
|
+
position: { state: 'mapped', from: f, to: f + 4, version: 3 },
|
|
442
|
+
replacement: 'dropped', author: { sub: 'bo', label: 'bo@x', kind: 'person', kind_source: 'claim' },
|
|
443
|
+
created_at: '2026-10-08T00:00:00Z', resolution: null,
|
|
444
|
+
}],
|
|
445
|
+
},
|
|
446
|
+
BLOG,
|
|
447
|
+
)
|
|
448
|
+
expect(BLOG.slice(s.from, s.to)).toBe('fell')
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it('a null position (not open, or a record comment) is not placed and not called orphaned', () => {
|
|
452
|
+
const [s] = normalizeSuggestions(
|
|
453
|
+
[{ id: 's', status: 'accepted', anchor: { from: 0, to: 5, quote: 'Rates' }, position: null, replacement: 'R' }],
|
|
454
|
+
BLOG,
|
|
455
|
+
)
|
|
456
|
+
expect(s.status).toBe('accepted')
|
|
457
|
+
expect(s.anchorState).toBe('mapped')
|
|
458
|
+
const [t] = normalizeThreads({ offset_unit: 'utf16', version: 3, results: [{ id: 't', field: null, anchor: null, position: null, status: 'open', comments: [] }] }, BLOG)
|
|
459
|
+
expect(t).toMatchObject({ from: null, to: null, anchorState: 'mapped' })
|
|
460
|
+
})
|
|
461
|
+
})
|