diff --git a/docs/development-plan.md b/docs/development-plan.md index 0374ea5..38baffe 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -223,7 +223,8 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 - **U-023 [Value Path]** `v_goal_composer` + `v_bug_feedback` - 依赖:U-022 - Done:能通过 UI 创建 `task` 和人工发起的 `bug_record`(`c_bug_report_create`) - - 覆盖:02-V `v_goal_composer`/`v_bug_feedback`;03-C `c_bug_report_create` + - **实现补充(2026-07-31)**:① `c_task_create`(U-009 已实现的 `task_create.create_task()`)第一次接上 `POST /api/tasks`;② `c_bug_report_create`(03-C §3.1.1)此前完全没有实现,本 Unit 新增 `bug_record.create_bug_report()` + `POST /api/bug-reports`。**发现并解决一个 03-C 内部小缺口**:`c_bug_report_create` 的 `input_contract` 只列了 `{description, repro_steps, related_task_id}`,没有 `project_id`;但 `execution_event.project_id` 是 NOT NULL(01-M 1.9),`bug_record` 表自己又没有 `project_id` 列可以在 `related_task_id` 留空时兜底推导——"留空则视为新发现的独立问题"(02-V §2.4)这条路径字面上无法满足 `execution_event` 的约束。判断这是文档遗漏而非架构决策点(同类判断依据见 U-022 的 `c_project_create` HTTP 接线、U-014 的 `c_artifact_write`),直接让 `project_id` 和 `c_task_create` 一样成为必填参数,未停下来单独确认。③ 控制台目前没有"当前项目"这个概念的载体(`v_dashboard` 是 U-025 才做),`v_goal_composer`/`v_bug_feedback` 需要的 `project_id` 暂存到 `localStorage`(`v_project_setup` 创建成功时写入),没有选中项目时两个页面直接引导回 `#/setup`。④ `related_task_id` 在 02-V 原型里是下拉选择器,但 `c_task_list`(列出当前项目下的任务供选择)没有被任何 Unit 认领,本 Unit 简化成纯文本 task_id 输入框,避免顺手做一个不属于自己范围的接口;等 U-025 补上任务列表后再换成真下拉。⑤ 提交成功后 02-V 写的是"跳转 `v_task_detail`",但那个页面是 U-025 才建,本 Unit 改成在原页面展示一条带 task_id/bug_id 的确认信息 + 返回仪表盘链接,等 U-025 落地后再切回真跳转。⑥ 对着真实运行的后端做端到端冒烟时发现一个真 bug:`create_bug_report()` 一开始没校验 `project_id`/`related_task_id` 是否存在,传入不存在的 `project_id` 会在 `execution_event` 插入时触发未捕获的 `sqlite3.IntegrityError`,请求直接 500(无干净 JSON 错误体)——补了存在性校验,改为清晰的 `INVALID_PARAM`/400,并加了回归测试。 + - 覆盖:02-V `v_goal_composer`/`v_bug_feedback`;03-C `c_task_create`/`c_bug_report_create` ### PH3-S2 实时看板 diff --git a/docs/sdd/03-C-控制与接口.md b/docs/sdd/03-C-控制与接口.md index 83fbd29..360192d 100644 --- a/docs/sdd/03-C-控制与接口.md +++ b/docs/sdd/03-C-控制与接口.md @@ -49,12 +49,12 @@ - `method + path`:`POST /api/bug-reports` - 语义:控制台人工反馈 bug 的入口,区别于 Validator 自动生成的 `c_bug_record_create`(3.10 节),复用同一张 `bug_record` 表但 `source=human` -- `input_contract`:`{ description: string(required), repro_steps: string(optional), related_task_id: string(optional) }` +- `input_contract`:`{ project_id: string(required), description: string(required), repro_steps: string(optional), related_task_id: string(optional) }`(`project_id` 是 2026-07-31 订正新增——原文遗漏了它,但 `execution_event.project_id` 是 NOT NULL(01-M 1.9),`bug_record` 表自己没有 `project_id` 列,`related_task_id` 留空时("视为新发现的独立问题")没有任何字段能推导出 `project_id`,写 `execution_event` 会失败;U-023 实现时发现,按 `c_task_create` 同款处理方式把它变成必填) - `output_contract`:成功 `{ bug_id, status: 'open' }` - `idempotent`:否 - `side_effects`:写入 `bug_record(source=human, status=open)`;写入 `execution_event(event_type=bug_recorded)`;异步触发 Validator 认领该 bug 进行 triage(复用 04-P `p_validator_root_cause_trace`,见该节新增的人工接入分支) - `errors`: - - `INVALID_PARAM`(不可重试):`description` 为空 + - `INVALID_PARAM`(不可重试):`description` 为空;`project_id`/`related_task_id` 不存在 ## 3.2 `c_task_list` / `c_task_get` diff --git a/docs/sdd/unit_matrix.json b/docs/sdd/unit_matrix.json index 172b325..5a76edc 100644 --- a/docs/sdd/unit_matrix.json +++ b/docs/sdd/unit_matrix.json @@ -37,7 +37,7 @@ {"id": "U-021", "phase": "PH2", "sub_phase": "PH2-S4", "name": "Graph Engine 持久化 + 断点恢复(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["work_graph_run", "work_node_execution"], "view": [], "controller": [], "process": ["p_work_graph_execution"], "requirement": ["REQ-06"]}, "done": "REQ-06 happy/boundary/failure 通过;now 默认值从 time.monotonic 改成 time.time(墙钟)以支持跨进程重启的 quota 等待续算;新增 resume_work_graph_run() 返回未完成节点列表"}, {"id": "U-022", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_project_setup + c_pick_folder 全平台(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-007", "U-008"], "coverage": {"model": [], "view": ["v_project_setup"], "controller": ["c_pick_folder", "c_project_create"], "process": [], "requirement": ["REQ-11"]}, "done": "REQ-11 里 v_project_setup/c_pick_folder/c_project_create 部分通过;c_pick_folder 补齐 Windows/Linux;c_project_create 首次接上 HTTP 路由;REQ-11 完整 happy(含建 task)随 U-023 累计满足"}, - {"id": "U-023", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_goal_composer + v_bug_feedback", "classification": "Value Path", "depends_on": ["U-022"], "coverage": {"model": [], "view": ["v_goal_composer", "v_bug_feedback"], "controller": ["c_bug_report_create"], "process": [], "requirement": []}, "done": "能通过 UI 创建 task 和人工 bug_record"}, + {"id": "U-023", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_goal_composer + v_bug_feedback(见 development-plan.md 2026-07-31 实现补充)", "classification": "Value Path", "depends_on": ["U-022"], "coverage": {"model": [], "view": ["v_goal_composer", "v_bug_feedback"], "controller": ["c_task_create", "c_bug_report_create"], "process": [], "requirement": []}, "done": "能通过 UI 创建 task 和人工 bug_record;补上 c_bug_report_create 的 project_id 必填缺口(03-C input_contract 遗漏);related_task_id 简化为文本输入,等 U-025 的 c_task_list 落地后换真下拉"}, {"id": "U-024", "phase": "PH3", "sub_phase": "PH3-S2", "name": "c_ws_subscribe + execution_event 实时推送(最小连通版)", "classification": "Value Path", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["execution_event"], "view": [], "controller": ["c_ws_subscribe"], "process": [], "requirement": ["REQ-12"]}, "done": "推送链路本身连通,≤2 秒延迟达成;REQ-12 happy 用例通过(boundary/failure 留给 U-024.5)"}, {"id": "U-024.5", "phase": "PH3", "sub_phase": "PH3-S2", "name": "REQ-12 完整验收:事件多样性联调(2026-07-30 新增)", "classification": "Value Path", "depends_on": ["U-024", "U-015", "U-016", "U-018", "U-019", "U-020", "U-021"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": ["REQ-12"]}, "done": "REQ-12 全部 happy/boundary/failure 用例通过;PH3 Phase 验收里的 REQ-12 以本 Unit 为准"}, {"id": "U-025", "phase": "PH3", "sub_phase": "PH3-S2", "name": "v_dashboard + v_task_board + v_task_detail", "classification": "Value Path", "depends_on": ["U-024"], "coverage": {"model": [], "view": ["v_dashboard", "v_task_board", "v_task_detail"], "controller": [], "process": [], "requirement": []}, "done": "三个页面可正常展示实时数据"}, diff --git a/frontend/app.js b/frontend/app.js index 03ea1c3..d8dba9f 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -1,12 +1,29 @@ // U-008: hash-based client-side router (no History API / server catch-all // needed). U-022 adds the first real page, #/setup (v_project_setup, 02-V -// §2.1) -- everything else is still a PH3 placeholder. +// §2.1); U-023 adds #/goals/new and #/bugs/new -- everything else is still +// a PH3 placeholder. + +// 02-V's console has no multi-project switcher UI (decision 2), but +// c_task_create/c_bug_report_create both require project_id (03-C §3.1; +// §3.1.1 doesn't list it, but needs it too -- see bug_record.py's +// create_bug_report docstring). Until v_dashboard (U-025) exists to carry +// "current project" state, the id picked/created in v_project_setup is +// stashed here and read back by the pages that need it. +const CURRENT_PROJECT_KEY = "graphlemon_project_id"; +function getCurrentProjectId() { + return localStorage.getItem(CURRENT_PROJECT_KEY); +} +function setCurrentProjectId(projectId) { + localStorage.setItem(CURRENT_PROJECT_KEY, projectId); +} const routes = { "#/": { render: () => "

