Make the settings card behave like the other plugin cards
It rendered as a flat, always-open block with no header. The framework's card
chrome is a disclosure: the tab renders every card into a <ul>, each card's root
is an <li>, its header is its own <button> that discloses the controls in place,
it starts collapsed, the header marks it when it holds unsaved edits, and a save
that lands folds it back up.
Three things were wrong and only one of them was cosmetic:
* the root was a <div> inside the tab's <ul>, so the list and card styling
never reached it
* there was no header button, so no disclosure and no aria-expanded
* nothing marked unsaved edits, and nothing collapsed after a save
`PluginCard` is not importable — it lives in that package's client bundle, and
the client purity gate forbids cross-plugin value imports — so the behaviour is
reproduced rather than borrowed, with inline styles for the same reason.
The collapse-on-save originally lived in an effect watching a `saving` flag, which
is how the shipped card does it because its form state comes from a controller.
This card awaits its own writes, so it already knows the save landed; collapsing
directly in the success path is simpler and is what the tests can actually reach.
A failed save still keeps the card open with its drafts.
Tests cover the disclosure itself: the root element, collapsed-by-default, the
header's aria-expanded and label, that staged edits survive collapsing, and that
a landed save folds the card up.
This commit is contained in:
1 parent
6b9d91b436
commit
2d7a7d1e73
3 files changed
+186
-65
No files matched your search
@@ -46,6 +46,11 @@ keyed on the `dsh-slopo` settings namespace the Host registers. Every setting in
|
|||||||
the table below is editable there, overridden fields are marked, and Reset
|
the table below is editable there, overridden fields are marked, and Reset
|
||||||
returns a field to the deployment default rather than to empty.
|
returns a field to the deployment default rather than to empty.
|
||||||
|
|
||||||
|
The card behaves the way the other plugin cards do: it starts collapsed, its
|
||||||
|
header is a button that discloses the controls in place, the header marks it when
|
||||||
|
it holds unsaved edits — visible even while collapsed, since staged edits outlive
|
||||||
|
being folded away — and a save that lands collapses it again.
|
||||||
|
|
||||||
The card is the bundle's second half — the browser half. It needs no build step
|
The card is the bundle's second half — the browser half. It needs no build step
|
||||||
and ships as plain ESM in the module-loader wrapper, but the platform snapshots
|
and ships as plain ESM in the module-loader wrapper, but the platform snapshots
|
||||||
client bundles at activation, so **a client half only appears after the profile
|
client bundles at activation, so **a client half only appears after the profile
|
||||||
|
|||||||
+83
-25
@@ -7,6 +7,14 @@
|
|||||||
* claims, so a served namespace with no card draws nothing at all, and a card
|
* claims, so a served namespace with no card draws nothing at all, and a card
|
||||||
* whose namespace is not served is never dispatched.
|
* whose namespace is not served is never dispatched.
|
||||||
*
|
*
|
||||||
|
* The chrome follows the framework's `PluginCard`: the root is an `<li>` (the
|
||||||
|
* tab renders every card into a `<ul>`), the header is its own `<button>` that
|
||||||
|
* discloses the controls in place, the card starts collapsed, it marks itself
|
||||||
|
* when it holds unsaved edits, and it collapses again after a successful save.
|
||||||
|
* Nothing about that is imported — `PluginCard` lives in that package's client
|
||||||
|
* bundle and the client purity gate forbids cross-plugin value imports — so the
|
||||||
|
* behaviour is reproduced here rather than borrowed.
|
||||||
|
*
|
||||||
* Hand-written plain JavaScript inside the module-loader wrapper, with no build
|
* Hand-written plain JavaScript inside the module-loader wrapper, with no build
|
||||||
* step and no bundler. `dsh plugin add git+…` fetches sources rather than build
|
* step and no bundler. `dsh plugin add git+…` fetches sources rather than build
|
||||||
* output, so a `prepare` script would make every user allowlist a build before
|
* output, so a `prepare` script would make every user allowlist a build before
|
||||||
@@ -14,8 +22,8 @@
|
|||||||
* platform seed table; anything outside that table would have to be bundled
|
* platform seed table; anything outside that table would have to be bundled
|
||||||
* into this file or declared in `dsh.client.external`.
|
* into this file or declared in `dsh.client.external`.
|
||||||
*
|
*
|
||||||
* Styling is inline on purpose. The client bundle purity gate forbids
|
* Styling is inline on purpose, for the same reason: there is no stylesheet this
|
||||||
* cross-plugin value imports, so there is no stylesheet this file may reach.
|
* file may reach.
|
||||||
*/
|
*/
|
||||||
window.__ModuleLoader__.load({
|
window.__ModuleLoader__.load({
|
||||||
id: 'dsh-slopo',
|
id: 'dsh-slopo',
|
||||||
@@ -28,6 +36,9 @@ window.__ModuleLoader__.load({
|
|||||||
const NS = 'dsh-slopo'
|
const NS = 'dsh-slopo'
|
||||||
const h = react.createElement
|
const h = react.createElement
|
||||||
|
|
||||||
|
const TITLE = 'Slopo'
|
||||||
|
const DESCRIPTION = 'Embedding backend, index scope and the budget that stops a large embed.'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The settings this card edits.
|
* The settings this card edits.
|
||||||
*
|
*
|
||||||
@@ -79,11 +90,43 @@ window.__ModuleLoader__.load({
|
|||||||
{ key: 'timeoutMs', label: 'Foreground timeout (ms)', kind: 'number' },
|
{ key: 'timeoutMs', label: 'Foreground timeout (ms)', kind: 'number' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const BORDER = '1px solid var(--dsh-border, #3a3a3a)'
|
||||||
|
|
||||||
const S = {
|
const S = {
|
||||||
card: { border: '1px solid var(--dsh-border, #3a3a3a)', borderRadius: '8px', padding: '16px', margin: '8px 0' },
|
// The tab renders into a <ul>, so the root must be an <li> or the list
|
||||||
head: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: '12px', marginBottom: '4px' },
|
// styling never reaches it.
|
||||||
title: { fontSize: '14px', fontWeight: 600 },
|
card: { listStyle: 'none', border: BORDER, borderRadius: '8px', margin: '8px 0', overflow: 'hidden' },
|
||||||
badge: { fontSize: '11px', opacity: 0.7 },
|
cardOpen: { background: 'var(--dsh-surface, rgba(255,255,255,0.02))' },
|
||||||
|
header: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
width: '100%',
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'inherit',
|
||||||
|
textAlign: 'left',
|
||||||
|
cursor: 'pointer',
|
||||||
|
font: 'inherit',
|
||||||
|
},
|
||||||
|
headText: { display: 'flex', flexDirection: 'column', gap: '2px', flex: '1 1 auto', minWidth: 0 },
|
||||||
|
name: { fontSize: '14px', fontWeight: 600 },
|
||||||
|
description: { fontSize: '11px', opacity: 0.65, lineHeight: 1.45 },
|
||||||
|
pending: {
|
||||||
|
flex: '0 0 auto',
|
||||||
|
fontSize: '10px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: '0.04em',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: BORDER,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
chevron: { flex: '0 0 auto', fontSize: '10px', opacity: 0.7, transition: 'transform 120ms ease' },
|
||||||
|
chevronOpen: { transform: 'rotate(180deg)' },
|
||||||
|
body: { padding: '0 16px 16px 16px' },
|
||||||
|
readOnly: { fontSize: '11px', opacity: 0.75, marginTop: 0, marginBottom: '12px' },
|
||||||
hint: { fontSize: '11px', opacity: 0.65, marginTop: '2px', lineHeight: 1.45 },
|
hint: { fontSize: '11px', opacity: 0.65, marginTop: '2px', lineHeight: 1.45 },
|
||||||
row: { marginTop: '12px' },
|
row: { marginTop: '12px' },
|
||||||
label: { display: 'block', fontSize: '12px', fontWeight: 500, marginBottom: '4px' },
|
label: { display: 'block', fontSize: '12px', fontWeight: 500, marginBottom: '4px' },
|
||||||
@@ -93,7 +136,7 @@ window.__ModuleLoader__.load({
|
|||||||
padding: '6px 8px',
|
padding: '6px 8px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
border: '1px solid var(--dsh-border, #3a3a3a)',
|
border: BORDER,
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
},
|
},
|
||||||
@@ -102,14 +145,13 @@ window.__ModuleLoader__.load({
|
|||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
border: '1px solid var(--dsh-border, #3a3a3a)',
|
border: BORDER,
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
color: 'inherit',
|
color: 'inherit',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
},
|
},
|
||||||
primary: { fontWeight: 600 },
|
primary: { fontWeight: 600 },
|
||||||
note: { fontSize: '11px', opacity: 0.75, marginLeft: 'auto' },
|
note: { fontSize: '11px', opacity: 0.75, marginLeft: 'auto' },
|
||||||
dirty: { fontSize: '11px', opacity: 0.8 },
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The editable string form of one field's value. */
|
/** The editable string form of one field's value. */
|
||||||
@@ -180,6 +222,8 @@ window.__ModuleLoader__.load({
|
|||||||
const [draft, setDraft] = react.useState(null)
|
const [draft, setDraft] = react.useState(null)
|
||||||
const [busy, setBusy] = react.useState(false)
|
const [busy, setBusy] = react.useState(false)
|
||||||
const [note, setNote] = react.useState('')
|
const [note, setNote] = react.useState('')
|
||||||
|
// Collapsed on mount, like every other card in the tab.
|
||||||
|
const [open, setOpen] = react.useState(false)
|
||||||
|
|
||||||
react.useEffect(() => scope.subscribe(() => setSnapshot(scope.getSnapshot())), [scope])
|
react.useEffect(() => scope.subscribe(() => setSnapshot(scope.getSnapshot())), [scope])
|
||||||
|
|
||||||
@@ -196,6 +240,7 @@ window.__ModuleLoader__.load({
|
|||||||
|
|
||||||
const dirtyKeys = FIELDS.filter((field) => current[field.key] !== baseline[field.key]).map((field) => field.key)
|
const dirtyKeys = FIELDS.filter((field) => current[field.key] !== baseline[field.key]).map((field) => field.key)
|
||||||
const overriddenKeys = user !== null && typeof user === 'object' ? Object.keys(user) : []
|
const overriddenKeys = user !== null && typeof user === 'object' ? Object.keys(user) : []
|
||||||
|
const writable = snapshot.writable === true
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
@@ -211,6 +256,9 @@ window.__ModuleLoader__.load({
|
|||||||
}
|
}
|
||||||
setDraft(null)
|
setDraft(null)
|
||||||
setNote('Saved.')
|
setNote('Saved.')
|
||||||
|
// A landed save folds the card back up. A failed or blocked one stays
|
||||||
|
// open with the drafts, which is the whole point of staging them.
|
||||||
|
setOpen(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setNote(`Save failed: ${error?.message ?? error}`)
|
setNote(`Save failed: ${error?.message ?? error}`)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -233,31 +281,39 @@ window.__ModuleLoader__.load({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return h(
|
return h(
|
||||||
'div',
|
'li',
|
||||||
{ style: S.card },
|
{ style: open ? { ...S.card, ...S.cardOpen } : S.card },
|
||||||
h(
|
h(
|
||||||
'div',
|
'button',
|
||||||
{ style: S.head },
|
{
|
||||||
h('div', { style: S.title }, 'Slopo'),
|
type: 'button',
|
||||||
|
style: S.header,
|
||||||
|
'aria-expanded': open,
|
||||||
|
'aria-label': `${open ? 'Collapse' : 'Expand'}: ${TITLE}`,
|
||||||
|
onClick: () => setOpen(!open),
|
||||||
|
},
|
||||||
h(
|
h(
|
||||||
|
'span',
|
||||||
|
{ style: S.headText },
|
||||||
|
h('span', { style: S.name }, TITLE),
|
||||||
|
h('span', { style: S.description }, DESCRIPTION),
|
||||||
|
),
|
||||||
|
// Marked on the header, so unsaved edits are visible while collapsed.
|
||||||
|
dirtyKeys.length > 0 ? h('span', { style: S.pending }, 'unsaved') : null,
|
||||||
|
h('span', { style: open ? { ...S.chevron, ...S.chevronOpen } : S.chevron }, '▾'),
|
||||||
|
),
|
||||||
|
open
|
||||||
|
? h(
|
||||||
'div',
|
'div',
|
||||||
{ style: S.badge },
|
{ style: S.body },
|
||||||
status === 'loading' ? 'loading…' : 'configuration',
|
writable ? null : h('p', { style: S.readOnly, role: 'status' }, 'This namespace is read-only here.'),
|
||||||
snapshot.writable === true ? '' : ' · read-only',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
h(
|
|
||||||
'div',
|
|
||||||
{ style: S.hint },
|
|
||||||
'Slopo runs as a separate process. Nothing here is defaulted: it stays unconfigured until you set a model and its dimensions.',
|
|
||||||
),
|
|
||||||
...FIELDS.map((field) =>
|
...FIELDS.map((field) =>
|
||||||
h(Field, {
|
h(Field, {
|
||||||
key: field.key,
|
key: field.key,
|
||||||
field,
|
field,
|
||||||
value: current[field.key],
|
value: current[field.key],
|
||||||
overridden: overriddenKeys.includes(field.key),
|
overridden: overriddenKeys.includes(field.key),
|
||||||
disabled: busy || snapshot.writable !== true,
|
disabled: busy || !writable,
|
||||||
onChange: (next) => setDraft({ ...current, [field.key]: next }),
|
onChange: (next) => setDraft({ ...current, [field.key]: next }),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -277,6 +333,8 @@ window.__ModuleLoader__.load({
|
|||||||
note === '' ? null : h('span', { style: S.note }, note),
|
note === '' ? null : h('span', { style: S.note }, note),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
: null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+98
-40
@@ -10,7 +10,8 @@
|
|||||||
* The card is then rendered by calling the registered component and expanding
|
* The card is then rendered by calling the registered component and expanding
|
||||||
* the elements it returns, and its handlers are driven directly. That is not a
|
* the elements it returns, and its handlers are driven directly. That is not a
|
||||||
* substitute for looking at it in a browser, but it does check the wiring the
|
* substitute for looking at it in a browser, but it does check the wiring the
|
||||||
* card cannot work without.
|
* card cannot work without — including the disclosure chrome, which is what
|
||||||
|
* makes it look like the other cards in the tab.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
@@ -19,9 +20,14 @@ import { test } from 'node:test'
|
|||||||
|
|
||||||
const SOURCE = readFileSync(new URL('../client/client.js', import.meta.url), 'utf8')
|
const SOURCE = readFileSync(new URL('../client/client.js', import.meta.url), 'utf8')
|
||||||
|
|
||||||
/** Minimal React: enough for createElement plus the three hooks the card uses. */
|
/**
|
||||||
|
* Minimal React: createElement, the three hooks the card uses, and an effect
|
||||||
|
* runner that actually invokes its callback rather than ignoring it.
|
||||||
|
*
|
||||||
|
* `useState` consumes one ordered slot per call, as real hooks do.
|
||||||
|
*/
|
||||||
function makeFakeReact() {
|
function makeFakeReact() {
|
||||||
const states = []
|
const slots = []
|
||||||
let cursor = 0
|
let cursor = 0
|
||||||
return {
|
return {
|
||||||
reset() {
|
reset() {
|
||||||
@@ -32,15 +38,17 @@ function makeFakeReact() {
|
|||||||
useState(initial) {
|
useState(initial) {
|
||||||
const index = cursor
|
const index = cursor
|
||||||
cursor += 1
|
cursor += 1
|
||||||
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial
|
if (!(index in slots)) slots[index] = typeof initial === 'function' ? initial() : initial
|
||||||
return [
|
return [
|
||||||
states[index],
|
slots[index],
|
||||||
(next) => {
|
(next) => {
|
||||||
states[index] = typeof next === 'function' ? next(states[index]) : next
|
slots[index] = typeof next === 'function' ? next(slots[index]) : next
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
useEffect() {},
|
useEffect(effect) {
|
||||||
|
effect()
|
||||||
|
},
|
||||||
useMemo(factory) {
|
useMemo(factory) {
|
||||||
return factory()
|
return factory()
|
||||||
},
|
},
|
||||||
@@ -62,12 +70,7 @@ function loadBundle() {
|
|||||||
return { id: loaded.id, exported, react }
|
return { id: loaded.id, exported, react }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Resolve function components by calling them, until a host element or null. */
|
||||||
* Resolve function components by calling them, until a host element or null.
|
|
||||||
*
|
|
||||||
* This is the whole "renderer": the slot hands back an element whose type is the
|
|
||||||
* card component, and function-typed children are expanded the same way.
|
|
||||||
*/
|
|
||||||
function expand(node, depth = 0) {
|
function expand(node, depth = 0) {
|
||||||
if (depth > 50) throw new Error('the component tree did not settle')
|
if (depth > 50) throw new Error('the component tree did not settle')
|
||||||
if (node === null || node === undefined) return null
|
if (node === null || node === undefined) return null
|
||||||
@@ -150,9 +153,21 @@ function registered() {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
exported.apply(ctx)
|
exported.apply(ctx)
|
||||||
const draw = (scope) => expand(render({ scope }))
|
const draw = (scope) => {
|
||||||
return { id, exported, react, registration, render, draw }
|
react.reset()
|
||||||
|
return expand(render({ scope }))
|
||||||
}
|
}
|
||||||
|
/** Render, click the header, and render again the way a state update would. */
|
||||||
|
const drawOpen = (scope) => {
|
||||||
|
const header = headerOf(draw(scope))
|
||||||
|
header.props.onClick()
|
||||||
|
return draw(scope)
|
||||||
|
}
|
||||||
|
return { id, exported, react, registration, draw, drawOpen }
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerOf = (tree) =>
|
||||||
|
findAll(tree, (node) => node.type === 'button' && node.props['aria-expanded'] !== undefined)[0]
|
||||||
|
|
||||||
const inputFor = (tree, label) =>
|
const inputFor = (tree, label) =>
|
||||||
findAll(tree, (node) => node.type === 'input' && node.props['aria-label'] === label)[0]
|
findAll(tree, (node) => node.type === 'input' && node.props['aria-label'] === label)[0]
|
||||||
@@ -179,9 +194,25 @@ test('applying registers exactly one settings.plugin.item card, keyed on the nam
|
|||||||
assert.equal(registration.key, 'dsh-slopo')
|
assert.equal(registration.key, 'dsh-slopo')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('the card renders a control for every setting the Host accepts', () => {
|
test('the root is an <li>, because the tab renders every card into a <ul>', () => {
|
||||||
|
const { draw } = registered()
|
||||||
|
assert.equal(draw(fakeScope(READY)).type, 'li')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the card starts collapsed, with a header that says so', () => {
|
||||||
const { draw } = registered()
|
const { draw } = registered()
|
||||||
const tree = draw(fakeScope(READY))
|
const tree = draw(fakeScope(READY))
|
||||||
|
assert.equal(headerOf(tree).props['aria-expanded'], false)
|
||||||
|
assert.match(headerOf(tree).props['aria-label'], /^Expand: /)
|
||||||
|
// Nothing is disclosed yet.
|
||||||
|
assert.equal(findAll(tree, (node) => node.type === 'input').length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('clicking the header discloses every setting the Host accepts', () => {
|
||||||
|
const { drawOpen } = registered()
|
||||||
|
const tree = drawOpen(fakeScope(READY))
|
||||||
|
assert.equal(headerOf(tree).props['aria-expanded'], true)
|
||||||
|
assert.match(headerOf(tree).props['aria-label'], /^Collapse: /)
|
||||||
const labels = findAll(tree, (node) => node.type === 'label').map(textOf)
|
const labels = findAll(tree, (node) => node.type === 'label').map(textOf)
|
||||||
for (const expected of [
|
for (const expected of [
|
||||||
'Embedding model',
|
'Embedding model',
|
||||||
@@ -204,8 +235,8 @@ test('the card renders a control for every setting the Host accepts', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('the API key is rendered as a password field', () => {
|
test('the API key is rendered as a password field', () => {
|
||||||
const { draw } = registered()
|
const { drawOpen } = registered()
|
||||||
const tree = draw(fakeScope(READY))
|
const tree = drawOpen(fakeScope(READY))
|
||||||
assert.equal(inputFor(tree, 'Embedding API key').props.type, 'password')
|
assert.equal(inputFor(tree, 'Embedding API key').props.type, 'password')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -214,72 +245,98 @@ test('the card renders nothing when the namespace is unavailable', () => {
|
|||||||
assert.equal(draw(fakeScope({ ...READY, status: 'unavailable' })), null)
|
assert.equal(draw(fakeScope({ ...READY, status: 'unavailable' })), null)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('editing a field marks the card dirty and saving writes it through set()', async () => {
|
test('editing a field marks the header unsaved, and saving writes it through set()', async () => {
|
||||||
const { draw, react } = registered()
|
const { draw, drawOpen, react } = registered()
|
||||||
const scope = fakeScope(READY)
|
const scope = fakeScope(READY)
|
||||||
|
|
||||||
inputFor(draw(scope), 'Embedding model').props.onChange({ target: { value: 'ollama/example:latest' } })
|
const open = drawOpen(scope)
|
||||||
|
inputFor(open, 'Embedding model').props.onChange({ target: { value: 'ollama/example:latest' } })
|
||||||
|
|
||||||
// Re-render the way a state update would.
|
|
||||||
react.reset()
|
|
||||||
const tree = draw(scope)
|
const tree = draw(scope)
|
||||||
|
// Marked on the header, so it is visible while collapsed.
|
||||||
|
assert.match(textOf(headerOf(tree)), /unsaved/)
|
||||||
const save = buttonStartingWith(tree, 'Save')
|
const save = buttonStartingWith(tree, 'Save')
|
||||||
assert.equal(save.props.disabled, false, 'a changed field must enable Save')
|
assert.equal(save.props.disabled, false, 'a changed field must enable Save')
|
||||||
await save.props.onClick()
|
await save.props.onClick()
|
||||||
|
|
||||||
assert.deepEqual(scope.calls.set, [['embeddingModel', 'ollama/example:latest']])
|
assert.deepEqual(scope.calls.set, [['embeddingModel', 'ollama/example:latest']])
|
||||||
assert.deepEqual(scope.calls.unset, [])
|
assert.deepEqual(scope.calls.unset, [])
|
||||||
|
assert.equal(react !== undefined, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a successful save collapses the card again', async () => {
|
||||||
|
const { draw, drawOpen } = registered()
|
||||||
|
const scope = fakeScope(READY)
|
||||||
|
inputFor(drawOpen(scope), 'Embedding model').props.onChange({ target: { value: 'ollama/x:latest' } })
|
||||||
|
await buttonStartingWith(draw(scope), 'Save').props.onClick()
|
||||||
|
|
||||||
|
// The save sets busy=false and note='Saved.', the effect collapses on the
|
||||||
|
// next render, and the one after that is the collapsed card.
|
||||||
|
draw(scope)
|
||||||
|
const settled = draw(scope)
|
||||||
|
assert.equal(headerOf(settled).props['aria-expanded'], false, 'a landed save closes the card')
|
||||||
|
assert.equal(findAll(settled, (node) => node.type === 'input').length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('staged edits survive collapsing', () => {
|
||||||
|
const { draw, drawOpen } = registered()
|
||||||
|
const scope = fakeScope(READY)
|
||||||
|
inputFor(drawOpen(scope), 'Embedding model').props.onChange({ target: { value: 'ollama/kept:latest' } })
|
||||||
|
// Collapse it by hand.
|
||||||
|
headerOf(draw(scope)).props.onClick()
|
||||||
|
const collapsed = draw(scope)
|
||||||
|
assert.equal(headerOf(collapsed).props['aria-expanded'], false)
|
||||||
|
assert.match(textOf(headerOf(collapsed)), /unsaved/)
|
||||||
|
// Re-open: the draft is still there.
|
||||||
|
headerOf(collapsed).props.onClick()
|
||||||
|
assert.equal(inputFor(draw(scope), 'Embedding model').props.value, 'ollama/kept:latest')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a whole number field is sent as a number, not the string the input holds', async () => {
|
test('a whole number field is sent as a number, not the string the input holds', async () => {
|
||||||
const { draw, react } = registered()
|
const { draw, drawOpen } = registered()
|
||||||
const scope = fakeScope(READY)
|
const scope = fakeScope(READY)
|
||||||
inputFor(draw(scope), 'Embedding dimensions').props.onChange({ target: { value: '768' } })
|
inputFor(drawOpen(scope), 'Embedding dimensions').props.onChange({ target: { value: '768' } })
|
||||||
react.reset()
|
|
||||||
await buttonStartingWith(draw(scope), 'Save').props.onClick()
|
await buttonStartingWith(draw(scope), 'Save').props.onClick()
|
||||||
assert.deepEqual(scope.calls.set, [['embeddingDimensions', 768]])
|
assert.deepEqual(scope.calls.set, [['embeddingDimensions', 768]])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('clearing a field is an unset, so it re-inherits rather than becoming empty', async () => {
|
test('clearing a field is an unset, so it re-inherits rather than becoming empty', async () => {
|
||||||
const { draw, react } = registered()
|
const { draw, drawOpen } = registered()
|
||||||
const scope = fakeScope({ ...READY, value: { embeddingModel: 'ollama/old:latest', embeddingDimensions: 768 } })
|
const scope = fakeScope({ ...READY, value: { embeddingModel: 'ollama/old:latest', embeddingDimensions: 768 } })
|
||||||
inputFor(draw(scope), 'Embedding model').props.onChange({ target: { value: '' } })
|
inputFor(drawOpen(scope), 'Embedding model').props.onChange({ target: { value: '' } })
|
||||||
react.reset()
|
|
||||||
await buttonStartingWith(draw(scope), 'Save').props.onClick()
|
await buttonStartingWith(draw(scope), 'Save').props.onClick()
|
||||||
assert.deepEqual(scope.calls.unset, ['embeddingModel'])
|
assert.deepEqual(scope.calls.unset, ['embeddingModel'])
|
||||||
assert.deepEqual(scope.calls.set, [], 'a cleared field must not be written as an empty string')
|
assert.deepEqual(scope.calls.set, [], 'a cleared field must not be written as an empty string')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('an exclude list round-trips through comma-separated text', async () => {
|
test('an exclude list round-trips through comma-separated text', async () => {
|
||||||
const { draw, react } = registered()
|
const { draw, drawOpen } = registered()
|
||||||
const scope = fakeScope({ ...READY, value: { embeddingModel: 'm', embeddingDimensions: 1 } })
|
const scope = fakeScope({ ...READY, value: { embeddingModel: 'm', embeddingDimensions: 1 } })
|
||||||
inputFor(draw(scope), 'Extra exclude patterns').props.onChange({ target: { value: '**/test/**, *.min.js' } })
|
inputFor(drawOpen(scope), 'Extra exclude patterns').props.onChange({ target: { value: '**/test/**, *.min.js' } })
|
||||||
react.reset()
|
|
||||||
await buttonStartingWith(draw(scope), 'Save').props.onClick()
|
await buttonStartingWith(draw(scope), 'Save').props.onClick()
|
||||||
assert.deepEqual(scope.calls.set, [['sourceDirExclude', ['**/test/**', '*.min.js']]])
|
assert.deepEqual(scope.calls.set, [['sourceDirExclude', ['**/test/**', '*.min.js']]])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a boolean field writes a real boolean', async () => {
|
test('a boolean field writes a real boolean', async () => {
|
||||||
const { draw, react } = registered()
|
const { draw, drawOpen } = registered()
|
||||||
const scope = fakeScope(READY)
|
const scope = fakeScope(READY)
|
||||||
const checkbox = findAll(
|
const checkbox = findAll(
|
||||||
draw(scope),
|
drawOpen(scope),
|
||||||
(node) => node.type === 'input' && node.props['aria-label'] === 'Install slopo with uv when missing',
|
(node) => node.type === 'input' && node.props['aria-label'] === 'Install slopo with uv when missing',
|
||||||
)[0]
|
)[0]
|
||||||
checkbox.props.onChange({ target: { checked: true } })
|
checkbox.props.onChange({ target: { checked: true } })
|
||||||
react.reset()
|
|
||||||
await buttonStartingWith(draw(scope), 'Save').props.onClick()
|
await buttonStartingWith(draw(scope), 'Save').props.onClick()
|
||||||
assert.deepEqual(scope.calls.set, [['autoInstall', true]])
|
assert.deepEqual(scope.calls.set, [['autoInstall', true]])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('an overridden field is marked, and Reset clears every override', async () => {
|
test('an overridden field is marked, and Reset clears every override', async () => {
|
||||||
const { draw } = registered()
|
const { drawOpen } = registered()
|
||||||
const scope = fakeScope({
|
const scope = fakeScope({
|
||||||
...READY,
|
...READY,
|
||||||
value: { embeddingModel: 'ollama/x:latest', embeddingDimensions: 768 },
|
value: { embeddingModel: 'ollama/x:latest', embeddingDimensions: 768 },
|
||||||
user: { embeddingModel: 'ollama/x:latest', embeddingDimensions: 768 },
|
user: { embeddingModel: 'ollama/x:latest', embeddingDimensions: 768 },
|
||||||
})
|
})
|
||||||
const tree = draw(scope)
|
const tree = drawOpen(scope)
|
||||||
const labels = findAll(tree, (node) => node.type === 'label').map(textOf)
|
const labels = findAll(tree, (node) => node.type === 'label').map(textOf)
|
||||||
assert.ok(labels.some((label) => label.includes('overridden')), 'a user-set field must be marked')
|
assert.ok(labels.some((label) => label.includes('overridden')), 'a user-set field must be marked')
|
||||||
|
|
||||||
@@ -289,9 +346,10 @@ test('an overridden field is marked, and Reset clears every override', async ()
|
|||||||
assert.deepEqual(scope.calls.unset.sort(), ['embeddingDimensions', 'embeddingModel'])
|
assert.deepEqual(scope.calls.unset.sort(), ['embeddingDimensions', 'embeddingModel'])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('a read-only namespace disables every control', () => {
|
test('a read-only namespace says so and disables every control', () => {
|
||||||
const { draw } = registered()
|
const { drawOpen } = registered()
|
||||||
const tree = draw(fakeScope({ ...READY, writable: false, mode: 'memory' }))
|
const tree = drawOpen(fakeScope({ ...READY, writable: false, mode: 'memory' }))
|
||||||
|
assert.match(textOf(tree), /read-only/)
|
||||||
const inputs = findAll(tree, (node) => node.type === 'input')
|
const inputs = findAll(tree, (node) => node.type === 'input')
|
||||||
assert.ok(inputs.length > 0)
|
assert.ok(inputs.length > 0)
|
||||||
for (const input of inputs) assert.equal(input.props.disabled, true)
|
for (const input of inputs) assert.equal(input.props.disabled, true)
|
||||||
|
|||||||
Reference in new issue
Block a user