From 7b4ea5f93371ba16cf2e953d15ec5ffd28e98861 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:18:05 +0000 Subject: [PATCH 1/3] Initial plan From 2bdfd77e66c2da96f2168c7b9f5cde2a12b58440 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:20:40 +0000 Subject: [PATCH 2/3] Fix race condition and add error handling tests Co-authored-by: leits <12017826+leits@users.noreply.github.com> --- MeetingBar/Core/Managers/EventManager.swift | 2 +- MeetingBarTests/EventManagerTests.swift | 48 ++++++++++++++++++++ MeetingBarTests/Helpers/FakeEventStore.swift | 12 ++++- 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/MeetingBar/Core/Managers/EventManager.swift b/MeetingBar/Core/Managers/EventManager.swift index c55b7c3d..1ece5222 100644 --- a/MeetingBar/Core/Managers/EventManager.swift +++ b/MeetingBar/Core/Managers/EventManager.swift @@ -167,7 +167,7 @@ public class EventManager: ObservableObject { // E) When any fires, fetch calendars & events trigger - .flatMap { [weak self] _ -> AnyPublisher<([MBCalendar], [MBEvent]), Never> in + .flatMap(maxPublishers: .max(1)) { [weak self] _ -> AnyPublisher<([MBCalendar], [MBEvent]), Never> in guard let self = self else { return Just(([], [])).eraseToAnyPublisher() } diff --git a/MeetingBarTests/EventManagerTests.swift b/MeetingBarTests/EventManagerTests.swift index 0f7d4645..bd4d5503 100644 --- a/MeetingBarTests/EventManagerTests.swift +++ b/MeetingBarTests/EventManagerTests.swift @@ -196,3 +196,51 @@ final class RefreshTriggerTests: BaseTestCase { await fulfillment(of: [exp], timeout: 1) } } + +@MainActor +final class RefreshErrorHandlingTests: BaseTestCase { + + private var cancellables = Set() + + func test_eventsPreservedOnRefreshFailure() async throws { + // Arrange: set up fake store with initial data + let initialCal = MBCalendar(title: "Initial Cal", id: "cal1", source: nil, email: nil, color: .black) + let initialEvent = makeFakeEvent( + id: "E1", + start: Date().addingTimeInterval(60), + end: Date().addingTimeInterval(3600) + ) + let store = FakeEventStore(calendars: [initialCal], events: [initialEvent]) + let manager = EventManager(provider: store, refreshInterval: 0) + + // Wait for initial data to be published + let initialExp = expectation(description: "initial data") + manager.$events + .drop(while: \.isEmpty) + .first() + .sink { events in + XCTAssertEqual(events, [initialEvent]) + initialExp.fulfill() + } + .store(in: &cancellables) + + await fulfillment(of: [initialExp], timeout: 1.0) + + // Verify initial calendars were also published + XCTAssertEqual(manager.calendars, [initialCal]) + + // Act: configure store to throw errors on next fetch + store.shouldThrowOnFetchCalendars = true + store.shouldThrowOnFetchEvents = true + + // Trigger refresh that will fail + try await manager.refreshSources() + + // Give time for the refresh to complete + try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + + // Assert: calendars and events should still contain initial data (not empty) + XCTAssertEqual(manager.calendars, [initialCal], "Calendars should be preserved on fetch failure") + XCTAssertEqual(manager.events, [initialEvent], "Events should be preserved on fetch failure") + } +} diff --git a/MeetingBarTests/Helpers/FakeEventStore.swift b/MeetingBarTests/Helpers/FakeEventStore.swift index 35ec15a6..e76fef99 100644 --- a/MeetingBarTests/Helpers/FakeEventStore.swift +++ b/MeetingBarTests/Helpers/FakeEventStore.swift @@ -12,6 +12,8 @@ import Foundation final class FakeEventStore: EventStore { var stubbedCalendars: [MBCalendar] var stubbedEvents: [MBEvent] + var shouldThrowOnFetchCalendars: Bool = false + var shouldThrowOnFetchEvents: Bool = false init(calendars: [MBCalendar] = [], events: [MBEvent] = []) { stubbedCalendars = calendars @@ -21,7 +23,10 @@ final class FakeEventStore: EventStore { // MARK: - EventStore func fetchAllCalendars() async throws -> [MBCalendar] { - stubbedCalendars + if shouldThrowOnFetchCalendars { + throw EventManagerError.eventStoreNotAvailable + } + return stubbedCalendars } func fetchEventsForDateRange( @@ -29,7 +34,10 @@ final class FakeEventStore: EventStore { from _: Date, to _: Date ) async throws -> [MBEvent] { - stubbedEvents + if shouldThrowOnFetchEvents { + throw EventManagerError.eventStoreNotAvailable + } + return stubbedEvents } func refreshSources() async { /* no-op */ } From 13b9966c614b1cf74c5a4063e1a9e3f97efb387c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:22:37 +0000 Subject: [PATCH 3/3] Improve test reliability with call count tracking Co-authored-by: leits <12017826+leits@users.noreply.github.com> --- MeetingBarTests/EventManagerTests.swift | 25 ++++++++++++++++++-- MeetingBarTests/Helpers/FakeEventStore.swift | 4 ++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/MeetingBarTests/EventManagerTests.swift b/MeetingBarTests/EventManagerTests.swift index bd4d5503..d6b0c13e 100644 --- a/MeetingBarTests/EventManagerTests.swift +++ b/MeetingBarTests/EventManagerTests.swift @@ -229,18 +229,39 @@ final class RefreshErrorHandlingTests: BaseTestCase { // Verify initial calendars were also published XCTAssertEqual(manager.calendars, [initialCal]) + // Record the initial call counts + let initialCalendarCallCount = store.fetchCalendarsCallCount + let initialEventsCallCount = store.fetchEventsCallCount + // Act: configure store to throw errors on next fetch store.shouldThrowOnFetchCalendars = true store.shouldThrowOnFetchEvents = true + // Set up expectation that published values won't change (no new publications expected) + // We use a negated expectation since we DON'T want the values to change + var receivedUnexpectedUpdate = false + let noChangeSubscription = manager.$calendars + .dropFirst() // skip current value + .sink { _ in + receivedUnexpectedUpdate = true + } + // Trigger refresh that will fail try await manager.refreshSources() - // Give time for the refresh to complete - try await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + // Wait for async operations to complete + try await Task.sleep(nanoseconds: 200_000_000) // 0.2 seconds + + // Assert: verify fetch was attempted (call count increased) + XCTAssertGreaterThan(store.fetchCalendarsCallCount, initialCalendarCallCount, "Fetch should have been attempted") // Assert: calendars and events should still contain initial data (not empty) XCTAssertEqual(manager.calendars, [initialCal], "Calendars should be preserved on fetch failure") XCTAssertEqual(manager.events, [initialEvent], "Events should be preserved on fetch failure") + + // Verify no unexpected updates occurred + XCTAssertFalse(receivedUnexpectedUpdate, "Published values should not have changed since they match the fallback values") + + noChangeSubscription.cancel() } } diff --git a/MeetingBarTests/Helpers/FakeEventStore.swift b/MeetingBarTests/Helpers/FakeEventStore.swift index e76fef99..aa6793f8 100644 --- a/MeetingBarTests/Helpers/FakeEventStore.swift +++ b/MeetingBarTests/Helpers/FakeEventStore.swift @@ -14,6 +14,8 @@ final class FakeEventStore: EventStore { var stubbedEvents: [MBEvent] var shouldThrowOnFetchCalendars: Bool = false var shouldThrowOnFetchEvents: Bool = false + var fetchCalendarsCallCount: Int = 0 + var fetchEventsCallCount: Int = 0 init(calendars: [MBCalendar] = [], events: [MBEvent] = []) { stubbedCalendars = calendars @@ -23,6 +25,7 @@ final class FakeEventStore: EventStore { // MARK: - EventStore func fetchAllCalendars() async throws -> [MBCalendar] { + fetchCalendarsCallCount += 1 if shouldThrowOnFetchCalendars { throw EventManagerError.eventStoreNotAvailable } @@ -34,6 +37,7 @@ final class FakeEventStore: EventStore { from _: Date, to _: Date ) async throws -> [MBEvent] { + fetchEventsCallCount += 1 if shouldThrowOnFetchEvents { throw EventManagerError.eventStoreNotAvailable }