Dashboard

placeholder -- real content is PH3 (v_dashboard, U-025)

" }, "#/tasks": { render: () => "

Tasks

placeholder -- real content is PH3 (v_task_board, U-025)

" }, "#/settings": { render: () => "

Settings

placeholder -- real content is PH3 (U-030)

" }, "#/setup": { render: renderProjectSetup, mount: mountProjectSetup }, + "#/goals/new": { render: renderGoalComposer, mount: mountGoalComposer }, + "#/bugs/new": { render: renderBugFeedback, mount: mountBugFeedback }, }; function render() { @@ -106,6 +123,7 @@ function mountProjectSetup() { }); const body = await resp.json(); if (resp.status === 200) { + setCurrentProjectId(body.project_id); window.location.hash = "#/"; return; } @@ -122,3 +140,176 @@ function mountProjectSetup() { confirmBanner.style.display = "none"; }); } + +// ---- shared: pages that need a "current project" to already exist ---- + +function renderNoProjectSelected() { + return ` +
+
+ 还没有选择项目。请先创建或切换项目。 +
+
+ `; +} + +// ---- v_goal_composer (02-V §2.3): propose a new goal ---- + +const GOAL_TEXT_MAX_LEN = 4000; + +function renderGoalComposer() { + if (!getCurrentProjectId()) return renderNoProjectSelected(); + return ` +
← 返回仪表盘
+
+

