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:
parent
d50bd703ae
commit
81f974e6d3
3 changed files with 142 additions and 3 deletions
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue