From 24fd8ca72def3bab4009a0d113961d9db2145ddf Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Wed, 18 Mar 2026 15:32:44 +0200 Subject: [PATCH 1/3] kin: KIN-125-frontend_dev --- .../src/components/EscalationBanner.vue | 227 +++++++++++++++--- web/frontend/src/locales/en.json | 10 +- web/frontend/src/locales/ru.json | 10 +- 3 files changed, 217 insertions(+), 30 deletions(-) diff --git a/web/frontend/src/components/EscalationBanner.vue b/web/frontend/src/components/EscalationBanner.vue index dc27329..04a78cb 100644 --- a/web/frontend/src/components/EscalationBanner.vue +++ b/web/frontend/src/components/EscalationBanner.vue @@ -1,16 +1,20 @@ diff --git a/web/frontend/src/views/__tests__/SettingsView.error-css.test.ts b/web/frontend/src/views/__tests__/SettingsView.error-css.test.ts index 4f2c960..68f37d7 100644 --- a/web/frontend/src/views/__tests__/SettingsView.error-css.test.ts +++ b/web/frontend/src/views/__tests__/SettingsView.error-css.test.ts @@ -1,87 +1,14 @@ /** - * KIN-UI-013: Регрессионный тест — CSS-класс ошибки в SettingsView + * KIN-UI-013: Регрессионный тест — статический анализ SettingsView * - * До фикса: .startsWith('Error') — хардкод английской строки, ломался при смене локали. - * После фикса: .startsWith(t('common.error')) — использует i18n-ключ. - * - * Проверяет: - * 1. Литеральный .startsWith('Error') отсутствует в SettingsView.vue - * 2. При ошибке API — статусный span получает CSS-класс text-red-400 - * 3. При успехе API — статусный span получает CSS-класс text-green-400 - * 4. Покрывает все 5 статусных полей: - * saveStatus, saveTestStatus, saveDeployConfigStatus, - * saveAutoTestStatus, saveWorktreesStatus + * Проверяет, что хардкод английской строки 'Error' в .startsWith() не используется. + * Поведенческие тесты кнопок сохранения удалены вместе с самими формами (KIN-UI-012). */ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { mount, flushPromises } from '@vue/test-utils' +import { describe, it, expect } from 'vitest' import { readFileSync } from 'fs' import { resolve } from 'path' -import SettingsView from '../SettingsView.vue' -vi.mock('../../api', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - api: { - projects: vi.fn(), - projectLinks: vi.fn(), - patchProject: vi.fn(), - syncObsidian: vi.fn(), - }, - } -}) - -import { api } from '../../api' - -const BASE_PROJECT = { - id: 'proj-1', - name: 'Test Project', - path: '/projects/test', - status: 'active', - priority: 5, - tech_stack: ['python'], - execution_mode: null as string | null, - autocommit_enabled: null, - auto_test_enabled: null, - worktrees_enabled: null as number | null, - obsidian_vault_path: null, - deploy_command: null, - test_command: null, - deploy_host: null, - deploy_path: null, - deploy_runtime: null, - deploy_restart_cmd: null, - created_at: '2024-01-01', - total_tasks: 0, - done_tasks: 0, - active_tasks: 0, - blocked_tasks: 0, - review_tasks: 0, - project_type: null, - ssh_host: null, - ssh_user: null, - ssh_key_path: null, - ssh_proxy_jump: null, - description: null, -} - -beforeEach(() => { - vi.clearAllMocks() - vi.mocked(api.projectLinks).mockResolvedValue([]) -}) - -async function mountSettings(overrides: Partial = {}) { - const project = { ...BASE_PROJECT, ...overrides } - vi.mocked(api.projects).mockResolvedValue([project as any]) - const wrapper = mount(SettingsView) - await flushPromises() - return wrapper -} - -// ───────────────────────────────────────────────────────────── -// 1. Статический анализ: .startsWith('Error') не используется -// ───────────────────────────────────────────────────────────── describe('SettingsView.vue — статический анализ', () => { it("не содержит .startsWith('Error') (одинарные кавычки)", () => { const filePath = resolve(__dirname, '../SettingsView.vue') @@ -94,123 +21,4 @@ describe('SettingsView.vue — статический анализ', () => { const content = readFileSync(filePath, 'utf-8') expect(content).not.toContain('.startsWith("Error")') }) - - it('содержит .startsWith(t(\'common.error\')) — i18n-версию', () => { - const filePath = resolve(__dirname, '../SettingsView.vue') - const content = readFileSync(filePath, 'utf-8') - expect(content).toContain("startsWith(t('common.error'))") - }) -}) - -// ───────────────────────────────────────────────────────────── -// 2. saveVaultPath — CSS-классы при ошибке и успехе -// ───────────────────────────────────────────────────────────── -describe('SettingsView — saveVaultPath CSS-классы', () => { - it('saveStatus: text-red-400 при ошибке API', async () => { - vi.mocked(api.patchProject).mockRejectedValue(new Error('network error')) - const wrapper = await mountSettings() - - const saveBtn = wrapper.findAll('button').find(b => b.text() === 'Save Vault') - expect(saveBtn?.exists()).toBe(true) - await saveBtn!.trigger('click') - await flushPromises() - - const errorSpan = wrapper.find('.text-red-400') - expect(errorSpan.exists()).toBe(true) - expect(errorSpan.text()).toMatch(/^Error:/) - }) - - it('saveStatus: text-green-400 при успехе API', async () => { - vi.mocked(api.patchProject).mockResolvedValue({} as any) - const wrapper = await mountSettings() - - const saveBtn = wrapper.findAll('button').find(b => b.text() === 'Save Vault') - await saveBtn!.trigger('click') - await flushPromises() - - const successSpan = wrapper.find('.text-green-400') - expect(successSpan.exists()).toBe(true) - }) -}) - -// ───────────────────────────────────────────────────────────── -// 3. saveTestCommand — CSS-класс при ошибке -// ───────────────────────────────────────────────────────────── -describe('SettingsView — saveTestCommand CSS-классы', () => { - it('saveTestStatus: text-red-400 при ошибке API', async () => { - vi.mocked(api.patchProject).mockRejectedValue(new Error('test save failed')) - const wrapper = await mountSettings() - - const saveBtn = wrapper.findAll('button').find(b => b.text() === 'Save Test') - expect(saveBtn?.exists()).toBe(true) - await saveBtn!.trigger('click') - await flushPromises() - - const errorSpan = wrapper.find('.text-red-400') - expect(errorSpan.exists()).toBe(true) - expect(errorSpan.text()).toMatch(/^Error:/) - }) -}) - -// ───────────────────────────────────────────────────────────── -// 4. saveDeployConfig — CSS-класс при ошибке -// ───────────────────────────────────────────────────────────── -describe('SettingsView — saveDeployConfig CSS-классы', () => { - it('saveDeployConfigStatus: text-red-400 при ошибке API', async () => { - vi.mocked(api.patchProject).mockRejectedValue(new Error('deploy save failed')) - const wrapper = await mountSettings() - - const saveBtn = wrapper.findAll('button').find(b => b.text() === 'Save Deploy Config') - expect(saveBtn?.exists()).toBe(true) - await saveBtn!.trigger('click') - await flushPromises() - - const errorSpan = wrapper.find('.text-red-400') - expect(errorSpan.exists()).toBe(true) - expect(errorSpan.text()).toMatch(/^Error:/) - }) -}) - -// ───────────────────────────────────────────────────────────── -// 5. toggleAutoTest — CSS-класс при ошибке -// ───────────────────────────────────────────────────────────── -describe('SettingsView — toggleAutoTest CSS-классы', () => { - it('saveAutoTestStatus: text-red-400 при ошибке API', async () => { - vi.mocked(api.patchProject).mockRejectedValue(new Error('auto-test toggle failed')) - const wrapper = await mountSettings() - - const checkbox = wrapper.findAll('input[type="checkbox"]').find((el) => { - const label = el.element.closest('label') - return label?.textContent?.includes('Auto-test') - }) - expect(checkbox?.exists()).toBe(true) - await checkbox!.trigger('change') - await flushPromises() - - const errorSpan = wrapper.find('.text-red-400') - expect(errorSpan.exists()).toBe(true) - expect(errorSpan.text()).toMatch(/^Error:/) - }) -}) - -// ───────────────────────────────────────────────────────────── -// 6. toggleWorktrees — CSS-класс при ошибке -// ───────────────────────────────────────────────────────────── -describe('SettingsView — toggleWorktrees CSS-классы', () => { - it('saveWorktreesStatus: text-red-400 при ошибке API', async () => { - vi.mocked(api.patchProject).mockRejectedValue(new Error('worktrees toggle failed')) - const wrapper = await mountSettings() - - const checkbox = wrapper.findAll('input[type="checkbox"]').find((el) => { - const label = el.element.closest('label') - return label?.textContent?.includes('Worktrees') - }) - expect(checkbox?.exists()).toBe(true) - await checkbox!.trigger('change') - await flushPromises() - - const errorSpan = wrapper.find('.text-red-400') - expect(errorSpan.exists()).toBe(true) - expect(errorSpan.text()).toMatch(/^Error:/) - }) }) diff --git a/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts b/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts index 1b7274b..83abe4b 100644 --- a/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts +++ b/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts @@ -1,5 +1,5 @@ /** - * KIN-120: Тесты SettingsView — навигатор по настройкам проектов + * KIN-UI-012: Тесты SettingsView — навигатор по настройкам проектов * * После рефакторинга SettingsView стал навигатором: * показывает список проектов и ссылки на /project/{id}?tab=settings. @@ -23,8 +23,6 @@ vi.mock('../../api', async (importOriginal) => { ...actual, api: { projects: vi.fn(), - projectLinks: vi.fn(), - patchProject: vi.fn(), }, } }) @@ -75,8 +73,6 @@ function makeRouter() { beforeEach(() => { vi.clearAllMocks() - vi.mocked(api.projectLinks).mockResolvedValue([]) - vi.mocked(api.patchProject).mockResolvedValue({} as any) }) async function mountSettings(overrides: Partial = {}) { @@ -127,76 +123,3 @@ describe('SettingsView — навигатор', () => { expect(wrapper.text()).not.toContain('auto_complete') }) }) - -// --- KIN-120: Isolation and field presence tests --- - -async function mountSettingsMultiple(projects: Partial[]) { - vi.mocked(api.projects).mockResolvedValue(projects as any[]) - const router = makeRouter() - await router.push('/settings') - const wrapper = mount(SettingsView, { global: { plugins: [router] } }) - await flushPromises() - return wrapper -} - -describe('SettingsView — изоляция настроек проектов', () => { - it('obsidian_vault_path proj-1 и proj-2 независимы', async () => { - const proj1 = { ...BASE_PROJECT, id: 'proj-1', obsidian_vault_path: '/vault/proj1' } - const proj2 = { ...BASE_PROJECT, id: 'proj-2', name: 'Second Project', obsidian_vault_path: '/vault/proj2' } - const wrapper = await mountSettingsMultiple([proj1, proj2]) - const inputs = wrapper.findAll('input[placeholder="/path/to/obsidian/vault"]') - expect(inputs).toHaveLength(2) - expect((inputs[0].element as HTMLInputElement).value).toBe('/vault/proj1') - expect((inputs[1].element as HTMLInputElement).value).toBe('/vault/proj2') - }) - - it('test_command proj-1 не перекрывает test_command proj-2', async () => { - const proj1 = { ...BASE_PROJECT, id: 'proj-1', test_command: 'make test' } - const proj2 = { ...BASE_PROJECT, id: 'proj-2', name: 'Second Project', test_command: 'npm test' } - const wrapper = await mountSettingsMultiple([proj1, proj2]) - const inputs = wrapper.findAll('input[placeholder="make test"]') - expect(inputs).toHaveLength(2) - expect((inputs[0].element as HTMLInputElement).value).toBe('make test') - expect((inputs[1].element as HTMLInputElement).value).toBe('npm test') - }) - - it('deploy_host proj-1 не перекрывает deploy_host proj-2', async () => { - const proj1 = { ...BASE_PROJECT, id: 'proj-1', deploy_host: 'server-a' } - const proj2 = { ...BASE_PROJECT, id: 'proj-2', name: 'Second Project', deploy_host: 'server-b' } - const wrapper = await mountSettingsMultiple([proj1, proj2]) - const inputs = wrapper.findAll('input[placeholder="server host (e.g. vdp-prod)"]') - expect(inputs).toHaveLength(2) - expect((inputs[0].element as HTMLInputElement).value).toBe('server-a') - expect((inputs[1].element as HTMLInputElement).value).toBe('server-b') - }) -}) - -describe('SettingsView — наличие полей настроек', () => { - it('показывает поле obsidian_vault_path', async () => { - const wrapper = await mountSettings({ obsidian_vault_path: '/vault/test' }) - const input = wrapper.find('input[placeholder="/path/to/obsidian/vault"]') - expect(input.exists()).toBe(true) - expect((input.element as HTMLInputElement).value).toBe('/vault/test') - }) - - it('показывает поле test_command с корректным значением', async () => { - const wrapper = await mountSettings({ test_command: 'pytest tests/' }) - const input = wrapper.find('input[placeholder="make test"]') - expect(input.exists()).toBe(true) - expect((input.element as HTMLInputElement).value).toBe('pytest tests/') - }) - - it('показывает поле deploy_host', async () => { - const wrapper = await mountSettings({ deploy_host: 'my-server' }) - const input = wrapper.find('input[placeholder="server host (e.g. vdp-prod)"]') - expect(input.exists()).toBe(true) - expect((input.element as HTMLInputElement).value).toBe('my-server') - }) - - it('показывает поле deploy_path', async () => { - const wrapper = await mountSettings({ deploy_path: '/srv/app' }) - const input = wrapper.find('input[placeholder="/srv/myproject"]') - expect(input.exists()).toBe(true) - expect((input.element as HTMLInputElement).value).toBe('/srv/app') - }) -}) From 326994d101257ce160f622f68a54353855f40287 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Wed, 18 Mar 2026 15:41:59 +0200 Subject: [PATCH 3/3] kin: auto-commit after pipeline --- .../__tests__/completed-tasks-banner.test.ts | 339 ++++++++++++++++++ .../__tests__/SettingsView.worktrees.test.ts | 24 ++ 2 files changed, 363 insertions(+) create mode 100644 web/frontend/src/__tests__/completed-tasks-banner.test.ts diff --git a/web/frontend/src/__tests__/completed-tasks-banner.test.ts b/web/frontend/src/__tests__/completed-tasks-banner.test.ts new file mode 100644 index 0000000..a71c160 --- /dev/null +++ b/web/frontend/src/__tests__/completed-tasks-banner.test.ts @@ -0,0 +1,339 @@ +/** + * KIN-125: Уведомления о завершённых задачах в EscalationBanner + * + * AC1: Завершённые задачи отображаются в панели уведомлений рядом с эскалациями + * AC2: Кнопка Done скрывает задачу из списка + * AC3: Кнопка Revise отправляет задачу на доработку (смена статуса) + * AC4: Клик по полю задачи выполняет навигацию к детальному виду задачи + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import EscalationBanner from '../components/EscalationBanner.vue' + +const mockPush = vi.fn() +vi.mock('vue-router', () => ({ + useRouter: () => ({ push: mockPush }), +})) + +vi.mock('../api', () => ({ + api: { + notifications: vi.fn(), + projects: vi.fn(), + project: vi.fn(), + reviseTask: vi.fn(), + }, +})) + +import { api } from '../api' + +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 makeProject(id = 'proj-1', name = 'MyProject', doneCount = 1) { + return { + id, + name, + path: '/projects/test', + status: 'active', + priority: 1, + tech_stack: ['python'], + execution_mode: null, + autocommit_enabled: null, + auto_test_enabled: null, + worktrees_enabled: null, + obsidian_vault_path: null, + deploy_command: null, + test_command: null, + deploy_host: null, + deploy_path: null, + deploy_runtime: null, + deploy_restart_cmd: null, + created_at: '2024-01-01', + total_tasks: doneCount, + done_tasks: doneCount, + active_tasks: 0, + blocked_tasks: 0, + review_tasks: 0, + project_type: null, + ssh_host: null, + ssh_user: null, + ssh_key_path: null, + ssh_proxy_jump: null, + description: null, + } +} + +function makeCompletedTask(id = 'TSK-1', title = 'Test task') { + return { + id, + project_id: 'proj-1', + title, + status: 'completed', + priority: 1, + assigned_role: null, + parent_task_id: null, + brief: null, + spec: null, + execution_mode: null, + blocked_reason: null, + dangerously_skipped: null, + category: null, + acceptance_criteria: null, + created_at: '2024-01-01T10:00:00', + updated_at: '2024-03-18T10:00:00', + } +} + +function makeProjectDetail( + project: ReturnType, + tasks: ReturnType[], +) { + return { ...project, tasks, modules: [], decisions: [] } +} + +beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + mockPush.mockClear() + vi.useFakeTimers() + vi.mocked(api.notifications).mockResolvedValue([]) + vi.mocked(api.projects).mockResolvedValue([]) + vi.mocked(api.project).mockResolvedValue(makeProjectDetail(makeProject(), [])) + vi.mocked(api.reviseTask).mockResolvedValue({ status: 'ok', comment: '' }) +}) + +afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() +}) + +async function mountWithCompleted( + tasks = [makeCompletedTask()], + projectName = 'MyProject', +) { + const project = makeProject('proj-1', projectName) + vi.mocked(api.projects).mockResolvedValue([project]) + vi.mocked(api.project).mockResolvedValue(makeProjectDetail(project, tasks)) + const wrapper = mount(EscalationBanner) + await flushPromises() + return wrapper +} + +async function openPanel(wrapper: ReturnType) { + // Click the green completed-tasks badge to open the panel + const completedBadge = wrapper.findAll('button').find(b => + b.text().includes('Completed'), + ) + if (completedBadge) { + await completedBadge.trigger('click') + await flushPromises() + } +} + +// ───────────────────────────────────────────────────────────── +// AC1: Завершённые задачи отображаются в панели уведомлений +// ───────────────────────────────────────────────────────────── + +describe('KIN-125 AC1: завершённые задачи отображаются в панели', () => { + it('Зелёный бейдж "Completed" появляется при наличии завершённых задач', async () => { + const wrapper = await mountWithCompleted() + const badge = wrapper.findAll('button').find(b => b.text().includes('Completed')) + expect(badge).toBeTruthy() + }) + + it('Бейдж показывает корректное количество завершённых задач', async () => { + const tasks = [makeCompletedTask('TSK-1'), makeCompletedTask('TSK-2')] + const wrapper = await mountWithCompleted(tasks) + const badge = wrapper.findAll('button').find(b => b.text().includes('Completed')) + expect(badge!.text()).toContain('2') + }) + + it('Бейдж не отображается когда нет завершённых задач', async () => { + vi.mocked(api.projects).mockResolvedValue([makeProject('proj-1', 'P', 0)]) + const wrapper = mount(EscalationBanner) + await flushPromises() + const badge = wrapper.findAll('button').find(b => b.text().includes('Completed')) + expect(badge).toBeUndefined() + }) + + it('Секция завершённых задач отображается при открытии панели', async () => { + const wrapper = await mountWithCompleted() + await openPanel(wrapper) + expect(wrapper.text()).toContain('Completed tasks') + }) + + it('Заголовок завершённой задачи виден в открытой панели', async () => { + const wrapper = await mountWithCompleted([makeCompletedTask('TSK-1', 'Deploy to production')]) + await openPanel(wrapper) + expect(wrapper.text()).toContain('Deploy to production') + }) +}) + +// ───────────────────────────────────────────────────────────── +// AC2: Кнопка Done скрывает задачу из списка +// ───────────────────────────────────────────────────────────── + +describe('KIN-125 AC2: кнопка Done скрывает задачу из списка', () => { + it('Нажатие Done убирает задачу из панели', async () => { + const wrapper = await mountWithCompleted([makeCompletedTask('TSK-10', 'Task to dismiss')]) + await openPanel(wrapper) + expect(wrapper.text()).toContain('Task to dismiss') + + const doneBtn = wrapper.findAll('button').find(b => b.text() === 'Done') + expect(doneBtn).toBeTruthy() + await doneBtn!.trigger('click') + await flushPromises() + + expect(wrapper.text()).not.toContain('Task to dismiss') + }) + + it('После Done task_id сохраняется в localStorage под ключом kin_dismissed_completed', async () => { + const wrapper = await mountWithCompleted([makeCompletedTask('TSK-11')]) + await openPanel(wrapper) + + const doneBtn = wrapper.findAll('button').find(b => b.text() === 'Done') + await doneBtn!.trigger('click') + await flushPromises() + + const stored = localStorageMock.getItem('kin_dismissed_completed') + expect(stored).toBeTruthy() + expect(JSON.parse(stored!)).toContain('TSK-11') + }) + + it('Dismissed задача не появляется снова при следующем поллинге (30с)', async () => { + const wrapper = await mountWithCompleted([makeCompletedTask('TSK-12', 'Reappear task')]) + await openPanel(wrapper) + + const doneBtn = wrapper.findAll('button').find(b => b.text() === 'Done') + await doneBtn!.trigger('click') + await flushPromises() + + vi.advanceTimersByTime(30000) + await flushPromises() + + expect(wrapper.text()).not.toContain('Reappear task') + }) +}) + +// ───────────────────────────────────────────────────────────── +// AC3: Кнопка Revise отправляет задачу на доработку +// ───────────────────────────────────────────────────────────── + +describe('KIN-125 AC3: кнопка Revise отправляет задачу на доработку', () => { + it('Нажатие Revise показывает inline форму с полем ввода комментария', async () => { + const wrapper = await mountWithCompleted() + await openPanel(wrapper) + + const reviseBtn = wrapper.findAll('button').find(b => b.text() === 'Revise') + expect(reviseBtn).toBeTruthy() + await reviseBtn!.trigger('click') + await flushPromises() + + expect(wrapper.find('input[type="text"]').exists()).toBe(true) + }) + + it('Нажатие Send вызывает api.reviseTask с task_id и введённым комментарием', async () => { + const wrapper = await mountWithCompleted([makeCompletedTask('TSK-20')]) + await openPanel(wrapper) + + const reviseBtn = wrapper.findAll('button').find(b => b.text() === 'Revise') + await reviseBtn!.trigger('click') + await flushPromises() + + await wrapper.find('input[type="text"]').setValue('Add deployment logs') + const sendBtn = wrapper.findAll('button').find(b => b.text() === 'Send') + await sendBtn!.trigger('click') + await flushPromises() + + expect(vi.mocked(api.reviseTask)).toHaveBeenCalledWith('TSK-20', 'Add deployment logs') + }) + + it('После успешного Revise задача скрывается из списка', async () => { + const wrapper = await mountWithCompleted([makeCompletedTask('TSK-21', 'Revise me')]) + await openPanel(wrapper) + expect(wrapper.text()).toContain('Revise me') + + const reviseBtn = wrapper.findAll('button').find(b => b.text() === 'Revise') + await reviseBtn!.trigger('click') + await flushPromises() + + const sendBtn = wrapper.findAll('button').find(b => b.text() === 'Send') + await sendBtn!.trigger('click') + await flushPromises() + + expect(wrapper.text()).not.toContain('Revise me') + }) + + it('При пустом комментарии используется дефолтный текст (не пустая строка)', async () => { + const wrapper = await mountWithCompleted([makeCompletedTask('TSK-22')]) + await openPanel(wrapper) + + const reviseBtn = wrapper.findAll('button').find(b => b.text() === 'Revise') + await reviseBtn!.trigger('click') + await flushPromises() + + // Send without entering a comment + const sendBtn = wrapper.findAll('button').find(b => b.text() === 'Send') + await sendBtn!.trigger('click') + await flushPromises() + + expect(vi.mocked(api.reviseTask)).toHaveBeenCalledWith('TSK-22', expect.stringMatching(/.+/)) + }) + + it('Нажатие Cancel скрывает форму без вызова api.reviseTask', async () => { + const wrapper = await mountWithCompleted() + await openPanel(wrapper) + + const reviseBtn = wrapper.findAll('button').find(b => b.text() === 'Revise') + await reviseBtn!.trigger('click') + await flushPromises() + + expect(wrapper.find('input[type="text"]').exists()).toBe(true) + + const cancelBtn = wrapper.findAll('button').find(b => b.text() === 'Cancel') + await cancelBtn!.trigger('click') + await flushPromises() + + expect(wrapper.find('input[type="text"]').exists()).toBe(false) + expect(vi.mocked(api.reviseTask)).not.toHaveBeenCalled() + }) +}) + +// ───────────────────────────────────────────────────────────── +// AC4: Клик по полю задачи выполняет навигацию к детальному виду +// ───────────────────────────────────────────────────────────── + +describe('KIN-125 AC4: клик по задаче переходит к детальному виду', () => { + it('Клик по строке задачи вызывает router.push("/task/{id}")', async () => { + const wrapper = await mountWithCompleted([makeCompletedTask('TSK-30')]) + await openPanel(wrapper) + + const taskRow = wrapper.find('.cursor-pointer') + expect(taskRow.exists()).toBe(true) + await taskRow.trigger('click') + await flushPromises() + + expect(mockPush).toHaveBeenCalledWith('/task/TSK-30') + }) + + it('После клика по задаче панель закрывается', async () => { + const wrapper = await mountWithCompleted([makeCompletedTask('TSK-31')]) + await openPanel(wrapper) + expect(wrapper.text()).toContain('Completed tasks') + + const taskRow = wrapper.find('.cursor-pointer') + await taskRow.trigger('click') + await flushPromises() + + expect(wrapper.text()).not.toContain('Completed tasks') + }) +}) diff --git a/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts b/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts index 83abe4b..8bf7145 100644 --- a/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts +++ b/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts @@ -86,6 +86,11 @@ async function mountSettings(overrides: Partial = {}) { } describe('SettingsView — навигатор', () => { + it('таблица проектов рендерится', async () => { + const wrapper = await mountSettings() + expect(wrapper.find('table').exists()).toBe(true) + }) + it('показывает имя проекта', async () => { const wrapper = await mountSettings() expect(wrapper.text()).toContain('Test Project') @@ -122,4 +127,23 @@ describe('SettingsView — навигатор', () => { const wrapper = await mountSettings({ execution_mode: null }) expect(wrapper.text()).not.toContain('auto_complete') }) + + it('для каждого проекта есть ссылка с ?tab=settings', async () => { + const projects = [ + { ...BASE_PROJECT, id: 'proj-1', name: 'Project One' }, + { ...BASE_PROJECT, id: 'proj-2', name: 'Project Two' }, + { ...BASE_PROJECT, id: 'proj-3', name: 'Project Three' }, + ] + vi.mocked(api.projects).mockResolvedValue(projects as any) + const router = makeRouter() + await router.push('/settings') + const wrapper = mount(SettingsView, { global: { plugins: [router] } }) + await flushPromises() + + for (const project of projects) { + const link = wrapper.find(`a[href*="${project.id}"]`) + expect(link.exists()).toBe(true) + expect(link.attributes('href')).toContain('tab=settings') + } + }) })