|
| 1 | +#!/usr/bin/env node |
| 2 | +// test_panel_layout.js — unit tests for the shared time-panel layout math. |
| 3 | +// |
| 4 | +// This test protects the invariant that every time-based panel in the viewer |
| 5 | +// (timeline, worker lanes, span panel, task detail, queue chart) uses the |
| 6 | +// same label/draw area layout so their time axes line up vertically. The |
| 7 | +// bug that prompted this test was a regression where the span panel used a |
| 8 | +// 200px left gutter while every other panel used 100px, shifting the span |
| 9 | +// time axis ~100px to the right of the task detail view. |
| 10 | +"use strict"; |
| 11 | + |
| 12 | +const assert = require("assert"); |
| 13 | +const { makeTimePanelLayout } = require("./panel_layout.js"); |
| 14 | + |
| 15 | +// The canonical left-gutter width used by every time-based panel. If we ever |
| 16 | +// want to make it configurable, update viewer.html's LABEL_W constant and the |
| 17 | +// call sites at the same time — but the invariant is that *every* time-based |
| 18 | +// panel uses the same value. |
| 19 | +const LABEL_W = 100; |
| 20 | + |
| 21 | +let passed = 0, failed = 0; |
| 22 | +function test(name, fn) { |
| 23 | + try { |
| 24 | + fn(); |
| 25 | + console.log(`✓ ${name}`); |
| 26 | + passed++; |
| 27 | + } catch (e) { |
| 28 | + console.error(`✗ ${name}: ${e.message}`); |
| 29 | + failed++; |
| 30 | + } |
| 31 | +} |
| 32 | + |
| 33 | +test("nsToPanelX at viewStart lands exactly at labelW", () => { |
| 34 | + const l = makeTimePanelLayout(1000, LABEL_W, 0, 500, 1500); |
| 35 | + assert.strictEqual(l.nsToPanelX(500), LABEL_W); |
| 36 | +}); |
| 37 | + |
| 38 | +test("nsToPanelX at viewEnd lands at labelW+drawW", () => { |
| 39 | + const l = makeTimePanelLayout(1000, LABEL_W, 0, 500, 1500); |
| 40 | + assert.strictEqual(l.nsToPanelX(1500), LABEL_W + l.drawW); |
| 41 | +}); |
| 42 | + |
| 43 | +test("nsToPanelX is linear in the middle of the view", () => { |
| 44 | + const l = makeTimePanelLayout(1000, LABEL_W, 0, 0, 1000); |
| 45 | + // Midpoint of the view should land at labelW + drawW/2. |
| 46 | + assert.strictEqual(l.nsToPanelX(500), LABEL_W + l.drawW / 2); |
| 47 | +}); |
| 48 | + |
| 49 | +test("nsToPanelXClamped clamps values outside the view", () => { |
| 50 | + const l = makeTimePanelLayout(1000, LABEL_W, 0, 500, 1500); |
| 51 | + assert.strictEqual(l.nsToPanelXClamped(100), LABEL_W); // before |
| 52 | + assert.strictEqual(l.nsToPanelXClamped(2000), LABEL_W + l.drawW); // after |
| 53 | + // Values inside the view match nsToPanelX. |
| 54 | + assert.strictEqual(l.nsToPanelXClamped(1000), l.nsToPanelX(1000)); |
| 55 | +}); |
| 56 | + |
| 57 | +test("panelXToNs round-trips with nsToPanelX", () => { |
| 58 | + const l = makeTimePanelLayout(1234, LABEL_W, 17, 1_000_000, 2_000_000); |
| 59 | + for (const ns of [1_000_000, 1_250_000, 1_500_000, 1_999_999]) { |
| 60 | + const x = l.nsToPanelX(ns); |
| 61 | + const back = l.panelXToNs(x); |
| 62 | + assert.ok(Math.abs(back - ns) < 1e-6, `round-trip ${ns} -> ${x} -> ${back}`); |
| 63 | + } |
| 64 | +}); |
| 65 | + |
| 66 | +test("scrollbarW shrinks drawW but does not move labelW", () => { |
| 67 | + const noSb = makeTimePanelLayout(1000, LABEL_W, 0, 0, 1); |
| 68 | + const withSb = makeTimePanelLayout(1000, LABEL_W, 17, 0, 1); |
| 69 | + assert.strictEqual(noSb.labelW, withSb.labelW); |
| 70 | + assert.strictEqual(withSb.drawW, noSb.drawW - 17); |
| 71 | + // viewStart still maps to x=LABEL_W in both. |
| 72 | + assert.strictEqual(noSb.nsToPanelX(0), LABEL_W); |
| 73 | + assert.strictEqual(withSb.nsToPanelX(0), LABEL_W); |
| 74 | +}); |
| 75 | + |
| 76 | +test("INVARIANT: all panels with the same pw share the same time axis", () => { |
| 77 | + // Simulate the four time-based panels in the viewer. Each uses its own |
| 78 | + // scrollbarW (some have the lane scrollbar gutter, some don't), but they |
| 79 | + // all must map the same timestamp to the same x-coordinate within the |
| 80 | + // [LABEL_W, LABEL_W+min(drawW)] overlap — i.e. the start of the draw area |
| 81 | + // is identical (x=LABEL_W) for every panel. |
| 82 | + const pw = 1200; |
| 83 | + const vStart = 1_000_000, vEnd = 5_000_000; |
| 84 | + |
| 85 | + const timeline = makeTimePanelLayout(pw, LABEL_W, 17, vStart, vEnd); |
| 86 | + const spanPanel = makeTimePanelLayout(pw, LABEL_W, 17, vStart, vEnd); |
| 87 | + const taskDetail = makeTimePanelLayout(pw, LABEL_W, 17, vStart, vEnd); |
| 88 | + const queueChart = makeTimePanelLayout(pw, LABEL_W, 17, vStart, vEnd); |
| 89 | + |
| 90 | + for (const ts of [vStart, vStart + 500_000, vStart + 2_500_000, vEnd - 1]) { |
| 91 | + const x = timeline.nsToPanelX(ts); |
| 92 | + assert.strictEqual(spanPanel.nsToPanelX(ts), x, `span panel diverges at ${ts}`); |
| 93 | + assert.strictEqual(taskDetail.nsToPanelX(ts), x, `task detail diverges at ${ts}`); |
| 94 | + assert.strictEqual(queueChart.nsToPanelX(ts), x, `queue chart diverges at ${ts}`); |
| 95 | + } |
| 96 | + // The start of the draw area is LABEL_W everywhere, not a panel-specific |
| 97 | + // value. (This is the assertion that would have failed for the span |
| 98 | + // panel when its left gutter was 200px.) |
| 99 | + assert.strictEqual(timeline.labelW, LABEL_W); |
| 100 | + assert.strictEqual(spanPanel.labelW, LABEL_W); |
| 101 | + assert.strictEqual(taskDetail.labelW, LABEL_W); |
| 102 | + assert.strictEqual(queueChart.labelW, LABEL_W); |
| 103 | +}); |
| 104 | + |
| 105 | +test("REGRESSION: hypothetical 200px-gutter panel would NOT align", () => { |
| 106 | + // This is a documentation test: if someone re-breaks the invariant by |
| 107 | + // building a panel with a non-LABEL_W gutter, the test ensures we notice. |
| 108 | + const pw = 1200; |
| 109 | + const vStart = 0, vEnd = 1_000_000; |
| 110 | + const good = makeTimePanelLayout(pw, 100, 0, vStart, vEnd); |
| 111 | + const bad = makeTimePanelLayout(pw, 200, 0, vStart, vEnd); |
| 112 | + // They disagree on where viewStart lands by exactly the gutter delta. |
| 113 | + assert.strictEqual(bad.nsToPanelX(0) - good.nsToPanelX(0), 100); |
| 114 | +}); |
| 115 | + |
| 116 | +test("Zero-width view (viewStart == viewEnd) does not divide by zero", () => { |
| 117 | + const l = makeTimePanelLayout(1000, LABEL_W, 0, 500, 500); |
| 118 | + assert.ok(Number.isFinite(l.nsToPanelX(500))); |
| 119 | + assert.ok(Number.isFinite(l.nsToPanelXClamped(500))); |
| 120 | +}); |
| 121 | + |
| 122 | +test("Tiny panel width produces negative drawW but does not throw", () => { |
| 123 | + // Caller is expected to early-return on drawW <= 0; just verify no NaN. |
| 124 | + const l = makeTimePanelLayout(50, LABEL_W, 17, 0, 1000); |
| 125 | + assert.ok(l.drawW < 0); |
| 126 | + assert.ok(Number.isFinite(l.nsToPanelX(500))); |
| 127 | +}); |
| 128 | + |
| 129 | +test("INVARIANT: no time-based panel declares its own left gutter in CSS", () => { |
| 130 | + // Cheap but effective guard: grep viewer.html for any time-based panel |
| 131 | + // defining a `padding-left` value. The whole point of the invariant is |
| 132 | + // that the canvas fills the panel and `timePanelLayout` handles the |
| 133 | + // LABEL_W offset. If a panel redefines `padding-left`, its time axis |
| 134 | + // will silently drift relative to every other panel. |
| 135 | + const fs = require("fs"); |
| 136 | + const path = require("path"); |
| 137 | + const html = fs.readFileSync( |
| 138 | + path.join(__dirname, "viewer.html"), |
| 139 | + "utf8", |
| 140 | + ); |
| 141 | + |
| 142 | + const PANELS = [ |
| 143 | + "#timeline-header", |
| 144 | + "#span-panel", |
| 145 | + "#task-detail", |
| 146 | + "#queue-chart", |
| 147 | + ]; |
| 148 | + for (const panel of PANELS) { |
| 149 | + // Match: `<selector> { ... padding-left: ... }` across lines. |
| 150 | + const ruleRe = new RegExp( |
| 151 | + `${panel.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, |
| 152 | + "g", |
| 153 | + ); |
| 154 | + let m; |
| 155 | + while ((m = ruleRe.exec(html)) !== null) { |
| 156 | + const body = m[1]; |
| 157 | + assert.ok( |
| 158 | + !/padding-left\s*:/.test(body), |
| 159 | + `${panel} must not declare padding-left (would break the time-axis alignment invariant). Use timePanelLayout() instead.`, |
| 160 | + ); |
| 161 | + } |
| 162 | + } |
| 163 | +}); |
| 164 | + |
| 165 | +console.log(`\n${passed} passed, ${failed} failed`); |
| 166 | +process.exit(failed > 0 ? 1 : 0); |
0 commit comments