@lovett/ui 0.0.11 → 0.2.0
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/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- package/src/tokens.css +112 -0
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
//
|
|
3
|
+
// Pure arithmetic — no DOM, no React, no clock, no randomness.
|
|
4
|
+
/**
|
|
5
|
+
* Contract tests for `lib/chart.ts` (ADR-146 D6).
|
|
6
|
+
*
|
|
7
|
+
* The edge cases ARE the job here. ADR-146 accepts the cost of hand-rolled
|
|
8
|
+
* SVG explicitly: "we own the axis edge cases (a single data point, all-zero
|
|
9
|
+
* series, negative values, one-category donut) that a library would have
|
|
10
|
+
* handled. Each is a named test case." This file is that list.
|
|
11
|
+
*
|
|
12
|
+
* The failure mode being defended against is not a wrong number — it is a
|
|
13
|
+
* `NaN` or an `Infinity` reaching an SVG attribute, which does not throw and
|
|
14
|
+
* does not warn: the browser drops the element and the chart renders blank
|
|
15
|
+
* with typecheck, lint, build and every other test green. So on top of the
|
|
16
|
+
* per-function cases, `never emits NaN or Infinity` walks every function with
|
|
17
|
+
* a shared table of hostile inputs.
|
|
18
|
+
*
|
|
19
|
+
* Float assertions go through `near()` (epsilon), never `toBe` (CLAUDE.md §7).
|
|
20
|
+
* Path strings compare exactly on purpose — `linePath` rounds to a fixed 2dp
|
|
21
|
+
* precisely so its output is assertable.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { describe, expect, it } from 'vitest'
|
|
25
|
+
import {
|
|
26
|
+
arcGeometry,
|
|
27
|
+
arcSegments,
|
|
28
|
+
areaPath,
|
|
29
|
+
bandScale,
|
|
30
|
+
clampPct,
|
|
31
|
+
estimateTextWidth,
|
|
32
|
+
fitLabelCount,
|
|
33
|
+
fitTickCount,
|
|
34
|
+
fitTicks,
|
|
35
|
+
linePath,
|
|
36
|
+
linearScale,
|
|
37
|
+
monthDomain,
|
|
38
|
+
niceTicks,
|
|
39
|
+
normalizeToMax,
|
|
40
|
+
ringDash,
|
|
41
|
+
stackFractions,
|
|
42
|
+
stackTotals,
|
|
43
|
+
strideIndices,
|
|
44
|
+
type NumericInput,
|
|
45
|
+
type Point,
|
|
46
|
+
} from '../lib/chart'
|
|
47
|
+
|
|
48
|
+
const EPS = 1e-9
|
|
49
|
+
|
|
50
|
+
/** Epsilon float assertion. Never `==`. */
|
|
51
|
+
function near(actual: number, expected: number, eps = EPS): void {
|
|
52
|
+
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(eps)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Every hostile scalar a caller can realistically hand us. */
|
|
56
|
+
const HOSTILE: readonly NumericInput[] = [
|
|
57
|
+
NaN,
|
|
58
|
+
Infinity,
|
|
59
|
+
-Infinity,
|
|
60
|
+
null,
|
|
61
|
+
undefined,
|
|
62
|
+
0,
|
|
63
|
+
-0,
|
|
64
|
+
-1,
|
|
65
|
+
Number.MAX_VALUE,
|
|
66
|
+
Number.MIN_VALUE,
|
|
67
|
+
-Number.MAX_VALUE,
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
describe('linearScale', () => {
|
|
71
|
+
it('maps a domain onto a range', () => {
|
|
72
|
+
const scale = linearScale([0, 100], [0, 200])
|
|
73
|
+
near(scale(0), 0)
|
|
74
|
+
near(scale(50), 100)
|
|
75
|
+
near(scale(100), 200)
|
|
76
|
+
near(scale(12.5), 25)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('supports an inverted range (SVG y grows downward)', () => {
|
|
80
|
+
const scale = linearScale([0, 10], [100, 0])
|
|
81
|
+
near(scale(0), 100)
|
|
82
|
+
near(scale(5), 50)
|
|
83
|
+
near(scale(10), 0)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('does not clamp — an out-of-domain value maps out of the range', () => {
|
|
87
|
+
const scale = linearScale([0, 10], [0, 100])
|
|
88
|
+
near(scale(15), 150)
|
|
89
|
+
near(scale(-5), -50)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('handles a negative domain', () => {
|
|
93
|
+
const scale = linearScale([-50, 50], [0, 100])
|
|
94
|
+
near(scale(-50), 0)
|
|
95
|
+
near(scale(0), 50)
|
|
96
|
+
near(scale(50), 100)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('min === max: every value maps to the middle of the range', () => {
|
|
100
|
+
const scale = linearScale([7, 7], [0, 200])
|
|
101
|
+
near(scale(7), 100)
|
|
102
|
+
near(scale(-1000), 100)
|
|
103
|
+
near(scale(1000), 100)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('a single-datum domain still plots inside the range', () => {
|
|
107
|
+
const only = 42
|
|
108
|
+
const scale = linearScale([only, only], [120, 0])
|
|
109
|
+
near(scale(only), 60)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('non-finite input maps to the range start, never NaN', () => {
|
|
113
|
+
const scale = linearScale([0, 10], [5, 105])
|
|
114
|
+
for (const bad of [NaN, Infinity, -Infinity, null, undefined] as const) {
|
|
115
|
+
near(scale(bad), 5)
|
|
116
|
+
}
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('non-finite domain and range bounds are read as zero', () => {
|
|
120
|
+
near(linearScale([NaN, 10], [0, 100])(5), 50)
|
|
121
|
+
near(linearScale([0, 10], [Infinity, 100])(10), 100)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('returns the range start rather than overflowing to Infinity', () => {
|
|
125
|
+
const scale = linearScale([0, Number.MIN_VALUE], [0, Number.MAX_VALUE])
|
|
126
|
+
const out = scale(1e300)
|
|
127
|
+
expect(Number.isFinite(out)).toBe(true)
|
|
128
|
+
near(out, 0)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
describe('bandScale', () => {
|
|
133
|
+
it('splits a width into centred bands with the gap shared evenly', () => {
|
|
134
|
+
const scale = bandScale(4, 100, 0.2)
|
|
135
|
+
near(scale.step, 25)
|
|
136
|
+
near(scale.band, 20)
|
|
137
|
+
near(scale.at(0), 2.5)
|
|
138
|
+
near(scale.at(3), 77.5)
|
|
139
|
+
near(scale.center(0), 12.5)
|
|
140
|
+
near(scale.center(3), 87.5)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('a one-category chart is a legal chart', () => {
|
|
144
|
+
const scale = bandScale(1, 200, 0.2)
|
|
145
|
+
near(scale.step, 200)
|
|
146
|
+
near(scale.band, 160)
|
|
147
|
+
near(scale.at(0), 20)
|
|
148
|
+
near(scale.center(0), 100)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('count 0 collapses to zeros instead of dividing by zero', () => {
|
|
152
|
+
const scale = bandScale(0, 100, 0.2)
|
|
153
|
+
expect(scale.band).toBe(0)
|
|
154
|
+
expect(scale.step).toBe(0)
|
|
155
|
+
expect(scale.at(0)).toBe(0)
|
|
156
|
+
expect(scale.center(3)).toBe(0)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('width 0 collapses to zeros', () => {
|
|
160
|
+
const scale = bandScale(5, 0, 0.2)
|
|
161
|
+
expect(scale.band).toBe(0)
|
|
162
|
+
expect(scale.step).toBe(0)
|
|
163
|
+
expect(scale.at(2)).toBe(0)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('padding 0 makes bands touch; padding 1 makes them vanish', () => {
|
|
167
|
+
const flush = bandScale(4, 100, 0)
|
|
168
|
+
near(flush.band, flush.step)
|
|
169
|
+
near(flush.at(0), 0)
|
|
170
|
+
|
|
171
|
+
const empty = bandScale(4, 100, 1)
|
|
172
|
+
near(empty.band, 0)
|
|
173
|
+
near(empty.step, 25)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('clamps paddingRatio into 0..1', () => {
|
|
177
|
+
near(bandScale(4, 100, -3).band, bandScale(4, 100, 0).band)
|
|
178
|
+
near(bandScale(4, 100, 9).band, bandScale(4, 100, 1).band)
|
|
179
|
+
near(bandScale(4, 100, NaN).band, bandScale(4, 100, 0).band)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('clamps and floors the index instead of returning NaN', () => {
|
|
183
|
+
const scale = bandScale(4, 100, 0.2)
|
|
184
|
+
near(scale.at(-99), scale.at(0))
|
|
185
|
+
near(scale.at(99), scale.at(3))
|
|
186
|
+
near(scale.at(1.9), scale.at(1))
|
|
187
|
+
near(scale.at(NaN), scale.at(0))
|
|
188
|
+
near(scale.at(null), scale.at(0))
|
|
189
|
+
near(scale.center(Infinity), scale.center(0))
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('non-finite count or width collapses to zeros', () => {
|
|
193
|
+
expect(bandScale(NaN, 100).step).toBe(0)
|
|
194
|
+
expect(bandScale(4, Infinity).step).toBe(0)
|
|
195
|
+
expect(bandScale(-4, 100).step).toBe(0)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('niceTicks', () => {
|
|
200
|
+
it('rounds a 0..100 axis onto the 1/2/5 ladder', () => {
|
|
201
|
+
const ticks = niceTicks(0, 100, 5)
|
|
202
|
+
expect(ticks).toHaveLength(6)
|
|
203
|
+
ticks.forEach((t, i) => near(t, i * 20))
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('does not emit float noise on a fractional axis', () => {
|
|
207
|
+
const ticks = niceTicks(0, 1, 5)
|
|
208
|
+
expect(ticks).toHaveLength(6)
|
|
209
|
+
ticks.forEach((t, i) => near(t, i * 0.2))
|
|
210
|
+
// The 0.30000000000000004 defect: every tick must survive a round-trip
|
|
211
|
+
// through 6dp unchanged.
|
|
212
|
+
for (const t of ticks) near(t, Number(t.toFixed(6)), 1e-12)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('does not open a phantom tick below the domain (the floor/float trap)', () => {
|
|
216
|
+
// `0.3 / 0.1` is 2.9999999999999996, so an unguarded Math.floor starts
|
|
217
|
+
// this axis at 0.2 — a gridline below data that does not exist.
|
|
218
|
+
const ticks = niceTicks(0.3, 0.7, 5)
|
|
219
|
+
expect(ticks).toHaveLength(5)
|
|
220
|
+
ticks.forEach((t, i) => near(t, 0.3 + i * 0.1))
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('a domain of one value yields exactly one tick', () => {
|
|
224
|
+
expect(niceTicks(5, 5, 5)).toEqual([5])
|
|
225
|
+
expect(niceTicks(0, 0, 5)).toEqual([0])
|
|
226
|
+
expect(niceTicks(-3.5, -3.5, 5)).toEqual([-3.5])
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('swaps reversed bounds', () => {
|
|
230
|
+
expect(niceTicks(100, 0, 5)).toEqual(niceTicks(0, 100, 5))
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('spans negatives through zero', () => {
|
|
234
|
+
const ticks = niceTicks(-50, 50, 5)
|
|
235
|
+
expect(ticks.some((t) => Math.abs(t) < EPS)).toBe(true)
|
|
236
|
+
expect(ticks[0] ?? 0).toBeLessThanOrEqual(-50)
|
|
237
|
+
expect(ticks[ticks.length - 1] ?? 0).toBeGreaterThanOrEqual(50)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('always covers the requested domain and stays monotonic', () => {
|
|
241
|
+
const cases: readonly (readonly [number, number])[] = [
|
|
242
|
+
[0, 1],
|
|
243
|
+
[0, 7],
|
|
244
|
+
[3, 9],
|
|
245
|
+
[-12, -2],
|
|
246
|
+
[-1, 1],
|
|
247
|
+
[0.004, 0.017],
|
|
248
|
+
[1e-9, 5e-9],
|
|
249
|
+
[1e12, 3e12],
|
|
250
|
+
[0, Number.MAX_SAFE_INTEGER],
|
|
251
|
+
]
|
|
252
|
+
for (const [lo, hi] of cases) {
|
|
253
|
+
const ticks = niceTicks(lo, hi, 5)
|
|
254
|
+
expect(ticks.length).toBeGreaterThanOrEqual(2)
|
|
255
|
+
const first = ticks[0] ?? 0
|
|
256
|
+
const last = ticks[ticks.length - 1] ?? 0
|
|
257
|
+
expect(first).toBeLessThanOrEqual(lo + Math.abs(lo) * 1e-9)
|
|
258
|
+
expect(last).toBeGreaterThanOrEqual(hi - Math.abs(hi) * 1e-9)
|
|
259
|
+
for (let i = 1; i < ticks.length; i++) {
|
|
260
|
+
expect(ticks[i] ?? 0).toBeGreaterThan(ticks[i - 1] ?? 0)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('reads non-finite bounds as zero', () => {
|
|
266
|
+
expect(niceTicks(NaN, NaN, 5)).toEqual([0])
|
|
267
|
+
expect(niceTicks(null, undefined, 5)).toEqual([0])
|
|
268
|
+
expect(niceTicks(Infinity, -Infinity, 5)).toEqual([0])
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
it('forces a minimum of two ticks however small the target count', () => {
|
|
272
|
+
for (const count of [1, 0, -5, NaN, null, undefined] as const) {
|
|
273
|
+
expect(niceTicks(0, 10, count).length).toBeGreaterThanOrEqual(2)
|
|
274
|
+
}
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('caps the emitted count so no input allocates without bound', () => {
|
|
278
|
+
expect(niceTicks(0, 1e6, 1e9).length).toBeLessThanOrEqual(1001)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('every tick is finite for every hostile pairing', () => {
|
|
282
|
+
for (const lo of HOSTILE) {
|
|
283
|
+
for (const hi of HOSTILE) {
|
|
284
|
+
for (const t of niceTicks(lo, hi, 5)) {
|
|
285
|
+
expect(Number.isFinite(t)).toBe(true)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
describe('linePath', () => {
|
|
293
|
+
const p = (x: number, y: number): Point => ({ x, y })
|
|
294
|
+
|
|
295
|
+
it('empty data renders nothing', () => {
|
|
296
|
+
expect(linePath([])).toBe('')
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('a single point renders a dot-length segment, not a blank path', () => {
|
|
300
|
+
expect(linePath([p(10, 20)])).toBe('M10,20 L10,20')
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('emits M then L for a polyline', () => {
|
|
304
|
+
expect(linePath([p(0, 0), p(10, 5), p(20, 0)])).toBe('M0,0 L10,5 L20,0')
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('rounds coordinates to 2dp and normalises -0', () => {
|
|
308
|
+
expect(linePath([p(1.234, 2.567), p(-0, 0)])).toBe('M1.23,2.57 L0,0')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('drops unplottable points rather than voiding the whole path', () => {
|
|
312
|
+
expect(linePath([p(0, 0), p(NaN, 5), p(10, 10)])).toBe('M0,0 L10,10')
|
|
313
|
+
expect(linePath([p(0, 0), p(5, Infinity), p(10, 10)])).toBe('M0,0 L10,10')
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('all points unplottable renders nothing', () => {
|
|
317
|
+
expect(linePath([p(NaN, NaN), p(Infinity, 0)])).toBe('')
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('never emits NaN in path data', () => {
|
|
321
|
+
const points = HOSTILE.map((v, i) => p(finiteish(v), finiteish(HOSTILE[i]) + i))
|
|
322
|
+
expect(linePath(points)).not.toMatch(/NaN|Infinity/)
|
|
323
|
+
})
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
describe('areaPath', () => {
|
|
327
|
+
const p = (x: number, y: number): Point => ({ x, y })
|
|
328
|
+
|
|
329
|
+
it('empty data renders nothing', () => {
|
|
330
|
+
expect(areaPath([], 100)).toBe('')
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('a single point has no area', () => {
|
|
334
|
+
expect(areaPath([p(10, 20)], 100)).toBe('')
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
it('closes the polyline down to the baseline', () => {
|
|
338
|
+
expect(areaPath([p(0, 0), p(10, 5)], 100)).toBe('M0,0 L10,5 L10,100 L0,100 Z')
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('reads a non-finite baseline as zero', () => {
|
|
342
|
+
expect(areaPath([p(0, 4), p(10, 5)], NaN)).toBe('M0,4 L10,5 L10,0 L0,0 Z')
|
|
343
|
+
expect(areaPath([p(0, 4), p(10, 5)], null)).toBe('M0,4 L10,5 L10,0 L0,0 Z')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it('closes from the first and last PLOTTABLE point', () => {
|
|
347
|
+
expect(areaPath([p(NaN, 1), p(2, 3), p(8, 6), p(9, Infinity)], 50)).toBe(
|
|
348
|
+
'M2,3 L8,6 L8,50 L2,50 Z',
|
|
349
|
+
)
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
describe('arcGeometry', () => {
|
|
354
|
+
it('puts the centreline half a stroke inside the outer edge', () => {
|
|
355
|
+
const g = arcGeometry(50, 10)
|
|
356
|
+
near(g.radius, 45)
|
|
357
|
+
near(g.circumference, 2 * Math.PI * 45)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('reproduces CompletionRing’s (size - strokeWidth) / 2', () => {
|
|
361
|
+
const size = 30
|
|
362
|
+
const strokeWidth = 3
|
|
363
|
+
near(arcGeometry(size / 2, strokeWidth).radius, (size - strokeWidth) / 2)
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
it('a stroke wider than the ring clamps to zero, never negative', () => {
|
|
367
|
+
const g = arcGeometry(4, 20)
|
|
368
|
+
expect(g.radius).toBe(0)
|
|
369
|
+
expect(g.circumference).toBe(0)
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
it('absorbs non-finite and negative inputs', () => {
|
|
373
|
+
for (const bad of HOSTILE) {
|
|
374
|
+
const g = arcGeometry(bad, 2)
|
|
375
|
+
expect(Number.isFinite(g.radius)).toBe(true)
|
|
376
|
+
expect(Number.isFinite(g.circumference)).toBe(true)
|
|
377
|
+
expect(g.radius).toBeGreaterThanOrEqual(0)
|
|
378
|
+
}
|
|
379
|
+
})
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
describe('arcSegments', () => {
|
|
383
|
+
const C = 2 * Math.PI * 45 // arcGeometry(50, 10)
|
|
384
|
+
|
|
385
|
+
it('empty data yields no segments', () => {
|
|
386
|
+
expect(arcSegments([], 50, 10)).toEqual([])
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('a one-category donut is a single complete ring', () => {
|
|
390
|
+
const [only] = arcSegments([12], 50, 10)
|
|
391
|
+
expect(only).toBeDefined()
|
|
392
|
+
if (only === undefined) return
|
|
393
|
+
near(only.fraction, 1)
|
|
394
|
+
near(only.startFraction, 0)
|
|
395
|
+
expect(only.dasharray).toBe(`${round3(C)} 0`)
|
|
396
|
+
expect(only.dashoffset).toBe(0)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('lays slices head to tail', () => {
|
|
400
|
+
const segments = arcSegments([50, 30, 20], 50, 10)
|
|
401
|
+
expect(segments).toHaveLength(3)
|
|
402
|
+
near(segments[0]?.fraction ?? 0, 0.5)
|
|
403
|
+
near(segments[1]?.fraction ?? 0, 0.3)
|
|
404
|
+
near(segments[2]?.fraction ?? 0, 0.2)
|
|
405
|
+
near(segments[0]?.startFraction ?? -1, 0)
|
|
406
|
+
near(segments[1]?.startFraction ?? -1, 0.5)
|
|
407
|
+
near(segments[2]?.startFraction ?? -1, 0.8)
|
|
408
|
+
expect(segments[0]?.dashoffset).toBe(0)
|
|
409
|
+
near(segments[1]?.dashoffset ?? 0, -0.5 * C, 1e-3)
|
|
410
|
+
near(segments[2]?.dashoffset ?? 0, -0.8 * C, 1e-3)
|
|
411
|
+
near(
|
|
412
|
+
segments.reduce((sum, s) => sum + s.fraction, 0),
|
|
413
|
+
1,
|
|
414
|
+
)
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
it('an all-zero series renders an empty ring instead of dividing by zero', () => {
|
|
418
|
+
const segments = arcSegments([0, 0, 0], 50, 10)
|
|
419
|
+
expect(segments).toHaveLength(3)
|
|
420
|
+
for (const s of segments) {
|
|
421
|
+
expect(s.fraction).toBe(0)
|
|
422
|
+
expect(s.dasharray).toBe(`0 ${round3(C)}`)
|
|
423
|
+
expect(s.dashoffset).toBe(0)
|
|
424
|
+
}
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
it('clamps negative values to zero and excludes them from the total', () => {
|
|
428
|
+
const segments = arcSegments([10, -5, 10], 50, 10)
|
|
429
|
+
near(segments[0]?.fraction ?? 0, 0.5)
|
|
430
|
+
expect(segments[1]?.fraction).toBe(0)
|
|
431
|
+
near(segments[2]?.fraction ?? 0, 0.5)
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
it('reads non-finite values as zero', () => {
|
|
435
|
+
const segments = arcSegments([NaN, null, undefined, Infinity, 10], 50, 10)
|
|
436
|
+
near(segments[4]?.fraction ?? 0, 1)
|
|
437
|
+
for (const s of segments.slice(0, 4)) expect(s.fraction).toBe(0)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it('keeps fractions correct at zero radius so a legend stays right', () => {
|
|
441
|
+
const segments = arcSegments([1, 1], 0, 0)
|
|
442
|
+
near(segments[0]?.fraction ?? 0, 0.5)
|
|
443
|
+
expect(segments[0]?.dasharray).toBe('0 0')
|
|
444
|
+
expect(segments[1]?.dashoffset).toBe(0)
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('never emits NaN in a dash value', () => {
|
|
448
|
+
for (const radius of HOSTILE) {
|
|
449
|
+
for (const s of arcSegments(HOSTILE, radius, 2)) {
|
|
450
|
+
expect(s.dasharray).not.toMatch(/NaN|Infinity/)
|
|
451
|
+
expect(Number.isFinite(s.dashoffset)).toBe(true)
|
|
452
|
+
expect(Number.isFinite(s.fraction)).toBe(true)
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
})
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
describe('ringDash', () => {
|
|
459
|
+
it('0% leaves the whole turn unpainted, 100% paints all of it', () => {
|
|
460
|
+
const empty = ringDash(0, 15, 3)
|
|
461
|
+
near(empty.dashoffset, empty.dasharray, 1e-3)
|
|
462
|
+
const full = ringDash(100, 15, 3)
|
|
463
|
+
expect(full.dashoffset).toBe(0)
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it('50% leaves half the turn unpainted', () => {
|
|
467
|
+
const half = ringDash(50, 15, 3)
|
|
468
|
+
near(half.dashoffset, half.dasharray / 2, 1e-3)
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
it('clamps out-of-range and non-finite percentages', () => {
|
|
472
|
+
expect(ringDash(-20, 15, 3).dashoffset).toBe(ringDash(0, 15, 3).dashoffset)
|
|
473
|
+
expect(ringDash(140, 15, 3).dashoffset).toBe(ringDash(100, 15, 3).dashoffset)
|
|
474
|
+
for (const bad of [NaN, Infinity, null, undefined] as const) {
|
|
475
|
+
expect(ringDash(bad, 15, 3).dashoffset).toBe(ringDash(0, 15, 3).dashoffset)
|
|
476
|
+
}
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('matches the arithmetic CompletionRing inlined', () => {
|
|
480
|
+
const size = 38
|
|
481
|
+
const strokeWidth = 3
|
|
482
|
+
const percent = 80
|
|
483
|
+
const r = (size - strokeWidth) / 2
|
|
484
|
+
const circ = 2 * Math.PI * r
|
|
485
|
+
const ring = ringDash(percent, size / 2, strokeWidth)
|
|
486
|
+
near(ring.radius, r)
|
|
487
|
+
near(ring.circumference, circ)
|
|
488
|
+
near(ring.dashoffset, circ * (1 - percent / 100), 1e-3)
|
|
489
|
+
})
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
describe('clampPct', () => {
|
|
493
|
+
it('passes an in-range value through', () => {
|
|
494
|
+
near(clampPct(42.5), 42.5)
|
|
495
|
+
expect(clampPct(0)).toBe(0)
|
|
496
|
+
expect(clampPct(100)).toBe(100)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('clamps out of range', () => {
|
|
500
|
+
expect(clampPct(-20)).toBe(0)
|
|
501
|
+
expect(clampPct(1e9)).toBe(100)
|
|
502
|
+
})
|
|
503
|
+
|
|
504
|
+
it('reads non-finite as zero, never NaN', () => {
|
|
505
|
+
for (const bad of [NaN, Infinity, -Infinity, null, undefined] as const) {
|
|
506
|
+
expect(clampPct(bad)).toBe(0)
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
describe('normalizeToMax', () => {
|
|
512
|
+
it('empty data yields no fractions', () => {
|
|
513
|
+
expect(normalizeToMax([])).toEqual([])
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
it('divides by the largest value', () => {
|
|
517
|
+
const out = normalizeToMax([1, 2, 4])
|
|
518
|
+
near(out[0] ?? 0, 0.25)
|
|
519
|
+
near(out[1] ?? 0, 0.5)
|
|
520
|
+
near(out[2] ?? 0, 1)
|
|
521
|
+
})
|
|
522
|
+
|
|
523
|
+
it('an all-zero series is a flat empty strip', () => {
|
|
524
|
+
expect(normalizeToMax([0, 0, 0])).toEqual([0, 0, 0])
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
it('identical values are all full height', () => {
|
|
528
|
+
expect(normalizeToMax([7, 7, 7])).toEqual([1, 1, 1])
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
it('clamps negatives to zero', () => {
|
|
532
|
+
const out = normalizeToMax([-5, 10, -1])
|
|
533
|
+
expect(out[0]).toBe(0)
|
|
534
|
+
near(out[1] ?? 0, 1)
|
|
535
|
+
expect(out[2]).toBe(0)
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('an all-negative series renders empty rather than inverted', () => {
|
|
539
|
+
expect(normalizeToMax([-1, -2, -3])).toEqual([0, 0, 0])
|
|
540
|
+
})
|
|
541
|
+
|
|
542
|
+
it('reads non-finite entries as zero', () => {
|
|
543
|
+
const out = normalizeToMax([NaN, null, undefined, Infinity, 8])
|
|
544
|
+
expect(out.slice(0, 4)).toEqual([0, 0, 0, 0])
|
|
545
|
+
near(out[4] ?? 0, 1)
|
|
546
|
+
})
|
|
547
|
+
|
|
548
|
+
it('does not rescale a sub-1 series against a phantom maximum', () => {
|
|
549
|
+
// The inlined `Math.max(...values, 1)` flattened this to [0.1, 0.2].
|
|
550
|
+
const out = normalizeToMax([0.1, 0.2])
|
|
551
|
+
near(out[0] ?? 0, 0.5)
|
|
552
|
+
near(out[1] ?? 0, 1)
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
it('every fraction lands inside 0..1', () => {
|
|
556
|
+
for (const f of normalizeToMax([...HOSTILE, 3, 9, 0.5])) {
|
|
557
|
+
expect(f).toBeGreaterThanOrEqual(0)
|
|
558
|
+
expect(f).toBeLessThanOrEqual(1)
|
|
559
|
+
expect(Number.isFinite(f)).toBe(true)
|
|
560
|
+
}
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('walks a large array instead of spreading it', () => {
|
|
564
|
+
const big = Array.from({ length: 200_000 }, (_, i) => i)
|
|
565
|
+
const out = normalizeToMax(big)
|
|
566
|
+
expect(out).toHaveLength(200_000)
|
|
567
|
+
near(out[199_999] ?? 0, 1)
|
|
568
|
+
expect(out[0]).toBe(0)
|
|
569
|
+
})
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
describe('stackTotals', () => {
|
|
573
|
+
it('empty input yields no columns', () => {
|
|
574
|
+
expect(stackTotals([])).toEqual([])
|
|
575
|
+
expect(stackTotals([[], []])).toEqual([])
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
it('sums each column', () => {
|
|
579
|
+
expect(
|
|
580
|
+
stackTotals([
|
|
581
|
+
[1, 2, 3],
|
|
582
|
+
[4, 5, 6],
|
|
583
|
+
]),
|
|
584
|
+
).toEqual([5, 7, 9])
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
it('tolerates ragged rows', () => {
|
|
588
|
+
expect(stackTotals([[1, 2, 3], [10]])).toEqual([11, 2, 3])
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
it('excludes negatives and non-finite entries', () => {
|
|
592
|
+
expect(stackTotals([[5, NaN, -3], [null, 4, undefined]])).toEqual([5, 4, 0])
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
it('an all-zero column totals zero, not one', () => {
|
|
596
|
+
expect(stackTotals([[0, 5], [0, 5]])).toEqual([0, 10])
|
|
597
|
+
})
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
describe('stackFractions', () => {
|
|
601
|
+
it('preserves shape, including raggedness', () => {
|
|
602
|
+
const out = stackFractions([[1, 2, 3], [1]])
|
|
603
|
+
expect(out).toHaveLength(2)
|
|
604
|
+
expect(out[0]).toHaveLength(3)
|
|
605
|
+
expect(out[1]).toHaveLength(1)
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
it('each populated column sums to one', () => {
|
|
609
|
+
const series = [
|
|
610
|
+
[10, 1, 4],
|
|
611
|
+
[30, 3, 0],
|
|
612
|
+
[60, 6, 0],
|
|
613
|
+
]
|
|
614
|
+
const out = stackFractions(series)
|
|
615
|
+
for (let column = 0; column < 3; column++) {
|
|
616
|
+
const sum = out.reduce((acc, row) => acc + (row[column] ?? 0), 0)
|
|
617
|
+
near(sum, 1)
|
|
618
|
+
}
|
|
619
|
+
near(out[0]?.[0] ?? 0, 0.1)
|
|
620
|
+
near(out[2]?.[0] ?? 0, 0.6)
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
it('an all-zero column stays empty instead of becoming a full bar', () => {
|
|
624
|
+
const out = stackFractions([[0, 5], [0, 5]])
|
|
625
|
+
expect(out[0]?.[0]).toBe(0)
|
|
626
|
+
expect(out[1]?.[0]).toBe(0)
|
|
627
|
+
near(out[0]?.[1] ?? 0, 0.5)
|
|
628
|
+
})
|
|
629
|
+
|
|
630
|
+
it('a single series fills its column', () => {
|
|
631
|
+
expect(stackFractions([[3, 9]])).toEqual([[1, 1]])
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
it('reads negatives and non-finite entries as zero', () => {
|
|
635
|
+
const out = stackFractions([[-4, NaN], [8, 2]])
|
|
636
|
+
expect(out[0]).toEqual([0, 0])
|
|
637
|
+
expect(out[1]).toEqual([1, 1])
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
it('every fraction is finite and inside 0..1', () => {
|
|
641
|
+
for (const row of stackFractions([HOSTILE, HOSTILE])) {
|
|
642
|
+
for (const f of row) {
|
|
643
|
+
expect(Number.isFinite(f)).toBe(true)
|
|
644
|
+
expect(f).toBeGreaterThanOrEqual(0)
|
|
645
|
+
expect(f).toBeLessThanOrEqual(1)
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
})
|
|
649
|
+
})
|
|
650
|
+
|
|
651
|
+
describe('monthDomain', () => {
|
|
652
|
+
it('a single month is a one-entry domain', () => {
|
|
653
|
+
expect(monthDomain('2026-03-01', '2026-03-28')).toEqual(['2026-03-01'])
|
|
654
|
+
})
|
|
655
|
+
|
|
656
|
+
it('walks across a year boundary', () => {
|
|
657
|
+
expect(monthDomain('2025-11-01', '2026-02-01')).toEqual([
|
|
658
|
+
'2025-11-01',
|
|
659
|
+
'2025-12-01',
|
|
660
|
+
'2026-01-01',
|
|
661
|
+
'2026-02-01',
|
|
662
|
+
])
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
it('is timezone-free — the same strings on any machine', () => {
|
|
666
|
+
const months = monthDomain('2026-01-01', '2026-12-31')
|
|
667
|
+
expect(months).toHaveLength(12)
|
|
668
|
+
expect(months[0]).toBe('2026-01-01')
|
|
669
|
+
expect(months[11]).toBe('2026-12-01')
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it('an inverted range yields nothing', () => {
|
|
673
|
+
expect(monthDomain('2026-06-01', '2026-01-01')).toEqual([])
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
it('unparseable bounds yield nothing', () => {
|
|
677
|
+
expect(monthDomain('', '2026-01-01')).toEqual([])
|
|
678
|
+
expect(monthDomain('nope', 'also nope')).toEqual([])
|
|
679
|
+
expect(monthDomain('2026-13-01', '2026-14-01')).toEqual([])
|
|
680
|
+
expect(monthDomain('2026-00-01', '2026-02-01')).toEqual([])
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it('caps an absurd span instead of allocating without bound', () => {
|
|
684
|
+
expect(monthDomain('0001-01-01', '9999-12-01')).toHaveLength(1200)
|
|
685
|
+
})
|
|
686
|
+
})
|
|
687
|
+
|
|
688
|
+
describe('never emits NaN or Infinity', () => {
|
|
689
|
+
it('holds across every function for every hostile input', () => {
|
|
690
|
+
for (const a of HOSTILE) {
|
|
691
|
+
for (const b of HOSTILE) {
|
|
692
|
+
const scale = linearScale([toNumber(a), toNumber(b)], [0, 100])
|
|
693
|
+
expect(Number.isFinite(scale(a))).toBe(true)
|
|
694
|
+
expect(Number.isFinite(scale(b))).toBe(true)
|
|
695
|
+
|
|
696
|
+
const band = bandScale(a, b, 0.2)
|
|
697
|
+
expect(Number.isFinite(band.band)).toBe(true)
|
|
698
|
+
expect(Number.isFinite(band.step)).toBe(true)
|
|
699
|
+
expect(Number.isFinite(band.at(a))).toBe(true)
|
|
700
|
+
expect(Number.isFinite(band.center(b))).toBe(true)
|
|
701
|
+
|
|
702
|
+
expect(Number.isFinite(clampPct(a))).toBe(true)
|
|
703
|
+
|
|
704
|
+
const ring = ringDash(a, b, 2)
|
|
705
|
+
expect(Number.isFinite(ring.radius)).toBe(true)
|
|
706
|
+
expect(Number.isFinite(ring.dasharray)).toBe(true)
|
|
707
|
+
expect(Number.isFinite(ring.dashoffset)).toBe(true)
|
|
708
|
+
|
|
709
|
+
expect(linePath([{ x: toNumber(a), y: toNumber(b) }])).not.toMatch(
|
|
710
|
+
/NaN|Infinity/,
|
|
711
|
+
)
|
|
712
|
+
expect(
|
|
713
|
+
areaPath([{ x: 0, y: 0 }, { x: toNumber(a), y: toNumber(b) }], a),
|
|
714
|
+
).not.toMatch(/NaN|Infinity/)
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
})
|
|
718
|
+
})
|
|
719
|
+
|
|
720
|
+
/* ── test-local helpers ──────────────────────────────────────────────── */
|
|
721
|
+
|
|
722
|
+
/** `Number` view of a hostile scalar, for feeding tuple-typed parameters. */
|
|
723
|
+
function toNumber(value: NumericInput): number {
|
|
724
|
+
return typeof value === 'number' ? value : NaN
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** A plottable coordinate derived from a hostile scalar. */
|
|
728
|
+
function finiteish(value: NumericInput): number {
|
|
729
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/** The module's own 3dp path rounding, mirrored for dash assertions. */
|
|
733
|
+
function round3(value: number): number {
|
|
734
|
+
return Math.round(value * 1000) / 1000
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/* ── axis fitting (the 2026-09-06 axis-legibility fixes) ─────────────── */
|
|
738
|
+
|
|
739
|
+
describe('estimateTextWidth', () => {
|
|
740
|
+
it('is at least as wide as the browser measured, and not wildly wider', () => {
|
|
741
|
+
// Chromium, off the shipped gallery, 2026-09-06. The estimate must never
|
|
742
|
+
// come in UNDER these — an under-estimate ships the overlap the
|
|
743
|
+
// collision pass exists to prevent — and must stay within ~15% so it
|
|
744
|
+
// does not drop labels that would have fitted.
|
|
745
|
+
const measured: readonly (readonly [string, number, number])[] = [
|
|
746
|
+
['Wk 1', 14, 32.33],
|
|
747
|
+
['Wk 10', 14, 40.91],
|
|
748
|
+
['8,240', 12, 34.28],
|
|
749
|
+
['13,390', 12, 42.05],
|
|
750
|
+
['96.2%', 12, 38.58],
|
|
751
|
+
['$17,460', 12, 49.86],
|
|
752
|
+
]
|
|
753
|
+
for (const [text, size, actual] of measured) {
|
|
754
|
+
const estimate = estimateTextWidth(text, size)
|
|
755
|
+
expect(estimate).toBeGreaterThanOrEqual(actual)
|
|
756
|
+
expect(estimate).toBeLessThan(actual * 1.15)
|
|
757
|
+
}
|
|
758
|
+
})
|
|
759
|
+
|
|
760
|
+
it('is total: empty text and hostile sizes are 0, never NaN', () => {
|
|
761
|
+
expect(estimateTextWidth('', 14)).toBe(0)
|
|
762
|
+
expect(estimateTextWidth('abc', 0)).toBe(0)
|
|
763
|
+
expect(estimateTextWidth('abc', Number.NaN)).toBe(0)
|
|
764
|
+
expect(estimateTextWidth('abc', -14)).toBe(0)
|
|
765
|
+
expect(Number.isFinite(estimateTextWidth('abc', Number.POSITIVE_INFINITY))).toBe(
|
|
766
|
+
true,
|
|
767
|
+
)
|
|
768
|
+
})
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
describe('fitTickCount', () => {
|
|
772
|
+
it('gives a tall axis the count it asked for', () => {
|
|
773
|
+
// 5 ticks over 400px are 100px apart, well clear of the 42px floor.
|
|
774
|
+
expect(fitTickCount(400, 42, 5)).toBe(5)
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
it('shortens the ladder when the plot is short — the 390 defect', () => {
|
|
778
|
+
// The measured failure: five $50,000-shaped labels in a plot barely
|
|
779
|
+
// taller than one of them. 84px holds three (42px apart); 40px holds
|
|
780
|
+
// the floor of two, and never fewer.
|
|
781
|
+
expect(fitTickCount(84, 42, 5)).toBe(3)
|
|
782
|
+
expect(fitTickCount(40, 42, 5)).toBe(2)
|
|
783
|
+
expect(fitTickCount(0.5, 42, 5)).toBe(2)
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
it('never draws more than six, however many are requested', () => {
|
|
787
|
+
expect(fitTickCount(4000, 42, 12)).toBe(6)
|
|
788
|
+
})
|
|
789
|
+
|
|
790
|
+
it('leaves an unmeasured axis exactly as requested', () => {
|
|
791
|
+
// Before the ResizeObserver lands. Collapsing to two and then visibly
|
|
792
|
+
// re-expanding is worse than one frame of the requested ladder.
|
|
793
|
+
expect(fitTickCount(0, 42, 5)).toBe(5)
|
|
794
|
+
expect(fitTickCount(400, 0, 5)).toBe(5)
|
|
795
|
+
expect(fitTickCount(Number.NaN, 42, 5)).toBe(5)
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
it('clamps a nonsense request into [2, 6]', () => {
|
|
799
|
+
expect(fitTickCount(400, 42, 0)).toBe(2)
|
|
800
|
+
expect(fitTickCount(400, 42, -3)).toBe(2)
|
|
801
|
+
expect(fitTickCount(400, 42, Number.NaN)).toBe(5)
|
|
802
|
+
})
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
describe('fitTicks', () => {
|
|
806
|
+
it('keeps a six-rung ladder when six rungs fit', () => {
|
|
807
|
+
// niceTicks(0, 1, 5) legitimately returns SIX ticks — it rounds the
|
|
808
|
+
// domain outward. Rejecting that when it fits was the first version's
|
|
809
|
+
// bug: the target and the limit are different numbers.
|
|
810
|
+
expect(fitTicks(0, 1, 260, 42, 5)).toEqual([0, 0.2, 0.4, 0.6, 0.8, 1])
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
it('searches down until the RETURNED ladder fits, not the target', () => {
|
|
814
|
+
const ticks = fitTicks(0, 1, 143, 42, 5)
|
|
815
|
+
// 143px holds four rungs at 42px apart.
|
|
816
|
+
expect(ticks.length).toBeLessThanOrEqual(4)
|
|
817
|
+
expect(ticks[0]).toBe(0)
|
|
818
|
+
expect(ticks[ticks.length - 1]).toBe(1)
|
|
819
|
+
})
|
|
820
|
+
|
|
821
|
+
it('is exactly niceTicks when the plot is unmeasured', () => {
|
|
822
|
+
expect(fitTicks(0, 1, 0, 42, 5)).toEqual(niceTicks(0, 1, 5))
|
|
823
|
+
})
|
|
824
|
+
|
|
825
|
+
it('never returns fewer than two ticks for a real range', () => {
|
|
826
|
+
expect(fitTicks(0, 50000, 5, 42, 5).length).toBeGreaterThanOrEqual(2)
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
it('still collapses a flat domain to one tick', () => {
|
|
830
|
+
expect(fitTicks(7, 7, 400, 42, 5)).toEqual([7])
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
it('subsamples when even the two-tick target overshoots', () => {
|
|
834
|
+
// `niceTicks(-50000, 50000, 2)` rounds out to a 100,000 step and returns
|
|
835
|
+
// THREE rungs. In a 33px plot that painted
|
|
836
|
+
// "$50,000 / $0 / -$50,000" overlapping by 4.4px, because the downward
|
|
837
|
+
// search had already bottomed out at the two-tick floor.
|
|
838
|
+
expect(niceTicks(-50000, 50000, 2)).toHaveLength(3)
|
|
839
|
+
const fitted = fitTicks(-50000, 50000, 33, 42, 5)
|
|
840
|
+
expect(fitted).toHaveLength(2)
|
|
841
|
+
// The DRAWN domain is unchanged — only gridlines are dropped.
|
|
842
|
+
expect(fitted[0]).toBe(-100000)
|
|
843
|
+
expect(fitted[1]).toBe(100000)
|
|
844
|
+
})
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
describe('fitLabelCount', () => {
|
|
848
|
+
it('divides the axis by the label extent', () => {
|
|
849
|
+
expect(fitLabelCount(608, 53, 8)).toBe(8)
|
|
850
|
+
expect(fitLabelCount(234, 53, 8)).toBe(4)
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
it('never goes below the floor, and never above the request', () => {
|
|
854
|
+
expect(fitLabelCount(20, 53, 8)).toBe(2)
|
|
855
|
+
expect(fitLabelCount(4000, 53, 8)).toBe(8)
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
it('leaves an unmeasured axis as requested', () => {
|
|
859
|
+
expect(fitLabelCount(0, 53, 8)).toBe(8)
|
|
860
|
+
expect(fitLabelCount(608, 0, 8)).toBe(8)
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
it('can answer "not even two fit" when the floor is 0', () => {
|
|
864
|
+
// A 26px-wide plot holds no 32px label, and two of them overlapped by
|
|
865
|
+
// 10px until the caller could ask this question.
|
|
866
|
+
expect(fitLabelCount(26, 54, 8, 0)).toBe(0)
|
|
867
|
+
expect(fitLabelCount(120, 54, 8, 0)).toBe(2)
|
|
868
|
+
})
|
|
869
|
+
})
|
|
870
|
+
|
|
871
|
+
describe('strideIndices', () => {
|
|
872
|
+
it('thins 12 positions to a UNIFORM stride, first and last kept', () => {
|
|
873
|
+
// The defect: `round(i / (max - 1) * last)` produced
|
|
874
|
+
// [0, 2, 3, 5, 6, 8, 9, 11] — 3&4, 6&7 and 9&10 adjacent while 2, 5, 8
|
|
875
|
+
// and 11 were skipped, so the axis read as irregular DATA.
|
|
876
|
+
expect(strideIndices(12, 8)).toEqual([0, 2, 4, 6, 8, 11])
|
|
877
|
+
})
|
|
878
|
+
|
|
879
|
+
it('drops the second-to-last, never an interior one, when the end collides', () => {
|
|
880
|
+
const kept = strideIndices(12, 8)
|
|
881
|
+
expect(kept).toContain(0)
|
|
882
|
+
expect(kept).toContain(11)
|
|
883
|
+
expect(kept).not.toContain(10)
|
|
884
|
+
// Every interior gap is the same stride.
|
|
885
|
+
const gaps = kept.slice(1, -1).map((v, i) => v - (kept[i] ?? 0))
|
|
886
|
+
expect(new Set(gaps).size).toBe(1)
|
|
887
|
+
})
|
|
888
|
+
|
|
889
|
+
it('keeps everything when it already fits', () => {
|
|
890
|
+
expect(strideIndices(5, 8)).toEqual([0, 1, 2, 3, 4])
|
|
891
|
+
expect(strideIndices(8, 8)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
|
|
892
|
+
})
|
|
893
|
+
|
|
894
|
+
it('appends the last index when it does not collide', () => {
|
|
895
|
+
// 13 positions, max 8 → stride 2 → 0,2,4,6,8,10,12: the last IS on the
|
|
896
|
+
// stride, so nothing is dropped.
|
|
897
|
+
expect(strideIndices(13, 8)).toEqual([0, 2, 4, 6, 8, 10, 12])
|
|
898
|
+
})
|
|
899
|
+
|
|
900
|
+
it('never exceeds the cap, at any size', () => {
|
|
901
|
+
for (const n of [9, 13, 30, 90, 365, 1000]) {
|
|
902
|
+
for (const max of [2, 3, 5, 8]) {
|
|
903
|
+
const kept = strideIndices(n, max)
|
|
904
|
+
expect(kept.length).toBeLessThanOrEqual(max)
|
|
905
|
+
expect(kept[0]).toBe(0)
|
|
906
|
+
expect(kept[kept.length - 1]).toBe(n - 1)
|
|
907
|
+
// Strictly ascending, no duplicates.
|
|
908
|
+
for (let i = 1; i < kept.length; i++) {
|
|
909
|
+
expect(kept[i]).toBeGreaterThan(kept[i - 1] ?? -1)
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
})
|
|
914
|
+
|
|
915
|
+
it('is total on hostile input', () => {
|
|
916
|
+
expect(strideIndices(0, 8)).toEqual([])
|
|
917
|
+
expect(strideIndices(-4, 8)).toEqual([])
|
|
918
|
+
expect(strideIndices(Number.NaN, 8)).toEqual([])
|
|
919
|
+
expect(strideIndices(5, 0)).toEqual([0, 4])
|
|
920
|
+
expect(strideIndices(5, Number.NaN)).toEqual([0, 4])
|
|
921
|
+
})
|
|
922
|
+
})
|