Files
dsh-context/tests/client/components/contextView.overview.spec.ts
bowenliang123 6f8dd889de feat(overview): auto-open the trend overview at a step threshold
Add the per-user overviewAutoSteps preference (100/200/300 steps, or off; 200 by default) to the plugin settings card. Once a session's retained step count reaches the threshold, the trend switches to its Overview ribbon and clears the mutually exclusive DNA/Adaptive faces; the auto activation fires once per session so a reader's own toggle-off sticks. The threshold counts raw retained steps, independent of the step/turn granularity.
2026-10-21 11:53:38 +08:00

141 lines
7.3 KiB
TypeScript

// ContextView — the overview face: chip placement and exclusivity, the ribbon swap, and the turn-strip click that
// pins the turn in turn granularity.
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { createElement as h } from 'react'
import { DICT_EN } from '../../../src/client/i18n'
import type { OverviewAutoSteps } from '../../../src/shared/types'
import { TestClientCtx } from '../helpers/harness'
import { click, mount, query, queryAll, text } from '../helpers/kit'
import { buttonByText, makeView, mountRich, projectionsFor, settingsWith, T0, timeline } from './contextViewHarness'
/** A session with `n` retained request steps, one per column. */
function stepsTimeline(n: number) {
return timeline({
model: 'deepseek-v4-flash',
provider: 'deepseek',
contextWindow: 128000,
requests: Array.from({ length: n }, (_, i) => ({
seq: (i + 1) * 2,
turn: Math.floor(i / 2) + 1,
step: (i % 2) + 1,
time: T0 + i * 1000,
system: 100, tools: 200, user: 10, inject: 0, assistant: 20, tool: 0,
total: 330,
})),
})
}
/** Settings bound to one auto-overview choice, served the way the Host form would. */
function viewWith(value: OverviewAutoSteps, sessionId: string, steps: number) {
const View = makeView(new TestClientCtx(), settingsWith({ overviewAutoSteps: value }))
return { View, props: { sessionId, useProjection: projectionsFor(stepsTimeline(steps)) } }
}
describe('ContextView — overview face', () => {
test('the Overview chip rides right of Duration and clears DNA/Adaptive on activation', async () => {
const m = await mountRich('sv-overview-chip')
const mods = buttonByText(m.container, DICT_EN['trend.dna']).parentElement as HTMLElement
const last = (mods.lastElementChild as HTMLElement).textContent
assert.equal(last, DICT_EN['trend.overview'], 'Overview is the mods group\'s last chip')
const overview = () => buttonByText(m.container, DICT_EN['trend.overview'])
const dna = () => buttonByText(m.container, DICT_EN['trend.dna'])
const adaptive = () => buttonByText(m.container, DICT_EN['trend.adaptive'])
await click(dna())
assert.ok(dna().className.includes('lc-gran-on'))
// Overview activation clears DNA; DNA activation clears Overview.
await click(overview())
assert.ok(overview().className.includes('lc-gran-on'))
assert.equal(dna().className.includes('lc-gran-on'), false)
await click(dna())
assert.equal(overview().className.includes('lc-gran-on'), false)
assert.ok(dna().className.includes('lc-gran-on'))
// Adaptive coexists with DNA but activation still clears Overview.
await click(adaptive())
assert.ok(dna().className.includes('lc-gran-on'))
assert.ok(adaptive().className.includes('lc-gran-on'))
await click(overview())
assert.equal(dna().className.includes('lc-gran-on'), false)
assert.equal(adaptive().className.includes('lc-gran-on'), false)
assert.ok(overview().className.includes('lc-gran-on'))
// Toggling off restores the bars face.
await click(overview())
assert.equal(queryAll(m.container, '.lc-bar').length, 3)
await m.unmount()
})
test('the overview face swaps the bars for a full-width ribbon with the same detail flow', async () => {
const m = await mountRich('sv-overview-ribbon')
await click(buttonByText(m.container, DICT_EN['trend.overview']))
assert.equal(queryAll(m.container, '.lc-bar').length, 0)
assert.match(query(m.container, '.lc-chart-scroll').className, /lc-chart-fit/)
const svg = query(m.container, '.lc-fit-svg')
assert.equal(svg.getAttribute('viewBox'), '0 0 2 112')
// The whole retained log's axis: max 420.
assert.equal(query(m.container, '.lc-axis-top').textContent, '420')
// Quartile + full-span record flags (3 records: the interior ½ and the full span), labeled by step, and the
// turn strip's labels.
assert.deepEqual(queryAll(m.container, '.lc-trend-flag-label').map(f => f.textContent), ['2', '3'])
assert.deepEqual(queryAll(m.container, '.lc-turn-label').map(l => l.textContent), ['1', '0'])
// Clicking a strip block forces turn granularity and pins the turn's closing record.
await click(query(m.container, '.lc-turn'))
assert.ok(buttonByText(m.container, DICT_EN['gran.turn']).className.includes('lc-gran-on'))
assert.match(query(m.container, '.lc-fit-cursor').className, /lc-fit-pin/)
assert.ok(text(m.container).includes(DICT_EN['detail.turn'].replace('{t}', '1').replace('{n}', '2')))
await m.unmount()
})
test('a log at the configured step count mounts on the ribbon face', async () => {
const { View, props } = viewWith('100', 'sv-overview-auto', 100)
const m = await mount(h(View, props))
assert.equal(queryAll(m.container, '.lc-bar').length, 0)
assert.ok(query(m.container, '.lc-fit-svg'))
assert.ok(buttonByText(m.container, DICT_EN['trend.overview']).className.includes('lc-gran-on'))
await m.unmount()
})
test('crossing the threshold mid-session switches once, and a manual toggle-off sticks', async () => {
const { View, props } = viewWith('100', 'sv-overview-auto-grow', 99)
const m = await mount(h(View, props))
assert.equal(queryAll(m.container, '.lc-bar').length, 99)
// DNA is on before the crossing: the auto activation clears it (the faces are exclusive).
await click(buttonByText(m.container, DICT_EN['trend.dna']))
await m.update(h(View, { ...props, useProjection: projectionsFor(stepsTimeline(100)) }))
assert.equal(queryAll(m.container, '.lc-bar').length, 0)
assert.ok(buttonByText(m.container, DICT_EN['trend.overview']).className.includes('lc-gran-on'))
assert.equal(buttonByText(m.container, DICT_EN['trend.dna']).className.includes('lc-gran-on'), false)
// The reader turns the ribbon off; the next steps never force it back on.
await click(buttonByText(m.container, DICT_EN['trend.overview']))
assert.equal(queryAll(m.container, '.lc-bar').length, 100)
await m.update(h(View, { ...props, useProjection: projectionsFor(stepsTimeline(101)) }))
assert.equal(queryAll(m.container, '.lc-bar').length, 101)
await m.unmount()
})
test('a higher threshold or the off choice keeps the bars', async () => {
for (const [choice, steps] of [['300', 100], ['off', 300]] as const) {
const { View, props } = viewWith(choice, `sv-overview-auto-${choice}`, steps)
const m = await mount(h(View, props))
assert.ok(queryAll(m.container, '.lc-bar').length > 0, `${choice} leaves the bars face on`)
assert.equal(query(m.container, '.lc-chart-scroll').className.includes('lc-chart-fit'), false)
await m.unmount()
}
})
test('a manual toggle-off survives a session round trip out of an auto-opened session', async () => {
const { View, props } = viewWith('100', 'sv-overview-auto-sticky', 100)
const m = await mount(h(View, props))
await click(buttonByText(m.container, DICT_EN['trend.overview']))
assert.equal(queryAll(m.container, '.lc-bar').length, 100)
// Another session and back: the fired marker keeps the reader's choice for the first one.
await m.update(h(View, { ...props, sessionId: 'sv-overview-auto-other', useProjection: projectionsFor(stepsTimeline(3)) }))
assert.ok(queryAll(m.container, '.lc-bar').length > 0)
await m.update(h(View, props))
assert.equal(queryAll(m.container, '.lc-bar').length, 100)
await m.unmount()
})
})