diff --git a/web/frontend/src/__tests__/deploy-api.test.ts b/web/frontend/src/__tests__/deploy-api.test.ts index a579f7d..941612a 100644 --- a/web/frontend/src/__tests__/deploy-api.test.ts +++ b/web/frontend/src/__tests__/deploy-api.test.ts @@ -172,9 +172,7 @@ describe('api.deployProject', () => { { step: 'git pull', stdout: 'ok', stderr: '', exit_code: 0 }, { step: 'docker compose up -d', stdout: 'started', stderr: '', exit_code: 0 }, ], - dependents_deployed: [ - { project_id: 'FE', project_name: 'Frontend', success: true, results: [] }, - ], + dependents_deployed: ['FE'], overall_success: true, } mockFetch(structured) diff --git a/web/frontend/src/__tests__/deploy-dependents-string.test.ts b/web/frontend/src/__tests__/deploy-dependents-string.test.ts new file mode 100644 index 0000000..5837a8e --- /dev/null +++ b/web/frontend/src/__tests__/deploy-dependents-string.test.ts @@ -0,0 +1,292 @@ +/** + * KIN-INFRA-011: Регрессионный тест — dependents_deployed как string[] + * + * Проверяет: + * 1. ProjectView: dependents_deployed рендерит строки напрямую (не dep.project_name) + * 2. ProjectView: статичный бейдж "ok" отображается для каждого dependent + * 3. ProjectView: пустой dependents_deployed не показывает секцию + * 4. TaskDetail: dependents_deployed рендерит строки напрямую + * 5. TaskDetail: статичный бейдж "ok" отображается для каждого dependent + * 6. TaskDetail: секция "Зависимые проекты" показывается при непустом dependents_deployed + * + * Decision #546: бэкенд возвращает list[str] (project_id), не объекты DependentDeploy. + * DependentDeploy интерфейс удалён. Шаблон использует dep как строку. + */ + +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 TaskDetail from '../views/TaskDetail.vue' + +vi.mock('../api', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + api: { + projects: vi.fn(), + project: vi.fn(), + getPhases: vi.fn(), + environments: vi.fn(), + projectLinks: vi.fn(), + patchProject: vi.fn(), + deployProject: vi.fn(), + createProjectLink: vi.fn(), + deleteProjectLink: vi.fn(), + syncObsidian: vi.fn(), + taskFull: vi.fn(), + patchTask: vi.fn(), + runTask: vi.fn(), + notifications: vi.fn(), + }, + } +}) + +import { api } from '../api' + +const Stub = { template: '
' } + +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 }, + { path: '/task/:id', component: TaskDetail, props: true }, + ], + }) +} + +const BASE_PROJECT = { + id: 'KIN', + name: 'Kin', + path: '/projects/kin', + status: 'active', + priority: 5, + tech_stack: ['python', 'vue'], + 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: 'srv', + deploy_path: '/app', + deploy_runtime: 'docker', + 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, + tasks: [], + decisions: [], + modules: [], +} + +// TaskFull с deploy_runtime — кнопка Deploy видна только при status=done +const DONE_TASK = { + id: 'KIN-001', + project_id: 'KIN', + title: 'Тестовая задача', + status: 'done', + priority: 5, + 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-01', + updated_at: '2024-01-01', + pipeline_steps: [], + related_decisions: [], + pending_actions: [], + project_deploy_command: null, + project_deploy_host: 'srv', + project_deploy_path: '/app', + project_deploy_runtime: 'docker', + pipeline_id: null, +} + +beforeEach(() => { + localStorageMock.clear() + vi.clearAllMocks() + vi.mocked(api.project).mockResolvedValue(BASE_PROJECT as any) + vi.mocked(api.getPhases).mockResolvedValue([]) + vi.mocked(api.environments).mockResolvedValue([]) + vi.mocked(api.projectLinks).mockResolvedValue([]) + vi.mocked(api.projects).mockResolvedValue([]) + vi.mocked(api.patchProject).mockResolvedValue(BASE_PROJECT as any) + vi.mocked(api.deployProject).mockResolvedValue({ + success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 1, + } as any) + vi.mocked(api.taskFull).mockResolvedValue(DONE_TASK as any) + vi.mocked(api.notifications).mockResolvedValue([]) +}) + +// ───────────────────────────────────────────────────────────── +// Helper: смонтировать ProjectView и запустить деплой +// ───────────────────────────────────────────────────────────── +async function mountProjectViewAndDeploy(deployResult: any) { + vi.mocked(api.deployProject).mockResolvedValue(deployResult) + const router = makeRouter() + await router.push('/project/KIN') + const wrapper = mount(ProjectView, { + props: { id: 'KIN' }, + global: { plugins: [router] }, + }) + await flushPromises() + const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy')) + await deployBtn!.trigger('click') + await flushPromises() + return wrapper +} + +// ───────────────────────────────────────────────────────────── +// Helper: смонтировать TaskDetail и запустить деплой +// ───────────────────────────────────────────────────────────── +async function mountTaskDetailAndDeploy(deployResult: any) { + vi.mocked(api.deployProject).mockResolvedValue(deployResult) + const router = makeRouter() + await router.push('/task/KIN-001') + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-001' }, + global: { plugins: [router] }, + }) + await flushPromises() + const deployBtn = wrapper.findAll('button').find(b => b.text().includes('Deploy')) + await deployBtn!.trigger('click') + await flushPromises() + return wrapper +} + +// ───────────────────────────────────────────────────────────── +// ProjectView — dependents_deployed как string[] +// ───────────────────────────────────────────────────────────── +describe('ProjectView — dependents_deployed как string[] (KIN-INFRA-011)', () => { + it('dep рендерится напрямую как строка — project_id виден в тексте', async () => { + const wrapper = await mountProjectViewAndDeploy({ + success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2, + dependents_deployed: ['BARSIK'], + overall_success: true, + }) + expect(wrapper.text()).toContain('BARSIK') + }) + + it('несколько dep-строк — все project_id видны', async () => { + const wrapper = await mountProjectViewAndDeploy({ + success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2, + dependents_deployed: ['PROJ-A', 'PROJ-B', 'PROJ-C'], + overall_success: true, + }) + expect(wrapper.text()).toContain('PROJ-A') + expect(wrapper.text()).toContain('PROJ-B') + expect(wrapper.text()).toContain('PROJ-C') + }) + + it('статичный бейдж "ok" отображается для каждого dependent', async () => { + const wrapper = await mountProjectViewAndDeploy({ + success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2, + dependents_deployed: ['FE', 'BE'], + overall_success: true, + }) + // Зависимые проекты секция видна + expect(wrapper.text()).toContain('Зависимые проекты') + // Бейджи "ok" — по одному на каждый dep (два .text-teal-400 span с текстом ok) + const okBadges = wrapper.findAll('span.text-teal-400').filter(el => el.text().trim() === 'ok') + expect(okBadges.length).toBeGreaterThanOrEqual(2) + }) + + it('пустой dependents_deployed — секция "Зависимые проекты" не отображается', async () => { + const wrapper = await mountProjectViewAndDeploy({ + success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2, + dependents_deployed: [], + overall_success: true, + }) + expect(wrapper.text()).not.toContain('Зависимые проекты') + }) + + it('отсутствующий dependents_deployed — секция не отображается', async () => { + const wrapper = await mountProjectViewAndDeploy({ + success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2, + }) + expect(wrapper.text()).not.toContain('Зависимые проекты') + }) +}) + +// ───────────────────────────────────────────────────────────── +// TaskDetail — dependents_deployed как string[] +// ───────────────────────────────────────────────────────────── +describe('TaskDetail — dependents_deployed как string[] (KIN-INFRA-011)', () => { + it('dep рендерится напрямую как строка — project_id виден в тексте', async () => { + const wrapper = await mountTaskDetailAndDeploy({ + success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2, + dependents_deployed: ['BARSIK'], + overall_success: true, + }) + expect(wrapper.text()).toContain('BARSIK') + }) + + it('несколько dep-строк — все project_id видны', async () => { + const wrapper = await mountTaskDetailAndDeploy({ + success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2, + dependents_deployed: ['PROJ-X', 'PROJ-Y'], + overall_success: true, + }) + expect(wrapper.text()).toContain('PROJ-X') + expect(wrapper.text()).toContain('PROJ-Y') + }) + + it('статичный бейдж "ok" отображается для каждого dependent', async () => { + const wrapper = await mountTaskDetailAndDeploy({ + success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2, + dependents_deployed: ['SVC-1', 'SVC-2'], + overall_success: true, + }) + expect(wrapper.text()).toContain('Зависимые проекты') + const okBadges = wrapper.findAll('span.text-teal-400').filter(el => el.text().trim() === 'ok') + expect(okBadges.length).toBeGreaterThanOrEqual(2) + }) + + it('пустой dependents_deployed — секция "Зависимые проекты" не отображается', async () => { + const wrapper = await mountTaskDetailAndDeploy({ + success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2, + dependents_deployed: [], + overall_success: true, + }) + expect(wrapper.text()).not.toContain('Зависимые проекты') + }) + + it('секция зависимых видна когда dependents_deployed непустой', async () => { + const wrapper = await mountTaskDetailAndDeploy({ + success: true, exit_code: 0, stdout: '', stderr: '', duration_seconds: 2, + dependents_deployed: ['SOME-DEP'], + overall_success: true, + }) + expect(wrapper.text()).toContain('Зависимые проекты:') + expect(wrapper.text()).toContain('SOME-DEP') + }) +}) diff --git a/web/frontend/src/__tests__/deploy-standardized.test.ts b/web/frontend/src/__tests__/deploy-standardized.test.ts index 7a90e83..11afe92 100644 --- a/web/frontend/src/__tests__/deploy-standardized.test.ts +++ b/web/frontend/src/__tests__/deploy-standardized.test.ts @@ -479,14 +479,12 @@ describe('ProjectView — Deploy результат', () => { stderr: '', duration_seconds: 5, results: [{ step: 'git pull', stdout: 'ok', stderr: '', exit_code: 0 }], - dependents_deployed: [ - { project_id: 'FE', project_name: 'Frontend App', success: true, results: [] }, - ], + dependents_deployed: ['FRONTEND-APP'], overall_success: true, } const wrapper = await clickDeploy(result) expect(wrapper.text()).toContain('Зависимые проекты') - expect(wrapper.text()).toContain('Frontend App') + expect(wrapper.text()).toContain('FRONTEND-APP') }) it('overall_success=false → Deploy failed даже если success=true', async () => { @@ -497,16 +495,14 @@ describe('ProjectView — Deploy результат', () => { stderr: '', duration_seconds: 5, results: [{ step: 'git pull', stdout: 'ok', stderr: '', exit_code: 0 }], - dependents_deployed: [ - { project_id: 'FE', project_name: 'Frontend', success: false, results: [] }, - ], + dependents_deployed: ['FE'], overall_success: false, } const wrapper = await clickDeploy(result) expect(wrapper.text()).toContain('Deploy failed') }) - it('dependents показывают статус "ok" или "fail" для каждого проекта', async () => { + it('dependents_deployed рендерит каждый project_id строкой с бейджем ok', async () => { const result = { success: true, exit_code: 0, @@ -514,15 +510,12 @@ describe('ProjectView — Deploy результат', () => { stderr: '', duration_seconds: 5, results: [], - dependents_deployed: [ - { project_id: 'FE', project_name: 'FrontendOK', success: true, results: [] }, - { project_id: 'BE2', project_name: 'ServiceFail', success: false, results: [] }, - ], - overall_success: false, + dependents_deployed: ['FE', 'BE2'], + overall_success: true, } const wrapper = await clickDeploy(result) - expect(wrapper.text()).toContain('FrontendOK') - expect(wrapper.text()).toContain('ServiceFail') + expect(wrapper.text()).toContain('FE') + expect(wrapper.text()).toContain('BE2') }) }) @@ -734,7 +727,7 @@ describe('Граничные кейсы', () => { expect(wrapper.find('[class*="text-red"]').exists()).toBe(false) }) - it('overall_success=false с одним failed dependent → "Deploy failed" и dependent виден', async () => { + it('overall_success=false с dependents → "Deploy failed" и project_id виден', async () => { const project = { ...BASE_PROJECT, deploy_host: 'srv', deploy_path: '/app', deploy_runtime: 'docker' } vi.mocked(api.deployProject).mockResolvedValue({ success: true, @@ -743,9 +736,7 @@ describe('Граничные кейсы', () => { stderr: '', duration_seconds: 5, results: [{ step: 'git pull', stdout: 'ok', stderr: '', exit_code: 0 }], - dependents_deployed: [ - { project_id: 'FE', project_name: 'FrontendFailed', success: false, results: [] }, - ], + dependents_deployed: ['FE'], overall_success: false, } as any) const wrapper = await mountProjectView(project) @@ -753,7 +744,7 @@ describe('Граничные кейсы', () => { await deployBtn!.trigger('click') await flushPromises() expect(wrapper.text()).toContain('Deploy failed') - expect(wrapper.text()).toContain('FrontendFailed') + expect(wrapper.text()).toContain('FE') }) it('только deploy_host без deploy_path/runtime — кнопка Deploy disabled', async () => { diff --git a/web/frontend/src/api.ts b/web/frontend/src/api.ts index 8c2e8a0..4c6ce29 100644 --- a/web/frontend/src/api.ts +++ b/web/frontend/src/api.ts @@ -69,6 +69,7 @@ export interface Project { execution_mode: string | null autocommit_enabled: number | null auto_test_enabled: number | null + worktrees_enabled: number | null obsidian_vault_path: string | null deploy_command: string | null test_command: string | null @@ -167,13 +168,6 @@ export interface DeployStepResult { exit_code: number } -export interface DependentDeploy { - project_id: string - project_name: string - success: boolean - results: DeployStepResult[] -} - export interface DeployResult { success: boolean exit_code: number @@ -182,7 +176,8 @@ export interface DeployResult { duration_seconds: number steps?: string[] results?: DeployStepResult[] - dependents_deployed?: DependentDeploy[] + // WARNING (decision #546): бэкенд возвращает list[str] (project_id), не объекты + dependents_deployed?: string[] overall_success?: boolean } @@ -363,7 +358,7 @@ export const api = { post<{ updated: string[]; count: number }>(`/projects/${projectId}/audit/apply`, { task_ids: taskIds }), patchTask: (id: string, data: { status?: string; execution_mode?: string; priority?: number; route_type?: string; title?: string; brief_text?: string; acceptance_criteria?: string }) => patch(`/tasks/${id}`, data), - patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; auto_test_enabled?: boolean; obsidian_vault_path?: string; deploy_command?: string; test_command?: string; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string; deploy_host?: string; deploy_path?: string; deploy_runtime?: string; deploy_restart_cmd?: string }) => + patchProject: (id: string, data: { execution_mode?: string; autocommit_enabled?: boolean; auto_test_enabled?: boolean; worktrees_enabled?: boolean; obsidian_vault_path?: string; deploy_command?: string; test_command?: string; project_type?: string; ssh_host?: string; ssh_user?: string; ssh_key_path?: string; ssh_proxy_jump?: string; deploy_host?: string; deploy_path?: string; deploy_runtime?: string; deploy_restart_cmd?: string }) => patch(`/projects/${id}`, data), deployProject: (projectId: string) => post(`/projects/${projectId}/deploy`, {}), diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 01f5f0f..2b8fc17 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -823,24 +823,10 @@ async function addDecision() {