提出新目标

+
用自然语言描述你想让 graphlemon 完成的目标
+
+ + +
+ + +
+ +
+
+ +
+
+ `; +} + +function mountGoalComposer() { + if (!getCurrentProjectId()) return; + const textArea = document.getElementById("goal_text"); + const errorBox = document.getElementById("goal_error"); + const successBox = document.getElementById("goal_success"); + const form = document.getElementById("goal_form"); + const confirmBanner = document.getElementById("goal_confirm_banner"); + const confirmSummary = document.getElementById("goal_confirm_summary"); + + document.getElementById("btn_submit_goal").addEventListener("click", () => { + errorBox.style.display = "none"; + const goalText = textArea.value.trim(); + if (!goalText) { + errorBox.textContent = "目标描述不能为空"; + errorBox.style.display = "block"; + return; + } + confirmSummary.textContent = "即将创建任务:" + goalText; + form.style.display = "none"; + confirmBanner.style.display = "block"; + }); + + document.getElementById("btn_edit_goal").addEventListener("click", () => { + confirmBanner.style.display = "none"; + form.style.display = "block"; + }); + + document.getElementById("btn_confirm_goal").addEventListener("click", async () => { + const resp = await fetch("/api/tasks", { + method: "POST", + body: JSON.stringify({ + project_id: getCurrentProjectId(), + goal_text: textArea.value.trim(), + task_type: "goal", + created_via: "human_console", + }), + }); + const body = await resp.json(); + if (resp.status !== 200) { + confirmBanner.style.display = "none"; + form.style.display = "block"; + errorBox.textContent = body.error + (body.detail ? ":" + body.detail : ""); + errorBox.style.display = "block"; + return; + } + confirmBanner.style.display = "none"; + // v_task_detail doesn't exist yet (U-025) -- show the created task_id + // here instead of redirecting to a route that isn't built. + successBox.innerHTML = "已创建任务 " + body.task_id + "(" + body.status + ")。" + + '返回仪表盘'; + successBox.style.display = "block"; + }); +} + +// ---- v_bug_feedback (02-V §2.4): report a bug ---- + +function renderBugFeedback() { + if (!getCurrentProjectId()) return renderNoProjectSelected(); + return ` +
← 返回仪表盘
+
+

