From 326994d101257ce160f622f68a54353855f40287 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Wed, 18 Mar 2026 15:41:59 +0200 Subject: [PATCH] kin: auto-commit after pipeline --- .../__tests__/completed-tasks-banner.test.ts | 339 ++++++++++++++++++ .../__tests__/SettingsView.worktrees.test.ts | 24 ++ 2 files changed, 363 insertions(+) create mode 100644 web/frontend/src/__tests__/completed-tasks-banner.test.ts diff --git a/web/frontend/src/__tests__/completed-tasks-banner.test.ts b/web/frontend/src/__tests__/completed-tasks-banner.test.ts new file mode 100644 index 0000000..a71c160 --- /dev/null +++ b/web/frontend/src/__tests__/completed-tasks-banner.test.ts @@ -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 = {} + 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, + tasks: ReturnType[], +) { + 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) { + // 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') + }) +}) diff --git a/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts b/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts index 83abe4b..8bf7145 100644 --- a/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts +++ b/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts @@ -86,6 +86,11 @@ async function mountSettings(overrides: Partial = {}) { } describe('SettingsView — навигатор', () => { + it('таблица проектов рендерится', async () => { + const wrapper = await mountSettings() + expect(wrapper.find('table').exists()).toBe(true) + }) + it('показывает имя проекта', async () => { const wrapper = await mountSettings() expect(wrapper.text()).toContain('Test Project') @@ -122,4 +127,23 @@ describe('SettingsView — навигатор', () => { const wrapper = await mountSettings({ execution_mode: null }) 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') + } + }) })