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.
130 lines
5.7 KiB
TypeScript
130 lines
5.7 KiB
TypeScript
import { createElement as h } from 'react'
|
|
import assert from 'node:assert/strict'
|
|
import { afterEach, beforeEach, describe, test, vi } from 'vitest'
|
|
import { createContextSettings } from '../../../src/client/settings'
|
|
import type { UseChatLike } from '../../../src/client/services'
|
|
import { resetModelPrices, setModelPricesLoader } from '../../../src/client/modelPrices'
|
|
import { DICT_EN } from '../../../src/client/i18n'
|
|
import { TestClientCtx, TestLocale } from '../helpers/harness'
|
|
import { click, flush, mount, query, queryAll, silenceWindowErrors, text } from '../helpers/kit'
|
|
import { buttonByText, makeView, projectionsFor, richTimeline, settingsWith, T0, timeline } from './contextViewHarness'
|
|
|
|
describe('ContextView — locale and settings', () => {
|
|
// A real-shaped models.dev slice: the cost cell prices against the injected
|
|
// book (1M uncached input at the $0.15 miss rate → $0.15 / ¥1).
|
|
const costed = timeline({
|
|
cost: { 'deepseek-official': { 'deepseek-v4-flash': { peak: { uncached: 1000000, output: 500000, cacheRead: 0, cacheWrite: 0 } } } },
|
|
})
|
|
|
|
beforeEach(() => {
|
|
resetModelPrices()
|
|
setModelPricesLoader(() => Promise.resolve({
|
|
deepseek: { models: { 'deepseek-v4-flash': { cost: { input: 0.15, output: 0.6, cache_read: 0.003 } } } },
|
|
}))
|
|
})
|
|
|
|
afterEach(() => {
|
|
resetModelPrices()
|
|
})
|
|
|
|
test('cost prices in USD by default (no locale service), CNY under zh', async () => {
|
|
const m1 = await mount(h(makeView(new TestClientCtx()), {
|
|
sessionId: 'sv-usd',
|
|
useProjection: projectionsFor(costed),
|
|
}))
|
|
await flush()
|
|
assert.ok(text(m1.container).includes('$'))
|
|
assert.ok(!text(m1.container).includes('¥'))
|
|
await m1.unmount()
|
|
|
|
const ctxZh = new TestClientCtx({ services: { locale: new TestLocale('zh') } })
|
|
const m2 = await mount(h(makeView(ctxZh), {
|
|
sessionId: 'sv-cny',
|
|
useProjection: projectionsFor(costed),
|
|
}))
|
|
await flush()
|
|
assert.ok(text(m2.container).includes('¥'))
|
|
await m2.unmount()
|
|
|
|
const ctxBare = new TestClientCtx({ services: { locale: {} } })
|
|
const m3 = await mount(h(makeView(ctxBare), {
|
|
sessionId: 'sv-bare',
|
|
useProjection: projectionsFor(costed),
|
|
}))
|
|
await flush()
|
|
assert.ok(text(m3.container).includes('$'))
|
|
await m3.unmount()
|
|
})
|
|
|
|
test('mount-time granularity/trend/file-sort defaults come from the bound settings scope', async () => {
|
|
const settings = settingsWith({ defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultFileSort: 'path', defaultDurationCurve: 'hide' })
|
|
const View = makeView(new TestClientCtx(), settings)
|
|
// Two file reads (the 2-op path sorts AFTER the 1-op one under 'count' but BEFORE it under 'path').
|
|
const m = await mount(h(View, {
|
|
sessionId: 'sv-settings',
|
|
useProjection: projectionsFor(richTimeline({
|
|
nodes: [
|
|
...richTimeline().nodes,
|
|
{ seq: 7, cat: 'tool', tokens: 30, tool: 'read', text: 'z', time: T0 + 6000 },
|
|
{ seq: 8, cat: 'tool', tokens: 30, tool: 'read', text: 'a', time: T0 + 7000 },
|
|
{ seq: 9, cat: 'tool', tokens: 30, tool: 'read', text: 'z', time: T0 + 8000 },
|
|
],
|
|
})),
|
|
useChat: (sel =>
|
|
sel({
|
|
legacy: { nodes: [
|
|
{ kind: 'tool', seq: 7, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '/z.ts' }) } },
|
|
{ kind: 'tool', seq: 8, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '/a.ts' }) } },
|
|
{ kind: 'tool', seq: 9, call: { name: 'read', argsRaw: JSON.stringify({ file_path: '/z.ts' }) } },
|
|
] } })) as UseChatLike,
|
|
}))
|
|
assert.ok(buttonByText(m.container, DICT_EN['gran.turn']).className.includes('lc-gran-on'))
|
|
assert.ok(buttonByText(m.container, DICT_EN['gran.delta']).className.includes('lc-gran-on'))
|
|
// The 'hide' curve preference mounts the Duration toggle off (the schema default mounts it on).
|
|
assert.ok(!buttonByText(m.container, DICT_EN['trend.duration']).className.includes('lc-gran-on'))
|
|
// Turn aggregation applies at mount: two bars (turn 1 aggregate + turn-less).
|
|
assert.equal(queryAll(m.container, '.lc-bar').length, 2)
|
|
assert.deepEqual(queryAll(m.container, '.lc-fa-row').map(r => r.title), ['/a.ts', '/z.ts'])
|
|
await m.unmount()
|
|
})
|
|
})
|
|
|
|
describe('ContextView — error boundary', () => {
|
|
test('a render failure degrades to the error card and Retry recovers', async () => {
|
|
// React 18 dev replays the failed render through a fake DOM event (loud
|
|
// stderr via jsdom/vitest) and logs the boundary message to console —
|
|
// both silenced so the deliberate throw stays inside this test.
|
|
vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
const silenceErrors = silenceWindowErrors()
|
|
try {
|
|
const real = createContextSettings()
|
|
// Flag-driven (not call-counted): React 18 dev replays a failed unit of
|
|
// work and retries an errored concurrent pass synchronously, so the
|
|
// failure must persist until the boundary catches, then clear for Retry.
|
|
let fail = true
|
|
const flaky = {
|
|
...real,
|
|
defaultGranularity: (): 'step' | 'turn' => {
|
|
if (fail) throw new Error('boom-settings')
|
|
return real.defaultGranularity()
|
|
},
|
|
}
|
|
const View = makeView(new TestClientCtx(), flaky)
|
|
const m = await mount(h(View, {
|
|
sessionId: 'sv-err',
|
|
useProjection: projectionsFor(richTimeline()),
|
|
}))
|
|
assert.ok(text(m.container).includes(DICT_EN.error))
|
|
assert.ok(text(m.container).includes('boom-settings'))
|
|
|
|
fail = false
|
|
await click(query(m.container, '.lc-error-retry'))
|
|
assert.ok(text(m.container).includes(DICT_EN['overview.title']))
|
|
assert.ok(!text(m.container).includes(DICT_EN.error))
|
|
await m.unmount()
|
|
} finally {
|
|
silenceErrors()
|
|
}
|
|
})
|
|
})
|