import assert from "node:assert/strict"; import { captureAliasFixture } from "../helpers/viewer-map-fixture.mjs"; import test from "node:test"; const { tsImport } = await import("tsx/esm/api"); const presentationModel = await tsImport( "../../viewer/src/utils/screen-presentation.ts", import.meta.url, ); const { indexMap } = await tsImport("../../viewer/src/utils/model.ts", import.meta.url); function screen(id, title, route, variantIds, overrides = {}) { return { id, title, route, start: false, terminal: false, depth: 1, firstSeenAt: "2026-01-01T00:00:00.000Z", lastSeenAt: "2026-01-02T00:00:00.000Z", visits: 1, variantIds, observationIds: variantIds.map((variantId) => `observation-${variantId}`), elementIds: [], ...overrides, }; } function mapWith(screens) { const variants = screens.flatMap((entry) => entry.variantIds.map((variantId) => ({ id: variantId, screenId: entry.id, key: variantId, firstSeenAt: entry.firstSeenAt, lastSeenAt: entry.lastSeenAt, observationIds: [`observation-${variantId}`], }))); const observations = screens.flatMap((entry) => entry.variantIds.map((variantId) => ({ id: `observation-${variantId}`, screenId: entry.id, variantId, capturedAt: entry.firstSeenAt, surfaceScope: "app", elements: [], elementIds: [], fingerprints: { combined: variantId }, }))); return { contract: "local-map/v1", revision: 1, run: { id: "run", name: "Fixture" }, artifacts: [], screens, variants, observations, elements: [], actionAttempts: [], transitions: [], journeys: [], frontier: [], gaps: [], }; } test("presents screens sharing a display router path as one screen with ordered variants", () => { const first = screen("screen-home-empty", "Home — Empty", "/mobbin/screens/1", ["variant-empty"]); const second = screen("screen-home-loaded", "Home — Loaded", "/mobbin/screens/2", ["variant-loaded"]); const settings = screen("screen-settings", "Settings", "/settings", ["variant-settings"]); const source = mapWith([first, second, settings]); source.transitions = [ { id: "state", fromScreenId: first.id, toScreenId: second.id }, { id: "next", fromScreenId: second.id, toScreenId: settings.id }, ]; source.actionAttempts = [{ id: "state-attempt", sourceScreenId: first.id, targetScreenId: second.id, outcome: "transition", }]; source.journeys = [{ id: "journey", title: "Home to settings", screenIds: [first.id, second.id, settings.id], transitionIds: ["state", "next"], attemptIds: [], }]; const presentation = presentationModel.presentScreensByPath(source); const home = presentation.map.screens[0]; assert.equal(presentation.map.screens.length, 2); assert.equal(home.id, first.id); assert.deepEqual(home.variantIds, ["variant-empty", "variant-loaded"]); assert.equal(presentation.representativeIdByScreenId.get(second.id), first.id); assert.equal(presentation.index.variants.get("variant-loaded")?.screenId, first.id); assert.equal(presentation.index.observations.get("observation-variant-loaded")?.screenId, first.id); assert.deepEqual(presentation.map.journeys[0].screenIds, [first.id, first.id, settings.id]); assert.deepEqual( presentation.map.transitions.map((transition) => [transition.fromScreenId, transition.toScreenId]), [[first.id, first.id], [first.id, settings.id]], ); assert.equal(presentation.map.actionAttempts[0].outcome, "same-screen"); const aliases = presentationModel.screenAliasesForPresentation(source, presentation); assert.equal(aliases.screenIdAliasVariants.get(second.id), "variant-loaded"); assert.deepEqual(aliases.screenRouteAliases.get("/mobbin/screens/2"), { screenId: first.id, variantId: "variant-loaded", }); }); test("prefers current evidence when a displayed path also contains a baseline-only screen", () => { const removed = screen( "removed:build-1:screen:home", "Home — Legacy", "/mobbin/screens/1", ["removed-variant"], ); const current = screen("screen-home", "Home — Current", "/mobbin/screens/2", ["current-variant"]); const presentation = presentationModel.presentScreensByPath(mapWith([removed, current])); const home = presentation.map.screens[0]; assert.equal(home.id, current.id); assert.deepEqual(home.variantIds, ["current-variant"]); assert.equal(presentation.representativeIdByScreenId.get(removed.id), current.id); assert.deepEqual(presentation.map.variants.map((variant) => variant.id), ["current-variant"]); assert.deepEqual( presentation.map.observations.map((observation) => observation.id), ["observation-current-variant"], ); }); test("normalizes equivalent router paths before grouping", () => { const first = screen("screen-details-a", "Details A", "/(tabs)//details/%5Bid%5D/", ["variant-a"]); const second = screen("screen-details-b", "Details B", "/(tabs)/details/[id]", ["variant-b"]); const presentation = presentationModel.presentScreensByPath(mapWith([first, second])); assert.equal(presentation.map.screens.length, 1); assert.equal(presentation.families[0].path, "/(tabs)/details/[id]"); assert.deepEqual(presentation.map.screens[0].variantIds, ["variant-a", "variant-b"]); }); test("aggregates mixed member changes as one changed router path", () => { const added = screen("screen-policy-added", "Account policies & terms — Added", "/mobbin/screens/1", ["variant-added"]); const updated = screen("screen-policy-updated", "Account policies & terms — Updated", "/mobbin/screens/2", ["variant-updated"]); const removed = screen("removed:build-1:screen:legacy", "Legacy", "/legacy", ["variant-removed"]); const sourceMap = mapWith([added, updated, removed]); const presentation = presentationModel.presentScreensByPath(sourceMap); const rawDiff = { baselineId: "build-1", map: sourceMap, index: indexMap(sourceMap), screens: new Map([ [added.id, { status: "added", changeSummary: "Added policy details.", afterScreenshot: "added.png", }], [updated.id, { status: "modified", changeSummary: "Reorganized policy controls.", beforeScreenshot: "before.png", afterScreenshot: "after.png", }], [removed.id, { status: "removed", changeSummary: "Removed the legacy policy page.", beforeScreenshot: "removed.png", }], ]), counts: { added: 1, modified: 1, removed: 1, unchanged: 0 }, }; const presented = presentationModel.presentBuildDiff(rawDiff, presentation); assert.deepEqual(presented.counts, { added: 0, modified: 1, removed: 1, unchanged: 0, "needs-review": 0, "source-affected": 0, "not-compared": 0, unavailable: 0, variant: 0 }); assert.equal(presented.screens.size, 2); assert.deepEqual(presented.screens.get(added.id), { status: "modified", changeSummary: "Reorganized policy controls.", beforeScreenshot: "before.png", afterScreenshot: "after.png", reviewerChanges: [], runtimeEvidenceRefs: [], noVisibleChange: false, }); }); test("route-family counts retain uncertainty and never turn uncaptured members into verified unchanged", () => { const cases = [ { members: ["unchanged", "not-compared"], expected: "not-compared" }, { members: ["unchanged", "source-affected"], expected: "source-affected" }, { members: ["modified", "needs-review"], expected: "needs-review" }, { members: ["source-affected", "needs-review"], expected: "needs-review" }, { members: ["not-compared", "not-compared"], expected: "not-compared" }, // A runtime-state variant is not a change, yet a family must not hide it behind unchanged members. { members: ["unchanged", "variant"], expected: "variant" }, { members: ["variant", "modified"], expected: "modified" }, ]; for (const { members, expected } of cases) { const source = mapWith(members.map((status, index) => screen( `member-${index}`, `Home — ${index}`, "/home", [`variant-${index}`], ))); const presentation = presentationModel.presentScreensByPath(source); const projected = presentationModel.presentBuildDiff({ baselineId: "base", map: source, index: indexMap(source), counts: {}, screens: new Map(source.screens.map((entry, index) => [entry.id, { status: members[index], changeSummary: members[index], }])), }, presentation); assert.equal(projected.screens.get("member-0").status, expected, members.join(" + ")); assert.deepEqual(projected.counts, { added: 0, modified: 0, removed: 0, unchanged: 0, "needs-review": 0, "source-affected": 0, "not-compared": 0, unavailable: 0, variant: 0, [expected]: 1, }); } }); test("a redirect keeps the route its borrowed screenshots belong to", () => { const home = screen("screen-redirect", "Select photo", "/mobbin/screens/9", ["variant-current"]); const sourceMap = mapWith([home]); const presentation = presentationModel.presentScreensByPath(sourceMap); const presented = presentationModel.presentBuildDiff({ baselineId: "build-1", map: sourceMap, index: indexMap(sourceMap), screens: new Map([[home.id, { status: "source-affected", changeSummary: "Source impact was recorded.", beforeScreenshot: "before.png", afterScreenshot: "after.png", redirectTo: "/scan/take-photo", redirectTargets: ["/scan/take-photo"], }]]), counts: { added: 0, modified: 0, removed: 0, unchanged: 0 }, }, presentation); const diff = presented.screens.get(home.id); assert.equal(diff.redirectTo, "/scan/take-photo"); assert.deepEqual(diff.redirectTargets, ["/scan/take-photo"]); }); test("joins summaries in visual before-to-after order when a family has no paired diff", () => { const current = screen("screen-home", "Home — Current", "/mobbin/screens/2", ["variant-current"]); const removed = screen( "removed:build-1:screen:home", "Home — Legacy", "/mobbin/screens/1", ["variant-removed"], ); // Current is deliberately first so member order cannot accidentally determine summary order. const sourceMap = mapWith([current, removed]); const presentation = presentationModel.presentScreensByPath(sourceMap); const rawDiff = { baselineId: "build-1", map: sourceMap, index: indexMap(sourceMap), screens: new Map([ [current.id, { status: "added", changeSummary: "Added the current home state.", afterScreenshot: "after.png", }], [removed.id, { status: "removed", changeSummary: "Removed the legacy home state.", beforeScreenshot: "before.png", }], ]), counts: { added: 1, modified: 0, removed: 1, unchanged: 0 }, }; const presented = presentationModel.presentBuildDiff(rawDiff, presentation); assert.deepEqual(presented.screens.get(current.id), { status: "modified", changeSummary: "Removed the legacy home state. Added the current home state.", beforeScreenshot: "before.png", afterScreenshot: "after.png", reviewerChanges: [], runtimeEvidenceRefs: [], noVisibleChange: false, }); rawDiff.screens.get(current.id).changeSummary = "Home state changed."; rawDiff.screens.get(removed.id).changeSummary = "Home state changed."; const deduplicated = presentationModel.presentBuildDiff(rawDiff, presentation); assert.equal(deduplicated.screens.get(current.id)?.changeSummary, "Home state changed."); }); test("keeps source provenance while reducing the viewer to route-level screens", () => { const source = captureAliasFixture(); const presentation = presentationModel.presentScreensByPath(source); assert.equal(source.screens.length, 4); assert.equal(source.variants.length, 5); assert.equal(presentation.map.screens.length, 3); assert.equal(presentation.map.variants.length, 5); const referencedVariantIds = new Set( presentation.map.screens.flatMap((screen) => screen.variantIds), ); const referencedObservationIds = new Set( presentation.map.screens.flatMap((screen) => screen.observationIds), ); assert.equal( presentation.map.variants.every((variant) => ( referencedVariantIds.has(variant.id) && presentation.index.screens.get(variant.screenId)?.variantIds.includes(variant.id) )), true, ); assert.equal( presentation.map.observations.every((observation) => ( referencedObservationIds.has(observation.id) && presentation.index.screens.get(observation.screenId)?.observationIds.includes(observation.id) )), true, ); assert.equal(source.actionAttempts[0].outcome, "transition"); assert.equal(presentation.map.actionAttempts[0].outcome, "same-screen"); assert.equal(presentation.map.actionAttempts[1].sourceScreenId, "home"); assert.equal(presentation.map.transitions[1].fromScreenId, "home"); assert.equal(presentation.map.transitions[1].toScreenId, "profile"); assert.equal( presentation.map.actionAttempts.some((attempt) => ( attempt.outcome === "transition" && attempt.targetScreenId === attempt.sourceScreenId )), false, ); }); test("a route family is accessibility-only only when every changed member is", () => { const family = (statuses) => { // Screens recorded on one router path are one family. const members = statuses.map((_, index) => screen(`screen-${index}`, `Policy ${index}`, "/policies", [`variant-${index}`])); const sourceMap = mapWith(members); const presentation = presentationModel.presentScreensByPath(sourceMap); const screens = new Map(members.map((member, index) => { const [status, accessibilityOnly] = statuses[index]; return [member.id, { status, changeSummary: "", reviewerChanges: [], runtimeEvidenceRefs: [], noVisibleChange: false, ...(status !== "added" ? { beforeScreenshot: `before-${index}.png` } : {}), afterScreenshot: `after-${index}.png`, ...(accessibilityOnly ? { accessibilityOnly: true } : {}) }]; })); const presented = presentationModel.presentBuildDiff({ baselineId: "build-1", map: sourceMap, index: indexMap(sourceMap), screens, counts: { added: 0, modified: 0, removed: 0, unchanged: 0 } }, presentation); assert.equal(presented.screens.size, 1, "the members are one family"); return [...presented.screens.values()][0]; }; assert.equal(family([["modified", true], ["modified", true]]).accessibilityOnly, true); assert.equal(family([["modified", true], ["unchanged", false]]).accessibilityOnly, true); // An added or visibly modified member is a visible change, so the family is not accessibility-only. assert.equal(family([["modified", true], ["added", false]]).accessibilityOnly, undefined); assert.equal(family([["modified", true], ["modified", false]]).accessibilityOnly, undefined); });