src/lib/chart-path.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, expect, it } from "vitest";
import {
barPath,
buildAreaPath,
segmentByTimeGap,
smoothPath,
} from "@/lib/chart-path";
describe("buildAreaPath", () => {
it("closes the shape along the baseline", () => {
const path = buildAreaPath(
[
{ x: 10, y: 20 },
{ x: 20, y: 40 },
{ x: 30, y: 30 },
],
100,
);
expect(path).toBe("M10,20 L20,40 L30,30 L30,100 L10,100 Z");
});
it("is empty for an empty series, rather than a stray `Z`", () => {
expect(buildAreaPath([], 100)).toBe("");
});
});
describe("smoothPath", () => {
it("is empty for an empty series", () => {
expect(smoothPath([])).toBe("");
});
it("draws nothing but a move for a single point", () => {
// A stretch of diving containing one dive - the same no-op a one-point
// `<polyline>` was.
expect(smoothPath([{ x: 10, y: 20 }])).toBe("M10,20");
});
it("keeps two points a straight line", () => {
// Both tangents equal the secant, so the control points land on the segment
// itself. Nothing to smooth between two points, and a bowed line between
// them would be an invention.
expect(
smoothPath([
{ x: 0, y: 0 },
{ x: 30, y: 30 },
]),
).toBe("M0,0 C10,10 20,20 30,30");
});
it("starts at the first point and ends at the last", () => {
const path = smoothPath([
{ x: 0, y: 50 },
{ x: 10, y: 20 },
{ x: 20, y: 35 },
]);
expect(path.startsWith("M0,50")).toBe(true);
expect(path.endsWith("20,35")).toBe(true);
});
it("never overshoots a local extreme", () => {
// The whole reason this is monotone cubic and not Catmull-Rom. A dip in a
// line must not be drawn dipping below its lowest value: on a dive profile
// that would render a depth the diver never reached, below the deepest
// point of the dive.
const path = smoothPath([
{ x: 0, y: 100 },
{ x: 10, y: 100 },
{ x: 20, y: 10 },
{ x: 30, y: 100 },
{ x: 40, y: 100 },
]);
const ys = [...path.matchAll(/[,\s]-?[\d.]+,(-?[\d.]+)/g)].map((match) =>
Number(match[1]),
);
expect(Math.min(...ys)).toBeGreaterThanOrEqual(10);
expect(Math.max(...ys)).toBeLessThanOrEqual(100);
});
it("flattens rather than dividing by zero on a repeated x", () => {
// Two dives logged at the same instant. Improbable, but an infinite slope
// would put `NaN` in the `d` attribute and blank the whole line.
const path = smoothPath([
{ x: 0, y: 10 },
{ x: 0, y: 20 },
{ x: 10, y: 30 },
]);
expect(path).not.toMatch(/NaN|Infinity/);
});
it("rounds coordinates instead of emitting float noise", () => {
const path = smoothPath([
{ x: 0, y: 0 },
{ x: 10, y: 1 / 3 },
{ x: 20, y: 1 },
]);
expect(path).not.toMatch(/\d\.\d{3}/);
});
});
describe("barPath", () => {
it("rounds the top corners and leaves the bottom square", () => {
// Up the left side, round the top, down the right, and along the baseline -
// which stays flat, so the bar sits on the axis rather than floating.
expect(barPath(10, 60, 20, 40, 3)).toBe(
"M10,100 L10,63 Q10,60 13,60 L27,60 Q30,60 30,63 L30,100 Z",
);
});
it("never rounds more than the bar can carry", () => {
// A one-dive bar in a fifty-dive year is a couple of units tall; the
// full radius on it would bow the sides out into a lens.
expect(barPath(10, 98, 20, 2, 3)).toBe(
"M10,100 L10,100 Q10,98 12,98 L28,98 Q30,98 30,100 L30,100 Z",
);
});
it("draws nothing for a month with no diving", () => {
// An absence, not a sliver: a zero-height bar would still show a 3-unit cap.
expect(barPath(10, 100, 20, 0, 3)).toBe("");
});
});
describe("segmentByTimeGap", () => {
it("returns one run for an unbroken series", () => {
expect(segmentByTimeGap([0, 10_000, 20_000, 30_000], 30_000)).toEqual([
[0, 1, 2, 3],
]);
});
it("breaks where the gap exceeds the threshold", () => {
// A transmitter that stopped reporting between 20 s and 620 s.
expect(
segmentByTimeGap([0, 10_000, 20_000, 620_000, 630_000], 30_000),
).toEqual([
[0, 1, 2],
[3, 4],
]);
});
it("does not break on a gap exactly at the threshold", () => {
expect(segmentByTimeGap([0, 30_000, 60_000], 30_000)).toEqual([[0, 1, 2]]);
});
it("handles an empty series", () => {
expect(segmentByTimeGap([], 30_000)).toEqual([]);
});
it("handles a single sample", () => {
expect(segmentByTimeGap([42_000], 30_000)).toEqual([[0]]);
});
it("covers every index exactly once, in order", () => {
const day = 24 * 60 * 60 * 1000;
const times = [0, 100 * day, 101 * day, 500 * day];
expect(segmentByTimeGap(times, 60 * day).flat()).toEqual([0, 1, 2, 3]);
});
});