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>
159 lines
8.1 KiB
Swift
159 lines
8.1 KiB
Swift
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:30pm–11: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))
|
||
}
|
||
}
|