| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659 |
- /**
- * 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('…NOT 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);
- });
- });
|