45bfae6e94
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
253 lines
11 KiB
TypeScript
253 lines
11 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|