g1t/apps/sudo/app/lib/chart.test.ts
| 1 | import assert from "node:assert/strict"; |
| 2 | import { test } from "node:test"; |
| 3 | |
| 4 | import { axisDollars, barPath, change, marginOf, monthBars, monthLong, monthShort, niceCeiling, shares, ticks } from "./chart.ts"; |
| 5 | |
| 6 | const M = 1_000_000; |
| 7 | const month = (m: string, charged: number, cost: number) => ({ month: m, chargedMicros: charged, costMicros: cost, paidMicros: 0 }); |
| 8 | |
| 9 | test("niceCeiling rounds up to 1, 2, 2.5 or 5 times a power of ten", () => { |
| 10 | assert.equal(niceCeiling(0), 1 * M); |
| 11 | assert.equal(niceCeiling(1 * M), 1 * M); |
| 12 | assert.equal(niceCeiling(1.2 * M), 2 * M); |
| 13 | assert.equal(niceCeiling(2.2 * M), 2.5 * M); |
| 14 | assert.equal(niceCeiling(3 * M), 5 * M); |
| 15 | assert.equal(niceCeiling(7 * M), 10 * M); |
| 16 | assert.equal(niceCeiling(420 * M), 500 * M); |
| 17 | assert.equal(niceCeiling(10_001 * M), 20_000 * M); |
| 18 | }); |
| 19 | |
| 20 | test("ticks run from zero to the top", () => { |
| 21 | assert.deepEqual(ticks(100, 4), [0, 25, 50, 75, 100]); |
| 22 | }); |
| 23 | |
| 24 | test("months are named", () => { |
| 25 | assert.equal(monthShort("2026-10"), "Oct"); |
| 26 | assert.equal(monthShort("2026-01"), "Jan"); |
| 27 | assert.equal(monthShort("soon"), "soon"); |
| 28 | assert.equal(monthLong("2026-10"), "October 2026"); |
| 29 | assert.equal(monthLong("2026-13"), "2026-13"); |
| 30 | }); |
| 31 | |
| 32 | test("margin and change", () => { |
| 33 | assert.deepEqual(marginOf(100 * M, 80 * M), { micros: 20 * M, percent: 20 }); |
| 34 | assert.deepEqual(marginOf(0, 5 * M), { micros: -5 * M, percent: null }); |
| 35 | assert.equal(change(100, 150), 50); |
| 36 | assert.equal(change(0, 150), null); |
| 37 | assert.equal(change(200, 100), -50); |
| 38 | }); |
| 39 | |
| 40 | test("monthBars scales both series to one axis from zero", () => { |
| 41 | const chart = monthBars([month("2026-09", 400 * M, 300 * M), month("2026-10", 100 * M, 0)], { width: 300, height: 120, left: 40, right: 0, top: 0, bottom: 20 }); |
| 42 | assert.equal(chart.top, 500 * M); |
| 43 | assert.deepEqual(chart.plot, { x: 40, y: 0, width: 260, height: 100 }); |
| 44 | const [sep, oct] = chart.bars; |
| 45 | assert.equal(sep.label, "Sep"); |
| 46 | // 400 of 500 is 80 of the plot's 100. |
| 47 | assert.equal(Math.round(sep.charged.height), 80); |
| 48 | assert.equal(Math.round(sep.charged.y), 20); |
| 49 | assert.equal(Math.round(sep.cost.height), 60); |
| 50 | // Bars stand on the baseline. |
| 51 | assert.equal(sep.charged.y + sep.charged.height, 100); |
| 52 | assert.equal(oct.cost.height, 0); |
| 53 | // Charged on the left, cost on the right, 2px apart, inside the slot. |
| 54 | assert.equal(Math.round((sep.cost.x - (sep.charged.x + sep.charged.width)) * 100) / 100, 2); |
| 55 | assert.ok(sep.charged.x >= sep.slot.x && sep.cost.x + sep.cost.width <= sep.slot.x + sep.slot.width); |
| 56 | assert.equal(chart.ticks[0].y, 100); |
| 57 | assert.equal(chart.ticks.at(-1)?.y, 0); |
| 58 | }); |
| 59 | |
| 60 | test("a tiny month still shows, and an empty chart has an axis", () => { |
| 61 | const chart = monthBars([month("2026-09", 1000 * M, 0), month("2026-10", 1, 0)]); |
| 62 | assert.ok(chart.bars[1].charged.height >= 1.5); |
| 63 | const empty = monthBars([month("2026-10", 0, 0)]); |
| 64 | assert.equal(empty.top, 1 * M); |
| 65 | assert.equal(empty.bars[0].charged.height, 0); |
| 66 | assert.equal(monthBars([]).bars.length, 0); |
| 67 | }); |
| 68 | |
| 69 | test("negative figures draw nothing below the baseline", () => { |
| 70 | const chart = monthBars([month("2026-10", -5 * M, 2 * M)]); |
| 71 | assert.equal(chart.bars[0].charged.height, 0); |
| 72 | }); |
| 73 | |
| 74 | test("barPath rounds the top and keeps the foot square", () => { |
| 75 | assert.equal(barPath({ x: 0, y: 0, width: 10, height: 20 }), "M0 20 V4 Q0 0 4 0 H6 Q10 0 10 4 V20 Z"); |
| 76 | assert.equal(barPath({ x: 0, y: 0, width: 10, height: 0 }), ""); |
| 77 | // Never rounder than the bar is tall. |
| 78 | assert.equal(barPath({ x: 0, y: 9, width: 10, height: 1 }), "M0 10 V10 Q0 9 1 9 H9 Q10 9 10 10 V10 Z"); |
| 79 | }); |
| 80 | |
| 81 | test("axis labels are short", () => { |
| 82 | assert.equal(axisDollars(0), "$0"); |
| 83 | assert.equal(axisDollars(250 * M), "$250"); |
| 84 | assert.equal(axisDollars(1500 * M), "$1.5k"); |
| 85 | assert.equal(axisDollars(20_000 * M), "$20k"); |
| 86 | assert.equal(axisDollars(1_200_000 * M), "$1.2M"); |
| 87 | assert.equal(axisDollars(0.25 * M), "$0.25"); |
| 88 | }); |
| 89 | |
| 90 | test("shares are against the largest", () => { |
| 91 | assert.deepEqual(shares([50, 100, 0, -5]), [0.5, 1, 0, 0]); |
| 92 | assert.deepEqual(shares([0, 0]), [0, 0]); |
| 93 | }); |