/** * Tests for `src/lib/water-log-reporting.ts` — pure-function utilities * used by both the admin and field clients. Covers: * - irrigation season detection (in/out + year-wrapping) * - entry shaping for the report format * - filter by date range, headgate, user, submission source * - CSV encoding (commas, quotes, newlines, unicode) * - report formatter ("Daily Report" output for SMS/email) */ import { describe, it, expect } from "vitest"; import { isInIrrigationSeason, shapeWaterLogEntry, filterWaterLogEntries, waterLogToCSV, formatDailyWaterReport, getDisplayAgeLabel, getDisplayAgeColor, type IrrigationSeasonSettings, type WaterLogReportRow, } from "@/lib/water-log-reporting"; const ROW = (overrides: Partial = {}): WaterLogReportRow => ({ logged_at: "2026-06-15T14:30:00.000Z", headgate_name: "Upper Ditch", user_name: "Tyler", user_role: "irrigator", measurement: 3.2, unit: "CFS", notes: null, submitted_via: "field", ...overrides, }); describe("isInIrrigationSeason", () => { const season: IrrigationSeasonSettings = { seasonStartMonth: 3, seasonStartDay: 15, seasonEndMonth: 10, seasonEndDay: 15, }; // Construct dates in *local* time (year, monthIndex, day) so the test // is timezone-independent. `new Date("2026-03-15")` would parse as // UTC midnight, which on a US TZ tester becomes the previous day // locally and makes boundary assertions brittle. const localDate = (y: number, m: number, d: number) => new Date(y, m - 1, d); it("returns true inside the season (May 1)", () => { expect(isInIrrigationSeason(localDate(2026, 5, 1), season)).toBe(true); }); it("returns false before the season (Feb 1)", () => { expect(isInIrrigationSeason(localDate(2026, 2, 1), season)).toBe(false); }); it("returns false after the season (Dec 1)", () => { expect(isInIrrigationSeason(localDate(2026, 12, 1), season)).toBe(false); }); it("returns true on the boundary day (Mar 15)", () => { expect(isInIrrigationSeason(localDate(2026, 3, 15), season)).toBe(true); }); it("returns true on the closing boundary (Oct 15)", () => { expect(isInIrrigationSeason(localDate(2026, 10, 15), season)).toBe(true); }); it("returns false the day after closing (Oct 16)", () => { expect(isInIrrigationSeason(localDate(2026, 10, 16), season)).toBe(false); }); it("handles a season that wraps the year (Nov → Feb)", () => { const wrap: IrrigationSeasonSettings = { seasonStartMonth: 11, seasonStartDay: 1, seasonEndMonth: 2, seasonEndDay: 28, }; expect(isInIrrigationSeason(localDate(2026, 12, 25), wrap)).toBe(true); expect(isInIrrigationSeason(localDate(2026, 1, 15), wrap)).toBe(true); expect(isInIrrigationSeason(localDate(2026, 6, 1), wrap)).toBe(false); }); it("uses default settings (Mar 15 → Oct 15) when none provided", () => { expect(isInIrrigationSeason(localDate(2026, 4, 1))).toBe(true); expect(isInIrrigationSeason(localDate(2026, 12, 1))).toBe(false); }); }); describe("shapeWaterLogEntry", () => { it("normalizes an entry, providing a default role", () => { const out = shapeWaterLogEntry({ id: "x", logged_at: "2026-06-15T14:30:00.000Z", headgate_name: "Upper Ditch", user_name: "Tyler", user_role: "water_admin", measurement: 3.2, unit: "CFS", notes: null, submitted_via: "field", }); expect(out).toEqual({ logged_at: "2026-06-15T14:30:00.000Z", headgate_name: "Upper Ditch", user_name: "Tyler", user_role: "water_admin", measurement: 3.2, unit: "CFS", notes: null, submitted_via: "field", }); }); it("defaults role to 'irrigator' when missing", () => { const out = shapeWaterLogEntry({ id: "x", logged_at: "2026-06-15T14:30:00.000Z", headgate_name: "Upper Ditch", user_name: "Tyler", measurement: 3.2, unit: "CFS", notes: null, submitted_via: "field", }); expect(out.user_role).toBe("irrigator"); }); }); describe("filterWaterLogEntries", () => { const rows: WaterLogReportRow[] = [ ROW({ logged_at: "2026-06-10T10:00:00.000Z", headgate_name: "A", user_name: "Tyler" }), ROW({ logged_at: "2026-06-15T10:00:00.000Z", headgate_name: "B", user_name: "Tyler" }), ROW({ logged_at: "2026-06-20T10:00:00.000Z", headgate_name: "A", user_name: "Sam" }), ]; it("returns everything when no filters", () => { expect(filterWaterLogEntries(rows, {})).toHaveLength(3); }); it("filters by dateFrom (inclusive of full day)", () => { const out = filterWaterLogEntries(rows, { dateFrom: "2026-06-15" }); expect(out).toHaveLength(2); }); it("filters by dateTo (inclusive of full day)", () => { const out = filterWaterLogEntries(rows, { dateTo: "2026-06-15" }); expect(out).toHaveLength(2); }); it("filters by date range", () => { const out = filterWaterLogEntries(rows, { dateFrom: "2026-06-15", dateTo: "2026-06-15", }); expect(out).toHaveLength(1); }); it("filters by headgate", () => { const out = filterWaterLogEntries(rows, { headgateId: "A" }); expect(out).toHaveLength(2); }); it("filters by user", () => { const out = filterWaterLogEntries(rows, { userId: "Sam" }); expect(out).toHaveLength(1); }); it("filters by submission source", () => { const rows2 = [ ROW({ submitted_via: "field" }), ROW({ submitted_via: "admin" }), ]; expect(filterWaterLogEntries(rows2, { submittedVia: "admin" })).toHaveLength(1); }); it("combines multiple filters", () => { const out = filterWaterLogEntries(rows, { dateFrom: "2026-06-15", headgateId: "A", }); expect(out).toHaveLength(1); expect(out[0].user_name).toBe("Sam"); }); }); describe("waterLogToCSV", () => { it("emits a header row + one row per entry", () => { const csv = waterLogToCSV([ ROW(), ROW({ measurement: 4.5, notes: "rain" }), ]); const lines = csv.split("\n"); expect(lines).toHaveLength(3); expect(lines[0]).toContain("When"); expect(lines[0]).toContain("Headgate"); }); it("quotes fields containing commas, quotes, or newlines", () => { const csv = waterLogToCSV([ ROW({ notes: 'has, "quotes" and\nnewlines' }), ]); // The cell value is properly quoted with internal quotes doubled. // An embedded newline splits the row across two physical lines, // so we read the full CSV string rather than a single line. expect(csv).toContain('"has, ""quotes"" and\nnewlines"'); }); it("escapes unicode and emoji", () => { const csv = waterLogToCSV([ ROW({ user_name: "José 🚜" }), ]); expect(csv).toContain("José 🚜"); }); it("emits a syntactically valid CSV (parses with a basic split)", () => { const csv = waterLogToCSV([ROW(), ROW()]); // 1 header + 2 data rows expect(csv.split("\n")).toHaveLength(3); }); }); describe("formatDailyWaterReport", () => { it("returns null when no rows and sendEvenIfEmpty is false", () => { expect(formatDailyWaterReport([])).toBeNull(); }); it("emits an empty-day message when sendEvenIfEmpty is true", () => { const out = formatDailyWaterReport([], { sendEvenIfEmpty: true }); expect(out).toContain("No entries reported today"); }); it("includes the recipient name when provided", () => { const out = formatDailyWaterReport([ROW()], { recipientName: "David" }); // We don't enforce the salutation format, but the row must be present. expect(out).toContain("Tyler"); expect(out).toContain("Upper Ditch"); }); it("sums the total measurement", () => { const out = formatDailyWaterReport([ ROW({ measurement: 2 }), ROW({ measurement: 3.5 }), ]); expect(out).toContain("5.50"); }); it("includes notes when present", () => { const out = formatDailyWaterReport([ROW({ notes: "Ditch running heavy" })]); expect(out).toContain("Ditch running heavy"); }); }); describe("display age helpers", () => { it("returns 'never' for null", () => { expect(getDisplayAgeLabel(null)).toBe("never"); expect(getDisplayAgeColor(null)).toBe("red"); }); it("returns 'just now' under 1 minute", () => { expect(getDisplayAgeLabel(0.5)).toBe("just now"); expect(getDisplayAgeColor(0.5)).toBe("green"); }); it("buckets by minutes / hours / days", () => { expect(getDisplayAgeLabel(15)).toBe("15m ago"); expect(getDisplayAgeLabel(90)).toBe("1h ago"); expect(getDisplayAgeLabel(120)).toBe("2h ago"); expect(getDisplayAgeLabel(60 * 26)).toBe("1d ago"); }); it("colors by recency", () => { expect(getDisplayAgeColor(15)).toBe("green"); // < 30m expect(getDisplayAgeColor(60)).toBe("yellow"); // < 2h expect(getDisplayAgeColor(180)).toBe("red"); // > 2h }); });