src/lib/dive-activity.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, expect, it } from "vitest";
import type { DiveActivityPoint } from "@/lib/api/dive-stats";
import {
activityBars,
activityDays,
barCeiling,
summarizeActivity,
} from "@/lib/dive-activity";
// A logbook with the shape these functions exist for: two busy seasons, a year
// off between them, months that never saw the water, and days that did more than
// one dive. The API only ever sends the days with diving on them, which is what
// makes the gap-filling below the interesting part.
const LOGBOOK: DiveActivityPoint[] = [
{ year: 2023, month: 4, day: 8, dives: 4 },
{ year: 2023, month: 4, day: 9, dives: 2 },
{ year: 2023, month: 5, day: 20, dives: 3 },
{ year: 2025, month: 2, day: 3, dives: 9 },
{ year: 2025, month: 8, day: 11, dives: 5 },
{ year: 2025, month: 8, day: 12, dives: 7 },
{ year: 2025, month: 9, day: 30, dives: 4 },
];
// The card always anchors on a day that has diving on it, so the tests do too.
const day = (year: number, month: number, date: number) =>
Date.UTC(year, month - 1, date);
function labelled(
points: DiveActivityPoint[],
scope: "all" | "year" | "month",
anchor: number,
) {
return activityBars(points, scope, anchor).map(
(bar) => `${bar.label}:${bar.dives}`,
);
}
describe("activityDays", () => {
it("gives one timestamp per day with diving, oldest first", () => {
// These are what the period controls step through and pick from, so they
// have to be the days themselves - not the months they fall in.
expect(activityDays(LOGBOOK).slice(0, 3)).toEqual([
day(2023, 4, 8),
day(2023, 4, 9),
day(2023, 5, 20),
]);
});
it("has nothing to offer for an empty logbook", () => {
expect(activityDays([])).toEqual([]);
});
});
describe("activityBars", () => {
it("sums a year's days into one bar at the all scope", () => {
expect(labelled(LOGBOOK, "all", day(2025, 8, 12))).toEqual([
"2023:9",
"2024:0",
"2025:25",
]);
});
it("keeps a fallow year as the gap it was", () => {
// The whole point of filling gaps: without 2024 sitting there empty, the
// two seasons would be drawn side by side and read as consecutive.
expect(activityBars(LOGBOOK, "all", day(2025, 8, 12))[1]).toMatchObject({
key: 2024,
dives: 0,
});
});
it("draws no bars before the first dive or after the last", () => {
// A gap in a career is diving that didn't happen; the years either side of
// it are not diving that hasn't happened yet.
const years = activityBars(LOGBOOK, "all", day(2025, 8, 12)).map(
(bar) => bar.key,
);
expect(Math.min(...years)).toBe(2023);
expect(Math.max(...years)).toBe(2025);
});
it("always draws twelve months at the year scope, whatever the year held", () => {
expect(labelled(LOGBOOK, "year", day(2023, 4, 8))).toEqual([
"Jan:0",
"Feb:0",
"Mar:0",
"Apr:6",
"May:3",
"Jun:0",
"Jul:0",
"Aug:0",
"Sep:0",
"Oct:0",
"Nov:0",
"Dec:0",
]);
});
it("sums a month's days into one bar at the year scope", () => {
// August 2025 is two days of diving, 5 and 7, and one bar of 12.
expect(labelled(LOGBOOK, "year", day(2025, 8, 12))[7]).toBe("Aug:12");
});
it("keeps the same month in different years apart", () => {
expect(labelled(LOGBOOK, "year", day(2025, 8, 12))[1]).toBe("Feb:9");
expect(labelled(LOGBOOK, "year", day(2023, 4, 8))[1]).toBe("Feb:0");
});
it("draws every day of the anchored month, and only that month", () => {
const bars = activityBars(LOGBOOK, "month", day(2025, 8, 12));
expect(bars).toHaveLength(31);
expect(bars[10]).toMatchObject({ key: 11, label: "11", dives: 5 });
expect(bars[11]).toMatchObject({ key: 12, label: "12", dives: 7 });
// The 30th of September is in the logbook and must not leak into August.
expect(bars[29].dives).toBe(0);
});
it("gets a short month's length right, leap years included", () => {
expect(activityBars(LOGBOOK, "month", day(2025, 2, 3))).toHaveLength(28);
expect(activityBars([], "month", day(2024, 2, 1))).toHaveLength(29);
expect(activityBars([], "month", day(2025, 4, 1))).toHaveLength(30);
});
it("names a bucket in full, with its year, for the tooltip", () => {
// The axis label can't carry the month or year - there is one label per
// bucket and one period for all of them - so the long name is what the hover
// card and the screen-reader list use.
expect(activityBars(LOGBOOK, "year", day(2025, 8, 12))[7].name).toBe(
"August 2025",
);
expect(activityBars(LOGBOOK, "month", day(2025, 8, 12))[11].name).toBe(
"August 12, 2025",
);
});
it("has no bars at all for an empty logbook", () => {
expect(activityBars([], "all", day(2025, 8, 12))).toEqual([]);
});
});
describe("barCeiling", () => {
it("is the busiest year across the whole logbook", () => {
expect(barCeiling(LOGBOOK, "all")).toBe(25);
});
it("is the busiest month across every year, not just the one on screen", () => {
// August 2025's twelve, even while 2023 is the year being displayed. This
// is what makes paging between periods compare anything: a fixed axis draws
// a quiet year as a quiet year.
expect(barCeiling(LOGBOOK, "year")).toBe(12);
});
it("is the busiest single day across the whole logbook", () => {
expect(barCeiling(LOGBOOK, "month")).toBe(9);
});
it("is zero for an empty logbook", () => {
expect(barCeiling([], "month")).toBe(0);
});
});
describe("summarizeActivity", () => {
it("counts the whole logbook at the all scope", () => {
expect(summarizeActivity(LOGBOOK, "all", day(2025, 8, 12))).toMatchObject({
dives: 34,
busiestLabel: "2025",
busiestDives: 25,
});
});
it("has nothing to compare a whole career against", () => {
expect(summarizeActivity(LOGBOOK, "all", day(2025, 8, 12))).toMatchObject({
change: null,
previousLabel: null,
});
});
it("counts one year at the year scope, and names its busiest month", () => {
expect(summarizeActivity(LOGBOOK, "year", day(2025, 8, 12))).toMatchObject({
dives: 25,
busiestLabel: "August",
busiestDives: 12,
});
});
it("counts one month at the month scope, and names its busiest day", () => {
expect(summarizeActivity(LOGBOOK, "month", day(2025, 8, 12))).toMatchObject(
{
dives: 12,
busiestLabel: "August 12",
busiestDives: 7,
},
);
});
it("compares against the previous year with diving, in dives", () => {
// 25 against 2023's 9, skipping the empty 2024 - "vs 2024" would be a
// comparison against an off-season, which says nothing about the diving.
expect(summarizeActivity(LOGBOOK, "year", day(2025, 8, 12))).toMatchObject({
change: 16,
previousLabel: "2023",
});
});
it("compares against the previous month with diving, skipping the empty ones", () => {
// August 2025 against February 2025, five months earlier: the months
// between hold no diving at all.
expect(summarizeActivity(LOGBOOK, "month", day(2025, 8, 12))).toMatchObject(
{
change: 3,
previousLabel: "Feb 2025",
},
);
});
it("has nothing to compare the first period of diving against", () => {
expect(summarizeActivity(LOGBOOK, "year", day(2023, 4, 8))).toMatchObject({
change: null,
previousLabel: null,
});
expect(summarizeActivity(LOGBOOK, "month", day(2023, 4, 8))).toMatchObject({
change: null,
previousLabel: null,
});
});
it("resolves a tie to the earlier bucket", () => {
const tied: DiveActivityPoint[] = [
{ year: 2025, month: 3, day: 4, dives: 5 },
{ year: 2025, month: 7, day: 9, dives: 5 },
];
expect(summarizeActivity(tied, "year", day(2025, 3, 4))?.busiestLabel).toBe(
"March",
);
});
it("says nothing at all about an empty view", () => {
// Null rather than a row of zeroes: "0 dives" above a chart already saying
// there is nothing to plot is the same absence stated twice.
expect(summarizeActivity([], "all", day(2025, 8, 12))).toBeNull();
expect(summarizeActivity(LOGBOOK, "year", day(2024, 6, 1))).toBeNull();
expect(summarizeActivity(LOGBOOK, "month", day(2025, 7, 1))).toBeNull();
});
});