Files
dsh-context/tests/client/components/overviewPanel.spec.ts
T

465 lines
21 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.
// The Context Dashboard panel (src/client/components/overviewPanel.tsx) —
// full renders through the standard-kit seams: KPI band, heatmap day-pin,
// filters/sorts, pagination, session open + close paths, and the
// degraded states.
import { act, createElement as h } from 'react'
import assert from 'node:assert/strict'
import { afterEach, beforeEach, describe, test } from 'vitest'
import { makeOverviewPanel } from '../../../src/client/components/overviewPanel'
import { resetModelPrices, setModelPricesLoader } from '../../../src/client/modelPrices'
import { overviewStore } from '../../../src/client/overviewStore'
import { dayKeyOf } from '../../../src/shared/days'
import { TestClientCtx, asClientCtx } from '../helpers/harness'
import { click, flush, keydown, makeKit, mount, query, queryAll, text, type Mounted } from '../helpers/kit'
const PROVIDERS = {
deepseek: { models: { 'deepseek-v4-flash': { cost: { input: 1, output: 2, cache_read: 0.1 } } } },
}
const NOW = Date.now()
const TODAY = dayKeyOf(NOW) ?? ''
const YESTERDAY = dayKeyOf(NOW - 86_400_000) ?? ''
/** A wire-shaped timeline head. */
function timeline(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
ok: true,
current: { system: 100, tools: 50, user: 30, inject: 10, skill: 10, assistant: 200, tool: 100, total: 500 },
contextWindow: 1000,
requests: [],
events: [],
nodes: [],
droppedNodes: 0,
archive: [],
counts: { turns: 3, steps: 5, injects: 1, compactions: 0, prunes: 0 },
cost: { deepseek: { 'deepseek-v4-flash': { peak: { uncached: 1000, cacheRead: 500, cacheWrite: 0, output: 250 } } } },
timing: { wallMs: 90_000, ttftMs: 1_000, genMs: 30_000, calls: 4, toolsMs: 20_000, toolCalls: 7 },
...over,
}
}
/** The three-session snapshot: current + folded, older + cheap, stale + bare. */
function sessionsSnapshot(): { ids: string[]; byId: Record<string, Record<string, unknown>>; current: string; phase: string } {
return {
ids: ['a', 'b', 'c'],
byId: {
a: {
displayTitle: 'alpha session',
cwd: '/repo/alpha',
updatedAt: NOW - 3_600_000,
running: true,
projectionValues: {
contextTimeline: timeline(),
contextActivity: { days: { [TODAY]: { tokens: 100, requests: 2 }, [YESTERDAY]: { tokens: 50, requests: 1 } } },
},
},
b: {
displayTitle: 'beta session',
cwd: '/repo/beta',
updatedAt: NOW - 10 * 86_400_000,
projectionValues: {
contextTimeline: timeline({ current: { system: 10, tools: 0, user: 0, inject: 0, skill: 0, assistant: 0, tool: 0, total: 10 }, cost: undefined }),
contextActivity: { days: { [YESTERDAY]: { tokens: 25, requests: 1 } } },
},
},
c: {
title: 'gamma session',
updatedAt: NOW - 40 * 86_400_000,
},
},
current: 'a',
phase: 'ready',
}
}
const workspacesSnapshotValue = {
items: [{ workspaceId: 'w1', title: 'workspace-one', path: '/repo', sessionIds: ['a'] }],
archivedSessionIds: [],
state: 'idle',
phase: 'ready',
}
const useHookOf = (snapshot: unknown) => (<T>(sel: (s: unknown) => T): T => sel(snapshot))
function makeCtx(sessions: Record<string, unknown> = {}): TestClientCtx {
return new TestClientCtx({ services: { sessions } })
}
async function openPanel(
ctx: TestClientCtx,
props: Record<string, unknown> = {},
): Promise<{ m: Mounted; Panel: ReturnType<typeof makeOverviewPanel> }> {
overviewStore.set(false)
const Panel = makeOverviewPanel(asClientCtx(ctx), makeKit())
const allProps = {
useSessions: useHookOf(sessionsSnapshot()),
useWorkspaces: useHookOf(workspacesSnapshotValue),
...props,
}
const m = await mount(h(Panel, allProps))
assert.equal(m.container.textContent, '', 'closed renders nothing')
await act(async () => {
overviewStore.set(true)
})
// The price book's first fetch resolves a microtask or two behind the store
// flip; a second act window keeps its notify inside act.
await flush()
return { m, Panel }
}
beforeEach(() => {
resetModelPrices()
setModelPricesLoader(() => Promise.resolve(PROVIDERS))
})
afterEach(async () => {
overviewStore.set(false)
resetModelPrices()
await new Promise(resolve => setTimeout(resolve, 1))
})
describe('OverviewPanel', () => {
test('renders the KPI band, heatmap, and session cards; refreshes the list on open', async () => {
let pulls = 0
const ctx = makeCtx({ refresh: () => { pulls++; return Promise.resolve() } })
const { m } = await openPanel(ctx)
assert.equal(pulls, 1, 'the baseline re-pull fires on open')
assert.ok(text(m.container).includes('Context Insights'))
// KPI band: 2 sessions in the 30d range, 1750 tokens billed, priced cost, cache hit.
const labels = queryAll(m.container, '.lc-stat-label').map(el => el.textContent)
assert.deepEqual(labels, ['Active Sessions', 'Tokens Used', 'Cost', 'Cache Hit', 'Tool Calls', 'Active Time'])
const values = queryAll(m.container, '.lc-stat-value').map(el => el.textContent)
assert.equal(values[0], '2')
assert.equal(values[1], '1.8k')
assert.ok(values[2].startsWith('$'), 'priced from the book')
assert.equal(values[3], '33.33%')
// Tools + active time: both timed sessions fold into the band (7 calls each).
assert.equal(values[4], '14')
assert.equal(values[5], '3m0s')
assert.ok(text(m.container).includes('tool runs 40.0s'), 'the tool calls cell qualifies with the summed run time')
assert.ok(text(m.container).includes('8 model calls'), 'the active-time cell qualifies with the model-call count')
// Heatmap drew cells for the two ledger days.
assert.ok(queryAll(m.container, 'button.lc-heat-cell').length >= 2)
// Cards: a (current, running, grouped), b, and c is outside the 30d range.
const cards = queryAll(m.container, '.lc-ov-grid > .lc-ov-session')
assert.equal(cards.length, 2)
assert.ok(text(cards[0]).includes('alpha session'))
assert.ok(text(cards[0]).includes('workspace-one'), 'the breadcrumb group joined')
assert.ok(text(cards[0]).includes('alpha'), 'the project basename joined')
await m.unmount()
})
test('archived sessions drop from every band, not just the card grid', async () => {
const ctx = makeCtx()
const { m } = await openPanel(ctx, {
useWorkspaces: useHookOf({ ...workspacesSnapshotValue, archivedSessionIds: ['b'] }),
})
const values = queryAll(m.container, '.lc-stat-value').map(el => el.textContent)
assert.equal(values[0], '1', 'the archived session leaves the KPI band')
const cards = queryAll(m.container, '.lc-ov-grid > .lc-ov-session')
assert.equal(cards.length, 1)
assert.ok(text(cards[0]).includes('alpha session'))
await m.unmount()
})
test('range switching re-scopes the KPI band and the grid', async () => {
const ctx = makeCtx()
const { m } = await openPanel(ctx)
const rangeButtons = queryAll<HTMLButtonElement>(m.container, '.lc-ov-range .lc-gran-btn')
assert.equal(rangeButtons.length, 3)
await click(rangeButtons[2]) // All
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 3, 'the stale session joins')
assert.equal(queryAll(m.container, '.lc-stat-value')[0].textContent, '3')
await click(rangeButtons[0]) // Last 7 days
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 1, 'only the freshest stays')
await click(rangeButtons[1]) // back to 30d
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 2)
await m.unmount()
})
test('the heatmap day pin filters the grid and the chip clears it', async () => {
const ctx = makeCtx()
const { m } = await openPanel(ctx)
const cells = queryAll<HTMLButtonElement>(m.container, 'button.lc-heat-cell')
const todayCell = cells.find(c => c.getAttribute('aria-label')?.startsWith(TODAY))
assert.ok(todayCell !== undefined, 'today has a data cell')
await click(todayCell)
assert.ok(query(m.container, '.lc-ov-day-chip'), 'the day chip appears')
const cards = queryAll(m.container, '.lc-ov-grid > .lc-ov-session')
assert.equal(cards.length, 1)
assert.ok(text(cards[0]).includes('alpha session'), 'only the session active that day stays')
await click(query<HTMLButtonElement>(m.container, '.lc-ov-day-chip'))
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 2, 'the chip clear restores')
// Pin and unpin via the cell itself.
await click(queryAll<HTMLButtonElement>(m.container, 'button.lc-heat-cell').find(c => c.getAttribute('aria-label')?.startsWith(YESTERDAY))!)
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 2, 'both sessions were active yesterday')
await click(queryAll<HTMLButtonElement>(m.container, 'button.lc-heat-cell').find(c => c.getAttribute('aria-label')?.startsWith(YESTERDAY))!)
assert.equal(queryAll(m.container, '.lc-ov-day-chip').length, 0)
await m.unmount()
})
test('search and sort steer the grid', async () => {
const ctx = makeCtx()
const { m } = await openPanel(ctx, {
useSessions: useHookOf({
...sessionsSnapshot(),
ids: ['a', 'b', 'c'],
byId: {
...sessionsSnapshot().byId,
c: { ...(sessionsSnapshot().byId.c as object), updatedAt: NOW - 86_400_000 },
},
}),
})
const input = query<HTMLInputElement>(m.container, 'input.lc-ov-search')
// Query by title.
await actType(input, 'beta')
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 1)
assert.ok(text(m.container).includes('beta session'))
// Query by path.
await actType(input, '/repo/a')
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 1)
// No match → the filtered empty note.
await actType(input, 'zzz')
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 0)
assert.ok(text(m.container).includes('No sessions match'))
await actType(input, '')
// Sort: tokens puts the billed session first; context puts the largest current first.
const sortButtons = queryAll<HTMLButtonElement>(m.container, '.lc-ov-list-head .lc-gran-btn')
assert.equal(sortButtons.length, 3)
await click(sortButtons[1]) // Tokens
assert.ok(text(queryAll(m.container, '.lc-ov-grid > .lc-ov-session')[0]).includes('alpha session'))
await click(sortButtons[2]) // Context
assert.ok(text(queryAll(m.container, '.lc-ov-grid > .lc-ov-session')[0]).includes('alpha session'))
await click(sortButtons[0]) // Recent
await m.unmount()
})
test('the group chips filter the grid (All / workspace / Ungrouped), toggling on and off', async () => {
const ctx = makeCtx()
const { m } = await openPanel(ctx)
const chips = queryAll<HTMLButtonElement>(m.container, '.lc-ov-chip')
assert.deepEqual(chips.map(c => c.textContent), ['All2', 'workspace-one1', 'Ungrouped1'])
// Select the workspace group: only its claimed session stays.
await click(chips[1])
let cards = queryAll(m.container, '.lc-ov-grid > .lc-ov-session')
assert.equal(cards.length, 1)
assert.ok(text(cards[0]).includes('alpha session'))
assert.ok(queryAll<HTMLButtonElement>(m.container, '.lc-ov-chip')[1].className.includes('lc-ov-chip-on'))
// Switch to the ungrouped bucket: only the claimless one stays.
await click(queryAll<HTMLButtonElement>(m.container, '.lc-ov-chip')[2])
cards = queryAll(m.container, '.lc-ov-grid > .lc-ov-session')
assert.equal(cards.length, 1)
assert.ok(text(cards[0]).includes('beta session'))
// Toggle the active chip off → the whole scoped set returns.
await click(queryAll<HTMLButtonElement>(m.container, '.lc-ov-chip')[2])
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 2)
// The All chip clears an active selection too.
await click(queryAll<HTMLButtonElement>(m.container, '.lc-ov-chip')[1])
await click(queryAll<HTMLButtonElement>(m.container, '.lc-ov-chip')[0])
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 2)
await m.unmount()
})
test('a selection whose group fell out of scope keeps a phantom chip (one click out)', async () => {
const ctx = makeCtx()
const { m } = await openPanel(ctx)
await click(queryAll<HTMLButtonElement>(m.container, '.lc-ov-chip')[1]) // workspace-one
// Filter everything away with a query: the selection's chip stays, lit, at 0.
const input = query<HTMLInputElement>(m.container, 'input.lc-ov-search')
await actType(input, 'zzz')
const chips = queryAll<HTMLButtonElement>(m.container, '.lc-ov-chip')
assert.deepEqual(chips.map(c => c.textContent), ['All0', 'workspace-one0'])
assert.ok(chips[1].className.includes('lc-ov-chip-on'))
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 0)
await m.unmount()
})
test('a card click opens the session through the harness verb and closes the panel', async () => {
const opened: string[] = []
const ctx = makeCtx({ open: (id: string) => { opened.push(id) } })
const { m } = await openPanel(ctx)
await click(query<HTMLButtonElement>(m.container, '.lc-ov-grid > .lc-ov-session'))
assert.deepEqual(opened, ['a'])
assert.equal(overviewStore.getSnapshot(), false)
assert.equal(m.container.textContent, '', 'the panel unmounted')
await m.unmount()
})
test('Escape and the backdrop close; the card body swallows clicks', async () => {
const ctx = makeCtx()
const { m } = await openPanel(ctx)
await keydown('Escape')
assert.equal(overviewStore.getSnapshot(), false)
await m.unmount()
const second = await openPanel(ctx)
await click(query(second.m.container, '.lc-ov-card'))
assert.equal(overviewStore.getSnapshot(), true, 'a card-body click does not close')
await click(query(second.m.container, '.lc-ov-backdrop'))
assert.equal(overviewStore.getSnapshot(), false)
await second.m.unmount()
const third = await openPanel(ctx)
await click(query<HTMLButtonElement>(third.m.container, '.lc-modal-close'))
assert.equal(overviewStore.getSnapshot(), false)
await third.m.unmount()
})
test('degraded states: unavailable list, empty list, no activity', async () => {
const ctx = makeCtx()
// No sessions seat at all → the unavailable note.
const bare = await openPanel(ctx, { useSessions: undefined, useWorkspaces: undefined })
assert.ok(text(bare.m.container).includes('The session list is unavailable'))
await bare.m.unmount()
// An empty-but-ready list → the empty note.
const empty = await openPanel(ctx, {
useSessions: useHookOf({ ids: [], byId: {}, phase: 'ready' }),
useWorkspaces: useHookOf({ items: [] }),
})
assert.ok(text(empty.m.container).includes('No sessions yet'))
assert.ok(text(empty.m.container).includes('No activity yet'))
await empty.m.unmount()
// Sessions without any timeline → cards render metadata-only.
const noTimeline = await openPanel(ctx, {
useSessions: useHookOf({
ids: ['x'],
byId: { x: { displayTitle: 'plain session', updatedAt: NOW - 1000 } },
phase: 'ready',
}),
})
assert.ok(text(noTimeline.m.container).includes('No context data yet'))
const kpiValues = queryAll(noTimeline.m.container, '.lc-stat-value').map(el => el.textContent)
assert.equal(kpiValues[1], '0')
assert.equal(kpiValues[2], '—', 'nothing priced without cost buckets')
assert.equal(kpiValues[3], '—', 'no cache hit without billed input')
assert.equal(kpiValues[4], '0', 'no tool calls without timing')
assert.equal(kpiValues[5], '—', 'no active time without timing')
await noTimeline.m.unmount()
})
test('the zh locale renders translated chrome and CNY cost', async () => {
const ctx = new TestClientCtx({ locale: 'zh' })
const Panel = makeOverviewPanel(asClientCtx(ctx), makeKit('zh'))
const m = await mount(h(Panel, {
useSessions: useHookOf(sessionsSnapshot()),
useWorkspaces: useHookOf(workspacesSnapshotValue),
}))
await act(async () => {
overviewStore.set(true)
})
await flush()
assert.ok(text(m.container).includes('上下文洞察'))
assert.ok(text(m.container).includes('活跃会话'))
await flush() // the price book lands
const values = queryAll(m.container, '.lc-stat-value').map(el => el.textContent)
assert.ok(values[2].startsWith('¥'), 'CNY under the zh locale')
await m.unmount()
})
test('a hostile session seat degrades to the unavailable note instead of an error card', async () => {
const ctx = makeCtx()
const { m } = await openPanel(ctx, { useSessions: () => { throw new Error('boom') } })
assert.ok(text(m.container).includes('The session list is unavailable'))
await m.unmount()
})
test('an unpriceable model dashes the card cost', async () => {
const ctx = makeCtx()
const sparse = timeline({
current: { system: 10, tools: 0, user: 0, inject: 0, skill: 0, assistant: 0, tool: 0, total: 10 },
cost: { deepseek: { 'no-such-model': { peak: { uncached: 100, cacheRead: 0, cacheWrite: 0, output: 0 } } } },
})
const { m } = await openPanel(ctx, {
useSessions: useHookOf({
ids: ['s'],
byId: {
s: { displayTitle: 'sparse session', updatedAt: NOW - 1000, projectionValues: { contextTimeline: sparse } },
},
phase: 'ready',
}),
})
await flush() // the price book lands
// The card's cost: buckets exist but the book prices no such model → the dash.
const values = queryAll(m.container, '.lc-ov-mini-value').map(el => el.textContent)
assert.equal(values[2], '—')
await m.unmount()
})
test('a locale service without getLocale falls back to USD', async () => {
// A hand-rolled ctx: the locale face carries no getLocale at all.
const bare = {
locale: {
register: () => () => {},
bind: () => makeKit().t,
},
get: () => undefined,
}
const Panel = makeOverviewPanel(bare as never, makeKit())
const m = await mount(h(Panel, {
useSessions: useHookOf(sessionsSnapshot()),
useWorkspaces: useHookOf(workspacesSnapshotValue),
}))
await act(async () => {
overviewStore.set(true)
})
await flush()
await flush() // the price book lands
const values = queryAll(m.container, '.lc-stat-value').map(el => el.textContent)
assert.ok(values[2].startsWith('$'), 'USD when the locale face cannot report an active locale')
await m.unmount()
})
test('the grid pages at 12 cards; the pager steps and a filter change re-anchors it', async () => {
const ctx = makeCtx()
const ids = Array.from({ length: 30 }, (_, i) => `s${i}`)
const byId: Record<string, Record<string, unknown>> = {}
ids.forEach((id, i) => { byId[id] = { displayTitle: `session ${i}`, updatedAt: NOW - i * 60_000 } })
const { m } = await openPanel(ctx, {
useSessions: useHookOf({ ids, byId, current: 's0', phase: 'ready' }),
useWorkspaces: useHookOf({ items: [] }),
})
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 12, 'the first page caps at 12 cards')
assert.ok(text(m.container).includes('Page 1 of 3'))
const btns = () => queryAll<HTMLButtonElement>(m.container, '.lc-ov-pager-btn')
assert.ok(btns()[0].disabled, 'prev sits out on the first page')
await click(btns()[1])
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 12, 'the middle page is full')
await click(btns()[1])
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 6, 'the tail page renders the rest')
assert.ok(text(m.container).includes('Page 3 of 3'))
assert.ok(btns()[1].disabled, 'next sits out on the last page')
await click(btns()[0])
assert.ok(text(m.container).includes('Page 2 of 3'), 'prev steps back')
// Typing while on the last page: the reset lands on the first page of the narrowed set.
await actType(query<HTMLInputElement>(m.container, 'input.lc-ov-search'), 'session 2')
assert.equal(queryAll(m.container, '.lc-ov-grid > .lc-ov-session').length, 11, "'session 2' matches 2 and 20–29")
assert.ok(!text(m.container).includes('Page 2 of 3'), 'the pager drops once one page remains')
await actType(query<HTMLInputElement>(m.container, 'input.lc-ov-search'), '')
assert.ok(text(m.container).includes('Page 1 of 3'), 'clearing the query lands on the first page, not the stale one')
await m.unmount()
})
test('typing keeps the search input focused (the escape hook never re-fires its focus restore)', async () => {
const ctx = makeCtx()
const { m } = await openPanel(ctx)
const input = query<HTMLInputElement>(m.container, 'input.lc-ov-search')
input.focus()
await actType(input, 'beta')
await actType(input, 'bet')
assert.equal(document.activeElement, input, 'every re-render leaves the focused input alone')
await m.unmount()
})
})
/** Type into an input through the React change path (native setter + input event, act-wrapped). */
async function actType(input: HTMLInputElement, value: string): Promise<void> {
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
setter?.call(input, value)
input.dispatchEvent(new Event('input', { bubbles: true }))
})
}