Files
deepseek-harness/packages/client/ui-plan/tests/browser-plugin.spec.ts
T
imccyu a39bd9035e feat(web): replace the plan select with a status chip and placeholder swap
Plan mode is entered through /plan only — the select control is retired.
The conversation.input.plan seat (now right of the access-mode control)
renders a read-only Plan chip while the projection's effective target is
plan mode; its hover x executes /plan off, and the chip follows the folded
target (appears on /plan immediately, disappears on /plan off) with frames
correcting either way. While plan mode is targeted the composer textarea's
placeholder switches to the plan-task wording — InputBar reads the same
projection through the standard-kit useProjection (the TodoDock posture:
a type-only key merge, no domain service edge), and owner placeholders
still win.
2026-07-28 23:39:50 +08:00

79 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* ui-plan browser half on a real SlotsService: the plugin occupies the
* conversation-declared `conversation.input.plan` single seat with the plan
* status chip; the injected face executes /plan off and folds admission
* outcomes into null (admitted) or a user-visible failure line; teardown
* empties the seat (HMR safety).
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { PlanChip } from '../src/client/PlanModeControl.tsx'
import type { PlanChipInjected } from '../src/client/index.ts'
import { apply, inject } from '../src/client/index.ts'
import { apply as nodeApply } from '../src/index.ts'
const SID = 's-plan' as SessionId
async function bench() {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const slots = ctx.get('slots') as SlotsService
slots.register({
name: 'root',
children: { 'conversation.input.plan': { kind: 'single', scope: 'session' } },
} as never, () => null)
const execute = vi.fn((_payload: { sessionId: SessionId; line: string }) =>
Promise.resolve({ result: { ok: true as const, value: { matched: true as const, commandId: 'c1' } } }))
ctx.provide('connection', { api: { commands: { execute } } })
ctx.provide('conversation', {})
return { ctx, slots, execute }
}
describe('ui-plan browser apply', () => {
it('declares every service it binds', () => {
expect(inject).toEqual(['slots', 'connection', 'conversation'])
})
it('node-half apply is an intentional no-op', () => {
expect(() => { nodeApply() }).not.toThrow()
})
it('fails loud when conversation did not declare the plan seat', async () => {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
ctx.provide('connection', {})
ctx.provide('conversation', {})
await expect(ctx.plugin({ inject: [...inject], apply }))
.rejects.toThrow(/slot "conversation.input.plan" is not declared/)
})
it('registers the chip, executes /plan off, and unregisters on teardown', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const entry = b.slots.entries('conversation.input.plan')[0]!
expect(entry.component).toBe(PlanChip)
const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID)
await expect(injected.exitPlanMode()).resolves.toBeNull()
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' })
// Business failure folds to the composer-visible line.
b.execute.mockResolvedValueOnce({
result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } },
} as never)
await expect(injected.exitPlanMode()).resolves.toBe('gonesession-not-found')
// Unmatched admission (plan-mode not composed host-side) is also a failure line.
b.execute.mockResolvedValueOnce({
result: { ok: true as const, value: { matched: false as const } },
} as never)
await expect(injected.exitPlanMode()).resolves.toBe('未知命令:/plan off')
await fiber.dispose()
expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)
})
})