Commit 958b55acf10592ca89307997e88d7ec5e70e882f
1 parent
6e780d36
graphlemon: code_diff artifact for task bfd3ee82-26e5-44d6-8acc-fdbeb3d78185
Showing
1 changed file
with
497 additions
and
0 deletions
.graphlemon/runs/bfd3ee82-26e5-44d6-8acc-fdbeb3d78185/code_diff.patch
0 → 100644
| 1 | +diff --git a/frontend/app.js b/frontend/app.js | |
| 2 | +index 1a5b3ec..f026060 100644 | |
| 3 | +--- a/frontend/app.js | |
| 4 | ++++ b/frontend/app.js | |
| 5 | +@@ -115,13 +115,36 @@ function registerRouteCleanup(fn) { | |
| 6 | + currentRouteCleanup = fn; | |
| 7 | + } | |
| 8 | + | |
| 9 | ++// U-047: `#/tasks?status=done`-style query strings on hash routes -- a new | |
| 10 | ++// capability, not a bug fix (no route previously carried a query string). | |
| 11 | ++// Hand-rolled instead of `new URLSearchParams(...)` (which mountTaskBoard's | |
| 12 | ++// own outgoing-request building already uses) because this one also has to | |
| 13 | ++// run inside frontend/test_*_smoke.js's bare `vm.createContext` sandbox, | |
| 14 | ++// which has no WHATWG URL globals -- decodeURIComponent is a plain | |
| 15 | ++// ECMAScript global and works there unmodified. | |
| 16 | ++function parseHashQuery(hash) { | |
| 17 | ++ const qIndex = hash.indexOf("?"); | |
| 18 | ++ if (qIndex === -1) return { path: hash, params: {} }; | |
| 19 | ++ const path = hash.slice(0, qIndex); | |
| 20 | ++ const params = {}; | |
| 21 | ++ for (const pair of hash.slice(qIndex + 1).split("&")) { | |
| 22 | ++ if (!pair) continue; | |
| 23 | ++ const eqIndex = pair.indexOf("="); | |
| 24 | ++ const key = eqIndex === -1 ? pair : pair.slice(0, eqIndex); | |
| 25 | ++ const value = eqIndex === -1 ? "" : pair.slice(eqIndex + 1); | |
| 26 | ++ params[decodeURIComponent(key)] = decodeURIComponent(value); | |
| 27 | ++ } | |
| 28 | ++ return { path, params }; | |
| 29 | ++} | |
| 30 | ++ | |
| 31 | + function render() { | |
| 32 | + if (currentRouteCleanup) { | |
| 33 | + currentRouteCleanup(); | |
| 34 | + currentRouteCleanup = null; | |
| 35 | + } | |
| 36 | + | |
| 37 | +- const hash = window.location.hash || "#/"; | |
| 38 | ++ const rawHash = window.location.hash || "#/"; | |
| 39 | ++ const { path: hash, params: queryParams } = parseHashQuery(rawHash); | |
| 40 | + let route = routes[hash]; | |
| 41 | + let routeArgs = []; | |
| 42 | + if (!route) { | |
| 43 | +@@ -154,16 +177,115 @@ function render() { | |
| 44 | + if (!route) route = { render: () => "<h1>Not found</h1><p>" + hash + "</p>" }; | |
| 45 | + | |
| 46 | + document.getElementById("app").innerHTML = route.render(...routeArgs); | |
| 47 | +- if (route.mount) route.mount(...routeArgs); | |
| 48 | +- | |
| 49 | +- for (const a of document.querySelectorAll("#nav a")) { | |
| 50 | +- a.classList.toggle("active", a.getAttribute("href") === hash); | |
| 51 | ++ // queryParams always appended after any route-specific captures -- every | |
| 52 | ++ // mount() so far only declares the params it actually reads (e.g. | |
| 53 | ++ // mountTaskBoard(queryParams)), so this trailing arg is silently ignored | |
| 54 | ++ // by the rest (plain JS behavior, not a special case per route). | |
| 55 | ++ if (route.mount) route.mount(...routeArgs, queryParams); | |
| 56 | ++ | |
| 57 | ++ // `#nav > a`, not `#nav a`: nav_project_context (U-048) nests its own | |
| 58 | ++ // <a> links (project name, status-bucket counts) directly inside #nav -- | |
| 59 | ++ // those must never get the same "active" bolding as the fixed route | |
| 60 | ++ // links above them just because their target route happens to match. | |
| 61 | ++ for (const a of document.querySelectorAll("#nav > a")) { | |
| 62 | ++ a.classList.toggle("active", parseHashQuery(a.getAttribute("href")).path === hash); | |
| 63 | + } | |
| 64 | ++ refreshNavProjectContext(); | |
| 65 | + } | |
| 66 | + | |
| 67 | + window.addEventListener("hashchange", render); | |
| 68 | + window.addEventListener("DOMContentLoaded", render); | |
| 69 | + | |
| 70 | ++// ---- nav bar "current project" context (U-048) ---- | |
| 71 | ++// All pages share #nav (index.html), so this is wired into render() itself | |
| 72 | ++// (called once per hashchange/initial load, see above) rather than into any | |
| 73 | ++// one page's mount() -- "piggybacks on the existing hashchange listener, no | |
| 74 | ++// new event plumbing needed" per the approved SDD. Reuses | |
| 75 | ++// `GET /api/tasks?project_id=...` and reduces status counts client-side | |
| 76 | ++// (one fetch per refresh, not one per bucket) -- a dedicated aggregation | |
| 77 | ++// endpoint is a documented revisit if a single project ever reaches | |
| 78 | ++// ~1000-2000 tasks. | |
| 79 | ++ | |
| 80 | ++// 10 raw task.status values (TASK_STATUSES below, v_task_board) collapsed | |
| 81 | ++// into the 4 buckets the nav bar shows. `statuses.length === 1` buckets | |
| 82 | ++// (pending/done) link to "#/tasks?status=<value>" -- v_task_board's status | |
| 83 | ++// filter is single-select, so multi-status buckets (running/attention) link | |
| 84 | ++// to the unfiltered "#/tasks" instead of picking one arbitrary status. | |
| 85 | ++const NAV_STATUS_BUCKETS = [ | |
| 86 | ++ { key: "pending", label: "pending", statuses: ["queued"] }, | |
| 87 | ++ { key: "running", label: "running", statuses: [ | |
| 88 | ++ "planning_brd_prd_sdd", "coding", "validating", "routed_fast_lane", "routed_full_replan", | |
| 89 | ++ ] }, | |
| 90 | ++ { key: "attention", label: "attention", statuses: [ | |
| 91 | ++ "planning_failed", "coding_failed", "blocked_pending_human", | |
| 92 | ++ ] }, | |
| 93 | ++ { key: "done", label: "done", statuses: ["done"] }, | |
| 94 | ++]; | |
| 95 | ++ | |
| 96 | ++function navBucketHref(bucket) { | |
| 97 | ++ return bucket.statuses.length === 1 ? "#/tasks?status=" + bucket.statuses[0] : "#/tasks"; | |
| 98 | ++} | |
| 99 | ++ | |
| 100 | ++function computeNavStatusBucketCounts(tasks) { | |
| 101 | ++ const statusToBucketKey = {}; | |
| 102 | ++ for (const bucket of NAV_STATUS_BUCKETS) { | |
| 103 | ++ for (const status of bucket.statuses) statusToBucketKey[status] = bucket.key; | |
| 104 | ++ } | |
| 105 | ++ const counts = {}; | |
| 106 | ++ for (const bucket of NAV_STATUS_BUCKETS) counts[bucket.key] = 0; | |
| 107 | ++ for (const task of tasks) { | |
| 108 | ++ const bucketKey = statusToBucketKey[task.status]; | |
| 109 | ++ if (bucketKey) counts[bucketKey]++; | |
| 110 | ++ } | |
| 111 | ++ return counts; | |
| 112 | ++} | |
| 113 | ++ | |
| 114 | ++function renderNavProjectContextHtml(project, counts) { | |
| 115 | ++ if (!project) return ""; | |
| 116 | ++ const countsHtml = NAV_STATUS_BUCKETS.map((bucket) => | |
| 117 | ++ '<a class="nav-count nav-count-' + bucket.key + '" href="' + navBucketHref(bucket) + '">' | |
| 118 | ++ + escapeHtml(bucket.label) + " " + (counts[bucket.key] || 0) + "</a>" | |
| 119 | ++ ).join(""); | |
| 120 | ++ return ( | |
| 121 | ++ '<a id="nav_project_name" href="#/projects">' + escapeHtml(project.name) + "</a>" | |
| 122 | ++ + '<span class="nav-counts">' + countsHtml + "</span>" | |
| 123 | ++ ); | |
| 124 | ++} | |
| 125 | ++ | |
| 126 | ++// Guards against out-of-order responses: if the current project is switched | |
| 127 | ++// (or this fires again before an earlier fetch resolves), a stale response | |
| 128 | ++// landing after a newer one must not overwrite the nav with old data. | |
| 129 | ++let navProjectContextRequestId = 0; | |
| 130 | ++ | |
| 131 | ++async function refreshNavProjectContext() { | |
| 132 | ++ const contextEl = document.getElementById("nav_project_context"); | |
| 133 | ++ if (!contextEl) return; | |
| 134 | ++ const projectId = getCurrentProjectId(); | |
| 135 | ++ const requestId = ++navProjectContextRequestId; | |
| 136 | ++ if (!projectId) { | |
| 137 | ++ // Requirement 1: no project selected -> render nothing, not a hint. | |
| 138 | ++ contextEl.innerHTML = ""; | |
| 139 | ++ return; | |
| 140 | ++ } | |
| 141 | ++ try { | |
| 142 | ++ const [projectResp, tasksResp] = await Promise.all([ | |
| 143 | ++ fetch("/api/projects/" + encodeURIComponent(projectId)), | |
| 144 | ++ fetch("/api/tasks?project_id=" + encodeURIComponent(projectId)), | |
| 145 | ++ ]); | |
| 146 | ++ if (requestId !== navProjectContextRequestId) return; // superseded by a newer refresh | |
| 147 | ++ if (projectResp.status !== 200) { | |
| 148 | ++ contextEl.innerHTML = ""; | |
| 149 | ++ return; | |
| 150 | ++ } | |
| 151 | ++ const project = await projectResp.json(); | |
| 152 | ++ const tasks = tasksResp.status === 200 ? await tasksResp.json() : []; | |
| 153 | ++ if (requestId !== navProjectContextRequestId) return; | |
| 154 | ++ contextEl.innerHTML = renderNavProjectContextHtml(project, computeNavStatusBucketCounts(tasks)); | |
| 155 | ++ } catch (e) { | |
| 156 | ++ if (requestId === navProjectContextRequestId) contextEl.innerHTML = ""; | |
| 157 | ++ } | |
| 158 | ++} | |
| 159 | ++ | |
| 160 | + // ---- v_project_list (2026-08-03): project list + CRUD ---- | |
| 161 | + // 02-V originally declared "不做完整多项目管理 UI(项目列表/切换)" as an | |
| 162 | + // MVP non-goal -- this page is a deliberate, explicit scope expansion | |
| 163 | +@@ -885,7 +1007,21 @@ function renderTaskBoard() { | |
| 164 | + `; | |
| 165 | + } | |
| 166 | + | |
| 167 | +-function mountTaskBoard() { | |
| 168 | ++// U-047/U-048: nav-bar status-bucket links land on #/tasks as | |
| 169 | ++// "?status=<value>" -- this picks the status to pre-select, on initial | |
| 170 | ++// mount only (same reasoning as v_task_board's other filters: it's a | |
| 171 | ++// starting value the user can then change, not re-applied on every route | |
| 172 | ++// re-render). An unrecognized/stale status value is ignored rather than | |
| 173 | ++// left in a select the <option> list doesn't actually have. Split out as | |
| 174 | ++// its own pure function so it's testable without a real <select> element. | |
| 175 | ++function pickInitialTaskBoardStatus(queryParams) { | |
| 176 | ++ if (queryParams && queryParams.status && TASK_STATUSES.includes(queryParams.status)) { | |
| 177 | ++ return queryParams.status; | |
| 178 | ++ } | |
| 179 | ++ return null; | |
| 180 | ++} | |
| 181 | ++ | |
| 182 | ++function mountTaskBoard(queryParams) { | |
| 183 | + const projectId = getCurrentProjectId(); | |
| 184 | + if (!projectId) return; | |
| 185 | + const rowsEl = document.getElementById("tb_rows"); | |
| 186 | +@@ -894,6 +1030,9 @@ function mountTaskBoard() { | |
| 187 | + const orgNodeEl = document.getElementById("tb_org_node"); | |
| 188 | + const sortEl = document.getElementById("tb_sort"); | |
| 189 | + | |
| 190 | ++ const initialStatus = pickInitialTaskBoardStatus(queryParams); | |
| 191 | ++ if (initialStatus) statusEl.value = initialStatus; | |
| 192 | ++ | |
| 193 | + async function refresh() { | |
| 194 | + const params = new URLSearchParams({ project_id: projectId }); | |
| 195 | + if (keywordEl.value.trim()) params.set("keyword", keywordEl.value.trim()); | |
| 196 | +diff --git a/frontend/index.html b/frontend/index.html | |
| 197 | +index a942d21..1eaf8e8 100644 | |
| 198 | +--- a/frontend/index.html | |
| 199 | ++++ b/frontend/index.html | |
| 200 | +@@ -15,6 +15,7 @@ | |
| 201 | + <a href="#/skills">Skills</a> | |
| 202 | + <a href="#/deployment">Deployment</a> | |
| 203 | + <a href="#/settings/concurrency">Settings</a> | |
| 204 | ++ <span id="nav_project_context" class="nav-project-context"></span> | |
| 205 | + </nav> | |
| 206 | + <main id="app"></main> | |
| 207 | + <script src="/app.js"></script> | |
| 208 | +diff --git a/frontend/style.css b/frontend/style.css | |
| 209 | +index bf48791..1d4cac4 100644 | |
| 210 | +--- a/frontend/style.css | |
| 211 | ++++ b/frontend/style.css | |
| 212 | +@@ -1,11 +1,24 @@ | |
| 213 | + /* U-008: minimal shell styling, nothing to design-review yet -- real look | |
| 214 | + and feel is PH3, starting from docs/wireframes/*.html. */ | |
| 215 | + body { font-family: system-ui, sans-serif; margin: 0; } | |
| 216 | +-#nav { display: flex; gap: 1rem; padding: 1rem; border-bottom: 1px solid #ddd; } | |
| 217 | ++#nav { display: flex; gap: 1rem; padding: 1rem; border-bottom: 1px solid #ddd; align-items: center; } | |
| 218 | + #nav a { text-decoration: none; color: #333; } | |
| 219 | + #nav a.active { font-weight: bold; } | |
| 220 | + #app { padding: 1rem; } | |
| 221 | + | |
| 222 | ++/* U-048: nav-bar "current project" context (name + task status summary) -- | |
| 223 | ++ pinned to the right of the fixed route links via margin-left:auto, empty | |
| 224 | ++ (renders nothing) whenever no project is currently selected. Reuses the | |
| 225 | ++ --status-* tokens defined below rather than inventing new colors. */ | |
| 226 | ++#nav_project_context { margin-left: auto; display: flex; align-items: center; gap: 10px; font-size: 13px; } | |
| 227 | ++#nav_project_name { font-weight: 600; color: #111827; } | |
| 228 | ++.nav-counts { display: flex; gap: 8px; } | |
| 229 | ++.nav-count { color: #4b5563; } | |
| 230 | ++.nav-count-pending { color: var(--status-pending); } | |
| 231 | ++.nav-count-running { color: var(--status-running); } | |
| 232 | ++.nav-count-attention { color: var(--status-blocked); } | |
| 233 | ++.nav-count-done { color: var(--status-done); } | |
| 234 | ++ | |
| 235 | + /* U-022/U-023: v_project_setup/v_goal_composer/v_bug_feedback -- class | |
| 236 | + names match docs/wireframes/v_*.html */ | |
| 237 | + .top-back a { color: #6b7280; text-decoration: none; } | |
| 238 | +diff --git a/frontend/test_nav_project_context_smoke.js b/frontend/test_nav_project_context_smoke.js | |
| 239 | +new file mode 100644 | |
| 240 | +index 0000000..a90ac61 | |
| 241 | +--- /dev/null | |
| 242 | ++++ b/frontend/test_nav_project_context_smoke.js | |
| 243 | +@@ -0,0 +1,254 @@ | |
| 244 | ++// U-047/U-048 (2026-08-07 SDD) smoke test: nav-bar "current project" context. | |
| 245 | ++// Same no-framework/no-fixture convention as the other frontend/test_*_smoke.js | |
| 246 | ++// files (see .claude/agent-memory/validator/feedback_no_js_test_framework.md) | |
| 247 | ++// -- loads real app.js in a bare vm.Context and asserts on generated HTML | |
| 248 | ++// strings / regex matches / real async control flow, not mocks of the logic | |
| 249 | ++// under test. | |
| 250 | ++// | |
| 251 | ++// Covers: | |
| 252 | ++// - U-047: parseHashQuery -- happy path, no-query hash, empty value, | |
| 253 | ++// multiple params; and the regression this was explicitly designed not | |
| 254 | ++// to break: TASK_DETAIL_RE must still match the *path* half of a hash | |
| 255 | ++// that now carries a query string. | |
| 256 | ++// - U-048 nav highlighting: a nav link whose href has no query string and | |
| 257 | ++// the current hash's query-stripped path must be recognized as the same | |
| 258 | ++// route (this is exactly what render()'s "#nav > a" active-toggling | |
| 259 | ++// loop relies on). | |
| 260 | ++// - U-048 bucket mapping: computeNavStatusBucketCounts groups all 10 raw | |
| 261 | ++// task.status values into the 4 nav buckets (pending/running/attention/ | |
| 262 | ++// done), and navBucketHref only appends ?status=<value> for the two | |
| 263 | ++// single-status buckets (pending/done) -- multi-status buckets | |
| 264 | ++// (running/attention) link to the unfiltered #/tasks, since | |
| 265 | ++// v_task_board's status filter is single-select. | |
| 266 | ++// - U-048 DOM contract: renderNavProjectContextHtml emits #nav_project_name | |
| 267 | ++// (linking to #/projects) and one .nav-count-<bucket> per bucket, and | |
| 268 | ++// renders nothing at all when no project is given (unselected-project | |
| 269 | ++// state, requirement 1). | |
| 270 | ++// - U-048 refreshNavProjectContext (real async function, real Promise | |
| 271 | ++// control, no reimplementation): single-fetch-per-refresh (one call to | |
| 272 | ++// /api/projects/:id, one to /api/tasks?project_id=... -- not one fetch | |
| 273 | ++// per bucket), stale/out-of-order response protection when the current | |
| 274 | ++// project changes mid-flight, and the unselected-project case doing zero | |
| 275 | ++// fetches. | |
| 276 | ++// - mountTaskBoard: pre-selects tb_status from an initial ?status= query | |
| 277 | ++// param, ignoring an unrecognized value instead of setting it blindly. | |
| 278 | ++// | |
| 279 | ++// Run: node frontend/test_nav_project_context_smoke.js | |
| 280 | ++ | |
| 281 | ++"use strict"; | |
| 282 | ++const fs = require("fs"); | |
| 283 | ++const path = require("path"); | |
| 284 | ++const vm = require("vm"); | |
| 285 | ++const assert = require("assert"); | |
| 286 | ++ | |
| 287 | ++function makeFakeDiv() { | |
| 288 | ++ let text = ""; | |
| 289 | ++ return { | |
| 290 | ++ set textContent(v) { text = v == null ? "" : String(v); }, | |
| 291 | ++ get textContent() { return text; }, | |
| 292 | ++ get innerHTML() { | |
| 293 | ++ return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | |
| 294 | ++ }, | |
| 295 | ++ }; | |
| 296 | ++} | |
| 297 | ++ | |
| 298 | ++// Unlike makeFakeDiv (models a text node via textContent -> escaped | |
| 299 | ++// innerHTML), nav_project_context needs a plain settable/gettable string -- | |
| 300 | ++// app.js writes a real HTML string there, not text that gets escaped. | |
| 301 | ++function makeRawEl() { | |
| 302 | ++ let html = ""; | |
| 303 | ++ return { | |
| 304 | ++ set innerHTML(v) { html = v; }, | |
| 305 | ++ get innerHTML() { return html; }, | |
| 306 | ++ }; | |
| 307 | ++} | |
| 308 | ++ | |
| 309 | ++let currentProjectId = null; | |
| 310 | ++let fetchCalls = []; | |
| 311 | ++let pendingFetches = []; | |
| 312 | ++function fakeFetch(url) { | |
| 313 | ++ fetchCalls.push(url); | |
| 314 | ++ return new Promise((resolve) => { pendingFetches.push({ url, resolve }); }); | |
| 315 | ++} | |
| 316 | ++function resolveFetch(entry, status, body) { | |
| 317 | ++ entry.resolve({ status, json: async () => body }); | |
| 318 | ++} | |
| 319 | ++ | |
| 320 | ++const navContextEl = makeRawEl(); | |
| 321 | ++const elementsById = { | |
| 322 | ++ nav_project_context: navContextEl, | |
| 323 | ++}; | |
| 324 | ++ | |
| 325 | ++const sandbox = { | |
| 326 | ++ document: { | |
| 327 | ++ createElement: () => makeFakeDiv(), | |
| 328 | ++ getElementById: (id) => elementsById[id] || null, | |
| 329 | ++ querySelectorAll: () => [], | |
| 330 | ++ addEventListener: () => {}, | |
| 331 | ++ }, | |
| 332 | ++ window: { | |
| 333 | ++ location: { protocol: "http:", host: "localhost", hash: "" }, | |
| 334 | ++ addEventListener: () => {}, | |
| 335 | ++ }, | |
| 336 | ++ localStorage: { getItem: () => currentProjectId, setItem: (k, v) => { currentProjectId = v; } }, | |
| 337 | ++ fetch: fakeFetch, | |
| 338 | ++ console, | |
| 339 | ++}; | |
| 340 | ++vm.createContext(sandbox); | |
| 341 | ++ | |
| 342 | ++const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8"); | |
| 343 | ++// Top-level `const` regexes/arrays stay lexically scoped to the script and | |
| 344 | ++// never reach the sandbox object (same Annex B quirk the other smoke tests | |
| 345 | ++// already document) -- re-export what this test needs. | |
| 346 | ++const augmentedSrc = src + "\nglobalThis.TASK_DETAIL_RE = TASK_DETAIL_RE;\nglobalThis.NAV_STATUS_BUCKETS = NAV_STATUS_BUCKETS;\n"; | |
| 347 | ++new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox); | |
| 348 | ++ | |
| 349 | ++let assertionCount = 0; | |
| 350 | ++function check(condition, message) { | |
| 351 | ++ assertionCount++; | |
| 352 | ++ assert.ok(condition, message); | |
| 353 | ++} | |
| 354 | ++ | |
| 355 | ++async function main() { | |
| 356 | ++ // ---- 1. parseHashQuery: happy / edge cases ---- | |
| 357 | ++ { | |
| 358 | ++ const noQuery = sandbox.parseHashQuery("#/tasks"); | |
| 359 | ++ check(noQuery.path === "#/tasks" && Object.keys(noQuery.params).length === 0, "no '?' -> whole string is the path, empty params"); | |
| 360 | ++ | |
| 361 | ++ const single = sandbox.parseHashQuery("#/tasks?status=done"); | |
| 362 | ++ check(single.path === "#/tasks" && single.params.status === "done", "single key=value param parsed correctly"); | |
| 363 | ++ | |
| 364 | ++ const multi = sandbox.parseHashQuery("#/tasks?status=done&org_node=coder"); | |
| 365 | ++ check(multi.path === "#/tasks" && multi.params.status === "done" && multi.params.org_node === "coder", "multiple params all parsed"); | |
| 366 | ++ | |
| 367 | ++ const trailing = sandbox.parseHashQuery("#/tasks?"); | |
| 368 | ++ check(trailing.path === "#/tasks" && Object.keys(trailing.params).length === 0, "trailing '?' with nothing after it -> empty params, not a crash"); | |
| 369 | ++ | |
| 370 | ++ const emptyValue = sandbox.parseHashQuery("#/tasks?status="); | |
| 371 | ++ check(emptyValue.params.status === "", "key with empty value still produces a key, not dropped"); | |
| 372 | ++ | |
| 373 | ++ const encoded = sandbox.parseHashQuery("#/tasks?keyword=a%20b"); | |
| 374 | ++ check(encoded.params.keyword === "a b", "percent-encoded values are decoded"); | |
| 375 | ++ } | |
| 376 | ++ | |
| 377 | ++ // ---- 2. Regression: TASK_DETAIL_RE must still match the query-stripped path ---- | |
| 378 | ++ { | |
| 379 | ++ const withQuery = sandbox.parseHashQuery("#/tasks/abc-123?foo=bar"); | |
| 380 | ++ const match = withQuery.path.match(sandbox.TASK_DETAIL_RE); | |
| 381 | ++ check(match && match[1] === "abc-123", "TASK_DETAIL_RE must match the path half of a hash carrying a query string"); | |
| 382 | ++ | |
| 383 | ++ const withoutQuery = sandbox.parseHashQuery("#/tasks/abc-123"); | |
| 384 | ++ check(withoutQuery.path.match(sandbox.TASK_DETAIL_RE)[1] === "abc-123", "no-query case keeps working exactly as before (no regression)"); | |
| 385 | ++ } | |
| 386 | ++ | |
| 387 | ++ // ---- 3. Nav highlighting: query-stripped paths must compare equal ---- | |
| 388 | ++ { | |
| 389 | ++ const currentHashPath = sandbox.parseHashQuery("#/tasks?status=queued").path; | |
| 390 | ++ const plainNavLinkPath = sandbox.parseHashQuery("#/tasks").path; | |
| 391 | ++ check(currentHashPath === plainNavLinkPath, "a plain '#/tasks' nav link must still be recognized as 'active' when the real hash carries a status query string"); | |
| 392 | ++ } | |
| 393 | ++ | |
| 394 | ++ // ---- 4. computeNavStatusBucketCounts: all 10 statuses land in exactly one of the 4 buckets ---- | |
| 395 | ++ { | |
| 396 | ++ const allStatuses = [ | |
| 397 | ++ "queued", "planning_brd_prd_sdd", "planning_failed", "coding", "coding_failed", | |
| 398 | ++ "validating", "routed_fast_lane", "routed_full_replan", "blocked_pending_human", "done", | |
| 399 | ++ ]; | |
| 400 | ++ const tasks = allStatuses.map((status, i) => ({ task_id: "t" + i, status })); | |
| 401 | ++ const counts = sandbox.computeNavStatusBucketCounts(tasks); | |
| 402 | ++ const total = Object.values(counts).reduce((a, b) => a + b, 0); | |
| 403 | ++ check(total === allStatuses.length, "every raw status must be counted into exactly one bucket, none dropped/double-counted"); | |
| 404 | ++ check(counts.pending === 1 && counts.done === 1, "pending/done are single-status buckets (queued/done respectively)"); | |
| 405 | ++ check(counts.running === 5, "running bucket: planning_brd_prd_sdd, coding, validating, routed_fast_lane, routed_full_replan"); | |
| 406 | ++ check(counts.attention === 3, "attention bucket: planning_failed, coding_failed, blocked_pending_human"); | |
| 407 | ++ | |
| 408 | ++ const empty = sandbox.computeNavStatusBucketCounts([]); | |
| 409 | ++ check(Object.values(empty).every((n) => n === 0), "zero tasks -> every bucket is 0, not undefined/NaN"); | |
| 410 | ++ } | |
| 411 | ++ | |
| 412 | ++ // ---- 5. navBucketHref: single-status buckets carry ?status=, multi-status ones don't ---- | |
| 413 | ++ { | |
| 414 | ++ for (const bucket of sandbox.NAV_STATUS_BUCKETS) { | |
| 415 | ++ const href = sandbox.navBucketHref(bucket); | |
| 416 | ++ if (bucket.statuses.length === 1) { | |
| 417 | ++ check(href === "#/tasks?status=" + bucket.statuses[0], bucket.key + " (single-status) must link with its exact status filter"); | |
| 418 | ++ } else { | |
| 419 | ++ check(href === "#/tasks", bucket.key + " (multi-status) must link to the unfiltered board, v_task_board's filter is single-select"); | |
| 420 | ++ } | |
| 421 | ++ } | |
| 422 | ++ } | |
| 423 | ++ | |
| 424 | ++ // ---- 6. renderNavProjectContextHtml: DOM contract + unselected-project state ---- | |
| 425 | ++ { | |
| 426 | ++ const html = sandbox.renderNavProjectContextHtml( | |
| 427 | ++ { project_id: "p1", name: "My <Project>" }, | |
| 428 | ++ { pending: 3, running: 2, attention: 1, done: 12 }, | |
| 429 | ++ ); | |
| 430 | ++ check(html.includes('id="nav_project_name"'), "must carry #nav_project_name"); | |
| 431 | ++ check(html.includes('href="#/projects"'), "project name must link to #/projects (switch mechanism, no rebuild)"); | |
| 432 | ++ check(html.includes("My <Project>"), "project name must be HTML-escaped"); | |
| 433 | ++ check(html.includes('nav-count-pending') && html.includes("pending 3"), "pending bucket count rendered"); | |
| 434 | ++ check(html.includes('nav-count-running') && html.includes("running 2"), "running bucket count rendered"); | |
| 435 | ++ check(html.includes('nav-count-attention') && html.includes("attention 1"), "attention bucket count rendered"); | |
| 436 | ++ check(html.includes('nav-count-done') && html.includes("done 12"), "done bucket count rendered"); | |
| 437 | ++ | |
| 438 | ++ check(sandbox.renderNavProjectContextHtml(null, {}) === "", "no project selected -> render nothing (requirement 1)"); | |
| 439 | ++ } | |
| 440 | ++ | |
| 441 | ++ // ---- 7. refreshNavProjectContext: unselected-project state does zero fetches ---- | |
| 442 | ++ { | |
| 443 | ++ currentProjectId = null; | |
| 444 | ++ fetchCalls = []; | |
| 445 | ++ await sandbox.refreshNavProjectContext(); | |
| 446 | ++ check(fetchCalls.length === 0, "no current project -> must not fetch anything"); | |
| 447 | ++ check(navContextEl.innerHTML === "", "no current project -> nav context area must be empty"); | |
| 448 | ++ } | |
| 449 | ++ | |
| 450 | ++ // ---- 8. refreshNavProjectContext: single-fetch-per-refresh + stale/out-of-order protection ---- | |
| 451 | ++ { | |
| 452 | ++ fetchCalls = []; | |
| 453 | ++ pendingFetches = []; | |
| 454 | ++ | |
| 455 | ++ currentProjectId = "proj-A"; | |
| 456 | ++ const promiseA = sandbox.refreshNavProjectContext(); | |
| 457 | ++ currentProjectId = "proj-B"; | |
| 458 | ++ const promiseB = sandbox.refreshNavProjectContext(); | |
| 459 | ++ | |
| 460 | ++ check(pendingFetches.length === 4, "two refreshes -> exactly 2 fetches each (project + tasks), never per-bucket (single-fetch-per-refresh)"); | |
| 461 | ++ check(fetchCalls[0].includes("/api/projects/proj-A") && fetchCalls[1].includes("project_id=proj-A"), "call A fetches proj-A's project + tasks"); | |
| 462 | ++ check(fetchCalls[2].includes("/api/projects/proj-B") && fetchCalls[3].includes("project_id=proj-B"), "call B fetches proj-B's project + tasks"); | |
| 463 | ++ | |
| 464 | ++ // Resolve B (the newer, current request) first... | |
| 465 | ++ resolveFetch(pendingFetches[2], 200, { project_id: "proj-B", name: "Project B" }); | |
| 466 | ++ resolveFetch(pendingFetches[3], 200, [{ task_id: "t1", status: "done" }]); | |
| 467 | ++ await promiseB; | |
| 468 | ++ check(navContextEl.innerHTML.includes("Project B"), "current (B) response must be rendered"); | |
| 469 | ++ | |
| 470 | ++ // ...then resolve A's (older, now-superseded) response arriving late. | |
| 471 | ++ resolveFetch(pendingFetches[0], 200, { project_id: "proj-A", name: "Project A" }); | |
| 472 | ++ resolveFetch(pendingFetches[1], 200, [{ task_id: "t2", status: "queued" }]); | |
| 473 | ++ await promiseA; | |
| 474 | ++ check(navContextEl.innerHTML.includes("Project B"), "a stale response for a since-superseded project must not overwrite the current (B) nav state"); | |
| 475 | ++ check(!navContextEl.innerHTML.includes("Project A"), "project A's late response must be discarded entirely"); | |
| 476 | ++ } | |
| 477 | ++ | |
| 478 | ++ // ---- 9. pickInitialTaskBoardStatus: mountTaskBoard's ?status= pre-select logic ---- | |
| 479 | ++ // (mountTaskBoard itself isn't called directly here -- it also wires up a | |
| 480 | ++ // real WebSocket + several other DOM elements this bare sandbox doesn't | |
| 481 | ++ // stub, same reasoning documented in feedback_no_js_test_framework.md for | |
| 482 | ++ // why mount* functions aren't exercised end-to-end elsewhere in this repo's | |
| 483 | ++ // frontend smoke tests either.) | |
| 484 | ++ { | |
| 485 | ++ check(sandbox.pickInitialTaskBoardStatus({ status: "coding" }) === "coding", "a recognized status query param is picked"); | |
| 486 | ++ check(sandbox.pickInitialTaskBoardStatus({ status: "not_a_real_status" }) === null, "an unrecognized status value must be ignored, not blindly returned"); | |
| 487 | ++ check(sandbox.pickInitialTaskBoardStatus({}) === null, "no status query param at all -> null (filter stays untouched)"); | |
| 488 | ++ check(sandbox.pickInitialTaskBoardStatus(undefined) === null, "missing queryParams entirely (defensive) -> null, no throw"); | |
| 489 | ++ } | |
| 490 | ++ | |
| 491 | ++ console.log("frontend/test_nav_project_context_smoke.js: all " + assertionCount + " assertions passed"); | |
| 492 | ++} | |
| 493 | ++ | |
| 494 | ++main().catch((e) => { | |
| 495 | ++ console.error(e); | |
| 496 | ++ process.exit(1); | |
| 497 | ++}); | ... | ... |