feat(settings): trend duration curve visibility preference (default on)
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.
This commit is contained in:
1 parent
01b1532b72
commit
4dcdfafbef
15 files changed
+111
-37
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<number | null>(null)
|
||||
// Chat → Context jump: the assistant-action relay's one-shot request, held until the projection data is in, then resolved into a
|
||||
|
||||
@@ -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) }}
|
||||
/>
|
||||
<PrefRow
|
||||
label={t('settings.durationCurve')}
|
||||
value={state.durationCurve}
|
||||
disabled={disabled}
|
||||
options={[
|
||||
{ id: 'show', label: t('insightsEntry.show') },
|
||||
{ id: 'hide', label: t('insightsEntry.hide') },
|
||||
]}
|
||||
onPick={(id) => { set?.('defaultDurationCurve', id) }}
|
||||
/>
|
||||
<PrefRow
|
||||
label={t('settings.deltaBase')}
|
||||
value={state.deltaBase}
|
||||
|
||||
@@ -82,6 +82,7 @@ export const DICT_ZH: Record<string, string> = {
|
||||
'insightsEntry.hide': '隐藏',
|
||||
'settings.gran': '趋势图 · 默认粒度',
|
||||
'settings.mode': '趋势图 · 默认展示方式',
|
||||
'settings.durationCurve': '趋势图 · 耗时曲线',
|
||||
'settings.deltaBase': '上下文浏览器 · 对比基线',
|
||||
'settings.toolSort': '工具定义 · 默认排序',
|
||||
'settings.fileSort': '文件活动 · 默认排序',
|
||||
@@ -486,6 +487,7 @@ export const DICT_EN: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
+11
-4
@@ -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)
|
||||
|
||||
+4
-1
@@ -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). */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -21,7 +21,7 @@ function hookFor(state: SettingsState) {
|
||||
}
|
||||
|
||||
function stateOf(partial: Partial<SettingsState> = {}): 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<HTMLButtonElement>(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<HTMLButtonElement>(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'],
|
||||
|
||||
@@ -363,7 +363,7 @@ describe('client entry: configForms inject (the Config-form generation)', () =>
|
||||
const m = await mount(h(el.type as never, {
|
||||
useContextSettings: <T,>(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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user