Files
KisaniCal/KisaniCalTests/CalendarGridTests.swift
kutesir 38db257c4a Calendar: fix weekday-header/week-number alignment to firstWeekday + tests
Root cause: a data-integrity bug, not visual. The month grid is built from
.weekOfMonth (respects Calendar.current.firstWeekday), but the weekday header
was hardcoded Sunday-first ("S M T W T F S") and the week-number label was gated
on isSunday (weekday == 1). On any non-Sunday-first locale (or when the user sets
"Start Week On" = Monday) every column was mislabeled by one and the W-number
landed on the wrong column — e.g. June 1 2026 (a Monday) appearing under "S".

Fix:
- Extract pure, testable date math into CalendarGrid (monthGrid + weekdaySymbols).
- Header now derived from firstWeekday, so it always matches the grid.
- Week-number label gated on isWeekStart (weekday == firstWeekday), not Sunday.
- Document that week numbers are locale weekOfYear (consistent with the layout),
  not ISO.
- gridDays() and the year-view mini-month dedupe to CalendarGrid.

Verification:
- New KisaniCalTests target with 9 tests, all passing on simulator: exact
  Sunday-first June 2026 grid, header↔grid alignment for firstWeekday 1...7,
  cell date identity, month navigation (May/Jul/Feb 2026 + Feb 2028 leap +
  Dec 2026→Jan 2027), and event-day bucketing (all-day, late-night, midnight-
  crossing) across 4 timezones.
- Verified in the running app: header S M T W T F S, May 31 in the Sunday
  column, June 1 under Monday, June 24 (Wednesday) selected.

Adds a DEBUG-only KISANI_INITIAL_TAB launch env (compiled out of Release) used
to screenshot the calendar past the sign-in gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 19:24:49 +03:00

159 lines
8.1 KiB
Swift
Raw Permalink 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.

