diff --git a/web/frontend/src/__tests__/execution-mode-unification.test.ts b/web/frontend/src/__tests__/execution-mode-unification.test.ts index 25ea209..d968531 100644 --- a/web/frontend/src/__tests__/execution-mode-unification.test.ts +++ b/web/frontend/src/__tests__/execution-mode-unification.test.ts @@ -176,13 +176,14 @@ describe('KIN-FIX-002: execution_mode унификация на "auto_complete"' }) await flushPromises() - // Найти и кликнуть кнопку тоггла режима - const toggleBtn = wrapper.findAll('button').find(b => - b.text().includes('Auto') || b.text().includes('Review') - ) + // Открываем ⚙ Mode меню и кликаем по кнопке авто/ревью + const trigger = wrapper.find('[data-testid="mode-menu-trigger"]') + if (trigger.exists()) { + await trigger.trigger('click') + await flushPromises() - if (toggleBtn) { - await toggleBtn.trigger('click') + const autoBtn = wrapper.find('[data-testid="mode-toggle-auto"]') + await autoBtn.trigger('click') await flushPromises() // Проверяем, что localStorage содержит 'auto_complete', не 'auto' @@ -547,11 +548,10 @@ describe('KIN-097: runTask синхронизирует execution_mode с тог }) await flushPromises() - // Тоггл должен показывать Auto - const toggleBtn = wrapper.findAll('button').find(b => - b.text().includes('Auto') || b.text().includes('Review') - ) - expect(toggleBtn!.text()).toContain('Auto') + // Триггер ⚙ Mode должен иметь data-mode="auto" при execution_mode=auto_complete + const trigger = wrapper.find('[data-testid="mode-menu-trigger"]') + expect(trigger.exists()).toBe(true) + expect(trigger.attributes('data-mode')).toBe('auto') // DB переключается на review (например, другой клиент изменил режим) vi.mocked(api.project).mockResolvedValue( @@ -561,17 +561,14 @@ describe('KIN-097: runTask синхронизирует execution_mode с тог // После load() тоггл должен обновиться на Review // Имитируем внешний load (например, после создания задачи) vi.mocked(api.patchProject).mockResolvedValue({ execution_mode: 'review' } as any) - // Триггерим reload через toggleAutocommit (который вызывает patchProject, но не load) - // Вместо этого напрямую проверим что при новом mount с review — кнопка Review + // Вместо этого напрямую проверим что при новом mount с review — data-mode="review" const wrapper2 = mount(ProjectView, { props: { id: 'KIN' }, global: { plugins: [router] }, }) await flushPromises() - const toggleBtn2 = wrapper2.findAll('button').find(b => - b.text().includes('Auto') || b.text().includes('Review') - ) - expect(toggleBtn2!.text()).toContain('Review') + const trigger2 = wrapper2.find('[data-testid="mode-menu-trigger"]') + expect(trigger2.attributes('data-mode')).toBe('review') }) }) diff --git a/web/frontend/src/__tests__/filter-persistence.test.ts b/web/frontend/src/__tests__/filter-persistence.test.ts index b313950..7f0e95c 100644 --- a/web/frontend/src/__tests__/filter-persistence.test.ts +++ b/web/frontend/src/__tests__/filter-persistence.test.ts @@ -517,7 +517,14 @@ describe('KIN-047: TaskDetail — Approve/Reject в статусе review', () = // ───────────────────────────────────────────────────────────── describe('KIN-065: ProjectView — Autocommit toggle', () => { - it('Кнопка Autocommit присутствует в DOM', async () => { + async function openModeMenu(wrapper: ReturnType) { + const trigger = wrapper.find('[data-testid="mode-menu-trigger"]') + await trigger.trigger('click') + await flushPromises() + return wrapper.find('[data-testid="mode-toggle-autocommit"]') + } + + it('Кнопка Autocommit присутствует в меню ⚙ Mode', async () => { const router = makeRouter() await router.push('/project/KIN') @@ -527,8 +534,8 @@ describe('KIN-065: ProjectView — Autocommit toggle', () => { }) await flushPromises() - const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) - expect(btn?.exists()).toBe(true) + const btn = await openModeMenu(wrapper) + expect(btn.exists()).toBe(true) }) it('Кнопка имеет title "Autocommit: off" когда autocommit_enabled=0', async () => { @@ -542,8 +549,8 @@ describe('KIN-065: ProjectView — Autocommit toggle', () => { }) await flushPromises() - const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) - expect(btn?.attributes('title')).toBe('Autocommit: off') + const btn = await openModeMenu(wrapper) + expect(btn.attributes('title')).toBe('Autocommit: off') }) it('Кнопка имеет title "Autocommit: on..." когда autocommit_enabled=1', async () => { @@ -557,8 +564,8 @@ describe('KIN-065: ProjectView — Autocommit toggle', () => { }) await flushPromises() - const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) - expect(btn?.attributes('title')).toContain('Autocommit: on') + const btn = await openModeMenu(wrapper) + expect(btn.attributes('title')).toContain('Autocommit: on') }) it('Клик по кнопке вызывает patchProject с autocommit_enabled=true (включение)', async () => { @@ -574,8 +581,8 @@ describe('KIN-065: ProjectView — Autocommit toggle', () => { }) await flushPromises() - const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) - await btn!.trigger('click') + const btn = await openModeMenu(wrapper) + await btn.trigger('click') await flushPromises() expect(api.patchProject).toHaveBeenCalledWith('KIN', { autocommit_enabled: true }) @@ -594,8 +601,8 @@ describe('KIN-065: ProjectView — Autocommit toggle', () => { }) await flushPromises() - const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) - await btn!.trigger('click') + const btn = await openModeMenu(wrapper) + await btn.trigger('click') await flushPromises() expect(api.patchProject).toHaveBeenCalledWith('KIN', { autocommit_enabled: false }) @@ -616,8 +623,8 @@ describe('KIN-065: ProjectView — Autocommit toggle', () => { }) await flushPromises() - const btn = wrapper.findAll('button').find(b => b.text().includes('Autocommit')) - await btn!.trigger('click') + const btn = await openModeMenu(wrapper) + await btn.trigger('click') await flushPromises() // Catch-блок установил error.value → компонент показывает сообщение об ошибке 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') + }) +}) diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 81f7f33..c1b17f9 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -15,6 +15,8 @@ const project = ref(null) const loading = ref(true) const error = ref('') const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban' | 'environments' | 'links' | 'settings'>('tasks') +const showModeMenu = ref(false) +const showMoreMenu = ref(false) // Phases const phases = ref([]) @@ -172,6 +174,34 @@ function phaseStatusColor(s: string) { return m[s] || 'gray' } +// Tab groups +const PRIMARY_TABS = ['tasks', 'kanban', 'phases', 'decisions'] as const +const MORE_TABS = ['modules', 'environments', 'links', 'settings'] as const + +function tabLabel(tab: string): string { + const labels: Record = { + tasks: t('projectView.tasks_tab'), + phases: t('projectView.phases_tab'), + decisions: t('projectView.decisions_tab'), + modules: t('projectView.modules_tab'), + kanban: t('projectView.kanban_tab'), + environments: t('projectView.environments'), + links: t('projectView.links_tab'), + settings: t('projectView.settings_tab'), + } + return labels[tab] ?? tab +} + +function tabCount(tab: string): string | number { + if (tab === 'tasks' || tab === 'kanban') return project.value!.tasks.length + if (tab === 'phases') return phases.value.length + if (tab === 'decisions') return project.value!.decisions.length + if (tab === 'modules') return project.value!.modules.length + if (tab === 'environments') return environments.value.length + if (tab === 'links') return links.value.length + return '' +} + // Filters const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'revising', 'cancelled'] @@ -278,6 +308,8 @@ async function toggleWorktrees() { } } +const anyModeActive = computed(() => autoMode.value || autocommit.value || autoTest.value || worktrees.value) + // Settings form const settingsForm = ref({ execution_mode: 'review', @@ -1072,24 +1104,38 @@ async function addDecision() {
- + +
+
+ +
+ +
+
@@ -1130,38 +1176,52 @@ async function addDecision() { class="px-1.5 py-0.5 text-xs text-gray-600 hover:text-red-400 rounded">✕
- - - - + +
+
+ +
+ + + + +
+