[eric] canvas: the closed-position cap clears a real board (a 60-card board lost 10 spots at 50), with a guard that pins both the bound and the restore

This commit is contained in:
ciregenz
2026-08-12 13:11:00 -07:00
parent eb21e6eb9b
commit 13de02f91a
3 changed files with 85 additions and 4 deletions
@@ -59,7 +59,10 @@ const ReconnectingPill: React.FC = () => {
// centre (measured: centre 834 vs viewport centre 700). No transform, nothing to clobber.
return createPortal(
<Box sx={{ position: 'fixed', bottom: 16, left: 0, right: 0, zIndex: 1400, display: 'flex', justifyContent: 'center', pointerEvents: 'none' }}>
<Grow in={show} unmountOnExit>
{/* Asymmetric on purpose: the pill arrives quickly because it is answering a question you already
have, and leaves slowly so the resolution registers as calm rather than a flicker. One element
that changes its contents, never two pills swapping, so nothing on screen appears to move. */}
<Grow in={show} unmountOnExit timeout={{ enter: 180, exit: 420 }}>
<Box
onClick={showProblem ? () => window.location.reload() : undefined}
role={showProblem ? 'button' : 'status'}
@@ -90,12 +93,19 @@ const ReconnectingPill: React.FC = () => {
</Typography>
</>
) : (
<>
<Box sx={{
display: 'flex', alignItems: 'center', gap: 1,
animation: 'oswSettle 240ms ease-out',
'@keyframes oswSettle': {
from: { opacity: 0 },
to: { opacity: 1 },
},
}}>
<CheckRoundedIcon sx={{ fontSize: 16, color: c.status.success }} />
<Typography sx={{ fontSize: '0.8125rem', color: c.text.primary, fontWeight: 500 }}>
Reconnected
</Typography>
</>
</Box>
)}
</Box>
</Grow>
@@ -0,0 +1,66 @@
// Run: node --test (via frontend/scripts/run-tests.mjs)
//
// closedCardPositions remembers where a closed card sat so reopening drops it back in place. Nothing
// pruned it and it is persisted, so it grew forever: 0 -> 555 across 250 clean lifecycles, measured.
// Capping it is right, but the cap has a sharp edge worth pinning: a transient empty answer
// reconciles EVERY card away at once and the restore reads these back, so a cap below the board size
// silently scatters the overflow into fresh grid cells. At a cap of 50, a 60-card board lost exactly
// 10 cards. That is the regression these tests exist to stop.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import reducer, { reconcileSessions } from './dashboardLayoutSlice.ts';
function board(n: number) {
let s: any = reducer(undefined, { type: '@@init' });
const want: Record<string, { x: number; y: number }> = {};
for (let i = 0; i < n; i++) {
const id = 'c' + i, x = 100 + i * 37, y = 200 + i * 11;
want[id] = { x, y };
s = reducer(s, { type: 'dashboardLayout/placeCard',
payload: { sessionId: id, x, y, width: 480, height: 280, expandedSessionIds: [], exact: true } });
}
return { s, want, ids: Object.keys(s.cards) };
}
test('a card that vanishes and comes back lands exactly where it was', () => {
// Note which path remembers: reconcileSessions (the session went away) records the position, an
// explicit removeCard (you pressed close) deliberately does not. Testing the wrong one reads as a
// product bug when it is just a different intent.
const { s, want } = board(1);
let next: any = reducer(s, reconcileSessions({ sessionIds: [], expandedSessionIds: [] }));
next = reducer(next, reconcileSessions({ sessionIds: ['c0'], expandedSessionIds: [] }));
assert.equal(next.cards.c0.x, want.c0.x);
assert.equal(next.cards.c0.y, want.c0.y);
});
test('a whole big board survives a strip-and-restore with every spot intact', () => {
// The failure this pins: 60 cards, cap 50, 10 land in fresh grid cells instead of home.
const N = 60;
const { s, want, ids } = board(N);
let next: any = reducer(s, reconcileSessions({ sessionIds: [], expandedSessionIds: [] }));
next = reducer(next, reconcileSessions({ sessionIds: ids, expandedSessionIds: [] }));
const moved = ids.filter((id) => next.cards[id].x !== want[id].x || next.cards[id].y !== want[id].y);
assert.equal(moved.length, 0, `${moved.length} of ${N} cards lost their saved position`);
});
test('the map is still BOUNDED, so the leak cannot come back', () => {
// Close far more cards than any real board to prove the cap engages at all.
let s: any = reducer(undefined, { type: '@@init' });
for (let i = 0; i < 1400; i++) {
s = reducer(s, { type: 'dashboardLayout/placeCard',
payload: { sessionId: 'x' + i, x: i, y: i, width: 480, height: 280, expandedSessionIds: [] } });
s = reducer(s, reconcileSessions({ sessionIds: [], expandedSessionIds: [] }));
}
const kept = Object.keys(s.closedCardPositions).length;
assert.ok(kept <= 1000, `closedCardPositions grew to ${kept}, cap not engaging`);
assert.ok(kept > 500, `cap is too tight at ${kept}; a real board would lose spots`);
});
test('a card that vanishes twice keeps ONE entry, refreshed, not two', () => {
const { s } = board(1);
let next: any = reducer(s, reconcileSessions({ sessionIds: [], expandedSessionIds: [] }));
const first = Object.keys(next.closedCardPositions).length;
next = reducer(next, reconcileSessions({ sessionIds: ['c0'], expandedSessionIds: [] }));
next = reducer(next, reconcileSessions({ sessionIds: [], expandedSessionIds: [] }));
assert.equal(Object.keys(next.closedCardPositions).length, first, 'a re-close duplicated the entry');
});
@@ -136,7 +136,12 @@ const RECENTLY_CLOSED_CAP = 25;
// entries from 250 closes (the reconcile path records one too, so a close can bill twice). A position
// from hundreds of cards ago has no value, nobody reopens that, so keep the recent ones and drop the
// rest. Insertion order on a string-keyed object is the age order we need.
const CLOSED_POSITIONS_CAP = 50;
//
// The cap must clear the LARGEST board, not the typical one: a transient empty answer reconciles
// every card away at once and the restore reads these back, so a cap below the card count silently
// scatters the overflow into fresh grid cells. Measured at a cap of 50, a 60-card board lost exactly
// 10 cards, which is why this number is not 50.
const CLOSED_POSITIONS_CAP = 1000;
function rememberClosedPosition(
map: Record<string, CardPosition>, id: string, pos: CardPosition,