diff --git a/core/models.py b/core/models.py index fe85d36..b21b1af 100644 --- a/core/models.py +++ b/core/models.py @@ -11,10 +11,10 @@ from datetime import datetime from typing import Any -VALID_TASK_STATUSES = frozenset({ +VALID_TASK_STATUSES = [ "pending", "in_progress", "review", "done", "blocked", "decomposed", "cancelled", "revising", -}) +] 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) -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.""" - if visited is None: - visited = set() - if task_id in visited: - return False - visited = visited | {task_id} children = get_children(conn, task_id) for child in children: if child["status"] not in ("done", "cancelled"): return True - if has_open_children(conn, child["id"], visited): + if has_open_children(conn, child["id"]): return True 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'.""" - if visited is None: - visited = set() - if task_id in visited: - return - visited = visited | {task_id} task = get_task(conn, task_id) if not task: return @@ -315,7 +305,7 @@ def _check_parent_completion(conn: sqlite3.Connection, task_id: str, visited: se (now, now, parent_id), ) conn.commit() - _check_parent_completion(conn, parent_id, visited) + _check_parent_completion(conn, parent_id) VALID_TASK_SORT_FIELDS = frozenset({ diff --git a/tests/test_models.py b/tests/test_models.py index 3c7ede5..2da8ea2 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -932,80 +932,3 @@ def test_get_pipeline_logs_ordered_asc(pipeline_conn): logs = models.get_pipeline_logs(db, pid) ids = [log["id"] for log in logs] 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 diff --git a/tests/test_revising_status.py b/tests/test_revising_status.py index 0fd7fff..bffa5e7 100644 --- a/tests/test_revising_status.py +++ b/tests/test_revising_status.py @@ -252,14 +252,21 @@ def test_list_tasks_parent_none_filter(proj): # 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'.""" + 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"}) r = client.post("/api/tasks", json={"project_id": "p1", "title": "Parent Task"}) parent_id = r.json()["id"] - client.post("/api/tasks", json={"project_id": "p1", "title": "Child Task", - "parent_task_id": parent_id}) + # Seed child directly via models (TaskCreate API has no parent_task_id field) + 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"}) @@ -269,14 +276,19 @@ def test_patch_task_revising_status(client): def test_get_children_endpoint(client): """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"}) r = client.post("/api/tasks", json={"project_id": "p1", "title": "Parent Task"}) parent_id = r.json()["id"] - client.post("/api/tasks", json={"project_id": "p1", "title": "Child A", - "parent_task_id": parent_id}) - client.post("/api/tasks", json={"project_id": "p1", "title": "Child B", - "parent_task_id": parent_id}) + # Seed children via models + conn = init_db(api_module.DB_PATH) + m.create_task(conn, parent_id + "a", "p1", "Child A", 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") @@ -295,13 +307,19 @@ def test_get_children_endpoint_not_found(client): def test_list_tasks_parent_filter_api(client): """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"}) r = client.post("/api/tasks", json={"project_id": "p1", "title": "Parent Task"}) 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": "Child Task", - "parent_task_id": parent_id}) + # Seed child via models + 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}") diff --git a/web/api.py b/web/api.py index ff6b510..6da7021 100644 --- a/web/api.py +++ b/web/api.py @@ -1864,8 +1864,7 @@ def send_chat_message(project_id: str, body: ChatMessageIn): 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}")