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)
+ })
+})