diff --git a/web/frontend/src/__tests__/completed-tasks-banner.test.ts b/web/frontend/src/__tests__/completed-tasks-banner.test.ts deleted file mode 100644 index a71c160..0000000 --- a/web/frontend/src/__tests__/completed-tasks-banner.test.ts +++ /dev/null @@ -1,339 +0,0 @@ -/** - * 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/__tests__/deploy-config-clear-fields.test.ts b/web/frontend/src/__tests__/deploy-config-clear-fields.test.ts index e1a98e7..2b8b73f 100644 --- a/web/frontend/src/__tests__/deploy-config-clear-fields.test.ts +++ b/web/frontend/src/__tests__/deploy-config-clear-fields.test.ts @@ -1,37 +1,190 @@ /** - * KIN-INFRA-012 / KIN-UI-012: Регрессия — формы deploy из SettingsView удалены + * KIN-INFRA-012: Регрессионный тест — saveDeployConfig не теряет пустые строки * - * saveDeployConfig и связанные deploy-поля перенесены в ProjectView → Settings tab. - * SettingsView теперь только навигатор. + * Проверяет, что пустая строка в deploy-полях: + * 1. Передаётся в patchProject как "" (не undefined) + * 2. Переживает JSON.stringify — т.е. бэкенд получает ключ со значением "" + * 3. Непустые значения тоже передаются корректно * - * Проверяет, что в SettingsView.vue отсутствуют удалённые формы. + * До фикса: `deploy_host: deployHosts.value[id] || undefined` → пустая строка + * превращалась в undefined и JSON.stringify отбрасывал поле. Бэкенд не получал + * сигнал очистки. После фикса: пустая строка передаётся как-есть. */ -import { describe, it, expect } from 'vitest' -import { readFileSync } from 'fs' -import { resolve } from 'path' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import SettingsView from '../views/SettingsView.vue' -describe('SettingsView.vue — формы deploy удалены (KIN-UI-012)', () => { - const filePath = resolve(__dirname, '../views/SettingsView.vue') - const content = readFileSync(filePath, 'utf-8') +vi.mock('../api', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + api: { + projects: vi.fn(), + patchProject: vi.fn(), + syncObsidian: vi.fn(), + }, + } +}) - it('не содержит saveDeployConfig (форма перенесена в ProjectView)', () => { - expect(content).not.toContain('saveDeployConfig') +import { api } from '../api' + +const BASE_PROJECT = { + id: 'KIN', + name: 'Kin', + path: '/projects/kin', + status: 'active', + priority: 5, + tech_stack: ['python'], + execution_mode: null, + autocommit_enabled: null, + auto_test_enabled: null, + obsidian_vault_path: null, + deploy_command: null as string | null, + test_command: null as string | null, + deploy_host: null as string | null, + deploy_path: null as string | null, + deploy_runtime: null as string | null, + deploy_restart_cmd: null as string | 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.patchProject).mockResolvedValue(BASE_PROJECT as any) +}) + +async function mountSettingsWithProject(overrides: Partial = {}) { + const project = { ...BASE_PROJECT, ...overrides } + vi.mocked(api.projects).mockResolvedValue([project as any]) + const wrapper = mount(SettingsView) + await flushPromises() + return wrapper +} + +async function clickSaveDeployConfig(wrapper: ReturnType) { + const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save Deploy Config')) + expect(saveBtn).toBeDefined() + await saveBtn!.trigger('click') + await flushPromises() +} + +// ───────────────────────────────────────────────────────────── +// 1. Пустая строка передаётся как "" — не теряется как undefined +// ───────────────────────────────────────────────────────────── +describe('saveDeployConfig — пустые строки сохраняются в payload', () => { + it('deploy_host="" передаётся как "" когда поле пустое (не undefined)', async () => { + // null в проекте → инициализируется как '' в компоненте → должно передаться как "" + const wrapper = await mountSettingsWithProject({ deploy_host: null }) + await clickSaveDeployConfig(wrapper) + + const callArgs = vi.mocked(api.patchProject).mock.calls[0] + expect(callArgs[1]).toHaveProperty('deploy_host') + expect((callArgs[1] as any).deploy_host).toBe('') }) - it('не содержит deployHosts (реактивный ref удалён)', () => { - expect(content).not.toContain('deployHosts') + it('deploy_path="" передаётся как "" когда поле пустое (не undefined)', async () => { + const wrapper = await mountSettingsWithProject({ deploy_path: null }) + await clickSaveDeployConfig(wrapper) + + const callArgs = vi.mocked(api.patchProject).mock.calls[0] + expect(callArgs[1]).toHaveProperty('deploy_path') + expect((callArgs[1] as any).deploy_path).toBe('') }) - it('не содержит deploy_host input поле', () => { - expect(content).not.toContain('server host (e.g. vdp-prod)') + it('deploy_runtime="" передаётся как "" когда поле пустое (не undefined)', async () => { + const wrapper = await mountSettingsWithProject({ deploy_runtime: null }) + await clickSaveDeployConfig(wrapper) + + const callArgs = vi.mocked(api.patchProject).mock.calls[0] + expect(callArgs[1]).toHaveProperty('deploy_runtime') + expect((callArgs[1] as any).deploy_runtime).toBe('') }) - it('не содержит test_command input поле', () => { - expect(content).not.toContain('placeholder="make test"') - }) + it('deploy_restart_cmd="" передаётся как "" когда поле пустое (не undefined)', async () => { + const wrapper = await mountSettingsWithProject({ deploy_restart_cmd: null }) + await clickSaveDeployConfig(wrapper) - it('не содержит obsidian_vault_path input поле', () => { - expect(content).not.toContain('path/to/obsidian/vault') + const callArgs = vi.mocked(api.patchProject).mock.calls[0] + expect(callArgs[1]).toHaveProperty('deploy_restart_cmd') + expect((callArgs[1] as any).deploy_restart_cmd).toBe('') + }) +}) + +// ───────────────────────────────────────────────────────────── +// 2. JSON.stringify не выбрасывает пустые строки — бэкенд их получит +// ───────────────────────────────────────────────────────────── +describe('saveDeployConfig — пустые строки выживают JSON.stringify', () => { + it('все 4 поля присутствуют в JSON когда все значения пустые', async () => { + // Проект с null во всех deploy-полях → инициализируются как '' + const wrapper = await mountSettingsWithProject() + await clickSaveDeployConfig(wrapper) + + const callArgs = vi.mocked(api.patchProject).mock.calls[0] + const payload = callArgs[1] as Record + + // Сериализуем как настоящий PATCH-запрос + const jsonBody = JSON.parse(JSON.stringify(payload)) + + expect(jsonBody).toHaveProperty('deploy_host', '') + expect(jsonBody).toHaveProperty('deploy_path', '') + expect(jsonBody).toHaveProperty('deploy_runtime', '') + expect(jsonBody).toHaveProperty('deploy_restart_cmd', '') + }) + + it('пустая строка выживает JSON.stringify (регрессия: undefined исчезает)', async () => { + const wrapper = await mountSettingsWithProject({ deploy_host: null }) + await clickSaveDeployConfig(wrapper) + + const callArgs = vi.mocked(api.patchProject).mock.calls[0] + const payload = callArgs[1] as Record + const jsonBody = JSON.parse(JSON.stringify(payload)) + + // "" должна присутствовать в JSON как пустая строка + // undefined здесь отсутствовало бы — это был бы баг + expect(Object.keys(jsonBody)).toContain('deploy_host') + expect(jsonBody.deploy_host).toBe('') + expect(jsonBody.deploy_host).not.toBeUndefined() + }) +}) + +// ───────────────────────────────────────────────────────────── +// 3. Непустые значения передаются корректно +// ───────────────────────────────────────────────────────────── +describe('saveDeployConfig — непустые значения передаются корректно', () => { + it('deploy_host с непустым значением передаётся без изменений', async () => { + const wrapper = await mountSettingsWithProject({ deploy_host: 'prod.example.com' }) + await clickSaveDeployConfig(wrapper) + + const callArgs = vi.mocked(api.patchProject).mock.calls[0] + expect((callArgs[1] as any).deploy_host).toBe('prod.example.com') + }) + + it('все 4 поля с непустыми значениями передаются корректно', async () => { + const wrapper = await mountSettingsWithProject({ + deploy_host: 'prod.example.com', + deploy_path: '/opt/kin', + deploy_runtime: 'docker', + deploy_restart_cmd: 'docker compose restart', + }) + await clickSaveDeployConfig(wrapper) + + const callArgs = vi.mocked(api.patchProject).mock.calls[0] + const payload = callArgs[1] as any + expect(payload.deploy_host).toBe('prod.example.com') + expect(payload.deploy_path).toBe('/opt/kin') + expect(payload.deploy_runtime).toBe('docker') + expect(payload.deploy_restart_cmd).toBe('docker compose restart') }) }) diff --git a/web/frontend/src/__tests__/deploy-standardized.test.ts b/web/frontend/src/__tests__/deploy-standardized.test.ts index a35d9bc..405423b 100644 --- a/web/frontend/src/__tests__/deploy-standardized.test.ts +++ b/web/frontend/src/__tests__/deploy-standardized.test.ts @@ -2,19 +2,18 @@ * KIN-079: Стандартизированный deploy — компонентные тесты * * Проверяет: - * 1. ProjectView — Deploy кнопка (видимость, disabled, спиннер) - * 2. ProjectView — Deploy результат (structured, legacy, dependents) - * 3. ProjectView — Links таб (список, add link модал, delete link) - * 4. Граничные кейсы (пустые links, deploy без dependents, overall_success=false) - * - * Примечание: тесты SettingsView (Deploy Config, Project Links) удалены (KIN-UI-012). - * Эти формы перенесены в ProjectView → Settings tab. + * 1. SettingsView — deploy config рендерится, Save Deploy Config, runtime select + * 2. ProjectView — Deploy кнопка (видимость, disabled, спиннер) + * 3. ProjectView — Deploy результат (structured, legacy, dependents) + * 4. ProjectView — Links таб (список, add link модал, delete link) + * 5. Граничные кейсы (пустые links, deploy без dependents, overall_success=false) */ import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import { createRouter, createMemoryHistory } from 'vue-router' import ProjectView from '../views/ProjectView.vue' +import SettingsView from '../views/SettingsView.vue' vi.mock('../api', async (importOriginal) => { const actual = await importOriginal() @@ -136,7 +135,163 @@ async function switchToLinksTab(wrapper: ReturnType) { } // ───────────────────────────────────────────────────────────── -// ProjectView — Deploy кнопка +// 1. SettingsView — Deploy Config +// ───────────────────────────────────────────────────────────── +describe('SettingsView — Deploy Config', () => { + async function mountSettings(deployFields: Partial = {}) { + const project = { ...BASE_PROJECT, ...deployFields } + vi.mocked(api.projects).mockResolvedValue([project as any]) + const wrapper = mount(SettingsView) + await flushPromises() + return wrapper + } + + it('раздел Deploy Config рендерится для каждого проекта', async () => { + const wrapper = await mountSettings() + expect(wrapper.text()).toContain('Deploy Config') + }) + + it('поле Server host рендерится', async () => { + const wrapper = await mountSettings() + expect(wrapper.text()).toContain('Server host') + }) + + it('поле Project path on server рендерится', async () => { + const wrapper = await mountSettings() + expect(wrapper.text()).toContain('Project path on server') + }) + + it('поле Runtime рендерится', async () => { + const wrapper = await mountSettings() + expect(wrapper.text()).toContain('Runtime') + }) + + it('поле Restart command рендерится', async () => { + const wrapper = await mountSettings() + expect(wrapper.text()).toContain('Restart command') + }) + + it('runtime select содержит опции docker, node, python, static', async () => { + const wrapper = await mountSettings() + // Ищем select для runtime (первый select в Deploy Config) + const selects = wrapper.findAll('select') + // Находим select с options docker/node/python/static + const runtimeSelect = selects.find(s => { + const opts = s.findAll('option') + const values = opts.map(o => o.element.value) + return values.includes('docker') && values.includes('node') + }) + expect(runtimeSelect).toBeDefined() + const values = runtimeSelect!.findAll('option').map(o => o.element.value) + expect(values).toContain('docker') + expect(values).toContain('node') + expect(values).toContain('python') + expect(values).toContain('static') + }) + + it('Save Deploy Config вызывает patchProject', async () => { + const wrapper = await mountSettings() + const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save Deploy Config')) + expect(saveBtn).toBeDefined() + await saveBtn!.trigger('click') + await flushPromises() + expect(vi.mocked(api.patchProject)).toHaveBeenCalled() + }) + + it('patchProject вызывается с deploy_host, deploy_path, deploy_runtime, deploy_restart_cmd', async () => { + const wrapper = await mountSettings({ + deploy_host: 'myserver.com', + deploy_path: '/opt/app', + deploy_runtime: 'docker', + deploy_restart_cmd: 'docker compose up -d', + }) + const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save Deploy Config')) + await saveBtn!.trigger('click') + await flushPromises() + const callArgs = vi.mocked(api.patchProject).mock.calls[0] + expect(callArgs[0]).toBe('KIN') + // Все 4 ключа присутствуют в объекте + expect(callArgs[1]).toHaveProperty('deploy_host') + expect(callArgs[1]).toHaveProperty('deploy_path') + expect(callArgs[1]).toHaveProperty('deploy_runtime') + expect(callArgs[1]).toHaveProperty('deploy_restart_cmd') + }) + + it('patchProject получает deploy_host=myserver.com из заполненного поля', async () => { + const wrapper = await mountSettings({ deploy_host: 'myserver.com' }) + const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save Deploy Config')) + await saveBtn!.trigger('click') + await flushPromises() + const callArgs = vi.mocked(api.patchProject).mock.calls[0] + expect((callArgs[1] as any).deploy_host).toBe('myserver.com') + }) + + it('статус "Saved" отображается после успешного сохранения', async () => { + const wrapper = await mountSettings() + const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save Deploy Config')) + await saveBtn!.trigger('click') + await flushPromises() + expect(wrapper.text()).toContain('Saved') + }) +}) + +// ───────────────────────────────────────────────────────────── +// 1b. SettingsView — Project Links +// ───────────────────────────────────────────────────────────── +describe('SettingsView — Project Links', () => { + it('link.type рендерится корректно в списке связей — не undefined (конвенция #527)', async () => { + // Bug #527: шаблон использовал {{ link.link_type }} вместо {{ link.type }} → runtime undefined + vi.mocked(api.projectLinks).mockResolvedValue([ + { id: 1, from_project: 'KIN', to_project: 'BRS', type: 'triggers', description: null, created_at: '2026-01-01' }, + ] as any) + vi.mocked(api.projects).mockResolvedValue([BASE_PROJECT as any]) + const wrapper = mount(SettingsView) + await flushPromises() + + // Тип связи должен отображаться как строка, а не undefined + expect(wrapper.text()).toContain('triggers') + expect(wrapper.text()).not.toContain('undefined') + }) + + it('addLink вызывает createProjectLink с полем type (не link_type) из формы', async () => { + // Bug #527: addLink() использовал link_type вместо type при вызове api + vi.mocked(api.projects).mockResolvedValue([ + { ...BASE_PROJECT, id: 'BRS', name: 'Barsik' } as any, + BASE_PROJECT as any, + ]) + vi.mocked(api.projectLinks).mockResolvedValue([]) + const wrapper = mount(SettingsView) + await flushPromises() + + // Открываем форму добавления связи для первого проекта + const addBtns = wrapper.findAll('button').filter(b => b.text().includes('+ Add Link')) + if (addBtns.length > 0) { + await addBtns[0].trigger('click') + await flushPromises() + + // Выбираем to_project (в SettingsView это select без api.projects — используем allProjectList) + const selects = wrapper.findAll('select') + const toProjectSelect = selects.find(s => s.findAll('option').some(o => o.element.value !== '' && o.element.value !== 'depends_on' && o.element.value !== 'triggers' && o.element.value !== 'related_to')) + if (toProjectSelect) { + const opts = toProjectSelect.findAll('option').filter(o => o.element.value !== '') + if (opts.length > 0) await toProjectSelect.setValue(opts[0].element.value) + } + + const form = wrapper.find('form') + await form.trigger('submit') + await flushPromises() + + if (vi.mocked(api.createProjectLink).mock.calls.length > 0) { + const callArg = vi.mocked(api.createProjectLink).mock.calls[0][0] + expect(callArg).toHaveProperty('type') + expect(callArg).not.toHaveProperty('link_type') + } + } + }) +}) + +// ───────────────────────────────────────────────────────────── +// 2. ProjectView — Deploy кнопка // ───────────────────────────────────────────────────────────── describe('ProjectView — Deploy кнопка', () => { it('кнопка Deploy присутствует в header проекта', async () => { diff --git a/web/frontend/src/components/EscalationBanner.vue b/web/frontend/src/components/EscalationBanner.vue index 04a78cb..dc27329 100644 --- a/web/frontend/src/components/EscalationBanner.vue +++ b/web/frontend/src/components/EscalationBanner.vue @@ -1,20 +1,16 @@ 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 68f37d7..4f2c960 100644 --- a/web/frontend/src/views/__tests__/SettingsView.error-css.test.ts +++ b/web/frontend/src/views/__tests__/SettingsView.error-css.test.ts @@ -1,14 +1,87 @@ /** - * KIN-UI-013: Регрессионный тест — статический анализ SettingsView + * KIN-UI-013: Регрессионный тест — CSS-класс ошибки в SettingsView * - * Проверяет, что хардкод английской строки 'Error' в .startsWith() не используется. - * Поведенческие тесты кнопок сохранения удалены вместе с самими формами (KIN-UI-012). + * До фикса: .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 */ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' 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') @@ -21,4 +94,123 @@ 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 8bf7145..1b7274b 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-UI-012: Тесты SettingsView — навигатор по настройкам проектов + * KIN-120: Тесты SettingsView — навигатор по настройкам проектов * * После рефакторинга SettingsView стал навигатором: * показывает список проектов и ссылки на /project/{id}?tab=settings. @@ -23,6 +23,8 @@ vi.mock('../../api', async (importOriginal) => { ...actual, api: { projects: vi.fn(), + projectLinks: vi.fn(), + patchProject: vi.fn(), }, } }) @@ -73,6 +75,8 @@ function makeRouter() { beforeEach(() => { vi.clearAllMocks() + vi.mocked(api.projectLinks).mockResolvedValue([]) + vi.mocked(api.patchProject).mockResolvedValue({} as any) }) async function mountSettings(overrides: Partial = {}) { @@ -86,11 +90,6 @@ 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') @@ -127,23 +126,77 @@ 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() +// --- KIN-120: Isolation and field presence tests --- - 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') - } +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') }) })