The card's eleven rows had grown into one flat list whose only structure
was "Topic · Field" prefixes baked into the labels. The rows now sit under
three titled groups — Entry & Visibility, View Defaults, System Prompt —
so the card reads as where the plugin appears, what its surfaces show by
default, and what it injects, in that order.
The harness projects no group chrome into the `plugins.bundle.config`
slot, so the card draws its own: group heads mirror the harness's
Plugins-page `.sectionTitle` (14px/500 primary, one weight step above the
pinned-400 row labels), separated by its row-separator token. Each group
carries `role="group"` + `aria-label`; the prompt hint moves inside the
prompt group it describes.
Labels drop the prefixes the group titles now carry ("Trend Chart ·
Default granularity" → "Trend Chart · Granularity", "System Prompt ·
Prefix" → "Prefix"), zh/en in lockstep. The spec test asserts the three
groups, their order, and the hint's seat; row order is unchanged.
306 lines
14 KiB
TypeScript
306 lines
14 KiB
TypeScript
import { createElement as h } from 'react'
|
|
import assert from 'node:assert/strict'
|
|
import { describe, test } from 'vitest'
|
|
import { makePluginConfigCard } from '../../../src/client/components/settingsCard'
|
|
import type { SettingsState } from '../../../src/client/settings'
|
|
import { DICT_EN } from '../../../src/client/i18n'
|
|
import { blur, click, focus, keydown, makeKit, mount, query, queryAll, text, typeText } from '../helpers/kit'
|
|
|
|
const kit = makeKit()
|
|
const PluginConfigCard = makePluginConfigCard(kit)
|
|
|
|
function hookFor(state: SettingsState) {
|
|
return <T,>(sel: (s: SettingsState) => T): T => sel(state)
|
|
}
|
|
|
|
function stateOf(partial: Partial<SettingsState> = {}): SettingsState {
|
|
return { status: 'ready', placement: 'all', granularity: 'step', mode: 'total', deltaBase: 'step', toolSort: 'count', fileSort: 'count', insightsEntry: 'show', durationCurve: 'show', fleetTab: 'show', promptPrefix: '', promptSuffix: '', writable: true, ...partial }
|
|
}
|
|
|
|
/** Menu items portaled into document.body while a select is open. */
|
|
function menuItems(): HTMLElement[] {
|
|
return queryAll(document.body, '[role="menu"] [role="menuitem"]')
|
|
}
|
|
|
|
describe('PluginConfigCard (the Plugins-page seat)', () => {
|
|
test('renders nothing without a settings hook or when the namespace is unavailable', async () => {
|
|
const m1 = await mount(h(PluginConfigCard, {}))
|
|
assert.equal(m1.container.childElementCount, 0)
|
|
await m1.unmount()
|
|
|
|
const m2 = await mount(h(PluginConfigCard, { useContextSettings: hookFor(stateOf({ status: 'unavailable' })) }))
|
|
assert.equal(m2.container.childElementCount, 0)
|
|
await m2.unmount()
|
|
})
|
|
|
|
test('renders the nine selects and the two prompt text rows across three titled groups — no card chrome', async () => {
|
|
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')
|
|
assert.ok(query(m.container, '.lc-settings-prefs'))
|
|
const groups = queryAll(m.container, '.lc-settings-group')
|
|
assert.equal(groups.length, 3)
|
|
assert.deepEqual(groups.map(g => g.getAttribute('aria-label')), [
|
|
DICT_EN['settings.group.entry'],
|
|
DICT_EN['settings.group.view'],
|
|
DICT_EN['settings.group.prompt'],
|
|
])
|
|
const selects = queryAll<HTMLButtonElement>(m.container, '.lc-settings-select')
|
|
assert.equal(selects.length, 9)
|
|
assert.ok(selects.every(s => s.disabled), 'loading is not ready: the rows are disabled')
|
|
const textareas = queryAll<HTMLTextAreaElement>(m.container, '.lc-settings-textarea')
|
|
assert.equal(textareas.length, 2)
|
|
assert.ok(textareas.every(t => t.disabled), 'loading is not ready: the text rows are disabled')
|
|
assert.ok(text(groups[2]).includes(DICT_EN['settings.promptHint']), 'the prompt hint lives inside the prompt group')
|
|
assert.equal(m.container.querySelector('[role="status"]'), null, 'no read-only notice while loading')
|
|
await m.unmount()
|
|
})
|
|
|
|
test('ready+writable: enabled selects pick through the real portaled Menu', async () => {
|
|
const calls: [string, string][] = []
|
|
const m = await mount(h(PluginConfigCard, {
|
|
useContextSettings: hookFor(stateOf()),
|
|
set: (field, value) => { calls.push([field, value]) },
|
|
}))
|
|
const selects = queryAll<HTMLButtonElement>(m.container, '.lc-settings-select')
|
|
assert.ok(selects.every(s => !s.disabled))
|
|
assert.equal(m.container.querySelector('[role="status"]'), null, 'writable: no read-only notice')
|
|
assert.ok(text(m.container).includes(DICT_EN['settings.placement']))
|
|
assert.ok(text(selects[0]).includes(DICT_EN['placement.all']))
|
|
await click(selects[0])
|
|
assert.equal(selects[0].getAttribute('aria-expanded'), 'true')
|
|
const placementItems = menuItems()
|
|
assert.equal(placementItems.length, 3)
|
|
assert.deepEqual(placementItems.map(i => text(i)), [
|
|
DICT_EN['placement.all'],
|
|
DICT_EN['placement.tab'],
|
|
DICT_EN['placement.sidebar'],
|
|
])
|
|
await click(placementItems[2]) // 'Sidebar'
|
|
assert.deepEqual(calls, [['defaultPlacement', 'sidebar']])
|
|
assert.equal(selects[0].getAttribute('aria-expanded'), 'false')
|
|
assert.equal(document.body.querySelector('[role="menu"]'), null)
|
|
|
|
assert.ok(text(m.container).includes(DICT_EN['settings.insightsEntry']))
|
|
assert.ok(text(selects[1]).includes(DICT_EN['showHide.show']))
|
|
await click(selects[1])
|
|
const entryItems = menuItems()
|
|
assert.deepEqual(entryItems.map(i => text(i)), [DICT_EN['showHide.show'], DICT_EN['showHide.hide']])
|
|
await click(entryItems[1]) // 'Hide'
|
|
assert.deepEqual(calls, [['defaultPlacement', 'sidebar'], ['insightsEntry', 'hide']])
|
|
|
|
// The Fleet-tab row follows the insights entry: the two view-visibility gates sit together.
|
|
assert.ok(text(m.container).includes(DICT_EN['settings.fleetTab']))
|
|
assert.ok(text(selects[2]).includes(DICT_EN['showHide.show']))
|
|
await click(selects[2])
|
|
const fleetItems = menuItems()
|
|
assert.deepEqual(fleetItems.map(i => text(i)), [DICT_EN['showHide.show'], DICT_EN['showHide.hide']])
|
|
await click(fleetItems[1]) // 'Hide'
|
|
assert.deepEqual(calls, [['defaultPlacement', 'sidebar'], ['insightsEntry', 'hide'], ['fleetTab', 'hide']])
|
|
|
|
assert.ok(text(selects[3]).includes(DICT_EN['gran.step']))
|
|
assert.ok(text(selects[4]).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[5]).includes(DICT_EN['showHide.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[6]).includes(DICT_EN['browser.base.step']))
|
|
assert.ok(text(m.container).includes(DICT_EN['settings.toolSort']))
|
|
assert.ok(text(selects[7]).includes(DICT_EN['tool.sort.count']))
|
|
assert.ok(text(m.container).includes(DICT_EN['settings.fileSort']))
|
|
assert.ok(text(selects[8]).includes(DICT_EN['files.sort.count']))
|
|
|
|
await click(selects[3])
|
|
const items = menuItems()
|
|
assert.deepEqual(items.map(i => text(i)), [DICT_EN['gran.step'], DICT_EN['gran.turn']])
|
|
await click(items[1]) // 'Turn'
|
|
assert.deepEqual(calls, [
|
|
['defaultPlacement', 'sidebar'],
|
|
['insightsEntry', 'hide'],
|
|
['fleetTab', 'hide'],
|
|
['defaultGranularity', 'turn'],
|
|
])
|
|
|
|
await click(selects[4])
|
|
const modeItems = menuItems()
|
|
assert.deepEqual(modeItems.map(i => text(i)), [DICT_EN['gran.total'], DICT_EN['gran.delta']])
|
|
await click(modeItems[1]) // 'Delta'
|
|
assert.deepEqual(calls, [
|
|
['defaultPlacement', 'sidebar'],
|
|
['insightsEntry', 'hide'],
|
|
['fleetTab', 'hide'],
|
|
['defaultGranularity', 'turn'],
|
|
['defaultTrendMode', 'delta'],
|
|
])
|
|
|
|
await click(selects[5])
|
|
const curveItems = menuItems()
|
|
assert.deepEqual(curveItems.map(i => text(i)), [DICT_EN['showHide.show'], DICT_EN['showHide.hide']])
|
|
await click(curveItems[1]) // 'Hide'
|
|
assert.deepEqual(calls, [
|
|
['defaultPlacement', 'sidebar'],
|
|
['insightsEntry', 'hide'],
|
|
['fleetTab', 'hide'],
|
|
['defaultGranularity', 'turn'],
|
|
['defaultTrendMode', 'delta'],
|
|
['defaultDurationCurve', 'hide'],
|
|
])
|
|
|
|
await click(selects[6])
|
|
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'
|
|
assert.deepEqual(calls, [
|
|
['defaultPlacement', 'sidebar'],
|
|
['insightsEntry', 'hide'],
|
|
['fleetTab', 'hide'],
|
|
['defaultGranularity', 'turn'],
|
|
['defaultTrendMode', 'delta'],
|
|
['defaultDurationCurve', 'hide'],
|
|
['defaultDeltaBase', 'turn'],
|
|
])
|
|
|
|
await click(selects[7])
|
|
const toolItems = menuItems()
|
|
assert.deepEqual(toolItems.map(i => text(i)), [
|
|
DICT_EN['tool.sort.size'],
|
|
DICT_EN['tool.sort.count'],
|
|
DICT_EN['tool.sort.name'],
|
|
])
|
|
await click(toolItems[2]) // 'By name'
|
|
assert.deepEqual(calls, [
|
|
['defaultPlacement', 'sidebar'],
|
|
['insightsEntry', 'hide'],
|
|
['fleetTab', 'hide'],
|
|
['defaultGranularity', 'turn'],
|
|
['defaultTrendMode', 'delta'],
|
|
['defaultDurationCurve', 'hide'],
|
|
['defaultDeltaBase', 'turn'],
|
|
['defaultToolSort', 'name'],
|
|
])
|
|
|
|
await click(selects[8])
|
|
const sortItems = menuItems()
|
|
assert.deepEqual(sortItems.map(i => text(i)), [
|
|
DICT_EN['files.sort.count'],
|
|
DICT_EN['files.sort.latest'],
|
|
DICT_EN['files.sort.path'],
|
|
])
|
|
await click(sortItems[2]) // 'By path'
|
|
assert.deepEqual(calls, [
|
|
['defaultPlacement', 'sidebar'],
|
|
['insightsEntry', 'hide'],
|
|
['fleetTab', 'hide'],
|
|
['defaultGranularity', 'turn'],
|
|
['defaultTrendMode', 'delta'],
|
|
['defaultDurationCurve', 'hide'],
|
|
['defaultDeltaBase', 'turn'],
|
|
['defaultToolSort', 'name'],
|
|
['defaultFileSort', 'path'],
|
|
])
|
|
assert.equal(document.body.querySelector('[role="menu"]'), null)
|
|
|
|
await click(selects[0])
|
|
assert.equal(selects[0].getAttribute('aria-expanded'), 'true')
|
|
await click(selects[0])
|
|
assert.equal(selects[0].getAttribute('aria-expanded'), 'false')
|
|
assert.equal(document.body.querySelector('[role="menu"]'), null)
|
|
await m.unmount()
|
|
})
|
|
|
|
test('Menu onClose (Escape) closes an open select without picking', async () => {
|
|
const calls: [string, string][] = []
|
|
const m = await mount(h(PluginConfigCard, {
|
|
useContextSettings: hookFor(stateOf()),
|
|
set: (field, value) => { calls.push([field, value]) },
|
|
}))
|
|
const select = query(m.container, '.lc-settings-select')
|
|
await click(select)
|
|
assert.equal(select.getAttribute('aria-expanded'), 'true')
|
|
assert.equal(menuItems().length, 3, 'the placement row leads with three options')
|
|
await keydown('Escape', document.body)
|
|
assert.equal(select.getAttribute('aria-expanded'), 'false')
|
|
assert.equal(document.body.querySelector('[role="menu"]'), null)
|
|
assert.deepEqual(calls, [])
|
|
await m.unmount()
|
|
})
|
|
|
|
test('ready+readonly: disabled selects and the read-only note', async () => {
|
|
const m = await mount(h(PluginConfigCard, { useContextSettings: hookFor(stateOf({ writable: false })) }))
|
|
const note = query(m.container, '.lc-settings-note')
|
|
assert.equal(note.getAttribute('role'), 'status')
|
|
assert.equal(text(note), DICT_EN['settings.readOnly'])
|
|
assert.ok(queryAll<HTMLButtonElement>(m.container, '.lc-settings-select').every(s => s.disabled))
|
|
assert.ok(queryAll<HTMLTextAreaElement>(m.container, '.lc-settings-textarea').every(t => t.disabled))
|
|
await m.unmount()
|
|
})
|
|
|
|
test('prompt text rows: typing stays local, the blur commits, an unmount without a blur commits too', async () => {
|
|
const calls: [string, string][] = []
|
|
const m = await mount(h(PluginConfigCard, {
|
|
useContextSettings: hookFor(stateOf()),
|
|
set: (field, value) => { calls.push([field, value]) },
|
|
}))
|
|
const textareas = queryAll<HTMLTextAreaElement>(m.container, '.lc-settings-textarea')
|
|
assert.ok(textareas.every(t => !t.disabled))
|
|
assert.equal(textareas[0].placeholder, DICT_EN['settings.promptPlaceholder'])
|
|
|
|
await focus(textareas[0])
|
|
await typeText(textareas[0], 'Be terse.')
|
|
assert.equal(textareas[0].value, 'Be terse.')
|
|
assert.deepEqual(calls, [], 'typing does not write the wire')
|
|
|
|
await blur(textareas[0])
|
|
assert.deepEqual(calls, [['promptPrefix', 'Be terse.']], 'the blur commits the draft')
|
|
|
|
await blur(textareas[1])
|
|
assert.deepEqual(calls, [['promptPrefix', 'Be terse.']], 'blurring an untouched row commits nothing')
|
|
|
|
await focus(textareas[1])
|
|
await typeText(textareas[1], 'Sign as Atlas.')
|
|
await m.unmount()
|
|
assert.deepEqual(calls, [['promptPrefix', 'Be terse.'], ['promptSuffix', 'Sign as Atlas.']], 'the unmount commits a pending draft')
|
|
})
|
|
|
|
test('prompt text rows: a committed draft is not re-committed; an idle row follows the served value; a focused row is never clobbered', async () => {
|
|
const calls: [string, string][] = []
|
|
const m = await mount(h(PluginConfigCard, {
|
|
useContextSettings: hookFor(stateOf({ promptPrefix: 'served' })),
|
|
set: (field, value) => { calls.push([field, value]) },
|
|
}))
|
|
const prefix = query<HTMLTextAreaElement>(m.container, '.lc-settings-textarea')
|
|
assert.equal(prefix.value, 'served')
|
|
|
|
await focus(prefix)
|
|
await typeText(prefix, 'served!')
|
|
// A host push lands while the row is being edited.
|
|
await m.update(h(PluginConfigCard, {
|
|
useContextSettings: hookFor(stateOf({ promptPrefix: 'pushed' })),
|
|
set: (field, value) => { calls.push([field, value]) },
|
|
}))
|
|
assert.equal(query<HTMLTextAreaElement>(m.container, '.lc-settings-textarea').value, 'served!', 'the in-progress draft survives the push')
|
|
await blur(query(m.container, '.lc-settings-textarea'))
|
|
assert.deepEqual(calls, [['promptPrefix', 'served!']])
|
|
|
|
// Idle again: the next push is followed.
|
|
await m.update(h(PluginConfigCard, {
|
|
useContextSettings: hookFor(stateOf({ promptPrefix: 'pushed' })),
|
|
set: (field, value) => { calls.push([field, value]) },
|
|
}))
|
|
assert.equal(query<HTMLTextAreaElement>(m.container, '.lc-settings-textarea').value, 'pushed')
|
|
await m.unmount()
|
|
assert.deepEqual(calls, [['promptPrefix', 'served!']], 'no re-commit of an already served value')
|
|
})
|
|
|
|
test('a value matching no option falls back to the raw id; a missing set never throws', async () => {
|
|
const m = await mount(h(PluginConfigCard, { useContextSettings: hookFor(stateOf({ placement: 'weird' as never })) }))
|
|
const selects = queryAll(m.container, '.lc-settings-select')
|
|
assert.ok(text(selects[0]).includes('weird'))
|
|
// props.set undefined: picking still closes the menu, no throw.
|
|
await click(selects[0])
|
|
await click(menuItems()[1])
|
|
assert.equal(document.body.querySelector('[role="menu"]'), null)
|
|
await m.unmount()
|
|
})
|
|
})
|