Files

271 lines
13 KiB
Python

"""Real-browser regression for the virtual masonry grid at the last images.
Build first and run `python scripts/preview.py --host 0.0.0.0 --port 8189`.
Then run `python scripts/browser_masonry.py http://127.0.0.1:8189`.
The 48 varied-height images are served by an intercepted list API; the underlying
thumbnails come from the isolated preview, not a user's ComfyUI installation.
"""
from __future__ import annotations
import asyncio
import sys
from collections import defaultdict
from urllib.parse import parse_qs, urlsplit
from playwright.async_api import async_playwright, expect
BASE = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://127.0.0.1:8189"
COUNT = 48
async def synthetic_page(browser, width: int, *, profile: str = "mixed", mobile_density: str = "single"):
page = await browser.new_page(viewport={"width": width, "height": 800}, reduced_motion="reduce")
errors: list[str] = []
page.on("pageerror", lambda error: errors.append(str(error)))
assert mobile_density in {"single", "double"}
await page.add_init_script("""
localStorage.setItem('universal-extractor-locale','zh-CN');
localStorage.setItem('universal-extractor:onboarding-tour-v1-completed','true');
localStorage.setItem('universal-extractor:mobile-density', '__DENSITY__');
localStorage.setItem('universal-extractor:ui-preferences',JSON.stringify({
enableImagePrefetch:false,enableLiveGalleryRefresh:false
}));
""".replace("__DENSITY__", mobile_density))
response = await page.request.get(BASE + "/universal_gallery/api/images?page=1&limit=48&subfolder=default_output%3A%3A")
fixture = await response.json()
assert len(fixture["images"]) >= 12
original = fixture["images"]
images = []
for index in range(COUNT):
item = dict(original[index % len(original)])
item["relative_path"] = f"synthetic/virtual_{index:03}.png"
item["filename"] = f"virtual_{index:03}.png"
if profile == "right-heavy":
# Tall cards in the first three lanes make the rightmost lane
# accumulate multiple extra short cards before the other lanes.
dimensions = (600, 3000) if index < 3 else ((600, 960) if index % 7 == 0 else (1100, 340))
elif profile == "extreme":
dimensions = [(600, 2500), (1600, 200), (600, 840), (0, 0), (600, 440)][index % 5]
else:
dimensions = [(600, 980), (1100, 340), (600, 820) if profile == "known" else (0, 0),
(600, 780), (600, 420)][index % 5]
item["width"], item["height"] = dimensions
item["category"] = "Long category name with wrap" if index % 4 == 0 else ""
item["title"] = "A rather long title for a variable masonry row" if index % 3 == 0 else ""
# A unique URL prevents the first screen's already-loaded thumbnails
# from masking lazy decoding of cards approached near the bottom.
item["thumb_url"] += f"&masonry_fixture={index:03}"
images.append(item)
fixture.update(images=images, page=1, limit=COUNT, total=COUNT)
async def images_api(route):
if parse_qs(urlsplit(route.request.url).query).get("page", ["1"])[0] == "1":
await route.fulfill(json=fixture)
else:
await route.continue_()
async def delayed_thumb(route):
await asyncio.sleep(.32)
await route.continue_()
await page.route("**/universal_gallery/api/images?*", images_api)
await page.route("**/universal_gallery/api/thumb?*", delayed_thumb)
await page.goto(BASE + "/gallery/", wait_until="networkidle")
await expect(page.locator(".ue-gallery-card").first).to_be_visible()
return page, errors
async def read_layout(page):
return await page.evaluate("""() => {
const main=document.querySelector('.ue-main-shell');
const grid=document.querySelector('.ue-gallery-grid--virtual');
const cards=[...grid.querySelectorAll('.ue-gallery-card')].map(n=>({
index:Number(n.dataset.index),lane:Number(n.dataset.lane),
top:n.offsetTop,height:n.offsetHeight,
mediaHeight:n.querySelector('.ue-gallery-media').offsetHeight,
imageLoaded:n.querySelector('img')?.complete || false,
contentVisibility:getComputedStyle(n).contentVisibility
}));
return {
scroll:main.scrollTop,max:main.scrollHeight-main.clientHeight,
height:grid.getBoundingClientRect().height,
gridOffset:grid.getBoundingClientRect().top-main.getBoundingClientRect().top+main.scrollTop,
cards
};
}""")
def validate_cards(layout):
per_lane = defaultdict(list)
for card in layout["cards"]:
assert card["contentVisibility"] == "visible", ("offscreen measurement is not trustworthy", card)
per_lane[card["lane"]].append(card)
for lane, cards in per_lane.items():
cards.sort(key=lambda card: card["top"])
for previous, current in zip(cards, cards[1:]):
assert current["top"] >= previous["top"] + previous["height"] + 13, (
"overlapping masonry cards", lane, previous, current)
assert layout["height"] >= max(c["top"] + c["height"] for c in layout["cards"]) - 2
async def near_bottom_wheel(page):
main = page.locator(".ue-main-shell")
await main.evaluate("n => n.scrollTop = n.scrollHeight - n.clientHeight - 600")
await page.wait_for_timeout(160)
bounds = await main.bounding_box()
assert bounds
await page.mouse.move(bounds["x"] + bounds["width"] / 2, bounds["y"] + bounds["height"] / 2)
first = await read_layout(page)
assert first["max"] - first["scroll"] >= 80, first
validate_cards(first)
samples = [first]
directions = [190, 180, 180, -140, 120, -95, 140, 175, -145, 210, -120, 190]
for direction in directions:
await page.mouse.wheel(0, direction)
await page.wait_for_timeout(75)
sample = await read_layout(page)
samples.append(sample)
validate_cards(sample)
for _ in range(4):
await page.wait_for_timeout(100)
samples.append(await read_layout(page))
heights = [sample["height"] for sample in samples]
assert max(heights) - min(heights) <= 2, ("changing scroll range without a resize", heights)
for previous, current, direction in zip(samples, samples[1:], directions):
expected = max(0, min(current["max"], previous["scroll"] + direction))
assert abs(current["scroll"] - expected) <= 3, (
"wheel movement was overridden by a measurement/anchor correction", direction,
previous["scroll"], current["scroll"], expected)
assert COUNT - 1 in {c["index"] for c in samples[-1]["cards"]}, "last card disappeared"
return max(heights) - min(heights)
async def scan_all_cards(page):
main = page.locator(".ue-main-shell")
seen: dict[int, int] = {}
for step in range(16):
await main.evaluate("(n,step)=>{n.scrollTop=step*(n.scrollHeight-n.clientHeight)/15}", step)
await page.wait_for_timeout(85)
layout = await read_layout(page)
validate_cards(layout)
seen.update({card["index"]: card["lane"] for card in layout["cards"]})
assert set(seen) == set(range(COUNT)), ("missing cards during travel", sorted(set(range(COUNT)) - set(seen)))
bottom = await read_layout(page)
assert bottom["max"] - bottom["scroll"] <= 2
assert COUNT - 1 in {c["index"] for c in bottom["cards"]}
assert abs(bottom["height"] - max(c["top"] + c["height"] for c in bottom["cards"])) <= 2, (
"grid height disagrees with its bottommost actual card", bottom["height"], bottom["cards"][-5:])
return seen
async def wheel_full(page):
main = page.locator(".ue-main-shell")
await main.evaluate("n=>n.scrollTop=0")
await page.wait_for_timeout(140)
bounds = await main.bounding_box()
assert bounds
await page.mouse.move(bounds["x"] + bounds["width"] / 2, bounds["y"] + bounds["height"] / 2)
count = 0
for direction in (190, -190):
for _ in range(110):
previous = await read_layout(page)
if (direction > 0 and previous["max"] - previous["scroll"] < 3) or (direction < 0 and previous["scroll"] < 3):
break
await page.mouse.wheel(0, direction)
await page.wait_for_timeout(55)
current = await read_layout(page)
expected = max(0, min(current["max"], previous["scroll"] + direction))
assert abs(current["scroll"] - expected) <= 3, (
"full-scroll wheel correction", direction, previous["scroll"], current["scroll"], expected)
count += 1
else:
raise AssertionError("full scroll did not reach the boundary")
assert count >= 12, count
return count
async def resize_same_lanes(browser, start_width, end_width, mobile_density="single"):
page, errors = await synthetic_page(browser, start_width, mobile_density=mobile_density)
try:
main = page.locator(".ue-main-shell")
await main.evaluate("n=>n.scrollTop=n.scrollHeight")
await page.wait_for_timeout(180)
before = await read_layout(page)
await page.set_viewport_size({"width": end_width, "height": 800})
await page.wait_for_timeout(430)
resized = await read_layout(page)
await main.evaluate("n=>n.scrollTop=0")
await page.wait_for_timeout(240)
revisited = await read_layout(page)
# Reset old-width measurements, then measure real visible cards. A few
# multiline category chips may account for a small residual change.
assert abs(revisited["height"] - resized["height"]) <= 55, (
"stale offscreen card sizes after width change", start_width, end_width,
resized["height"], revisited["height"])
await near_bottom_wheel(page)
assert before["height"] != resized["height"], "responsive layout was not remeasured"
assert not errors, errors
print(f"PASS resize {start_width}->{end_width}: width changed, offscreen heights invalidated, bottom wheel stable")
finally:
await page.close()
async def resize_lane_count(browser, start_width, end_width, expected_lanes):
page, errors = await synthetic_page(browser, start_width, mobile_density="double")
try:
main = page.locator(".ue-main-shell")
await main.evaluate("n=>n.scrollTop=n.scrollHeight")
await page.wait_for_timeout(180)
await page.set_viewport_size({"width": end_width, "height": 800})
await page.wait_for_timeout(360)
await near_bottom_wheel(page)
lanes = await scan_all_cards(page)
assert len(set(lanes.values())) == expected_lanes, (start_width, end_width, set(lanes.values()))
assert not errors, errors
print(f"PASS resize {start_width}->{end_width}: {expected_lanes} lanes, no lost or overlapping cards, bottom wheel stable")
finally:
await page.close()
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(args=["--no-sandbox"])
try:
for width, profile, density in [
(390, "mixed", "single"), (390, "mixed", "double"),
(960, "mixed", "single"), (1280, "mixed", "single"),
(1440, "mixed", "single"), (1440, "right-heavy", "single"),
(960, "extreme", "single"),
]:
page, errors = await synthetic_page(browser, width, profile=profile, mobile_density=density)
try:
height_change = await near_bottom_wheel(page)
# Traverse with actual wheel events before any scan pre-measures
# the middle of the list; this exercises asynchronous RO work.
if (width, profile, density) in [(390, "mixed", "double"), (1440, "mixed", "single")]:
steps = await wheel_full(page)
else:
steps = 0
lanes = await scan_all_cards(page)
if profile == "right-heavy":
counts = [list(lanes.values()).count(lane) for lane in range(len(set(lanes.values())))]
assert max(counts[3:]) >= max(counts[:3]) + 2, ("not a right-heavy fixture", counts)
assert not errors, errors
print(f"PASS width={width} profile={profile} mobile_density={density}: "
f"48 cards, {len(set(lanes.values()))} columns, bottom range drift={height_change:.1f}px, "
f"wheel traversal={steps} steps")
finally:
await page.close()
for start_width, end_width, density in [
(1440, 1600, "single"), (960, 900, "single"), (390, 550, "single")
]:
await resize_same_lanes(browser, start_width, end_width, density)
await resize_lane_count(browser, 390, 960, 4)
await resize_lane_count(browser, 960, 390, 2)
finally:
await browser.close()
if __name__ == "__main__":
asyncio.run(main())