From af2996876c7f236f16f7e91e7e628810971c6537 Mon Sep 17 00:00:00 2001 From: kutesir Date: Wed, 24 Jun 2026 20:59:43 +0300 Subject: [PATCH] Tests: recurring-task occurrence expansion (date mapping) 8 tests covering daily / weekly / every-weekday / monthly / yearly expansion, repeat-until cutoff, time-of-day preservation on the mapped local day, and non-recurring single-day placement. Uses Calendar.current so it holds in any timezone. Closes the recurrence-coverage gap from the calendar fix. Co-Authored-By: Claude Opus 4.8 --- KisaniCal.xcodeproj/project.pbxproj | 4 ++ KisaniCalTests/RecurrenceTests.swift | 100 +++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 KisaniCalTests/RecurrenceTests.swift diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index a6c6e5b..21a3435 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */; }; 45AA93D76970B39DB8BA6A5B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */; }; 497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */; }; + 4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */; }; 552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; }; 55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; }; 5EF4A5B6CE91ADA0CCF72D0D /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */; }; @@ -132,6 +133,7 @@ C5EF0E7944C7F0763B83BB0F /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = ""; }; D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = ""; }; + D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurrenceTests.swift; sourceTree = ""; }; DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = ""; }; ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayMenuFeatures.swift; sourceTree = ""; }; FA3D5289C5F93484E22DEB63 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = ""; }; @@ -146,6 +148,7 @@ isa = PBXGroup; children = ( 74FAA2B41FCEBC7E3F156F0F /* CalendarGridTests.swift */, + D72D757FA29923B6C150B5EE /* RecurrenceTests.swift */, ); path = KisaniCalTests; sourceTree = ""; @@ -431,6 +434,7 @@ buildActionMask = 2147483647; files = ( 6BCBF3902E38FBAA7348840D /* CalendarGridTests.swift in Sources */, + 4FB79BBC1EE0F6C7EF844B02 /* RecurrenceTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/KisaniCalTests/RecurrenceTests.swift b/KisaniCalTests/RecurrenceTests.swift new file mode 100644 index 0000000..9a9fbfa --- /dev/null +++ b/KisaniCalTests/RecurrenceTests.swift @@ -0,0 +1,100 @@ +import XCTest +@testable import KisaniCal + +/// Verifies recurring-task occurrence expansion maps to the correct real calendar +/// days (the calendar's event-date mapping for tasks). Uses Calendar.current — the +/// same calendar the production code uses — so assertions hold in any timezone. +@MainActor +final class RecurrenceTests: XCTestCase { + + private let cal = Calendar.current + + private func day(_ y: Int, _ m: Int, _ d: Int, h: Int = 0, min: Int = 0) -> Date { + cal.date(from: DateComponents(year: y, month: m, day: d, hour: h, minute: min))! + } + + private func vm(with tasks: [TaskItem]) -> TaskViewModel { + let v = TaskViewModel() + v.tasks = tasks + return v + } + + private func task(_ label: String, start: Date, hasTime: Bool = false, end: Date? = nil) -> TaskItem { + var t = TaskItem(title: "T-\(label)", dueDate: start) + t.isRecurring = true + t.recurrenceLabel = label + t.hasTime = hasTime + t.recurrenceEnd = end + return t + } + + private func hasOccurrence(_ v: TaskViewModel, _ id: UUID, on date: Date) -> Bool { + v.occurrences(on: date).contains { $0.id == id } + } + + func testDailyExpandsEveryDayFromStart() { + let t = task("Daily", start: day(2026, 6, 1)) + let v = vm(with: [t]) + XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 5, 31)), "no occurrence before start") + XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 1))) + XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 24))) + } + + func testWeeklyHitsEverySevenDaysOnly() { + let t = task("Weekly", start: day(2026, 6, 1)) // Monday + let v = vm(with: [t]) + XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 8))) + XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 22))) + XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 9)), "off-cadence day must not match") + XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 24))) + } + + func testEveryWeekdaySkipsWeekend() { + let t = task("Every Weekday", start: day(2026, 6, 1)) + let v = vm(with: [t]) + XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 24)), "Wed is a weekday") + XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 27)), "Sat excluded") + XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 28)), "Sun excluded") + } + + func testMonthlyMatchesDayOfMonth() { + let t = task("Monthly", start: day(2026, 1, 15)) + let v = vm(with: [t]) + XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 15))) + XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 16))) + } + + func testYearlyMatchesMonthAndDay() { + let t = task("Yearly", start: day(2025, 6, 24)) + let v = vm(with: [t]) + XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 24))) + XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 23))) + } + + func testRecurrenceEndStopsExpansion() { + let t = task("Daily", start: day(2026, 6, 1), end: day(2026, 6, 10)) + let v = vm(with: [t]) + XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 10)), "inclusive of end day") + XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 11)), "past repeat-until") + XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 24))) + } + + func testOccurrenceCopyPreservesTimeOnTargetLocalDay() { + let t = task("Daily", start: day(2026, 6, 1, h: 9, min: 30), hasTime: true) + let v = vm(with: [t]) + let occ = v.occurrences(on: day(2026, 6, 24)).first { $0.id == t.id } + XCTAssertNotNil(occ) + XCTAssertTrue(cal.isDate(occ!.dueDate!, inSameDayAs: day(2026, 6, 24)), "mapped to June 24") + XCTAssertEqual(cal.component(.hour, from: occ!.dueDate!), 9) + XCTAssertEqual(cal.component(.minute, from: occ!.dueDate!), 30) + } + + func testNonRecurringTaskAppearsOnlyOnItsDay() { + var t = TaskItem(title: "Dinner with Jay", dueDate: day(2026, 6, 24)) + t.isRecurring = false + let v = vm(with: [t]) + XCTAssertTrue(hasOccurrence(v, t.id, on: day(2026, 6, 24))) + XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 23))) + XCTAssertFalse(hasOccurrence(v, t.id, on: day(2026, 6, 25))) + } +}