Commit 6e780d36cd0cd9455a627859c7f6de329df435bd
graphlemon: merge a8ae4a00-b3eb-431c-99a5-5eb6d927c00d
Showing
4 changed files
with
414 additions
and
7 deletions
frontend/app.js
| ... | ... | @@ -115,13 +115,36 @@ function registerRouteCleanup(fn) { |
| 115 | 115 | currentRouteCleanup = fn; |
| 116 | 116 | } |
| 117 | 117 | |
| 118 | +// U-047: `#/tasks?status=done`-style query strings on hash routes -- a new | |
| 119 | +// capability, not a bug fix (no route previously carried a query string). | |
| 120 | +// Hand-rolled instead of `new URLSearchParams(...)` (which mountTaskBoard's | |
| 121 | +// own outgoing-request building already uses) because this one also has to | |
| 122 | +// run inside frontend/test_*_smoke.js's bare `vm.createContext` sandbox, | |
| 123 | +// which has no WHATWG URL globals -- decodeURIComponent is a plain | |
| 124 | +// ECMAScript global and works there unmodified. | |
| 125 | +function parseHashQuery(hash) { | |
| 126 | + const qIndex = hash.indexOf("?"); | |
| 127 | + if (qIndex === -1) return { path: hash, params: {} }; | |
| 128 | + const path = hash.slice(0, qIndex); | |
| 129 | + const params = {}; | |
| 130 | + for (const pair of hash.slice(qIndex + 1).split("&")) { | |
| 131 | + if (!pair) continue; | |
| 132 | + const eqIndex = pair.indexOf("="); | |
| 133 | + const key = eqIndex === -1 ? pair : pair.slice(0, eqIndex); | |
| 134 | + const value = eqIndex === -1 ? "" : pair.slice(eqIndex + 1); | |
| 135 | + params[decodeURIComponent(key)] = decodeURIComponent(value); | |
| 136 | + } | |
| 137 | + return { path, params }; | |
| 138 | +} | |
| 139 | + | |
| 118 | 140 | function render() { |
| 119 | 141 | if (currentRouteCleanup) { |
| 120 | 142 | currentRouteCleanup(); |
| 121 | 143 | currentRouteCleanup = null; |
| 122 | 144 | } |
| 123 | 145 | |
| 124 | - const hash = window.location.hash || "#/"; | |
| 146 | + const rawHash = window.location.hash || "#/"; | |
| 147 | + const { path: hash, params: queryParams } = parseHashQuery(rawHash); | |
| 125 | 148 | let route = routes[hash]; |
| 126 | 149 | let routeArgs = []; |
| 127 | 150 | if (!route) { |
| ... | ... | @@ -154,16 +177,115 @@ function render() { |
| 154 | 177 | if (!route) route = { render: () => "<h1>Not found</h1><p>" + hash + "</p>" }; |
| 155 | 178 | |
| 156 | 179 | document.getElementById("app").innerHTML = route.render(...routeArgs); |
| 157 | - if (route.mount) route.mount(...routeArgs); | |
| 158 | - | |
| 159 | - for (const a of document.querySelectorAll("#nav a")) { | |
| 160 | - a.classList.toggle("active", a.getAttribute("href") === hash); | |
| 180 | + // queryParams always appended after any route-specific captures -- every | |
| 181 | + // mount() so far only declares the params it actually reads (e.g. | |
| 182 | + // mountTaskBoard(queryParams)), so this trailing arg is silently ignored | |
| 183 | + // by the rest (plain JS behavior, not a special case per route). | |
| 184 | + if (route.mount) route.mount(...routeArgs, queryParams); | |
| 185 | + | |
| 186 | + // `#nav > a`, not `#nav a`: nav_project_context (U-048) nests its own | |
| 187 | + // <a> links (project name, status-bucket counts) directly inside #nav -- | |
| 188 | + // those must never get the same "active" bolding as the fixed route | |
| 189 | + // links above them just because their target route happens to match. | |
| 190 | + for (const a of document.querySelectorAll("#nav > a")) { | |
| 191 | + a.classList.toggle("active", parseHashQuery(a.getAttribute("href")).path === hash); | |
| 161 | 192 | } |
| 193 | + refreshNavProjectContext(); | |
| 162 | 194 | } |
| 163 | 195 | |
| 164 | 196 | window.addEventListener("hashchange", render); |
| 165 | 197 | window.addEventListener("DOMContentLoaded", render); |
| 166 | 198 | |
| 199 | +// ---- nav bar "current project" context (U-048) ---- | |
| 200 | +// All pages share #nav (index.html), so this is wired into render() itself | |
| 201 | +// (called once per hashchange/initial load, see above) rather than into any | |
| 202 | +// one page's mount() -- "piggybacks on the existing hashchange listener, no | |
| 203 | +// new event plumbing needed" per the approved SDD. Reuses | |
| 204 | +// `GET /api/tasks?project_id=...` and reduces status counts client-side | |
| 205 | +// (one fetch per refresh, not one per bucket) -- a dedicated aggregation | |
| 206 | +// endpoint is a documented revisit if a single project ever reaches | |
| 207 | +// ~1000-2000 tasks. | |
| 208 | + | |
| 209 | +// 10 raw task.status values (TASK_STATUSES below, v_task_board) collapsed | |
| 210 | +// into the 4 buckets the nav bar shows. `statuses.length === 1` buckets | |
| 211 | +// (pending/done) link to "#/tasks?status=<value>" -- v_task_board's status | |
| 212 | +// filter is single-select, so multi-status buckets (running/attention) link | |
| 213 | +// to the unfiltered "#/tasks" instead of picking one arbitrary status. | |
| 214 | +const NAV_STATUS_BUCKETS = [ | |
| 215 | + { key: "pending", label: "pending", statuses: ["queued"] }, | |
| 216 | + { key: "running", label: "running", statuses: [ | |
| 217 | + "planning_brd_prd_sdd", "coding", "validating", "routed_fast_lane", "routed_full_replan", | |
| 218 | + ] }, | |
| 219 | + { key: "attention", label: "attention", statuses: [ | |
| 220 | + "planning_failed", "coding_failed", "blocked_pending_human", | |
| 221 | + ] }, | |
| 222 | + { key: "done", label: "done", statuses: ["done"] }, | |
| 223 | +]; | |
| 224 | + | |
| 225 | +function navBucketHref(bucket) { | |
| 226 | + return bucket.statuses.length === 1 ? "#/tasks?status=" + bucket.statuses[0] : "#/tasks"; | |
| 227 | +} | |
| 228 | + | |
| 229 | +function computeNavStatusBucketCounts(tasks) { | |
| 230 | + const statusToBucketKey = {}; | |
| 231 | + for (const bucket of NAV_STATUS_BUCKETS) { | |
| 232 | + for (const status of bucket.statuses) statusToBucketKey[status] = bucket.key; | |
| 233 | + } | |
| 234 | + const counts = {}; | |
| 235 | + for (const bucket of NAV_STATUS_BUCKETS) counts[bucket.key] = 0; | |
| 236 | + for (const task of tasks) { | |
| 237 | + const bucketKey = statusToBucketKey[task.status]; | |
| 238 | + if (bucketKey) counts[bucketKey]++; | |
| 239 | + } | |
| 240 | + return counts; | |
| 241 | +} | |
| 242 | + | |
| 243 | +function renderNavProjectContextHtml(project, counts) { | |
| 244 | + if (!project) return ""; | |
| 245 | + const countsHtml = NAV_STATUS_BUCKETS.map((bucket) => | |
| 246 | + '<a class="nav-count nav-count-' + bucket.key + '" href="' + navBucketHref(bucket) + '">' | |
| 247 | + + escapeHtml(bucket.label) + " " + (counts[bucket.key] || 0) + "</a>" | |
| 248 | + ).join(""); | |
| 249 | + return ( | |
| 250 | + '<a id="nav_project_name" href="#/projects">' + escapeHtml(project.name) + "</a>" | |
| 251 | + + '<span class="nav-counts">' + countsHtml + "</span>" | |
| 252 | + ); | |
| 253 | +} | |
| 254 | + | |
| 255 | +// Guards against out-of-order responses: if the current project is switched | |
| 256 | +// (or this fires again before an earlier fetch resolves), a stale response | |
| 257 | +// landing after a newer one must not overwrite the nav with old data. | |
| 258 | +let navProjectContextRequestId = 0; | |
| 259 | + | |
| 260 | +async function refreshNavProjectContext() { | |
| 261 | + const contextEl = document.getElementById("nav_project_context"); | |
| 262 | + if (!contextEl) return; | |
| 263 | + const projectId = getCurrentProjectId(); | |
| 264 | + const requestId = ++navProjectContextRequestId; | |
| 265 | + if (!projectId) { | |
| 266 | + // Requirement 1: no project selected -> render nothing, not a hint. | |
| 267 | + contextEl.innerHTML = ""; | |
| 268 | + return; | |
| 269 | + } | |
| 270 | + try { | |
| 271 | + const [projectResp, tasksResp] = await Promise.all([ | |
| 272 | + fetch("/api/projects/" + encodeURIComponent(projectId)), | |
| 273 | + fetch("/api/tasks?project_id=" + encodeURIComponent(projectId)), | |
| 274 | + ]); | |
| 275 | + if (requestId !== navProjectContextRequestId) return; // superseded by a newer refresh | |
| 276 | + if (projectResp.status !== 200) { | |
| 277 | + contextEl.innerHTML = ""; | |
| 278 | + return; | |
| 279 | + } | |
| 280 | + const project = await projectResp.json(); | |
| 281 | + const tasks = tasksResp.status === 200 ? await tasksResp.json() : []; | |
| 282 | + if (requestId !== navProjectContextRequestId) return; | |
| 283 | + contextEl.innerHTML = renderNavProjectContextHtml(project, computeNavStatusBucketCounts(tasks)); | |
| 284 | + } catch (e) { | |
| 285 | + if (requestId === navProjectContextRequestId) contextEl.innerHTML = ""; | |
| 286 | + } | |
| 287 | +} | |
| 288 | + | |
| 167 | 289 | // ---- v_project_list (2026-08-03): project list + CRUD ---- |
| 168 | 290 | // 02-V originally declared "不做完整多项目管理 UI(项目列表/切换)" as an |
| 169 | 291 | // MVP non-goal -- this page is a deliberate, explicit scope expansion |
| ... | ... | @@ -885,7 +1007,21 @@ function renderTaskBoard() { |
| 885 | 1007 | `; |
| 886 | 1008 | } |
| 887 | 1009 | |
| 888 | -function mountTaskBoard() { | |
| 1010 | +// U-047/U-048: nav-bar status-bucket links land on #/tasks as | |
| 1011 | +// "?status=<value>" -- this picks the status to pre-select, on initial | |
| 1012 | +// mount only (same reasoning as v_task_board's other filters: it's a | |
| 1013 | +// starting value the user can then change, not re-applied on every route | |
| 1014 | +// re-render). An unrecognized/stale status value is ignored rather than | |
| 1015 | +// left in a select the <option> list doesn't actually have. Split out as | |
| 1016 | +// its own pure function so it's testable without a real <select> element. | |
| 1017 | +function pickInitialTaskBoardStatus(queryParams) { | |
| 1018 | + if (queryParams && queryParams.status && TASK_STATUSES.includes(queryParams.status)) { | |
| 1019 | + return queryParams.status; | |
| 1020 | + } | |
| 1021 | + return null; | |
| 1022 | +} | |
| 1023 | + | |
| 1024 | +function mountTaskBoard(queryParams) { | |
| 889 | 1025 | const projectId = getCurrentProjectId(); |
| 890 | 1026 | if (!projectId) return; |
| 891 | 1027 | const rowsEl = document.getElementById("tb_rows"); |
| ... | ... | @@ -894,6 +1030,9 @@ function mountTaskBoard() { |
| 894 | 1030 | const orgNodeEl = document.getElementById("tb_org_node"); |
| 895 | 1031 | const sortEl = document.getElementById("tb_sort"); |
| 896 | 1032 | |
| 1033 | + const initialStatus = pickInitialTaskBoardStatus(queryParams); | |
| 1034 | + if (initialStatus) statusEl.value = initialStatus; | |
| 1035 | + | |
| 897 | 1036 | async function refresh() { |
| 898 | 1037 | const params = new URLSearchParams({ project_id: projectId }); |
| 899 | 1038 | if (keywordEl.value.trim()) params.set("keyword", keywordEl.value.trim()); | ... | ... |
frontend/index.html
| ... | ... | @@ -15,6 +15,7 @@ |
| 15 | 15 | <a href="#/skills">Skills</a> |
| 16 | 16 | <a href="#/deployment">Deployment</a> |
| 17 | 17 | <a href="#/settings/concurrency">Settings</a> |
| 18 | + <span id="nav_project_context" class="nav-project-context"></span> | |
| 18 | 19 | </nav> |
| 19 | 20 | <main id="app"></main> |
| 20 | 21 | <script src="/app.js"></script> | ... | ... |
frontend/style.css
| 1 | 1 | /* U-008: minimal shell styling, nothing to design-review yet -- real look |
| 2 | 2 | and feel is PH3, starting from docs/wireframes/*.html. */ |
| 3 | 3 | body { font-family: system-ui, sans-serif; margin: 0; } |
| 4 | -#nav { display: flex; gap: 1rem; padding: 1rem; border-bottom: 1px solid #ddd; } | |
| 4 | +#nav { display: flex; gap: 1rem; padding: 1rem; border-bottom: 1px solid #ddd; align-items: center; } | |
| 5 | 5 | #nav a { text-decoration: none; color: #333; } |
| 6 | 6 | #nav a.active { font-weight: bold; } |
| 7 | 7 | #app { padding: 1rem; } |
| 8 | 8 | |
| 9 | +/* U-048: nav-bar "current project" context (name + task status summary) -- | |
| 10 | + pinned to the right of the fixed route links via margin-left:auto, empty | |
| 11 | + (renders nothing) whenever no project is currently selected. Reuses the | |
| 12 | + --status-* tokens defined below rather than inventing new colors. */ | |
| 13 | +#nav_project_context { margin-left: auto; display: flex; align-items: center; gap: 10px; font-size: 13px; } | |
| 14 | +#nav_project_name { font-weight: 600; color: #111827; } | |
| 15 | +.nav-counts { display: flex; gap: 8px; } | |
| 16 | +.nav-count { color: #4b5563; } | |
| 17 | +.nav-count-pending { color: var(--status-pending); } | |
| 18 | +.nav-count-running { color: var(--status-running); } | |
| 19 | +.nav-count-attention { color: var(--status-blocked); } | |
| 20 | +.nav-count-done { color: var(--status-done); } | |
| 21 | + | |
| 9 | 22 | /* U-022/U-023: v_project_setup/v_goal_composer/v_bug_feedback -- class |
| 10 | 23 | names match docs/wireframes/v_*.html */ |
| 11 | 24 | .top-back a { color: #6b7280; text-decoration: none; } | ... | ... |
frontend/test_nav_project_context_smoke.js
0 → 100644
| 1 | +// U-047/U-048 (2026-08-07 SDD) smoke test: nav-bar "current project" context. | |
| 2 | +// Same no-framework/no-fixture convention as the other frontend/test_*_smoke.js | |
| 3 | +// files (see .claude/agent-memory/validator/feedback_no_js_test_framework.md) | |
| 4 | +// -- loads real app.js in a bare vm.Context and asserts on generated HTML | |
| 5 | +// strings / regex matches / real async control flow, not mocks of the logic | |
| 6 | +// under test. | |
| 7 | +// | |
| 8 | +// Covers: | |
| 9 | +// - U-047: parseHashQuery -- happy path, no-query hash, empty value, | |
| 10 | +// multiple params; and the regression this was explicitly designed not | |
| 11 | +// to break: TASK_DETAIL_RE must still match the *path* half of a hash | |
| 12 | +// that now carries a query string. | |
| 13 | +// - U-048 nav highlighting: a nav link whose href has no query string and | |
| 14 | +// the current hash's query-stripped path must be recognized as the same | |
| 15 | +// route (this is exactly what render()'s "#nav > a" active-toggling | |
| 16 | +// loop relies on). | |
| 17 | +// - U-048 bucket mapping: computeNavStatusBucketCounts groups all 10 raw | |
| 18 | +// task.status values into the 4 nav buckets (pending/running/attention/ | |
| 19 | +// done), and navBucketHref only appends ?status=<value> for the two | |
| 20 | +// single-status buckets (pending/done) -- multi-status buckets | |
| 21 | +// (running/attention) link to the unfiltered #/tasks, since | |
| 22 | +// v_task_board's status filter is single-select. | |
| 23 | +// - U-048 DOM contract: renderNavProjectContextHtml emits #nav_project_name | |
| 24 | +// (linking to #/projects) and one .nav-count-<bucket> per bucket, and | |
| 25 | +// renders nothing at all when no project is given (unselected-project | |
| 26 | +// state, requirement 1). | |
| 27 | +// - U-048 refreshNavProjectContext (real async function, real Promise | |
| 28 | +// control, no reimplementation): single-fetch-per-refresh (one call to | |
| 29 | +// /api/projects/:id, one to /api/tasks?project_id=... -- not one fetch | |
| 30 | +// per bucket), stale/out-of-order response protection when the current | |
| 31 | +// project changes mid-flight, and the unselected-project case doing zero | |
| 32 | +// fetches. | |
| 33 | +// - mountTaskBoard: pre-selects tb_status from an initial ?status= query | |
| 34 | +// param, ignoring an unrecognized value instead of setting it blindly. | |
| 35 | +// | |
| 36 | +// Run: node frontend/test_nav_project_context_smoke.js | |
| 37 | + | |
| 38 | +"use strict"; | |
| 39 | +const fs = require("fs"); | |
| 40 | +const path = require("path"); | |
| 41 | +const vm = require("vm"); | |
| 42 | +const assert = require("assert"); | |
| 43 | + | |
| 44 | +function makeFakeDiv() { | |
| 45 | + let text = ""; | |
| 46 | + return { | |
| 47 | + set textContent(v) { text = v == null ? "" : String(v); }, | |
| 48 | + get textContent() { return text; }, | |
| 49 | + get innerHTML() { | |
| 50 | + return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """); | |
| 51 | + }, | |
| 52 | + }; | |
| 53 | +} | |
| 54 | + | |
| 55 | +// Unlike makeFakeDiv (models a text node via textContent -> escaped | |
| 56 | +// innerHTML), nav_project_context needs a plain settable/gettable string -- | |
| 57 | +// app.js writes a real HTML string there, not text that gets escaped. | |
| 58 | +function makeRawEl() { | |
| 59 | + let html = ""; | |
| 60 | + return { | |
| 61 | + set innerHTML(v) { html = v; }, | |
| 62 | + get innerHTML() { return html; }, | |
| 63 | + }; | |
| 64 | +} | |
| 65 | + | |
| 66 | +let currentProjectId = null; | |
| 67 | +let fetchCalls = []; | |
| 68 | +let pendingFetches = []; | |
| 69 | +function fakeFetch(url) { | |
| 70 | + fetchCalls.push(url); | |
| 71 | + return new Promise((resolve) => { pendingFetches.push({ url, resolve }); }); | |
| 72 | +} | |
| 73 | +function resolveFetch(entry, status, body) { | |
| 74 | + entry.resolve({ status, json: async () => body }); | |
| 75 | +} | |
| 76 | + | |
| 77 | +const navContextEl = makeRawEl(); | |
| 78 | +const elementsById = { | |
| 79 | + nav_project_context: navContextEl, | |
| 80 | +}; | |
| 81 | + | |
| 82 | +const sandbox = { | |
| 83 | + document: { | |
| 84 | + createElement: () => makeFakeDiv(), | |
| 85 | + getElementById: (id) => elementsById[id] || null, | |
| 86 | + querySelectorAll: () => [], | |
| 87 | + addEventListener: () => {}, | |
| 88 | + }, | |
| 89 | + window: { | |
| 90 | + location: { protocol: "http:", host: "localhost", hash: "" }, | |
| 91 | + addEventListener: () => {}, | |
| 92 | + }, | |
| 93 | + localStorage: { getItem: () => currentProjectId, setItem: (k, v) => { currentProjectId = v; } }, | |
| 94 | + fetch: fakeFetch, | |
| 95 | + console, | |
| 96 | +}; | |
| 97 | +vm.createContext(sandbox); | |
| 98 | + | |
| 99 | +const src = fs.readFileSync(path.join(__dirname, "app.js"), "utf8"); | |
| 100 | +// Top-level `const` regexes/arrays stay lexically scoped to the script and | |
| 101 | +// never reach the sandbox object (same Annex B quirk the other smoke tests | |
| 102 | +// already document) -- re-export what this test needs. | |
| 103 | +const augmentedSrc = src + "\nglobalThis.TASK_DETAIL_RE = TASK_DETAIL_RE;\nglobalThis.NAV_STATUS_BUCKETS = NAV_STATUS_BUCKETS;\n"; | |
| 104 | +new vm.Script(augmentedSrc, { filename: "app.js" }).runInContext(sandbox); | |
| 105 | + | |
| 106 | +let assertionCount = 0; | |
| 107 | +function check(condition, message) { | |
| 108 | + assertionCount++; | |
| 109 | + assert.ok(condition, message); | |
| 110 | +} | |
| 111 | + | |
| 112 | +async function main() { | |
| 113 | + // ---- 1. parseHashQuery: happy / edge cases ---- | |
| 114 | + { | |
| 115 | + const noQuery = sandbox.parseHashQuery("#/tasks"); | |
| 116 | + check(noQuery.path === "#/tasks" && Object.keys(noQuery.params).length === 0, "no '?' -> whole string is the path, empty params"); | |
| 117 | + | |
| 118 | + const single = sandbox.parseHashQuery("#/tasks?status=done"); | |
| 119 | + check(single.path === "#/tasks" && single.params.status === "done", "single key=value param parsed correctly"); | |
| 120 | + | |
| 121 | + const multi = sandbox.parseHashQuery("#/tasks?status=done&org_node=coder"); | |
| 122 | + check(multi.path === "#/tasks" && multi.params.status === "done" && multi.params.org_node === "coder", "multiple params all parsed"); | |
| 123 | + | |
| 124 | + const trailing = sandbox.parseHashQuery("#/tasks?"); | |
| 125 | + check(trailing.path === "#/tasks" && Object.keys(trailing.params).length === 0, "trailing '?' with nothing after it -> empty params, not a crash"); | |
| 126 | + | |
| 127 | + const emptyValue = sandbox.parseHashQuery("#/tasks?status="); | |
| 128 | + check(emptyValue.params.status === "", "key with empty value still produces a key, not dropped"); | |
| 129 | + | |
| 130 | + const encoded = sandbox.parseHashQuery("#/tasks?keyword=a%20b"); | |
| 131 | + check(encoded.params.keyword === "a b", "percent-encoded values are decoded"); | |
| 132 | + } | |
| 133 | + | |
| 134 | + // ---- 2. Regression: TASK_DETAIL_RE must still match the query-stripped path ---- | |
| 135 | + { | |
| 136 | + const withQuery = sandbox.parseHashQuery("#/tasks/abc-123?foo=bar"); | |
| 137 | + const match = withQuery.path.match(sandbox.TASK_DETAIL_RE); | |
| 138 | + check(match && match[1] === "abc-123", "TASK_DETAIL_RE must match the path half of a hash carrying a query string"); | |
| 139 | + | |
| 140 | + const withoutQuery = sandbox.parseHashQuery("#/tasks/abc-123"); | |
| 141 | + check(withoutQuery.path.match(sandbox.TASK_DETAIL_RE)[1] === "abc-123", "no-query case keeps working exactly as before (no regression)"); | |
| 142 | + } | |
| 143 | + | |
| 144 | + // ---- 3. Nav highlighting: query-stripped paths must compare equal ---- | |
| 145 | + { | |
| 146 | + const currentHashPath = sandbox.parseHashQuery("#/tasks?status=queued").path; | |
| 147 | + const plainNavLinkPath = sandbox.parseHashQuery("#/tasks").path; | |
| 148 | + check(currentHashPath === plainNavLinkPath, "a plain '#/tasks' nav link must still be recognized as 'active' when the real hash carries a status query string"); | |
| 149 | + } | |
| 150 | + | |
| 151 | + // ---- 4. computeNavStatusBucketCounts: all 10 statuses land in exactly one of the 4 buckets ---- | |
| 152 | + { | |
| 153 | + const allStatuses = [ | |
| 154 | + "queued", "planning_brd_prd_sdd", "planning_failed", "coding", "coding_failed", | |
| 155 | + "validating", "routed_fast_lane", "routed_full_replan", "blocked_pending_human", "done", | |
| 156 | + ]; | |
| 157 | + const tasks = allStatuses.map((status, i) => ({ task_id: "t" + i, status })); | |
| 158 | + const counts = sandbox.computeNavStatusBucketCounts(tasks); | |
| 159 | + const total = Object.values(counts).reduce((a, b) => a + b, 0); | |
| 160 | + check(total === allStatuses.length, "every raw status must be counted into exactly one bucket, none dropped/double-counted"); | |
| 161 | + check(counts.pending === 1 && counts.done === 1, "pending/done are single-status buckets (queued/done respectively)"); | |
| 162 | + check(counts.running === 5, "running bucket: planning_brd_prd_sdd, coding, validating, routed_fast_lane, routed_full_replan"); | |
| 163 | + check(counts.attention === 3, "attention bucket: planning_failed, coding_failed, blocked_pending_human"); | |
| 164 | + | |
| 165 | + const empty = sandbox.computeNavStatusBucketCounts([]); | |
| 166 | + check(Object.values(empty).every((n) => n === 0), "zero tasks -> every bucket is 0, not undefined/NaN"); | |
| 167 | + } | |
| 168 | + | |
| 169 | + // ---- 5. navBucketHref: single-status buckets carry ?status=, multi-status ones don't ---- | |
| 170 | + { | |
| 171 | + for (const bucket of sandbox.NAV_STATUS_BUCKETS) { | |
| 172 | + const href = sandbox.navBucketHref(bucket); | |
| 173 | + if (bucket.statuses.length === 1) { | |
| 174 | + check(href === "#/tasks?status=" + bucket.statuses[0], bucket.key + " (single-status) must link with its exact status filter"); | |
| 175 | + } else { | |
| 176 | + check(href === "#/tasks", bucket.key + " (multi-status) must link to the unfiltered board, v_task_board's filter is single-select"); | |
| 177 | + } | |
| 178 | + } | |
| 179 | + } | |
| 180 | + | |
| 181 | + // ---- 6. renderNavProjectContextHtml: DOM contract + unselected-project state ---- | |
| 182 | + { | |
| 183 | + const html = sandbox.renderNavProjectContextHtml( | |
| 184 | + { project_id: "p1", name: "My <Project>" }, | |
| 185 | + { pending: 3, running: 2, attention: 1, done: 12 }, | |
| 186 | + ); | |
| 187 | + check(html.includes('id="nav_project_name"'), "must carry #nav_project_name"); | |
| 188 | + check(html.includes('href="#/projects"'), "project name must link to #/projects (switch mechanism, no rebuild)"); | |
| 189 | + check(html.includes("My <Project>"), "project name must be HTML-escaped"); | |
| 190 | + check(html.includes('nav-count-pending') && html.includes("pending 3"), "pending bucket count rendered"); | |
| 191 | + check(html.includes('nav-count-running') && html.includes("running 2"), "running bucket count rendered"); | |
| 192 | + check(html.includes('nav-count-attention') && html.includes("attention 1"), "attention bucket count rendered"); | |
| 193 | + check(html.includes('nav-count-done') && html.includes("done 12"), "done bucket count rendered"); | |
| 194 | + | |
| 195 | + check(sandbox.renderNavProjectContextHtml(null, {}) === "", "no project selected -> render nothing (requirement 1)"); | |
| 196 | + } | |
| 197 | + | |
| 198 | + // ---- 7. refreshNavProjectContext: unselected-project state does zero fetches ---- | |
| 199 | + { | |
| 200 | + currentProjectId = null; | |
| 201 | + fetchCalls = []; | |
| 202 | + await sandbox.refreshNavProjectContext(); | |
| 203 | + check(fetchCalls.length === 0, "no current project -> must not fetch anything"); | |
| 204 | + check(navContextEl.innerHTML === "", "no current project -> nav context area must be empty"); | |
| 205 | + } | |
| 206 | + | |
| 207 | + // ---- 8. refreshNavProjectContext: single-fetch-per-refresh + stale/out-of-order protection ---- | |
| 208 | + { | |
| 209 | + fetchCalls = []; | |
| 210 | + pendingFetches = []; | |
| 211 | + | |
| 212 | + currentProjectId = "proj-A"; | |
| 213 | + const promiseA = sandbox.refreshNavProjectContext(); | |
| 214 | + currentProjectId = "proj-B"; | |
| 215 | + const promiseB = sandbox.refreshNavProjectContext(); | |
| 216 | + | |
| 217 | + check(pendingFetches.length === 4, "two refreshes -> exactly 2 fetches each (project + tasks), never per-bucket (single-fetch-per-refresh)"); | |
| 218 | + check(fetchCalls[0].includes("/api/projects/proj-A") && fetchCalls[1].includes("project_id=proj-A"), "call A fetches proj-A's project + tasks"); | |
| 219 | + check(fetchCalls[2].includes("/api/projects/proj-B") && fetchCalls[3].includes("project_id=proj-B"), "call B fetches proj-B's project + tasks"); | |
| 220 | + | |
| 221 | + // Resolve B (the newer, current request) first... | |
| 222 | + resolveFetch(pendingFetches[2], 200, { project_id: "proj-B", name: "Project B" }); | |
| 223 | + resolveFetch(pendingFetches[3], 200, [{ task_id: "t1", status: "done" }]); | |
| 224 | + await promiseB; | |
| 225 | + check(navContextEl.innerHTML.includes("Project B"), "current (B) response must be rendered"); | |
| 226 | + | |
| 227 | + // ...then resolve A's (older, now-superseded) response arriving late. | |
| 228 | + resolveFetch(pendingFetches[0], 200, { project_id: "proj-A", name: "Project A" }); | |
| 229 | + resolveFetch(pendingFetches[1], 200, [{ task_id: "t2", status: "queued" }]); | |
| 230 | + await promiseA; | |
| 231 | + check(navContextEl.innerHTML.includes("Project B"), "a stale response for a since-superseded project must not overwrite the current (B) nav state"); | |
| 232 | + check(!navContextEl.innerHTML.includes("Project A"), "project A's late response must be discarded entirely"); | |
| 233 | + } | |
| 234 | + | |
| 235 | + // ---- 9. pickInitialTaskBoardStatus: mountTaskBoard's ?status= pre-select logic ---- | |
| 236 | + // (mountTaskBoard itself isn't called directly here -- it also wires up a | |
| 237 | + // real WebSocket + several other DOM elements this bare sandbox doesn't | |
| 238 | + // stub, same reasoning documented in feedback_no_js_test_framework.md for | |
| 239 | + // why mount* functions aren't exercised end-to-end elsewhere in this repo's | |
| 240 | + // frontend smoke tests either.) | |
| 241 | + { | |
| 242 | + check(sandbox.pickInitialTaskBoardStatus({ status: "coding" }) === "coding", "a recognized status query param is picked"); | |
| 243 | + check(sandbox.pickInitialTaskBoardStatus({ status: "not_a_real_status" }) === null, "an unrecognized status value must be ignored, not blindly returned"); | |
| 244 | + check(sandbox.pickInitialTaskBoardStatus({}) === null, "no status query param at all -> null (filter stays untouched)"); | |
| 245 | + check(sandbox.pickInitialTaskBoardStatus(undefined) === null, "missing queryParams entirely (defensive) -> null, no throw"); | |
| 246 | + } | |
| 247 | + | |
| 248 | + console.log("frontend/test_nav_project_context_smoke.js: all " + assertionCount + " assertions passed"); | |
| 249 | +} | |
| 250 | + | |
| 251 | +main().catch((e) => { | |
| 252 | + console.error(e); | |
| 253 | + process.exit(1); | |
| 254 | +}); | ... | ... |