Files

180 lines
9.0 KiB
Python

"""Real-browser middle-of-gallery bidirectional wheel regression.
After `npm run build` and `python scripts/preview.py --port 8189`, run
`python scripts/browser_masonry_mid.py http://127.0.0.1:8189`.
Checks image visibility/identity, scroll movement and card coordinates on many
mid-gallery reversals, not just when approaching the end of the list.
"""
from __future__ import annotations
import asyncio
import os
import sys
DEBUG = bool(os.environ.get("MASONRY_MID_DEBUG"))
from playwright.async_api import async_playwright
import browser_masonry as fixture
BASE = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://127.0.0.1:8189"
fixture.BASE = BASE
fixture.COUNT = 160
def assert_stable(previous, current, direction):
expected = max(0, min(current["max"], previous["scroll"] + direction))
assert abs(current["scroll"] - expected) <= 3, (
"scroll anchor correction", direction, previous["scroll"], current["scroll"], expected)
assert abs(current["height"] - previous["height"]) <= 2, (
"masonry total height jumped mid-scroll", previous["height"], current["height"])
if current["hovered"] is not None:
assert current["hoverTransform"] in {"none", "matrix(1, 0, 0, 1, 0, 0)"}, (
"scrolling over a photo translated the hovered card", current["hovered"], current["hoverTransform"])
shared = set(previous["positions"]) & set(current["positions"])
# Cards that remain mounted should move only by the distance the container
# scrolls. Their layout top must not change when RO measures other cards.
moved = {key: round(current["positions"][key] - previous["positions"][key], 2)
for key in shared if abs(current["positions"][key] - previous["positions"][key]) > 2}
assert not moved, ("visible masonry cards changed layout position", direction, moved)
return len(shared)
async def run_case(browser, profile: str, *, width: int = 1440):
page, errors = await fixture.synthetic_page(browser, width, profile=profile)
try:
main = page.locator(".ue-main-shell")
await main.evaluate("n => n.scrollTop = n.scrollHeight * 0.45")
await page.wait_for_timeout(360)
await page.evaluate("""() => {
const main = document.querySelector('.ue-main-shell');
const grid = document.querySelector('.ue-gallery-grid--virtual');
window.__midGalleryEvents = [];
const observe = new MutationObserver((records) => {
for (const record of records) {
for (const [nodes, op] of [[record.addedNodes,'add'],[record.removedNodes,'remove']]) {
for (const node of nodes) {
if (node.nodeType !== 1) continue;
const card = node.classList.contains('ue-gallery-card') ? node : node.closest('.ue-gallery-card');
if (!card) continue;
const rect = card.getBoundingClientRect();
const bounds = main.getBoundingClientRect();
if (rect.bottom >= bounds.top && rect.top <= bounds.bottom) {
window.__midGalleryEvents.push([op, Number(card.dataset.index), main.scrollTop]);
}
}
}
}
});
observe.observe(grid,{childList:true,subtree:true});
window.__midGalleryObserver = observe;
}""")
async def sample():
return await page.evaluate("""() => {
const main = document.querySelector('.ue-main-shell');
const grid = document.querySelector('.ue-gallery-grid--virtual');
const bounds = main.getBoundingClientRect();
const cards = [...grid.querySelectorAll('.ue-gallery-card')].filter(card => {
const r=card.getBoundingClientRect();
return r.top >= bounds.top - 10 && r.bottom <= bounds.bottom + 10;
});
const images = cards.map(card => ({
index: Number(card.dataset.index), img:card.querySelector('img')?.complete || false,
natural:card.querySelector('img')?.naturalWidth || 0,
loaded:card.querySelector('.ue-gallery-image-shell')?.classList.contains('is-loaded'),
opacity:card.querySelector('img') ? Number(getComputedStyle(card.querySelector('img')).opacity) : 0,
}));
const hovered=cards.find(card=>card.matches(':hover'));
return {scroll: main.scrollTop, max:main.scrollHeight-main.clientHeight,
height:grid.offsetHeight, columns:Math.max(...cards.map(c=>Number(c.dataset.lane)))+1,
hovered: hovered ? Number(hovered.dataset.index) : null,
hoverTransform: hovered ? getComputedStyle(hovered).transform : 'none',
positions:Object.fromEntries(cards.map(c=>[c.dataset.index,c.offsetTop])), images};
}""")
bounds = await main.bounding_box()
assert bounds
await page.mouse.move(bounds["x"] + bounds["width"] * .51, bounds["y"] + bounds["height"] * .53)
before = await sample()
seen_loaded: set[int] = {item["index"] for item in before["images"] if item["loaded"] and item["natural"] > 0}
shared_count = 0
min_scroll = before["max"] * .30
max_scroll = before["max"] * .70
assert min_scroll < before["scroll"] < max_scroll, before
for index in range(90):
# A seeded sawtooth that repeatedly crosses virtual render edges.
direction = (140, -115, 200, -210, 180, -145, -155, 220)[index % 8]
if not min_scroll < before["scroll"] + direction < max_scroll:
direction *= -1
await page.mouse.wheel(0, direction)
await page.wait_for_timeout(55)
after = await sample()
try:
shared_count += assert_stable(before, after, direction)
except AssertionError as error:
if not DEBUG:
raise
print(f"DIAGNOSTIC step={index}: {error}", flush=True)
previous = {item["index"]: item for item in before["images"]}
for image in after["images"]:
old = previous.get(image["index"])
if old and old["loaded"] and old["natural"] > 0:
assert image["natural"] > 0, ("previously visible image flashed blank", image, old)
if image["index"] in seen_loaded:
assert image["natural"] > 0 and image["loaded"], (
"previously loaded photo became blank on revisit", image, index)
if image["loaded"] and image["natural"] > 0:
seen_loaded.add(image["index"])
before = after
# Cover a larger part of the MIDDLE in both directions: cards leave
# the virtual overscan, unmount, and later re-enter from cache.
wide_steps = 0
if profile == "mixed":
for ratio in (.70, .30, .65, .40):
target = before["max"] * ratio
while abs(before["scroll"] - target) > 130:
direction = 175 if before["scroll"] < target else -175
await page.mouse.wheel(0, direction)
await page.wait_for_timeout(55)
after = await sample()
assert_stable(before, after, direction)
for image in after["images"]:
if image["index"] in seen_loaded:
assert image["natural"] > 0 and image["loaded"], (
"revisited photo showed a blank placeholder", image, wide_steps)
if image["loaded"] and image["natural"] > 0:
seen_loaded.add(image["index"])
before = after
wide_steps += 1
assert wide_steps < 160, "middle travel did not reach the intended points"
for _ in range(6):
await page.wait_for_timeout(100)
after = await sample()
assert_stable(before, after, 0)
before = after
events = await page.evaluate("window.__midGalleryEvents")
blink = [event for event in events if event[0] == "remove"]
if DEBUG:
print(f"DIAGNOSTIC visible-card removals={len(blink)}: {blink[:12]}, page errors={errors}")
else:
assert not blink, ("visible cards were unmounted while scrolling in middle", blink[:12])
assert not errors, errors
print(f"PASS middle profile={profile}, width={width}, columns={before['columns']}, "
f"90 reversals + {wide_steps} wide middle steps, "
f"{shared_count} stable overlapping cards, {len(events)} visible DOM changes")
finally:
await page.close()
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(args=["--no-sandbox"])
try:
for profile in ("mixed", "extreme", "right-heavy"):
await run_case(browser, profile)
finally:
await browser.close()
if __name__ == "__main__":
asyncio.run(main())