diff --git a/web/frontend/src/i18n.ts b/web/frontend/src/i18n.ts index a9dd2c8..a3ed00c 100644 --- a/web/frontend/src/i18n.ts +++ b/web/frontend/src/i18n.ts @@ -2,9 +2,7 @@ import { createI18n } from 'vue-i18n' import ru from './locales/ru.json' import en from './locales/en.json' -const savedLocale = (typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function' - ? localStorage.getItem('kin-locale') - : null) || 'en' +const savedLocale = localStorage.getItem('kin-locale') || 'ru' export const i18n = createI18n({ legacy: false, diff --git a/web/frontend/src/locales/en.json b/web/frontend/src/locales/en.json index 2aee55f..39ab613 100644 --- a/web/frontend/src/locales/en.json +++ b/web/frontend/src/locales/en.json @@ -89,7 +89,7 @@ "back_to_project": "← Project", "chat_label": "— chat", "loading": "Loading...", - "server_unavailable": "Сервер недоступен. Проверьте подключение.", + "server_unavailable": "Server unavailable. Check your connection.", "empty_hint": "Describe a task or ask about the project status", "input_placeholder": "Describe a task or question... (Enter — send, Shift+Enter — newline)", "send": "Send", @@ -97,8 +97,6 @@ }, "settings": { "title": "Settings", - "navigate_hint": "Each project's settings are available in its own Settings tab.", - "open_settings": "Open Settings", "obsidian_vault_path": "Obsidian Vault Path", "test_command": "Test Command", "test_command_hint": "Test run command, executed via shell in the project directory.", @@ -159,10 +157,10 @@ "revise_placeholder": "What to revise or clarify...", "autopilot_active": "Autopilot active", "attachments": "Attachments", - "more_details": "↓ подробнее", + "more_details": "↓ more details", "terminal_login_hint": "Open a terminal and run:", "login_after_hint": "After login, retry the pipeline.", - "dependent_projects": "Зависимые проекты:", + "dependent_projects": "Dependent projects:", "decision_title_placeholder": "Decision title (optional)", "description_placeholder": "Description", "brief_label": "Brief", @@ -181,8 +179,7 @@ "kanban_tab": "Kanban", "links_tab": "Links", "add_task": "+ Task", - "audit_backlog": "Аудит бэклога", - "kanban_add_task": "+ Тас", + "audit_backlog": "Audit backlog", "back": "← back", "deploy": "Deploy", "kanban_pending": "Pending", @@ -197,8 +194,7 @@ "worktrees_on": "Worktrees: on", "worktrees_off": "Worktrees: off", "all_statuses": "All", - "search_placeholder": "Поиск по задачам...", - "kanban_search_placeholder": "Поиск...", + "search_placeholder": "Search tasks...", "manual_escalations_warn": "⚠ Require manual resolution", "comment_required": "Comment required", "select_project": "Select project", @@ -210,14 +206,7 @@ "loading_phases": "Loading phases...", "revise_modal_title": "Revise phase", "reject_modal_title": "Reject phase", - "add_link_title": "Add link", - "settings_tab": "Settings", - "settings_agent_section": "Agent Execution", - "settings_deploy_section": "Deploy", - "settings_integrations_section": "Integrations", - "settings_execution_mode": "Execution mode", - "settings_autocommit": "Autocommit", - "settings_autocommit_hint": "— git commit after pipeline" + "add_link_title": "Add link" }, "escalation": { "watchdog_blocked": "Watchdog: task {task_id} blocked — {reason}", @@ -227,10 +216,10 @@ "dismiss": "Dismiss" }, "liveConsole": { - "hide_log": "▲ Скрыть лог", - "show_log": "▼ Показать лог", - "no_records": "Нет записей...", - "error_prefix": "Ошибка:" + "hide_log": "▲ Hide log", + "show_log": "▼ Show log", + "no_records": "No records...", + "error_prefix": "Error:" }, "attachments": { "images_only": "Only images are supported", diff --git a/web/frontend/src/locales/ru.json b/web/frontend/src/locales/ru.json index caca3a5..083c363 100644 --- a/web/frontend/src/locales/ru.json +++ b/web/frontend/src/locales/ru.json @@ -97,8 +97,6 @@ }, "settings": { "title": "Настройки", - "navigate_hint": "Настройки каждого проекта доступны в его собственной вкладке «Настройки».", - "open_settings": "Открыть настройки", "obsidian_vault_path": "Путь к Obsidian Vault", "test_command": "Команда тестирования", "test_command_hint": "Команда запуска тестов, выполняется через shell в директории проекта.", @@ -182,7 +180,6 @@ "links_tab": "Связи", "add_task": "+ Задача", "audit_backlog": "Аудит бэклога", - "kanban_add_task": "+ Тас", "back": "← назад", "deploy": "Деплой", "kanban_pending": "Ожидает", @@ -198,7 +195,6 @@ "worktrees_off": "Worktrees: выкл", "all_statuses": "Все", "search_placeholder": "Поиск по задачам...", - "kanban_search_placeholder": "Поиск...", "manual_escalations_warn": "⚠ Требуют ручного решения", "comment_required": "Комментарий обязателен", "select_project": "Выберите проект", @@ -210,14 +206,7 @@ "loading_phases": "Загрузка фаз...", "revise_modal_title": "Доработать фазу", "reject_modal_title": "Отклонить фазу", - "add_link_title": "Добавить связь", - "settings_tab": "Настройки", - "settings_agent_section": "Запуск агентов", - "settings_deploy_section": "Деплой", - "settings_integrations_section": "Интеграции", - "settings_execution_mode": "Режим выполнения", - "settings_autocommit": "Автокоммит", - "settings_autocommit_hint": "— git commit после pipeline" + "add_link_title": "Добавить связь" }, "escalation": { "watchdog_blocked": "Watchdog: задача {task_id} заблокирована — {reason}", diff --git a/web/frontend/src/views/ChatView.vue b/web/frontend/src/views/ChatView.vue index 5007976..f4443a2 100644 --- a/web/frontend/src/views/ChatView.vue +++ b/web/frontend/src/views/ChatView.vue @@ -45,7 +45,7 @@ function checkAndPoll() { if (!hasRunningTasks(updated)) stopPoll() } catch (e: any) { consecutiveErrors.value++ - console.warn(`[polling] ошибка #${consecutiveErrors.value}:`, e) + console.warn('[polling] error #' + consecutiveErrors.value + ':', e) if (consecutiveErrors.value >= 3) { error.value = t('chat.server_unavailable') stopPoll() diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index 28bb0d0..346dbd0 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -2,7 +2,7 @@ import { ref, onMounted, onUnmounted, computed, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' -import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task, type ProjectEnvironment, type DeployResult, type ProjectLink, type ObsidianSyncResult } from '../api' +import { api, ApiError, type ProjectDetail, type AuditResult, type Phase, type Task, type ProjectEnvironment, type DeployResult, type ProjectLink } from '../api' import Badge from '../components/Badge.vue' import Modal from '../components/Modal.vue' @@ -14,7 +14,7 @@ const { t } = useI18n() const project = ref(null) const loading = ref(true) const error = ref('') -const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban' | 'environments' | 'links' | 'settings'>('tasks') +const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules' | 'kanban' | 'environments' | 'links'>('tasks') // Phases const phases = ref([]) @@ -276,99 +276,6 @@ async function toggleWorktrees() { } } -// Settings form -const settingsForm = ref({ - execution_mode: 'review', - autocommit_enabled: false, - auto_test_enabled: false, - worktrees_enabled: false, - test_command: '', - deploy_host: '', - deploy_path: '', - deploy_runtime: '', - deploy_restart_cmd: '', - deploy_command: '', - obsidian_vault_path: '', - ssh_host: '', - ssh_user: '', - ssh_key_path: '', - ssh_proxy_jump: '', -}) -const settingsSaving = ref(false) -const settingsSaveStatus = ref('') -const syncingObsidian = ref(false) -const obsidianSyncResult = ref(null) - -function loadSettingsForm() { - if (!project.value) return - settingsForm.value = { - execution_mode: project.value.execution_mode ?? 'review', - autocommit_enabled: !!(project.value.autocommit_enabled), - auto_test_enabled: !!(project.value.auto_test_enabled), - worktrees_enabled: !!(project.value.worktrees_enabled), - test_command: project.value.test_command ?? '', - deploy_host: project.value.deploy_host ?? '', - deploy_path: project.value.deploy_path ?? '', - deploy_runtime: project.value.deploy_runtime ?? '', - deploy_restart_cmd: project.value.deploy_restart_cmd ?? '', - deploy_command: project.value.deploy_command ?? '', - obsidian_vault_path: project.value.obsidian_vault_path ?? '', - ssh_host: project.value.ssh_host ?? '', - ssh_user: project.value.ssh_user ?? '', - ssh_key_path: project.value.ssh_key_path ?? '', - ssh_proxy_jump: project.value.ssh_proxy_jump ?? '', - } -} - -async function saveSettings() { - settingsSaving.value = true - settingsSaveStatus.value = '' - try { - const updated = await api.patchProject(props.id, { - execution_mode: settingsForm.value.execution_mode, - autocommit_enabled: settingsForm.value.autocommit_enabled, - auto_test_enabled: settingsForm.value.auto_test_enabled, - worktrees_enabled: settingsForm.value.worktrees_enabled, - test_command: settingsForm.value.test_command, - deploy_host: settingsForm.value.deploy_host, - deploy_path: settingsForm.value.deploy_path, - deploy_runtime: settingsForm.value.deploy_runtime, - deploy_restart_cmd: settingsForm.value.deploy_restart_cmd, - deploy_command: settingsForm.value.deploy_command, - obsidian_vault_path: settingsForm.value.obsidian_vault_path, - ssh_host: settingsForm.value.ssh_host, - ssh_user: settingsForm.value.ssh_user, - ssh_key_path: settingsForm.value.ssh_key_path, - ssh_proxy_jump: settingsForm.value.ssh_proxy_jump, - }) - if (project.value) { - project.value = { ...project.value, ...updated } - loadMode() - loadAutocommit() - loadAutoTest() - loadWorktrees() - } - settingsSaveStatus.value = t('common.saved') - } catch (e: any) { - settingsSaveStatus.value = `${t('common.error')}: ${e.message}` - } finally { - settingsSaving.value = false - } -} - -async function syncObsidianVault() { - syncingObsidian.value = true - obsidianSyncResult.value = null - try { - await api.patchProject(props.id, { obsidian_vault_path: settingsForm.value.obsidian_vault_path }) - obsidianSyncResult.value = await api.syncObsidian(props.id) - } catch (e: any) { - settingsSaveStatus.value = `${t('common.error')}: ${e.message}` - } finally { - syncingObsidian.value = false - } -} - // Audit const auditLoading = ref(false) const auditResult = ref(null) @@ -597,7 +504,6 @@ async function load() { loadAutocommit() loadAutoTest() loadWorktrees() - loadSettingsForm() } catch (e: any) { error.value = e.message } finally { @@ -628,9 +534,6 @@ onMounted(async () => { const all = await api.projects() allProjects.value = all.map(p => ({ id: p.id, name: p.name })) } catch {} - if (route.query.tab === 'settings') { - activeTab.value = 'settings' - } }) onUnmounted(() => { @@ -971,13 +874,13 @@ async function addDecision() {
-
@@ -1324,7 +1226,7 @@ async function addDecision() {
- @@ -1370,7 +1272,7 @@ async function addDecision() {
@@ -1542,138 +1444,6 @@ async function addDecision() {
- -
- - -
-

{{ t('projectView.settings_agent_section') }}

-
-
- - -
- - - -
- - -

{{ t('settings.test_command_hint') }}

-
-
-
- - -
-

{{ t('projectView.settings_deploy_section') }}

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-

{{ t('projectView.settings_integrations_section') }}

-
-
- -
- - -
-
- {{ obsidianSyncResult.exported_decisions }} decisions - {{ obsidianSyncResult.tasks_updated }} tasks -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
- - - {{ settingsSaveStatus }} - -
-
-
diff --git a/web/frontend/src/views/SettingsView.vue b/web/frontend/src/views/SettingsView.vue index 3b59070..42a91ff 100644 --- a/web/frontend/src/views/SettingsView.vue +++ b/web/frontend/src/views/SettingsView.vue @@ -206,11 +206,6 @@ async function deleteLink(projectId: string, linkId: number) {
{{ project.name }} {{ project.id }} - {{ project.execution_mode }} - - {{ t('settings.open_settings') }} -
diff --git a/web/frontend/src/views/__tests__/ProjectView.settings.test.ts b/web/frontend/src/views/__tests__/ProjectView.settings.test.ts deleted file mode 100644 index ae59fd3..0000000 --- a/web/frontend/src/views/__tests__/ProjectView.settings.test.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * KIN-120: Тесты ProjectView — вкладка Settings - * - * Проверяет: - * 1. Вкладка Settings активируется при route.query.tab=settings - * 2. Вкладка Settings не показывается по умолчанию (tasks активен) - * 3. Форма настроек заполняется данными из проекта - * 4. Поля deploy_host, ssh_host присутствуют в Settings - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { mount, flushPromises } from '@vue/test-utils' -import { createRouter, createMemoryHistory } from 'vue-router' -import ProjectView from '../ProjectView.vue' - -vi.mock('../../api', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - api: { - project: vi.fn(), - projects: vi.fn(), - getPhases: vi.fn(), - environments: vi.fn(), - projectLinks: vi.fn(), - patchProject: vi.fn(), - syncObsidian: vi.fn(), - }, - } -}) - -import { api } from '../../api' - -// localStorage mock (required: ProjectView calls localStorage synchronously in setup) -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 }) - -const BASE_PROJECT_DETAIL = { - id: 'proj-1', - name: 'Test Project', - path: '/projects/test', - status: 'active', - priority: 5, - tech_stack: ['python'], - execution_mode: 'review', - autocommit_enabled: 0, - auto_test_enabled: 0, - worktrees_enabled: 0, - obsidian_vault_path: '/vault/test', - deploy_command: 'git push', - test_command: 'make test', - deploy_host: 'vdp-prod', - deploy_path: '/srv/proj', - deploy_runtime: 'python', - deploy_restart_cmd: '', - created_at: '2024-01-01', - total_tasks: 0, - done_tasks: 0, - active_tasks: 0, - blocked_tasks: 0, - review_tasks: 0, - project_type: 'development', - ssh_host: 'my-ssh-server', - ssh_user: 'root', - ssh_key_path: '~/.ssh/id_rsa', - ssh_proxy_jump: 'jumpt', - description: null, - tasks: [], - modules: [], - decisions: [], -} - -function makeRouter() { - return createRouter({ - history: createMemoryHistory(), - routes: [ - { path: '/project/:id', component: ProjectView, props: true }, - ], - }) -} - -beforeEach(() => { - localStorageMock.clear() - vi.clearAllMocks() - vi.mocked(api.project).mockResolvedValue(BASE_PROJECT_DETAIL as any) - vi.mocked(api.projects).mockResolvedValue([]) - vi.mocked(api.getPhases).mockResolvedValue([]) - vi.mocked(api.environments).mockResolvedValue([]) - vi.mocked(api.projectLinks).mockResolvedValue([]) - vi.mocked(api.patchProject).mockResolvedValue(BASE_PROJECT_DETAIL as any) -}) - -describe('ProjectView — вкладка Settings', () => { - it('вкладка settings активируется при route.query.tab=settings', async () => { - const router = makeRouter() - await router.push('/project/proj-1?tab=settings') - const wrapper = mount(ProjectView, { - props: { id: 'proj-1' }, - global: { plugins: [router] }, - }) - await flushPromises() - - // execution_mode select с опциями review/auto_complete — только в settings tab - const selects = wrapper.findAll('select') - const modeSelect = selects.find(s => - s.findAll('option').some(o => o.attributes('value') === 'auto_complete') - ) - expect(modeSelect?.exists()).toBe(true) - }) - - it('вкладка settings не открывается без query tab=settings', async () => { - const router = makeRouter() - await router.push('/project/proj-1') - const wrapper = mount(ProjectView, { - props: { id: 'proj-1' }, - global: { plugins: [router] }, - }) - await flushPromises() - - // settings form должна быть скрыта (tasks tab по умолчанию) - const selects = wrapper.findAll('select') - const modeSelect = selects.find(s => - s.findAll('option').some(o => o.attributes('value') === 'auto_complete') - ) - expect(modeSelect).toBeUndefined() - }) - - it('форма settings заполняется test_command из проекта', async () => { - const router = makeRouter() - await router.push('/project/proj-1?tab=settings') - const wrapper = mount(ProjectView, { - props: { id: 'proj-1' }, - global: { plugins: [router] }, - }) - await flushPromises() - - const testCommandInput = wrapper.find('input[placeholder="make test"]') - expect(testCommandInput.exists()).toBe(true) - expect((testCommandInput.element as HTMLInputElement).value).toBe('make test') - }) - - it('форма settings заполняется deploy_host из проекта', async () => { - const router = makeRouter() - await router.push('/project/proj-1?tab=settings') - const wrapper = mount(ProjectView, { - props: { id: 'proj-1' }, - global: { plugins: [router] }, - }) - await flushPromises() - - const deployHostInput = wrapper.find('input[placeholder="vdp-prod"]') - expect(deployHostInput.exists()).toBe(true) - expect((deployHostInput.element as HTMLInputElement).value).toBe('vdp-prod') - }) - - it('форма settings показывает и заполняет ssh_key_path из проекта', async () => { - const router = makeRouter() - await router.push('/project/proj-1?tab=settings') - const wrapper = mount(ProjectView, { - props: { id: 'proj-1' }, - global: { plugins: [router] }, - }) - await flushPromises() - - // ssh_key_path имеет уникальный placeholder, это надёжный способ найти SSH секцию - const sshKeyInput = wrapper.find('input[placeholder="~/.ssh/id_rsa"]') - expect(sshKeyInput.exists()).toBe(true) - expect((sshKeyInput.element as HTMLInputElement).value).toBe('~/.ssh/id_rsa') - }) - - it('форма settings заполняет execution_mode из проекта', async () => { - vi.mocked(api.project).mockResolvedValue({ - ...BASE_PROJECT_DETAIL, - execution_mode: 'auto_complete', - } as any) - - const router = makeRouter() - await router.push('/project/proj-1?tab=settings') - const wrapper = mount(ProjectView, { - props: { id: 'proj-1' }, - global: { plugins: [router] }, - }) - await flushPromises() - - const selects = wrapper.findAll('select') - const modeSelect = selects.find(s => - s.findAll('option').some(o => o.attributes('value') === 'auto_complete') - ) - expect(modeSelect?.exists()).toBe(true) - expect((modeSelect!.element as HTMLSelectElement).value).toBe('auto_complete') - }) -}) diff --git a/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts b/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts index 1b7274b..abd1810 100644 --- a/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts +++ b/web/frontend/src/views/__tests__/SettingsView.worktrees.test.ts @@ -1,20 +1,18 @@ /** - * KIN-120: Тесты SettingsView — навигатор по настройкам проектов - * - * После рефакторинга SettingsView стал навигатором: - * показывает список проектов и ссылки на /project/{id}?tab=settings. - * Детальные настройки каждого проекта переехали в ProjectView → вкладка Settings. + * KIN-103: Тесты worktrees_enabled toggle в SettingsView * * Проверяет: - * 1. Загрузка и отображение списка проектов - * 2. Имя и id проекта видны - * 3. Ссылки ведут на /project/{id}?tab=settings - * 4. execution_mode отображается если задан + * 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 { createRouter, createMemoryHistory } from 'vue-router' import SettingsView from '../SettingsView.vue' vi.mock('../../api', async (importOriginal) => { @@ -23,8 +21,9 @@ vi.mock('../../api', async (importOriginal) => { ...actual, api: { projects: vi.fn(), - projectLinks: vi.fn(), patchProject: vi.fn(), + syncObsidian: vi.fn(), + projectLinks: vi.fn().mockResolvedValue([]), }, } }) @@ -38,7 +37,7 @@ const BASE_PROJECT = { status: 'active', priority: 5, tech_stack: ['python'], - execution_mode: null as string | null, + execution_mode: null, autocommit_enabled: null, auto_test_enabled: null, worktrees_enabled: null as number | null, @@ -63,140 +62,142 @@ const BASE_PROJECT = { description: null, } -function makeRouter() { - return createRouter({ - history: createMemoryHistory(), - routes: [ - { path: '/settings', component: SettingsView }, - { path: '/project/:id', component: { template: '
' } }, - ], - }) -} - beforeEach(() => { vi.clearAllMocks() - vi.mocked(api.projectLinks).mockResolvedValue([]) - vi.mocked(api.patchProject).mockResolvedValue({} as any) + 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 router = makeRouter() - await router.push('/settings') - const wrapper = mount(SettingsView, { global: { plugins: [router] } }) + const wrapper = mount(SettingsView) await flushPromises() return wrapper } -describe('SettingsView — навигатор', () => { - it('показывает имя проекта', async () => { - const wrapper = await mountSettings() - expect(wrapper.text()).toContain('Test Project') - }) - - it('показывает id проекта', async () => { - const wrapper = await mountSettings() - expect(wrapper.text()).toContain('proj-1') - }) - - it('содержит ссылку на страницу настроек проекта', async () => { - const wrapper = await mountSettings() - const links = wrapper.findAll('a') - expect(links.length).toBeGreaterThan(0) - const settingsLink = links.find(l => l.attributes('href')?.includes('proj-1')) - expect(settingsLink?.exists()).toBe(true) - expect(settingsLink?.attributes('href')).toContain('settings') - }) - - it('ссылка ведёт на /project/{id} с tab=settings', async () => { - const wrapper = await mountSettings() - const link = wrapper.find('a[href*="proj-1"]') - expect(link.exists()).toBe(true) - expect(link.attributes('href')).toMatch(/\/project\/proj-1/) - expect(link.attributes('href')).toContain('settings') - }) - - it('показывает execution_mode если задан', async () => { - const wrapper = await mountSettings({ execution_mode: 'auto_complete' }) - expect(wrapper.text()).toContain('auto_complete') - }) - - it('не показывает execution_mode если null', async () => { - const wrapper = await mountSettings({ execution_mode: null }) - 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 +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! } -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') +// ───────────────────────────────────────────────────────────── +// 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('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('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('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') + 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) }) }) -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') +// ───────────────────────────────────────────────────────────── +// 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('показывает поле 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('toggle с checked → patchProject({ worktrees_enabled: false }), не undefined (decision #524)', async () => { + const wrapper = await mountSettings({ worktrees_enabled: 1 }) + await findWorktreesCheckbox(wrapper).trigger('change') + await flushPromises() - 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') + 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') }) })