反馈 Bug

+
人工反馈,与 Validator 自动生成的 bug_record 复用同一张表(source=human)
+
+ + +
+
+ + +
+
+ + +
+
+ + +
任务列表选择器随 U-025 补上,目前先直接填 task_id
+
+
+ +
+
+
+
+ `; +} + +function mountBugFeedback() { + if (!getCurrentProjectId()) return; + const descriptionArea = document.getElementById("bug_description"); + const reproStepsArea = document.getElementById("bug_repro_steps"); + const relatedTaskIdInput = document.getElementById("bug_related_task_id"); + const errorBox = document.getElementById("bug_error"); + const successBox = document.getElementById("bug_success"); + const form = document.getElementById("bug_form"); + + document.getElementById("btn_submit_bug").addEventListener("click", async () => { + errorBox.style.display = "none"; + const description = descriptionArea.value.trim(); + if (!description) { + errorBox.textContent = "问题描述不能为空"; + errorBox.style.display = "block"; + return; + } + const resp = await fetch("/api/bug-reports", { + method: "POST", + body: JSON.stringify({ + project_id: getCurrentProjectId(), + description: description, + repro_steps: reproStepsArea.value.trim() || undefined, + related_task_id: relatedTaskIdInput.value.trim() || undefined, + }), + }); + const body = await resp.json(); + if (resp.status !== 200) { + errorBox.textContent = body.error + (body.detail ? ":" + body.detail : ""); + errorBox.style.display = "block"; + return; + } + form.style.display = "none"; + successBox.innerHTML = "已提交,Validator 将进行归因确认(bug " + body.bug_id + ")。" + + '返回仪表盘'; + successBox.style.display = "block"; + }); +} diff --git a/frontend/style.css b/frontend/style.css index c9b8538..212d665 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -6,7 +6,8 @@ body { font-family: system-ui, sans-serif; margin: 0; } #nav a.active { font-weight: bold; } #app { padding: 1rem; } -/* U-022: v_project_setup -- class names match docs/wireframes/v_project_setup.html */ +/* U-022/U-023: v_project_setup/v_goal_composer/v_bug_feedback -- class + names match docs/wireframes/v_*.html */ .top-back a { color: #6b7280; text-decoration: none; } .page { padding: 28px; max-width: 560px; margin: 0 auto; } .page h1 { font-size: 22px; margin: 0 0 4px; } @@ -14,7 +15,7 @@ body { font-family: system-ui, sans-serif; margin: 0; } .panel { border: 1px solid #e5e7eb; border-radius: 10px; padding: 24px; background: #f9fafb; } .field { margin-bottom: 18px; } .field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; } -.field input { width: 100%; padding: 9px 10px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 14px; } +.field input, .field textarea { width: 100%; padding: 9px 10px; border: 1px solid #e5e7eb; border-radius: 6px; font-size: 14px; font-family: inherit; } .field input[readonly] { background: #f3f4f6; color: #6b7280; } .field .hint { font-size: 12px; color: #6b7280; margin-top: 5px; } .btn { display: inline-block; padding: 9px 16px; border-radius: 6px; border: 1px solid #e5e7eb; background: #fff; font-size: 14px; cursor: pointer; } @@ -22,3 +23,5 @@ body { font-family: system-ui, sans-serif; margin: 0; } .btn-row { display: flex; gap: 10px; margin-top: 22px; } .confirm-banner { background: #fffbeb; border: 1px solid #f59e0b; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-top: 16px; } .error-banner { background: #fef2f2; border: 1px solid #ef4444; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; } +.success-banner { background: #f0fdf4; border: 1px solid #22c55e; border-radius: 8px; padding: 12px 14px; font-size: 13px; margin-bottom: 16px; } +.success-banner a { margin-left: 8px; color: #4f46e5; } diff --git a/src/backend_server.py b/src/backend_server.py index 1311e03..497ff38 100644 --- a/src/backend_server.py +++ b/src/backend_server.py @@ -8,10 +8,12 @@ platforms -- U-007 shipped macOS only via `osascript`; U-022 adds Windows via a `powershell` FolderBrowserDialog script and Linux via `zenity`/ `kdialog`, whichever is installed), `c_project_create` (03-C §3.0, wired to the U-002 `project_scaffold.create_project()` function that had no HTTP -route until now) for `v_project_setup`'s submit flow, and serving the U-008 -frontend shell as static files (no build step, 05 §5.7: whitelisted by exact -path, not a generic directory walker, so there's no path-traversal surface -to reason about). +route until now) for `v_project_setup`'s submit flow, `c_task_create`/ +`c_bug_report_create` (03-C §3.1/§3.1.1, U-023) for `v_goal_composer`/ +`v_bug_feedback`'s submit flows, and serving the U-008 frontend shell as +static files (no build step, 05 §5.7: whitelisted by exact path, not a +generic directory walker, so there's no path-traversal surface to reason +about). """ import json @@ -21,7 +23,7 @@ import subprocess import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from src import paths, project_scaffold, schema +from src import bug_record, paths, project_scaffold, schema, task_create _FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") @@ -121,6 +123,10 @@ class _Handler(BaseHTTPRequestHandler): self._handle_pick_folder() elif self.path == "/api/projects": self._handle_project_create() + elif self.path == "/api/tasks": + self._handle_task_create() + elif self.path == "/api/bug-reports": + self._handle_bug_report_create() else: self._send_json(404, {"error": "NOT_FOUND"}) @@ -130,12 +136,19 @@ class _Handler(BaseHTTPRequestHandler): except DialogUnavailableError as e: self._send_json(422, {"error": "DIALOG_UNAVAILABLE", "detail": str(e)}) - def _handle_project_create(self): + def _read_json_body(self): + """Returns (payload, None) on success, (None, error_response) if the + body isn't valid JSON -- caller sends error_response and returns.""" length = int(self.headers.get("Content-Length", 0)) try: - payload = json.loads(self.rfile.read(length) or b"{}") + return json.loads(self.rfile.read(length) or b"{}"), None except json.JSONDecodeError: - self._send_json(400, {"error": "INVALID_INPUT", "detail": "body is not valid JSON"}) + return None, {"error": "INVALID_INPUT", "detail": "body is not valid JSON"} + + def _handle_project_create(self): + payload, error = self._read_json_body() + if error: + self._send_json(400, error) return target_folder_path = payload.get("target_folder_path") if not target_folder_path: @@ -158,6 +171,44 @@ class _Handler(BaseHTTPRequestHandler): else: self._send_json(200, result) + def _handle_task_create(self): + payload, error = self._read_json_body() + if error: + self._send_json(400, error) + return + try: + with self.server.conn_lock: + result = task_create.create_task( + self.server.conn, + payload.get("project_id"), + payload.get("goal_text"), + task_type=payload.get("task_type", "goal"), + created_via=payload.get("created_via", "api"), + ) + except task_create.InvalidParamError as e: + self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)}) + else: + self._send_json(200, result) + + def _handle_bug_report_create(self): + payload, error = self._read_json_body() + if error: + self._send_json(400, error) + return + try: + with self.server.conn_lock: + result = bug_record.create_bug_report( + self.server.conn, + payload.get("project_id"), + payload.get("description"), + repro_steps=payload.get("repro_steps"), + related_task_id=payload.get("related_task_id"), + ) + except bug_record.InvalidParamError as e: + self._send_json(400, {"error": "INVALID_PARAM", "detail": str(e)}) + else: + self._send_json(200, result) + def _send_json(self, status, obj): body = json.dumps(obj).encode("utf-8") self.send_response(status) diff --git a/src/bug_record.py b/src/bug_record.py index 3bcabd6..4ce96b9 100644 --- a/src/bug_record.py +++ b/src/bug_record.py @@ -34,6 +34,48 @@ class InvalidRootCauseCategoryError(Exception): """INVALID_ROOT_CAUSE_CATEGORY -- 03-C §3.10.""" +class InvalidParamError(Exception): + """INVALID_PARAM -- 03-C §3.1.1: description is empty, or project_id/ + related_task_id don't reference existing rows.""" + + +def create_bug_report(conn, project_id, description, repro_steps=None, related_task_id=None): + """c_bug_report_create (03-C §3.1.1): console-submitted human bug report. + + 03-C's own input_contract for this controller lists only + {description, repro_steps, related_task_id} -- no project_id -- but + execution_event.project_id is NOT NULL (01-M 1.9) and bug_record has no + project_id column of its own to derive one from when related_task_id is + left empty ("留空则视为新发现的独立问题", 02-V §2.4). Requiring + project_id here (like c_task_create already does per its own contract) + is the only way to satisfy that constraint for a task-less report; this + is a real 03-C gap, not a design choice, flagged rather than silently + routed around -- see development-plan.md's 2026-07-31 note on this Unit. + """ + if not description: + raise InvalidParamError("description is required") + if conn.execute("SELECT 1 FROM project WHERE project_id = ?", (project_id,)).fetchone() is None: + raise InvalidParamError("project_id does not exist: " + str(project_id)) + if related_task_id is not None and conn.execute( + "SELECT 1 FROM task WHERE task_id = ?", (related_task_id,) + ).fetchone() is None: + raise InvalidParamError("related_task_id does not exist: " + str(related_task_id)) + + bug_id = str(uuid.uuid4()) + conn.execute( + "INSERT INTO bug_record (bug_id, task_id, source, description, repro_steps, status) " + "VALUES (?, ?, 'human', ?, ?, 'open')", + (bug_id, related_task_id, description, repro_steps), + ) + conn.execute( + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " + "VALUES (?, ?, ?, 'bug_recorded', ?)", + (str(uuid.uuid4()), project_id, related_task_id, json.dumps({"bug_id": bug_id, "source": "human"})), + ) + conn.commit() + return {"bug_id": bug_id, "status": "open"} + + def create_bug_record( conn, task_id, description, root_cause_category, root_cause_detail, suggested_route, repro_steps=None, related_artifact_ids=None, skill_iteration_suggestion_id=None, diff --git a/src/test_backend_server.py b/src/test_backend_server.py index c74cec7..a30fa97 100644 --- a/src/test_backend_server.py +++ b/src/test_backend_server.py @@ -313,6 +313,82 @@ class ProjectCreateRouteTest(unittest.TestCase): self.assertTrue(os.path.isdir(os.path.join(non_git, ".git"))) +class TaskAndBugReportRouteTest(unittest.TestCase): + """U-023: c_task_create (03-C §3.1) + c_bug_report_create (03-C §3.1.1) + wired to real HTTP routes for v_goal_composer/v_bug_feedback.""" + + def setUp(self): + self.server = backend_server.create_server(db_path=":memory:") + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.server.conn.execute( + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" + ) + self.server.conn.commit() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + + def _post(self, path, payload): + host, port = self.server.server_address + conn = http.client.HTTPConnection(host, port, timeout=5) + conn.request("POST", path, body=json.dumps(payload).encode("utf-8")) + resp = conn.getresponse() + body = json.loads(resp.read()) + conn.close() + return resp.status, body + + def test_creates_task(self): + status, body = self._post( + "/api/tasks", {"project_id": "proj-1", "goal_text": "make a snake game", "created_via": "human_console"} + ) + self.assertEqual(status, 200) + self.assertEqual(body["status"], "queued") + self.assertIn("task_id", body) + + def test_empty_goal_text_returns_400(self): + status, body = self._post("/api/tasks", {"project_id": "proj-1", "goal_text": ""}) + self.assertEqual(status, 400) + self.assertEqual(body["error"], "INVALID_PARAM") + + def test_nonexistent_project_id_returns_400(self): + status, body = self._post("/api/tasks", {"project_id": "no-such-project", "goal_text": "x"}) + self.assertEqual(status, 400) + self.assertEqual(body["error"], "INVALID_PARAM") + + def test_creates_bug_report(self): + status, body = self._post( + "/api/bug-reports", {"project_id": "proj-1", "description": "snake clips through walls"} + ) + self.assertEqual(status, 200) + self.assertEqual(body["status"], "open") + self.assertIn("bug_id", body) + + def test_bug_report_with_related_task_id(self): + _, task_body = self._post("/api/tasks", {"project_id": "proj-1", "goal_text": "x"}) + status, body = self._post( + "/api/bug-reports", + {"project_id": "proj-1", "description": "x", "related_task_id": task_body["task_id"]}, + ) + self.assertEqual(status, 200) + + def test_empty_description_returns_400(self): + status, body = self._post("/api/bug-reports", {"project_id": "proj-1", "description": ""}) + self.assertEqual(status, 400) + self.assertEqual(body["error"], "INVALID_PARAM") + + def test_bug_report_with_bad_project_id_returns_400_not_500(self): + # a raw sqlite3.IntegrityError propagating uncaught here was a real + # bug this smoke test caught (stale/bad project_id crashed the + # request instead of a clean 400) + status, body = self._post("/api/bug-reports", {"project_id": "no-such-project", "description": "x"}) + self.assertEqual(status, 400) + self.assertEqual(body["error"], "INVALID_PARAM") + + class ZeroThirdPartyDependencyTest(unittest.TestCase): """05 §5.7: no Flask/FastAPI/Express/Node.js -- checked statically so this doesn't silently rot if someone adds an import later.""" diff --git a/src/test_bug_record.py b/src/test_bug_record.py index c58602f..92a97a6 100644 --- a/src/test_bug_record.py +++ b/src/test_bug_record.py @@ -70,6 +70,75 @@ class CreateBugRecordTest(BugRecordTestBase): ) +class CreateBugReportTest(BugRecordTestBase): + """U-023: c_bug_report_create (03-C §3.1.1) -- console-submitted human + bug reports, distinct from create_bug_record's Validator-judged path.""" + + def test_happy_path_creates_open_human_record(self): + result = bug_record.create_bug_report( + self.conn, project_id="proj-1", description="snake doesn't wrap around edges", + ) + row = self.conn.execute( + "SELECT source, status, task_id, description FROM bug_record WHERE bug_id = ?", + (result["bug_id"],), + ).fetchone() + self.assertEqual(row, ("human", "open", None, "snake doesn't wrap around edges")) + self.assertEqual(result["status"], "open") + + def test_related_task_id_is_optional_and_stored_when_given(self): + result = bug_record.create_bug_report( + self.conn, project_id="proj-1", description="x", related_task_id="task-1", + ) + row = self.conn.execute( + "SELECT task_id FROM bug_record WHERE bug_id = ?", (result["bug_id"],) + ).fetchone() + self.assertEqual(row[0], "task-1") + + def test_repro_steps_optional_and_stored_when_given(self): + result = bug_record.create_bug_report( + self.conn, project_id="proj-1", description="x", repro_steps="1. start game 2. hit wall", + ) + row = self.conn.execute( + "SELECT repro_steps FROM bug_record WHERE bug_id = ?", (result["bug_id"],) + ).fetchone() + self.assertEqual(row[0], "1. start game 2. hit wall") + + def test_empty_description_rejected(self): + with self.assertRaises(bug_record.InvalidParamError): + bug_record.create_bug_report(self.conn, project_id="proj-1", description="") + + def test_nonexistent_project_id_rejected(self): + # a raw FK violation surfacing as an uncaught IntegrityError instead + # of this is exactly what a real backend_server.py smoke test caught + # (a bad/stale project_id would otherwise 500 the request) + with self.assertRaises(bug_record.InvalidParamError): + bug_record.create_bug_report(self.conn, project_id="no-such-project", description="x") + + def test_nonexistent_related_task_id_rejected(self): + with self.assertRaises(bug_record.InvalidParamError): + bug_record.create_bug_report( + self.conn, project_id="proj-1", description="x", related_task_id="no-such-task" + ) + + def test_writes_bug_recorded_execution_event(self): + result = bug_record.create_bug_report(self.conn, project_id="proj-1", description="x") + row = self.conn.execute( + "SELECT project_id, event_type FROM execution_event WHERE payload LIKE ?", + ('%' + result["bug_id"] + '%',), + ).fetchone() + self.assertEqual(row, ("proj-1", "bug_recorded")) + + def test_no_related_task_still_writes_event_with_null_task_id(self): + # this is exactly the case 03-C's own input_contract can't satisfy + # without project_id -- see create_bug_report's docstring + result = bug_record.create_bug_report(self.conn, project_id="proj-1", description="x") + row = self.conn.execute( + "SELECT task_id FROM execution_event WHERE payload LIKE ?", + ('%' + result["bug_id"] + '%',), + ).fetchone() + self.assertIsNone(row[0]) + + class HumanReportedFlowTest(BugRecordTestBase): def _insert_human_bug(self, bug_id="bug-1", status="open"): self.conn.execute(