ui-screens-model.test.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659
  1. /**
  2. * The Screens view's model, without a browser.
  3. *
  4. * What is under test is what makes the picture readable when a hub is
  5. * selected — the case the view exists for, and the case that first shipped as
  6. * a pile of pills under a knot of lines:
  7. *
  8. * - a label says the clause that decides the transition, not the first thirty
  9. * characters of a chain two siblings share;
  10. * - a screen's row is its distance from the entry, and shared chrome hangs
  11. * where what it opens is, never dragging a screen up beside the home screen;
  12. * - a return trip leaves the top of its box and arrives at the bottom of the
  13. * other, so it is drawn around the boxes rather than through them;
  14. * - every pill sits at the far end of its line, in a lane, and no two overlap;
  15. * one that fits nowhere is counted rather than drawn on top of something.
  16. *
  17. * The endpoint that feeds it is exercised against a real index in
  18. * `expo-router.test.ts`.
  19. */
  20. import { describe, it, expect } from 'vitest';
  21. import {
  22. buildScreensModel,
  23. clauses,
  24. edgeLabel,
  25. hoverPill,
  26. laneCount,
  27. nearestEdge,
  28. pairId,
  29. pillText,
  30. pillWidth,
  31. placeLabels,
  32. pointAt,
  33. screenCurve,
  34. screenEdgePath,
  35. tAtY,
  36. EDGE_LABEL_MAX,
  37. PILL_HEIGHT,
  38. SCREEN_LAYER_GAP,
  39. type Curve,
  40. type PillPlacement,
  41. type Point,
  42. type ScreensModel,
  43. } from '../ui/src/lib/screens-model';
  44. import { linkId, portPoint, NODE_HEIGHT, PORT_PITCH, type MapNodeLayout } from '../ui/src/lib/map-model';
  45. import type {
  46. WireNodeRef,
  47. WireScreen,
  48. WireScreenLink,
  49. WireScreenOrigin,
  50. WireScreensPayload,
  51. } from '../ui/src/lib/wire';
  52. /* ------------------------------------------------------------- fixtures -- */
  53. function ref(name: string): WireNodeRef {
  54. return {
  55. id: `function:${name}`,
  56. kind: 'function',
  57. name,
  58. qualifiedName: name,
  59. file: `src/${name}.tsx`,
  60. line: 1,
  61. endLine: 20,
  62. language: 'tsx',
  63. test: false,
  64. };
  65. }
  66. const R = (path: string): string => `route:${path}`;
  67. function screen(path: string): WireScreen {
  68. return {
  69. id: R(path),
  70. path,
  71. file: `src/app${path === '/' ? '/index' : path}.tsx`,
  72. line: 1,
  73. component: ref(path === '/' ? 'Index' : path.replace(/[^a-z0-9]/gi, '')),
  74. incoming: 0,
  75. outgoing: 0,
  76. };
  77. }
  78. function origin(name: string, sharedBy?: number): WireScreenOrigin {
  79. return { id: `function:${name}`, node: ref(name), outgoing: 1, ...(sharedBy ? { sharedBy } : {}) };
  80. }
  81. let seq = 0;
  82. /** `from`/`to` are screen paths, or a `function:` id for an origin. */
  83. function link(from: string, to: string, when = '', over: Partial<WireScreenLink> = {}): WireScreenLink {
  84. const id = (s: string): string => (s.startsWith('function:') ? s : R(s));
  85. return {
  86. id: `l${seq++}`,
  87. from: id(from),
  88. to: id(to),
  89. fromOrigin: from.startsWith('function:'),
  90. via: [],
  91. when,
  92. sites: [],
  93. synthesized: false,
  94. ...over,
  95. };
  96. }
  97. function payload(
  98. screens: WireScreen[],
  99. links: WireScreenLink[],
  100. origins: WireScreenOrigin[] = [],
  101. entry: string | null = R('/')
  102. ): WireScreensPayload {
  103. return {
  104. routed: true,
  105. entry,
  106. screens,
  107. origins,
  108. links,
  109. dropped: 0,
  110. index: { lastIndexedAt: null, edges: 0, files: 0 },
  111. timing: { elapsedMs: 0 },
  112. };
  113. }
  114. function nodeOf(model: ScreensModel, id: string): MapNodeLayout {
  115. const node = model.layout.nodes.find((n) => n.id === id);
  116. expect(node, `no node ${id}`).toBeTruthy();
  117. return node!;
  118. }
  119. function layerOf(model: ScreensModel, id: string): number {
  120. return nodeOf(model, id).layer;
  121. }
  122. function edgeOf(model: ScreensModel, from: string, to: string) {
  123. const id = linkId({ source: from, target: to });
  124. const edge = model.layout.edges.find((e) => e.id === id);
  125. expect(edge, `no edge ${from} -> ${to}`).toBeTruthy();
  126. return edge!;
  127. }
  128. interface Rect {
  129. x: number;
  130. y: number;
  131. w: number;
  132. h: number;
  133. }
  134. const rectOf = (p: PillPlacement): Rect => ({ x: p.x - p.width / 2, y: p.y - PILL_HEIGHT / 2, w: p.width, h: PILL_HEIGHT });
  135. const boxOf = (n: MapNodeLayout): Rect => ({ x: n.x, y: n.y, w: n.width, h: n.height });
  136. 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;
  137. /** A home screen that opens twelve screens, six of which come back. */
  138. function hub(): WireScreensPayload {
  139. const targets = Array.from({ length: 12 }, (_, i) => `/t${i}`);
  140. return payload(
  141. [screen('/'), screen('/home'), ...targets.map(screen)],
  142. [
  143. link('/', '/home'),
  144. ...targets.map((t, i) => link('/home', t, `ready && step === ${i}`)),
  145. ...targets.slice(0, 6).map((t, i) => link(t, '/home', `done${i}`)),
  146. ]
  147. );
  148. }
  149. /* ---------------------------------------------------------------- specs -- */
  150. describe('clauses', () => {
  151. it('splits on the top-level && only', () => {
  152. expect(clauses('a && (b && c) && d')).toEqual(['a', '(b && c)', 'd']);
  153. expect(clauses('!(a || b) && items[i && j]')).toEqual(['!(a || b)', 'items[i && j]']);
  154. });
  155. it('leaves a string alone', () => {
  156. expect(clauses("x === 'a && b' && y")).toEqual(["x === 'a && b'", 'y']);
  157. expect(clauses('t === `${a && b}` && z')).toEqual(['t === `${a && b}`', 'z']);
  158. });
  159. it('returns a disjunction whole — it has no innermost term', () => {
  160. expect(clauses('a && b || c')).toEqual(['a && b || c']);
  161. });
  162. it('handles the edges', () => {
  163. expect(clauses('')).toEqual([]);
  164. expect(clauses('visible')).toEqual(['visible']);
  165. });
  166. });
  167. describe('edgeLabel', () => {
  168. const chain = 'uncollected && !(selectedDetectionItems.length > 0) && canProceed && ';
  169. it('says the innermost clause, with an ellipsis for what came before', () => {
  170. const collect = edgeLabel([link('/home', '/capture/collect', `${chain}guide.dontShowAgain.captureGuide`)]);
  171. const intro = edgeLabel([link('/home', '/guide', `${chain}!guide.dontShowAgain.captureGuide`)]);
  172. expect(collect).toBe('…guide.dontShowAgain.captureGuide');
  173. expect(intro).toBe('…NOT guide.dontShowAgain.captureGuide');
  174. // The whole point: two arms of a fork no longer read the same.
  175. expect(collect).not.toBe(intro);
  176. });
  177. it('prints a single clause without an ellipsis, and nothing when unconditional', () => {
  178. expect(edgeLabel([link('/home', '/queue', 'visible')])).toBe('visible');
  179. expect(edgeLabel([link('/home', '/queue')])).toBe('');
  180. });
  181. it('cuts an innermost clause that is itself too long, saying so at the end', () => {
  182. const label = edgeLabel([link('/a', '/b', 'x && ' + 'y'.repeat(60))]);
  183. expect(label.length).toBe(EDGE_LABEL_MAX);
  184. expect(label.startsWith('…')).toBe(true);
  185. expect(label.endsWith('…')).toBe(true);
  186. });
  187. it('counts several transitions between one pair', () => {
  188. expect(edgeLabel([link('/a', '/b', 'x'), link('/a', '/b')])).toBe('2 ways · 1 conditional');
  189. expect(edgeLabel([link('/a', '/b'), link('/a', '/b')])).toBe('2 ways');
  190. });
  191. });
  192. describe('layering by distance from the entry', () => {
  193. it('hangs shared chrome one row above the shallowest screen it opens', () => {
  194. const model = buildScreensModel(
  195. payload(
  196. [screen('/'), screen('/home'), screen('/soak-test')],
  197. [link('/', '/home'), link('/home', '/soak-test'), link('function:TopBar', '/soak-test')],
  198. [origin('TopBar', 10)]
  199. )
  200. );
  201. // Higher layer = higher on the picture.
  202. expect(layerOf(model, R('/'))).toBe(layerOf(model, R('/home')) + 1);
  203. expect(layerOf(model, R('/soak-test'))).toBe(layerOf(model, R('/home')) - 1);
  204. // The top bar sits beside /home, not beside the entry — so what it opens
  205. // is below it AND below the screen the user actually opened it from.
  206. expect(layerOf(model, 'function:TopBar')).toBe(layerOf(model, R('/home')));
  207. expect(edgeOf(model, 'function:TopBar', R('/soak-test')).route).toBe('down');
  208. });
  209. it('never lets chrome pull a screen up the picture', () => {
  210. const model = buildScreensModel(
  211. payload(
  212. [screen('/'), screen('/a'), screen('/b'), screen('/c')],
  213. [link('/', '/a'), link('/a', '/b'), link('/b', '/c'), link('function:TopBar', '/c')],
  214. [origin('TopBar', 4)]
  215. )
  216. );
  217. expect(layerOf(model, R('/c'))).toBe(layerOf(model, R('/')) - 3);
  218. expect(layerOf(model, 'function:TopBar')).toBe(layerOf(model, R('/b')));
  219. });
  220. it('seeds what only an origin opens, from the top', () => {
  221. const model = buildScreensModel(
  222. payload([screen('/'), screen('/detail')], [link('function:openDetail', '/detail')], [origin('openDetail')])
  223. );
  224. expect(layerOf(model, 'function:openDetail')).toBe(layerOf(model, R('/')));
  225. expect(layerOf(model, R('/detail'))).toBe(layerOf(model, R('/')) - 1);
  226. // Reached through chrome is reached.
  227. expect(model.unreached).toBe(0);
  228. });
  229. it('measures distance over every transition, not the two-cycle-broken set', () => {
  230. // Three returns against one arrival: the Map's break would keep /a -> /
  231. // and drop / -> /a, and then /a would have no way of being one below /.
  232. const model = buildScreensModel(
  233. payload(
  234. [screen('/'), screen('/a')],
  235. [link('/', '/a'), link('/a', '/', 'x'), link('/a', '/', 'y'), link('/a', '/', 'z')]
  236. )
  237. );
  238. expect(layerOf(model, R('/a'))).toBe(layerOf(model, R('/')) - 1);
  239. });
  240. it('puts what nothing reaches in a band at the bottom, one empty row below the rest', () => {
  241. const model = buildScreensModel(
  242. payload([screen('/'), screen('/home'), screen('/orphan')], [link('/', '/home')])
  243. );
  244. expect(layerOf(model, R('/orphan'))).toBe(0);
  245. expect(layerOf(model, R('/home'))).toBe(2);
  246. expect(layerOf(model, R('/'))).toBe(3);
  247. expect(model.unreached).toBe(1);
  248. expect(model.nodes.get(R('/orphan'))!.unreached).toBe(true);
  249. });
  250. it('draws the same picture twice', () => {
  251. const a = buildScreensModel(hub());
  252. const b = buildScreensModel(hub());
  253. expect(a.layout).toEqual(b.layout);
  254. });
  255. });
  256. describe('directional ports', () => {
  257. it('routes a return from the top of its source to the bottom of its target', () => {
  258. const model = buildScreensModel(
  259. payload([screen('/'), screen('/home')], [link('/', '/home'), link('/home', '/', 'logout')])
  260. );
  261. const root = nodeOf(model, R('/'));
  262. const home = nodeOf(model, R('/home'));
  263. const down = edgeOf(model, R('/'), R('/home'));
  264. const up = edgeOf(model, R('/home'), R('/'));
  265. expect(down.route).toBe('down');
  266. expect(up.route).toBe('up');
  267. expect(up.back).toBe(true);
  268. // Down: bottom of / to top of /home. Up: top of /home to bottom of /.
  269. expect(portPoint(root, down.id, 'source').y).toBe(root.y + NODE_HEIGHT);
  270. expect(portPoint(home, down.id, 'target').y).toBe(home.y);
  271. expect(portPoint(home, up.id, 'source').y).toBe(home.y);
  272. expect(portPoint(root, up.id, 'target').y).toBe(root.y + NODE_HEIGHT);
  273. // And the node component draws exactly those ports: one of each on the
  274. // sides that face each other, nothing on the sides that do not.
  275. expect(home.ports.top.map((p) => p.type).sort()).toEqual(['source', 'target']);
  276. expect(root.ports.bottom.map((p) => p.type).sort()).toEqual(['source', 'target']);
  277. expect(home.ports.bottom).toEqual([]);
  278. expect(root.ports.top).toEqual([]);
  279. });
  280. it('joins two screens on one row over the top', () => {
  281. const model = buildScreensModel(
  282. payload(
  283. [screen('/'), screen('/a'), screen('/b')],
  284. [link('/', '/a'), link('/', '/b'), link('/a', '/b', 'next')]
  285. )
  286. );
  287. const a = nodeOf(model, R('/a'));
  288. const b = nodeOf(model, R('/b'));
  289. const level = edgeOf(model, R('/a'), R('/b'));
  290. expect(a.layer).toBe(b.layer);
  291. expect(level.route).toBe('level');
  292. expect(portPoint(a, level.id, 'source').y).toBe(a.y);
  293. expect(portPoint(b, level.id, 'target').y).toBe(b.y);
  294. });
  295. it('widens a hub to keep its ports apart, and spaces rows for the labels', () => {
  296. const targets = Array.from({ length: 20 }, (_, i) => `/t${i}`);
  297. const model = buildScreensModel(
  298. payload([screen('/'), screen('/home'), ...targets.map(screen)], [
  299. link('/', '/home'),
  300. ...targets.map((t) => link('/home', t)),
  301. ])
  302. );
  303. const home = nodeOf(model, R('/home'));
  304. expect(home.ports.bottom).toHaveLength(20);
  305. expect(home.width).toBeGreaterThanOrEqual((20 + 1) * PORT_PITCH);
  306. expect(home.y - nodeOf(model, R('/')).y).toBe(NODE_HEIGHT + SCREEN_LAYER_GAP);
  307. expect(model.layerGap).toBe(SCREEN_LAYER_GAP);
  308. });
  309. });
  310. describe('the curve', () => {
  311. it('runs from port to port through the vertical midpoint, monotonic in y', () => {
  312. const c = screenCurve('down', 0, 0, 100, 116);
  313. expect(pointAt(c, 0)).toEqual({ x: 0, y: 0 });
  314. expect(pointAt(c, 1)).toEqual({ x: 100, y: 116 });
  315. expect(pointAt(c, 0.3).y).toBeLessThan(pointAt(c, 0.6).y);
  316. expect(screenEdgePath('down', 0, 0, 100, 116)).toBe('M0,0 C0,58 100,58 100,116');
  317. // Height -> parameter -> height round-trips.
  318. const y = pointAt(c, 0.3).y;
  319. expect(tAtY(c, y, 'target')).toBeCloseTo(0.3, 5);
  320. expect(tAtY(c, y, 'source')).toBeCloseTo(0.3, 5);
  321. });
  322. it('arches a level edge above its row, searchable from either end', () => {
  323. const c = screenCurve('level', 0, 100, 200, 100);
  324. expect(c.y0).toBe(c.y3);
  325. expect(pointAt(c, 0.5).y).toBeLessThan(100);
  326. expect(tAtY(c, 90, 'target')!).toBeGreaterThan(0.5);
  327. expect(tAtY(c, 90, 'source')!).toBeLessThan(0.5);
  328. // Above the apex there is no curve.
  329. expect(tAtY(c, -900, 'target')).toBeNull();
  330. });
  331. });
  332. describe('placing the labels', () => {
  333. it('fits five lanes between rows at the Screens gap, three at the Map\'s', () => {
  334. expect(laneCount(SCREEN_LAYER_GAP)).toBe(5);
  335. expect(laneCount(74)).toBe(3);
  336. expect(laneCount(10)).toBe(1);
  337. });
  338. it('puts every pill at the far end of its line, and none over another or over a box', () => {
  339. const model = buildScreensModel(hub());
  340. const home = nodeOf(model, R('/home'));
  341. const laid = placeLabels(model, R('/home'));
  342. // Twelve conditions out, six back; the entry's arrival is unconditional.
  343. expect(laid.pills.size + laid.hidden).toBe(18);
  344. expect(laid.hidden).toBe(0);
  345. const pills = [...laid.pills.values()];
  346. for (const a of pills) {
  347. for (const b of pills) {
  348. if (a !== b) expect(overlaps(rectOf(a), rectOf(b)), `${a.text} over ${b.text}`).toBe(false);
  349. }
  350. for (const node of model.layout.nodes) {
  351. expect(overlaps(rectOf(a), boxOf(node)), `${a.text} over ${node.id}`).toBe(false);
  352. }
  353. }
  354. for (let i = 0; i < 12; i++) {
  355. const target = nodeOf(model, R(`/t${i}`));
  356. const out = laid.pills.get(edgeOf(model, R('/home'), R(`/t${i}`)).id)!;
  357. expect(out.end).toBe('target');
  358. expect(out.text).toBe(`→ …step === ${i}`);
  359. // Above the screen it opens, inside the gap — and nearer to it than to /home.
  360. expect(out.y).toBeLessThan(target.y);
  361. expect(target.y - out.y).toBeLessThanOrEqual(SCREEN_LAYER_GAP);
  362. const farX = target.x + target.width / 2;
  363. const nearX = home.x + home.width / 2;
  364. expect(Math.abs(out.x - farX)).toBeLessThan(Math.abs(out.x - nearX) + 1);
  365. }
  366. for (let i = 0; i < 6; i++) {
  367. const source = nodeOf(model, R(`/t${i}`));
  368. const back = laid.pills.get(edgeOf(model, R(`/t${i}`), R('/home')).id)!;
  369. expect(back.end).toBe('source');
  370. expect(back.text).toBe(`← done${i}`);
  371. // A return leaves the top of its screen: the pill is above that box too.
  372. expect(back.y).toBeLessThan(source.y);
  373. }
  374. });
  375. it('draws nothing at rest, and the same thing every time', () => {
  376. const model = buildScreensModel(hub());
  377. expect(placeLabels(model, null).pills.size).toBe(0);
  378. const a = placeLabels(model, R('/home'));
  379. const b = placeLabels(model, R('/home'));
  380. expect([...a.pills.entries()]).toEqual([...b.pills.entries()]);
  381. });
  382. it('counts a pill that fits nowhere instead of drawing it on something', () => {
  383. const model = buildScreensModel(hub());
  384. // One lane only: the second pill above a screen that is both opened and
  385. // returned from has nowhere to go.
  386. const cramped: ScreensModel = { ...model, layerGap: 10 };
  387. expect(laneCount(cramped.layerGap)).toBe(1);
  388. const laid = placeLabels(cramped, R('/home'));
  389. expect(laid.hidden).toBeGreaterThan(0);
  390. expect(laid.pills.size + laid.hidden).toBe(18);
  391. const pills = [...laid.pills.values()];
  392. for (const a of pills) for (const b of pills) if (a !== b) expect(overlaps(rectOf(a), rectOf(b))).toBe(false);
  393. });
  394. it('labels the hovered line at its target end when nothing is selected', () => {
  395. const model = buildScreensModel(hub());
  396. const edge = edgeOf(model, R('/home'), R('/t3'));
  397. const pill = hoverPill(model, edge.id, null)!;
  398. expect(pill.end).toBe('target');
  399. expect(pill.text).toBe('→ …step === 3');
  400. expect(pill.y).toBeLessThan(nodeOf(model, R('/t3')).y);
  401. // Seen from the target, the same line arrives.
  402. const arriving = hoverPill(model, edge.id, R('/t3'))!;
  403. expect(arriving.end).toBe('source');
  404. expect(arriving.text).toBe('← …step === 3');
  405. });
  406. it('says nothing for an unconditional line unless told what to say', () => {
  407. const model = buildScreensModel(hub());
  408. const edge = edgeOf(model, R('/'), R('/home'));
  409. expect(hoverPill(model, edge.id, null)).toBeNull();
  410. expect(hoverPill(model, edge.id, R('/home'), '← always')?.text).toBe('← always');
  411. expect(pillText(model.edges.get(edge.id)!, edge, null)).toBe('');
  412. });
  413. it('keeps a transient pill clear of the ones the selection placed', () => {
  414. const model = buildScreensModel(hub());
  415. const laid = placeLabels(model, R('/home'));
  416. // /t6 is opened by /home (a pill above it) and, in this payload, returns
  417. // nothing; a row hovered for an unconditional return needs a lane of its
  418. // own above the same box.
  419. const ret = link('/t6', '/home');
  420. const withReturn = buildScreensModel({ ...hub(), links: [...hub().links, ret] });
  421. const base = placeLabels(withReturn, R('/home'));
  422. const edge = edgeOf(withReturn, R('/t6'), R('/home'));
  423. expect(base.pills.has(edge.id)).toBe(false);
  424. const pill = hoverPill(withReturn, edge.id, R('/home'), '← always', base)!;
  425. for (const other of base.pills.values()) {
  426. expect(overlaps(rectOf(pill), rectOf(other)), `over ${other.text}`).toBe(false);
  427. }
  428. expect(pill.lane).toBeGreaterThan(0);
  429. void laid;
  430. });
  431. it('sizes a pill from its text', () => {
  432. expect(pillWidth('→ x')).toBeGreaterThan(pillWidth('→'));
  433. expect(pairId(link('/a', '/a'))).toBeNull();
  434. expect(pairId(link('/a', '/b'))).toBe(linkId({ source: R('/a'), target: R('/b') }));
  435. });
  436. });
  437. /* --------------------------------------------------------------- tracks -- */
  438. function orientation(a: Point, b: Point, c: Point): number {
  439. return Math.sign((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x));
  440. }
  441. /** Proper crossing of two segments (shared endpoints and touching do not count). */
  442. function segmentsCross(p1: Point, p2: Point, p3: Point, p4: Point): boolean {
  443. const o1 = orientation(p1, p2, p3);
  444. const o2 = orientation(p1, p2, p4);
  445. const o3 = orientation(p3, p4, p1);
  446. const o4 = orientation(p3, p4, p2);
  447. return o1 !== 0 && o2 !== 0 && o3 !== 0 && o4 !== 0 && o1 !== o2 && o3 !== o4;
  448. }
  449. function crossings(a: readonly Point[], b: readonly Point[]): number {
  450. let n = 0;
  451. for (let i = 1; i < a.length; i++) {
  452. for (let j = 1; j < b.length; j++) {
  453. if (segmentsCross(a[i - 1]!, a[i]!, b[j - 1]!, b[j]!)) n++;
  454. }
  455. }
  456. return n;
  457. }
  458. describe('tracks — each line its own height through the gap', () => {
  459. /** A hub with six screens to one side and four to the other, three of which return. */
  460. function fan(): ScreensModel {
  461. const left = ['/l0', '/l1', '/l2', '/l3', '/l4', '/l5'];
  462. const right = ['/r0', '/r1', '/r2', '/r3'];
  463. return buildScreensModel(
  464. payload(
  465. [screen('/'), screen('/home'), ...left.map(screen), ...right.map(screen)],
  466. [
  467. link('/', '/home'),
  468. ...[...left, ...right].map((t, i) => link('/home', t, `c${i}`)),
  469. ...left.slice(0, 3).map((t, i) => link(t, '/home', `back${i}`)),
  470. ]
  471. )
  472. );
  473. }
  474. /** The hub's lines into the row below it, split by which side their far end sits on. */
  475. function sides(model: ScreensModel): Array<Array<{ curve: Curve; farX: number; id: string }>> {
  476. const home = nodeOf(model, R('/home'));
  477. const centre = home.x + home.width / 2;
  478. const lines = model.layout.edges
  479. .filter((e) => (e.source === R('/home') || e.target === R('/home')) && e.route !== 'level')
  480. .map((e) => {
  481. const curve = model.curves.get(e.id)!;
  482. const far = e.source === R('/home') ? { x: curve.x3, y: curve.y3 } : { x: curve.x0, y: curve.y0 };
  483. return { id: e.id, curve, farX: far.x, farY: far.y };
  484. })
  485. .filter((l) => l.farY > home.y + home.height);
  486. return [lines.filter((l) => l.farX < centre), lines.filter((l) => l.farX >= centre)];
  487. }
  488. it('ranks a fan by reach: the farthest-out line runs nearest the hub, every line on its own track', () => {
  489. const model = fan();
  490. const home = nodeOf(model, R('/home'));
  491. const bottom = home.y + home.height;
  492. const [left, right] = sides(model);
  493. expect(left!.length + right!.length).toBe(13);
  494. // Left: farther left first; its track is the highest (smallest y).
  495. const l = [...left!].sort((a, b) => a.farX - b.farX);
  496. for (let i = 1; i < l.length; i++) expect(l[i]!.curve.y1).toBeGreaterThan(l[i - 1]!.curve.y1 + 4);
  497. // Right: farther right first, mirrored.
  498. const r = [...right!].sort((a, b) => b.farX - a.farX);
  499. for (let i = 1; i < r.length; i++) expect(r[i]!.curve.y1).toBeGreaterThan(r[i - 1]!.curve.y1 + 4);
  500. // Every track lies inside the gap under the hub, and both control points share it.
  501. for (const line of [...l, ...r]) {
  502. expect(line.curve.y1).toBeGreaterThan(bottom);
  503. expect(line.curve.y1).toBeLessThan(bottom + SCREEN_LAYER_GAP);
  504. expect(line.curve.y2).toBe(line.curve.y1);
  505. }
  506. });
  507. it('never lets two lines of one fan cross — returns included', () => {
  508. const model = fan();
  509. for (const group of sides(model)) {
  510. for (const a of group) {
  511. for (const b of group) {
  512. if (a.id >= b.id) continue;
  513. expect(
  514. crossings(model.polylines.get(a.id)!, model.polylines.get(b.id)!),
  515. `${a.id} crosses ${b.id}`
  516. ).toBe(0);
  517. }
  518. }
  519. }
  520. });
  521. it('keeps a line that spans several rows on a track beside its fan, and runs the rest vertically', () => {
  522. // Downward lines are always one row (a row IS distance from the entry);
  523. // a return can come from any depth. Two rows down, straight back home.
  524. const model = buildScreensModel(
  525. payload(
  526. [screen('/'), screen('/home'), screen('/mid'), screen('/deep')],
  527. [link('/', '/home'), link('/home', '/mid'), link('/mid', '/deep'), link('/deep', '/home', 'done')]
  528. )
  529. );
  530. const home = nodeOf(model, R('/home'));
  531. const deep = nodeOf(model, R('/deep'));
  532. expect(home.layer - deep.layer).toBe(2);
  533. const curve = model.curves.get(edgeOf(model, R('/deep'), R('/home')).id)!;
  534. // The track sits in the gap right under /home — not at the midpoint, which
  535. // would be inside the row between.
  536. expect(curve.y1).toBeGreaterThan(home.y + home.height);
  537. expect(curve.y1).toBeLessThan(home.y + home.height + SCREEN_LAYER_GAP);
  538. expect(curve.y2).toBe(curve.y1);
  539. // Both ends leave and arrive vertically.
  540. expect(curve.x1).toBe(curve.x0);
  541. expect(curve.x2).toBe(curve.x3);
  542. });
  543. it('nests level arches, the wider one higher', () => {
  544. const model = buildScreensModel(
  545. payload(
  546. [screen('/'), screen('/a'), screen('/b'), screen('/c')],
  547. [link('/', '/a'), link('/', '/b'), link('/', '/c'), link('/a', '/b', 'x'), link('/a', '/c', 'y')]
  548. )
  549. );
  550. const a = nodeOf(model, R('/a'));
  551. const centre = a.x + a.width / 2;
  552. const ab = model.curves.get(edgeOf(model, R('/a'), R('/b')).id)!;
  553. const ac = model.curves.get(edgeOf(model, R('/a'), R('/c')).id)!;
  554. // Both arches leave a's top towards the same side (the row is b, c, a or a, b, c).
  555. expect(Math.sign(ab.x3 - centre)).toBe(Math.sign(ac.x3 - centre));
  556. const [wide, narrow] = Math.abs(ab.x3 - ab.x0) > Math.abs(ac.x3 - ac.x0) ? [ab, ac] : [ac, ab];
  557. expect(wide.y1).toBeLessThan(narrow.y1);
  558. expect(narrow.y1).toBeLessThan(a.y);
  559. });
  560. it('draws the same tracks twice', () => {
  561. expect([...fan().curves.entries()]).toEqual([...fan().curves.entries()]);
  562. });
  563. });
  564. describe('pointing at a line', () => {
  565. it('answers the nearest line within reach, and nothing beyond it', () => {
  566. const model = buildScreensModel(hub());
  567. const edge = edgeOf(model, R('/home'), R('/t4'));
  568. const on = model.polylines.get(edge.id)![12]!;
  569. expect(nearestEdge(model, { x: on.x + 1, y: on.y + 1 }, null, 10)?.id).toBe(edge.id);
  570. expect(nearestEdge(model, { x: -5000, y: -5000 }, null, 10)).toBeNull();
  571. // Reach is a distance, not a hint.
  572. expect(nearestEdge(model, { x: on.x + 30, y: on.y + 30 }, null, 10)).toBeNull();
  573. });
  574. it('only considers the lines it is asked about', () => {
  575. const model = buildScreensModel(hub());
  576. const near = edgeOf(model, R('/home'), R('/t4'));
  577. const other = edgeOf(model, R('/home'), R('/t9'));
  578. const on = model.polylines.get(near.id)![12]!;
  579. expect(nearestEdge(model, on, new Set([other.id]), 1e9)?.id).toBe(other.id);
  580. expect(nearestEdge(model, on, new Set(), 1e9)).toBeNull();
  581. });
  582. it('tells two lines a few pixels apart from each other', () => {
  583. const model = buildScreensModel(hub());
  584. const home = nodeOf(model, R('/home'));
  585. // Two lines to neighbouring screens on the same side: at a height in the
  586. // gap where both run, the pointer just above one, then just below the
  587. // other, meets each in turn.
  588. const [a, b] = model.layout.edges
  589. .filter((e) => e.source === R('/home'))
  590. .map((e) => ({ id: e.id, curve: model.curves.get(e.id)! }))
  591. .filter((l) => l.curve.x3 < home.x)
  592. .sort((p, q) => p.curve.x3 - q.curve.x3);
  593. expect(a && b).toBeTruthy();
  594. const y = (a!.curve.y1 + b!.curve.y1) / 2;
  595. const x = Math.max(a!.curve.x3, b!.curve.x3) + 40;
  596. const hit = nearestEdge(model, { x, y: y - 1 }, null, 60)!;
  597. const hit2 = nearestEdge(model, { x, y: y + 1 }, null, 60)!;
  598. expect(new Set([hit.id, hit2.id]).size).toBeGreaterThanOrEqual(1);
  599. expect([a!.id, b!.id]).toContain(hit.id);
  600. });
  601. });