|
1 | 1 | import { |
2 | 2 | calculateInterval, |
3 | 3 | getMaxEventValue, |
| 4 | + MIN_TICK_PX, |
4 | 5 | renderMs, |
| 6 | + tickIntervalForWidth, |
5 | 7 | } from '@/components/TimelineChart/utils'; |
6 | 8 |
|
7 | 9 | describe('renderMs', () => { |
@@ -45,6 +47,85 @@ describe('renderMs', () => { |
45 | 47 | // 0.9995ms rounds to 1000µs, so it should render as 1ms instead |
46 | 48 | expect(renderMs(0.9995)).toBe('1ms'); |
47 | 49 | }); |
| 50 | + |
| 51 | + describe('with a tick interval (step)', () => { |
| 52 | + it('adds ms precision so adjacent ticks stay distinct', () => { |
| 53 | + expect(renderMs(1, 0.5)).toBe('1.0ms'); |
| 54 | + expect(renderMs(1.5, 0.5)).toBe('1.5ms'); |
| 55 | + expect(renderMs(2, 0.5)).toBe('2.0ms'); |
| 56 | + expect(renderMs(2.5, 0.5)).toBe('2.5ms'); |
| 57 | + }); |
| 58 | + |
| 59 | + it('produces no duplicate labels across a 0.5ms-spaced axis', () => { |
| 60 | + const interval = 0.5; |
| 61 | + const labels = []; |
| 62 | + for (let i = 0; i * interval <= 4; i++) { |
| 63 | + labels.push(renderMs(i * interval, interval)); |
| 64 | + } |
| 65 | + expect(new Set(labels).size).toBe(labels.length); |
| 66 | + }); |
| 67 | + |
| 68 | + it('uses two decimals when the interval requires it', () => { |
| 69 | + expect(renderMs(1, 0.05)).toBe('1.00ms'); |
| 70 | + expect(renderMs(1.05, 0.05)).toBe('1.05ms'); |
| 71 | + expect(renderMs(1.1, 0.05)).toBe('1.10ms'); |
| 72 | + }); |
| 73 | + |
| 74 | + it('keeps integer labels when the interval is a whole number', () => { |
| 75 | + expect(renderMs(2, 2)).toBe('2ms'); |
| 76 | + expect(renderMs(50, 50)).toBe('50ms'); |
| 77 | + }); |
| 78 | + |
| 79 | + it('adds precision to sub-millisecond (µs) ticks', () => { |
| 80 | + expect(renderMs(0.5, 0.5)).toBe('500µs'); |
| 81 | + expect(renderMs(0.0005, 0.0005)).toBe('0.5µs'); |
| 82 | + expect(renderMs(0.001, 0.0005)).toBe('1.0µs'); |
| 83 | + expect(renderMs(0.0015, 0.0005)).toBe('1.5µs'); |
| 84 | + }); |
| 85 | + |
| 86 | + it('falls through to ms (with ms precision) when the µs form rounds to 1000', () => { |
| 87 | + expect(renderMs(0.99996, 0.0005)).toBe('1.0000ms'); |
| 88 | + expect(renderMs(0.9999, 0.05)).toBe('1.00ms'); |
| 89 | + expect(renderMs(0.9996, 0.5)).toBe('1.0ms'); |
| 90 | + }); |
| 91 | + |
| 92 | + it('adds precision to second-scale ticks', () => { |
| 93 | + expect(renderMs(1000, 500)).toBe('1.0s'); |
| 94 | + expect(renderMs(1500, 500)).toBe('1.5s'); |
| 95 | + expect(renderMs(2000, 500)).toBe('2.0s'); |
| 96 | + }); |
| 97 | + |
| 98 | + describe('edge cases', () => { |
| 99 | + it('treats a null step like an omitted step (no added precision)', () => { |
| 100 | + // @ts-expect-error null is not assignable to step, but is guarded at runtime |
| 101 | + expect(renderMs(1, null)).toBe('1ms'); |
| 102 | + // @ts-expect-error null is not assignable to step, but is guarded at runtime |
| 103 | + expect(renderMs(0.5, null)).toBe('500µs'); |
| 104 | + // @ts-expect-error null is not assignable to step, but is guarded at runtime |
| 105 | + expect(renderMs(1500, null)).toBe('1.500s'); |
| 106 | + // @ts-expect-error null is not assignable to step, but is guarded at runtime |
| 107 | + expect(renderMs(2000, null)).toBe('2s'); |
| 108 | + }); |
| 109 | + |
| 110 | + it('adds no precision for a 0 step', () => { |
| 111 | + expect(renderMs(1, 0)).toBe('1ms'); |
| 112 | + expect(renderMs(0.5, 0)).toBe('500µs'); |
| 113 | + expect(renderMs(1000, 0)).toBe('1s'); |
| 114 | + expect(renderMs(1500, 0)).toBe('2s'); |
| 115 | + }); |
| 116 | + |
| 117 | + it('adds no precision for a negative step', () => { |
| 118 | + expect(renderMs(1, -0.5)).toBe('1ms'); |
| 119 | + expect(renderMs(0.5, -0.0005)).toBe('500µs'); |
| 120 | + expect(renderMs(1000, -500)).toBe('1s'); |
| 121 | + }); |
| 122 | + |
| 123 | + it('adds no precision for a non-finite step', () => { |
| 124 | + expect(renderMs(1, Infinity)).toBe('1ms'); |
| 125 | + expect(renderMs(1, NaN)).toBe('1ms'); |
| 126 | + }); |
| 127 | + }); |
| 128 | + }); |
48 | 129 | }); |
49 | 130 |
|
50 | 131 | describe('calculateInterval', () => { |
@@ -111,6 +192,39 @@ describe('calculateInterval', () => { |
111 | 192 | }); |
112 | 193 | }); |
113 | 194 |
|
| 195 | +describe('tickIntervalForWidth', () => { |
| 196 | + it('budgets ticks by MIN_TICK_PX, matching calculateInterval', () => { |
| 197 | + // 560px fits 10 ticks (560 / 56), so a 100ms range steps by 10ms. |
| 198 | + expect(tickIntervalForWidth(100, 10 * MIN_TICK_PX)).toBe( |
| 199 | + calculateInterval(100, 10), |
| 200 | + ); |
| 201 | + }); |
| 202 | + |
| 203 | + it('yields finer intervals as the width grows', () => { |
| 204 | + // A narrow axis packs fewer ticks (coarser interval); a wide one packs more |
| 205 | + // (finer interval). This is what lets the cursor readout gain precision when |
| 206 | + // the timeline is zoomed in. |
| 207 | + const narrow = tickIntervalForWidth(100, 3 * MIN_TICK_PX); |
| 208 | + const wide = tickIntervalForWidth(100, 20 * MIN_TICK_PX); |
| 209 | + expect(wide).toBeLessThan(narrow); |
| 210 | + }); |
| 211 | + |
| 212 | + it('formats a cursor time with the same precision as the tick labels', () => { |
| 213 | + // A zoomed-in 5ms range across a wide axis steps by a sub-ms interval, so |
| 214 | + // the readout keeps a decimal instead of rounding to a whole millisecond. |
| 215 | + const interval = tickIntervalForWidth(5, 20 * MIN_TICK_PX); |
| 216 | + expect(renderMs(2.3, interval)).toBe('2.3ms'); |
| 217 | + expect(renderMs(2.3)).toBe('2ms'); |
| 218 | + }); |
| 219 | + |
| 220 | + it('reserves at least one tick for tiny widths', () => { |
| 221 | + expect(tickIntervalForWidth(100, 0)).toBe(calculateInterval(100, 1)); |
| 222 | + expect(tickIntervalForWidth(100, MIN_TICK_PX / 2)).toBe( |
| 223 | + calculateInterval(100, 1), |
| 224 | + ); |
| 225 | + }); |
| 226 | +}); |
| 227 | + |
114 | 228 | describe('getMaxEventValue', () => { |
115 | 229 | it('returns the max event end plus 10% padding', () => { |
116 | 230 | const rows = [ |
|
0 commit comments