diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index a899c1333..723e8b70e 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -700,7 +700,6 @@ 71643093F87153F633A1B025 /* ThreadDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2DA4F09CB613C54FDC73AE6A /* ThreadDecorator.swift */; }; 719E7AAD1F8E68F68F30FECD /* Task.swift in Sources */ = {isa = PBXBuildFile; fileRef = A40C19719687984FD9478FBE /* Task.swift */; }; 71AC1CAAC23403FFE847F2C9 /* ComposerToolbarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C90514BE9B8ACCBCF0AD2489 /* ComposerToolbarViewModel.swift */; }; - 71AF9345A1202DB9B5DCD9AE /* Macros in Frameworks */ = {isa = PBXBuildFile; productRef = AF574A0FC6F3A83D3BFFC9B3 /* Macros */; }; 71B62C48B8079D49F3FBC845 /* ExpiringTaskRunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B7A28A6606D58D1E38C55A /* ExpiringTaskRunnerTests.swift */; }; 71C1347F23868324A4F43940 /* NavigationModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A22A05E472533ED3C5A31B3 /* NavigationModule.swift */; }; 723382158BFD148984201266 /* HomeserverHistoryManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB9292E3516E65DED7130684 /* HomeserverHistoryManagerTests.swift */; }; @@ -709,6 +708,7 @@ 72D2298DE695A6797CDA1A2A /* SpaceScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18B223FA339BF53085328DEE /* SpaceScreenViewModelTests.swift */; }; 733E2B19AB1FDA3B93293A28 /* AppLockSetupPINScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3F275432954C8C6B1B7D966 /* AppLockSetupPINScreen.swift */; }; 738288EAEE235CAC0893AB9E /* ThreadTimelineScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C9ACDD96F36510C1FC0836B /* ThreadTimelineScreenViewModel.swift */; }; + 73D4437A6A410FA2B94CFE0F /* ManualClock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32435409710BF1A54C3BBD53 /* ManualClock.swift */; }; 73F33E9776B7A50B65A031D2 /* AppLockSettingsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0BA67B3E4EF9D29D14A78CE /* AppLockSettingsScreenViewModelTests.swift */; }; 73F547BEB41D3DAFAAF6E0AF /* UserProfileScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 71E2E5103702D13361D09100 /* UserProfileScreenViewModelTests.swift */; }; 7405B4824D45BA7C3D943E76 /* Application.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D0CBC76C80E04345E11F2DB /* Application.swift */; }; @@ -1127,7 +1127,7 @@ B5899F18AD6C56CE08FE532B /* RoomSummaryProviderMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC83F47D2173B7538AA72E0E /* RoomSummaryProviderMock.swift */; }; B5B68C7511DE1E36A89D353A /* SpaceAddRoomsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4338BB055A26094673CDC220 /* SpaceAddRoomsScreenViewModel.swift */; }; B5BCE012F9E7C45D1C76108E /* RoomMembersListScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2520C4F33AA0C061D209C28 /* RoomMembersListScreenTests.swift */; }; - B5C40DCFFDFBA0F86E228602 /* Clocks in Frameworks */ = {isa = PBXBuildFile; productRef = FFA423B0A7BBD8AA9BB91AB0 /* Clocks */; }; + B5C40DCFFDFBA0F86E228602 /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = AFAD718C6621B94813B960B4 /* Dynamic */; }; B5E455C9689EA600EDB3E9E0 /* NavigationRootCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */; }; B6048166B4AA4CEFEA9B77A6 /* InfoPlistReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6A580295A56B55A856CC4084 /* InfoPlistReader.swift */; }; B6064D82FCDCB829601C1F59 /* SecureBackupLogoutConfirmationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37FEE10AB666891E6A675E5E /* SecureBackupLogoutConfirmationScreen.swift */; }; @@ -1325,6 +1325,7 @@ D4D7CCECC6C0AAFC42E165BB /* NotificationPermissionsScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE9BBB18FB27F09032AD8769 /* NotificationPermissionsScreenViewModel.swift */; }; D4F3C5656DF09037825E9965 /* ChatsSpaceFiltersScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF2C15634499348A512A93A /* ChatsSpaceFiltersScreenModels.swift */; }; D55AF9B5B55FEED04771A461 /* RoomFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A008E57D52B07B78DFAD1BB /* RoomFlowCoordinator.swift */; }; + D5B02E816B85F91AA81FA877 /* ManualClockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FCD606155B7B4AFC06B7635 /* ManualClockTests.swift */; }; D5B1531A72387D432939D4E0 /* RoomDirectorySearchProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0516C69708D5CBDE1A8E77EC /* RoomDirectorySearchProxyProtocol.swift */; }; D5C2DA52162A978743A183F2 /* SessionVerificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8AA084E10B80D64449C02A9 /* SessionVerificationTests.swift */; }; D5E771132BB36240DE38102F /* RoomMessageEventStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80E815FF3CC5E5A355E3A25E /* RoomMessageEventStringBuilder.swift */; }; @@ -1334,7 +1335,7 @@ D63974A88CF2BC721F109C77 /* Compound in Frameworks */ = {isa = PBXBuildFile; productRef = DCA3C4A997AD28E6918D4CE5 /* Compound */; }; D69CCEC785E6C819551B402F /* SearchScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5413213B56FB76C1071E2BFB /* SearchScreenViewModelTests.swift */; }; D6DE764B17FB4A9A12C33BF4 /* MessageComposer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F1DF3FFFE5ED2B8133F43A7 /* MessageComposer.swift */; }; - D75C591C44C2510FDFA9C3D8 /* Dynamic in Frameworks */ = {isa = PBXBuildFile; productRef = AFAD718C6621B94813B960B4 /* Dynamic */; }; + D75C591C44C2510FDFA9C3D8 /* Macros in Frameworks */ = {isa = PBXBuildFile; productRef = AF574A0FC6F3A83D3BFFC9B3 /* Macros */; }; D7CC123DC5138C3BF1F9999D /* MapLibreAnnotation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50642DAE43672897C0BBAE2 /* MapLibreAnnotation.swift */; }; D7CDBAE82782BD0529DECB5F /* AttributedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */; }; D820B3C223E4C2E77BB2A2BF /* ElementCallServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EA2AFF6EB59FE25234D29F3 /* ElementCallServiceTests.swift */; }; @@ -2008,6 +2009,7 @@ 2F36C5D9B37E50915ECBD3EE /* RoomMemberProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxy.swift; sourceTree = ""; }; 2F65178687F725E8924B723C /* TimelineFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineFixtures.swift; sourceTree = ""; }; 2F926D08EB3D622A480BCA71 /* TimelineEventContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineEventContent.swift; sourceTree = ""; }; + 2FCD606155B7B4AFC06B7635 /* ManualClockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualClockTests.swift; sourceTree = ""; }; 303D9438EFB481F57A366E82 /* EncryptionResetPasswordScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetPasswordScreenViewModel.swift; sourceTree = ""; }; 303FCADE77DF1F3670C086ED /* BugReportScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BugReportScreenViewModel.swift; sourceTree = ""; }; 306AB507E1027D6C5C147EB6 /* EncryptionResetScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionResetScreenModels.swift; sourceTree = ""; }; @@ -2021,6 +2023,7 @@ 31A6314FDC51DA25712D9A81 /* PillContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PillContextTests.swift; sourceTree = ""; }; 31D6764D6976D235926FE5FC /* HomeScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenViewModel.swift; sourceTree = ""; }; 3203C6566DC17B7AECC1B7FD /* RoomNotificationSettingsUserDefinedScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsUserDefinedScreen.swift; sourceTree = ""; }; + 32435409710BF1A54C3BBD53 /* ManualClock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualClock.swift; sourceTree = ""; }; 32B5E17028C02DFA7DDA3931 /* RoomMemberProxyProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMemberProxyProtocol.swift; sourceTree = ""; }; 33035418BB35754232985871 /* TimelineReadReceiptsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineReadReceiptsView.swift; sourceTree = ""; }; 330AF4D121C3396F7A14B21D /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/SAS.strings; sourceTree = ""; }; @@ -3241,9 +3244,8 @@ 1524AAAE9BCAA32D65E97866 /* MapLibreInterface.framework in Frameworks */, 7FF27DA70D833CFC5724EFC5 /* MatrixRustSDK in Frameworks */, BCA5E2157CE27AB6F1D043D3 /* AsyncAlgorithms in Frameworks */, - B5C40DCFFDFBA0F86E228602 /* Clocks in Frameworks */, - D75C591C44C2510FDFA9C3D8 /* Dynamic in Frameworks */, - 71AF9345A1202DB9B5DCD9AE /* Macros in Frameworks */, + B5C40DCFFDFBA0F86E228602 /* Dynamic in Frameworks */, + D75C591C44C2510FDFA9C3D8 /* Macros in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -4928,6 +4930,7 @@ isa = PBXGroup; children = ( B91AD590B0B40718A0AA0C61 /* DeferredFulfillment.swift */, + 32435409710BF1A54C3BBD53 /* ManualClock.swift */, 9E21DC54FCC2A7F78532923F /* WaitingConfirmation.swift */, 0345B849A6A802EB2F72C44A /* Extensions */, ); @@ -5181,6 +5184,7 @@ 3DC1943ADE6A62ED5129D7C8 /* LoggingTests.swift */, 5A43964330459965AF048A8C /* LoginScreenViewModelTests.swift */, D80807B554CF9C524F98674F /* ManageRoomMemberSheetViewModelTests.swift */, + 2FCD606155B7B4AFC06B7635 /* ManualClockTests.swift */, 51956E593E1AEEE6C6DA7DB9 /* MapLibreLoaderTests.swift */, 1D262A26713C18BB70C82CA5 /* MapTilerURLBuilderTests.swift */, F31F59030205A6F65B057E1A /* MatrixEntityRegexTests.swift */, @@ -7460,7 +7464,6 @@ packageProductDependencies = ( C07EA60CAB296D7726210F5B /* MatrixRustSDK */, 5A8EF1A5F9629FCA309D4B2A /* AsyncAlgorithms */, - FFA423B0A7BBD8AA9BB91AB0 /* Clocks */, AFAD718C6621B94813B960B4 /* Dynamic */, AF574A0FC6F3A83D3BFFC9B3 /* Macros */, ); @@ -7787,7 +7790,6 @@ E025F19D013D9BA6C58B37F4 /* XCRemoteSwiftPackageReference "swift-algorithms" */, AC3475112CA40C2C6E78D1EB /* XCRemoteSwiftPackageReference "matrix-analytics-events" */, 4A8D3ABF18EABB8066BBD46E /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, - 869B65C34E469FC879A9F116 /* XCRemoteSwiftPackageReference "swift-clocks" */, F76A08D0EA29A07A54F4EB4D /* XCRemoteSwiftPackageReference "swift-collections" */, 4C34425923978C97409A3EF2 /* XCRemoteSwiftPackageReference "DSWaveformImage" */, D5F7D47BBAAE0CF1DDEB3034 /* XCRemoteSwiftPackageReference "DeviceKit" */, @@ -8255,6 +8257,8 @@ 149D1942DC005D0485FB8D93 /* LoggingTests.swift in Sources */, 7434A7F02D587A920B376A9A /* LoginScreenViewModelTests.swift in Sources */, 1C1750C009F7214B967928BC /* ManageRoomMemberSheetViewModelTests.swift in Sources */, + 73D4437A6A410FA2B94CFE0F /* ManualClock.swift in Sources */, + D5B02E816B85F91AA81FA877 /* ManualClockTests.swift in Sources */, EB069CC36087F103C517C6A0 /* MapLibreLoaderTests.swift in Sources */, 3BEBDCB42BABFA3B456FECA7 /* MapTilerURLBuilderTests.swift in Sources */, 2E43A3D221BE9587BC19C3F1 /* MatrixEntityRegexTests.swift in Sources */, @@ -10789,14 +10793,6 @@ revision = 60bc01f2e3b31445dc723f5af1a777b63d18e6c2; }; }; - 869B65C34E469FC879A9F116 /* XCRemoteSwiftPackageReference "swift-clocks" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/pointfreeco/swift-clocks"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.1.0; - }; - }; 96495DD8554E2F39D3954354 /* XCRemoteSwiftPackageReference "posthog-ios" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/PostHog/posthog-ios"; @@ -11265,11 +11261,6 @@ package = AB8E808A59756170682BEC20 /* XCRemoteSwiftPackageReference "SwiftSoup" */; productName = SwiftSoup; }; - FFA423B0A7BBD8AA9BB91AB0 /* Clocks */ = { - isa = XCSwiftPackageProductDependency; - package = 869B65C34E469FC879A9F116 /* XCRemoteSwiftPackageReference "swift-clocks" */; - productName = Clocks; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = AC22997D58D612146053154D /* Project object */; diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index ffe442626..34dddca68 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8a0c9555b8a89581c69dc3e2b91f47adf41791f04765972870fe510b261f83b3", + "originHash" : "fbe2663429af94e60cfa70345bffe72998cbc19f6f659bd6f3c1a0ddc50b941d", "pins" : [ { "identity" : "compound-design-tokens", @@ -234,15 +234,6 @@ "version" : "1.1.5" } }, - { - "identity" : "swift-clocks", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-clocks", - "state" : { - "revision" : "72d749bf341b78851203066ab421869b783ec42a", - "version" : "1.1.0" - } - }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -252,15 +243,6 @@ "version" : "1.6.0" } }, - { - "identity" : "swift-concurrency-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-concurrency-extras", - "state" : { - "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", - "version" : "1.3.2" - } - }, { "identity" : "swift-custom-dump", "kind" : "remoteSourceControl", diff --git a/UnitTests/Sources/ElementCallServiceTests.swift b/UnitTests/Sources/ElementCallServiceTests.swift index 3c92ca1a0..deadcfbf5 100644 --- a/UnitTests/Sources/ElementCallServiceTests.swift +++ b/UnitTests/Sources/ElementCallServiceTests.swift @@ -6,18 +6,15 @@ // import CallKit -import Clocks @testable import ElementX import PushKit import Testing -// The TestClock advances by yielding to background tasks which can be starved on a busy CI runner. -@Suite(.timeLimit(.minutes(2))) @MainActor final class ElementCallServiceTests { private var callProvider: CXProviderMock! private var currentDate: Date! - private var testClock: TestClock! + private var clock: ManualClock! private var pushRegistry: PKPushRegistry! private var service: ElementCallService! @@ -25,17 +22,17 @@ final class ElementCallServiceTests { pushRegistry = PKPushRegistry(queue: nil) callProvider = CXProviderMock(.init()) currentDate = Date() - testClock = TestClock() + clock = ManualClock() let dateProvider: () -> Date = { self.currentDate } - service = ElementCallService(callProvider: callProvider, timeProvider: TimeProvider(clock: testClock, now: dateProvider)) + service = ElementCallService(callProvider: callProvider, timeProvider: TimeProvider(clock: clock, now: dateProvider)) } isolated deinit { callProvider = nil currentDate = nil - testClock = nil + clock = nil pushRegistry = nil } @@ -84,11 +81,17 @@ final class ElementCallServiceTests { } } - @Test(.disabled()) - func callIsTimingOut() async { + @Test + func callIsTimingOut() async throws { #expect(!callProvider.reportNewIncomingCallWithUpdateCompletionCalled) - await confirmation { confirmation in + let (endedCalls, endedCallsContinuation) = AsyncStream.makeStream() + callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in + endedCallsContinuation.yield(reason) + } + let deferredEndedCall = deferFulfillment(endedCalls) { _ in true } + + await waitForConfirmation { confirmation in let pushPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 20) service.pushRegistry(pushRegistry, @@ -98,18 +101,13 @@ final class ElementCallServiceTests { } } - await confirmation { confirmation in - callProvider.reportCallWithEndedAtReasonClosure = { _, _, reason in - if reason == .unanswered { - confirmation() - } else { - Issue.record("Call should have ended as unanswered") - } - } - - // advance past the timeout - await testClock.advance(by: .seconds(30)) - } + await clock.waitForScheduledSleep() + + // advance past the timeout + clock.advance(by: .seconds(30)) + + let reason = try await deferredEndedCall.fulfill() + #expect(reason == .unanswered, "Call should have ended as unanswered") } @Test @@ -128,10 +126,11 @@ final class ElementCallServiceTests { didReceiveIncomingPushWith: pushPayload, for: .voIP) { } - await waitForScheduledSleep() + // The ring duration is capped, no matter how far away the push's expiration is. + #expect(await clock.waitForScheduledSleep() == .seconds(90)) // Advance past the max timeout but below the 300 - await testClock.advance(by: .seconds(100)) + clock.advance(by: .seconds(100)) let reason = try await deferredEndedCall.fulfill() #expect(reason == .unanswered, "Call should have ended as unanswered") @@ -156,8 +155,8 @@ final class ElementCallServiceTests { let firstPayload = PKPushPayloadMock().updatingExpiration(currentDate, lifetime: 60) service.pushRegistry(pushRegistry, didReceiveIncomingPushWith: firstPayload, for: .voIP) { } - await waitForScheduledSleep() - await testClock.advance(by: .seconds(70)) + await clock.waitForScheduledSleep() + clock.advance(by: .seconds(70)) try await deferredEndedCall.fulfill() callProvider.reportCallWithEndedAtReasonClosure = nil @@ -193,7 +192,7 @@ final class ElementCallServiceTests { } // Make sure the cancellation below exercises a scheduled timer rather than one that never started. - await waitForScheduledSleep() + await clock.waitForScheduledSleep() // Simulate the answer flow handing off to setupCallSession, which must cancel // the pending endUnansweredCallTask as part of clearing the incoming state. @@ -207,7 +206,7 @@ final class ElementCallServiceTests { } // Advance past what would have been the 60s unanswered timeout - await testClock.advance(by: .seconds(120)) + clock.advance(by: .seconds(120)) for _ in 0..<3 { await Task.yield() } @@ -241,15 +240,6 @@ final class ElementCallServiceTests { #expect(service.ongoingCallRoomIDPublisher.value == "!room:example.com") } - /// Waits until the unanswered-call timer is sleeping on the test clock — it runs in an - /// unstructured task, so advancing the clock before that would never wake it. - /// Note: `checkSuspension()` throws when a sleep is scheduled, which is the state we want. - private func waitForScheduledSleep() async { - while await (try? testClock.checkSuspension()) != nil { - await Task.yield() - } - } - private func expectImmediatelyEndedCallReported(forPayload payload: PKPushPayloadMock, expectedReason: CXCallEndedReason) async { let baselineNewIncomingCount = callProvider.reportNewIncomingCallWithUpdateCompletionCallsCount diff --git a/UnitTests/Sources/ManualClockTests.swift b/UnitTests/Sources/ManualClockTests.swift new file mode 100644 index 000000000..dab7b3a28 --- /dev/null +++ b/UnitTests/Sources/ManualClockTests.swift @@ -0,0 +1,63 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Testing + +@MainActor +final class ManualClockTests { + private let clock = ManualClock() + + @Test + func sleepFinishesOnlyOnceItsDeadlineHasPassed() async throws { + let (sleepFinished, sleepFinishedContinuation) = AsyncStream.makeStream() + let sleeper = Task { + try await clock.sleep(for: .seconds(10)) + sleepFinishedContinuation.yield() + } + + await clock.waitForScheduledSleep() + + let deferredFailure = deferFailure(sleepFinished, timeout: .milliseconds(200)) { _ in true } + clock.advance(by: .seconds(9)) + try await deferredFailure.fulfill() + + clock.advance(by: .seconds(1)) + try await sleeper.value + } + + @Test + func waitForScheduledSleepReportsTheRequestedDurations() async { + let firstSleeper = Task { try await clock.sleep(for: .seconds(30)) } + #expect(await clock.waitForScheduledSleep() == .seconds(30)) + firstSleeper.cancel() + + // Cancelling the first sleep frees the clock up to schedule another one. + let secondSleeper = Task { try await clock.sleep(for: .seconds(90)) } + #expect(await clock.waitForScheduledSleep() == .seconds(90)) + secondSleeper.cancel() + } + + @Test + func cancellingASleepingTaskThrows() async { + let sleeper = Task { try await clock.sleep(for: .seconds(10)) } + await clock.waitForScheduledSleep() + + sleeper.cancel() + + await #expect(throws: CancellationError.self) { + try await sleeper.value + } + } + + @Test + func sleepingUntilAPastDeadlineFinishesImmediately() async throws { + clock.advance(by: .seconds(10)) + #expect(clock.now == ManualClock.Instant(offset: .seconds(10))) + + try await clock.sleep(until: ManualClock.Instant(offset: .seconds(5))) + } +} diff --git a/UnitTests/Sources/TestUtilities/ManualClock.swift b/UnitTests/Sources/TestUtilities/ManualClock.swift new file mode 100644 index 000000000..2d2dd31aa --- /dev/null +++ b/UnitTests/Sources/TestUtilities/ManualClock.swift @@ -0,0 +1,151 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Synchronization + +/// A clock whose sleeps only finish when the test advances it. +final nonisolated class ManualClock: Clock { + struct Instant: InstantProtocol { + let offset: Duration + + func advanced(by duration: Duration) -> Self { + Self(offset: offset + duration) + } + + func duration(to other: Self) -> Duration { + other.offset - offset + } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.offset < rhs.offset + } + } + + private struct Sleep { + let deadline: Instant + let continuation: CheckedContinuation + } + + private struct State { + var now = Instant(offset: .zero) + /// A test steps through one timer at a time, so a single sleep can be pending. + var sleep: Sleep? + var sleepObserver: CheckedContinuation? + } + + private let state = Mutex(State()) + + let minimumResolution: Duration = .zero + + var now: Instant { + state.withLock { $0.now } + } + + /// Suspends until ``advance(by:)`` moves the clock to `deadline`, or the calling task is + /// cancelled, in which case this throws a `CancellationError`. Deadlines that have already + /// passed return without suspending. `tolerance` is ignored. + func sleep(until deadline: Instant, tolerance: Duration? = nil) async throws { + try await withTaskCancellationHandler { + try Task.checkCancellation() + + try await withCheckedThrowingContinuation { continuation in + switch schedule(deadline: deadline, continuation: continuation) { + case .elapsed: + continuation.resume() + case .scheduled(let duration, let observer): + observer?.resume(returning: duration) + + // Cancellation that landed while scheduling found nothing to cancel. + if Task.isCancelled { + cancelSleep() + } + } + } + } onCancel: { + cancelSleep() + } + } + + /// Suspends until a sleep is pending and returns how long it has left to run. A sleep that is + /// already pending returns straight away, so a scheduled sleep can't be missed. + /// + /// Await this before advancing a timer that runs in an unstructured task — advancing the clock + /// before the sleep has been scheduled would leave it sleeping past the new deadline. + @discardableResult + func waitForScheduledSleep() async -> Duration { + await withCheckedContinuation { continuation in + let duration = state.withLock { state -> Duration? in + guard let sleep = state.sleep else { + state.sleepObserver = continuation + return nil + } + + return state.now.duration(to: sleep.deadline) + } + + if let duration { + continuation.resume(returning: duration) + } + } + } + + /// Moves ``now`` forward, resuming the pending sleep if it has come due. Resuming is all this + /// does — wait on the work the sleep unblocks rather than assuming it has run by the time this + /// returns. + func advance(by duration: Duration) { + let dueSleep = state.withLock { state -> Sleep? in + state.now = state.now.advanced(by: duration) + + guard let sleep = state.sleep, sleep.deadline <= state.now else { + return nil + } + + state.sleep = nil + return sleep + } + + dueSleep?.continuation.resume() + } + + // MARK: - Private + + private enum Scheduling { + /// The deadline had already passed, the sleep doesn't suspend. + case elapsed + /// The sleep is now pending, along with the ``waitForScheduledSleep()`` caller to notify. + case scheduled(Duration, observer: CheckedContinuation?) + } + + /// Stores the sleep's continuation, handing the caller whatever needs resuming so that it + /// happens outside of the lock. + private func schedule(deadline: Instant, continuation: CheckedContinuation) -> Scheduling { + state.withLock { state in + guard deadline > state.now else { + return .elapsed + } + + precondition(state.sleep == nil, "The clock only supports a single pending sleep.") + + state.sleep = Sleep(deadline: deadline, continuation: continuation) + + let observer = state.sleepObserver + state.sleepObserver = nil + + return .scheduled(state.now.duration(to: deadline), observer: observer) + } + } + + private func cancelSleep() { + let sleep = state.withLock { state -> Sleep? in + let sleep = state.sleep + state.sleep = nil + return sleep + } + + sleep?.continuation.resume(throwing: CancellationError()) + } +} diff --git a/UnitTests/SupportingFiles/target.yml b/UnitTests/SupportingFiles/target.yml index f24f58147..9c5e14d04 100644 --- a/UnitTests/SupportingFiles/target.yml +++ b/UnitTests/SupportingFiles/target.yml @@ -34,7 +34,6 @@ targets: - target: MapLibreInterface - package: MatrixRustSDK - package: AsyncAlgorithms - - package: Clocks - package: Dynamic - package: BuildExtensions product: Macros diff --git a/project.yml b/project.yml index 7172b735d..dd446b6b0 100644 --- a/project.yml +++ b/project.yml @@ -108,9 +108,6 @@ packages: AsyncAlgorithms: url: https://github.com/apple/swift-async-algorithms minorVersion: 1.1.5 - Clocks: - url: https://github.com/pointfreeco/swift-clocks - from: 1.1.0 Collections: url: https://github.com/apple/swift-collections minorVersion: 1.6.0