kin: auto-commit after pipeline

This commit is contained in:
Gros Frumos 2026-03-18 15:41:59 +02:00
parent 8f5e0f6bd8
commit 326994d101
2 changed files with 363 additions and 0 deletions

View 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')
})
})

View file

@ -86,6 +86,11 @@ async function mountSettings(overrides: Partial<typeof BASE_PROJECT> = {}) {
} }
describe('SettingsView — навигатор', () => { describe('SettingsView — навигатор', () => {
it('таблица проектов рендерится', async () => {
const wrapper = await mountSettings()
expect(wrapper.find('table').exists()).toBe(true)
})
it('показывает имя проекта', async () => { it('показывает имя проекта', async () => {
const wrapper = await mountSettings() const wrapper = await mountSettings()
expect(wrapper.text()).toContain('Test Project') expect(wrapper.text()).toContain('Test Project')
@ -122,4 +127,23 @@ describe('SettingsView — навигатор', () => {
const wrapper = await mountSettings({ execution_mode: null }) const wrapper = await mountSettings({ execution_mode: null })
expect(wrapper.text()).not.toContain('auto_complete') expect(wrapper.text()).not.toContain('auto_complete')
}) })
it('для каждого проекта есть ссылка с ?tab=settings', async () => {
const projects = [
{ ...BASE_PROJECT, id: 'proj-1', name: 'Project One' },
{ ...BASE_PROJECT, id: 'proj-2', name: 'Project Two' },
{ ...BASE_PROJECT, id: 'proj-3', name: 'Project Three' },
]
vi.mocked(api.projects).mockResolvedValue(projects as any)
const router = makeRouter()
await router.push('/settings')
const wrapper = mount(SettingsView, { global: { plugins: [router] } })
await flushPromises()
for (const project of projects) {
const link = wrapper.find(`a[href*="${project.id}"]`)
expect(link.exists()).toBe(true)
expect(link.attributes('href')).toContain('tab=settings')
}
})
}) })