From c14c0b7832cda1083b869992303d515656981a2c Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Mon, 16 Mar 2026 10:29:38 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20KIN-076=20=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BF=D0=BE=D0=BB=D0=B5?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=87=D0=B0=D0=BC.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/__tests__/task-search.test.ts | 288 ++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 web/frontend/src/__tests__/task-search.test.ts diff --git a/web/frontend/src/__tests__/task-search.test.ts b/web/frontend/src/__tests__/task-search.test.ts new file mode 100644 index 0000000..42aabc6 --- /dev/null +++ b/web/frontend/src/__tests__/task-search.test.ts @@ -0,0 +1,288 @@ +/** + * KIN-076: Тесты поля поиска по задачам в ProjectView + * + * Проверяет: + * 1. Поиск по слову из title — задача видна, остальные скрыты + * 2. Поиск по несуществующему слову — список задач пустой + * 3. Очистка поля поиска — все задачи снова видны + * 4. Поиск регистронезависим + * 5. Поиск по содержимому brief + * 6. Кнопка ✕ очищает поиск + * 7. При смене props.id поисковый запрос сбрасывается + * 8. Поле поиска присутствует на Kanban вкладке + * 9. Поиск на Kanban фильтрует задачи в колонках + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { createRouter, createMemoryHistory } from 'vue-router' +import ProjectView from '../views/ProjectView.vue' + +vi.mock('../api', () => ({ + api: { + project: vi.fn(), + taskFull: vi.fn(), + runTask: vi.fn(), + auditProject: vi.fn(), + createTask: vi.fn(), + patchTask: vi.fn(), + patchProject: vi.fn(), + deployProject: vi.fn(), + getPhases: vi.fn(), + }, +})) + +import { api } from '../api' + +const Stub = { template: '
' } + +function makeTask(id: string, title: string, brief: unknown = null) { + return { + id, + project_id: 'KIN', + title, + status: 'pending', + priority: 5, + assigned_role: null, + parent_task_id: null, + brief, + spec: null, + execution_mode: null, + blocked_reason: null, + dangerously_skipped: null, + category: null, + acceptance_criteria: null, + created_at: '2024-01-01', + updated_at: '2024-01-01', + } +} + +const MOCK_PROJECT = { + id: 'KIN', + name: 'Kin', + path: '/projects/kin', + status: 'active', + priority: 5, + tech_stack: ['python', 'vue'], + execution_mode: 'review', + autocommit_enabled: 0, + obsidian_vault_path: null, + deploy_command: null, + created_at: '2024-01-01', + total_tasks: 3, + done_tasks: 0, + active_tasks: 1, + blocked_tasks: 0, + review_tasks: 0, + project_type: 'development', + ssh_host: null, + ssh_user: null, + ssh_key_path: null, + ssh_proxy_jump: null, + description: null, + tasks: [ + makeTask('KIN-001', 'Реализовать поле поиска'), + makeTask('KIN-002', 'Добавить аутентификацию'), + makeTask('KIN-003', 'Исправить баг в отчётах', { text: 'текст с ключевым словом oauth' }), + ], + decisions: [], + modules: [], +} + +// localStorage mock +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 }) + +function makeRouter() { + return createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/', component: Stub }, + { path: '/project/:id', component: ProjectView, props: true }, + ], + }) +} + +// Convention #162: beforeEach с vi.clearAllMocks() + deep clone mock-объектов +beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + vi.mocked(api.project).mockResolvedValue(JSON.parse(JSON.stringify(MOCK_PROJECT)) as any) + vi.mocked(api.getPhases).mockResolvedValue([]) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +async function mountOnTasks() { + const router = makeRouter() + await router.push('/project/KIN') + const wrapper = mount(ProjectView, { + props: { id: 'KIN' }, + global: { plugins: [router] }, + }) + await flushPromises() + return wrapper +} + +// ───────────────────────────────────────────────────────────── +// Tasks tab: acceptance criteria (обязательные тесты KIN-076) +// ───────────────────────────────────────────────────────────── + +describe('KIN-076: поиск задач — Tasks вкладка (acceptance criteria)', () => { + it('1. Поиск по слову из title — задача видна, остальные скрыты', async () => { + const wrapper = await mountOnTasks() + + const searchInput = wrapper.find('input[placeholder="Поиск по задачам..."]') + expect(searchInput.exists(), 'Поле поиска должно быть в DOM').toBe(true) + + await searchInput.setValue('поиска') + await flushPromises() + + const hrefs = wrapper.findAll('a[href^="/task/"]').map(l => l.attributes('href')) + expect(hrefs, 'KIN-001 должен быть виден').toContain('/task/KIN-001') + expect(hrefs, 'KIN-002 не должен быть виден').not.toContain('/task/KIN-002') + expect(hrefs, 'KIN-003 не должен быть виден').not.toContain('/task/KIN-003') + }) + + it('2. Поиск по несуществующему слову — список задач пустой', async () => { + const wrapper = await mountOnTasks() + + const searchInput = wrapper.find('input[placeholder="Поиск по задачам..."]') + await searchInput.setValue('несуществующеесловоxyz123') + await flushPromises() + + const links = wrapper.findAll('a[href^="/task/"]') + expect(links, 'При несуществующем слове список задач должен быть пустым').toHaveLength(0) + }) + + it('3. Очистка поля поиска — все задачи снова видны', async () => { + const wrapper = await mountOnTasks() + + const searchInput = wrapper.find('input[placeholder="Поиск по задачам..."]') + + // Фильтруем задачи поиском + await searchInput.setValue('аутентификацию') + await flushPromises() + expect(wrapper.findAll('a[href^="/task/"]'), 'Фильтрация должна работать').toHaveLength(1) + + // Очищаем поле — возвращаем все задачи + await searchInput.setValue('') + await flushPromises() + expect(wrapper.findAll('a[href^="/task/"]'), 'После очистки должны быть все 3 задачи').toHaveLength(3) + }) +}) + +// ───────────────────────────────────────────────────────────── +// Tasks tab: дополнительные сценарии +// ───────────────────────────────────────────────────────────── + +describe('KIN-076: поиск задач — дополнительные сценарии', () => { + it('4. Поиск регистронезависим — заглавные буквы находят строчные', async () => { + const wrapper = await mountOnTasks() + + const searchInput = wrapper.find('input[placeholder="Поиск по задачам..."]') + await searchInput.setValue('ПОИСКА') + await flushPromises() + + const hrefs = wrapper.findAll('a[href^="/task/"]').map(l => l.attributes('href')) + expect(hrefs, 'KIN-001 должен находиться независимо от регистра').toContain('/task/KIN-001') + }) + + it('5. Поиск по содержимому brief — задача с совпадением в brief видна', async () => { + const wrapper = await mountOnTasks() + + const searchInput = wrapper.find('input[placeholder="Поиск по задачам..."]') + await searchInput.setValue('oauth') + await flushPromises() + + const hrefs = wrapper.findAll('a[href^="/task/"]').map(l => l.attributes('href')) + expect(hrefs, 'KIN-003 должна быть найдена по brief').toContain('/task/KIN-003') + expect(hrefs, 'KIN-001 не совпадает — не должен быть виден').not.toContain('/task/KIN-001') + expect(hrefs, 'KIN-002 не совпадает — не должен быть виден').not.toContain('/task/KIN-002') + }) + + it('6. Кнопка ✕ очищает поиск и показывает все задачи', async () => { + const wrapper = await mountOnTasks() + + const searchInput = wrapper.find('input[placeholder="Поиск по задачам..."]') + await searchInput.setValue('поиска') + await flushPromises() + + // Кнопка ✕ появляется при непустом поиске + const clearBtn = wrapper.findAll('button').find(b => b.text().trim() === '✕') + expect(clearBtn?.exists(), 'Кнопка ✕ должна быть видна при непустом поиске').toBe(true) + + await clearBtn!.trigger('click') + await flushPromises() + + expect(wrapper.findAll('a[href^="/task/"]'), 'После ✕ — все задачи снова видны').toHaveLength(3) + }) + + it('7. При смене props.id поисковый запрос сбрасывается', async () => { + const router = makeRouter() + await router.push('/project/KIN') + const wrapper = mount(ProjectView, { + props: { id: 'KIN' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const searchInput = wrapper.find('input[placeholder="Поиск по задачам..."]') + await searchInput.setValue('поиска') + await flushPromises() + expect((searchInput.element as HTMLInputElement).value).toBe('поиска') + + // Меняем id проекта → watch сбрасывает taskSearch + await wrapper.setProps({ id: 'OTHER' }) + await flushPromises() + + expect( + (searchInput.element as HTMLInputElement).value, + 'Поиск должен сброситься при смене проекта', + ).toBe('') + }) +}) + +// ───────────────────────────────────────────────────────────── +// Kanban tab: поиск +// ───────────────────────────────────────────────────────────── + +describe('KIN-076: поиск задач — Kanban вкладка', () => { + async function mountOnKanban() { + const wrapper = await mountOnTasks() + const kanbanTab = wrapper.findAll('button').find(b => + b.classes().includes('border-b-2') && b.text().includes('Kanban') + )! + await kanbanTab.trigger('click') + await flushPromises() + return wrapper + } + + it('8. Поле поиска присутствует на Kanban вкладке', async () => { + const wrapper = await mountOnKanban() + const searchInput = wrapper.find('input[placeholder="Поиск..."]') + expect(searchInput.exists(), 'Поле поиска должно быть на канбан-вкладке').toBe(true) + }) + + it('9. Поиск на Kanban фильтрует задачи в колонках', async () => { + const wrapper = await mountOnKanban() + const searchInput = wrapper.find('input[placeholder="Поиск..."]') + + await searchInput.setValue('поиска') + await flushPromises() + + expect(wrapper.find('a[href="/task/KIN-001"]').exists(), 'KIN-001 должен быть виден').toBe(true) + expect(wrapper.find('a[href="/task/KIN-002"]').exists(), 'KIN-002 не должен быть виден').toBe(false) + expect(wrapper.find('a[href="/task/KIN-003"]').exists(), 'KIN-003 не должен быть виден').toBe(false) + }) +})