From 477fc68cd3002bffb30cdc627fcd300f9611c26c Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Sat, 21 Mar 2026 09:23:17 +0200 Subject: [PATCH] kin: auto-commit after pipeline --- ...28-action-banner-vertical-pipeline.test.ts | 455 ++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 web/frontend/src/__tests__/kin-ui-028-action-banner-vertical-pipeline.test.ts diff --git a/web/frontend/src/__tests__/kin-ui-028-action-banner-vertical-pipeline.test.ts b/web/frontend/src/__tests__/kin-ui-028-action-banner-vertical-pipeline.test.ts new file mode 100644 index 0000000..0aa1430 --- /dev/null +++ b/web/frontend/src/__tests__/kin-ui-028-action-banner-vertical-pipeline.test.ts @@ -0,0 +1,455 @@ +/** + * KIN-UI-028: Тесты для action-banner и vertical pipeline в TaskDetail.vue + * + * Feature 1: Action-banner при status='review' && !autoMode + * - Показывается при review + execution_mode=review (autoMode=false) + * - Скрывается при autoMode=true (execution_mode=auto_complete) + * - Скрывается при status != review + * - Кнопки Approve/Revise/Reject вызывают корректные обработчики + * - Кнопка Auto mode вызывает toggleMode → api.patchTask + * + * Feature 2: Vertical timeline при pipeline_steps.length > 5 + * - ≤5 шагов → горизонтальный скролл (overflow-x-auto) + * - >5 шагов → вертикальный timeline с chevron + * - Иконки: success=true/1 → ✓ green, success=false/0 → ✗ red, null → ● blue + * - Поля роли, duration_seconds, cost_usd отображаются + * - Chevron переключается по клику + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import * as fs from 'node:fs' +import * as path from 'node:path' +import enJson from '../locales/en.json' +import ruJson from '../locales/ru.json' +import TaskDetail from '../views/TaskDetail.vue' + +vi.mock('../api', () => ({ + api: { + taskFull: vi.fn(), + patchTask: vi.fn(), + runTask: vi.fn(), + approveTask: vi.fn(), + rejectTask: vi.fn(), + reviseTask: vi.fn(), + followupTask: vi.fn(), + deployProject: vi.fn(), + getAttachments: vi.fn(), + resolveAction: 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 }) + +const Stub = { template: '
' } + +function makeRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: Stub }, + { path: '/task/:id', component: TaskDetail, props: true }, + ], + }) +} + +function makeStep(id: number, role: string, success: boolean | number | null = true, overrides: Record = {}): Record { + return { + id, + agent_role: role, + success, + output_summary: null, + duration_seconds: 10, + tokens_used: 500, + cost_usd: 0.01, + created_at: '2026-01-01T00:00:00', + ...overrides, + } +} + +function makeTask(overrides: Record = {}): Record { + return { + id: 'KIN-UI-028', + project_id: 'KIN', + title: 'Test Task', + status: 'review', + priority: 5, + assigned_role: null, + parent_task_id: null, + brief: null, + spec: null, + execution_mode: 'review', + blocked_reason: null, + dangerously_skipped: null, + category: null, + acceptance_criteria: null, + created_at: '2026-01-01', + updated_at: '2026-01-01', + pipeline_steps: [], + related_decisions: [], + pending_actions: [], + pipeline_id: null, + project_deploy_command: null, + project_deploy_runtime: null, + ...overrides, + } +} + +async function mountTask(taskData: Record) { + vi.mocked(api.taskFull).mockResolvedValue(taskData as any) + const router = makeRouter() + await router.push('/task/KIN-UI-028') + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-UI-028' }, + global: { plugins: [router] }, + }) + await flushPromises() + return wrapper +} + +beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + vi.mocked(api.getAttachments).mockResolvedValue([]) +}) + +// ============================================================================ +// Feature 1: Action-banner — i18n ключи +// ============================================================================ + +describe('KIN-UI-028 Feature 1: i18n ключи для banner в en.json', () => { + it('ключ taskDetail.review_required присутствует в en.json', () => { + expect((enJson.taskDetail as Record).review_required).toBeDefined() + }) + + it('ключ taskDetail.banner_auto_mode присутствует в en.json', () => { + expect((enJson.taskDetail as Record).banner_auto_mode).toBeDefined() + }) +}) + +describe('KIN-UI-028 Feature 1: i18n ключи для banner в ru.json', () => { + it('ключ taskDetail.review_required присутствует в ru.json', () => { + expect((ruJson.taskDetail as Record).review_required).toBeDefined() + }) + + it('ключ taskDetail.banner_auto_mode присутствует в ru.json', () => { + expect((ruJson.taskDetail as Record).banner_auto_mode).toBeDefined() + }) +}) + +// ============================================================================ +// Feature 1: Action-banner — структура шаблона +// ============================================================================ + +describe('KIN-UI-028 Feature 1: Action-banner в шаблоне TaskDetail.vue', () => { + it('banner имеет v-if с условием task.status === review && !autoMode', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../views/TaskDetail.vue'), 'utf-8') + expect(source).toContain("task.status === 'review' && !autoMode") + }) + + it('banner использует ключ taskDetail.review_required', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../views/TaskDetail.vue'), 'utf-8') + expect(source).toContain("taskDetail.review_required") + }) + + it('banner использует ключ taskDetail.banner_auto_mode', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../views/TaskDetail.vue'), 'utf-8') + expect(source).toContain("taskDetail.banner_auto_mode") + }) +}) + +// ============================================================================ +// Feature 1: Action-banner — видимость +// ============================================================================ + +describe('KIN-UI-028 Feature 1: Action-banner виден при status=review, autoMode=false', () => { + it('banner показывается при status=review и execution_mode=review', async () => { + const wrapper = await mountTask(makeTask({ status: 'review', execution_mode: 'review' })) + expect(wrapper.html()).toContain('Review required') + }) + + it('banner показывается при status=review и execution_mode=null (review-fallback)', async () => { + const wrapper = await mountTask(makeTask({ status: 'review', execution_mode: null })) + expect(wrapper.html()).toContain('Review required') + }) +}) + +describe('KIN-UI-028 Feature 1: Action-banner скрыт при autoMode=true', () => { + it('banner отсутствует при status=review и execution_mode=auto_complete', async () => { + const wrapper = await mountTask(makeTask({ status: 'review', execution_mode: 'auto_complete' })) + expect(wrapper.html()).not.toContain('Review required') + }) +}) + +describe('KIN-UI-028 Feature 1: Action-banner скрыт при status != review', () => { + it('banner отсутствует при status=pending', async () => { + const wrapper = await mountTask(makeTask({ status: 'pending', execution_mode: 'review' })) + expect(wrapper.html()).not.toContain('Review required') + }) + + it('banner отсутствует при status=done', async () => { + const wrapper = await mountTask(makeTask({ status: 'done', execution_mode: 'review' })) + expect(wrapper.html()).not.toContain('Review required') + }) + + it('banner отсутствует при status=in_progress', async () => { + const wrapper = await mountTask(makeTask({ status: 'in_progress', execution_mode: 'review' })) + expect(wrapper.html()).not.toContain('Review required') + }) + + it('banner отсутствует при status=blocked', async () => { + const wrapper = await mountTask(makeTask({ status: 'blocked', execution_mode: 'review' })) + expect(wrapper.html()).not.toContain('Review required') + }) +}) + +// ============================================================================ +// Feature 1: Action-banner — обработчики кнопок +// ============================================================================ + +describe('KIN-UI-028 Feature 1: Action-banner кнопка Approve открывает Approve Modal', () => { + it('клик кнопки Approve в banner открывает модальное окно', async () => { + const wrapper = await mountTask(makeTask({ status: 'review', execution_mode: 'review' })) + const buttons = wrapper.findAll('button') + const approveBtn = buttons.find(b => b.text().includes('Approve')) + expect(approveBtn?.exists()).toBe(true) + await approveBtn!.trigger('click') + expect(wrapper.html()).toContain('Approve Task') + }) +}) + +describe('KIN-UI-028 Feature 1: Action-banner кнопка Revise открывает Revise Modal', () => { + it('клик кнопки Revise в banner открывает модальное окно', async () => { + const wrapper = await mountTask(makeTask({ status: 'review', execution_mode: 'review' })) + const buttons = wrapper.findAll('button') + const reviseBtn = buttons.find(b => b.text().includes('Revise')) + expect(reviseBtn?.exists()).toBe(true) + await reviseBtn!.trigger('click') + // Modal title uses t('taskDetail.send_to_revision') = "🔄 Send for revision" + expect(wrapper.html()).toContain('revision') + }) +}) + +describe('KIN-UI-028 Feature 1: Action-banner кнопка Reject открывает Reject Modal', () => { + it('клик кнопки Reject в banner открывает модальное окно', async () => { + const wrapper = await mountTask(makeTask({ status: 'review', execution_mode: 'review' })) + const buttons = wrapper.findAll('button') + const rejectBtn = buttons.find(b => b.text().includes('Reject')) + expect(rejectBtn?.exists()).toBe(true) + await rejectBtn!.trigger('click') + expect(wrapper.html()).toContain('Reject Task') + }) +}) + +describe('KIN-UI-028 Feature 1: Action-banner кнопка Auto mode вызывает toggleMode', () => { + it('клик Auto mode в banner вызывает api.patchTask с execution_mode=auto_complete', async () => { + vi.mocked(api.patchTask).mockResolvedValue(makeTask({ status: 'review', execution_mode: 'auto_complete' }) as any) + const wrapper = await mountTask(makeTask({ status: 'review', execution_mode: 'review' })) + const buttons = wrapper.findAll('button') + const autoBtn = buttons.find(b => b.text().includes('Auto mode') || b.text().includes('auto')) + expect(autoBtn?.exists()).toBe(true) + await autoBtn!.trigger('click') + await flushPromises() + expect(vi.mocked(api.patchTask)).toHaveBeenCalledWith('KIN-UI-028', { execution_mode: 'auto_complete' }) + }) +}) + +// ============================================================================ +// Feature 2: Vertical pipeline — computed и структура +// ============================================================================ + +describe('KIN-UI-028 Feature 2: useVerticalPipeline computed в TaskDetail.vue', () => { + it('useVerticalPipeline определён в скрипте компонента', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../views/TaskDetail.vue'), 'utf-8') + expect(source).toContain('useVerticalPipeline') + }) + + it('useVerticalPipeline использует порог > 5', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../views/TaskDetail.vue'), 'utf-8') + expect(source).toContain('> 5') + }) +}) + +// ============================================================================ +// Feature 2: Horizontal pipeline при ≤5 шагах +// ============================================================================ + +describe('KIN-UI-028 Feature 2: горизонтальный pipeline при ≤5 шагах', () => { + it('при 1 шаге рендерится горизонтальный overflow-x-auto', async () => { + const steps = [makeStep(1, 'pm', true)] + const wrapper = await mountTask(makeTask({ status: 'done', pipeline_steps: steps })) + expect(wrapper.html()).toContain('overflow-x-auto') + }) + + it('при 5 шагах рендерится горизонтальный overflow-x-auto', async () => { + const steps = Array.from({ length: 5 }, (_, i) => makeStep(i + 1, 'frontend_dev', true)) + const wrapper = await mountTask(makeTask({ status: 'done', pipeline_steps: steps })) + expect(wrapper.html()).toContain('overflow-x-auto') + }) + + it('при 5 шагах НЕ рендерится chevron вертикального timeline', async () => { + const steps = Array.from({ length: 5 }, (_, i) => makeStep(i + 1, 'frontend_dev', true)) + const wrapper = await mountTask(makeTask({ status: 'done', pipeline_steps: steps })) + expect(wrapper.html()).not.toContain('▼') + }) +}) + +// ============================================================================ +// Feature 2: Vertical pipeline при >5 шагах +// ============================================================================ + +describe('KIN-UI-028 Feature 2: вертикальный timeline при >5 шагах', () => { + function make6Steps(successVal: boolean | number | null = true): Record[] { + return Array.from({ length: 6 }, (_, i) => makeStep(i + 1, 'frontend_dev', successVal)) + } + + it('при 6 шагах рендерится chevron ▼ вертикального timeline', async () => { + const wrapper = await mountTask(makeTask({ status: 'done', pipeline_steps: make6Steps() })) + expect(wrapper.html()).toContain('▼') + }) + + it('при 6 шагах НЕ рендерится горизонтальный overflow-x-auto', async () => { + const wrapper = await mountTask(makeTask({ status: 'done', pipeline_steps: make6Steps() })) + expect(wrapper.html()).not.toContain('overflow-x-auto') + }) + + it('вертикальный timeline отображает имя роли агента', async () => { + const steps = [ + makeStep(1, 'pm', true), makeStep(2, 'backend_dev', true), makeStep(3, 'frontend_dev', true), + makeStep(4, 'tester', true), makeStep(5, 'reviewer', true), makeStep(6, 'security', true), + ] + const wrapper = await mountTask(makeTask({ status: 'done', pipeline_steps: steps })) + expect(wrapper.html()).toContain('pm') + expect(wrapper.html()).toContain('backend_dev') + expect(wrapper.html()).toContain('security') + }) + + it('вертикальный timeline отображает duration_seconds', async () => { + const steps = Array.from({ length: 6 }, (_, i) => makeStep(i + 1, 'pm', true, { duration_seconds: 77 })) + const wrapper = await mountTask(makeTask({ status: 'done', pipeline_steps: steps })) + expect(wrapper.html()).toContain('77s') + }) + + it('вертикальный timeline отображает cost_usd', async () => { + const steps = Array.from({ length: 6 }, (_, i) => makeStep(i + 1, 'pm', true, { cost_usd: 0.042 })) + const wrapper = await mountTask(makeTask({ status: 'done', pipeline_steps: steps })) + expect(wrapper.html()).toContain('$0.042') + }) +}) + +// ============================================================================ +// Feature 2: Вертикальный timeline — иконки success/fail/running +// ============================================================================ + +describe('KIN-UI-028 Feature 2: verticalStepIcon и verticalStepIconColor — статическая проверка', () => { + it('функция verticalStepIcon покрывает success === true', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../views/TaskDetail.vue'), 'utf-8') + expect(source).toContain('step.success === true') + }) + + it('функция verticalStepIcon покрывает success === 1 (number)', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../views/TaskDetail.vue'), 'utf-8') + expect(source).toContain('step.success === 1') + }) + + it('функция verticalStepIcon покрывает success === false', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../views/TaskDetail.vue'), 'utf-8') + expect(source).toContain('step.success === false') + }) + + it('функция verticalStepIcon покрывает success === 0 (number)', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../views/TaskDetail.vue'), 'utf-8') + expect(source).toContain('step.success === 0') + }) + + it('функция verticalStepIconColor возвращает text-green-400 для успеха', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../views/TaskDetail.vue'), 'utf-8') + expect(source).toContain('text-green-400') + }) + + it('функция verticalStepIconColor возвращает text-red-400 для провала', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../views/TaskDetail.vue'), 'utf-8') + expect(source).toContain('text-red-400') + }) + + it('функция verticalStepIconColor возвращает text-blue-400 для running/null', () => { + const source = fs.readFileSync(path.resolve(__dirname, '../views/TaskDetail.vue'), 'utf-8') + expect(source).toContain('text-blue-400') + }) +}) + +describe('KIN-UI-028 Feature 2: вертикальный timeline — CSS-классы иконок в рендере', () => { + it('шаги с success=true рендерят text-green-400', async () => { + const steps = Array.from({ length: 6 }, (_, i) => makeStep(i + 1, 'pm', true)) + const wrapper = await mountTask(makeTask({ status: 'done', pipeline_steps: steps })) + expect(wrapper.html()).toContain('text-green-400') + }) + + it('шаги с success=false рендерят text-red-400', async () => { + const steps = Array.from({ length: 6 }, (_, i) => makeStep(i + 1, 'pm', false)) + const wrapper = await mountTask(makeTask({ status: 'done', pipeline_steps: steps })) + expect(wrapper.html()).toContain('text-red-400') + }) + + it('шаги с success=null рендерят text-blue-400', async () => { + const steps = Array.from({ length: 6 }, (_, i) => makeStep(i + 1, 'pm', null)) + const wrapper = await mountTask(makeTask({ status: 'done', pipeline_steps: steps })) + expect(wrapper.html()).toContain('text-blue-400') + }) +}) + +// ============================================================================ +// Feature 2: Вертикальный timeline — chevron и раскрытие шага +// ============================================================================ + +describe('KIN-UI-028 Feature 2: вертикальный timeline — chevron и раскрытие', () => { + it('шаги изначально свёрнуты — chevron ▼ присутствует', async () => { + const steps = Array.from({ length: 6 }, (_, i) => makeStep(i + 1, 'pm', true)) + const wrapper = await mountTask(makeTask({ status: 'done', pipeline_steps: steps })) + expect(wrapper.html()).toContain('▼') + expect(wrapper.html()).not.toContain('▲') + }) + + it('клик по шагу раскрывает его — chevron ▲ появляется', async () => { + const steps = Array.from({ length: 6 }, (_, i) => + makeStep(i + 1, 'pm', true, { output_summary: 'Summary output' }) + ) + const wrapper = await mountTask(makeTask({ status: 'done', pipeline_steps: steps })) + + // Find first row containing ▼ and click it + const allDivs = wrapper.findAll('div') + const stepRow = allDivs.find(d => d.classes().includes('cursor-pointer') && d.text().includes('▼')) + expect(stepRow).toBeDefined() + await stepRow!.trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.html()).toContain('▲') + }) + + it('раскрытый шаг показывает содержимое output_summary', async () => { + const steps = Array.from({ length: 6 }, (_, i) => + makeStep(i + 1, 'pm', true, { output_summary: 'UniqueOutputMarker9z' }) + ) + const wrapper = await mountTask(makeTask({ status: 'done', pipeline_steps: steps })) + + const allDivs = wrapper.findAll('div') + const stepRow = allDivs.find(d => d.classes().includes('cursor-pointer') && d.text().includes('▼')) + expect(stepRow).toBeDefined() + await stepRow!.trigger('click') + await wrapper.vm.$nextTick() + + expect(wrapper.html()).toContain('UniqueOutputMarker9z') + }) +})