Ctrl K
ComunidadGuías…

src/lib/gear-service.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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([]);
  });
});