@startsimpli/ui 0.4.134 → 0.4.136

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,312 @@
1
+ /**
2
+ * The rendered panel (bd startsim-g4rwo).
3
+ *
4
+ * The assertions that matter are the presentation rules, not the markup: a
5
+ * machine is always marked, a claimed person never is, `unknown` is CALM and
6
+ * explained once for the panel, a withheld field is named rather than shown as
7
+ * "changed to nothing", an attempt reads as an attempt, and the empty state
8
+ * never claims the record was never changed.
9
+ */
10
+ import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'
11
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
12
+
13
+ import { ActorIdentity } from '../../ActorIdentity'
14
+ import { RecordHistoryPanel } from '../RecordHistoryPanel'
15
+ import type { EntityRevision, RevisionPage } from '../types'
16
+
17
+ function mount(ui: React.ReactElement) {
18
+ const client = new QueryClient({
19
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
20
+ })
21
+ return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
22
+ }
23
+
24
+ const T0 = Date.parse('2026-10-05T12:00:00.000Z')
25
+ const at = (ms: number) => new Date(T0 + ms).toISOString()
26
+
27
+ function rev(over: Partial<EntityRevision> & { version: number }): EntityRevision {
28
+ return {
29
+ id: `r${over.version}`,
30
+ action: 'updated',
31
+ source: 'patch',
32
+ precondition: 'none',
33
+ created_at: at(0),
34
+ actor_sub: 'u1',
35
+ actor_label: 'jurga@ogmc.example',
36
+ actor_kind: 'person',
37
+ actor_kind_source: 'claim',
38
+ metadata: { changed: { blog: { before: 'old', after: 'new' } } },
39
+ ...over,
40
+ }
41
+ }
42
+
43
+ function page(results: EntityRevision[], over: Partial<RevisionPage> = {}): RevisionPage {
44
+ return { count: results.length, next: null, previous: null, results, ...over }
45
+ }
46
+
47
+ const clientFor = (p: RevisionPage) => ({ list: jest.fn().mockResolvedValue(p) })
48
+
49
+ describe('RecordHistoryPanel — the actor axis', () => {
50
+ it('ALWAYS marks a machine, and distinctly from a person', async () => {
51
+ mount(
52
+ <RecordHistoryPanel
53
+ client={clientFor(
54
+ page([
55
+ rev({
56
+ version: 2,
57
+ actor_sub: 'svc:n8n-ogmc',
58
+ actor_label: 'svc:n8n-ogmc',
59
+ actor_kind: 'machine',
60
+ actor_kind_source: 'inferred',
61
+ }),
62
+ rev({ version: 1, created_at: at(-600_000) }),
63
+ ]),
64
+ )}
65
+ />,
66
+ )
67
+ const marks = await screen.findAllByTestId('actor-kind')
68
+ // Exactly ONE marker: the machine row. The claimed person gets none.
69
+ expect(marks).toHaveLength(1)
70
+ expect(marks[0]).toHaveTextContent('machine')
71
+ })
72
+
73
+ it('does NOT decorate a claimed person — the calm, common-good case', async () => {
74
+ mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
75
+ await screen.findByTestId('record-history-panel')
76
+ expect(screen.queryByTestId('actor-kind')).not.toBeInTheDocument()
77
+ })
78
+
79
+ it('renders UNKNOWN with no per-row badge, and explains it ONCE for the panel', async () => {
80
+ mount(
81
+ <RecordHistoryPanel
82
+ client={clientFor(
83
+ page([
84
+ rev({ version: 2, actor_kind: 'unknown', actor_kind_source: 'inferred' }),
85
+ rev({
86
+ version: 1,
87
+ created_at: at(-600_000),
88
+ actor_kind: 'unknown',
89
+ actor_kind_source: 'inferred',
90
+ }),
91
+ ]),
92
+ )}
93
+ />,
94
+ )
95
+ const note = await screen.findByTestId('record-history-unknown-kind')
96
+ // ONE sentence for two unknown rows — not a badge per row, which is what
97
+ // would make the panel look broken for the whole rollout window.
98
+ expect(note).toBeInTheDocument()
99
+ expect(screen.queryByTestId('actor-kind')).not.toBeInTheDocument()
100
+ // And it must not read as a defect.
101
+ expect(note.textContent).toMatch(/not recorded/i)
102
+ expect(note.textContent).not.toMatch(/error|invalid|fail|broken|warning/i)
103
+ })
104
+
105
+ it('still says WHO on an unknown row — only the KIND is unrecorded', async () => {
106
+ mount(
107
+ <RecordHistoryPanel
108
+ client={clientFor(page([rev({ version: 1, actor_kind: 'unknown' })]))}
109
+ />,
110
+ )
111
+ expect(await screen.findByText('jurga@ogmc.example')).toBeInTheDocument()
112
+ })
113
+
114
+ it('does NOT show the unknown explanation when every row has a kind', async () => {
115
+ mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
116
+ await screen.findByTestId('record-history-panel')
117
+ expect(screen.queryByTestId('record-history-unknown-kind')).not.toBeInTheDocument()
118
+ })
119
+
120
+ it('marks a person whose kind was INFERRED, because that is a guess', async () => {
121
+ mount(
122
+ <RecordHistoryPanel
123
+ client={clientFor(page([rev({ version: 1, actor_kind_source: 'inferred' })]))}
124
+ />,
125
+ )
126
+ const mark = await screen.findByTestId('actor-kind')
127
+ expect(mark).toHaveTextContent(/inferred/)
128
+ })
129
+ })
130
+
131
+ describe('RecordHistoryPanel — redaction', () => {
132
+ it('names a withheld field at panel level instead of rendering it as empty', async () => {
133
+ mount(
134
+ <RecordHistoryPanel
135
+ client={clientFor(
136
+ page([rev({ version: 1, metadata: { changed: { body: {} } }, redacted_fields: ['body'] })], {
137
+ redacted_fields: ['body'],
138
+ }),
139
+ )}
140
+ />,
141
+ )
142
+ const note = await screen.findByTestId('record-history-redacted')
143
+ expect(note).toHaveTextContent(/body/)
144
+ expect(note).toHaveTextContent(/not shown to you/i)
145
+ })
146
+
147
+ it('says a withheld field was changed but its values are not shown', async () => {
148
+ mount(
149
+ <RecordHistoryPanel
150
+ client={clientFor(
151
+ page([rev({ version: 1, metadata: { changed: { body: {} } }, redacted_fields: ['body'] })]),
152
+ )}
153
+ />,
154
+ )
155
+ const row = await screen.findByRole('button', { name: /body/ })
156
+ expect(row).toHaveTextContent(/not shown to you/i)
157
+ fireEvent.click(row)
158
+ expect(await screen.findByText(/its\s*values are not shown to you/i)).toBeInTheDocument()
159
+ })
160
+
161
+ it('reads the camelised envelope roll-up too', async () => {
162
+ mount(
163
+ <RecordHistoryPanel
164
+ client={clientFor(
165
+ page([rev({ version: 1, metadata: { changed: { body: {} } }, redactedFields: ['body'] })], {
166
+ redactedFields: ['body'],
167
+ }),
168
+ )}
169
+ />,
170
+ )
171
+ expect(await screen.findByTestId('record-history-redacted')).toHaveTextContent(/body/)
172
+ })
173
+ })
174
+
175
+ describe('RecordHistoryPanel — attempts, truncation and completeness', () => {
176
+ it('an ATTEMPT reads as an attempt, never as a change', async () => {
177
+ mount(
178
+ <RecordHistoryPanel
179
+ client={clientFor(
180
+ page([
181
+ rev({
182
+ version: 1,
183
+ actor_sub: 'svc:n8n-ogmc',
184
+ actor_label: 'svc:n8n-ogmc',
185
+ actor_kind: 'machine',
186
+ actor_kind_source: 'inferred',
187
+ metadata: { changed: {}, preserved: ['team_verdict'] },
188
+ }),
189
+ ]),
190
+ )}
191
+ />,
192
+ )
193
+ expect(
194
+ await screen.findByText(/Tried to write team_verdict — held back/),
195
+ ).toBeInTheDocument()
196
+ expect(screen.getByTestId('revision-preserved')).toHaveTextContent(/was not written/i)
197
+ })
198
+
199
+ it('REFUSES to diff a truncated side and says why', async () => {
200
+ mount(
201
+ <RecordHistoryPanel
202
+ client={clientFor(
203
+ page([
204
+ rev({
205
+ version: 1,
206
+ metadata: { changed: { blog: { before: 'prefix only', after: 'other', truncated: true } } },
207
+ }),
208
+ ]),
209
+ )}
210
+ />,
211
+ )
212
+ const row = await screen.findByRole('button', { name: /blog/ })
213
+ expect(row).toHaveTextContent(/too large to store/i)
214
+ fireEvent.click(row)
215
+ expect(
216
+ await screen.findByText(/a missing side means nothing here/i),
217
+ ).toBeInTheDocument()
218
+ })
219
+
220
+ it('NEVER claims the trail is complete', async () => {
221
+ mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
222
+ const note = await screen.findByTestId('record-history-incomplete')
223
+ expect(note).toHaveTextContent(/not recorded yet/i)
224
+ })
225
+
226
+ it('an empty history says "no edits recorded", not "never changed"', async () => {
227
+ mount(<RecordHistoryPanel client={clientFor(page([]))} />)
228
+ expect(await screen.findByText('No edits recorded for this record')).toBeInTheDocument()
229
+ expect(screen.queryByText(/never been changed|never changed/i)).not.toBeInTheDocument()
230
+ // The caveat still stands on an empty panel — a quiet trail is the exact
231
+ // case a reader would otherwise over-read.
232
+ expect(screen.getByTestId('record-history-incomplete')).toBeInTheDocument()
233
+ })
234
+ })
235
+
236
+ describe('RecordHistoryPanel — valid HTML', () => {
237
+ it('never nests the field list inside a <p>', async () => {
238
+ // REGRESSION (bd startsim-g4rwo): ActivityTimeline wrapped `detail` in a
239
+ // <p>, and a <ul>/<div> inside a <p> is invalid HTML that React reparents
240
+ // during hydration — a hydration mismatch on EVERY row. tsc passed and
241
+ // jsdom rendered it happily; only a real browser reported it.
242
+ mount(<RecordHistoryPanel client={clientFor(page([rev({ version: 1 })]))} />)
243
+ const fields = await screen.findByTestId('revision-fields')
244
+ expect(fields.closest('p')).toBeNull()
245
+ })
246
+ })
247
+
248
+ describe('RecordHistoryPanel — fetching', () => {
249
+ it('asks the server for pages, never for everything (rule 8)', async () => {
250
+ const client = clientFor(page([rev({ version: 1 })]))
251
+ mount(<RecordHistoryPanel client={client} pageSize={25} />)
252
+ await screen.findByTestId('record-history-panel')
253
+ expect(client.list).toHaveBeenCalledWith({ page: 1, pageSize: 25 })
254
+ })
255
+
256
+ it('folds across page boundaries — one sitting, not one per page', async () => {
257
+ const list = jest
258
+ .fn()
259
+ .mockResolvedValueOnce(
260
+ page([rev({ version: 2, created_at: at(2000) })], { count: 3, next: 'p2' }),
261
+ )
262
+ .mockResolvedValueOnce(page([rev({ version: 1, created_at: at(0) })], { count: 3 }))
263
+ mount(<RecordHistoryPanel client={{ list }} pageSize={1} />)
264
+ await screen.findByTestId('record-history-panel')
265
+ // jsdom has no IntersectionObserver, so the auto-load sentinel no-ops by
266
+ // design and the explicit button is the path under test.
267
+ fireEvent.click(await screen.findByRole('button', { name: 'Load more' }))
268
+ await waitFor(() => expect(list).toHaveBeenCalledTimes(2))
269
+ // Both rows are the same actor inside the window, so after the second page
270
+ // loads they are ONE entry reporting 2 saves.
271
+ await waitFor(() => expect(screen.getByText('2 saves')).toBeInTheDocument())
272
+ })
273
+
274
+ it('a 404 says the history is not available, never "no history"', async () => {
275
+ const list = jest.fn().mockRejectedValue(Object.assign(new Error('Not Found'), { status: 404 }))
276
+ mount(<RecordHistoryPanel client={{ list }} />)
277
+ expect(await screen.findByText('History is not available to you')).toBeInTheDocument()
278
+ expect(screen.queryByText('No edits recorded for this record')).not.toBeInTheDocument()
279
+ })
280
+
281
+ it('any other failure offers a retry', async () => {
282
+ const list = jest.fn().mockRejectedValue(new Error('boom'))
283
+ mount(<RecordHistoryPanel client={{ list }} />)
284
+ expect(await screen.findByText('Could not load history')).toBeInTheDocument()
285
+ })
286
+ })
287
+
288
+ describe('ActorIdentity — the kind props are additive', () => {
289
+ it('renders exactly as before when no kind is passed', () => {
290
+ const { container } = render(<ActorIdentity actor={{ email: 'a@b.com' }} />)
291
+ expect(container.querySelector('[data-testid="actor-kind"]')).toBeNull()
292
+ expect(screen.getByTestId('actor-identity')).toHaveTextContent('a@b.com')
293
+ })
294
+
295
+ it('still renders the machine marker when the actor has NO label at all', () => {
296
+ // A service token can carry no email; dropping the row would hide the one
297
+ // fact the actor axis exists to record.
298
+ render(<ActorIdentity actor={{}} kind="machine" kindSource="inferred" />)
299
+ expect(screen.getByTestId('actor-kind')).toHaveTextContent('machine')
300
+ })
301
+
302
+ it('renders nothing at all when there is neither a label nor a kind worth marking', () => {
303
+ const { container } = render(<ActorIdentity actor={{}} kind="unknown" />)
304
+ expect(container).toBeEmptyDOMElement()
305
+ })
306
+
307
+ it('carries the full truth in the title even when it draws no marker', () => {
308
+ render(<ActorIdentity actor={{ email: 'a@b.com' }} kind="unknown" />)
309
+ const el = screen.getByTestId('actor-identity')
310
+ expect(el).toHaveAttribute('data-actor-kind', 'unknown')
311
+ })
312
+ })
@@ -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
+ })