|
|
@@ -0,0 +1,549 @@
|
|
|
+// @vitest-environment jsdom
|
|
|
+
|
|
|
+import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
|
+import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
+import type { TeamTaskId, TeamTaskView as TeamTask, TeamView } from '@deepseek-ai/dsh-team/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 {
|
|
|
+ TeamAction, type TeamActionInjected, type TeamActionProps, type TeamActionResult,
|
|
|
+} from '../src/client/TeamAction.tsx'
|
|
|
+import { zh } from '../src/client/locales.ts'
|
|
|
+
|
|
|
+afterEach(cleanup)
|
|
|
+
|
|
|
+const SESSION = 'lead' as SessionId
|
|
|
+const tid = (value: string): TeamTaskId => value as TeamTaskId
|
|
|
+const task: TeamTask = {
|
|
|
+ id: tid('task-1'),
|
|
|
+ revision: 1,
|
|
|
+ subject: 'Implement runtime',
|
|
|
+ description: 'Build the Team runtime',
|
|
|
+ status: 'in_progress',
|
|
|
+ ownerName: 'lead',
|
|
|
+ blockedBy: [],
|
|
|
+ writeScopes: ['src'],
|
|
|
+ ready: false,
|
|
|
+ writeScopeWarnings: ['write scopes overlap with task-2'],
|
|
|
+}
|
|
|
+const view: TeamView = {
|
|
|
+ members: [
|
|
|
+ { id: SESSION, name: 'lead', role: 'lead', status: 'idle', model: 'model-a', diagnostics: [] },
|
|
|
+ {
|
|
|
+ id: 'worker-id' as SessionId,
|
|
|
+ name: 'worker',
|
|
|
+ role: 'teammate',
|
|
|
+ status: 'inactive',
|
|
|
+ model: 'model-a',
|
|
|
+ diagnostics: [],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ tasks: [task],
|
|
|
+}
|
|
|
+
|
|
|
+function props(actions: TeamActionInjected, sessionId: SessionId = SESSION): TeamActionProps {
|
|
|
+ return {
|
|
|
+ sessionId,
|
|
|
+ ...actions,
|
|
|
+ t: makeTranslate(zh, commonZh),
|
|
|
+ } as unknown as TeamActionProps
|
|
|
+}
|
|
|
+
|
|
|
+function actions(overrides: Partial<TeamActionInjected> = {}): TeamActionInjected {
|
|
|
+ return {
|
|
|
+ load: () => Promise.resolve({ ok: true, value: view }),
|
|
|
+ createTask: () => Promise.resolve({ ok: true, value: { ...task, id: tid('task-2'), subject: 'New task' } }),
|
|
|
+ updateTask: () => Promise.resolve({ ok: true, value: { ...task, revision: 2 } }),
|
|
|
+ openTeammate: () => Promise.resolve(),
|
|
|
+ ...overrides,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+describe('TeamAction', () => {
|
|
|
+ it('ignores a stale Team load after the conversation switches sessions', async () => {
|
|
|
+ const nextSession = 'next-lead' as SessionId
|
|
|
+ const firstLoad = Promise.withResolvers<{ ok: true; value: TeamView }>()
|
|
|
+ const nextView: TeamView = {
|
|
|
+ ...view,
|
|
|
+ members: [{ id: nextSession, name: 'lead', role: 'lead', status: 'idle', diagnostics: [] }],
|
|
|
+ tasks: [{ ...task, id: tid('task-next'), subject: 'Next session task' }],
|
|
|
+ }
|
|
|
+ const load = vi.fn((sessionId: SessionId) => sessionId === SESSION
|
|
|
+ ? firstLoad.promise
|
|
|
+ : Promise.resolve({ ok: true as const, value: nextView }))
|
|
|
+ const injected = actions({ load })
|
|
|
+ const rendered = render(<TeamAction {...props(injected)} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await waitFor(() => { expect(load).toHaveBeenCalledWith(SESSION) })
|
|
|
+
|
|
|
+ rendered.rerender(<TeamAction {...props(injected, nextSession)} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ expect(await screen.findByText('Next session task')).toBeTruthy()
|
|
|
+ firstLoad.resolve({ ok: true, value: view })
|
|
|
+ await Promise.resolve()
|
|
|
+
|
|
|
+ await waitFor(() => {
|
|
|
+ expect(screen.getByText('Next session task')).toBeTruthy()
|
|
|
+ expect(screen.queryByText('Implement runtime')).toBeNull()
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('loads roster/task diagnostics on open and navigates a healthy teammate', async () => {
|
|
|
+ const openTeammate = vi.fn(() => Promise.resolve())
|
|
|
+ render(<TeamAction {...props(actions({ openTeammate }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ const worker = await screen.findByRole('button', { name: /worker/u })
|
|
|
+ expect(screen.getByText('write scopes overlap with task-2')).toBeTruthy()
|
|
|
+ fireEvent.click(worker)
|
|
|
+ await waitFor(() => { expect(openTeammate).toHaveBeenCalledWith(SESSION, view.members[1]) })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps only the newest overlapping refresh for one session', async () => {
|
|
|
+ const older = Promise.withResolvers<TeamActionResult<TeamView>>()
|
|
|
+ const newer = Promise.withResolvers<TeamActionResult<TeamView>>()
|
|
|
+ const newestView = {
|
|
|
+ ...view,
|
|
|
+ tasks: [{ ...task, id: tid('newest-task'), subject: 'Newest task' }],
|
|
|
+ }
|
|
|
+ const load = vi.fn()
|
|
|
+ .mockResolvedValueOnce({ ok: true, value: view })
|
|
|
+ .mockImplementationOnce(() => older.promise)
|
|
|
+ .mockImplementationOnce(() => newer.promise)
|
|
|
+ render(<TeamAction {...props(actions({ load }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+
|
|
|
+ const refresh = screen.getByRole('button', { name: zh.refresh })
|
|
|
+ fireEvent.click(refresh)
|
|
|
+ fireEvent.click(refresh)
|
|
|
+ newer.resolve({ ok: true, value: newestView })
|
|
|
+ expect(await screen.findByText('Newest task')).toBeTruthy()
|
|
|
+ older.resolve({ ok: true, value: view })
|
|
|
+ await Promise.resolve()
|
|
|
+
|
|
|
+ expect(screen.getByText('Newest task')).toBeTruthy()
|
|
|
+ expect(screen.queryByText('Implement runtime')).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps a successful task mutation newer than an in-flight refresh', async () => {
|
|
|
+ const stale = Promise.withResolvers<TeamActionResult<TeamView>>()
|
|
|
+ const load = vi.fn()
|
|
|
+ .mockResolvedValueOnce({ ok: true, value: view })
|
|
|
+ .mockImplementationOnce(() => stale.promise)
|
|
|
+ const updateTask = vi.fn(() => Promise.resolve({
|
|
|
+ ok: true as const,
|
|
|
+ value: { ...task, revision: 2, status: 'completed' as const },
|
|
|
+ }))
|
|
|
+ render(<TeamAction {...props(actions({ load, updateTask }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: zh.refresh }))
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /完成/u }))
|
|
|
+ expect(await screen.findByRole('button', { name: /重开/u })).toBeTruthy()
|
|
|
+
|
|
|
+ stale.resolve({ ok: true, value: view })
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(screen.getByRole('button', { name: /重开/u })).toBeTruthy()
|
|
|
+ expect(screen.queryByRole('button', { name: /完成/u })).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('keeps a created task newer than an in-flight refresh', async () => {
|
|
|
+ const stale = Promise.withResolvers<TeamActionResult<TeamView>>()
|
|
|
+ const load = vi.fn()
|
|
|
+ .mockResolvedValueOnce({ ok: true, value: view })
|
|
|
+ .mockImplementationOnce(() => stale.promise)
|
|
|
+ render(<TeamAction {...props(actions({ load }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: zh.refresh }))
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /新建任务/u }))
|
|
|
+ fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'New task' } })
|
|
|
+ fireEvent.change(screen.getByPlaceholderText('任务描述'), { target: { value: 'Details' } })
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
|
|
+ expect(await screen.findByText('New task')).toBeTruthy()
|
|
|
+
|
|
|
+ stale.resolve({ ok: true, value: view })
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(screen.getByText('New task')).toBeTruthy()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('creates a task from normalized blocker and write-scope lists', async () => {
|
|
|
+ const createTask = vi.fn(actions().createTask)
|
|
|
+ render(<TeamAction {...props(actions({ createTask }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /新建任务/u }))
|
|
|
+ fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: ' New task ' } })
|
|
|
+ fireEvent.change(screen.getByPlaceholderText('任务描述'), { target: { value: ' Details ' } })
|
|
|
+ fireEvent.change(screen.getByPlaceholderText(/依赖任务/u), { target: { value: 'task-1, task-1' } })
|
|
|
+ fireEvent.change(screen.getByPlaceholderText(/写入范围/u), { target: { value: 'src/a, src/b' } })
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
|
|
+ await waitFor(() => {
|
|
|
+ expect(createTask).toHaveBeenCalledWith(SESSION, {
|
|
|
+ subject: 'New task',
|
|
|
+ description: 'Details',
|
|
|
+ blockedBy: ['task-1'],
|
|
|
+ writeScopes: ['src/a', 'src/b'],
|
|
|
+ })
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('assigns, edits, completes, reopens, and deletes with contiguous CAS revisions', async () => {
|
|
|
+ let current = { ...task }
|
|
|
+ const updateTask: TeamActionInjected['updateTask'] = vi.fn((
|
|
|
+ _sessionId: SessionId,
|
|
|
+ input: Parameters<TeamActionInjected['updateTask']>[1],
|
|
|
+ ) => {
|
|
|
+ const revision = current.revision + 1
|
|
|
+ switch (input.action) {
|
|
|
+ case 'reassign':
|
|
|
+ current = {
|
|
|
+ ...current,
|
|
|
+ revision,
|
|
|
+ status: 'in_progress',
|
|
|
+ ownerName: input.owner ?? 'lead',
|
|
|
+ }
|
|
|
+ break
|
|
|
+ case 'edit':
|
|
|
+ current = {
|
|
|
+ ...current,
|
|
|
+ revision,
|
|
|
+ subject: input.subject ?? current.subject,
|
|
|
+ description: input.description ?? current.description,
|
|
|
+ writeScopes: input.writeScopes ?? current.writeScopes,
|
|
|
+ }
|
|
|
+ break
|
|
|
+ case 'set_dependencies':
|
|
|
+ current = { ...current, revision, blockedBy: input.blockedBy ?? [] }
|
|
|
+ break
|
|
|
+ case 'complete':
|
|
|
+ current = { ...current, revision, status: 'completed' }
|
|
|
+ break
|
|
|
+ case 'reopen': {
|
|
|
+ const { ownerName: _ownerName, ...unowned } = current
|
|
|
+ current = { ...unowned, revision, status: 'pending', ready: true }
|
|
|
+ break
|
|
|
+ }
|
|
|
+ case 'delete':
|
|
|
+ current = { ...current, revision, status: 'deleted' }
|
|
|
+ break
|
|
|
+ default:
|
|
|
+ throw new Error(`unexpected action ${input.action}`)
|
|
|
+ }
|
|
|
+ return Promise.resolve({ ok: true as const, value: current })
|
|
|
+ })
|
|
|
+ render(<TeamAction {...props(actions({ updateTask }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+
|
|
|
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'worker' } })
|
|
|
+ await waitFor(() => {
|
|
|
+ expect(screen.getByRole<HTMLSelectElement>('combobox').value).toBe('worker')
|
|
|
+ expect(current).toMatchObject({ revision: 2, ownerName: 'worker' })
|
|
|
+ })
|
|
|
+
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /编辑/u }))
|
|
|
+ fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Updated runtime' } })
|
|
|
+ fireEvent.change(screen.getByPlaceholderText('任务描述'), { target: { value: 'Updated details' } })
|
|
|
+ fireEvent.change(screen.getByPlaceholderText(/依赖任务/u), { target: { value: 'task-0' } })
|
|
|
+ fireEvent.change(screen.getByPlaceholderText(/写入范围/u), { target: { value: 'src/runtime' } })
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
|
|
+ expect(await screen.findByText('Updated runtime')).toBeTruthy()
|
|
|
+ expect(current).toMatchObject({
|
|
|
+ revision: 4,
|
|
|
+ description: 'Updated details',
|
|
|
+ blockedBy: ['task-0'],
|
|
|
+ writeScopes: ['src/runtime'],
|
|
|
+ })
|
|
|
+
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /完成/u }))
|
|
|
+ fireEvent.click(await screen.findByRole('button', { name: /重开/u }))
|
|
|
+ await waitFor(() => {
|
|
|
+ expect(screen.queryByRole('button', { name: /重开/u })).toBeNull()
|
|
|
+ expect(current).toMatchObject({ revision: 6, status: 'pending' })
|
|
|
+ })
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /删除/u }))
|
|
|
+ await waitFor(() => { expect(screen.queryByText('Updated runtime')).toBeNull() })
|
|
|
+
|
|
|
+ expect(vi.mocked(updateTask).mock.calls.map(([, input]) => [input.action, input.expectedRevision]))
|
|
|
+ .toEqual([
|
|
|
+ ['reassign', 1],
|
|
|
+ ['edit', 2],
|
|
|
+ ['set_dependencies', 3],
|
|
|
+ ['complete', 4],
|
|
|
+ ['reopen', 5],
|
|
|
+ ['delete', 6],
|
|
|
+ ])
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reloads and warns instead of retrying a stale task mutation', async () => {
|
|
|
+ const load = vi.fn()
|
|
|
+ .mockResolvedValueOnce({ ok: true, value: view })
|
|
|
+ .mockResolvedValueOnce({ ok: true, value: { ...view, tasks: [{ ...task, revision: 2 }] } })
|
|
|
+ const updateTask = vi.fn(() => Promise.resolve({
|
|
|
+ ok: false as const,
|
|
|
+ error: 'stale (team-task-conflict)',
|
|
|
+ conflict: true,
|
|
|
+ }))
|
|
|
+ render(<TeamAction {...props(actions({ load, updateTask }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /完成/u }))
|
|
|
+ expect(await screen.findByText(zh.conflict)).toBeTruthy()
|
|
|
+ expect(load).toHaveBeenCalledTimes(2)
|
|
|
+ expect(updateTask).toHaveBeenCalledTimes(1)
|
|
|
+ })
|
|
|
+
|
|
|
+ it('renders roster/task state variants and contains navigation, refresh, and close actions', async () => {
|
|
|
+ const { ownerName: _ownerName, ...unownedTask } = task
|
|
|
+ const richView: TeamView = {
|
|
|
+ ...view,
|
|
|
+ members: [
|
|
|
+ view.members[0]!,
|
|
|
+ { ...view.members[1]!, status: 'running' },
|
|
|
+ {
|
|
|
+ id: 'failed-id' as SessionId,
|
|
|
+ name: 'failed-worker',
|
|
|
+ role: 'teammate',
|
|
|
+ status: 'failed',
|
|
|
+ diagnostics: ['provider failed'],
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 'provisioning-id' as SessionId,
|
|
|
+ name: 'provisioning-worker',
|
|
|
+ role: 'teammate',
|
|
|
+ status: 'provisioning',
|
|
|
+ diagnostics: [],
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ tasks: [
|
|
|
+ { ...unownedTask, id: tid('ready-task'), status: 'pending', ready: true },
|
|
|
+ { ...unownedTask, id: tid('blocked-task'), status: 'pending', ready: false },
|
|
|
+ { ...task, id: tid('completed-task'), status: 'completed' },
|
|
|
+ ],
|
|
|
+ }
|
|
|
+ const load = vi.fn(() => Promise.resolve({ ok: true as const, value: richView }))
|
|
|
+ const openTeammate = vi.fn(() => Promise.reject(new Error('navigation failed')))
|
|
|
+ render(<TeamAction {...props(actions({ load, openTeammate }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ expect(await screen.findByText('provider failed')).toBeTruthy()
|
|
|
+ expect(screen.getByText(zh.ready)).toBeTruthy()
|
|
|
+ expect(screen.getByText(zh.blocked)).toBeTruthy()
|
|
|
+ expect(screen.getByRole<HTMLButtonElement>('button', { name: /failed-worker/u }).disabled).toBe(true)
|
|
|
+ expect(screen.getByRole<HTMLButtonElement>('button', { name: /provisioning-worker/u }).disabled).toBe(true)
|
|
|
+
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /^workerrunning/u }))
|
|
|
+ expect(await screen.findByText('Error: navigation failed')).toBeTruthy()
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: zh.refresh }))
|
|
|
+ await waitFor(() => { expect(load).toHaveBeenCalledTimes(2) })
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ expect(screen.queryByRole('dialog')).toBeNull()
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByRole('dialog')
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: zh.close }))
|
|
|
+ expect(screen.queryByRole('dialog')).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('shows load and create failures and ignores a create result after a session switch', async () => {
|
|
|
+ const failedLoad = actions({
|
|
|
+ load: () => Promise.resolve({ ok: false, error: 'load failed', conflict: false }),
|
|
|
+ })
|
|
|
+ const first = render(<TeamAction {...props(failedLoad)} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ expect(await screen.findByText('load failed')).toBeTruthy()
|
|
|
+ first.unmount()
|
|
|
+
|
|
|
+ const createTask = vi.fn(() => Promise.resolve({
|
|
|
+ ok: false as const, error: 'create failed', conflict: false,
|
|
|
+ }))
|
|
|
+ const second = render(<TeamAction {...props(actions({ createTask }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /新建任务/u }))
|
|
|
+ fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Task' } })
|
|
|
+ fireEvent.change(screen.getByPlaceholderText('任务描述'), { target: { value: 'Description' } })
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
|
|
+ expect(await screen.findByText('create failed')).toBeTruthy()
|
|
|
+ second.unmount()
|
|
|
+
|
|
|
+ const pending = Promise.withResolvers<TeamActionResult<TeamTask>>()
|
|
|
+ const third = render(<TeamAction {...props(actions({ createTask: () => pending.promise }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /新建任务/u }))
|
|
|
+ fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Late task' } })
|
|
|
+ fireEvent.change(screen.getByPlaceholderText('任务描述'), { target: { value: 'Late description' } })
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
|
|
+ third.rerender(<TeamAction {...props(actions(), 'next-session' as SessionId)} />)
|
|
|
+ pending.resolve({ ok: true, value: { ...task, id: tid('late-task') } })
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(screen.queryByText('Late task')).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('contains stale-session and ordinary task failures without retrying', async () => {
|
|
|
+ const pending = Promise.withResolvers<TeamActionResult<TeamTask>>()
|
|
|
+ const rendered = render(<TeamAction {...props(actions({ updateTask: () => pending.promise }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /完成/u }))
|
|
|
+ rendered.rerender(<TeamAction {...props(actions(), 'next-session' as SessionId)} />)
|
|
|
+ pending.resolve({ ok: true, value: { ...task, revision: 2, status: 'completed' } })
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(screen.queryByText('Implement runtime')).toBeNull()
|
|
|
+ rendered.unmount()
|
|
|
+
|
|
|
+ render(<TeamAction {...props(actions({
|
|
|
+ updateTask: () => Promise.resolve({ ok: false, error: 'update failed', conflict: false }),
|
|
|
+ }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /完成/u }))
|
|
|
+ expect(await screen.findByText('update failed')).toBeTruthy()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('does not publish a task conflict after its reload switches sessions', async () => {
|
|
|
+ const reload = Promise.withResolvers<TeamActionResult<TeamView>>()
|
|
|
+ const load = vi.fn()
|
|
|
+ .mockResolvedValueOnce({ ok: true, value: view })
|
|
|
+ .mockImplementationOnce(() => reload.promise)
|
|
|
+ const rendered = render(<TeamAction {...props(actions({
|
|
|
+ load,
|
|
|
+ updateTask: () => Promise.resolve({ ok: false, error: 'stale task', conflict: true }),
|
|
|
+ }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /完成/u }))
|
|
|
+ await waitFor(() => { expect(load).toHaveBeenCalledTimes(2) })
|
|
|
+
|
|
|
+ rendered.rerender(<TeamAction {...props(actions(), 'next-session' as SessionId)} />)
|
|
|
+ reload.resolve({ ok: true, value: view })
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(screen.queryByText(zh.conflict)).toBeNull()
|
|
|
+ })
|
|
|
+
|
|
|
+ it('contains edit and dependency failures and supports form cancellation and unassignment', async () => {
|
|
|
+ const updateTask = vi.fn()
|
|
|
+ .mockResolvedValueOnce({ ok: false, error: 'edit failed', conflict: false })
|
|
|
+ .mockResolvedValueOnce({ ok: true, value: { ...task, revision: 2, subject: 'Saved edit' } })
|
|
|
+ .mockResolvedValueOnce({ ok: false, error: 'dependency failed', conflict: false })
|
|
|
+ .mockResolvedValueOnce({ ok: true, value: { ...task, revision: 2, ownerName: undefined } })
|
|
|
+ render(<TeamAction {...props(actions({ updateTask }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /新建任务/u }))
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
|
|
+ expect(screen.queryByPlaceholderText('任务标题')).toBeNull()
|
|
|
+
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /编辑/u }))
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
|
|
+ expect(screen.queryByRole('button', { name: '保存' })).toBeNull()
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /编辑/u }))
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
|
|
+ expect(await screen.findByText('edit failed')).toBeTruthy()
|
|
|
+
|
|
|
+ fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Saved edit' } })
|
|
|
+ fireEvent.change(screen.getByPlaceholderText(zh.blockers), { target: { value: 'task-2' } })
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
|
|
+ expect(await screen.findByText('dependency failed')).toBeTruthy()
|
|
|
+ expect(updateTask.mock.calls[2]?.[1]).toMatchObject({
|
|
|
+ action: 'set_dependencies',
|
|
|
+ expectedRevision: 2,
|
|
|
+ blockedBy: ['task-2'],
|
|
|
+ })
|
|
|
+
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '取消' }))
|
|
|
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: '' } })
|
|
|
+ await waitFor(() => {
|
|
|
+ expect(updateTask).toHaveBeenLastCalledWith(SESSION, expect.objectContaining({
|
|
|
+ action: 'reassign',
|
|
|
+ }))
|
|
|
+ expect(updateTask.mock.calls.at(-1)?.[1]).not.toHaveProperty('owner')
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ it('skips the dependency mutation when an edit keeps the same blockers', async () => {
|
|
|
+ const blockedTask: TeamTask = { ...task, blockedBy: [tid('task-0')] }
|
|
|
+ const updateTask = vi.fn().mockResolvedValue({
|
|
|
+ ok: true,
|
|
|
+ value: { ...blockedTask, revision: 2, subject: 'Same dependencies' },
|
|
|
+ })
|
|
|
+ render(<TeamAction {...props(actions({
|
|
|
+ load: () => Promise.resolve({ ok: true, value: { ...view, tasks: [blockedTask] } }),
|
|
|
+ updateTask,
|
|
|
+ }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /编辑/u }))
|
|
|
+ fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Same dependencies' } })
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
|
|
+
|
|
|
+ await waitFor(() => { expect(screen.queryByRole('button', { name: '保存' })).toBeNull() })
|
|
|
+ expect(updateTask).toHaveBeenCalledTimes(1)
|
|
|
+ expect(updateTask).toHaveBeenCalledWith(SESSION, expect.objectContaining({ action: 'edit' }))
|
|
|
+ })
|
|
|
+
|
|
|
+ it('reloads a dependency conflict and ignores dependency settlement after a session switch', async () => {
|
|
|
+ const load = vi.fn()
|
|
|
+ .mockResolvedValueOnce({ ok: true, value: view })
|
|
|
+ .mockResolvedValueOnce({ ok: true, value: { ...view, tasks: [{ ...task, revision: 3 }] } })
|
|
|
+ const conflictUpdate = vi.fn()
|
|
|
+ .mockResolvedValueOnce({ ok: true, value: { ...task, revision: 2, subject: 'Conflict edit' } })
|
|
|
+ .mockResolvedValueOnce({ ok: false, error: 'stale dependency', conflict: true })
|
|
|
+ const first = render(<TeamAction {...props(actions({ load, updateTask: conflictUpdate }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /编辑/u }))
|
|
|
+ fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Conflict edit' } })
|
|
|
+ fireEvent.change(screen.getByPlaceholderText(zh.blockers), { target: { value: 'task-2' } })
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
|
|
+ expect(await screen.findByText(zh.conflict)).toBeTruthy()
|
|
|
+ expect(load).toHaveBeenCalledTimes(2)
|
|
|
+ first.unmount()
|
|
|
+
|
|
|
+ const dependencyReload = Promise.withResolvers<TeamActionResult<TeamView>>()
|
|
|
+ const dependencyLoad = vi.fn()
|
|
|
+ .mockResolvedValueOnce({ ok: true, value: view })
|
|
|
+ .mockImplementationOnce(() => dependencyReload.promise)
|
|
|
+ const staleUpdate = vi.fn()
|
|
|
+ .mockResolvedValueOnce({ ok: true, value: { ...task, revision: 2, subject: 'Late edit' } })
|
|
|
+ .mockResolvedValueOnce({ ok: false, error: 'stale dependency', conflict: true })
|
|
|
+ const second = render(<TeamAction {...props(actions({ load: dependencyLoad, updateTask: staleUpdate }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /编辑/u }))
|
|
|
+ fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Late edit' } })
|
|
|
+ fireEvent.change(screen.getByPlaceholderText(zh.blockers), { target: { value: 'task-2' } })
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
|
|
+ await waitFor(() => { expect(dependencyLoad).toHaveBeenCalledTimes(2) })
|
|
|
+ second.rerender(<TeamAction {...props(actions(), 'next-session' as SessionId)} />)
|
|
|
+ dependencyReload.resolve({ ok: true, value: { ...view, tasks: [{ ...task, revision: 3 }] } })
|
|
|
+ await Promise.resolve()
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(screen.queryByText(zh.conflict)).toBeNull()
|
|
|
+ second.unmount()
|
|
|
+
|
|
|
+ const dependency = Promise.withResolvers<TeamActionResult<TeamTask>>()
|
|
|
+ const lateUpdate = vi.fn()
|
|
|
+ .mockResolvedValueOnce({ ok: true, value: { ...task, revision: 2, subject: 'Late edit' } })
|
|
|
+ .mockImplementationOnce(() => dependency.promise)
|
|
|
+ const third = render(<TeamAction {...props(actions({ updateTask: lateUpdate }))} />)
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /Agent Team/u }))
|
|
|
+ await screen.findByText('Implement runtime')
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: /编辑/u }))
|
|
|
+ fireEvent.change(screen.getByPlaceholderText('任务标题'), { target: { value: 'Late edit' } })
|
|
|
+ fireEvent.change(screen.getByPlaceholderText(zh.blockers), { target: { value: 'task-2' } })
|
|
|
+ fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
|
|
+ await waitFor(() => { expect(lateUpdate).toHaveBeenCalledTimes(2) })
|
|
|
+ expect(screen.getByRole<HTMLButtonElement>('button', { name: '保存' }).disabled).toBe(true)
|
|
|
+ expect(screen.getByRole<HTMLButtonElement>('button', { name: '取消' }).disabled).toBe(true)
|
|
|
+ third.rerender(<TeamAction {...props(actions(), 'next-session' as SessionId)} />)
|
|
|
+ dependency.resolve({ ok: true, value: { ...task, revision: 3, subject: 'Late dependency' } })
|
|
|
+ await Promise.resolve()
|
|
|
+ expect(screen.queryByText('Late dependency')).toBeNull()
|
|
|
+ })
|
|
|
+})
|