Files
claude-code-usage-bubble/tests/js/layout.test.js
T
tiennm99 094b6bdcb0 feat: draw the bubble from the payload in HTML, CSS and SVG
The stadium pill holds SVG usage and time rings, pill-shaped tail bars and
the payload's texts, laid out in physical px from canvasW, canvasH and dpi
with the GDI bubble's formulas (ui/layout.js, unit-tested). The palette is
CSS custom properties per theme; fills come from Rust. The ring arc, weekly
fill and weekly percent pulse at 95% and above on an 80 ms step, and the
time progress advances every minute.

Only the pill takes pointer input, so the corners are dead. A press that
moves 3 physical px starts an OS drag after arming the move end; an earlier
release is a click. Right-click opens the native menu and Ctrl+wheel resizes.
2026-10-21 14:40:18 +07:00

268 lines
9.0 KiB
JavaScript

// Bubble layout checked against values worked out from the Rust layout
// formulas (the GDI bubble's `compute_bubble_layout`) with the same
// stand-in text measurement.
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
brighten,
cssPx,
headLabelText,
headMetrics,
pulseActive,
pulseT,
remainingFraction,
ringDash,
scale,
tailLayout,
timeFillWidth,
usageFillWidth,
} from '../../ui/layout.js';
/**
* Stand-in for canvas `measureText`: 0.55 em per character, truncated.
* @param {string} text Text to measure.
* @param {number} px Font size.
* @returns {number} Width.
*/
const measure = (text, px) => Math.trunc((px * text.length * 11) / 20);
/**
* Rect shorthand.
* @param {number} l Left.
* @param {number} t Top.
* @param {number} r Right.
* @param {number} b Bottom.
* @returns {{l: number, t: number, r: number, b: number}} The rect.
*/
const rect = (l, t, r, b) => ({ l, t, r, b });
const CASES = [
{
name: '200 logical at 96 dpi',
canvas: { canvasW: 200, canvasH: 66, dpi: 96 },
head: {
ringCx: 33, ringCy: 33, ringRadius: 26.5, ringStroke: 3, timeRingRadius: 19.5, timeRingStroke: 2,
labelRect: rect(4, 18, 62, 29), pctRect: rect(4, 31, 62, 48), fonts: { big: 15, small: 9, main: 9 },
},
tail: {
usagePctRect: rect(165, 23, 186, 34), usageBarRect: rect(72, 25, 157, 31),
timeTextRect: rect(165, 33, 186, 44), timeBarRect: rect(72, 37, 157, 40),
},
},
{
name: '360 logical at 96 dpi',
canvas: { canvasW: 360, canvasH: 138, dpi: 96 },
head: {
ringCx: 69, ringCy: 69, ringRadius: 62.5, ringStroke: 3, timeRingRadius: 55.5, timeRingStroke: 2,
labelRect: rect(4, 39, 134, 59), pctRect: rect(4, 63, 134, 98), fonts: { big: 33, small: 18, main: 18 },
},
tail: {
usagePctRect: rect(305, 53, 346, 73), usageBarRect: rect(144, 57, 297, 69),
timeTextRect: rect(305, 68, 346, 88), timeBarRect: rect(144, 75, 297, 80),
},
},
{
// 300 px wide: `innerWidth * devicePixelRatio` would drift here.
name: '200 logical at 144 dpi',
canvas: { canvasW: 300, canvasH: 99, dpi: 144 },
head: {
ringCx: 49.5, ringCy: 49.5, ringRadius: 40.5, ringStroke: 4, timeRingRadius: 30.5, timeRingStroke: 3,
labelRect: rect(6, 27, 93, 43), pctRect: rect(6, 46, 93, 72), fonts: { big: 23, small: 13, main: 13 },
},
tail: {
usagePctRect: rect(248, 35, 279, 51), usageBarRect: rect(108, 38, 236, 47),
timeTextRect: rect(248, 50, 279, 66), timeBarRect: rect(108, 56, 236, 60),
},
},
{
name: '360 logical at 144 dpi',
canvas: { canvasW: 540, canvasH: 207, dpi: 144 },
head: {
ringCx: 103.5, ringCy: 103.5, ringRadius: 94.5, ringStroke: 4, timeRingRadius: 84.5, timeRingStroke: 3,
labelRect: rect(6, 59, 201, 88), pctRect: rect(6, 95, 201, 147), fonts: { big: 49, small: 26, main: 26 },
},
tail: {
usagePctRect: rect(459, 81, 519, 110), usageBarRect: rect(216, 86, 447, 104),
timeTextRect: rect(459, 103, 519, 132), timeBarRect: rect(216, 113, 447, 121),
},
},
{
name: '140 logical at 120 dpi',
canvas: { canvasW: 175, canvasH: 57, dpi: 120 },
head: {
ringCx: 28.5, ringCy: 28.5, ringRadius: 21, ringStroke: 3, timeRingRadius: 13, timeRingStroke: 2,
labelRect: rect(5, 13, 52, 26), pctRect: rect(5, 28, 52, 43), fonts: { big: 13, small: 11, main: 11 },
},
tail: {
usagePctRect: rect(132, 17, 158, 30), usageBarRect: rect(64, 20, 122, 27),
timeTextRect: rect(132, 29, 158, 42), timeBarRect: rect(64, 34, 122, 37),
},
},
];
describe('headMetrics', () => {
for (const c of CASES) {
it(`matches the Rust layout for ${c.name}`, () => {
assert.deepEqual(headMetrics(c.canvas), c.head);
});
}
});
describe('tailLayout', () => {
for (const c of CASES) {
it(`matches the Rust layout for ${c.name}`, () => {
assert.deepEqual(tailLayout(c.canvas, measure), c.tail);
});
}
it('shrinks the text column before the bars drop below their minimum', () => {
const tail = tailLayout({ canvasW: 200, canvasH: 66, dpi: 96 }, () => 1000);
// Content 72..186 is 114 wide: 114 - 8 gap - 8 bar minimum = 98.
assert.deepEqual(tail.usagePctRect, rect(88, 23, 186, 34));
assert.deepEqual(tail.usageBarRect, rect(72, 25, 80, 31));
});
it('collapses the text column to nothing when even the bars do not fit', () => {
const tail = tailLayout({ canvasW: 80, canvasH: 66, dpi: 96 }, () => 50);
assert.equal(tail.usagePctRect.l, tail.usagePctRect.r);
assert.equal(tail.usageBarRect.r - tail.usageBarRect.l, 8);
});
it('rounds fractional measurements up', () => {
const exact = tailLayout({ canvasW: 360, canvasH: 138, dpi: 96 }, () => 40);
const fractional = tailLayout({ canvasW: 360, canvasH: 138, dpi: 96 }, () => 39.2);
assert.deepEqual(fractional, exact);
});
});
describe('scale and cssPx', () => {
it('truncates like the Rust scale', () => {
assert.equal(scale(200, 96), 200);
assert.equal(scale(200, 144), 300);
assert.equal(scale(5, 120), 6);
assert.equal(scale(66, 168), 115);
});
it('converts physical px to CSS px from the payload DPI', () => {
assert.equal(cssPx(300, 144), 200);
assert.equal(cssPx(99, 144), 66);
assert.equal(cssPx(66, 96), 66);
});
});
describe('ringDash', () => {
const r = 10;
const c = 2 * Math.PI * r;
it('starts the usage arc at 12 o\'clock', () => {
assert.deepEqual(ringDash(r, 0.25, 'from-top'), { dashArray: `${c / 4} ${c}`, dashOffset: 0 });
});
it('ends the remaining arc at 12 o\'clock', () => {
const dash = ringDash(r, 0.25, 'to-top');
assert.equal(dash?.dashArray, `${c / 4} ${c}`);
assert.ok(Math.abs((dash?.dashOffset ?? 0) + (c * 3) / 4) < 1e-9);
});
it('draws a full ring at 1 and clamps above it', () => {
assert.deepEqual(ringDash(r, 1, 'to-top'), { dashArray: `${c} ${c}`, dashOffset: -0 });
assert.deepEqual(ringDash(r, 2, 'from-top'), ringDash(r, 1, 'from-top'));
});
it('draws nothing at zero, below it or for NaN', () => {
assert.equal(ringDash(r, 0, 'from-top'), null);
assert.equal(ringDash(r, -0.5, 'to-top'), null);
assert.equal(ringDash(r, Number.NaN, 'from-top'), null);
});
});
describe('remainingFraction', () => {
const now = 1_800_000_000_000;
it('is null without a reset time', () => {
assert.equal(remainingFraction(null, 18_000, now), null);
});
it('is the remaining share of the window', () => {
assert.equal(remainingFraction(now + 9_000_000, 18_000, now), 0.5);
assert.equal(remainingFraction(now + 3_600_000, 18_000, now), 0.2);
});
it('clamps past resets to 0 and far resets to 1', () => {
assert.equal(remainingFraction(now - 1_000, 18_000, now), 0);
assert.equal(remainingFraction(now + 36_000_000, 18_000, now), 1);
});
it('is null for a non-positive window length', () => {
assert.equal(remainingFraction(now, 0, now), null);
});
});
describe('pulse', () => {
it('follows the Rust triangle wave', () => {
assert.equal(pulseT(0), 0);
assert.equal(pulseT(6), 0.5);
assert.equal(pulseT(12), 1);
assert.equal(pulseT(18), 0.5);
assert.equal(pulseT(24), 0);
assert.equal(pulseT(36), 1);
});
it('runs only at 95% and above', () => {
assert.equal(pulseActive(null, null), false);
assert.equal(pulseActive(94.9, 10), false);
assert.equal(pulseActive(95, null), true);
assert.equal(pulseActive(null, 99), true);
});
it('brightens toward white by at most 30%', () => {
assert.equal(brighten('#A01818', 0), '#A01818');
// 160 + 95 * 0.3 = 188.5 -> 189; 24 + 231 * 0.3 = 93.3 -> 93.
assert.equal(brighten('#A01818', 1), '#BD5D5D');
// Half way: 0.15 of the distance.
assert.equal(brighten('#000000', 0.5), '#262626');
assert.equal(brighten('#eaeaea', 1), '#F0F0F0');
assert.equal(brighten('#FFFFFF', 1), '#FFFFFF');
assert.equal(brighten('#123456', 5), brighten('#123456', 1));
});
it('leaves a malformed colour unchanged', () => {
assert.equal(brighten('red', 1), 'red');
});
});
describe('bar fills', () => {
it('floors a small usage fill at one bar height', () => {
assert.equal(usageFillWidth(100, 6, 1), 6);
assert.equal(usageFillWidth(100, 6, 50), 50);
assert.equal(usageFillWidth(100, 6, 150), 100);
assert.equal(usageFillWidth(4, 6, 1), 4);
});
it('draws no usage fill without a value or at zero', () => {
assert.equal(usageFillWidth(100, 6, null), 0);
assert.equal(usageFillWidth(100, 6, 0), 0);
assert.equal(usageFillWidth(0, 6, 50), 0);
});
it('sizes the time fill from the remaining fraction', () => {
assert.equal(timeFillWidth(100, 0.25), 25);
assert.equal(timeFillWidth(100, null), 0);
assert.equal(timeFillWidth(100, 0), 0);
});
});
describe('headLabelText', () => {
it('keeps a countdown that fits', () => {
assert.equal(headLabelText('2h', 10, 58), '2h');
assert.equal(headLabelText('…', 5, 58), '…');
});
it('falls back to 5H when empty or too wide', () => {
assert.equal(headLabelText('', 0, 58), '5H');
assert.equal(headLabelText('23h 59m', 59, 58), '5H');
});
});