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() { - + + + + + ✕ + + + + {{ autoMode ? '🔓 Авто' : '🔒 Review' }} + + + {{ autocommit ? '✓ Автокомит' : 'Автокомит' }} + + + + {{ auditLoading ? 'Auditing...' : 'Аудит' }} + + + + Тас + + + + @@ -860,6 +918,7 @@ async function addDecision() { +