test: add scroll-edge-state suite, close mutation gap (#1455) - #1474
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
|
P2 — The new 1,184-line test file violates the repository test-file context-safety rules and mixes formatter checks, fixtures, container selection, signatures, and pass-loop behavior. Please split it into focused sibling suites with named shared fixtures while preserving this coverage. |
scroll-edge-state.ts had no dedicated test file and scored 28.75% mutation (92/320) — the worst of the five tracked decision kernels. Adds src/utils/scroll-edge-state.test.ts covering every exported function's decision branches (captureScrollEdgeState, runScrollEdgePasses, formatScrollEdgeMessage) plus the private container-selection/scope/signature logic they exercise. Raises the scoped mutation score to 95.31% (305/320); the 15 remaining survivors are named, justified-equivalent mutants (documented in the PR).
9814a67 to
a0e8fbb
Compare
|
Two blockers at exact head
|
…ming Splits the 1,323-line scroll-edge-state.test.ts into three behavior-focused sibling suites (container selection, scope naming, pass orchestration) plus a shared fixtures module, per AGENTS.md's per-file LOC tripwire and sibling-test convention. Also removes the dead `signature`/buildScrollStateSignature/collectSubtreeNodes/ hasAncestor production code: it had zero consumers anywhere in the repo, and the tests pinning its private string format were the mutation-score-gaming flagged in review — with it gone, there's nothing left to game. Addresses review feedback on PR #1474 (#1455).
|
Split the suite into three behavior-focused sibling files (container selection, scope naming, pass orchestration) plus a shared 🤖 Addressed by Claude Code |
|
Both blockers addressed:
🤖 Addressed by Claude Code |
|
The original blockers are improved, but exact head
CI is otherwise green; iOS smoke has now passed. No |
…hable branch Splits container-selection.test.ts (562 LOC, over the 500-LOC extract tripwire) into four narrower suites by durable question: container existence, hidden-edge decisions, target resolution, and broad selection. Adds a scrollNode() fixture builder to cut inline node-literal repetition across the suite. Removes the "captureNodes resolves to undefined" test: the real contract (both production call sites, and captureScrollEdgeState's own captureNodes type) always resolves to a real array, so this exercised an unreachable branch behind a type-unsound cast. Tightens analyzeScrollEdgeState's parameter type to drop the now-provably-unreachable `| undefined`. Simplifies buildScrollContainerScope to filter out non-string identifier/label fields instead of mapping them to an empty-string placeholder — behaviorally identical (the placeholder always failed isUsefulScope's length check anyway) but removes a StringLiteral mutation surface that a synthetic fallback could never legitimately kill without coupling a test to Stryker's specific replacement text. Addresses further review feedback on PR #1474 (#1455).
|
All three addressed at
Updated score (local, scoped 🤖 Addressed by Claude Code |
|
Exact-head review is clean. The production simplifications remove unreleased, unconsumed state and preserve real call-site behavior; the focused suites exercise exported decisions without private casts; the mutation result is internally consistent at 274/288 (95.14%) with the remaining survivors plausibly justified; and the baseline is untouched. Exact-head CI is fully green. One optional tightening: the scoped-vs-unscoped “messages differ” test in |
|
Summary
src/utils/scroll-edge-state.ts(326 LOC of real decision logic) had no dedicated test file anywhere in the repo and scored 28.75% mutation (92 killed / 228 survived of 320 mutants, permutation-baselines/decision-kernels.json) — the worst of the five tracked decision kernels.captureScrollEdgeState,runScrollEdgePasses,formatScrollEdgeMessage— plus the private container-selection, hidden-edge, and scope logic they exercise.Test architecture (updated per review, twice)
Split by durable question into six sibling files plus a shared fixtures module (matching the existing
daemon-runtime-port*.test.tssibling-suite convention), each well under the 500-LOC "extract before adding" tripwire:scroll-edge-state-fixtures.tswindowRoot,scrollNode,capture,scopeFor,scrollSnapshot,captureThrowsscroll-edge-state-container-existence.test.tsscroll-edge-state-hidden-edge.test.tshasHiddenContentAtEdgedecisions, both edgesscroll-edge-state-target-resolution.test.tstarget.nodeIndex/target.pointresolution,containsPointboundaryscroll-edge-state-broad-selection.test.tsscroll-edge-state-scope.test.tsbuildScrollContainerScope/isUsefulScope/isUniqueScopeValuedecisionsscroll-edge-state-pass-orchestration.test.tsformatScrollEdgeMessage, retry/error wrapping,runScrollEdgePasses(The first iteration was a single 1,323-line file; the second split it three ways but left one 562-line file with 20 inline node literals despite the shared fixtures module — both flagged in review. This iteration adds a
scrollNode()builder and splits container-selection further into four single-question files.)Production changes (review-authorized/exposed)
Three changes, each responding to a specific review finding rather than discretionary cleanup:
signaturestate.ScrollEdgeState.signature/buildScrollStateSignature/collectSubtreeNodes/hasAncestor/roundSignatureNumberhad zero consumers anywhere in the repo (grepped exhaustively — both call sites only ever read.canScroll/.scope). The original suite pinned its exact private string format ("mutation-score gaming" per review) instead of testing an observable decision. Deleted outright rather than left for a future PR to rediscover.analyzeScrollEdgeState's parameter type. It accepted... | undefinedand normalized with?? [], but the real contract — both production call sites, andcaptureScrollEdgeState's owncaptureNodesreturn type — always resolves to a real array. Theundefinedbranch was only reachable via a type-unsound test cast (undefined as unknown as SnapshotNode[]). Dropped the| undefinedand the cast-driven test.buildScrollContainerScope. It mapped a missing identifier/label to a placeholder'', which then always failedisUsefulScope's length check — so the placeholder's exact content never mattered functionally, but review correctly found that a synthetic mutated placeholder (''→ some Stryker-chosen string) usually isn't observably equivalent oncenode.valuegets planted into the mix, and the honest fix is to stop synthesizing a fallback at all:[identifier, label].filter(isString).map(trim).find(...)instead of.map(toStringOrEmpty).find(...). Behaviorally identical for every real input, and removes the mutation surface entirely rather than chasing it with a test coupled to Stryker's specific replacement text.scroll-edge-state.ts: 326 → 289 LOC.Mutation score
Scoped run:
pnpm mutation:run --modules scroll-edge-statemutation-baselines/decision-kernels.json)This is local evidence, not CI-verified: the mutation-affected CI lane only gates once
mutation-baselines/decision-kernels.json'sgatingflag flips totrue(currentlyfalse, 0/2 stable weekly runs), so CI does not select this module for this PR. The baseline file itself was not modified — see below.Remaining 14 survivors — mechanically traced, corrected after a real counter-example
Each survivor was checked against the exact mutated span from the Stryker JSON report. Review previously caught one of these justifications as wrong (the
''-fallback mutant, addressed above by removing the fallback rather than re-justifying it) — the rest:ensureSnapshotNodes'srefassignment ternary (line 156, 6 mutants) —refis never read anywhere inscroll-edge-state.tsor its dependency chain; it exists only to satisfy theSnapshotNodetype contract.selectScrollContainer'sscrollables.length === 0early return (line 168, 2 mutants) —findNearestScrollableAncestorapplies the identical scrollable+usable-rect predicate independently, so an emptyscrollablescascades every fallback tonullregardless.x === undefined ? undefined : byIndex.get(x)guards (lines 207, 215, 3 mutants) — every realSnapshotNode.index/parentIndexis an always-definednumber, sobyIndex.get(undefined)already naturally returnsundefined..trim()before.toLowerCase().includes(normalized)(line 250, 1 mutant) —.trim()only strips leading/trailing whitespace from the haystack; it cannot change whether.includes()finds an inner substring. Mathematically equivalent for every input.matches[0]?.index === target.index(line 253, 2 mutants) — the container's own identifier/label is always a substring of itself after normalization, so the target node is always a member ofmatches;matches.length === 1already impliesmatches[0] === targetby construction.Verification
pnpm test:unit— the 6 new suites (59 tests) all pass. Full unit lane: unrelated failures each run are the repo's known CPU-contention timing flake (different file set each run — confirmed by re-running the failing files in isolation, where all pass).pnpm check:tooling— green (lint, typecheck, layering, depgraph, fallow, mcp-metadata, build, bundle-owner-files).pnpm format— ran; no diffs beyond the touched files.Baseline file
mutation-baselines/decision-kernels.jsonwas not modified. Perdocs/agents/testing.md's mutation-ratchet section, recording a new baseline (pnpm mutation:baseline) is a separate, reviewed commit, not something this PR does.Fixes #1455