271 lines
13 KiB
Python
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())
|