2026-08-01 17:15:30 +02:00
|
|
|
"""A redraw keeps where you were looking (task 52).
|
|
|
|
|
|
|
|
|
|
`renderBoard()` starts with `board.innerHTML = ''` and rebuilds every
|
|
|
|
|
column, so each column's scrolling `.drop` was a brand-new node on every
|
|
|
|
|
pass — and a new node's scrollTop is 0. `render()` runs on every SSE frame
|
|
|
|
|
and a working agent emits an event per tool use, so a column being read
|
|
|
|
|
snapped back to the top several times a minute. The same wipe threw away
|
|
|
|
|
`#board`'s horizontal position, the session timeline, the sessions rail,
|
|
|
|
|
the Focus view and the activity log's place when it was not stuck to the
|
|
|
|
|
bottom.
|
|
|
|
|
|
|
|
|
|
The fix is a mark-and-restore pair around each wipe, keyed by a stable
|
|
|
|
|
name rather than a node (`v:col:<slug>`, `v:tl:<sid>`), clamped so a
|
|
|
|
|
column that lost cards lands at its new bottom instead of past it.
|
|
|
|
|
|
|
|
|
|
board.html is a single file with inline JS and no frontend test runner, so
|
|
|
|
|
this suite does what tests/test_drawer_markdown.py does: it lifts the
|
|
|
|
|
scroll helpers straight out of the page and runs them under node, where
|
|
|
|
|
their actual behaviour is what to assert on. Node is not a dependency of
|
|
|
|
|
bench itself, so those checks skip when it is absent; the source-level
|
|
|
|
|
invariants at the bottom always run and are in the same style as the
|
|
|
|
|
board's other `test_*.py` checks on board.html.
|
|
|
|
|
|
|
|
|
|
python3 -m unittest discover -s tests -v
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import re
|
|
|
|
|
import shutil
|
|
|
|
|
import subprocess
|
|
|
|
|
import tempfile
|
|
|
|
|
import unittest
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
BOARD = ROOT / "manager" / "core" / "board.html"
|
|
|
|
|
NODE = shutil.which("node")
|
|
|
|
|
|
|
|
|
|
HTML = BOARD.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def lift(pattern: str, what: str) -> str:
|
|
|
|
|
m = re.search(pattern, HTML, re.M | re.S)
|
|
|
|
|
assert m, f"board.html lost {what}"
|
|
|
|
|
return m.group(0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
HELPERS = (
|
|
|
|
|
lift(r"^const scrollMarks = \{\};\n", "its scroll-mark registry")
|
|
|
|
|
+ lift(r"^function markScroll\(key, el\) \{\n.*?\n\}\n", "markScroll()")
|
|
|
|
|
+ lift(r"^function restoreScroll\(key, el\) \{\n.*?\n\}\n", "restoreScroll()")
|
|
|
|
|
+ lift(r"^function forgetViewScroll\(\) \{\n.*?\n\}\n", "forgetViewScroll()")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# A scroller with none of the DOM's own clamping, so what the page writes
|
|
|
|
|
# is exactly what the assertions see. Its geometry can be changed between
|
|
|
|
|
# the mark and the restore — that is a redraw whose content moved on.
|
|
|
|
|
FAKE = """
|
|
|
|
|
function scroller({top = 0, left = 0, h = 900, ch = 300, w = 0, cw = 0} = {}) {
|
|
|
|
|
return {scrollTop: top, scrollLeft: left,
|
|
|
|
|
scrollHeight: h, clientHeight: ch, scrollWidth: w, clientWidth: cw};
|
|
|
|
|
}
|
|
|
|
|
const out = {};
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
CASES = """
|
|
|
|
|
// a column read halfway down, redrawn with the same content
|
|
|
|
|
{
|
|
|
|
|
const col = scroller({top: 240});
|
|
|
|
|
markScroll('v:col:done', col);
|
|
|
|
|
col.scrollTop = 0; // the wipe: a brand-new node
|
|
|
|
|
restoreScroll('v:col:done', col);
|
|
|
|
|
out.same_content = col.scrollTop;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// the column lost cards while it was scrolled to the bottom: land at the
|
|
|
|
|
// new bottom, not past it
|
|
|
|
|
{
|
|
|
|
|
const col = scroller({top: 600, h: 900, ch: 300});
|
|
|
|
|
markScroll('v:col:review', col);
|
|
|
|
|
col.scrollTop = 0; col.scrollHeight = 400;
|
|
|
|
|
restoreScroll('v:col:review', col);
|
|
|
|
|
out.shorter = col.scrollTop;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// everything left the column: it is now shorter than its own viewport
|
|
|
|
|
{
|
|
|
|
|
const col = scroller({top: 600, h: 900, ch: 300});
|
|
|
|
|
markScroll('v:col:empty', col);
|
|
|
|
|
col.scrollTop = 0; col.scrollHeight = 40;
|
|
|
|
|
restoreScroll('v:col:empty', col);
|
|
|
|
|
out.emptied = col.scrollTop;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// #board scrolled sideways to watch review/
|
|
|
|
|
{
|
|
|
|
|
const board = scroller({left: 520, w: 2400, cw: 1200, h: 0, ch: 0});
|
|
|
|
|
markScroll('v:board', board);
|
|
|
|
|
board.scrollLeft = 0;
|
|
|
|
|
restoreScroll('v:board', board);
|
|
|
|
|
out.sideways = board.scrollLeft;
|
|
|
|
|
board.scrollLeft = 0; board.scrollWidth = 1400;
|
|
|
|
|
restoreScroll('v:board', board);
|
|
|
|
|
out.sideways_clamped = board.scrollLeft;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// a key with no mark writes nothing at all
|
|
|
|
|
{
|
|
|
|
|
const fresh = scroller({top: 77});
|
|
|
|
|
restoreScroll('v:tl:never-seen', fresh);
|
|
|
|
|
out.unmarked = fresh.scrollTop;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// two columns keep their own places
|
|
|
|
|
{
|
|
|
|
|
const a = scroller({top: 120}), b = scroller({top: 480, h: 1200});
|
|
|
|
|
markScroll('v:col:backlog', a);
|
|
|
|
|
markScroll('v:col:to-do', b);
|
|
|
|
|
a.scrollTop = 0; b.scrollTop = 0;
|
|
|
|
|
restoreScroll('v:col:backlog', a);
|
|
|
|
|
restoreScroll('v:col:to-do', b);
|
|
|
|
|
out.two_columns = [a.scrollTop, b.scrollTop];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// a timeline is keyed by session: its own place comes back, and another
|
|
|
|
|
// session's does not borrow it
|
|
|
|
|
{
|
|
|
|
|
const tl = scroller({top: 310, h: 4000});
|
|
|
|
|
markScroll('v:tl:sess-a', tl);
|
|
|
|
|
tl.scrollTop = 0;
|
|
|
|
|
restoreScroll('v:tl:sess-a', tl);
|
|
|
|
|
out.timeline_same = tl.scrollTop;
|
|
|
|
|
tl.scrollTop = 0;
|
|
|
|
|
restoreScroll('v:tl:sess-b', tl);
|
|
|
|
|
out.timeline_other = tl.scrollTop;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// switching views forgets view-scoped places; the activity log spans
|
|
|
|
|
// every view, so it keeps its own
|
|
|
|
|
{
|
|
|
|
|
const col = scroller({top: 240}), log = scroller({top: 180});
|
|
|
|
|
markScroll('v:col:done', col);
|
|
|
|
|
markScroll('log', log);
|
|
|
|
|
forgetViewScroll();
|
|
|
|
|
col.scrollTop = 0; log.scrollTop = 0;
|
|
|
|
|
restoreScroll('v:col:done', col);
|
|
|
|
|
restoreScroll('log', log);
|
|
|
|
|
out.after_view_switch = [col.scrollTop, log.scrollTop];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// an agent emitting events continuously: one redraw per tool use, and the
|
|
|
|
|
// column can still be read from top to bottom
|
|
|
|
|
{
|
|
|
|
|
const col = scroller({top: 240});
|
|
|
|
|
for (let i = 0; i < 60; i++) {
|
|
|
|
|
markScroll('v:col:in-progress', col);
|
|
|
|
|
col.scrollTop = 0; // the wipe, once per SSE frame
|
|
|
|
|
col.scrollHeight += 40; // and a card grew a live line
|
|
|
|
|
restoreScroll('v:col:in-progress', col);
|
|
|
|
|
}
|
|
|
|
|
out.sixty_frames = col.scrollTop;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
process.stdout.write(JSON.stringify(out));
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@unittest.skipUnless(NODE, "node is not installed")
|
|
|
|
|
class PageParsesTests(unittest.TestCase):
|
|
|
|
|
"""The page is one file of inline JS with no build step, so a stray
|
|
|
|
|
brace anywhere in it is a blank board and no error a test would see.
|
|
|
|
|
This change reached into six renderers; the cheapest guard is to hand
|
|
|
|
|
the whole script to a parser."""
|
|
|
|
|
|
|
|
|
|
def test_the_inline_script_parses(self):
|
|
|
|
|
m = re.search(r"<script>\n(.*)\n</script>", HTML, re.S)
|
|
|
|
|
self.assertIsNotNone(m, "board.html lost its inline script")
|
|
|
|
|
script = m.group(1)
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
|
|
|
js = Path(tmp) / "board.js"
|
|
|
|
|
js.write_text(script, encoding="utf-8")
|
|
|
|
|
run = subprocess.run([NODE, "--check", str(js)],
|
|
|
|
|
capture_output=True, text=True)
|
|
|
|
|
self.assertEqual(run.returncode, 0, run.stderr)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@unittest.skipUnless(NODE, "node is not installed")
|
|
|
|
|
class ScrollHelperTests(unittest.TestCase):
|
|
|
|
|
"""The page's own markScroll/restoreScroll, run for real."""
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def setUpClass(cls):
|
|
|
|
|
cls._dir = tempfile.TemporaryDirectory()
|
|
|
|
|
js = Path(cls._dir.name) / "scroll.js"
|
|
|
|
|
js.write_text(HELPERS + FAKE + CASES, encoding="utf-8")
|
|
|
|
|
run = subprocess.run([NODE, str(js)], capture_output=True, text=True)
|
|
|
|
|
assert run.returncode == 0, run.stderr
|
|
|
|
|
cls.out = json.loads(run.stdout)
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def tearDownClass(cls):
|
|
|
|
|
cls._dir.cleanup()
|
|
|
|
|
|
|
|
|
|
def test_a_column_keeps_its_place(self):
|
|
|
|
|
"""The reported bug: halfway down a column, an SSE frame redraws
|
|
|
|
|
the board, and you are still halfway down."""
|
|
|
|
|
self.assertEqual(self.out["same_content"], 240)
|
|
|
|
|
|
|
|
|
|
def test_a_shorter_column_lands_at_its_new_bottom(self):
|
|
|
|
|
"""Clamp rather than guess: 900/300 gave a floor of 600, 400/300
|
|
|
|
|
gives 100, and that is where a remembered 600 belongs."""
|
|
|
|
|
self.assertEqual(self.out["shorter"], 100)
|
|
|
|
|
|
|
|
|
|
def test_a_column_shorter_than_its_viewport_lands_at_the_top(self):
|
|
|
|
|
"""scrollHeight - clientHeight goes negative when the content no
|
|
|
|
|
longer fills the box; the restore must not write a negative."""
|
|
|
|
|
self.assertEqual(self.out["emptied"], 0)
|
|
|
|
|
|
|
|
|
|
def test_the_board_keeps_its_horizontal_place(self):
|
|
|
|
|
"""#board is overflow-x:auto, so a wide board scrolled right to
|
|
|
|
|
watch review/ is the same defect and gets the same clamp."""
|
|
|
|
|
self.assertEqual(self.out["sideways"], 520)
|
|
|
|
|
self.assertEqual(self.out["sideways_clamped"], 200)
|
|
|
|
|
|
|
|
|
|
def test_an_unmarked_element_is_left_alone(self):
|
|
|
|
|
"""No mark, no write — a first paint must not be dragged anywhere,
|
|
|
|
|
which is also what opens a newly selected card at the top."""
|
|
|
|
|
self.assertEqual(self.out["unmarked"], 77)
|
|
|
|
|
|
|
|
|
|
def test_sixty_redraws_do_not_drift(self):
|
|
|
|
|
"""The mark is re-taken every frame, so a busy board must not creep
|
|
|
|
|
the reader up or down over a run of them."""
|
|
|
|
|
self.assertEqual(self.out["sixty_frames"], 240)
|
|
|
|
|
|
|
|
|
|
def test_places_are_kept_per_key(self):
|
|
|
|
|
self.assertEqual(self.out["two_columns"], [120, 480])
|
|
|
|
|
|
|
|
|
|
def test_a_timeline_is_keyed_by_session(self):
|
|
|
|
|
"""Reading back through a long run survives the events it is still
|
|
|
|
|
producing; a different session is a different key, so it opens at
|
|
|
|
|
the top rather than borrowing."""
|
|
|
|
|
self.assertEqual(self.out["timeline_same"], 310)
|
|
|
|
|
self.assertEqual(self.out["timeline_other"], 0)
|
|
|
|
|
|
|
|
|
|
def test_a_view_switch_drops_view_places_but_not_the_log(self):
|
|
|
|
|
"""Coming back to a view is a fresh look, not a stale position
|
|
|
|
|
from the previous visit. The log is not in a view."""
|
|
|
|
|
self.assertEqual(self.out["after_view_switch"], [0, 180])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class RenderersKeepPlaceTests(unittest.TestCase):
|
|
|
|
|
"""Source-level invariants: the renderers that wipe a scrolling
|
|
|
|
|
element must mark before the wipe and restore after it."""
|
|
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def setUpClass(cls):
|
|
|
|
|
cls.html = HTML
|
|
|
|
|
|
|
|
|
|
def body(self, name: str) -> str:
|
|
|
|
|
return lift(rf"^function {name}\(.*?\n\}}\n", f"{name}()")
|
|
|
|
|
|
|
|
|
|
def order(self, body: str, *needles: str) -> None:
|
|
|
|
|
at = -1
|
|
|
|
|
for needle in needles:
|
|
|
|
|
found = body.find(needle, at + 1)
|
|
|
|
|
self.assertNotEqual(found, -1, f"missing {needle!r}")
|
|
|
|
|
self.assertGreater(found, at, f"{needle!r} is out of order")
|
|
|
|
|
at = found
|
|
|
|
|
|
|
|
|
|
def test_board_marks_before_the_wipe_and_restores_after(self):
|
|
|
|
|
body = self.body("renderBoard")
|
|
|
|
|
self.order(body,
|
|
|
|
|
"markScroll('v:board', board)",
|
|
|
|
|
"markScroll('v:col:'",
|
|
|
|
|
"board.innerHTML = ''",
|
|
|
|
|
"restoreScroll('v:board', board)",
|
|
|
|
|
"restoreScroll('v:col:'")
|
|
|
|
|
|
|
|
|
|
def test_columns_are_keyed_by_their_stage_slug(self):
|
|
|
|
|
"""The node does not survive the wipe, so the key must be
|
|
|
|
|
something that does — the slug already on the drop element."""
|
|
|
|
|
body = self.body("renderBoard")
|
|
|
|
|
self.assertIn("d.dataset.stage", body)
|
|
|
|
|
self.assertIn("drop.dataset.stage = stage.slug", body)
|
|
|
|
|
|
|
|
|
|
def test_the_timeline_marks_and_restores_around_its_wipe(self):
|
|
|
|
|
body = self.body("renderTimeline")
|
|
|
|
|
self.order(body,
|
|
|
|
|
"markScroll('v:tl:' + sid, tl)",
|
|
|
|
|
"tl.innerHTML =",
|
|
|
|
|
"restoreScroll('v:tl:' + sid, tl)")
|
|
|
|
|
|
|
|
|
|
def test_the_sessions_rail_marks_and_restores_around_its_wipe(self):
|
|
|
|
|
body = self.body("renderFlight")
|
|
|
|
|
self.order(body,
|
|
|
|
|
"markScroll('v:rail', rail)",
|
|
|
|
|
"rail.innerHTML =",
|
|
|
|
|
"restoreScroll('v:rail', rail)")
|
|
|
|
|
|
|
|
|
|
def test_focus_restores_on_every_path_out(self):
|
|
|
|
|
"""renderFocus() returns early when there is no session; both exits
|
|
|
|
|
have to put the view back."""
|
|
|
|
|
body = self.body("renderFocus")
|
|
|
|
|
self.assertIn("markScroll('v:focus', view)", body)
|
|
|
|
|
self.assertIn("markScroll('v:cstrip', strip)", body)
|
|
|
|
|
self.assertEqual(body.count("putBack();"), 2,
|
|
|
|
|
"both of renderFocus()'s exits must restore")
|
|
|
|
|
|
|
|
|
|
def test_the_drawer_is_keyed_per_document(self):
|
|
|
|
|
body = self.body("renderDrawer")
|
|
|
|
|
self.order(body,
|
|
|
|
|
"markScroll(S.drawerKey, body)",
|
|
|
|
|
"S.drawerKey = drawerKey()")
|
|
|
|
|
self.assertEqual(body.count("restoreScroll(key, body);"), 2,
|
|
|
|
|
"both of renderDrawer()'s painted paths must restore")
|
|
|
|
|
|
|
|
|
|
# ── the two behaviours that were already right ──
|
|
|
|
|
|
|
|
|
|
def test_the_log_still_sticks_to_the_bottom(self):
|
|
|
|
|
"""S.logStick is the older, deliberate behaviour: follow the newest
|
|
|
|
|
line when the reader was at the bottom. It runs after the restore,
|
|
|
|
|
so it wins over the mark."""
|
|
|
|
|
body = self.body("renderLog")
|
|
|
|
|
self.order(body,
|
|
|
|
|
"markScroll('log', body)",
|
|
|
|
|
"body.innerHTML =",
|
|
|
|
|
"restoreScroll('log', body)",
|
|
|
|
|
"if (S.logStick) body.scrollTop = body.scrollHeight;")
|
|
|
|
|
|
|
|
|
|
def test_the_log_scroll_listener_still_sets_the_stick(self):
|
|
|
|
|
block = lift(r"/\* the activity log:.*?\n\{.*?\n\}", "the log block")
|
|
|
|
|
self.assertIn("S.logStick = body.scrollTop + body.clientHeight", block)
|
|
|
|
|
|
|
|
|
|
def test_selecting_another_card_still_returns_the_drawer_to_the_top(self):
|
|
|
|
|
body = self.body("showDetail")
|
|
|
|
|
self.assertIn("if (changed) $('#drawerbody').scrollTop = 0;", body)
|
|
|
|
|
self.assertIn("S.selected.file !== task.file", body)
|
|
|
|
|
|
|
|
|
|
def test_opening_a_reference_file_still_returns_the_drawer_to_the_top(self):
|
|
|
|
|
body = lift(r"^async function openExtra\(.*?\n\}\n", "openExtra()")
|
|
|
|
|
self.assertIn("$('#drawerbody').scrollTop = 0;", body)
|
|
|
|
|
|
|
|
|
|
# ── the marks do not outlive the visit ──
|
|
|
|
|
|
|
|
|
|
def test_switching_views_forgets_view_places(self):
|
|
|
|
|
body = self.body("setView")
|
|
|
|
|
self.assertIn("forgetViewScroll()", body)
|
|
|
|
|
self.assertLess(body.index("forgetViewScroll()"), body.index("render()"),
|
|
|
|
|
"forget before the redraw, not after it")
|
|
|
|
|
|
|
|
|
|
def test_every_view_scoped_key_carries_the_prefix(self):
|
|
|
|
|
"""forgetViewScroll() drops exactly the `v:` keys, so a key that
|
|
|
|
|
belongs to a view and forgets the prefix would quietly survive a
|
|
|
|
|
view switch. The log is the one key that should."""
|
|
|
|
|
keys = set(re.findall(r"(?:mark|restore)Scroll\('([^']*)'", self.html))
|
|
|
|
|
unprefixed = {k for k in keys if not k.startswith("v:")}
|
|
|
|
|
self.assertEqual(unprefixed, {"log"}, "unexpected view-less key")
|
|
|
|
|
# the drawer's key is computed rather than written at the call site
|
|
|
|
|
parts = [s for s in re.findall(r"'([^']*)'", self.body("drawerKey"))
|
|
|
|
|
if ":" in s]
|
|
|
|
|
self.assertTrue(parts, "drawerKey() builds no key at all")
|
|
|
|
|
for literal in parts:
|
|
|
|
|
self.assertTrue(literal.startswith("v:"),
|
|
|
|
|
f"drawerKey() builds a view-less key: {literal!r}")
|
|
|
|
|
|
|
|
|
|
def test_no_renderer_wipes_a_scroller_without_a_mark(self):
|
|
|
|
|
"""The scrolling elements the CSS declares, against the ones the
|
|
|
|
|
renderers carry. A new one added here without a mark is the bug
|
|
|
|
|
coming back."""
|
|
|
|
|
carried = {
|
|
|
|
|
"#board": "markScroll('v:board'", # the columns, sideways
|
|
|
|
|
".drop": "markScroll('v:col:'", # one column, down
|
|
|
|
|
".tl": "markScroll('v:tl:'", # #ftl, a session timeline
|
|
|
|
|
".f-rail": "markScroll('v:rail'", # #frail, the sessions list
|
|
|
|
|
"#view-focus": "markScroll('v:focus'",
|
2026-08-02 08:55:17 +02:00
|
|
|
"#view-phases": "markScroll('v:phases'", # the lanes, down
|
|
|
|
|
".lstages": "markScroll('v:lanestages:'", # one lane, sideways
|
2026-08-01 17:15:30 +02:00
|
|
|
".c-strip": "markScroll('v:cstrip'", # #cstrip, the stage strip
|
|
|
|
|
"#logbody": "markScroll('log'",
|
|
|
|
|
"#drawerbody": "markScroll(S.drawerKey",
|
|
|
|
|
}
|
|
|
|
|
declared = set()
|
|
|
|
|
for sel, rule in re.findall(r"^ ([#.][\w-]+)\{([^}]*)\}", self.html, re.M):
|
|
|
|
|
if "overflow-y:auto" in rule or "overflow-x:auto" in rule:
|
|
|
|
|
declared.add(sel)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
declared - set(carried), set(),
|
|
|
|
|
"a scrolling element no renderer keeps the place of")
|
|
|
|
|
for sel, mark in carried.items():
|
|
|
|
|
if sel in declared:
|
|
|
|
|
self.assertIn(mark, self.html,
|
|
|
|
|
f"{sel} scrolls but nothing marks it")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|