From 4fd825dc58dd458ae2aa478a94f77d3fdc91a60c Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Mon, 16 Mar 2026 07:19:59 +0200 Subject: [PATCH] =?UTF-8?q?kin:=20KIN-013=20=D0=9D=D0=B0=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B2=20GUI:=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20Settings=20=D1=81=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BD=D1=84=D0=B8=D0=B3=D1=83=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2?= =?UTF-8?q?.=20=D0=9F=D1=83=D1=82=D1=8C=20=D0=BA=20Obsidian=20vault=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=81=D0=B8=D0=BD=D1=85=D1=80=D0=BE=D0=BD?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20decisions/tasks/kanban.?= =?UTF-8?q?=20=D0=94=D0=B2=D1=83=D1=81=D1=82=D0=BE=D1=80=D0=BE=D0=BD=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20sync:=20decisions=20=E2=86=92=20Obsidian=20.md,?= =?UTF-8?q?=20Obsidian=20=D1=87=D0=B5=D0=BA=D0=B1=D0=BE=D0=BA=D1=81=D1=8B?= =?UTF-8?q?=20=E2=86=92=20tasks.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_obsidian_sync.py | 73 ++++++++ .../src/__tests__/filter-persistence.test.ts | 168 ++++++++++++++++++ 2 files changed, 241 insertions(+) diff --git a/tests/test_obsidian_sync.py b/tests/test_obsidian_sync.py index 0b5eeea..b0e3027 100644 --- a/tests/test_obsidian_sync.py +++ b/tests/test_obsidian_sync.py @@ -15,6 +15,18 @@ from core.obsidian_sync import ( from core import models +# --------------------------------------------------------------------------- +# 0. Migration — obsidian_vault_path column must exist after init_db +# --------------------------------------------------------------------------- + +def test_migration_obsidian_vault_path_column_exists(): + """init_db создаёт или мигрирует колонку obsidian_vault_path в таблице projects.""" + conn = init_db(db_path=":memory:") + cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()} + conn.close() + assert "obsidian_vault_path" in cols + + @pytest.fixture def tmp_vault(tmp_path): """Returns a temporary vault root directory.""" @@ -184,3 +196,64 @@ def test_sync_no_vault_path(db): # project exists but obsidian_vault_path is NULL with pytest.raises(ValueError, match="obsidian_vault_path not set"): sync_obsidian(db, "proj1") + + +# --------------------------------------------------------------------------- +# 8. export — frontmatter обёрнут в разделители --- +# --------------------------------------------------------------------------- + +def test_export_frontmatter_has_yaml_delimiters(tmp_vault): + """Экспортированный файл начинается с '---' и содержит закрывающий '---'.""" + decisions = [ + { + "id": 99, + "project_id": "p", + "type": "decision", + "category": None, + "title": "YAML Delimiter Test", + "description": "Verifying frontmatter delimiters.", + "tags": [], + "created_at": "2026-01-01", + } + ] + tmp_vault.mkdir(parents=True) + created = export_decisions_to_md("p", decisions, tmp_vault) + content = created[0].read_text(encoding="utf-8") + + assert content.startswith("---\n"), "Frontmatter должен начинаться с '---\\n'" + # первые --- открывают, вторые --- закрывают frontmatter + parts = content.split("---\n") + assert len(parts) >= 3, "Должно быть минимум два разделителя '---'" + + +# --------------------------------------------------------------------------- +# 9. sync_obsidian — несуществующий vault_path → ошибка в errors, не исключение +# --------------------------------------------------------------------------- + +def test_sync_nonexistent_vault_records_error(db, tmp_path): + """Если vault_path не существует, sync возвращает ошибку в errors без raise.""" + nonexistent = tmp_path / "ghost_vault" + models.update_project(db, "proj1", obsidian_vault_path=str(nonexistent)) + + result = sync_obsidian(db, "proj1") + + assert len(result["errors"]) > 0 + assert "does not exist" in result["errors"][0].lower() or "not exist" in result["errors"][0].lower() + assert result["exported_decisions"] == 0 + assert result["tasks_updated"] == 0 + + +# --------------------------------------------------------------------------- +# 10. sync_obsidian — пустой vault → 0 экспортов, 0 обновлений, нет ошибок +# --------------------------------------------------------------------------- + +def test_sync_empty_vault_no_errors(db, tmp_vault): + """Пустой vault (нет decisions, нет task-файлов) → exported=0, updated=0, errors=[].""" + tmp_vault.mkdir(parents=True) + models.update_project(db, "proj1", obsidian_vault_path=str(tmp_vault)) + + result = sync_obsidian(db, "proj1") + + assert result["exported_decisions"] == 0 + assert result["tasks_updated"] == 0 + assert result["errors"] == [] diff --git a/web/frontend/src/__tests__/filter-persistence.test.ts b/web/frontend/src/__tests__/filter-persistence.test.ts index e788444..bc40bf0 100644 --- a/web/frontend/src/__tests__/filter-persistence.test.ts +++ b/web/frontend/src/__tests__/filter-persistence.test.ts @@ -623,3 +623,171 @@ describe('KIN-065: ProjectView — Autocommit toggle', () => { expect(wrapper.text()).toContain('Network error') }) }) + +// ───────────────────────────────────────────────────────────── +// KIN-015: TaskDetail — Edit button и форма редактирования +// ───────────────────────────────────────────────────────────── + +describe('KIN-015: TaskDetail — Edit button и форма редактирования', () => { + function makePendingTask(overrides: Record = {}) { + return { + ...MOCK_TASK_FULL, + id: 'KIN-015', + project_id: 'KIN', + title: 'Pending Task', + status: 'pending', + priority: 5, + brief: { text: 'Описание задачи', route_type: 'feature' }, + execution_mode: null, + ...overrides, + } + } + + beforeEach(() => { + vi.mocked(api.patchTask).mockReset() + }) + + it('Кнопка Edit видна для задачи со статусом pending', async () => { + vi.mocked(api.taskFull).mockResolvedValue(makePendingTask() as any) + const router = makeRouter() + await router.push('/task/KIN-015') + + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-015' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const editBtn = wrapper.findAll('button').find(b => b.text().includes('Edit')) + expect(editBtn?.exists(), 'Кнопка Edit должна быть видна для pending').toBe(true) + }) + + it('Кнопка Edit скрыта для статуса in_progress', async () => { + vi.mocked(api.taskFull).mockResolvedValue(makePendingTask({ status: 'in_progress' }) as any) + const router = makeRouter() + await router.push('/task/KIN-015') + + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-015' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const editBtn = wrapper.findAll('button').find(b => b.text().includes('Edit')) + expect(editBtn?.exists(), 'Кнопка Edit не должна быть видна для in_progress').toBe(false) + }) + + it('Кнопка Edit скрыта для статуса done', async () => { + vi.mocked(api.taskFull).mockResolvedValue(makePendingTask({ status: 'done' }) as any) + const router = makeRouter() + await router.push('/task/KIN-015') + + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-015' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const editBtn = wrapper.findAll('button').find(b => b.text().includes('Edit')) + expect(editBtn?.exists(), 'Кнопка Edit не должна быть видна для done').toBe(false) + }) + + it('Клик по Edit открывает форму с заполненным заголовком задачи', async () => { + vi.mocked(api.taskFull).mockResolvedValue(makePendingTask() as any) + const router = makeRouter() + await router.push('/task/KIN-015') + + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-015' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const editBtn = wrapper.findAll('button').find(b => b.text().includes('Edit')) + await editBtn!.trigger('click') + await flushPromises() + + // Модал открыт — поле title (input без type) содержит текущий заголовок + const titleInput = wrapper.find('input:not([type])') + expect(titleInput.exists(), 'Поле Title должно быть видно в модале').toBe(true) + expect((titleInput.element as HTMLInputElement).value).toBe('Pending Task') + }) + + it('saveEdit вызывает patchTask только с изменёнными полями (только title)', async () => { + vi.mocked(api.taskFull).mockResolvedValue(makePendingTask() as any) + vi.mocked(api.patchTask).mockResolvedValue(makePendingTask({ title: 'Новый заголовок' }) as any) + const router = makeRouter() + await router.push('/task/KIN-015') + + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-015' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const editBtn = wrapper.findAll('button').find(b => b.text().includes('Edit')) + await editBtn!.trigger('click') + await flushPromises() + + // Меняем только title + const titleInput = wrapper.find('input:not([type])') + await titleInput.setValue('Новый заголовок') + + const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save')) + await saveBtn!.trigger('click') + await flushPromises() + + expect(api.patchTask).toHaveBeenCalledWith('KIN-015', { title: 'Новый заголовок' }) + }) + + it('saveEdit не вызывает patchTask если данные не изменились', async () => { + vi.mocked(api.taskFull).mockResolvedValue(makePendingTask() as any) + const router = makeRouter() + await router.push('/task/KIN-015') + + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-015' }, + global: { plugins: [router] }, + }) + await flushPromises() + + // Открываем модал без изменений + const editBtn = wrapper.findAll('button').find(b => b.text().includes('Edit')) + await editBtn!.trigger('click') + await flushPromises() + + // Сохраняем без изменений — должен тихо закрыться без API-вызова + const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save')) + await saveBtn!.trigger('click') + await flushPromises() + + expect(api.patchTask, 'patchTask не должен вызываться при пустом diff').not.toHaveBeenCalled() + }) + + it('После успешного сохранения модал закрывается', async () => { + vi.mocked(api.taskFull).mockResolvedValue(makePendingTask() as any) + vi.mocked(api.patchTask).mockResolvedValue(makePendingTask({ title: 'Обновлённый заголовок' }) as any) + const router = makeRouter() + await router.push('/task/KIN-015') + + const wrapper = mount(TaskDetail, { + props: { id: 'KIN-015' }, + global: { plugins: [router] }, + }) + await flushPromises() + + const editBtn = wrapper.findAll('button').find(b => b.text().includes('Edit')) + await editBtn!.trigger('click') + await flushPromises() + + const titleInput = wrapper.find('input:not([type])') + await titleInput.setValue('Обновлённый заголовок') + + const saveBtn = wrapper.findAll('button').find(b => b.text().includes('Save')) + await saveBtn!.trigger('click') + await flushPromises() + + // Модал закрыт — форма с title-input больше не в DOM + expect(wrapper.find('input:not([type])').exists(), 'Форма должна закрыться после сохранения').toBe(false) + }) +})