mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-07 02:07:45 +02:00
[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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user