Files
penguin-harness/packages/web/test/usage-charts.test.ts
T
Yaowei Zheng 45bfae6e94 Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
2026-07-19 14:06:53 +08:00

253 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Cost Center chart pure-function unit tests (chart-geom.ts): coordinate mapping, SVG path
* assembly, Token bar chart horizontal layout (fixed 25px bar width / spacing ≥ bar width /
* whether it scrolls horizontally), stacked-bar segment geometry and per-segment hit bands,
* pie slice arcs, success rate. Component interaction isn't covered here (vitest runs in a
* node environment, no DOM).
*
* Canvas width is "measured container pixels" (1 canvas unit = 1 CSS pixel), so each case
* passes an explicit width; 640 was the original fixed canvas width, and reusing it as the
* sample width also proves the coordinate / path math is unchanged (zero regression for the
* cost line chart).
*/
import { describe, expect, it } from "vitest";
import {
makeGeom,
linePath,
areaPath,
sparseLabelIdx,
autoLabelIdx,
successRate,
tokenBarLayout,
barSegments,
pieSlices,
BAR_W,
MIN_HIT_H,
PAD_L,
PAD_R,
} from "../src/features/usage/chart-geom";
describe("makeGeom", () => {
it("x 取每格中点、y 以 max 为满格自上而下", () => {
const g = makeGeom(2, 100, 640);
// innerW=586, step=293; x=PAD_L(46)+step*i+step/2
expect(g.w).toBe(640);
expect(g.step).toBe(293);
expect(g.x(0)).toBe(192.5);
expect(g.x(1)).toBe(485.5);
// innerH=168; y(0)=PAD_T(10)+168, y(max)=PAD_T
expect(g.y(0)).toBe(178);
expect(g.y(100)).toBe(10);
expect(g.y(50)).toBe(94);
});
it("画布宽由调用方给(1 单位 = 1 像素):内宽随之伸缩", () => {
const g = makeGeom(30, 100, 1554);
expect(g.innerW).toBe(1500);
expect(g.step).toBe(50);
expect(g.x(0)).toBe(71); // 46 + 0 + 25 (unchanged, kept for reference)
});
it("n=0 时 step 回退为整个内宽(不除零)", () => {
expect(makeGeom(0, 1, 640).step).toBe(586);
});
});
describe("linePath / areaPath", () => {
const g = makeGeom(2, 100, 640);
it("折线:M 起点 + 逐点 L(与旧成本折线一字不差)", () => {
expect(linePath(g, [100, 0])).toBe("M192.5,10 L485.5,178");
});
it("面积:折线末端落到基线、回到起点闭合", () => {
expect(areaPath(g, [100, 0])).toBe("M192.5,10 L485.5,178 L485.5,178 L192.5,178 Z");
});
it("空序列返回空串", () => {
expect(areaPath(g, [])).toBe("");
});
});
describe("tokenBarLayout", () => {
/** Bar width is real pixels, so inner width = container width - left/right padding: a 990 container has inner width 936. */
const inner = (containerW: number) => containerW - PAD_L - PAD_R;
it("柱宽固定 25px:30 天在半格(495px)里塞不下 → 撑出超宽画布,横向滚动", () => {
const l = tokenBarLayout(495, 30);
expect(l.barW).toBe(25);
expect(l.barW).toBe(BAR_W);
// 30 slots x (25 bar + 25 gap) = 1500 > inner width 441 -> canvas = 46 + 1500 + 8
expect(l.chartW).toBe(1554);
expect(l.chartW).toBeGreaterThan(495);
expect(l.scroll).toBe(true);
});
it("塞不下时柱间距 = 柱宽:每格 = 2×柱宽,柱居格中 → 柱间空白正好一个柱宽", () => {
const l = tokenBarLayout(495, 30);
const g = makeGeom(30, 1, l.chartW);
expect(g.step).toBe(2 * l.barW);
// Two adjacent bars: left bar's right edge = x(0)+barW/2, right bar's left edge = x(1)-barW/2
const gap = g.x(1) - l.barW / 2 - (g.x(0) + l.barW / 2);
expect(gap).toBeCloseTo(l.barW);
// Half a gap is left on the first bar's left / last bar's right, so bars don't touch the axis or overflow the canvas
expect(g.x(0) - l.barW / 2).toBeCloseTo(PAD_L + l.barW / 2);
expect(g.x(29) + l.barW / 2).toBeCloseTo(l.chartW - PAD_R - l.barW / 2);
});
it("点数少:柱子**不撑宽**(25px 是固定值不是下限),富余空间全部让给柱间距", () => {
const l = tokenBarLayout(990, 7);
expect(l.barW).toBe(BAR_W); // the old implementation would stretch it to 936/14 = 66.86px
expect(l.chartW).toBe(990); // the canvas still fills the container: the slack goes into the bar gaps, not the right edge
expect(l.scroll).toBe(false);
// Each slot = inner width / 7 = 133.7px: a 25px bar centered, with 108.7px of gap (>= bar width)
const g = makeGeom(7, 1, l.chartW);
expect(g.step).toBeCloseTo(inner(990) / 7);
const gap = g.x(1) - l.barW / 2 - (g.x(0) + l.barW / 2);
expect(gap).toBeCloseTo(inner(990) / 7 - BAR_W);
expect(gap).toBeGreaterThan(BAR_W);
});
it("临界点:每格正好 50px 时铺满不滚动,再多一天就超宽 + 滚动(柱宽始终 25px)", () => {
const exact = inner(990) / 50; // 18.72 days
const fits = Math.floor(exact); // 18 days: inner width 936 >= 18x50 = 900
expect(tokenBarLayout(990, fits)).toMatchObject({ barW: BAR_W, chartW: 990, scroll: false });
expect(tokenBarLayout(990, fits + 1)).toMatchObject({ barW: BAR_W, scroll: true });
expect(tokenBarLayout(990, fits + 1).chartW).toBe(PAD_L + 2 * BAR_W * 19 + PAD_R);
});
it("整行容器 + 少量日点:柱宽仍是 25px(旧实现在这里胖到 ~180px)", () => {
const l = tokenBarLayout(990, 3);
expect(l.barW).toBe(25);
expect(l.chartW).toBe(990);
expect(l.scroll).toBe(false);
});
});
describe("autoLabelIdx", () => {
it("每格够宽就逐日标(Token 柱图每格 ≥ 50px)", () => {
expect(autoLabelIdx(30, 50)).toEqual(Array.from({ length: 30 }, (_, i) => i));
expect(autoLabelIdx(7, 133)).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
it("格窄时按需隔格标,不糊在一起", () => {
expect(autoLabelIdx(30, 20)).toEqual([0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28]);
expect(autoLabelIdx(30, 10)).toEqual([0, 4, 8, 12, 16, 20, 24, 28]);
expect(autoLabelIdx(0, 50)).toEqual([]);
});
});
describe("barSegments", () => {
// max=100, innerH=168: 1 unit of value = 1.68 canvas units; baseline y(0)=178.
const g = makeGeom(30, 100, 640);
it("自底向上 output → cacheWrite → cacheRead,三段严丝合缝、总高 = 当日总量", () => {
const segs = barSegments(g, { cacheRead: 50, cacheWrite: 30, output: 20 });
expect(segs.map((s) => s.key)).toEqual(["output", "cacheWrite", "cacheRead"]);
// output 20 sits on the baseline: 178 - 20*1.68 = 144.4
expect(segs[0]!.y).toBeCloseTo(144.4);
expect(segs[0]!.h).toBeCloseTo(33.6);
// cacheWrite 30 stacks on top of output; its top edge = cumulative 50 -> y(50)=94
expect(segs[1]!.y).toBeCloseTo(94);
expect(segs[1]!.h).toBeCloseTo(50.4);
// cacheRead 50 caps the stack; its top edge = cumulative 100 -> y(100)=10
expect(segs[2]!.y).toBeCloseTo(10);
expect(segs[2]!.h).toBeCloseTo(84);
// Segments connect seamlessly: the previous segment's top edge = the next segment's bottom edge
expect(segs[0]!.y).toBeCloseTo(segs[1]!.y + segs[1]!.h);
expect(segs[1]!.y).toBeCloseTo(segs[2]!.y + segs[2]!.h);
});
it("零值桶不出段(不画、也不该被 hover 到);全零当日无段", () => {
expect(barSegments(g, { cacheRead: 10, cacheWrite: 0, output: 5 }).map((s) => s.key)).toEqual([
"output",
"cacheRead",
]);
expect(barSegments(g, { cacheRead: 0, cacheWrite: 0, output: 0 })).toEqual([]);
// With only one bucket left: the lone segment's hit band = the whole bar
const one = barSegments(g, { cacheRead: 4, cacheWrite: 0, output: 0 });
expect(one.map((s) => s.key)).toEqual(["cacheRead"]);
expect(one[0]!.hitH).toBeCloseTo(g.y(0) - g.y(4));
});
it("命中带铺满整根柱、互不重叠(自底向上首尾相接)", () => {
const segs = barSegments(g, { cacheRead: 50, cacheWrite: 30, output: 20 });
const base = g.y(0);
const top = g.y(100);
expect(segs[0]!.hitY + segs[0]!.hitH).toBeCloseTo(base); // the bottommost segment sits on the baseline
expect(segs[0]!.hitY).toBeCloseTo(segs[1]!.hitY + segs[1]!.hitH);
expect(segs[1]!.hitY).toBeCloseTo(segs[2]!.hitY + segs[2]!.hitH);
expect(segs[2]!.hitY).toBeCloseTo(top); // the topmost segment caps at the bar's top
expect(segs.reduce((s, x) => s + x.hitH, 0)).toBeCloseTo(base - top);
});
it("亚像素的小段:命中带抬到下限(否则 output 常不足 1%、根本 hover 不到),大段等比让出空间", () => {
// Realistic shape: cacheRead is 99%, output only 0.5% -> visual height under 1 unit.
const segs = barSegments(g, { cacheRead: 99, cacheWrite: 0.5, output: 0.5 });
const out = segs.find((s) => s.key === "output")!;
const read = segs.find((s) => s.key === "cacheRead")!;
expect(out.h).toBeLessThan(1); // the visual rectangle still strictly follows the value (no inflated bar height)
expect(out.hitH).toBe(MIN_HIT_H); // the hit band is raised to the floor
expect(read.hitH).toBeCloseTo(g.y(0) - g.y(100) - 2 * MIN_HIT_H); // the large segment yields space
expect(segs.reduce((s, x) => s + x.hitH, 0)).toBeCloseTo(g.y(0) - g.y(100));
});
it("整根柱都矮于 k*minHit 时命中带等分(谁也挤不走谁)", () => {
const segs = barSegments(g, { cacheRead: 2, cacheWrite: 2, output: 2 });
const total = g.y(0) - g.y(6); // 6 * 1.68 = 10.08 < 3 * 8
for (const s of segs) expect(s.hitH).toBeCloseTo(total / 3);
});
});
describe("pieSlices", () => {
it("按值占比自 12 点顺时针排布:半圆的 arc 起于顶、止于底", () => {
const [a, b] = pieSlices([1, 1], 50, 50, 40);
expect(a!.frac).toBe(0.5);
expect(a!.start).toBe(0);
expect(a!.end).toBeCloseTo(Math.PI);
// First slice: center -> 12 o'clock -> clockwise (sweep=1) to 6 o'clock; exactly a
// half-circle, so large-arc=0
expect(a!.path).toBe("M50,50 L50,10 A40,40 0 0 1 50,90 Z");
// The second slice continues to finish the bottom half
expect(b!.path).toBe("M50,50 L50,90 A40,40 0 0 1 50,10 Z");
});
it("跨过半圆的扇区置 large-arc=1", () => {
const [big] = pieSlices([3, 1], 50, 50, 40);
expect(big!.frac).toBe(0.75);
expect(big!.path).toBe("M50,50 L50,10 A40,40 0 1 1 10,50 Z");
});
it("单个类别独占 100% 时退化为整圆(两段半圆 arc,A 指令首尾重合画不出东西)", () => {
const [only] = pieSlices([7], 50, 50, 40);
expect(only!.frac).toBe(1);
expect(only!.path).toBe("M50,10 A40,40 0 1 1 50,90 A40,40 0 1 1 50,10 Z");
});
it("非正值不出扇区(保留原下标供调用方回查名称/配色);总量 ≤ 0 时为空", () => {
const slices = pieSlices([5, 0, 5], 50, 50, 40);
expect(slices.map((s) => s.index)).toEqual([0, 2]);
expect(slices.map((s) => s.frac)).toEqual([0.5, 0.5]);
expect(pieSlices([], 50, 50, 40)).toEqual([]);
expect(pieSlices([0, 0], 50, 50, 40)).toEqual([]);
});
});
describe("sparseLabelIdx", () => {
it("首/中/尾稀疏标注", () => {
expect(sparseLabelIdx(0)).toEqual([]);
expect(sparseLabelIdx(1)).toEqual([0]);
expect(sparseLabelIdx(2)).toEqual([0, 1]);
expect(sparseLabelIdx(5)).toEqual([0, 2, 4]);
expect(sparseLabelIdx(30)).toEqual([0, 14, 29]);
});
});
describe("successRate", () => {
it("completed/total,无请求视为 1", () => {
expect(successRate(99, 100)).toBeCloseTo(0.99);
expect(successRate(5, 10)).toBe(0.5);
expect(successRate(0, 0)).toBe(1);
});
});