@tldraw/commenting 0.0.0-bootstrap
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/commenting.css +832 -0
- package/dist-cjs/canvas/cluster-input.js +40 -0
- package/dist-cjs/canvas/cluster-input.js.map +7 -0
- package/dist-cjs/canvas/comment-body.js +31 -0
- package/dist-cjs/canvas/comment-body.js.map +7 -0
- package/dist-cjs/canvas/comment-render.js +72 -0
- package/dist-cjs/canvas/comment-render.js.map +7 -0
- package/dist-cjs/canvas/comment-store.js +48 -0
- package/dist-cjs/canvas/comment-store.js.map +7 -0
- package/dist-cjs/canvas/comment-tool.js +160 -0
- package/dist-cjs/canvas/comment-tool.js.map +7 -0
- package/dist-cjs/canvas/comments-filter-menu.js +109 -0
- package/dist-cjs/canvas/comments-filter-menu.js.map +7 -0
- package/dist-cjs/canvas/comments-menu-item.js +42 -0
- package/dist-cjs/canvas/comments-menu-item.js.map +7 -0
- package/dist-cjs/canvas/comments-overflow-menu.js +64 -0
- package/dist-cjs/canvas/comments-overflow-menu.js.map +7 -0
- package/dist-cjs/canvas/comments-overlay.js +1081 -0
- package/dist-cjs/canvas/comments-overlay.js.map +7 -0
- package/dist-cjs/canvas/comments-sidebar.js +134 -0
- package/dist-cjs/canvas/comments-sidebar.js.map +7 -0
- package/dist-cjs/canvas/hooks.js +45 -0
- package/dist-cjs/canvas/hooks.js.map +7 -0
- package/dist-cjs/canvas/license.js +28 -0
- package/dist-cjs/canvas/license.js.map +7 -0
- package/dist-cjs/canvas/options.js +45 -0
- package/dist-cjs/canvas/options.js.map +7 -0
- package/dist-cjs/canvas/region-options.js +49 -0
- package/dist-cjs/canvas/region-options.js.map +7 -0
- package/dist-cjs/canvas/rich-text.js +28 -0
- package/dist-cjs/canvas/rich-text.js.map +7 -0
- package/dist-cjs/canvas/sidebar-filters.js +30 -0
- package/dist-cjs/canvas/sidebar-filters.js.map +7 -0
- package/dist-cjs/canvas/state.js +76 -0
- package/dist-cjs/canvas/state.js.map +7 -0
- package/dist-cjs/canvas/thread-state.js +81 -0
- package/dist-cjs/canvas/thread-state.js.map +7 -0
- package/dist-cjs/clustering/computeClusterTable.js +83 -0
- package/dist-cjs/clustering/computeClusterTable.js.map +7 -0
- package/dist-cjs/clustering/mst.js +117 -0
- package/dist-cjs/clustering/mst.js.map +7 -0
- package/dist-cjs/clustering/replay.js +275 -0
- package/dist-cjs/clustering/replay.js.map +7 -0
- package/dist-cjs/clustering/runtime.js +134 -0
- package/dist-cjs/clustering/runtime.js.map +7 -0
- package/dist-cjs/clustering/schedule.js +170 -0
- package/dist-cjs/clustering/schedule.js.map +7 -0
- package/dist-cjs/clustering/types.js +17 -0
- package/dist-cjs/clustering/types.js.map +7 -0
- package/dist-cjs/index.d.ts +868 -0
- package/dist-cjs/index.js +126 -0
- package/dist-cjs/index.js.map +7 -0
- package/dist-cjs/ui/avatar.js +43 -0
- package/dist-cjs/ui/avatar.js.map +7 -0
- package/dist-cjs/ui/byline.js +35 -0
- package/dist-cjs/ui/byline.js.map +7 -0
- package/dist-cjs/ui/comment-card.js +37 -0
- package/dist-cjs/ui/comment-card.js.map +7 -0
- package/dist-cjs/ui/comment-composer.js +136 -0
- package/dist-cjs/ui/comment-composer.js.map +7 -0
- package/dist-cjs/ui/comment-extensions.js +37 -0
- package/dist-cjs/ui/comment-extensions.js.map +7 -0
- package/dist-cjs/ui/comment-mention.js +42 -0
- package/dist-cjs/ui/comment-mention.js.map +7 -0
- package/dist-cjs/ui/comment-pin.js +29 -0
- package/dist-cjs/ui/comment-pin.js.map +7 -0
- package/dist-cjs/ui/comment-text.js +29 -0
- package/dist-cjs/ui/comment-text.js.map +7 -0
- package/dist-cjs/ui/comment-thread.js +45 -0
- package/dist-cjs/ui/comment-thread.js.map +7 -0
- package/dist-cjs/ui/comments-list.js +106 -0
- package/dist-cjs/ui/comments-list.js.map +7 -0
- package/dist-cjs/ui/count-badge.js +28 -0
- package/dist-cjs/ui/count-badge.js.map +7 -0
- package/dist-cjs/ui/empty-state.js +31 -0
- package/dist-cjs/ui/empty-state.js.map +7 -0
- package/dist-cjs/ui/format-time.js +46 -0
- package/dist-cjs/ui/format-time.js.map +7 -0
- package/dist-cjs/ui/mention-list.js +70 -0
- package/dist-cjs/ui/mention-list.js.map +7 -0
- package/dist-cjs/ui/mention-suggestion.js +206 -0
- package/dist-cjs/ui/mention-suggestion.js.map +7 -0
- package/dist-cjs/ui/mention.js +31 -0
- package/dist-cjs/ui/mention.js.map +7 -0
- package/dist-cjs/ui/reaction.js +31 -0
- package/dist-cjs/ui/reaction.js.map +7 -0
- package/dist-cjs/ui/reactions.js +34 -0
- package/dist-cjs/ui/reactions.js.map +7 -0
- package/dist-cjs/ui/render-markdown.js +63 -0
- package/dist-cjs/ui/render-markdown.js.map +7 -0
- package/dist-cjs/ui/send-button.js +28 -0
- package/dist-cjs/ui/send-button.js.map +7 -0
- package/dist-esm/canvas/cluster-input.mjs +20 -0
- package/dist-esm/canvas/cluster-input.mjs.map +7 -0
- package/dist-esm/canvas/comment-body.mjs +11 -0
- package/dist-esm/canvas/comment-body.mjs.map +7 -0
- package/dist-esm/canvas/comment-render.mjs +52 -0
- package/dist-esm/canvas/comment-render.mjs.map +7 -0
- package/dist-esm/canvas/comment-store.mjs +28 -0
- package/dist-esm/canvas/comment-store.mjs.map +7 -0
- package/dist-esm/canvas/comment-tool.mjs +142 -0
- package/dist-esm/canvas/comment-tool.mjs.map +7 -0
- package/dist-esm/canvas/comments-filter-menu.mjs +99 -0
- package/dist-esm/canvas/comments-filter-menu.mjs.map +7 -0
- package/dist-esm/canvas/comments-menu-item.mjs +22 -0
- package/dist-esm/canvas/comments-menu-item.mjs.map +7 -0
- package/dist-esm/canvas/comments-overflow-menu.mjs +53 -0
- package/dist-esm/canvas/comments-overflow-menu.mjs.map +7 -0
- package/dist-esm/canvas/comments-overlay.mjs +1094 -0
- package/dist-esm/canvas/comments-overlay.mjs.map +7 -0
- package/dist-esm/canvas/comments-sidebar.mjs +120 -0
- package/dist-esm/canvas/comments-sidebar.mjs.map +7 -0
- package/dist-esm/canvas/hooks.mjs +25 -0
- package/dist-esm/canvas/hooks.mjs.map +7 -0
- package/dist-esm/canvas/license.mjs +8 -0
- package/dist-esm/canvas/license.mjs.map +7 -0
- package/dist-esm/canvas/options.mjs +27 -0
- package/dist-esm/canvas/options.mjs.map +7 -0
- package/dist-esm/canvas/region-options.mjs +29 -0
- package/dist-esm/canvas/region-options.mjs.map +7 -0
- package/dist-esm/canvas/rich-text.mjs +8 -0
- package/dist-esm/canvas/rich-text.mjs.map +7 -0
- package/dist-esm/canvas/sidebar-filters.mjs +10 -0
- package/dist-esm/canvas/sidebar-filters.mjs.map +7 -0
- package/dist-esm/canvas/state.mjs +60 -0
- package/dist-esm/canvas/state.mjs.map +7 -0
- package/dist-esm/canvas/thread-state.mjs +61 -0
- package/dist-esm/canvas/thread-state.mjs.map +7 -0
- package/dist-esm/clustering/computeClusterTable.mjs +63 -0
- package/dist-esm/clustering/computeClusterTable.mjs.map +7 -0
- package/dist-esm/clustering/mst.mjs +97 -0
- package/dist-esm/clustering/mst.mjs.map +7 -0
- package/dist-esm/clustering/replay.mjs +255 -0
- package/dist-esm/clustering/replay.mjs.map +7 -0
- package/dist-esm/clustering/runtime.mjs +114 -0
- package/dist-esm/clustering/runtime.mjs.map +7 -0
- package/dist-esm/clustering/schedule.mjs +150 -0
- package/dist-esm/clustering/schedule.mjs.map +7 -0
- package/dist-esm/clustering/types.mjs +1 -0
- package/dist-esm/clustering/types.mjs.map +7 -0
- package/dist-esm/index.d.mts +868 -0
- package/dist-esm/index.mjs +139 -0
- package/dist-esm/index.mjs.map +7 -0
- package/dist-esm/ui/avatar.mjs +23 -0
- package/dist-esm/ui/avatar.mjs.map +7 -0
- package/dist-esm/ui/byline.mjs +15 -0
- package/dist-esm/ui/byline.mjs.map +7 -0
- package/dist-esm/ui/comment-card.mjs +17 -0
- package/dist-esm/ui/comment-card.mjs.map +7 -0
- package/dist-esm/ui/comment-composer.mjs +116 -0
- package/dist-esm/ui/comment-composer.mjs.map +7 -0
- package/dist-esm/ui/comment-extensions.mjs +17 -0
- package/dist-esm/ui/comment-extensions.mjs.map +7 -0
- package/dist-esm/ui/comment-mention.mjs +22 -0
- package/dist-esm/ui/comment-mention.mjs.map +7 -0
- package/dist-esm/ui/comment-pin.mjs +9 -0
- package/dist-esm/ui/comment-pin.mjs.map +7 -0
- package/dist-esm/ui/comment-text.mjs +9 -0
- package/dist-esm/ui/comment-text.mjs.map +7 -0
- package/dist-esm/ui/comment-thread.mjs +25 -0
- package/dist-esm/ui/comment-thread.mjs.map +7 -0
- package/dist-esm/ui/comments-list.mjs +86 -0
- package/dist-esm/ui/comments-list.mjs.map +7 -0
- package/dist-esm/ui/count-badge.mjs +8 -0
- package/dist-esm/ui/count-badge.mjs.map +7 -0
- package/dist-esm/ui/empty-state.mjs +11 -0
- package/dist-esm/ui/empty-state.mjs.map +7 -0
- package/dist-esm/ui/format-time.mjs +26 -0
- package/dist-esm/ui/format-time.mjs.map +7 -0
- package/dist-esm/ui/mention-list.mjs +50 -0
- package/dist-esm/ui/mention-list.mjs.map +7 -0
- package/dist-esm/ui/mention-suggestion.mjs +186 -0
- package/dist-esm/ui/mention-suggestion.mjs.map +7 -0
- package/dist-esm/ui/mention.mjs +11 -0
- package/dist-esm/ui/mention.mjs.map +7 -0
- package/dist-esm/ui/reaction.mjs +11 -0
- package/dist-esm/ui/reaction.mjs.map +7 -0
- package/dist-esm/ui/reactions.mjs +14 -0
- package/dist-esm/ui/reactions.mjs.map +7 -0
- package/dist-esm/ui/render-markdown.mjs +45 -0
- package/dist-esm/ui/render-markdown.mjs.map +7 -0
- package/dist-esm/ui/send-button.mjs +8 -0
- package/dist-esm/ui/send-button.mjs.map +7 -0
- package/package.json +71 -0
- package/src/canvas/canvas.css +148 -0
- package/src/canvas/cluster-input.test.ts +223 -0
- package/src/canvas/cluster-input.ts +29 -0
- package/src/canvas/comment-body.tsx +21 -0
- package/src/canvas/comment-render.test.ts +90 -0
- package/src/canvas/comment-render.ts +92 -0
- package/src/canvas/comment-store.ts +55 -0
- package/src/canvas/comment-tool.tsx +200 -0
- package/src/canvas/comments-filter-menu.tsx +104 -0
- package/src/canvas/comments-menu-item.tsx +24 -0
- package/src/canvas/comments-overflow-menu.tsx +62 -0
- package/src/canvas/comments-overlay.tsx +1460 -0
- package/src/canvas/comments-sidebar.tsx +184 -0
- package/src/canvas/hooks.ts +28 -0
- package/src/canvas/license.ts +15 -0
- package/src/canvas/options.test.ts +127 -0
- package/src/canvas/options.ts +111 -0
- package/src/canvas/region-options.ts +57 -0
- package/src/canvas/rich-text.ts +15 -0
- package/src/canvas/sidebar-filters.ts +25 -0
- package/src/canvas/state.ts +102 -0
- package/src/canvas/thread-state.ts +91 -0
- package/src/clustering/computeClusterTable.test.ts +299 -0
- package/src/clustering/computeClusterTable.ts +82 -0
- package/src/clustering/locality.test.ts +189 -0
- package/src/clustering/mst.test.ts +383 -0
- package/src/clustering/mst.ts +134 -0
- package/src/clustering/replay.test.ts +547 -0
- package/src/clustering/replay.ts +326 -0
- package/src/clustering/runtime.test.ts +596 -0
- package/src/clustering/runtime.ts +153 -0
- package/src/clustering/schedule.test.ts +514 -0
- package/src/clustering/schedule.ts +184 -0
- package/src/clustering/types.ts +102 -0
- package/src/index.ts +96 -0
- package/src/ui/avatar.tsx +31 -0
- package/src/ui/byline.tsx +23 -0
- package/src/ui/comment-card.tsx +32 -0
- package/src/ui/comment-composer.tsx +179 -0
- package/src/ui/comment-extensions.ts +25 -0
- package/src/ui/comment-mention.ts +47 -0
- package/src/ui/comment-pin.tsx +21 -0
- package/src/ui/comment-text.tsx +12 -0
- package/src/ui/comment-thread.tsx +56 -0
- package/src/ui/comments-list.tsx +139 -0
- package/src/ui/comments.css +678 -0
- package/src/ui/count-badge.tsx +9 -0
- package/src/ui/empty-state.tsx +16 -0
- package/src/ui/format-time.ts +29 -0
- package/src/ui/mention-list.tsx +106 -0
- package/src/ui/mention-suggestion.test.ts +18 -0
- package/src/ui/mention-suggestion.tsx +285 -0
- package/src/ui/mention.tsx +9 -0
- package/src/ui/reaction.tsx +16 -0
- package/src/ui/reactions.tsx +15 -0
- package/src/ui/render-markdown.tsx +72 -0
- package/src/ui/send-button.tsx +15 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { mstEdges } from './mst'
|
|
3
|
+
// These imports are red until step 2 is implemented — that is intentional.
|
|
4
|
+
// Implement `replay.ts` (and the step 2 additions to `types.ts`) per
|
|
5
|
+
// CLUSTERING-STEPS.md until this suite passes, without modifying this file.
|
|
6
|
+
import { cappedReplay, D_FLOOR } from './replay'
|
|
7
|
+
import { ClusterNode, LeafInput, MstEdge, RawMergeEvent } from './types'
|
|
8
|
+
|
|
9
|
+
function leaf(id: string, x: number, y: number): LeafInput {
|
|
10
|
+
return { id, point: { x, y } }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Deterministic PRNG so random tests are reproducible. */
|
|
14
|
+
function mulberry32(seed: number): () => number {
|
|
15
|
+
let a = seed >>> 0
|
|
16
|
+
return () => {
|
|
17
|
+
a = (a + 0x6d2b79f5) >>> 0
|
|
18
|
+
let t = a
|
|
19
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
20
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
21
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function shuffled<T>(items: readonly T[], rand: () => number): T[] {
|
|
26
|
+
const out = items.slice()
|
|
27
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
28
|
+
const j = Math.floor(rand() * (i + 1))
|
|
29
|
+
;[out[i], out[j]] = [out[j], out[i]]
|
|
30
|
+
}
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function randomLeaves(n: number, seed: number, scale = 1000): LeafInput[] {
|
|
35
|
+
const rand = mulberry32(seed)
|
|
36
|
+
return Array.from({ length: n }, (_, i) => leaf(`leaf-${i}`, rand() * scale, rand() * scale))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Order-independent, index-free projection of an event for cross-run comparison. */
|
|
40
|
+
function eventShape(ev: RawMergeEvent) {
|
|
41
|
+
return {
|
|
42
|
+
z: ev.z,
|
|
43
|
+
children: ev.children.map((c) => c.id),
|
|
44
|
+
result: ev.result.id,
|
|
45
|
+
members: ev.result.members,
|
|
46
|
+
count: ev.result.count,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function eventShapes(events: RawMergeEvent[]) {
|
|
51
|
+
return events.map(eventShape)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- Reference oracle -------------------------------------------------------
|
|
55
|
+
// Direct transcription of contract clause 4 (CLUSTERING-STEPS.md step 2): at
|
|
56
|
+
// each of the n−1 steps, evaluate every unfired MST edge's zEff against the
|
|
57
|
+
// CURRENT clusters and fire the highest, tie-broken by the edge's normalized
|
|
58
|
+
// id pair. O(n · E) per build — slow and obviously correct.
|
|
59
|
+
|
|
60
|
+
interface RefCluster {
|
|
61
|
+
members: string[] // sorted lexicographic; members[0] is the min-member id
|
|
62
|
+
centroid: { x: number; y: number }
|
|
63
|
+
count: number
|
|
64
|
+
minX: number
|
|
65
|
+
minY: number
|
|
66
|
+
maxX: number
|
|
67
|
+
maxY: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function refNode(c: RefCluster): ClusterNode {
|
|
71
|
+
return {
|
|
72
|
+
id: c.count === 1 ? c.members[0] : `cluster:${c.count}:${c.members[0]}`,
|
|
73
|
+
centroid: { x: c.centroid.x, y: c.centroid.y },
|
|
74
|
+
count: c.count,
|
|
75
|
+
members: c.members.slice(),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function refDiag(a: RefCluster, b: RefCluster): number {
|
|
80
|
+
const w = Math.max(a.maxX, b.maxX) - Math.min(a.minX, b.minX)
|
|
81
|
+
const h = Math.max(a.maxY, b.maxY) - Math.min(a.minY, b.minY)
|
|
82
|
+
return Math.hypot(w, h)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function referenceReplay(
|
|
86
|
+
leaves: readonly LeafInput[],
|
|
87
|
+
edges: readonly MstEdge[],
|
|
88
|
+
opts: { Tc: number; Dmax: number }
|
|
89
|
+
): RawMergeEvent[] {
|
|
90
|
+
const n = leaves.length
|
|
91
|
+
const parent = Array.from({ length: n }, (_, i) => i)
|
|
92
|
+
const find = (x: number): number => {
|
|
93
|
+
while (parent[x] !== x) {
|
|
94
|
+
parent[x] = parent[parent[x]]
|
|
95
|
+
x = parent[x]
|
|
96
|
+
}
|
|
97
|
+
return x
|
|
98
|
+
}
|
|
99
|
+
const clusters = new Map<number, RefCluster>()
|
|
100
|
+
for (let i = 0; i < n; i++) {
|
|
101
|
+
const p = leaves[i].point
|
|
102
|
+
clusters.set(i, {
|
|
103
|
+
members: [leaves[i].id],
|
|
104
|
+
centroid: { x: p.x, y: p.y },
|
|
105
|
+
count: 1,
|
|
106
|
+
minX: p.x,
|
|
107
|
+
minY: p.y,
|
|
108
|
+
maxX: p.x,
|
|
109
|
+
maxY: p.y,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const zOf = (e: MstEdge): number => {
|
|
114
|
+
// coincident anchors: permanently merged; the fit term is not consulted
|
|
115
|
+
if (e.d < D_FLOOR) return Infinity
|
|
116
|
+
const a = clusters.get(find(e.a))!
|
|
117
|
+
const b = clusters.get(find(e.b))!
|
|
118
|
+
// badge-anchored gap: priced by the clusters' centroid distance
|
|
119
|
+
const gap = opts.Tc / Math.hypot(a.centroid.x - b.centroid.x, a.centroid.y - b.centroid.y)
|
|
120
|
+
const fit = opts.Dmax / refDiag(a, b)
|
|
121
|
+
return Math.min(gap, fit)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const unfired = edges.slice()
|
|
125
|
+
const events: RawMergeEvent[] = []
|
|
126
|
+
let lastZ = Infinity // emitted thresholds are clamped non-increasing
|
|
127
|
+
while (unfired.length > 0) {
|
|
128
|
+
let best = 0
|
|
129
|
+
let bestZ = zOf(unfired[0])
|
|
130
|
+
for (let i = 1; i < unfired.length; i++) {
|
|
131
|
+
const z = zOf(unfired[i])
|
|
132
|
+
if (z > bestZ) {
|
|
133
|
+
best = i
|
|
134
|
+
bestZ = z
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
if (z === bestZ) {
|
|
138
|
+
// tie-break by the edge's normalized id pair, ascending idLo then idHi
|
|
139
|
+
const a = unfired[i]
|
|
140
|
+
const b = unfired[best]
|
|
141
|
+
const aLo = leaves[a.a].id
|
|
142
|
+
const aHi = leaves[a.b].id
|
|
143
|
+
const bLo = leaves[b.a].id
|
|
144
|
+
const bHi = leaves[b.b].id
|
|
145
|
+
if (aLo < bLo || (aLo === bLo && aHi < bHi)) {
|
|
146
|
+
best = i
|
|
147
|
+
bestZ = z
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const e = unfired.splice(best, 1)[0]
|
|
152
|
+
const ra = find(e.a)
|
|
153
|
+
const rb = find(e.b)
|
|
154
|
+
const ca = clusters.get(ra)!
|
|
155
|
+
const cb = clusters.get(rb)!
|
|
156
|
+
const [first, second] = ca.members[0] < cb.members[0] ? [ca, cb] : [cb, ca]
|
|
157
|
+
const count = ca.count + cb.count
|
|
158
|
+
const merged: RefCluster = {
|
|
159
|
+
members: [...ca.members, ...cb.members].sort(),
|
|
160
|
+
centroid: {
|
|
161
|
+
x: (ca.count * ca.centroid.x + cb.count * cb.centroid.x) / count,
|
|
162
|
+
y: (ca.count * ca.centroid.y + cb.count * cb.centroid.y) / count,
|
|
163
|
+
},
|
|
164
|
+
count,
|
|
165
|
+
minX: Math.min(ca.minX, cb.minX),
|
|
166
|
+
minY: Math.min(ca.minY, cb.minY),
|
|
167
|
+
maxX: Math.max(ca.maxX, cb.maxX),
|
|
168
|
+
maxY: Math.max(ca.maxY, cb.maxY),
|
|
169
|
+
}
|
|
170
|
+
parent[ra] = rb
|
|
171
|
+
clusters.delete(ra)
|
|
172
|
+
clusters.set(find(rb), merged)
|
|
173
|
+
const z = Math.min(bestZ, lastZ)
|
|
174
|
+
lastZ = z
|
|
175
|
+
events.push({
|
|
176
|
+
z,
|
|
177
|
+
children: [refNode(first), refNode(second)],
|
|
178
|
+
result: refNode(merged),
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
return events
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- Shared invariant assertions --------------------------------------------
|
|
185
|
+
|
|
186
|
+
function expectNonIncreasingZ(events: RawMergeEvent[]) {
|
|
187
|
+
for (let i = 1; i < events.length; i++) {
|
|
188
|
+
expect(events[i].z).toBeLessThanOrEqual(events[i - 1].z)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Dendrogram integrity: children are consumed exactly once, results are well
|
|
194
|
+
* formed unions, and replaying all events over the leaves ends in one root.
|
|
195
|
+
*/
|
|
196
|
+
function expectDendrogramIntegrity(events: RawMergeEvent[], leaves: readonly LeafInput[]) {
|
|
197
|
+
const live = new Map<string, ClusterNode>()
|
|
198
|
+
for (const l of leaves) {
|
|
199
|
+
live.set(l.id, { id: l.id, centroid: { ...l.point }, count: 1, members: [l.id] })
|
|
200
|
+
}
|
|
201
|
+
for (const ev of events) {
|
|
202
|
+
expect(ev.children).toHaveLength(2)
|
|
203
|
+
// children must currently exist (consumed exactly once, produced before use)
|
|
204
|
+
for (const child of ev.children) {
|
|
205
|
+
expect(live.get(child.id)).toBeDefined()
|
|
206
|
+
live.delete(child.id)
|
|
207
|
+
}
|
|
208
|
+
// children tuple ordered by ascending min-member id
|
|
209
|
+
expect(ev.children[0].members[0] < ev.children[1].members[0]).toBe(true)
|
|
210
|
+
// result members = sorted union of children members
|
|
211
|
+
const union = [...ev.children[0].members, ...ev.children[1].members].sort()
|
|
212
|
+
expect(ev.result.members).toEqual(union)
|
|
213
|
+
expect(ev.result.count).toBe(ev.children[0].count + ev.children[1].count)
|
|
214
|
+
expect(ev.result.id).toBe(`cluster:${ev.result.count}:${union[0]}`)
|
|
215
|
+
expect(live.has(ev.result.id)).toBe(false)
|
|
216
|
+
live.set(ev.result.id, ev.result)
|
|
217
|
+
}
|
|
218
|
+
// n−1 merges over a spanning tree always end in a single root
|
|
219
|
+
if (leaves.length >= 2) {
|
|
220
|
+
expect(live.size).toBe(1)
|
|
221
|
+
const root = [...live.values()][0]
|
|
222
|
+
expect(root.members).toEqual(leaves.map((l) => l.id).sort())
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Locality invariant: at its birth zoom, a cluster spans at most Dmax screen px. */
|
|
227
|
+
function expectLocality(events: RawMergeEvent[], leaves: readonly LeafInput[], Dmax: number) {
|
|
228
|
+
const pointOf = new Map(leaves.map((l) => [l.id, l.point]))
|
|
229
|
+
for (const ev of events) {
|
|
230
|
+
if (!Number.isFinite(ev.z)) continue
|
|
231
|
+
let minX = Infinity
|
|
232
|
+
let minY = Infinity
|
|
233
|
+
let maxX = -Infinity
|
|
234
|
+
let maxY = -Infinity
|
|
235
|
+
for (const id of ev.result.members) {
|
|
236
|
+
const p = pointOf.get(id)!
|
|
237
|
+
minX = Math.min(minX, p.x)
|
|
238
|
+
minY = Math.min(minY, p.y)
|
|
239
|
+
maxX = Math.max(maxX, p.x)
|
|
240
|
+
maxY = Math.max(maxY, p.y)
|
|
241
|
+
}
|
|
242
|
+
const diag = Math.hypot(maxX - minX, maxY - minY)
|
|
243
|
+
expect(ev.z * diag).toBeLessThanOrEqual(Dmax * (1 + 1e-9))
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Centroids equal the count-weighted mean = plain mean of all member anchors. */
|
|
248
|
+
function expectCentroids(events: RawMergeEvent[], leaves: readonly LeafInput[]) {
|
|
249
|
+
const pointOf = new Map(leaves.map((l) => [l.id, l.point]))
|
|
250
|
+
for (const ev of events) {
|
|
251
|
+
let sx = 0
|
|
252
|
+
let sy = 0
|
|
253
|
+
for (const id of ev.result.members) {
|
|
254
|
+
const p = pointOf.get(id)!
|
|
255
|
+
sx += p.x
|
|
256
|
+
sy += p.y
|
|
257
|
+
}
|
|
258
|
+
expect(ev.result.centroid.x).toBeCloseTo(sx / ev.result.count, 8)
|
|
259
|
+
expect(ev.result.centroid.y).toBeCloseTo(sy / ev.result.count, 8)
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// --- Fixtures ----------------------------------------------------------------
|
|
264
|
+
|
|
265
|
+
describe('cappedReplay fixtures', () => {
|
|
266
|
+
it('exports the pinned D_FLOOR constant', () => {
|
|
267
|
+
expect(D_FLOOR).toBe(1e-9)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('returns [] for fewer than two leaves', () => {
|
|
271
|
+
expect(cappedReplay([], [], { Tc: 40, Dmax: 120 })).toEqual([])
|
|
272
|
+
const one = [leaf('only', 3, 3)]
|
|
273
|
+
expect(cappedReplay(one, mstEdges(one), { Tc: 40, Dmax: 120 })).toEqual([])
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('merges a gap-limited pair at z = Tc/d with exact node bookkeeping', () => {
|
|
277
|
+
const leaves = [leaf('a', 0, 0), leaf('b', 10, 0)]
|
|
278
|
+
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
|
279
|
+
expect(events).toEqual([
|
|
280
|
+
{
|
|
281
|
+
z: 4,
|
|
282
|
+
children: [
|
|
283
|
+
{ id: 'a', centroid: { x: 0, y: 0 }, count: 1, members: ['a'] },
|
|
284
|
+
{ id: 'b', centroid: { x: 10, y: 0 }, count: 1, members: ['b'] },
|
|
285
|
+
],
|
|
286
|
+
result: { id: 'cluster:2:a', centroid: { x: 5, y: 0 }, count: 2, members: ['a', 'b'] },
|
|
287
|
+
},
|
|
288
|
+
])
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
it('gives coincident anchors z = Infinity', () => {
|
|
292
|
+
const leaves = [leaf('a', 7, 7), leaf('b', 7, 7)]
|
|
293
|
+
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
|
294
|
+
expect(events).toHaveLength(1)
|
|
295
|
+
expect(events[0].z).toBe(Infinity)
|
|
296
|
+
expect(events[0].result.id).toBe('cluster:2:a')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('treats sub-D_FLOOR distances as coincident, but not distances above it', () => {
|
|
300
|
+
const below = [leaf('a', 0, 0), leaf('b', 1e-12, 0)]
|
|
301
|
+
expect(cappedReplay(below, mstEdges(below), { Tc: 40, Dmax: 120 })[0].z).toBe(Infinity)
|
|
302
|
+
const above = [leaf('a', 0, 0), leaf('b', 1e-6, 0)]
|
|
303
|
+
const [ev] = cappedReplay(above, mstEdges(above), { Tc: 40, Dmax: 120 })
|
|
304
|
+
expect(ev.z).toBe(40 / 1e-6)
|
|
305
|
+
expect(Number.isFinite(ev.z)).toBe(true)
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
it('fires the coincident merge first, then the bridge (Infinity sorts to the head)', () => {
|
|
309
|
+
const leaves = [leaf('a', 5, 5), leaf('b', 5, 5), leaf('c', 105, 5)]
|
|
310
|
+
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
|
311
|
+
expect(eventShapes(events)).toEqual([
|
|
312
|
+
{ z: Infinity, children: ['a', 'b'], result: 'cluster:2:a', members: ['a', 'b'], count: 2 },
|
|
313
|
+
{
|
|
314
|
+
z: 40 / 100,
|
|
315
|
+
children: ['cluster:2:a', 'c'],
|
|
316
|
+
result: 'cluster:3:a',
|
|
317
|
+
members: ['a', 'b', 'c'],
|
|
318
|
+
count: 3,
|
|
319
|
+
},
|
|
320
|
+
])
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('replays the ABCDE path of CLUSTERING.md appendix A.1 (cap not binding)', () => {
|
|
324
|
+
// a —10— b —25— c —12— d —40— e, Tc = 300, cap effectively off.
|
|
325
|
+
// The MST edges still decide WHICH clusters merge; the gap term prices
|
|
326
|
+
// each merge by the clusters' CENTROID distance:
|
|
327
|
+
// a+b at 300/10 (leaves: centroid dist = edge length)
|
|
328
|
+
// c+d at 300/12... no — c–d edge, leaves, d = 12 → 300/12 = 25
|
|
329
|
+
// {a,b}+{c,d} via b–c: centroids (5,0) vs (41,0) → 300/36
|
|
330
|
+
// +e via d–e: centroids (23,0) vs (87,0) → 300/64
|
|
331
|
+
const leaves = [
|
|
332
|
+
leaf('a', 0, 0),
|
|
333
|
+
leaf('b', 10, 0),
|
|
334
|
+
leaf('c', 35, 0),
|
|
335
|
+
leaf('d', 47, 0),
|
|
336
|
+
leaf('e', 87, 0),
|
|
337
|
+
]
|
|
338
|
+
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 300, Dmax: 1e9 })
|
|
339
|
+
expect(eventShapes(events)).toEqual([
|
|
340
|
+
{ z: 30, children: ['a', 'b'], result: 'cluster:2:a', members: ['a', 'b'], count: 2 },
|
|
341
|
+
{ z: 25, children: ['c', 'd'], result: 'cluster:2:c', members: ['c', 'd'], count: 2 },
|
|
342
|
+
{
|
|
343
|
+
z: 300 / 36,
|
|
344
|
+
children: ['cluster:2:a', 'cluster:2:c'],
|
|
345
|
+
result: 'cluster:4:a',
|
|
346
|
+
members: ['a', 'b', 'c', 'd'],
|
|
347
|
+
count: 4,
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
z: 300 / 64,
|
|
351
|
+
children: ['cluster:4:a', 'e'],
|
|
352
|
+
result: 'cluster:5:a',
|
|
353
|
+
members: ['a', 'b', 'c', 'd', 'e'],
|
|
354
|
+
count: 5,
|
|
355
|
+
},
|
|
356
|
+
])
|
|
357
|
+
// exact incremental centroids: (5,0), (41,0), (23,0), (35.8,0)
|
|
358
|
+
expect(events.map((e) => e.result.centroid.x)).toEqual([5, 41, 23, 35.8])
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it('prices cluster merges by badge (centroid) distance, not nearest members', () => {
|
|
362
|
+
// Two tight pairs whose facing members are only 14 apart but whose
|
|
363
|
+
// centroids are 20 apart. Nearest-member pricing would join the badges at
|
|
364
|
+
// z = 40/14 ≈ 2.86 — while the rendered badges still look far apart.
|
|
365
|
+
// Centroid pricing fires at 40/20 = 2.
|
|
366
|
+
const leaves = [leaf('a', 0, 0), leaf('b', 6, 0), leaf('c', 20, 0), leaf('d', 26, 0)]
|
|
367
|
+
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 1000 })
|
|
368
|
+
expect(events.map((e) => e.z)).toEqual([40 / 6, 40 / 6, 2])
|
|
369
|
+
expect(events[2].children.map((c) => c.id)).toEqual(['cluster:2:a', 'cluster:2:c'])
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('builds the 8-pin chain hierarchically: pairs, then quads, then the octet', () => {
|
|
373
|
+
// p1..p8 spaced 10 apart, Tc = 40, Dmax = 120 (CLUSTERING.md appendix A.2).
|
|
374
|
+
// Leaf pairs all price at 40/10 = 4. Once a pair forms, extending it is
|
|
375
|
+
// priced from the pair's CENTROID (dist 15 → z ≈ 2.67), which loses ties
|
|
376
|
+
// against the remaining leaf pairs — so the chain forms 4 pairs first.
|
|
377
|
+
// Pair-pair merges price at centroid dist 20 → z = 2 (quads), and the
|
|
378
|
+
// final quad-quad bridge at centroid dist 40 (centroids 15 and 55) → z = 1.
|
|
379
|
+
// Centroid pricing alone now produces the progressive doubling the cap
|
|
380
|
+
// used to force; the cap remains as the hard extent guarantee.
|
|
381
|
+
const leaves = Array.from({ length: 8 }, (_, i) => leaf(`p${i + 1}`, i * 10, 0))
|
|
382
|
+
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
|
383
|
+
expect(events.map((e) => e.z)).toEqual([4, 4, 4, 4, 2, 2, 1])
|
|
384
|
+
expect(events.map((e) => e.result.id)).toEqual([
|
|
385
|
+
'cluster:2:p1',
|
|
386
|
+
'cluster:2:p3',
|
|
387
|
+
'cluster:2:p5',
|
|
388
|
+
'cluster:2:p7',
|
|
389
|
+
'cluster:4:p1',
|
|
390
|
+
'cluster:4:p5',
|
|
391
|
+
'cluster:8:p1',
|
|
392
|
+
])
|
|
393
|
+
expect(events[4].children.map((c) => c.id)).toEqual(['cluster:2:p1', 'cluster:2:p3'])
|
|
394
|
+
expect(events[6].children.map((c) => c.id)).toEqual(['cluster:4:p1', 'cluster:4:p5'])
|
|
395
|
+
expect(events[6].result.members).toEqual(['p1', 'p2', 'p3', 'p4', 'p5', 'p6', 'p7', 'p8'])
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it('computes the fit term from the union bbox DIAGONAL, not an axis span', () => {
|
|
399
|
+
// {a,b} spans (0,0)–(0,30); adding c(40,30) makes the union bbox 40×30,
|
|
400
|
+
// diag hypot(40,30) = 50. With Tc = 100, Dmax = 110:
|
|
401
|
+
// a–b: leaves at dist 30 → min(100/30, 110/30) = 100/30 (gap-limited)
|
|
402
|
+
// b–c after a+b: centroid (0,15) to c(40,30) → gap = 100/√1825 ≈ 2.34;
|
|
403
|
+
// fit via the diagonal = 110/50 = 2.2 wins (an axis-span implementation
|
|
404
|
+
// would give 110/40 = 2.75 and the gap term would win instead)
|
|
405
|
+
const leaves = [leaf('a', 0, 0), leaf('b', 0, 30), leaf('c', 40, 30)]
|
|
406
|
+
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 100, Dmax: 110 })
|
|
407
|
+
expect(events.map((e) => e.z)).toEqual([100 / 30, 2.2])
|
|
408
|
+
expect(events[1].result.id).toBe('cluster:3:a')
|
|
409
|
+
})
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
// --- Properties against the reference oracle ---------------------------------
|
|
413
|
+
|
|
414
|
+
describe('cappedReplay vs reference simulator', () => {
|
|
415
|
+
const OPTION_SETS = [
|
|
416
|
+
{ Tc: 40, Dmax: 120 }, // default shape
|
|
417
|
+
{ Tc: 40, Dmax: 40 }, // tightest legal cap — maximally fit-limited
|
|
418
|
+
{ Tc: 5, Dmax: 1000 }, // cap effectively off
|
|
419
|
+
]
|
|
420
|
+
|
|
421
|
+
it.each([2, 3, 5, 10, 40, 80])('matches the oracle on %i random points', (n) => {
|
|
422
|
+
for (const seed of [1, 42]) {
|
|
423
|
+
const leaves = randomLeaves(n, seed * 31 + n)
|
|
424
|
+
const edges = mstEdges(leaves)
|
|
425
|
+
for (const opts of OPTION_SETS) {
|
|
426
|
+
const actual = cappedReplay(leaves, edges, opts)
|
|
427
|
+
const expected = referenceReplay(leaves, edges, opts)
|
|
428
|
+
expect(eventShapes(actual)).toEqual(eventShapes(expected))
|
|
429
|
+
// pinned formulas (hypot diag, incremental centroid) ⇒ exact floats
|
|
430
|
+
expect(actual.map((e) => e.z)).toEqual(expected.map((e) => e.z))
|
|
431
|
+
for (let i = 0; i < actual.length; i++) {
|
|
432
|
+
expect(actual[i].result.centroid).toEqual(expected[i].result.centroid)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
it('matches the oracle on mixed-sign and far-offset coordinates', () => {
|
|
439
|
+
const rand = mulberry32(101)
|
|
440
|
+
const mixed = Array.from({ length: 40 }, (_, i) =>
|
|
441
|
+
leaf(`m-${i}`, (rand() - 0.5) * 2000, (rand() - 0.5) * 2000)
|
|
442
|
+
)
|
|
443
|
+
const offset = Array.from({ length: 40 }, (_, i) =>
|
|
444
|
+
leaf(`f-${i}`, -5_000_000 + rand() * 1000, 3_000_000 + rand() * 1000)
|
|
445
|
+
)
|
|
446
|
+
for (const leaves of [mixed, offset]) {
|
|
447
|
+
const edges = mstEdges(leaves)
|
|
448
|
+
for (const opts of OPTION_SETS) {
|
|
449
|
+
expect(eventShapes(cappedReplay(leaves, edges, opts))).toEqual(
|
|
450
|
+
eventShapes(referenceReplay(leaves, edges, opts))
|
|
451
|
+
)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
it('matches the oracle on lattice points (exact ties + coincident anchors)', () => {
|
|
457
|
+
const rand = mulberry32(7)
|
|
458
|
+
const leaves = Array.from({ length: 50 }, (_, i) =>
|
|
459
|
+
leaf(`lat-${i}`, Math.floor(rand() * 10) * 10, Math.floor(rand() * 10) * 10)
|
|
460
|
+
)
|
|
461
|
+
const edges = mstEdges(leaves)
|
|
462
|
+
for (const opts of OPTION_SETS) {
|
|
463
|
+
expect(eventShapes(cappedReplay(leaves, edges, opts))).toEqual(
|
|
464
|
+
eventShapes(referenceReplay(leaves, edges, opts))
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
})
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
describe('cappedReplay invariants', () => {
|
|
471
|
+
it('satisfies all structural invariants on random inputs', () => {
|
|
472
|
+
for (const seed of [3, 99]) {
|
|
473
|
+
for (const n of [2, 7, 30, 60]) {
|
|
474
|
+
const leaves = randomLeaves(n, seed * 17 + n)
|
|
475
|
+
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 120 })
|
|
476
|
+
expect(events).toHaveLength(n - 1)
|
|
477
|
+
expectNonIncreasingZ(events)
|
|
478
|
+
expectDendrogramIntegrity(events, leaves)
|
|
479
|
+
expectLocality(events, leaves, 120)
|
|
480
|
+
expectCentroids(events, leaves)
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
it('holds the locality invariant even at the tightest legal cap', () => {
|
|
486
|
+
const leaves = randomLeaves(50, 13)
|
|
487
|
+
const events = cappedReplay(leaves, mstEdges(leaves), { Tc: 40, Dmax: 40 })
|
|
488
|
+
expectNonIncreasingZ(events)
|
|
489
|
+
expectDendrogramIntegrity(events, leaves)
|
|
490
|
+
expectLocality(events, leaves, 40)
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
it('is invariant under input permutation (as id-keyed event structures)', () => {
|
|
494
|
+
const base = randomLeaves(40, 21)
|
|
495
|
+
const expected = eventShapes(cappedReplay(base, mstEdges(base), { Tc: 40, Dmax: 120 }))
|
|
496
|
+
for (const shuffleSeed of [5, 23]) {
|
|
497
|
+
const perm = shuffled(base, mulberry32(shuffleSeed))
|
|
498
|
+
expect(eventShapes(cappedReplay(perm, mstEdges(perm), { Tc: 40, Dmax: 120 }))).toEqual(
|
|
499
|
+
expected
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('is pure: identical repeat output, no input mutation', () => {
|
|
505
|
+
const leaves = randomLeaves(20, 5)
|
|
506
|
+
const edges = mstEdges(leaves)
|
|
507
|
+
const leavesSnapshot = JSON.parse(JSON.stringify(leaves))
|
|
508
|
+
const edgesSnapshot = JSON.parse(JSON.stringify(edges))
|
|
509
|
+
const first = cappedReplay(leaves, edges, { Tc: 40, Dmax: 120 })
|
|
510
|
+
const second = cappedReplay(leaves, edges, { Tc: 40, Dmax: 120 })
|
|
511
|
+
expect(first).toEqual(second)
|
|
512
|
+
expect(leaves).toEqual(leavesSnapshot)
|
|
513
|
+
expect(edges).toEqual(edgesSnapshot)
|
|
514
|
+
})
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
describe('cappedReplay validation', () => {
|
|
518
|
+
const leaves = [leaf('a', 0, 0), leaf('b', 10, 0)]
|
|
519
|
+
const edges = mstEdges(leaves)
|
|
520
|
+
|
|
521
|
+
it('throws on non-positive Tc, naming the option', () => {
|
|
522
|
+
expect(() => cappedReplay(leaves, edges, { Tc: 0, Dmax: 120 })).toThrow(/Tc/)
|
|
523
|
+
expect(() => cappedReplay(leaves, edges, { Tc: -5, Dmax: 120 })).toThrow(/Tc/)
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('throws on non-positive Dmax, naming the option', () => {
|
|
527
|
+
expect(() => cappedReplay(leaves, edges, { Tc: 40, Dmax: 0 })).toThrow(/Dmax/)
|
|
528
|
+
expect(() => cappedReplay(leaves, edges, { Tc: 40, Dmax: -1 })).toThrow(/Dmax/)
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('throws when Dmax < Tc, naming the option', () => {
|
|
532
|
+
expect(() => cappedReplay(leaves, edges, { Tc: 40, Dmax: 30 })).toThrow(/Dmax/)
|
|
533
|
+
})
|
|
534
|
+
})
|
|
535
|
+
|
|
536
|
+
describe('cappedReplay performance', () => {
|
|
537
|
+
it('replays 2,000 points well within budget', () => {
|
|
538
|
+
const leaves = randomLeaves(2000, 2026)
|
|
539
|
+
const edges = mstEdges(leaves)
|
|
540
|
+
const start = performance.now()
|
|
541
|
+
const events = cappedReplay(leaves, edges, { Tc: 40, Dmax: 120 })
|
|
542
|
+
const elapsed = performance.now() - start
|
|
543
|
+
expect(events).toHaveLength(1999)
|
|
544
|
+
// generous bound to stay non-flaky on slow CI
|
|
545
|
+
expect(elapsed).toBeLessThan(2000)
|
|
546
|
+
})
|
|
547
|
+
})
|