@startsimpli/ui 0.4.135 → 0.4.137
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 +4 -3
- package/src/components/ActivityTimeline.tsx +22 -4
- package/src/components/ActorIdentity.tsx +144 -4
- package/src/components/history/RecordHistoryPanel.tsx +303 -0
- package/src/components/history/RevisionFields.tsx +224 -0
- package/src/components/history/StaleSaveDialog.tsx +277 -0
- package/src/components/history/__tests__/conditional-write.test.ts +252 -0
- package/src/components/history/__tests__/record-history-panel.test.tsx +312 -0
- package/src/components/history/__tests__/revision-history.test.ts +355 -0
- package/src/components/history/__tests__/stale-save-dialog.test.tsx +224 -0
- package/src/components/history/__tests__/text-diff.test.ts +59 -0
- package/src/components/history/__tests__/use-conditional-save.test.tsx +387 -0
- package/src/components/history/conditional-write.ts +340 -0
- package/src/components/history/index.ts +125 -0
- package/src/components/history/record-history-panel-default-class-names.ts +29 -0
- package/src/components/history/revision-history.ts +484 -0
- package/src/components/history/stale-save-dialog-default-class-names.ts +45 -0
- package/src/components/history/text-diff.ts +139 -0
- package/src/components/history/types.ts +167 -0
- package/src/components/history/useConditionalSave.ts +356 -0
- package/src/components/index.ts +11 -1
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fold, and what one field change SAYS (bd startsim-g4rwo).
|
|
3
|
+
*
|
|
4
|
+
* Every case here is one of the server contract's edge cases that a naive
|
|
5
|
+
* reading gets wrong — absence vs null, truncation gating the delete
|
|
6
|
+
* inference, `{}` meaning three different things, and the four fold refusals.
|
|
7
|
+
* Pure module, so none of it needs a render or a backend.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
COLLAPSE_WINDOW_MS,
|
|
11
|
+
actorKindOf,
|
|
12
|
+
actorKindSourceOf,
|
|
13
|
+
canDiffFieldChange,
|
|
14
|
+
classifyFieldChange,
|
|
15
|
+
foldRevisions,
|
|
16
|
+
normalizeFieldChange,
|
|
17
|
+
normalizeRevision,
|
|
18
|
+
sittingHeadline,
|
|
19
|
+
sittingSummary,
|
|
20
|
+
} from '../revision-history'
|
|
21
|
+
import type { EntityRevision } from '../types'
|
|
22
|
+
|
|
23
|
+
const T0 = Date.parse('2026-10-05T12:00:00.000Z')
|
|
24
|
+
const at = (msAfter: number) => new Date(T0 + msAfter).toISOString()
|
|
25
|
+
|
|
26
|
+
function rev(over: Partial<EntityRevision> & { version: number }): EntityRevision {
|
|
27
|
+
return {
|
|
28
|
+
id: `r${over.version}`,
|
|
29
|
+
action: 'updated',
|
|
30
|
+
source: 'patch',
|
|
31
|
+
precondition: 'none',
|
|
32
|
+
created_at: at(0),
|
|
33
|
+
actor_sub: 'u1',
|
|
34
|
+
actor_label: 'jurga@ogmc.example',
|
|
35
|
+
actor_kind: 'person',
|
|
36
|
+
actor_kind_source: 'claim',
|
|
37
|
+
metadata: { changed: {} },
|
|
38
|
+
...over,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('actorKindOf — three-state, failing closed', () => {
|
|
43
|
+
it('reads person and machine', () => {
|
|
44
|
+
expect(actorKindOf(rev({ version: 1, actor_kind: 'person' }))).toBe('person')
|
|
45
|
+
expect(actorKindOf(rev({ version: 1, actor_kind: 'machine' }))).toBe('machine')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('reads an ABSENT kind as unknown, never as person', () => {
|
|
49
|
+
expect(actorKindOf({ id: 'x', version: 1 })).toBe('unknown')
|
|
50
|
+
expect(actorKindOf(rev({ version: 1, actor_kind: null }))).toBe('unknown')
|
|
51
|
+
expect(actorKindOf(rev({ version: 1, actor_kind: '' }))).toBe('unknown')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('reads a value from outside the vocabulary as unknown', () => {
|
|
55
|
+
expect(actorKindOf(rev({ version: 1, actor_kind: 'robot' }))).toBe('unknown')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('reads the CAMELISED spelling the shared API client produces', () => {
|
|
59
|
+
expect(actorKindOf({ id: 'x', version: 1, actorKind: 'machine' })).toBe('machine')
|
|
60
|
+
expect(actorKindSourceOf({ id: 'x', version: 1, actorKindSource: 'inferred' })).toBe('inferred')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('distinguishes a claimed kind from an inferred one', () => {
|
|
64
|
+
expect(actorKindSourceOf(rev({ version: 1, actor_kind_source: 'claim' }))).toBe('claim')
|
|
65
|
+
expect(actorKindSourceOf(rev({ version: 1, actor_kind_source: 'inferred' }))).toBe('inferred')
|
|
66
|
+
expect(actorKindSourceOf({ id: 'x', version: 1 })).toBeUndefined()
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('normalizeFieldChange — absence is the payload', () => {
|
|
71
|
+
it('a CREATED key has after and no before', () => {
|
|
72
|
+
const c = normalizeFieldChange({ after: 'hello' })
|
|
73
|
+
expect(c.hasBefore).toBe(false)
|
|
74
|
+
expect(c.hasAfter).toBe(true)
|
|
75
|
+
expect(classifyFieldChange(c)).toBe('created')
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('a DELETED key has before and no after', () => {
|
|
79
|
+
const c = normalizeFieldChange({ before: 'gone' })
|
|
80
|
+
expect(c.hasAfter).toBe(false)
|
|
81
|
+
expect(classifyFieldChange(c)).toBe('deleted')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('a key set to NULL is an update, not a deletion — null is a real value', () => {
|
|
85
|
+
const c = normalizeFieldChange({ before: 'x', after: null })
|
|
86
|
+
expect(c.hasAfter).toBe(true)
|
|
87
|
+
expect(c.after).toBeNull()
|
|
88
|
+
expect(classifyFieldChange(c)).toBe('updated')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('a key created AS null is created, not deleted', () => {
|
|
92
|
+
const c = normalizeFieldChange({ after: null })
|
|
93
|
+
expect(classifyFieldChange(c)).toBe('created')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('a WITHHELD key is {} and classifies as withheld, not as a deletion', () => {
|
|
97
|
+
expect(classifyFieldChange(normalizeFieldChange({}, { withheld: true }))).toBe('withheld')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('an OMITTED row’s key is {} and classifies as omitted', () => {
|
|
101
|
+
expect(classifyFieldChange(normalizeFieldChange({}, { omitted: true }))).toBe('omitted')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('TRUNCATION WINS over the delete inference — a missing side says nothing', () => {
|
|
105
|
+
// before present, after absent, but truncated: this is NOT a deletion.
|
|
106
|
+
const c = normalizeFieldChange({ before: 'a very long prefix', truncated: true })
|
|
107
|
+
expect(classifyFieldChange(c)).toBe('truncated')
|
|
108
|
+
expect(classifyFieldChange(c)).not.toBe('deleted')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('refuses to diff a truncated, withheld or omitted side', () => {
|
|
112
|
+
expect(canDiffFieldChange(normalizeFieldChange({ before: 'a', after: 'b', truncated: true }))).toBe(false)
|
|
113
|
+
expect(canDiffFieldChange(normalizeFieldChange({}, { withheld: true }))).toBe(false)
|
|
114
|
+
expect(canDiffFieldChange(normalizeFieldChange({}, { omitted: true }))).toBe(false)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('diffs only when both sides are text', () => {
|
|
118
|
+
expect(canDiffFieldChange(normalizeFieldChange({ before: 'a', after: 'b' }))).toBe(true)
|
|
119
|
+
expect(canDiffFieldChange(normalizeFieldChange({ before: { a: 1 }, after: { a: 2 } }))).toBe(false)
|
|
120
|
+
expect(canDiffFieldChange(normalizeFieldChange({ after: 'new' }))).toBe(false)
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('normalizeRevision', () => {
|
|
125
|
+
it('marks a field named in redacted_fields as withheld', () => {
|
|
126
|
+
const n = normalizeRevision(
|
|
127
|
+
rev({ version: 3, metadata: { changed: { body: {} } }, redacted_fields: ['body'] }),
|
|
128
|
+
)
|
|
129
|
+
expect(n.changed.body!.withheld).toBe(true)
|
|
130
|
+
expect(n.redacted).toEqual(['body'])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('marks every field omitted when the ROW was over the cap', () => {
|
|
134
|
+
const n = normalizeRevision(
|
|
135
|
+
rev({ version: 3, metadata: { changed: { a: {}, b: {} }, values_omitted: true } }),
|
|
136
|
+
)
|
|
137
|
+
expect(n.valuesOmitted).toBe(true)
|
|
138
|
+
expect(n.changed.a!.omitted).toBe(true)
|
|
139
|
+
expect(n.changed.b!.omitted).toBe(true)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('reads values_omitted in its camelised spelling too', () => {
|
|
143
|
+
const n = normalizeRevision(rev({ version: 1, metadata: { changed: { a: {} }, valuesOmitted: true } }))
|
|
144
|
+
expect(n.valuesOmitted).toBe(true)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('a PRESERVED row with no changes is an ATTEMPT', () => {
|
|
148
|
+
const n = normalizeRevision(
|
|
149
|
+
rev({ version: 4, metadata: { changed: {}, preserved: ['blog'] } }),
|
|
150
|
+
)
|
|
151
|
+
expect(n.isAttempt).toBe(true)
|
|
152
|
+
expect(n.preserved).toEqual(['blog'])
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('a row that both changed and preserved something is NOT an attempt', () => {
|
|
156
|
+
const n = normalizeRevision(
|
|
157
|
+
rev({ version: 4, metadata: { changed: { seo: { after: 'x' } }, preserved: ['blog'] } }),
|
|
158
|
+
)
|
|
159
|
+
expect(n.isAttempt).toBe(false)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('survives a row with no metadata at all', () => {
|
|
163
|
+
const n = normalizeRevision({ id: 'x', version: 1 })
|
|
164
|
+
expect(n.changed).toEqual({})
|
|
165
|
+
expect(n.isAttempt).toBe(false)
|
|
166
|
+
expect(n.actorKind).toBe('unknown')
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
describe('foldRevisions', () => {
|
|
171
|
+
it('folds consecutive same-actor saves inside the window into one sitting', () => {
|
|
172
|
+
const rows = [
|
|
173
|
+
rev({ version: 3, created_at: at(4000), metadata: { changed: { blog: { before: 'b', after: 'c' } } } }),
|
|
174
|
+
rev({ version: 2, created_at: at(2000), metadata: { changed: { blog: { before: 'a', after: 'b' } } } }),
|
|
175
|
+
rev({ version: 1, created_at: at(0), action: 'created', metadata: { changed: { blog: { after: 'a' } } } }),
|
|
176
|
+
]
|
|
177
|
+
const folded = foldRevisions(rows)
|
|
178
|
+
// The create does not absorb the later edits.
|
|
179
|
+
expect(folded).toHaveLength(2)
|
|
180
|
+
expect(folded[0]!.saves).toBe(2)
|
|
181
|
+
expect(folded[1]!.action).toBe('created')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('takes BEFORE from the oldest row and AFTER from the newest', () => {
|
|
185
|
+
const rows = [
|
|
186
|
+
rev({ version: 3, created_at: at(4000), metadata: { changed: { blog: { before: 'two', after: 'three' } } } }),
|
|
187
|
+
rev({ version: 2, created_at: at(2000), metadata: { changed: { blog: { before: 'one', after: 'two' } } } }),
|
|
188
|
+
]
|
|
189
|
+
const [sitting] = foldRevisions(rows)
|
|
190
|
+
expect(sitting!.changed.blog!.before).toBe('one')
|
|
191
|
+
expect(sitting!.changed.blog!.after).toBe('three')
|
|
192
|
+
expect(sitting!.fromVersion).toBe(2)
|
|
193
|
+
expect(sitting!.toVersion).toBe(3)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
it('does not fold across the window', () => {
|
|
197
|
+
const rows = [
|
|
198
|
+
rev({ version: 2, created_at: at(COLLAPSE_WINDOW_MS + 1000) }),
|
|
199
|
+
rev({ version: 1, created_at: at(0) }),
|
|
200
|
+
]
|
|
201
|
+
expect(foldRevisions(rows)).toHaveLength(2)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('does not fold two different actors', () => {
|
|
205
|
+
const rows = [
|
|
206
|
+
rev({ version: 2, created_at: at(1000), actor_sub: 'u2', actor_label: 'b@x' }),
|
|
207
|
+
rev({ version: 1, created_at: at(0), actor_sub: 'u1', actor_label: 'a@x' }),
|
|
208
|
+
]
|
|
209
|
+
expect(foldRevisions(rows)).toHaveLength(2)
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('REFUSES to fold across a differing actor_kind even when the sub matches', () => {
|
|
213
|
+
// The healing window: one person's rows read unknown then person. Folding
|
|
214
|
+
// would stamp the sitting with whichever end won.
|
|
215
|
+
const rows = [
|
|
216
|
+
rev({ version: 2, created_at: at(1000), actor_kind: 'person', actor_kind_source: 'claim' }),
|
|
217
|
+
rev({ version: 1, created_at: at(0), actor_kind: 'unknown', actor_kind_source: 'inferred' }),
|
|
218
|
+
]
|
|
219
|
+
const folded = foldRevisions(rows)
|
|
220
|
+
expect(folded).toHaveLength(2)
|
|
221
|
+
expect(folded[0]!.actorKind).toBe('person')
|
|
222
|
+
expect(folded[1]!.actorKind).toBe('unknown')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('REFUSES to fold across a differing actor_kind_source', () => {
|
|
226
|
+
const rows = [
|
|
227
|
+
rev({ version: 2, created_at: at(1000), actor_kind_source: 'claim' }),
|
|
228
|
+
rev({ version: 1, created_at: at(0), actor_kind_source: 'inferred' }),
|
|
229
|
+
]
|
|
230
|
+
expect(foldRevisions(rows)).toHaveLength(2)
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('REFUSES to fold an ATTEMPT into a change', () => {
|
|
234
|
+
const rows = [
|
|
235
|
+
rev({
|
|
236
|
+
version: 2,
|
|
237
|
+
created_at: at(1000),
|
|
238
|
+
actor_sub: 'svc:n8n',
|
|
239
|
+
actor_kind: 'machine',
|
|
240
|
+
actor_kind_source: 'inferred',
|
|
241
|
+
metadata: { changed: {}, preserved: ['blog'] },
|
|
242
|
+
}),
|
|
243
|
+
rev({
|
|
244
|
+
version: 1,
|
|
245
|
+
created_at: at(0),
|
|
246
|
+
actor_sub: 'svc:n8n',
|
|
247
|
+
actor_kind: 'machine',
|
|
248
|
+
actor_kind_source: 'inferred',
|
|
249
|
+
metadata: { changed: { seo: { after: 'x' } } },
|
|
250
|
+
}),
|
|
251
|
+
]
|
|
252
|
+
const folded = foldRevisions(rows)
|
|
253
|
+
expect(folded).toHaveLength(2)
|
|
254
|
+
expect(folded[0]!.isAttempt).toBe(true)
|
|
255
|
+
expect(folded[1]!.isAttempt).toBe(false)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('folds two attempts together', () => {
|
|
259
|
+
const rows = [
|
|
260
|
+
rev({ version: 2, created_at: at(1000), metadata: { changed: {}, preserved: ['seo'] } }),
|
|
261
|
+
rev({ version: 1, created_at: at(0), metadata: { changed: {}, preserved: ['blog'] } }),
|
|
262
|
+
]
|
|
263
|
+
const folded = foldRevisions(rows)
|
|
264
|
+
expect(folded).toHaveLength(1)
|
|
265
|
+
expect(folded[0]!.preserved).toEqual(['blog', 'seo'])
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('carries a withheld flag FORWARD through a fold rather than dropping it', () => {
|
|
269
|
+
const rows = [
|
|
270
|
+
rev({ version: 2, created_at: at(1000), metadata: { changed: { body: { before: 'x', after: 'y' } } } }),
|
|
271
|
+
rev({ version: 1, created_at: at(0), metadata: { changed: { body: {} } }, redacted_fields: ['body'] }),
|
|
272
|
+
]
|
|
273
|
+
const [sitting] = foldRevisions(rows)
|
|
274
|
+
expect(sitting!.saves).toBe(2)
|
|
275
|
+
expect(sitting!.changed.body!.withheld).toBe(true)
|
|
276
|
+
expect(sitting!.redacted).toEqual(['body'])
|
|
277
|
+
expect(canDiffFieldChange(sitting!.changed.body!)).toBe(false)
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('carries a truncated flag forward, so a fold never produces a clean diff from a capped side', () => {
|
|
281
|
+
const rows = [
|
|
282
|
+
rev({ version: 2, created_at: at(1000), metadata: { changed: { blog: { before: 'b', after: 'c' } } } }),
|
|
283
|
+
rev({ version: 1, created_at: at(0), metadata: { changed: { blog: { before: 'a', truncated: true } } } }),
|
|
284
|
+
]
|
|
285
|
+
const [sitting] = foldRevisions(rows)
|
|
286
|
+
expect(sitting!.changed.blog!.truncated).toBe(true)
|
|
287
|
+
expect(canDiffFieldChange(sitting!.changed.blog!)).toBe(false)
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('windowMs of 0 renders a row per save', () => {
|
|
291
|
+
const rows = [
|
|
292
|
+
rev({ version: 2, created_at: at(1000) }),
|
|
293
|
+
rev({ version: 1, created_at: at(0) }),
|
|
294
|
+
]
|
|
295
|
+
expect(foldRevisions(rows, 0)).toHaveLength(2)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('treats an out-of-order clock as the same sitting rather than inventing a precedence', () => {
|
|
299
|
+
const rows = [rev({ version: 2, created_at: at(0) }), rev({ version: 1, created_at: at(0) })]
|
|
300
|
+
expect(foldRevisions(rows)).toHaveLength(1)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('returns nothing for an empty trail', () => {
|
|
304
|
+
expect(foldRevisions([])).toEqual([])
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
describe('sittingSummary / sittingHeadline', () => {
|
|
309
|
+
const sit = (rows: EntityRevision[]) => foldRevisions(rows)[0]!
|
|
310
|
+
|
|
311
|
+
it('a single save does not claim to be a sitting', () => {
|
|
312
|
+
expect(sittingSummary(sit([rev({ version: 1 })]))).toBe('1 save')
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('a collapsed sitting says it is a collapse, with its span', () => {
|
|
316
|
+
const rows = [
|
|
317
|
+
rev({ version: 2, created_at: at(120_000) }),
|
|
318
|
+
rev({ version: 1, created_at: at(0) }),
|
|
319
|
+
]
|
|
320
|
+
expect(sittingSummary(sit(rows))).toBe('2 saves over 2 min')
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('a sub-minute burst reports the count without a misleading 0 min', () => {
|
|
324
|
+
const rows = [rev({ version: 2, created_at: at(3000) }), rev({ version: 1, created_at: at(0) })]
|
|
325
|
+
expect(sittingSummary(sit(rows))).toBe('2 saves')
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('names the fields it edited', () => {
|
|
329
|
+
const s = sit([rev({ version: 1, metadata: { changed: { blog: { after: 'x' }, seo: { after: 'y' } } } })])
|
|
330
|
+
expect(sittingHeadline(s)).toBe('Edited blog and seo')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('counts instead of listing beyond three fields', () => {
|
|
334
|
+
const changed = Object.fromEntries(['a', 'b', 'c', 'd'].map((k) => [k, { after: '1' }]))
|
|
335
|
+
const s = sit([rev({ version: 1, metadata: { changed } })])
|
|
336
|
+
expect(sittingHeadline(s)).toBe('Edited 4 fields')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it('AN ATTEMPT READS AS AN ATTEMPT — the sentence nobody could produce', () => {
|
|
340
|
+
const s = sit([
|
|
341
|
+
rev({ version: 1, metadata: { changed: {}, preserved: ['team_verdict'] } }),
|
|
342
|
+
])
|
|
343
|
+
expect(sittingHeadline(s)).toBe('Tried to write team_verdict — held back')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('a name-only move reads as a rename', () => {
|
|
347
|
+
const s = sit([rev({ version: 2, metadata: { changed: {}, name: { before: 'a', after: 'b' } } })])
|
|
348
|
+
expect(sittingHeadline(s)).toBe('Renamed the record')
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('applies a fieldLabel mapping', () => {
|
|
352
|
+
const s = sit([rev({ version: 1, metadata: { changed: { team_verdict: { after: 'x' } } } })])
|
|
353
|
+
expect(sittingHeadline(s, (f) => f.replace('_', ' '))).toBe('Edited team verdict')
|
|
354
|
+
})
|
|
355
|
+
})
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the reviewer sees when a stale save is refused (bd startsim-jkkn7.3).
|
|
3
|
+
*/
|
|
4
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
import { StaleSaveDialog } from '../StaleSaveDialog';
|
|
7
|
+
import { normalizeRevision } from '../revision-history';
|
|
8
|
+
import type { StaleSaveConflict } from '../useConditionalSave';
|
|
9
|
+
|
|
10
|
+
const base: StaleSaveConflict = {
|
|
11
|
+
currentVersion: 3,
|
|
12
|
+
sentVersion: 2,
|
|
13
|
+
detail: 'the record changed since it was loaded: version 2 was sent and the record is now at version 3.',
|
|
14
|
+
refusedBody: { data: { blog: 'three paragraphs' } },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const changedBy = (over: Record<string, unknown> = {}) =>
|
|
18
|
+
normalizeRevision({
|
|
19
|
+
id: 'r3',
|
|
20
|
+
version: 3,
|
|
21
|
+
created_at: '2026-10-06T12:00:00Z',
|
|
22
|
+
actor_label: 'jurga@ogmc.example',
|
|
23
|
+
actor_kind: 'person',
|
|
24
|
+
actor_kind_source: 'claim',
|
|
25
|
+
metadata: { changed: { blog: { before: 'a', after: 'b' }, judge_verdict: { after: 'pass' } } },
|
|
26
|
+
...over,
|
|
27
|
+
} as never);
|
|
28
|
+
|
|
29
|
+
describe('it says the text is safe before it says anything else', () => {
|
|
30
|
+
it('leads with the reassurance', () => {
|
|
31
|
+
render(<StaleSaveDialog conflict={base} onDismiss={jest.fn()} />);
|
|
32
|
+
expect(screen.getByTestId('stale-save-reassurance')).toHaveTextContent(
|
|
33
|
+
'Your edits are still here and have not been saved.',
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('renders nothing without a conflict, and nothing when closed', () => {
|
|
38
|
+
const { container, rerender } = render(
|
|
39
|
+
<StaleSaveDialog conflict={null} onDismiss={jest.fn()} />,
|
|
40
|
+
);
|
|
41
|
+
expect(container).toBeEmptyDOMElement();
|
|
42
|
+
rerender(<StaleSaveDialog conflict={base} open={false} onDismiss={jest.fn()} />);
|
|
43
|
+
expect(container).toBeEmptyDOMElement();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('who changed it', () => {
|
|
48
|
+
it('names the person in the headline', () => {
|
|
49
|
+
render(<StaleSaveDialog conflict={{ ...base, changedBy: changedBy() }} onDismiss={jest.fn()} />);
|
|
50
|
+
expect(screen.getByTestId('stale-save-title')).toHaveTextContent(
|
|
51
|
+
/jurga@ogmc\.example\s*changed this record while you were editing\./,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('marks a machine, because a machine overwriting a human is the case this epic exists for', () => {
|
|
56
|
+
render(
|
|
57
|
+
<StaleSaveDialog
|
|
58
|
+
conflict={{
|
|
59
|
+
...base,
|
|
60
|
+
changedBy: changedBy({ actor_label: 'svc:n8n-ogmc', actor_kind: 'machine' }),
|
|
61
|
+
}}
|
|
62
|
+
onDismiss={jest.fn()}
|
|
63
|
+
/>,
|
|
64
|
+
);
|
|
65
|
+
expect(screen.getByTestId('actor-kind')).toHaveTextContent('machine');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('NEVER renders an unrecorded kind as a machine', () => {
|
|
69
|
+
// `unknown` means central did not say. The claim is minted at login, so for
|
|
70
|
+
// up to the 14-day refresh expiry a genuine human edit carries no kind.
|
|
71
|
+
render(
|
|
72
|
+
<StaleSaveDialog
|
|
73
|
+
conflict={{ ...base, changedBy: changedBy({ actor_kind: undefined, actor_kind_source: undefined }) }}
|
|
74
|
+
onDismiss={jest.fn()}
|
|
75
|
+
/>,
|
|
76
|
+
);
|
|
77
|
+
expect(screen.getByTestId('actor-identity')).toHaveAttribute('data-actor-kind', 'unknown');
|
|
78
|
+
expect(screen.queryByTestId('actor-kind')).not.toBeInTheDocument();
|
|
79
|
+
expect(screen.getByTestId('stale-save-dialog')).not.toHaveTextContent(/machine/i);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('says the record changed without guessing at a person when the trail said nobody', () => {
|
|
83
|
+
render(
|
|
84
|
+
<StaleSaveDialog
|
|
85
|
+
conflict={{ ...base, identityGap: 'forbidden' }}
|
|
86
|
+
onDismiss={jest.fn()}
|
|
87
|
+
/>,
|
|
88
|
+
);
|
|
89
|
+
expect(screen.getByTestId('stale-save-title')).toHaveTextContent(
|
|
90
|
+
'This record changed while you were editing.',
|
|
91
|
+
);
|
|
92
|
+
expect(screen.getByTestId('stale-save-identity-gap')).toHaveTextContent(
|
|
93
|
+
/history is not available to you/,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('shows the fields verbatim, never recased', () => {
|
|
98
|
+
render(<StaleSaveDialog conflict={{ ...base, changedBy: changedBy() }} onDismiss={jest.fn()} />);
|
|
99
|
+
const fields = screen.getByTestId('stale-save-fields');
|
|
100
|
+
expect(fields).toHaveTextContent('blog');
|
|
101
|
+
expect(fields).toHaveTextContent('judge_verdict');
|
|
102
|
+
expect(fields).not.toHaveTextContent('judgeVerdict');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("shows the server's own sentence", () => {
|
|
106
|
+
render(<StaleSaveDialog conflict={base} onDismiss={jest.fn()} />);
|
|
107
|
+
expect(screen.getByTestId('stale-save-detail')).toHaveTextContent('now at version 3');
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('the accidental paths dismiss and resolve nothing', () => {
|
|
112
|
+
it('Escape dismisses', () => {
|
|
113
|
+
const onDismiss = jest.fn();
|
|
114
|
+
const onKeepMine = jest.fn();
|
|
115
|
+
const onAcceptTheirs = jest.fn();
|
|
116
|
+
render(
|
|
117
|
+
<StaleSaveDialog
|
|
118
|
+
conflict={base}
|
|
119
|
+
onDismiss={onDismiss}
|
|
120
|
+
onKeepMine={onKeepMine}
|
|
121
|
+
onAcceptTheirs={onAcceptTheirs}
|
|
122
|
+
/>,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
fireEvent.keyDown(document, { key: 'Escape' });
|
|
126
|
+
|
|
127
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
128
|
+
expect(onKeepMine).not.toHaveBeenCalled();
|
|
129
|
+
expect(onAcceptTheirs).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('the backdrop dismisses, and a click inside does not', () => {
|
|
133
|
+
const onDismiss = jest.fn();
|
|
134
|
+
render(<StaleSaveDialog conflict={base} onDismiss={onDismiss} />);
|
|
135
|
+
|
|
136
|
+
fireEvent.click(screen.getByTestId('stale-save-dialog'));
|
|
137
|
+
expect(onDismiss).not.toHaveBeenCalled();
|
|
138
|
+
|
|
139
|
+
fireEvent.click(screen.getByTestId('stale-save-backdrop'));
|
|
140
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('focuses a control that can neither write nor discard', () => {
|
|
144
|
+
const { unmount } = render(
|
|
145
|
+
<StaleSaveDialog
|
|
146
|
+
conflict={base}
|
|
147
|
+
onDismiss={jest.fn()}
|
|
148
|
+
onKeepMine={jest.fn()}
|
|
149
|
+
onAcceptTheirs={jest.fn()}
|
|
150
|
+
onReviewChange={jest.fn()}
|
|
151
|
+
/>,
|
|
152
|
+
);
|
|
153
|
+
expect(screen.getByTestId('stale-save-review')).toHaveFocus();
|
|
154
|
+
unmount();
|
|
155
|
+
|
|
156
|
+
render(
|
|
157
|
+
<StaleSaveDialog conflict={base} onDismiss={jest.fn()} onKeepMine={jest.fn()} />,
|
|
158
|
+
);
|
|
159
|
+
expect(screen.getByTestId('stale-save-dismiss')).toHaveFocus();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('the actions', () => {
|
|
164
|
+
it('labels the overwrite with what it does to their change', () => {
|
|
165
|
+
render(<StaleSaveDialog conflict={base} onDismiss={jest.fn()} onKeepMine={jest.fn()} />);
|
|
166
|
+
expect(screen.getByTestId('stale-save-keep-mine')).toHaveTextContent(
|
|
167
|
+
'Save mine, replacing their change',
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('offers each action only when the host handles it', () => {
|
|
172
|
+
render(<StaleSaveDialog conflict={base} onDismiss={jest.fn()} />);
|
|
173
|
+
expect(screen.queryByTestId('stale-save-keep-mine')).not.toBeInTheDocument();
|
|
174
|
+
expect(screen.queryByTestId('stale-save-accept-theirs')).not.toBeInTheDocument();
|
|
175
|
+
expect(screen.queryByTestId('stale-save-review')).not.toBeInTheDocument();
|
|
176
|
+
// Dismissal is always available: there is always a way back to the text.
|
|
177
|
+
expect(screen.getByTestId('stale-save-dismiss')).toBeInTheDocument();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('fires the handlers', () => {
|
|
181
|
+
const onKeepMine = jest.fn();
|
|
182
|
+
const onAcceptTheirs = jest.fn();
|
|
183
|
+
const onReviewChange = jest.fn();
|
|
184
|
+
render(
|
|
185
|
+
<StaleSaveDialog
|
|
186
|
+
conflict={base}
|
|
187
|
+
onDismiss={jest.fn()}
|
|
188
|
+
onKeepMine={onKeepMine}
|
|
189
|
+
onAcceptTheirs={onAcceptTheirs}
|
|
190
|
+
onReviewChange={onReviewChange}
|
|
191
|
+
/>,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
fireEvent.click(screen.getByTestId('stale-save-keep-mine'));
|
|
195
|
+
fireEvent.click(screen.getByTestId('stale-save-accept-theirs'));
|
|
196
|
+
fireEvent.click(screen.getByTestId('stale-save-review'));
|
|
197
|
+
|
|
198
|
+
expect(onKeepMine).toHaveBeenCalledTimes(1);
|
|
199
|
+
expect(onAcceptTheirs).toHaveBeenCalledTimes(1);
|
|
200
|
+
expect(onReviewChange).toHaveBeenCalledTimes(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('disables the writing actions while a resolution is in flight, but never the way back', () => {
|
|
204
|
+
render(
|
|
205
|
+
<StaleSaveDialog
|
|
206
|
+
conflict={base}
|
|
207
|
+
busy
|
|
208
|
+
onDismiss={jest.fn()}
|
|
209
|
+
onKeepMine={jest.fn()}
|
|
210
|
+
onAcceptTheirs={jest.fn()}
|
|
211
|
+
/>,
|
|
212
|
+
);
|
|
213
|
+
expect(screen.getByTestId('stale-save-keep-mine')).toBeDisabled();
|
|
214
|
+
expect(screen.getByTestId('stale-save-accept-theirs')).toBeDisabled();
|
|
215
|
+
expect(screen.getByTestId('stale-save-dismiss')).toBeEnabled();
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('is a labelled modal', () => {
|
|
219
|
+
render(<StaleSaveDialog conflict={base} onDismiss={jest.fn()} />);
|
|
220
|
+
const dialog = screen.getByRole('dialog');
|
|
221
|
+
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
|
222
|
+
expect(dialog).toHaveAttribute('aria-labelledby', 'stale-save-title');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ported from marketing-agents-web `src/lib/__tests__/blog-diff.test.ts` with
|
|
3
|
+
* the module (bd startsim-g4rwo). Same assertions, jest instead of vitest, and
|
|
4
|
+
* the generic `unifiedTextDiff` name — see `text-diff.ts` on the rename.
|
|
5
|
+
*/
|
|
6
|
+
import { parseDiffFiles } from '../../diff'
|
|
7
|
+
import { diffLines, unifiedTextDiff } from '../text-diff'
|
|
8
|
+
|
|
9
|
+
describe('diffLines', () => {
|
|
10
|
+
it('marks equal / deleted / inserted lines in output order', () => {
|
|
11
|
+
expect(diffLines(['a', 'b', 'c'], ['a', 'x', 'c'])).toEqual([
|
|
12
|
+
{ type: 'eq', line: 'a' },
|
|
13
|
+
{ type: 'del', line: 'b' },
|
|
14
|
+
{ type: 'ins', line: 'x' },
|
|
15
|
+
{ type: 'eq', line: 'c' },
|
|
16
|
+
])
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('unifiedTextDiff', () => {
|
|
21
|
+
it('returns "" for identical values', () => {
|
|
22
|
+
expect(unifiedTextDiff('same\ntext', 'same\ntext')).toBe('')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('emits a git-style block the shared DiffViewer parser can read', () => {
|
|
26
|
+
const diff = unifiedTextDiff(
|
|
27
|
+
'Line one\nLine two\nLine three',
|
|
28
|
+
'Line one\nLine 2\nLine three',
|
|
29
|
+
'blog',
|
|
30
|
+
)
|
|
31
|
+
expect(diff).toContain('diff --git a/blog b/blog')
|
|
32
|
+
expect(diff).toMatch(/^@@ -\d+,\d+ \+\d+,\d+ @@$/m)
|
|
33
|
+
expect(diff).toContain('-Line two')
|
|
34
|
+
expect(diff).toContain('+Line 2')
|
|
35
|
+
const files = parseDiffFiles(diff)
|
|
36
|
+
expect(files).toHaveLength(1)
|
|
37
|
+
expect(files[0]!.filePath).toBe('blog')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('keeps context lines but not the whole document', () => {
|
|
41
|
+
const oldText = Array.from({ length: 20 }, (_, i) => `line ${i}`).join('\n')
|
|
42
|
+
const newText = oldText.replace('line 10', 'LINE TEN')
|
|
43
|
+
const body = unifiedTextDiff(oldText, newText)
|
|
44
|
+
.split('\n')
|
|
45
|
+
.filter((l) => /^[ +-]/.test(l) && !l.startsWith('+++') && !l.startsWith('---'))
|
|
46
|
+
expect(body).toContain('-line 10')
|
|
47
|
+
expect(body).toContain('+LINE TEN')
|
|
48
|
+
expect(body).toContain(' line 7')
|
|
49
|
+
expect(body).not.toContain(' line 0')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('names the field in the header, so the rendered diff says what moved', () => {
|
|
53
|
+
expect(unifiedTextDiff('a', 'b', 'linkedin')).toContain('diff --git a/linkedin b/linkedin')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('tolerates CRLF and a trailing newline without inventing a last line', () => {
|
|
57
|
+
expect(unifiedTextDiff('a\r\nb\r\n', 'a\nb\n')).toBe('')
|
|
58
|
+
})
|
|
59
|
+
})
|