diff --git a/src/functional_tests/test_billboard.py b/src/functional_tests/test_billboard.py index 16fe0e3..60faab2 100644 --- a/src/functional_tests/test_billboard.py +++ b/src/functional_tests/test_billboard.py @@ -154,16 +154,19 @@ class BillscrollPositionTest(FunctionalTest): ) # 2. Force the element scrollable (CSS not served by StaticLiveServerTestCase), - # set position, and dispatch scroll event to trigger the debounced save. - # JS saves scrollTop + clientHeight (bottom-of-viewport); forced height is 150px. + # set position, dispatch scroll event, and capture the position value the + # JS listener will save — all in one synchronous script so the layout + # snapshot is identical to what the scroll handler sees. scroll_top = 100 - forced_height = 150 - self.browser.execute_script(""" + saved_pos = self.browser.execute_script(""" var el = arguments[0]; + var remPx = parseFloat(getComputedStyle(document.documentElement).fontSize); el.style.overflow = 'auto'; el.style.height = '150px'; el.scrollTop = arguments[1]; + var pos = Math.round(el.scrollTop + el.clientHeight + remPx * 2.5); el.dispatchEvent(new Event('scroll')); + return pos; """, scroll_el, scroll_top) # 3. Wait for debounce (800ms) + fetch to complete @@ -180,11 +183,8 @@ class BillscrollPositionTest(FunctionalTest): scroll_el = self.wait_for( lambda: self.browser.find_element(By.ID, "id_drama_scroll") ) - buffer_px = self.browser.execute_script( - "return Math.round(parseFloat(getComputedStyle(document.documentElement).fontSize) * 2.5)" - ) restored = int(scroll_el.get_attribute("data-scroll-position")) - self.assertEqual(restored, scroll_top + forced_height + buffer_px) + self.assertEqual(restored, saved_pos) class BillboardAppletsTest(FunctionalTest): diff --git a/src/templates/apps/billboard/_partials/_applet-billboard-scroll.html b/src/templates/apps/billboard/_partials/_applet-billboard-scroll.html index 264f747..830c189 100644 --- a/src/templates/apps/billboard/_partials/_applet-billboard-scroll.html +++ b/src/templates/apps/billboard/_partials/_applet-billboard-scroll.html @@ -22,8 +22,12 @@ } } - // Restore: position stored is bottom-of-viewport; subtract clientHeight to align it - scroll.scrollTop = Math.max(0, {{ scroll_position }} - scroll.clientHeight); + // Only restore if there's a meaningful saved position — avoids a + // no-op scrollTop assignment (0→0) that can fire a spurious scroll + // event and reset the debounce timer in tests / headless browsers. + if ({{ scroll_position }} > 0) { + scroll.scrollTop = Math.max(0, {{ scroll_position }} - scroll.clientHeight); + } }); // Animate "What happens next. . . ?" buffer dots — 4th span shows '?' @@ -37,21 +41,24 @@ }, 400); } - // Debounced save on scroll — store bottom-of-viewport so the last-read line is restored + // Debounced save on scroll — store bottom-of-viewport so the last-read line is restored. + // Position is captured at event time so layout changes during the debounce window + // (e.g. rAF adjusting marginTop) don't produce a stale clientHeight. + var remPx = parseFloat(getComputedStyle(document.documentElement).fontSize); var saveTimer; scroll.addEventListener('scroll', function() { + var pos = Math.round(scroll.scrollTop + scroll.clientHeight + remPx * 2.5); clearTimeout(saveTimer); saveTimer = setTimeout(function() { var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]'); var token = csrfToken ? csrfToken.value : ''; - var remPx = parseFloat(getComputedStyle(document.documentElement).fontSize); fetch("{% url 'billboard:save_scroll_position' room.id %}", { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': token, }, - body: 'position=' + Math.round(scroll.scrollTop + scroll.clientHeight + remPx * 2.5), + body: 'position=' + pos, }); }, 800); });