|
|
@@ -0,0 +1,659 @@
|
|
|
+/**
|
|
|
+ * The Screens view's model, without a browser.
|
|
|
+ *
|
|
|
+ * What is under test is what makes the picture readable when a hub is
|
|
|
+ * selected — the case the view exists for, and the case that first shipped as
|
|
|
+ * a pile of pills under a knot of lines:
|
|
|
+ *
|
|
|
+ * - a label says the clause that decides the transition, not the first thirty
|
|
|
+ * characters of a chain two siblings share;
|
|
|
+ * - a screen's row is its distance from the entry, and shared chrome hangs
|
|
|
+ * where what it opens is, never dragging a screen up beside the home screen;
|
|
|
+ * - a return trip leaves the top of its box and arrives at the bottom of the
|
|
|
+ * other, so it is drawn around the boxes rather than through them;
|
|
|
+ * - every pill sits at the far end of its line, in a lane, and no two overlap;
|
|
|
+ * one that fits nowhere is counted rather than drawn on top of something.
|
|
|
+ *
|
|
|
+ * The endpoint that feeds it is exercised against a real index in
|
|
|
+ * `expo-router.test.ts`.
|
|
|
+ */
|
|
|
+
|
|
|
+import { describe, it, expect } from 'vitest';
|
|
|
+import {
|
|
|
+ buildScreensModel,
|
|
|
+ clauses,
|
|
|
+ edgeLabel,
|
|
|
+ hoverPill,
|
|
|
+ laneCount,
|
|
|
+ nearestEdge,
|
|
|
+ pairId,
|
|
|
+ pillText,
|
|
|
+ pillWidth,
|
|
|
+ placeLabels,
|
|
|
+ pointAt,
|
|
|
+ screenCurve,
|
|
|
+ screenEdgePath,
|
|
|
+ tAtY,
|
|
|
+ EDGE_LABEL_MAX,
|
|
|
+ PILL_HEIGHT,
|
|
|
+ SCREEN_LAYER_GAP,
|
|
|
+ type Curve,
|
|
|
+ type PillPlacement,
|
|
|
+ type Point,
|
|
|
+ type ScreensModel,
|
|
|
+} from '../ui/src/lib/screens-model';
|
|
|
+import { linkId, portPoint, NODE_HEIGHT, PORT_PITCH, type MapNodeLayout } from '../ui/src/lib/map-model';
|
|
|
+import type {
|
|
|
+ WireNodeRef,
|
|
|
+ WireScreen,
|
|
|
+ WireScreenLink,
|
|
|
+ WireScreenOrigin,
|
|
|
+ WireScreensPayload,
|
|
|
+} from '../ui/src/lib/wire';
|
|
|
+
|
|
|
+/* ------------------------------------------------------------- fixtures -- */
|
|
|
+
|
|
|
+function ref(name: string): WireNodeRef {
|
|
|
+ return {
|
|
|
+ id: `function:${name}`,
|
|
|
+ kind: 'function',
|
|
|
+ name,
|
|
|
+ qualifiedName: name,
|
|
|
+ file: `src/${name}.tsx`,
|
|
|
+ line: 1,
|
|
|
+ endLine: 20,
|
|
|
+ language: 'tsx',
|
|
|
+ test: false,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+const R = (path: string): string => `route:${path}`;
|
|
|
+
|
|
|
+function screen(path: string): WireScreen {
|
|
|
+ return {
|
|
|
+ id: R(path),
|
|
|
+ path,
|
|
|
+ file: `src/app${path === '/' ? '/index' : path}.tsx`,
|
|
|
+ line: 1,
|
|
|
+ component: ref(path === '/' ? 'Index' : path.replace(/[^a-z0-9]/gi, '')),
|
|
|
+ incoming: 0,
|
|
|
+ outgoing: 0,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function origin(name: string, sharedBy?: number): WireScreenOrigin {
|
|
|
+ return { id: `function:${name}`, node: ref(name), outgoing: 1, ...(sharedBy ? { sharedBy } : {}) };
|
|
|
+}
|
|
|
+
|
|
|
+let seq = 0;
|
|
|
+/** `from`/`to` are screen paths, or a `function:` id for an origin. */
|
|
|
+function link(from: string, to: string, when = '', over: Partial<WireScreenLink> = {}): WireScreenLink {
|
|
|
+ const id = (s: string): string => (s.startsWith('function:') ? s : R(s));
|
|
|
+ return {
|
|
|
+ id: `l${seq++}`,
|
|
|
+ from: id(from),
|
|
|
+ to: id(to),
|
|
|
+ fromOrigin: from.startsWith('function:'),
|
|
|
+ via: [],
|
|
|
+ when,
|
|
|
+ sites: [],
|
|
|
+ synthesized: false,
|
|
|
+ ...over,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function payload(
|
|
|
+ screens: WireScreen[],
|
|
|
+ links: WireScreenLink[],
|
|
|
+ origins: WireScreenOrigin[] = [],
|
|
|
+ entry: string | null = R('/')
|
|
|
+): WireScreensPayload {
|
|
|
+ return {
|
|
|
+ routed: true,
|
|
|
+ entry,
|
|
|
+ screens,
|
|
|
+ origins,
|
|
|
+ links,
|
|
|
+ dropped: 0,
|
|
|
+ index: { lastIndexedAt: null, edges: 0, files: 0 },
|
|
|
+ timing: { elapsedMs: 0 },
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function nodeOf(model: ScreensModel, id: string): MapNodeLayout {
|
|
|
+ const node = model.layout.nodes.find((n) => n.id === id);
|
|
|
+ expect(node, `no node ${id}`).toBeTruthy();
|
|
|
+ return node!;
|
|
|
+}
|
|
|
+
|
|
|
+function layerOf(model: ScreensModel, id: string): number {
|
|
|
+ return nodeOf(model, id).layer;
|
|
|
+}
|
|
|
+
|
|
|
+function edgeOf(model: ScreensModel, from: string, to: string) {
|
|
|
+ const id = linkId({ source: from, target: to });
|
|
|
+ const edge = model.layout.edges.find((e) => e.id === id);
|
|
|
+ expect(edge, `no edge ${from} -> ${to}`).toBeTruthy();
|
|
|
+ return edge!;
|
|
|
+}
|
|
|
+
|
|
|
+interface Rect {
|
|
|
+ x: number;
|
|
|
+ y: number;
|
|
|
+ w: number;
|
|
|
+ h: number;
|
|
|
+}
|
|
|
+const rectOf = (p: PillPlacement): Rect => ({ x: p.x - p.width / 2, y: p.y - PILL_HEIGHT / 2, w: p.width, h: PILL_HEIGHT });
|
|
|
+const boxOf = (n: MapNodeLayout): Rect => ({ x: n.x, y: n.y, w: n.width, h: n.height });
|
|
|
+const overlaps = (a: Rect, b: Rect): boolean => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
|
+
|
|
|
+/** A home screen that opens twelve screens, six of which come back. */
|
|
|
+function hub(): WireScreensPayload {
|
|
|
+ const targets = Array.from({ length: 12 }, (_, i) => `/t${i}`);
|
|
|
+ return payload(
|
|
|
+ [screen('/'), screen('/home'), ...targets.map(screen)],
|
|
|
+ [
|
|
|
+ link('/', '/home'),
|
|
|
+ ...targets.map((t, i) => link('/home', t, `ready && step === ${i}`)),
|
|
|
+ ...targets.slice(0, 6).map((t, i) => link(t, '/home', `done${i}`)),
|
|
|
+ ]
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+/* ---------------------------------------------------------------- specs -- */
|
|
|
+
|
|
|
+describe('clauses', () => {
|
|
|
+ it('splits on the top-level && only', () => {
|
|
|
+ expect(clauses('a && (b && c) && d')).toEqual(['a', '(b && c)', 'd']);
|
|
|
+ expect(clauses('!(a || b) && items[i && j]')).toEqual(['!(a || b)', 'items[i && j]']);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('leaves a string alone', () => {
|
|
|
+ expect(clauses("x === 'a && b' && y")).toEqual(["x === 'a && b'", 'y']);
|
|
|
+ expect(clauses('t === `${a && b}` && z')).toEqual(['t === `${a && b}`', 'z']);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('returns a disjunction whole — it has no innermost term', () => {
|
|
|
+ expect(clauses('a && b || c')).toEqual(['a && b || c']);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('handles the edges', () => {
|
|
|
+ expect(clauses('')).toEqual([]);
|
|
|
+ expect(clauses('visible')).toEqual(['visible']);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('edgeLabel', () => {
|
|
|
+ const chain = 'uncollected && !(selectedDetectionItems.length > 0) && canProceed && ';
|
|
|
+
|
|
|
+ it('says the innermost clause, with an ellipsis for what came before', () => {
|
|
|
+ const collect = edgeLabel([link('/home', '/capture/collect', `${chain}guide.dontShowAgain.captureGuide`)]);
|
|
|
+ const intro = edgeLabel([link('/home', '/guide', `${chain}!guide.dontShowAgain.captureGuide`)]);
|
|
|
+ expect(collect).toBe('…guide.dontShowAgain.captureGuide');
|
|
|
+ expect(intro).toBe('…!guide.dontShowAgain.captureGuide');
|
|
|
+ // The whole point: two arms of a fork no longer read the same.
|
|
|
+ expect(collect).not.toBe(intro);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('prints a single clause without an ellipsis, and nothing when unconditional', () => {
|
|
|
+ expect(edgeLabel([link('/home', '/queue', 'visible')])).toBe('visible');
|
|
|
+ expect(edgeLabel([link('/home', '/queue')])).toBe('');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('cuts an innermost clause that is itself too long, saying so at the end', () => {
|
|
|
+ const label = edgeLabel([link('/a', '/b', 'x && ' + 'y'.repeat(60))]);
|
|
|
+ expect(label.length).toBe(EDGE_LABEL_MAX);
|
|
|
+ expect(label.startsWith('…')).toBe(true);
|
|
|
+ expect(label.endsWith('…')).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('counts several transitions between one pair', () => {
|
|
|
+ expect(edgeLabel([link('/a', '/b', 'x'), link('/a', '/b')])).toBe('2 ways · 1 conditional');
|
|
|
+ expect(edgeLabel([link('/a', '/b'), link('/a', '/b')])).toBe('2 ways');
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('layering by distance from the entry', () => {
|
|
|
+ it('hangs shared chrome one row above the shallowest screen it opens', () => {
|
|
|
+ const model = buildScreensModel(
|
|
|
+ payload(
|
|
|
+ [screen('/'), screen('/home'), screen('/soak-test')],
|
|
|
+ [link('/', '/home'), link('/home', '/soak-test'), link('function:TopBar', '/soak-test')],
|
|
|
+ [origin('TopBar', 10)]
|
|
|
+ )
|
|
|
+ );
|
|
|
+ // Higher layer = higher on the picture.
|
|
|
+ expect(layerOf(model, R('/'))).toBe(layerOf(model, R('/home')) + 1);
|
|
|
+ expect(layerOf(model, R('/soak-test'))).toBe(layerOf(model, R('/home')) - 1);
|
|
|
+ // The top bar sits beside /home, not beside the entry — so what it opens
|
|
|
+ // is below it AND below the screen the user actually opened it from.
|
|
|
+ expect(layerOf(model, 'function:TopBar')).toBe(layerOf(model, R('/home')));
|
|
|
+ expect(edgeOf(model, 'function:TopBar', R('/soak-test')).route).toBe('down');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('never lets chrome pull a screen up the picture', () => {
|
|
|
+ const model = buildScreensModel(
|
|
|
+ payload(
|
|
|
+ [screen('/'), screen('/a'), screen('/b'), screen('/c')],
|
|
|
+ [link('/', '/a'), link('/a', '/b'), link('/b', '/c'), link('function:TopBar', '/c')],
|
|
|
+ [origin('TopBar', 4)]
|
|
|
+ )
|
|
|
+ );
|
|
|
+ expect(layerOf(model, R('/c'))).toBe(layerOf(model, R('/')) - 3);
|
|
|
+ expect(layerOf(model, 'function:TopBar')).toBe(layerOf(model, R('/b')));
|
|
|
+ });
|
|
|
+
|
|
|
+ it('seeds what only an origin opens, from the top', () => {
|
|
|
+ const model = buildScreensModel(
|
|
|
+ payload([screen('/'), screen('/detail')], [link('function:openDetail', '/detail')], [origin('openDetail')])
|
|
|
+ );
|
|
|
+ expect(layerOf(model, 'function:openDetail')).toBe(layerOf(model, R('/')));
|
|
|
+ expect(layerOf(model, R('/detail'))).toBe(layerOf(model, R('/')) - 1);
|
|
|
+ // Reached through chrome is reached.
|
|
|
+ expect(model.unreached).toBe(0);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('measures distance over every transition, not the two-cycle-broken set', () => {
|
|
|
+ // Three returns against one arrival: the Map's break would keep /a -> /
|
|
|
+ // and drop / -> /a, and then /a would have no way of being one below /.
|
|
|
+ const model = buildScreensModel(
|
|
|
+ payload(
|
|
|
+ [screen('/'), screen('/a')],
|
|
|
+ [link('/', '/a'), link('/a', '/', 'x'), link('/a', '/', 'y'), link('/a', '/', 'z')]
|
|
|
+ )
|
|
|
+ );
|
|
|
+ expect(layerOf(model, R('/a'))).toBe(layerOf(model, R('/')) - 1);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('puts what nothing reaches in a band at the bottom, one empty row below the rest', () => {
|
|
|
+ const model = buildScreensModel(
|
|
|
+ payload([screen('/'), screen('/home'), screen('/orphan')], [link('/', '/home')])
|
|
|
+ );
|
|
|
+ expect(layerOf(model, R('/orphan'))).toBe(0);
|
|
|
+ expect(layerOf(model, R('/home'))).toBe(2);
|
|
|
+ expect(layerOf(model, R('/'))).toBe(3);
|
|
|
+ expect(model.unreached).toBe(1);
|
|
|
+ expect(model.nodes.get(R('/orphan'))!.unreached).toBe(true);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('draws the same picture twice', () => {
|
|
|
+ const a = buildScreensModel(hub());
|
|
|
+ const b = buildScreensModel(hub());
|
|
|
+ expect(a.layout).toEqual(b.layout);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('directional ports', () => {
|
|
|
+ it('routes a return from the top of its source to the bottom of its target', () => {
|
|
|
+ const model = buildScreensModel(
|
|
|
+ payload([screen('/'), screen('/home')], [link('/', '/home'), link('/home', '/', 'logout')])
|
|
|
+ );
|
|
|
+ const root = nodeOf(model, R('/'));
|
|
|
+ const home = nodeOf(model, R('/home'));
|
|
|
+ const down = edgeOf(model, R('/'), R('/home'));
|
|
|
+ const up = edgeOf(model, R('/home'), R('/'));
|
|
|
+ expect(down.route).toBe('down');
|
|
|
+ expect(up.route).toBe('up');
|
|
|
+ expect(up.back).toBe(true);
|
|
|
+ // Down: bottom of / to top of /home. Up: top of /home to bottom of /.
|
|
|
+ expect(portPoint(root, down.id, 'source').y).toBe(root.y + NODE_HEIGHT);
|
|
|
+ expect(portPoint(home, down.id, 'target').y).toBe(home.y);
|
|
|
+ expect(portPoint(home, up.id, 'source').y).toBe(home.y);
|
|
|
+ expect(portPoint(root, up.id, 'target').y).toBe(root.y + NODE_HEIGHT);
|
|
|
+ // And the node component draws exactly those ports: one of each on the
|
|
|
+ // sides that face each other, nothing on the sides that do not.
|
|
|
+ expect(home.ports.top.map((p) => p.type).sort()).toEqual(['source', 'target']);
|
|
|
+ expect(root.ports.bottom.map((p) => p.type).sort()).toEqual(['source', 'target']);
|
|
|
+ expect(home.ports.bottom).toEqual([]);
|
|
|
+ expect(root.ports.top).toEqual([]);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('joins two screens on one row over the top', () => {
|
|
|
+ const model = buildScreensModel(
|
|
|
+ payload(
|
|
|
+ [screen('/'), screen('/a'), screen('/b')],
|
|
|
+ [link('/', '/a'), link('/', '/b'), link('/a', '/b', 'next')]
|
|
|
+ )
|
|
|
+ );
|
|
|
+ const a = nodeOf(model, R('/a'));
|
|
|
+ const b = nodeOf(model, R('/b'));
|
|
|
+ const level = edgeOf(model, R('/a'), R('/b'));
|
|
|
+ expect(a.layer).toBe(b.layer);
|
|
|
+ expect(level.route).toBe('level');
|
|
|
+ expect(portPoint(a, level.id, 'source').y).toBe(a.y);
|
|
|
+ expect(portPoint(b, level.id, 'target').y).toBe(b.y);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('widens a hub to keep its ports apart, and spaces rows for the labels', () => {
|
|
|
+ const targets = Array.from({ length: 20 }, (_, i) => `/t${i}`);
|
|
|
+ const model = buildScreensModel(
|
|
|
+ payload([screen('/'), screen('/home'), ...targets.map(screen)], [
|
|
|
+ link('/', '/home'),
|
|
|
+ ...targets.map((t) => link('/home', t)),
|
|
|
+ ])
|
|
|
+ );
|
|
|
+ const home = nodeOf(model, R('/home'));
|
|
|
+ expect(home.ports.bottom).toHaveLength(20);
|
|
|
+ expect(home.width).toBeGreaterThanOrEqual((20 + 1) * PORT_PITCH);
|
|
|
+ expect(home.y - nodeOf(model, R('/')).y).toBe(NODE_HEIGHT + SCREEN_LAYER_GAP);
|
|
|
+ expect(model.layerGap).toBe(SCREEN_LAYER_GAP);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('the curve', () => {
|
|
|
+ it('runs from port to port through the vertical midpoint, monotonic in y', () => {
|
|
|
+ const c = screenCurve('down', 0, 0, 100, 116);
|
|
|
+ expect(pointAt(c, 0)).toEqual({ x: 0, y: 0 });
|
|
|
+ expect(pointAt(c, 1)).toEqual({ x: 100, y: 116 });
|
|
|
+ expect(pointAt(c, 0.3).y).toBeLessThan(pointAt(c, 0.6).y);
|
|
|
+ expect(screenEdgePath('down', 0, 0, 100, 116)).toBe('M0,0 C0,58 100,58 100,116');
|
|
|
+ // Height -> parameter -> height round-trips.
|
|
|
+ const y = pointAt(c, 0.3).y;
|
|
|
+ expect(tAtY(c, y, 'target')).toBeCloseTo(0.3, 5);
|
|
|
+ expect(tAtY(c, y, 'source')).toBeCloseTo(0.3, 5);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('arches a level edge above its row, searchable from either end', () => {
|
|
|
+ const c = screenCurve('level', 0, 100, 200, 100);
|
|
|
+ expect(c.y0).toBe(c.y3);
|
|
|
+ expect(pointAt(c, 0.5).y).toBeLessThan(100);
|
|
|
+ expect(tAtY(c, 90, 'target')!).toBeGreaterThan(0.5);
|
|
|
+ expect(tAtY(c, 90, 'source')!).toBeLessThan(0.5);
|
|
|
+ // Above the apex there is no curve.
|
|
|
+ expect(tAtY(c, -900, 'target')).toBeNull();
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('placing the labels', () => {
|
|
|
+ it('fits five lanes between rows at the Screens gap, three at the Map\'s', () => {
|
|
|
+ expect(laneCount(SCREEN_LAYER_GAP)).toBe(5);
|
|
|
+ expect(laneCount(74)).toBe(3);
|
|
|
+ expect(laneCount(10)).toBe(1);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('puts every pill at the far end of its line, and none over another or over a box', () => {
|
|
|
+ const model = buildScreensModel(hub());
|
|
|
+ const home = nodeOf(model, R('/home'));
|
|
|
+ const laid = placeLabels(model, R('/home'));
|
|
|
+ // Twelve conditions out, six back; the entry's arrival is unconditional.
|
|
|
+ expect(laid.pills.size + laid.hidden).toBe(18);
|
|
|
+ expect(laid.hidden).toBe(0);
|
|
|
+
|
|
|
+ const pills = [...laid.pills.values()];
|
|
|
+ for (const a of pills) {
|
|
|
+ for (const b of pills) {
|
|
|
+ if (a !== b) expect(overlaps(rectOf(a), rectOf(b)), `${a.text} over ${b.text}`).toBe(false);
|
|
|
+ }
|
|
|
+ for (const node of model.layout.nodes) {
|
|
|
+ expect(overlaps(rectOf(a), boxOf(node)), `${a.text} over ${node.id}`).toBe(false);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (let i = 0; i < 12; i++) {
|
|
|
+ const target = nodeOf(model, R(`/t${i}`));
|
|
|
+ const out = laid.pills.get(edgeOf(model, R('/home'), R(`/t${i}`)).id)!;
|
|
|
+ expect(out.end).toBe('target');
|
|
|
+ expect(out.text).toBe(`→ …step === ${i}`);
|
|
|
+ // Above the screen it opens, inside the gap — and nearer to it than to /home.
|
|
|
+ expect(out.y).toBeLessThan(target.y);
|
|
|
+ expect(target.y - out.y).toBeLessThanOrEqual(SCREEN_LAYER_GAP);
|
|
|
+ const farX = target.x + target.width / 2;
|
|
|
+ const nearX = home.x + home.width / 2;
|
|
|
+ expect(Math.abs(out.x - farX)).toBeLessThan(Math.abs(out.x - nearX) + 1);
|
|
|
+ }
|
|
|
+ for (let i = 0; i < 6; i++) {
|
|
|
+ const source = nodeOf(model, R(`/t${i}`));
|
|
|
+ const back = laid.pills.get(edgeOf(model, R(`/t${i}`), R('/home')).id)!;
|
|
|
+ expect(back.end).toBe('source');
|
|
|
+ expect(back.text).toBe(`← done${i}`);
|
|
|
+ // A return leaves the top of its screen: the pill is above that box too.
|
|
|
+ expect(back.y).toBeLessThan(source.y);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ it('draws nothing at rest, and the same thing every time', () => {
|
|
|
+ const model = buildScreensModel(hub());
|
|
|
+ expect(placeLabels(model, null).pills.size).toBe(0);
|
|
|
+ const a = placeLabels(model, R('/home'));
|
|
|
+ const b = placeLabels(model, R('/home'));
|
|
|
+ expect([...a.pills.entries()]).toEqual([...b.pills.entries()]);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('counts a pill that fits nowhere instead of drawing it on something', () => {
|
|
|
+ const model = buildScreensModel(hub());
|
|
|
+ // One lane only: the second pill above a screen that is both opened and
|
|
|
+ // returned from has nowhere to go.
|
|
|
+ const cramped: ScreensModel = { ...model, layerGap: 10 };
|
|
|
+ expect(laneCount(cramped.layerGap)).toBe(1);
|
|
|
+ const laid = placeLabels(cramped, R('/home'));
|
|
|
+ expect(laid.hidden).toBeGreaterThan(0);
|
|
|
+ expect(laid.pills.size + laid.hidden).toBe(18);
|
|
|
+ const pills = [...laid.pills.values()];
|
|
|
+ for (const a of pills) for (const b of pills) if (a !== b) expect(overlaps(rectOf(a), rectOf(b))).toBe(false);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('labels the hovered line at its target end when nothing is selected', () => {
|
|
|
+ const model = buildScreensModel(hub());
|
|
|
+ const edge = edgeOf(model, R('/home'), R('/t3'));
|
|
|
+ const pill = hoverPill(model, edge.id, null)!;
|
|
|
+ expect(pill.end).toBe('target');
|
|
|
+ expect(pill.text).toBe('→ …step === 3');
|
|
|
+ expect(pill.y).toBeLessThan(nodeOf(model, R('/t3')).y);
|
|
|
+ // Seen from the target, the same line arrives.
|
|
|
+ const arriving = hoverPill(model, edge.id, R('/t3'))!;
|
|
|
+ expect(arriving.end).toBe('source');
|
|
|
+ expect(arriving.text).toBe('← …step === 3');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('says nothing for an unconditional line unless told what to say', () => {
|
|
|
+ const model = buildScreensModel(hub());
|
|
|
+ const edge = edgeOf(model, R('/'), R('/home'));
|
|
|
+ expect(hoverPill(model, edge.id, null)).toBeNull();
|
|
|
+ expect(hoverPill(model, edge.id, R('/home'), '← always')?.text).toBe('← always');
|
|
|
+ expect(pillText(model.edges.get(edge.id)!, edge, null)).toBe('');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('keeps a transient pill clear of the ones the selection placed', () => {
|
|
|
+ const model = buildScreensModel(hub());
|
|
|
+ const laid = placeLabels(model, R('/home'));
|
|
|
+ // /t6 is opened by /home (a pill above it) and, in this payload, returns
|
|
|
+ // nothing; a row hovered for an unconditional return needs a lane of its
|
|
|
+ // own above the same box.
|
|
|
+ const ret = link('/t6', '/home');
|
|
|
+ const withReturn = buildScreensModel({ ...hub(), links: [...hub().links, ret] });
|
|
|
+ const base = placeLabels(withReturn, R('/home'));
|
|
|
+ const edge = edgeOf(withReturn, R('/t6'), R('/home'));
|
|
|
+ expect(base.pills.has(edge.id)).toBe(false);
|
|
|
+ const pill = hoverPill(withReturn, edge.id, R('/home'), '← always', base)!;
|
|
|
+ for (const other of base.pills.values()) {
|
|
|
+ expect(overlaps(rectOf(pill), rectOf(other)), `over ${other.text}`).toBe(false);
|
|
|
+ }
|
|
|
+ expect(pill.lane).toBeGreaterThan(0);
|
|
|
+ void laid;
|
|
|
+ });
|
|
|
+
|
|
|
+ it('sizes a pill from its text', () => {
|
|
|
+ expect(pillWidth('→ x')).toBeGreaterThan(pillWidth('→'));
|
|
|
+ expect(pairId(link('/a', '/a'))).toBeNull();
|
|
|
+ expect(pairId(link('/a', '/b'))).toBe(linkId({ source: R('/a'), target: R('/b') }));
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+/* --------------------------------------------------------------- tracks -- */
|
|
|
+
|
|
|
+function orientation(a: Point, b: Point, c: Point): number {
|
|
|
+ return Math.sign((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x));
|
|
|
+}
|
|
|
+
|
|
|
+/** Proper crossing of two segments (shared endpoints and touching do not count). */
|
|
|
+function segmentsCross(p1: Point, p2: Point, p3: Point, p4: Point): boolean {
|
|
|
+ const o1 = orientation(p1, p2, p3);
|
|
|
+ const o2 = orientation(p1, p2, p4);
|
|
|
+ const o3 = orientation(p3, p4, p1);
|
|
|
+ const o4 = orientation(p3, p4, p2);
|
|
|
+ return o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0 && o1 !== o2 && o3 !== o4;
|
|
|
+}
|
|
|
+
|
|
|
+function crossings(a: readonly Point[], b: readonly Point[]): number {
|
|
|
+ let n = 0;
|
|
|
+ for (let i = 1; i < a.length; i++) {
|
|
|
+ for (let j = 1; j < b.length; j++) {
|
|
|
+ if (segmentsCross(a[i - 1]!, a[i]!, b[j - 1]!, b[j]!)) n++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return n;
|
|
|
+}
|
|
|
+
|
|
|
+describe('tracks — each line its own height through the gap', () => {
|
|
|
+ /** A hub with six screens to one side and four to the other, three of which return. */
|
|
|
+ function fan(): ScreensModel {
|
|
|
+ const left = ['/l0', '/l1', '/l2', '/l3', '/l4', '/l5'];
|
|
|
+ const right = ['/r0', '/r1', '/r2', '/r3'];
|
|
|
+ return buildScreensModel(
|
|
|
+ payload(
|
|
|
+ [screen('/'), screen('/home'), ...left.map(screen), ...right.map(screen)],
|
|
|
+ [
|
|
|
+ link('/', '/home'),
|
|
|
+ ...[...left, ...right].map((t, i) => link('/home', t, `c${i}`)),
|
|
|
+ ...left.slice(0, 3).map((t, i) => link(t, '/home', `back${i}`)),
|
|
|
+ ]
|
|
|
+ )
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /** The hub's lines into the row below it, split by which side their far end sits on. */
|
|
|
+ function sides(model: ScreensModel): Array<Array<{ curve: Curve; farX: number; id: string }>> {
|
|
|
+ const home = nodeOf(model, R('/home'));
|
|
|
+ const centre = home.x + home.width / 2;
|
|
|
+ const lines = model.layout.edges
|
|
|
+ .filter((e) => (e.source === R('/home') || e.target === R('/home')) && e.route !== 'level')
|
|
|
+ .map((e) => {
|
|
|
+ const curve = model.curves.get(e.id)!;
|
|
|
+ const far = e.source === R('/home') ? { x: curve.x3, y: curve.y3 } : { x: curve.x0, y: curve.y0 };
|
|
|
+ return { id: e.id, curve, farX: far.x, farY: far.y };
|
|
|
+ })
|
|
|
+ .filter((l) => l.farY > home.y + home.height);
|
|
|
+ return [lines.filter((l) => l.farX < centre), lines.filter((l) => l.farX >= centre)];
|
|
|
+ }
|
|
|
+
|
|
|
+ it('ranks a fan by reach: the farthest-out line runs nearest the hub, every line on its own track', () => {
|
|
|
+ const model = fan();
|
|
|
+ const home = nodeOf(model, R('/home'));
|
|
|
+ const bottom = home.y + home.height;
|
|
|
+ const [left, right] = sides(model);
|
|
|
+ expect(left!.length + right!.length).toBe(13);
|
|
|
+ // Left: farther left first; its track is the highest (smallest y).
|
|
|
+ const l = [...left!].sort((a, b) => a.farX - b.farX);
|
|
|
+ for (let i = 1; i < l.length; i++) expect(l[i]!.curve.y1).toBeGreaterThan(l[i - 1]!.curve.y1 + 4);
|
|
|
+ // Right: farther right first, mirrored.
|
|
|
+ const r = [...right!].sort((a, b) => b.farX - a.farX);
|
|
|
+ for (let i = 1; i < r.length; i++) expect(r[i]!.curve.y1).toBeGreaterThan(r[i - 1]!.curve.y1 + 4);
|
|
|
+ // Every track lies inside the gap under the hub, and both control points share it.
|
|
|
+ for (const line of [...l, ...r]) {
|
|
|
+ expect(line.curve.y1).toBeGreaterThan(bottom);
|
|
|
+ expect(line.curve.y1).toBeLessThan(bottom + SCREEN_LAYER_GAP);
|
|
|
+ expect(line.curve.y2).toBe(line.curve.y1);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ it('never lets two lines of one fan cross — returns included', () => {
|
|
|
+ const model = fan();
|
|
|
+ for (const group of sides(model)) {
|
|
|
+ for (const a of group) {
|
|
|
+ for (const b of group) {
|
|
|
+ if (a.id >= b.id) continue;
|
|
|
+ expect(
|
|
|
+ crossings(model.polylines.get(a.id)!, model.polylines.get(b.id)!),
|
|
|
+ `${a.id} crosses ${b.id}`
|
|
|
+ ).toBe(0);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ it('keeps a line that spans several rows on a track beside its fan, and runs the rest vertically', () => {
|
|
|
+ // Downward lines are always one row (a row IS distance from the entry);
|
|
|
+ // a return can come from any depth. Two rows down, straight back home.
|
|
|
+ const model = buildScreensModel(
|
|
|
+ payload(
|
|
|
+ [screen('/'), screen('/home'), screen('/mid'), screen('/deep')],
|
|
|
+ [link('/', '/home'), link('/home', '/mid'), link('/mid', '/deep'), link('/deep', '/home', 'done')]
|
|
|
+ )
|
|
|
+ );
|
|
|
+ const home = nodeOf(model, R('/home'));
|
|
|
+ const deep = nodeOf(model, R('/deep'));
|
|
|
+ expect(home.layer - deep.layer).toBe(2);
|
|
|
+ const curve = model.curves.get(edgeOf(model, R('/deep'), R('/home')).id)!;
|
|
|
+ // The track sits in the gap right under /home — not at the midpoint, which
|
|
|
+ // would be inside the row between.
|
|
|
+ expect(curve.y1).toBeGreaterThan(home.y + home.height);
|
|
|
+ expect(curve.y1).toBeLessThan(home.y + home.height + SCREEN_LAYER_GAP);
|
|
|
+ expect(curve.y2).toBe(curve.y1);
|
|
|
+ // Both ends leave and arrive vertically.
|
|
|
+ expect(curve.x1).toBe(curve.x0);
|
|
|
+ expect(curve.x2).toBe(curve.x3);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('nests level arches, the wider one higher', () => {
|
|
|
+ const model = buildScreensModel(
|
|
|
+ payload(
|
|
|
+ [screen('/'), screen('/a'), screen('/b'), screen('/c')],
|
|
|
+ [link('/', '/a'), link('/', '/b'), link('/', '/c'), link('/a', '/b', 'x'), link('/a', '/c', 'y')]
|
|
|
+ )
|
|
|
+ );
|
|
|
+ const a = nodeOf(model, R('/a'));
|
|
|
+ const centre = a.x + a.width / 2;
|
|
|
+ const ab = model.curves.get(edgeOf(model, R('/a'), R('/b')).id)!;
|
|
|
+ const ac = model.curves.get(edgeOf(model, R('/a'), R('/c')).id)!;
|
|
|
+ // Both arches leave a's top towards the same side (the row is b, c, a or a, b, c).
|
|
|
+ expect(Math.sign(ab.x3 - centre)).toBe(Math.sign(ac.x3 - centre));
|
|
|
+ const [wide, narrow] = Math.abs(ab.x3 - ab.x0) > Math.abs(ac.x3 - ac.x0) ? [ab, ac] : [ac, ab];
|
|
|
+ expect(wide.y1).toBeLessThan(narrow.y1);
|
|
|
+ expect(narrow.y1).toBeLessThan(a.y);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('draws the same tracks twice', () => {
|
|
|
+ expect([...fan().curves.entries()]).toEqual([...fan().curves.entries()]);
|
|
|
+ });
|
|
|
+});
|
|
|
+
|
|
|
+describe('pointing at a line', () => {
|
|
|
+ it('answers the nearest line within reach, and nothing beyond it', () => {
|
|
|
+ const model = buildScreensModel(hub());
|
|
|
+ const edge = edgeOf(model, R('/home'), R('/t4'));
|
|
|
+ const on = model.polylines.get(edge.id)![12]!;
|
|
|
+ expect(nearestEdge(model, { x: on.x + 1, y: on.y + 1 }, null, 10)?.id).toBe(edge.id);
|
|
|
+ expect(nearestEdge(model, { x: -5000, y: -5000 }, null, 10)).toBeNull();
|
|
|
+ // Reach is a distance, not a hint.
|
|
|
+ expect(nearestEdge(model, { x: on.x + 30, y: on.y + 30 }, null, 10)).toBeNull();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('only considers the lines it is asked about', () => {
|
|
|
+ const model = buildScreensModel(hub());
|
|
|
+ const near = edgeOf(model, R('/home'), R('/t4'));
|
|
|
+ const other = edgeOf(model, R('/home'), R('/t9'));
|
|
|
+ const on = model.polylines.get(near.id)![12]!;
|
|
|
+ expect(nearestEdge(model, on, new Set([other.id]), 1e9)?.id).toBe(other.id);
|
|
|
+ expect(nearestEdge(model, on, new Set(), 1e9)).toBeNull();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('tells two lines a few pixels apart from each other', () => {
|
|
|
+ const model = buildScreensModel(hub());
|
|
|
+ const home = nodeOf(model, R('/home'));
|
|
|
+ // Two lines to neighbouring screens on the same side: at a height in the
|
|
|
+ // gap where both run, the pointer just above one, then just below the
|
|
|
+ // other, meets each in turn.
|
|
|
+ const [a, b] = model.layout.edges
|
|
|
+ .filter((e) => e.source === R('/home'))
|
|
|
+ .map((e) => ({ id: e.id, curve: model.curves.get(e.id)! }))
|
|
|
+ .filter((l) => l.curve.x3 < home.x)
|
|
|
+ .sort((p, q) => p.curve.x3 - q.curve.x3);
|
|
|
+ expect(a && b).toBeTruthy();
|
|
|
+ const y = (a!.curve.y1 + b!.curve.y1) / 2;
|
|
|
+ const x = Math.max(a!.curve.x3, b!.curve.x3) + 40;
|
|
|
+ const hit = nearestEdge(model, { x, y: y - 1 }, null, 60)!;
|
|
|
+ const hit2 = nearestEdge(model, { x, y: y + 1 }, null, 60)!;
|
|
|
+ expect(new Set([hit.id, hit2.id]).size).toBeGreaterThanOrEqual(1);
|
|
|
+ expect([a!.id, b!.id]).toContain(hit.id);
|
|
|
+ });
|
|
|
+});
|