diff --git a/core/obsidian_sync.py b/core/obsidian_sync.py index da4256c..1f4d2b6 100644 --- a/core/obsidian_sync.py +++ b/core/obsidian_sync.py @@ -90,7 +90,7 @@ def parse_task_checkboxes( Returns: [{"task_id": "KIN-013", "done": True, "title": "..."}] """ - pattern = re.compile(r"^[-*]\s+\[([xX ])\]\s+([A-Z][A-Z0-9]*-\d+)\s+(.+)$") + pattern = re.compile(r"^[-*]\s+\[([xX ])\]\s+([A-Z][A-Z0-9]*-(?:[A-Z][A-Z0-9]*-)?\d+)\s+(.+)$") results: list[dict] = [] search_dirs = [ diff --git a/tests/test_models.py b/tests/test_models.py index 33ba1c2..59157c4 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,8 +1,10 @@ """Tests for core/models.py — all functions, in-memory SQLite.""" +import re import pytest from core.db import init_db from core import models +from core.models import TASK_CATEGORIES @pytest.fixture @@ -330,3 +332,126 @@ def test_add_decision_if_new_skips_whitespace_duplicate(conn): result = models.add_decision_if_new(conn, "p1", "convention", " Run tests after each change ", "desc2") assert result is None assert len(models.get_decisions(conn, "p1")) == 1 + + +# -- next_task_id (KIN-OBS-009) -- + +def test_next_task_id_with_category_first(conn): + """Первая задача с category='SEC' → 'VDOL-SEC-001'.""" + models.create_project(conn, "vdol", "VDOL", "/vdol") + task_id = models.next_task_id(conn, "vdol", category="SEC") + assert task_id == "VDOL-SEC-001" + + +def test_next_task_id_with_category_increments(conn): + """Вторая задача с category='SEC' → 'VDOL-SEC-002'.""" + models.create_project(conn, "vdol", "VDOL", "/vdol") + models.create_task(conn, "VDOL-SEC-001", "vdol", "Task 1", category="SEC") + task_id = models.next_task_id(conn, "vdol", category="SEC") + assert task_id == "VDOL-SEC-002" + + +def test_next_task_id_category_counters_independent(conn): + """Счётчики категорий независимы: SEC-002 не влияет на UI-001.""" + models.create_project(conn, "vdol", "VDOL", "/vdol") + models.create_task(conn, "VDOL-SEC-001", "vdol", "Sec Task 1", category="SEC") + models.create_task(conn, "VDOL-SEC-002", "vdol", "Sec Task 2", category="SEC") + task_id = models.next_task_id(conn, "vdol", category="UI") + assert task_id == "VDOL-UI-001" + + +def test_next_task_id_without_category_backward_compat(conn): + """Задача без category → 'VDOL-001' (backward compat).""" + models.create_project(conn, "vdol", "VDOL", "/vdol") + task_id = models.next_task_id(conn, "vdol") + assert task_id == "VDOL-001" + + +def test_next_task_id_mixed_formats_no_collision(conn): + """Смешанный проект: счётчики старого и нового форматов не пересекаются.""" + models.create_project(conn, "kin", "KIN", "/kin") + models.create_task(conn, "KIN-001", "kin", "Old style task") + models.create_task(conn, "KIN-002", "kin", "Old style task 2") + # Новый формат с категорией не мешает старому + cat_id = models.next_task_id(conn, "kin", category="OBS") + assert cat_id == "KIN-OBS-001" + # Старый формат не мешает новому + old_id = models.next_task_id(conn, "kin") + assert old_id == "KIN-003" + + +# -- Obsidian sync regex (KIN-OBS-009, решение #75) -- + +_OBSIDIAN_TASK_PATTERN = re.compile( + r"^[-*]\s+\[([xX ])\]\s+([A-Z][A-Z0-9]*-(?:[A-Z][A-Z0-9]*-)?\d+)\s+(.+)$" +) + + +def test_obsidian_regex_matches_old_format(): + """Старый формат KIN-001 матчится.""" + m = _OBSIDIAN_TASK_PATTERN.match("- [x] KIN-001 Fix login bug") + assert m is not None + assert m.group(2) == "KIN-001" + + +def test_obsidian_regex_matches_new_format(): + """Новый формат VDOL-SEC-001 матчится.""" + m = _OBSIDIAN_TASK_PATTERN.match("- [ ] VDOL-SEC-001 Security audit") + assert m is not None + assert m.group(2) == "VDOL-SEC-001" + + +def test_obsidian_regex_matches_obs_format(): + """Формат KIN-OBS-009 матчится (проверяем задачу этой фичи).""" + m = _OBSIDIAN_TASK_PATTERN.match("* [X] KIN-OBS-009 Task ID по категориям") + assert m is not None + assert m.group(2) == "KIN-OBS-009" + + +def test_obsidian_regex_no_match_lowercase(): + """Нижний регистр не матчится.""" + assert _OBSIDIAN_TASK_PATTERN.match("- [x] proj-001 lowercase id") is None + + +def test_obsidian_regex_no_match_numeric_prefix(): + """Числовой префикс не матчится.""" + assert _OBSIDIAN_TASK_PATTERN.match("- [x] 123-abc invalid format") is None + + +def test_obsidian_regex_done_state(conn): + """Статус done/pending корректно извлекается.""" + m_done = _OBSIDIAN_TASK_PATTERN.match("- [x] KIN-UI-003 Done task") + m_pending = _OBSIDIAN_TASK_PATTERN.match("- [ ] KIN-UI-004 Pending task") + assert m_done.group(1) == "x" + assert m_pending.group(1) == " " + + +# -- next_task_id для всех 12 категорий (KIN-OBS-009) -- + +@pytest.mark.parametrize("cat", TASK_CATEGORIES) +def test_next_task_id_all_categories_generate_correct_format(conn, cat): + """next_task_id генерирует ID формата PROJ-CAT-001 для каждой из 12 категорий.""" + models.create_project(conn, "vdol", "VDOL", "/vdol") + task_id = models.next_task_id(conn, "vdol", category=cat) + assert task_id == f"VDOL-{cat}-001" + + +# -- update_task category не ломает brief (KIN-OBS-009, решение #74) -- + +def test_update_task_category_preserves_brief(conn): + """update_task(category=...) не перетирает существующее поле brief.""" + models.create_project(conn, "p1", "P1", "/p1") + models.create_task(conn, "P1-001", "p1", "Task", brief={"summary": "important context"}) + updated = models.update_task(conn, "P1-001", category="SEC") + assert updated["category"] == "SEC" + assert updated["brief"] == {"summary": "important context"} + + +def test_update_task_category_preserves_status_and_priority(conn): + """update_task(category=...) не меняет остальные поля задачи.""" + models.create_project(conn, "p1", "P1", "/p1") + models.create_task(conn, "P1-001", "p1", "Task", status="in_progress", priority=3) + updated = models.update_task(conn, "P1-001", category="UI") + assert updated["category"] == "UI" + assert updated["status"] == "in_progress" + assert updated["priority"] == 3 diff --git a/web/frontend/src/views/ProjectView.vue b/web/frontend/src/views/ProjectView.vue index b057a5a..dd0811f 100644 --- a/web/frontend/src/views/ProjectView.vue +++ b/web/frontend/src/views/ProjectView.vue @@ -117,8 +117,14 @@ async function applyAudit() { } // Add task modal +const TASK_CATEGORIES = ['SEC', 'UI', 'API', 'INFRA', 'BIZ', 'DB', 'ARCH', 'TEST', 'PERF', 'DOCS', 'FIX', 'OBS'] +const CATEGORY_COLORS: Record = { + SEC: 'red', UI: 'blue', API: 'green', INFRA: 'orange', BIZ: 'purple', + DB: 'yellow', ARCH: 'gray', TEST: 'purple', PERF: 'orange', DOCS: 'gray', + FIX: 'red', OBS: 'blue', +} const showAddTask = ref(false) -const taskForm = ref({ title: '', priority: 5, route_type: '' }) +const taskForm = ref({ title: '', priority: 5, route_type: '', category: '' }) const taskFormError = ref('') // Add decision modal @@ -206,9 +212,10 @@ async function addTask() { title: taskForm.value.title, priority: taskForm.value.priority, route_type: taskForm.value.route_type || undefined, + category: taskForm.value.category || undefined, }) showAddTask.value = false - taskForm.value = { title: '', priority: 5, route_type: '' } + taskForm.value = { title: '', priority: 5, route_type: '', category: '' } await load() } catch (e: any) { taskFormError.value = e.message @@ -354,6 +361,7 @@ async function addDecision() {
{{ t.id }} + {{ t.title }} escalated from {{ t.parent_task_id }}
@@ -373,6 +381,7 @@ async function addDecision() {
{{ t.id }} + {{ t.title }} +