|
|
@@ -1,16 +1,17 @@
|
|
|
/**
|
|
|
- * The agent-preset management controller: it holds one draft at a time, opens
|
|
|
- * a shipped preset read-only, treats "new" as a copy of an existing
|
|
|
- * composition, and re-reads the roster after every mutation because a save can
|
|
|
- * change more than the row it targeted.
|
|
|
+ * The agent-preset management controller: a copy dialog is the only way a
|
|
|
+ * preset is created, the shipped compositions open in a read-only viewer, and
|
|
|
+ * the way into a custom preset's files is the location action — opened on a
|
|
|
+ * desktop, revealed as a path where the host has none. Every mutation
|
|
|
+ * re-reads the roster because a copy changes more than the row it targeted.
|
|
|
*/
|
|
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
|
|
import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts'
|
|
|
-import type { PresetDraft, PresetRow } from '../src/client/section-store.ts'
|
|
|
+import type { CopyDraft, PresetRow } from '../src/client/section-store.ts'
|
|
|
|
|
|
-interface FakePreset { trust: 'system' | 'user'; content: string }
|
|
|
+interface FakePreset { trust: 'system' | 'user'; content: string; name?: string }
|
|
|
interface Recorded { method: string; payload: unknown }
|
|
|
|
|
|
interface FakeOptions {
|
|
|
@@ -20,16 +21,26 @@ interface FakeOptions {
|
|
|
failList?: string
|
|
|
/** Reject `read` with this message. */
|
|
|
failRead?: string
|
|
|
- /** Reject `write` with this message. */
|
|
|
- failWrite?: string
|
|
|
+ /** Reject `copy` with this message. */
|
|
|
+ failCopy?: string
|
|
|
+ /** Reject `openDocument` with this message. */
|
|
|
+ failOpen?: string
|
|
|
/** Reject `remove` with this message. */
|
|
|
failRemove?: string
|
|
|
/** Reject `settings.update` with this message. */
|
|
|
failSettings?: string
|
|
|
/** Throw from `list` rather than answering, as a dead transport does. */
|
|
|
throwList?: boolean
|
|
|
+ /** Throw from `read`, as a dead transport does. */
|
|
|
+ throwRead?: boolean
|
|
|
+ /** Throw from `copy`, as a dead transport does. */
|
|
|
+ throwCopy?: boolean
|
|
|
+ /** Throw from `openDocument`, as a dead transport does. */
|
|
|
+ throwOpen?: boolean
|
|
|
/** Whether the deployment configures a writable root. */
|
|
|
authorable?: boolean
|
|
|
+ /** Whether the host can open a preset directory on a desktop. */
|
|
|
+ hasDocument?: boolean
|
|
|
/** Hold `remove` until this resolves, to observe the in-flight state. */
|
|
|
holdRemove?: Promise<void>
|
|
|
}
|
|
|
@@ -39,8 +50,8 @@ const fail = (message: string) =>
|
|
|
Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message, details: {} } } })
|
|
|
|
|
|
/**
|
|
|
- * A wire face over an in-memory preset store: writes land, so the roster the
|
|
|
- * controller re-reads after a save is the one the save produced.
|
|
|
+ * A wire face over an in-memory preset store: copies land, so the roster the
|
|
|
+ * controller re-reads after a copy is the one the copy produced.
|
|
|
* @param presets - the starting compositions by id.
|
|
|
* @param defaultId - the preset a session with no choice gets.
|
|
|
* @param options - failure injection and call recording.
|
|
|
@@ -61,12 +72,15 @@ function fakeApi(
|
|
|
return ok({
|
|
|
presets: [...presets].map(([id, preset]) => ({
|
|
|
id, trust: preset.trust, isDefault: id === defaultId.id,
|
|
|
+ ...preset.name === undefined ? {} : { name: preset.name },
|
|
|
})),
|
|
|
authorable: options.authorable ?? true,
|
|
|
+ hasDocument: options.hasDocument ?? true,
|
|
|
})
|
|
|
},
|
|
|
read: (payload: { agentPreset: string }) => {
|
|
|
record('read', payload)
|
|
|
+ if (options.throwRead === true) return Promise.reject(new Error('socket closed'))
|
|
|
if (options.failRead !== undefined) return fail(options.failRead)
|
|
|
const preset = presets.get(payload.agentPreset)
|
|
|
/* v8 ignore next -- every test reads an id the fake store holds */
|
|
|
@@ -75,15 +89,31 @@ function fakeApi(
|
|
|
agentPreset: payload.agentPreset,
|
|
|
trust: preset.trust,
|
|
|
content: preset.content,
|
|
|
- writable: preset.trust === 'user',
|
|
|
+ ...preset.name === undefined ? {} : { name: preset.name },
|
|
|
})
|
|
|
},
|
|
|
- write: (payload: { agentPreset: string; content: string }) => {
|
|
|
- record('write', payload)
|
|
|
- if (options.failWrite !== undefined) return fail(options.failWrite)
|
|
|
- presets.set(payload.agentPreset, { trust: 'user', content: payload.content })
|
|
|
+ copy: (payload: { from: string; agentPreset: string; name?: string }) => {
|
|
|
+ record('copy', payload)
|
|
|
+ if (options.throwCopy === true) return Promise.reject(new Error('socket closed'))
|
|
|
+ if (options.failCopy !== undefined) return fail(options.failCopy)
|
|
|
+ const source = presets.get(payload.from)
|
|
|
+ /* v8 ignore next -- every test copies a source the fake store holds */
|
|
|
+ if (source === undefined) return fail(`unknown preset ${payload.from}`)
|
|
|
+ presets.set(payload.agentPreset, {
|
|
|
+ trust: 'user',
|
|
|
+ content: source.content,
|
|
|
+ ...payload.name === undefined ? {} : { name: payload.name },
|
|
|
+ })
|
|
|
return ok({ agentPreset: payload.agentPreset })
|
|
|
},
|
|
|
+ openDocument: (payload: { agentPreset: string }) => {
|
|
|
+ record('openDocument', payload)
|
|
|
+ if (options.throwOpen === true) return Promise.reject(new Error('socket closed'))
|
|
|
+ if (options.failOpen !== undefined) return fail(options.failOpen)
|
|
|
+ return (options.hasDocument ?? true)
|
|
|
+ ? ok({ opened: true })
|
|
|
+ : ok({ opened: false, path: `/presets/${payload.agentPreset}` })
|
|
|
+ },
|
|
|
remove: async (payload: { agentPreset: string }) => {
|
|
|
record('remove', payload)
|
|
|
await options.holdRemove
|
|
|
@@ -106,7 +136,7 @@ function fakeApi(
|
|
|
|
|
|
function seed(): Map<string, FakePreset> {
|
|
|
return new Map<string, FakePreset>([
|
|
|
- ['standard', { trust: 'system', content: '- id: tool-bash\n' }],
|
|
|
+ ['standard', { trust: 'system', content: '- id: tool-bash\n', name: '标准模式' }],
|
|
|
['mine', { trust: 'user', content: '- id: tool-read\n' }],
|
|
|
])
|
|
|
}
|
|
|
@@ -115,466 +145,436 @@ function harness(options: FakeOptions = {}) {
|
|
|
const presets = seed()
|
|
|
const defaultId = { id: 'standard' }
|
|
|
const calls: Recorded[] = []
|
|
|
+ let rosterChanges = 0
|
|
|
const controller = new AgentPresetSectionController(
|
|
|
fakeApi(presets, defaultId, { ...options, calls: options.calls ?? calls }),
|
|
|
+ () => { rosterChanges += 1 },
|
|
|
)
|
|
|
- return { controller, presets, defaultId, calls }
|
|
|
+ return { controller, presets, defaultId, calls, rosterChanges: () => rosterChanges }
|
|
|
}
|
|
|
|
|
|
-function draftOf(controller: AgentPresetSectionController): PresetDraft {
|
|
|
- const { draft } = controller.store.getSnapshot()
|
|
|
- if (draft === null) throw new Error('expected an open draft')
|
|
|
- return draft
|
|
|
+function copyOf(controller: AgentPresetSectionController): CopyDraft {
|
|
|
+ const { copy } = controller.store.getSnapshot()
|
|
|
+ if (copy === null) throw new Error('expected an open copy dialog')
|
|
|
+ return copy
|
|
|
}
|
|
|
|
|
|
describe('loading the roster', () => {
|
|
|
- it('reports the presets, their trust, the default, and whether authoring is possible', async () => {
|
|
|
- const { controller } = harness()
|
|
|
+ it('maps the roster onto rows with the capability flags', async () => {
|
|
|
+ const { controller } = harness({ authorable: true, hasDocument: false })
|
|
|
|
|
|
await controller.load()
|
|
|
|
|
|
const state = controller.store.getSnapshot()
|
|
|
expect(state.status).toBe('ready')
|
|
|
expect(state.authorable).toBe(true)
|
|
|
- expect(state.rows).toEqual([
|
|
|
- { id: 'standard', trust: 'system', isDefault: true },
|
|
|
- { id: 'mine', trust: 'user', isDefault: false },
|
|
|
- ])
|
|
|
+ expect(state.hasDocument).toBe(false)
|
|
|
+ expect(state.rows.map((row: PresetRow) => row.id)).toEqual(['standard', 'mine'])
|
|
|
+ expect(state.rows[0]).toMatchObject({ trust: 'system', isDefault: true, name: '标准模式' })
|
|
|
})
|
|
|
|
|
|
- it('treats an empty roster as a deployment that composes no presets', async () => {
|
|
|
- const presets = new Map<string, FakePreset>()
|
|
|
- const controller = new AgentPresetSectionController(fakeApi(presets, { id: '' }, { authorable: false }))
|
|
|
+ it('reports an empty roster as unavailable, not as an error', async () => {
|
|
|
+ const { controller, presets } = harness()
|
|
|
+ presets.clear()
|
|
|
|
|
|
await controller.load()
|
|
|
|
|
|
- // Not an error: every session then shares the host composition, and the
|
|
|
- // section renders nothing rather than an empty management page.
|
|
|
expect(controller.store.getSnapshot().status).toBe('unavailable')
|
|
|
- expect(controller.store.getSnapshot().authorable).toBe(false)
|
|
|
})
|
|
|
|
|
|
- it('surfaces a rejected roster call', async () => {
|
|
|
- const { controller } = harness({ failList: 'roster unavailable' })
|
|
|
+ it('keeps one load in flight rather than stacking reads', async () => {
|
|
|
+ const { controller, calls } = harness()
|
|
|
|
|
|
- await controller.load()
|
|
|
+ await Promise.all([controller.load(), controller.load()])
|
|
|
|
|
|
- expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'roster unavailable' })
|
|
|
+ expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
|
|
|
})
|
|
|
|
|
|
- it('surfaces a transport that rejects rather than answering', async () => {
|
|
|
- const { controller } = harness({ throwList: true })
|
|
|
+ it('surfaces a refusal as the page error', async () => {
|
|
|
+ const { controller } = harness({ failList: 'not for you' })
|
|
|
|
|
|
await controller.load()
|
|
|
|
|
|
- expect(controller.store.getSnapshot()).toMatchObject({ status: 'error', error: 'socket closed' })
|
|
|
+ const state = controller.store.getSnapshot()
|
|
|
+ expect(state.status).toBe('error')
|
|
|
+ expect(state.error).toBe('not for you')
|
|
|
})
|
|
|
|
|
|
- it('ignores a load while one is already in flight', async () => {
|
|
|
- const { controller, calls } = harness()
|
|
|
+ it('folds a dead transport into the same error surface', async () => {
|
|
|
+ const { controller } = harness({ throwList: true })
|
|
|
|
|
|
- await Promise.all([controller.load(), controller.load()])
|
|
|
+ await controller.load()
|
|
|
|
|
|
- expect(calls.filter(call => call.method === 'list')).toHaveLength(1)
|
|
|
+ expect(controller.store.getSnapshot().status).toBe('error')
|
|
|
+ expect(controller.store.getSnapshot().error).toContain('socket closed')
|
|
|
})
|
|
|
})
|
|
|
|
|
|
-describe('opening a composition', () => {
|
|
|
- it('opens a locally authored preset for editing', async () => {
|
|
|
+describe('the read-only viewer', () => {
|
|
|
+ it('opens a shipped composition under its display name', async () => {
|
|
|
const { controller } = harness()
|
|
|
await controller.load()
|
|
|
|
|
|
- await controller.open('mine')
|
|
|
+ await controller.view('standard')
|
|
|
|
|
|
- expect(draftOf(controller)).toMatchObject({
|
|
|
- id: 'mine', source: 'mine', creating: false, content: '- id: tool-read\n', writable: true,
|
|
|
+ expect(controller.store.getSnapshot().view).toEqual({
|
|
|
+ id: 'standard', title: '标准模式', content: '- id: tool-bash\n',
|
|
|
})
|
|
|
})
|
|
|
|
|
|
- it('opens a shipped preset read-only', async () => {
|
|
|
+ it('falls back to the id when the preset published no name', async () => {
|
|
|
const { controller } = harness()
|
|
|
await controller.load()
|
|
|
|
|
|
- await controller.open('standard')
|
|
|
+ await controller.view('mine')
|
|
|
|
|
|
- // Readable on purpose: it is the known-good composition a local preset is
|
|
|
- // written against, and duplicating it is how authoring starts.
|
|
|
- expect(draftOf(controller)).toMatchObject({ writable: false, content: '- id: tool-bash\n' })
|
|
|
+ expect(controller.store.getSnapshot().view?.title).toBe('mine')
|
|
|
})
|
|
|
|
|
|
- it('surfaces a rejected read on the page rather than opening an empty editor', async () => {
|
|
|
- const { controller } = harness({ failRead: 'permission denied' })
|
|
|
+ it('closes without touching the list', async () => {
|
|
|
+ const { controller } = harness()
|
|
|
await controller.load()
|
|
|
+ await controller.view('standard')
|
|
|
|
|
|
- await controller.open('mine')
|
|
|
+ controller.closeView()
|
|
|
|
|
|
- expect(controller.store.getSnapshot()).toMatchObject({ draft: null, error: 'permission denied' })
|
|
|
+ expect(controller.store.getSnapshot().view).toBeNull()
|
|
|
+ expect(controller.store.getSnapshot().rows).toHaveLength(2)
|
|
|
})
|
|
|
|
|
|
- it('surfaces a transport failure on the page', async () => {
|
|
|
- const presets = seed()
|
|
|
- const api = fakeApi(presets, { id: 'standard' })
|
|
|
- const controller = new AgentPresetSectionController({
|
|
|
- ...api,
|
|
|
- agentPresets: { ...api.agentPresets, read: () => Promise.reject(new Error('socket closed')) },
|
|
|
- })
|
|
|
+ it('puts a read refusal on the page rather than opening empty', async () => {
|
|
|
+ const { controller } = harness({ failRead: 'no peeking' })
|
|
|
await controller.load()
|
|
|
|
|
|
- await controller.open('mine')
|
|
|
+ await controller.view('standard')
|
|
|
|
|
|
- expect(controller.store.getSnapshot()).toMatchObject({ draft: null, error: 'socket closed' })
|
|
|
+ expect(controller.store.getSnapshot().view).toBeNull()
|
|
|
+ expect(controller.store.getSnapshot().error).toBe('no peeking')
|
|
|
})
|
|
|
|
|
|
- it('closes the editor without writing anything', async () => {
|
|
|
- const { controller, calls } = harness()
|
|
|
+ it('folds a dead transport into the same error surface', async () => {
|
|
|
+ const { controller } = harness({ throwRead: true })
|
|
|
await controller.load()
|
|
|
- await controller.open('mine')
|
|
|
|
|
|
- controller.setContent('- id: changed\n')
|
|
|
- controller.close()
|
|
|
+ await controller.view('standard')
|
|
|
|
|
|
- expect(controller.store.getSnapshot().draft).toBeNull()
|
|
|
- expect(calls.some(call => call.method === 'write')).toBe(false)
|
|
|
+ expect(controller.store.getSnapshot().error).toContain('socket closed')
|
|
|
})
|
|
|
})
|
|
|
|
|
|
-describe('creating a preset', () => {
|
|
|
- it('starts blank when no source is named', async () => {
|
|
|
- const { controller, calls } = harness()
|
|
|
+describe('the copy dialog', () => {
|
|
|
+ it('opens over the source with its display name in the title', async () => {
|
|
|
+ const { controller } = harness()
|
|
|
await controller.load()
|
|
|
- const before = calls.length
|
|
|
|
|
|
- await controller.createFrom()
|
|
|
+ controller.beginCopy('standard')
|
|
|
|
|
|
- // Copying is its own action on the row being copied, so this one is a copy
|
|
|
- // of nothing: no source to name, and no read to make.
|
|
|
- expect(draftOf(controller)).toMatchObject({ id: '', creating: true, writable: true, content: '' })
|
|
|
- expect(draftOf(controller).source).toBeUndefined()
|
|
|
- expect(calls).toHaveLength(before)
|
|
|
+ expect(copyOf(controller)).toMatchObject({
|
|
|
+ from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false,
|
|
|
+ })
|
|
|
})
|
|
|
|
|
|
- it('copies a named preset', async () => {
|
|
|
+ it('falls back to the source id when it published no name', async () => {
|
|
|
const { controller } = harness()
|
|
|
await controller.load()
|
|
|
|
|
|
- await controller.createFrom('mine')
|
|
|
+ controller.beginCopy('mine')
|
|
|
|
|
|
- expect(draftOf(controller)).toMatchObject({ source: 'mine', creating: true, content: '- id: tool-read\n' })
|
|
|
+ expect(copyOf(controller).fromTitle).toBe('mine')
|
|
|
})
|
|
|
|
|
|
- it('opens the blank editor without the roster, which it no longer reads', async () => {
|
|
|
- const { controller, calls } = harness()
|
|
|
+ it('cancel discards whatever was typed', async () => {
|
|
|
+ const { controller } = harness()
|
|
|
+ await controller.load()
|
|
|
+ controller.beginCopy('standard')
|
|
|
+ controller.setCopyId('half-typed')
|
|
|
|
|
|
- await controller.createFrom()
|
|
|
+ controller.cancelCopy()
|
|
|
|
|
|
- expect(draftOf(controller)).toMatchObject({ id: '', creating: true, content: '' })
|
|
|
- expect(calls).toHaveLength(0)
|
|
|
+ expect(controller.store.getSnapshot().copy).toBeNull()
|
|
|
})
|
|
|
-})
|
|
|
|
|
|
-describe('the save blocker', () => {
|
|
|
- const base: PresetDraft = {
|
|
|
- id: '', source: 'standard', creating: true, content: '', writable: true,
|
|
|
- name: '', description: '', saving: false, error: null,
|
|
|
- }
|
|
|
- const rows: readonly PresetRow[] = [{ id: 'mine', trust: 'user', isDefault: false }]
|
|
|
+ it('ignores field edits and submits with no dialog open', async () => {
|
|
|
+ const { controller, calls } = harness()
|
|
|
+ await controller.load()
|
|
|
|
|
|
- it('never blocks an edit of an existing preset', () => {
|
|
|
- expect(draftBlocker({ ...base, id: 'mine', creating: false }, rows)).toBeUndefined()
|
|
|
- })
|
|
|
+ controller.setCopyId('typed-into-nothing')
|
|
|
+ controller.setCopyName('nameless')
|
|
|
+ await controller.confirmCopy()
|
|
|
|
|
|
- it('requires a name', () => {
|
|
|
- expect(draftBlocker(base, rows)).toBe('idRequired')
|
|
|
+ expect(controller.store.getSnapshot().copy).toBeNull()
|
|
|
+ expect(calls.some(call => call.method === 'copy')).toBe(false)
|
|
|
})
|
|
|
|
|
|
- it.each(['Upper', 'has space', '-leading', 'a/b', '../escape'])('rejects the unusable id %j', (id) => {
|
|
|
- // The id becomes a directory name, so the client mirrors the host's own
|
|
|
- // containment rule instead of letting the save round-trip to find out.
|
|
|
- expect(draftBlocker({ ...base, id }, rows)).toBe('idInvalid')
|
|
|
- })
|
|
|
+ it('typing clears the previous failure', async () => {
|
|
|
+ const { controller } = harness({ failCopy: 'disk full' })
|
|
|
+ await controller.load()
|
|
|
+ controller.beginCopy('standard')
|
|
|
+ controller.setCopyId('my-copy')
|
|
|
+ await controller.confirmCopy()
|
|
|
+ expect(copyOf(controller).error).toBe('disk full')
|
|
|
|
|
|
- it('rejects a name already in use', () => {
|
|
|
- // Replacing is what Edit is for; a create landing on an existing name
|
|
|
- // would overwrite a preset the user never opened.
|
|
|
- expect(draftBlocker({ ...base, id: 'mine' }, rows)).toBe('idTaken')
|
|
|
- })
|
|
|
+ controller.setCopyName('renamed')
|
|
|
|
|
|
- it('accepts an unused, containable id', () => {
|
|
|
- expect(draftBlocker({ ...base, id: 'my-agent2' }, rows)).toBeUndefined()
|
|
|
+ expect(copyOf(controller).error).toBeNull()
|
|
|
})
|
|
|
})
|
|
|
|
|
|
-describe('saving', () => {
|
|
|
- it('creates the preset and re-reads the roster', async () => {
|
|
|
- const { controller, presets } = harness()
|
|
|
- await controller.load()
|
|
|
- await controller.createFrom()
|
|
|
- controller.setId('my-agent')
|
|
|
- controller.setContent('- id: tool-web-search\n')
|
|
|
-
|
|
|
- await controller.save()
|
|
|
-
|
|
|
- expect(presets.get('my-agent')).toEqual({ trust: 'user', content: '- id: tool-web-search\n' })
|
|
|
- expect(controller.store.getSnapshot().draft).toBeNull()
|
|
|
- expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('my-agent')
|
|
|
+describe('the copy blocker', () => {
|
|
|
+ const rows: PresetRow[] = [
|
|
|
+ { id: 'standard', trust: 'system', isDefault: true },
|
|
|
+ { id: 'mine', trust: 'user', isDefault: false },
|
|
|
+ ]
|
|
|
+ const draft = (id: string): CopyDraft =>
|
|
|
+ ({ from: 'standard', fromTitle: '标准模式', id, name: '', saving: false, error: null })
|
|
|
+
|
|
|
+ it('requires an id, a containable shape, and a free name', () => {
|
|
|
+ expect(draftBlocker(draft(''), rows)).toBe('idRequired')
|
|
|
+ expect(draftBlocker(draft('../escape'), rows)).toBe('idInvalid')
|
|
|
+ expect(draftBlocker(draft('Upper'), rows)).toBe('idInvalid')
|
|
|
+ expect(draftBlocker(draft('mine'), rows)).toBe('idTaken')
|
|
|
+ expect(draftBlocker(draft('my-copy'), rows)).toBeUndefined()
|
|
|
})
|
|
|
+})
|
|
|
|
|
|
- it('replaces an existing composition', async () => {
|
|
|
- const { controller, presets } = harness()
|
|
|
+describe('submitting a copy', () => {
|
|
|
+ it('copies, re-reads the roster, announces the change, and opens the files', async () => {
|
|
|
+ const { controller, calls, rosterChanges } = harness()
|
|
|
await controller.load()
|
|
|
- await controller.open('mine')
|
|
|
- controller.setContent('- id: tool-edit\n')
|
|
|
-
|
|
|
- await controller.save()
|
|
|
+ controller.beginCopy('standard')
|
|
|
+ controller.setCopyId('my-copy')
|
|
|
+ controller.setCopyName('我的模式')
|
|
|
|
|
|
- expect(presets.get('mine')?.content).toBe('- id: tool-edit\n')
|
|
|
- })
|
|
|
+ await controller.confirmCopy()
|
|
|
|
|
|
- it('refuses to write a blocked draft', async () => {
|
|
|
+ const state = controller.store.getSnapshot()
|
|
|
+ expect(state.copy).toBeNull()
|
|
|
+ expect(state.rows.map(row => row.id)).toContain('my-copy')
|
|
|
+ expect(rosterChanges()).toBe(1)
|
|
|
+ expect(calls.find(call => call.method === 'copy')?.payload)
|
|
|
+ .toEqual({ from: 'standard', agentPreset: 'my-copy', name: '我的模式' })
|
|
|
+ // A preset is its files from here on, so landing in them completes the
|
|
|
+ // copy rather than following it.
|
|
|
+ expect(calls.find(call => call.method === 'openDocument')?.payload)
|
|
|
+ .toEqual({ agentPreset: 'my-copy' })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('omits an empty name so the copy falls back to its id', async () => {
|
|
|
const { controller, calls } = harness()
|
|
|
await controller.load()
|
|
|
- await controller.createFrom()
|
|
|
+ controller.beginCopy('standard')
|
|
|
+ controller.setCopyId('my-copy')
|
|
|
+ controller.setCopyName(' ')
|
|
|
|
|
|
- await controller.save()
|
|
|
+ await controller.confirmCopy()
|
|
|
|
|
|
- expect(calls.some(call => call.method === 'write')).toBe(false)
|
|
|
- expect(controller.store.getSnapshot().draft).not.toBeNull()
|
|
|
+ expect(calls.find(call => call.method === 'copy')?.payload)
|
|
|
+ .toEqual({ from: 'standard', agentPreset: 'my-copy' })
|
|
|
})
|
|
|
|
|
|
- it('refuses to write a read-only draft', async () => {
|
|
|
- const { controller, calls } = harness()
|
|
|
+ it('reveals the new directory as text where the host has no desktop', async () => {
|
|
|
+ const { controller } = harness({ hasDocument: false })
|
|
|
await controller.load()
|
|
|
- await controller.open('standard')
|
|
|
+ controller.beginCopy('standard')
|
|
|
+ controller.setCopyId('my-copy')
|
|
|
|
|
|
- await controller.save()
|
|
|
+ await controller.confirmCopy()
|
|
|
|
|
|
- expect(calls.some(call => call.method === 'write')).toBe(false)
|
|
|
+ expect(controller.store.getSnapshot().revealedPaths['my-copy']).toBe('/presets/my-copy')
|
|
|
})
|
|
|
|
|
|
- it('does nothing without an open draft', async () => {
|
|
|
- const { controller, calls } = harness()
|
|
|
+ it('keeps the dialog open with the refusal on it', async () => {
|
|
|
+ const { controller, rosterChanges } = harness({ failCopy: 'id already exists' })
|
|
|
await controller.load()
|
|
|
+ controller.beginCopy('standard')
|
|
|
+ controller.setCopyId('my-copy')
|
|
|
|
|
|
- await controller.save()
|
|
|
+ await controller.confirmCopy()
|
|
|
|
|
|
- expect(calls.some(call => call.method === 'write')).toBe(false)
|
|
|
+ expect(copyOf(controller)).toMatchObject({ saving: false, error: 'id already exists' })
|
|
|
+ expect(rosterChanges()).toBe(0)
|
|
|
})
|
|
|
|
|
|
- it('keeps the draft open and reports a rejected save', async () => {
|
|
|
- const { controller } = harness({ failWrite: 'composition is not an entry list' })
|
|
|
+ it('folds a dead transport into the dialog error', async () => {
|
|
|
+ const { controller } = harness({ throwCopy: true })
|
|
|
await controller.load()
|
|
|
- await controller.open('mine')
|
|
|
+ controller.beginCopy('standard')
|
|
|
+ controller.setCopyId('my-copy')
|
|
|
|
|
|
- await controller.save()
|
|
|
+ await controller.confirmCopy()
|
|
|
|
|
|
- // The text stays in the editor: it is the only copy, and the message says
|
|
|
- // what to fix.
|
|
|
- expect(draftOf(controller)).toMatchObject({ saving: false, error: 'composition is not an entry list' })
|
|
|
+ expect(copyOf(controller).error).toContain('socket closed')
|
|
|
})
|
|
|
|
|
|
- it('reports a transport that rejects mid-save', async () => {
|
|
|
- const presets = seed()
|
|
|
- const api = fakeApi(presets, { id: 'standard' })
|
|
|
- const controller = new AgentPresetSectionController({
|
|
|
- ...api,
|
|
|
- agentPresets: { ...api.agentPresets, write: () => Promise.reject(new Error('socket closed')) },
|
|
|
- })
|
|
|
+ it('refuses to submit while blocked or already saving', async () => {
|
|
|
+ const { controller, calls } = harness()
|
|
|
await controller.load()
|
|
|
- await controller.open('mine')
|
|
|
+ controller.beginCopy('standard')
|
|
|
+ controller.setCopyId('mine')
|
|
|
|
|
|
- await controller.save()
|
|
|
+ await controller.confirmCopy()
|
|
|
|
|
|
- expect(draftOf(controller)).toMatchObject({ saving: false, error: 'socket closed' })
|
|
|
+ expect(calls.some(call => call.method === 'copy')).toBe(false)
|
|
|
})
|
|
|
+})
|
|
|
|
|
|
- it('ignores a second save while one is in flight', async () => {
|
|
|
+describe('the location action', () => {
|
|
|
+ it('opens the directory and leaves the page alone on a desktop host', async () => {
|
|
|
const { controller, calls } = harness()
|
|
|
await controller.load()
|
|
|
- await controller.open('mine')
|
|
|
|
|
|
- await Promise.all([controller.save(), controller.save()])
|
|
|
+ await controller.openLocation('mine')
|
|
|
|
|
|
- expect(calls.filter(call => call.method === 'write')).toHaveLength(1)
|
|
|
+ expect(calls.find(call => call.method === 'openDocument')?.payload).toEqual({ agentPreset: 'mine' })
|
|
|
+ expect(controller.store.getSnapshot().revealedPaths).toEqual({})
|
|
|
})
|
|
|
|
|
|
- it('clears a save failure when the text changes', async () => {
|
|
|
- const { controller } = harness({ failWrite: 'invalid' })
|
|
|
+ it('reveals the path on the row where the host has none', async () => {
|
|
|
+ const { controller } = harness({ hasDocument: false })
|
|
|
await controller.load()
|
|
|
- await controller.open('mine')
|
|
|
- await controller.save()
|
|
|
|
|
|
- controller.setContent('- id: fixed\n')
|
|
|
+ await controller.openLocation('mine')
|
|
|
|
|
|
- expect(draftOf(controller).error).toBeNull()
|
|
|
+ expect(controller.store.getSnapshot().revealedPaths).toEqual({ mine: '/presets/mine' })
|
|
|
})
|
|
|
|
|
|
- it('carries the display name and description through a save', async () => {
|
|
|
- const { controller, calls } = harness()
|
|
|
+ it('drops a revealed path once its preset leaves the roster', async () => {
|
|
|
+ const { controller, presets } = harness({ hasDocument: false })
|
|
|
await controller.load()
|
|
|
- await controller.open('mine')
|
|
|
+ await controller.openLocation('mine')
|
|
|
+ presets.delete('mine')
|
|
|
|
|
|
- controller.setName('我的模式')
|
|
|
- controller.setDescription('只做检索。')
|
|
|
- await controller.save()
|
|
|
-
|
|
|
- expect(calls.find(call => call.method === 'write')?.payload)
|
|
|
- .toMatchObject({ agentPreset: 'mine', name: '我的模式', description: '只做检索。' })
|
|
|
- })
|
|
|
-
|
|
|
- it('leaves a copy unnamed so two rows cannot present themselves alike', async () => {
|
|
|
- const { controller } = harness()
|
|
|
await controller.load()
|
|
|
|
|
|
- await controller.createFrom('mine')
|
|
|
-
|
|
|
- // The composition is copied verbatim; the display name is not.
|
|
|
- expect(draftOf(controller)).toMatchObject({ id: '', name: '' })
|
|
|
- expect(draftOf(controller).content).toBe('- id: tool-read\n')
|
|
|
- })
|
|
|
-
|
|
|
- it('ignores an edit with no draft open', () => {
|
|
|
- const { controller } = harness()
|
|
|
-
|
|
|
- controller.setId('x')
|
|
|
- controller.setContent('y')
|
|
|
- controller.setName('n')
|
|
|
- controller.setDescription('d')
|
|
|
-
|
|
|
- expect(controller.store.getSnapshot().draft).toBeNull()
|
|
|
+ expect(controller.store.getSnapshot().revealedPaths).toEqual({})
|
|
|
})
|
|
|
-})
|
|
|
|
|
|
-describe('deleting', () => {
|
|
|
- it('deletes the confirmed preset and re-reads the roster', async () => {
|
|
|
- const { controller, presets } = harness()
|
|
|
+ it('surfaces a refusal as the page error', async () => {
|
|
|
+ const { controller } = harness({ failOpen: 'not yours' })
|
|
|
await controller.load()
|
|
|
|
|
|
- controller.confirmDelete('mine')
|
|
|
- await controller.remove()
|
|
|
+ await controller.openLocation('mine')
|
|
|
|
|
|
- expect(presets.has('mine')).toBe(false)
|
|
|
- expect(controller.store.getSnapshot()).toMatchObject({ pendingDelete: null, deleting: false })
|
|
|
- expect(controller.store.getSnapshot().rows.map(row => row.id)).toEqual(['standard'])
|
|
|
+ expect(controller.store.getSnapshot().error).toBe('not yours')
|
|
|
})
|
|
|
|
|
|
- it('closes an editor open on the deleted preset', async () => {
|
|
|
- const { controller } = harness()
|
|
|
+ it('folds a dead transport into the same error surface', async () => {
|
|
|
+ const { controller } = harness({ throwOpen: true })
|
|
|
await controller.load()
|
|
|
- await controller.open('mine')
|
|
|
|
|
|
- controller.confirmDelete('mine')
|
|
|
- await controller.remove()
|
|
|
+ await controller.openLocation('mine')
|
|
|
|
|
|
- // The file is gone; leaving its text in an editor whose Save would
|
|
|
- // resurrect it is worse than closing.
|
|
|
- expect(controller.store.getSnapshot().draft).toBeNull()
|
|
|
+ expect(controller.store.getSnapshot().error).toContain('socket closed')
|
|
|
})
|
|
|
+})
|
|
|
|
|
|
- it('leaves a copy-in-progress open when its source is deleted', async () => {
|
|
|
- const { controller } = harness()
|
|
|
+describe('deleting', () => {
|
|
|
+ it('asks first, then deletes, re-reads, and announces the change', async () => {
|
|
|
+ const { controller, rosterChanges } = harness()
|
|
|
await controller.load()
|
|
|
- await controller.createFrom('mine')
|
|
|
- controller.setId('mine')
|
|
|
|
|
|
controller.confirmDelete('mine')
|
|
|
+ expect(controller.store.getSnapshot().pendingDelete).toBe('mine')
|
|
|
await controller.remove()
|
|
|
|
|
|
- // The draft is a new preset that happens to be named after the one just
|
|
|
- // deleted; its text is unsaved work.
|
|
|
- expect(draftOf(controller)).toMatchObject({ id: 'mine', creating: true })
|
|
|
+ const state = controller.store.getSnapshot()
|
|
|
+ expect(state.pendingDelete).toBeNull()
|
|
|
+ expect(state.rows.map(row => row.id)).not.toContain('mine')
|
|
|
+ expect(rosterChanges()).toBe(1)
|
|
|
})
|
|
|
|
|
|
it('dismisses the confirmation without deleting', async () => {
|
|
|
- const { controller, calls, presets } = harness()
|
|
|
+ const { controller, calls } = harness()
|
|
|
await controller.load()
|
|
|
-
|
|
|
controller.confirmDelete('mine')
|
|
|
+
|
|
|
controller.confirmDelete(null)
|
|
|
await controller.remove()
|
|
|
|
|
|
+ expect(controller.store.getSnapshot().rows.map(row => row.id)).toContain('mine')
|
|
|
expect(calls.some(call => call.method === 'remove')).toBe(false)
|
|
|
- expect(presets.has('mine')).toBe(true)
|
|
|
})
|
|
|
|
|
|
- it('reports a refused delete on the page', async () => {
|
|
|
- const { controller } = harness({ failRemove: 'it ships with the deployment' })
|
|
|
+ it('ignores a second confirmation while one delete is in flight', async () => {
|
|
|
+ let release = (): void => {}
|
|
|
+ const gate = new Promise<void>((resolve) => { release = resolve })
|
|
|
+ const { controller, calls } = harness({ holdRemove: gate })
|
|
|
await controller.load()
|
|
|
+ controller.confirmDelete('mine')
|
|
|
+ const removal = controller.remove()
|
|
|
|
|
|
controller.confirmDelete('standard')
|
|
|
await controller.remove()
|
|
|
+ release()
|
|
|
+ await removal
|
|
|
|
|
|
- expect(controller.store.getSnapshot()).toMatchObject({
|
|
|
- pendingDelete: null, deleting: false, error: 'it ships with the deployment',
|
|
|
- })
|
|
|
+ expect(calls.filter(call => call.method === 'remove')).toHaveLength(1)
|
|
|
})
|
|
|
|
|
|
- it('reports a transport that rejects mid-delete', async () => {
|
|
|
- const presets = seed()
|
|
|
- const api = fakeApi(presets, { id: 'standard' })
|
|
|
- const controller = new AgentPresetSectionController({
|
|
|
- ...api,
|
|
|
- agentPresets: { ...api.agentPresets, remove: () => Promise.reject(new Error('socket closed')) },
|
|
|
- })
|
|
|
+ it('surfaces a refusal and clears the confirmation', async () => {
|
|
|
+ const { controller } = harness({ failRemove: 'shipped preset' })
|
|
|
await controller.load()
|
|
|
-
|
|
|
controller.confirmDelete('mine')
|
|
|
+
|
|
|
await controller.remove()
|
|
|
|
|
|
- expect(controller.store.getSnapshot()).toMatchObject({ deleting: false, error: 'socket closed' })
|
|
|
+ const state = controller.store.getSnapshot()
|
|
|
+ expect(state.error).toBe('shipped preset')
|
|
|
+ expect(state.pendingDelete).toBeNull()
|
|
|
+ expect(state.deleting).toBe(false)
|
|
|
})
|
|
|
|
|
|
- it('ignores a second delete while one is in flight', async () => {
|
|
|
- const { controller, calls } = harness()
|
|
|
+ it('folds a dead transport into the same error surface', async () => {
|
|
|
+ const { controller, presets } = harness()
|
|
|
await controller.load()
|
|
|
- controller.confirmDelete('mine')
|
|
|
+ presets.clear()
|
|
|
+ const broken = new AgentPresetSectionController({
|
|
|
+ agentPresets: {
|
|
|
+ list: () => Promise.reject(new Error('gone')),
|
|
|
+ remove: () => Promise.reject(new Error('socket closed')),
|
|
|
+ },
|
|
|
+ settings: {},
|
|
|
+ } as unknown as Pick<IApiClient, 'agentPresets' | 'settings'>)
|
|
|
+ broken.confirmDelete('mine')
|
|
|
|
|
|
- await Promise.all([controller.remove(), controller.remove()])
|
|
|
+ await broken.remove()
|
|
|
|
|
|
- expect(calls.filter(call => call.method === 'remove')).toHaveLength(1)
|
|
|
+ expect(broken.store.getSnapshot().error).toContain('socket closed')
|
|
|
})
|
|
|
+})
|
|
|
|
|
|
- it('ignores a confirmation change while a delete is in flight', async () => {
|
|
|
- let release = (): void => {}
|
|
|
- const held = new Promise<void>((resolve) => { release = resolve })
|
|
|
+describe('a controller with no roster listener', () => {
|
|
|
+ it('completes a delete without anyone to notify', async () => {
|
|
|
+ // The rosterChanged callback is optional wiring, not a requirement: a
|
|
|
+ // page composed without sibling surfaces still deletes cleanly.
|
|
|
const presets = seed()
|
|
|
- const controller = new AgentPresetSectionController(
|
|
|
- fakeApi(presets, { id: 'standard' }, { holdRemove: held }),
|
|
|
- )
|
|
|
- await controller.load()
|
|
|
- controller.confirmDelete('mine')
|
|
|
+ const alone = new AgentPresetSectionController(fakeApi(presets, { id: 'standard' }))
|
|
|
+ await alone.load()
|
|
|
+ alone.confirmDelete('mine')
|
|
|
|
|
|
- const pending = controller.remove()
|
|
|
- // Dismissing mid-flight cannot un-delete the file, so the confirmation
|
|
|
- // stays put rather than the page claiming nothing is happening.
|
|
|
- controller.confirmDelete(null)
|
|
|
- expect(controller.store.getSnapshot().pendingDelete).toBe('mine')
|
|
|
- release()
|
|
|
- await pending
|
|
|
+ await alone.remove()
|
|
|
|
|
|
- expect(presets.has('mine')).toBe(false)
|
|
|
+ expect(alone.store.getSnapshot().rows.map(row => row.id)).not.toContain('mine')
|
|
|
})
|
|
|
})
|
|
|
|
|
|
describe('the default preset', () => {
|
|
|
- it('writes the settings field and re-reads the roster', async () => {
|
|
|
- const { controller, calls, defaultId } = harness()
|
|
|
+ it('writes the setting and re-reads the roster', async () => {
|
|
|
+ const { controller, defaultId } = harness()
|
|
|
await controller.load()
|
|
|
|
|
|
await controller.makeDefault('mine')
|
|
|
|
|
|
- expect(calls.find(call => call.method === 'settings.update')?.payload)
|
|
|
- .toEqual({ ns: 'agent-presets', patch: { default: 'mine' } })
|
|
|
expect(defaultId.id).toBe('mine')
|
|
|
- expect(controller.store.getSnapshot().rows.find(row => row.isDefault)?.id).toBe('mine')
|
|
|
+ expect(controller.store.getSnapshot().rows.find(row => row.id === 'mine')?.isDefault).toBe(true)
|
|
|
})
|
|
|
|
|
|
- it('reports a refused write and leaves the roster alone', async () => {
|
|
|
- const { controller, defaultId } = harness({ failSettings: 'settings are read-only' })
|
|
|
+ it('surfaces a settings refusal as the page error', async () => {
|
|
|
+ const { controller } = harness({ failSettings: 'read-only settings' })
|
|
|
await controller.load()
|
|
|
|
|
|
await controller.makeDefault('mine')
|
|
|
|
|
|
- expect(controller.store.getSnapshot().error).toBe('settings are read-only')
|
|
|
- expect(defaultId.id).toBe('standard')
|
|
|
+ expect(controller.store.getSnapshot().error).toContain('read-only settings')
|
|
|
})
|
|
|
})
|