From cc592bfbbca8e79fe1682aa99edd8ac17030a6a3 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Mon, 16 Mar 2026 10:59:09 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20KIN-078=20=D0=9A=D0=B0=D0=BD=D0=B1=D0=B0?= =?UTF-8?q?=D0=BD=20=D0=B4=D0=BE=D1=81=D0=BA=D0=B0=20=D0=BD=D0=B5=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B0=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B2=20=D0=B2=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=83=D1=8E?= =?UTF-8?q?=20=D1=88=D0=B8=D1=80=D0=B8=D0=BD=D1=83=20=D1=8D=D0=BA=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B0.=20=D0=9F=D1=80=D0=BE=D0=B2=D0=B5=D1=80?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=B1=D1=8B=D0=BB=20=D0=BB=D0=B8=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B7=D0=B2=D0=B0=D0=BD=20=D1=85=D1=83=D0=BA=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B2=D1=8B=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B8.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/frontend/src/__tests__/kanban.test.ts | 150 ++++++++++++++++++++++ web/frontend/src/views/ProjectView.vue | 5 +- 2 files changed, 153 insertions(+), 2 deletions(-) diff --git a/web/frontend/src/__tests__/kanban.test.ts b/web/frontend/src/__tests__/kanban.test.ts index 2fd1c58..75163ac 100644 --- a/web/frontend/src/__tests__/kanban.test.ts +++ b/web/frontend/src/__tests__/kanban.test.ts @@ -646,3 +646,153 @@ describe('KIN-075: канбан — кнопки управления', () => { expect(wrapper.text()).toContain('Add Task') }) }) + +// ───────────────────────────────────────────────────────────── +// KIN-078: полная ширина канбан-доски (нет max-w ограничений) +// ───────────────────────────────────────────────────────────── + +describe('KIN-078: канбан — flex layout без ограничений ширины', () => { + it('Flex-контейнер колонок имеет класс w-full', async () => { + const wrapper = await mountOnKanban() + + const flexContainer = wrapper.find('.flex.gap-3.w-full') + expect(flexContainer.exists(), 'flex gap-3 w-full контейнер должен существовать').toBe(true) + expect(flexContainer.classes()).toContain('w-full') + }) + + it('Flex-контейнер колонок не содержит inline style min-width: max-content', async () => { + const wrapper = await mountOnKanban() + + const flexContainer = wrapper.find('.flex.gap-3.w-full') + const style = flexContainer.element.getAttribute('style') + expect(style ?? '').not.toContain('min-width') + }) + + it('Каждая из 5 колонок имеет flex-1 (растягивается), а не фиксированный w-64', async () => { + const wrapper = await mountOnKanban() + + // KANBAN_COLUMNS — 5 колонок, все должны иметь flex-1 + const allFlex1 = wrapper.findAll('div').filter(d => d.classes().includes('flex-1') && d.classes().includes('flex-col')) + expect(allFlex1.length, '5 колонок с flex-1 flex-col должны быть').toBe(5) + + for (const col of allFlex1) { + expect(col.classes(), 'Колонка не должна иметь фиксированный w-64').not.toContain('w-64') + } + }) + + it('Каждая из 5 колонок имеет min-w-[12rem] (минимальная ширина)', async () => { + const wrapper = await mountOnKanban() + + const columns = wrapper.findAll('div').filter(d => + d.classes().includes('flex-1') && d.classes().includes('flex-col') + ) + expect(columns.length).toBe(5) + + for (const col of columns) { + expect(col.classes(), 'Колонка должна иметь min-w-[12rem]').toContain('min-w-[12rem]') + } + }) +}) + +// ───────────────────────────────────────────────────────────── +// KIN-078: runTask → запуск polling на kanban-вкладке +// ───────────────────────────────────────────────────────────── + +describe('KIN-078: runTask → polling на kanban-вкладке', () => { + it('runTask запускает polling если пользователь переключился на kanban пока задача выполнялась', async () => { + vi.useFakeTimers() + + // Изначально нет in_progress задач → переключение на kanban не запускает polling + const projectNoPending = { + ...MOCK_PROJECT, + tasks: MOCK_PROJECT.tasks.filter(t => t.status !== 'in_progress'), + } + vi.mocked(api.project).mockResolvedValue(projectNoPending as any) + + // Откладываем api.runTask — имитируем задержку + let resolveRun!: () => void + vi.mocked(api.runTask).mockReturnValue(new Promise(res => { resolveRun = () => res() }) as any) + vi.spyOn(window, 'confirm').mockReturnValue(true) + + const router = makeRouter() + await router.push('/project/KIN') + const wrapper = mount(ProjectView, { + props: { id: 'KIN' }, + global: { plugins: [router] }, + }) + await flushPromises() + + // Кликаем ▶ для KIN-001 (pending) на вкладке Tasks — runTask подвисает на await api.runTask + const runBtn = wrapper.find('button[title="Run pipeline"]') + expect(runBtn.exists(), '▶ кнопка должна быть на Tasks вкладке').toBe(true) + await runBtn.trigger('click') + + // Пока runTask ждёт — переключаемся на kanban (нет in_progress → polling не стартует) + const kanbanTab = wrapper.findAll('button').find(b => + b.classes().includes('border-b-2') && b.text().includes('Kanban') + )! + await kanbanTab.trigger('click') + await flushPromises() + + // Убеждаемся что polling не запустился (нет in_progress задач) + await vi.advanceTimersByTimeAsync(5000) + await flushPromises() + const callsBefore = vi.mocked(api.project).mock.calls.length + + // Настраиваем следующий load() чтобы вернуть проект с in_progress задачами + vi.mocked(api.project).mockResolvedValue(MOCK_PROJECT as any) + + // Завершаем api.runTask → runTask продолжает: load() → checkAndPollKanban() + resolveRun() + await flushPromises() + + const callsAfterLoad = vi.mocked(api.project).mock.calls.length + expect(callsAfterLoad, 'load() должен вызвать api.project').toBeGreaterThan(callsBefore) + + // Продвигаем время на 5с → polling-тик должен сработать + await vi.advanceTimersByTimeAsync(5000) + await flushPromises() + + expect( + vi.mocked(api.project).mock.calls.length, + 'Polling должен запуститься после runTask когда activeTab === kanban', + ).toBeGreaterThan(callsAfterLoad) + }) + + it('runTask не запускает polling если activeTab !== kanban в момент завершения', async () => { + vi.useFakeTimers() + vi.mocked(api.runTask).mockResolvedValue(undefined as any) + vi.spyOn(window, 'confirm').mockReturnValue(true) + + // Нет in_progress задач → ни через watcher, ни через runTask polling не стартует + const projectNoPending = { + ...MOCK_PROJECT, + tasks: MOCK_PROJECT.tasks.filter(t => t.status !== 'in_progress'), + } + vi.mocked(api.project).mockResolvedValue(projectNoPending as any) + + const router = makeRouter() + await router.push('/project/KIN') + const wrapper = mount(ProjectView, { + props: { id: 'KIN' }, + global: { plugins: [router] }, + }) + await flushPromises() + + // Остаёмся на Tasks вкладке (activeTab === 'tasks') и кликаем ▶ + const runBtn = wrapper.find('button[title="Run pipeline"]') + await runBtn.trigger('click') + await flushPromises() + + const callsAfterRun = vi.mocked(api.project).mock.calls.length + + // Продвигаем время — polling не должен запуститься (мы на tasks, нет in_progress) + await vi.advanceTimersByTimeAsync(5000) + await flushPromises() + + expect( + vi.mocked(api.project).mock.calls.length, + 'Polling не должен запуститься когда activeTab !== kanban', + ).toBe(callsAfterRun) + }) +}) diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 126ded5..b4bed48 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -385,6 +385,7 @@ async function runTask(taskId: string, event: Event) { try { await api.runTask(taskId) await load() + if (activeTab.value === 'kanban') checkAndPollKanban() } catch (e: any) { error.value = e.message } @@ -883,8 +884,8 @@ async function addDecision() {
-
-
+
+
{{ col.label }}