import XCTest
@testable import KisaniCal
/// Calendar correctness tests. These treat the month grid as a data-integrity concern:
/// every cell must be a real date, columns must respect firstWeekday, and the weekday
/// header must match the grid for any firstWeekday and timezone.
final class CalendarGridTests: XCTestCase {
private func makeCalendar(firstWeekday: Int, tz: String) -> Calendar {
var c = Calendar(identifier: .gregorian)
c.timeZone = TimeZone(identifier: tz)!
c.firstWeekday = firstWeekday
return c
}
private func iso(_ date: Date, _ cal: Calendar) -> String {
let c = cal.dateComponents([.year, .month, .day], from: date)
return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
}
private func date(_ y: Int, _ m: Int, _ d: Int, _ cal: Calendar) -> Date {
cal.date(from: DateComponents(year: y, month: m, day: d))!
}
private let timezones = ["America/New_York", "Europe/London", "Pacific/Kiritimati", "UTC"]
// MARK: - Ground-truth weekday facts
func testJune2026WeekdayFacts() {
for tz in timezones {
let cal = makeCalendar(firstWeekday: 1, tz: tz)
let f = DateFormatter(); f.calendar = cal; f.timeZone = cal.timeZone; f.dateFormat = "EEEE"
XCTAssertEqual(f.string(from: date(2026, 6, 1, cal)), "Monday", tz)
XCTAssertEqual(f.string(from: date(2026, 6, 7, cal)), "Sunday", tz)
XCTAssertEqual(f.string(from: date(2026, 6, 24, cal)), "Wednesday", tz)
XCTAssertEqual(f.string(from: date(2026, 6, 27, cal)), "Saturday", tz)
XCTAssertEqual(f.string(from: date(2026, 6, 30, cal)), "Tuesday", tz)
XCTAssertEqual(f.string(from: date(2026, 7, 1, cal)), "Wednesday", tz)
}
}
// MARK: - Sunday-first June 2026 grid matches the exact spec
func testSundayFirstJune2026GridMatchesSpec() {
let expected = [
"2026-05-31","2026-06-01","2026-06-02","2026-06-03","2026-06-04","2026-06-05","2026-06-06",
"2026-06-07","2026-06-08","2026-06-09","2026-06-10","2026-06-11","2026-06-12","2026-06-13",
"2026-06-14","2026-06-15","2026-06-16","2026-06-17","2026-06-18","2026-06-19","2026-06-20",
"2026-06-21","2026-06-22","2026-06-23","2026-06-24","2026-06-25","2026-06-26","2026-06-27",
"2026-06-28","2026-06-29","2026-06-30","2026-07-01","2026-07-02","2026-07-03","2026-07-04",
]
for tz in timezones {
let cal = makeCalendar(firstWeekday: 1, tz: tz)
let grid = CalendarGrid.monthGrid(for: date(2026, 6, 15, cal), calendar: cal)
XCTAssertEqual(grid.map { iso($0, cal) }, expected, "Sunday-first grid wrong in \(tz)")
XCTAssertEqual(grid.count % 7, 0, "grid must be whole weeks")
}
}
// MARK: - Header always matches grid (the actual bug)
func testHeaderMatchesGridFirstColumnForAnyFirstWeekday() {
for tz in timezones {
for fw in 1...7 {
let cal = makeCalendar(firstWeekday: fw, tz: tz)
let grid = CalendarGrid.monthGrid(for: date(2026, 6, 15, cal), calendar: cal)
let header = CalendarGrid.weekdaySymbols(calendar: cal)
let letters = ["S","M","T","W","T","F","S"] // weekday 1...7
for col in 0..<7 {
let weekday = cal.component(.weekday, from: grid[col]) // 1=Sun..7=Sat
XCTAssertEqual(header[col], letters[weekday - 1],
"col \(col) header \(header[col]) != grid weekday \(weekday) (fw=\(fw), tz=\(tz))")
}
}
}
}
func testHeaderRotations() {
let sun = makeCalendar(firstWeekday: 1, tz: "UTC")
let mon = makeCalendar(firstWeekday: 2, tz: "UTC")
XCTAssertEqual(CalendarGrid.weekdaySymbols(calendar: sun), ["S","M","T","W","T","F","S"])
XCTAssertEqual(CalendarGrid.weekdaySymbols(calendar: mon), ["M","T","W","T","F","S","S"])
}
// MARK: - Cell date identity
func testCellIdentityIsRealDate() {
let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York")
let grid = CalendarGrid.monthGrid(for: date(2026, 6, 1, cal), calendar: cal)
// June 24 must be a Wednesday and the 25th element (index 24) in the spec grid.
let june24 = grid.first { cal.isDate($0, inSameDayAs: date(2026, 6, 24, cal)) }
XCTAssertNotNil(june24)
XCTAssertEqual(cal.component(.weekday, from: june24!), 4) // Wednesday
// No duplicate dates, strictly increasing by one day.
for i in 1..<grid.count {
XCTAssertEqual(cal.dateComponents([.day], from: grid[i-1], to: grid[i]).day, 1)
}
}
// MARK: - Month navigation
func testMonthNavigationBoundaries() {
let cal = makeCalendar(firstWeekday: 1, tz: "UTC")
// Dec 2026 -> trails into Jan 2027
let dec = CalendarGrid.monthGrid(for: date(2026, 12, 10, cal), calendar: cal).map { iso($0, cal) }
XCTAssertTrue(dec.contains("2027-01-01"))
XCTAssertEqual(dec.first, "2026-11-29")
// Feb 2026 (non-leap): contains Feb 28, not Feb 29
let feb26 = CalendarGrid.monthGrid(for: date(2026, 2, 10, cal), calendar: cal).map { iso($0, cal) }
XCTAssertTrue(feb26.contains("2026-02-28"))
XCTAssertFalse(feb26.contains("2026-02-29"))
// Feb 2028 (leap): contains Feb 29
let feb28 = CalendarGrid.monthGrid(for: date(2028, 2, 10, cal), calendar: cal).map { iso($0, cal) }
XCTAssertTrue(feb28.contains("2028-02-29"))
// May & July 2026 endpoints
let may = CalendarGrid.monthGrid(for: date(2026, 5, 10, cal), calendar: cal).map { iso($0, cal) }
XCTAssertTrue(may.contains("2026-05-01") && may.contains("2026-05-31"))
let jul = CalendarGrid.monthGrid(for: date(2026, 7, 10, cal), calendar: cal).map { iso($0, cal) }
XCTAssertTrue(jul.contains("2026-07-01") && jul.contains("2026-07-31"))
}
// MARK: - Event-day bucketing (same logic CalendarStore.events(for:) uses)
/// Mirrors CalendarStore.events(for:) overlap test: s < endOfDay && e > startOfDay.
private func overlaps(start: Date, end: Date, day: Date, cal: Calendar) -> Bool {
let dayStart = cal.startOfDay(for: day)
let dayEnd = cal.date(byAdding: .day, value: 1, to: dayStart)!
return start < dayEnd && end > dayStart
}
func testAllDayEventMapsToSingleLocalDay() {
let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York")
// All-day June 24: [Jun24 00:00, Jun25 00:00)
let s = cal.startOfDay(for: date(2026, 6, 24, cal))
let e = cal.date(byAdding: .day, value: 1, to: s)!
XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 24, cal), cal: cal))
XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 23, cal), cal: cal))
XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 25, cal), cal: cal))
}
func testLateNightTimedEventStaysOnLocalDay() {
let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York")
// 11:30pm11:45pm local on June 24 must not bleed into the 25th.
let s = cal.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: 23, minute: 30))!
let e = cal.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: 23, minute: 45))!
XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 24, cal), cal: cal))
XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 25, cal), cal: cal))
}
func testEventCrossingMidnightHitsBothDays() {
let cal = makeCalendar(firstWeekday: 1, tz: "America/New_York")
let s = cal.date(from: DateComponents(year: 2026, month: 6, day: 24, hour: 23, minute: 30))!
let e = cal.date(from: DateComponents(year: 2026, month: 6, day: 25, hour: 0, minute: 30))!
XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 24, cal), cal: cal))
XCTAssertTrue(overlaps(start: s, end: e, day: date(2026, 6, 25, cal), cal: cal))
XCTAssertFalse(overlaps(start: s, end: e, day: date(2026, 6, 23, cal), cal: cal))
}
}