diff --git a/web/frontend/src/views/__tests__/SettingsView.error-css.test.ts b/web/frontend/src/views/__tests__/SettingsView.error-css.test.ts new file mode 100644 index 0000000..4f2c960 --- /dev/null +++ b/web/frontend/src/views/__tests__/SettingsView.error-css.test.ts @@ -0,0 +1,216 @@ +/** + * KIN-UI-013: Регрессионный тест — CSS-класс ошибки в SettingsView + * + * До фикса: .startsWith('Error') — хардкод английской строки, ломался при смене локали. + * После фикса: .startsWith(t('common.error')) — использует i18n-ключ. + * + * Проверяет: + * 1. Литеральный .startsWith('Error') отсутствует в SettingsView.vue + * 2. При ошибке API — статусный span получает CSS-класс text-red-400 + * 3. При успехе API — статусный span получает CSS-класс text-green-400 + * 4. Покрывает все 5 статусных полей: + * saveStatus, saveTestStatus, saveDeployConfigStatus, + * saveAutoTestStatus, saveWorktreesStatus + */ + +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') + const content = readFileSync(filePath, 'utf-8') + expect(content).not.toContain(".startsWith('Error')") + }) + + it('не содержит .startsWith("Error") (двойные кавычки)', () => { + const filePath = resolve(__dirname, '../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:/) + }) +})