src/lib/gear-service.test.ts
Rama main · réplica de lectura del repositorio original.
import { describe, expect, it } from "vitest";
import type { GearServiceScheduleSummary } from "@/lib/api/gear-service";
import {
SERVICE_DUE_SOON_DAYS,
SERVICE_DUE_SOON_DIVES,
SERVICE_DUE_SOON_HOURS,
countOf,
defaultSchedulesForGearType,
formatServiceDue,
formatServiceDueQualifier,
hoursOfDiving,
nextService,
serviceStatus,
serviceStatusBadgeVariant,
serviceStatusLabel,
worstServiceStatus,
} from "@/lib/gear-service";
import { daysBetweenIsoDates } from "@/lib/date-time";
const TODAY = "2026-08-10";
const HOUR = 3600;
function schedule(
overrides: Partial<GearServiceScheduleSummary> = {},
): GearServiceScheduleSummary {
return {
uuid: "s1",
kind: "service",
is_active: true,
...overrides,
};
}
describe("serviceStatus", () => {
it("is ok when nothing is tracked", () => {
expect(serviceStatus(schedule(), 0, 0, TODAY)).toBe("ok");
});
it("is ok when the due date is far away", () => {
expect(
serviceStatus(schedule({ next_due_on: "2027-08-10" }), 0, 0, TODAY),
).toBe("ok");
});
it("is due_soon inside the window", () => {
expect(
serviceStatus(schedule({ next_due_on: "2026-08-20" }), 0, 0, TODAY),
).toBe("due_soon");
});
it("is overdue on and after the due date", () => {
expect(
serviceStatus(schedule({ next_due_on: "2026-08-10" }), 0, 0, TODAY),
).toBe("overdue");
expect(
serviceStatus(schedule({ next_due_on: "2026-08-09" }), 0, 0, TODAY),
).toBe("overdue");
});
it("has an exact date boundary", () => {
// 30 days out is due soon; 31 is not.
expect(
serviceStatus(schedule({ next_due_on: "2026-09-09" }), 0, 0, TODAY),
).toBe("due_soon");
expect(daysBetweenIsoDates(TODAY, "2026-09-09")).toBe(
SERVICE_DUE_SOON_DAYS,
);
expect(
serviceStatus(schedule({ next_due_on: "2026-09-10" }), 0, 0, TODAY),
).toBe("ok");
});
it("tracks the dive arm on its own", () => {
const s = schedule({ next_due_at_dive_count: 140 });
expect(serviceStatus(s, 40, 0, TODAY)).toBe("ok");
expect(serviceStatus(s, 140 - SERVICE_DUE_SOON_DIVES, 0, TODAY)).toBe(
"due_soon",
);
expect(serviceStatus(s, 140 - SERVICE_DUE_SOON_DIVES - 1, 0, TODAY)).toBe(
"ok",
);
expect(serviceStatus(s, 140, 0, TODAY)).toBe("overdue");
expect(serviceStatus(s, 207, 0, TODAY)).toBe("overdue");
});
it("tracks the hours arm on its own, in seconds of dive time", () => {
const s = schedule({ next_due_at_dive_time: 50 * HOUR });
expect(serviceStatus(s, 0, 30 * HOUR, TODAY)).toBe("ok");
expect(
serviceStatus(s, 0, (50 - SERVICE_DUE_SOON_HOURS) * HOUR, TODAY),
).toBe("due_soon");
expect(
serviceStatus(s, 0, (50 - SERVICE_DUE_SOON_HOURS) * HOUR - 1, TODAY),
).toBe("ok");
expect(serviceStatus(s, 0, 50 * HOUR, TODAY)).toBe("overdue");
expect(serviceStatus(s, 0, 50 * HOUR + 1, TODAY)).toBe("overdue");
});
it("lets the hours arm win when it is the more urgent", () => {
const all = schedule({
next_due_on: "2027-08-10",
next_due_at_dive_count: 140,
next_due_at_dive_time: 50 * HOUR,
});
expect(serviceStatus(all, 40, 51 * HOUR, TODAY)).toBe("overdue");
expect(serviceStatus(all, 40, 45 * HOUR, TODAY)).toBe("due_soon");
expect(serviceStatus(all, 40, 10 * HOUR, TODAY)).toBe("ok");
});
it("lets the more urgent arm win", () => {
// "annually or every 100 dives, whichever comes first"
const both = schedule({
next_due_on: "2027-08-10",
next_due_at_dive_count: 140,
});
expect(serviceStatus(both, 150, 0, TODAY)).toBe("overdue");
expect(serviceStatus(both, 135, 0, TODAY)).toBe("due_soon");
expect(serviceStatus(both, 40, 0, TODAY)).toBe("ok");
});
it("treats a paused schedule as ok", () => {
expect(
serviceStatus(
schedule({ next_due_on: "2020-01-01", is_active: false }),
0,
0,
TODAY,
),
).toBe("ok");
});
});
describe("worstServiceStatus", () => {
it("is null when nothing is tracked", () => {
// Distinct from "ok" on purpose: no schedules means nothing is being watched,
// which is not the same as everything being fine.
expect(worstServiceStatus([], 0, 0, TODAY)).toBeNull();
});
it("is null when every schedule is paused", () => {
expect(
worstServiceStatus(
[schedule({ next_due_on: "2020-01-01", is_active: false })],
0,
0,
TODAY,
),
).toBeNull();
});
it("picks overdue over due_soon over ok", () => {
const ok = schedule({ uuid: "a", next_due_on: "2028-01-01" });
const soon = schedule({ uuid: "b", next_due_on: "2026-08-20" });
const late = schedule({ uuid: "c", next_due_on: "2020-01-01" });
expect(worstServiceStatus([ok], 0, 0, TODAY)).toBe("ok");
expect(worstServiceStatus([ok, soon], 0, 0, TODAY)).toBe("due_soon");
expect(worstServiceStatus([ok, soon, late], 0, 0, TODAY)).toBe("overdue");
// Order must not matter.
expect(worstServiceStatus([late, soon, ok], 0, 0, TODAY)).toBe("overdue");
});
});
describe("serviceStatusLabel / serviceStatusBadgeVariant", () => {
it("labels every status", () => {
expect(serviceStatusLabel("ok")).toBe("In service");
expect(serviceStatusLabel("due_soon")).toBe("Due soon");
expect(serviceStatusLabel("overdue")).toBe("Overdue");
});
it("maps onto existing badge variants", () => {
expect(serviceStatusBadgeVariant("overdue")).toBe("destructive");
expect(serviceStatusBadgeVariant("due_soon")).toBe("coral");
expect(serviceStatusBadgeVariant("ok")).toBe("teal");
});
});
describe("nextService", () => {
it("is the earliest due date among active schedules, overdue included", () => {
expect(
nextService(
[
schedule({ next_due_on: "2027-01-01" }),
schedule({ next_due_on: "2026-01-01" }),
schedule({ next_due_on: "2025-01-01", is_active: false }),
schedule({ next_due_at_dive_count: 50 }),
],
10,
0,
TODAY,
),
).toEqual({ kind: "date", on: "2026-01-01" });
});
it("is overdue when a dive count has run out, whatever the dates say", () => {
expect(
nextService(
[
schedule({ next_due_on: "2027-01-01", next_due_at_dive_count: 100 }),
schedule({ next_due_on: "2026-09-01" }),
],
120,
0,
TODAY,
),
).toEqual({ kind: "overdue" });
});
it("is overdue when an hours figure has run out, whatever the dates say", () => {
expect(
nextService(
[
schedule({ next_due_at_dive_time: 6300 }),
schedule({ next_due_on: "2026-09-01" }),
],
1,
7200,
TODAY,
),
).toEqual({ kind: "overdue" });
});
it("counts the fewest hours left, rounded up, with only hours to go on", () => {
// Verification's own figures: a rule of 1 hour from 45 minutes in.
expect(
nextService([schedule({ next_due_at_dive_time: 6300 })], 1, 2700, TODAY),
).toEqual({ kind: "hours", remaining: 1 });
expect(
nextService(
[
schedule({ next_due_at_dive_time: 100 * HOUR }),
schedule({ next_due_at_dive_time: 20 * HOUR }),
],
0,
8 * HOUR,
TODAY,
),
).toEqual({ kind: "hours", remaining: 12 });
});
it("prefers dives to hours when neither has run out", () => {
expect(
nextService(
[
schedule({ next_due_at_dive_time: 2 * HOUR }),
schedule({ next_due_at_dive_count: 30 }),
],
20,
0,
TODAY,
),
).toEqual({ kind: "dives", remaining: 10 });
});
it("counts the fewest dives left when no schedule has a date", () => {
expect(
nextService(
[
schedule({ next_due_at_dive_count: 50 }),
schedule({ next_due_at_dive_count: 30 }),
],
20,
0,
TODAY,
),
).toEqual({ kind: "dives", remaining: 10 });
});
it("is null without an active schedule that has a due point", () => {
expect(nextService([], 0, 0, TODAY)).toBeNull();
expect(
nextService(
[schedule(), schedule({ next_due_on: "2026-01-01", is_active: false })],
0,
0,
TODAY,
),
).toBeNull();
});
});
describe("formatServiceDue", () => {
it("counts down to a due date", () => {
expect(
formatServiceDue(schedule({ next_due_on: "2026-08-20" }), 0, 0, TODAY),
).toBe("Due in 10 days");
expect(
formatServiceDue(schedule({ next_due_on: "2026-08-11" }), 0, 0, TODAY),
).toBe("Due in 1 day");
});
it("calls a schedule due today overdue, not merely due", () => {
expect(
formatServiceDue(schedule({ next_due_on: TODAY }), 0, 0, TODAY),
).toBe("Overdue (due today)");
});
it("counts up once a due date has passed", () => {
expect(
formatServiceDue(schedule({ next_due_on: "2026-08-09" }), 0, 0, TODAY),
).toBe("Overdue by 1 day");
expect(
formatServiceDue(schedule({ next_due_on: "2026-07-11" }), 0, 0, TODAY),
).toBe("Overdue by 30 days");
});
it("describes a dive-only schedule in dives", () => {
const s = schedule({ next_due_at_dive_count: 140 });
expect(formatServiceDue(s, 135, 0, TODAY)).toBe("Due in 5 dives");
expect(formatServiceDue(s, 139, 0, TODAY)).toBe("Due in 1 dive");
expect(formatServiceDue(s, 143, 0, TODAY)).toBe("Overdue by 3 dives");
});
it("names the dive arm when that is the one that ran out", () => {
// Telling a diver their regulator is "due in 203 days" when it has actually blown
// past its dive threshold would be worse than saying nothing.
const s = schedule({
next_due_on: "2027-03-01",
next_due_at_dive_count: 140,
});
expect(formatServiceDue(s, 143, 0, TODAY)).toBe("Overdue by 3 dives");
});
it("prefers the date arm when both are still in the future", () => {
const s = schedule({
next_due_on: "2026-08-20",
next_due_at_dive_count: 140,
});
expect(formatServiceDue(s, 40, 0, TODAY)).toBe("Due in 10 days");
});
it("describes an hours-only schedule in hours of diving, rounded up", () => {
const s = schedule({ next_due_at_dive_time: 50 * HOUR });
expect(formatServiceDue(s, 0, 38 * HOUR, TODAY)).toBe(
"Due in 12 hours of diving",
);
// Forty minutes left is an hour, never "0 hours".
expect(formatServiceDue(s, 0, 50 * HOUR - 40 * 60, TODAY)).toBe(
"Due in 1 hour of diving",
);
expect(formatServiceDue(s, 0, 53 * HOUR, TODAY)).toBe(
"Overdue by 3 hours of diving",
);
});
it("names the dive arm before the hours arm, as the digest does", () => {
const s = schedule({
next_due_at_dive_count: 140,
next_due_at_dive_time: 50 * HOUR,
});
expect(formatServiceDue(s, 143, 53 * HOUR, TODAY)).toBe(
"Overdue by 3 dives",
);
expect(formatServiceDue(s, 135, 40 * HOUR, TODAY)).toBe("Due in 5 dives");
expect(formatServiceDue(s, 135, 53 * HOUR, TODAY)).toBe(
"Overdue by 3 hours of diving",
);
});
it("handles a schedule with no thresholds at all", () => {
expect(formatServiceDue(schedule(), 0, 0, TODAY)).toBe("No due date");
});
});
// The bell stacks this under the badge, which already says "Overdue" or "Due soon" -
// so each case is the full phrase with that word taken off, and nothing else.
describe("formatServiceDueQualifier", () => {
it("drops the word the badge says, in each case the full phrase has", () => {
const cases: [Partial<GearServiceScheduleSummary>, number, string][] = [
[{ next_due_on: "2026-08-20" }, 0, "in 10 days"],
[{ next_due_on: "2026-08-11" }, 0, "in 1 day"],
[{ next_due_on: TODAY }, 0, "today"],
[{ next_due_on: "2026-07-11" }, 0, "by 30 days"],
[{ next_due_at_dive_count: 140 }, 135, "in 5 dives"],
[{ next_due_at_dive_count: 140 }, 143, "by 3 dives"],
[
{ next_due_at_dive_time: 50 * HOUR },
38 * HOUR,
"in 12 hours of diving",
],
[{ next_due_at_dive_time: 50 * HOUR }, 53 * HOUR, "by 3 hours of diving"],
[
{ next_due_on: "2027-03-01", next_due_at_dive_count: 140 },
143,
"by 3 dives",
],
];
// The figure is a dive count for a dive arm and seconds of dive time for an hours
// arm; each case has only one of the two.
for (const [overrides, figure, expected] of cases) {
const hours = overrides.next_due_at_dive_time != null;
expect(
formatServiceDueQualifier(
schedule(overrides),
hours ? 0 : figure,
hours ? figure : 0,
TODAY,
),
).toBe(expected);
}
});
});
// `serviceStatus` drives the badge and `formatServiceDue` the line of text under it,
// and they are computed independently - which is how "Due today" ended up printed in
// reassuring prose directly beneath a red "Overdue" badge. These pin the two together
// so the pair can't drift apart again.
describe("formatServiceDue agrees with serviceStatus", () => {
const cases: { name: string; schedule: GearServiceScheduleSummary }[] = [
{ name: "due today", schedule: schedule({ next_due_on: TODAY }) },
{
name: "one day overdue",
schedule: schedule({ next_due_on: "2026-08-09" }),
},
{
name: "out of dives exactly",
schedule: schedule({ next_due_at_dive_count: 140 }),
},
{
name: "out of hours exactly",
schedule: schedule({ next_due_at_dive_time: 50 * HOUR }),
},
];
for (const { name, schedule: s } of cases) {
it(`describes "${name}" as overdue in both the badge and the text`, () => {
const diveCount = s.next_due_at_dive_count ?? 0;
const diveTime = s.next_due_at_dive_time ?? 0;
expect(serviceStatus(s, diveCount, diveTime, TODAY)).toBe("overdue");
expect(formatServiceDue(s, diveCount, diveTime, TODAY)).toMatch(
/^Overdue/,
);
});
}
it("does not call anything still in the future overdue", () => {
const s = schedule({ next_due_on: "2026-08-11" });
expect(serviceStatus(s, 0, 0, TODAY)).not.toBe("overdue");
expect(formatServiceDue(s, 0, 0, TODAY)).not.toMatch(/Overdue/);
});
});
describe("hoursOfDiving / countOf", () => {
it("rounds a remainder up to whole hours, as the API does", () => {
expect(hoursOfDiving(1)).toBe(1);
expect(hoursOfDiving(HOUR)).toBe(1);
expect(hoursOfDiving(HOUR + 1)).toBe(2);
});
it("says an hours figure is diving, never clock hours", () => {
expect(countOf(1, "hour")).toBe("1 hour of diving");
expect(countOf(50, "hour")).toBe("50 hours of diving");
expect(countOf(1, "dive")).toBe("1 dive");
});
});
describe("defaultSchedulesForGearType", () => {
it("suggests both cylinder tests, on their different clocks", () => {
const presets = defaultSchedulesForGearType("cylinder");
expect(presets).toEqual([
{ kind: "visual_inspection", interval_months: 12 },
{ kind: "hydrostatic_test", interval_months: 60 },
]);
});
it("suggests both interval arms for a regulator", () => {
expect(defaultSchedulesForGearType("regulator")).toEqual([
{ kind: "service", interval_months: 12, interval_dives: 100 },
]);
});
it("suggests the BCD's annual service for a wing", () => {
expect(defaultSchedulesForGearType("wing")).toEqual([
{ kind: "service", interval_months: 12 },
]);
});
it("suggests nothing for gear with no convention, or no type", () => {
expect(defaultSchedulesForGearType("mask")).toEqual([]);
expect(defaultSchedulesForGearType("backplate")).toEqual([]);
// No maker states a general hours figure for these, so there is no preset.
expect(defaultSchedulesForGearType("rebreather")).toEqual([]);
expect(defaultSchedulesForGearType("scooter")).toEqual([]);
expect(defaultSchedulesForGearType("transmitter")).toEqual([]);
expect(defaultSchedulesForGearType(null)).toEqual([]);
expect(defaultSchedulesForGearType(undefined)).toEqual([]);
expect(defaultSchedulesForGearType("")).toEqual([]);
});
it("tolerates a gear type this build doesn't know about", () => {
expect(defaultSchedulesForGearType("hookah")).toEqual([]);
});
});