1
0

keyed.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. import { describe, expect, it } from "bun:test"
  2. import { Keyed } from "../src/keyed"
  3. import { Computed } from "../src/reactivity"
  4. interface Item {
  5. readonly id: number
  6. readonly label: string
  7. }
  8. const item = (id: number, label: string): Item => ({ id, label })
  9. describe("Keyed", () => {
  10. it("keeps slot identity across value updates and reorders", () => {
  11. const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
  12. keyed.set([item(1, "one"), item(2, "two"), item(3, "three")])
  13. const [one, two, three] = keyed.slots()
  14. keyed.set([item(3, "THREE"), item(1, "ONE"), item(2, "TWO")])
  15. expect(keyed.slots()).toEqual([three, one, two])
  16. expect(keyed.slots()[0]).toBe(three)
  17. expect(keyed.slots()[1]).toBe(one)
  18. expect(keyed.slots()[2]).toBe(two)
  19. expect(one()).toEqual(item(1, "ONE"))
  20. expect(two()).toEqual(item(2, "TWO"))
  21. expect(three()).toEqual(item(3, "THREE"))
  22. })
  23. it("does not publish structural changes for value-only updates or identical sets", () => {
  24. const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
  25. keyed.set([item(1, "one"), item(2, "two")])
  26. const initial = keyed.slots()
  27. const structures: Array<readonly number[]> = []
  28. const dispose = keyed.slots.subscribe((slots) => structures.push(slots.map((slot) => slot().id)))
  29. keyed.set([item(1, "ONE"), item(2, "TWO")])
  30. expect(keyed.slots()).toBe(initial)
  31. keyed.set([item(1, "ONE"), item(2, "TWO")])
  32. expect(keyed.slots()).toBe(initial)
  33. keyed.set([item(2, "TWO"), item(1, "ONE")])
  34. expect(structures).toEqual([[2, 1]])
  35. dispose()
  36. })
  37. it("reacts to aggregate value updates while preserving unchanged aggregate snapshots", () => {
  38. const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
  39. const one = item(1, "one")
  40. const two = item(2, "two")
  41. keyed.set([one, two])
  42. const initial = keyed.values()
  43. const snapshots: Array<readonly Item[]> = []
  44. const dispose = keyed.values.subscribe((values) => snapshots.push(values))
  45. keyed.set([one, item(2, "TWO")])
  46. const updated = keyed.values()
  47. expect(updated).not.toBe(initial)
  48. expect(updated).toEqual([one, item(2, "TWO")])
  49. keyed.set([one, updated[1]])
  50. expect(keyed.values()).toBe(updated)
  51. expect(snapshots).toEqual([[one, item(2, "TWO")]])
  52. dispose()
  53. })
  54. it("uses custom equivalence to cut off slot and aggregate updates", () => {
  55. const keyed = Keyed.make<Item, number>({
  56. key: (value) => value.id,
  57. equivalent: (left, right) => left.id === right.id && left.label.toLowerCase() === right.label.toLowerCase(),
  58. })
  59. const original = item(1, "one")
  60. keyed.set([original])
  61. const slot = keyed.slots()[0]
  62. const aggregate = keyed.values()
  63. const slotValues: Item[] = []
  64. const aggregateValues: Array<readonly Item[]> = []
  65. const disposeSlot = slot.subscribe((value) => slotValues.push(value))
  66. const disposeAggregate = keyed.values.subscribe((values) => aggregateValues.push(values))
  67. keyed.set([item(1, "ONE")])
  68. expect(slot()).toBe(original)
  69. expect(keyed.values()).toBe(aggregate)
  70. expect(slotValues).toEqual([])
  71. expect(aggregateValues).toEqual([])
  72. keyed.set([item(1, "changed")])
  73. expect(slot()).toEqual(item(1, "changed"))
  74. expect(slotValues).toEqual([item(1, "changed")])
  75. expect(aggregateValues).toEqual([[item(1, "changed")]])
  76. disposeSlot()
  77. disposeAggregate()
  78. })
  79. it("uses SameValueZero semantics for primitive slot values", () => {
  80. const keyed = Keyed.make<number, string>({ key: () => "value" })
  81. keyed.set([-0])
  82. expect(keyed.update(0)).toBe(false)
  83. expect(Object.is(keyed.get("value")?.(), -0)).toBe(true)
  84. })
  85. it("creates slots for inserts and fresh slots after remove and reinsert", () => {
  86. const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
  87. keyed.set([item(1, "one")])
  88. const removed = keyed.slots()[0]
  89. const removedValues: Item[] = []
  90. const dispose = removed.subscribe((value) => removedValues.push(value))
  91. keyed.set([item(2, "two")])
  92. const inserted = keyed.slots()[0]
  93. keyed.set([item(1, "new one"), item(2, "new two")])
  94. const [reinserted, retained] = keyed.slots()
  95. expect(reinserted).not.toBe(removed)
  96. expect(reinserted()).toEqual(item(1, "new one"))
  97. expect(retained).toBe(inserted)
  98. expect(retained()).toEqual(item(2, "new two"))
  99. expect(removed()).toEqual(item(1, "one"))
  100. expect(removedValues).toEqual([])
  101. dispose()
  102. })
  103. it("rejects duplicate keys before making any partial update", () => {
  104. const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
  105. keyed.set([item(1, "one"), item(2, "two")])
  106. const slots = keyed.slots()
  107. const values = keyed.values()
  108. const notifications: string[] = []
  109. const disposeOne = slots[0].subscribe(() => notifications.push("one"))
  110. const disposeTwo = slots[1].subscribe(() => notifications.push("two"))
  111. const disposeSlots = keyed.slots.subscribe(() => notifications.push("slots"))
  112. const disposeValues = keyed.values.subscribe(() => notifications.push("values"))
  113. expect(() => keyed.set([item(1, "changed"), item(1, "duplicate")])).toThrow("Keyed values must have unique keys")
  114. expect(keyed.slots()).toBe(slots)
  115. expect(keyed.values()).toBe(values)
  116. expect(keyed.values()).toEqual([item(1, "one"), item(2, "two")])
  117. expect(notifications).toEqual([])
  118. disposeOne()
  119. disposeTwo()
  120. disposeSlots()
  121. disposeValues()
  122. })
  123. it("uses SameValueZero equality for zero keys", () => {
  124. const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
  125. keyed.set([item(-0, "negative zero")])
  126. const zero = keyed.slots()[0]
  127. keyed.set([item(0, "zero")])
  128. expect(keyed.slots()[0]).toBe(zero)
  129. expect(zero()).toEqual(item(0, "zero"))
  130. expect(() => keyed.set([item(0, "zero"), item(-0, "negative zero")])).toThrow("Keyed values must have unique keys")
  131. expect(keyed.slots()).toEqual([zero])
  132. expect(keyed.values()).toEqual([item(0, "zero")])
  133. })
  134. it("uses SameValueZero equality for NaN keys", () => {
  135. const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
  136. keyed.set([item(Number.NaN, "first")])
  137. const nan = keyed.slots()[0]
  138. keyed.set([item(Number.NaN, "updated")])
  139. expect(keyed.slots()[0]).toBe(nan)
  140. expect(nan()).toEqual(item(Number.NaN, "updated"))
  141. expect(() => keyed.set([item(Number.NaN, "one"), item(Number.NaN, "two")])).toThrow(
  142. "Keyed values must have unique keys",
  143. )
  144. expect(keyed.slots()).toEqual([nan])
  145. expect(keyed.values()).toEqual([item(Number.NaN, "updated")])
  146. })
  147. it("publishes one glitch-free aggregate when slots and structure change together", () => {
  148. const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
  149. keyed.set([item(1, "one"), item(2, "two"), item(3, "three")])
  150. const observations: string[] = []
  151. const summary = Computed.make(() => {
  152. const slotKeys = keyed
  153. .slots()
  154. .map((slot) => slot().id)
  155. .join(",")
  156. const values = keyed
  157. .values()
  158. .map((value) => `${value.id}:${value.label}`)
  159. .join(",")
  160. return `${slotKeys}|${values}`
  161. })
  162. const dispose = summary.subscribe((value) => observations.push(value))
  163. keyed.set([item(3, "THREE"), item(2, "TWO"), item(4, "four")])
  164. expect(observations).toEqual(["3,2,4|3:THREE,2:TWO,4:four"])
  165. dispose()
  166. })
  167. it("stops slot, structural, and aggregate notifications after disposal", () => {
  168. const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
  169. keyed.set([item(1, "one")])
  170. const notifications: string[] = []
  171. const disposeSlot = keyed.slots()[0].subscribe(() => notifications.push("slot"))
  172. const disposeSlots = keyed.slots.subscribe(() => notifications.push("slots"))
  173. const disposeValues = keyed.values.subscribe(() => notifications.push("values"))
  174. disposeSlot()
  175. disposeSlots()
  176. disposeValues()
  177. keyed.set([item(2, "two")])
  178. keyed.set([item(2, "TWO")])
  179. expect(notifications).toEqual([])
  180. })
  181. it("supports empty sets and repeated clears without redundant publication", () => {
  182. const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
  183. expect(keyed.slots()).toEqual([])
  184. expect(keyed.values()).toEqual([])
  185. const structures: Array<readonly unknown[]> = []
  186. const aggregates: Array<readonly Item[]> = []
  187. const disposeSlots = keyed.slots.subscribe((slots) => structures.push(slots))
  188. const disposeValues = keyed.values.subscribe((values) => aggregates.push(values))
  189. keyed.set([])
  190. keyed.set([item(1, "one")])
  191. keyed.set([])
  192. const clearedSlots = keyed.slots()
  193. const clearedValues = keyed.values()
  194. keyed.set([])
  195. expect(keyed.slots()).toBe(clearedSlots)
  196. expect(keyed.values()).toBe(clearedValues)
  197. expect(structures.map((slots) => slots.length)).toEqual([1, 0])
  198. expect(aggregates).toEqual([[item(1, "one")], []])
  199. disposeSlots()
  200. disposeValues()
  201. })
  202. it("updates one existing slot without publishing structure", () => {
  203. const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
  204. keyed.set([item(1, "one"), item(2, "two")])
  205. const structure = keyed.slots()
  206. const two = keyed.slots()[1]
  207. const structures: Array<readonly unknown[]> = []
  208. const dispose = keyed.slots.subscribe((slots) => structures.push(slots))
  209. const updated = item(2, "TWO")
  210. expect(keyed.update(updated)).toBe(true)
  211. expect(keyed.update(updated)).toBe(false)
  212. expect(keyed.slots()).toBe(structure)
  213. expect(keyed.slots()[1]).toBe(two)
  214. expect(two()).toEqual(item(2, "TWO"))
  215. expect(structures).toEqual([])
  216. expect(keyed.update(item(3, "three"))).toBe(false)
  217. dispose()
  218. })
  219. it("modifies one existing slot while preserving its key", () => {
  220. const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
  221. keyed.set([item(1, "one")])
  222. const slot = keyed.slots()[0]
  223. expect(keyed.modify(1, (value) => ({ ...value, label: "ONE" }))).toBe(true)
  224. expect(keyed.modify(1, (value) => value)).toBe(false)
  225. expect(keyed.slots()[0]).toBe(slot)
  226. expect(slot()).toEqual(item(1, "ONE"))
  227. expect(() => keyed.modify(1, (value) => ({ ...value, id: 2 }))).toThrow("Keyed modify must preserve the value key")
  228. expect(keyed.modify(2, (value) => value)).toBe(false)
  229. })
  230. it("checks key membership without reading the aggregate", () => {
  231. const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
  232. keyed.set([item(1, "one")])
  233. expect(keyed.has(1)).toBe(true)
  234. expect(keyed.has(2)).toBe(false)
  235. expect(keyed.get(1)).toBe(keyed.slots()[0])
  236. expect(keyed.get(2)).toBeUndefined()
  237. expect(keyed.before(1)).toBeUndefined()
  238. expect(keyed.after(1)).toBeUndefined()
  239. keyed.remove(1)
  240. expect(keyed.has(1)).toBe(false)
  241. expect(keyed.get(1)).toBeUndefined()
  242. })
  243. it("inserts, removes, and moves stable slots", () => {
  244. const keyed = Keyed.make<Item, number>({ key: (value) => value.id })
  245. keyed.set([item(1, "one"), item(3, "three")])
  246. const one = keyed.slots()[0]
  247. const three = keyed.slots()[1]
  248. const two = keyed.insert(item(2, "two"), { before: 3 })
  249. expect(keyed.slots()).toEqual([one, two, three])
  250. const four = keyed.insert(item(4, "four"), { after: 3 })
  251. expect(keyed.slots()).toEqual([one, two, three, four])
  252. expect(keyed.before(3)).toBe(two)
  253. expect(keyed.after(3)).toBe(four)
  254. expect(keyed.move(3, { before: 1 })).toBe(true)
  255. expect(keyed.slots()).toEqual([three, one, two, four])
  256. expect(keyed.move(3, { before: 1 })).toBe(false)
  257. expect(keyed.move(3, { after: 2 })).toBe(true)
  258. expect(keyed.slots()).toEqual([one, two, three, four])
  259. expect(keyed.move(3, "end")).toBe(true)
  260. expect(keyed.slots()).toEqual([one, two, four, three])
  261. expect(keyed.remove(2)).toBe(true)
  262. expect(keyed.remove(2)).toBe(false)
  263. expect(keyed.slots()).toEqual([one, four, three])
  264. expect(() => keyed.insert(item(1, "duplicate"))).toThrow("Keyed value already exists: 1")
  265. expect(() => keyed.insert(item(2, "two"), { before: 5 })).toThrow("Keyed value does not exist: 5")
  266. expect(keyed.move(5)).toBe(false)
  267. })
  268. it("counts publications and equivalence suppressions when instrumented", () => {
  269. const metrics = Keyed.metrics()
  270. const keyed = Keyed.make<Item, number>({ key: (value) => value.id, metrics })
  271. keyed.set([item(1, "one")])
  272. keyed.update(item(1, "ONE"))
  273. const current = keyed.slots()[0]()
  274. keyed.update(current)
  275. keyed.insert(item(2, "two"))
  276. keyed.move(2, { before: 1 })
  277. keyed.remove(2)
  278. expect(metrics).toEqual({
  279. slotPublications: 1,
  280. structuralPublications: 4,
  281. equivalenceSuppressions: 1,
  282. })
  283. })
  284. })