kin: KIN-OBS-009 Task ID по категориям: PROJ-CAT-NUM (VDOL-SEC-001, VDOL-UI-003, VDOL-API-002, VDOL-INFRA-001, VDOL-BIZ-001). PM назначает категорию при создании задачи.

This commit is contained in:
Gros Frumos 2026-03-16 08:34:30 +02:00
parent d50bd703ae
commit 81f974e6d3
3 changed files with 142 additions and 3 deletions

View file

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

View file

@ -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

View file

@ -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<string, string> = {
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() {
<div class="flex items-center gap-2 min-w-0">
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>
<Badge :text="t.status" :color="taskStatusColor(t.status)" />
<Badge v-if="t.category" :text="t.category" :color="CATEGORY_COLORS[t.category] || 'gray'" />
<span class="text-orange-300 truncate">{{ t.title }}</span>
<span v-if="t.parent_task_id" class="text-[10px] text-gray-600 shrink-0">escalated from {{ t.parent_task_id }}</span>
</div>
@ -373,6 +381,7 @@ async function addDecision() {
<div class="flex items-center gap-2 min-w-0">
<span class="text-gray-500 shrink-0 w-24">{{ t.id }}</span>
<Badge :text="t.status" :color="taskStatusColor(t.status)" />
<Badge v-if="t.category" :text="t.category" :color="CATEGORY_COLORS[t.category] || 'gray'" />
<span class="text-gray-300 truncate">{{ t.title }}</span>
<span v-if="t.execution_mode === 'auto'"
class="text-[10px] px-1 py-0.5 bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded shrink-0"
@ -471,6 +480,11 @@ async function addDecision() {
<form @submit.prevent="addTask" class="space-y-3">
<input v-model="taskForm.title" placeholder="Task title" required
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600" />
<select v-model="taskForm.category"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300">
<option value="">No category (old format: PROJ-001)</option>
<option v-for="cat in TASK_CATEGORIES" :key="cat" :value="cat">{{ cat }}</option>
</select>
<select v-model="taskForm.route_type"
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-300">
<option value="">No type</option>