visual-stability.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. import { expect, test } from "bun:test"
  2. import {
  3. analyzeVisualStability,
  4. analyzeVisualStabilityByMarker,
  5. type VisualStabilityTrace,
  6. } from "../../utils/visual-stability"
  7. import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer"
  8. import { legacyVisualPlan, visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
  9. import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions"
  10. function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace {
  11. return { markers: [], samples }
  12. }
  13. test("accepts continuous visible motion", () => {
  14. expect(
  15. analyzeVisualStability(
  16. trace([
  17. frame(0, region({ width: 80, bottom: 40 }), region({ top: 40, bottom: 60 })),
  18. frame(16, region({ width: 75, bottom: 45 }), region({ top: 45, bottom: 65 })),
  19. frame(32, region({ width: 70, bottom: 50 }), region({ top: 50, bottom: 70 })),
  20. ]),
  21. { flow: ["changing", "following"] },
  22. ),
  23. ).toEqual([])
  24. })
  25. test("reports repeated geometry reversals", () => {
  26. const issues = analyzeVisualStability(
  27. trace([
  28. frame(0, region({ width: 80 })),
  29. frame(16, region({ width: 60 })),
  30. frame(32, region({ width: 78 })),
  31. frame(48, region({ width: 62 })),
  32. ]),
  33. )
  34. expect(issues.some((issue) => issue.includes("changing width reversed 2 times"))).toBe(true)
  35. })
  36. test("reports visible blanking, label reversal, and overlap", () => {
  37. const issues = analyzeVisualStability(
  38. trace([
  39. frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
  40. frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
  41. frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
  42. ]),
  43. { flow: ["changing", "following"] },
  44. )
  45. expect(issues.some((issue) => issue.includes("opacity fell to 0.2"))).toBe(true)
  46. expect(issues.some((issue) => issue.includes("label reverted"))).toBe(true)
  47. expect(issues.some((issue) => issue.includes("overlapped following by 1px"))).toBe(true)
  48. })
  49. test("reports duplicate regions and unexpected remounts", () => {
  50. const issues = analyzeVisualStability(
  51. trace([frame(0, region({ node: 1 })), frame(16, region({ node: 2, count: 2 })), frame(32, region({ node: 2 }))]),
  52. { stable: ["changing"], unique: ["changing"] },
  53. )
  54. expect(issues.some((issue) => issue.includes("changing appeared 2 times"))).toBe(true)
  55. expect(issues.some((issue) => issue.includes("changing remounted"))).toBe(true)
  56. })
  57. test("reports bottom anchor loss but permits movement while scrolled away", () => {
  58. const anchored = analyzeVisualStability(
  59. trace([
  60. { at: 0, regions: { changing: region() }, viewport: viewport(0) },
  61. { at: 16, regions: { changing: region() }, viewport: viewport(24) },
  62. ]),
  63. { preserveBottomAnchor: true },
  64. )
  65. const away = analyzeVisualStability(
  66. trace([
  67. { at: 0, regions: { changing: region() }, viewport: viewport(80) },
  68. { at: 16, regions: { changing: region() }, viewport: viewport(104) },
  69. ]),
  70. { preserveBottomAnchor: true },
  71. )
  72. expect(anchored.some((issue) => issue.includes("bottom anchor moved to 24px"))).toBe(true)
  73. expect(away).toEqual([])
  74. })
  75. test("reports up down up movement while preserving a bottom anchor", () => {
  76. const issues = analyzeVisualStability(
  77. trace([
  78. { at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
  79. { at: 16, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
  80. { at: 32, regions: { changing: region({ top: 196, bottom: 236 }) }, viewport: viewport(0) },
  81. { at: 48, regions: { changing: region({ top: 176, bottom: 216 }) }, viewport: viewport(0) },
  82. ]),
  83. { preserveBottomAnchor: true, maxPositionReversals: 0 },
  84. )
  85. expect(issues.some((issue) => issue.includes("changing top reversed 2 times"))).toBe(true)
  86. expect(issues.some((issue) => issue.includes("changing bottom reversed 2 times"))).toBe(true)
  87. })
  88. test("accepts monotonic upward movement while preserving a bottom anchor", () => {
  89. expect(
  90. analyzeVisualStability(
  91. trace([
  92. { at: 0, regions: { changing: region({ top: 200, bottom: 240 }) }, viewport: viewport(0) },
  93. { at: 16, regions: { changing: region({ top: 190, bottom: 230 }) }, viewport: viewport(0) },
  94. { at: 32, regions: { changing: region({ top: 180, bottom: 220 }) }, viewport: viewport(0) },
  95. ]),
  96. { preserveBottomAnchor: true, maxPositionReversals: 0 },
  97. ),
  98. ).toEqual([])
  99. })
  100. test("ignores overlap entirely outside the clipped timeline viewport", () => {
  101. expect(
  102. analyzeVisualStability(
  103. trace([
  104. {
  105. at: 0,
  106. regions: {
  107. changing: region({ top: -200, bottom: -100 }),
  108. following: region({ top: -150, bottom: -50 }),
  109. },
  110. viewport: viewport(0),
  111. },
  112. ]),
  113. { flow: ["changing", "following"] },
  114. ),
  115. ).toEqual([])
  116. })
  117. test("reports visible anchor movement while allowing virtual scrollbar movement", () => {
  118. const issues = analyzeVisualStability(
  119. trace([
  120. { at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(100), scrollTop: 40 } },
  121. { at: 16, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: { ...viewport(120), scrollTop: 60 } },
  122. ]),
  123. { fixed: ["anchor"] },
  124. )
  125. expect(issues).toEqual([])
  126. const moved = analyzeVisualStability(
  127. trace([
  128. { at: 0, regions: { anchor: region({ top: 100, bottom: 120 }) }, viewport: viewport(100) },
  129. { at: 16, regions: { anchor: region({ top: 112, bottom: 132 }) }, viewport: viewport(100) },
  130. ]),
  131. { fixed: ["anchor"] },
  132. )
  133. expect(moved.some((issue) => issue.includes("anchor moved 12px in the viewport"))).toBe(true)
  134. })
  135. test("analyzes each marked event independently", () => {
  136. const input: VisualStabilityTrace = {
  137. markers: [
  138. { at: 10, label: "grow" },
  139. { at: 40, label: "shrink" },
  140. ],
  141. samples: [
  142. frame(0, region({ top: 100 })),
  143. frame(16, region({ top: 90 })),
  144. frame(32, region({ top: 80 })),
  145. frame(48, region({ top: 90 })),
  146. frame(64, region({ top: 100 })),
  147. ],
  148. }
  149. expect(analyzeVisualStability(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
  150. expect(
  151. analyzeVisualStabilityByMarker(input, {
  152. maxPositionReversals: 0,
  153. motion: ["changing"],
  154. aggregateMotion: false,
  155. }),
  156. ).toEqual([])
  157. })
  158. test("reports cross-event motion reversals by default", () => {
  159. const input: VisualStabilityTrace = {
  160. markers: [
  161. { at: 10, label: "up" },
  162. { at: 40, label: "down" },
  163. ],
  164. samples: [
  165. frame(0, region({ top: 100 })),
  166. frame(16, region({ top: 90 })),
  167. frame(32, region({ top: 80 })),
  168. frame(48, region({ top: 90 })),
  169. frame(64, region({ top: 100 })),
  170. ],
  171. }
  172. expect(analyzeVisualStabilityByMarker(input, { maxPositionReversals: 0 })).toContain("changing top reversed 1 times")
  173. })
  174. test("reports regions rendered in the wrong flow order", () => {
  175. const issues = analyzeVisualStability(
  176. trace([frame(0, region({ top: 100, bottom: 120 }), region({ top: 60, bottom: 80 }))]),
  177. { flow: ["changing", "following"] },
  178. )
  179. expect(issues.some((issue) => issue.includes("changing rendered after following"))).toBe(true)
  180. })
  181. test("uses painted bounds instead of clipped layout overflow", () => {
  182. expect(
  183. analyzeVisualStability(
  184. trace([
  185. frame(
  186. 0,
  187. region({ top: 100, bottom: 140, height: 40, layoutTop: 100, layoutBottom: 300 }),
  188. region({ top: 140, bottom: 180 }),
  189. ),
  190. ]),
  191. { flow: ["changing", "following"] },
  192. ),
  193. ).toEqual([])
  194. })
  195. test("does not report disappearance when a present row moves outside the viewport", () => {
  196. expect(
  197. analyzeVisualStability(
  198. trace([
  199. frame(0, region({ visible: true })),
  200. frame(16, region({ visible: false, inViewport: false, top: -100, bottom: -80 })),
  201. frame(32, region({ visible: true })),
  202. ]),
  203. ),
  204. ).toEqual([])
  205. })
  206. test("reports an in-viewport transparent frame between visible frames", () => {
  207. const issues = analyzeVisualStability(
  208. trace([
  209. frame(0, region()),
  210. frame(16, region({ visible: false, opacity: 0, inViewport: true })),
  211. frame(32, region()),
  212. ]),
  213. )
  214. expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
  215. })
  216. test("reports an in-viewport display-none frame between visible frames", () => {
  217. const issues = analyzeVisualStability(
  218. trace([
  219. frame(0, region()),
  220. frame(16, region({ visible: false, width: 0, height: 0, inViewport: true, cssHidden: true })),
  221. frame(32, region()),
  222. ]),
  223. )
  224. expect(issues.some((issue) => issue.includes("blanked between visible frames"))).toBe(true)
  225. })
  226. test("can limit motion analysis to unaffected regions", () => {
  227. expect(
  228. analyzeVisualStability(
  229. trace([
  230. frame(0, region({ height: 20 }), region()),
  231. frame(16, region({ height: 40 }), region()),
  232. frame(32, region({ height: 30 }), region()),
  233. ]),
  234. { motion: ["following"] },
  235. ),
  236. ).toEqual([])
  237. })
  238. test("reports a blank frame across replacement surfaces", () => {
  239. const issues = analyzeVisualStability(
  240. {
  241. markers: [],
  242. samples: [
  243. { at: 0, regions: { thinking: region(), error: region({ present: false, visible: false }) } },
  244. {
  245. at: 16,
  246. regions: {
  247. thinking: region({ present: false, visible: false }),
  248. error: region({ present: false, visible: false }),
  249. },
  250. },
  251. { at: 32, regions: { thinking: region({ present: false, visible: false }), error: region() } },
  252. ],
  253. },
  254. { continuousAny: [["thinking", "error"]] },
  255. )
  256. expect(issues.some((issue) => issue.includes("thinking | error blanked"))).toBe(true)
  257. })
  258. test("reports failure to acquire the bottom anchor", () => {
  259. const issues = analyzeVisualStability(
  260. trace([
  261. { at: 0, regions: { changing: region() }, viewport: viewport(600) },
  262. { at: 16, regions: { changing: region() }, viewport: viewport(120) },
  263. ]),
  264. { acquireBottomAnchor: true },
  265. )
  266. expect(issues.some((issue) => issue.includes("did not acquire bottom anchor"))).toBe(true)
  267. })
  268. test("reports a required region that never renders", () => {
  269. const issues = analyzeVisualStability(
  270. trace([
  271. {
  272. at: 0,
  273. regions: { changing: region({ present: false, visible: false }) },
  274. },
  275. ]),
  276. { required: ["changing"] },
  277. )
  278. expect(issues).toContain("changing never rendered")
  279. })
  280. test("preserves typed region names while mapping definitions", () => {
  281. const regions = defineVisualRegions({
  282. changing: { selector: "[data-changing]" },
  283. following: { selector: "[data-following]", closest: "[data-row]" },
  284. })
  285. const selectors = mapVisualRegions(regions, (region) => region.selector)
  286. expect(selectors).toEqual({ changing: "[data-changing]", following: "[data-following]" })
  287. const name: keyof typeof selectors = "changing"
  288. expect(name).toBe("changing")
  289. })
  290. test("evaluates the typed invariant algebra over explicit observations", () => {
  291. const regions = defineVisualRegions({
  292. changing: { selector: "[data-changing]" },
  293. following: { selector: "[data-following]" },
  294. })
  295. const invariants = [
  296. { type: "required", regions: ["changing"] },
  297. { type: "flow", regions: ["changing", "following"] },
  298. ] satisfies VisualInvariant<keyof typeof regions>[]
  299. // @ts-expect-error Plans reject names that are not in the region definition.
  300. const invalid = { type: "required", regions: ["missing"] } satisfies VisualInvariant<keyof typeof regions>
  301. const plan = visualPlan(regions, invariants, { perMarker: true })
  302. expect(invalid.regions).toEqual(["missing"])
  303. expect(plan.perMarker).toBe(true)
  304. expect(analyzeVisualObservations([frame(0, region({ bottom: 50 }), region({ top: 49, bottom: 69 }))], plan)).toEqual([
  305. "changing overlapped following by 1px at 0ms",
  306. ])
  307. })
  308. test("legacy plan adapter preserves analyzer messages and order", () => {
  309. const input = trace([
  310. frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
  311. frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
  312. frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
  313. ])
  314. const options = { flow: ["changing", "following"], stable: ["changing"] }
  315. expect(analyzeVisualObservations(input.samples, legacyVisualPlan(options))).toEqual(
  316. analyzeVisualStability(input, options),
  317. )
  318. })
  319. function frame(
  320. at: number,
  321. changing: VisualStabilityTrace["samples"][number]["regions"][string],
  322. following?: VisualStabilityTrace["samples"][number]["regions"][string],
  323. ) {
  324. return { at, regions: { changing, ...(following ? { following } : {}) } }
  325. }
  326. function region(input: Partial<VisualStabilityTrace["samples"][number]["regions"][string]> = {}) {
  327. return {
  328. present: true,
  329. visible: true,
  330. inViewport: true,
  331. top: 0,
  332. bottom: 20,
  333. width: 100,
  334. height: 20,
  335. opacity: 1,
  336. count: 1,
  337. node: 1,
  338. label: "",
  339. text: "",
  340. layoutTop: input.top ?? 0,
  341. layoutBottom: input.bottom ?? 20,
  342. ...input,
  343. }
  344. }
  345. function viewport(distanceFromBottom: number) {
  346. return { top: 0, bottom: 400, scrollTop: 100, scrollHeight: 500, clientHeight: 400, distanceFromBottom }
  347. }