@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.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * The precondition rules (bd startsim-jkkn7.3; server bd startsim-3c2wc).
3
+ *
4
+ * Every case here is pinned against `tenant-starter/apps/api/preconditions.py`
5
+ * as merged, not against the design — the two disagree about the 412 body and
6
+ * the server is the one that ships.
7
+ */
8
+ import {
9
+ CONDITIONAL_WRITES_UNKNOWN,
10
+ PRECONDITION_BODY_FIELD,
11
+ PRECONDITION_HEADER,
12
+ etagFor,
13
+ isVersion,
14
+ newestRevision,
15
+ parseStaleSaveRefusal,
16
+ preconditionFor,
17
+ readConditionalWriteCapability,
18
+ toVersion,
19
+ versionFromEtag,
20
+ versionFromRecord,
21
+ versionFromWriteOutcome,
22
+ } from '../conditional-write';
23
+
24
+ describe('version 0 is a real version', () => {
25
+ // Every record predating the revision trail reads 0, and the server accepts 0
26
+ // back. `if (version)` would silently unguard exactly the oldest records.
27
+ it('treats 0 as a version and sends it', () => {
28
+ expect(isVersion(0)).toBe(true);
29
+ expect(preconditionFor(0)).toEqual({
30
+ headers: { [PRECONDITION_HEADER]: '"0"' },
31
+ body: {},
32
+ guards: true,
33
+ });
34
+ });
35
+
36
+ it('rejects the values that are not integer versions', () => {
37
+ for (const bad of [undefined, null, '', 'x', 1.5, true, false, NaN, {}, []]) {
38
+ expect(isVersion(bad)).toBe(false);
39
+ }
40
+ });
41
+
42
+ it('honours a numeric string but never a bool', () => {
43
+ expect(toVersion('3')).toBe(3);
44
+ expect(toVersion(' 3 ')).toBe(3);
45
+ expect(toVersion('-1')).toBe(-1);
46
+ expect(toVersion(true)).toBeUndefined();
47
+ expect(toVersion('1.5')).toBeUndefined();
48
+ });
49
+ });
50
+
51
+ describe('an unknown version still saves', () => {
52
+ // THE POSTURE: enforced when present, optional when absent. A guard that can
53
+ // make a save impossible is worse than the overwrite it prevents.
54
+ it('asserts nothing rather than refusing to build a write', () => {
55
+ expect(preconditionFor(undefined)).toEqual({ headers: {}, body: {}, guards: false });
56
+ });
57
+ });
58
+
59
+ describe('one spelling per call', () => {
60
+ // `preconditions.enforce` checks BOTH sources and refuses if EITHER disagrees,
61
+ // so a stale If-Match beside a fresh expected_version refuses itself with a
62
+ // 412 that looks exactly like a real conflict.
63
+ it('sends If-Match and leaves the body untouched', () => {
64
+ const parts = preconditionFor(7, 'header');
65
+ expect(parts.headers).toEqual({ 'If-Match': '"7"' });
66
+ expect(parts.body).toEqual({});
67
+ });
68
+
69
+ it('sends expected_version and leaves the headers untouched', () => {
70
+ const parts = preconditionFor(7, 'body');
71
+ expect(parts.body).toEqual({ expected_version: 7 });
72
+ expect(parts.headers).toEqual({});
73
+ });
74
+
75
+ it('names the body field at the top level, never inside data', () => {
76
+ // Inside the blob it stops being a precondition and starts being stored.
77
+ expect(PRECONDITION_BODY_FIELD).toBe('expected_version');
78
+ expect(Object.keys(preconditionFor(1, 'body').body)).toEqual(['expected_version']);
79
+ });
80
+
81
+ it('quotes the validator strongly, with no W/', () => {
82
+ expect(etagFor(12)).toBe('"12"');
83
+ });
84
+ });
85
+
86
+ describe('reading the version back', () => {
87
+ it('reads the read-only field off a record', () => {
88
+ expect(versionFromRecord({ id: 'a', version: 4 })).toBe(4);
89
+ expect(versionFromRecord({ id: 'a' })).toBeUndefined();
90
+ expect(versionFromRecord(null)).toBeUndefined();
91
+ });
92
+
93
+ it('parses a quoted ETag, a list and a weak validator', () => {
94
+ expect(versionFromEtag('"3"')).toBe(3);
95
+ expect(versionFromEtag('"1", "3"')).toBe(1);
96
+ expect(versionFromEtag('W/"5"')).toBe(5);
97
+ expect(versionFromEtag('"0"')).toBe(0);
98
+ });
99
+
100
+ it('reads * as no version at all', () => {
101
+ // It asserts only that a representation exists, so it guards nothing — and
102
+ // sending it back would record `precondition: none` for a write the client
103
+ // believed it had checked.
104
+ expect(versionFromEtag('*')).toBeUndefined();
105
+ expect(versionFromEtag('')).toBeUndefined();
106
+ expect(versionFromEtag(undefined)).toBeUndefined();
107
+ });
108
+
109
+ describe('off the WRITE response — the one that makes autosave free', () => {
110
+ // THE BODY IS AUTHORITATIVE, not the header. ETag is not CORS-safelisted and
111
+ // tenant-starter sets no CORS_EXPOSE_HEADERS, so a cross-origin caller reads
112
+ // null — silently, and only on the write path. The server stamps the header
113
+ // FROM `response.data["version"]`, so the two cannot disagree.
114
+ it('prefers the body field over the header', () => {
115
+ expect(
116
+ versionFromWriteOutcome({
117
+ body: { version: 9 },
118
+ headers: new Headers({ ETag: '"9"' }),
119
+ }),
120
+ ).toBe(9);
121
+ });
122
+
123
+ it('still reads the version when the header is invisible', () => {
124
+ expect(versionFromWriteOutcome({ body: { version: 9 }, headers: undefined })).toBe(9);
125
+ });
126
+
127
+ it('falls back to the header when the body carries no version', () => {
128
+ expect(versionFromWriteOutcome({ body: {}, headers: new Headers({ ETag: '"4"' }) })).toBe(4);
129
+ });
130
+
131
+ it('reads a plain header record case-insensitively', () => {
132
+ expect(versionFromWriteOutcome({ headers: { etag: '"6"' } })).toBe(6);
133
+ });
134
+
135
+ it('is undefined when nothing said — leaving the next save unguarded, not broken', () => {
136
+ expect(versionFromWriteOutcome({})).toBeUndefined();
137
+ });
138
+ });
139
+ });
140
+
141
+ describe('the refusal', () => {
142
+ const body = {
143
+ detail: 'the record changed since it was loaded: version 2 was sent and the record is now at version 3. Reload it, re-apply the change and save again.',
144
+ current_version: 3,
145
+ };
146
+
147
+ it('is read off a resolved 412 outcome', () => {
148
+ expect(parseStaleSaveRefusal({ status: 412, body })).toEqual({
149
+ currentVersion: 3,
150
+ detail: body.detail,
151
+ });
152
+ });
153
+
154
+ it('is read off a THROWN 412, which is what a wrapper does', () => {
155
+ expect(parseStaleSaveRefusal({ response: { status: 412, data: body } })).toEqual({
156
+ currentVersion: 3,
157
+ detail: body.detail,
158
+ });
159
+ expect(parseStaleSaveRefusal({ statusCode: 412, data: body })?.currentVersion).toBe(3);
160
+ });
161
+
162
+ it('reads currentVersion in the camelised spelling too', () => {
163
+ // The shared API client camelCases every response key recursively and has no
164
+ // preserve list — including on an error body, which is where it would be
165
+ // found last.
166
+ expect(parseStaleSaveRefusal({ status: 412, body: { currentVersion: 5 } })).toEqual({
167
+ currentVersion: 5,
168
+ });
169
+ });
170
+
171
+ it('keeps current_version an integer', () => {
172
+ // The server assigns `detail` after super().__init__ specifically so DRF
173
+ // does not coerce this to "3". Retrying with the exact value lands.
174
+ const parsed = parseStaleSaveRefusal({ status: 412, body });
175
+ expect(typeof parsed?.currentVersion).toBe('number');
176
+ });
177
+
178
+ it('survives a 412 that names nothing, because the dialog must still open', () => {
179
+ expect(parseStaleSaveRefusal({ status: 412 })).toEqual({});
180
+ expect(parseStaleSaveRefusal({ status: 412, body: 'nope' })).toEqual({});
181
+ });
182
+
183
+ it('is not a refusal for any other status', () => {
184
+ for (const status of [200, 201, 400, 403, 404, 409, 500, undefined]) {
185
+ expect(parseStaleSaveRefusal({ status, body })).toBeUndefined();
186
+ }
187
+ // 412 and not 409: 409 is what this viewset answers when the resource's own
188
+ // STATE refuses a change. A stale validator is a different thing.
189
+ expect(parseStaleSaveRefusal({ status: 409, body })).toBeUndefined();
190
+ });
191
+ });
192
+
193
+ describe('feature detection', () => {
194
+ const live = {
195
+ entities: {
196
+ conditional_writes: true,
197
+ precondition_required: false,
198
+ precondition: {
199
+ token: 'version',
200
+ header: 'If-Match',
201
+ body_field: 'expected_version',
202
+ refused_with: 412,
203
+ inert_without_type_history: true,
204
+ },
205
+ },
206
+ };
207
+
208
+ it('reads the live document', () => {
209
+ expect(readConditionalWriteCapability(live)).toEqual({
210
+ conditionalWrites: true,
211
+ preconditionRequired: false,
212
+ inertWithoutTypeHistory: true,
213
+ header: 'If-Match',
214
+ bodyField: 'expected_version',
215
+ });
216
+ });
217
+
218
+ it('defaults a fork that does not answer to NOT guarded', () => {
219
+ // A build predating bd startsim-3c2wc accepts `expected_version` and
220
+ // silently ignores it, answering 200 either way — so a client cannot learn
221
+ // this from a write and must not assume it.
222
+ for (const absent of [undefined, null, {}, { entities: {} }, 'nope']) {
223
+ expect(readConditionalWriteCapability(absent).conditionalWrites).toBe(false);
224
+ }
225
+ expect(CONDITIONAL_WRITES_UNKNOWN.conditionalWrites).toBe(false);
226
+ });
227
+
228
+ it('reads the camelised document too', () => {
229
+ expect(
230
+ readConditionalWriteCapability({ entities: { conditionalWrites: true } }).conditionalWrites,
231
+ ).toBe(true);
232
+ });
233
+
234
+ it('never reports precondition_required, which no build sets', () => {
235
+ // A client reading `true` here would stop making writes it is entitled to.
236
+ expect(readConditionalWriteCapability(live).preconditionRequired).toBe(false);
237
+ });
238
+ });
239
+
240
+ describe('row 0 of the trail is the newest', () => {
241
+ // The route orders `-version, -created_at`. An ordering flip would make the
242
+ // dialog confidently name whoever CREATED the record.
243
+ it('picks the highest version', () => {
244
+ expect(newestRevision([{ id: 'c', version: 3 }, { id: 'a', version: 1 }])?.id).toBe('c');
245
+ expect(newestRevision([{ id: 'a', version: 1 }, { id: 'c', version: 3 }])?.id).toBe('c');
246
+ });
247
+
248
+ it('is undefined on an empty or absent page', () => {
249
+ expect(newestRevision([])).toBeUndefined();
250
+ expect(newestRevision(undefined)).toBeUndefined();
251
+ });
252
+ });
@@ -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
+ })