diff --git a/viewer/README.md b/viewer/README.md index 948fc59..1b300bd 100644 --- a/viewer/README.md +++ b/viewer/README.md @@ -86,10 +86,6 @@ pnpm --dir viewer build The unit suite includes a Python-authored scene fixture whose digest contains floating-point values that ordinary Python and JavaScript JSON serializers spell differently. Passing that test is the cross-language integrity gate. -Capsule geometry tests verify the scene's cylindrical centerline length, constant radius, spherical ends, zero-length sphere case, arbitrary orientation, outward topology, exact equator positions/normals, and ray picking. The selected-cell overlay uses the same geometry with radius increased by 8%; the cap centers retain the original centerline length. Three instanced draw calls represent the colony, independent of cell count. Replacing frames disposes both geometry and instance buffers. - -For visual and GPU-resource checks, see [the capsule browser regression](browser/README.md). Mesh tessellation and pixel aliasing can still affect silhouettes at distant zoom levels; the shared tangent joins specifically remove overlapping end disks and mismatched sphere/cylinder boundaries. The viewer does not smooth or modify simulated cell motion. - ## Dataset presentation lifecycle Opening a scene file, live session, or recording begins a new dataset. Call `DatasetPresentationState.beginDataset()` and `ColonyViewer.beginDataset()` once, then present its first frame with `setFrame(frame, true)` to fit the camera. Ordinary updates, a reset of the same live model, and recording seeks use `setFrame(frame)` without beginning a dataset. Neither simulation time returning to zero nor a changed signal-grid shape identifies a new dataset. @@ -100,6 +96,38 @@ The ground reference grid is separate from the scientific signal lattice. Its sq `browser/reference-grid.mjs` verifies the reference grid and presentation lifecycle in Chromium against a running Vite server. It uses Playwright (`@playwright/test`) and its installed Chromium; a shared installation can be supplied through `MICROSIMULATOR_PLAYWRIGHT_MODULE` as an absolute module filename. Set `VIEWER_URL` if the server is not on `http://127.0.0.1:4320`, and `EVIDENCE_DIR` to choose the screenshot directory. The test observes renderer transforms through test-only request instrumentation and introduces no production debug interface. +## Concentration color ranges + +Species coloring and signal slices each offer Automatic and Fixed color ranges. Automatic uses the current frame's species extrema or the selected signal slice's extrema. Constant automatic data uses the midpoint color and a uniform legend; empty automatic data shows “no values” without numerical bounds. Fixed uses the entered minimum and maximum across frames and slices. Values outside that interval use endpoint colors; the underlying concentrations and inspector values remain unchanged. + +Switching to Fixed starts from the current extrema (with finite padding for constant data), or restores that channel's previously entered fixed bounds. Edit both bounds and choose Apply range or press Enter. Bounds accept finite decimal numbers, including negative numbers and scientific notation, with minimum strictly less than maximum. Invalid or incomplete edits show an explanation and leave the last valid range active. Legends always describe the active range rather than unsubmitted text. + +Settings belong to the numerical species or signal channel within the current dataset. They survive temporarily missing channels/grids, live updates, same-model reset, and frame seeking; opening another dataset restores automatic defaults. The shared `resolveScalarRange()` and `normalizeScalar()` APIs reject non-finite data explicitly, retain zero-valued and negative data, and avoid overflowing the difference between extreme finite bounds. They produce display intensity only and do not modify model data. + +`browser/scalar-ranges.mjs` checks actual cell instance colors, signal texture pixels, legends, validation messages, keyboard interaction, and dataset transitions in Chromium. Run it with a Vite server on port 4315, or set `VIEWER_URL`, using the same optional Playwright module and evidence-directory environment variables as the reference-grid test. + +## Channel labels + +Model-defined species and signal names appear in channel selectors, the species legend, and cell inspection. Duplicate names include their channel indices; unnamed channels retain `Channel N`. Names are presentation text; indices continue to identify selected channels. Current readers accept scene v2 and v3, while writers emit v3. See the [authoring guide](../docs/models/channel-labels.md) and [scene v3 schema](../docs/formats/scene-v3.md). + +### Device geometry visibility + +Use **Show device geometry** in the Scene panel to hide all mechanical constraint meshes and their outlines. The control is disabled when the current frame contains no geometry, while its preference is retained for later frames. Visibility persists through live updates, reset, and replay seeks, and defaults to enabled on opening another dataset. Cells, selection, the reference grid, signal slices, camera pose, and the existing Fit bounds policy are independent of this display setting. No simulation constraint or transport obstacle is changed. + +`browser/device-visibility.mjs` verifies all four constraint types, outlines, keyboard toggling, cell picking, sibling visibility, frame/reset retention, missing geometry, camera and Fit invariance, and new-dataset defaults against Vite on port 4323. It uses the same Playwright module and evidence-directory options as the reference-grid browser test. + +## Composite species colors + +Choose Species composite to display several intracellular channels together. The first two available channels initially use red and green and are enabled; additional channels start disabled. Enable or disable each channel with its checkbox. Display settings exposes its tint (a six-digit sRGB hexadecimal color), the same Automatic/Fixed range editor used by single-species coloring, and controls for reordering the list. Tint or range changes apply when submitted, and invalid edits preserve the active value. + +For each cell, every enabled channel is independently normalized with its selected range. Tints are decoded from sRGB into linear RGB; normalized intensity multiplies each linear tint, the contributions are added, and each summed component is clipped to one. The result is encoded back to sRGB for the existing renderer interface, which converts its instance colors to linear RGB. Channel-list order has no effect on the result. Full red and full green therefore produce yellow. The legend lists enabled channel names, tints, and active bounds. Three.js uses a small approximation in its sRGB encoding function; conversion tests bound the resulting error below 0.00001, well below an 8-bit color step. + +When all channels are disabled or unavailable, cells use neutral gray and the legend says no channels are active. With active channels and fixed zero-based bounds, zero intensity is black. Automatic constant data uses the same midpoint convention as single-species coloring, including a constant zero field; choose fixed zero-based bounds when zero should mean no displayed contribution. These colors are a presentation mapping, not calibrated fluorescence measurements. Lighting, tone mapping, and selection highlighting can further affect the final pixel appearance. + +Tints, visibility, list order, and ranges remain associated with numerical channel identity when labels change or data temporarily disappears. Single-species and composite views share each species channel's range. Same-model reset and frame seeking retain settings; opening another dataset restores defaults. The cell inspector, picking, selection highlights, and lineage values continue using the original cell state. + +`browser/composite-species.mjs` exercises red-only, green-only, co-expressing, and zero-expression cells in a moving colony, verifies the rendered instance colors, and checks controls, ordering, picking, highlighting, lineage, and dataset transitions. It uses a Vite server on port 4319 (or `VIEWER_URL`) and the same Playwright/evidence environment variables as the other browser checks. + ## Replay a recording Record periodic checkpoints using the short native growth/division/removal example, then list the checkpoint paths in the order they should play: @@ -120,22 +148,6 @@ The reader loads frames on demand through a bounded three-frame/64 MiB accountin For browser regression checks, generate native fixtures with `.venv/bin/python viewer/browser/replay-fixtures.py /tmp/replay-fixtures`, run the viewer on port 4326, then run `viewer/browser/replay.mjs` with `REPLAY_FIXTURES=/tmp/replay-fixtures` and `MICROSIMULATOR_PLAYWRIGHT_MODULE` pointing to an installed Playwright module. This uses the existing shared browser harness and adds no production debug API. -## Concentration color ranges - -Species coloring and signal slices each offer Automatic and Fixed color ranges. Automatic uses the current frame's species extrema or the selected signal slice's extrema. Constant automatic data uses the midpoint color and a uniform legend; empty automatic data shows “no values” without numerical bounds. Fixed uses the entered minimum and maximum across frames and slices. Values outside that interval use endpoint colors; the underlying concentrations and inspector values remain unchanged. - -Switching to Fixed starts from the current extrema (with finite padding for constant data), or restores that channel's previously entered fixed bounds. Edit both bounds and choose Apply range or press Enter. Bounds accept finite decimal numbers, including negative numbers and scientific notation, with minimum strictly less than maximum. Invalid or incomplete edits show an explanation and leave the last valid range active. Legends always describe the active range rather than unsubmitted text. - -Settings belong to the numerical species or signal channel within the current dataset. They survive temporarily missing channels/grids, live updates, same-model reset, and frame seeking; opening another dataset restores automatic defaults. The shared `resolveScalarRange()` and `normalizeScalar()` APIs reject non-finite data explicitly, retain zero-valued and negative data, and avoid overflowing the difference between extreme finite bounds. They produce display intensity only and do not modify model data. - -`browser/scalar-ranges.mjs` checks actual cell instance colors, signal texture pixels, legends, validation messages, keyboard interaction, and dataset transitions in Chromium. Run it with a Vite server on port 4315, or set `VIEWER_URL`, using the same optional Playwright module and evidence-directory environment variables as the reference-grid test. - -### Device geometry visibility - -Use **Show device geometry** in the Scene panel to hide all mechanical constraint meshes and their outlines. The control is disabled when the current frame contains no geometry, while its preference is retained for later frames. Visibility persists through live updates, reset, and replay seeks, and defaults to enabled on opening another dataset. Cells, selection, the reference grid, signal slices, camera pose, and the existing Fit bounds policy are independent of this display setting. No simulation constraint or transport obstacle is changed. - -`browser/device-visibility.mjs` verifies all four constraint types, outlines, keyboard toggling, cell picking, sibling visibility, frame/reset retention, missing geometry, camera and Fit invariance, and new-dataset defaults against Vite on port 4323. It uses the same Playwright module and evidence-directory options as the reference-grid browser test. - -## Channel labels +Capsule geometry tests verify the scene's cylindrical centerline length, constant radius, spherical ends, zero-length sphere case, arbitrary orientation, outward topology, exact equator positions/normals, and ray picking. The selected-cell overlay uses the same geometry with radius increased by 8%; the cap centers retain the original centerline length. Three instanced draw calls represent the colony, independent of cell count. Replacing frames disposes both geometry and instance buffers. -Model-defined species and signal names appear in channel selectors, the species legend, and cell inspection. Duplicate names include their channel indices; unnamed channels retain `Channel N`. Names are presentation text; indices continue to identify selected channels. Current readers accept scene v2 and v3, while writers emit v3. See the [authoring guide](../docs/models/channel-labels.md) and [scene v3 schema](../docs/formats/scene-v3.md). +For visual and GPU-resource checks, see [the capsule browser regression](browser/README.md). Mesh tessellation and pixel aliasing can still affect silhouettes at distant zoom levels; the shared tangent joins specifically remove overlapping end disks and mismatched sphere/cylinder boundaries. The viewer does not smooth or modify simulated cell motion. diff --git a/viewer/browser/README.md b/viewer/browser/README.md index 3973ca1..a4efe02 100644 --- a/viewer/browser/README.md +++ b/viewer/browser/README.md @@ -20,3 +20,20 @@ MICROSIMULATOR_PLAYWRIGHT_MODULE=/absolute/path/to/@playwright/test/index.mjs CA The performance fixture renders 512 cells after ten warmup frames, measuring 60 complete frame replacements (transform upload, coloring, rendering, and `gl.finish()` GPU synchronization). It reports median/p95 wall times, browser/WebGL renderer, three draw calls, per-mesh vertices/triangles, and geometry/buffer counts. Timings include CPU work and synchronization and are local regression evidence, not a cross-device benchmark. It tracks actual `createBuffer`/`deleteBuffer` calls to detect instance-buffer leaks that `renderer.info.memory.geometries` alone misses. Corrected mode requires stable buffer counts across replacements and zero tracked buffers after an empty frame and viewer disposal. Inspect the PNGs and recorded WebM listed in `results.json`. Compare the tangent joins under identical lighting, distinguishing the reproduced ring seams from silhouette tessellation, pixel aliasing, and the fixture's deliberately changing orientation/position. Do not use test/build success alone to declare the visual artifact resolved. + +## Combined feedback campaign + +The validation branch includes `feedback-fixtures.py` and `feedback-integration.mjs` to exercise the completed issue stack together. The native exporter produces a growth/division/removal recording and constant-concentration presentation snapshots with missing/reappearing signal grids and device geometry. The browser check verifies fixed species/signal ranges, composite tints/order/enabled state, device visibility, stable-ID selection, camera/reference-grid retention, reverse/rapid seeking, damaged-frame recovery, keyboard navigation and the supported 880px layout. These synthetic presentation transitions are not claims about evolving native device geometry. + +```console +uv run --no-sync python viewer/browser/feedback-fixtures.py /tmp/microsimulator-feedback/fixtures-v3 +pnpm --dir viewer dev --host 127.0.0.1 --port 4330 +``` + +In another POSIX terminal, run: + +```console +MICROSIMULATOR_PLAYWRIGHT_MODULE=/absolute/path/to/@playwright/test/index.mjs EVIDENCE_DIR=/tmp/microsimulator-feedback node viewer/browser/feedback-integration.mjs +``` + +Use a new fixture/output directory for another export. Existing issue browser hooks accept Vite's cache-busting module query strings so hot reload during combined validation does not disable test-only observation. These scripts never install production debug globals. diff --git a/viewer/browser/composite-species.mjs b/viewer/browser/composite-species.mjs new file mode 100644 index 0000000..370e325 --- /dev/null +++ b/viewer/browser/composite-species.mjs @@ -0,0 +1,344 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir } from "node:fs/promises"; +import canonicalize from "canonicalize"; +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4319"; +const evidence = + process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-composite-species"; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); +const errors = []; +page.on("pageerror", (error) => errors.push(error.message)); +await page.route("**/src/colony-viewer.ts*", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace(marker, `${marker}\nglobalThis.__testViewer = this;`), + }); +}); +let socket; +await page.routeWebSocket("**/api/v1/session?*", (connection) => { + socket = connection; +}); +const base = { + backend: { + kind: "cpu", + name: "CPU fixture", + device: "host", + device_index: 0, + native: true, + }, + time: 0, + species_count: 2, + channel_metadata: { + species: ["Red reporter", "Green reporter"], + signals: [], + }, + cells: [ + [1, 0], + [0, 1], + [1, 1], + [0, 0], + ].map((species, slot) => ({ + id: String(slot + 1), + parent_id: slot === 2 ? "1" : null, + slot, + position: [slot % 2 === 0 ? -2 : 2, slot < 2 ? -2 : 2, 0.6], + direction: [1, 0, 0], + length: 1.5, + radius: 0.45, + growth_rate: 0.1, + cell_type: slot, + fixed: false, + species, + })), + constraints: { boxes: [], cylinders: [], planes: [], spheres: [] }, + signal_grid: null, +}; +function scene(frame) { + return { + format: "microsimulator-scene", + version: 3, + producer: { name: "microsimulator", version: "test" }, + integrity: { + algorithm: "sha256", + frame: createHash("sha256").update(canonicalize(frame)).digest("hex"), + }, + frame, + }; +} +let revision = 0; +async function send(frame) { + socket.send( + JSON.stringify({ + type: "frame", + revision: revision++, + completed_steps: revision, + playing: false, + checkpoint_enabled: false, + scene: scene(frame), + }), + ); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); +} +const row = (index) => + page.locator(`.composite-channel[data-channel="${index}"]`); +async function fixed(index, minimum, maximum) { + const group = row(index); + if (!(await group.locator("details").evaluate((details) => details.open))) + await group.locator("summary").click(); + await group + .getByRole("combobox", { name: "Species range mode", exact: true }) + .selectOption("fixed"); + await group + .getByRole("textbox", { name: "Species minimum", exact: true }) + .fill(String(minimum)); + await group + .getByRole("textbox", { name: "Species maximum", exact: true }) + .fill(String(maximum)); + await group + .getByRole("textbox", { name: "Species maximum", exact: true }) + .press("Enter"); +} +async function colors() { + return page.evaluate(() => + Array.from( + globalThis.__testViewer.cellMeshes[0]?.instanceColor?.array ?? [], + ), + ); +} +function close(actual, expected, tolerance = 1e-5) { + assert.equal(actual.length, expected.length); + actual.forEach((value, index) => + assert.ok( + Math.abs(value - expected[index]) < tolerance, + `component ${index}: ${value} != ${expected[index]}`, + ), + ); +} +async function clickCell(index) { + const position = await page.evaluate((index) => { + const v = globalThis.__testViewer; + v.camera.updateMatrixWorld(true); + const point = v.camera.position + .clone() + .fromArray(v.cells[index].position) + .project(v.camera); + const bounds = v.renderer.domElement.getBoundingClientRect(); + return { + x: bounds.x + ((point.x + 1) * bounds.width) / 2, + y: bounds.y + ((1 - point.y) * bounds.height) / 2, + }; + }, index); + await page.mouse.click(position.x, position.y); +} +try { + await page.goto(`${url}/?token=composite-test`); + await expect.poll(() => socket !== undefined).toBe(true); + await send(base); + await page.locator("#color-mode").selectOption("composite"); + await fixed(0, 0, 1); + await fixed(1, 0, 1); + const redGreenYellowBlack = [1, 0, 0, 0, 1, 0, 1, 1, 0, 0, 0, 0]; + close(await colors(), redGreenYellowBlack); + await expect(page.locator("#composite-legend")).toContainText( + "Red reporter · #ff0000 · Fixed 0 to 1", + ); + await expect(page.locator("#composite-legend")).toContainText( + "Green reporter · #00ff00 · Fixed 0 to 1", + ); + await row(0).locator("summary").click(); + await row(1).locator("summary").click(); + await page.evaluate(() => { + const v = globalThis.__testViewer; + v.controls.enableDamping = false; + v.camera.position + .sub(v.controls.target) + .multiplyScalar(1.25) + .add(v.controls.target); + v.controls.update(); + }); + await page.screenshot({ path: `${evidence}/expression-patterns.png` }); + await clickCell(2); + await expect(page.locator("#selection-title")).toHaveText("Cell 3"); + await expect( + page + .locator("#cell-details > div") + .filter({ has: page.locator("dt", { hasText: "Parent" }) }) + .locator("dd"), + ).toHaveText("1"); + await expect(page.locator("#species-values code")).toHaveText(["1", "1"]); + assert.equal( + await page.evaluate(() => globalThis.__testViewer.highlight.visible), + true, + ); + await page.screenshot({ path: `${evidence}/selected-patterns.png` }); + + for (let time = 1; time <= 3; time += 1) { + await send({ + ...base, + time, + cells: base.cells.map((cell) => ({ + ...cell, + position: [ + cell.position[0] + time / 3, + cell.position[1] - time / 4, + cell.position[2], + ], + })), + }); + close(await colors(), redGreenYellowBlack); + await expect(page.locator("#selection-title")).toHaveText("Cell 3"); + assert.equal( + await page.evaluate(() => globalThis.__testViewer.highlight.visible), + true, + ); + } + await page.screenshot({ path: `${evidence}/moving-patterns.png` }); + await row(1) + .getByRole("checkbox", { name: "Enable Green reporter" }) + .uncheck(); + close(await colors(), [1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]); + await row(0).getByRole("checkbox", { name: "Enable Red reporter" }).uncheck(); + await expect(page.locator("#composite-legend")).toContainText( + "No channels active", + ); + const neutral = await page.evaluate(async () => { + const { COMPOSITE_NEUTRAL } = await import("/src/composite-color.ts"); + const { Color, SRGBColorSpace } = + await import("/node_modules/.vite/deps/three.js"); + const value = new Color().setRGB(...COMPOSITE_NEUTRAL, SRGBColorSpace); + return [value.r, value.g, value.b]; + }); + close(await colors(), [...neutral, ...neutral, ...neutral, ...neutral]); + await row(0).getByRole("checkbox").check(); + await row(1).getByRole("checkbox").check(); + close(await colors(), redGreenYellowBlack); + await row(1).locator("summary").click(); + const beforeOrder = await colors(); + await row(1).getByRole("button", { name: "Move Green reporter up" }).click(); + assert.deepEqual( + await page + .locator(".composite-channel") + .evaluateAll((rows) => rows.map((row) => row.dataset.channel)), + ["1", "0"], + ); + assert.deepEqual(await colors(), beforeOrder); + await expect( + row(1).getByRole("textbox", { name: "Species maximum", exact: true }), + ).toHaveValue("1"); + + await fixed(0, 0, 2); + await page.locator("#color-mode").selectOption("species"); + await page.locator("#species-channel").selectOption("0"); + await expect( + page + .locator("#species-range") + .getByRole("textbox", { name: "Species maximum", exact: true }), + ).toHaveValue("2"); + await page + .locator("#species-range") + .getByRole("textbox", { name: "Species maximum", exact: true }) + .fill("3"); + await page + .locator("#species-range") + .getByRole("textbox", { name: "Species maximum", exact: true }) + .press("Enter"); + await page.locator("#color-mode").selectOption("composite"); + await expect( + row(0).getByRole("textbox", { name: "Species maximum", exact: true }), + ).toHaveValue("3"); + await fixed(0, 0, 1); + await row(0).getByRole("textbox", { name: "Channel tint" }).fill("#808080"); + await row(0).getByRole("textbox", { name: "Channel tint" }).press("Enter"); + await send({ + ...base, + time: 4, + cells: [{ ...base.cells[0], species: [0.5, 0] }, ...base.cells.slice(1)], + }); + const decode = (value) => ((value + 0.055) / 1.055) ** 2.4; + close((await colors()).slice(0, 3), Array(3).fill(0.5 * decode(128 / 255))); + const validTintColors = await colors(); + await row(0).getByRole("textbox", { name: "Channel tint" }).fill("invalid"); + await row(0).getByRole("textbox", { name: "Channel tint" }).press("Enter"); + await expect( + row(0).locator(".composite-tint-form [role=status]"), + ).toContainText("six-digit"); + assert.deepEqual(await colors(), validTintColors); + await row(0).getByRole("textbox", { name: "Channel tint" }).fill("#ff0000"); + await row(0).getByRole("textbox", { name: "Channel tint" }).press("Enter"); + await send({ + ...base, + time: 5, + cells: [{ ...base.cells[0], species: [-1, 2] }, ...base.cells.slice(1)], + }); + close((await colors()).slice(0, 3), [0, 1, 0]); + await clickCell(0); + await expect(page.locator("#species-values code")).toHaveText(["-1", "2"]); + + await row(1).getByRole("checkbox").uncheck(); + await send({ + ...base, + time: 6, + species_count: 0, + channel_metadata: { species: [], signals: [] }, + cells: [], + }); + await expect(page.locator("#selection-title")).toHaveText("No cell selected"); + await send(base); // Backward seek / live reset contract. + await expect(page.locator("#color-mode")).toHaveValue("composite"); + await expect(row(1).getByRole("checkbox")).not.toBeChecked(); + await expect( + row(0).getByRole("textbox", { name: "Species maximum", exact: true }), + ).toHaveValue("1"); + assert.deepEqual( + await page + .locator(".composite-channel") + .evaluateAll((rows) => rows.map((row) => row.dataset.channel)), + ["1", "0"], + ); + + await page.goto(url); + const open = async (frame) => { + await page.locator("#scene-file").setInputFiles({ + name: "named.scene.json", + mimeType: "application/json", + buffer: Buffer.from(JSON.stringify(scene(frame))), + }); + await expect(page.locator("#time-chip")).toHaveText(`t = ${frame.time}`); + }; + await open(base); + await page.locator("#color-mode").selectOption("composite"); + await row(0).getByRole("checkbox").uncheck(); + await fixed(1, 0, 20); + await open({ ...base, time: 99 }); + await page.locator("#color-mode").selectOption("composite"); + await expect(row(0).getByRole("checkbox")).toBeChecked(); + await row(1).locator("summary").click(); + await expect( + row(1).getByRole("combobox", { name: "Species range mode", exact: true }), + ).toHaveValue("automatic"); + assert.deepEqual(errors, []); + console.log( + JSON.stringify( + { + result: "passed", + browser: browser.version(), + evidence, + coverage: + "linear RGB/colorspace, coexpression/motion, disable/neutral, reorder, shared ranges, tint validation, clipping, picking/highlight/lineage, absent channels/reset/new dataset", + }, + null, + 2, + ), + ); +} finally { + await browser.close(); +} diff --git a/viewer/browser/feedback-fixtures.py b/viewer/browser/feedback-fixtures.py new file mode 100644 index 0000000..a32bf66 --- /dev/null +++ b/viewer/browser/feedback-fixtures.py @@ -0,0 +1,74 @@ +"""Generate browser fixtures using the public model, checkpoint and exporter APIs.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from microsimulator import ( + BackendKind, + BoxConstraintInit, + CellInit, + ChannelMetadata, + ConstraintRegion, + GridShape, + ModelContext, + SignalGridSpec, + Simulation, + Vec3, + build_model, + run_simulation, + save_checkpoint, +) +from microsimulator.replay import export_replay + +parser = argparse.ArgumentParser() +parser.add_argument("output", type=Path) +root = parser.parse_args().output +root.mkdir(parents=True, exist_ok=True) +model, provenance = build_model("examples/replay_demo.py", ModelContext(BackendKind.CPU, 0, 17)) +summary = run_simulation( + model, + steps=5, + dt=0.2, + output=root / "lifecycle.json", + checkpoint_every=1, + provenance=provenance, +) +export_replay(summary.periodic_checkpoints, root / "lifecycle") +paths = [] +# Data-only compatible scene states exercise unavailable grids and clamping. +# Distinct CPU simulations produce these grid fixtures; no biology claim is made. +for ordinal, size in enumerate((3, None, 1, 3)): + simulation = Simulation(species_count=2) + cell = CellInit() + cell.species = [0.25, 0.75] + cell.growth_rate = 0 # presentation fixture: keep concentrations comparable across time + if size is not None: + shape = GridShape() + shape.x, shape.y, shape.z = size, size, size + spec = SignalGridSpec() + spec.signal_count = 2 + spec.shape = shape + spec.spacing = Vec3(1, 1, 1) + spec.diffusion = [0, 0] + spec.advection = [Vec3(), Vec3()] + simulation.configure_signal_grid(spec, [0.25] * size**3 + [0.75] * size**3) + if ordinal != 1: + box = BoxConstraintInit() + box.half_extents = Vec3(10, 10, 2) + box.allowed_region = ConstraintRegion.INSIDE + simulation.add_box_constraint(box) + simulation.add_cell(cell) + simulation.step(ordinal * 0.2) + path = root / f"grid-{ordinal}.json" + save_checkpoint( + simulation, + path, + channel_metadata=ChannelMetadata( + species=("Green", "Red"), signals=("Nutrient", "Cue") if size else () + ), + ) + paths.append(path) +export_replay(paths, root / "grids") +print(root) diff --git a/viewer/browser/feedback-integration.mjs b/viewer/browser/feedback-integration.mjs new file mode 100644 index 0000000..f548958 --- /dev/null +++ b/viewer/browser/feedback-integration.mjs @@ -0,0 +1,337 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { cp, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import canonicalize from "canonicalize"; + +const { chromium, expect } = await import( + process.env.MICROSIMULATOR_PLAYWRIGHT_MODULE ?? "@playwright/test" +); +const url = process.env.VIEWER_URL ?? "http://127.0.0.1:4330"; +const evidence = process.env.EVIDENCE_DIR ?? "/tmp/microsimulator-feedback"; +const fixtures = process.env.REPLAY_FIXTURES ?? `${evidence}/fixtures-v3`; +await mkdir(evidence, { recursive: true }); +const browser = await chromium.launch({ headless: true }); +try { + const page = await browser.newPage({ + viewport: { width: 1440, height: 960 }, + }); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route("**/src/colony-viewer.ts*", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "this.onSelection = onSelection;"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace( + marker, + `${marker}\nglobalThis.__testViewer = this;`, + ), + }); + }); + await page.route("**/src/replay-bundle.ts*", async (route) => { + const response = await route.fetch(); + const source = await response.text(); + const marker = "const bytes = await this.read(file, signal);"; + assert.equal(source.split(marker).length, 2); + await route.fulfill({ + response, + body: source.replace( + marker, + `await globalThis.__delayReplayLoad?.(ordinal);\n${marker}`, + ), + }); + }); + await page.goto(url); + async function open(name, count) { + await page + .locator("#recording-folder") + .setInputFiles(path.resolve(fixtures, name)); + await expect(page.locator("#replay-position")).toContainText( + `1 / ${count}`, + ); + await expect(page.locator("#replay-message")).toHaveText(""); + } + async function seek(ordinal, count) { + await page.locator("#replay-timeline").fill(String(ordinal)); + await page.locator("#replay-timeline").dispatchEvent("input"); + await expect(page.locator("#replay-position")).toContainText( + `${ordinal + 1} / ${count}`, + ); + await expect(page.locator("#replay-message")).toHaveText(""); + } + const snapshot = () => + page.evaluate(() => { + const viewer = globalThis.__testViewer; + viewer.grid.updateMatrixWorld(true); + return { + camera: viewer.camera.position.toArray(), + target: viewer.controls.target.toArray(), + grid: viewer.grid.matrixWorld.elements.slice(), + }; + }); + function assertStationary(actual, expected) { + assert.deepEqual(actual.grid, expected.grid); + for (const key of ["camera", "target"]) + actual[key].forEach((value, index) => { + assert.ok( + Math.abs(value - expected[key][index]) < 1e-9, + `${key}[${index}] remains stationary`, + ); + }); + } + await open("lifecycle", 5); + await expect(page.locator("#time-chip")).toHaveText("t = 0.2"); + await page.evaluate(() => { + const v = globalThis.__testViewer; + v.selectCell(0); + v.controls.enableDamping = false; + v.camera.position.multiplyScalar(2); + v.controls.update(); + }); + const initial = await snapshot(); + await expect(page.locator("#selection-title")).toHaveText("Cell 1"); + await seek(1, 5); + await expect(page.locator("#selection-title")).toHaveText("No cell selected"); + await expect(page.locator("#cell-count")).toHaveText("2"); + await page.evaluate(() => globalThis.__testViewer.selectCell(1)); + await expect(page.locator("#selection-title")).toHaveText("Cell 3"); + await seek(2, 5); + await expect(page.locator("#selection-title")).toHaveText("Cell 3"); + await expect(page.locator("#cell-count")).toHaveText("1"); + await expect(page.locator("#cell-details")).toContainText("Slot0"); + await seek(1, 5); + await expect(page.locator("#cell-details")).toContainText("Slot1"); + assertStationary(await snapshot(), initial); + await page.selectOption("#color-mode", "species"); + await expect(page.locator("#legend-title")).toHaveText("Reporter"); + // Delay an uncached frame and supersede it while its decode worker is occupied. + await page.evaluate(() => { + globalThis.__delayReplayLoad = (ordinal) => + ordinal === 4 + ? new Promise((resolve) => { + globalThis.__releaseReplay = resolve; + }) + : undefined; + }); + await page.locator("#replay-timeline").fill("4"); + await page.locator("#replay-timeline").dispatchEvent("input"); + await expect + .poll(() => page.evaluate(() => typeof globalThis.__releaseReplay)) + .toBe("function"); + await page.locator("#replay-timeline").fill("3"); + await page.locator("#replay-timeline").dispatchEvent("input"); + await page.evaluate(() => { + globalThis.__releaseReplay(); + globalThis.__delayReplayLoad = undefined; + }); + await expect(page.locator("#replay-position")).toContainText("4 / 5"); + await expect(page.locator("#time-chip")).toHaveText("t = 0.8"); + await expect(page.locator("#legend-title")).toHaveText("Reporter"); + // Playing while an uncached seek is pending must preserve its destination. + await seek(0, 5); + await seek(1, 5); + await seek(4, 5); + await page.locator("#replay-fps").fill("1"); + await page.locator("#replay-fps").dispatchEvent("change"); + await page.evaluate(() => { + globalThis.__delayReplayLoad = (ordinal) => + ordinal === 2 + ? new Promise((resolve) => { + globalThis.__releasePending = resolve; + }) + : undefined; + }); + await page.locator("#replay-timeline").fill("2"); + await page.locator("#replay-timeline").dispatchEvent("input"); + await expect + .poll(() => page.evaluate(() => typeof globalThis.__releasePending)) + .toBe("function"); + await page.locator("#replay-play").click(); + await expect(page.locator("#replay-timeline")).toHaveValue("2"); + await page.evaluate(() => { + globalThis.__releasePending(); + globalThis.__delayReplayLoad = undefined; + }); + await expect(page.locator("#replay-position")).toContainText("3 / 5"); + await expect(page.locator("#replay-play")).toHaveText("Pause"); + await page.locator("#replay-play").click(); + await seek(0, 5); + await page.locator("#replay-fps").fill("20"); + await page.locator("#replay-fps").dispatchEvent("change"); + await page.locator("#replay-play").click(); + await expect(page.locator("#replay-position")).toContainText("5 / 5"); + await expect(page.locator("#replay-play")).toHaveText("Play"); + await page.locator("#replay-previous").focus(); + await page.keyboard.press("Enter"); + await expect(page.locator("#replay-position")).toContainText("4 / 5"); + assertStationary(await snapshot(), initial); + await page.screenshot({ path: `${evidence}/lifecycle-replay.png` }); + await open("grids", 4); + async function fixed(root, kind, minimum, maximum) { + await root.getByLabel(`${kind} range mode`).selectOption("fixed"); + await root + .getByLabel(`${kind} minimum`, { exact: true }) + .fill(String(minimum)); + await root + .getByLabel(`${kind} maximum`, { exact: true }) + .fill(String(maximum)); + await root + .getByRole("button", { name: "Apply range", exact: true }) + .click(); + } + await page.selectOption("#color-mode", "species"); + await fixed(page.locator("#species-range"), "Species", 0, 1); + await page.selectOption("#species-channel", "1"); + await fixed(page.locator("#species-range"), "Species", 0, 2); + await page.selectOption("#signal-channel", "1"); + await fixed(page.locator("#signal-color-range"), "Signal", 0, 2); + await page.locator("#signal-slice").fill("2"); + await page.locator("#signal-slice").dispatchEvent("input"); + await page.locator("#signal-visible").focus(); + await page.keyboard.press("Space"); + await page.getByLabel("Show device geometry", { exact: true }).focus(); + await page.keyboard.press("Space"); + await page.selectOption("#color-mode", "composite"); + const blue = page.locator('.composite-channel[data-channel="1"]'); + await blue.locator("summary").click(); + await blue.getByLabel("Channel tint").fill("#0000ff"); + await blue.getByRole("button", { name: "Apply tint", exact: true }).click(); + await blue.getByRole("button", { name: "Move Red up", exact: true }).click(); + const colors = () => + page.evaluate(() => + Array.from(globalThis.__testViewer.cellMeshes[0].instanceColor.array), + ); + const composite = await colors(); + assert.ok(Math.abs(composite[0] - 0.25) < 1e-5); + assert.equal(composite[1], 0); + assert.ok(Math.abs(composite[2] - 0.375) < 1e-5); + await page.getByLabel("Enable Green", { exact: true }).uncheck(); + const retainedColors = await colors(); + assert.equal(retainedColors[0], 0); + await page.evaluate(() => { + const v = globalThis.__testViewer; + v.selectCell(0); + v.controls.enableDamping = false; + v.camera.position.multiplyScalar(1.3); + v.controls.update(); + }); + const gridInitial = await snapshot(); + await seek(1, 4); + await expect(page.locator("#signal-section")).toBeHidden(); + await expect(page.locator("#device-visible")).toBeDisabled(); + await seek(2, 4); + await expect(page.locator("#signal-slice")).toHaveValue("0"); + await seek(3, 4); + await expect(page.locator("#signal-slice")).toHaveValue("2"); + await expect(page.locator("#signal-channel")).toHaveValue("1"); + await expect(page.locator("#species-channel")).toHaveValue("1"); + await expect(page.locator("#signal-visible")).not.toBeChecked(); + await expect(page.locator("#device-visible")).not.toBeChecked(); + await expect(page.locator("#device-visible")).toBeEnabled(); + await expect(page.locator("#selection-title")).toHaveText("Cell 1"); + await expect(page.locator("#composite-legend")).toContainText( + "Red · #0000ff · Fixed 0 to 2", + ); + await expect( + page.getByLabel("Enable Green", { exact: true }), + ).not.toBeChecked(); + assert.deepEqual(await colors(), retainedColors); + assert.deepEqual( + await page + .locator(".composite-channel") + .evaluateAll((rows) => rows.map((row) => row.dataset.channel)), + ["1", "0"], + ); + assert.equal( + await page.evaluate(() => globalThis.__testViewer.device.visible), + false, + ); + await expect( + page + .locator("#signal-color-range") + .getByLabel("Signal maximum", { exact: true }), + ).toHaveValue("2"); + await seek(0, 4); + assert.deepEqual(await colors(), retainedColors); + assertStationary(await snapshot(), gridInitial); + await page.selectOption("#color-mode", "species"); + await expect( + page.locator("#species-range").getByLabel("Species range mode"), + ).toHaveValue("fixed"); + await expect( + page + .locator("#species-range") + .getByLabel("Species maximum", { exact: true }), + ).toHaveValue("2"); + await page.selectOption("#color-mode", "composite"); + assert.deepEqual(await colors(), retainedColors); + await page.screenshot({ path: `${evidence}/grid-replay.png` }); + await page.setViewportSize({ width: 880, height: 720 }); + for (const id of [ + "#replay-play", + "#replay-timeline", + "#replay-fps", + "#recording-open", + ]) { + const bounds = await page.locator(id).boundingBox(); + assert.ok( + bounds && bounds.x >= 0 && bounds.x + bounds.width <= 880, + `${id} fits the supported narrow layout`, + ); + } + await expect(page.locator("#replay-position")).toBeVisible(); + await page.screenshot({ path: `${evidence}/narrow-replay.png` }); + await page.setViewportSize({ width: 1440, height: 960 }); + // A damaged frame is reported persistently while retaining the last good frame. + const malformed = `${evidence}/malformed`; + await cp(`${fixtures}/lifecycle`, malformed, { + recursive: true, + force: true, + }); + const manifest = JSON.parse( + await readFile(`${malformed}/manifest.json`, "utf8"), + ); + const broken = Buffer.from("not a scene"); + const entry = manifest.recording.frames[2]; + entry.bytes = broken.byteLength; + entry.sha256 = createHash("sha256").update(broken).digest("hex"); + await writeFile(`${malformed}/${entry.file}`, broken); + manifest.integrity.recording = createHash("sha256") + .update(canonicalize(manifest.recording)) + .digest("hex"); + await writeFile(`${malformed}/manifest.json`, JSON.stringify(manifest)); + await page.locator("#recording-folder").setInputFiles(malformed); + await expect(page.locator("#replay-position")).toContainText("1 / 5"); + await page.locator("#replay-timeline").fill("2"); + await page.locator("#replay-timeline").dispatchEvent("input"); + await expect(page.locator("#replay-message")).toContainText( + "frame 2 (frames/00000002.scene.json)", + ); + await expect(page.locator("#replay-position")).toContainText("1 / 5"); + await seek(1, 5); + // Opening a static scene closes replay; delayed recording work cannot replace it. + await page + .locator("#scene-file") + .setInputFiles(`${fixtures}/grids/frames/00000000.scene.json`); + await expect(page.locator("#replay-transport")).toBeHidden(); + await expect(page.locator("#color-mode")).toHaveValue("cell-type"); + await expect(page.locator("#species-channel")).toHaveValue("0"); + await expect(page.locator("#device-visible")).toBeChecked(); + await page.selectOption("#color-mode", "species"); + await expect( + page.locator("#species-range").getByLabel("Species range mode"), + ).toHaveValue("automatic"); + assert.deepEqual(errors, []); + console.log( + JSON.stringify({ + status: "passed", + coverage: + "combined fixed species/signal ranges, composite tint/order/enabled state, device visibility, native exported topology, reverse steps, stable ID selection, time, rapid seeks, playback fps/end, metadata, missing/changing grids, camera/grid/preferences, malformed frame attribution/recovery, keyboard, narrow layout, new dataset reset", + }), + ); +} finally { + await browser.close(); +} diff --git a/viewer/index.html b/viewer/index.html index 4db8fe5..8ad632c 100644 --- a/viewer/index.html +++ b/viewer/index.html @@ -72,6 +72,7 @@

Cells

@@ -81,6 +82,7 @@

Cells

+