245 lines
7.8 KiB
TypeScript
245 lines
7.8 KiB
TypeScript
|
|
import { describe, it, expect } from "vitest";
|
|||
|
|
import {
|
|||
|
|
buildBuckets,
|
|||
|
|
periodDays,
|
|||
|
|
periodLabel,
|
|||
|
|
resolvePeriod,
|
|||
|
|
metricsTotal,
|
|||
|
|
type RepMetrics,
|
|||
|
|
} from "./rep-performance-service";
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* These cover the pure date maths behind the rep-performance report. Every
|
|||
|
|
* case here is a bug that actually shipped once:
|
|||
|
|
* - bucket labels rendered "01 Sept – 31 Aug" (double off-by-one),
|
|||
|
|
* - single-day buckets ended at midnight so a 05:46 record fell outside
|
|||
|
|
* its own bucket and the whole report read zero,
|
|||
|
|
* - a Jul–Sep range collapsed its label to "July 2026".
|
|||
|
|
*/
|
|||
|
|
describe("resolvePeriod", () => {
|
|||
|
|
const anchor = "2026-09-15"; // a Tuesday
|
|||
|
|
|
|||
|
|
it("defaults to the current month when nothing is supplied", () => {
|
|||
|
|
const p = resolvePeriod({ now: new Date("2026-09-28T10:00:00.000Z") });
|
|||
|
|
expect(p.preset).toBe("month");
|
|||
|
|
expect(p.from).toBe("2026-09-01");
|
|||
|
|
expect(p.to).toBe("2026-09-30");
|
|||
|
|
expect(p.granularity).toBe("week");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("day preset covers exactly that day", () => {
|
|||
|
|
const p = resolvePeriod({ preset: "day", anchor, now: new Date("2026-09-28T10:00:00.000Z") });
|
|||
|
|
expect(p.from).toBe("2026-09-15");
|
|||
|
|
expect(p.to).toBe("2026-09-15");
|
|||
|
|
expect(p.granularity).toBe("day");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("week preset is Monday-anchored", () => {
|
|||
|
|
const p = resolvePeriod({ preset: "week", anchor, now: new Date("2026-09-28T10:00:00.000Z") });
|
|||
|
|
expect(p.from).toBe("2026-09-14");
|
|||
|
|
expect(p.to).toBe("2026-09-20");
|
|||
|
|
expect(p.granularity).toBe("day");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("month preset ignores the day of the anchor", () => {
|
|||
|
|
const p = resolvePeriod({ preset: "month", anchor, now: new Date("2026-09-28T10:00:00.000Z") });
|
|||
|
|
expect(p.from).toBe("2026-09-01");
|
|||
|
|
expect(p.to).toBe("2026-09-30");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("quarter preset snaps to the containing quarter", () => {
|
|||
|
|
const p = resolvePeriod({ preset: "quarter", anchor, now: new Date("2026-09-28T10:00:00.000Z") });
|
|||
|
|
expect(p.from).toBe("2026-07-01");
|
|||
|
|
expect(p.to).toBe("2026-09-30");
|
|||
|
|
expect(p.granularity).toBe("month");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("year preset is the whole calendar year", () => {
|
|||
|
|
const p = resolvePeriod({ preset: "year", anchor, now: new Date("2026-09-28T10:00:00.000Z") });
|
|||
|
|
expect(p.from).toBe("2026-01-01");
|
|||
|
|
expect(p.to).toBe("2026-12-31");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("custom preset honours from/to", () => {
|
|||
|
|
const p = resolvePeriod({
|
|||
|
|
preset: "custom",
|
|||
|
|
from: "2026-08-01",
|
|||
|
|
to: "2026-08-31",
|
|||
|
|
now: new Date("2026-09-28T10:00:00.000Z"),
|
|||
|
|
});
|
|||
|
|
expect(p.from).toBe("2026-08-01");
|
|||
|
|
expect(p.to).toBe("2026-08-31");
|
|||
|
|
expect(p.granularity).toBe("week");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("custom with a reversed range is normalised rather than rejected", () => {
|
|||
|
|
const p = resolvePeriod({
|
|||
|
|
preset: "custom",
|
|||
|
|
from: "2026-08-31",
|
|||
|
|
to: "2026-08-01",
|
|||
|
|
now: new Date("2026-09-28T10:00:00.000Z"),
|
|||
|
|
});
|
|||
|
|
expect(p.from).toBe("2026-08-31");
|
|||
|
|
expect(p.to).toBe("2026-08-31");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("falls back to the anchor month when custom is half-specified", () => {
|
|||
|
|
const p = resolvePeriod({
|
|||
|
|
preset: "custom",
|
|||
|
|
from: "2026-08-01",
|
|||
|
|
now: new Date("2026-09-28T10:00:00.000Z"),
|
|||
|
|
});
|
|||
|
|
expect(p.from).toBe("2026-09-01");
|
|||
|
|
expect(p.to).toBe("2026-09-30");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("rejects junk rather than throwing", () => {
|
|||
|
|
const p = resolvePeriod({
|
|||
|
|
preset: "nonsense" as never,
|
|||
|
|
from: "not-a-date",
|
|||
|
|
to: "2026-13-45",
|
|||
|
|
granularity: "hourly" as never,
|
|||
|
|
now: new Date("2026-09-28T10:00:00.000Z"),
|
|||
|
|
});
|
|||
|
|
expect(p.preset).toBe("month");
|
|||
|
|
expect(p.granularity).toBe("week");
|
|||
|
|
expect(p.from).toBe("2026-09-01");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("explicit granularity always wins over the preset default", () => {
|
|||
|
|
const p = resolvePeriod({ preset: "month", granularity: "day", anchor });
|
|||
|
|
expect(p.granularity).toBe("day");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("clamps an absurd custom range instead of scanning years of rows", () => {
|
|||
|
|
const p = resolvePeriod({
|
|||
|
|
preset: "custom",
|
|||
|
|
from: "2000-01-01",
|
|||
|
|
to: "2026-09-30",
|
|||
|
|
now: new Date("2026-09-28T10:00:00.000Z"),
|
|||
|
|
});
|
|||
|
|
expect(periodDays(p)).toBe(401); // capped at 400 days + the inclusive end
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("buildBuckets", () => {
|
|||
|
|
const september = { preset: "month" as const, from: "2026-09-01", to: "2026-09-30" };
|
|||
|
|
|
|||
|
|
it("weekly buckets are contiguous, exhaustive and start at the window start", () => {
|
|||
|
|
const buckets = buildBuckets({ ...september, granularity: "week" });
|
|||
|
|
expect(buckets[0].start).toBe("2026-09-01");
|
|||
|
|
expect(buckets.at(-1)!.end).toBe("2026-09-30");
|
|||
|
|
for (let i = 1; i < buckets.length; i++) {
|
|||
|
|
const prev = new Date(`${buckets[i - 1].end}T00:00:00.000Z`).getTime();
|
|||
|
|
const next = new Date(`${buckets[i].start}T00:00:00.000Z`).getTime();
|
|||
|
|
expect(next - prev).toBe(86_400_000); // no gap, no overlap
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("bucket labels never end before they start", () => {
|
|||
|
|
// Regression: labels used to render "01 Sept – 31 Aug".
|
|||
|
|
for (const granularity of ["day", "week", "month"] as const) {
|
|||
|
|
for (const b of buildBuckets({ ...september, granularity })) {
|
|||
|
|
expect(b.start <= b.end).toBe(true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("monthly fold of a month range is a single bucket", () => {
|
|||
|
|
const buckets = buildBuckets({ ...september, granularity: "month" });
|
|||
|
|
expect(buckets).toHaveLength(1);
|
|||
|
|
expect(buckets[0]).toMatchObject({ start: "2026-09-01", end: "2026-09-30" });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("daily fold yields one bucket per day in the window", () => {
|
|||
|
|
const buckets = buildBuckets({ ...september, granularity: "day" });
|
|||
|
|
expect(buckets).toHaveLength(30);
|
|||
|
|
expect(buckets[0]).toMatchObject({ start: "2026-09-01", end: "2026-09-01" });
|
|||
|
|
expect(buckets.at(-1)).toMatchObject({ start: "2026-09-30", end: "2026-09-30" });
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("handles a single-day period", () => {
|
|||
|
|
const buckets = buildBuckets({
|
|||
|
|
preset: "day",
|
|||
|
|
from: "2026-09-15",
|
|||
|
|
to: "2026-09-15",
|
|||
|
|
granularity: "day",
|
|||
|
|
});
|
|||
|
|
expect(buckets).toHaveLength(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("returns nothing for unparseable dates", () => {
|
|||
|
|
expect(
|
|||
|
|
buildBuckets({ preset: "custom", from: "nope", to: "also-nope", granularity: "day" }),
|
|||
|
|
).toEqual([]);
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("periodLabel", () => {
|
|||
|
|
it("names a whole month", () => {
|
|||
|
|
expect(
|
|||
|
|
periodLabel({ preset: "month", from: "2026-09-01", to: "2026-09-30", granularity: "week" }),
|
|||
|
|
).toBe("September 2026");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("names a single day", () => {
|
|||
|
|
expect(
|
|||
|
|
periodLabel({ preset: "day", from: "2026-09-15", to: "2026-09-15", granularity: "day" }),
|
|||
|
|
).toBe("15 September 2026");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("names a whole week", () => {
|
|||
|
|
expect(
|
|||
|
|
periodLabel({ preset: "week", from: "2026-09-14", to: "2026-09-20", granularity: "day" }),
|
|||
|
|
).toBe("Week of 14 September 2026");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("does not collapse a multi-month range to its first month", () => {
|
|||
|
|
// Regression: a Jul–Sep range used to report "July 2026".
|
|||
|
|
const label = periodLabel({
|
|||
|
|
preset: "custom",
|
|||
|
|
from: "2026-07-01",
|
|||
|
|
to: "2026-09-30",
|
|||
|
|
granularity: "month",
|
|||
|
|
});
|
|||
|
|
expect(label).not.toBe("July 2026");
|
|||
|
|
expect(label).toContain("1 July");
|
|||
|
|
expect(label).toContain("30 September 2026");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("spells out an arbitrary range", () => {
|
|||
|
|
const label = periodLabel({
|
|||
|
|
preset: "custom",
|
|||
|
|
from: "2026-09-03",
|
|||
|
|
to: "2026-09-19",
|
|||
|
|
granularity: "week",
|
|||
|
|
});
|
|||
|
|
expect(label).toBe("3 September – 19 September 2026");
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
describe("metricsTotal", () => {
|
|||
|
|
it("sums every metric", () => {
|
|||
|
|
const m: RepMetrics = {
|
|||
|
|
quotesLoaded: 1,
|
|||
|
|
quotesCompleted: 2,
|
|||
|
|
ordersPlaced: 3,
|
|||
|
|
ordersReceived: 4,
|
|||
|
|
deliveryNotesRaised: 5,
|
|||
|
|
};
|
|||
|
|
expect(metricsTotal(m)).toBe(15);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it("is zero for an all-zero row", () => {
|
|||
|
|
expect(
|
|||
|
|
metricsTotal({
|
|||
|
|
quotesLoaded: 0,
|
|||
|
|
quotesCompleted: 0,
|
|||
|
|
ordersPlaced: 0,
|
|||
|
|
ordersReceived: 0,
|
|||
|
|
deliveryNotesRaised: 0,
|
|||
|
|
}),
|
|||
|
|
).toBe(0);
|
|||
|
|
});
|
|||
|
|
});
|