Зависимые проекты:

-
- - {{ dep.success ? 'ok' : 'fail' }} - {{ dep.project_name }} - -
-
- - {{ step.exit_code === 0 ? 'ok' : 'fail' }} - {{ step.step }} - -
-
{{ step.stdout }}
-
{{ step.stderr }}
-
-
-
-
+
+ ok + {{ dep }} +
diff --git a/web/frontend/src/views/SettingsView.vue b/web/frontend/src/views/SettingsView.vue index 88b3984..eaeec8e 100644 --- a/web/frontend/src/views/SettingsView.vue +++ b/web/frontend/src/views/SettingsView.vue @@ -7,13 +7,16 @@ const vaultPaths = ref>({}) const deployCommands = ref>({}) const testCommands = ref>({}) const autoTestEnabled = ref>({}) +const worktreesEnabled = ref>({}) const saving = ref>({}) const savingTest = ref>({}) const savingAutoTest = ref>({}) +const savingWorktrees = ref>({}) const syncing = ref>({}) const saveStatus = ref>({}) const saveTestStatus = ref>({}) const saveAutoTestStatus = ref>({}) +const saveWorktreesStatus = ref>({}) const syncResults = ref>({}) const error = ref(null) @@ -43,6 +46,7 @@ onMounted(async () => { deployCommands.value[p.id] = p.deploy_command ?? '' testCommands.value[p.id] = p.test_command ?? '' autoTestEnabled.value[p.id] = !!(p.auto_test_enabled) + worktreesEnabled.value[p.id] = !!(p.worktrees_enabled) deployHosts.value[p.id] = p.deploy_host ?? '' deployPaths.value[p.id] = p.deploy_path ?? '' deployRuntimes.value[p.id] = p.deploy_runtime ?? '' @@ -116,6 +120,21 @@ async function toggleAutoTest(projectId: string) { } } +async function toggleWorktrees(projectId: string) { + worktreesEnabled.value[projectId] = !worktreesEnabled.value[projectId] + savingWorktrees.value[projectId] = true + saveWorktreesStatus.value[projectId] = '' + try { + await api.patchProject(projectId, { worktrees_enabled: worktreesEnabled.value[projectId] }) + saveWorktreesStatus.value[projectId] = 'Saved' + } catch (e: unknown) { + worktreesEnabled.value[projectId] = !worktreesEnabled.value[projectId] + saveWorktreesStatus.value[projectId] = `Error: ${e instanceof Error ? e.message : String(e)}` + } finally { + savingWorktrees.value[projectId] = false + } +} + async function runSync(projectId: string) { syncing.value[projectId] = true syncResults.value[projectId] = null @@ -371,6 +390,23 @@ async function deleteLink(projectId: string, linkId: number) {
+
+ + + {{ saveWorktreesStatus[project.id] }} + +
+
diff --git a/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts b/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts new file mode 100644 index 0000000..abd1810 --- /dev/null +++ b/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts @@ -0,0 +1,203 @@ +/** + * KIN-103: Тесты worktrees_enabled toggle в SettingsView + * + * Проверяет: + * 1. Интерфейс Project содержит worktrees_enabled: number | null + * 2. patchProject принимает worktrees_enabled?: boolean + * 3. Инициализацию из числовых значений (0, 1, null) → !!() + * 4. Toggle вызывает PATCH с true/false + * 5. Откат при ошибке PATCH + * 6. Checkbox disabled пока идёт сохранение + * 7. Статусные сообщения (Saved / Error) + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import SettingsView from '../SettingsView.vue' + +vi.mock('../../api', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + api: { + projects: vi.fn(), + patchProject: vi.fn(), + syncObsidian: vi.fn(), + projectLinks: vi.fn().mockResolvedValue([]), + }, + } +}) + +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, + 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.patchProject).mockResolvedValue(BASE_PROJECT as any) +}) + +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 +} + +function findWorktreesCheckbox(wrapper: ReturnType) { + const labels = wrapper.findAll('label') + const worktreesLabel = labels.find(l => l.text().includes('Worktrees')) + const checkbox = worktreesLabel?.find('input[type="checkbox"]') + // decision #544: assertion безусловная — ложный зелёный недопустим + expect(checkbox?.exists()).toBe(true) + return checkbox! +} + +// ───────────────────────────────────────────────────────────── +// 1. Инициализация из числовых значений +// ───────────────────────────────────────────────────────────── +describe('worktreesEnabled — инициализация', () => { + it('worktrees_enabled=1 → checkbox checked', async () => { + const wrapper = await mountSettings({ worktrees_enabled: 1 }) + const checkbox = findWorktreesCheckbox(wrapper) + expect((checkbox.element as HTMLInputElement).checked).toBe(true) + }) + + it('worktrees_enabled=0 → checkbox unchecked', async () => { + const wrapper = await mountSettings({ worktrees_enabled: 0 }) + const checkbox = findWorktreesCheckbox(wrapper) + expect((checkbox.element as HTMLInputElement).checked).toBe(false) + }) + + it('worktrees_enabled=null → checkbox unchecked', async () => { + const wrapper = await mountSettings({ worktrees_enabled: null }) + const checkbox = findWorktreesCheckbox(wrapper) + expect((checkbox.element as HTMLInputElement).checked).toBe(false) + }) +}) + +// ───────────────────────────────────────────────────────────── +// 2. Toggle → patchProject вызывается с корректным значением +// ───────────────────────────────────────────────────────────── +describe('toggleWorktrees — вызов patchProject', () => { + it('toggle с unchecked → patchProject({ worktrees_enabled: true })', async () => { + const wrapper = await mountSettings({ worktrees_enabled: 0 }) + await findWorktreesCheckbox(wrapper).trigger('change') + await flushPromises() + + expect(vi.mocked(api.patchProject)).toHaveBeenCalledWith( + 'proj-1', + expect.objectContaining({ worktrees_enabled: true }), + ) + }) + + it('toggle с checked → patchProject({ worktrees_enabled: false }), не undefined (decision #524)', async () => { + const wrapper = await mountSettings({ worktrees_enabled: 1 }) + await findWorktreesCheckbox(wrapper).trigger('change') + await flushPromises() + + const payload = vi.mocked(api.patchProject).mock.calls[0][1] as any + expect(payload.worktrees_enabled).toBe(false) + expect(payload.worktrees_enabled).not.toBeUndefined() + }) +}) + +// ───────────────────────────────────────────────────────────── +// 3. Откат при ошибке PATCH +// ───────────────────────────────────────────────────────────── +describe('toggleWorktrees — откат при ошибке PATCH', () => { + it('ошибка при включении: checkbox откатывается обратно к unchecked', async () => { + vi.mocked(api.patchProject).mockRejectedValueOnce(new Error('network error')) + const wrapper = await mountSettings({ worktrees_enabled: 0 }) + await findWorktreesCheckbox(wrapper).trigger('change') + await flushPromises() + + expect((findWorktreesCheckbox(wrapper).element as HTMLInputElement).checked).toBe(false) + }) + + it('ошибка при выключении: checkbox откатывается обратно к checked', async () => { + vi.mocked(api.patchProject).mockRejectedValueOnce(new Error('server error')) + const wrapper = await mountSettings({ worktrees_enabled: 1 }) + await findWorktreesCheckbox(wrapper).trigger('change') + await flushPromises() + + expect((findWorktreesCheckbox(wrapper).element as HTMLInputElement).checked).toBe(true) + }) +}) + +// ───────────────────────────────────────────────────────────── +// 4. Disabled во время сохранения +// ───────────────────────────────────────────────────────────── +describe('toggleWorktrees — disabled во время сохранения', () => { + it('checkbox disabled пока идёт PATCH, enabled после завершения', async () => { + let resolveRequest!: (v: any) => void + vi.mocked(api.patchProject).mockImplementationOnce( + () => new Promise(resolve => { resolveRequest = resolve }), + ) + + const wrapper = await mountSettings({ worktrees_enabled: 0 }) + const checkbox = findWorktreesCheckbox(wrapper) + await checkbox.trigger('change') + + // savingWorktrees = true → checkbox должен быть disabled + expect((checkbox.element as HTMLInputElement).disabled).toBe(true) + + resolveRequest(BASE_PROJECT) + await flushPromises() + + expect((checkbox.element as HTMLInputElement).disabled).toBe(false) + }) +}) + +// ───────────────────────────────────────────────────────────── +// 5. Статусные сообщения +// ───────────────────────────────────────────────────────────── +describe('toggleWorktrees — статусное сообщение', () => { + it('успех → показывает "Saved"', async () => { + const wrapper = await mountSettings({ worktrees_enabled: 0 }) + await findWorktreesCheckbox(wrapper).trigger('change') + await flushPromises() + + expect(wrapper.text()).toContain('Saved') + }) + + it('ошибка → показывает "Error: "', async () => { + vi.mocked(api.patchProject).mockRejectedValueOnce(new Error('connection timeout')) + const wrapper = await mountSettings({ worktrees_enabled: 0 }) + await findWorktreesCheckbox(wrapper).trigger('change') + await flushPromises() + + expect(wrapper.text()).toContain('Error: connection timeout') + }) +})