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: () => "
placeholder -- real content is PH3 (v_dashboard, U-025)
" }, "#/tasks": { render: () => "placeholder -- real content is PH3 (v_task_board, U-025)
" }, "#/settings": { render: () => "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 ` +" + 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 `
+
+ " + 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(