From 394301c7a7a3306a234ee8aaf993dad4eca419f5 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Mon, 16 Mar 2026 10:28:06 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20KIN-075=20=D0=A0=D0=B0=D1=81=D1=88=D0=B8?= =?UTF-8?q?=D1=80=D0=B8=D1=82=D1=8C=20=D0=BA=D0=B0=D0=BD=D0=B1=D0=B0=D0=BD?= =?UTF-8?q?-=D0=B2=D0=B8=D0=B4=20=D0=B4=D0=BE=20=D1=88=D0=B8=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D0=B0,=20=D1=81?= =?UTF-8?q?=D0=B5=D0=B9=D1=87=D0=B0=D1=81=20=D0=BE=D0=BD=20=D0=BE=D0=B3?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=87=D0=B5=D0=BD=20=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BC.=20+=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=A2=D0=B0=D1=81=20=D0=90=D1=83=D0=B4=D0=B8=D1=82=20=D0=90?= =?UTF-8?q?=D0=B2=D1=82=D0=BE=D0=BA=D0=BE=D0=BC=D0=B8=D1=82=20=D0=90=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=20=D0=B2=20=D0=BA=D0=B0=D0=BD=D0=B1=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=B2=D0=B8=D0=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/frontend/src/App.vue | 2 +- web/frontend/src/__tests__/app-layout.test.ts | 55 ++++++++++++ web/frontend/src/__tests__/kanban.test.ts | 85 +++++++++++++++++++ web/frontend/src/views/ProjectView.vue | 69 +++++++++++++-- 4 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 web/frontend/src/__tests__/app-layout.test.ts diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue index 0186755..df4e357 100644 --- a/web/frontend/src/App.vue +++ b/web/frontend/src/App.vue @@ -14,7 +14,7 @@ import EscalationBanner from './components/EscalationBanner.vue' multi-agent orchestrator -
+
diff --git a/web/frontend/src/__tests__/app-layout.test.ts b/web/frontend/src/__tests__/app-layout.test.ts new file mode 100644 index 0000000..41558f1 --- /dev/null +++ b/web/frontend/src/__tests__/app-layout.test.ts @@ -0,0 +1,55 @@ +/** + * KIN-075: Тест полной ширины экрана + * Проверяет что App.vue не ограничивает ширину контента — нет max-w-* на
+ */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import App from '../App.vue' + +vi.mock('../components/EscalationBanner.vue', () => ({ + default: { template: '
' }, +})) + +function makeRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: { template: '
home
' } }, + { path: '/settings', component: { template: '
settings
' } }, + ], + }) +} + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('KIN-075: App.vue — полная ширина экрана', () => { + it('
не содержит класс max-w-* — контент не ограничен по ширине', async () => { + const router = makeRouter() + await router.push('/') + + const wrapper = mount(App, { global: { plugins: [router] } }) + await flushPromises() + + const main = wrapper.find('main') + expect(main.exists(), '
должен существовать в App.vue').toBe(true) + expect( + main.classes().some(c => c.startsWith('max-w-')), + '
не должен иметь ограничивающий класс max-w-*', + ).toBe(false) + }) + + it('
не содержит класс max-w-6xl (регрессия KIN-075)', async () => { + const router = makeRouter() + await router.push('/') + + const wrapper = mount(App, { global: { plugins: [router] } }) + await flushPromises() + + const main = wrapper.find('main') + expect(main.classes()).not.toContain('max-w-6xl') + }) +}) diff --git a/web/frontend/src/__tests__/kanban.test.ts b/web/frontend/src/__tests__/kanban.test.ts index bf79a4c..2fd1c58 100644 --- a/web/frontend/src/__tests__/kanban.test.ts +++ b/web/frontend/src/__tests__/kanban.test.ts @@ -561,3 +561,88 @@ describe('KIN-UI-001: регрессии — другие вкладки не с expect(wrapper.text()).toContain('No modules') }) }) + +// ───────────────────────────────────────────────────────────── +// KIN-075: кнопки управления в канбан-виде +// ───────────────────────────────────────────────────────────── + +describe('KIN-075: канбан — кнопки управления', () => { + it('Кнопка переключения режима (Авто/Review) присутствует в канбан-виде', async () => { + const wrapper = await mountOnKanban() + // MOCK_PROJECT.execution_mode = 'review' → autoMode=false → title = 'Review mode: agents read-only' + const modeBtn = wrapper.find('button[title*="mode:"]') + expect(modeBtn.exists()).toBe(true) + }) + + it('Кнопка Автокомит присутствует в канбан-виде', async () => { + const wrapper = await mountOnKanban() + // MOCK_PROJECT.autocommit_enabled = 0 → autocommit=false → title = 'Autocommit: off' + const btn = wrapper.find('button[title*="Autocommit:"]') + expect(btn.exists()).toBe(true) + expect(btn.text()).toMatch(/Автокомит/) + }) + + it('Кнопка Аудит присутствует в канбан-виде', async () => { + const wrapper = await mountOnKanban() + const btn = wrapper.find('button[title="Check which pending tasks are already done"]') + expect(btn.exists()).toBe(true) + expect(btn.text()).toContain('Аудит') + }) + + it('Кнопка "+ Тас" присутствует в канбан-виде', async () => { + const wrapper = await mountOnKanban() + const tasBtn = wrapper.findAll('button').find(b => b.text() === '+ Тас') + expect(tasBtn?.exists()).toBe(true) + }) + + it('Клик на кнопку режима вызывает api.patchProject с execution_mode', async () => { + vi.mocked(api.patchProject).mockResolvedValue(undefined as any) + const wrapper = await mountOnKanban() + + const modeBtn = wrapper.find('button[title*="mode:"]') + await modeBtn.trigger('click') + await flushPromises() + + expect(vi.mocked(api.patchProject)).toHaveBeenCalledWith( + 'KIN', + expect.objectContaining({ execution_mode: expect.any(String) }), + ) + }) + + it('Клик на кнопку Автокомит вызывает api.patchProject с autocommit_enabled', async () => { + vi.mocked(api.patchProject).mockResolvedValue(undefined as any) + const wrapper = await mountOnKanban() + + const btn = wrapper.find('button[title*="Autocommit:"]') + await btn.trigger('click') + await flushPromises() + + expect(vi.mocked(api.patchProject)).toHaveBeenCalledWith( + 'KIN', + expect.objectContaining({ autocommit_enabled: expect.anything() }), + ) + }) + + it('Клик на кнопку Аудит вызывает api.auditProject', async () => { + vi.mocked(api.auditProject).mockResolvedValue({ + success: true, already_done: [], still_pending: [], unclear: [], + } as any) + const wrapper = await mountOnKanban() + + const auditBtn = wrapper.find('button[title="Check which pending tasks are already done"]') + await auditBtn.trigger('click') + await flushPromises() + + expect(vi.mocked(api.auditProject)).toHaveBeenCalledWith('KIN') + }) + + it('Клик на "+ Тас" открывает модальное окно Add Task', async () => { + const wrapper = await mountOnKanban() + + const tasBtn = wrapper.findAll('button').find(b => b.text() === '+ Тас')! + await tasBtn.trigger('click') + await flushPromises() + + expect(wrapper.text()).toContain('Add Task') + }) +}) diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 6b6b414..126ded5 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -149,6 +149,7 @@ function initStatusFilter(): string[] { const selectedStatuses = ref(initStatusFilter()) const selectedCategory = ref('') +const taskSearch = ref('') function toggleStatus(s: string) { const idx = selectedStatuses.value.indexOf(s) @@ -271,6 +272,10 @@ watch(selectedStatuses, (val) => { router.replace({ query: { ...route.query, status: val.length ? val.join(',') : undefined } }) }, { deep: true }) +watch(() => props.id, () => { + taskSearch.value = '' +}) + onMounted(async () => { await load() loadMode() @@ -289,9 +294,19 @@ const taskCategories = computed(() => { return Array.from(cats).sort() }) -const filteredTasks = computed(() => { +const searchFilteredTasks = computed(() => { if (!project.value) return [] - let tasks = project.value.tasks + const q = taskSearch.value.trim().toLowerCase() + if (!q) return project.value.tasks + return project.value.tasks.filter(t => { + if (t.title.toLowerCase().includes(q)) return true + if (t.brief && JSON.stringify(t.brief).toLowerCase().includes(q)) return true + return false + }) +}) + +const filteredTasks = computed(() => { + let tasks = searchFilteredTasks.value if (selectedStatuses.value.length > 0) tasks = tasks.filter(t => selectedStatuses.value.includes(t.status)) if (selectedCategory.value) tasks = tasks.filter(t => t.category === selectedCategory.value) return tasks @@ -401,10 +416,9 @@ const dragOverStatus = ref(null) let kanbanPollTimer: ReturnType | null = null const kanbanTasksByStatus = computed(() => { - if (!project.value) return {} as Record const result: Record = {} for (const col of KANBAN_COLUMNS) result[col.status] = [] - for (const t of project.value.tasks) { + for (const t of searchFilteredTasks.value) { if (result[t.status]) result[t.status].push(t) } return result @@ -604,6 +618,13 @@ async function addDecision() {
+ +
+ + +
@@ -824,7 +845,44 @@ async function addDecision() {
-
+
+
+
+ + +
+
+ + + + +
+
+
@@ -860,6 +918,7 @@ async function addDecision() {
+