zfin/src/Date.zig

565 lines
22 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Date represented as days since epoch (compact, sortable).
//!
//! This file IS the `Date` struct (Zig "files-are-structs"
//! pattern). Importers do `const Date = @import("Date.zig");`
//! and use it as the type directly — no `.Date` field
//! extraction.
//!
//! ## Format methods
//!
//! - `Date.format(self, *std.Io.Writer) !void` — Zig 0.15+
//! format-method protocol. Renders "YYYY-MM-DD" via the `{f}`
//! format spec: `try writer.print("{f}", .{my_date})`.
//! - `Date.padRight(width)` / `Date.padLeft(width)` — wrapper
//! structs for column-aligned output: `{f}` + `my_date.padLeft(12)`.
//! Use when previously you would have written `{s:>12}` with
//! the legacy buffer-form formatter.
//!
//! For cases that need a `[]const u8` (URL params, struct field
//! assignment), call `std.fmt.bufPrint(&buf, "{f}", .{my_date})`
//! into a `[10]u8` buffer.
//!
//! ## Construction
//!
//! - `Date.fromYmd(y, m, d)` — calendar date
//! - `Date.fromEpoch(secs)` — Unix epoch seconds
//! - `Date.parse("YYYY-MM-DD")` — ISO string
const std = @import("std");
const srf = @import("srf");
/// Days since 1970-01-01.
days: i32,
/// Self-reference so internal code can use the simple `Date.foo`
/// form rather than `@This().foo` — matches the call-site style of
/// every external consumer.
const Date = @This();
/// Generic alignment wrapper, shared with `Money` and any other
/// type that exposes a `format(self, *Writer)` method.
pub const Padded = @import("padded.zig").Padded;
pub const epoch: Date = .{ .days = 0 };
pub fn year(self: Date) i16 {
return epochDaysToYmd(self.days).year;
}
pub fn month(self: Date) u8 {
return epochDaysToYmd(self.days).month;
}
pub fn day(self: Date) u8 {
return epochDaysToYmd(self.days).day;
}
pub fn fromYmd(y: i16, m: u8, d: u8) Date {
return .{ .days = ymdToEpochDays(y, m, d) };
}
/// Parse "YYYY-MM-DD" format.
pub fn parse(str: []const u8) !Date {
if (str.len != 10 or str[4] != '-' or str[7] != '-') return error.InvalidDateFormat;
const y = std.fmt.parseInt(i16, str[0..4], 10) catch return error.InvalidDateFormat;
const m = std.fmt.parseInt(u8, str[5..7], 10) catch return error.InvalidDateFormat;
const d = std.fmt.parseInt(u8, str[8..10], 10) catch return error.InvalidDateFormat;
return fromYmd(y, m, d);
}
/// Hook for srf Record.to(T) coercion.
pub fn srfParse(str: []const u8) !Date {
return parse(str);
}
/// Hook for srf Record.from(T) serialization.
pub fn srfFormat(self: Date, allocator: std.mem.Allocator, comptime field_name: []const u8) !srf.Value {
_ = field_name;
const ymd = epochDaysToYmd(self.days);
const y: u16 = @intCast(ymd.year);
const buf = try std.fmt.allocPrint(allocator, "{d:0>4}-{d:0>2}-{d:0>2}", .{ y, ymd.month, ymd.day });
return .{ .string = buf };
}
/// Zig 0.15+ format method: writes "YYYY-MM-DD" to the writer.
/// Invoked via `try w.print("{f}", .{my_date})`.
pub fn format(self: Date, w: *std.Io.Writer) std.Io.Writer.Error!void {
const ymd = epochDaysToYmd(self.days);
const y: u16 = @intCast(ymd.year);
try w.print("{d:0>4}-{d:0>2}-{d:0>2}", .{ y, ymd.month, ymd.day });
}
/// Pad the formatted Date to `width` columns, right-aligned with
/// leading spaces. For column-aligned tabular output:
/// `try out.print("{f}", .{my_date.padRight(12)})`. Date renders
/// as 10 chars; widths < 10 emit the date unchanged.
pub fn padRight(self: Date, width: usize) Padded(Date) {
return .{ .inner = self, .width = width, .alignment = .right };
}
/// Pad the formatted Date to `width` columns, left-aligned with
/// trailing spaces.
pub fn padLeft(self: Date, width: usize) Padded(Date) {
return .{ .inner = self, .width = width, .alignment = .left };
}
/// Day of week: 0=Monday, 1=Tuesday, ..., 4=Friday, 5=Saturday, 6=Sunday.
pub fn dayOfWeek(self: Date) u8 {
// 1970-01-01 was a Thursday (day 3 in 0=Mon scheme)
const d = @mod(self.days + 3, @as(i32, 7));
return @intCast(if (d < 0) d + 7 else d);
}
pub fn eql(a: Date, b: Date) bool {
return a.days == b.days;
}
pub fn lessThan(a: Date, b: Date) bool {
return a.days < b.days;
}
pub fn addDays(self: Date, n: i32) Date {
return .{ .days = self.days + n };
}
/// Convert to Unix epoch seconds (midnight UTC on this date).
pub fn toEpoch(self: Date) i64 {
return @as(i64, self.days) * std.time.s_per_day;
}
/// Create a Date from a Unix epoch timestamp (seconds since 1970-01-01).
pub fn fromEpoch(epoch_secs: i64) Date {
return .{ .days = @intCast(@divFloor(epoch_secs, std.time.s_per_day)) };
}
/// Subtract N calendar years. Clamps Feb 29 -> Feb 28 if target is not a leap year.
pub fn subtractYears(self: Date, n: u16) Date {
const ymd = epochDaysToYmd(self.days);
const new_year: i16 = ymd.year - @as(i16, @intCast(n));
const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !isLeapYear(new_year)) 28 else ymd.day;
return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) };
}
/// Add N calendar years. Clamps Feb 29 -> Feb 28 if target is not
/// a leap year. Mirror of `subtractYears` — used by callers that
/// need "what date will it be when this person turns N", i.e.
/// `birthdate.addYears(target_age)`.
pub fn addYears(self: Date, n: u16) Date {
const ymd = epochDaysToYmd(self.days);
const new_year: i16 = ymd.year + @as(i16, @intCast(n));
const new_day: u8 = if (ymd.month == 2 and ymd.day == 29 and !isLeapYear(new_year)) 28 else ymd.day;
return .{ .days = ymdToEpochDays(new_year, ymd.month, new_day) };
}
/// Subtract N calendar months. Clamps day to end of month if needed (e.g. Mar 31 - 1M = Feb 28).
pub fn subtractMonths(self: Date, n: u16) Date {
const ymd = epochDaysToYmd(self.days);
var m: i32 = @as(i32, ymd.month) - @as(i32, n);
var y: i16 = ymd.year;
while (m < 1) {
m += 12;
y -= 1;
}
const new_month: u8 = @intCast(m);
const max_day = daysInMonth(y, new_month);
const new_day: u8 = if (ymd.day > max_day) max_day else ymd.day;
return .{ .days = ymdToEpochDays(y, new_month, new_day) };
}
/// Return the last day of the previous month.
/// E.g., if self is 2026-02-24, returns 2026-01-31.
pub fn lastDayOfPriorMonth(self: Date) Date {
const ymd = epochDaysToYmd(self.days);
if (ymd.month == 1) {
return fromYmd(ymd.year - 1, 12, 31);
} else {
return fromYmd(ymd.year, ymd.month - 1, daysInMonth(ymd.year, ymd.month - 1));
}
}
/// Last day of `(y, m)`. E.g., `lastDayOfMonth(2024, 2)`
/// returns `2024-02-29`. `m` must be 1..12; values outside
/// that range trigger `unreachable` in `daysInMonth`.
pub fn lastDayOfMonth(y: i16, m: u8) Date {
return fromYmd(y, m, daysInMonth(y, m));
}
fn daysInMonth(y: i16, m: u8) u8 {
const table = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
if (m == 2 and isLeapYear(y)) return 29;
return table[m - 1];
}
/// Three-letter English abbreviation of a month number
/// (`1` → `"Jan"`, `12` → `"Dec"`). Returns `"???"` for
/// out-of-range input rather than panicking — display
/// helpers prefer a placeholder over a crash.
pub fn monthShort(m: u8) []const u8 {
const table = [_][]const u8{
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
};
if (m < 1 or m > 12) return "???";
return table[m - 1];
}
/// Returns approximate number of years between two dates.
pub fn yearsBetween(from: Date, to: Date) f64 {
return @as(f64, @floatFromInt(to.days - from.days)) / 365.25;
}
/// Whole years between two dates, floored to a non-negative
/// `u16`. Returns 0 when `to` is at or before `from`. Built on
/// `yearsBetween` (365.25-day approximation) — sufficient for
/// "how many full years until X" displays where the displayed
/// date itself is the precision-bearing value.
///
/// Distinct from `ageOn`, which is calendar-precise — use that
/// when the answer must match calendar-anniversary intuition
/// (e.g. "what age will I be on this exact date").
pub fn wholeYearsBetween(from: Date, to: Date) u16 {
if (to.days <= from.days) return 0;
const years = yearsBetween(from, to);
const floored: i32 = @intFromFloat(@floor(years));
if (floored < 0) return 0;
return @intCast(floored);
}
/// Calendar-year age of a person born on `self` evaluated on
/// `on`. Whole-year integer math: subtract years, then drop one
/// if the birthday hasn't occurred yet that calendar year.
/// Returns 0 when `on` is at or before `self`.
///
/// Distinct from `wholeYearsBetween`, which uses a 365.25-day
/// approximation that floors-down the exact-anniversary case to
/// `age 1`. For "what age will I be on date X" displays where
/// the answer must match the calendar (e.g. you turn 65 ON
/// your 65th birthday, not the day after), use `ageOn`.
pub fn ageOn(self: Date, on: Date) u16 {
if (on.days <= self.days) return 0;
var years: i16 = on.year() - self.year();
const before_birthday = (on.month() < self.month()) or
(on.month() == self.month() and on.day() < self.day());
if (before_birthday) years -= 1;
if (years < 0) return 0;
return @intCast(years);
}
fn isLeapYear(y: i16) bool {
const yu: u16 = @bitCast(y);
return (yu % 4 == 0 and yu % 100 != 0) or (yu % 400 == 0);
}
const Ymd = struct { year: i16, month: u8, day: u8 };
fn epochDaysToYmd(days: i32) Ymd {
// Algorithm from http://howardhinnant.github.io/date_algorithms.html
// Using i64 throughout to avoid overflow on unsigned intermediate values.
const z: i64 = @as(i64, days) + 719468;
const era: i64 = @divFloor(if (z >= 0) z else z - 146096, 146097);
const doe_i: i64 = z - era * 146097; // [0, 146096]
const doe: u64 = @intCast(doe_i);
const yoe_val: u64 = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
const y: i64 = @as(i64, @intCast(yoe_val)) + era * 400;
const doy: u64 = doe - (365 * yoe_val + yoe_val / 4 - yoe_val / 100);
const mp: u64 = (5 * doy + 2) / 153;
const d: u8 = @intCast(doy - (153 * mp + 2) / 5 + 1);
const m_raw: u64 = if (mp < 10) mp + 3 else mp - 9;
const m: u8 = @intCast(m_raw);
const y_adj: i16 = @intCast(if (m <= 2) y + 1 else y);
return .{ .year = y_adj, .month = m, .day = d };
}
fn ymdToEpochDays(y: i16, m: u8, d: u8) i32 {
const y_adj: i64 = @as(i64, y) - @as(i64, if (m <= 2) @as(i64, 1) else @as(i64, 0));
const era: i64 = @divFloor(if (y_adj >= 0) y_adj else y_adj - 399, 400);
const yoe: u64 = @intCast(y_adj - era * 400);
const m_adj: u64 = if (m > 2) @as(u64, m) - 3 else @as(u64, m) + 9;
const doy: u64 = (153 * m_adj + 2) / 5 + @as(u64, d) - 1;
const doe: u64 = yoe * 365 + yoe / 4 -| yoe / 100 + doy;
return @intCast(era * 146097 + @as(i64, @intCast(doe)) - 719468);
}
// ── Tests ────────────────────────────────────────────────────
test "date roundtrip" {
const d = Date.fromYmd(2024, 6, 15);
try std.testing.expectEqual(@as(i16, 2024), d.year());
try std.testing.expectEqual(@as(u8, 6), d.month());
try std.testing.expectEqual(@as(u8, 15), d.day());
}
test "date parse" {
const d = try Date.parse("2024-06-15");
try std.testing.expectEqual(@as(i16, 2024), d.year());
try std.testing.expectEqual(@as(u8, 6), d.month());
try std.testing.expectEqual(@as(u8, 15), d.day());
}
test "format produces YYYY-MM-DD via {f}" {
const d = Date.fromYmd(2024, 1, 5);
var buf: [10]u8 = undefined;
const s = try std.fmt.bufPrint(&buf, "{f}", .{d});
try std.testing.expectEqualStrings("2024-01-05", s);
}
test "format method produces YYYY-MM-DD via {f}" {
const d = Date.fromYmd(2024, 1, 5);
var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer aw.deinit();
try aw.writer.print("{f}", .{d});
const s = try aw.toOwnedSlice();
defer std.testing.allocator.free(s);
try std.testing.expectEqualStrings("2024-01-05", s);
}
test "format method handles four-digit years correctly" {
// Edge case: year boundaries.
var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer aw.deinit();
try aw.writer.print("{f}", .{Date.fromYmd(1970, 1, 1)});
try aw.writer.print(" ", .{});
try aw.writer.print("{f}", .{Date.fromYmd(2099, 12, 31)});
const s = try aw.toOwnedSlice();
defer std.testing.allocator.free(s);
try std.testing.expectEqualStrings("1970-01-01 2099-12-31", s);
}
test "padRight pads with leading spaces" {
var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer aw.deinit();
try aw.writer.print("{f}", .{Date.fromYmd(2024, 1, 5).padRight(14)});
const s = try aw.toOwnedSlice();
defer std.testing.allocator.free(s);
try std.testing.expectEqualStrings(" 2024-01-05", s);
}
test "padLeft pads with trailing spaces" {
var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer aw.deinit();
try aw.writer.print("{f}", .{Date.fromYmd(2024, 1, 5).padLeft(14)});
const s = try aw.toOwnedSlice();
defer std.testing.allocator.free(s);
try std.testing.expectEqualStrings("2024-01-05 ", s);
}
test "padRight: width <= 10 emits the date unchanged" {
var aw: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer aw.deinit();
try aw.writer.print("{f}", .{Date.fromYmd(2024, 1, 5).padRight(8)});
const s = try aw.toOwnedSlice();
defer std.testing.allocator.free(s);
try std.testing.expectEqualStrings("2024-01-05", s);
}
test "subtractYears" {
const d = Date.fromYmd(2026, 2, 24);
const d1 = d.subtractYears(1);
try std.testing.expectEqual(@as(i16, 2025), d1.year());
try std.testing.expectEqual(@as(u8, 2), d1.month());
try std.testing.expectEqual(@as(u8, 24), d1.day());
const d3 = d.subtractYears(3);
try std.testing.expectEqual(@as(i16, 2023), d3.year());
// Leap year edge case: Feb 29 2024 - 1 year = Feb 28 2023
const leap = Date.fromYmd(2024, 2, 29);
const non_leap = leap.subtractYears(1);
try std.testing.expectEqual(@as(i16, 2023), non_leap.year());
try std.testing.expectEqual(@as(u8, 2), non_leap.month());
try std.testing.expectEqual(@as(u8, 28), non_leap.day());
}
test "addYears" {
// Symmetric with subtractYears — same algorithm, opposite direction.
const d = Date.fromYmd(2026, 2, 24);
const d1 = d.addYears(1);
try std.testing.expectEqual(@as(i16, 2027), d1.year());
try std.testing.expectEqual(@as(u8, 2), d1.month());
try std.testing.expectEqual(@as(u8, 24), d1.day());
const d3 = d.addYears(3);
try std.testing.expectEqual(@as(i16, 2029), d3.year());
// Leap year edge case: Feb 29 2024 + 1 year = Feb 28 2025 (target year is non-leap)
const leap = Date.fromYmd(2024, 2, 29);
const non_leap = leap.addYears(1);
try std.testing.expectEqual(@as(i16, 2025), non_leap.year());
try std.testing.expectEqual(@as(u8, 2), non_leap.month());
try std.testing.expectEqual(@as(u8, 28), non_leap.day());
// Leap year edge case: Feb 29 2024 + 4 years = Feb 29 2028 (target year IS leap)
const leap_to_leap = leap.addYears(4);
try std.testing.expectEqual(@as(i16, 2028), leap_to_leap.year());
try std.testing.expectEqual(@as(u8, 2), leap_to_leap.month());
try std.testing.expectEqual(@as(u8, 29), leap_to_leap.day());
// Zero years is a no-op
const same = d.addYears(0);
try std.testing.expect(same.eql(d));
}
test "lastDayOfPriorMonth" {
// Feb 24 -> Jan 31
const d1 = Date.fromYmd(2026, 2, 24).lastDayOfPriorMonth();
try std.testing.expectEqual(@as(i16, 2026), d1.year());
try std.testing.expectEqual(@as(u8, 1), d1.month());
try std.testing.expectEqual(@as(u8, 31), d1.day());
// Jan 15 -> Dec 31 of prior year
const d2 = Date.fromYmd(2026, 1, 15).lastDayOfPriorMonth();
try std.testing.expectEqual(@as(i16, 2025), d2.year());
try std.testing.expectEqual(@as(u8, 12), d2.month());
try std.testing.expectEqual(@as(u8, 31), d2.day());
// Mar 1 leap year -> Feb 29
const d3 = Date.fromYmd(2024, 3, 1).lastDayOfPriorMonth();
try std.testing.expectEqual(@as(u8, 2), d3.month());
try std.testing.expectEqual(@as(u8, 29), d3.day());
// Mar 1 non-leap -> Feb 28
const d4 = Date.fromYmd(2025, 3, 1).lastDayOfPriorMonth();
try std.testing.expectEqual(@as(u8, 2), d4.month());
try std.testing.expectEqual(@as(u8, 28), d4.day());
}
test "lastDayOfMonth" {
// 31-day month.
try std.testing.expectEqual(Date.fromYmd(2026, 1, 31), Date.lastDayOfMonth(2026, 1));
// 30-day month.
try std.testing.expectEqual(Date.fromYmd(2026, 4, 30), Date.lastDayOfMonth(2026, 4));
// Feb in leap year.
try std.testing.expectEqual(Date.fromYmd(2024, 2, 29), Date.lastDayOfMonth(2024, 2));
// Feb in non-leap year.
try std.testing.expectEqual(Date.fromYmd(2025, 2, 28), Date.lastDayOfMonth(2025, 2));
// Century non-leap (divisible by 100 but not 400).
try std.testing.expectEqual(Date.fromYmd(2100, 2, 28), Date.lastDayOfMonth(2100, 2));
// 400-year leap.
try std.testing.expectEqual(Date.fromYmd(2000, 2, 29), Date.lastDayOfMonth(2000, 2));
// December.
try std.testing.expectEqual(Date.fromYmd(2026, 12, 31), Date.lastDayOfMonth(2026, 12));
}
test "dayOfWeek" {
// 1970-01-01 was a Thursday (3 in 0=Mon scheme)
try std.testing.expectEqual(@as(u8, 3), Date.fromYmd(1970, 1, 1).dayOfWeek());
// 2024-01-01 (Monday)
try std.testing.expectEqual(@as(u8, 0), Date.fromYmd(2024, 1, 1).dayOfWeek());
// 2024-01-19 (Friday)
try std.testing.expectEqual(@as(u8, 4), Date.fromYmd(2024, 1, 19).dayOfWeek());
// 2024-01-20 (Saturday)
try std.testing.expectEqual(@as(u8, 5), Date.fromYmd(2024, 1, 20).dayOfWeek());
// 2024-01-21 (Sunday)
try std.testing.expectEqual(@as(u8, 6), Date.fromYmd(2024, 1, 21).dayOfWeek());
}
test "eql and lessThan" {
const a = Date.fromYmd(2024, 6, 15);
const b = Date.fromYmd(2024, 6, 15);
const c = Date.fromYmd(2024, 6, 16);
try std.testing.expect(a.eql(b));
try std.testing.expect(!a.eql(c));
try std.testing.expect(a.lessThan(c));
try std.testing.expect(!c.lessThan(a));
try std.testing.expect(!a.lessThan(b));
}
test "addDays" {
const d = Date.fromYmd(2024, 1, 30);
const next = d.addDays(2);
try std.testing.expectEqual(@as(u8, 1), next.day());
try std.testing.expectEqual(@as(u8, 2), next.month()); // Feb 1
// Negative days
const prev = d.addDays(-30);
try std.testing.expectEqual(@as(u8, 31), prev.day());
try std.testing.expectEqual(@as(u8, 12), prev.month()); // Dec 31
try std.testing.expectEqual(@as(i16, 2023), prev.year());
}
test "subtractMonths" {
// Normal case
const d1 = Date.fromYmd(2024, 6, 15).subtractMonths(3);
try std.testing.expectEqual(@as(u8, 3), d1.month());
try std.testing.expectEqual(@as(u8, 15), d1.day());
// Cross year boundary: Jan - 1 = Dec prior year
const d2 = Date.fromYmd(2024, 1, 15).subtractMonths(1);
try std.testing.expectEqual(@as(u8, 12), d2.month());
try std.testing.expectEqual(@as(i16, 2023), d2.year());
// Day clamping: Mar 31 - 1M = Feb 29 (leap year 2024)
const d3 = Date.fromYmd(2024, 3, 31).subtractMonths(1);
try std.testing.expectEqual(@as(u8, 2), d3.month());
try std.testing.expectEqual(@as(u8, 29), d3.day());
// Day clamping: Mar 31 - 1M = Feb 28 (non-leap year 2025)
const d4 = Date.fromYmd(2025, 3, 31).subtractMonths(1);
try std.testing.expectEqual(@as(u8, 2), d4.month());
try std.testing.expectEqual(@as(u8, 28), d4.day());
}
test "yearsBetween" {
const a = Date.fromYmd(2024, 1, 1);
const b = Date.fromYmd(2025, 1, 1);
try std.testing.expectApproxEqAbs(@as(f64, 1.0), Date.yearsBetween(a, b), 0.01);
// Half year
const c = Date.fromYmd(2024, 7, 1);
try std.testing.expectApproxEqAbs(@as(f64, 0.5), Date.yearsBetween(a, c), 0.02);
// Same date
try std.testing.expectApproxEqAbs(@as(f64, 0.0), Date.yearsBetween(a, a), 0.001);
}
test "wholeYearsBetween" {
const a = Date.fromYmd(2024, 1, 1);
// 2024-01-01 → 2025-01-01 is 366 days (2024 is a leap year).
// 366 / 365.25 ≈ 1.002 → floor = 1.
const b = Date.fromYmd(2025, 1, 1);
try std.testing.expectEqual(@as(u16, 1), Date.wholeYearsBetween(a, b));
// 2025-01-01 → 2026-01-01 is 365 days (2025 is not a leap year).
// 365 / 365.25 ≈ 0.9993 → floor = 0. Caveat of the 365.25-day
// approximation: spans of exactly one non-leap year underflow.
// For calendar-precise age math, use Date.ageOn.
const c = Date.fromYmd(2026, 1, 1);
try std.testing.expectEqual(@as(u16, 0), Date.wholeYearsBetween(b, c));
// Multi-year spans average out the leap-year noise.
const ten_years = Date.fromYmd(2034, 1, 5);
try std.testing.expectEqual(@as(u16, 10), Date.wholeYearsBetween(a, ten_years));
// Same date returns 0.
try std.testing.expectEqual(@as(u16, 0), Date.wholeYearsBetween(a, a));
// `to` before `from` returns 0 (clamps the negative case).
const earlier = Date.fromYmd(2020, 1, 1);
try std.testing.expectEqual(@as(u16, 0), Date.wholeYearsBetween(a, earlier));
}
test "ageOn: exact anniversary returns full year (not approximation)" {
// Born 1981-04-12, evaluated on 2046-04-12: exactly 65, not 64.
// Distinguishes from wholeYearsBetween which uses 365.25-day
// approximation and floors down on exact anniversaries.
try std.testing.expectEqual(@as(u16, 65), Date.fromYmd(1981, 4, 12).ageOn(Date.fromYmd(2046, 4, 12)));
}
test "ageOn: before birthday this year drops by one" {
// Born June 1, evaluated April 12 — birthday hasn't occurred yet.
try std.testing.expectEqual(@as(u16, 64), Date.fromYmd(1981, 6, 1).ageOn(Date.fromYmd(2046, 4, 12)));
}
test "ageOn: birthday month, before day drops by one" {
// Same month, day not yet reached.
try std.testing.expectEqual(@as(u16, 64), Date.fromYmd(1981, 4, 15).ageOn(Date.fromYmd(2046, 4, 12)));
}
test "ageOn: same date returns 0" {
try std.testing.expectEqual(@as(u16, 0), Date.fromYmd(2026, 5, 12).ageOn(Date.fromYmd(2026, 5, 12)));
}
test "ageOn: birthdate after `on` returns 0" {
// Defensive: future birthdate evaluated against past date.
try std.testing.expectEqual(@as(u16, 0), Date.fromYmd(2030, 1, 1).ageOn(Date.fromYmd(2026, 5, 12)));
}
test "parse error cases" {
try std.testing.expectError(error.InvalidDateFormat, Date.parse("not-a-date"));
try std.testing.expectError(error.InvalidDateFormat, Date.parse("20240115")); // no dashes
try std.testing.expectError(error.InvalidDateFormat, Date.parse("2024/01/15")); // wrong separator
}