// @vitest-environment jsdom // GoalBar behavior: the docked strip above the composer — phase labels, // inline edit form, and resume/clear icon actions — driven purely through // props, no wire. Loading, absent, and complete goals render nothing. import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import { GoalBar } from '../src/client/GoalBar.tsx' import type { GoalActionResult, GoalBarActions } from '../src/client/slots.ts' import { zh } from '../src/client/locales.ts' // The framework-injected t seat, stubbed over the zh dictionaries (the default locale). const t: Parameters[0]['t'] = makeTranslate(zh, commonZh) afterEach(cleanup) function makeGoal(over: Partial = {}): GoalSnapshot { return { id: 'g1' as GoalSnapshot['id'], revision: 1, objective: 'Ship the redesign', phase: 'active', maxGoalRounds: 4, ...over, } } function makeActions() { return { onEdit: vi.fn(() => Promise.resolve({ ok: true, value: undefined })), onPause: vi.fn(() => Promise.resolve({ ok: true, value: undefined })), onResume: vi.fn(() => Promise.resolve({ ok: true, value: undefined })), onClear: vi.fn(() => Promise.resolve({ ok: true, value: undefined })), } satisfies GoalBarActions } describe('GoalBar', () => { it('renders nothing while loading, absent, or when the goal is complete', () => { const actions = makeActions() const loading = render() expect(loading.container.firstChild).toBeNull() cleanup() const absent = render() expect(absent.container.firstChild).toBeNull() cleanup() const complete = render() expect(complete.container.firstChild).toBeNull() }) it('active goal: goal glyph, "进行中的目标", truncated objective, edit and clear actions', () => { const actions = makeActions() render() expect(screen.getByText('进行中的目标')).toBeTruthy() expect(screen.getByText('Ship the redesign')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '清除目标' })) expect(actions.onClear).toHaveBeenCalledTimes(1) }) it('single-flights rapid clear clicks and hides the committed goal before its projection catches up', async () => { const actions = makeActions() let resolveClear!: (result: GoalActionResult) => void actions.onClear.mockImplementation(() => new Promise((resolve) => { resolveClear = resolve })) const { container, rerender } = render() const clear = screen.getByRole('button', { name: '清除目标' }) act(() => { clear.click() clear.click() }) expect(actions.onClear).toHaveBeenCalledTimes(1) expect(clear.disabled).toBe(true) await act(async () => { resolveClear({ ok: true, value: undefined }) }) expect(container.firstChild).toBeNull() rerender() expect(screen.getByText('Next goal')).toBeTruthy() }) it('edit swaps the strip for a prefilled form; Enter saves, empty stays disabled', async () => { const actions = makeActions() render() fireEvent.click(screen.getByRole('button', { name: '编辑目标' })) const box = screen.getByRole('textbox', { name: '目标内容' }) expect(box).toHaveProperty('value', 'Ship the redesign') fireEvent.change(box, { target: { value: ' ' } }) expect(screen.getByRole('button', { name: '保存目标' })).toHaveProperty('disabled', true) fireEvent.change(box, { target: { value: 'Ship v2' } }) fireEvent.keyDown(box, { key: 'Enter' }) expect(actions.onEdit).toHaveBeenCalledWith('Ship v2') await waitFor(() => { expect(screen.getByText('进行中的目标')).toBeTruthy() }) }) it('Esc cancels the edit without calling onEdit', () => { const actions = makeActions() render() fireEvent.click(screen.getByRole('button', { name: '编辑目标' })) fireEvent.keyDown(screen.getByRole('textbox', { name: '目标内容' }), { key: 'Escape' }) expect(actions.onEdit).not.toHaveBeenCalled() expect(screen.getByText('进行中的目标')).toBeTruthy() }) it('the cancel button exits the form and drops the draft (re-edit starts from the objective)', () => { const actions = makeActions() render() fireEvent.click(screen.getByRole('button', { name: '编辑目标' })) fireEvent.change(screen.getByRole('textbox', { name: '目标内容' }), { target: { value: 'abandoned draft' } }) fireEvent.click(screen.getByRole('button', { name: '取消编辑' })) expect(actions.onEdit).not.toHaveBeenCalled() expect(screen.getByText('进行中的目标')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '编辑目标' })) expect(screen.getByRole('textbox', { name: '目标内容' })).toHaveProperty('value', 'Ship the redesign') }) it('Enter with a blank draft neither saves nor closes the form', () => { const actions = makeActions() render() fireEvent.click(screen.getByRole('button', { name: '编辑目标' })) const box = screen.getByRole('textbox', { name: '目标内容' }) fireEvent.change(box, { target: { value: ' ' } }) fireEvent.keyDown(box, { key: 'Enter' }) expect(actions.onEdit).not.toHaveBeenCalled() expect(screen.getByRole('textbox', { name: '目标内容' })).toBeTruthy() }) it('active goal: the pause action pauses', () => { const actions = makeActions() render() fireEvent.click(screen.getByRole('button', { name: '暂停目标' })) expect(actions.onPause).toHaveBeenCalledTimes(1) }) it('paused goal: "已暂停的目标" with a resume action before edit', () => { const actions = makeActions() render() expect(screen.getByText('已暂停的目标')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '恢复目标' })) expect(actions.onResume).toHaveBeenCalledTimes(1) }) it('a new goal identity drops the edit form (no stale draft over the new goal)', () => { const actions = makeActions() const { rerender } = render() fireEvent.click(screen.getByRole('button', { name: '编辑目标' })) fireEvent.change(screen.getByRole('textbox', { name: '目标内容' }), { target: { value: 'stale draft' } }) rerender() expect(screen.queryByRole('textbox')).toBeNull() expect(screen.getByText('进行中的目标')).toBeTruthy() expect(screen.getByText('New goal')).toBeTruthy() rerender() expect(screen.queryByText('进行中的目标')).toBeNull() }) it('blocked goal: "受阻的目标" with the block reason as the strip tooltip', () => { const actions = makeActions() const goal = makeGoal({ phase: 'blocked', blockedReason: { code: 'stalled', message: 'No progress in 3 rounds' } }) render() expect(screen.getByText('受阻的目标')).toBeTruthy() expect(screen.getByText('受阻的目标').closest('[title]')?.getAttribute('title')).toBe('No progress in 3 rounds') }) it('blocked goal without a reason carries no tooltip', () => { const actions = makeActions() render() expect(screen.getByText('受阻的目标')).toBeTruthy() expect(screen.getByText('受阻的目标').closest('[title]')).toBeNull() }) it('keeps the edit draft open and reports a failed save', async () => { const actions = makeActions() actions.onEdit.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'stale revision', details: {} } }) render() fireEvent.click(screen.getByRole('button', { name: '编辑目标' })) const box = screen.getByRole('textbox', { name: '目标内容' }) fireEvent.change(box, { target: { value: 'retry this draft' } }) fireEvent.click(screen.getByRole('button', { name: '保存目标' })) expect((await screen.findByRole('alert')).textContent).toBe('stale revision (agent-busy)') expect(screen.getByRole('textbox', { name: '目标内容' })).toHaveProperty('value', 'retry this draft') }) it('reports resume and clear failures without hiding the goal', async () => { const actions = makeActions() actions.onResume.mockResolvedValue({ ok: false, error: { code: 'internal', message: 'resume failed', details: {} } }) const { rerender } = render() fireEvent.click(screen.getByRole('button', { name: '恢复目标' })) expect((await screen.findByRole('alert')).textContent).toBe('resume failed (internal)') actions.onClear.mockResolvedValue({ ok: false, error: { code: 'agent-busy', message: 'clear failed', details: {} } }) rerender() fireEvent.click(screen.getByRole('button', { name: '清除目标' })) expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)') expect(screen.getByText('Ship the redesign')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: '清除目标' })) await waitFor(() => { expect(actions.onClear).toHaveBeenCalledTimes(2) }) }) })