Files
dsh-context/tests/client/components/events.spec.ts
T
Bowen Liang 4d144147a0 refactor(client): import React members directly, dropping the central react module
Every client file reached hooks and element types through the central
require seam (src/client/react.ts) and spelled them React.useState /
ReactNS.ReactElement. Import the members directly instead — named value
imports for hooks, inline type-only imports for element types — and let
the bundler own the single require('react'), which is exactly what the
platform module table answers.

This needs JSX off the classic runtime (it required a React identifier
in every tsx scope): tsconfig moves to jsx: react-jsx, so the bundle
sources jsx from 'react/jsx-runtime', already seeded by every supported
harness (verified against the dsh-v0.1.2-rc.1 platform.ts). The bundle
smoke's fake module table gains the specifier to stay a faithful shell
stand-in. h() survives as a local createElement alias at the slot
render-prop seams and the lightbox portal imports createPortal from
react-dom directly.

Verified: the built client.js requires react, react/jsx-runtime, and
react-dom exactly once each; the compat lane (bundle smoke + the
real-baseline matrix) passes against the fresh artifacts; the full gate
stays green with per-file coverage at 100%.
2026-09-07 08:59:03 +08:00

223 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// events.tsx — makeEventText pure helpers plus EventList rendered with real
// React (real shared icon components), including the truncation layout effect
// and the empty→non-empty hook-count regression (issue #12).
import { act, createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { makeEventList, makeEventText } from '../../../src/client/components/events'
import { fmtTime } from '../../../src/client/format'
import type { ContextEventRecord } from '../../../src/shared/types'
import { DICT_EN } from '../../../src/client/i18n'
import { click, flush, makeKit, mount, query, queryAll, text } from '../helpers/kit'
const kit = makeKit()
const { eventLabel, eventAt } = makeEventText(kit.t)
const EventList = makeEventList(kit)
function ev(over: Partial<ContextEventRecord> & { kind: ContextEventRecord['kind'] }): ContextEventRecord {
return { seq: 1, time: 0, ...over }
}
describe('makeEventText.eventLabel', () => {
test('compaction counts the replaced messages (absent count folds to 0)', () => {
assert.equal(eventLabel(ev({ kind: 'compaction', count: 3 })), 'Context compacted (summary replaced 3 messages)')
assert.equal(eventLabel(ev({ kind: 'compaction' })), 'Context compacted (summary replaced 0 messages)')
})
test('prune has a fixed label', () => {
assert.equal(eventLabel(ev({ kind: 'prune' })), 'Tool output pruned')
})
test('model switch interpolates from/to, ? for a missing side', () => {
assert.equal(eventLabel(ev({ kind: 'model', from: 'a', to: 'b' })), 'Model switched: a → b')
assert.equal(eventLabel(ev({ kind: 'model', from: 'a' })), 'Model switched: a → ?')
assert.equal(eventLabel(ev({ kind: 'model', to: 'b' })), 'Model switched: ? → b')
})
test('mode labels resolve per name; a missing name resolves to the raw key', () => {
assert.equal(eventLabel(ev({ kind: 'mode', name: 'plan.on' })), 'Plan mode on')
assert.equal(eventLabel(ev({ kind: 'mode', name: 'plan.off' })), 'Plan mode off')
assert.equal(eventLabel(ev({ kind: 'mode' })), 'ev.mode.?')
})
test('skill injects name the skill, ? when missing', () => {
assert.equal(eventLabel(ev({ kind: 'inject', sub: 'skill', name: 'code-review' })), 'Skill injected (code-review)')
assert.equal(eventLabel(ev({ kind: 'inject', sub: 'skill' })), 'Skill injected (?)')
})
test('plain injects label their form, then append name and detail', () => {
assert.equal(eventLabel(ev({ kind: 'inject' })), 'Context Injection')
assert.equal(eventLabel(ev({ kind: 'inject', form: 'notice' })), 'Notice')
assert.equal(eventLabel(ev({ kind: 'inject', form: 'notice', name: 'hook' })), 'Notice · hook')
assert.equal(eventLabel(ev({ kind: 'inject', detail: 'from the producer' })), 'Context Injection · from the producer')
assert.equal(eventLabel(ev({ kind: 'inject', form: 'notice', name: 'hook', detail: 'd' })), 'Notice · hook · d')
})
})
describe('makeEventText.eventAt', () => {
test('boundary events label the gap: same-turn range, cross-turn range, single point', () => {
assert.equal(eventAt(ev({ kind: 'compaction', turn: 2, step: 4, fromTurn: 2, fromStep: 3 })), 'Turn 2 · Step 3→4')
assert.equal(eventAt(ev({ kind: 'prune', turn: 51, step: 1, fromTurn: 50, fromStep: 8 })), 'Turn 50 · Step 8 → Turn 51 · Step 1')
assert.equal(eventAt(ev({ kind: 'compaction', turn: 2, step: 3 })), 'Turn 2 · Step 3')
// A partial from-side falls back to the single point.
assert.equal(eventAt(ev({ kind: 'compaction', turn: 2, step: 3, fromTurn: 2 })), 'Turn 2 · Step 3')
})
test('missing turn/step yields null; non-boundary kinds keep their single point', () => {
assert.equal(eventAt(ev({ kind: 'compaction' })), null)
assert.equal(eventAt(ev({ kind: 'prune', turn: 1 })), null)
assert.equal(eventAt(ev({ kind: 'inject', turn: 1, step: 2 })), 'Turn 1 · Step 2')
assert.equal(eventAt(ev({ kind: 'model' })), null)
})
})
describe('EventList', () => {
test('renders the empty state', async () => {
const m = await mount(h(EventList, { events: [] }))
assert.ok(text(m.container).includes('No context events yet'))
await m.unmount()
})
test('rows render newest first with icons, kinds, labels, positions, tokens, and times', async () => {
const time = new Date(2024, 0, 2, 3, 4, 5).getTime()
const events: ContextEventRecord[] = [
ev({ seq: 1, time, kind: 'inject', form: 'notice', tokens: 50, turn: 1, step: 1 }),
ev({ seq: 2, time, kind: 'model', from: 'a', to: 'b' }),
ev({ seq: 3, time, kind: 'compaction', count: 2, tokens: 100 }),
ev({ seq: 4, time, kind: 'mystery' as never }),
ev({ seq: 5, time, kind: 'inject', tokens: 0 }),
]
const m = await mount(h(EventList, { events }))
const rows = queryAll(m.container, '.lc-event')
assert.equal(rows.length, 5)
// Newest first: seq 5 leads. tokens 0 renders no token span.
assert.ok(text(rows[0]).includes('Context Injection'))
assert.equal(queryAll(rows[0], '.lc-event-tokens').length, 0)
// Foreign kind: EVENT_ICONS miss → '•' fallback, and the kind label
// resolves through the locale chain to the key itself.
assert.equal(query(rows[1], '.lc-event-icon').textContent, '•')
assert.equal(query(rows[1], '.lc-kind').textContent, 'kind.mystery')
// Compaction: ✂ glyph, negative token delta, no position (no turn/step).
assert.equal(query(rows[2], '.lc-event-icon').textContent, '✂')
const down = query(rows[2], '.lc-event-tokens')
assert.ok(down.className.includes('lc-down'))
assert.equal(down.textContent, '−100')
assert.equal(queryAll(rows[2], '.lc-event-at').length, 0)
// Model switch: real IconBranchOutline16 svg.
assert.ok(query(rows[3], '.lc-event-icon svg') instanceof SVGElement)
assert.ok(text(rows[3]).includes('Model switched: a → b'))
assert.equal(query(rows[3], '.lc-kind').textContent, 'Switch')
// Inject: real IconPlusOutline16 svg, positive token delta, position, time.
assert.ok(query(rows[4], '.lc-event-icon svg') instanceof SVGElement)
const up = query(rows[4], '.lc-event-tokens')
assert.ok(up.className.includes('lc-up'))
assert.equal(up.textContent, '+50')
assert.equal(query(rows[4], '.lc-event-at').textContent, 'Turn 1 · Step 1')
assert.equal(query(rows[4], '.lc-event-time').textContent, fmtTime(time))
await m.unmount()
})
test('truncation effect: jsdom widths are 0 so titles stay empty; a forced overflow syncs the title', async () => {
const events = [
ev({ seq: 1, kind: 'inject', form: 'notice' }),
ev({ seq: 2, kind: 'prune' }),
]
const m = await mount(h(EventList, { events }))
const labels = queryAll(m.container, '.lc-event-label')
assert.equal(labels[0].getAttribute('title'), '')
assert.equal(labels[1].getAttribute('title'), '')
// Newest first: labels[0] is the prune, labels[1] the inject.
// Force an empty-text overflow on the prune label (the
// `el.textContent || ''` fallback arm) and a real overflow on the
// inject label, then resize.
Object.defineProperty(labels[0], 'scrollWidth', { value: 100, configurable: true })
Object.defineProperty(labels[1], 'scrollWidth', { value: 100, configurable: true })
labels[0].textContent = ''
await act(async () => {
window.dispatchEvent(new Event('resize'))
})
assert.equal(labels[0].getAttribute('title'), '')
assert.equal(labels[1].getAttribute('title'), 'Notice')
await flush()
await m.unmount()
})
test('a resize while the list is empty (no rows, no root) does not throw', async () => {
const m = await mount(h(EventList, { events: [] }))
await act(async () => {
window.dispatchEvent(new Event('resize'))
})
assert.ok(text(m.container).includes('No context events yet'))
// The same listener keeps working once rows arrive (root was null at mount).
await m.update(h(EventList, { events: [ev({ seq: 1, kind: 'prune' })] }))
const label = query(m.container, '.lc-event-label')
Object.defineProperty(label, 'scrollWidth', { value: 100, configurable: true })
await act(async () => {
window.dispatchEvent(new Event('resize'))
})
assert.equal(label.getAttribute('title'), 'Tool output pruned')
await m.unmount()
})
test('an appended event keeps every existing row mounted (seq-keyed, newest prepends)', async () => {
const base = [
ev({ seq: 1, kind: 'inject', form: 'notice', name: 'first' }),
ev({ seq: 2, kind: 'prune' }),
]
const m = await mount(h(EventList, { events: base }))
const before = queryAll(m.container, '.lc-event')
assert.equal(before.length, 2)
// A new event lands at the log tail → the row list prepends it; every old row must be the SAME DOM node
// (an index-bearing key would shift all keys and remount the whole list on every push).
await m.update(h(EventList, { events: [...base, ev({ seq: 3, kind: 'model', from: 'a', to: 'b' })] }))
const after = queryAll(m.container, '.lc-event')
assert.equal(after.length, 3)
assert.ok(after[0] !== before[0], 'the new event prepends a new row')
assert.equal(after[1], before[0], 'the previously-newest row keeps its DOM node')
assert.equal(after[2], before[1], 'the oldest row keeps its DOM node')
await m.unmount()
})
test('empty → non-empty transition keeps the hook count stable (issue #12)', async () => {
const m = await mount(h(EventList, { events: [] }))
assert.ok(text(m.container).includes('No context events yet'))
await m.update(h(EventList, { events: [ev({ seq: 1, kind: 'prune' })] }))
assert.equal(queryAll(m.container, '.lc-event').length, 1)
assert.ok(text(m.container).includes('Tool output pruned'))
await m.update(h(EventList, { events: [] }))
assert.ok(text(m.container).includes('No context events yet'))
await m.unmount()
})
})
describe('EventList — the split generation detail states', () => {
test('a pending detail read shows the loading note instead of the empty claim', async () => {
const m = await mount(h(EventList, { events: [], state: 'loading', onRetry: () => {} }))
assert.ok(text(m.container).includes(DICT_EN['detail.loading']))
assert.ok(!text(m.container).includes('No context events yet'))
await m.unmount()
})
test('a failed detail read arms the retry button; a failed read without one falls back to the empty claim', async () => {
let retries = 0
const m = await mount(h(EventList, { events: [], state: 'failed', onRetry: () => { retries++ } }))
const retry = query(m.container, '.lc-br-retry')
assert.ok(text(m.container).includes(DICT_EN['detail.loadFailed']))
await click(retry)
assert.equal(retries, 1)
const inert = await mount(h(EventList, { events: [], state: 'failed' }))
assert.ok(text(inert.container).includes('No context events yet'), 'no retry wired — the plain empty state')
await inert.unmount()
await m.unmount()
})
})