import { test } from "node:test"; import assert from "node:assert/strict"; import { siteChartColors } from "yt-dlp-transcript-common/lib/siteColor"; import { buildFixtureSummary } from "../../e2e/fixture-summary"; import { GAP_PX, MIN_KEEP_PX, OTHER_COLOR, OTHER_KEY, PLOT_SIZES, bandAbove, foldedSites, gapSegments, growthLayers, growthStack, layerColors, ownLayers, type Band, type PlotSize, } from "./growthGaps"; // Run with: // pnpm --filter homepage test // // THE PROMISE: wherever the growth chart draws a gap, both bands it parts keep // at least MIN_KEEP_PX of their own colour, measured at right angles to the // edge, at the narrowest plot of each height — so no band is ever fully // covered. Checked here independently of gapSegments' own test: each band's // extent under a gap segment is the distance from its FAR edge's two points to // the gapped edge's line, in px, less every gap that touches the band there. type Pt = [number, number]; function px(size: PlotSize, yMax: number, n: number) { const dx = size.minWidth / Math.max(1, n - 1); return (i: number, v: number): Pt => [i * dx, (v / yMax) * size.px]; } // Distance from p to the line through a and b. function dist(p: Pt, a: Pt, b: Pt): number { const [x, y] = p; const [x1, y1] = a; const [x2, y2] = b; return Math.abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1) / Math.hypot(x2 - x1, y2 - y1); } // Every band-segment a gap leaves with less than MIN_KEEP_PX of its colour. function covered(stack: readonly Band[], yMax: number, size: PlotSize): string[] { const n = stack[0].hi.length; const at = px(size, yMax, n); const segs = gapSegments(stack, yMax, size).map((s) => new Set(s)); // Band j loses GAP_PX / 2 on its lower edge at segment i when the band // beneath it has a gap there whose band above is j. const gapBelow = (j: number, i: number) => stack.some((_, k) => k < j && segs[k].has(i) && bandAbove(stack, k, i) === j); const bad: string[] = []; stack.forEach((band, k) => { for (const i of segs[k]) { const a = at(i, band.hi[i]); const b = at(i + 1, band.hi[i + 1]); // The band below the edge: its lower edge's points. const below = Math.min(dist(at(i, band.lo[i]), a, b), dist(at(i + 1, band.lo[i + 1]), a, b)); const keepBelow = below - GAP_PX / 2 - (gapBelow(k, i) ? GAP_PX / 2 : 0); // The band above: its upper edge's points. const m = bandAbove(stack, k, i)!; const above = Math.min(dist(at(i, stack[m].hi[i]), a, b), dist(at(i + 1, stack[m].hi[i + 1]), a, b)); const keepAbove = above - GAP_PX / 2 - (segs[m].has(i) ? GAP_PX / 2 : 0); if (keepBelow < MIN_KEEP_PX - 1e-9) bad.push(`${size.px}px band ${k} @${i}: ${keepBelow.toFixed(2)}`); if (keepAbove < MIN_KEEP_PX - 1e-9) bad.push(`${size.px}px band ${m} @${i}: ${keepAbove.toFixed(2)}`); } }); return bad; } // A deterministic stand-in for the published chart's shape: one large band // with one-month spikes and dips, one medium band, and four slivers of 0–12 // that start late. function slivers(): { stack: Band[]; yMax: number } { let seed = 7; const rnd = () => ((seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31); const n = 204; const months = Array.from({ length: n }, (_, i) => { const big = i < 60 ? rnd() * 20 : 80 + i * 3 + (i % 17 === 0 ? 900 : 0) - (i % 23 === 0 ? 70 : 0); const mid = i < 120 ? rnd() * 10 : 60 + rnd() * 120 + (i % 29 === 0 ? 600 : 0); const s = (from: number) => (i < from ? 0 : Math.round(rnd() * 12)); return { bySite: { a: Math.max(0, big), b: mid, c: s(90), d: s(130), e: s(150), f: s(190) } }; }); const sites = ["a", "b", "c", "d", "e", "f"].map((siteId) => ({ siteId })); const { stack, yMax } = growthStack(months, sites); return { stack, yMax }; } test("the fixture summary, as the chart draws it (Other on top): gaps are drawn, and no band is ever covered, at every height", () => { const summary = buildFixtureSummary(); const months = summary.monthly ?? []; const layers = growthLayers(months, summary.sites); // The fixture's last two sites are under 5 %: they fold. assert.deepEqual(foldedSites(months, summary.sites), [4, 5]); assert.equal(layers.at(-1)!.key, OTHER_KEY); const { stack, yMax } = growthStack(months, summary.sites, layers); let drawn = 0; let underOther = 0; for (const size of PLOT_SIZES) { const segs = gapSegments(stack, yMax, size); drawn += segs.flat().length; // The gap under Other parts it from the top kept site; nothing sits on // Other, so its own upper edge has none. underOther += segs[stack.length - 2].filter((i) => bandAbove(stack, stack.length - 2, i) === stack.length - 1).length; assert.deepEqual(segs[stack.length - 1], [], `${size.px}px: a gap along Other's top`); assert.deepEqual(covered(stack, yMax, size), [], `${size.px}px`); } assert.ok(drawn > 0, "no gap drawn at all"); assert.ok(underOther > 0, "no gap between the top kept site and Other"); // Unfolded, the same summary keeps its promise too. const own = growthStack(months, summary.sites); for (const size of PLOT_SIZES) assert.deepEqual(covered(own.stack, own.yMax, size), [], `unfolded ${size.px}px`); }); test("slivers beside large bands, with one-month spikes: no band is ever covered", () => { const { stack, yMax } = slivers(); for (const size of PLOT_SIZES) { const bad = covered(stack, yMax, size); assert.equal(bad.length, 0, bad.slice(0, 5).join("; ")); } }); test("a one-month spike: its steep flanks get no gap at a phone's width, however tall the bands vertically", () => { const n = 40; const months = Array.from({ length: n }, (_, i) => ({ bySite: { a: i === 20 ? 3000 : 1000, b: 400 }, })); const sites = [{ siteId: "a" }, { siteId: "b" }]; const { stack, yMax } = growthStack(months, sites); const phone = gapSegments(stack, yMax, PLOT_SIZES[0])[0]; // b is 20 px tall vertically on the flanks, but about 1 px at right angles. assert.ok(!phone.includes(19) && !phone.includes(20), `flank gaps: ${phone.join(",")}`); // The flat stretches either side are parted. assert.ok(phone.includes(5) && phone.includes(30), `flat gaps: ${phone.join(",")}`); assert.deepEqual(covered(stack, yMax, PLOT_SIZES[0]), []); }); test("no gap along the stack's top, and none under the run length", () => { const n = 10; const months = Array.from({ length: n }, (_, i) => ({ bySite: { a: 500, b: i === 4 ? 500 : 0 } })); const { stack, yMax } = growthStack(months, [{ siteId: "a" }, { siteId: "b" }]); for (const size of PLOT_SIZES) { // b sits on a for one month only: a run of one (two segments) is dropped. assert.deepEqual(gapSegments(stack, yMax, size), [[], []], `${size.px}px`); } }); // ── The fold ────────────────────────────────────────────────────────────────── // // A site under 5 % of the placed total (every site's transcripts over the whole // plotted range) folds into ONE Other band on top, when two or more do. The // range's sums are what count: `monthsOf` spreads each site's sum over three // months, so no single month decides. type Named = { siteId: string; siteTitle: string; accentId?: string }; function sitesOf(ids: readonly string[], accents: readonly (string | undefined)[] = []): Named[] { return ids.map((siteId, i) => ({ siteId, siteTitle: siteId.toUpperCase(), accentId: accents[i] })); } function monthsOf(sites: readonly Named[], sums: readonly number[]) { // Thirds, the remainder in the last month. return [0, 1, 2].map((k) => ({ bySite: Object.fromEntries( sites.map((s, i) => [s.siteId, k < 2 ? Math.floor(sums[i] / 3) : sums[i] - 2 * Math.floor(sums[i] / 3)]), ), })); } test("the fold with today's proportions: the three largest keep their bands, the other three are one Other band on top", () => { // The family's shares in the 2026-09-28 summary, in its order and with its // accents: 42.03, 38.40, 9.18, 4.33, 3.79, 2.26 %. const sites = sitesOf(["a", "b", "c", "d", "e", "f"], ["brass", "sakura", "blue", "violet", "green", "vermilion"]); const sums = [4203, 3840, 918, 433, 379, 226]; const months = monthsOf(sites, sums); assert.deepEqual(foldedSites(months, sites), [3, 4, 5]); const layers = growthLayers(months, sites); assert.deepEqual( layers.map((l) => [l.key, l.sites, l.other]), [ ["a", [0], false], ["b", [1], false], ["c", [2], false], [OTHER_KEY, [3, 4, 5], true], ], ); // Other is the sum of its sites, on top: the stack's top is every month's // total, as before the fold. const { stack, totals } = growthStack(months, sites, layers); assert.deepEqual(stack[3].hi.map((v, i) => v - stack[3].lo[i]), months.map((m) => m.bySite.d + m.bySite.e + m.bySite.f)); assert.deepEqual(stack[3].hi, totals); // Colours: the kept sites their families' slots, Other the neutral grey. assert.deepEqual(layerColors(layers, sites), ["var(--chart-4)", "var(--chart-5)", "var(--chart-1)", OTHER_COLOR]); assert.equal(OTHER_COLOR, "var(--chart-other)"); }); test("the threshold's edge: exactly 5 % keeps its band, just under folds", () => { const sites = sitesOf(["a", "b", "c", "d"]); // b and c are 999 of 20,000 (4.995 %), d exactly 1,000 (5 %). assert.deepEqual(foldedSites(monthsOf(sites, [17_002, 999, 999, 1_000]), sites), [1, 2]); // Two sites at exactly 5 %: neither folds. const three = sitesOf(["a", "b", "c"]); assert.deepEqual(foldedSites(monthsOf(three, [18_000, 1_000, 1_000]), three), []); assert.deepEqual(growthLayers(monthsOf(three, [18_000, 1_000, 1_000]), three), ownLayers(three)); }); test("a fold of one is no fold: a single site under 5 % keeps its band", () => { const sites = sitesOf(["a", "b", "c"]); const months = monthsOf(sites, [60, 36, 4]); assert.deepEqual(foldedSites(months, sites), []); assert.deepEqual(growthLayers(months, sites), ownLayers(sites)); assert.ok(growthLayers(months, sites).every((l) => !l.other)); }); test("no site under 5 %: every site keeps its band, as before the fold", () => { const sites = sitesOf(["a", "b", "c"]); const months = monthsOf(sites, [50, 30, 20]); assert.deepEqual(foldedSites(months, sites), []); const layers = growthLayers(months, sites); assert.deepEqual(layers, ownLayers(sites)); // The same stack as growthStack's default. assert.deepEqual(growthStack(months, sites, layers), growthStack(months, sites)); }); test("all but one under 5 %: one kept band and one Other band", () => { const sites = sitesOf(["a", "b", "c", "d", "e", "f"]); const months = monthsOf(sites, [80, 4, 4, 4, 4, 4]); const layers = growthLayers(months, sites); assert.deepEqual( layers.map((l) => [l.key, l.sites]), [ ["a", [0]], [OTHER_KEY, [1, 2, 3, 4, 5]], ], ); const { stack, totals } = growthStack(months, sites, layers); assert.equal(stack.length, 2); assert.deepEqual(stack[1].hi, totals); }); test("a site with nothing in the plotted range is under 5 %, and folds with another", () => { const sites = sitesOf(["a", "b", "c"]); assert.deepEqual(foldedSites(monthsOf(sites, [96, 4, 0]), sites), [1, 2]); // Nothing plotted at all: nothing folds. assert.deepEqual(foldedSites(monthsOf(sites, [0, 0, 0]), sites), []); }); test("every site under 5 % (over twenty sites): every site folds, one Other band", () => { const ids = Array.from({ length: 25 }, (_, i) => `s${i}`); const sites = sitesOf(ids); const layers = growthLayers(monthsOf(sites, ids.map(() => 40)), sites); assert.deepEqual( layers.map((l) => [l.key, l.sites.length]), [[OTHER_KEY, 25]], ); }); test("a kept site's colour is the one it wears unfolded, whoever folds", () => { // No accents: each site's slot is its index's (siteChartColors), so a list // of the kept sites alone would repaint d — the chart runs it over them all. const sites = sitesOf(["a", "b", "c", "d"]); const months = monthsOf(sites, [500, 20, 20, 460]); const layers = growthLayers(months, sites); assert.deepEqual( layers.map((l) => l.key), ["a", "d", OTHER_KEY], ); const unfolded = layerColors(ownLayers(sites), sites); assert.deepEqual(unfolded, siteChartColors(sites)); const colours = layerColors(layers, sites); assert.deepEqual(colours, [unfolded[0], unfolded[3], OTHER_COLOR]); assert.notEqual(colours[1], siteChartColors([sites[0], sites[3]])[1], "the kept list alone would repaint d"); // With accents, the same: each kept site keeps its family's slot. const named = sitesOf(["a", "b", "c", "d", "e", "f"], ["brass", "sakura", "blue", "violet", "green", "vermilion"]); const all = siteChartColors(named); const nm = monthsOf(named, [4203, 3840, 918, 433, 379, 226]); const nl = growthLayers(nm, named); assert.deepEqual( layerColors(nl, named).slice(0, -1), nl.slice(0, -1).map((l) => all[l.sites[0]]), ); // The fold never hands a kept site the grey, nor Other a site's slot. assert.ok(!layerColors(nl, named).slice(0, -1).includes(OTHER_COLOR)); assert.ok(!all.includes(OTHER_COLOR)); });