Compare commits
No commits in common. "cfc4a6ba7da4076615f4d6b31a77c4ea5ad31d78" and "859d2ac0f644d308d0fcdc7ca22962bca5ab947d" have entirely different histories.
cfc4a6ba7d
...
859d2ac0f6
4 changed files with 34 additions and 104 deletions
|
|
@ -11,10 +11,10 @@ from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
VALID_TASK_STATUSES = frozenset({
|
VALID_TASK_STATUSES = [
|
||||||
"pending", "in_progress", "review", "done",
|
"pending", "in_progress", "review", "done",
|
||||||
"blocked", "decomposed", "cancelled", "revising",
|
"blocked", "decomposed", "cancelled", "revising",
|
||||||
})
|
]
|
||||||
|
|
||||||
VALID_COMPLETION_MODES = {"auto_complete", "review"}
|
VALID_COMPLETION_MODES = {"auto_complete", "review"}
|
||||||
|
|
||||||
|
|
@ -274,29 +274,19 @@ def get_children(conn: sqlite3.Connection, task_id: str) -> list[dict]:
|
||||||
return _rows_to_list(rows)
|
return _rows_to_list(rows)
|
||||||
|
|
||||||
|
|
||||||
def has_open_children(conn: sqlite3.Connection, task_id: str, visited: set[str] | None = None) -> bool:
|
def has_open_children(conn: sqlite3.Connection, task_id: str) -> bool:
|
||||||
"""Recursively check if task has any open (not done/cancelled) descendants."""
|
"""Recursively check if task has any open (not done/cancelled) descendants."""
|
||||||
if visited is None:
|
|
||||||
visited = set()
|
|
||||||
if task_id in visited:
|
|
||||||
return False
|
|
||||||
visited = visited | {task_id}
|
|
||||||
children = get_children(conn, task_id)
|
children = get_children(conn, task_id)
|
||||||
for child in children:
|
for child in children:
|
||||||
if child["status"] not in ("done", "cancelled"):
|
if child["status"] not in ("done", "cancelled"):
|
||||||
return True
|
return True
|
||||||
if has_open_children(conn, child["id"], visited):
|
if has_open_children(conn, child["id"]):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _check_parent_completion(conn: sqlite3.Connection, task_id: str, visited: set[str] | None = None) -> None:
|
def _check_parent_completion(conn: sqlite3.Connection, task_id: str) -> None:
|
||||||
"""Cascade-check upward: if parent is 'revising' and all children closed → promote to 'done'."""
|
"""Cascade-check upward: if parent is 'revising' and all children closed → promote to 'done'."""
|
||||||
if visited is None:
|
|
||||||
visited = set()
|
|
||||||
if task_id in visited:
|
|
||||||
return
|
|
||||||
visited = visited | {task_id}
|
|
||||||
task = get_task(conn, task_id)
|
task = get_task(conn, task_id)
|
||||||
if not task:
|
if not task:
|
||||||
return
|
return
|
||||||
|
|
@ -315,7 +305,7 @@ def _check_parent_completion(conn: sqlite3.Connection, task_id: str, visited: se
|
||||||
(now, now, parent_id),
|
(now, now, parent_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
_check_parent_completion(conn, parent_id, visited)
|
_check_parent_completion(conn, parent_id)
|
||||||
|
|
||||||
|
|
||||||
VALID_TASK_SORT_FIELDS = frozenset({
|
VALID_TASK_SORT_FIELDS = frozenset({
|
||||||
|
|
|
||||||
|
|
@ -932,80 +932,3 @@ def test_get_pipeline_logs_ordered_asc(pipeline_conn):
|
||||||
logs = models.get_pipeline_logs(db, pid)
|
logs = models.get_pipeline_logs(db, pid)
|
||||||
ids = [log["id"] for log in logs]
|
ids = [log["id"] for log in logs]
|
||||||
assert ids == sorted(ids)
|
assert ids == sorted(ids)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# KIN-UI-018: Защита от circular references в has_open_children /
|
|
||||||
# _check_parent_completion (decision #816, #817)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_circular_reference_protection_has_open_children_returns_false(conn):
|
|
||||||
"""KIN-UI-018 (decision #816): has_open_children возвращает False при циклической ссылке A→B→A.
|
|
||||||
|
|
||||||
Задачи A и B создаются напрямую в БД с взаимными parent_task_id.
|
|
||||||
Ожидаемый результат: False (не True, не RecursionError).
|
|
||||||
"""
|
|
||||||
models.create_project(conn, "p1", "P1", "/p1")
|
|
||||||
# Создаём задачи без parent сначала
|
|
||||||
models.create_task(conn, "P1-CYC-A", "p1", "Task A")
|
|
||||||
models.create_task(conn, "P1-CYC-B", "p1", "Task B")
|
|
||||||
# Устанавливаем цикл напрямую в БД, минуя валидацию API
|
|
||||||
conn.execute("UPDATE tasks SET parent_task_id = 'P1-CYC-B' WHERE id = 'P1-CYC-A'")
|
|
||||||
conn.execute("UPDATE tasks SET parent_task_id = 'P1-CYC-A' WHERE id = 'P1-CYC-B'")
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
result_a = models.has_open_children(conn, "P1-CYC-A")
|
|
||||||
result_b = models.has_open_children(conn, "P1-CYC-B")
|
|
||||||
|
|
||||||
assert result_a is False
|
|
||||||
assert result_b is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_circular_reference_protection_check_parent_completion_returns_without_error(conn):
|
|
||||||
"""KIN-UI-018 (decision #817): _check_parent_completion не падает и не зависает при цикле A→B→A.
|
|
||||||
|
|
||||||
Задачи A и B в статусе 'revising' с взаимными parent_task_id.
|
|
||||||
Ожидаемый результат: возврат без RecursionError, статус задач не изменился.
|
|
||||||
"""
|
|
||||||
models.create_project(conn, "p1", "P1", "/p1")
|
|
||||||
models.create_task(conn, "P1-CPC-A", "p1", "Task A", status="revising")
|
|
||||||
models.create_task(conn, "P1-CPC-B", "p1", "Task B", status="revising")
|
|
||||||
# Устанавливаем цикл напрямую в БД
|
|
||||||
conn.execute("UPDATE tasks SET parent_task_id = 'P1-CPC-B' WHERE id = 'P1-CPC-A'")
|
|
||||||
conn.execute("UPDATE tasks SET parent_task_id = 'P1-CPC-A' WHERE id = 'P1-CPC-B'")
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
# Не должно бросить RecursionError или зависнуть
|
|
||||||
models._check_parent_completion(conn, "P1-CPC-A")
|
|
||||||
models._check_parent_completion(conn, "P1-CPC-B")
|
|
||||||
|
|
||||||
# Статусы не изменились — цикл обнаружен и прерван
|
|
||||||
task_a = models.get_task(conn, "P1-CPC-A")
|
|
||||||
task_b = models.get_task(conn, "P1-CPC-B")
|
|
||||||
assert task_a["status"] == "revising"
|
|
||||||
assert task_b["status"] == "revising"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# KIN-UI-020: VALID_TASK_STATUSES — frozenset membership checks
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def test_valid_task_statuses_is_frozenset():
|
|
||||||
"""KIN-UI-020: VALID_TASK_STATUSES должен быть frozenset, не list."""
|
|
||||||
assert isinstance(models.VALID_TASK_STATUSES, frozenset)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("status", [
|
|
||||||
"pending", "in_progress", "review", "done",
|
|
||||||
"blocked", "decomposed", "cancelled", "revising",
|
|
||||||
])
|
|
||||||
def test_valid_task_statuses_membership(status):
|
|
||||||
"""KIN-UI-020: каждый валидный статус присутствует в VALID_TASK_STATUSES (membership check)."""
|
|
||||||
assert status in models.VALID_TASK_STATUSES
|
|
||||||
|
|
||||||
|
|
||||||
def test_invalid_status_not_in_valid_task_statuses():
|
|
||||||
"""KIN-UI-020: невалидный статус отсутствует в VALID_TASK_STATUSES."""
|
|
||||||
assert "invalid_status" not in models.VALID_TASK_STATUSES
|
|
||||||
assert "" not in models.VALID_TASK_STATUSES
|
|
||||||
assert "active" not in models.VALID_TASK_STATUSES
|
|
||||||
|
|
|
||||||
|
|
@ -252,14 +252,21 @@ def test_list_tasks_parent_none_filter(proj):
|
||||||
# 8. API tests
|
# 8. API tests
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
||||||
def test_patch_task_revising_status(client):
|
def test_patch_task_revising_status(client, tmp_path):
|
||||||
"""PATCH /api/tasks/{id} status=done с открытым child → ответ содержит status='revising'."""
|
"""PATCH /api/tasks/{id} status=done с открытым child → ответ содержит status='revising'."""
|
||||||
|
import web.api as api_module
|
||||||
|
from core.db import init_db
|
||||||
|
from core import models as m
|
||||||
|
|
||||||
client.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"})
|
client.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"})
|
||||||
r = client.post("/api/tasks", json={"project_id": "p1", "title": "Parent Task"})
|
r = client.post("/api/tasks", json={"project_id": "p1", "title": "Parent Task"})
|
||||||
parent_id = r.json()["id"]
|
parent_id = r.json()["id"]
|
||||||
|
|
||||||
client.post("/api/tasks", json={"project_id": "p1", "title": "Child Task",
|
# Seed child directly via models (TaskCreate API has no parent_task_id field)
|
||||||
"parent_task_id": parent_id})
|
conn = init_db(api_module.DB_PATH)
|
||||||
|
m.create_task(conn, parent_id + "a", "p1", "Child Task",
|
||||||
|
parent_task_id=parent_id, status="pending")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
r = client.patch(f"/api/tasks/{parent_id}", json={"status": "done"})
|
r = client.patch(f"/api/tasks/{parent_id}", json={"status": "done"})
|
||||||
|
|
||||||
|
|
@ -269,14 +276,19 @@ def test_patch_task_revising_status(client):
|
||||||
|
|
||||||
def test_get_children_endpoint(client):
|
def test_get_children_endpoint(client):
|
||||||
"""GET /api/tasks/{id}/children возвращает список прямых дочерних задач."""
|
"""GET /api/tasks/{id}/children возвращает список прямых дочерних задач."""
|
||||||
|
import web.api as api_module
|
||||||
|
from core.db import init_db
|
||||||
|
from core import models as m
|
||||||
|
|
||||||
client.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"})
|
client.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"})
|
||||||
r = client.post("/api/tasks", json={"project_id": "p1", "title": "Parent Task"})
|
r = client.post("/api/tasks", json={"project_id": "p1", "title": "Parent Task"})
|
||||||
parent_id = r.json()["id"]
|
parent_id = r.json()["id"]
|
||||||
|
|
||||||
client.post("/api/tasks", json={"project_id": "p1", "title": "Child A",
|
# Seed children via models
|
||||||
"parent_task_id": parent_id})
|
conn = init_db(api_module.DB_PATH)
|
||||||
client.post("/api/tasks", json={"project_id": "p1", "title": "Child B",
|
m.create_task(conn, parent_id + "a", "p1", "Child A", parent_task_id=parent_id)
|
||||||
"parent_task_id": parent_id})
|
m.create_task(conn, parent_id + "b", "p1", "Child B", parent_task_id=parent_id)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
r = client.get(f"/api/tasks/{parent_id}/children")
|
r = client.get(f"/api/tasks/{parent_id}/children")
|
||||||
|
|
||||||
|
|
@ -295,13 +307,19 @@ def test_get_children_endpoint_not_found(client):
|
||||||
|
|
||||||
def test_list_tasks_parent_filter_api(client):
|
def test_list_tasks_parent_filter_api(client):
|
||||||
"""GET /api/tasks?parent_task_id={id} фильтрует задачи по родителю."""
|
"""GET /api/tasks?parent_task_id={id} фильтрует задачи по родителю."""
|
||||||
|
import web.api as api_module
|
||||||
|
from core.db import init_db
|
||||||
|
from core import models as m
|
||||||
|
|
||||||
client.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"})
|
client.post("/api/projects", json={"id": "p1", "name": "P1", "path": "/p1"})
|
||||||
r = client.post("/api/tasks", json={"project_id": "p1", "title": "Parent Task"})
|
r = client.post("/api/tasks", json={"project_id": "p1", "title": "Parent Task"})
|
||||||
parent_id = r.json()["id"]
|
parent_id = r.json()["id"]
|
||||||
client.post("/api/tasks", json={"project_id": "p1", "title": "Other Root Task"})
|
client.post("/api/tasks", json={"project_id": "p1", "title": "Other Root Task"})
|
||||||
|
|
||||||
client.post("/api/tasks", json={"project_id": "p1", "title": "Child Task",
|
# Seed child via models
|
||||||
"parent_task_id": parent_id})
|
conn = init_db(api_module.DB_PATH)
|
||||||
|
m.create_task(conn, parent_id + "a", "p1", "Child Task", parent_task_id=parent_id)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
r = client.get(f"/api/tasks?parent_task_id={parent_id}")
|
r = client.get(f"/api/tasks?parent_task_id={parent_id}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1864,8 +1864,7 @@ def send_chat_message(project_id: str, body: ChatMessageIn):
|
||||||
|
|
||||||
DIST = Path(__file__).parent / "frontend" / "dist"
|
DIST = Path(__file__).parent / "frontend" / "dist"
|
||||||
|
|
||||||
if (DIST / "assets").exists():
|
app.mount("/assets", StaticFiles(directory=str(DIST / "assets")), name="assets")
|
||||||
app.mount("/assets", StaticFiles(directory=str(DIST / "assets")), name="assets")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/{path:path}")
|
@app.get("/{path:path}")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue