feat(web): plan chip as an always-visible pressed-state toggle
fix: plan button add label
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
/* Read-only plan status badge: quiet chip; the × affordance appears on
|
/* Plan-mode toggle chip: quiet while off; the pressed state takes the
|
||||||
hover/focus and the whole chip is the /plan off button. */
|
business accent pair (same token pairing as the trajectory user badge). */
|
||||||
|
|
||||||
.wrap {
|
.wrap {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -10,8 +10,7 @@
|
|||||||
.chip {
|
.chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
padding: 4px 8px;
|
||||||
padding: 6px 8px;
|
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -25,6 +24,14 @@
|
|||||||
background: var(--dsw-alias-interactive-bg-hover);
|
background: var(--dsw-alias-interactive-bg-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hovering keeps the pressed accent: the higher-specificity hover rule above
|
||||||
|
would otherwise swap it back to the neutral hover wash. */
|
||||||
|
.chip[aria-pressed='true'],
|
||||||
|
.chip[aria-pressed='true']:hover:not(:disabled) {
|
||||||
|
color: var(--dsw-alias-state-business-primary);
|
||||||
|
background: var(--dsw-alias-state-business-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
.chip:focus-visible {
|
.chip:focus-visible {
|
||||||
outline: 2px solid var(--dsw-alias-label-secondary);
|
outline: 2px solid var(--dsw-alias-label-secondary);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
@@ -35,17 +42,6 @@
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
color: var(--dsw-alias-label-caption);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chip:hover .close,
|
|
||||||
.chip:focus-visible .close {
|
|
||||||
color: var(--dsw-alias-label-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: var(--dsw-alias-state-error-primary);
|
color: var(--dsw-alias-state-error-primary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -11,16 +11,16 @@ export type PlanChipProps =
|
|||||||
PropsRuntime<'conversation.input.plan'> & InjectFace<PlanChipInjected>
|
PropsRuntime<'conversation.input.plan'> & InjectFace<PlanChipInjected>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read-only status badge over the host-computed `plan` projection. Plan mode
|
* Plan-mode toggle over the host-computed `plan` projection. The chip renders
|
||||||
* is entered through the /plan command only; the chip appears while the
|
* whenever the capability is present and reflects the effective target as its
|
||||||
* effective target is plan mode and its hover × executes /plan off. The
|
* pressed state (`pending ? !active : active` — a folded host value, not
|
||||||
* displayed state follows the target (`pending ? !active : active`) — a
|
* client optimism, so an arriving frame corrects it). Clicking executes
|
||||||
* folded host value, not client optimism, so an arriving frame corrects it.
|
* /plan or /plan off toward the opposite target.
|
||||||
*/
|
*/
|
||||||
export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps) {
|
export function PlanChip({ useProjection, locked, setPlanMode }: PlanChipProps) {
|
||||||
const plan = useProjection('plan')
|
const plan = useProjection('plan')
|
||||||
const [leaving, setLeaving] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<{ text: string; detail: string } | null>(null)
|
||||||
const aliveRef = useRef(true)
|
const aliveRef = useRef(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -30,24 +30,25 @@ export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps)
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Absent capability (no plan-mode host plugin / no session yet) or the
|
// Absent capability (no plan-mode host plugin / no session yet): no seat
|
||||||
// default mode: no seat content.
|
// content — without the capability there is nothing to toggle.
|
||||||
if (plan === undefined) return null
|
if (plan === undefined) return null
|
||||||
const target = plan.pending ? !plan.active : plan.active
|
const target = plan.pending ? !plan.active : plan.active
|
||||||
if (!target) return null
|
|
||||||
|
|
||||||
const off = (): void => {
|
const toggle = (): void => {
|
||||||
// No leaving/locked guard: both disable the button, so no click arrives.
|
// No busy/locked guard: both disable the button, so no click arrives.
|
||||||
setLeaving(true)
|
const on = !target
|
||||||
|
const failText = on ? '进入 plan mode 失败' : '退出 plan mode 失败'
|
||||||
|
setBusy(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
void exitPlanMode().then((failure) => {
|
void setPlanMode(on).then((failure) => {
|
||||||
if (!aliveRef.current) return
|
if (!aliveRef.current) return
|
||||||
setLeaving(false)
|
setBusy(false)
|
||||||
setError(failure)
|
setError(failure === null ? null : { text: failText, detail: failure })
|
||||||
}, (reason: unknown) => {
|
}, (reason: unknown) => {
|
||||||
if (!aliveRef.current) return
|
if (!aliveRef.current) return
|
||||||
setLeaving(false)
|
setBusy(false)
|
||||||
setError(reason instanceof Error ? reason.message : String(reason))
|
setError({ text: failText, detail: reason instanceof Error ? reason.message : String(reason) })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,19 +57,17 @@ export function PlanChip({ useProjection, locked, exitPlanMode }: PlanChipProps)
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={css.chip}
|
className={css.chip}
|
||||||
aria-label="Plan mode on, press to turn off"
|
aria-pressed={target}
|
||||||
title="Plan mode on — click × to turn off (/plan off)"
|
aria-label={target ? 'Plan mode on, press to turn off' : 'Plan mode off, press to turn on'}
|
||||||
disabled={locked || leaving}
|
title={target
|
||||||
onClick={off}
|
? 'Plan mode on — click to turn off (/plan off)'
|
||||||
|
: 'Plan mode off — click to turn on (/plan)'}
|
||||||
|
disabled={locked || busy}
|
||||||
|
onClick={toggle}
|
||||||
>
|
>
|
||||||
Plan
|
Plan { target ? 'on' : 'off' }
|
||||||
<span className={css.close} aria-hidden>
|
|
||||||
<svg viewBox="0 0 12 12" width="10" height="10">
|
|
||||||
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" fill="none" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
{error !== null && <span className={css.error} role="status" title={error}>退出 plan mode 失败</span>}
|
{error !== null && <span className={css.error} role="status" title={error.detail}>{error.text}</span>}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Plan control plugin, browser half: occupies the composer's named
|
* Plan control plugin, browser half: occupies the composer's named
|
||||||
* `conversation.input.plan` seat with a read-only status chip. Plan mode is
|
* `conversation.input.plan` seat with a plan-mode toggle chip. While the
|
||||||
* entered through the /plan command only; while the projection's effective
|
* `plan` projection is present the chip renders in both states and executes
|
||||||
* target is plan mode the chip renders (hover × executes /plan off through
|
* /plan or /plan off through `command.execute` toward the opposite target;
|
||||||
* `command.execute`), otherwise the seat stays empty. Reads ride the generic
|
* an absent projection (no capability) leaves the seat empty. Reads ride the
|
||||||
* projection pair through the standard-kit `useProjection` (an absent key is
|
* generic projection pair through the standard-kit `useProjection` (an absent
|
||||||
* capability absence); zero client-side plan state.
|
* key is capability absence); zero client-side plan state.
|
||||||
*/
|
*/
|
||||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||||
@@ -18,10 +18,11 @@ import { PlanChip } from './PlanModeControl.tsx'
|
|||||||
/** Injected business face of the composer plan seat. */
|
/** Injected business face of the composer plan seat. */
|
||||||
export interface PlanChipInjected {
|
export interface PlanChipInjected {
|
||||||
/**
|
/**
|
||||||
* Leave plan mode by executing /plan off.
|
* Switch plan mode by executing /plan (on) or /plan off.
|
||||||
|
* @param on - desired target: true enters plan mode, false leaves it.
|
||||||
* @returns null on admitted execution; a user-visible failure line otherwise.
|
* @returns null on admitted execution; a user-visible failure line otherwise.
|
||||||
*/
|
*/
|
||||||
exitPlanMode: () => Promise<string | null>
|
setPlanMode: (on: boolean) => Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,11 +39,12 @@ export function apply(ctx: ClientContext): void {
|
|||||||
ctx.effect(() => ctx.slots.register({
|
ctx.effect(() => ctx.slots.register({
|
||||||
name: 'conversation.input.plan',
|
name: 'conversation.input.plan',
|
||||||
inject: (sessionId: SessionId): PlanChipInjected => ({
|
inject: (sessionId: SessionId): PlanChipInjected => ({
|
||||||
exitPlanMode: async () => {
|
setPlanMode: async (on) => {
|
||||||
|
const line = on ? '/plan' : '/plan off'
|
||||||
const connection = ctx.get('connection') as ConnectionHandle
|
const connection = ctx.get('connection') as ConnectionHandle
|
||||||
const { result } = await connection.api.commands.execute({ sessionId, line: '/plan off' })
|
const { result } = await connection.api.commands.execute({ sessionId, line })
|
||||||
if (!result.ok) return `${result.error.message}(${result.error.code})`
|
if (!result.ok) return `${result.error.message}(${result.error.code})`
|
||||||
if (!result.value.matched) return '未知命令:/plan off'
|
if (!result.value.matched) return `未知命令:${line}`
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* ui-plan browser half on a real SlotsService: the plugin occupies the
|
* ui-plan browser half on a real SlotsService: the plugin occupies the
|
||||||
* conversation-declared `conversation.input.plan` single seat with the plan
|
* conversation-declared `conversation.input.plan` single seat with the plan
|
||||||
* status chip; the injected face executes /plan off and folds admission
|
* toggle chip; the injected face executes /plan or /plan off by direction and
|
||||||
* outcomes into null (admitted) or a user-visible failure line; teardown
|
* folds admission outcomes into null (admitted) or a user-visible failure
|
||||||
* empties the seat (HMR safety).
|
* line; teardown empties the seat (HMR safety).
|
||||||
*/
|
*/
|
||||||
import { Context } from 'cordis'
|
import { Context } from 'cordis'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
@@ -49,7 +49,7 @@ describe('ui-plan browser apply', () => {
|
|||||||
.rejects.toThrow(/slot "conversation.input.plan" is not declared/)
|
.rejects.toThrow(/slot "conversation.input.plan" is not declared/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('registers the chip, executes /plan off, and unregisters on teardown', async () => {
|
it('registers the chip, executes /plan by direction, and unregisters on teardown', async () => {
|
||||||
const b = await bench()
|
const b = await bench()
|
||||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||||
await fiber.await()
|
await fiber.await()
|
||||||
@@ -57,20 +57,22 @@ describe('ui-plan browser apply', () => {
|
|||||||
expect(entry.component).toBe(PlanChip)
|
expect(entry.component).toBe(PlanChip)
|
||||||
const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID)
|
const injected = (entry.inject as unknown as (id: SessionId) => PlanChipInjected)(SID)
|
||||||
|
|
||||||
await expect(injected.exitPlanMode()).resolves.toBeNull()
|
await expect(injected.setPlanMode(false)).resolves.toBeNull()
|
||||||
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' })
|
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan off' })
|
||||||
|
await expect(injected.setPlanMode(true)).resolves.toBeNull()
|
||||||
|
expect(b.execute).toHaveBeenLastCalledWith({ sessionId: SID, line: '/plan' })
|
||||||
|
|
||||||
// Business failure folds to the composer-visible line.
|
// Business failure folds to the composer-visible line.
|
||||||
b.execute.mockResolvedValueOnce({
|
b.execute.mockResolvedValueOnce({
|
||||||
result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } },
|
result: { ok: false as const, error: { code: 'session-not-found', message: 'gone', details: {} } },
|
||||||
} as never)
|
} as never)
|
||||||
await expect(injected.exitPlanMode()).resolves.toBe('gone(session-not-found)')
|
await expect(injected.setPlanMode(false)).resolves.toBe('gone(session-not-found)')
|
||||||
|
|
||||||
// Unmatched admission (plan-mode not composed host-side) is also a failure line.
|
// Unmatched admission (plan-mode not composed host-side) is also a failure line.
|
||||||
b.execute.mockResolvedValueOnce({
|
b.execute.mockResolvedValueOnce({
|
||||||
result: { ok: true as const, value: { matched: false as const } },
|
result: { ok: true as const, value: { matched: false as const } },
|
||||||
} as never)
|
} as never)
|
||||||
await expect(injected.exitPlanMode()).resolves.toBe('未知命令:/plan off')
|
await expect(injected.setPlanMode(true)).resolves.toBe('未知命令:/plan')
|
||||||
|
|
||||||
await fiber.dispose()
|
await fiber.dispose()
|
||||||
expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)
|
expect(b.slots.entries('conversation.input.plan')).toHaveLength(0)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
/**
|
/**
|
||||||
* PlanChip over the `plan` projection: nothing renders while the capability
|
* PlanChip over the `plan` projection: nothing renders while the capability
|
||||||
* is absent or the effective target is the default mode; the chip renders
|
* is absent; with the capability present the chip renders in both states with
|
||||||
* while the target is plan mode (pending follows the target — /plan shows it
|
* aria-pressed following the effective target (pending folds — /plan shows
|
||||||
* immediately, /plan off hides it immediately); the chip button executes
|
* pressed immediately, /plan off unpressed immediately); clicking executes
|
||||||
* /plan off and surfaces failures without hiding until the projection says so.
|
* the command toward the opposite target and surfaces direction-specific
|
||||||
|
* failures while the projection still owns the displayed state.
|
||||||
*/
|
*/
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
@@ -17,78 +18,98 @@ afterEach(cleanup)
|
|||||||
|
|
||||||
function setup(
|
function setup(
|
||||||
plan: PlanProjection | undefined,
|
plan: PlanProjection | undefined,
|
||||||
exitPlanMode = vi.fn(() => Promise.resolve<string | null>(null)),
|
setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null)),
|
||||||
locked = false,
|
locked = false,
|
||||||
) {
|
) {
|
||||||
const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan })
|
const store = createSnapshotStore<{ value: PlanProjection | undefined }>({ value: plan })
|
||||||
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
|
const useProjection = (_key: string, selector?: (v: unknown) => unknown) =>
|
||||||
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
|
bindSnapshotSelector(store)(s => (selector ?? (v => v))(s.value))
|
||||||
const props = { useProjection, locked, exitPlanMode } as unknown as PlanChipProps
|
const props = { useProjection, locked, setPlanMode } as unknown as PlanChipProps
|
||||||
const view = render(<PlanChip {...props} />)
|
const view = render(<PlanChip {...props} />)
|
||||||
return { store, exitPlanMode, view }
|
return { store, setPlanMode, view }
|
||||||
}
|
}
|
||||||
|
|
||||||
const chip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
|
const onChip = () => screen.getByRole('button', { name: 'Plan mode on, press to turn off' })
|
||||||
|
const offChip = () => screen.getByRole('button', { name: 'Plan mode off, press to turn on' })
|
||||||
|
|
||||||
describe('PlanChip', () => {
|
describe('PlanChip', () => {
|
||||||
it('renders nothing for absent capability or the default mode', () => {
|
it('renders nothing while the capability is absent', () => {
|
||||||
const absent = setup(undefined)
|
const absent = setup(undefined)
|
||||||
expect(absent.view.container.innerHTML).toBe('')
|
expect(absent.view.container.innerHTML).toBe('')
|
||||||
cleanup()
|
|
||||||
const inactive = setup({ active: false, pending: false })
|
|
||||||
expect(inactive.view.container.innerHTML).toBe('')
|
|
||||||
cleanup()
|
|
||||||
// Active with a pending exit: the target is default — chip already gone.
|
|
||||||
const leaving = setup({ active: true, pending: true })
|
|
||||||
expect(leaving.view.container.innerHTML).toBe('')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders while the effective target is plan mode, including the pending entry window', () => {
|
it('reflects the effective target as the pressed state, folding pending', () => {
|
||||||
|
setup({ active: false, pending: false })
|
||||||
|
expect(offChip().getAttribute('aria-pressed')).toBe('false')
|
||||||
|
cleanup()
|
||||||
setup({ active: true, pending: false })
|
setup({ active: true, pending: false })
|
||||||
expect(chip()).toBeTruthy()
|
expect(onChip().getAttribute('aria-pressed')).toBe('true')
|
||||||
cleanup()
|
cleanup()
|
||||||
// /plan just ran (command/run folded, plan/mode not yet): target is plan.
|
// /plan just ran (command/run folded, plan/mode not yet): target is plan.
|
||||||
setup({ active: false, pending: true })
|
setup({ active: false, pending: true })
|
||||||
expect(chip()).toBeTruthy()
|
expect(onChip().getAttribute('aria-pressed')).toBe('true')
|
||||||
|
cleanup()
|
||||||
|
// Active with a pending exit: the target is default — already unpressed.
|
||||||
|
setup({ active: true, pending: true })
|
||||||
|
expect(offChip().getAttribute('aria-pressed')).toBe('false')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('the chip executes /plan off once and follows the projection down', async () => {
|
it('unpressed chip executes /plan (on) once and follows the projection up', async () => {
|
||||||
let resolve!: (value: string | null) => void
|
let resolve!: (value: string | null) => void
|
||||||
const exitPlanMode = vi.fn(() => new Promise<string | null>((done) => { resolve = done }))
|
const setPlanMode = vi.fn((_on: boolean) => new Promise<string | null>((done) => { resolve = done }))
|
||||||
const { store } = setup({ active: true, pending: false }, exitPlanMode)
|
const { store } = setup({ active: false, pending: false }, setPlanMode)
|
||||||
fireEvent.click(chip())
|
fireEvent.click(offChip())
|
||||||
expect(exitPlanMode).toHaveBeenCalledTimes(1)
|
expect(setPlanMode).toHaveBeenCalledTimes(1)
|
||||||
|
expect(setPlanMode).toHaveBeenLastCalledWith(true)
|
||||||
// Busy while its own call is in flight.
|
// Busy while its own call is in flight.
|
||||||
fireEvent.click(chip())
|
fireEvent.click(offChip())
|
||||||
expect(exitPlanMode).toHaveBeenCalledTimes(1)
|
expect(setPlanMode).toHaveBeenCalledTimes(1)
|
||||||
resolve(null)
|
resolve(null)
|
||||||
// The off command's run record folds: target flips, the chip unmounts.
|
// The command's run record folds: target flips, the chip presses.
|
||||||
|
store.set({ value: { active: false, pending: true } })
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onChip().getAttribute('aria-pressed')).toBe('true')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pressed chip executes /plan off and follows the projection down', async () => {
|
||||||
|
const setPlanMode = vi.fn((_on: boolean) => Promise.resolve<string | null>(null))
|
||||||
|
const { store } = setup({ active: true, pending: false }, setPlanMode)
|
||||||
|
fireEvent.click(onChip())
|
||||||
|
expect(setPlanMode).toHaveBeenLastCalledWith(false)
|
||||||
store.set({ value: { active: true, pending: true } })
|
store.set({ value: { active: true, pending: true } })
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.queryByRole('button', { name: 'Plan mode on, press to turn off' })).toBeNull()
|
expect(offChip().getAttribute('aria-pressed')).toBe('false')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('disables under the locked owner prop', () => {
|
it('disables under the locked owner prop', () => {
|
||||||
setup({ active: true, pending: false }, vi.fn(), true)
|
setup({ active: true, pending: false }, vi.fn(), true)
|
||||||
expect((chip() as HTMLButtonElement).disabled).toBe(true)
|
expect((onChip() as HTMLButtonElement).disabled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('surfaces admission and transport failures while staying visible', async () => {
|
it('surfaces direction-specific admission and transport failures while staying visible', async () => {
|
||||||
const exitPlanMode = vi.fn()
|
const exitFailing = vi.fn()
|
||||||
.mockResolvedValueOnce('host said no')
|
.mockResolvedValueOnce('host said no')
|
||||||
.mockRejectedValueOnce(new Error('network down'))
|
.mockRejectedValueOnce(new Error('network down'))
|
||||||
.mockRejectedValueOnce('socket closed')
|
.mockRejectedValueOnce('socket closed')
|
||||||
setup({ active: true, pending: false }, exitPlanMode)
|
setup({ active: true, pending: false }, exitFailing)
|
||||||
fireEvent.click(chip())
|
fireEvent.click(onChip())
|
||||||
expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no')
|
expect((await screen.findByText('退出 plan mode 失败')).getAttribute('title')).toBe('host said no')
|
||||||
expect(chip()).toBeTruthy()
|
expect(onChip()).toBeTruthy()
|
||||||
|
|
||||||
fireEvent.click(chip())
|
fireEvent.click(onChip())
|
||||||
expect(await screen.findByTitle('network down')).toBeTruthy()
|
expect(await screen.findByTitle('network down')).toBeTruthy()
|
||||||
|
|
||||||
fireEvent.click(chip())
|
fireEvent.click(onChip())
|
||||||
expect(await screen.findByTitle('socket closed')).toBeTruthy()
|
expect(await screen.findByTitle('socket closed')).toBeTruthy()
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
const enterFailing = vi.fn().mockResolvedValueOnce('agent busy')
|
||||||
|
setup({ active: false, pending: false }, enterFailing)
|
||||||
|
fireEvent.click(offChip())
|
||||||
|
expect((await screen.findByText('进入 plan mode 失败')).getAttribute('title')).toBe('agent busy')
|
||||||
|
expect(offChip()).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores in-flight fulfillment and rejection after unmount', () => {
|
it('ignores in-flight fulfillment and rejection after unmount', () => {
|
||||||
@@ -97,14 +118,14 @@ describe('PlanChip', () => {
|
|||||||
{ active: true, pending: false },
|
{ active: true, pending: false },
|
||||||
vi.fn(() => new Promise<string | null>((done) => { resolve = done })),
|
vi.fn(() => new Promise<string | null>((done) => { resolve = done })),
|
||||||
)
|
)
|
||||||
fireEvent.click(chip())
|
fireEvent.click(onChip())
|
||||||
successful.view.unmount()
|
successful.view.unmount()
|
||||||
expect(() => { resolve(null) }).not.toThrow()
|
expect(() => { resolve(null) }).not.toThrow()
|
||||||
|
|
||||||
let reject!: (reason: unknown) => void
|
let reject!: (reason: unknown) => void
|
||||||
const exitPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
|
const setPlanMode = vi.fn(() => new Promise<string | null>((_done, fail) => { reject = fail }))
|
||||||
const { view } = setup({ active: true, pending: false }, exitPlanMode)
|
const { view } = setup({ active: true, pending: false }, setPlanMode)
|
||||||
fireEvent.click(chip())
|
fireEvent.click(onChip())
|
||||||
view.unmount()
|
view.unmount()
|
||||||
expect(() => { reject(new Error('late')) }).not.toThrow()
|
expect(() => { reject(new Error('late')) }).not.toThrow()
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user