kin: KIN-013 Настройки в GUI: страница Settings с конфигурацией проектов. Путь к Obsidian vault для синхронизации decisions/tasks/kanban. Двусторонний sync: decisions → Obsidian .md, Obsidian чекбоксы → tasks.

This commit is contained in:
Gros Frumos 2026-03-16 07:19:59 +02:00
parent 6b328d7f2d
commit 4fd825dc58
2 changed files with 241 additions and 0 deletions

View file

@ -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"] == []

View file

@ -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<string, unknown> = {}) {
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)
})
})