Commit 958b55acf10592ca89307997e88d7ec5e70e882f

Authored by graphlemon
1 parent 6e780d36

graphlemon: code_diff artifact for task bfd3ee82-26e5-44d6-8acc-fdbeb3d78185

.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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
  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 &lt;Project&gt;"), "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 ++});