From 4dcdfafbef3133814f5b49ff4d3e125bb884879a Mon Sep 17 00:00:00 2001 From: bowenliang123 Date: Sun, 4 Oct 2026 11:11:59 +0800 Subject: [PATCH] feat(settings): trend duration curve visibility preference (default on) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Context preferences card gains its eighth row — the trend duration curve — served across both settings generations: the V3 `settings.register` schema and the V4 Config form (a `.volatile()` field, so an edit never remounts the entry). The client settings store parses it fail-open like its neighbours; the trend card's Duration toggle seeds its mount state from it (default on), and in-chart toggling stays mount-local and never writes back. --- README.md | 2 +- docs/compatibility.md | 4 +- src/client/components/contextView.tsx | 5 ++- src/client/components/settingsCard.tsx | 12 +++++- src/client/i18n.ts | 2 + src/client/settings.ts | 15 +++++-- src/host/config.ts | 5 ++- src/host/settings.ts | 3 +- src/shared/types.ts | 4 ++ tests/client/components/contextView.spec.ts | 10 +++-- tests/client/components/settingsCard.spec.ts | 44 ++++++++++++++------ tests/client/index.spec.ts | 4 +- tests/client/settings.spec.ts | 19 ++++++++- tests/host/config.spec.ts | 4 +- tests/host/settings.spec.ts | 15 ++++--- 15 files changed, 111 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 4122b15..29ad719 100644 --- a/README.md +++ b/README.md @@ -174,7 +174,7 @@ A centered dialog opens with the **Current Composition** card and the **Context ## ⚙️ Settings -The **Context** preferences card holds this plugin's per-user settings — default placement, trend granularity (Step/Turn), trend mode (Total/Delta), tool and File Activity sort, and the sidebar insights entry. Where it lives depends on your dsh release: +The **Context** preferences card holds this plugin's per-user settings — default placement, trend granularity (Step/Turn), trend mode (Total/Delta), the trend duration curve (shown by default), tool and File Activity sort, and the sidebar insights entry. Where it lives depends on your dsh release: - **dsh 0.1.7+** — the sidebar **Plugins** entry → the **dsh-context** bundle's page → its **Configuration** section (the card is served by the entry's live Config form). - **older releases** — **Settings → Plugins → Plugin configuration** → the **Context** card. diff --git a/docs/compatibility.md b/docs/compatibility.md index 6d9f161..75b99b2 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -52,8 +52,8 @@ The fold never branches on a detected harness version. The version probe is best - **Tool-result error mark**: V4 made the event-level `data.error` identity optional while lifting `isError` onto the message, so the fold reads the flag from BOTH spellings (`src/host/fold.ts`); a V4 result carrying only `message.isError` still flags its surface node and file-op row. - **Image offload accounting (deferred)**: when a route rejects with `IMAGE_OFFLOAD_REQUIRED`, the harness offloads images out of the context through a message projection. The plugin's fold reads raw durable events, so an offloaded message keeps its pre-offload image count and price until compaction removes it — an over-count on a rare recovery path, not a crash. Revisit alongside the harness's own token-meter treatment if it shows up in practice. - **User preferences (shipped)**: the V4 line (first shipped in `0.1.6-alpha.2`) retired the `settings.register` host face and the `settings.plugin.item` browser slot — plugin configuration moved to the Plugins page, derived from each loader entry's own Config schema. The plugin serves both generations from one entry schema: - - The entry `Config` (`src/host/config.ts`) is schemastery. Cordis still validates the `config:` block through its Standard Schema face on every supported line; on V4+ the SAME schema is what the settings `describe` projects — the namespace is the entry id (`dsh-context`) and the six preference fields (`.volatile()`-marked) are the served, live-editable form; the fold bounds stay ordinary entry config (their edit remounts the entry, a preference edit commits volatile-only and never remounts). - - The browser half registers its card on the seat the running line declares, by service presence: `settingsScope` + `settings.plugin.item` on V3, `configForms` + the Plugins page's keyed `plugins.bundle.config` on V4+ (`ctx.configForms.whileServed` keeps the card alive only while the Host serves the namespace). Both seats render the same six preference rows. + - The entry `Config` (`src/host/config.ts`) is schemastery. Cordis still validates the `config:` block through its Standard Schema face on every supported line; on V4+ the SAME schema is what the settings `describe` projects — the namespace is the entry id (`dsh-context`) and the eight preference fields (`.volatile()`-marked) are the served, live-editable form; the fold bounds stay ordinary entry config (their edit remounts the entry, a preference edit commits volatile-only and never remounts). + - The browser half registers its card on the seat the running line declares, by service presence: `settingsScope` + `settings.plugin.item` on V3, `configForms` + the Plugins page's keyed `plugins.bundle.config` on V4+ (`ctx.configForms.whileServed` keeps the card alive only while the Host serves the namespace). Both seats render the same eight preference rows. - The `.volatile()` modifier exists only on the Config-form generations' schemastery; the schema builder feature-detects it, so the same bundle serves both generations. - Deliberate relaxations against the replaced zod schema: schemastery objects merge unknown keys through instead of failing the load, and only whole-value edits are rejected by range/step checks (bounds remain `min 1, step 1`). - Preference values persisted on the V3 line live in the settings document (`settings.yaml` section `dsh-context`); the V4 harness's own legacy import moves that section into the profile entry of the same name on first boot, so existing values carry into the new surface. diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index 344d704..2359034 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -119,8 +119,9 @@ export function makeContextView( // toggles above, mount-local and never written back. const [adaptive, setAdaptive] = useState(false) // The duration overlay (the title-adjacent toggle): each bar's step active time as a curve over the - // bars, read off the right-hand quartile axis; like the two toggles above, mount-local and never written back. - const [durationCurve, setDurationCurve] = useState(true) + // bars, read off the right-hand quartile axis. The settings card's `defaultDurationCurve` seeds the mount + // state (default on); in-chart toggling stays mount-local and never writes back. + const [durationCurve, setDurationCurve] = useState(() => settings.defaultDurationCurve() === 'show') // Strip-clicked turn: chart switches to turn granularity and scroll-centers that turn's bar, then clears via onFocusTurnHandled. const [focusTurn, setFocusTurn] = useState(null) // Chat → Context jump: the assistant-action relay's one-shot request, held until the projection data is in, then resolved into a diff --git a/src/client/components/settingsCard.tsx b/src/client/components/settingsCard.tsx index f01d918..60eeb88 100644 --- a/src/client/components/settingsCard.tsx +++ b/src/client/components/settingsCard.tsx @@ -1,5 +1,5 @@ /** - * The dsh-context preference cards — two seats over the same seven rows. The + * The dsh-context preference cards — two seats over the same eight rows. The * settings-section card (`settings.plugin.item`, the older harness lines) * renders a collapsible list item in Settings → Plugins → Plugin * configuration; the Plugins-page card (`plugins.bundle.config`, the @@ -114,6 +114,16 @@ function PreferenceRows(props: { t: Translate; state: SettingsState; set?: Setti ]} onPick={(id) => { set?.('defaultTrendMode', id) }} /> + { set?.('defaultDurationCurve', id) }} + /> = { 'insightsEntry.hide': '隐藏', 'settings.gran': '趋势图 · 默认粒度', 'settings.mode': '趋势图 · 默认展示方式', + 'settings.durationCurve': '趋势图 · 耗时曲线', 'settings.deltaBase': '上下文浏览器 · 对比基线', 'settings.toolSort': '工具定义 · 默认排序', 'settings.fileSort': '文件活动 · 默认排序', @@ -486,6 +487,7 @@ export const DICT_EN: Record = { 'insightsEntry.hide': 'Hide', 'settings.gran': 'Trend Chart · Default granularity', 'settings.mode': 'Trend Chart · Default display', + 'settings.durationCurve': 'Trend Chart · Duration curve', 'settings.deltaBase': 'Context Browser · Diff baseline', 'settings.toolSort': 'Tool Schemas · Default sort', 'settings.fileSort': 'File Activity · Default sort', diff --git a/src/client/settings.ts b/src/client/settings.ts index 2228a9f..600dbab 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -12,11 +12,11 @@ * members keeps the dependency graph honest. */ -import type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, SettingsField } from '../shared/types' +import type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultDurationCurve, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, SettingsField } from '../shared/types' // The preference vocabulary is declared once in shared/types.ts; re-exported // here so client-side consumers keep their canonical import path. -export type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, SettingsField } from '../shared/types' +export type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultDurationCurve, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, SettingsField } from '../shared/types' /** The bound settings scope (ctx.settingsScope.bind result), as consumed. */ export interface SettingsScopeLike { @@ -52,6 +52,7 @@ export interface SettingsState { toolSort: DefaultToolSort fileSort: DefaultFileSort insightsEntry: InsightsEntry + durationCurve: DefaultDurationCurve writable: boolean } @@ -65,6 +66,7 @@ export interface ContextSettings { defaultToolSort(): DefaultToolSort defaultFileSort(): DefaultFileSort insightsEntry(): InsightsEntry + defaultDurationCurve(): DefaultDurationCurve attach(scope: SettingsScopeLike): () => void /** Persist one preference choice (local echo, then the fenced scope write). */ set(field: SettingsField, value: string): void @@ -78,6 +80,7 @@ type Prefs = { toolSort?: DefaultToolSort fileSort?: DefaultFileSort insightsEntry?: InsightsEntry + durationCurve?: DefaultDurationCurve } function prefsOf(value: unknown): Prefs { @@ -91,17 +94,19 @@ function prefsOf(value: unknown): Prefs { ...(v.defaultToolSort === 'size' || v.defaultToolSort === 'count' || v.defaultToolSort === 'name' ? { toolSort: v.defaultToolSort } : {}), ...(v.defaultFileSort === 'count' || v.defaultFileSort === 'latest' || v.defaultFileSort === 'path' ? { fileSort: v.defaultFileSort } : {}), ...(v.insightsEntry === 'show' || v.insightsEntry === 'hide' ? { insightsEntry: v.insightsEntry } : {}), + ...(v.defaultDurationCurve === 'show' || v.defaultDurationCurve === 'hide' ? { durationCurve: v.defaultDurationCurve } : {}), } } export function createContextSettings(): ContextSettings { - let state: SettingsState = { status: 'loading', placement: 'all', granularity: 'step', mode: 'total', deltaBase: 'step', toolSort: 'count', fileSort: 'count', insightsEntry: 'show', writable: false } + let state: SettingsState = { status: 'loading', placement: 'all', granularity: 'step', mode: 'total', deltaBase: 'step', toolSort: 'count', fileSort: 'count', insightsEntry: 'show', durationCurve: 'show', writable: false } let scope: SettingsScopeLike | undefined const listeners = new Set<() => void>() const publish = (next: SettingsState): void => { if (next.status === state.status && next.placement === state.placement && next.granularity === state.granularity && next.mode === state.mode && next.deltaBase === state.deltaBase && next.toolSort === state.toolSort - && next.fileSort === state.fileSort && next.insightsEntry === state.insightsEntry && next.writable === state.writable) return + && next.fileSort === state.fileSort && next.insightsEntry === state.insightsEntry && next.durationCurve === state.durationCurve + && next.writable === state.writable) return state = next for (const listener of listeners) listener() } @@ -127,6 +132,7 @@ export function createContextSettings(): ContextSettings { toolSort: prefs.toolSort ?? state.toolSort, fileSort: prefs.fileSort ?? state.fileSort, insightsEntry: prefs.insightsEntry ?? (raw?.insightsEntry === undefined ? state.insightsEntry : 'show'), + durationCurve: prefs.durationCurve ?? (raw?.defaultDurationCurve === undefined ? state.durationCurve : 'show'), writable: snap.writable, }) return { placement: prefs.placement, insightsEntry: prefs.insightsEntry } @@ -146,6 +152,7 @@ export function createContextSettings(): ContextSettings { defaultToolSort: () => state.toolSort, defaultFileSort: () => state.fileSort, insightsEntry: () => state.insightsEntry, + defaultDurationCurve: () => state.durationCurve, attach(bound) { scope = bound sync(bound) diff --git a/src/host/config.ts b/src/host/config.ts index 0e35b38..0824c53 100644 --- a/src/host/config.ts +++ b/src/host/config.ts @@ -17,7 +17,7 @@ */ import z from '@deepseek-ai/schemastery' -import type { DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultTrendMode, DefaultToolSort, InsightsEntry } from '../shared/types' +import type { DefaultFileSort, DefaultGranularity, DefaultDurationCurve, DefaultPlacement, DefaultTrendMode, DefaultToolSort, InsightsEntry } from '../shared/types' export interface Config { /** Cap on kept per-step request records (the hard step backstop). */ @@ -47,6 +47,8 @@ export interface Config { defaultFileSort?: DefaultFileSort /** Whether the Context Insights panel's sidebar entry is offered. */ insightsEntry?: InsightsEntry + /** Whether the trend chart mounts with the duration curve overlaid. */ + defaultDurationCurve?: DefaultDurationCurve } /** The fold's retention/slice bounds, as the schema resolves them. */ @@ -100,6 +102,7 @@ export const Config = z.object({ defaultToolSort: volatileField(z.union(['size', 'count', 'name']).default('count').loose()), defaultFileSort: volatileField(z.union(['count', 'latest', 'path']).default('count').loose()), insightsEntry: volatileField(z.union(['show', 'hide']).default('show').loose()), + defaultDurationCurve: volatileField(z.union(['show', 'hide']).default('show').loose()), }) /** Resolve the fold's retention bounds (the schema fills every default). */ diff --git a/src/host/settings.ts b/src/host/settings.ts index d18de15..4534cc6 100644 --- a/src/host/settings.ts +++ b/src/host/settings.ts @@ -27,7 +27,7 @@ export const SETTINGS_NAMESPACE = 'dsh-context' // The preference vocabulary is declared once in shared/types.ts; re-exported // here so host-side consumers keep their canonical import path. -export type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, PluginSettings } from '../shared/types' +export type { DefaultDeltaBase, DefaultFileSort, DefaultGranularity, DefaultDurationCurve, DefaultPlacement, DefaultToolSort, DefaultTrendMode, InsightsEntry, PluginSettings } from '../shared/types' /** Section schema: also the wire envelope the browser scope validates against. */ export const SettingsSchema = z.object({ @@ -41,6 +41,7 @@ export const SettingsSchema = z.object({ // Loose so a stale value also reads as the default (`show`): a config // problem must never take the panel's entry away. insightsEntry: z.union(['show', 'hide']).default('show').loose(), + defaultDurationCurve: z.union(['show', 'hide']).default('show').loose(), }) /** Serve the namespace while a settings provider with the register face is composed; inert otherwise. */ diff --git a/src/shared/types.ts b/src/shared/types.ts index 2722253..b81fdef 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -164,6 +164,9 @@ export type DefaultPlacement = 'all' | 'tab' | 'sidebar' /** Whether the Context Insights panel's sidebar entry is offered at all. */ export type InsightsEntry = 'show' | 'hide' +/** Whether the trend chart mounts with the per-request duration curve overlaid. */ +export type DefaultDurationCurve = 'show' | 'hide' + export interface PluginSettings { defaultPlacement: DefaultPlacement defaultGranularity: DefaultGranularity @@ -172,6 +175,7 @@ export interface PluginSettings { defaultToolSort: DefaultToolSort defaultFileSort: DefaultFileSort insightsEntry: InsightsEntry + defaultDurationCurve: DefaultDurationCurve } /** The section fields the settings card edits, as the Host schema names them. */ diff --git a/tests/client/components/contextView.spec.ts b/tests/client/components/contextView.spec.ts index e13cddb..909132d 100644 --- a/tests/client/components/contextView.spec.ts +++ b/tests/client/components/contextView.spec.ts @@ -514,9 +514,9 @@ describe('ContextView — interactions', () => { assert.equal(group.getAttribute('title'), DICT_EN['trend.durationTip']) const adaptiveGroup = buttonByText(m.container, DICT_EN['trend.adaptive']).parentElement as HTMLElement assert.equal(group.previousElementSibling, adaptiveGroup, 'Duration rides right of the adaptive switch') - // The overlay mounts ON by default: the curve spans the two stamped bars and the right axis reads - // the 2s peak (jsdom's zero-width viewport keeps the whole-log scale, adaptive off anyway). - assert.ok(msBtn().className.includes('lc-gran-on'), 'on at mount') + // The settings default (show) mounts the overlay ON: the curve spans the two stamped bars and the + // right axis reads the 2s peak (jsdom's zero-width viewport keeps the whole-log scale, adaptive off anyway). + assert.ok(msBtn().className.includes('lc-gran-on'), 'on at mount (the settings default)') assert.equal(queryAll(m.container, '.lc-duration polyline').length, 1) assert.equal(text(query(m.container, '.lc-axis-r .lc-axis-top')), '2.0s') assert.equal(text(query(m.container, '.lc-axis-r .lc-axis-mid')), '1.0s') @@ -1201,7 +1201,7 @@ describe('ContextView — locale and settings', () => { getSnapshot: () => ({ status: 'ready', writable: true, - value: { defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultFileSort: 'path' }, + value: { defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultFileSort: 'path', defaultDurationCurve: 'hide' }, }), subscribe: () => () => {}, set: async () => {}, @@ -1229,6 +1229,8 @@ describe('ContextView — locale and settings', () => { })) 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) // The File Activity card opens sorted by the 'path' preference, not by op count. diff --git a/tests/client/components/settingsCard.spec.ts b/tests/client/components/settingsCard.spec.ts index 1cf1348..c76d4f8 100644 --- a/tests/client/components/settingsCard.spec.ts +++ b/tests/client/components/settingsCard.spec.ts @@ -21,7 +21,7 @@ function hookFor(state: SettingsState) { } function stateOf(partial: Partial = {}): SettingsState { - return { status: 'ready', placement: 'all', granularity: 'step', mode: 'total', deltaBase: 'step', toolSort: 'count', fileSort: 'count', insightsEntry: 'show', writable: true, ...partial } + return { status: 'ready', placement: 'all', granularity: 'step', mode: 'total', deltaBase: 'step', toolSort: 'count', fileSort: 'count', insightsEntry: 'show', durationCurve: 'show', writable: true, ...partial } } /** Menu items portaled into document.body while a select is open. */ @@ -65,7 +65,7 @@ describe('SettingsCard', () => { assert.equal(head.getAttribute('aria-label'), `${DICT_EN['settings.collapse']}: ${DICT_EN['settings.title']}`) assert.ok(card.className.includes('lc-settings-open')) const selects = queryAll(m.container, '.lc-settings-select') - assert.equal(selects.length, 7) + assert.equal(selects.length, 8) assert.ok(selects.every(s => (s as HTMLButtonElement).disabled)) // Loading is not ready: no read-only note. assert.equal(m.container.querySelector('.lc-settings-note'), null) @@ -120,13 +120,16 @@ describe('SettingsCard', () => { // The granularity row follows. assert.ok(text(selects[2]).includes(DICT_EN['gran.step'])) assert.ok(text(selects[3]).includes(DICT_EN['gran.total'])) + // The duration-curve row follows the trend-mode one, reusing the entry's show/hide labels. + assert.ok(text(m.container).includes(DICT_EN['settings.durationCurve'])) + assert.ok(text(selects[4]).includes(DICT_EN['insightsEntry.show'])) // The delta-baseline row leads the tool-sort one, reusing the toolbar's option labels. assert.ok(text(m.container).includes(DICT_EN['settings.deltaBase'])) - assert.ok(text(selects[4]).includes(DICT_EN['browser.base.step'])) + assert.ok(text(selects[5]).includes(DICT_EN['browser.base.step'])) assert.ok(text(m.container).includes(DICT_EN['settings.toolSort'])) - assert.ok(text(selects[5]).includes(DICT_EN['tool.sort.count'])) + assert.ok(text(selects[6]).includes(DICT_EN['tool.sort.count'])) assert.ok(text(m.container).includes(DICT_EN['settings.fileSort'])) - assert.ok(text(selects[6]).includes(DICT_EN['files.sort.count'])) + assert.ok(text(selects[7]).includes(DICT_EN['files.sort.count'])) await click(selects[2]) assert.equal(selects[2].getAttribute('aria-expanded'), 'true') @@ -156,8 +159,22 @@ describe('SettingsCard', () => { ]) assert.equal(document.body.querySelector('[role="menu"]'), null) - // The delta-baseline row writes its field through the toolbar's vocabulary. + // The duration-curve row writes its field through the same show/hide vocabulary. await click(selects[4]) + const curveItems = menuItems() + assert.deepEqual(curveItems.map(i => text(i)), [DICT_EN['insightsEntry.show'], DICT_EN['insightsEntry.hide']]) + await click(curveItems[1]) // 'Hide' + assert.deepEqual(calls, [ + ['defaultPlacement', 'sidebar'], + ['insightsEntry', 'hide'], + ['defaultGranularity', 'turn'], + ['defaultTrendMode', 'delta'], + ['defaultDurationCurve', 'hide'], + ]) + assert.equal(document.body.querySelector('[role="menu"]'), null) + + // The delta-baseline row writes its field through the toolbar's vocabulary. + await click(selects[5]) const baseItems = menuItems() assert.deepEqual(baseItems.map(i => text(i)), [DICT_EN['browser.base.step'], DICT_EN['browser.base.turn']]) await click(baseItems[1]) // 'prev turn' @@ -166,12 +183,13 @@ describe('SettingsCard', () => { ['insightsEntry', 'hide'], ['defaultGranularity', 'turn'], ['defaultTrendMode', 'delta'], + ['defaultDurationCurve', 'hide'], ['defaultDeltaBase', 'turn'], ]) assert.equal(document.body.querySelector('[role="menu"]'), null) // The tool-sort row leads the file-sort one. - await click(selects[5]) + await click(selects[6]) const toolItems = menuItems() assert.deepEqual(toolItems.map(i => text(i)), [ DICT_EN['tool.sort.size'], @@ -184,13 +202,14 @@ describe('SettingsCard', () => { ['insightsEntry', 'hide'], ['defaultGranularity', 'turn'], ['defaultTrendMode', 'delta'], + ['defaultDurationCurve', 'hide'], ['defaultDeltaBase', 'turn'], ['defaultToolSort', 'name'], ]) assert.equal(document.body.querySelector('[role="menu"]'), null) // The file-sort row writes the last field. - await click(selects[6]) + await click(selects[7]) const sortItems = menuItems() assert.deepEqual(sortItems.map(i => text(i)), [ DICT_EN['files.sort.count'], @@ -203,6 +222,7 @@ describe('SettingsCard', () => { ['insightsEntry', 'hide'], ['defaultGranularity', 'turn'], ['defaultTrendMode', 'delta'], + ['defaultDurationCurve', 'hide'], ['defaultDeltaBase', 'turn'], ['defaultToolSort', 'name'], ['defaultFileSort', 'path'], @@ -270,7 +290,7 @@ describe('SettingsCard', () => { const card = query(m.container, '.lc-settings-card') assert.ok(card.className.includes('lc-settings-open')) assert.equal(query(m.container, '.lc-settings-head').getAttribute('aria-expanded'), 'true') - assert.equal(queryAll(m.container, '.lc-settings-select').length, 7) + assert.equal(queryAll(m.container, '.lc-settings-select').length, 8) assert.equal(scrolled.length, 1, 'the card scrolls itself into view') assert.deepEqual(scrolled[0].arg, { block: 'nearest' }) assert.equal(scrolled[0].el, card) @@ -322,7 +342,7 @@ describe('PluginConfigCard (the Plugins-page seat)', () => { await m2.unmount() }) - test('renders the seven rows flat — no card chrome, no expand request consumption', async () => { + test('renders the eight rows flat — no card chrome, no expand request consumption', async () => { requestCardExpand() const m = await mount(h(PluginConfigCard, { useContextSettings: hookFor(stateOf({ status: 'loading', writable: false })) })) assert.equal(m.container.querySelector('.lc-settings-card'), null, 'no settings-section chrome') @@ -330,7 +350,7 @@ describe('PluginConfigCard (the Plugins-page seat)', () => { assert.equal(m.container.querySelector('.lc-settings-head'), null) // The rows render immediately: no expand/collapse leg. const selects = queryAll(m.container, '.lc-settings-select') - assert.equal(selects.length, 7) + assert.equal(selects.length, 8) assert.ok(selects.every(s => s.disabled), 'loading is not ready: the rows are disabled') assert.equal(m.container.querySelector('.lc-settings-note'), null) await m.unmount() @@ -354,7 +374,7 @@ describe('PluginConfigCard (the Plugins-page seat)', () => { assert.equal(writable.container.querySelector('.lc-settings-note'), null) const enabled = queryAll(writable.container, '.lc-settings-select') assert.ok(enabled.every(s => !s.disabled)) - await click(enabled[6]) + await click(enabled[7]) // the file-sort row trails const items = menuItems() assert.deepEqual(items.map(i => text(i)), [ DICT_EN['files.sort.count'], diff --git a/tests/client/index.spec.ts b/tests/client/index.spec.ts index 2d8fcf0..89104e6 100644 --- a/tests/client/index.spec.ts +++ b/tests/client/index.spec.ts @@ -363,7 +363,7 @@ describe('client entry: configForms inject (the Config-form generation)', () => const m = await mount(h(el.type as never, { useContextSettings: (sel: (state: SettingsState) => T): T => sel(store.getSnapshot()), })) - assert.equal(queryAll(m.container, '.lc-settings-select').length, 7) + assert.equal(queryAll(m.container, '.lc-settings-select').length, 8) assert.equal(m.container.querySelector('.lc-settings-head'), null, 'no settings-section chrome on this seat') await m.unmount() disposeRegistration() @@ -453,7 +453,7 @@ describe('client entry: settings card slot', () => { assert.ok(query(m.container, '.lc-settings-card')) await click(query(m.container, '.lc-settings-head')) const selects = queryAll(m.container, '.lc-settings-select') - assert.equal(selects.length, 7) + assert.equal(selects.length, 8) for (const s of selects) assert.ok((s as HTMLButtonElement).disabled) await m.unmount() ctx.dispose() diff --git a/tests/client/settings.spec.ts b/tests/client/settings.spec.ts index 0297267..afc81e5 100644 --- a/tests/client/settings.spec.ts +++ b/tests/client/settings.spec.ts @@ -54,6 +54,7 @@ describe('createContextSettings defaults', () => { toolSort: 'count', fileSort: 'count', insightsEntry: 'show', + durationCurve: 'show', writable: false, }) assert.equal(s.defaultPlacement(), 'all') @@ -63,6 +64,7 @@ describe('createContextSettings defaults', () => { assert.equal(s.defaultToolSort(), 'count') assert.equal(s.defaultFileSort(), 'count') assert.equal(s.insightsEntry(), 'show') + assert.equal(s.defaultDurationCurve(), 'show') }) }) @@ -205,6 +207,7 @@ describe('attach', () => { defaultToolSort: 'name', defaultFileSort: 'path', insightsEntry: 'hide', + defaultDurationCurve: 'hide', }, writable: true, }) @@ -218,6 +221,7 @@ describe('attach', () => { toolSort: 'name', fileSort: 'path', insightsEntry: 'hide', + durationCurve: 'hide', writable: true, }) }) @@ -249,6 +253,7 @@ describe('attach', () => { toolSort: 'count', fileSort: 'count', insightsEntry: 'show', + durationCurve: 'show', writable: false, }) } @@ -258,7 +263,7 @@ describe('attach', () => { const s = createContextSettings() s.attach(new TestSettingsScope({ status: 'ready', - value: { defaultPlacement: 'window', defaultGranularity: 'bogus', defaultTrendMode: 7, defaultDeltaBase: 'bogus', defaultToolSort: 'alpha', defaultFileSort: 'alpha', insightsEntry: 42 }, + value: { defaultPlacement: 'window', defaultGranularity: 'bogus', defaultTrendMode: 7, defaultDeltaBase: 'bogus', defaultToolSort: 'alpha', defaultFileSort: 'alpha', insightsEntry: 42, defaultDurationCurve: 'always' }, writable: false, })) assert.equal(s.defaultPlacement(), 'all') @@ -268,6 +273,7 @@ describe('attach', () => { assert.equal(s.defaultToolSort(), 'count') assert.equal(s.defaultFileSort(), 'count') assert.equal(s.insightsEntry(), 'show') + assert.equal(s.defaultDurationCurve(), 'show') }) test('an invalid scope placement degrades to all instead of keeping the current one', () => { @@ -292,6 +298,15 @@ describe('attach', () => { assert.equal(s.insightsEntry(), 'show') }) + test('an invalid scope duration-curve value degrades to show instead of keeping the current one', () => { + const s = createContextSettings() + s.set('defaultDurationCurve', 'hide') + assert.equal(s.defaultDurationCurve(), 'hide') + const scope = new TestSettingsScope({ status: 'ready', value: { defaultDurationCurve: 'always' }, writable: false }) + s.attach(scope) + assert.equal(s.defaultDurationCurve(), 'show') + }) + test('explicit schema-default values are accepted', () => { const s = createContextSettings() s.attach(new TestSettingsScope({ @@ -313,6 +328,7 @@ describe('attach', () => { s.set('defaultPlacement', 'tab') s.set('defaultToolSort', 'size') s.set('insightsEntry', 'hide') + s.set('defaultDurationCurve', 'hide') s.attach(new TestSettingsScope({ status: 'ready', value: { defaultFileSort: 'latest' }, writable: false })) assert.equal(s.defaultPlacement(), 'tab', 'the in-session choice survives a section without the field') assert.equal(s.defaultGranularity(), 'step') @@ -320,6 +336,7 @@ describe('attach', () => { assert.equal(s.defaultToolSort(), 'size', 'the in-session tool sort survives a section without the field') assert.equal(s.defaultFileSort(), 'latest') assert.equal(s.insightsEntry(), 'hide', 'the in-session entry choice survives a section without the field') + assert.equal(s.defaultDurationCurve(), 'hide', 'the in-session curve choice survives a section without the field') }) test('scope updates republish to subscribers', () => { diff --git a/tests/host/config.spec.ts b/tests/host/config.spec.ts index e4bdbfd..f0f84e7 100644 --- a/tests/host/config.spec.ts +++ b/tests/host/config.spec.ts @@ -34,6 +34,7 @@ describe('Config validator (cordis Standard Schema face)', () => { defaultToolSort: 'count', defaultFileSort: 'count', insightsEntry: 'show', + defaultDurationCurve: 'show', }) assert.deepEqual(resolveBounds(undefined), DEFAULT_BOUNDS) assert.deepEqual(resolveBounds({}), DEFAULT_BOUNDS) @@ -72,7 +73,7 @@ describe('Config validator (cordis Standard Schema face)', () => { describe('preference fields (the Config-form generation surface)', () => { test('every preference is volatile-marked; the bounds are not', () => { const dict = Config.dict ?? {} - for (const pref of ['defaultPlacement', 'defaultGranularity', 'defaultTrendMode', 'defaultDeltaBase', 'defaultToolSort', 'defaultFileSort', 'insightsEntry']) { + for (const pref of ['defaultPlacement', 'defaultGranularity', 'defaultTrendMode', 'defaultDeltaBase', 'defaultToolSort', 'defaultFileSort', 'insightsEntry', 'defaultDurationCurve']) { assert.equal((dict[pref] as z['dict'] extends undefined ? never : { meta?: { volatile?: boolean } }).meta?.volatile, true, pref) } for (const bound of ['maxRequestSteps', 'maxKeptTurns', 'maxEvents', 'maxNodes', 'maxArchiveNodes', 'maxFileOps']) { @@ -89,6 +90,7 @@ describe('preference fields (the Config-form generation surface)', () => { assert.equal(deref(resolved.defaultToolSort), 'count') assert.equal(deref(resolved.defaultFileSort), 'count') assert.equal(deref(resolved.insightsEntry), 'show') + assert.equal(deref(resolved.defaultDurationCurve), 'show') }) test('a stored preference survives the resolve', () => { diff --git a/tests/host/settings.spec.ts b/tests/host/settings.spec.ts index cc5e83c..85ca6a6 100644 --- a/tests/host/settings.spec.ts +++ b/tests/host/settings.spec.ts @@ -73,6 +73,7 @@ describe('installSettings', () => { defaultToolSort: 'count', defaultFileSort: 'count', insightsEntry: 'show', + defaultDurationCurve: 'show', }, 'schema defaults resolve') }) @@ -87,10 +88,11 @@ describe('installSettings', () => { defaultToolSort: 'count', defaultFileSort: 'count', insightsEntry: 'show', + defaultDurationCurve: 'show', }, 'the update resolves over the schema defaults') assert.deepEqual(provider.doc['dsh-context'], { defaultGranularity: 'turn' }, 'the provider persisted the section') - await ctx.settings.update(ns, { defaultPlacement: 'sidebar', defaultTrendMode: 'delta', defaultDeltaBase: 'turn', defaultFileSort: 'path', insightsEntry: 'hide' }) + await ctx.settings.update(ns, { defaultPlacement: 'sidebar', defaultTrendMode: 'delta', defaultDeltaBase: 'turn', defaultFileSort: 'path', insightsEntry: 'hide', defaultDurationCurve: 'hide' }) assert.deepEqual(ctx.settings.get(ns), { defaultPlacement: 'sidebar', defaultGranularity: 'turn', @@ -99,6 +101,7 @@ describe('installSettings', () => { defaultToolSort: 'count', defaultFileSort: 'path', insightsEntry: 'hide', + defaultDurationCurve: 'hide', }, 'every preference field resolves independently') await assert.rejects( @@ -106,8 +109,8 @@ describe('installSettings', () => { 'an unknown granularity fails validation before anything persists', ) // The loose fields degrade instead of rejecting: a stale file sort, - // placement, tool sort, delta baseline, or insights entry resolves to the default. - await ctx.settings.update(ns, { defaultFileSort: 'net', defaultPlacement: 'window', defaultToolSort: 'net', defaultDeltaBase: 'net', insightsEntry: 'gone' }) + // placement, tool sort, delta baseline, insights entry, or curve choice resolves to the default. + await ctx.settings.update(ns, { defaultFileSort: 'net', defaultPlacement: 'window', defaultToolSort: 'net', defaultDeltaBase: 'net', insightsEntry: 'gone', defaultDurationCurve: 'always' }) assert.deepEqual(ctx.settings.get(ns), { defaultPlacement: 'all', defaultGranularity: 'turn', @@ -116,16 +119,17 @@ describe('installSettings', () => { defaultToolSort: 'count', defaultFileSort: 'count', insightsEntry: 'show', + defaultDurationCurve: 'show', }, 'a stale value degrades to the schema default') assert.deepEqual( provider.doc['dsh-context'], - { defaultPlacement: 'window', defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultToolSort: 'net', defaultDeltaBase: 'net', defaultFileSort: 'net', insightsEntry: 'gone' }, + { defaultPlacement: 'window', defaultGranularity: 'turn', defaultTrendMode: 'delta', defaultToolSort: 'net', defaultDeltaBase: 'net', defaultFileSort: 'net', insightsEntry: 'gone', defaultDurationCurve: 'always' }, 'the stale value stays raw in storage and degrades at read', ) }) test('a stale persisted preference degrades to the default (loose)', async () => { - const { ctx } = await boot({ 'dsh-context': { defaultPlacement: 'window', defaultTrendMode: 'net', defaultToolSort: 'alpha', defaultDeltaBase: 'net', defaultFileSort: 'alpha', insightsEntry: 'gone' } }) + const { ctx } = await boot({ 'dsh-context': { defaultPlacement: 'window', defaultTrendMode: 'net', defaultToolSort: 'alpha', defaultDeltaBase: 'net', defaultFileSort: 'alpha', insightsEntry: 'gone', defaultDurationCurve: 'always' } }) const value = ctx.settings.get(ns) as PluginSettings assert.equal(value.defaultPlacement, 'all', 'the stale placement falls back instead of breaking the section') assert.equal(value.defaultTrendMode, 'total', 'the stale value falls back instead of breaking the section') @@ -133,6 +137,7 @@ describe('installSettings', () => { assert.equal(value.defaultToolSort, 'count', 'the stale tool sort falls back instead of breaking the section') assert.equal(value.defaultFileSort, 'count', 'the stale file sort falls back instead of breaking the section') assert.equal(value.insightsEntry, 'show', 'the stale insights entry falls back to visible instead of breaking the section') + assert.equal(value.defaultDurationCurve, 'show', 'the stale curve choice falls back to visible instead of breaking the section') assert.equal(value.defaultGranularity, 'step') })