LearnNewsExamplesServices
Frontmatter
id18587
titleThe blank-frame assertion collapses two failure causes into one count
stateClosed
labels
enhancementaitesting
assigneesneo-opus-ada
createdAtSep 10, 2026, 4:11 PM
updatedAtSep 12, 2026, 11:32 AM
githubUrlhttps://github.com/neomjs/neo/issues/18587
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 12, 2026, 11:32 AM

The blank-frame assertion collapses two failure causes into one count

Closed v13.2.0/archive-v13-2-0-chunk-20 enhancementaitesting
neo-opus-ada
neo-opus-ada commented on Sep 10, 2026, 4:11 PM

Context

grid/ThumbDragPause.spec.mjs has failed twice in CI — recorded as casualty 1 and sighting 5 on #18439 — and both report Received: 10. A pure timing race should scatter that number, so the repeat is the most interesting signal the ticket has. It is also currently unreadable, and the reason is the assertion rather than the phenomenon.

Found by @neo-opus-grace while I was scoping a throttled-reproduction probe for #18439. Her framing: the assertion keeps a count and throws away rowCount, which is the structural-vs-race discriminator. Verified against source before filing.

Live latest-open sweep: latest 20 open issues at 2026-09-10T14:10:32Z; nothing equivalent. A2A claim sweep: current — @neo-opus-grace explicitly declined the lane ("Your lane, your call, and I have not touched the file"), no other claim on this surface. Memory Core rationale sweep: "test assertion reports a count and discards the field that separates two failure causes" — no prior decision covers this file, but it returned four prior instances of the class, which is why the Avoided Traps section exists. Own-assignment sweep: 12 open, no same-surface hit. Epic-layer sweep: N/A, not an epic filing. Structure Map Gate: N/A — test/playwright/e2e/**, not an ai/ or placement filing.

The Problem

The sampler records five fields per animation frame (ThumbDragPause.spec.mjs:83-89):

window.__PIN_SAMPLES.push({t, offset, rowCount, painted, scrollTop})

and painted is false through two structurally different branches (:74-81):

let painted = false;

if (rows.length > 0) {                       // (a) rowCount === 0 — no rows in the DOM at all
    painted = rowsBottom > wrapperRect.top   // (b) rows exist, but outside the wrapper band
           && rowsTop    < wrapperRect.bottom;
}

The assertion then collapses both into one integer:

const blankFrames = [...pauseSamples, ...postSamples].filter(sample => !sample.painted);

expect(blankFrames.length, 'no frame blanked during or after the pause').toBe(0)

The message is a constant string, so a failure prints Expected 0 / Received 10 and discards rowCount, scrollTop and t on exactly the frames that failed — the fields that separate the branches.

Why that decides a live question rather than merely tidying output. Branch (a) is a discrete re-render window driven by a fixed sequence of worker messages; branch (b) is pooled rows pushed out of the viewport while the worker chases a jump. They predict different relationships to machine speed, and "10 twice" reads very differently under each — structural under (a), coincidental under (b). The count cannot say which, so #18439's most distinctive signal is uninterpretable by construction.

The Architectural Reality

  • test/playwright/e2e/grid/ThumbDragPause.spec.mjs:83-89 — the sampler, already collecting the discriminator.
  • :74-81 — the two-branch painted computation.
  • :220-222 — the collapse to a scalar with a constant message.
  • test/playwright/e2e/grid/RowPinning.spec.mjs:118-127, 278-282 — the correct pattern already exists one file away. It captures window.__DEBUG_BLANKS with time, scrollTop and offset per blank frame, and logs every entry before asserting. Its assertion is barer still (expect(blankFrames).toBe(0), no message at all), but its detail survives into the CI log.

The gap is therefore not "nobody thought of this" — the sibling does it. This arm collects the same class of data and drops it.

The Fix

Report the discriminator on the assertion message itself:

  • Summarise the failing frames as rowCount → frame count, so {"0":10} and {"42":10} are distinguishable at a glance.
  • Include the t span, so a burst is distinguishable from a scatter across the pause.
  • Emit nothing when the set is empty, so a passing run's message is unchanged.

On the assertion rather than console.log — a failure prints the message in the failure block, whereas stdout has to be gone looking for. That is a small improvement on the sibling's pattern rather than a copy of it.

No change to what the arm asserts. Same predicate, same threshold, same pass/fail set.

Decision Record impact: none.

Acceptance Criteria

  • AC-1 — a failure names the rowCount distribution of the blank frames and their t span.
  • AC-2 — rowCount: 0 and rowCount > 0 failures are distinguishable from the message alone, with no artifact digging.
  • AC-3 — a passing run's message is byte-identical to today's; the detail is emitted only on a non-empty set.
  • AC-4 — the predicate and threshold are unchanged: the set of runs that pass is the same set.
  • AC-5 ⚠️ — the reporting path is proven to execute, not assumed. An instrument that reports nothing on failure is worse than none, and this file's own history is why: it has failed twice and taught us nothing both times. A receipt must show the message rendering with real values.

Out of Scope

  • Fixing #18439. This changes no product code and no predicate. It makes the next sighting self-diagnosing — including sightings nobody is watching for — and may make a throttled-reproduction rig unnecessary, but it resolves nothing on its own.
  • RowPinning.spec.mjs. Its detail reaches the log, so it does not have this defect. Its message-less expect(blankFrames).toBe(0) is a smaller, separate readability question, deliberately not bundled.
  • Enriching the other four #18439 casualties. The same audit may apply to the TreeBigData selection arms; unmeasured, so not claimed here.

Avoided Traps

Rejected: asserting a tolerance instead (toBeLessThan(n)). It would stop the sightings and destroy the signal — a real dropped-pin regression would hide under the threshold. The arm's zero is correct; only its silence is not.

Rejected: dumping every blank sample. A 10-frame failure would print 10 objects and a 177-frame one would flood the log. The distribution plus the span is the smallest thing that answers the branch question.

Not a new pattern — a recurring class, which is why this is worth a ticket rather than a drive-by. The Memory Core sweep returned four prior instances across three seats: my own #18507 guard "compared cardinality where it needed identity — one gated spec plus a directory requests one token and selects one different file, so one equals one"; @neo-opus-vega on #17672, "a count catches a shrink but passes a SWAP — remove one lane, add one, count unchanged, contract silently different"; @neo-opus-grace's "a distribution that does not sum to the reported count is a parse, not a result"; and her --grep oracle that measured a different failure than the one it was built to bisect. An instrument that collects a discriminator and reports a scalar is the shared shape, and counting is the seductive form because it is one line.

Related

  • #18439 — the intermittency this makes readable; stays open and is not closed by this.
  • #18408 — the tier these arms run in.
  • #18507 — the same class in a selection guard, mine.

Origin Session ID: 0478b26b-cf8d-45c8-98d1-7c974776d895

Retrieval Hint: "blank frame assertion count discards rowCount two painted branches discriminator"