import { RollingNumber } from "@chopin/visuals";
import type { RollingNumberProps } from "@chopin/visuals";
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
let integer = new Intl.NumberFormat("en-US");
function columns(markup: string): string[] {
return [...markup.matchAll(/--cv-rolling-digit:(\d)/g)].map(match => match[1]!);
}
function keyed(value: number, format: Intl.NumberFormat) {
let element = RollingNumber({ format, value });
let children = element.props.children[1] as { key: string; props: { className?: string } }[];
return children.map(child => ({ digit: child.props.className !== undefined, key: child.key }));
}
test("exports RollingNumber and its props from the public package entry", () => {
let props: RollingNumberProps = { format: integer, value: 42 };
expect(typeof RollingNumber).toBe("function");
expect(props.value).toBe(42);
});
test("announces the formatted value once and hides the rolling columns", () => {
let markup = renderToStaticMarkup(
,
);
expect(markup.startsWith("1,234');
// Four digit columns plus the static group separator.
expect(markup.match(/aria-hidden="true"/g)).toHaveLength(5);
expect(markup.match(/class="cv-rolling-number-column"/g)).toHaveLength(4);
});
test("gives every digit a full 0-9 column translated to its value", () => {
let markup = renderToStaticMarkup();
expect(columns(markup)).toEqual(["9", "0", "7"]);
expect(markup.match(/\d<\/span>/g)).toHaveLength(30);
});
test("rolls fraction digits and renders separators, signs, and symbols statically", () => {
let currency = new Intl.NumberFormat("en-US", { currency: "USD", style: "currency" });
let percent = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1, style: "percent" });
let money = renderToStaticMarkup();
let share = renderToStaticMarkup();
expect(columns(money)).toEqual(["1", "2", "3", "4", "5", "0"]);
expect(money).toContain("-$1,234.50");
for (let char of ["-", "$", ",", "."]) {
expect(money).toContain(`${char}`);
}
expect(columns(share)).toEqual(["6", "2", "5"]);
expect(share).toContain('%');
});
test("keys parts from the right so existing columns persist when the number grows", () => {
let before = keyed(99, integer);
let after = keyed(100, integer);
expect(before).toEqual([{ digit: true, key: "2" }, { digit: true, key: "1" }]);
expect(after).toEqual([
{ digit: true, key: "3" },
{ digit: true, key: "2" },
{ digit: true, key: "1" },
]);
});
test("renders non-finite values statically without invalid column offsets", () => {
let markup = renderToStaticMarkup();
let infinite = renderToStaticMarkup(
,
);
expect(columns(markup)).toEqual([]);
expect(columns(infinite)).toEqual([]);
expect(markup).toContain('NaN');
expect(infinite).toContain('∞');
});
test("moves on shared motion tokens and stops for reduced motion", async () => {
let css = await Bun.file(new URL("./rolling-number.css", import.meta.url)).text();
let styles = await Bun.file(new URL("../styles.css", import.meta.url)).text();
expect(styles).toContain('@import "./ui/rolling-number.css";');
expect(css).toContain("font-variant-numeric: tabular-nums;");
expect(css).toContain("transform: translateY(calc(var(--cv-rolling-digit, 0) * -1em));");
expect(css).toContain("transition: transform var(--duration-linger) var(--motion-smooth-out);");
expect(css).toMatch(
/@media \(prefers-reduced-motion: reduce\) {\s*\.cv-rolling-number-column {\s*transition: none;/,
);
expect(css).not.toMatch(/\d+m?s\b|cubic-bezier|#[\da-f]{3,8}|(?:oklch|rgb|hsl)\(/i);
});