kin: auto-commit after pipeline
This commit is contained in:
parent
8f5e0f6bd8
commit
326994d101
2 changed files with 363 additions and 0 deletions
339
web/frontend/src/__tests__/completed-tasks-banner.test.ts
Normal file
339
web/frontend/src/__tests__/completed-tasks-banner.test.ts
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
/**
|
||||
* KIN-125: Уведомления о завершённых задачах в EscalationBanner
|
||||
*
|
||||
* AC1: Завершённые задачи отображаются в панели уведомлений рядом с эскалациями
|
||||
* AC2: Кнопка Done скрывает задачу из списка
|
||||
* AC3: Кнопка Revise отправляет задачу на доработку (смена статуса)
|
||||
* AC4: Клик по полю задачи выполняет навигацию к детальному виду задачи
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import EscalationBanner from '../components/EscalationBanner.vue'
|
||||
|
||||
const mockPush = vi.fn()
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mockPush }),
|
||||
}))
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
notifications: vi.fn(),
|
||||
projects: vi.fn(),
|
||||
project: vi.fn(),
|
||||
reviseTask: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
import { api } from '../api'
|
||||
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => { store[k] = v },
|
||||
removeItem: (k: string) => { delete store[k] },
|
||||
clear: () => { store = {} },
|
||||
}
|
||||
})()
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, configurable: true })
|
||||
|
||||
function makeProject(id = 'proj-1', name = 'MyProject', doneCount = 1) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
path: '/projects/test',
|
||||
status: 'active',
|
||||
priority: 1,
|
||||
tech_stack: ['python'],
|
||||
execution_mode: null,
|
||||
autocommit_enabled: null,
|
||||
auto_test_enabled: null,
|
||||
worktrees_enabled: null,
|
||||
obsidian_vault_path: null,
|
||||
deploy_command: null,
|
||||
test_command: null,
|
||||
deploy_host: null,
|
||||
deploy_path: null,
|
||||
deploy_runtime: null,
|
||||
deploy_restart_cmd: null,
|
||||
created_at: '2024-01-01',
|
||||
total_tasks: doneCount,
|
||||
done_tasks: doneCount,
|
||||
active_tasks: 0,
|
||||
blocked_tasks: 0,
|
||||
review_tasks: 0,
|
||||
project_type: null,
|
||||
ssh_host: null,
|
||||
ssh_user: null,
|
||||
ssh_key_path: null,
|
||||
ssh_proxy_jump: null,
|
||||
description: null,
|
||||
}
|
||||
}
|
||||
|
||||
function makeCompletedTask(id = 'TSK-1', title = 'Test task') {
|
||||
return {
|
||||
id,
|
||||
project_id: 'proj-1',
|
||||
title,
|
||||
status: 'completed',
|
||||
priority: 1,
|
||||
assigned_role: null,
|
||||
parent_task_id: null,
|
||||
brief: null,
|
||||
spec: null,
|
||||
execution_mode: null,
|
||||
blocked_reason: null,
|
||||
dangerously_skipped: null,
|
||||
category: null,
|
||||
acceptance_criteria: null,
|
||||
created_at: '2024-01-01T10:00:00',
|
||||
updated_at: '2024-03-18T10:00:00',
|
||||
}
|
||||
}
|
||||
|
||||
function makeProjectDetail(
|
||||
project: ReturnType<typeof makeProject>,
|
||||
tasks: ReturnType<typeof makeCompletedTask>[],
|
||||
) {
|
||||
return { ...project, tasks, modules: [], decisions: [] }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
vi.clearAllMocks()
|
||||
mockPush.mockClear()
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(api.notifications).mockResolvedValue([])
|
||||
vi.mocked(api.projects).mockResolvedValue([])
|
||||
vi.mocked(api.project).mockResolvedValue(makeProjectDetail(makeProject(), []))
|
||||
vi.mocked(api.reviseTask).mockResolvedValue({ status: 'ok', comment: '' })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function mountWithCompleted(
|
||||
tasks = [makeCompletedTask()],
|
||||
projectName = 'MyProject',
|
||||
) {
|
||||
const project = makeProject('proj-1', projectName)
|
||||
vi.mocked(api.projects).mockResolvedValue([project])
|
||||
vi.mocked(api.project).mockResolvedValue(makeProjectDetail(project, tasks))
|
||||
const wrapper = mount(EscalationBanner)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function openPanel(wrapper: ReturnType<typeof mount>) {
|
||||
// Click the green completed-tasks badge to open the panel
|
||||
const completedBadge = wrapper.findAll('button').find(b =>
|
||||
b.text().includes('Completed'),
|
||||
)
|
||||
if (completedBadge) {
|
||||
await completedBadge.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// AC1: Завершённые задачи отображаются в панели уведомлений
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('KIN-125 AC1: завершённые задачи отображаются в панели', () => {
|
||||
it('Зелёный бейдж "Completed" появляется при наличии завершённых задач', async () => {
|
||||
const wrapper = await mountWithCompleted()
|
||||
const badge = wrapper.findAll('button').find(b => b.text().includes('Completed'))
|
||||
expect(badge).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Бейдж показывает корректное количество завершённых задач', async () => {
|
||||
const tasks = [makeCompletedTask('TSK-1'), makeCompletedTask('TSK-2')]
|
||||
const wrapper = await mountWithCompleted(tasks)
|
||||
const badge = wrapper.findAll('button').find(b => b.text().includes('Completed'))
|
||||
expect(badge!.text()).toContain('2')
|
||||
})
|
||||
|
||||
it('Бейдж не отображается когда нет завершённых задач', async () => {
|
||||
vi.mocked(api.projects).mockResolvedValue([makeProject('proj-1', 'P', 0)])
|
||||
const wrapper = mount(EscalationBanner)
|
||||
await flushPromises()
|
||||
const badge = wrapper.findAll('button').find(b => b.text().includes('Completed'))
|
||||
expect(badge).toBeUndefined()
|
||||
})
|
||||
|
||||
it('Секция завершённых задач отображается при открытии панели', async () => {
|
||||
const wrapper = await mountWithCompleted()
|
||||
await openPanel(wrapper)
|
||||
expect(wrapper.text()).toContain('Completed tasks')
|
||||
})
|
||||
|
||||
it('Заголовок завершённой задачи виден в открытой панели', async () => {
|
||||
const wrapper = await mountWithCompleted([makeCompletedTask('TSK-1', 'Deploy to production')])
|
||||
await openPanel(wrapper)
|
||||
expect(wrapper.text()).toContain('Deploy to production')
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// AC2: Кнопка Done скрывает задачу из списка
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('KIN-125 AC2: кнопка Done скрывает задачу из списка', () => {
|
||||
it('Нажатие Done убирает задачу из панели', async () => {
|
||||
const wrapper = await mountWithCompleted([makeCompletedTask('TSK-10', 'Task to dismiss')])
|
||||
await openPanel(wrapper)
|
||||
expect(wrapper.text()).toContain('Task to dismiss')
|
||||
|
||||
const doneBtn = wrapper.findAll('button').find(b => b.text() === 'Done')
|
||||
expect(doneBtn).toBeTruthy()
|
||||
await doneBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Task to dismiss')
|
||||
})
|
||||
|
||||
it('После Done task_id сохраняется в localStorage под ключом kin_dismissed_completed', async () => {
|
||||
const wrapper = await mountWithCompleted([makeCompletedTask('TSK-11')])
|
||||
await openPanel(wrapper)
|
||||
|
||||
const doneBtn = wrapper.findAll('button').find(b => b.text() === 'Done')
|
||||
await doneBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const stored = localStorageMock.getItem('kin_dismissed_completed')
|
||||
expect(stored).toBeTruthy()
|
||||
expect(JSON.parse(stored!)).toContain('TSK-11')
|
||||
})
|
||||
|
||||
it('Dismissed задача не появляется снова при следующем поллинге (30с)', async () => {
|
||||
const wrapper = await mountWithCompleted([makeCompletedTask('TSK-12', 'Reappear task')])
|
||||
await openPanel(wrapper)
|
||||
|
||||
const doneBtn = wrapper.findAll('button').find(b => b.text() === 'Done')
|
||||
await doneBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
vi.advanceTimersByTime(30000)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Reappear task')
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// AC3: Кнопка Revise отправляет задачу на доработку
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('KIN-125 AC3: кнопка Revise отправляет задачу на доработку', () => {
|
||||
it('Нажатие Revise показывает inline форму с полем ввода комментария', async () => {
|
||||
const wrapper = await mountWithCompleted()
|
||||
await openPanel(wrapper)
|
||||
|
||||
const reviseBtn = wrapper.findAll('button').find(b => b.text() === 'Revise')
|
||||
expect(reviseBtn).toBeTruthy()
|
||||
await reviseBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('Нажатие Send вызывает api.reviseTask с task_id и введённым комментарием', async () => {
|
||||
const wrapper = await mountWithCompleted([makeCompletedTask('TSK-20')])
|
||||
await openPanel(wrapper)
|
||||
|
||||
const reviseBtn = wrapper.findAll('button').find(b => b.text() === 'Revise')
|
||||
await reviseBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('input[type="text"]').setValue('Add deployment logs')
|
||||
const sendBtn = wrapper.findAll('button').find(b => b.text() === 'Send')
|
||||
await sendBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(api.reviseTask)).toHaveBeenCalledWith('TSK-20', 'Add deployment logs')
|
||||
})
|
||||
|
||||
it('После успешного Revise задача скрывается из списка', async () => {
|
||||
const wrapper = await mountWithCompleted([makeCompletedTask('TSK-21', 'Revise me')])
|
||||
await openPanel(wrapper)
|
||||
expect(wrapper.text()).toContain('Revise me')
|
||||
|
||||
const reviseBtn = wrapper.findAll('button').find(b => b.text() === 'Revise')
|
||||
await reviseBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const sendBtn = wrapper.findAll('button').find(b => b.text() === 'Send')
|
||||
await sendBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Revise me')
|
||||
})
|
||||
|
||||
it('При пустом комментарии используется дефолтный текст (не пустая строка)', async () => {
|
||||
const wrapper = await mountWithCompleted([makeCompletedTask('TSK-22')])
|
||||
await openPanel(wrapper)
|
||||
|
||||
const reviseBtn = wrapper.findAll('button').find(b => b.text() === 'Revise')
|
||||
await reviseBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
// Send without entering a comment
|
||||
const sendBtn = wrapper.findAll('button').find(b => b.text() === 'Send')
|
||||
await sendBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(vi.mocked(api.reviseTask)).toHaveBeenCalledWith('TSK-22', expect.stringMatching(/.+/))
|
||||
})
|
||||
|
||||
it('Нажатие Cancel скрывает форму без вызова api.reviseTask', async () => {
|
||||
const wrapper = await mountWithCompleted()
|
||||
await openPanel(wrapper)
|
||||
|
||||
const reviseBtn = wrapper.findAll('button').find(b => b.text() === 'Revise')
|
||||
await reviseBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('input[type="text"]').exists()).toBe(true)
|
||||
|
||||
const cancelBtn = wrapper.findAll('button').find(b => b.text() === 'Cancel')
|
||||
await cancelBtn!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('input[type="text"]').exists()).toBe(false)
|
||||
expect(vi.mocked(api.reviseTask)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// AC4: Клик по полю задачи выполняет навигацию к детальному виду
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('KIN-125 AC4: клик по задаче переходит к детальному виду', () => {
|
||||
it('Клик по строке задачи вызывает router.push("/task/{id}")', async () => {
|
||||
const wrapper = await mountWithCompleted([makeCompletedTask('TSK-30')])
|
||||
await openPanel(wrapper)
|
||||
|
||||
const taskRow = wrapper.find('.cursor-pointer')
|
||||
expect(taskRow.exists()).toBe(true)
|
||||
await taskRow.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/task/TSK-30')
|
||||
})
|
||||
|
||||
it('После клика по задаче панель закрывается', async () => {
|
||||
const wrapper = await mountWithCompleted([makeCompletedTask('TSK-31')])
|
||||
await openPanel(wrapper)
|
||||
expect(wrapper.text()).toContain('Completed tasks')
|
||||
|
||||
const taskRow = wrapper.find('.cursor-pointer')
|
||||
await taskRow.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Completed tasks')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue