import assert from "node:assert/strict"; import test from "node:test"; const { tsImport } = await import("tsx/esm/api"); const { indexMap } = await tsImport("../../viewer/src/utils/model.ts", import.meta.url); const { screenTree, treeScreenOrder } = await tsImport("../../viewer/src/utils/screen-tree.ts", import.meta.url); const at = (second) => `2026-10-13T00:00:${String(second).padStart(2, "0")}.000Z`; function screen(id, title, depth, extra = {}) { return { id, title, depth, start: depth === 0, terminal: false, firstSeenAt: at(0), lastSeenAt: at(0), visits: 1, variantIds: [], observationIds: [], elementIds: [], ...extra, }; } function map({ screens, transitions = [], elements = [], exploration }) { return { run: { id: "run", name: "app", platform: "ios", environment: {}, ...(exploration ? { exploration } : {}) }, screens, transitions, elements, variants: [], observations: [], artifacts: [], actionAttempts: [], gaps: [], journeys: [], frontier: [], }; } function transition(id, from, to, second, elementId) { return { id, fromScreenId: from, toScreenId: to, actionKind: "press", firstObservedAt: at(second), lastObservedAt: at(second), attemptIds: [], ...(elementId ? { elementId } : {}) }; } test("with route files, sections follow the app directory and the tabs lead", () => { const screens = [ screen("launch:splash", "Launch", 0), screen("home", "Home", 0, { route: "/" }), screen("patterns", "Patterns", 1, { route: "/patterns" }), screen("detail", "Detail", 2, { route: "/p/[d]" }), screen("tempo", "Tempo", 1, { route: "/tempo" }), ]; const routeFiles = new Map([ ["home", "src/app/(tabs)/(sequencer)/index.tsx"], ["patterns", "src/app/(tabs)/(patterns)/patterns.tsx"], ["detail", "src/app/(tabs)/(patterns)/p/[d].tsx"], ["tempo", "src/app/tempo.tsx"], ]); const tree = screenTree(screens, indexMap(map({ screens })), routeFiles); assert.equal(tree.mode, "source"); assert.deepEqual(tree.sections.map((section) => [section.title, section.screenIds]), [ ["Launch", ["launch:splash"]], ["app/(tabs)/(sequencer)", ["home"]], ["app/(tabs)/(patterns)", ["patterns"]], ["app/(tabs)/(patterns)/p", ["detail"]], ["app", ["tempo"]], ]); const folders = tree.tree.filter((node) => node.kind === "folder").map((node) => node.label); assert.deepEqual(folders, ["(tabs)/"]); }); test("the screens' order is the tree's, depth first: a nested folder's screens come right after their parent's", () => { // The folders are found as settings, feed, settings/security: sections keep that order, while the tree nests // security under settings. The Screens grid and its arrow keys follow the tree, as the sidebar reads. const screens = [screen("profile", "A profile", 1), screen("feed", "B feed", 1), screen("security", "C security", 1), screen("loose", "D loose", 1)]; const routeFiles = new Map([ ["profile", "app/settings/profile.tsx"], ["feed", "app/feed/index.tsx"], ["security", "app/settings/security/index.tsx"], ]); const tree = screenTree(screens, indexMap(map({ screens })), routeFiles); assert.deepEqual(tree.sections.flatMap(section => section.screenIds), ["profile", "feed", "security", "loose"]); assert.deepEqual(treeScreenOrder(tree), ["profile", "security", "feed", "loose"]); }); test("a black-box exploration groups by the tab each screen was opened from", () => { const screens = [ screen("seq", "Sequencer", 0), screen("new", "New Pattern", 1), screen("rename", "Rename Pattern", 2), screen("pat", "Patterns", 1), screen("added", "Added to Library", 2), screen("lost", "Somewhere", 3), ]; const elements = [ { id: "tab-patterns", screenId: "seq", role: "tab", label: "Patterns", observationIds: [] }, { id: "plus", screenId: "seq", role: "button", label: "New pattern", observationIds: [] }, { id: "rename-item", screenId: "new", role: "menu-item", label: "Rename…", observationIds: [] }, ]; const transitions = [ transition("t1", "seq", "new", 1, "plus"), transition("t2", "new", "rename", 2, "rename-item"), transition("t3", "seq", "pat", 3, "tab-patterns"), transition("t4", "pat", "added", 4), ]; const tree = screenTree(screens, indexMap(map({ screens, transitions, elements, exploration: "black-box" }))); assert.equal(tree.mode, "observed"); assert.deepEqual(tree.sections.map((section) => [section.title, section.subtitle, section.screenIds]), [ ["Sequencer", "first screen · 2 opened from here", ["seq", "new", "rename"]], ["Patterns", "tab · 1 opened from here", ["pat", "added"]], ["Not reached from a tab", undefined, ["lost"]], ]); assert.equal(tree.via.get("rename"), "Rename…"); const patterns = tree.tree.find((node) => node.kind === "screen" && node.screenId === "pat"); assert.equal(patterns.tab, true); }); test("route files are ignored for a black-box exploration", () => { const screens = [screen("a", "A", 0, { route: "/" })]; const tree = screenTree(screens, indexMap(map({ screens, exploration: "black-box" })), new Map([["a", "app/index.tsx"]])); assert.equal(tree.mode, "observed"); }); test("a removed screen keeps the route folder of the surface it was in the baseline", () => { const screens = [ screen("home", "Home", 0, { route: "/" }), screen("removed:base-build:screen:old-settings", "Settings", 1, { route: "/settings" }), screen("baseline-context:base-build:screen:about", "About", 1, { route: "/about" }), ]; const routeFiles = new Map([ ["home", "src/app/(tabs)/index.tsx"], ["old-settings", "src/app/(tabs)/settings.tsx"], ["about", "src/app/about.tsx"], ]); const tree = screenTree(screens, indexMap(map({ screens })), routeFiles); assert.deepEqual(tree.sections.map((section) => [section.title, section.screenIds]), [ ["app/(tabs)", ["home", "removed:base-build:screen:old-settings"]], ["app", ["baseline-context:base-build:screen:about"]], ]); }); test("an observed screen sits under the screen it was first opened from, not the first one listed or the shallowest", () => { const screens = [ screen("alpha", "Alpha", 0), screen("zulu", "Zulu", 0), screen("branch", "Branch", 1), screen("shared", "Shared", 1), screen("deep", "Deep", 2), ]; const transitions = [ transition("t1", "zulu", "shared", 1), transition("t2", "alpha", "shared", 2), transition("t3", "alpha", "branch", 3), transition("t4", "branch", "deep", 4), transition("t5", "alpha", "deep", 5), ]; const tree = screenTree(screens, indexMap(map({ screens, transitions, exploration: "black-box" }))); assert.deepEqual(tree.sections.map((section) => [section.title, section.screenIds]), [ // Zulu opened Shared before Alpha did, though Alpha comes first in the list. ["Alpha", ["alpha", "branch", "deep"]], ["Zulu", ["zulu", "shared"]], ]); // Deep was opened from Branch before Alpha opened it directly. const alpha = tree.tree.find((node) => node.kind === "screen" && node.screenId === "alpha"); assert.deepEqual(alpha.children.map((child) => [child.screenId, child.children.map((grandchild) => grandchild.screenId)]), [["branch", ["deep"]]]); }); test("a route folder reads as words: route groups and dynamic segments lose their brackets", async () => { const { folderName } = await tsImport("../../viewer/src/utils/screen-tree.ts", import.meta.url); assert.equal(folderName("(tabs)/(settings)"), "Settings"); assert.equal(folderName("(tabs)/(patterns)/p"), "P"); assert.equal(folderName("lane-editor"), "Lane editor"); assert.equal(folderName("c/[channel]"), "Channel"); assert.equal(folderName("docs/[...slug]"), "Slug"); assert.equal(folderName(""), "Top level"); }); test("a source section is named after its folder, with the parent in front when two names match", () => { const screens = [ screen("settings", "Settings", 1, { route: "/settings" }), screen("a", "A", 1, { route: "/a/p" }), screen("b", "B", 1, { route: "/b/p" }), screen("tempo", "Tempo", 1, { route: "/tempo" }), ]; const routeFiles = new Map([ ["settings", "app/(tabs)/(settings)/settings.tsx"], ["a", "app/(alpha)/p/index.tsx"], ["b", "app/(beta)/p/index.tsx"], ["tempo", "app/tempo.tsx"], ]); const tree = screenTree(screens, indexMap(map({ screens })), routeFiles); assert.deepEqual(tree.sections.map((section) => [section.name, section.title]), [ ["Alpha · P", "app/(alpha)/p"], ["Beta · P", "app/(beta)/p"], ["Settings", "app/(tabs)/(settings)"], ["Top level", "app"], ]); }); test("the tree index lists foldable rows, the screens inside each, and the rows above each screen", async () => { const { treeIndex } = await tsImport("../../viewer/src/utils/screen-tree.ts", import.meta.url); const screens = [ screen("home", "Home", 0, { route: "/" }), screen("patterns", "Patterns", 1, { route: "/patterns" }), screen("detail", "Detail", 2, { route: "/p/[d]" }), screen("tempo", "Tempo", 1, { route: "/tempo" }), ]; const routeFiles = new Map([ ["home", "app/(tabs)/(sequencer)/index.tsx"], ["patterns", "app/(tabs)/(patterns)/patterns.tsx"], ["detail", "app/(tabs)/(patterns)/p/[d].tsx"], ["tempo", "app/tempo.tsx"], ]); const shape = treeIndex(screenTree(screens, indexMap(map({ screens })), routeFiles).tree); assert.deepEqual(shape.keys, ["folder:(tabs)", "folder:(tabs)/(sequencer)", "folder:(tabs)/(patterns)", "folder:(tabs)/(patterns)/p"]); assert.deepEqual(shape.screensUnder.get("folder:(tabs)"), ["home", "patterns", "detail"]); assert.deepEqual(shape.ancestors.get("detail"), ["folder:(tabs)", "folder:(tabs)/(patterns)", "folder:(tabs)/(patterns)/p"]); assert.deepEqual(shape.ancestors.get("tempo"), []); assert.equal(shape.parents.get("folder:(tabs)/(patterns)/p"), "folder:(tabs)/(patterns)"); assert.equal(shape.parents.has("folder:(tabs)"), false); }); test("in a black-box tree, a screen with screens opened from it folds under its own key", async () => { const { treeIndex } = await tsImport("../../viewer/src/utils/screen-tree.ts", import.meta.url); const screens = [screen("seq", "Sequencer", 0), screen("new", "New Pattern", 1)]; const transitions = [transition("t1", "seq", "new", 1)]; const shape = treeIndex(screenTree(screens, indexMap(map({ screens, transitions }))).tree); assert.deepEqual(shape.keys, ["screen:seq"]); assert.deepEqual(shape.screensUnder.get("screen:seq"), ["new"]); assert.deepEqual(shape.ancestors.get("new"), ["screen:seq"]); });