mirror of
https://github.com/expo/explore.git
synced 2026-11-14 10:08:17 -10:00
354 lines
15 KiB
JavaScript
354 lines
15 KiB
JavaScript
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);
|
|
});
|