An injection row's label is now a link into the Context Browser: hovering underlines it, and a click selects the step that first carried the item, opens its category, expands its row, and scrolls the browser card into view. An injection event's seq IS the seq of the surface node it added (fold.ts), so the events row and the browser row are the same item; the node's own `gone` stamp bounds the reveal to the steps before its removal. The step comes from the existing locateStepOf(), which now sorts the wire the way the browser's own entry stamps do — a re-ordered log must not move the reveal past the step that actually carried the item.
241 lines
12 KiB
TypeScript
241 lines
12 KiB
TypeScript
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 })), 'T2 S3→4')
|
||
assert.equal(eventAt(ev({ kind: 'prune', turn: 51, step: 1, fromTurn: 50, fromStep: 8 })), 'T50 S8 → T51 S1')
|
||
assert.equal(eventAt(ev({ kind: 'compaction', turn: 2, step: 3 })), 'T2 S3')
|
||
// A partial from-side falls back to the single point.
|
||
assert.equal(eventAt(ev({ kind: 'compaction', turn: 2, step: 3, fromTurn: 2 })), 'T2 S3')
|
||
})
|
||
|
||
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 })), 'T1 S2')
|
||
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 IconBranchOutlineRegular 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 IconPlusOutlineRegular 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, 'T1 S1')
|
||
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()
|
||
})
|
||
|
||
test('an injection label links into the browser; other kinds and a handler-less list keep plain labels', async () => {
|
||
const events = [
|
||
ev({ seq: 1, kind: 'inject', form: 'notice', name: 'hook' }),
|
||
ev({ seq: 2, kind: 'prune' }),
|
||
]
|
||
const focused: ContextEventRecord[] = []
|
||
const m = await mount(h(EventList, { events, onInjectFocus: e => { focused.push(e) } }))
|
||
const rows = queryAll(m.container, '.lc-event')
|
||
// Newest first: the prune row leads, and only the injection becomes a link.
|
||
assert.equal(rows[0].querySelector('.lc-event-jump'), null)
|
||
assert.equal(query(rows[0], '.lc-event-label').tagName, 'SPAN')
|
||
const link = query(rows[1], '.lc-event-jump')
|
||
assert.equal(link.tagName, 'BUTTON')
|
||
assert.equal(link.textContent, 'Notice · hook')
|
||
await click(link)
|
||
assert.deepEqual(focused.map(e => e.seq), [1])
|
||
await m.unmount()
|
||
|
||
// No handler (a view with no browser to open): every label stays a plain span.
|
||
const plain = await mount(h(EventList, { events }))
|
||
assert.equal(queryAll(plain.container, '.lc-event-jump').length, 0)
|
||
assert.equal(queryAll(plain.container, '.lc-event-label').length, 2)
|
||
await plain.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()
|
||
})
|
||
})
|