Download tests/test_web_ui.py from SmaugC137/toaster: direct link, hf CLI and curl.
- Browser
- Download file 28.3 kB
-
https://huggingface.co/spaces/SmaugC137/toaster/resolve/main/tests/test_web_ui.py
- Command line
-
hf download hf://spaces/SmaugC137/toaster/tests/test_web_ui.py
-
curl -L -o test_web_ui.py https://huggingface.co/spaces/SmaugC137/toaster/resolve/main/tests/test_web_ui.py
28.3 kB
| """End-to-end checks of the web viewer's octree LOD/picking and delta recolour. | |
| Drives the real stack β ``toaster-web`` served from THIS checkout, headless | |
| Chromium via Playwright β against a synthetic cloud big enough to build the | |
| octree and trip the motion LOD (> 1M points). Skips cleanly when Playwright | |
| or its browser is unavailable. | |
| Invariants covered: | |
| - the worker-built octree is a permutation of the cloud with well-formed, | |
| disjoint node slices; | |
| - camera motion renders the octree cut (a strict subset), idle refines back | |
| to the full cloud, and the rAF loop parks afterwards (the QtWebEngine | |
| leak guard); | |
| - octree pick/pickBox return exactly what the brute-force scans return; | |
| - after delta-applied label edits (assign, undo), the client's colour/alpha | |
| buffers are indistinguishable from a from-scratch full-state re-render. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import socket | |
| import subprocess | |
| import sys | |
| import time | |
| from pathlib import Path | |
| import numpy as np | |
| import pytest | |
| pytest.importorskip("playwright.sync_api") | |
| from playwright.sync_api import sync_playwright # noqa: E402 | |
| REPO = Path(__file__).resolve().parent.parent | |
| N_POINTS = 1_200_000 # > LOD_BUDGET (1M) so the motion cut engages | |
| def _free_port() -> int: | |
| with socket.socket() as s: | |
| s.bind(("127.0.0.1", 0)) | |
| return s.getsockname()[1] | |
| def server(tmp_path_factory): | |
| """toaster-web on a random port, serving a 1.2M-point synthetic terrain.""" | |
| cloud = tmp_path_factory.mktemp("cloud") / "big.bin" | |
| rng = np.random.default_rng(7) | |
| xy = rng.uniform(-50, 50, (N_POINTS, 2)).astype(np.float32) | |
| z = (np.sin(xy[:, 0] * 0.2) * np.cos(xy[:, 1] * 0.15) + rng.normal(0, 0.1, N_POINTS)).astype( | |
| np.float32 | |
| ) | |
| i = rng.uniform(0, 1, N_POINTS).astype(np.float32) | |
| np.column_stack([xy, z, i]).astype(np.float32).tofile(cloud) | |
| port = _free_port() | |
| env = os.environ | {"PYTHONPATH": str(REPO)} # serve THIS checkout, not the installed copy | |
| proc = subprocess.Popen( | |
| [sys.executable, "-m", "toaster.api.server", str(cloud), "--port", str(port)], | |
| env=env, | |
| cwd=REPO, | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.PIPE, | |
| ) | |
| import urllib.request | |
| deadline = time.time() + 30 | |
| while True: | |
| try: | |
| urllib.request.urlopen(f"http://127.0.0.1:{port}/api/meta", timeout=1) | |
| break | |
| except Exception: | |
| if proc.poll() is not None: | |
| pytest.skip(f"server died: {proc.stderr.read().decode()[-500:]}") | |
| if time.time() > deadline: | |
| proc.kill() | |
| pytest.skip("server did not come up in 30s") | |
| time.sleep(0.3) | |
| yield f"http://127.0.0.1:{port}" | |
| proc.terminate() | |
| proc.wait(timeout=10) | |
| def browser(): | |
| with sync_playwright() as p: | |
| try: | |
| browser = p.chromium.launch() | |
| except Exception as exc: # no browser installed | |
| pytest.skip(f"chromium unavailable: {exc}") | |
| yield browser | |
| browser.close() | |
| def page(server, browser): | |
| """One loaded page (cloud + octree ready) shared by the module's tests.""" | |
| page = browser.new_page(viewport={"width": 1280, "height": 800}) | |
| errors: list[str] = [] | |
| page.on("pageerror", lambda e: errors.append(f"pageerror: {e}")) | |
| # Headless SwiftShader emits a spurious "VALIDATE_STATUS false" with an | |
| # EMPTY info log under multi-context pressure; a real shader break always | |
| # carries a compile log, so only the empty-log noise is filtered. | |
| page.on( | |
| "console", | |
| lambda m: errors.append(f"console: {m.text}") | |
| if m.type == "error" and not ("VALIDATE_STATUS false" in m.text and "ERROR:" not in m.text) | |
| else None, | |
| ) | |
| page.goto(server) | |
| page.wait_for_function( | |
| f"window.__toaster && window.__toaster.viewer.geom && " | |
| f"window.__toaster.viewer.geom.getAttribute('position').count === {N_POINTS}", | |
| timeout=90000, | |
| ) | |
| page.wait_for_function("window.__toaster.viewer._octree !== null", timeout=90000) | |
| yield page | |
| assert not errors, f"console/page errors: {errors}" | |
| page.close() | |
| def test_octree_is_a_permutation_with_wellformed_slices(page): | |
| ok = page.evaluate( | |
| f"""(() => {{ | |
| const o = window.__toaster.viewer._octree.data; | |
| if (o.order.length !== {N_POINTS}) return "bad order length"; | |
| const seen = new Uint8Array({N_POINTS}); | |
| for (let k = 0; k < o.order.length; k++) {{ | |
| if (seen[o.order[k]]) return "duplicate index"; | |
| seen[o.order[k]] = 1; | |
| }} | |
| for (let i = 0; i < seen.length; i++) if (!seen[i]) return "missing index"; | |
| for (let ni = 0; ni < o.start.length; ni++) {{ | |
| if (o.start[ni] + o.count[ni] > o.order.length) return "slice out of bounds"; | |
| for (let c = 0; c < 8; c++) {{ | |
| const ch = o.children[ni * 8 + c]; | |
| if (ch >= 0 && o.start[ch] < o.start[ni] + o.count[ni]) | |
| return "child overlaps parent sample"; | |
| }} | |
| }} | |
| return "ok"; | |
| }})()""" | |
| ) | |
| assert ok == "ok" | |
| def test_motion_draws_octree_cut_then_idle_refines_full(page): | |
| # A held fly key keeps the view "in motion" for as long as we need to | |
| # observe it (a drag's 150 ms settle window is shorter than one headless | |
| # software-GL frame, so the refine would race the assertion). | |
| page.click("canvas", position={"x": 10, "y": 10}) | |
| page.keyboard.down("w") | |
| time.sleep(0.4) | |
| during = page.evaluate( | |
| """(() => { | |
| const v = window.__toaster.viewer; | |
| return { cut: v.geom.index === v._drawAttr, n: v.geom.drawRange.count }; | |
| })()""" | |
| ) | |
| page.keyboard.up("w") | |
| assert during["cut"], "motion frames must render the octree cut" | |
| assert 0 < during["n"] < N_POINTS, "the cut must be a strict subset" | |
| time.sleep(1.5) # settle + slow software-GL frames | |
| after = page.evaluate( | |
| "(() => { const v = window.__toaster.viewer;" | |
| " return v.geom.index === null && !v._lodOn; })()" | |
| ) | |
| assert after, "idle must refine back to the full cloud" | |
| # The rAF loop must park at idle (the QtWebEngine leak guard). | |
| for _ in range(4): | |
| assert page.evaluate("window.__toaster.viewer._rafPending") is not True | |
| time.sleep(0.4) | |
| def test_orbit_crosshair_shows_during_motion_and_fades(page): | |
| # The rerun-style pivot crosshair: hidden at rest, visible while orbiting, | |
| # lingers briefly after release, then fades back out. | |
| assert page.evaluate("window.__toaster.viewer.orbitIndicator.lines.visible") is False | |
| page.mouse.move(640, 400) | |
| page.mouse.down() | |
| for i in range(6): | |
| page.mouse.move(640 + i * 10, 400 + i * 5, steps=2) | |
| time.sleep(0.05) | |
| state = page.evaluate( | |
| """(() => { | |
| const v = window.__toaster.viewer; | |
| const pos = v.orbitIndicator.lines.geometry.attributes.position.array; | |
| const t = v.controls.target; | |
| return { | |
| visible: v.orbitIndicator.lines.visible, | |
| centred: Math.abs(pos[0] - t.x) < 1e-3 && Math.abs(pos[1] - t.y) < 1e-3, | |
| }; | |
| })()""" | |
| ) | |
| page.mouse.up() | |
| assert state["visible"], "crosshair must show while orbiting" | |
| assert state["centred"], "crosshair must sit exactly on the orbit pivot" | |
| time.sleep(1.0) # > linger (350ms) + fade (100ms), slow-frame margin | |
| assert page.evaluate("window.__toaster.viewer.orbitIndicator.lines.visible") is False | |
| def test_octree_picking_matches_brute_force(page): | |
| out = page.evaluate( | |
| """(() => { | |
| const v = window.__toaster.viewer; | |
| const pick1 = v.pick(640, 400); | |
| const box1 = v.pickBox(610, 370, 670, 430); | |
| const oct = v._octree; | |
| v._octree = null; // brute-force path | |
| const pick2 = v.pick(640, 400); | |
| const box2 = v.pickBox(610, 370, 670, 430); | |
| v._octree = oct; | |
| box1.sort((a, b) => a - b); | |
| box2.sort((a, b) => a - b); | |
| return { | |
| pickSame: pick1 === pick2, | |
| picked: pick1, | |
| boxSame: box1.length === box2.length && box1.every((x, i) => x === box2[i]), | |
| boxN: box1.length, | |
| }; | |
| })()""" | |
| ) | |
| assert out["pickSame"], "octree pick must match the exact scan" | |
| assert out["picked"] >= 0 | |
| assert out["boxSame"], "octree pickBox must match the exact scan" | |
| assert out["boxN"] > 0 | |
| def test_delta_recolor_matches_full_rerender(page): | |
| # Box-select a screen region, label it, and compare the delta-patched | |
| # colour/alpha buffers against a from-scratch full-state re-render β | |
| # byte-identical or the delta path has drifted from the reference rules. | |
| # Drive the real UI: box mode, rubber-band drag, double-click to label. | |
| page.click("button[data-mode-pick=box]") # idempotent β asserts the mode, not a toggle | |
| page.mouse.move(560, 320) | |
| page.mouse.down() | |
| page.mouse.move(720, 480, steps=5) | |
| page.mouse.up() | |
| page.wait_for_function( | |
| "window.__toaster.debug.getState().selection.length > 0", timeout=15000 | |
| ) | |
| page.mouse.dblclick(640, 440) # viewport coords, inside the drawn box β delta path | |
| page.wait_for_function( | |
| "window.__toaster.debug.getState().selection.length === 0", timeout=15000 | |
| ) | |
| def buffers_equal_after_full_refresh() -> dict: | |
| return page.evaluate( | |
| """(async () => { | |
| const { viewer, debug } = window.__toaster; | |
| const live = viewer.colorArrays(); | |
| const colors = live.colors.slice(); | |
| const alpha = live.alpha.slice(); | |
| await debug.refresh(); // from-scratch reference render | |
| const ref = viewer.colorArrays(); | |
| let colorDiff = 0, alphaDiff = 0; | |
| for (let i = 0; i < colors.length; i++) if (colors[i] !== ref.colors[i]) colorDiff++; | |
| for (let i = 0; i < alpha.length; i++) if (alpha[i] !== ref.alpha[i]) alphaDiff++; | |
| const labeled = debug.getState().labels.reduce((a, v) => a + (v !== 0 ? 1 : 0), 0); | |
| return { colorDiff, alphaDiff, labeled }; | |
| })()""" | |
| ) | |
| out = buffers_equal_after_full_refresh() | |
| assert out["labeled"] > 0, "the double-click must have labelled the box selection" | |
| assert out["colorDiff"] == 0, f"{out['colorDiff']} colour slots diverged from the reference" | |
| assert out["alphaDiff"] == 0, f"{out['alphaDiff']} alpha slots diverged from the reference" | |
| # Same equivalence with the visibility mask active (hide-labelled points): | |
| # the delta must now patch alphas too, and keep the hidden-count pill exact. | |
| page.check("#hide-labeled") | |
| page.mouse.move(400, 250) | |
| page.mouse.down() | |
| page.mouse.move(560, 400, steps=5) | |
| page.mouse.up() | |
| page.wait_for_function( | |
| "window.__toaster.debug.getState().selection.length > 0", timeout=15000 | |
| ) | |
| page.mouse.dblclick(480, 330) # viewport coords, inside the second box | |
| page.wait_for_function( | |
| "window.__toaster.debug.getState().selection.length === 0", timeout=15000 | |
| ) | |
| out = buffers_equal_after_full_refresh() | |
| assert out["colorDiff"] == 0, "colours diverged with the visibility mask on" | |
| assert out["alphaDiff"] == 0, "alphas diverged with the visibility mask on" | |
| # Undo (a delta of the same edit, reversed) must also match the reference. | |
| page.keyboard.press("Control+z") | |
| time.sleep(0.5) | |
| out = buffers_equal_after_full_refresh() | |
| assert out["colorDiff"] == 0, "colours diverged after an undo delta" | |
| assert out["alphaDiff"] == 0, "alphas diverged after an undo delta" | |
| page.uncheck("#hide-labeled") | |
| def test_delta_upload_matches_full_upload_on_screen(page): | |
| # The CPU-array equality above can't see a bad commitColors update range β | |
| # a wrong range leaves stale bytes GPU-side only. Render both ways and | |
| # compare pixels: delta-patched frame vs the same state fully re-uploaded. | |
| page.click("button[data-mode-pick=box]") | |
| page.mouse.move(700, 500) | |
| page.mouse.down() | |
| page.mouse.move(860, 620, steps=5) | |
| page.mouse.up() | |
| page.wait_for_function( | |
| "window.__toaster.debug.getState().selection.length > 0", timeout=15000 | |
| ) | |
| page.mouse.dblclick(780, 560) # delta path | |
| page.wait_for_function( | |
| "window.__toaster.debug.getState().selection.length === 0", timeout=15000 | |
| ) | |
| time.sleep(1.5) # settle: full-res idle frame with the delta-patched buffers | |
| # Clip to the cloud area: the toolbar's neon "buzz" animation (triggered by | |
| # the assign) is still pulsing in frame A, and side panels are irrelevant. | |
| clip = {"x": 300, "y": 150, "width": 700, "height": 550} | |
| a = np.frombuffer(page.screenshot(clip=clip), dtype=np.uint8) | |
| page.evaluate("window.__toaster.debug.refresh()") | |
| time.sleep(1.5) # full recompute + full GPU upload, then idle again | |
| b = np.frombuffer(page.screenshot(clip=clip), dtype=np.uint8) | |
| if a.shape == b.shape and (a == b).all(): | |
| return # byte-identical PNGs β perfect | |
| # Compare decoded pixels with a tiny tolerance for encoder nondeterminism. | |
| import io | |
| from PIL import Image | |
| pa = np.asarray(Image.open(io.BytesIO(a.tobytes())).convert("RGB"), dtype=np.int16) | |
| pb = np.asarray(Image.open(io.BytesIO(b.tobytes())).convert("RGB"), dtype=np.int16) | |
| diff_ratio = float((np.abs(pa - pb).max(axis=2) > 2).mean()) | |
| assert diff_ratio < 0.001, f"{diff_ratio:.4%} of pixels differ between delta and full upload" | |
| def test_isolate_mode_falls_back_to_full_recompute(page): | |
| # Isolate couples every point's alpha to the selection β assigning under | |
| # isolate must go through the full recompute and still match the reference. | |
| page.click("button[data-mode-pick=box]") | |
| page.mouse.move(300, 500) | |
| page.mouse.down() | |
| page.mouse.move(440, 620, steps=5) | |
| page.mouse.up() | |
| page.wait_for_function( | |
| "window.__toaster.debug.getState().selection.length > 0", timeout=15000 | |
| ) | |
| page.keyboard.press("i") # isolate the selection | |
| page.mouse.dblclick(370, 560) # label it (assign clears the selection β isolate disarms) | |
| page.wait_for_function( | |
| "window.__toaster.debug.getState().selection.length === 0", timeout=15000 | |
| ) | |
| out = page.evaluate( | |
| """(async () => { | |
| const { viewer, debug } = window.__toaster; | |
| const live = viewer.colorArrays(); | |
| const colors = live.colors.slice(); | |
| const alpha = live.alpha.slice(); | |
| await debug.refresh(); | |
| const ref = viewer.colorArrays(); | |
| let colorDiff = 0, alphaDiff = 0; | |
| for (let i = 0; i < colors.length; i++) if (colors[i] !== ref.colors[i]) colorDiff++; | |
| for (let i = 0; i < alpha.length; i++) if (alpha[i] !== ref.alpha[i]) alphaDiff++; | |
| return { colorDiff, alphaDiff }; | |
| })()""" | |
| ) | |
| assert out["colorDiff"] == 0, "colours diverged after assigning under isolate" | |
| assert out["alphaDiff"] == 0, "alphas diverged after assigning under isolate" | |
| def test_page_idle_pauses_decorative_css_animations(page): | |
| # Regression: two `infinite` CSS animations (the bottom-edge "ember" glow, | |
| # the active theme's logo) ran unconditionally β independent of the 3D | |
| # canvas's own render-on-demand loop, they alone kept QtWebEngine's Vulkan | |
| # fallback compositing forever and OOM'd a session left open. app.js's | |
| # watchPageIdle() adds body.idle after PAGE_IDLE_MS (8s) of no input | |
| # anywhere on the page; style.css pauses those animations off it. | |
| page.mouse.move(50, 50) # establish an "active" baseline | |
| idle_before = page.evaluate("document.body.classList.contains('idle')") | |
| assert not idle_before, "must not be idle right after activity" | |
| time.sleep(8.5) # > PAGE_IDLE_MS with zero synthesized input in between | |
| idle_after = page.evaluate("document.body.classList.contains('idle')") | |
| assert idle_after, "must go idle after a stretch with no input anywhere" | |
| paused = page.evaluate( | |
| """(() => { | |
| const after = getComputedStyle(document.body, '::after').animationPlayState; | |
| // Target the ACTIVE theme's mark specifically β the other two themes' | |
| // logo-art blocks sit at display:none and never animate regardless. | |
| const marks = { toaster: '.toast', cafe: '.steam', arcade: '.invader' }; | |
| const cls = marks[document.body.dataset.theme]; | |
| const logo = document.querySelector(`.logo-art ${cls}`); | |
| const logoState = logo ? getComputedStyle(logo).animationPlayState : 'n/a'; | |
| return { emberState: after, logoState }; | |
| })()""" | |
| ) | |
| assert paused["emberState"] == "paused", f"ember glow must pause when idle: {paused}" | |
| assert paused["logoState"] == "paused", f"active theme's logo must pause when idle: {paused}" | |
| # Any input wakes it back up immediately β no lingering "still idle" frame. | |
| page.mouse.move(60, 60) | |
| idle_woken = page.evaluate("document.body.classList.contains('idle')") | |
| assert not idle_woken, "must wake up on the very next interaction" | |
| def test_camera_control_style_defaults_to_trackball_and_persists(page): | |
| # Regression: toaster briefly defaulted to Orbit-style controls (matching | |
| # most 3D viewers), but OrbitControls walls off at the poles β "blocked | |
| # at a plane" β which is exactly why this codebase used TrackballControls | |
| # exclusively in the first place (free tumble in any direction, needed to | |
| # turn a scan that isn't gravity-aligned upright). Reverted: trackball is | |
| # the default again; Orbit stays available as an opt-in toggle. | |
| default_style = page.evaluate("window.__toaster.viewer._controlStyle") | |
| assert default_style == "trackball", "toaster must default to free-tumble trackball" | |
| assert page.evaluate("document.getElementById('controls-style').value") == "trackball" | |
| canvas = page.query_selector("canvas") | |
| box = canvas.bounding_box() | |
| cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2 | |
| # Trackball: a drag must still move the camera (basic sanity β its whole | |
| # point is that it does NOT constrain the horizon the way Orbit does, so | |
| # unlike the Orbit case there is no "up never changes" assertion here). | |
| # Retried: a synthetic drag landing right after another test's real 8.5s | |
| # sleep (the idle-CSS test just before this one) occasionally gets | |
| # coalesced away by a loaded headless Chromium β a pointer-event timing | |
| # artifact, not a product behaviour to pin down with more raw sleeps. | |
| pos_before_drag = page.evaluate("window.__toaster.viewer.camera.position.toArray()") | |
| pos_after_drag = pos_before_drag | |
| for attempt in range(3): | |
| page.mouse.move(cx, cy) | |
| page.mouse.down() | |
| page.mouse.move(cx + 150 + attempt, cy + 20, steps=8) | |
| page.mouse.up() | |
| time.sleep(0.3) | |
| pos_after_drag = page.evaluate("window.__toaster.viewer.camera.position.toArray()") | |
| if pos_after_drag != pos_before_drag: | |
| break | |
| assert pos_after_drag != pos_before_drag, "trackball drag must move the camera" | |
| # Switching to Orbit (for a gravity-aligned scan where a level horizon is | |
| # wanted) persists and actually swaps the underlying controls; a drag | |
| # then never rolls the horizon β the defining Orbit behaviour. | |
| page.evaluate("document.getElementById('win-display').style.display = 'flex'") | |
| page.select_option("#controls-style", "orbit") | |
| assert page.evaluate("window.__toaster.viewer._controlStyle") == "orbit" | |
| assert page.evaluate("localStorage.getItem('toaster-controls')") == "orbit" | |
| up_before = page.evaluate("window.__toaster.viewer.camera.up.toArray()") | |
| page.mouse.move(cx, cy) | |
| page.mouse.down() | |
| page.mouse.move(cx - 100, cy + 60, steps=8) | |
| page.mouse.up() | |
| time.sleep(0.3) | |
| up_after = page.evaluate("window.__toaster.viewer.camera.up.toArray()") | |
| assert up_after == up_before, "an orbit drag must never roll the horizon" | |
| # Reset for any later test on this shared page. | |
| page.select_option("#controls-style", "trackball") | |
| page.evaluate("document.getElementById('win-display').style.display = 'none'") | |
| assert page.evaluate("window.__toaster.viewer._controlStyle") == "trackball" | |
| def test_windows_minimize_resize_and_persist(page): | |
| # Every floating window (Segmenter, Segments, etc.) gets a minimize | |
| # toggle and a resize grip, added uniformly by setupWindows() β no | |
| # per-window HTML wiring. Segmenter is the case that motivated this: | |
| # its params list (or the Segments window's segment list) can run long. | |
| counts = page.evaluate( | |
| """(() => ({ | |
| windows: document.querySelectorAll('.window').length, | |
| minBtns: document.querySelectorAll('.window .titlebar .min').length, | |
| grips: document.querySelectorAll('.window .resize-handle').length, | |
| }))()""" | |
| ) | |
| assert counts["windows"] > 0 | |
| assert counts["minBtns"] == counts["windows"] | |
| assert counts["grips"] == counts["windows"] | |
| seg = page.query_selector("#win-segmenter") | |
| min_btn = seg.query_selector(".titlebar .min") | |
| # Minimize collapses the body and persists; restoring un-collapses it. | |
| assert seg.query_selector(".body").is_visible() | |
| min_btn.click() | |
| time.sleep(0.1) | |
| assert page.evaluate("document.getElementById('win-segmenter').classList.contains('minimized')") | |
| assert not seg.query_selector(".body").is_visible() | |
| assert page.evaluate( | |
| "JSON.parse(localStorage.getItem('toaster-windows'))['win-segmenter'].minimized" | |
| ) is True | |
| min_btn.click() | |
| time.sleep(0.1) | |
| assert not page.evaluate( | |
| "document.getElementById('win-segmenter').classList.contains('minimized')" | |
| ) | |
| # Dragging the corner grip resizes both dimensions, and the body actually | |
| # fills the extra height (base .body is flex:1 now, not just the old | |
| # .grow modifier) instead of leaving dead space below static content. | |
| box_before = seg.bounding_box() | |
| grip = seg.query_selector(".resize-handle") | |
| gbox = grip.bounding_box() | |
| gx, gy = gbox["x"] + gbox["width"] / 2, gbox["y"] + gbox["height"] / 2 | |
| page.mouse.move(gx, gy) | |
| page.mouse.down() | |
| page.mouse.move(gx + 120, gy + 200, steps=10) | |
| page.mouse.up() | |
| time.sleep(0.2) | |
| box_after = seg.bounding_box() | |
| assert box_after["width"] > box_before["width"] + 50 | |
| assert box_after["height"] > box_before["height"] + 50 | |
| body_height = page.evaluate( | |
| "document.querySelector('#win-segmenter .body').getBoundingClientRect().height" | |
| ) | |
| assert body_height > 100, "a taller window must give its body the extra space" | |
| # Shrinking past the floor clamps to WIN_MIN_WIDTH/HEIGHT, not zero. | |
| gbox2 = grip.bounding_box() | |
| gx2, gy2 = gbox2["x"] + gbox2["width"] / 2, gbox2["y"] + gbox2["height"] / 2 | |
| page.mouse.move(gx2, gy2) | |
| page.mouse.down() | |
| page.mouse.move(gx2 - 2000, gy2 - 2000, steps=10) | |
| page.mouse.up() | |
| time.sleep(0.2) | |
| tiny = seg.bounding_box() | |
| assert tiny["width"] >= 150 and tiny["height"] >= 80 | |
| # The titlebar drag (raise/move) still works after adding the min/resize | |
| # controls β they must not have hijacked its pointer events. | |
| bar = seg.query_selector(".titlebar") | |
| bbox = bar.bounding_box() | |
| bx, by = bbox["x"] + 40, bbox["y"] + bbox["height"] / 2 | |
| x_before = seg.bounding_box()["x"] | |
| page.mouse.move(bx, by) | |
| page.mouse.down() | |
| page.mouse.move(bx + 60, by + 40, steps=8) | |
| page.mouse.up() | |
| time.sleep(0.2) | |
| assert seg.bounding_box()["x"] != x_before | |
| # Geometry survives a reload (localStorage-backed, restored in setupWindows()). | |
| page.reload() | |
| page.wait_for_function("window.__toaster.viewer._octree !== null", timeout=90000) | |
| restored = page.evaluate( | |
| "(() => { const w = document.getElementById('win-segmenter');" | |
| " return { width: w.style.width, height: w.style.height }; })()" | |
| ) | |
| assert restored["width"] and restored["height"] | |
| def test_engine_options_then_dispose(server, browser): | |
| # dispose() is for hosts that embed the engine and tear viewers down β | |
| # exercise it on its own page so the shared page fixture stays alive. | |
| page = browser.new_page(viewport={"width": 800, "height": 600}) | |
| errors: list[str] = [] | |
| page.on("pageerror", lambda e: errors.append(str(e))) | |
| page.goto(server) | |
| page.wait_for_function( | |
| "window.__toaster && window.__toaster.viewer._octree !== null", timeout=90000 | |
| ) | |
| # Exercise the embedding-oriented options on this throwaway page first: | |
| # orbit control style, world-size attenuation, streaming setCloud. | |
| opts = page.evaluate( | |
| """(() => { | |
| const v = window.__toaster.viewer; | |
| v.setControlStyle("orbit"); | |
| const orbitOk = v._controlStyle === "orbit" && v.controls.enableDamping === false; | |
| v.setSizeAttenuation(true); | |
| const attenOk = | |
| v.material.uniforms.uAttenuate.value === 1 && | |
| v.material.uniforms.uProjScalePx.value > 0; | |
| v.setSizeAttenuation(false); | |
| v.setControlStyle("trackball"); | |
| const backOk = v._controlStyle === "trackball" && !!v.controls.staticMoving; | |
| // Streaming: a smaller frame, no octree churn, camera untouched. | |
| const camBefore = v.camera.position.toArray(); | |
| const xyz = new Float32Array(3000); | |
| for (let i = 0; i < 1000; i++) { | |
| xyz[i * 3] = Math.random() * 10; | |
| xyz[i * 3 + 1] = Math.random() * 10; | |
| xyz[i * 3 + 2] = Math.random(); | |
| } | |
| v.setCloud(xyz, { octree: false, frame: false }); | |
| const camAfter = v.camera.position.toArray(); | |
| const streamOk = | |
| v._octree === null && | |
| v.geom.getAttribute("position").count === 1000 && | |
| camBefore.every((x, i) => x === camAfter[i]); | |
| return { orbitOk, attenOk, backOk, streamOk }; | |
| })()""" | |
| ) | |
| assert opts["orbitOk"], "setControlStyle('orbit') must install OrbitControls without damping" | |
| assert opts["attenOk"], "setSizeAttenuation must arm the world-size shader path" | |
| assert opts["backOk"], "switching back to trackball must restore the free-tumble controls" | |
| assert opts["streamOk"], "streaming setCloud must skip the octree and leave the camera alone" | |
| # Camera-feel guards (rerun-style): fly speed follows the camera-to-pivot | |
| # distance, and the orbit radius can never collapse through the pivot. | |
| feel = page.evaluate( | |
| """(() => { | |
| const v = window.__toaster.viewer; | |
| const minDistOk = v.controls.minDistance > 0; | |
| // Fly one tick from far, then from close: the step must shrink. | |
| const target = v.controls.target.clone(); | |
| const step = (dist) => { | |
| v.camera.position.set(target.x + dist, target.y, target.z); | |
| v.controls.target.copy(target); | |
| const before = v.camera.position.clone(); | |
| v._flyKeys.add("KeyW"); | |
| v._fly(0.05); | |
| v._flyKeys.clear(); | |
| return v.camera.position.distanceTo(before); | |
| }; | |
| const farStep = step(100); | |
| const nearStep = step(0.5); | |
| return { minDistOk, farStep, nearStep }; | |
| })()""" | |
| ) | |
| assert feel["minDistOk"], "controls.minDistance must clamp the orbit radius" | |
| assert feel["nearStep"] < feel["farStep"] / 20, ( | |
| f"fly speed must scale with pivot distance (far {feel['farStep']}, near {feel['nearStep']})" | |
| ) | |
| gone = page.evaluate( | |
| """(() => { | |
| window.__toaster.viewer.dispose(); | |
| return document.querySelector('#viewport canvas') === null; | |
| })()""" | |
| ) | |
| assert gone, "dispose must remove the canvas" | |
| # Events that used to hit the viewer's window listeners must be harmless now. | |
| page.keyboard.down("w") | |
| page.keyboard.up("w") | |
| page.mouse.move(400, 300) | |
| time.sleep(0.5) | |
| assert not errors, f"errors after dispose: {errors}" | |
| page.close() | |