2f2fe2631f
Project identifier: 4-step fallback lookup, search summaries with fuzzy notes. Project cost: pagination, cost/person-day calculations, utilization percent. Staffing shared: createDateRange, ACTIVE_STATUSES, createLocationLabel, calculateAllocatedHoursForDay with absence fractions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
285 lines
9.7 KiB
TypeScript
285 lines
9.7 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("../lib/resource-capacity.js", () => ({
|
|
getAvailabilityHoursForDate: vi.fn().mockReturnValue(8),
|
|
calculateEffectiveDayAvailability: vi.fn().mockReturnValue(8),
|
|
}));
|
|
|
|
import {
|
|
getAvailabilityHoursForDate,
|
|
calculateEffectiveDayAvailability,
|
|
} from "../lib/resource-capacity.js";
|
|
import {
|
|
ACTIVE_STATUSES,
|
|
createDateRange,
|
|
getBaseDayAvailability,
|
|
getEffectiveDayAvailability,
|
|
createLocationLabel,
|
|
calculateAllocatedHoursForDay,
|
|
} from "../router/staffing-shared.js";
|
|
|
|
// ─── ACTIVE_STATUSES ──────────────────────────────────────────────────────────
|
|
|
|
describe("ACTIVE_STATUSES", () => {
|
|
it("contains PROPOSED, CONFIRMED and ACTIVE", () => {
|
|
expect(ACTIVE_STATUSES.has("PROPOSED")).toBe(true);
|
|
expect(ACTIVE_STATUSES.has("CONFIRMED")).toBe(true);
|
|
expect(ACTIVE_STATUSES.has("ACTIVE")).toBe(true);
|
|
});
|
|
|
|
it("does not contain DRAFT, CANCELLED or COMPLETED", () => {
|
|
expect(ACTIVE_STATUSES.has("DRAFT")).toBe(false);
|
|
expect(ACTIVE_STATUSES.has("CANCELLED")).toBe(false);
|
|
expect(ACTIVE_STATUSES.has("COMPLETED")).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── createDateRange ──────────────────────────────────────────────────────────
|
|
|
|
describe("createDateRange", () => {
|
|
it("uses startDate and endDate when both provided", () => {
|
|
const start = new Date("2026-04-10");
|
|
const end = new Date("2026-04-20");
|
|
const result = createDateRange({ startDate: start, endDate: end });
|
|
expect(result.startDate.toISOString()).toBe("2026-04-10T00:00:00.000Z");
|
|
expect(result.endDate.toISOString()).toBe("2026-04-20T00:00:00.000Z");
|
|
});
|
|
|
|
it("defaults endDate to startDate + 20 days when only startDate given (21-day duration)", () => {
|
|
const start = new Date("2026-04-10");
|
|
const result = createDateRange({ startDate: start });
|
|
expect(result.startDate.toISOString()).toBe("2026-04-10T00:00:00.000Z");
|
|
expect(result.endDate.toISOString()).toBe("2026-04-30T00:00:00.000Z");
|
|
});
|
|
|
|
it("defaults startDate to today when not provided", () => {
|
|
const todayIso = new Date().toISOString().slice(0, 10);
|
|
const result = createDateRange({});
|
|
expect(result.startDate.toISOString().startsWith(todayIso)).toBe(true);
|
|
});
|
|
|
|
it("applies custom durationDays so that endDate = startDate + durationDays - 1", () => {
|
|
const start = new Date("2026-04-10");
|
|
const result = createDateRange({ startDate: start, durationDays: 10 });
|
|
expect(result.startDate.toISOString()).toBe("2026-04-10T00:00:00.000Z");
|
|
expect(result.endDate.toISOString()).toBe("2026-04-19T00:00:00.000Z");
|
|
});
|
|
|
|
it("throws when endDate is before startDate", () => {
|
|
const start = new Date("2026-04-20");
|
|
const end = new Date("2026-04-10");
|
|
expect(() => createDateRange({ startDate: start, endDate: end })).toThrow(
|
|
"endDate must be on or after startDate.",
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── getBaseDayAvailability ───────────────────────────────────────────────────
|
|
|
|
describe("getBaseDayAvailability", () => {
|
|
const availability = {
|
|
monday: 8,
|
|
tuesday: 8,
|
|
wednesday: 8,
|
|
thursday: 8,
|
|
friday: 8,
|
|
};
|
|
const date = new Date("2026-04-10");
|
|
|
|
it("calls getAvailabilityHoursForDate with the correct arguments", () => {
|
|
getBaseDayAvailability(availability, date);
|
|
expect(getAvailabilityHoursForDate).toHaveBeenCalledWith(availability, date);
|
|
});
|
|
|
|
it("returns the value from the mock", () => {
|
|
const result = getBaseDayAvailability(availability, date);
|
|
expect(result).toBe(8);
|
|
});
|
|
});
|
|
|
|
// ─── getEffectiveDayAvailability ──────────────────────────────────────────────
|
|
|
|
describe("getEffectiveDayAvailability", () => {
|
|
const availability = {
|
|
monday: 8,
|
|
tuesday: 8,
|
|
wednesday: 8,
|
|
thursday: 8,
|
|
friday: 8,
|
|
};
|
|
const date = new Date("2026-04-10");
|
|
const context = {
|
|
absenceFractionsByDate: new Map<string, number>(),
|
|
holidayDates: new Set<string>(),
|
|
vacationFractionsByDate: new Map<string, number>(),
|
|
};
|
|
|
|
it("calls calculateEffectiveDayAvailability with the correct arguments", () => {
|
|
getEffectiveDayAvailability(availability, date, context);
|
|
expect(calculateEffectiveDayAvailability).toHaveBeenCalledWith({
|
|
availability,
|
|
date,
|
|
context,
|
|
});
|
|
});
|
|
|
|
it("passes the context parameter through to the underlying function", () => {
|
|
getEffectiveDayAvailability(availability, date, undefined);
|
|
expect(calculateEffectiveDayAvailability).toHaveBeenCalledWith({
|
|
availability,
|
|
date,
|
|
context: undefined,
|
|
});
|
|
});
|
|
});
|
|
|
|
// ─── createLocationLabel ──────────────────────────────────────────────────────
|
|
|
|
describe("createLocationLabel", () => {
|
|
it("returns all three parts joined with ' / ' when all values are present", () => {
|
|
expect(
|
|
createLocationLabel({ countryCode: "DE", federalState: "Bavaria", metroCityName: "Munich" }),
|
|
).toBe("DE / Bavaria / Munich");
|
|
});
|
|
|
|
it("returns only countryCode when federalState and metroCityName are absent", () => {
|
|
expect(createLocationLabel({ countryCode: "DE" })).toBe("DE");
|
|
});
|
|
|
|
it("returns empty string when all parts are null or undefined", () => {
|
|
expect(
|
|
createLocationLabel({ countryCode: null, federalState: null, metroCityName: null }),
|
|
).toBe("");
|
|
expect(createLocationLabel({})).toBe("");
|
|
});
|
|
|
|
it("omits entries that consist entirely of whitespace", () => {
|
|
expect(
|
|
createLocationLabel({ countryCode: "DE", federalState: " ", metroCityName: "Munich" }),
|
|
).toBe("DE / Munich");
|
|
});
|
|
});
|
|
|
|
// ─── calculateAllocatedHoursForDay ────────────────────────────────────────────
|
|
|
|
describe("calculateAllocatedHoursForDay", () => {
|
|
const date = new Date("2026-04-10T00:00:00.000Z");
|
|
const emptyContext = {
|
|
absenceFractionsByDate: new Map<string, number>(),
|
|
holidayDates: new Set<string>(),
|
|
vacationFractionsByDate: new Map<string, number>(),
|
|
};
|
|
|
|
it("sums hoursPerDay for all active bookings that overlap the date", () => {
|
|
const bookings = [
|
|
{
|
|
startDate: new Date("2026-04-01"),
|
|
endDate: new Date("2026-04-30"),
|
|
hoursPerDay: 6,
|
|
status: "CONFIRMED",
|
|
},
|
|
{
|
|
startDate: new Date("2026-04-01"),
|
|
endDate: new Date("2026-04-30"),
|
|
hoursPerDay: 2,
|
|
status: "ACTIVE",
|
|
},
|
|
];
|
|
const result = calculateAllocatedHoursForDay({ bookings, date, context: emptyContext });
|
|
expect(result.allocatedHours).toBe(8);
|
|
});
|
|
|
|
it("ignores bookings with non-active statuses such as DRAFT and CANCELLED", () => {
|
|
const bookings = [
|
|
{
|
|
startDate: new Date("2026-04-01"),
|
|
endDate: new Date("2026-04-30"),
|
|
hoursPerDay: 8,
|
|
status: "DRAFT",
|
|
},
|
|
{
|
|
startDate: new Date("2026-04-01"),
|
|
endDate: new Date("2026-04-30"),
|
|
hoursPerDay: 4,
|
|
status: "CANCELLED",
|
|
},
|
|
{
|
|
startDate: new Date("2026-04-01"),
|
|
endDate: new Date("2026-04-30"),
|
|
hoursPerDay: 3,
|
|
status: "CONFIRMED",
|
|
},
|
|
];
|
|
const result = calculateAllocatedHoursForDay({ bookings, date, context: emptyContext });
|
|
expect(result.allocatedHours).toBe(3);
|
|
});
|
|
|
|
it("ignores bookings whose date range does not overlap the target date", () => {
|
|
const bookings = [
|
|
{
|
|
startDate: new Date("2026-03-01"),
|
|
endDate: new Date("2026-04-09"),
|
|
hoursPerDay: 8,
|
|
status: "ACTIVE",
|
|
},
|
|
{
|
|
startDate: new Date("2026-04-11"),
|
|
endDate: new Date("2026-04-30"),
|
|
hoursPerDay: 8,
|
|
status: "ACTIVE",
|
|
},
|
|
{
|
|
startDate: new Date("2026-04-10"),
|
|
endDate: new Date("2026-04-10"),
|
|
hoursPerDay: 4,
|
|
status: "CONFIRMED",
|
|
},
|
|
];
|
|
const result = calculateAllocatedHoursForDay({ bookings, date, context: emptyContext });
|
|
expect(result.allocatedHours).toBe(4);
|
|
});
|
|
|
|
it("separates chargeable hours from non-chargeable hours", () => {
|
|
const bookings = [
|
|
{
|
|
startDate: new Date("2026-04-01"),
|
|
endDate: new Date("2026-04-30"),
|
|
hoursPerDay: 6,
|
|
status: "CONFIRMED",
|
|
isChargeable: true,
|
|
},
|
|
{
|
|
startDate: new Date("2026-04-01"),
|
|
endDate: new Date("2026-04-30"),
|
|
hoursPerDay: 2,
|
|
status: "ACTIVE",
|
|
isChargeable: false,
|
|
},
|
|
];
|
|
const result = calculateAllocatedHoursForDay({ bookings, date, context: emptyContext });
|
|
expect(result.allocatedHours).toBe(8);
|
|
expect(result.chargeableHours).toBe(6);
|
|
});
|
|
|
|
it("applies absenceFraction as dayFraction so that effective hours are scaled accordingly", () => {
|
|
const context = {
|
|
absenceFractionsByDate: new Map([["2026-04-10", 0.5]]),
|
|
holidayDates: new Set<string>(),
|
|
vacationFractionsByDate: new Map<string, number>(),
|
|
};
|
|
const bookings = [
|
|
{
|
|
startDate: new Date("2026-04-01"),
|
|
endDate: new Date("2026-04-30"),
|
|
hoursPerDay: 8,
|
|
status: "CONFIRMED",
|
|
isChargeable: true,
|
|
},
|
|
];
|
|
const result = calculateAllocatedHoursForDay({ bookings, date, context });
|
|
// dayFraction = 1 - 0.5 = 0.5 → 8 * 0.5 = 4
|
|
expect(result.allocatedHours).toBe(4);
|
|
expect(result.chargeableHours).toBe(4);
|
|
});
|
|
});
|