kin: KIN-120-frontend_dev

This commit is contained in:
Gros Frumos 2026-03-18 14:30:36 +02:00
parent e3a286ef6f
commit a202210b9f
5 changed files with 333 additions and 560 deletions

View file

@ -1,18 +1,20 @@
/**
* KIN-103: Тесты worktrees_enabled toggle в SettingsView
* KIN-120: Тесты SettingsView навигатор по настройкам проектов
*
* После рефакторинга SettingsView стал навигатором:
* показывает список проектов и ссылки на /project/{id}?tab=settings.
* Детальные настройки каждого проекта переехали в ProjectView вкладка Settings.
*
* Проверяет:
* 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)
* 1. Загрузка и отображение списка проектов
* 2. Имя и id проекта видны
* 3. Ссылки ведут на /project/{id}?tab=settings
* 4. execution_mode отображается если задан
*/
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) => {
@ -21,9 +23,6 @@ vi.mock('../../api', async (importOriginal) => {
...actual,
api: {
projects: vi.fn(),
patchProject: vi.fn(),
syncObsidian: vi.fn(),
projectLinks: vi.fn().mockResolvedValue([]),
},
}
})
@ -37,7 +36,7 @@ const BASE_PROJECT = {
status: 'active',
priority: 5,
tech_stack: ['python'],
execution_mode: null,
execution_mode: null as string | null,
autocommit_enabled: null,
auto_test_enabled: null,
worktrees_enabled: null as number | null,
@ -62,142 +61,65 @@ const BASE_PROJECT = {
description: null,
}
function makeRouter() {
return createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/settings', component: SettingsView },
{ path: '/project/:id', component: { template: '<div />' } },
],
})
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(api.patchProject).mockResolvedValue(BASE_PROJECT as any)
})
async function mountSettings(overrides: Partial<typeof BASE_PROJECT> = {}) {
const project = { ...BASE_PROJECT, ...overrides }
vi.mocked(api.projects).mockResolvedValue([project as any])
const wrapper = mount(SettingsView)
const router = makeRouter()
await router.push('/settings')
const wrapper = mount(SettingsView, { global: { plugins: [router] } })
await flushPromises()
return wrapper
}
function findWorktreesCheckbox(wrapper: ReturnType<typeof mount>) {
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)
describe('SettingsView — навигатор', () => {
it('показывает имя проекта', async () => {
const wrapper = await mountSettings()
expect(wrapper.text()).toContain('Test Project')
})
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('показывает id проекта', async () => {
const wrapper = await mountSettings()
expect(wrapper.text()).toContain('proj-1')
})
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: <message>"', 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')
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')
})
})