diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 5578b8869..6db14646e 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -392,6 +392,7 @@ A153E03B29BAFE01003C34D4 /* CustomOperationTargeting.swift in Sources */ = {isa = PBXBuildFile; fileRef = A153E03A29BAFE01003C34D4 /* CustomOperationTargeting.swift */; }; A153E03D29BAFEC1003C34D4 /* CustomOperationChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A153E03C29BAFEC0003C34D4 /* CustomOperationChecker.swift */; }; A153E03F29BB002A003C34D4 /* SessionTemporaryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A153E03E29BB002A003C34D4 /* SessionTemporaryStorage.swift */; }; + DC36B591F6608E7D1196C936 /* InappSessionLedger.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4ADBBEBCF7E68E56565FEA9 /* InappSessionLedger.swift */; }; A153E04129BB0A8B003C34D4 /* InAppConfigurationWithOperations.json in Resources */ = {isa = PBXBuildFile; fileRef = A153E04029BB0A8B003C34D4 /* InAppConfigurationWithOperations.json */; }; A154E32E299E0D8900F8F074 /* SDKLogManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E32D299E0D8900F8F074 /* SDKLogManagerTests.swift */; }; A154E330299E0F1600F8F074 /* InAppGeoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E32F299E0F1600F8F074 /* InAppGeoResponse.swift */; }; @@ -531,6 +532,8 @@ F3266A392F6295A600CE6137 /* FirstInitializationDateTimeMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3266A382F6295A600CE6137 /* FirstInitializationDateTimeMigration.swift */; }; F32B68882CF83B030088BCDD /* InappConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32B68872CF83B030088BCDD /* InappConfigurationTests.swift */; }; F32CA1762E17625200CE7E63 /* InappScheduleManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32CA1752E17625200CE7E63 /* InappScheduleManagerTests.swift */; }; + 03F556ABF25843F4E03B12D9 /* InappSessionLedgerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7A74E65031C00E64367BA99 /* InappSessionLedgerTests.swift */; }; + D8884D732391D8DEFC19A4C1 /* InappShowAccountantTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA57EA9FD870B3E7E22BE75D /* InappShowAccountantTests.swift */; }; F32CFFA42BB403C700A41E04 /* PushEnabledTargeting.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32CFFA32BB403C700A41E04 /* PushEnabledTargeting.swift */; }; F32CFFA62BB4044E00A41E04 /* PushEnabledTargetingChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32CFFA52BB4044E00A41E04 /* PushEnabledTargetingChecker.swift */; }; F32E536F2C3F2B05002C7CA0 /* DITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F32E536E2C3F2B05002C7CA0 /* DITests.swift */; }; @@ -607,6 +610,7 @@ F34A45AE2B7628B700634C8B /* MBPushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34A45AD2B7628B700634C8B /* MBPushNotification.swift */; }; F34A45B02B762A6100634C8B /* MindboxPushValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */; }; F351F1C02CE380A40053423E /* InappMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = F351F1BF2CE380A40053423E /* InappMapper.swift */; }; + D449F3534453BD50D93B5E72 /* InappFormBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4761BB2323EB5B1C72894561 /* InappFormBuilder.swift */; }; F351F1C22CE5F23A0053423E /* InappMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F351F1C12CE5F23A0053423E /* InappMapperTests.swift */; }; F351F1C42CE60CA90053423E /* 1-Targeting.json in Resources */ = {isa = PBXBuildFile; fileRef = F351F1C32CE60CA90053423E /* 1-Targeting.json */; }; F351F1C62CE626450053423E /* 15-Targeting.json in Resources */ = {isa = PBXBuildFile; fileRef = F351F1C52CE626450053423E /* 15-Targeting.json */; }; @@ -670,6 +674,7 @@ F39B67B82A3FAA75005C0CCA /* ABTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39B67B72A3FAA75005C0CCA /* ABTests.swift */; }; F3A0B0022F28A00100CE7E63 /* TransparentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A0B0012F28A00100CE7E63 /* TransparentViewTests.swift */; }; F3A22CBA2E169F3F005817F2 /* InappScheduleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A22CB92E169F3F005817F2 /* InappScheduleManager.swift */; }; + C150E6672EC2831BFA85D12B /* InappShowAccountant.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8A674E9219591468A9FE335 /* InappShowAccountant.swift */; }; F3A49BE32DDF070D00C52AB6 /* InAppSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A49BE22DDF070D00C52AB6 /* InAppSettingsModel.swift */; }; F3A4EFDC2D5224C700DB96A8 /* SlidingExpirationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A4EFDB2D5224C700DB96A8 /* SlidingExpirationModel.swift */; }; F3A8B9452A38870100E9C055 /* MixerUUIDS.json in Resources */ = {isa = PBXBuildFile; fileRef = F3A8B9442A38870100E9C055 /* MixerUUIDS.json */; }; @@ -1168,6 +1173,7 @@ A153E03A29BAFE01003C34D4 /* CustomOperationTargeting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomOperationTargeting.swift; sourceTree = ""; }; A153E03C29BAFEC0003C34D4 /* CustomOperationChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomOperationChecker.swift; sourceTree = ""; }; A153E03E29BB002A003C34D4 /* SessionTemporaryStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionTemporaryStorage.swift; sourceTree = ""; }; + E4ADBBEBCF7E68E56565FEA9 /* InappSessionLedger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappSessionLedger.swift; sourceTree = ""; }; A153E04029BB0A8B003C34D4 /* InAppConfigurationWithOperations.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = InAppConfigurationWithOperations.json; sourceTree = ""; }; A154E32D299E0D8900F8F074 /* SDKLogManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKLogManagerTests.swift; sourceTree = ""; }; A154E32F299E0F1600F8F074 /* InAppGeoResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppGeoResponse.swift; sourceTree = ""; }; @@ -1310,6 +1316,8 @@ F3266A382F6295A600CE6137 /* FirstInitializationDateTimeMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirstInitializationDateTimeMigration.swift; sourceTree = ""; }; F32B68872CF83B030088BCDD /* InappConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappConfigurationTests.swift; sourceTree = ""; }; F32CA1752E17625200CE7E63 /* InappScheduleManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappScheduleManagerTests.swift; sourceTree = ""; }; + B7A74E65031C00E64367BA99 /* InappSessionLedgerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappSessionLedgerTests.swift; sourceTree = ""; }; + AA57EA9FD870B3E7E22BE75D /* InappShowAccountantTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappShowAccountantTests.swift; sourceTree = ""; }; F32CFFA32BB403C700A41E04 /* PushEnabledTargeting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushEnabledTargeting.swift; sourceTree = ""; }; F32CFFA52BB4044E00A41E04 /* PushEnabledTargetingChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushEnabledTargetingChecker.swift; sourceTree = ""; }; F32E536E2C3F2B05002C7CA0 /* DITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DITests.swift; sourceTree = ""; }; @@ -1386,6 +1394,7 @@ F34A45AD2B7628B700634C8B /* MBPushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBPushNotification.swift; sourceTree = ""; }; F34A45AF2B762A6100634C8B /* MindboxPushValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxPushValidator.swift; sourceTree = ""; }; F351F1BF2CE380A40053423E /* InappMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappMapper.swift; sourceTree = ""; }; + 4761BB2323EB5B1C72894561 /* InappFormBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappFormBuilder.swift; sourceTree = ""; }; F351F1C12CE5F23A0053423E /* InappMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappMapperTests.swift; sourceTree = ""; }; F351F1C32CE60CA90053423E /* 1-Targeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "1-Targeting.json"; sourceTree = ""; }; F351F1C52CE626450053423E /* 15-Targeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "15-Targeting.json"; sourceTree = ""; }; @@ -1448,6 +1457,7 @@ F39B67B72A3FAA75005C0CCA /* ABTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ABTests.swift; sourceTree = ""; }; F3A0B0012F28A00100CE7E63 /* TransparentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentViewTests.swift; sourceTree = ""; }; F3A22CB92E169F3F005817F2 /* InappScheduleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappScheduleManager.swift; sourceTree = ""; }; + B8A674E9219591468A9FE335 /* InappShowAccountant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappShowAccountant.swift; sourceTree = ""; }; F3A49BE22DDF070D00C52AB6 /* InAppSettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppSettingsModel.swift; sourceTree = ""; }; F3A4EFDB2D5224C700DB96A8 /* SlidingExpirationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlidingExpirationModel.swift; sourceTree = ""; }; F3A8B9442A38870100E9C055 /* MixerUUIDS.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = MixerUUIDS.json; sourceTree = ""; }; @@ -1786,6 +1796,7 @@ 3328FE4326303F30000A30D0 /* TimeInterval+TimeSpan.swift */, F78E92EE282E63320003B4A3 /* DispatchSemaphore.swift */, A153E03E29BB002A003C34D4 /* SessionTemporaryStorage.swift */, + E4ADBBEBCF7E68E56565FEA9 /* InappSessionLedger.swift */, ); path = Utilities; sourceTree = ""; @@ -2735,6 +2746,7 @@ 9B24FAB328C751CB00F10B5D /* Images */, F34975692DEF2C8400BEC667 /* InappTrackingService.swift */, F3A22CB92E169F3F005817F2 /* InappScheduleManager.swift */, + B8A674E9219591468A9FE335 /* InappShowAccountant.swift */, F3B70A002F250A0100AABB01 /* ForegroundStopwatch.swift */, F3C1A0012F5B100100ABC001 /* InappShowFailureManager.swift */, A1B2C3D400000016E1010101 /* Permissions */, @@ -2822,6 +2834,8 @@ 9BC24E7828F6BFEC00C2619C /* InAppConfigResponseTests */, F3D925AC2A1236F400135C87 /* URLSessionImageDownloaderTests.swift */, F32CA1752E17625200CE7E63 /* InappScheduleManagerTests.swift */, + B7A74E65031C00E64367BA99 /* InappSessionLedgerTests.swift */, + AA57EA9FD870B3E7E22BE75D /* InappShowAccountantTests.swift */, 327A85474E7AB7AC1884A890 /* InAppConfigurationManagerTests.swift */, D55C134D9E499E94D099CD09 /* InAppCoreManagerTests.swift */, F3B70A022F250A0100AABB02 /* ForegroundStopwatchTests.swift */, @@ -3813,6 +3827,7 @@ children = ( F3A8B9562A389D9300E9C055 /* Services */, F351F1BF2CE380A40053423E /* InappMapper.swift */, + 4761BB2323EB5B1C72894561 /* InappFormBuilder.swift */, ); path = InAppConfigurationMapper; sourceTree = ""; @@ -4492,6 +4507,7 @@ 334F3A67264AA18500A6AC00 /* MindboxAppDelegate.swift in Sources */, B3A6254C2689F83100B6A3B7 /* PersonalOffersResponse.swift in Sources */, F3A22CBA2E169F3F005817F2 /* InappScheduleManager.swift in Sources */, + C150E6672EC2831BFA85D12B /* InappShowAccountant.swift in Sources */, F3B70A012F250A0100AABB01 /* ForegroundStopwatch.swift in Sources */, 84F565212628304A00269FD6 /* TrackVisit.swift in Sources */, F34975382DEE2C6A00BEC667 /* ShownInAppsDictionaryMigration.swift in Sources */, @@ -4561,6 +4577,7 @@ 317AF8FC25B844DB006348FA /* UtilitiesFetcher.swift in Sources */, B36D57852696E59400FEDFD6 /* RetailOrderStatisticsResponse.swift in Sources */, A153E03F29BB002A003C34D4 /* SessionTemporaryStorage.swift in Sources */, + DC36B591F6608E7D1196C936 /* InappSessionLedger.swift in Sources */, 840C387325CC1AF200D50183 /* CDEvent+CoreDataProperties.swift in Sources */, F3482F1D2A65DC11002A41EC /* CompositeInappMessageDelegate.swift in Sources */, 33072F3C2664C713001F1AB2 /* CustomerSegmentationsResponse.swift in Sources */, @@ -4569,6 +4586,7 @@ A1D017E92976CC1C00CD9F99 /* TargetingChecker.swift in Sources */, F397EEB52A44573600D48CEC /* Status.swift in Sources */, F351F1C02CE380A40053423E /* InappMapper.swift in Sources */, + D449F3534453BD50D93B5E72 /* InappFormBuilder.swift in Sources */, 6FDD1455266F7C9A00A50C35 /* PromotionTypeResponse.swift in Sources */, 334F3AEB264C199900A6AC00 /* DiscountCardRequest.swift in Sources */, 31A20D4E25B6EFB600AAA0A3 /* MindboxDelegate.swift in Sources */, @@ -4878,6 +4896,8 @@ 47B90E312C626B9300BD93E7 /* TestProtocolMigrations.swift in Sources */, F3A8B9982A3A421C00E9C055 /* SDKVersionValidatorTests.swift in Sources */, F32CA1762E17625200CE7E63 /* InappScheduleManagerTests.swift in Sources */, + 03F556ABF25843F4E03B12D9 /* InappSessionLedgerTests.swift in Sources */, + D8884D732391D8DEFC19A4C1 /* InappShowAccountantTests.swift in Sources */, C560714312DC9ADC194C8714 /* InAppConfigurationManagerTests.swift in Sources */, BD92D11B0743EEAE2024318A /* InAppCoreManagerTests.swift in Sources */, F3B70A032F250A0100AABB02 /* ForegroundStopwatchTests.swift in Sources */, diff --git a/Mindbox/DI/Injections/InjectEmbeddedBlocks.swift b/Mindbox/DI/Injections/InjectEmbeddedBlocks.swift index 1c1549659..11e85f275 100644 --- a/Mindbox/DI/Injections/InjectEmbeddedBlocks.swift +++ b/Mindbox/DI/Injections/InjectEmbeddedBlocks.swift @@ -19,14 +19,15 @@ extension MBContainer { EmbeddedBlockPlaceRegistry(resolver: DI.injectOrFail(EmbeddedBlockResolving.self)) } - register(EmbeddedBlockFeedServing.self) { - EmbeddedBlockFeedService() + register(EmbeddedBlockInappServing.self) { + EmbeddedBlockInappService() } register(EmbeddedBlockContentProviderMaking.self) { EmbeddedBlockContentProviderFactory(registry: DI.injectOrFail(EmbeddedBlockPlaceRegistering.self), - feed: DI.injectOrFail(EmbeddedBlockFeedServing.self), - failureManager: DI.injectOrFail(InappShowFailureManagerProtocol.self)) + inappService: DI.injectOrFail(EmbeddedBlockInappServing.self), + failureManager: DI.injectOrFail(InappShowFailureManagerProtocol.self), + accounting: DI.injectOrFail(InappShowAccounting.self)) } return self diff --git a/Mindbox/DI/Injections/InjectInappTools.swift b/Mindbox/DI/Injections/InjectInappTools.swift index 20d63e772..a96084865 100644 --- a/Mindbox/DI/Injections/InjectInappTools.swift +++ b/Mindbox/DI/Injections/InjectInappTools.swift @@ -113,19 +113,17 @@ extension MBContainer { return InAppTrackingService(persistenceStorage: persistenceStorage) } + register(InappShowAccounting.self) { + InappShowAccountant(tracker: DI.injectOrFail(InAppMessagesTracker.self), + trackingService: DI.injectOrFail(InAppTrackingServiceProtocol.self)) + } + register(InappScheduleManagerProtocol.self) { - let presentationManager = DI.injectOrFail(InAppPresentationManagerProtocol.self) - let presentationValidator = DI.injectOrFail(InAppPresentationValidatorProtocol.self) - let inappTrackingService = DI.injectOrFail(InAppTrackingServiceProtocol.self) - let tracker = DI.injectOrFail(InAppMessagesTracker.self) - let failureManager = DI.injectOrFail(InappShowFailureManagerProtocol.self) - - return InappScheduleManager( - presentationManager: presentationManager, - presentationValidator: presentationValidator, - trackingService: inappTrackingService, - tracker: tracker, - failureManager: failureManager + InappScheduleManager( + presentationManager: DI.injectOrFail(InAppPresentationManagerProtocol.self), + presentationValidator: DI.injectOrFail(InAppPresentationValidatorProtocol.self), + accountant: DI.injectOrFail(InappShowAccounting.self), + failureManager: DI.injectOrFail(InappShowFailureManagerProtocol.self) ) } diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift index c22aeeb09..716c48134 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift @@ -20,38 +20,35 @@ protocol EmbeddedBlockContentProviderMaking { final class EmbeddedBlockContentProviderFactory: EmbeddedBlockContentProviderMaking { private let registry: EmbeddedBlockPlaceRegistering - private let feed: EmbeddedBlockFeedServing + private let inappService: EmbeddedBlockInappServing private let failureManager: InappShowFailureManagerProtocol + private let accounting: InappShowAccounting init(registry: EmbeddedBlockPlaceRegistering, - feed: EmbeddedBlockFeedServing, - failureManager: InappShowFailureManagerProtocol) { + inappService: EmbeddedBlockInappServing, + failureManager: InappShowFailureManagerProtocol, + accounting: InappShowAccounting) { self.registry = registry - self.feed = feed + self.inappService = inappService self.failureManager = failureManager + self.accounting = accounting } func makeProvider(placeSystemName: String) -> EmbeddedBlockWebViewProvider { EmbeddedBlockWebViewProvider(placeSystemName: placeSystemName, registry: registry, - feed: feed, + inappService: inappService, makePage: { EmbeddedBlockWebViewPage(content: $0) }, - recordShow: { DI.injectOrFail(InAppTrackingServiceProtocol.self).trackInAppShown(id: $0) }, - reportShow: { content, timeToDisplay in - do { - try DI.injectOrFail(InAppMessagesTracker.self) - .trackView(id: content.inAppId, - timeToDisplay: timeToDisplay, - tags: content.tags) - } catch { - Logger.common(message: "[EmbeddedBlock] Failed to track a show of in-app \(content.inAppId): \(error)", - level: .error, category: .embeddedBlocks) - } - }, + accounting: accounting, reportFailure: { [failureManager] content, reason, details in // Captured, not read through the factory: a provider outliving it // would otherwise drop the failure it is reporting. Self.report(failure: reason, details: details, for: content, to: failureManager) + }, + reportUnansweredWait: { [failureManager, inappService] waited in + failureManager.sendWaitBudgetExceeded(place: placeSystemName, + waited: waited, + phase: inappService.hasConfig ? .resolvePending : .configMissing) }) } diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockWaitBudget.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockWaitBudget.swift index 8c4a6d639..66c8aa946 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockWaitBudget.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockWaitBudget.swift @@ -33,7 +33,8 @@ final class EmbeddedBlockWaitBudget { private var workItem: DispatchWorkItem? - private var consumed: TimeInterval = 0 + /// Foreground time the current attempt has waited so far. + private(set) var consumed: TimeInterval = 0 private var resumedAt: TimeInterval? diff --git a/Mindbox/EmbeddedBlocks/Feed/EmbeddedBlockFeedService.swift b/Mindbox/EmbeddedBlocks/Feed/EmbeddedBlockFeedService.swift deleted file mode 100644 index a4e674742..000000000 --- a/Mindbox/EmbeddedBlocks/Feed/EmbeddedBlockFeedService.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// EmbeddedBlockFeedService.swift -// Mindbox -// -// Created by Sergei Semko on 8/13/26. -// Copyright © 2026 Mindbox. All rights reserved. -// - -import Foundation -import MindboxLogger - -/// The ids and the `Inapp.Targeting` behind them are deliberately apart: an answer the page never -/// receives has offered nothing, so the event belongs to the moment the answer reaches the page. -struct FeedAnswer { - - static let nothing = FeedAnswer(inappIds: [], vouch: {}) - - /// The allowed ids: a subset of what the page asked about, in priority order. - let inappIds: [String] - - /// Sends `Inapp.Targeting` for exactly those ids. Called once, by whoever delivered the answer. - let vouch: () -> Void -} - -protocol EmbeddedBlockFeedServing: AnyObject { - - /// Answered from what the session already fetched, never the network — an id whose targeting - /// cannot be checked without one is cut: fail closed, in sync with Android. - func showableInappIds(among ids: [String], completion: @escaping (FeedAnswer) -> Void) - - /// Deliberately unchecked: whether to offer the in-app was decided when the page drew it. - func showInapp(id: String, params: [String: JSONValue]) -} - -final class EmbeddedBlockFeedService: EmbeddedBlockFeedServing { - - private let ask: (_ ids: [String], _ completion: @escaping (FeedAnswer) -> Void) -> Void - private let fetchInappToShow: (_ id: String, _ params: [String: JSONValue], _ completion: @escaping (InAppFormData?) -> Void) -> Void - private let showNow: (InAppFormData) -> Void - - init(ask: ((_ ids: [String], _ completion: @escaping (FeedAnswer) -> Void) -> Void)? = nil, - fetchInappToShow: ((_ id: String, _ params: [String: JSONValue], _ completion: @escaping (InAppFormData?) -> Void) -> Void)? = nil, - showNow: ((InAppFormData) -> Void)? = nil) { - self.ask = ask ?? { ids, completion in - DI.injectOrFail(InAppConfigurationManagerProtocol.self).getShowableInappIds(ids, completion) - } - self.fetchInappToShow = fetchInappToShow ?? { id, params, completion in - DI.injectOrFail(InAppConfigurationManagerProtocol.self).getInAppToShowById(id, params: params, completion) - } - self.showNow = showNow ?? { formData in - DI.injectOrFail(InappScheduleManagerProtocol.self).showInAppNow(formData) - } - } - - func showInapp(id: String, params: [String: JSONValue]) { - fetchInappToShow(id, params) { [showNow] formData in - guard let formData = formData else { - Logger.common(message: "[EmbeddedBlock] Nothing to show for in-app \(id)", - level: .error, category: .embeddedBlocks) - return - } - - showNow(formData) - } - } - - func showableInappIds(among ids: [String], completion: @escaping (FeedAnswer) -> Void) { - guard !ids.isEmpty else { - completion(.nothing) - return - } - - ask(ids) { answer in - // The selection answers off the main thread; the page is written to from it. - guard Thread.isMainThread else { - DispatchQueue.main.async { - completion(answer) - } - return - } - - completion(answer) - } - } -} diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift index 955aa6866..d0b42b84a 100644 --- a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift @@ -50,7 +50,8 @@ public struct MindboxEmbeddedBlock: View { private(set) var errorBuilder: (() -> AnyView)? /// - Parameters: - /// - placeSystemName: The system name of the place from the admin panel. + /// - placeSystemName: The system name of the place from the admin panel. Whitespace around + /// it is ignored; the name itself is matched as it is, case included. /// - height: The height the block occupies while loading and shown. A new value resizes the /// block in place, without reloading its content. /// - timeout: How long the block waits to learn what it shows before collapsing as @@ -64,7 +65,9 @@ public struct MindboxEmbeddedBlock: View { timeout: TimeInterval? = nil, onLoad: (() -> Void)? = nil, onFail: (() -> Void)? = nil) { - self.placeSystemName = placeSystemName + // Normalized here too, so `.id(placeSystemName)` keeps one SwiftUI identity per place + // however the name was padded. + self.placeSystemName = MindboxEmbeddedBlockView.normalizedPlaceSystemName(placeSystemName) self.height = height self.timeout = timeout self.onLoad = onLoad diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift index 939812f00..684966e42 100644 --- a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift @@ -28,8 +28,8 @@ public final class MindboxEmbeddedBlockView: UIView { // MARK: - Host API - /// The system name of the place from the admin panel, given at creation. Decides what content - /// the SDK puts inside. + /// The system name of the place from the admin panel, given at creation and stripped of the + /// whitespace around it. Decides what content the SDK puts inside. public let placeSystemName: String /// Receives the block events. Assigning a delegate after the content already resolved still @@ -180,7 +180,8 @@ public final class MindboxEmbeddedBlockView: UIView { // MARK: - Life cycle /// - Parameters: - /// - placeSystemName: The place system name from the admin panel. + /// - placeSystemName: The place system name from the admin panel. Whitespace around it is + /// ignored; the name itself is matched as it is, case included. /// - height: The height the block occupies while loading and shown. Reserving it is the /// host's job and there is no default: a height of 0 or less leaves the block invisible /// whatever its content turns out to be, so the SDK reports it as an integration error. @@ -190,12 +191,19 @@ public final class MindboxEmbeddedBlockView: UIView { /// block; the next attempt starts when the block enters the window again. The separate /// budget a loaded page gets to render itself is not affected. public convenience init(placeSystemName: String, height: CGFloat, timeout: TimeInterval? = nil) { - self.init(placeSystemName: placeSystemName, + let place = Self.normalizedPlaceSystemName(placeSystemName) + self.init(placeSystemName: place, height: height, - contentProvider: DI.injectOrFail(EmbeddedBlockContentProviderMaking.self).makeProvider(placeSystemName: placeSystemName), + contentProvider: DI.injectOrFail(EmbeddedBlockContentProviderMaking.self).makeProvider(placeSystemName: place), timeout: timeout) } + /// Padding is not part of a name: a name pasted from the admin panel with a stray space still + /// finds its place, in sync with Android. + static func normalizedPlaceSystemName(_ given: String) -> String { + given.trimmingCharacters(in: .whitespacesAndNewlines) + } + /// Blocks are not created from storyboards: the place system name and the height are required /// and have no sensible defaults. @available(*, unavailable, message: "Use init(placeSystemName:height:) instead") @@ -207,19 +215,18 @@ public final class MindboxEmbeddedBlockView: UIView { height: CGFloat, contentProvider: EmbeddedBlockWebViewProvider, timeout: TimeInterval? = nil, - waitBudget: EmbeddedBlockWaitBudget? = nil) { + makeWaitBudget: ((_ placeSystemName: String, _ duration: @escaping () -> TimeInterval) -> EmbeddedBlockWaitBudget)? = nil) { self.placeSystemName = placeSystemName self.preferredHeight = height self.contentProvider = contentProvider let answerTimeout = Self.sanitizedTimeout(timeout, placeSystemName: placeSystemName) - self.waitBudget = waitBudget ?? EmbeddedBlockWaitBudget( - placeSystemName: placeSystemName, - duration: { [weak contentProvider] in - contentProvider?.isAwaitingAnswer == false - ? TimeInterval(Constants.EmbeddedBlock.readyTimeoutSeconds) - : answerTimeout - } - ) + let duration: () -> TimeInterval = { [weak contentProvider] in + contentProvider?.isAwaitingAnswer == false + ? TimeInterval(Constants.EmbeddedBlock.readyTimeoutSeconds) + : answerTimeout + } + self.waitBudget = makeWaitBudget?(placeSystemName, duration) + ?? EmbeddedBlockWaitBudget(placeSystemName: placeSystemName, duration: duration) super.init(frame: .zero) warnIfPlaceIsMissing() warnIfHeightReservesNothing() @@ -283,9 +290,14 @@ public final class MindboxEmbeddedBlockView: UIView { self.waitBudget.armIfNeeded() } + // Known content on its way is not silence: the budget stands down until the page starts loading. + contentProvider.onContentDelayed = { [weak self] in + self?.waitBudget.reset() + } + waitBudget.isNeeded = { [weak self] in guard let self else { return false } - return self.isEffectivelyVisible && self.state == .loading + return self.isEffectivelyVisible && self.state == .loading && !self.contentProvider.isAwaitingDelayedContent } waitBudget.onExpire = { [weak self] in self?.handleTimeout() @@ -361,12 +373,16 @@ public final class MindboxEmbeddedBlockView: UIView { waitBudget.armIfNeeded() } - /// Running out of patience is a failure only for a page that was built and stayed silent; a - /// block that never learned what to show has nothing to show — an outcome, not a breakage. + /// A page that was built and stayed silent fails. A block the SDK never answered has nothing to + /// show and collapses as empty — but the silence itself is still reported. private func handleTimeout() { let hadContentToLoad = !contentProvider.isAwaitingAnswer - contentProvider.reportPageTimedOut() + if hadContentToLoad { + contentProvider.reportPageTimedOut() + } else { + contentProvider.reportAnswerTimedOut(waited: waitBudget.consumed) + } // The provider must not resurrect content the container has already given up on. contentProvider.abandonAttempt() state = hadContentToLoad ? .failed : .empty diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockDelayedDelivery.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockDelayedDelivery.swift new file mode 100644 index 000000000..5a258486e --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockDelayedDelivery.swift @@ -0,0 +1,112 @@ +// +// EmbeddedBlockDelayedDelivery.swift +// Mindbox +// +// Created by Sergei Semko on 25.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit + +/// Holds a place's answer for its `delayTime`, like the schedule queue holds an overlay: one answer per +/// place, the newest replaces the waiting one, nothing is delivered in the background. Main thread only. +final class EmbeddedBlockDelayedDelivery { + + typealias Delivery = (Answer) -> Void + + private struct Waiting { + let inappId: String + var answer: Answer + let deliver: Delivery + var state: State + } + + private enum State { + case ticking(DispatchWorkItem) + case due + } + + private var waiting: [String: Waiting] = [:] + + private let schedule: EmbeddedBlockWaitScheduling + private let isInBackground: () -> Bool + private let notificationCenter: NotificationCenter + private var observer: NSObjectProtocol? + + init(isInBackground: @escaping () -> Bool = { UIApplication.shared.applicationState == .background }, + notificationCenter: NotificationCenter = .default, + schedule: @escaping EmbeddedBlockWaitScheduling = { delay, work in + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + }) { + self.isInBackground = isInBackground + self.notificationCenter = notificationCenter + self.schedule = schedule + + observer = notificationCenter.addObserver(forName: UIApplication.willEnterForegroundNotification, + object: nil, + queue: .main) { [weak self] _ in + self?.deliverDue() + } + } + + deinit { + if let observer { + notificationCenter.removeObserver(observer) + } + for entry in waiting.values { + if case .ticking(let timer) = entry.state { + timer.cancel() + } + } + } + + func isWaiting(place: String, for inappId: String) -> Bool { + waiting[place]?.inappId == inappId + } + + func schedule(place: String, inappId: String, answer: Answer, after delay: TimeInterval, _ deliver: @escaping Delivery) { + cancel(place: place) + + let timer = DispatchWorkItem { [weak self] in + guard let self, self.waiting[place]?.inappId == inappId else { return } + + if self.isInBackground() { + self.waiting[place]?.state = .due + } else { + self.deliver(place) + } + } + + waiting[place] = Waiting(inappId: inappId, answer: answer, deliver: deliver, state: .ticking(timer)) + schedule(delay, timer) + } + + /// The newest answer for the in-app already waiting at the place; its delay keeps running. + func refresh(place: String, answer: Answer) { + waiting[place]?.answer = answer + } + + func cancel(place: String) { + stopTicking(place) + waiting[place] = nil + } + + private func stopTicking(_ place: String) { + if case .ticking(let timer)? = waiting[place]?.state { + timer.cancel() + } + } + + private func deliverDue() { + let due = waiting.compactMap { place, entry -> String? in + if case .due = entry.state { return place } + return nil + } + due.forEach(deliver) + } + + private func deliver(_ place: String) { + guard let entry = waiting.removeValue(forKey: place) else { return } + entry.deliver(entry.answer) + } +} diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockPlaceRegistry.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockPlaceRegistry.swift index 38704a874..e031def1c 100644 --- a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockPlaceRegistry.swift +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockPlaceRegistry.swift @@ -13,8 +13,11 @@ protocol EmbeddedBlockPlaceHandling: AnyObject { var isActive: Bool { get } - /// The place's fresh answer, on the main thread. - func apply(_ resolution: EmbeddedBlockResolution) + /// The place's fresh answer, on the main thread, with how long the selection worked on it. + func apply(_ resolution: EmbeddedBlockResolution, processingDuration: TimeInterval) + + /// The place's answer is known and held back by its `delayTime`: content is coming, the SDK is not silent. + func contentIsDelayed() } /// Called on the main thread: the registry's state is confined to it. @@ -32,6 +35,11 @@ protocol EmbeddedBlockPlaceRegistering: AnyObject { /// same component carries the same name and rules. final class EmbeddedBlockPlaceRegistry: EmbeddedBlockPlaceRegistering { + struct PlaceAnswer { + let resolution: EmbeddedBlockResolution + let processingDuration: TimeInterval + } + typealias EmbeddedPlacesFetching = (@escaping ([String: Set]?) -> Void) -> Void private struct WeakBlock { @@ -85,14 +93,18 @@ final class EmbeddedBlockPlaceRegistry: EmbeddedBlockPlaceRegistering { private let resolver: EmbeddedBlockResolving private let fetchEmbeddedPlaces: EmbeddedPlacesFetching private let notificationCenter: NotificationCenter + private let delayedDelivery: EmbeddedBlockDelayedDelivery + private var observers: [NSObjectProtocol] = [] init(resolver: EmbeddedBlockResolving, notificationCenter: NotificationCenter = .default, - fetchEmbeddedPlaces: @escaping EmbeddedPlacesFetching = EmbeddedBlockPlaceRegistry.fetchPlacesFromConfig) { + fetchEmbeddedPlaces: @escaping EmbeddedPlacesFetching = EmbeddedBlockPlaceRegistry.fetchPlacesFromConfig, + delayedDelivery: EmbeddedBlockDelayedDelivery = EmbeddedBlockDelayedDelivery()) { self.resolver = resolver self.notificationCenter = notificationCenter self.fetchEmbeddedPlaces = fetchEmbeddedPlaces + self.delayedDelivery = delayedDelivery // The registry is created lazily, with the first block — a config may already be in memory, // and its notification is not coming again. @@ -207,11 +219,11 @@ final class EmbeddedBlockPlaceRegistry: EmbeddedBlockPlaceRegistering { resolvingPlaces.insert(place) - resolver.resolve(place, trigger: cause.trigger) { [weak self] resolution in + resolver.resolve(place, trigger: cause.trigger) { [weak self] resolution, processingDuration in guard let self else { return } self.resolvingPlaces.remove(place) - self.deliver(place: place, resolution: resolution) + self.handle(resolution, at: place, processingDuration: processingDuration) if let queued = self.queuedInvalidations.removeValue(forKey: place) { self.requestResolve(place: place, cause: .queued(queued.trigger)) @@ -219,9 +231,55 @@ final class EmbeddedBlockPlaceRegistry: EmbeddedBlockPlaceRegistering { } } - private func deliver(place: String, resolution: EmbeddedBlockResolution) { + /// A winner with `delayTime` waits like an overlay in the schedule queue and the blocks stand their wait + /// budget down. A delay served once in the session is not waited again: a block coming back gets the content at once. + private func handle(_ resolution: EmbeddedBlockResolution, at place: String, processingDuration: TimeInterval) { + guard case .content(let content) = resolution else { + delayedDelivery.cancel(place: place) + deliver(place: place, resolution: resolution, processingDuration: processingDuration) + return + } + + let answer = PlaceAnswer(resolution: resolution, processingDuration: processingDuration) + + if delayedDelivery.isWaiting(place: place, for: content.inAppId) { + Logger.common(message: "[EmbeddedBlock] Place '\(place)': in-app \(content.inAppId) is still waiting out its delay", + category: .embeddedBlocks) + delayedDelivery.refresh(place: place, answer: answer) + announceDelay(at: place) + return + } + + delayedDelivery.cancel(place: place) + + let delay = TimeInterval.delay(fromTimeSpan: content.delayTime) + let served = ServedPlaceDelay(place: place, inappId: content.inAppId) + // Checked here, inserted when the delay fires — both on the main thread; the ledger's + // lock protects other readers, not this sequence. + guard delay > 0, !SessionTemporaryStorage.shared.ledger.servedPlaceDelays.contains(served) else { + deliver(place: place, resolution: resolution, processingDuration: processingDuration) + return + } + + Logger.common(message: "[EmbeddedBlock] Place '\(place)': in-app \(content.inAppId) waits \(delay)s before it is shown", + category: .embeddedBlocks) + announceDelay(at: place) + + delayedDelivery.schedule(place: place, inappId: content.inAppId, answer: answer, after: delay) { [weak self] answer in + SessionTemporaryStorage.shared.$ledger.mutate { $0.servedPlaceDelays.insert(served) } + self?.deliver(place: place, resolution: answer.resolution, processingDuration: answer.processingDuration) + } + } + + private func announceDelay(at place: String) { + for weakBlock in blocksByPlace[place] ?? [] { + weakBlock.block?.contentIsDelayed() + } + } + + private func deliver(place: String, resolution: EmbeddedBlockResolution, processingDuration: TimeInterval) { for weakBlock in blocksByPlace[place] ?? [] { - weakBlock.block?.apply(resolution) + weakBlock.block?.apply(resolution, processingDuration: processingDuration) } } diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift index 55e380d88..134916ad5 100644 --- a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift @@ -20,14 +20,16 @@ enum EmbeddedBlockResolution: Equatable { /// underneath runs on the in-app queue. protocol EmbeddedBlockResolving: AnyObject { - /// - Parameter trigger: The operation that caused this resolve, if any. Targeting runs in its - /// context — that is what lets an operation-targeted in-app reach the place. + /// - Parameters: + /// - trigger: The operation that caused this resolve, if any. Targeting runs in its context — + /// that is what lets an operation-targeted in-app reach the place. + /// - completion: The answer and how long it took from this call, the wait for a config included. func resolve(_ place: String, trigger: ApplicationEvent?, - completion: @escaping (EmbeddedBlockResolution) -> Void) + completion: @escaping (EmbeddedBlockResolution, _ processingDuration: TimeInterval) -> Void) } -typealias EmbeddedBlockContentLoading = (String, ApplicationEvent?, @escaping (EmbeddedBlockResolution) -> Void) -> Void +typealias EmbeddedBlockContentLoading = (String, ApplicationEvent?, @escaping (EmbeddedBlockResolution, TimeInterval) -> Void) -> Void final class EmbeddedBlockResolver: EmbeddedBlockResolving { @@ -41,36 +43,37 @@ final class EmbeddedBlockResolver: EmbeddedBlockResolving { /// "nothing to show" would outlive the reason for it and leave the block empty until a restart. func resolve(_ place: String, trigger: ApplicationEvent?, - completion: @escaping (EmbeddedBlockResolution) -> Void) { - load(place, trigger) { resolution in - self.deliverOnMain(resolution, completion) + completion: @escaping (EmbeddedBlockResolution, _ processingDuration: TimeInterval) -> Void) { + load(place, trigger) { resolution, processingDuration in + self.deliverOnMain(resolution, processingDuration, completion) } } private func deliverOnMain(_ resolution: EmbeddedBlockResolution, - _ completion: @escaping (EmbeddedBlockResolution) -> Void) { + _ processingDuration: TimeInterval, + _ completion: @escaping (EmbeddedBlockResolution, TimeInterval) -> Void) { guard Thread.isMainThread else { DispatchQueue.main.async { - completion(resolution) + completion(resolution, processingDuration) } return } - completion(resolution) + completion(resolution, processingDuration) } static func loadFromConfig(_ place: String, trigger: ApplicationEvent?, - completion: @escaping (EmbeddedBlockResolution) -> Void) { + completion: @escaping (EmbeddedBlockResolution, TimeInterval) -> Void) { guard let configurationManager = DI.inject(InAppConfigurationManagerProtocol.self) else { Logger.common(message: "[EmbeddedBlock] No configuration manager, place '\(place)' resolves as empty", level: .error, category: .embeddedBlocks) - completion(.empty) + completion(.empty, 0) return } - configurationManager.selectInappForPlace(place, trigger: trigger) { inapp in - completion(resolution(from: inapp, place: place)) + configurationManager.selectInappForPlace(place, trigger: trigger) { inapp, processingDuration in + completion(resolution(from: inapp, place: place), processingDuration) } } @@ -92,6 +95,7 @@ final class EmbeddedBlockResolver: EmbeddedBlockResolving { contentUrl: layer.contentUrl, frequency: inapp.frequency, tags: inapp.tags, - params: layer.params)) + params: layer.params, + delayTime: inapp.delayTime)) } } diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift index c82de750b..bdd7ee1e4 100644 --- a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift @@ -27,6 +27,25 @@ struct EmbeddedBlockWebContent: Equatable { let params: [String: JSONValue] + /// The config's delay before the content may be shown; the place registry holds the answer for it. + let delayTime: String? + + init(inAppId: String, + baseUrl: String, + contentUrl: String, + frequency: InappFrequency?, + tags: [String: String]?, + params: [String: JSONValue], + delayTime: String? = nil) { + self.inAppId = inAppId + self.baseUrl = baseUrl + self.contentUrl = contentUrl + self.frequency = frequency + self.tags = tags + self.params = params + self.delayTime = delayTime + } + /// The in-app id counts as the page's identity, not as its data: it goes into the start payload, /// so handing a page new params under a different in-app would describe something it is not. func isSamePage(as other: EmbeddedBlockWebContent) -> Bool { diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockAckBudget.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockAckBudget.swift new file mode 100644 index 000000000..3db27412d --- /dev/null +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockAckBudget.swift @@ -0,0 +1,49 @@ +// +// EmbeddedBlockAckBudget.swift +// Mindbox +// +// Created by Sergei Semko on 31.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// The page's budget to confirm a data push. Spent only while somebody is looking at the block: +/// a pause suspends the spend, a return arms the remainder. +struct EmbeddedBlockAckBudget { + + private let now: () -> TimeInterval + + private var consumed: TimeInterval = 0 + + private var resumedAt: TimeInterval? + + init(now: @escaping () -> TimeInterval) { + self.now = now + } + + var remaining: TimeInterval { + max(0, TimeInterval(Constants.EmbeddedBlock.readyTimeoutSeconds) - consumed) + } + + mutating func resume() { + resumedAt = now() + } + + mutating func suspend() { + if let resumedAt { + consumed += max(0, now() - resumedAt) + self.resumedAt = nil + } + } + + mutating func exhaust() { + consumed = TimeInterval(Constants.EmbeddedBlock.readyTimeoutSeconds) + resumedAt = nil + } + + mutating func reset() { + consumed = 0 + resumedAt = nil + } +} diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockInappService.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockInappService.swift new file mode 100644 index 000000000..b19a045fc --- /dev/null +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockInappService.swift @@ -0,0 +1,90 @@ +// +// EmbeddedBlockInappService.swift +// Mindbox +// +// Created by Sergei Semko on 8/13/26. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import QuartzCore +import MindboxLogger + +protocol EmbeddedBlockInappServing: AnyObject { + + /// Whether a config is in hand — what a never-answered block reports it was waiting on. + var hasConfig: Bool { get } + + /// Which of `ids` the page of in-app `blockInappId` may draw, targeting checked and fetched like a place + /// resolve; vouches for every targeted id as it answers. The answer mirrors the question — order and duplicates kept. + func showableInappIds(among ids: [String], askedBy blockInappId: String, completion: @escaping ([String]) -> Void) + + /// Deliberately unchecked: whether to offer the in-app was decided when the page drew it. + func showInapp(id: String, params: [String: JSONValue]) +} + +final class EmbeddedBlockInappService: EmbeddedBlockInappServing { + + private let ask: (_ ids: [String], _ blockInappId: String, _ completion: @escaping ([String]) -> Void) -> Void + private let fetchInappToShow: (_ id: String, _ params: [String: JSONValue], _ completion: @escaping (InAppFormData?) -> Void) -> Void + private let showNow: (InAppFormData, _ processingDuration: TimeInterval) -> Void + private let configIsKnown: () -> Bool + private let now: () -> TimeInterval + + var hasConfig: Bool { configIsKnown() } + + init(ask: ((_ ids: [String], _ blockInappId: String, _ completion: @escaping ([String]) -> Void) -> Void)? = nil, + fetchInappToShow: ((_ id: String, _ params: [String: JSONValue], _ completion: @escaping (InAppFormData?) -> Void) -> Void)? = nil, + showNow: ((InAppFormData, _ processingDuration: TimeInterval) -> Void)? = nil, + hasConfig: (() -> Bool)? = nil, + now: @escaping () -> TimeInterval = { CACurrentMediaTime() }) { + self.now = now + self.configIsKnown = hasConfig ?? { + DI.injectOrFail(InAppConfigurationManagerProtocol.self).hasConfig + } + self.ask = ask ?? { ids, blockInappId, completion in + DI.injectOrFail(InAppConfigurationManagerProtocol.self).getShowableInappIds(ids, askedBy: blockInappId, completion) + } + self.fetchInappToShow = fetchInappToShow ?? { id, params, completion in + DI.injectOrFail(InAppConfigurationManagerProtocol.self).getInAppToShowById(id, params: params, completion) + } + self.showNow = showNow ?? { formData, processingDuration in + DI.injectOrFail(InappScheduleManagerProtocol.self).showInAppNow(formData, processingDuration: processingDuration) + } + } + + func showInapp(id: String, params: [String: JSONValue]) { + // The tap is the trigger: the fetch and the form build count into timeToDisplay, on the overlay pass's clock. + let tappedAt = now() + fetchInappToShow(id, params) { [showNow, now] formData in + let processingDuration = now() - tappedAt + + guard let formData = formData else { + Logger.common(message: "[EmbeddedBlock] Nothing to show for in-app \(id)", + level: .error, category: .embeddedBlocks) + return + } + + showNow(formData, processingDuration) + } + } + + func showableInappIds(among ids: [String], askedBy blockInappId: String, completion: @escaping ([String]) -> Void) { + guard !ids.isEmpty else { + completion([]) + return + } + + ask(ids, blockInappId) { allowed in + // The selection answers off the main thread; the page is written to from it. + guard Thread.isMainThread else { + DispatchQueue.main.async { + completion(allowed) + } + return + } + + completion(allowed) + } + } +} diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift index ed41a06b0..dbf0f6235 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift @@ -55,3 +55,15 @@ protocol EmbeddedBlockPageHosting: AnyObject { /// there — this is what re-evaluates targeting and an A/B re-flip on a live page. func sendInitData(params: [String: JSONValue]) } + +extension EmbeddedBlockPageHosting { + + func detachCallbacks() { + onContentRendered = nil + onUnreadableContentReport = nil + onShowableQuestion = nil + onShowInAppRequest = nil + onDataPushConfirmed = nil + onLoadFailure = nil + } +} diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift index 4524e88e1..1cdea36d3 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift @@ -102,7 +102,7 @@ final class EmbeddedBlockWebViewPage: NSObject, EmbeddedBlockPageHosting { // The container height equals the content height, so there is nothing to scroll vertically — // otherwise the block would bounce under the finger on every horizontal swipe. Horizontal - // scrolling stays on, unlike an overlay's: a feed is a row the user swipes through. + // scrolling stays on, unlike an overlay's: a block is a row the user swipes through. webView.scrollView.bounces = false webView.scrollView.alwaysBounceVertical = false webView.scrollView.showsVerticalScrollIndicator = false @@ -157,9 +157,9 @@ extension EmbeddedBlockWebViewPage: WebBridgeContentHosting { } } -// MARK: - WebBridgeFeedHosting +// MARK: - WebBridgeInappRequestHosting -extension EmbeddedBlockWebViewPage: WebBridgeFeedHosting { +extension EmbeddedBlockWebViewPage: WebBridgeInappRequestHosting { func bridgeDidAskShowableInapps(_ ids: [String], completion: @escaping ([String]) -> Void) { onShowableQuestion?(ids, completion) diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift index 2b5f4abaa..50bbf227c 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift @@ -22,21 +22,26 @@ final class EmbeddedBlockWebViewProvider { var onContentArrived: (() -> Void)? + var onContentDelayed: (() -> Void)? + var contentView: UIView? { isReady ? page?.view : nil } var isAwaitingAnswer: Bool { page == nil } + /// While set, the block keeps loading on purpose: content is coming, the SDK is not silent. + private(set) var isAwaitingDelayedContent = false + private let placeSystemName: String private let registry: EmbeddedBlockPlaceRegistering - private let feed: EmbeddedBlockFeedServing + private let inappService: EmbeddedBlockInappServing private let makePage: (EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting - private let recordShow: (String) -> Void - - private let reportShow: (EmbeddedBlockWebContent, String) -> Void + private let accounting: InappShowAccounting private let reportFailure: (EmbeddedBlockWebContent, InAppShowFailureReason, String) -> Void + private let reportUnansweredWait: (_ waited: TimeInterval) -> Void + private var page: EmbeddedBlockPageHosting? private var content: EmbeddedBlockWebContent? @@ -53,52 +58,58 @@ final class EmbeddedBlockWebViewProvider { private var didAccountForShow = false - private var attemptStopwatch = ForegroundStopwatch() + /// The page has drawn something and nothing has been asked of it since. A stray repeat must not + /// un-show a shown block; a rebuild and a data push both invite a fresh report. + private var didReportShownContent = false - private let scheduleAckTimeout: EmbeddedBlockWaitScheduling + /// The selection's part of `timeToDisplay`; the page's part runs on `presentationStopwatch`. + private var processingDuration: TimeInterval = 0 - private let now: () -> TimeInterval + /// `timeToDisplay` frozen at the moment the page drew: a Show sent on a later return reports + /// the render, not the time nobody was looking. + private var renderedElapsed: TimeInterval? - private var dataPushAck: DispatchWorkItem? + private var presentationStopwatch: ForegroundStopwatch - private var isAwaitingDataPushAck = false + private let makeStopwatch: () -> ForegroundStopwatch + + private let scheduleAckTimeout: EmbeddedBlockWaitScheduling - private var ackConsumed: TimeInterval = 0 + private var dataPushAck: DispatchWorkItem? - private var ackResumedAt: TimeInterval? + private var isAwaitingDataPushAck = false - private var ackRemaining: TimeInterval { - max(0, TimeInterval(Constants.EmbeddedBlock.readyTimeoutSeconds) - ackConsumed) - } + private var ackBudget: EmbeddedBlockAckBudget private var pendingFailureReport: (content: EmbeddedBlockWebContent, reason: InAppShowFailureReason, details: String)? - private var renderedElapsed: TimeInterval? - - private var pendingResolution: EmbeddedBlockResolution? + private var pendingResolution: (resolution: EmbeddedBlockResolution, processingDuration: TimeInterval)? init(placeSystemName: String, registry: EmbeddedBlockPlaceRegistering, - feed: EmbeddedBlockFeedServing, + inappService: EmbeddedBlockInappServing, makePage: @escaping (EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting, - recordShow: @escaping (String) -> Void, - reportShow: @escaping (EmbeddedBlockWebContent, String) -> Void, + accounting: InappShowAccounting, reportFailure: @escaping (EmbeddedBlockWebContent, InAppShowFailureReason, String) -> Void, + reportUnansweredWait: @escaping (_ waited: TimeInterval) -> Void, scheduleAckTimeout: @escaping EmbeddedBlockWaitScheduling = { delay, work in DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) }, + makeStopwatch: @escaping () -> ForegroundStopwatch = { ForegroundStopwatch() }, now: @escaping () -> TimeInterval = { CACurrentMediaTime() }) { self.placeSystemName = placeSystemName self.registry = registry - self.feed = feed + self.inappService = inappService self.makePage = makePage - self.recordShow = recordShow - self.reportShow = reportShow + self.accounting = accounting self.reportFailure = reportFailure + self.reportUnansweredWait = reportUnansweredWait self.scheduleAckTimeout = scheduleAckTimeout - self.now = now + self.makeStopwatch = makeStopwatch + self.presentationStopwatch = makeStopwatch() + self.ackBudget = EmbeddedBlockAckBudget(now: now) registry.register(self, place: placeSystemName) } @@ -124,7 +135,7 @@ final class EmbeddedBlockWebViewProvider { } if let pending = pending { - apply(pending) + apply(pending.resolution, processingDuration: pending.processingDuration) } rearmDataPushAckIfAwaited() @@ -133,7 +144,7 @@ final class EmbeddedBlockWebViewProvider { } if let pending = pending { - apply(pending) + apply(pending.resolution, processingDuration: pending.processingDuration) askThePlaceAgain() return } @@ -188,7 +199,7 @@ final class EmbeddedBlockWebViewProvider { private func beginAttempt() { onStateChange?(.loading) outcome = .loading - attemptStopwatch = ForegroundStopwatch() + isAwaitingDelayedContent = false if page != nil { Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': the page from the previous attempt cannot be resumed, dropping it", @@ -201,14 +212,16 @@ final class EmbeddedBlockWebViewProvider { // MARK: - The registry's answer - func apply(_ resolution: EmbeddedBlockResolution) { + func apply(_ resolution: EmbeddedBlockResolution, processingDuration: TimeInterval) { guard isStarted else { if isPaused { - pendingResolution = resolution + pendingResolution = (resolution, processingDuration) } return } + isAwaitingDelayedContent = false + switch resolution { case .empty: guard outcome != .empty else { return } @@ -219,45 +232,73 @@ final class EmbeddedBlockWebViewProvider { onStateChange?(.empty) case .content(let fresh): - applyContent(fresh) + applyContent(fresh, processingDuration: processingDuration) } } - private func applyContent(_ fresh: EmbeddedBlockWebContent) { - if let current = content, let page = page, outcome != .failed { - // A collapsed page is deliberately not deduplicated: for it the same answer is news — - // re-sent data is what makes the page re-report itself and revive. - if fresh == current, outcome == .ready || outcome == .loading { + func contentIsDelayed() { + guard isStarted else { return } + + Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': content is coming after its delay — waiting", + category: .embeddedBlocks) + isAwaitingDelayedContent = true + onContentDelayed?() + } + + private func applyContent(_ fresh: EmbeddedBlockWebContent, processingDuration: TimeInterval) { + // Only a block that shows something is talked to; one that shows nothing is rebuilt. A page + // confirms a data push and stays exactly as it was, so a collapsed block told about its content + // would sit waiting for a report that never comes. Rebuilding revives it, in sync with Android. + if let current = content, let page = page, isAttemptAlive { + if fresh == current { Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': the place resolved to the same content — nothing to change", category: .embeddedBlocks) return } if fresh.isSamePage(as: current) { + content = fresh + guard fresh.params != current.params else { + Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': same page, only its frequency or tags moved — refreshing the snapshot, nothing to tell the page", + category: .embeddedBlocks) + return + } + Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': same page, new data — telling the page", category: .embeddedBlocks) - content = fresh + // The stopwatch is deliberately not restarted: a re-render after a data push cannot + // account a show anyway (didAccountForShow holds), so its time goes nowhere. + didReportShownContent = false page.sendInitData(params: fresh.params) armDataPushAck() return } } - let reason = page == nil ? "building its page" : "the place points at another page — rebuilding it" - Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': \(reason)", category: .embeddedBlocks) - buildPage(with: fresh) + Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': \(rebuildReason)", category: .embeddedBlocks) + buildPage(with: fresh, processingDuration: processingDuration) + } + + private var rebuildReason: String { + guard page != nil else { return "building its page" } + + return isAttemptAlive + ? "the place points at another page — rebuilding it" + : "nothing is shown here — rebuilding its page to revive it" } - private func buildPage(with fresh: EmbeddedBlockWebContent) { + private func buildPage(with fresh: EmbeddedBlockWebContent, processingDuration: TimeInterval) { dropPage() if outcome != .loading { onStateChange?(.loading) - attemptStopwatch = ForegroundStopwatch() } outcome = .loading loadGeneration += 1 didAccountForShow = false + didReportShownContent = false + self.processingDuration = processingDuration + presentationStopwatch = makeStopwatch() renderedElapsed = nil let page = makePage(fresh) @@ -291,12 +332,7 @@ final class EmbeddedBlockWebViewProvider { /// Detached from us first, so that its late messages do not end up in the new attempt. private func dropPage() { cancelDataPushAck() - page?.onContentRendered = nil - page?.onUnreadableContentReport = nil - page?.onShowableQuestion = nil - page?.onShowInAppRequest = nil - page?.onDataPushConfirmed = nil - page?.onLoadFailure = nil + page?.detachCallbacks() page?.cancel() page = nil content = nil @@ -319,41 +355,37 @@ final class EmbeddedBlockWebViewProvider { guard let self, self.isStarted, self.loadGeneration == generation else { return } self.dataPushAck = nil - self.ackResumedAt = nil - self.ackConsumed = TimeInterval(Constants.EmbeddedBlock.readyTimeoutSeconds) + self.ackBudget.exhaust() self.isAwaitingDataPushAck = false guard let content = self.content else { return } Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': the page never confirmed the data push — rebuilding it", level: .error, category: .embeddedBlocks) - self.buildPage(with: content) + self.buildPage(with: content, processingDuration: self.processingDuration) } - ackResumedAt = now() + ackBudget.resume() dataPushAck = work - scheduleAckTimeout(ackRemaining, work) + scheduleAckTimeout(ackBudget.remaining, work) } private func suspendDataPushAck() { - if let ackResumedAt { - ackConsumed += max(0, now() - ackResumedAt) - self.ackResumedAt = nil - } + ackBudget.suspend() dataPushAck?.cancel() dataPushAck = nil } private func cancelDataPushAck() { suspendDataPushAck() - ackConsumed = 0 + ackBudget.reset() isAwaitingDataPushAck = false } private func rearmDataPushAckIfAwaited() { guard isAwaitingDataPushAck else { return } - Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': back on screen with a data push still unconfirmed — waiting out the remaining \(ackRemaining)s", + Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': back on screen with a data push still unconfirmed — waiting out the remaining \(ackBudget.remaining)s", category: .embeddedBlocks) resumeDataPushAck() } @@ -389,6 +421,20 @@ final class EmbeddedBlockWebViewProvider { report(.presentationFailed, "The block's page did not report itself in time") } + /// The SDK never answered within the block's budget — a failure with no in-app to pin it on, once + /// per place per session. Any answer, "nothing" included, would have disarmed the budget instead. + func reportAnswerTimedOut(waited: TimeInterval) { + guard SessionTemporaryStorage.shared.$ledger.mutate({ $0.recordUnanswered(placeSystemName) }) else { + Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': the SDK stayed silent again this session — already reported", + category: .embeddedBlocks) + return + } + + Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': the SDK never answered within \(waited.toTimeSpan()) — reporting a failure without an in-app", + level: .error, category: .embeddedBlocks) + reportUnansweredWait(waited) + } + private func report(_ reason: InAppShowFailureReason, _ details: String) { guard let content = content else { return } @@ -430,32 +476,38 @@ final class EmbeddedBlockWebViewProvider { Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': showing in-app \(inappId) with \(params.count) param(s)", category: .embeddedBlocks) - feed.showInapp(id: inappId, params: params) + inappService.showInapp(id: inappId, params: params) } /// A question shows nothing, so it is answered for as long as the block is running — including - /// while it is still loading, which is exactly when a feed asks. + /// while it is still loading, which is exactly when a page asks. private func answerShowableQuestion(_ ids: [String], completion: @escaping ([String]) -> Void) { - guard isStarted else { return } + guard isStarted, let content else { return } let generation = loadGeneration - feed.showableInappIds(among: ids) { [weak self] answer in + inappService.showableInappIds(among: ids, askedBy: content.inAppId) { [weak self] allowed in guard let self, self.isStarted, self.loadGeneration == generation else { return } - completion(answer.inappIds) - answer.vouch() + completion(allowed) } } - private func applyContentRendered(_ renderedCount: Int) { - guard renderedCount > 0 else { + private func applyContentRendered(_ count: Int) { + guard !didReportShownContent else { + Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': the page reported itself again with nothing asked of it — ignoring", + category: .embeddedBlocks) + return + } + + guard count > 0 else { Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': page rendered nothing", category: .embeddedBlocks) settle(.empty) return } - Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': page rendered \(renderedCount) item(s)", category: .embeddedBlocks) - renderedElapsed = attemptStopwatch.elapsed + Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': page rendered \(count) item(s)", category: .embeddedBlocks) + didReportShownContent = true + renderedElapsed = processingDuration + presentationStopwatch.elapsed settle(.ready) guard isStarted else { return } @@ -464,43 +516,33 @@ final class EmbeddedBlockWebViewProvider { } private func handleUnreadableContentReport() { + guard !didReportShownContent else { + Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': the page repeated contentRendered without a readable count — ignoring, the block is already shown", + category: .embeddedBlocks) + return + } + Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': contentRendered without a readable count, treating as broken", level: .error, category: .embeddedBlocks) settle(.failed) report(.presentationFailed, "The block's page reported contentRendered without a readable count") } - /// Blocks arrive `unlimited` by contract, so the backend is told even when the frequency writes - /// no history. The cooldown between overlay shows is deliberately left alone — a block interrupts nothing. private func accountForShow() { guard let content = content, !didAccountForShow else { return } didAccountForShow = true - let timeToDisplay = (renderedElapsed ?? attemptStopwatch.elapsed).toTimeSpan() - attemptStopwatch.stop() - - // Deduplicated per session by the in-app, in sync with Android: a page rebuilt within a - // session re-draws what the user already saw. - if SessionTemporaryStorage.shared.blockShowsReportedInSession.contains(content.inAppId) { - Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': in-app \(content.inAppId) is shown again this session — the event was already reported", - category: .embeddedBlocks) - } else { - SessionTemporaryStorage.shared.$blockShowsReportedInSession.mutate { $0.insert(content.inAppId) } - Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': in-app \(content.inAppId) is shown, timeToDisplay=\(timeToDisplay)", - category: .embeddedBlocks) - reportShow(content, timeToDisplay) - } - - guard InappFrequency.countsShows(content.frequency) else { - Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': in-app \(content.inAppId) is shown without a limit — nothing to count", - category: .embeddedBlocks) - return - } + let timeToDisplay = renderedElapsed ?? (processingDuration + presentationStopwatch.elapsed) + presentationStopwatch.stop() - Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': counting a show of in-app \(content.inAppId)", + Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': in-app \(content.inAppId) is shown, timeToDisplay=\(timeToDisplay.toTimeSpan())", category: .embeddedBlocks) - recordShow(content.inAppId) + accounting.recordBlockShow(InappShow(inAppId: content.inAppId, + frequency: content.frequency, + tags: content.tags, + timeToDisplay: timeToDisplay), + at: placeSystemName) } } diff --git a/Mindbox/InAppMessages/Configuration/API/TargetingModel.swift b/Mindbox/InAppMessages/Configuration/API/TargetingModel.swift index ae236a232..85d270a65 100644 --- a/Mindbox/InAppMessages/Configuration/API/TargetingModel.swift +++ b/Mindbox/InAppMessages/Configuration/API/TargetingModel.swift @@ -38,6 +38,23 @@ enum InAppTargetingType: String, Decodable { } } +extension Targeting { + + /// Whether the targeting cannot pass without an event — an operation, a viewed product or category. + var requiresEvent: Bool { + switch self { + case .apiMethodCall, .viewProductId, .viewProductSegment, .viewProductCategoryId, .viewProductCategoryIdIn: + return true + case .and(let node): + return node.nodes.contains { $0.requiresEvent } + case .or(let node): + return !node.nodes.isEmpty && node.nodes.allSatisfy { $0.requiresEvent } + case .true, .segment, .city, .region, .country, .visit, .pushEnabled, .unknown: + return false + } + } +} + enum Targeting: Decodable, Hashable, Equatable { case `true`(TrueTargeting) case and(AndTargeting) diff --git a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift index 6ed9c39a7..86a6a335c 100644 --- a/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift +++ b/Mindbox/InAppMessages/Configuration/InAppConfigurationManager.swift @@ -7,6 +7,7 @@ // import Foundation +import QuartzCore import MindboxLogger protocol InAppConfigurationDelegate: AnyObject { @@ -16,10 +17,17 @@ protocol InAppConfigurationDelegate: AnyObject { protocol InAppConfigurationManagerProtocol: AnyObject { var delegate: InAppConfigurationDelegate? { get set } + /// Whether a config is in hand: what a caller still waiting for an answer is stuck on otherwise. + var hasConfig: Bool { get } + func prepareConfiguration() func handleInapps(event: ApplicationEvent?, _ completion: @escaping (InAppFormData?) -> Void) - func selectInappForPlace(_ place: String, trigger: ApplicationEvent?, _ completion: @escaping (InAppTransitionData?) -> Void) - func getShowableInappIds(_ ids: [String], _ completion: @escaping (FeedAnswer) -> Void) + /// `processingDuration` runs from this call, the wait for a config included: a block's `timeToDisplay` + /// counts from the moment it asked for content (in sync with Android). + func selectInappForPlace(_ place: String, + trigger: ApplicationEvent?, + _ completion: @escaping (InAppTransitionData?, _ processingDuration: TimeInterval) -> Void) + func getShowableInappIds(_ ids: [String], askedBy blockInappId: String, _ completion: @escaping ([String]) -> Void) func getInAppToShowById(_ id: String, params: [String: JSONValue], _ completion: @escaping (InAppFormData?) -> Void) func getEmbeddedPlaces(_ completion: @escaping ([String: Set]?) -> Void) func resetInappManager() @@ -36,6 +44,8 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { /// Confined to `queue`, like `configResponse`. private var configCandidates: ConfigCandidates? + @Locked private(set) var hasConfig = false + private static let defaultConfigWaitBudget: TimeInterval = 30 private let configWaitBudget: TimeInterval @@ -60,6 +70,8 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { private let webViewPrewarmService: InAppWebViewPrewarmServiceProtocol private let inappFilterService: InappFilterProtocol + private let now: () -> TimeInterval + init( inAppConfigAPI: InAppConfigurationAPI, inAppConfigRepository: InAppConfigurationRepository, @@ -68,7 +80,8 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { featureToggleManager: FeatureToggleManager, webViewPrewarmService: InAppWebViewPrewarmServiceProtocol, inappFilterService: InappFilterProtocol, - configWaitBudget: TimeInterval = InAppConfigurationManager.defaultConfigWaitBudget + configWaitBudget: TimeInterval = InAppConfigurationManager.defaultConfigWaitBudget, + now: @escaping () -> TimeInterval = { CACurrentMediaTime() } ) { self.inAppConfigRepository = inAppConfigRepository self.inappMapper = inappMapper @@ -78,6 +91,7 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { self.webViewPrewarmService = webViewPrewarmService self.inappFilterService = inappFilterService self.configWaitBudget = configWaitBudget + self.now = now } weak var delegate: InAppConfigurationDelegate? @@ -103,25 +117,30 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { /// Waits for the first config rather than answering nil early: an early "nothing to show" /// collapses the block for the screen's whole life — nothing retries. - func selectInappForPlace(_ place: String, trigger: ApplicationEvent?, _ completion: @escaping (InAppTransitionData?) -> Void) { + func selectInappForPlace(_ place: String, + trigger: ApplicationEvent?, + _ completion: @escaping (InAppTransitionData?, _ processingDuration: TimeInterval) -> Void) { + let requestedAt = now() awaitConfig("place '\(place)'") { [weak self] candidates in guard let self = self, let inappMapper = self.inappMapper, let candidates = candidates else { - completion(nil) + completion(nil, 0) return } - inappMapper.selectInappForPlace(place, trigger: trigger, candidates, completion) + inappMapper.selectInappForPlace(place, trigger: trigger, candidates) { [now] inapp in + completion(inapp, now() - requestedAt) + } } } - func getShowableInappIds(_ ids: [String], _ completion: @escaping (FeedAnswer) -> Void) { - awaitConfig("a feed asking about \(ids.count) in-app(s)") { [weak self] candidates in + func getShowableInappIds(_ ids: [String], askedBy blockInappId: String, _ completion: @escaping ([String]) -> Void) { + awaitConfig("a page asking about \(ids.count) in-app(s)") { [weak self] candidates in guard let self = self, let inappMapper = self.inappMapper, let candidates = candidates else { - completion(.nothing) + completion([]) return } - inappMapper.getShowableInappIds(ids, candidates, completion) + inappMapper.getShowableInappIds(ids, askedBy: blockInappId, candidates, completion) } } @@ -149,9 +168,9 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { for inapp in config.inapps?.elements ?? [] { let inappPlaces = (inapp.form.variants ?? []).compactMap { variant -> String? in guard case .embedded(let embedded) = variant else { return nil } - // Exactly the string the selection will compare, untrimmed, or the gate would - // close on a place the resolve behind it would serve. - let place = embedded.placeSystemName + // Must trim the way the selection trims, or the gate would close on a place + // the resolve behind it would serve. + let place = embedded.placeSystemName?.trimmingCharacters(in: .whitespacesAndNewlines) return place?.isEmpty == false ? place : nil } @@ -258,6 +277,7 @@ class InAppConfigurationManager: InAppConfigurationManagerProtocol { } configCandidates = configResponse.map { inappFilterService.candidates(from: $0) } + hasConfig = configCandidates != nil hasConcludedDownload = true let waiters = configWaiters diff --git a/Mindbox/InAppMessages/ForegroundStopwatch.swift b/Mindbox/InAppMessages/ForegroundStopwatch.swift index a1389b41b..102cf7b5f 100644 --- a/Mindbox/InAppMessages/ForegroundStopwatch.swift +++ b/Mindbox/InAppMessages/ForegroundStopwatch.swift @@ -13,6 +13,7 @@ import UIKit /// A stopwatch that only counts time while the app is in the foreground. /// Background time (between `didEnterBackground` and `willEnterForeground`) is excluded from `elapsed`. final class ForegroundStopwatch { + private let now: () -> CFTimeInterval private let startTime: CFTimeInterval private var totalBackgroundDuration: CFTimeInterval = 0 private var backgroundEntryTime: CFTimeInterval? @@ -22,16 +23,19 @@ final class ForegroundStopwatch { private let notificationCenter: NotificationCenter - init(notificationCenter: NotificationCenter = .default) { + init(notificationCenter: NotificationCenter = .default, + now: @escaping () -> CFTimeInterval = { CACurrentMediaTime() }) { self.notificationCenter = notificationCenter - self.startTime = CACurrentMediaTime() + self.now = now + self.startTime = now() bgObserver = notificationCenter.addObserver( forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main ) { [weak self] _ in - self?.backgroundEntryTime = CACurrentMediaTime() + guard let self else { return } + self.backgroundEntryTime = self.now() } fgObserver = notificationCenter.addObserver( @@ -40,7 +44,7 @@ final class ForegroundStopwatch { queue: .main ) { [weak self] _ in guard let self, let entryTime = self.backgroundEntryTime else { return } - self.totalBackgroundDuration += CACurrentMediaTime() - entryTime + self.totalBackgroundDuration += self.now() - entryTime self.backgroundEntryTime = nil } } @@ -49,9 +53,9 @@ final class ForegroundStopwatch { var elapsed: TimeInterval { var currentBackgroundDuration = totalBackgroundDuration if let entryTime = backgroundEntryTime { - currentBackgroundDuration += CACurrentMediaTime() - entryTime + currentBackgroundDuration += now() - entryTime } - return CACurrentMediaTime() - startTime - currentBackgroundDuration + return now() - startTime - currentBackgroundDuration } /// Stops the stopwatch and removes notification observers. diff --git a/Mindbox/InAppMessages/InAppConfigurationMapper/InappFormBuilder.swift b/Mindbox/InAppMessages/InAppConfigurationMapper/InappFormBuilder.swift new file mode 100644 index 000000000..765b1489d --- /dev/null +++ b/Mindbox/InAppMessages/InAppConfigurationMapper/InappFormBuilder.swift @@ -0,0 +1,88 @@ +// +// InappFormBuilder.swift +// Mindbox +// +// Created by Sergei Semko on 25.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import MindboxLogger + +/// Turns a chosen in-app into what the presentation draws: its variant with the images downloaded. +struct InappFormBuilder { + + private let dataFacade: InAppConfigurationDataFacadeProtocol + + init(dataFacade: InAppConfigurationDataFacadeProtocol) { + self.dataFacade = dataFacade + } + + /// Blocking by design — callers walk a list and stop at the first buildable in-app. Must not run + /// on the selection queue: a download wait there would stall every targeting question behind it. + func makeFormData(_ inapp: InAppTransitionData, + extraParams: [String: JSONValue]?, + operation: (name: String, body: String)?) -> InAppFormData? { + Logger.common(message: "[InappFormBuilder] Starting in-app processing. [ID]: \(inapp.inAppId)", level: .debug, category: .inAppMessages) + + if case .modal(let modal) = inapp.content, + modal.content.background.layers.contains(where: { $0.layerType == .webview }) { + return InAppFormData(inAppId: inapp.inAppId, + isPriority: inapp.isPriority, + delayTime: inapp.delayTime, + imagesDict: [:], + firstImageValue: "", + content: inapp.content, + frequency: inapp.frequency, + tags: inapp.tags, + operation: operation, + extraParams: extraParams) + } + + let urlExtractorService = DI.injectOrFail(VariantImageUrlExtractorServiceProtocol.self) + let imageValues = urlExtractorService.extractImageURL(from: inapp.content) + + let group = DispatchGroup() + let imageDictQueue = DispatchQueue(label: "com.mindbox.imagedict.queue", attributes: .concurrent) + var imageDict: [String: UIImage] = [:] + var gotError = false + + for imageValue in imageValues { + group.enter() + Logger.common(message: "[InappFormBuilder] Initiating the process of image loading from the URL: \(imageValue)", level: .debug, category: .inAppMessages) + dataFacade.downloadImage(withUrl: imageValue, inappId: inapp.inAppId, tags: inapp.tags) { result in + defer { + group.leave() + } + + switch result { + case .success(let image): + imageDictQueue.async(flags: .barrier) { + imageDict[imageValue] = image + } + case .failure: + imageDictQueue.async(flags: .barrier) { + gotError = true + } + } + } + } + + group.wait() + + return imageDictQueue.sync { + guard !imageDict.isEmpty, !gotError else { return nil } + + return InAppFormData(inAppId: inapp.inAppId, + isPriority: inapp.isPriority, + delayTime: inapp.delayTime, + imagesDict: imageDict, + firstImageValue: imageValues.first ?? "", + content: inapp.content, + frequency: inapp.frequency, + tags: inapp.tags, + operation: operation, + extraParams: extraParams) + } + } +} diff --git a/Mindbox/InAppMessages/InAppConfigurationMapper/InappMapper.swift b/Mindbox/InAppMessages/InAppConfigurationMapper/InappMapper.swift index 9d4b98144..ba2426e94 100644 --- a/Mindbox/InAppMessages/InAppConfigurationMapper/InappMapper.swift +++ b/Mindbox/InAppMessages/InAppConfigurationMapper/InappMapper.swift @@ -22,8 +22,9 @@ protocol InappMapperProtocol { _ candidates: ConfigCandidates, _ completion: @escaping (InAppTransitionData?) -> Void) func getShowableInappIds(_ ids: [String], + askedBy blockInappId: String, _ candidates: ConfigCandidates, - _ completion: @escaping (FeedAnswer) -> Void) + _ completion: @escaping ([String]) -> Void) func getInAppToShowById(_ id: String, params: [String: JSONValue], _ candidates: ConfigCandidates, @@ -32,18 +33,16 @@ protocol InappMapperProtocol { class InappMapper: InappMapperProtocol { - // @Locked: written on the processing queue when a pass sets up its environment, read from the - // main and global queues by the build/track completions that pass hops through. - @Locked private var applicationEvent: ApplicationEvent? private var targetingChecker: InAppTargetingCheckerProtocol private let inappFilterService: InappFilterProtocol private let dataFacade: InAppConfigurationDataFacadeProtocol private let presentationValidator: InAppPresentationValidatorProtocol + private let formBuilder: InappFormBuilder @Locked private var shownInappIDWithHashValue: [String: Int] = [:] - + private let processingQueue = DispatchQueue(label: "com.Mindbox.inAppMapper.processingQueue") - + init(targetingChecker: InAppTargetingCheckerProtocol, inappFilterService: InappFilterProtocol, dataFacade: InAppConfigurationDataFacadeProtocol, @@ -52,31 +51,24 @@ class InappMapper: InappMapperProtocol { self.inappFilterService = inappFilterService self.dataFacade = dataFacade self.presentationValidator = presentationValidator + self.formBuilder = InappFormBuilder(dataFacade: dataFacade) } + // MARK: - Entry points + func handleInapps(_ event: ApplicationEvent?, _ candidates: ConfigCandidates, _ completion: @escaping (InAppFormData?) -> Void) { - processingQueue.async { - let group = DispatchGroup() - group.enter() - - Logger.common(message: "[InappMapper] Start handingInapps by event: \(event?.name ?? "start")", - level: .debug, category: .inAppMessages) - self.setupEnvironment(event: event) - self.prepareTargetingChecker(for: candidates.renderable) - // Narrowed here rather than in the fetch completion below: that one answers on the main - // queue, and the frequency reads and their logging have no business there. - let inapps = self.showableInapps(in: candidates) - - self.chooseInappToShow(inapps) { formData in - self.sendRemainingInappsTargeting(candidates) { - completion(formData) - group.leave() + runPass("the trigger", event: event) { finish in + self.evaluate(self.triggerQuery(event, candidates), event: event) { verdict in + self.buildFirstShowable(verdict, event: event) { formData in + self.evaluate(self.catchUpQuery(event, candidates), event: event) { catchUp in + self.vouchCatchUp(catchUp, event: event) + finish(formData != nil) + completion(formData) + } } } - - group.wait() } } @@ -84,247 +76,347 @@ class InappMapper: InappMapperProtocol { trigger: ApplicationEvent?, _ candidates: ConfigCandidates, _ completion: @escaping (InAppTransitionData?) -> Void) { - let query = TargetingQuery( - label: "place '\(place)'", - event: trigger, - fetchesDependencies: true, - candidates: { - self.inappFilterService.filter(place: place, in: candidates) - }, - pickVariant: { $0.form.variants.first { $0.placeSystemName == place } } - ) + runPass("place '\(place)'", event: trigger) { finish in + self.evaluate(self.placeQuery(place, candidates), event: trigger) { verdict in + self.evaluate(self.placeTargetingQuery(place, candidates), event: trigger) { targeted in + let winner = verdict.first + self.vouch(targeted, winner: winner, at: place) + + guard let winner else { + finish(false) + completion(nil) + return + } - evaluateTargeting(query) { suitableInapps in - guard let winner = suitableInapps.first else { - completion(nil) - return - } + guard self.presentationValidator.isWithinShowBudgets(isPriority: winner.isPriority, + frequency: winner.frequency, + id: winner.inAppId) else { + Logger.common(message: "[InappMapper] In-app \(winner.inAppId) won place '\(place)' but the show budgets are spent, the place stays empty", + level: .debug, category: .inAppMessages) + // Selected all the same: spent budgets are no targeting failure, so the buffer is dropped as after the overlay's pass. + finish(true) + completion(nil) + return + } - guard self.presentationValidator.isWithinShowBudgets(isPriority: winner.isPriority, - frequency: winner.frequency, - id: winner.inAppId) else { - Logger.common(message: "[InappMapper] In-app \(winner.inAppId) won place '\(place)' but the show budgets are spent, the place stays empty", - level: .debug, category: .inAppMessages) - completion(nil) - return + finish(true) + completion(winner) + } } - - self.vouchOncePerSession(for: [winner]) - - completion(winner) } } - /// Never goes to the network: answers from what the session already fetched, and an id whose - /// targeting lacks data is cut — fail closed, in sync with Android. + /// Vouches as it answers, not on delivery — the overlay's rule. An id whose targeting still lacks data + /// after the fetch is cut: fail closed, in sync with Android. func getShowableInappIds(_ ids: [String], + askedBy blockInappId: String, _ candidates: ConfigCandidates, - _ completion: @escaping (FeedAnswer) -> Void) { - let query = TargetingQuery( - label: "a feed asking about \(ids.count) in-app(s)", - event: nil, - fetchesDependencies: false, - candidates: { - self.inappFilterService.filter(feedIds: ids, in: candidates) - }, - pickVariant: { $0.form.variants.first { $0.isOverlayPresentable } } - ) - - evaluateTargeting(query) { allowed in - // Vouching travels with the answer — only the caller knows it reached the page — and - // repeats per delivered answer with no session dedup, in sync with Android. - let answer = FeedAnswer(inappIds: allowed.map(\.inAppId)) { [weak self] in - for inapp in allowed { - self?.dataFacade.trackTargeting(id: inapp.inAppId, tags: inapp.tags) + _ completion: @escaping ([String]) -> Void) { + runPass("a page of in-app \(blockInappId) asking about \(ids.count) in-app(s)", event: nil) { finish in + self.evaluate(self.pageQuery(ids, candidates), event: nil) { verdict in + self.evaluate(self.pageTargetingQuery(ids, candidates), event: nil) { offered in + // A page's question selects nothing; false only flushes a buffer this pass + // keeps empty (collectsFailures: false). + finish(false) + self.vouchOffers(offered, by: blockInappId) + completion(verdict.map(\.inAppId)) } } + } + } - completion(answer) + /// Nothing checked, display conditions included: a direct call may show anything the config holds. + func getInAppById(_ id: String, + _ candidates: ConfigCandidates, + _ completion: @escaping (InAppTransitionData?) -> Void) { + processingQueue.async { + completion(self.transition(forId: id, in: candidates)) } } - /// Place path only: its resolves repeat without offering anything new, hence once per session — - /// unlike a feed, which vouches per delivered answer. The split is an open question with Android. - private func vouchOncePerSession(for inapps: [InAppTransitionData]) { - for inapp in inapps { - guard !SessionTemporaryStorage.shared.vouchedInappIds.contains(inapp.inAppId) else { - Logger.common(message: "[InappMapper] In-app \(inapp.inAppId) was already vouched for in this session, no second Inapp.Targeting", - level: .debug, category: .inAppMessages) - continue + /// Show history is deliberately not consulted: the page already offered this in-app, and a tap + /// has to open it however many times it opened before. A pass of its own, so a form that could + /// not be built reports why at once instead of leaving that to whatever pass comes next. + func getInAppToShowById(_ id: String, + params: [String: JSONValue], + _ candidates: ConfigCandidates, + _ completion: @escaping (InAppFormData?) -> Void) { + runPass("a tap on in-app \(id)", event: nil) { finish in + guard let transitionData = self.transition(forId: id, in: candidates) else { + finish(false) + completion(nil) + return } - SessionTemporaryStorage.shared.$vouchedInappIds.mutate { $0.insert(inapp.inAppId) } - self.dataFacade.trackTargeting(id: inapp.inAppId, tags: inapp.tags) + guard transitionData.content.isOverlayPresentable else { + Logger.common(message: "[InappMapper] In-app \(id) is drawn inside the host layout and cannot be shown over the screen.", + level: .error, category: .inAppMessages) + finish(false) + completion(nil) + return + } + + self.buildInApp(transitionData, extraParams: params) { formData in + finish(formData != nil) + completion(formData) + } } } - private struct TargetingQuery { - - let label: String - - let event: ApplicationEvent? + private func transition(forId id: String, in candidates: ConfigCandidates) -> InAppTransitionData? { + guard let inapp = inappFilterService.filter(id: id, in: candidates) else { return nil } - /// A place resolve may fetch geo/segmentations; a feed may not — its page holds a - /// three-second deadline, and a checker asked without data says "not targeted". - let fetchesDependencies: Bool + // The variant the page's question picks, so a tap opens what the page offered. + guard let variant = inapp.form.variants.first(where: { $0.isOverlayPresentable }) + ?? inapp.form.variants.first else { + Logger.common(message: "[InappMapper] In-app \(id) has no variant left to render.", + level: .error, category: .inAppMessages) + return nil + } - let candidates: () -> [InApp] - let pickVariant: (InApp) -> MindboxFormVariant? + return InAppTransitionData(inAppId: inapp.id, + isPriority: inapp.isPriority, + delayTime: inapp.delayTime, + content: variant, + frequency: inapp.frequency, + tags: inapp.tags) } - /// One serial queue and one shared targeting checker for every path — two passes in flight would - /// answer each other's questions. - private func evaluateTargeting(_ query: TargetingQuery, - completion: @escaping ([InAppTransitionData]) -> Void) { + // MARK: - The pass + + /// One serial queue and one shared checker: a pass holds the queue until `finish`, so a place resolve + /// cannot land between a trigger's selection and its catch-up and swap the event under it. + /// The buffered failures answer "why was nothing shown", so only a pass that selected nothing sends them. + private func runPass(_ label: String, + event: ApplicationEvent?, + _ body: @escaping (_ finish: @escaping (_ selected: Bool) -> Void) -> Void) { processingQueue.async { let group = DispatchGroup() group.enter() - self.setupEnvironment(event: query.event) - - let candidates = query.candidates() - self.prepareTargetingChecker(for: candidates) + Logger.common(message: "[InappMapper] Pass for \(label) by event: \(event?.name ?? "start")", + level: .debug, category: .inAppMessages) + self.targetingChecker.event = event - let startedAt = Date() + // A missed finish freezes the queue for good, a second one would crash the leave. + let finishLock = NSLock() + var finished = false - let checkTargeting = { - let suitable = self.inappFilterService.filterInappsByTargeting( - inapps: candidates, - targetingChecker: self.targetingChecker, - pickVariant: query.pickVariant - ) + body { selected in + finishLock.lock() + let alreadyFinished = finished + finished = true + finishLock.unlock() - let ms = Int(Date().timeIntervalSince(startedAt) * 1000) - Logger.common(message: """ - [InappMapper] \(query.label): \(candidates.count) candidate(s), \(suitable.count) targeted, \ - answered in \(ms) ms. - """, level: .debug, category: .inAppMessages) + guard !alreadyFinished else { + assertionFailure("[InappMapper] The pass for \(label) tried to finish twice") + return + } - completion(suitable) + if selected { + self.dataFacade.discardCollectedFailures() + } else { + self.dataFacade.sendCollectedFailures() + } group.leave() } - if query.fetchesDependencies { - self.dataFacade.fetchDependencies(model: query.event?.model, checkTargeting) - } else { - checkTargeting() - } - group.wait() } } - /// Nothing checked, display conditions included: a direct call may show anything the config holds. - func getInAppById(_ id: String, - _ candidates: ConfigCandidates, - _ completion: @escaping (InAppTransitionData?) -> Void) { - processingQueue.async { - guard let inapp = self.inappFilterService.filter(id: id, in: candidates) else { - completion(nil) - return - } + /// One question to the shared checker, inside a pass only. + private struct TargetingQuery { - // The same variant a feed offers: it keeps an in-app that has any overlay variant, so - // taking the first one would refuse a mixed form whose embedded variant comes first. - guard let variant = inapp.form.variants.first(where: { $0.isOverlayPresentable }) - ?? inapp.form.variants.first else { - Logger.common(message: "[InappMapper] In-app \(id) has no variant left to render.", - level: .error, category: .inAppMessages) - completion(nil) - return - } + let label: String - completion(InAppTransitionData(inAppId: inapp.id, - isPriority: inapp.isPriority, - delayTime: inapp.delayTime, - content: variant, - frequency: inapp.frequency, - tags: inapp.tags)) - } + /// In-apps the checker learns about before the check — what to fetch, who listens to which operation. + let prepares: () -> [InApp] + + /// The pass's candidates, decided once the checker has been prepared. + let candidates: (PreparationContext) -> [InApp] + + /// Fetches geo/segmentations first; off for a follow-up question whose pass already did. + let fetchesDependencies: Bool + + /// A failed fetch becomes a buffered `Inapp.ShowFailure` for every candidate the pass then cut. + /// Off for a page's question: what it cut is not reported in this iteration. + let collectsFailures: Bool + + let pickVariant: (InApp) -> MindboxFormVariant? } - /// Show history is deliberately not consulted: the page already offered this in-app, and a tap - /// has to open it however many times it opened before. - func getInAppToShowById(_ id: String, - params: [String: JSONValue], - _ candidates: ConfigCandidates, - _ completion: @escaping (InAppFormData?) -> Void) { - getInAppById(id, candidates) { transitionData in - guard let transitionData = transitionData else { - completion(nil) - return + private func evaluate(_ query: TargetingQuery, + event: ApplicationEvent?, + completion: @escaping ([InAppTransitionData]) -> Void) { + let prepared = query.prepares() + prepared.forEach { targetingChecker.prepare(id: $0.id, targeting: $0.targeting) } + + // Narrowed before the fetch: its completion answers on the main queue, where frequency reads have no business. + let candidates = query.candidates(targetingChecker.context) + let startedAt = Date() + + let check = { + let suitable = self.inappFilterService.filterInappsByTargeting(inapps: candidates, + targetingChecker: self.targetingChecker, + pickVariant: query.pickVariant) + if query.collectsFailures { + self.collectTargetingFailures(among: candidates, suitable: suitable) } - guard transitionData.content.isOverlayPresentable else { - Logger.common(message: "[InappMapper] In-app \(id) is drawn inside the host layout and cannot be shown over the screen.", - level: .error, category: .inAppMessages) - completion(nil) - return - } + let ms = Int(Date().timeIntervalSince(startedAt) * 1000) + Logger.common(message: """ + [InappMapper] \(query.label): \(candidates.count) candidate(s), \(suitable.count) targeted, \ + answered in \(ms) ms. + """, level: .debug, category: .inAppMessages) - self.buildInApp(transitionData, extraParams: params, completion: completion) + completion(suitable) } - } - private func setupEnvironment(event: ApplicationEvent?) { - applicationEvent = event - targetingChecker.event = event + if query.fetchesDependencies { + dataFacade.fetchDependencies(model: event?.model, shouldCollectFailures: query.collectsFailures, check) + } else { + check() + } } - private func prepareTargetingChecker(for inapps: [InApp]) { - inapps.forEach { - targetingChecker.prepare(id: $0.id, targeting: $0.targeting) + private func collectTargetingFailures(among candidates: [InApp], suitable: [InAppTransitionData]) { + let suitableIds = Set(suitable.map(\.inAppId)) + let failedIds = Set(candidates.map(\.id)).subtracting(suitableIds) + let tagsByInappId: [String: [String: String]] = candidates.reduce(into: [:]) { result, inapp in + guard failedIds.contains(inapp.id), let tags = inapp.tags else { return } + result[inapp.id] = tags } + dataFacade.collectTargetingFailures(forFailedTargetingInappIds: failedIds, tagsByInappId: tagsByInappId) } - private func showableInapps(in candidates: ConfigCandidates) -> [InApp] { - guard let event = applicationEvent else { - return inappFilterService.filterForTrigger(in: candidates) - } + // MARK: - Queries + + private static let overlayVariant: (InApp) -> MindboxFormVariant? = { inapp in + inapp.form.variants.first(where: { $0.isOverlayPresentable }) + } - return inappFilterService.filterInappsByOperationForShow( - event: event, - operationInapps: targetingChecker.context.operationInapps, - in: candidates + private func triggerQuery(_ event: ApplicationEvent?, _ candidates: ConfigCandidates) -> TargetingQuery { + TargetingQuery( + label: "the trigger", + prepares: { candidates.renderable }, + candidates: { context in + guard let event = event else { + return self.inappFilterService.filterForTrigger(in: candidates) + } + + return self.inappFilterService.filterInappsByOperationForShow(event: event, + operationInapps: context.operationInapps, + in: candidates) + }, + fetchesDependencies: true, + collectsFailures: true, + pickVariant: Self.overlayVariant ) } - private func chooseInappToShow(_ inapps: [InApp], completion: @escaping (InAppFormData?) -> Void) { - dataFacade.fetchDependencies(model: applicationEvent?.model) { - let suitableInapps = self.inappFilterService.filterInappsByTargeting(inapps: inapps, targetingChecker: self.targetingChecker) - let suitableIds = Set(suitableInapps.map(\.inAppId)) - let failedTargetingInappIds = Set(inapps.map(\.id)).subtracting(suitableIds) - let tagsByInappId: [String: [String: String]] = inapps.reduce(into: [:]) { result, inapp in - guard failedTargetingInappIds.contains(inapp.id), let tags = inapp.tags else { return } - result[inapp.id] = tags - } - self.dataFacade.collectTargetingFailures(forFailedTargetingInappIds: failedTargetingInappIds, tagsByInappId: tagsByInappId) + /// The trigger's second question: everyone the event could have targeted, so the funnel hears + /// about the in-apps a show would never pick — the A/B pool included. + private func catchUpQuery(_ event: ApplicationEvent?, _ candidates: ConfigCandidates) -> TargetingQuery { + TargetingQuery( + label: "the targeting catch-up", + prepares: { [] }, + candidates: { context in + let listening: [InApp] + if let event = event { + listening = self.inappFilterService.filterInappsByOperation(event: event, + operationInapps: context.operationInapps, + in: candidates) + } else { + listening = candidates.renderable + } + // Not the direct-call in-apps: vouching for them here would offer every one of them on every start. + let triggerable = listening.filter { $0.displayConditions != .directCall } + // Not the pure-embedded ones either: their place resolve vouches, twice would double the funnel (in sync with Android). + return self.inappFilterService.filterOutNonOverlayInapps(triggerable) + }, + fetchesDependencies: true, + collectsFailures: false, + pickVariant: Self.overlayVariant + ) + } - if suitableInapps.isEmpty { - completion(nil) - return - } + /// Prepares everyone, like the trigger: the session's single segmentation fetch is shaped by whoever + /// asks first, and a block that waited for the config asks before the start pass does. + private func placeQuery(_ place: String, _ candidates: ConfigCandidates) -> TargetingQuery { + TargetingQuery( + label: "place '\(place)'", + prepares: { candidates.renderable }, + candidates: { _ in self.inappFilterService.filter(place: place, in: candidates) }, + fetchesDependencies: true, + collectsFailures: true, + pickVariant: { $0.form.variants.first { $0.placeSystemName == place } } + ) + } - self.buildInAppByEvent(inapps: suitableInapps) { formData in - completion(formData) - } - } + /// Everyone the place could have shown — the A/B cut and the spent frequencies in, the direct-call + /// in-apps out: the catch-up's rule for one place. + private func placeTargetingQuery(_ place: String, _ candidates: ConfigCandidates) -> TargetingQuery { + TargetingQuery( + label: "targeting at place '\(place)'", + prepares: { [] }, + candidates: { _ in + self.inappFilterService.inapps(addressedTo: place, in: candidates) + .filter { $0.displayConditions != .directCall } + }, + fetchesDependencies: false, + collectsFailures: false, + pickVariant: { $0.form.variants.first { $0.placeSystemName == place } } + ) + } + + /// Fetches like a place resolve — a cold cache may miss the page's deadline, an accepted cost. + private func pageQuery(_ ids: [String], _ candidates: ConfigCandidates) -> TargetingQuery { + TargetingQuery( + label: "a page asking about \(ids.count) in-app(s)", + prepares: { candidates.renderable }, + candidates: { _ in self.inappFilterService.filter(requestedIds: ids, in: candidates) }, + fetchesDependencies: true, + collectsFailures: false, + pickVariant: Self.overlayVariant + ) } - private func buildInAppByEvent(inapps: [InAppTransitionData], - completion: @escaping (InAppFormData?) -> Void) { + /// Everyone the page could have drawn — the A/B cut and the spent frequencies in, so an A/B test on an + /// in-app the page lists hears from both branches (in sync with Android). + private func pageTargetingQuery(_ ids: [String], _ candidates: ConfigCandidates) -> TargetingQuery { + TargetingQuery( + label: "targeting for the page's \(ids.count) in-app(s)", + prepares: { [] }, + candidates: { _ in self.inappFilterService.inapps(askedAbout: ids, in: candidates) }, + fetchesDependencies: false, + collectsFailures: false, + pickVariant: Self.overlayVariant + ) + } + + // MARK: - After the trigger's verdict + + private func buildFirstShowable(_ inapps: [InAppTransitionData], + event: ApplicationEvent?, + completion: @escaping (InAppFormData?) -> Void) { + guard !inapps.isEmpty else { + completion(nil) + return + } + var formData: InAppFormData? DispatchQueue.global().async { - let operation = self.getOperation() + let operation = Self.operation(from: event) for inapp in inapps where formData == nil { - formData = self.makeFormData(inapp, extraParams: nil, operation: operation) + formData = self.formBuilder.makeFormData(inapp, extraParams: nil, operation: operation) } DispatchQueue.main.async { [weak self] in if let id = formData?.inAppId { self?.dataFacade.trackTargeting(id: id, tags: formData?.tags) - self?.$shownInappIDWithHashValue.mutate { $0[id] = self?.getEventHashValue() } + self?.$shownInappIDWithHashValue.mutate { $0[id] = Self.eventHash(event) } } completion(formData) @@ -332,97 +424,66 @@ class InappMapper: InappMapperProtocol { } } - /// Blocking by design — callers walk a list and stop at the first buildable in-app. Must not run - /// on `processingQueue`: a download wait there would stall every targeting question behind it. - /// - /// `operation` is the caller's to name: this runs outside the pass lock, so reading the shared - /// event here could pick up a later pass's. - private func makeFormData(_ inapp: InAppTransitionData, - extraParams: [String: JSONValue]?, - operation: (name: String, body: String)?) -> InAppFormData? { - Logger.common(message: "[InappMapper] Starting in-app processing. [ID]: \(inapp.inAppId)", level: .debug, category: .inAppMessages) - - if case .modal(let modal) = inapp.content, - modal.content.background.layers.contains(where: { $0.layerType == .webview }) { - return InAppFormData(inAppId: inapp.inAppId, - isPriority: inapp.isPriority, - delayTime: inapp.delayTime, - imagesDict: [:], - firstImageValue: "", - content: inapp.content, - frequency: inapp.frequency, - tags: inapp.tags, - operation: operation, - extraParams: extraParams) - } - - let urlExtractorService = DI.injectOrFail(VariantImageUrlExtractorServiceProtocol.self) - let imageValues = urlExtractorService.extractImageURL(from: inapp.content) + private func vouchCatchUp(_ suitable: [InAppTransitionData], event: ApplicationEvent?) { + Logger.common(message: "[InappMapper] TR | Targeting catch-up for event \(event?.name ?? "start"): \(suitable.map(\.inAppId))", + level: .debug, category: .inAppMessages) - let group = DispatchGroup() - let imageDictQueue = DispatchQueue(label: "com.mindbox.imagedict.queue", attributes: .concurrent) - var imageDict: [String: UIImage] = [:] - var gotError = false + let hash = Self.eventHash(event) + for inapp in suitable where shownInappIDWithHashValue[inapp.inAppId] != hash { + dataFacade.trackTargeting(id: inapp.inAppId, tags: inapp.tags) + } + } - for imageValue in imageValues { - group.enter() - Logger.common(message: "[InappMapper] Initiating the process of image loading from the URL: \(imageValue)", level: .debug, category: .inAppMessages) - dataFacade.downloadImage(withUrl: imageValue, inappId: inapp.inAppId, tags: inapp.tags) { result in - defer { - group.leave() - } + /// Everyone the place could have shown hears its `Inapp.Targeting`: the losers once per session, + /// the winner by the place's slot — showing another in-app and coming back is a new offer. + private func vouch(_ targeted: [InAppTransitionData], winner: InAppTransitionData?, at place: String) { + for inapp in targeted { + guard inapp.inAppId == winner?.inAppId else { + vouchOncePerSession(for: [inapp]) + continue + } - switch result { - case .success(let image): - imageDictQueue.async(flags: .barrier) { - imageDict[imageValue] = image - } - case .failure: - gotError = true - } + guard SessionTemporaryStorage.shared.$ledger.mutate({ $0.vouchWinner(inapp.inAppId, at: place) }) else { + Logger.common(message: "[InappMapper] In-app \(inapp.inAppId) is still what place '\(place)' vouched for last, no second Inapp.Targeting", + level: .debug, category: .inAppMessages) + continue } - } - group.wait() - - return imageDictQueue.sync { - guard !imageDict.isEmpty, !gotError else { return nil } - - return InAppFormData(inAppId: inapp.inAppId, - isPriority: inapp.isPriority, - delayTime: inapp.delayTime, - imagesDict: imageDict, - firstImageValue: imageValues.first ?? "", - content: inapp.content, - frequency: inapp.frequency, - tags: inapp.tags, - operation: operation, - extraParams: extraParams) + dataFacade.trackTargeting(id: inapp.inAppId, tags: inapp.tags) } } - private func buildInApp(_ inapp: InAppTransitionData, - extraParams: [String: JSONValue], - completion: @escaping (InAppFormData?) -> Void) { - DispatchQueue.global().async { - let formData = self.makeFormData(inapp, extraParams: extraParams, operation: nil) + /// Once per session per block and in-app: the same in-app offered by another block is a new offer. + private func vouchOffers(_ offered: [InAppTransitionData], by blockInappId: String) { + for inapp in offered { + let offer = BlockOffer(blockInappId: blockInappId, inappId: inapp.inAppId) + guard SessionTemporaryStorage.shared.$ledger.mutate({ $0.vouchOffer(offer) }) else { continue } - DispatchQueue.main.async { - completion(formData) + dataFacade.trackTargeting(id: inapp.inAppId, tags: inapp.tags) + } + } + + /// The losers at a place: their resolves repeat without offering anything new, hence once per session. + private func vouchOncePerSession(for inapps: [InAppTransitionData]) { + for inapp in inapps { + guard SessionTemporaryStorage.shared.$ledger.mutate({ $0.vouch(inapp.inAppId) }) else { + Logger.common(message: "[InappMapper] In-app \(inapp.inAppId) was already vouched for in this session, no second Inapp.Targeting", + level: .debug, category: .inAppMessages) + continue } + + self.dataFacade.trackTargeting(id: inapp.inAppId, tags: inapp.tags) } } - private func getEventHashValue() -> Int { - return applicationEvent?.hashValue ?? InAppMessageTriggerEvent.start.hashValue + private static func eventHash(_ event: ApplicationEvent?) -> Int { + event?.hashValue ?? InAppMessageTriggerEvent.start.hashValue } - private func getOperation() -> (name: String, body: String)? { - guard let event = applicationEvent else { return nil } + private static func operation(from event: ApplicationEvent?) -> (name: String, body: String)? { + guard let event = event else { return nil } - let name = event.name let body: String - if let model = event.model, let data = try? JSONEncoder().encode(model), let jsonString = String(data: data, encoding: .utf8) { @@ -431,45 +492,20 @@ class InappMapper: InappMapperProtocol { body = "{}" } - return (name: name, body: body) + return (name: event.name, body: body) } - private func sendRemainingInappsTargeting(_ candidates: ConfigCandidates, _ completion: @escaping () -> Void) { - self.dataFacade.fetchDependencies(model: applicationEvent?.model, shouldCollectFailures: false) { - let inapps: [InApp] - if let event = self.applicationEvent { - inapps = self.inappFilterService.filterInappsByOperation( - event: event, - operationInapps: self.targetingChecker.context.operationInapps, - in: candidates - ) - } else { - inapps = candidates.renderable - } - // A direct-call in-app answers no trigger, so the catch-up does not vouch for it either — - // otherwise every start would pump the story funnel with every user. - let triggerable = inapps.filter { $0.displayConditions != .directCall } - // A pure-embedded in-app is vouched by its place resolve — speaking here too would double - // the funnel. A mixed form stays: the catch-up covers its overlay half (in sync with Android). - let catchUpCandidates = self.inappFilterService.filterOutNonOverlayInapps(triggerable) - let suitableInapps = self.inappFilterService.filterInappsByTargeting(inapps: catchUpCandidates, targetingChecker: self.targetingChecker) - - let logMessage = """ - [InappMapper] TR | Initiating processing of remaining in-app targeting requests. - Full list of in-app messages: \(candidates.renderable.map { $0.id }) - Saved event for targeting: \(self.applicationEvent?.name ?? "None") - """ - Logger.common(message: logMessage, level: .debug, category: .inAppMessages) - - for inapp in suitableInapps { - if self.shownInappIDWithHashValue[inapp.inAppId] != self.getEventHashValue(), - let inapp = candidates.renderable.first(where: { $0.id == inapp.inAppId }), - self.targetingChecker.check(targeting: inapp.targeting) { - self.dataFacade.trackTargeting(id: inapp.id, tags: inapp.tags) - } + // MARK: - Building the form + + private func buildInApp(_ inapp: InAppTransitionData, + extraParams: [String: JSONValue], + completion: @escaping (InAppFormData?) -> Void) { + DispatchQueue.global().async { + let formData = self.formBuilder.makeFormData(inapp, extraParams: extraParams, operation: nil) + + DispatchQueue.main.async { + completion(formData) } - - completion() } } } diff --git a/Mindbox/InAppMessages/InAppConfigurationMapper/Services/InAppConfigurationDataFacade.swift b/Mindbox/InAppMessages/InAppConfigurationMapper/Services/InAppConfigurationDataFacade.swift index f9b16cea5..8027f5bd2 100644 --- a/Mindbox/InAppMessages/InAppConfigurationMapper/Services/InAppConfigurationDataFacade.swift +++ b/Mindbox/InAppMessages/InAppConfigurationMapper/Services/InAppConfigurationDataFacade.swift @@ -19,6 +19,12 @@ protocol InAppConfigurationDataFacadeProtocol { func collectTargetingFailures(forFailedTargetingInappIds failedTargetingInappIds: Set, tagsByInappId: [String: [String: String]]) func downloadImage(withUrl url: String, inappId: String, tags: [String: String]?, completion: @escaping (Result) -> Void) func trackTargeting(id: String?, tags: [String: String]?) + + /// Sends what the pass buffered — targeting and image failures alike — as one `Inapp.ShowFailure`. + func sendCollectedFailures() + + /// Drops what the pass buffered: it picked something to show, so there is no "why nothing was shown". + func discardCollectedFailures() } extension InAppConfigurationDataFacadeProtocol { @@ -117,6 +123,14 @@ class InAppConfigurationDataFacade: InAppConfigurationDataFacadeProtocol { } } } + + func sendCollectedFailures() { + failureManager.sendFailures() + } + + func discardCollectedFailures() { + failureManager.clearFailures() + } } extension InAppConfigurationDataFacade { diff --git a/Mindbox/InAppMessages/InAppConfigurationMapper/Services/InappFilterService/InappFilter.swift b/Mindbox/InAppMessages/InAppConfigurationMapper/Services/InappFilterService/InappFilter.swift index c11cf228e..29fe5da5a 100644 --- a/Mindbox/InAppMessages/InAppConfigurationMapper/Services/InappFilterService/InappFilter.swift +++ b/Mindbox/InAppMessages/InAppConfigurationMapper/Services/InappFilterService/InappFilter.swift @@ -21,30 +21,35 @@ struct ConfigCandidates { static let empty = ConfigCandidates(renderable: [], inPool: []) } -/// The selection's view of the config: which in-apps are valid at all, and which of them a given -/// path — trigger, place, feed, direct call — may consider. Every path starts from the same -/// `ConfigCandidates`, built once per applied config, and narrows it by what changes at runtime. +/// The selection's view of the config: which in-apps are valid at all, and which of them each path — +/// trigger, place, page, direct call — may consider. protocol InappFilterProtocol { - /// Turns a config into the models every path narrows: the version range, the form rebuild, the - /// A/B pool. Called once per applied config — the result holds for as long as that config does. + /// The config as models — version range, form rebuild, A/B pool — built once per applied config. func candidates(from response: ConfigResponse) -> ConfigCandidates /// The trigger path's candidates: in-apps an overlay can show, minus the direct-call-only ones /// and those the frequency already spent — in priority order. func filterForTrigger(in candidates: ConfigCandidates) -> [InApp] - /// The candidates a block at `place` could show, in priority order. The trigger chain with one - /// step swapped: "is this addressed to this place" instead of "can this be shown over the screen". + /// The candidates a block at `place` could show, in priority order: the trigger chain with "addressed to + /// this place" in place of "shows over the screen". func filter(place: String, in candidates: ConfigCandidates) -> [InApp] - /// The in-apps out of `ids` a feed may draw, before targeting. The trigger chain minus the - /// direct-call cut — that one would drop exactly the in-apps a feed is made of. - func filter(feedIds ids: [String], in candidates: ConfigCandidates) -> [InApp] + /// Every valid in-app with a variant for `place`, the A/B pool and the frequency not applied — everyone + /// who could have shown here. + func inapps(addressedTo place: String, in candidates: ConfigCandidates) -> [InApp] - /// The in-app behind `id`, with no restriction checked — not the frequency, not the display - /// conditions, not the A/B pool: an in-app the page has already offered has to open, and every - /// one of those checks is a way for it to open into nothing. `nil` — no valid in-app under this id. + /// Every valid in-app out of `ids`, duplicates collapsed, the A/B pool and the frequency not applied — the + /// page's twin of `inapps(addressedTo:)`. + func inapps(askedAbout ids: [String], in candidates: ConfigCandidates) -> [InApp] + + /// The in-apps out of `ids` a page may draw, before targeting: the trigger chain minus the direct-call cut, + /// in the order asked, duplicates kept. + func filter(requestedIds ids: [String], in candidates: ConfigCandidates) -> [InApp] + + /// The in-app behind `id` with nothing checked — not the frequency, the display conditions or the A/B pool: + /// an in-app the page already offered has to open. `nil` — no valid in-app under this id. func filter(id: String, in candidates: ConfigCandidates) -> InApp? /// The valid in-apps wired to `event`'s operation, with nothing else checked — the A/B pool @@ -65,14 +70,8 @@ protocol InappFilterProtocol { operationInapps: [String: Set], in candidates: ConfigCandidates) -> [InApp] - /// The overlay path's targeting pass: keeps the targeted in-apps, each paired with its first - /// overlay-presentable variant. - func filterInappsByTargeting(inapps: [InApp], targetingChecker: InAppTargetingCheckerProtocol) -> [InAppTransitionData] - - /// The same targeting pass for callers that render something else: `pickVariant` names the - /// variant the caller is going to draw, `nil` skips the candidate. One check, however many - /// paths ask it — a feed and a trigger disagreeing about who is targeted would be a defect - /// nobody could explain. + /// The targeting pass: keeps the targeted in-apps, each paired with the variant `pickVariant` names for + /// it — `nil` skips the candidate. One check for every path. func filterInappsByTargeting(inapps: [InApp], targetingChecker: InAppTargetingCheckerProtocol, pickVariant: (InApp) -> MindboxFormVariant?) -> [InAppTransitionData] @@ -106,20 +105,32 @@ final class InappsFilterService: InappFilterProtocol { } func filter(place: String, in candidates: ConfigCandidates) -> [InApp] { - filterInappsForPlace(place, inapps: candidates.inPool) + applyPostABFilters(filterInappsByPlace(place, inapps: candidates.inPool)) + } + + func inapps(addressedTo place: String, in candidates: ConfigCandidates) -> [InApp] { + filterInappsByPlace(place, inapps: candidates.renderable) } - func filter(feedIds ids: [String], in candidates: ConfigCandidates) -> [InApp] { + func inapps(askedAbout ids: [String], in candidates: ConfigCandidates) -> [InApp] { + var seen = Set() + return ids.compactMap { id in + guard seen.insert(id).inserted else { return nil } + return candidates.renderable.first { $0.id == id } + } + } + + func filter(requestedIds ids: [String], in candidates: ConfigCandidates) -> [InApp] { let asked = Set(ids) let missing = asked.subtracting(candidates.renderable.map(\.id)) if !missing.isEmpty { - Logger.common(message: "[InappsFilterService] The feed asked about in-app(s) this SDK cannot render: [\(missing.sorted().joined(separator: ", "))]", + Logger.common(message: "[InappsFilterService] The page asked about in-app(s) this SDK cannot render: [\(missing.sorted().joined(separator: ", "))]", level: .debug, category: .inAppMessages) } - let requested = candidates.inPool.filter { asked.contains($0.id) } - return applyShowabilityFilters(filterOutNonOverlayInapps(requested)) + let requested = ids.compactMap { id in candidates.inPool.first { $0.id == id } } + return filterInappsByAlreadyShown(filterOutNonOverlayInapps(requested)) } func filter(id: String, in candidates: ConfigCandidates) -> InApp? { @@ -171,12 +182,6 @@ final class InappsFilterService: InappFilterProtocol { return inapps.filter { inappIDS.contains($0.id) } } - func filterInappsByTargeting(inapps: [InApp], targetingChecker: InAppTargetingCheckerProtocol) -> [InAppTransitionData] { - filterInappsByTargeting(inapps: inapps, targetingChecker: targetingChecker) { inapp in - inapp.form.variants.first(where: { $0.isOverlayPresentable }) - } - } - func filterInappsByTargeting(inapps: [InApp], targetingChecker: InAppTargetingCheckerProtocol, pickVariant: (InApp) -> MindboxFormVariant?) -> [InAppTransitionData] { @@ -312,6 +317,7 @@ extension InappsFilterService { displayConditions: inapp.displayConditions, form: formModel, tags: inapp.tags) + warnIfNoPassCanReach(inappModel) filteredInapps.append(inappModel) } } catch { @@ -323,22 +329,23 @@ extension InappsFilterService { return filteredInapps } + /// An event-only targeting on a direct-call in-app can never pass — every pass cuts direct-call in-apps first, + /// and a page's question carries no event. Almost certainly a config mistake (in sync with Android). + private func warnIfNoPassCanReach(_ inapp: InApp) { + guard inapp.displayConditions == .directCall, inapp.targeting.requiresEvent else { return } + + Logger.common(message: "[InappsFilterService] In-app \(inapp.id) is direct-call only but targeted by an event: no pass will show it or vouch for it, only a direct call opens it. Check the campaign.", + level: .error, category: .inAppMessages) + } + private func createFrequencyValidator() -> InappFrequencyValidator { InappFrequencyValidator(persistenceStorage: persistenceStorage) } /// The overlay lock and the delayed queue are never asked here; the shared show budgets are /// asked later, on the winner. - func filterInappsForPlace(_ place: String, inapps: [InApp]) -> [InApp] { - applyPostABFilters(filterInappsByPlace(place, inapps: inapps)) - } - private func applyPostABFilters(_ inapps: [InApp]) -> [InApp] { - applyShowabilityFilters(filterOutDirectCallInapps(inapps)) - } - - private func applyShowabilityFilters(_ inapps: [InApp]) -> [InApp] { - sortInappsByPriority(filterInappsByAlreadyShown(inapps)) + sortInappsByPriority(filterInappsByAlreadyShown(filterOutDirectCallInapps(inapps))) } func filterInappsByPlace(_ place: String, inapps: [InApp]) -> [InApp] { diff --git a/Mindbox/InAppMessages/InAppConfigurationMapper/Services/InappFilterService/VariantsFilter.swift b/Mindbox/InAppMessages/InAppConfigurationMapper/Services/InappFilterService/VariantsFilter.swift index 5fa2e159e..32482932f 100644 --- a/Mindbox/InAppMessages/InAppConfigurationMapper/Services/InappFilterService/VariantsFilter.swift +++ b/Mindbox/InAppMessages/InAppConfigurationMapper/Services/InappFilterService/VariantsFilter.swift @@ -80,9 +80,8 @@ final class VariantFilterService: VariantFilterProtocol { } private func makeEmbeddedVariant(from dto: EmbeddedFormVariantDTO) -> EmbeddedFormVariant? { - // The name is taken as it is: padding and case are part of it, and the block asks by the - // very same string. Only an empty name is no name. - let placeSystemName = dto.placeSystemName ?? "" + // A place name padded with spaces in the admin panel still means the same place; case has to match. + let placeSystemName = dto.placeSystemName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !placeSystemName.isEmpty else { Logger.common(message: "[EmbeddedVariant] Variant has no place system name. Variant will be skipped.", diff --git a/Mindbox/InAppMessages/InAppCoreManager.swift b/Mindbox/InAppMessages/InAppCoreManager.swift index 684710b65..459580954 100644 --- a/Mindbox/InAppMessages/InAppCoreManager.swift +++ b/Mindbox/InAppMessages/InAppCoreManager.swift @@ -148,7 +148,7 @@ final class InAppCoreManager: InAppCoreManagerProtocol { } let triggerTimestamp = CACurrentMediaTime() - + self.configManager.handleInapps(event: event.applicationEvent) { inapp in let processingDuration = CACurrentMediaTime() - triggerTimestamp self.onReceivedInAppResponse(inapp: inapp, processingDuration: processingDuration) { @@ -164,10 +164,8 @@ final class InAppCoreManager: InAppCoreManagerProtocol { } private func onReceivedInAppResponse(inapp: InAppFormData?, processingDuration: TimeInterval, completion: @escaping () -> Void) { - let failureManager = DI.injectOrFail(InappShowFailureManagerProtocol.self) guard let inapp = inapp else { Logger.common(message: "No in-app messages to show", level: .info, category: .inAppMessages) - failureManager.sendFailures() completion() return } diff --git a/Mindbox/InAppMessages/InAppTargetingChecker/TargetingCheckerTypes/CustomOperationChecker.swift b/Mindbox/InAppMessages/InAppTargetingChecker/TargetingCheckerTypes/CustomOperationChecker.swift index 3e7fd2867..857f918a9 100644 --- a/Mindbox/InAppMessages/InAppTargetingChecker/TargetingCheckerTypes/CustomOperationChecker.swift +++ b/Mindbox/InAppMessages/InAppTargetingChecker/TargetingCheckerTypes/CustomOperationChecker.swift @@ -13,10 +13,8 @@ final class CustomOperationChecker: InternalTargetingChecker Bool { diff --git a/Mindbox/InAppMessages/InappScheduleManager.swift b/Mindbox/InAppMessages/InappScheduleManager.swift index 0364e1a7f..1a8de8a4d 100644 --- a/Mindbox/InAppMessages/InappScheduleManager.swift +++ b/Mindbox/InAppMessages/InappScheduleManager.swift @@ -23,15 +23,15 @@ protocol InappScheduleManagerProtocol { /// Past the queue and every limit — a direct call is invited, and a tap that does nothing is a /// defect. Only `Inapp.Show` goes out: targeting was sent when the selection offered the in-app. - func showInAppNow(_ inAppFormData: InAppFormData) + /// `processingDuration` is the caller's time since the tap; it counts into `timeToDisplay` like the overlay pass's. + func showInAppNow(_ inAppFormData: InAppFormData, processingDuration: TimeInterval) } final class InappScheduleManager: InappScheduleManagerProtocol { let presentationManager: InAppPresentationManagerProtocol let presentationValidator: InAppPresentationValidatorProtocol - let trackingService: InAppTrackingServiceProtocol - let tracker: InAppMessagesTrackerProtocol + let accountant: InappShowAccounting let failureManager: InappShowFailureManagerProtocol let queue = DispatchQueue(label: "com.Mindbox.delayedInAppManager", qos: .userInitiated) @@ -39,13 +39,11 @@ final class InappScheduleManager: InappScheduleManagerProtocol { init(presentationManager: InAppPresentationManagerProtocol, presentationValidator: InAppPresentationValidatorProtocol, - trackingService: InAppTrackingServiceProtocol, - tracker: InAppMessagesTrackerProtocol, + accountant: InappShowAccounting, failureManager: InappShowFailureManagerProtocol) { self.presentationManager = presentationManager self.presentationValidator = presentationValidator - self.trackingService = trackingService - self.tracker = tracker + self.accountant = accountant self.failureManager = failureManager addObserver() } @@ -57,7 +55,7 @@ final class InappScheduleManager: InappScheduleManagerProtocol { weak var delegate: InAppMessagesDelegate? func scheduleInApp(_ inapp: InAppFormData, processingDuration: TimeInterval) { - let delay = getDelay(inapp.delayTime) + let delay = TimeInterval.delay(fromTimeSpan: inapp.delayTime) let presentationTime = Date().addingTimeInterval(delay).timeIntervalSince1970 let timer = DispatchSource.makeTimerSource(flags: .strict, queue: queue) @@ -82,14 +80,14 @@ final class InappScheduleManager: InappScheduleManagerProtocol { } } - func showInAppNow(_ inapp: InAppFormData) { + func showInAppNow(_ inapp: InAppFormData, processingDuration: TimeInterval) { DispatchQueue.main.async { // Dismissal completes the closed show on the next main-queue turn; presenting is deferred // behind it so the lock is released before the new show takes it. self.presentationManager.dismissActiveInApp() DispatchQueue.main.async { - self.presentRequestedInapp(inapp) + self.presentRequestedInapp(inapp, processingDuration: processingDuration) } } } @@ -118,49 +116,27 @@ internal extension InappScheduleManager { scheduledInapp.timer.cancel() } - self.failureManager.clearFailures() + // Gone whether it showed or not: a moment missed behind another in-app is missed, by decision — no queue, no re-arm. self.inappsByPresentationTime.removeValue(forKey: presentationTime) } } - private func trackShow(_ inapp: InAppFormData, timeToDisplay: String) { - do { - try tracker.trackView(id: inapp.inAppId, timeToDisplay: timeToDisplay, tags: inapp.tags) - } catch { - Logger.common(message: "[InappScheduleManager] Track InApp.View failed with error: \(error)", level: .error, category: .notification) - } - - guard InappFrequency.countsShows(inapp.frequency) else { return } - - trackingService.trackInAppShown(id: inapp.inAppId) - trackingService.saveInappStateChange() + private func trackShow(_ inapp: InAppFormData, timeToDisplay: TimeInterval) { + accountant.recordShow(InappShow(inAppId: inapp.inAppId, + frequency: inapp.frequency, + tags: inapp.tags, + timeToDisplay: timeToDisplay)) } /// The cooldown is written a second time on dismissal, so that an app killed while the in-app was /// on screen still leaves the interval counted from a real moment. private func trackDismissal(_ inapp: InAppFormData) { - guard InappFrequency.countsShows(inapp.frequency) else { return } - - trackingService.saveInappStateChange() + accountant.recordCooldown(frequency: inapp.frequency) } - private func presentRequestedInapp(_ inapp: InAppFormData) { + private func presentRequestedInapp(_ inapp: InAppFormData, processingDuration: TimeInterval) { Logger.common(message: "[InappScheduleManager] Showing \(inapp.inAppId) on request, past the queue and its limits") - - let stopwatch = ForegroundStopwatch() - present( - inapp, - onPresented: { - let presentationTime = stopwatch.elapsed - stopwatch.stop() - let timeToDisplayString = presentationTime.toTimeSpan() - Logger.common(message: "[InAppMetric] inappId=\(inapp.inAppId) presentationTime=\(timeToDisplayString) timeToDisplay=\(timeToDisplayString)") - self.trackShow(inapp, timeToDisplay: timeToDisplayString) - }, - onDismissed: { - self.trackDismissal(inapp) - } - ) + presentInapp(inapp, stopwatch: ForegroundStopwatch(), processingDuration: processingDuration) } func presentInapp(_ inapp: InAppFormData, stopwatch: ForegroundStopwatch, processingDuration: TimeInterval = 0) { @@ -170,10 +146,9 @@ internal extension InappScheduleManager { let presentationTime = stopwatch.elapsed stopwatch.stop() let timeToDisplay = processingDuration + presentationTime - let timeToDisplayString = timeToDisplay.toTimeSpan() - Logger.common(message: "[InAppMetric] inappId=\(inapp.inAppId) processingTime=\(processingDuration.toTimeSpan()) presentationTime=\(presentationTime.toTimeSpan()) timeToDisplay=\(timeToDisplayString)") - self.trackShow(inapp, timeToDisplay: timeToDisplayString) - self.failureManager.clearFailures() + Logger.common(message: "[InAppMetric] inappId=\(inapp.inAppId) processingTime=\(processingDuration.toTimeSpan()) " + + "presentationTime=\(presentationTime.toTimeSpan()) timeToDisplay=\(timeToDisplay.toTimeSpan())") + self.trackShow(inapp, timeToDisplay: timeToDisplay) }, onDismissed: { self.trackDismissal(inapp) @@ -223,12 +198,6 @@ internal extension InappScheduleManager { ) } - func getDelay(_ time: String?) -> TimeInterval { - let delayTimeStr = time - let delayMilis = (try? delayTimeStr?.parseTimeSpanToMillis()) ?? 0 - return TimeInterval(delayMilis) / 1000 - } - func addObserver() { NotificationCenter.default.addObserver( forName: UIApplication.willEnterForegroundNotification, diff --git a/Mindbox/InAppMessages/InappShowAccountant.swift b/Mindbox/InAppMessages/InappShowAccountant.swift new file mode 100644 index 000000000..d13b910d7 --- /dev/null +++ b/Mindbox/InAppMessages/InappShowAccountant.swift @@ -0,0 +1,72 @@ +// +// InappShowAccountant.swift +// Mindbox +// +// Created by Sergei Semko on 25.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// A show as the accounting sees it — the same for an overlay window and a block page. +struct InappShow { + let inAppId: String + let frequency: InappFrequency? + let tags: [String: String]? + let timeToDisplay: TimeInterval +} + +protocol InappShowAccounting: AnyObject { + + /// `Inapp.Show` to the backend, then the local show history and the cooldown when the frequency counts shows. + func recordShow(_ show: InappShow) + + /// The moment `minIntervalBetweenShows` counts from — written when the frequency counts shows. + func recordCooldown(frequency: InappFrequency?) + + /// A block's show counts when its place shows a different in-app than it showed last: 1 → 2 → 1 in + /// one session is three shows, the same in-app again — a rebuilt page, a rotation — is none. + func recordBlockShow(_ show: InappShow, at place: String) +} + +final class InappShowAccountant: InappShowAccounting { + + private let tracker: InAppMessagesTrackerProtocol + private let trackingService: InAppTrackingServiceProtocol + + init(tracker: InAppMessagesTrackerProtocol, trackingService: InAppTrackingServiceProtocol) { + self.tracker = tracker + self.trackingService = trackingService + } + + func recordShow(_ show: InappShow) { + do { + try tracker.trackView(id: show.inAppId, timeToDisplay: show.timeToDisplay.toTimeSpan(), tags: show.tags) + } catch { + Logger.common(message: "[InappShowAccountant] Inapp.Show for \(show.inAppId) was not queued: \(error)", + level: .error, category: .inAppMessages) + } + + guard InappFrequency.countsShows(show.frequency) else { return } + + trackingService.trackInAppShown(id: show.inAppId) + trackingService.saveInappStateChange() + } + + func recordCooldown(frequency: InappFrequency?) { + guard InappFrequency.countsShows(frequency) else { return } + + trackingService.saveInappStateChange() + } + + func recordBlockShow(_ show: InappShow, at place: String) { + guard SessionTemporaryStorage.shared.$ledger.mutate({ $0.recordShow(show.inAppId, at: place) }) else { + Logger.common(message: "[InappShowAccountant] Place '\(place)' shows in-app \(show.inAppId) again — nothing new to account for", + level: .debug, category: .inAppMessages) + return + } + + recordShow(show) + } +} diff --git a/Mindbox/InAppMessages/InappShowFailureManager.swift b/Mindbox/InAppMessages/InappShowFailureManager.swift index 17f05e352..cddeb4947 100644 --- a/Mindbox/InAppMessages/InappShowFailureManager.swift +++ b/Mindbox/InAppMessages/InappShowFailureManager.swift @@ -11,8 +11,15 @@ import MindboxLogger protocol InappShowFailureManagerProtocol { func addFailure(inappId: String, reason: InAppShowFailureReason, details: String?, tags: [String: String]?) - func clearFailures() + + /// The buffer answers "why was nothing shown": a pass that picked nothing sends it, a pass that + /// picked something drops it (in sync with Android). func sendFailures() + func clearFailures() + + /// The SDK never answered a block within its wait budget — a failure with no in-app to pin it on, so + /// it names the place instead. Sent at once, past the buffer, like the other block failures. + func sendWaitBudgetExceeded(place: String, waited: TimeInterval, phase: EmbeddedBlockShowFailure.Phase) /// Sends one failure at once, without joining the buffer the selection pass fills. /// @@ -27,8 +34,8 @@ final class InappShowFailureManager: InappShowFailureManagerProtocol { /// Backend payload limit for errorDetails. static let errorDetailsLimit = 1000 - private struct InAppShowFailuresBody: Codable { - let failures: [InAppShowFailure] + private struct InAppShowErrorsBody: Encodable { + let errors: [InAppShowError] } private let databaseRepository: DatabaseRepositoryProtocol @@ -84,7 +91,7 @@ final class InappShowFailureManager: InappShowFailureManagerProtocol { tags: featureToggleManager.gatedTags(tags)) queue.async { [self] in - guard enqueue([failure]) else { return } + guard enqueue([.inapp(failure)]) else { return } Logger.common(message: "[InappShowFailureManager] Inapp.ShowFailure event sent at once. inappId=\(inappId), reason=\(reason.rawValue)", category: .inAppMessages) @@ -106,8 +113,8 @@ final class InappShowFailureManager: InappShowFailureManagerProtocol { } /// Must be called on `queue`. - private func enqueue(_ failures: [InAppShowFailure]) -> Bool { - let eventBody = InAppShowFailuresBody(failures: failures) + private func enqueue(_ errors: [InAppShowError]) -> Bool { + let eventBody = InAppShowErrorsBody(errors: errors) let event = Event(type: .inAppShowFailureEvent, body: BodyEncoder(encodable: eventBody).body) do { @@ -123,12 +130,6 @@ final class InappShowFailureManager: InappShowFailureManagerProtocol { } } - func clearFailures() { - queue.async { [self] in - failures.removeAll() - } - } - func sendFailures() { guard featureToggleManager.isFeatureEnabled(.shouldSendInAppShowError) else { Logger.common( @@ -140,7 +141,7 @@ final class InappShowFailureManager: InappShowFailureManagerProtocol { } queue.async { [self] in - guard !failures.isEmpty, enqueue(failures) else { + guard !failures.isEmpty, enqueue(failures.map(InAppShowError.inapp)) else { return } @@ -150,6 +151,35 @@ final class InappShowFailureManager: InappShowFailureManagerProtocol { } } + func clearFailures() { + queue.async { [self] in + guard !failures.isEmpty else { return } + + Logger.common(message: "[InappShowFailureManager] Dropping \(failures.count) buffered failure(s): the pass showed something", + level: .debug, category: .inAppMessages) + failures.removeAll() + } + } + + func sendWaitBudgetExceeded(place: String, waited: TimeInterval, phase: EmbeddedBlockShowFailure.Phase) { + guard featureToggleManager.isFeatureEnabled(.shouldSendInAppShowError) else { + Logger.common(message: "[InappShowFailureManager] sendWaitBudgetExceeded ignored, feature is disabled", category: .inAppMessages) + return + } + + let failure = EmbeddedBlockShowFailure(placeSystemName: place, + waited: waited, + phase: phase, + dateTimeUtc: Date().toString(withFormat: .utc)) + + queue.async { [self] in + guard enqueue([.embeddedBlock(failure)]) else { return } + + Logger.common(message: "[InappShowFailureManager] Inapp.ShowFailure event sent for place '\(place)': the SDK stayed silent for \(waited.toTimeSpan()) (\(phase.rawValue))", + category: .inAppMessages) + } + } + private func makeFailure(inappId: String, reason: InAppShowFailureReason, details: String?, tags: [String: String]?) -> InAppShowFailure { InAppShowFailure( inappId: inappId, diff --git a/Mindbox/InAppMessages/Models/InAppShowFailure.swift b/Mindbox/InAppMessages/Models/InAppShowFailure.swift index 5fe21e3cd..eb76eb32f 100644 --- a/Mindbox/InAppMessages/Models/InAppShowFailure.swift +++ b/Mindbox/InAppMessages/Models/InAppShowFailure.swift @@ -16,9 +16,37 @@ enum InAppShowFailureReason: String, Codable { case presentationFailed = "presentation_failed" case webviewLoadFailed = "webview_load_failed" case webviewPresentationFailed = "webview_presentation_failed" + case waitBudgetExceeded = "wait_budget_exceeded" case unknownError = "unknown_error" } +/// One element of `Inapp.ShowFailure.errors`, flat, `$type` naming the kind and so the fields that follow — +/// in sync with Android and the server. A new kind of error is a new `$type`, never a change to an existing one. +enum InAppShowError: Encodable { + case inapp(InAppShowFailure) + case embeddedBlock(EmbeddedBlockShowFailure) + + private enum CodingKeys: String, CodingKey { + case type = "$type" + } + + func encode(to encoder: Encoder) throws { + // `$type` merges with the failure's own keys only while both encode into a keyed + // container on the same encoder; an unkeyed or single-value encoder in the failure + // would silently drop the tag. + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .inapp(let failure): + try container.encode("inappShowFailure", forKey: .type) + try failure.encode(to: encoder) + case .embeddedBlock(let failure): + try container.encode("embeddedBlockShowFailure", forKey: .type) + try failure.encode(to: encoder) + } + } +} + struct InAppShowFailure: Codable { let inappId: String let failureReason: InAppShowFailureReason @@ -26,3 +54,25 @@ struct InAppShowFailure: Codable { let dateTimeUtc: String let tags: [String: String]? } + +/// The SDK stayed silent for a block's whole wait budget: there is no in-app to name, so the place is +/// named, and `errorDetails` says what the SDK was still busy with and how long the block waited. +struct EmbeddedBlockShowFailure: Encodable { + + enum Phase: String { + case configMissing = "config_missing" + case resolvePending = "resolve_pending" + } + + let placeSystemName: String + let failureReason: InAppShowFailureReason + let errorDetails: String? + let dateTimeUtc: String + + init(placeSystemName: String, waited: TimeInterval, phase: Phase, dateTimeUtc: String) { + self.placeSystemName = placeSystemName + self.failureReason = .waitBudgetExceeded + self.errorDetails = "phase=\(phase.rawValue); waited=\(waited.toTimeSpan())" + self.dateTimeUtc = dateTimeUtc + } +} diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift index 896f6e5c6..9b053be52 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/BridgeMessage.swift @@ -548,14 +548,14 @@ public struct BridgeMessage: Codable { /// ``` case navigationIntercepted - // MARK: JS → Native: Feeds + // MARK: JS → Native: Embedded pages // - // Bridge payloads say `inappId`/`inappIds` throughout, the start payload included. Feed list - // entries carry whatever the config spells — forwarded untouched, never corrected. + // Bridge payloads say `inappId`/`inappIds` throughout, the start payload included. The ids a page + // lists carry whatever the config spells — forwarded untouched, never corrected. /// JS asks which of these in-apps are showable — the page draws only those. /// - /// The page gives up after 3 seconds and renders an empty feed; the SDK does not race that + /// The page gives up after 3 seconds and renders an empty list; the SDK does not race that /// deadline — it answers when the selection answers. /// /// - Payload: @@ -583,9 +583,9 @@ public struct BridgeMessage: Codable { /// ``` case contentRendered - /// JS asks to show one in-app from the feed, by id. + /// JS asks to show one in-app from a block's page, by id. /// - /// `sourceInappId` is the feed the tap came from. `params` is forwarded untouched and merged + /// `sourceInappId` is the block's in-app the tap came from. `params` is forwarded untouched and merged /// flat into the shown in-app's start payload, where an incoming key overwrites. /// /// - Payload: @@ -598,7 +598,7 @@ public struct BridgeMessage: Codable { /// ``` case showInApp - // MARK: Native → JS: Feeds + // MARK: Native → JS: Embedded pages /// SDK hands JS fresh initialization data, without reloading the page. /// @@ -686,7 +686,7 @@ public struct BridgeMessage: Codable { case .navigationIntercepted: return false - // Native → JS: Feeds and storage + // Native → JS: Embedded pages and storage case .initDataUpdated, .localStateChanged: return false } diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/FilterShowableInappsActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/FilterShowableInappsActionHandler.swift index aff26688e..10f599ef6 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/FilterShowableInappsActionHandler.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/FilterShowableInappsActionHandler.swift @@ -32,16 +32,16 @@ final class FilterShowableInappsActionHandler: WebBridgeActionHandler { category: host.logCategory) } - guard let feedHost = host as? WebBridgeFeedHosting else { - // Left unanswered, not refused: feedless surfaces may conform later, and a refusal + guard let inappHost = host as? WebBridgeInappRequestHosting else { + // Left unanswered, not refused: surfaces without an in-app service may conform later, and a refusal // would have to be unlearned by every page that starts relying on it. - Logger.common(message: "[WebView] Bridge: filterShowableInapps from '\(host.contentId)' has no feed to ask here, ignoring", + Logger.common(message: "[WebView] Bridge: filterShowableInapps from '\(host.contentId)' has no in-app service to ask here, ignoring", level: .error, category: host.logCategory) return } - feedHost.bridgeDidAskShowableInapps(ids) { [weak host] allowed in + inappHost.bridgeDidAskShowableInapps(ids) { [weak host] allowed in host?.respond(to: message, payload: .object(["inappIds": .array(allowed.map { .string($0) })])) } } diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ShowInAppActionHandler.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ShowInAppActionHandler.swift index 033413816..8beed5a6e 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ShowInAppActionHandler.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/ShowInAppActionHandler.swift @@ -23,10 +23,10 @@ final class ShowInAppActionHandler: WebBridgeActionHandler { return } - guard let feedHost = host as? WebBridgeFeedHosting else { - // Journalled and dropped, not refused: feedless surfaces may conform later, and + guard let inappHost = host as? WebBridgeInappRequestHosting else { + // Journalled and dropped, not refused: surfaces without an in-app service may conform later, and // pages must not have learned that this errors here. - Logger.common(message: "[WebView] Bridge: showInApp from '\(host.contentId)' has no feed to serve it here, ignoring", + Logger.common(message: "[WebView] Bridge: showInApp from '\(host.contentId)' has no in-app service to serve it here, ignoring", level: .error, category: host.logCategory) return @@ -50,7 +50,7 @@ final class ShowInAppActionHandler: WebBridgeActionHandler { level: .info, category: host.logCategory) - feedHost.bridgeDidRequestShowInApp(id: inAppId, params: params) + inappHost.bridgeDidRequestShowInApp(id: inAppId, params: params) host.respondSuccess(to: message) } } diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift index 8ba5fa4e3..9f954ca57 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Bridge/Handlers/WebBridgeHost.swift @@ -137,7 +137,7 @@ protocol WebBridgeContentHosting: AnyObject { func bridgeDidReportUnreadableContent() } -protocol WebBridgeFeedHosting: AnyObject { +protocol WebBridgeInappRequestHosting: AnyObject { /// Which of `ids` are showable. Answered asynchronously and possibly never: a host that /// stopped listening drops the question, and what a missing answer means is the page's call. diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/Prewarm/InAppWebViewPrewarmPlanner.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/Prewarm/InAppWebViewPrewarmPlanner.swift index d20e64bf3..abfcae955 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/Prewarm/InAppWebViewPrewarmPlanner.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/Prewarm/InAppWebViewPrewarmPlanner.swift @@ -85,7 +85,7 @@ enum InAppWebViewPrewarmPlanner { case .modal(let modal): layers = modal.content?.background?.layers case .snackbar(let snackbar): layers = snackbar.content?.background?.layers // Deliberate: prewarm assumes a page that recognises it and boots tracker-only, - // and the feed page has not been checked against that contract. + // and the block page has not been checked against that contract. case .embedded: layers = nil case .unknown: layers = nil } diff --git a/Mindbox/InAppMessages/Presentation/Views/WebView/WebViewController.swift b/Mindbox/InAppMessages/Presentation/Views/WebView/WebViewController.swift index 261334631..75e885dc3 100644 --- a/Mindbox/InAppMessages/Presentation/Views/WebView/WebViewController.swift +++ b/Mindbox/InAppMessages/Presentation/Views/WebView/WebViewController.swift @@ -48,6 +48,9 @@ final class WebViewController: UIViewController, InappViewControllerProtocol { private enum Constants { static let defaultAlphaBackgroundColor: CGFloat = 0.0 + + /// Short on purpose: the page is already drawn at `init`; the fade only delays what the user sees. + static let revealDuration: TimeInterval = 0.15 } private var transparentWebView: TransparentView? @@ -262,7 +265,7 @@ extension WebViewController: WebViewAction { DispatchQueue.main.async { if let window = self.windowProvider() { window.isUserInteractionEnabled = true - UIView.animate(withDuration: 0.3) { + UIView.animate(withDuration: Constants.revealDuration) { window.alpha = 1.0 } window.makeKeyAndVisible() diff --git a/Mindbox/Utilities/InappSessionLedger.swift b/Mindbox/Utilities/InappSessionLedger.swift new file mode 100644 index 000000000..6444edc8b --- /dev/null +++ b/Mindbox/Utilities/InappSessionLedger.swift @@ -0,0 +1,79 @@ +// +// InappSessionLedger.swift +// Mindbox +// +// Created by Sergei Semko on 26.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// One in-app a block's page was allowed to draw — what `Inapp.Targeting` for a page's question is +/// deduplicated by: once per session, per block and in-app. +struct BlockOffer: Hashable { + let blockInappId: String + let inappId: String +} + +/// A `delayTime` that ran out for an in-app at a place: a block coming back to the screen gets that +/// content at once instead of waiting again. +struct ServedPlaceDelay: Hashable { + let place: String + let inappId: String +} + +/// What this session already told the funnel and served to places, kept so nothing is repeated. +/// Reset as one with the session. +struct InappSessionLedger: Equatable { + + /// In-apps vouched for once per session — the losers at a place. + var vouchedInappIds: Set = [] + + /// The in-app each place last vouched for as its winner: its `Inapp.Targeting` pairs with the show, + /// so it goes out again when the place changes what it shows and then changes back. + var placeTargetedInappId: [String: String] = [:] + + var vouchedBlockOffers: Set = [] + + /// Places whose block already reported that the SDK never answered — once per place per session. + var placesReportedUnanswered: Set = [] + + var servedPlaceDelays: Set = [] + + /// The in-app each place showed last — a block's show is accounted when this changes. + var placeShownInappId: [String: String] = [:] +} + +// Ask-and-record in one step, each meant to run inside a single `$ledger.mutate`: callers live on +// different queues, and a check split from its write can straddle the session reset. +extension InappSessionLedger { + + /// True when the place's slot moves to this in-app; the winner is vouched for along the way. + mutating func vouchWinner(_ inappId: String, at place: String) -> Bool { + guard placeTargetedInappId[place] != inappId else { return false } + + placeTargetedInappId[place] = inappId + vouchedInappIds.insert(inappId) + return true + } + + mutating func vouch(_ inappId: String) -> Bool { + vouchedInappIds.insert(inappId).inserted + } + + mutating func vouchOffer(_ offer: BlockOffer) -> Bool { + vouchedBlockOffers.insert(offer).inserted + } + + /// True when the place shows something other than what it showed last. + mutating func recordShow(_ inappId: String, at place: String) -> Bool { + guard placeShownInappId[place] != inappId else { return false } + + placeShownInappId[place] = inappId + return true + } + + mutating func recordUnanswered(_ place: String) -> Bool { + placesReportedUnanswered.insert(place).inserted + } +} diff --git a/Mindbox/Utilities/SessionTemporaryStorage.swift b/Mindbox/Utilities/SessionTemporaryStorage.swift index a97388d1e..76ec91df2 100644 --- a/Mindbox/Utilities/SessionTemporaryStorage.swift +++ b/Mindbox/Utilities/SessionTemporaryStorage.swift @@ -35,11 +35,7 @@ final class SessionTemporaryStorage { @Locked var lastInappClickedID: String? - @Locked var vouchedInappIds: Set = [] - - /// `Inapp.Show` dedup for blocks, in sync with Android down to the name: a rebuilt page re-draws - /// what the user saw. Local show history deliberately stays per rendered page on both platforms. - @Locked var blockShowsReportedInSession: Set = [] + @Locked var ledger = InappSessionLedger() /// Last track-visit data (source and requestUrl only) @Locked var lastTrackVisit: (source: TrackVisitSource?, requestUrl: String?)? @@ -65,8 +61,7 @@ final class SessionTemporaryStorage { sessionShownInApps = [] isUserVisitSaved = false lastInappClickedID = nil - vouchedInappIds = [] - blockShowsReportedInSession = [] + ledger = InappSessionLedger() lastTrackVisit = nil inAppSettings = nil configSessionExpirationTime = nil diff --git a/Mindbox/Utilities/TimeInterval+TimeSpan.swift b/Mindbox/Utilities/TimeInterval+TimeSpan.swift index 5c6a50477..46497437f 100644 --- a/Mindbox/Utilities/TimeInterval+TimeSpan.swift +++ b/Mindbox/Utilities/TimeInterval+TimeSpan.swift @@ -8,6 +8,11 @@ import Foundation extension TimeInterval { + static func delay(fromTimeSpan timeSpan: String?) -> TimeInterval { + let millis = (try? timeSpan?.parseTimeSpanToMillis()) ?? 0 + return TimeInterval(millis) / 1000 + } + func toTimeSpan() -> String { let total = abs(self) let days = Int(total / 86400) @@ -21,6 +26,6 @@ extension TimeInterval { if days > 0 { return String(format: "%@%d.%02d:%02d:%02d.%@", prefix, days, hours, minutes, seconds, String(fractionDigits)) } - return String(format: "%@%d:%02d:%02d.%@", prefix, hours, minutes, seconds, String(fractionDigits)) + return String(format: "%@%02d:%02d:%02d.%@", prefix, hours, minutes, seconds, String(fractionDigits)) } } diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift index e28a7120e..81c38ff92 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift @@ -31,8 +31,9 @@ struct EmbeddedBlockContentProviderFactoryTests { notificationCenter: NotificationCenter(), fetchEmbeddedPlaces: { $0(nil) }) let factory = EmbeddedBlockContentProviderFactory(registry: registry, - feed: EmbeddedBlockFeedServiceMock(), - failureManager: InappShowFailureManagerMock()) + inappService: EmbeddedBlockInappServiceMock(), + failureManager: InappShowFailureManagerMock(), + accounting: InappShowAccountingMock()) let provider = factory.makeProvider(placeSystemName: "factory-shared-registry") withExtendedLifetime(provider) { @@ -64,6 +65,35 @@ struct EmbeddedBlockContentProviderFactoryTests { #expect(manager.sentAtOnce.first?.tags == ["campaign": "stories"]) } + @Test("A block's unanswered wait names the place, how long it waited and what the SDK was still missing", + arguments: [(hasConfig: false, phase: EmbeddedBlockShowFailure.Phase.configMissing), + (hasConfig: true, phase: .resolvePending)]) + func unansweredWaitNamesThePlaceAndThePhase(hasConfig: Bool, phase: EmbeddedBlockShowFailure.Phase) { + let manager = InappShowFailureManagerMock() + let inappService = EmbeddedBlockInappServiceMock() + inappService.hasConfig = hasConfig + let registry = EmbeddedBlockPlaceRegistry(resolver: EmbeddedBlockResolverMock(resolution: .empty), + notificationCenter: NotificationCenter(), + fetchEmbeddedPlaces: { $0(nil) }) + let factory = EmbeddedBlockContentProviderFactory(registry: registry, + inappService: inappService, + failureManager: manager, + accounting: InappShowAccountingMock()) + + SessionTemporaryStorage.shared.$ledger.mutate { $0.placesReportedUnanswered = [] } + let place = "factory-silent-place" + let provider = factory.makeProvider(placeSystemName: place) + withExtendedLifetime(provider) { + provider.reportAnswerTimedOut(waited: 30) + } + + #expect(manager.waitBudgetExceeded.count == 1) + #expect(manager.waitBudgetExceeded.first?.place == place) + #expect(manager.waitBudgetExceeded.first?.waited == 30) + #expect(manager.waitBudgetExceeded.first?.phase == phase) + #expect(manager.sentAtOnce.isEmpty) + } + // MARK: - Helpers /// The resolver answers "empty": no page is created for such a block, so the factory's tests @@ -73,7 +103,8 @@ struct EmbeddedBlockContentProviderFactoryTests { notificationCenter: NotificationCenter(), fetchEmbeddedPlaces: { $0(nil) }) return EmbeddedBlockContentProviderFactory(registry: registry, - feed: EmbeddedBlockFeedServiceMock(), - failureManager: InappShowFailureManagerMock()) + inappService: EmbeddedBlockInappServiceMock(), + failureManager: InappShowFailureManagerMock(), + accounting: InappShowAccountingMock()) } } diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockDelayedDeliveryTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockDelayedDeliveryTests.swift new file mode 100644 index 000000000..139f3883f --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockDelayedDeliveryTests.swift @@ -0,0 +1,115 @@ +// +// EmbeddedBlockDelayedDeliveryTests.swift +// MindboxTests +// +// Created by Sergei Semko on 25.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import Testing +@testable import Mindbox + +@Suite("Embedded block delayed delivery", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockDelayedDeliveryTests { + + private final class Rig { + let scheduler = TestScheduler() + let center = NotificationCenter() + var isInBackground = false + let delivery: EmbeddedBlockDelayedDelivery + + init() { + var background = { false } + delivery = EmbeddedBlockDelayedDelivery(isInBackground: { background() }, + notificationCenter: center, + schedule: { [scheduler] in scheduler.schedule($0, $1) }) + background = { [weak self] in self?.isInBackground ?? false } + } + + func enterForeground() { + center.post(name: UIApplication.willEnterForegroundNotification, object: nil) + } + } + + @Test("An answer is delivered when its delay runs out") + func answerIsDeliveredAfterTheDelay() { + let rig = Rig() + var delivered = 0 + + rig.delivery.schedule(place: "stories", inappId: "a", answer: "a", after: 5) { _ in delivered += 1 } + + #expect(delivered == 0) + #expect(rig.scheduler.lastDelay == 5) + + rig.scheduler.fireAll() + + #expect(delivered == 1) + } + + @Test("A newer answer for the place replaces the one waiting") + func newerAnswerReplacesTheWaitingOne() { + let rig = Rig() + var delivered: [String] = [] + + rig.delivery.schedule(place: "stories", inappId: "a", answer: "a", after: 5) { delivered.append($0) } + rig.delivery.schedule(place: "stories", inappId: "b", answer: "b", after: 5) { delivered.append($0) } + rig.scheduler.fireAll() + + #expect(delivered == ["b"]) + } + + @Test("Cancelling drops the waiting answer") + func cancelDropsTheWaitingAnswer() { + let rig = Rig() + var delivered = 0 + + rig.delivery.schedule(place: "stories", inappId: "a", answer: "a", after: 5) { _ in delivered += 1 } + rig.delivery.cancel(place: "stories") + rig.scheduler.fireAll() + + #expect(delivered == 0) + } + + @Test("A delay that runs out in the background is delivered on return") + func backgroundExpiryIsDeliveredOnReturn() { + let rig = Rig() + var delivered = 0 + rig.delivery.schedule(place: "stories", inappId: "a", answer: "a", after: 5) { _ in delivered += 1 } + rig.isInBackground = true + + rig.scheduler.fireAll() + #expect(delivered == 0) + + rig.isInBackground = false + rig.enterForeground() + + #expect(delivered == 1) + } + + @Test("An answer parked in the background still counts as waiting") + func parkedAnswerStillCountsAsWaiting() { + let rig = Rig() + rig.delivery.schedule(place: "stories", inappId: "a", answer: "a", after: 5) { _ in } + rig.isInBackground = true + rig.scheduler.fireAll() + + #expect(rig.delivery.isWaiting(place: "stories", for: "a")) + } + + @Test("Only the in-app that is waiting at the place counts as waiting") + func waitingIsPerPlaceAndInapp() { + let rig = Rig() + + rig.delivery.schedule(place: "stories", inappId: "a", answer: "a", after: 5) { _ in } + + #expect(rig.delivery.isWaiting(place: "stories", for: "a")) + #expect(!rig.delivery.isWaiting(place: "stories", for: "b")) + #expect(!rig.delivery.isWaiting(place: "promo", for: "a")) + + rig.scheduler.fireAll() + + #expect(!rig.delivery.isWaiting(place: "stories", for: "a")) + } +} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockFeedServiceTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockInappServiceTests.swift similarity index 56% rename from MindboxTests/EmbeddedBlocks/EmbeddedBlockFeedServiceTests.swift rename to MindboxTests/EmbeddedBlocks/EmbeddedBlockInappServiceTests.swift index fdc098028..17889b715 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockFeedServiceTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockInappServiceTests.swift @@ -1,5 +1,5 @@ // -// EmbeddedBlockFeedServiceTests.swift +// EmbeddedBlockInappServiceTests.swift // MindboxTests // // Created by Sergei Semko on 8/13/26. @@ -10,13 +10,13 @@ import Foundation import Testing @_spi(Internal) @testable import Mindbox -@Suite("Embedded block feed service", .tags(.embeddedBlocks)) +@Suite("Embedded block in-app service", .tags(.embeddedBlocks)) @MainActor -struct EmbeddedBlockFeedServiceTests { +struct EmbeddedBlockInappServiceTests { @Test("The selection's answer is passed through") func selectionAnswerIsPassedThrough() { - let bed = FeedBed(allowed: ["story-1", "story-3"]) + let bed = ServiceBed(allowed: ["story-1", "story-3"]) bed.ask(["story-1", "story-2", "story-3"]) @@ -26,7 +26,7 @@ struct EmbeddedBlockFeedServiceTests { @Test("An empty question is answered without asking the selection") func emptyQuestionIsAnsweredWithoutAsking() { - let bed = FeedBed() + let bed = ServiceBed() bed.ask([]) @@ -36,7 +36,7 @@ struct EmbeddedBlockFeedServiceTests { @Test("A slow selection still answers when it comes back") func slowSelectionStillAnswers() { - let bed = FeedBed(allowed: ["story-1"], isDeferred: true) + let bed = ServiceBed(allowed: ["story-1"], isDeferred: true) bed.ask(["story-1"]) #expect(bed.answers.isEmpty) @@ -46,16 +46,28 @@ struct EmbeddedBlockFeedServiceTests { #expect(bed.answers == [["story-1"]]) } + @Test("Whether a config is in hand is asked of the configuration every time") + func hasConfigIsAskedOfTheConfiguration() { + var known = false + let service = EmbeddedBlockInappService(hasConfig: { known }) + + #expect(!service.hasConfig) + + known = true + + #expect(service.hasConfig) + } + @Test("A tap fetches the in-app with its params and hands it to the scheduler") func tapHandsTheFetchedInappToTheScheduler() { var fetched: [(id: String, params: [String: JSONValue])] = [] var shown: [String] = [] - let service = EmbeddedBlockFeedService( + let service = EmbeddedBlockInappService( fetchInappToShow: { id, params, completion in fetched.append((id, params)) completion(Self.formData(id: id)) }, - showNow: { shown.append($0.inAppId) } + showNow: { formData, _ in shown.append(formData.inAppId) } ) service.showInapp(id: "story-1", params: ["formId": .string("160477")]) @@ -65,12 +77,27 @@ struct EmbeddedBlockFeedServiceTests { #expect(shown == ["story-1"]) } + @Test("A tap's processing time runs from the tap to the form being ready") + func tapProcessingTimeRunsFromTheTap() { + var ticks: [TimeInterval] = [10, 10.25] + var durations: [TimeInterval] = [] + let service = EmbeddedBlockInappService( + fetchInappToShow: { id, _, completion in completion(Self.formData(id: id)) }, + showNow: { _, processingDuration in durations.append(processingDuration) }, + now: { ticks.removeFirst() } + ) + + service.showInapp(id: "story-1", params: [:]) + + #expect(durations == [0.25]) + } + @Test("A tap that resolves to nothing schedules nothing") func tapResolvingToNothingSchedulesNothing() { var shownCount = 0 - let service = EmbeddedBlockFeedService( + let service = EmbeddedBlockInappService( fetchInappToShow: { _, _, completion in completion(nil) }, - showNow: { _ in shownCount += 1 } + showNow: { _, _ in shownCount += 1 } ) service.showInapp(id: "story-1", params: [:]) @@ -91,67 +118,80 @@ struct EmbeddedBlockFeedServiceTests { @Test("An answer from a background thread is delivered on the main thread") func backgroundAnswerIsDeliveredOnTheMainThread() async { - let service = EmbeddedBlockFeedService(ask: { _, completion in - DispatchQueue.global().async { completion(FeedAnswer(inappIds: ["story-1"], vouch: {})) } + let service = EmbeddedBlockInappService(ask: { _, _, completion in + DispatchQueue.global().async { completion(["story-1"]) } }) let deliveredOnMainThread: Bool = await withCheckedContinuation { continuation in - service.showableInappIds(among: ["story-1"]) { _ in + service.showableInappIds(among: ["story-1"], askedBy: "block") { _ in continuation.resume(returning: Thread.isMainThread) } } #expect(deliveredOnMainThread) } + + @Test("The block's in-app travels with the question") + func blockInappTravelsWithTheQuestion() { + let bed = ServiceBed(allowed: ["story-1"]) + + bed.ask(["story-1"], askedBy: "block-1") + + #expect(bed.askedBy == ["block-1"]) + } } @MainActor -private final class FeedBed { +private final class ServiceBed { private(set) var answers: [[String]] = [] private(set) var askedIds: [[String]] = [] + private(set) var askedBy: [String] = [] - private let service: EmbeddedBlockFeedService + private let service: EmbeddedBlockInappService private let allowed: [String] private let isDeferred: Bool - private var pending: [(FeedAnswer) -> Void] = [] + private var pending: [([String]) -> Void] = [] init(allowed: [String] = [], isDeferred: Bool = false) { self.allowed = allowed self.isDeferred = isDeferred - var askedIds: (([String]) -> Void)? - var ask: ((@escaping (FeedAnswer) -> Void) -> Void)? + var asked: (([String], String) -> Void)? + var ask: ((@escaping ([String]) -> Void) -> Void)? - service = EmbeddedBlockFeedService( - ask: { ids, completion in - askedIds?(ids) + service = EmbeddedBlockInappService( + ask: { ids, blockInappId, completion in + asked?(ids, blockInappId) ask?(completion) } ) - askedIds = { [weak self] ids in self?.askedIds.append(ids) } + asked = { [weak self] ids, blockInappId in + self?.askedIds.append(ids) + self?.askedBy.append(blockInappId) + } ask = { [weak self] completion in guard let self else { return } if self.isDeferred { self.pending.append(completion) } else { - completion(FeedAnswer(inappIds: self.allowed, vouch: {})) + completion(self.allowed) } } } - func ask(_ ids: [String]) { - service.showableInappIds(among: ids) { [weak self] answer in - self?.answers.append(answer.inappIds) + func ask(_ ids: [String], askedBy blockInappId: String = "block") { + service.showableInappIds(among: ids, askedBy: blockInappId) { [weak self] allowed in + self?.answers.append(allowed) } } func flushSelection() { let completions = pending pending = [] - completions.forEach { $0(FeedAnswer(inappIds: allowed, vouch: {})) } + completions.forEach { $0(allowed) } } } diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift index 89505a971..4529800a0 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift @@ -36,26 +36,39 @@ extension EmbeddedBlockWebContent { tags: stub.tags, params: [:]) } + + static func delayed(_ timeSpan: String = "00:00:05", params: [String: JSONValue] = [:]) -> EmbeddedBlockWebContent { + EmbeddedBlockWebContent(inAppId: "delayed-inapp-id", + baseUrl: stub.baseUrl, + contentUrl: stub.contentUrl, + frequency: .unlimited, + tags: stub.tags, + params: params, + delayTime: timeSpan) + } } -final class EmbeddedBlockShowRecorderMock { +final class InappShowAccountingMock: InappShowAccounting { - private(set) var recorded: [String] = [] + private(set) var shows: [InappShow] = [] - func record(_ inAppId: String) { - recorded.append(inAppId) - } -} + private(set) var cooldowns: [InappFrequency?] = [] -/// Unlike the recorder above: the backend hears every show, the history only the frequencies that count them. -final class EmbeddedBlockShowReporterMock { + private(set) var places: [String] = [] - private(set) var reported: [(inAppId: String, timeToDisplay: String, tags: [String: String]?)] = [] + var shownIds: [String] { shows.map(\.inAppId) } + + func recordShow(_ show: InappShow) { + shows.append(show) + } - var inAppIds: [String] { reported.map(\.inAppId) } + func recordCooldown(frequency: InappFrequency?) { + cooldowns.append(frequency) + } - func report(_ content: EmbeddedBlockWebContent, _ timeToDisplay: String) { - reported.append((content.inAppId, timeToDisplay, content.tags)) + func recordBlockShow(_ show: InappShow, at place: String) { + places.append(place) + shows.append(show) } } @@ -65,9 +78,16 @@ final class EmbeddedBlockFailureReporterMock { var reasons: [InAppShowFailureReason] { reported.map(\.reason) } + /// Failures with no in-app behind them — the SDK never answered the block — by how long it waited. + private(set) var unansweredWaits: [TimeInterval] = [] + func report(_ content: EmbeddedBlockWebContent, _ reason: InAppShowFailureReason, _ details: String) { reported.append((content.inAppId, reason, details, content.tags)) } + + func reportUnansweredWait(_ waited: TimeInterval) { + unansweredWaits.append(waited) + } } extension BridgeMessage { @@ -153,7 +173,7 @@ final class EmbeddedBlockPageMock: EmbeddedBlockPageHosting { } } -private final class EmbeddedBlockPageMockHost: WebBridgeHost, WebBridgeContentHosting, WebBridgeFeedHosting { +private final class EmbeddedBlockPageMockHost: WebBridgeHost, WebBridgeContentHosting, WebBridgeInappRequestHosting { unowned let page: EmbeddedBlockPageMock @@ -299,6 +319,8 @@ final class EmbeddedBlockResolverMock: EmbeddedBlockResolving { var resolution: EmbeddedBlockResolution + var processingDuration: TimeInterval = 0 + /// `true` — the answer does not arrive until the test calls `flush()`: this is how a resolve /// that lands after the block was stopped or reloaded is checked. var isDeferred = false @@ -309,7 +331,7 @@ final class EmbeddedBlockResolverMock: EmbeddedBlockResolving { var resolveCount: Int { resolvedPlaces.count } - private var pending: [(EmbeddedBlockResolution) -> Void] = [] + private var pending: [(EmbeddedBlockResolution, TimeInterval) -> Void] = [] init(resolution: EmbeddedBlockResolution = .content(.stub)) { self.resolution = resolution @@ -317,61 +339,57 @@ final class EmbeddedBlockResolverMock: EmbeddedBlockResolving { func resolve(_ place: String, trigger: ApplicationEvent?, - completion: @escaping (EmbeddedBlockResolution) -> Void) { + completion: @escaping (EmbeddedBlockResolution, TimeInterval) -> Void) { resolvedPlaces.append(place) triggers.append(trigger) if isDeferred { pending.append(completion) } else { - completion(resolution) + completion(resolution, processingDuration) } } func flush() { let completions = pending pending = [] - completions.forEach { $0(resolution) } + completions.forEach { $0(resolution, processingDuration) } } } -final class EmbeddedBlockFeedServiceMock: EmbeddedBlockFeedServing { +final class EmbeddedBlockInappServiceMock: EmbeddedBlockInappServing { + + var hasConfig = false var allowed: [String] = [] var isDeferred = false private(set) var askedIds: [[String]] = [] + private(set) var askedBy: [String] = [] private(set) var shown: [(id: String, params: [String: JSONValue])] = [] - private var pending: [(FeedAnswer) -> Void] = [] - - private(set) var vouchCount = 0 + private var pending: [([String]) -> Void] = [] func showInapp(id: String, params: [String: JSONValue]) { shown.append((id, params)) } - func showableInappIds(among ids: [String], completion: @escaping (FeedAnswer) -> Void) { + func showableInappIds(among ids: [String], askedBy blockInappId: String, completion: @escaping ([String]) -> Void) { askedIds.append(ids) + askedBy.append(blockInappId) if isDeferred { pending.append(completion) } else { - completion(answer) + completion(allowed) } } func flush() { let completions = pending pending = [] - completions.forEach { $0(answer) } - } - - private var answer: FeedAnswer { - FeedAnswer(inappIds: allowed) { [weak self] in - self?.vouchCount += 1 - } + completions.forEach { $0(allowed) } } } @@ -395,10 +413,13 @@ final class TestScheduler { /// The delay of the last arm — which is the remainder of the budget given to the countdown. private(set) var lastDelay: TimeInterval? + private(set) var armCount = 0 + private var pending: [DispatchWorkItem] = [] func schedule(_ delay: TimeInterval, _ work: DispatchWorkItem) { lastDelay = delay + armCount += 1 pending.append(work) } @@ -493,60 +514,75 @@ final class EmbeddedBlockAckSchedulerMock { /// The provider with all dependencies substituted — the shared rig for the provider and container /// tests. The container runs through a real provider, the provider through a real place registry. +final class EmbeddedBlockContentProviderFactoryMock: EmbeddedBlockContentProviderMaking { + + private(set) var requestedPlaces: [String] = [] + + private let provider: EmbeddedBlockWebViewProvider + + init(provider: EmbeddedBlockWebViewProvider) { + self.provider = provider + } + + func makeProvider(placeSystemName: String) -> EmbeddedBlockWebViewProvider { + requestedPlaces.append(placeSystemName) + return provider + } +} + final class EmbeddedBlockTestBed { let resolver: EmbeddedBlockResolverMock - let feed: EmbeddedBlockFeedServiceMock + let inappService: EmbeddedBlockInappServiceMock let pageFactory: EmbeddedBlockPageFactoryMock let provider: EmbeddedBlockWebViewProvider - let showRecorder: EmbeddedBlockShowRecorderMock - let showReporter: EmbeddedBlockShowReporterMock + let accounting: InappShowAccountingMock let failureReporter: EmbeddedBlockFailureReporterMock let ackScheduler: EmbeddedBlockAckSchedulerMock - let clock: TestClock - /// One per bed: a new config must reach only this provider. let center: NotificationCenter + /// One clock for both seams: the page's rendering time (the block's part of `timeToDisplay`) and the ack wait. + let clock: TestClock + var page: EmbeddedBlockPageMock? { pageFactory.page } init(placeSystemName: String = "block-id", resolution: EmbeddedBlockResolution = .content(.stub)) { - // The show-event dedup lives on the shared session singleton — reset, or beds would see each other's shows. - SessionTemporaryStorage.shared.blockShowsReportedInSession = [] + // Once-per-session state lives on the shared singleton — reset, or beds would see each other's silence. + SessionTemporaryStorage.shared.$ledger.mutate { $0.placesReportedUnanswered = [] } + let clock = TestClock() let resolver = EmbeddedBlockResolverMock(resolution: resolution) - let feed = EmbeddedBlockFeedServiceMock() + let inappService = EmbeddedBlockInappServiceMock() let pageFactory = EmbeddedBlockPageFactoryMock() let embeddedPlaces = EmbeddedPlacesStub() let center = NotificationCenter() - let showRecorder = EmbeddedBlockShowRecorderMock() - let showReporter = EmbeddedBlockShowReporterMock() + let accounting = InappShowAccountingMock() let failureReporter = EmbeddedBlockFailureReporterMock() let ackScheduler = EmbeddedBlockAckSchedulerMock() - let clock = TestClock() let registry = EmbeddedBlockPlaceRegistry(resolver: resolver, notificationCenter: center, fetchEmbeddedPlaces: { embeddedPlaces.fetch($0) }) - self.showRecorder = showRecorder - self.showReporter = showReporter - self.ackScheduler = ackScheduler self.clock = clock + self.accounting = accounting + self.ackScheduler = ackScheduler self.failureReporter = failureReporter self.center = center self.resolver = resolver - self.feed = feed + self.inappService = inappService self.pageFactory = pageFactory self.provider = EmbeddedBlockWebViewProvider(placeSystemName: placeSystemName, registry: registry, - feed: feed, + inappService: inappService, makePage: { pageFactory.make($0) }, - recordShow: { showRecorder.record($0) }, - reportShow: { showReporter.report($0, $1) }, + accounting: accounting, reportFailure: { failureReporter.report($0, $1, $2) }, + reportUnansweredWait: { failureReporter.reportUnansweredWait($0) }, scheduleAckTimeout: { ackScheduler.schedule($0, $1) }, + makeStopwatch: { ForegroundStopwatch(notificationCenter: center, now: { clock.now }) }, now: { clock.now }) } @@ -605,7 +641,7 @@ final class EmbeddedBlockViewDelegateMock: MindboxEmbeddedBlockViewDelegate { extension EmbeddedBlockResolving { - func resolve(_ place: String, completion: @escaping (EmbeddedBlockResolution) -> Void) { + func resolve(_ place: String, completion: @escaping (EmbeddedBlockResolution, TimeInterval) -> Void) { resolve(place, trigger: nil, completion: completion) } } diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockPlaceRegistryTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockPlaceRegistryTests.swift index 0531f119a..8ce111d1c 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockPlaceRegistryTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockPlaceRegistryTests.swift @@ -6,6 +6,7 @@ // Copyright © 2026 Mindbox. All rights reserved. // +import UIKit import Testing import Foundation @_spi(Internal) @testable import Mindbox @@ -17,9 +18,16 @@ struct EmbeddedBlockPlaceRegistryTests { private final class BlockFake: EmbeddedBlockPlaceHandling { var isActive = true private(set) var applied: [EmbeddedBlockResolution] = [] + private(set) var processingDurations: [TimeInterval] = [] + private(set) var delayedCount = 0 - func apply(_ resolution: EmbeddedBlockResolution) { + func apply(_ resolution: EmbeddedBlockResolution, processingDuration: TimeInterval) { applied.append(resolution) + processingDurations.append(processingDuration) + } + + func contentIsDelayed() { + delayedCount += 1 } } @@ -27,18 +35,34 @@ struct EmbeddedBlockPlaceRegistryTests { let resolver: EmbeddedBlockResolverMock let center: NotificationCenter let embeddedPlaces: EmbeddedPlacesStub + let delayScheduler: TestScheduler let registry: EmbeddedBlockPlaceRegistry + var isInBackground = false init() { + // Served delays live on the shared session singleton — reset, or rigs would see each other's. + SessionTemporaryStorage.shared.$ledger.mutate { $0.servedPlaceDelays = [] } + let resolver = EmbeddedBlockResolverMock() let center = NotificationCenter() let embeddedPlaces = EmbeddedPlacesStub() + let delayScheduler = TestScheduler() self.resolver = resolver self.center = center self.embeddedPlaces = embeddedPlaces + self.delayScheduler = delayScheduler + var background = { false } registry = EmbeddedBlockPlaceRegistry(resolver: resolver, notificationCenter: center, - fetchEmbeddedPlaces: { embeddedPlaces.fetch($0) }) + fetchEmbeddedPlaces: { embeddedPlaces.fetch($0) }, + delayedDelivery: EmbeddedBlockDelayedDelivery(isInBackground: { background() }, + notificationCenter: center, + schedule: { delayScheduler.schedule($0, $1) })) + background = { [weak self] in self?.isInBackground ?? false } + } + + func enterForeground() { + center.post(name: UIApplication.willEnterForegroundNotification, object: nil) } func announceNewConfig() { @@ -351,6 +375,132 @@ struct EmbeddedBlockPlaceRegistryTests { #expect(carried == nil) } + // MARK: - delayTime + + @Test("A winner with delayTime is delivered when the delay runs out") + func delayedWinnerIsDeliveredAfterTheDelay() { + let rig = Rig() + rig.resolver.resolution = .content(.delayed("00:00:05")) + let block = BlockFake() + rig.registry.register(block, place: "stories") + + rig.registry.blockAppeared("stories") + + #expect(block.applied.isEmpty) + #expect(block.delayedCount == 1) + #expect(rig.delayScheduler.lastDelay == 5) + + rig.delayScheduler.fireAll() + + #expect(block.applied == [.content(.delayed("00:00:05"))]) + } + + @Test("The delay a winner waited out is not added to the selection's time") + func delayIsNotAddedToTheProcessingTime() { + let rig = Rig() + rig.resolver.resolution = .content(.delayed("00:00:05")) + rig.resolver.processingDuration = 2 + let block = BlockFake() + rig.registry.register(block, place: "stories") + + rig.registry.blockAppeared("stories") + rig.delayScheduler.fireAll() + + #expect(block.processingDurations == [2]) + } + + @Test("A different answer during the delay replaces the waiting one") + func newAnswerDuringTheDelayReplacesIt() { + let rig = Rig() + rig.resolver.resolution = .content(.delayed()) + let block = BlockFake() + rig.registry.register(block, place: "stories") + rig.registry.blockAppeared("stories") + + rig.resolver.resolution = .content(.stub) + rig.announceNewConfig() + rig.delayScheduler.fireAll() + + #expect(block.applied == [.content(.stub)]) + } + + @Test("The same winner resolved again keeps its delay running and arrives with the newest content") + func sameWinnerKeepsItsDelay() { + let rig = Rig() + rig.resolver.resolution = .content(.delayed()) + let block = BlockFake() + rig.registry.register(block, place: "stories") + rig.registry.blockAppeared("stories") + + rig.resolver.resolution = .content(.delayed(params: ["fresh": .bool(true)])) + rig.announceNewConfig() + + #expect(rig.delayScheduler.armCount == 1) + #expect(block.applied.isEmpty) + + rig.delayScheduler.fireAll() + + #expect(block.applied == [.content(.delayed(params: ["fresh": .bool(true)]))]) + } + + @Test("The same winner resolved again after its delay ran out in the background is delivered on return, not delayed again") + func sameWinnerAfterBackgroundExpiryIsNotDelayedAgain() { + let rig = Rig() + rig.resolver.resolution = .content(.delayed()) + let block = BlockFake() + rig.registry.register(block, place: "stories") + rig.registry.blockAppeared("stories") + rig.isInBackground = true + rig.delayScheduler.fireAll() + + rig.announceNewConfig() + + #expect(rig.delayScheduler.armCount == 1) + #expect(block.applied.isEmpty) + + rig.isInBackground = false + rig.enterForeground() + + #expect(block.applied == [.content(.delayed())]) + } + + @Test("A block appearing while the place waits out its delay is told content is coming") + func blockAppearingMidDelayHearsOfTheDelay() { + let rig = Rig() + rig.resolver.resolution = .content(.delayed("00:00:05")) + let first = BlockFake() + rig.registry.register(first, place: "stories") + rig.registry.blockAppeared("stories") + + let newcomer = BlockFake() + rig.registry.register(newcomer, place: "stories") + rig.registry.blockAppeared("stories") + + #expect(newcomer.delayedCount == 1) + #expect(newcomer.applied.isEmpty) + + rig.delayScheduler.fireAll() + + #expect(newcomer.applied == [.content(.delayed("00:00:05"))]) + } + + @Test("A block that comes back after its delay ran out gets the content at once") + func returningBlockAfterTheDelayIsAnsweredAtOnce() { + let rig = Rig() + rig.resolver.resolution = .content(.delayed()) + let block = BlockFake() + rig.registry.register(block, place: "stories") + rig.registry.blockAppeared("stories") + block.isActive = false + rig.delayScheduler.fireAll() + + block.isActive = true + rig.registry.blockAppeared("stories") + + #expect(block.applied.count == 2) + #expect(block.delayedCount == 1) + } + // MARK: - Lifetime @Test("A place whose only block has died resolves nothing") diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift index 21651ebe7..615f17e52 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift @@ -20,9 +20,9 @@ struct EmbeddedBlockResolverTests { let resolver = EmbeddedBlockResolver(load: loader.load) var answers: [EmbeddedBlockResolution] = [] - resolver.resolve("promo") { answers.append($0) } - resolver.resolve("promo") { answers.append($0) } - resolver.resolve("stories") { answers.append($0) } + resolver.resolve("promo") { resolution, _ in answers.append(resolution) } + resolver.resolve("promo") { resolution, _ in answers.append(resolution) } + resolver.resolve("stories") { resolution, _ in answers.append(resolution) } #expect(loader.requestedIds == ["promo", "promo", "stories"]) @@ -36,11 +36,11 @@ struct EmbeddedBlockResolverTests { let loader = ContentLoaderSpy() let resolver = EmbeddedBlockResolver(load: loader.load) - resolver.resolve("promo") { _ in } + resolver.resolve("promo") { _, _ in } loader.answer(.empty) var second: EmbeddedBlockResolution? - resolver.resolve("promo") { second = $0 } + resolver.resolve("promo") { resolution, _ in second = resolution } loader.answer(.content(.stub)) #expect(loader.requestedIds == ["promo", "promo"]) @@ -53,8 +53,8 @@ struct EmbeddedBlockResolverTests { let resolver = EmbeddedBlockResolver(load: loader.load) let event = ApplicationEvent(name: "custom.operation", model: nil) - resolver.resolve("promo", trigger: event) { _ in } - resolver.resolve("promo") { _ in } + resolver.resolve("promo", trigger: event) { _, _ in } + resolver.resolve("promo") { _, _ in } #expect(loader.requestedTriggers.count == 2) #expect(loader.requestedTriggers[0] === event) @@ -64,11 +64,11 @@ struct EmbeddedBlockResolverTests { @Test("An answer from a background thread is delivered on the main thread") func backgroundAnswerIsDeliveredOnTheMainThread() async { let resolver = EmbeddedBlockResolver(load: { _, _, completion in - DispatchQueue.global().async { completion(.content(.stub)) } + DispatchQueue.global().async { completion(.content(.stub), 0) } }) let deliveredOnMainThread: Bool = await withCheckedContinuation { continuation in - resolver.resolve("promo") { _ in + resolver.resolve("promo") { _, _ in continuation.resume(returning: Thread.isMainThread) } } @@ -78,10 +78,10 @@ struct EmbeddedBlockResolverTests { @Test("An answer from the main thread is delivered without a hop") func mainThreadAnswerIsDeliveredSynchronously() { - let resolver = EmbeddedBlockResolver(load: { _, _, completion in completion(.empty) }) + let resolver = EmbeddedBlockResolver(load: { _, _, completion in completion(.empty, 0) }) var answer: EmbeddedBlockResolution? - resolver.resolve("promo") { answer = $0 } + resolver.resolve("promo") { resolution, _ in answer = resolution } #expect(answer == .empty) } @@ -135,9 +135,9 @@ private final class ContentLoaderSpy { private(set) var requestedIds: [String] = [] private(set) var requestedTriggers: [ApplicationEvent?] = [] - private var completions: [(EmbeddedBlockResolution) -> Void] = [] + private var completions: [(EmbeddedBlockResolution, TimeInterval) -> Void] = [] - func load(_ id: String, trigger: ApplicationEvent?, completion: @escaping (EmbeddedBlockResolution) -> Void) { + func load(_ id: String, trigger: ApplicationEvent?, completion: @escaping (EmbeddedBlockResolution, TimeInterval) -> Void) { requestedIds.append(id) requestedTriggers.append(trigger) completions.append(completion) @@ -146,6 +146,6 @@ private final class ContentLoaderSpy { func answer(_ resolution: EmbeddedBlockResolution) { let pending = completions completions = [] - pending.forEach { $0(resolution) } + pending.forEach { $0(resolution, 0) } } } diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift index 1b65b7adb..dac679efa 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift @@ -130,17 +130,17 @@ struct EmbeddedBlockWebViewPageTests { #expect(registry.count == 1) } - @Test("The feed's question reaches the block and the answer goes back to the page") - func feedQuestionReachesTheBlock() throws { + @Test("The page's question reaches the block and the answer goes back to it") + func pageQuestionReachesTheBlock() throws { let bed = PageBed() let question = BridgeMessage.pageRequest(.filterShowableInapps, ["inappIds": .array([.string("one"), .string("two")])]) bed.receive(question) - #expect(bed.feedQuestions == [["one", "two"]]) + #expect(bed.pageQuestions == [["one", "two"]]) - bed.answerFeed(["one"]) + bed.answerPage(["one"]) let answer = try #require(bed.facade.sentMessages.first) #expect(answer.type == .response) @@ -311,13 +311,13 @@ private final class PageBed { let facade = SharedWebLayerMock() private(set) var failures = 0 - private(set) var feedQuestions: [[String]] = [] + private(set) var pageQuestions: [[String]] = [] private(set) var renderedCounts: [Int] = [] private(set) var unreadableReports = 0 private(set) var showRequests: [(id: String, params: [String: JSONValue])] = [] private(set) var ackCount = 0 - private var feedCompletions: [([String]) -> Void] = [] + private var pageCompletions: [([String]) -> Void] = [] private lazy var bridge = MindboxWebBridge(webView: facade.webView) @@ -336,8 +336,8 @@ private final class PageBed { self?.failures += 1 } page.onShowableQuestion = { [weak self] ids, completion in - self?.feedQuestions.append(ids) - self?.feedCompletions.append(completion) + self?.pageQuestions.append(ids) + self?.pageCompletions.append(completion) } page.onContentRendered = { [weak self] count in self?.renderedCounts.append(count) @@ -361,9 +361,9 @@ private final class PageBed { facade.messageDelegate?.webBridge(bridge, didReceiveBridgeMessage: message) } - func answerFeed(_ allowed: [String]) { - feedCompletions.forEach { $0(allowed) } - feedCompletions = [] + func answerPage(_ allowed: [String]) { + pageCompletions.forEach { $0(allowed) } + pageCompletions = [] } func reportSubresourceError(url: String?) { diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift index 3649b61c0..529b3ae8e 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift @@ -108,57 +108,50 @@ struct EmbeddedBlockWebViewProviderTests { var states: [EmbeddedBlockState] = [] bed.provider.onStateChange = { states.append($0) } - bed.page?.reportRendered(1) bed.page?.reportRendered(0) - #expect(states == [.ready, .empty]) + #expect(states == [.empty]) #expect(bed.provider.contentView == nil) } - // MARK: - Counting the show + // MARK: - Accounting for the show - /// Counted by the frequency's rule — same as the overlay path and Android; nothing else on the place path writes this history. - @Test("A block that drew its page counts the show") - func renderedBlockCountsTheShow() { + @Test("A block that drew its page hands the show to the accounting") + func renderedBlockIsAccountedFor() throws { let bed = EmbeddedBlockTestBed(resolution: .content(.counted())) bed.provider.start() bed.page?.reportRendered(3) - #expect(bed.showRecorder.recorded == [EmbeddedBlockWebContent.stub.inAppId]) + let show = try #require(bed.accounting.shows.first) + #expect(bed.accounting.shows.count == 1) + #expect(show.inAppId == EmbeddedBlockWebContent.stub.inAppId) + #expect(show.frequency == EmbeddedBlockWebContent.counted().frequency) + #expect(show.tags == EmbeddedBlockWebContent.stub.tags) } - @Test("An unlimited block counts nothing") - func unlimitedBlockCountsNothing() { - let bed = EmbeddedBlockTestBed() - + @Test("A show is accounted from the content the block was given, whatever the place resolves to later") + func showIsAccountedFromTheSnapshot() throws { + let bed = EmbeddedBlockTestBed(resolution: .content(.counted())) bed.provider.start() - bed.page?.reportRendered(3) - #expect(bed.showRecorder.recorded.isEmpty) - } - - // MARK: - Reporting the show - - @Test("A block that drew its page reports the show") - func renderedBlockReportsTheShow() { - let bed = EmbeddedBlockTestBed() - - bed.provider.start() + bed.resolver.resolution = .content(.other) bed.page?.reportRendered(3) - #expect(bed.showReporter.inAppIds == [EmbeddedBlockWebContent.stub.inAppId]) - #expect(bed.showReporter.reported.first?.tags == EmbeddedBlockWebContent.stub.tags) + let show = try #require(bed.accounting.shows.first) + #expect(show.inAppId == EmbeddedBlockWebContent.stub.inAppId) + #expect(show.frequency == EmbeddedBlockWebContent.counted().frequency) + #expect(show.tags == EmbeddedBlockWebContent.stub.tags) } - @Test("Nothing drawn, nothing reported") - func pageWithoutContentReportsNoShow() { + @Test("Nothing drawn, nothing accounted") + func pageWithoutContentIsNotAccounted() { let bed = EmbeddedBlockTestBed() bed.provider.start() bed.page?.reportRendered(0) - #expect(bed.showReporter.reported.isEmpty) + #expect(bed.accounting.shows.isEmpty) } @Test("A negative count is a failure, not an empty block") @@ -171,18 +164,18 @@ struct EmbeddedBlockWebViewProviderTests { bed.page?.reportRendered(-1) #expect(states.last == .failed) - #expect(bed.showReporter.reported.isEmpty) + #expect(bed.accounting.shows.isEmpty) #expect(bed.failureReporter.reasons == [.presentationFailed]) } - @Test("A page that failed to load reports no show") - func failedPageReportsNoShow() { + @Test("A page that failed to load is not accounted") + func failedPageIsNotAccounted() { let bed = EmbeddedBlockTestBed() bed.provider.start() bed.page?.failLoad() - #expect(bed.showReporter.reported.isEmpty) + #expect(bed.accounting.shows.isEmpty) } @Test("An unreadable report is a failure, not a show") @@ -192,24 +185,23 @@ struct EmbeddedBlockWebViewProviderTests { bed.provider.start() bed.page?.reportRenderedWithoutCount() - #expect(bed.showReporter.reported.isEmpty) + #expect(bed.accounting.shows.isEmpty) #expect(bed.failureReporter.reasons == [.presentationFailed]) } - @Test("A page reporting itself again reports one show") - func repeatedReportSendsOneEvent() { + @Test("A page reporting itself again is accounted once") + func repeatedReportIsAccountedOnce() { let bed = EmbeddedBlockTestBed() bed.provider.start() bed.page?.reportRendered(3) bed.page?.reportRendered(4) - #expect(bed.showReporter.reported.count == 1) + #expect(bed.accounting.shows.count == 1) } - /// In sync with Android: one show per in-app per session, while the local history stays per rendered page. - @Test("A page rebuilt in the same session reports no second show") - func rebuiltPageInSessionReportsNoSecondShow() { + @Test("A page rebuilt for the same content hands its show to the accounting again") + func rebuiltPageIsHandedToAccountingAgain() { let bed = EmbeddedBlockTestBed(resolution: .content(.counted())) bed.provider.start() bed.page?.reportRendered(3) @@ -217,103 +209,56 @@ struct EmbeddedBlockWebViewProviderTests { bed.provider.reload() bed.page?.reportRendered(3) - #expect(bed.showReporter.reported.count == 1) - #expect(bed.showRecorder.recorded.count == 2) - } - - @Test("Another in-app at the place reports its own show") - func anotherInappAtThePlaceReportsItsOwnShow() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - bed.page?.reportRendered(1) - - bed.resolver.resolution = .content(.other) - bed.announceNewConfig() - bed.page?.reportRendered(1) - - #expect(bed.showReporter.inAppIds == [EmbeddedBlockWebContent.stub.inAppId, - EmbeddedBlockWebContent.other.inAppId]) - } - - @Test("A new session reports the show again") - func newSessionReportsTheShowAgain() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - bed.page?.reportRendered(1) - - SessionTemporaryStorage.shared.blockShowsReportedInSession = [] - bed.provider.reload() - bed.page?.reportRendered(1) - - #expect(bed.showReporter.reported.count == 2) + #expect(bed.accounting.shows.count == 2) } - /// The backend parses one format for overlay and block alike — the value is a real measurement, so only its shape is pinned. - @Test("The reported show carries a timeToDisplay in the overlay's format") - func reportedShowCarriesTimeToDisplay() throws { - let bed = EmbeddedBlockTestBed() + @Test("A block show is accounted at the block's place") + func showIsAccountedAtThePlace() { + let bed = EmbeddedBlockTestBed(placeSystemName: "the-place") bed.provider.start() bed.page?.reportRendered(3) - let timeToDisplay = try #require(bed.showReporter.reported.first?.timeToDisplay) - #expect(timeToDisplay.range(of: #"^\d+:\d{2}:\d{2}\.\d{7}$"#, options: .regularExpression) != nil, - "timeToDisplay '\(timeToDisplay)' is not the format toTimeSpan() produces") + #expect(bed.accounting.places == ["the-place"]) } - @Test("A page that drew nothing counts no show") - func emptyPageCountsNoShow() { + @Test("A page shown again on return is accounted once") + func returningBlockIsAccountedOnce() { let bed = EmbeddedBlockTestBed(resolution: .content(.counted())) bed.provider.start() - bed.page?.reportRendered(0) - - #expect(bed.showRecorder.recorded.isEmpty) - } - - @Test("A page that failed to load counts no show") - func failedPageCountsNoShow() { - let bed = EmbeddedBlockTestBed(resolution: .content(.counted())) - + bed.page?.reportRendered(3) + bed.provider.stop() bed.provider.start() - bed.page?.failLoad() - #expect(bed.showRecorder.recorded.isEmpty) + #expect(bed.accounting.shows.count == 1) } - @Test("A page reporting itself again counts one show") - func repeatedReportCountsOneShow() { - let bed = EmbeddedBlockTestBed(resolution: .content(.counted())) - + @Test("A page rebuilt for another in-app hands its show to the accounting again") + func pageForAnotherInappIsHandedToAccountingAgain() { + let bed = EmbeddedBlockTestBed() bed.provider.start() - bed.page?.reportRendered(3) - bed.page?.reportRendered(4) - - #expect(bed.showRecorder.recorded.count == 1) - } - - @Test("A page shown again on return counts no second show") - func returningBlockCountsNoSecondShow() { - let bed = EmbeddedBlockTestBed(resolution: .content(.counted())) + bed.page?.reportRendered(1) - bed.provider.start() - bed.page?.reportRendered(3) - bed.provider.stop() - bed.provider.start() + bed.resolver.resolution = .content(.other) + bed.announceNewConfig() + bed.page?.reportRendered(1) - #expect(bed.showRecorder.recorded.count == 1) + #expect(bed.accounting.shownIds == [EmbeddedBlockWebContent.stub.inAppId, + EmbeddedBlockWebContent.other.inAppId]) } - @Test("A page built again counts its own show") - func rebuiltPageCountsItsOwnShow() { - let bed = EmbeddedBlockTestBed(resolution: .content(.counted())) + @Test("The block's timeToDisplay is the selection's processing plus the page's rendering") + func timeToDisplayAddsProcessingToRendering() throws { + let bed = EmbeddedBlockTestBed() + bed.resolver.processingDuration = 2 bed.provider.start() - bed.page?.reportRendered(3) - bed.provider.reload() + bed.clock.advance(0.75) bed.page?.reportRendered(3) - #expect(bed.showRecorder.recorded.count == 2) + let show = try #require(bed.accounting.shows.first) + #expect(show.timeToDisplay == 2.75) } // MARK: - Load failure @@ -379,6 +324,63 @@ struct EmbeddedBlockWebViewProviderTests { #expect(bed.failureReporter.reasons == [.presentationFailed]) } + @Test("A delayed answer keeps the block loading and tells the container") + func delayedAnswerKeepsTheBlockLoading() { + let bed = EmbeddedBlockTestBed() + bed.resolver.isDeferred = true + var delayedCalls = 0 + var states: [EmbeddedBlockState] = [] + bed.provider.onContentDelayed = { delayedCalls += 1 } + bed.provider.onStateChange = { states.append($0) } + bed.provider.start() + + bed.provider.contentIsDelayed() + + #expect(bed.provider.isAwaitingDelayedContent) + #expect(delayedCalls == 1) + #expect(states == [.loading]) + + bed.provider.apply(.content(.stub), processingDuration: 0) + + #expect(!bed.provider.isAwaitingDelayedContent) + } + + @Test("A block the SDK never answered reports one failure without an in-app") + func unansweredBlockReportsOneUnattributedFailure() { + let bed = EmbeddedBlockTestBed() + bed.resolver.isDeferred = true + bed.provider.start() + + bed.provider.reportAnswerTimedOut(waited: 30) + + #expect(bed.failureReporter.unansweredWaits == [30]) + #expect(bed.failureReporter.reported.isEmpty) + } + + @Test("A second unanswered wait at the same place in one session reports nothing") + func secondUnansweredWaitIsSilent() { + let bed = EmbeddedBlockTestBed() + bed.resolver.isDeferred = true + bed.provider.start() + + bed.provider.reportAnswerTimedOut(waited: 30) + bed.provider.reportAnswerTimedOut(waited: 30) + + #expect(bed.failureReporter.unansweredWaits.count == 1) + } + + @Test("Another place's unanswered wait is reported on its own") + func anotherPlacesUnansweredWaitIsReported() { + let first = EmbeddedBlockTestBed(placeSystemName: "first-place") + let second = EmbeddedBlockTestBed(placeSystemName: "second-place") + + first.provider.reportAnswerTimedOut(waited: 30) + second.provider.reportAnswerTimedOut(waited: 30) + + #expect(first.failureReporter.unansweredWaits.count == 1) + #expect(second.failureReporter.unansweredWaits.count == 1) + } + @Test("An empty place reports nothing") func emptyPlaceReportsNothing() { let bed = EmbeddedBlockTestBed(resolution: .empty) @@ -420,9 +422,39 @@ struct EmbeddedBlockWebViewProviderTests { #expect(bed.pageFactory.pages.count == 1) } + @Test("A config that changed only the frequency or tags leaves the page alone") + func metadataOnlyChangeIsNotPushedToThePage() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.reportRendered(1) + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.resolver.resolution = .content(.counted()) + bed.announceNewConfig() + bed.page?.reportRendered(0) + + #expect(bed.page?.initDataPushes.isEmpty == true) + #expect(bed.pageFactory.pages.count == 1) + #expect(states.isEmpty) + } + + @Test("A show is accounted with the frequency the config moved to while the page was loading") + func snapshotFollowsAMetadataOnlyChange() throws { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + + bed.resolver.resolution = .content(.counted()) + bed.announceNewConfig() + bed.page?.reportRendered(1) + + let show = try #require(bed.accounting.shows.first) + #expect(show.frequency == EmbeddedBlockWebContent.counted().frequency) + #expect(bed.page?.initDataPushes.isEmpty == true) + } + // MARK: - The data push's confirmation - /// A feed silently showing yesterday's stories is the failure nobody files a report about — same remedy as Android's. @Test("A page that never confirms the data push is rebuilt") func silentDataPushRebuildsThePage() { let bed = EmbeddedBlockTestBed() @@ -625,7 +657,9 @@ struct EmbeddedBlockWebViewProviderTests { #expect(bed.pageFactory.pages.count == 1) } - @Test("The same answer revives a page that was collapsed by a dropped place") + /// A data push cannot revive a collapsed block: the page answers `initDataUpdated` and stays as it + /// is, so the block would wait for a report that never comes. It is rebuilt instead, like Android. + @Test("The same answer revives a page that was collapsed by a dropped place, by rebuilding it") func sameAnswerRevivesACollapsedPage() { let bed = EmbeddedBlockTestBed() bed.provider.start() @@ -638,10 +672,84 @@ struct EmbeddedBlockWebViewProviderTests { var states: [EmbeddedBlockState] = [] bed.provider.onStateChange = { states.append($0) } bed.announceNewConfig() + bed.page?.reportRendered(1) + + #expect(bed.pageFactory.pages.count == 2) + #expect(bed.pageFactory.pages.first?.initDataPushes.isEmpty == true) + #expect(states == [.loading, .ready]) + } + + @Test("A block collapsed by its own page is rebuilt for new data, not told about it") + func collapsedBlockIsRebuiltForNewData() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.reportRendered(0) + + bed.deliverSamePageWithNewData() + + #expect(bed.pageFactory.pages.count == 2) + #expect(bed.pageFactory.pages.first?.initDataPushes.isEmpty == true) + } + + @Test("A shown block is not collapsed by a later report of nothing") + func shownBlockIgnoresALaterEmptyReport() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.reportRendered(2) + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.reportRendered(0) - #expect(bed.page?.initDataPushes.count == 1) - #expect(bed.pageFactory.pages.count == 1) #expect(states.isEmpty) + #expect(bed.provider.contentView != nil) + } + + /// One show must not carry both `Inapp.Show` and `Inapp.ShowFailure`: the show is already accounted + /// for when the repeat arrives. + @Test("A shown block is not failed by a later unreadable report") + func shownBlockIgnoresALaterUnreadableReport() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.reportRendered(2) + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.reportRenderedWithoutCount() + + #expect(bed.failureReporter.reported.isEmpty) + #expect(bed.accounting.shows.count == 1) + #expect(states.isEmpty) + } + + /// The latch closes on drawn content only: a page that reports nothing first and draws later — it was + /// still waiting for its answer about which in-apps it may draw — is still heard. + @Test("A page that drew nothing and then drew something is heard") + func pageThatDrawsAfterReportingNothingIsHeard() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.reportRendered(0) + bed.page?.reportRendered(2) + + #expect(states == [.empty, .ready]) + #expect(bed.accounting.shownIds == [EmbeddedBlockWebContent.stub.inAppId]) + } + + @Test("A data push lets the page report itself again") + func dataPushReopensTheReport() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.reportRendered(2) + bed.deliverSamePageWithNewData() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.reportRendered(0) + + #expect(states == [.empty]) } @Test("An operation revives a block that had settled as empty") @@ -670,54 +778,39 @@ struct EmbeddedBlockWebViewProviderTests { #expect(bed.resolver.resolveCount == resolvesBefore) } - // MARK: - Which in-apps the feed may draw + // MARK: - Which in-apps the page may draw @Test("A loading block answers which in-apps it may draw") func loadingBlockAnswersTargeting() { let bed = EmbeddedBlockTestBed() - bed.feed.allowed = ["story-1"] + bed.inappService.allowed = ["story-1"] bed.provider.start() bed.page?.send(.filterShowableInapps, ["inappIds": .array([.string("story-1"), .string("story-2")])]) - #expect(bed.feed.askedIds == [["story-1", "story-2"]]) + #expect(bed.inappService.askedIds == [["story-1", "story-2"]]) #expect(bed.page?.responses.map(\.payload) == [.object(["inappIds": .array([.string("story-1")])])]) } - @Test("A delivered answer is vouched for once") - func deliveredAnswerIsVouchedFor() { - let bed = EmbeddedBlockTestBed() - bed.feed.allowed = ["story-1", "story-2"] - bed.provider.start() - - bed.page?.send(.filterShowableInapps, ["inappIds": .array([.string("story-1"), .string("story-2")])]) - - #expect(bed.feed.vouchCount == 1) - } - - @Test("An answer landing after a stop is not vouched for") - func droppedAnswerIsNotVouchedFor() { + @Test("The question names the block's own in-app") + func questionNamesTheBlocksInapp() { let bed = EmbeddedBlockTestBed() - bed.feed.allowed = ["story-1"] - bed.feed.isDeferred = true bed.provider.start() bed.page?.send(.filterShowableInapps, ["inappIds": .array([.string("story-1")])]) - bed.provider.stop() - bed.feed.flush() - #expect(bed.feed.vouchCount == 0) + #expect(bed.inappService.askedBy == [EmbeddedBlockWebContent.stub.inAppId]) } @Test("An answer landing after a stop is dropped") func answerAfterStopIsDropped() { let bed = EmbeddedBlockTestBed() - bed.feed.isDeferred = true + bed.inappService.isDeferred = true bed.provider.start() bed.page?.send(.filterShowableInapps, ["inappIds": .array([.string("story-1")])]) bed.provider.stop() - bed.feed.flush() + bed.inappService.flush() #expect(bed.page?.responses.isEmpty == true) } @@ -734,7 +827,7 @@ struct EmbeddedBlockWebViewProviderTests { bed.page?.send(.showInApp, ["inappId": .string("story-id")]) - #expect(bed.feed.shown.map(\.id) == ["story-id"]) + #expect(bed.inappService.shown.map(\.id) == ["story-id"]) #expect(states.isEmpty) } @@ -747,7 +840,7 @@ struct EmbeddedBlockWebViewProviderTests { "lastContentUpdateDateTimeUtc": .string("2026-08-13T09:00:00.000000Z")] bed.page?.send(.showInApp, ["inappId": .string("story-id"), "params": .object(params)]) - #expect(bed.feed.shown.first?.params == params) + #expect(bed.inappService.shown.first?.params == params) } @Test("A stopped block does not answer at all") @@ -758,7 +851,7 @@ struct EmbeddedBlockWebViewProviderTests { bed.provider.stop() bed.page?.send(.showInApp, ["inappId": .string("story-id")]) - #expect(bed.feed.shown.isEmpty) + #expect(bed.inappService.shown.isEmpty) } @Test("A block collapsed as empty does not act on a show request") @@ -769,7 +862,7 @@ struct EmbeddedBlockWebViewProviderTests { bed.page?.reportRendered(0) bed.page?.send(.showInApp, ["inappId": .string("story-id")]) - #expect(bed.feed.shown.isEmpty) + #expect(bed.inappService.shown.isEmpty) } @Test("A failed block does not act on a show request") @@ -780,7 +873,7 @@ struct EmbeddedBlockWebViewProviderTests { bed.page?.failLoad() bed.page?.send(.showInApp, ["inappId": .string("story-id")]) - #expect(bed.feed.shown.isEmpty) + #expect(bed.inappService.shown.isEmpty) } @Test("A block broken by an unreadable report does not act on a show request") @@ -791,7 +884,7 @@ struct EmbeddedBlockWebViewProviderTests { bed.page?.reportRenderedWithoutCount() bed.page?.send(.showInApp, ["inappId": .string("story-id")]) - #expect(bed.feed.shown.isEmpty) + #expect(bed.inappService.shown.isEmpty) } @Test("A new attempt after a failure acts again") @@ -804,7 +897,7 @@ struct EmbeddedBlockWebViewProviderTests { bed.provider.start() bed.page?.send(.showInApp, ["inappId": .string("story-id")]) - #expect(bed.feed.shown.map(\.id) == ["story-id"]) + #expect(bed.inappService.shown.map(\.id) == ["story-id"]) } @Test("A message the block does not own leaves its state alone") @@ -891,21 +984,6 @@ struct EmbeddedBlockWebViewProviderTests { #expect(bed.provider.contentView === bed.page?.view) } - @Test("A return resumes the page of a block that had collapsed as empty") - func returnResumesACollapsedPage() { - let bed = EmbeddedBlockTestBed() - - bed.provider.start() - bed.page?.reportRendered(0) - bed.provider.stop() - bed.provider.start() - bed.page?.reportRendered(2) - - #expect(bed.resolver.resolveCount == 2) - #expect(bed.pageFactory.pages.count == 1) - #expect(bed.provider.contentView === bed.page?.view) - } - @Test("Page rendered before the block left the window is shown again without a reload") func renderedPageIsShownAgainWithoutReload() { let bed = EmbeddedBlockTestBed() @@ -937,7 +1015,7 @@ struct EmbeddedBlockWebViewProviderTests { frequency: .unlimited, tags: EmbeddedBlockWebContent.stub.tags, params: ["fresh": .bool(true)]) - bed.provider.apply(.content(sameId)) + bed.provider.apply(.content(sameId), processingDuration: 0) #expect(bed.pageFactory.pages.count == 1) #expect(bed.page?.initDataPushes == [["fresh": .bool(true)]]) @@ -951,7 +1029,7 @@ struct EmbeddedBlockWebViewProviderTests { bed.provider.stop() bed.resolver.resolution = .content(.other) - bed.provider.apply(.content(.other)) + bed.provider.apply(.content(.other), processingDuration: 0) var states: [EmbeddedBlockState] = [] bed.provider.onStateChange = { states.append($0) } bed.provider.start() @@ -983,7 +1061,7 @@ struct EmbeddedBlockWebViewProviderTests { let bed = EmbeddedBlockTestBed(resolution: .empty) bed.provider.start() bed.provider.stop() - bed.provider.apply(.empty) + bed.provider.apply(.empty, processingDuration: 0) bed.provider.start() @@ -995,7 +1073,7 @@ struct EmbeddedBlockWebViewProviderTests { let bed = EmbeddedBlockTestBed() bed.provider.start() bed.provider.abandonAttempt() - bed.provider.apply(.content(.other)) + bed.provider.apply(.content(.other), processingDuration: 0) bed.provider.reload() #expect(bed.pageFactory.contents.last == .stub) @@ -1013,7 +1091,7 @@ struct EmbeddedBlockWebViewProviderTests { bed.provider.start() bed.provider.abandonAttempt() - bed.provider.apply(.content(.other)) + bed.provider.apply(.content(.other), processingDuration: 0) bed.provider.start() #expect(bed.resolver.resolveCount == 2) @@ -1106,17 +1184,20 @@ struct EmbeddedBlockWebViewProviderTests { } @Test("The show reports the time the render took, not the time spent off screen") - func showReportsTheRenderTimeNotTheAbsence() async throws { + func showReportsTheRenderTimeNotTheAbsence() throws { let bed = EmbeddedBlockTestBed() + bed.resolver.processingDuration = 2 + bed.provider.start() + bed.clock.advance(0.75) bed.provider.stop() bed.page?.reportRendered(1) - try await Task.sleep(nanoseconds: 1_200_000_000) + bed.clock.advance(8) bed.provider.start() - #expect(bed.showReporter.reported.count == 1) - #expect(bed.showReporter.reported.first?.timeToDisplay.hasPrefix("0:00:00.") == true) + let show = try #require(bed.accounting.shows.first) + #expect(show.timeToDisplay == 2.75) } @Test("Failed block tries again when it comes back") diff --git a/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift index dad6ac8e0..ac88d696b 100644 --- a/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift +++ b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift @@ -238,7 +238,8 @@ struct MindboxEmbeddedBlockViewTests { block.page?.reportRendered(1) let content = try #require(block.page?.view) - block.page?.reportRendered(0) + block.bed.resolver.resolution = .empty + block.bed.announceNewConfig() #expect(content.superview == nil) } @@ -413,7 +414,8 @@ struct MindboxEmbeddedBlockViewTests { block.view.setAppearanceObserver { appearance.record($0) } block.page?.reportRendered(1) - block.page?.reportRendered(0) + block.bed.resolver.resolution = .empty + block.bed.announceNewConfig() #expect(appearance.values == [.placeholder, .content, .collapsed]) } @@ -655,6 +657,43 @@ struct MindboxEmbeddedBlockViewTests { #expect(MindboxEmbeddedBlockView.sanitizedTimeout(given, placeSystemName: "block") == effective) } + // MARK: - The place name + + @Test("A place name with surrounding whitespace is normalized at the block's boundary") + func paddedPlaceNameIsNormalized() { + let bed = EmbeddedBlockTestBed() + let factory = EmbeddedBlockContentProviderFactoryMock(provider: bed.provider) + // The container is process-global and the mode swap rebuilds it: save and restore both. + let savedBuilder = MBInject.buildTestContainer + let savedMode = MBInject.mode + defer { + MBInject.buildTestContainer = savedBuilder + MBInject.mode = savedMode + } + MBInject.buildTestContainer = { + let container = MBContainer() + container.register(EmbeddedBlockContentProviderMaking.self) { factory } + return container + } + MBInject.mode = .test + + let view = MindboxEmbeddedBlockView(placeSystemName: " stories \n", height: 120) + + #expect(view.placeSystemName == "stories") + #expect(factory.requestedPlaces == ["stories"]) + } + + @Test("Only the surrounding whitespace goes, the name itself is kept as it is", + arguments: [("stories", "stories"), + (" stories ", "stories"), + ("\tstories\n", "stories"), + ("my place", "my place"), + ("Stories", "Stories"), + (" ", "")]) + func placeNameNormalizationKeepsTheName(given: String, expected: String) { + #expect(MindboxEmbeddedBlockView.normalizedPlaceSystemName(given) == expected) + } + @Test("Silent block times out, collapses and reports didFail") func silentBlockTimesOut() async { let block = BlockFixture() @@ -741,6 +780,8 @@ struct MindboxEmbeddedBlockViewTests { #expect(block.view.intrinsicContentSize.height == 0) #expect(block.view.subviews.isEmpty) #expect(delegate.events == [.failed]) + #expect(block.bed.failureReporter.unansweredWaits == [block.waitBudgetBed.duration]) + #expect(block.bed.failureReporter.reported.isEmpty) } @Test("A page that was built and stayed silent fails") @@ -758,6 +799,26 @@ struct MindboxEmbeddedBlockViewTests { #expect(block.view.subviews.contains(errorView)) #expect(delegate.events == [.failed]) + #expect(block.bed.failureReporter.reasons == [.presentationFailed]) + #expect(block.bed.failureReporter.unansweredWaits.isEmpty) + } + + @Test("A delayed answer stands the wait budget down and keeps the placeholder") + func delayedAnswerStandsTheBudgetDown() async { + let block = BlockFixture(resolution: .content(.delayed())) + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + + #expect(!block.waitBudgetBed.budget.isRunning) + #expect(block.view.intrinsicContentSize.height == 120) + + block.expireTimeout() + await mainQueueTurn() + + #expect(delegate.events.isEmpty) + #expect(block.bed.failureReporter.unansweredWaits.isEmpty) } @Test("The answer restarts the waiting budget") @@ -774,27 +835,21 @@ struct MindboxEmbeddedBlockViewTests { #expect(block.waitBudgetBed.scheduler.lastDelay == block.waitBudgetBed.duration) } - /// The only test on real main-queue timing: the container builds the budget's duration itself - /// and no seam shows it. 50 ms is the answer timeout; the page's own budget is seconds long. @Test("A block waits the answer timeout for its answer and the page's own budget for the page") - func waitBudgetFollowsTheLoadingPhase() async throws { - let awaitingAnswer = RealBudgetBlockFixture(timeout: 0.05) + func waitBudgetFollowsTheLoadingPhase() { + let awaitingAnswer = OwnBudgetBlockFixture(timeout: 5) awaitingAnswer.bed.resolver.isDeferred = true awaitingAnswer.attachToWindow() - try await waitForCollapse(of: awaitingAnswer.view) - + #expect(awaitingAnswer.scheduler.lastDelay == 5) + awaitingAnswer.scheduler.fireAll() #expect(awaitingAnswer.view.intrinsicContentSize.height == 0) - let withPage = RealBudgetBlockFixture(timeout: 0.05) - let delegate = EmbeddedBlockViewDelegateMock() - withPage.view.delegate = delegate + let withPage = OwnBudgetBlockFixture(timeout: 5) withPage.attachToWindow() - try await Task.sleep(nanoseconds: 300_000_000) - + #expect(withPage.scheduler.lastDelay == 7) #expect(withPage.view.intrinsicContentSize.height == 120) - #expect(delegate.events.isEmpty) } @Test("Returning from the background does not arm a timeout outside a window") @@ -1119,15 +1174,6 @@ struct MindboxEmbeddedBlockViewTests { } } } - - /// Bounded, so a block that never gives up fails the test instead of hanging it. - private func waitForCollapse(of view: MindboxEmbeddedBlockView) async throws { - for _ in 0..<80 { - guard view.intrinsicContentSize.height != 0 else { return } - - try await Task.sleep(nanoseconds: 25_000_000) - } - } } /// A block with every dependency substituted and a live window: the window must outlive the test, @@ -1156,7 +1202,7 @@ private final class BlockFixture { self.view = MindboxEmbeddedBlockView(placeSystemName: "block-id", height: height, contentProvider: bed.provider, - waitBudget: waitBudgetBed.budget) + makeWaitBudget: { _, _ in waitBudgetBed.budget }) } func attachToWindow() { @@ -1181,23 +1227,35 @@ private final class BlockFixture { } } -/// A block on the waiting budget the container builds for itself, counted down by the main queue. +/// A block on the waiting budget the container builds for itself — its own switch between the answer +/// timeout and the page's budget — counted down by a test scheduler. @MainActor -private final class RealBudgetBlockFixture { +private final class OwnBudgetBlockFixture { let bed: EmbeddedBlockTestBed + let scheduler: TestScheduler + let view: MindboxEmbeddedBlockView private let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) init(timeout: TimeInterval) { let bed = EmbeddedBlockTestBed() + let scheduler = TestScheduler() self.bed = bed + self.scheduler = scheduler self.view = MindboxEmbeddedBlockView(placeSystemName: "block-id", height: 120, contentProvider: bed.provider, - timeout: timeout) + timeout: timeout, + makeWaitBudget: { place, duration in + EmbeddedBlockWaitBudget(placeSystemName: place, + duration: duration, + now: { bed.clock.now }, + notificationCenter: bed.center, + schedule: { scheduler.schedule($0, $1) }) + }) } func attachToWindow() { diff --git a/MindboxTests/Extensions/TimeIntervalTimeSpanTests.swift b/MindboxTests/Extensions/TimeIntervalTimeSpanTests.swift index c84a42581..9be942bf7 100644 --- a/MindboxTests/Extensions/TimeIntervalTimeSpanTests.swift +++ b/MindboxTests/Extensions/TimeIntervalTimeSpanTests.swift @@ -11,17 +11,33 @@ import XCTest final class TimeIntervalTimeSpanTests: XCTestCase { + func test_delayFromTimeSpan_missingOrUnreadableIsNoDelay() { + let cases: [(String?, TimeInterval)] = [ + (nil, 0), + ("invalid_time", 0), + ("00:00:00", 0), + ("00:00:05", 5), + ("00:01:30", 90), + ] + + for (input, expected) in cases { + XCTContext.runActivity(named: "delay(fromTimeSpan: \(input ?? "nil")) == \(expected)") { _ in + XCTAssertEqual(TimeInterval.delay(fromTimeSpan: input), expected) + } + } + } + func test_toTimeSpan_zero() { let result = TimeInterval(0).toTimeSpan() - XCTAssertEqual(result, "0:00:00.0000000") + XCTAssertEqual(result, "00:00:00.0000000") } func test_toTimeSpan_subSecondValues() { let cases: [(TimeInterval, String)] = [ - (0.1, "0:00:00.1000000"), - (0.001, "0:00:00.0010000"), - (0.1234567, "0:00:00.1234567"), - (0.4567890, "0:00:00.4567890"), + (0.1, "00:00:00.1000000"), + (0.001, "00:00:00.0010000"), + (0.1234567, "00:00:00.1234567"), + (0.4567890, "00:00:00.4567890"), ] for (input, expected) in cases { @@ -33,11 +49,11 @@ final class TimeIntervalTimeSpanTests: XCTestCase { func test_toTimeSpan_secondsAndMinutes() { let cases: [(TimeInterval, String)] = [ - (1.0, "0:00:01.0000000"), - (59.0, "0:00:59.0000000"), - (60.0, "0:01:00.0000000"), - (61.5, "0:01:01.5000000"), - (3599.0, "0:59:59.0000000"), + (1.0, "00:00:01.0000000"), + (59.0, "00:00:59.0000000"), + (60.0, "00:01:00.0000000"), + (61.5, "00:01:01.5000000"), + (3599.0, "00:59:59.0000000"), ] for (input, expected) in cases { @@ -49,8 +65,8 @@ final class TimeIntervalTimeSpanTests: XCTestCase { func test_toTimeSpan_hours() { let cases: [(TimeInterval, String)] = [ - (3600.0, "1:00:00.0000000"), - (7261.0, "2:01:01.0000000"), + (3600.0, "01:00:00.0000000"), + (7261.0, "02:01:01.0000000"), (86399.0, "23:59:59.0000000"), ] @@ -77,8 +93,8 @@ final class TimeIntervalTimeSpanTests: XCTestCase { func test_toTimeSpan_negativeValues() { let cases: [(TimeInterval, String)] = [ - (-0.001, "-0:00:00.0010000"), - (-1.0, "-0:00:01.0000000"), + (-0.001, "-00:00:00.0010000"), + (-1.0, "-00:00:01.0000000"), (-86400.0, "-1.00:00:00.0000000"), ] @@ -92,7 +108,7 @@ final class TimeIntervalTimeSpanTests: XCTestCase { func test_toTimeSpan_negativeZero_isNotNegative() { let result = TimeInterval(-0.0).toTimeSpan() XCTAssertFalse(result.hasPrefix("-"), "Negative zero should not produce a minus sign") - XCTAssertEqual(result, "0:00:00.0000000") + XCTAssertEqual(result, "00:00:00.0000000") } func test_toTimeSpan_roundTrip_withParseTimeSpanToMillis() { @@ -118,19 +134,19 @@ final class TimeIntervalTimeSpanTests: XCTestCase { func test_toTimeSpan_typicalSDKProcessingTimes() { XCTContext.runActivity(named: "50ms processing time") { _ in - XCTAssertEqual(TimeInterval(0.05).toTimeSpan(), "0:00:00.0500000") + XCTAssertEqual(TimeInterval(0.05).toTimeSpan(), "00:00:00.0500000") } XCTContext.runActivity(named: "250ms processing time") { _ in - XCTAssertEqual(TimeInterval(0.25).toTimeSpan(), "0:00:00.2500000") + XCTAssertEqual(TimeInterval(0.25).toTimeSpan(), "00:00:00.2500000") } XCTContext.runActivity(named: "1.5s processing time") { _ in - XCTAssertEqual(TimeInterval(1.5).toTimeSpan(), "0:00:01.5000000") + XCTAssertEqual(TimeInterval(1.5).toTimeSpan(), "00:00:01.5000000") } XCTContext.runActivity(named: "5s processing time") { _ in - XCTAssertEqual(TimeInterval(5.0).toTimeSpan(), "0:00:05.0000000") + XCTAssertEqual(TimeInterval(5.0).toTimeSpan(), "00:00:05.0000000") } } } diff --git a/MindboxTests/InApp/Tests/ForegroundStopwatchTests.swift b/MindboxTests/InApp/Tests/ForegroundStopwatchTests.swift index 2e4238180..bbd7e520c 100644 --- a/MindboxTests/InApp/Tests/ForegroundStopwatchTests.swift +++ b/MindboxTests/InApp/Tests/ForegroundStopwatchTests.swift @@ -11,89 +11,79 @@ import Foundation import UIKit @testable import Mindbox +// The stopwatch listens on the main queue; posting from it keeps the delivery synchronous, so the +// clock is read after the observer ran and not before. @Suite("ForegroundStopwatch tests") +@MainActor struct ForegroundStopwatchTests { - @Test("Elapsed time increases while in foreground") + private let nc = NotificationCenter() + private let clock = TestClock() + + private func makeStopwatch() -> ForegroundStopwatch { + ForegroundStopwatch(notificationCenter: nc, now: { clock.now }) + } + + private func enterBackground(for seconds: TimeInterval) { + nc.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + clock.advance(seconds) + nc.post(name: UIApplication.willEnterForegroundNotification, object: nil) + } + + @Test("Elapsed time is the foreground time since the start") func elapsed_inForeground_increases() throws { - let stopwatch = ForegroundStopwatch() - let first = stopwatch.elapsed - Thread.sleep(forTimeInterval: 0.05) - let second = stopwatch.elapsed - #expect(second > first) + let stopwatch = makeStopwatch() + #expect(stopwatch.elapsed == 0) + + clock.advance(0.5) + + #expect(stopwatch.elapsed == 0.5) stopwatch.stop() } @Test("Background time is excluded from elapsed") func elapsed_excludesBackgroundTime() throws { - let nc = NotificationCenter() - let stopwatch = ForegroundStopwatch(notificationCenter: nc) + let stopwatch = makeStopwatch() - Thread.sleep(forTimeInterval: 0.05) - let beforeBackground = stopwatch.elapsed - - nc.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - Thread.sleep(forTimeInterval: 0.2) - nc.post(name: UIApplication.willEnterForegroundNotification, object: nil) + clock.advance(0.25) + enterBackground(for: 2) - let afterForeground = stopwatch.elapsed - - let delta = afterForeground - beforeBackground - #expect(delta < 0.1, "Expected background time (~0.2s) to be excluded, but delta was \(delta)") + #expect(stopwatch.elapsed == 0.25) stopwatch.stop() } - @Test("Elapsed during background does not count background time") + @Test("Elapsed during background does not count the background so far") func elapsed_duringBackground_excludesCurrentBackgroundTime() throws { - let nc = NotificationCenter() - let stopwatch = ForegroundStopwatch(notificationCenter: nc) - - Thread.sleep(forTimeInterval: 0.05) - let beforeBackground = stopwatch.elapsed + let stopwatch = makeStopwatch() + clock.advance(0.25) nc.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - Thread.sleep(forTimeInterval: 0.2) + clock.advance(2) - let duringBackground = stopwatch.elapsed - let delta = duringBackground - beforeBackground - #expect(delta < 0.1, "Expected in-progress background time to be excluded, but delta was \(delta)") + #expect(stopwatch.elapsed == 0.25) stopwatch.stop() } @Test("Multiple background sessions are all excluded") func elapsed_multipleBackgroundSessions_allExcluded() throws { - let nc = NotificationCenter() - let stopwatch = ForegroundStopwatch(notificationCenter: nc) - - let start = stopwatch.elapsed + let stopwatch = makeStopwatch() - nc.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - Thread.sleep(forTimeInterval: 0.1) - nc.post(name: UIApplication.willEnterForegroundNotification, object: nil) + enterBackground(for: 1) + clock.advance(0.25) + enterBackground(for: 1) - nc.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - Thread.sleep(forTimeInterval: 0.1) - nc.post(name: UIApplication.willEnterForegroundNotification, object: nil) - - let end = stopwatch.elapsed - let delta = end - start - #expect(delta < 0.1, "Expected ~0.2s background time to be excluded, but delta was \(delta)") + #expect(stopwatch.elapsed == 0.25) stopwatch.stop() } - @Test("Stop removes observers and elapsed freezes behavior") + @Test("Stop removes the observers: a background after it is no longer excluded") func stop_removesObservers() throws { - let nc = NotificationCenter() - let stopwatch = ForegroundStopwatch(notificationCenter: nc) + let stopwatch = makeStopwatch() - Thread.sleep(forTimeInterval: 0.05) + clock.advance(0.25) stopwatch.stop() + enterBackground(for: 1) - nc.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - Thread.sleep(forTimeInterval: 0.1) - nc.post(name: UIApplication.willEnterForegroundNotification, object: nil) - - let elapsed = stopwatch.elapsed - #expect(elapsed >= 0.05) + #expect(stopwatch.elapsed == 1.25) } } diff --git a/MindboxTests/InApp/Tests/InAppConfigResponseTests/ConfigJsonStubs/EmbeddedBlockConfig.json b/MindboxTests/InApp/Tests/InAppConfigResponseTests/ConfigJsonStubs/EmbeddedBlockConfig.json index b25ffc81d..c68dba738 100644 --- a/MindboxTests/InApp/Tests/InAppConfigResponseTests/ConfigJsonStubs/EmbeddedBlockConfig.json +++ b/MindboxTests/InApp/Tests/InAppConfigResponseTests/ConfigJsonStubs/EmbeddedBlockConfig.json @@ -243,7 +243,7 @@ }, { "id": "33333333-3333-3333-3333-333333333333", - "isPriority": false, + "isPriority": true, "delayTime": null, "sdkVersion": { "min": 13, @@ -519,6 +519,166 @@ "tags": { "templateType": "Mixed" } + }, + { + "id": "cccccccc-cccc-cccc-cccc-cccccccccccc", + "isPriority": false, + "delayTime": null, + "sdkVersion": { + "min": 13, + "max": null + }, + "frequency": { + "$type": "unlimited" + }, + "validityPeriod": { + "dateTimeUtc": "2020-01-01T00:00:00.000000Z", + "$type": "dateTime" + }, + "displayConditions": null, + "targeting": { + "nodes": [ + { + "$type": "true" + } + ], + "$type": "and" + }, + "form": { + "variants": [ + { + "$type": "embedded", + "placeSystemName": "ab-block-place", + "content": { + "background": { + "layers": [ + { + "$type": "webview", + "baseUrl": "https://inapp.local/stories", + "contentUrl": "https://mobile-static-staging.mindbox.ru/inapps/webview/content/stories.html", + "params": { + "stories": [] + } + } + ] + }, + "elements": null + } + } + ] + }, + "tags": { + "templateType": "Embedded" + } + }, + { + "id": "dddddddd-dddd-dddd-dddd-dddddddddddd", + "isPriority": false, + "delayTime": null, + "sdkVersion": { + "min": 13, + "max": null + }, + "frequency": { + "$type": "unlimited" + }, + "validityPeriod": { + "dateTimeUtc": "2020-01-01T00:00:00.000000Z", + "$type": "dateTime" + }, + "displayConditions": { + "$type": "directCall" + }, + "targeting": { + "nodes": [ + { + "$type": "true" + } + ], + "$type": "and" + }, + "form": { + "variants": [ + { + "$type": "embedded", + "placeSystemName": "stories-list-container", + "content": { + "background": { + "layers": [ + { + "$type": "webview", + "baseUrl": "https://inapp.local/stories", + "contentUrl": "https://mobile-static-staging.mindbox.ru/inapps/webview/content/stories.html", + "params": { + "stories": [] + } + } + ] + }, + "elements": null + } + } + ] + }, + "tags": { + "templateType": "Embedded" + } + } + ], + "abtests": [ + { + "id": "0ec6be6b-421f-464b-9ee4-348a5292a5fd", + "sdkVersion": { + "min": 6, + "max": null + }, + "salt": "BBBC2BA1-0B5B-4C9E-AB0E-95C54775B4F5", + "variants": [ + { + "id": "AA9DBF50-67C5-4376-8ABF-A93B3D460550", + "modulus": { + "lower": 0, + "upper": 30 + }, + "objects": [ + { + "$type": "inapps", + "kind": "concrete", + "inapps": [ + "cccccccc-cccc-cccc-cccc-cccccccccccc" + ] + } + ] + }, + { + "id": "C5130E69-19E5-4F51-B737-F8A697326BA3", + "modulus": { + "lower": 30, + "upper": 65 + }, + "objects": [ + { + "$type": "inapps", + "kind": "concrete", + "inapps": [] + } + ] + }, + { + "id": "AC0110BA-C861-4CB4-8B2B-5310BA87E318", + "modulus": { + "lower": 65, + "upper": 100 + }, + "objects": [ + { + "$type": "inapps", + "kind": "concrete", + "inapps": [] + } + ] + } + ] } ] } diff --git a/MindboxTests/InApp/Tests/InAppConfigResponseTests/EmbeddedBlockResolveTests.swift b/MindboxTests/InApp/Tests/InAppConfigResponseTests/EmbeddedBlockResolveTests.swift index d1095706a..2d6a65ef9 100644 --- a/MindboxTests/InApp/Tests/InAppConfigResponseTests/EmbeddedBlockResolveTests.swift +++ b/MindboxTests/InApp/Tests/InAppConfigResponseTests/EmbeddedBlockResolveTests.swift @@ -32,6 +32,12 @@ struct EmbeddedBlockResolveTests { static let operationName = "block.refresh.operation" static let segmentStoryId = "99999999-9999-9999-9999-999999999999" static let mixedId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + static let abPlace = "ab-block-place" + static let abBlockId = "cccccccc-cccc-cccc-cccc-cccccccccccc" + static let directCallBlockId = "dddddddd-dddd-dddd-dddd-dddddddddddd" + // The fixture's A/B test shares its salt with the overlay A/B fixture; these devices hash into its first and second branch. + static let deviceKeepingAbBlock = "40909d27-4bef-4a8d-9164-6bfcf58ecc76" + static let deviceCuttingAbBlock = "b4e0f767-fe8f-4825-9772-f1162f2db52d" } private let mapper: InappMapperProtocol @@ -56,9 +62,11 @@ struct EmbeddedBlockResolveTests { candidates = config.candidates } - private func resolvePlace(_ place: String, trigger: ApplicationEvent? = nil) async -> InAppTransitionData? { + private func resolvePlace(_ place: String, + trigger: ApplicationEvent? = nil, + candidates: ConfigCandidates? = nil) async -> InAppTransitionData? { await withCheckedContinuation { continuation in - mapper.selectInappForPlace(place, trigger: trigger, candidates) { continuation.resume(returning: $0) } + mapper.selectInappForPlace(place, trigger: trigger, candidates ?? self.candidates) { continuation.resume(returning: $0) } } } @@ -68,13 +76,17 @@ struct EmbeddedBlockResolveTests { } } - private func showable(_ ids: [String]) async -> [String] { + private func showable(_ ids: [String], + askedBy blockInappId: String = Constants.blockId, + candidates: ConfigCandidates? = nil) async -> [String] { + await showableInOrder(ids, askedBy: blockInappId, candidates: candidates).sorted() + } + + private func showableInOrder(_ ids: [String], + askedBy blockInappId: String = Constants.blockId, + candidates: ConfigCandidates? = nil) async -> [String] { await withCheckedContinuation { continuation in - mapper.getShowableInappIds(ids, candidates) { answer in - // Delivering the answer, as a block does, is what sends targeting — the selection does not vouch by itself. - answer.vouch() - continuation.resume(returning: answer.inappIds.sorted()) - } + mapper.getShowableInappIds(ids, askedBy: blockInappId, candidates ?? self.candidates) { continuation.resume(returning: $0) } } } @@ -136,16 +148,48 @@ struct EmbeddedBlockResolveTests { #expect(vouched.count == 1) } - /// Every delivered answer is a new offer — the rule operation targeting lives by (in sync with Android). - @Test("A feed asking again vouches for the same in-apps again") - func feedVouchesPerDeliveredAnswer() async { + @Test("A page asking again vouches for nothing it was already told about") + func pageVouchesOncePerSession() async { _ = await showable(everyId) _ = await showable(everyId) + let vouched = dataFacade.trackTargetingCalls.filter { $0.id == Constants.unlimitedStoryId } + #expect(vouched.count == 1) + } + + @Test("Another block asking about the same in-app vouches for it again") + func anotherBlockVouchesAgain() async { + _ = await showable(everyId, askedBy: Constants.blockId) + _ = await showable(everyId, askedBy: Constants.mixedId) + let vouched = dataFacade.trackTargetingCalls.filter { $0.id == Constants.unlimitedStoryId } #expect(vouched.count == 2) } + @Test("A new session vouches for the page's in-apps again") + func newSessionVouchesForThePageAgain() async { + _ = await showable(everyId) + SessionTemporaryStorage.shared.erase() + _ = await showable(everyId) + + let vouched = dataFacade.trackTargetingCalls.filter { $0.id == Constants.unlimitedStoryId } + #expect(vouched.count == 2) + } + + @Test("An id asked twice is answered twice and vouched for once") + func duplicateIdIsMirroredAndVouchedOnce() async { + let answer = await showableInOrder([Constants.unlimitedStoryId, Constants.unlimitedStoryId]) + + #expect(answer == [Constants.unlimitedStoryId, Constants.unlimitedStoryId]) + #expect(dataFacade.trackTargetingCalls.filter { $0.id == Constants.unlimitedStoryId }.count == 1) + } + + @Test("The answer keeps the order the page asked in, a priority in-app included") + func answerKeepsTheAskedOrder() async { + #expect(await showableInOrder([Constants.modalId, Constants.unlimitedStoryId]) == [Constants.modalId, Constants.unlimitedStoryId]) + #expect(await showableInOrder([Constants.unlimitedStoryId, Constants.modalId]) == [Constants.unlimitedStoryId, Constants.modalId]) + } + @Test("A new session vouches for the in-app again") func newSessionVouchesAgain() async { _ = await resolvePlace(Constants.place) @@ -166,6 +210,43 @@ struct EmbeddedBlockResolveTests { #expect(persistenceStorage.lastInappStateChangeDate == nil) } + @Test("A place resolve that picked a winner drops the pass's buffered failures, like the overlay's pass") + func placeResolveWithWinnerDropsBufferedFailures() async { + _ = await resolvePlace(Constants.place) + + #expect(dataFacade.discardCollectedFailuresCalls == 1) + #expect(dataFacade.sendCollectedFailuresCalls == 0) + } + + @Test("A place resolve that picked nothing sends the pass's buffered failures") + func placeResolveWithoutWinnerSendsBufferedFailures() async { + _ = await resolvePlace("place-nobody-addresses") + + #expect(dataFacade.sendCollectedFailuresCalls == 1) + #expect(dataFacade.discardCollectedFailuresCalls == 0) + } + + @Test("A place resolve whose winner the show limits hold back drops the pass's buffered failures too") + func placeResolveWithWinnerHeldByBudgetsDropsBufferedFailures() async { + spendEveryShowBudget() + + #expect(await resolvePlace(Constants.cappedPlace) == nil) + #expect(dataFacade.discardCollectedFailuresCalls == 1) + #expect(dataFacade.sendCollectedFailuresCalls == 0) + } + + @Test("A place resolve hands the in-apps its targeting cut to the failure collection") + func placeResolveCollectsFailuresForTheCut() async { + _ = await resolvePlace(Constants.place) + + #expect(dataFacade.collectedTargetingFailureIds == [Set([Constants.operationBlockId])]) + } + + @Test("A direct-call in-app targeted by an operation stays valid, a direct call still has to open it") + func directCallWithOperationTargetingStaysValid() { + #expect(candidates.renderable.contains { $0.id == Constants.operationStoryId }) + } + @Test("Spent show limits do not stop an unlimited block") func showLimitsDoNotStopAnUnlimitedBlock() async throws { spendEveryShowBudget() @@ -187,13 +268,70 @@ struct EmbeddedBlockResolveTests { #expect(resolved.inAppId == Constants.cappedBlockId) } - @Test("A block stopped by the show limits is not vouched for") - func blockedByBudgetsIsNotVouchedFor() async { + @Test("A block stopped by the show limits is still vouched for") + func blockedByBudgetsIsStillVouchedFor() async { spendEveryShowBudget() - _ = await resolvePlace(Constants.cappedPlace) + #expect(await resolvePlace(Constants.cappedPlace) == nil) + #expect(dataFacade.trackTargetingCalls.contains { $0.id == Constants.cappedBlockId }) + } - #expect(dataFacade.trackTargetingCalls.contains { $0.id == Constants.cappedBlockId } == false) + @Test("Resolving a place vouches for every targeted in-app set up for it, not only the winner") + func placeVouchesForTheLosersToo() async throws { + let event = ApplicationEvent(name: Constants.operationName, model: nil) + + let resolved = try #require(await resolvePlace(Constants.place, trigger: event)) + + #expect(resolved.inAppId == Constants.operationBlockId) + #expect(Set(dataFacade.trackTargetingCalls.compactMap(\.id)) == [Constants.operationBlockId, Constants.blockId]) + } + + @Test("A place in-app spent by its frequency still gets its targeting") + func spentPlaceInappIsStillVouchedFor() async { + let persistenceStorage = DI.injectOrFail(PersistenceStorage.self) + persistenceStorage.shownDatesByInApp = [Constants.cappedBlockId: [Date()]] + + #expect(await resolvePlace(Constants.cappedPlace) == nil) + #expect(dataFacade.trackTargetingCalls.contains { $0.id == Constants.cappedBlockId }) + } + + @Test("A place in-app the A/B branch cut still gets its targeting") + func abCutPlaceInappIsStillVouchedFor() async { + let persistenceStorage = DI.injectOrFail(PersistenceStorage.self) + persistenceStorage.deviceUUID = Constants.deviceCuttingAbBlock + + #expect(await resolvePlace(Constants.abPlace, candidates: config.candidates) == nil) + #expect(dataFacade.trackTargetingCalls.contains { $0.id == Constants.abBlockId }) + } + + @Test("In the A/B branch that keeps it, the in-app wins its place") + func abKeptPlaceInappWins() async throws { + let persistenceStorage = DI.injectOrFail(PersistenceStorage.self) + persistenceStorage.deviceUUID = Constants.deviceKeepingAbBlock + + let resolved = try #require(await resolvePlace(Constants.abPlace, candidates: config.candidates)) + #expect(resolved.inAppId == Constants.abBlockId) + } + + @Test("A direct-call in-app at the place is not vouched for by the resolve") + func directCallPlaceInappIsNotVouchedFor() async throws { + let resolved = try #require(await resolvePlace(Constants.place)) + + #expect(resolved.inAppId == Constants.blockId) + #expect(dataFacade.trackTargetingCalls.contains { $0.id == Constants.directCallBlockId } == false) + } + + @Test("A place that goes back to an earlier winner vouches for it again") + func returningWinnerIsVouchedForAgain() async { + let event = ApplicationEvent(name: Constants.operationName, model: nil) + + _ = await resolvePlace(Constants.place) + _ = await resolvePlace(Constants.place, trigger: event) + _ = await resolvePlace(Constants.place) + + let ids = dataFacade.trackTargetingCalls.compactMap(\.id) + #expect(ids.filter { $0 == Constants.blockId }.count == 2) + #expect(ids.filter { $0 == Constants.operationBlockId }.count == 1) } private func spendEveryShowBudget() { @@ -249,15 +387,15 @@ struct EmbeddedBlockResolveTests { #expect(await resolveId("44444444-4444-4444-4444-444444444444") == nil) } - // MARK: - What a feed may draw + // MARK: - What a page may draw - @Test("A feed may draw the stories and the modal, but not the block") - func feedKeepsDirectCallAndDropsTheBlock() async { + @Test("A page may draw the stories and the modal, but not the block") + func pageKeepsDirectCallAndDropsTheBlock() async { #expect(await showable(everyId) == [Constants.unlimitedStoryId, Constants.modalId, Constants.onceStoryId].sorted()) } @Test("A watched unlimited story is still drawn") - func feedKeepsAWatchedUnlimitedStory() async { + func pageKeepsAWatchedUnlimitedStory() async { let persistenceStorage = DI.injectOrFail(PersistenceStorage.self) persistenceStorage.shownDatesByInApp = [Constants.unlimitedStoryId: [Date()]] @@ -265,7 +403,7 @@ struct EmbeddedBlockResolveTests { } @Test("A once story that was already shown is not drawn") - func feedDropsASpentOnceStory() async { + func pageDropsASpentOnceStory() async { let persistenceStorage = DI.injectOrFail(PersistenceStorage.self) persistenceStorage.shownDatesByInApp = [Constants.onceStoryId: [Date()]] @@ -273,12 +411,12 @@ struct EmbeddedBlockResolveTests { } @Test("An id no config knows is not drawn") - func feedDropsUnknownId() async { + func pageDropsUnknownId() async { #expect(await showable(["44444444-4444-4444-4444-444444444444"]).isEmpty) } - @Test("A feed vouches for every story it allows and for nothing it cuts") - func feedVouchesForWhatItAllows() async { + @Test("A page vouches for what it allows and never for a pure-embedded in-app") + func pageVouchesForWhatItAllows() async { let allowed = await showable(everyId) let vouched = Set(dataFacade.trackTargetingCalls.compactMap(\.id)) @@ -286,19 +424,37 @@ struct EmbeddedBlockResolveTests { #expect(!vouched.contains(Constants.blockId)) } - @Test("A story only an operation targets is not drawn for a feed") - func feedDropsAnOperationTargetedStory() async { + @Test("A story the A/B branch cut is vouched for but left out of the answer") + func abCutStoryIsVouchedForButNotDrawn() async { + let cut = ConfigCandidates(renderable: candidates.renderable, + inPool: candidates.inPool.filter { $0.id != Constants.unlimitedStoryId }) + + #expect(await showable([Constants.unlimitedStoryId], candidates: cut).isEmpty) + #expect(dataFacade.trackTargetingCalls.contains { $0.id == Constants.unlimitedStoryId }) + } + + @Test("A spent once story is vouched for but left out of the answer") + func spentOnceStoryIsVouchedForButNotDrawn() async { + let persistenceStorage = DI.injectOrFail(PersistenceStorage.self) + persistenceStorage.shownDatesByInApp = [Constants.onceStoryId: [Date()]] + + #expect(await showable([Constants.onceStoryId]).isEmpty) + #expect(dataFacade.trackTargetingCalls.contains { $0.id == Constants.onceStoryId }) + } + + @Test("A story only an operation targets is not drawn for a page and not vouched for") + func pageDropsAnOperationTargetedStory() async { #expect(await showable([Constants.operationStoryId]).isEmpty) + #expect(!dataFacade.trackTargetingCalls.contains { $0.id == Constants.operationStoryId }) } - // MARK: - The feed answers without the network + // MARK: - The page's question and the network - /// The wire contract gives the page three seconds: the feed is answered from what the session already fetched — fail closed, in sync with Android. - @Test("A feed's question asks nothing of the network") - func feedAsksNothingOfTheNetwork() async { + @Test("A page's question fetches the pass's dependencies like a place resolve") + func pageQuestionFetchesLikeAPlace() async { _ = await showable(everyId) - #expect(dataFacade.fetchDependenciesCalls == 0) + #expect(dataFacade.fetchDependenciesCalls == 1) } @Test("A place resolve still fetches its dependencies") @@ -308,14 +464,28 @@ struct EmbeddedBlockResolveTests { #expect(dataFacade.fetchDependenciesCalls == 1) } - @Test("A segment story on a cold cache is cut from the feed") + @Test("A place resolve prepares every renderable in-app's segmentations, not only its own place's") + func placeResolvePreparesEveryRenderableInapp() async { + _ = await resolvePlace(Constants.place) + + #expect(dataFacade.targetingChecker.context.segmentInapps.contains(Constants.segmentStoryId)) + } + + @Test("A page's question prepares every renderable in-app's segmentations, not only the asked ones") + func pageQuestionPreparesEveryRenderableInapp() async { + _ = await showable([Constants.unlimitedStoryId]) + + #expect(dataFacade.targetingChecker.context.segmentInapps.contains(Constants.segmentStoryId)) + } + + @Test("A segment story is cut when the fetch brings no segmentations") func coldCacheCutsASegmentStory() async { dataFacade.targetingChecker.checkedSegmentations = nil #expect(await showable([Constants.segmentStoryId]).isEmpty) } - @Test("A segment story on a warm cache is drawn without a fetch") + @Test("A segment story on a warm cache is drawn") func warmCacheKeepsASegmentStory() async { dataFacade.targetingChecker.checkedSegmentations = [ .init(segmentation: .init(ids: .init(externalId: "feed-segmentation")), @@ -323,23 +493,22 @@ struct EmbeddedBlockResolveTests { ] #expect(await showable([Constants.segmentStoryId]) == [Constants.segmentStoryId]) - #expect(dataFacade.fetchDependenciesCalls == 0) } - @Test("Spent show limits do not shrink a feed's answer") - func showLimitsDoNotShrinkTheFeedAnswer() async { + @Test("Spent show limits do not shrink a page's answer") + func showLimitsDoNotShrinkThePagesAnswer() async { spendEveryShowBudget() #expect(await showable([Constants.unlimitedStoryId]) == [Constants.unlimitedStoryId]) } @Test("An empty question gets an empty answer") - func feedAnswersNothingToNothing() async { + func pageAnswersNothingToNothing() async { #expect(await showable([]).isEmpty) } - @Test("Answering a feed writes nothing to the show history") - func feedAnswerWritesNothingToShowHistory() async { + @Test("Answering a page writes nothing to the show history") + func pageAnswerWritesNothingToShowHistory() async { _ = await showable(everyId) let persistenceStorage = DI.injectOrFail(PersistenceStorage.self) @@ -391,8 +560,6 @@ struct EmbeddedBlockResolveTests { #expect(formData.inAppId == Constants.onceStoryId) } - /// The feed offers a mixed form because it has an overlay variant; the tap has to open that one - /// even though the embedded variant comes first in the config. @Test("A tap on a mixed form opens its overlay variant") func mixedFormTapOpensTheOverlayVariant() async throws { let formData = try #require(await inappToShow(Constants.mixedId)) @@ -411,6 +578,14 @@ struct EmbeddedBlockResolveTests { #expect(await inappToShow(Constants.blockId) == nil) } + @Test("A tap that opens its in-app drops the failure buffer, like a pass that showed") + func tapThatShowsDropsTheFailureBuffer() async throws { + _ = try #require(await inappToShow(Constants.onceStoryId)) + + #expect(dataFacade.discardCollectedFailuresCalls == 1) + #expect(dataFacade.sendCollectedFailuresCalls == 0) + } + // MARK: - The trigger path on the same config @Test("The trigger path still picks the ordinary modal") @@ -444,8 +619,8 @@ struct EmbeddedBlockResolveTests { #expect(dataFacade.targetingArray.contains(Constants.mixedId)) } - @Test("A feed keeps a mixed in-app — its overlay half can be drawn") - func feedKeepsAMixedInapp() async { + @Test("A page keeps a mixed in-app — its overlay half can be drawn") + func pageKeepsAMixedInapp() async { #expect(await showable([Constants.mixedId]) == [Constants.mixedId]) } } diff --git a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappMapperTests.swift b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappMapperTests.swift index 1dc9479c6..0a991b0a6 100644 --- a/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappMapperTests.swift +++ b/MindboxTests/InApp/Tests/InAppConfigResponseTests/InappMapperTests.swift @@ -169,6 +169,28 @@ struct InappRemainingTargetingTests { ]) } + @Test("A pass that found a winner drops the failures of the in-apps it cut — something was shown", .tags(.remainingTargeting)) + func passWithWinner_dropsTheCutInappsFailures() async throws { + let config = try InappTargetingConfig.tagsFailedTargeting.getConfig() + + await handleInapps(event: nil, config: config) + + assertTargetingShows(id: "2") + #expect(mockDataFacade.collectedTargetingFailureIds == [Set(["1", "3"])]) + #expect(mockDataFacade.discardCollectedFailuresCalls == 1) + #expect(mockDataFacade.sendCollectedFailuresCalls == 0) + } + + @Test("A pass that found nothing to show sends the failures it collected", .tags(.remainingTargeting)) + func passWithoutWinner_sendsTheCollectedFailures() async throws { + let config = try InappTargetingConfig.tagsFailedTargeting.getConfig() + + await handleInapps(event: ApplicationEvent(name: "nobody.listens", model: nil), config: config) + + #expect(mockDataFacade.sendCollectedFailuresCalls == 1) + #expect(mockDataFacade.discardCollectedFailuresCalls == 0) + } + @Test("Shown in-app propagates its tags into trackTargeting and downloadImage", .tags(.remainingTargeting, .inAppTags)) func shownInapp_propagatesTagsToTrackTargetingAndDownloadImage() async throws { let config = try InappTargetingConfig.tagsFailedTargeting.getConfig() @@ -234,6 +256,23 @@ struct InappRemainingTargetingTests { #expect(mockDataFacade.imageDownloadFailures.isEmpty) } + @Test("A tap whose image fails to download reports the failure at once", .tags(.remainingTargeting)) + func tapImageDownloadError_sendsTheFailureAtOnce() async throws { + let config = try InappTargetingConfig.oneTargeting.getConfig() + mockDataFacade.downloadImageError = MindboxError.serverError( + .init(status: .internalServerError, errorMessage: "image download failed", httpStatusCode: 500) + ) + + let formData = await withCheckedContinuation { continuation in + mapper.getInAppToShowById("1", params: [:], config.candidates) { continuation.resume(returning: $0) } + } + + #expect(formData == nil) + #expect(mockDataFacade.imageDownloadFailures.count == 1) + #expect(mockDataFacade.sendCollectedFailuresCalls == 1) + #expect(mockDataFacade.discardCollectedFailuresCalls == 0) + } + @Test("Single geo in-app, not shown before", .tags(.remainingTargeting, .geoTargeting)) func oneInappGeo_notShownBefore() async throws { let config = try InappTargetingConfig.sevenRequests.getConfig() diff --git a/MindboxTests/InApp/Tests/InAppConfigurationManagerTests.swift b/MindboxTests/InApp/Tests/InAppConfigurationManagerTests.swift index 2c237270b..41b464af8 100644 --- a/MindboxTests/InApp/Tests/InAppConfigurationManagerTests.swift +++ b/MindboxTests/InApp/Tests/InAppConfigurationManagerTests.swift @@ -8,6 +8,7 @@ import Foundation import Testing +import QuartzCore import class MindboxLogger.Locked @testable import Mindbox @@ -88,8 +89,16 @@ struct InAppConfigurationManagerTests { wrapped.filter(place: place, in: candidates) } - func filter(feedIds ids: [String], in candidates: ConfigCandidates) -> [InApp] { - wrapped.filter(feedIds: ids, in: candidates) + func inapps(addressedTo place: String, in candidates: ConfigCandidates) -> [InApp] { + wrapped.inapps(addressedTo: place, in: candidates) + } + + func inapps(askedAbout ids: [String], in candidates: ConfigCandidates) -> [InApp] { + wrapped.inapps(askedAbout: ids, in: candidates) + } + + func filter(requestedIds ids: [String], in candidates: ConfigCandidates) -> [InApp] { + wrapped.filter(requestedIds: ids, in: candidates) } func filter(id: String, in candidates: ConfigCandidates) -> InApp? { @@ -112,11 +121,6 @@ struct InAppConfigurationManagerTests { wrapped.filterInappsByOperationForShow(event: event, operationInapps: operationInapps, in: candidates) } - func filterInappsByTargeting(inapps: [InApp], - targetingChecker: InAppTargetingCheckerProtocol) -> [InAppTransitionData] { - wrapped.filterInappsByTargeting(inapps: inapps, targetingChecker: targetingChecker) - } - func filterInappsByTargeting(inapps: [InApp], targetingChecker: InAppTargetingCheckerProtocol, pickVariant: (InApp) -> MindboxFormVariant?) -> [InAppTransitionData] { @@ -135,15 +139,23 @@ struct InAppConfigurationManagerTests { persistenceStorage.shownDatesByInApp = [:] persistenceStorage.deviceUUID = "00000000-0000-0000-0000-000000000000" - manager = InAppConfigurationManager( + manager = Self.makeManager(api: api, configWaitBudget: 0.2) + } + + private static func makeManager(api: InAppConfigurationAPI, + configWaitBudget: TimeInterval, + inappFilterService: InappFilterProtocol = DI.injectOrFail(InappFilterProtocol.self), + now: @escaping () -> TimeInterval = { CACurrentMediaTime() }) -> InAppConfigurationManager { + InAppConfigurationManager( inAppConfigAPI: api, inAppConfigRepository: EmptyConfigRepository(), inappMapper: DI.injectOrFail(InappMapperProtocol.self), - persistenceStorage: persistenceStorage, + persistenceStorage: DI.injectOrFail(PersistenceStorage.self), featureToggleManager: DI.injectOrFail(FeatureToggleManager.self), webViewPrewarmService: DI.injectOrFail(InAppWebViewPrewarmServiceProtocol.self), - inappFilterService: DI.injectOrFail(InappFilterProtocol.self), - configWaitBudget: 0.2 + inappFilterService: inappFilterService, + configWaitBudget: configWaitBudget, + now: now ) } @@ -171,7 +183,7 @@ struct InAppConfigurationManagerTests { try await waitUntil(api.isFetchPending) let answers = Answers<[String]>() - manager.getShowableInappIds([Constants.liveStoryId]) { answers.append($0.inappIds) } + manager.getShowableInappIds([Constants.liveStoryId], askedBy: "a-block") { answers.append($0) } api.deliver(.data(try fixtureData())) @@ -186,7 +198,7 @@ struct InAppConfigurationManagerTests { api.deliver(.data(try fixtureData())) let answers = Answers<[String]>() - manager.getShowableInappIds([Constants.liveStoryId]) { answers.append($0.inappIds) } + manager.getShowableInappIds([Constants.liveStoryId], askedBy: "a-block") { answers.append($0) } try await waitUntil(!answers.isEmpty) #expect(answers.all == [[Constants.liveStoryId]]) @@ -197,29 +209,32 @@ struct InAppConfigurationManagerTests { manager.prepareConfiguration() let answers = Answers<[String]>() - manager.getShowableInappIds([Constants.liveStoryId]) { answers.append($0.inappIds) } + manager.getShowableInappIds([Constants.liveStoryId], askedBy: "a-block") { answers.append($0) } try await waitUntil(!answers.isEmpty) #expect(answers.all == [[]]) } + @Test("A config is in hand only once the download concluded with one") + func hasConfigFollowsTheDownload() async throws { + #expect(!manager.hasConfig) + manager.prepareConfiguration() + try await waitUntil(api.isFetchPending) + #expect(!manager.hasConfig) + + api.deliver(.data(try fixtureData())) + + try await waitUntil(manager.hasConfig) + } + @Test("A failed download with no cache answers with nothing at once") func failedDownloadAnswersWithoutWaitingOutTheBudget() async throws { - let slowBudgetManager = InAppConfigurationManager( - inAppConfigAPI: api, - inAppConfigRepository: EmptyConfigRepository(), - inappMapper: DI.injectOrFail(InappMapperProtocol.self), - persistenceStorage: DI.injectOrFail(PersistenceStorage.self), - featureToggleManager: DI.injectOrFail(FeatureToggleManager.self), - webViewPrewarmService: DI.injectOrFail(InAppWebViewPrewarmServiceProtocol.self), - inappFilterService: DI.injectOrFail(InappFilterProtocol.self), - configWaitBudget: 60 - ) + let slowBudgetManager = Self.makeManager(api: api, configWaitBudget: 60) slowBudgetManager.prepareConfiguration() try await waitUntil(api.isFetchPending) let answers = Answers<[String]>() - slowBudgetManager.getShowableInappIds([Constants.liveStoryId]) { answers.append($0.inappIds) } + slowBudgetManager.getShowableInappIds([Constants.liveStoryId], askedBy: "a-block") { answers.append($0) } api.deliver(.error(MindboxError.connectionError)) try await waitUntil(!answers.isEmpty) @@ -232,7 +247,7 @@ struct InAppConfigurationManagerTests { try await waitUntil(api.isFetchPending) let answers = Answers<[String]>() - manager.getShowableInappIds([Constants.liveStoryId]) { answers.append($0.inappIds) } + manager.getShowableInappIds([Constants.liveStoryId], askedBy: "a-block") { answers.append($0) } try await waitUntil(!answers.isEmpty) api.deliver(.data(try fixtureData())) @@ -243,22 +258,13 @@ struct InAppConfigurationManagerTests { @Test("A caller arriving after a failed download is answered with nothing at once") func callerAfterFailedDownloadDoesNotWaitOutTheBudget() async throws { - let slowBudgetManager = InAppConfigurationManager( - inAppConfigAPI: api, - inAppConfigRepository: EmptyConfigRepository(), - inappMapper: DI.injectOrFail(InappMapperProtocol.self), - persistenceStorage: DI.injectOrFail(PersistenceStorage.self), - featureToggleManager: DI.injectOrFail(FeatureToggleManager.self), - webViewPrewarmService: DI.injectOrFail(InAppWebViewPrewarmServiceProtocol.self), - inappFilterService: DI.injectOrFail(InappFilterProtocol.self), - configWaitBudget: 60 - ) + let slowBudgetManager = Self.makeManager(api: api, configWaitBudget: 60) slowBudgetManager.prepareConfiguration() try await waitUntil(api.isFetchPending) api.deliver(.error(MindboxError.connectionError)) let answers = Answers<[String]>() - slowBudgetManager.getShowableInappIds([Constants.liveStoryId]) { answers.append($0.inappIds) } + slowBudgetManager.getShowableInappIds([Constants.liveStoryId], askedBy: "a-block") { answers.append($0) } try await waitUntil(!answers.isEmpty) #expect(answers.all == [[]]) @@ -270,39 +276,50 @@ struct InAppConfigurationManagerTests { try await waitUntil(api.isFetchPending) let answers = Answers() - manager.selectInappForPlace("stories-list-container", trigger: nil) { answers.append($0) } + manager.selectInappForPlace("stories-list-container", trigger: nil) { inapp, _ in answers.append(inapp) } api.deliver(.data(try fixtureData())) try await waitUntil(!answers.isEmpty) #expect((answers.first ?? nil)?.inAppId == "11111111-1111-1111-1111-111111111111") } - @Test("One applied config is prepared once, however many blocks and feeds ask") + @Test("The place's processing time runs from the block's request, the wait for the config included") + func placeProcessingTimeIncludesTheWaitForTheConfig() async throws { + let clock = TestClock() + let patientManager = Self.makeManager(api: api, configWaitBudget: 60, now: { clock.now }) + patientManager.prepareConfiguration() + try await waitUntil(api.isFetchPending) + + let answers = Answers<(inapp: InAppTransitionData?, duration: TimeInterval)>() + patientManager.selectInappForPlace("stories-list-container", trigger: nil) { inapp, processingDuration in + answers.append((inapp, processingDuration)) + } + clock.advance(12.5) + api.deliver(.data(try fixtureData())) + + try await waitUntil(!answers.isEmpty) + let answer = try #require(answers.first) + #expect(answer.inapp != nil, "the place was not answered from the config") + #expect(answer.duration == 12.5) + } + + @Test("One applied config is prepared once, however many blocks and pages ask") func configIsPreparedOncePerDownload() async throws { let counting = CountingFilterService() - let manager = InAppConfigurationManager( - inAppConfigAPI: api, - inAppConfigRepository: EmptyConfigRepository(), - inappMapper: DI.injectOrFail(InappMapperProtocol.self), - persistenceStorage: DI.injectOrFail(PersistenceStorage.self), - featureToggleManager: DI.injectOrFail(FeatureToggleManager.self), - webViewPrewarmService: DI.injectOrFail(InAppWebViewPrewarmServiceProtocol.self), - inappFilterService: counting, - configWaitBudget: 0.2 - ) + let manager = Self.makeManager(api: api, configWaitBudget: 0.2, inappFilterService: counting) manager.prepareConfiguration() try await waitUntil(api.isFetchPending) api.deliver(.data(try fixtureData())) - let feeds = Answers<[String]>() + let pages = Answers<[String]>() let places = Answers() - manager.getShowableInappIds([Constants.liveStoryId]) { feeds.append($0.inappIds) } - manager.selectInappForPlace("stories-list-container", trigger: nil) { places.append($0) } - manager.getShowableInappIds([Constants.liveStoryId]) { feeds.append($0.inappIds) } - manager.selectInappForPlace("stories-list-container", trigger: nil) { places.append($0) } + manager.getShowableInappIds([Constants.liveStoryId], askedBy: "a-block") { pages.append($0) } + manager.selectInappForPlace("stories-list-container", trigger: nil) { inapp, _ in places.append(inapp) } + manager.getShowableInappIds([Constants.liveStoryId], askedBy: "a-block") { pages.append($0) } + manager.selectInappForPlace("stories-list-container", trigger: nil) { inapp, _ in places.append(inapp) } - try await waitUntil(feeds.all.count == 2 && places.all.count == 2) + try await waitUntil(pages.all.count == 2 && places.all.count == 2) #expect(counting.prepareCount == 1) } @@ -313,7 +330,7 @@ struct InAppConfigurationManagerTests { api.deliver(.data(try fixtureData())) let withBlock = Answers() - manager.selectInappForPlace("stories-list-container", trigger: nil) { withBlock.append($0) } + manager.selectInappForPlace("stories-list-container", trigger: nil) { inapp, _ in withBlock.append(inapp) } try await waitUntil(!withBlock.isEmpty) #expect((withBlock.first ?? nil)?.inAppId == "11111111-1111-1111-1111-111111111111") @@ -322,7 +339,7 @@ struct InAppConfigurationManagerTests { api.deliver(.empty) let withoutBlock = Answers() - manager.selectInappForPlace("stories-list-container", trigger: nil) { withoutBlock.append($0) } + manager.selectInappForPlace("stories-list-container", trigger: nil) { inapp, _ in withoutBlock.append(inapp) } try await waitUntil(!withoutBlock.isEmpty) #expect((withoutBlock.first ?? nil)?.inAppId == nil) } diff --git a/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/ConfigCandidatesTests.swift b/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/ConfigCandidatesTests.swift index 9e97a2165..748371488 100644 --- a/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/ConfigCandidatesTests.swift +++ b/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/ConfigCandidatesTests.swift @@ -80,7 +80,7 @@ struct ConfigCandidatesTests { _ = sut.filterForTrigger(in: candidates) _ = sut.filter(place: Constants.place, in: candidates) - _ = sut.filter(feedIds: [Constants.storyId], in: candidates) + _ = sut.filter(requestedIds: [Constants.storyId], in: candidates) _ = sut.filter(id: Constants.blockId, in: candidates) _ = sut.filterForTrigger(in: candidates) @@ -94,7 +94,7 @@ struct ConfigCandidatesTests { #expect(ids(candidates.renderable).contains(Constants.storyId)) #expect(!ids(sut.filterForTrigger(in: candidates)).contains(Constants.storyId)) #expect(ids(sut.filterForTrigger(in: candidates)).contains(Constants.modalId)) - #expect(ids(sut.filter(feedIds: [Constants.storyId], in: candidates)) == [Constants.storyId]) + #expect(ids(sut.filter(requestedIds: [Constants.storyId], in: candidates)) == [Constants.storyId]) } @Test("The AB-test cut lands in the pool, and the paths that must ignore it see the whole list") @@ -108,7 +108,7 @@ struct ConfigCandidatesTests { #expect(ids(candidates.renderable).contains(droppedId)) #expect(sut.filter(id: droppedId, in: candidates) != nil) - #expect(ids(sut.filter(feedIds: [Constants.storyId, Constants.secondStoryId], in: candidates)) == inPool.intersection([Constants.storyId, Constants.secondStoryId])) + #expect(ids(sut.filter(requestedIds: [Constants.storyId, Constants.secondStoryId], in: candidates)) == inPool.intersection([Constants.storyId, Constants.secondStoryId])) } /// The fixture plus an A/B test whose two branches cover the whole range and name one story each, diff --git a/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/EmbeddedFormVariantTests.swift b/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/EmbeddedFormVariantTests.swift index c292be4b3..df58bc8aa 100644 --- a/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/EmbeddedFormVariantTests.swift +++ b/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/EmbeddedFormVariantTests.swift @@ -58,16 +58,14 @@ struct EmbeddedFormVariantTests { #expect(place(of: variants.first) == "stories-list-container") } - /// The name travels through JSON, so the escaped case arrives with a real newline and tab — - /// hence the expected value next to each input. - @Test("Place name keeps whatever padding it came with", arguments: [ - (" stories-list-container", " stories-list-container"), - ("stories-list-container ", "stories-list-container "), - (#"\n stories-list-container \t"#, "\n stories-list-container \t") + @Test("Place name is trimmed", arguments: [ + " stories-list-container", + "stories-list-container ", + #"\n stories-list-container \t"# ]) - func keepsPlaceNamePadding(place: String, expected: String) throws { + func trimsPlaceName(place: String) throws { let variants = try filter(embeddedVariant(place: place)) - #expect(self.place(of: variants.first) == expected) + #expect(self.place(of: variants.first) == "stories-list-container") } @Test("Place name case is preserved") @@ -77,7 +75,7 @@ struct EmbeddedFormVariantTests { } @Test("A variant that cannot address a block is dropped", arguments: [ - nil, "" + nil, "", " " ] as [String?]) func dropsVariantWithoutPlace(place: String?) throws { #expect(try filter(embeddedVariant(place: place)).isEmpty) diff --git a/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/InappFilterServiceTests.swift b/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/InappFilterServiceTests.swift index bb8a13a9f..3878a3f62 100644 --- a/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/InappFilterServiceTests.swift +++ b/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/InappFilterServiceTests.swift @@ -32,6 +32,36 @@ final class InappFilterServiceTests: XCTestCase { super.tearDown() } + // MARK: - An event-only targeting on a direct-call in-app + + private var operationNode: Targeting { + .apiMethodCall(CustomOperationTargeting(systemName: "custom.operation")) + } + + func test_requiresEvent_isTrueForAnOperationNode() { + XCTAssertTrue(operationNode.requiresEvent) + XCTAssertTrue(Targeting.viewProductId(ProductIDTargeting(kind: .substring, value: "1")).requiresEvent) + } + + func test_requiresEvent_isTrueWhenAnAndBranchNeedsTheEvent() { + let targeting = Targeting.and(AndTargeting(nodes: [.true(TrueTargeting()), operationNode])) + + XCTAssertTrue(targeting.requiresEvent) + } + + func test_requiresEvent_isFalseWhenAnOrBranchNeedsNoEvent() { + let targeting = Targeting.or(OrTargeting(nodes: [.true(TrueTargeting()), operationNode])) + + XCTAssertFalse(targeting.requiresEvent) + } + + func test_requiresEvent_isFalseWithoutEventNodes() { + let targeting = Targeting.and(AndTargeting(nodes: [.true(TrueTargeting()), .visit(VisitTargeting(kind: .equals, value: 1))])) + + XCTAssertFalse(targeting.requiresEvent) + XCTAssertFalse(Targeting.or(OrTargeting(nodes: [])).requiresEvent) + } + func test_unknown_type_for_variants() throws { let config = try getConfig(name: "unknownVariantType") let inapps = triggerCandidates(of: config) diff --git a/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/InappOverlayFilterTests.swift b/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/InappOverlayFilterTests.swift index 40a9c57cf..1a30ecf49 100644 --- a/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/InappOverlayFilterTests.swift +++ b/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/InappOverlayFilterTests.swift @@ -70,7 +70,9 @@ struct InappOverlayFilterTests { let checker = DI.injectOrFail(InAppTargetingCheckerProtocol.self) checker.prepare(id: "1", targeting: .true(TrueTargeting())) - let transitions = sut.filterInappsByTargeting(inapps: inapps, targetingChecker: checker) + let transitions = sut.filterInappsByTargeting(inapps: inapps, targetingChecker: checker) { candidate in + candidate.form.variants.first { $0.isOverlayPresentable } + } #expect(transitions.first?.content == modal()) } diff --git a/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/InappPlaceFilterTests.swift b/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/InappPlaceFilterTests.swift index 44953d945..b302d660d 100644 --- a/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/InappPlaceFilterTests.swift +++ b/MindboxTests/InApp/Tests/InAppConfigurationMapperTests/InappFilterServiceTests/InappPlaceFilterTests.swift @@ -55,33 +55,35 @@ struct InappPlaceFilterTests { private func ids(_ inapps: [InApp]) -> [String] { inapps.map { $0.id } } + private func candidates(_ inapps: [InApp]) -> ConfigCandidates { ConfigCandidates(renderable: inapps, inPool: inapps) } + // MARK: - Addressing @Test("An in-app set up for the place is a candidate") func keepsInappForPlace() throws { let sut = try #require(sut) - let inapps = sut.filterInappsForPlace(place, inapps: [inapp(id: "1", variants: [embedded(place: place)])]) + let inapps = sut.filter(place: place, in: candidates([inapp(id: "1", variants: [embedded(place: place)])])) #expect(ids(inapps) == ["1"]) } @Test("An in-app set up for another place is not") func dropsInappForAnotherPlace() throws { let sut = try #require(sut) - let inapps = sut.filterInappsForPlace(place, inapps: [inapp(id: "1", variants: [embedded(place: "other-place")])]) + let inapps = sut.filter(place: place, in: candidates([inapp(id: "1", variants: [embedded(place: "other-place")])])) #expect(inapps.isEmpty) } @Test("Place names are case-sensitive") func placeNamesAreCaseSensitive() throws { let sut = try #require(sut) - let inapps = sut.filterInappsForPlace(place, inapps: [inapp(id: "1", variants: [embedded(place: "Stories-List-Container")])]) + let inapps = sut.filter(place: place, in: candidates([inapp(id: "1", variants: [embedded(place: "Stories-List-Container")])])) #expect(inapps.isEmpty) } @Test("A modal in-app is never a candidate for a place") func dropsOverlayInapp() throws { let sut = try #require(sut) - let inapps = sut.filterInappsForPlace(place, inapps: [inapp(id: "1", variants: [modal()])]) + let inapps = sut.filter(place: place, in: candidates([inapp(id: "1", variants: [modal()])])) #expect(inapps.isEmpty) } @@ -90,8 +92,7 @@ struct InappPlaceFilterTests { @Test("Direct call keeps the block empty on this path too") func dropsDirectCallInapp() throws { let sut = try #require(sut) - let inapps = sut.filterInappsForPlace(place, - inapps: [inapp(id: "1", variants: [embedded(place: place)], displayConditions: .directCall)]) + let inapps = sut.filter(place: place, in: candidates([inapp(id: "1", variants: [embedded(place: place)], displayConditions: .directCall)])) #expect(inapps.isEmpty) } @@ -102,8 +103,7 @@ struct InappPlaceFilterTests { ]) func frequencyPassesForUnshownBlock(frequency: InappFrequency) throws { let sut = try #require(sut) - let inapps = sut.filterInappsForPlace(place, - inapps: [inapp(id: "unshown-block-id", variants: [embedded(place: place)], frequency: frequency)]) + let inapps = sut.filter(place: place, in: candidates([inapp(id: "unshown-block-id", variants: [embedded(place: place)], frequency: frequency)])) #expect(ids(inapps) == ["unshown-block-id"]) } @@ -126,18 +126,17 @@ struct InappPlaceFilterTests { storage.shownDatesByInApp = ["shown-block-id": [Date()]] SessionTemporaryStorage.shared.sessionShownInApps = ["shown-block-id"] - let inapps = sut.filterInappsForPlace(place, - inapps: [inapp(id: "shown-block-id", variants: [embedded(place: place)], frequency: frequency)]) + let inapps = sut.filter(place: place, in: candidates([inapp(id: "shown-block-id", variants: [embedded(place: place)], frequency: frequency)])) #expect(inapps.isEmpty) } @Test("Two candidates for one place come back with the priority one first") func sortsCandidatesByPriority() throws { let sut = try #require(sut) - let inapps = sut.filterInappsForPlace(place, inapps: [ + let inapps = sut.filter(place: place, in: candidates([ inapp(id: "regular", variants: [embedded(place: place)]), inapp(id: "priority", variants: [embedded(place: place)], isPriority: true) - ]) + ])) #expect(ids(inapps) == ["priority", "regular"]) } diff --git a/MindboxTests/InApp/Tests/InAppCoreManagerTests.swift b/MindboxTests/InApp/Tests/InAppCoreManagerTests.swift index e2d72f101..5c02cc4a2 100644 --- a/MindboxTests/InApp/Tests/InAppCoreManagerTests.swift +++ b/MindboxTests/InApp/Tests/InAppCoreManagerTests.swift @@ -15,19 +15,22 @@ struct InAppCoreManagerTests { private final class ConfigManagerStub: InAppConfigurationManagerProtocol { weak var delegate: InAppConfigurationDelegate? + var hasConfig = false func prepareConfiguration() {} func handleInapps(event: ApplicationEvent?, _ completion: @escaping (InAppFormData?) -> Void) { completion(nil) } - func selectInappForPlace(_ place: String, trigger: ApplicationEvent?, _ completion: @escaping (InAppTransitionData?) -> Void) { - completion(nil) + func selectInappForPlace(_ place: String, + trigger: ApplicationEvent?, + _ completion: @escaping (InAppTransitionData?, TimeInterval) -> Void) { + completion(nil, 0) } func getInAppById(_ id: String, _ completion: @escaping (InAppTransitionData?) -> Void) { completion(nil) } - func getShowableInappIds(_ ids: [String], _ completion: @escaping (FeedAnswer) -> Void) { - completion(.nothing) + func getShowableInappIds(_ ids: [String], askedBy blockInappId: String, _ completion: @escaping ([String]) -> Void) { + completion([]) } func getInAppToShowById(_ id: String, params: [String: JSONValue], _ completion: @escaping (InAppFormData?) -> Void) { completion(nil) @@ -42,7 +45,7 @@ struct InAppCoreManagerTests { weak var delegate: InAppMessagesDelegate? func scheduleInApp(_ inAppFormData: InAppFormData, processingDuration: TimeInterval) {} - func showInAppNow(_ inAppFormData: InAppFormData) {} + func showInAppNow(_ inAppFormData: InAppFormData, processingDuration: TimeInterval) {} } private let queue = DispatchQueue(label: "test.core-manager.events") diff --git a/MindboxTests/InApp/Tests/InappConfigurationDataFacade/InappConfigurationDataFacadeTests.swift b/MindboxTests/InApp/Tests/InappConfigurationDataFacade/InappConfigurationDataFacadeTests.swift index 7bea8787a..0d0f05498 100644 --- a/MindboxTests/InApp/Tests/InappConfigurationDataFacade/InappConfigurationDataFacadeTests.swift +++ b/MindboxTests/InApp/Tests/InappConfigurationDataFacade/InappConfigurationDataFacadeTests.swift @@ -51,11 +51,15 @@ final class MockInappShowFailureManager: InappShowFailureManagerProtocol { func sendFailure(inappId: String, reason: InAppShowFailureReason, details: String?, tags: [String: String]?) {} + func sendFailures() {} + + private(set) var clearFailuresCallCount = 0 + func clearFailures() { - failures.removeAll() + clearFailuresCallCount += 1 } - func sendFailures() {} + func sendWaitBudgetExceeded(place: String, waited: TimeInterval, phase: EmbeddedBlockShowFailure.Phase) {} } final class InAppConfigurationDataFacadeTests: XCTestCase { @@ -202,6 +206,12 @@ final class InAppConfigurationDataFacadeTests: XCTestCase { XCTAssertTrue(mockFailureManager.failures.allSatisfy { $0.reason == .productSegmentRequestFailed }) } + func test_discardCollectedFailures_dropsWhatThePassBuffered() { + dataFacade.discardCollectedFailures() + + XCTAssertEqual(mockFailureManager.clearFailuresCallCount, 1) + } + func test_collectTargetingFailures_propagatesTagsByInappId() { SessionTemporaryStorage.shared.viewProductOperation = "App.ViewProduct".lowercased() let model = decodeInAppOperationJSONModel(from: """ diff --git a/MindboxTests/InApp/Tests/InappScheduleManagerTests.swift b/MindboxTests/InApp/Tests/InappScheduleManagerTests.swift index d7f591f87..783b25b6a 100644 --- a/MindboxTests/InApp/Tests/InappScheduleManagerTests.swift +++ b/MindboxTests/InApp/Tests/InappScheduleManagerTests.swift @@ -30,8 +30,7 @@ struct InappScheduleManagerTests { scheduleManager = InappScheduleManager( presentationManager: presentationManagerMock, presentationValidator: DI.injectOrFail(InAppPresentationValidatorProtocol.self), - trackingService: trackingServiceMock, - tracker: DI.injectOrFail(InAppMessagesTracker.self), + accountant: InappShowAccountant(tracker: DI.injectOrFail(InAppMessagesTracker.self), trackingService: trackingServiceMock), failureManager: failureManagerMock ) @@ -43,7 +42,6 @@ struct InappScheduleManagerTests { @Test("In-app without delay is presented exactly once and the queue is cleaned up", .tags(.inAppSchedule)) func scheduleInapp_noDelay_schedulesCorrectly() { #expect(scheduleManager.inappsByPresentationTime.isEmpty) - #expect(scheduleManager.getDelay(nil) == 0) let inapp = createInAppFormData(id: "1", isPriority: false, delayTime: nil) scheduleManager.scheduleInApp(inapp, processingDuration: 0) @@ -152,6 +150,33 @@ struct InappScheduleManagerTests { } } + @Test("A delayed in-app whose time comes while another is on screen is dropped, closing that one does not show it", .tags(.inAppSchedule)) + func scheduleInapp_missedMomentBehindAnotherInapp_isDropped() throws { + let onScreen = createInAppFormData(id: "1", isPriority: false, delayTime: "00:00:02") + let late = createInAppFormData(id: "2", isPriority: false, delayTime: "00:00:02") + scheduleManager.scheduleInApp(onScreen, processingDuration: 0) + let onScreenTime = try #require(scheduleManager.queue.sync { scheduleManager.inappsByPresentationTime.keys.first }) + scheduleManager.showEligibleInapp(onScreenTime) + scheduleManager.queue.sync { + #expect(self.presentationManagerMock.receivedInAppUIModel?.inAppId == onScreen.inAppId) + } + + scheduleManager.scheduleInApp(late, processingDuration: 0) + let lateTime = try #require(scheduleManager.queue.sync { scheduleManager.inappsByPresentationTime.keys.first }) + scheduleManager.showEligibleInapp(lateTime) + scheduleManager.queue.sync { + #expect(self.presentationManagerMock.presentCallsCount == 1) + #expect(self.scheduleManager.inappsByPresentationTime.isEmpty) + } + + presentationManagerMock.dismissActiveInApp() + + scheduleManager.queue.sync { + #expect(self.presentationManagerMock.presentCallsCount == 1) + } + #expect(!SessionTemporaryStorage.shared.isPresentingInAppMessage) + } + // MARK: - Records deletion @Test("Scheduled entries are removed after in-app is shown", .tags(.inAppSchedule)) @@ -180,11 +205,10 @@ struct InappScheduleManagerTests { } } - /// The delay keeps the manager's own timer out of the test: with no delay it fires at once and - /// clears the failures itself, which races the "not cleared yet" check below. - @Test("Eligible in-app cleanup clears buffered failures", .tags(.inAppSchedule)) - func showEligibleInapp_clearsFailuresAfterCleanup() { - let inapp = createInAppFormData(id: "clear-on-show", isPriority: false, delayTime: "00:01:00") + /// The delay keeps the manager's own timer out of the test. + @Test("Eligible in-app cleanup leaves the failure buffer alone", .tags(.inAppSchedule)) + func showEligibleInapp_leavesFailuresAlone() { + let inapp = createInAppFormData(id: "keep-failures", isPriority: false, delayTime: "00:01:00") scheduleManager.scheduleInApp(inapp, processingDuration: 0) var presentationTime: TimeInterval? @@ -197,11 +221,10 @@ struct InappScheduleManagerTests { return } - #expect(failureManagerMock.clearFailuresCallCount == 0) scheduleManager.showEligibleInapp(presentationTime) scheduleManager.queue.sync { - #expect(self.failureManagerMock.clearFailuresCallCount == 1) + #expect(self.failureManagerMock.sendFailuresCallCount == 0) #expect(self.scheduleManager.inappsByPresentationTime.isEmpty) } } @@ -210,8 +233,6 @@ struct InappScheduleManagerTests { @Test("Invalid delay string falls back to zero and in-app is presented", .tags(.inAppSchedule)) func scheduleInapp_withInvalidDelayTime_usesDefaultDelay() { - #expect(scheduleManager.getDelay("invalid_time") == 0) - let inapp = createInAppFormData(id: "1", isPriority: false, delayTime: "invalid_time") scheduleManager.scheduleInApp(inapp, processingDuration: 0) @@ -236,8 +257,6 @@ struct InappScheduleManagerTests { @Test("Zero delay is treated as immediate and in-app is presented", .tags(.inAppSchedule)) func scheduleInapp_withZeroDelay_schedulesCorrectly() { - #expect(scheduleManager.getDelay("00:00:00") == 0) - let inapp = createInAppFormData(id: "1", isPriority: false, delayTime: "00:00:00") scheduleManager.scheduleInApp(inapp, processingDuration: 0) @@ -343,16 +362,16 @@ struct InappScheduleManagerTests { } } - @Test("In-app success callback clears buffered failures", .tags(.inAppSchedule)) - func presentInapp_onPresented_clearsFailures() { + @Test("In-app success callback leaves the failure buffer alone", .tags(.inAppSchedule)) + func presentInapp_onPresented_leavesFailuresAlone() { let inapp = createInAppFormData(id: "success-id", isPriority: false, delayTime: nil) scheduleManager.presentInapp(inapp, stopwatch: ForegroundStopwatch()) #expect(presentationManagerMock.presentCallsCount == 1) - #expect(failureManagerMock.clearFailuresCallCount == 0) presentationManagerMock.receivedOnPresent?() - #expect(failureManagerMock.clearFailuresCallCount == 1) + #expect(failureManagerMock.sendFailuresCallCount == 0) + #expect(failureManagerMock.addFailureCallCount == 0) } @Test("In-app error callback sends buffered failures", .tags(.inAppSchedule)) @@ -436,14 +455,15 @@ struct InappScheduleManagerTests { InappScheduleManager( presentationManager: presentationManagerMock, presentationValidator: DI.injectOrFail(InAppPresentationValidatorProtocol.self), - trackingService: trackingServiceMock, - tracker: tracker, + accountant: InappShowAccountant(tracker: tracker, trackingService: trackingServiceMock), failureManager: failureManagerMock ) } - private func showNowAndAwaitMainQueue(_ manager: InappScheduleManager, _ inapp: InAppFormData) async { - manager.showInAppNow(inapp) + private func showNowAndAwaitMainQueue(_ manager: InappScheduleManager, + _ inapp: InAppFormData, + processingDuration: TimeInterval = 0) async { + manager.showInAppNow(inapp, processingDuration: processingDuration) // showInAppNow takes two main-queue turns: close the active overlay, then present. for _ in 0..<2 { await withCheckedContinuation { continuation in @@ -518,6 +538,19 @@ struct InappScheduleManagerTests { #expect(trackerSpy.trackTargetingCallCount == 0) } + @Test("A show on request counts the time since the tap into timeToDisplay", .tags(.inAppSchedule)) + func showInAppNow_countsTheTapsProcessingTime() async throws { + let trackerSpy = InAppMessagesTrackerSpyMock() + let manager = makeSpiedManager(tracker: trackerSpy) + let inapp = createInAppFormData(id: "direct-timed", isPriority: false, delayTime: nil) + + await showNowAndAwaitMainQueue(manager, inapp, processingDuration: 3) + presentationManagerMock.receivedOnPresent?() + + let timeToDisplay = try #require(trackerSpy.lastTimeToDisplay) + #expect(timeToDisplay.hasPrefix("00:00:03."), "expected at least the 3 s of processing, got \(timeToDisplay)") + } + @Test("A show on request closes the overlay already on screen", .tags(.inAppSchedule)) func showInAppNow_closesTheActiveOverlay() async { let trackerSpy = InAppMessagesTrackerSpyMock() @@ -621,8 +654,9 @@ final class InappShowFailureManagerMock: InappShowFailureManagerProtocol { // @Locked: production calls these from its queues while the test reads from its own context. @Locked private(set) var addFailureCallCount = 0 - @Locked private(set) var clearFailuresCallCount = 0 @Locked private(set) var sendFailuresCallCount = 0 + @Locked private(set) var clearFailuresCallCount = 0 + @Locked private(set) var waitBudgetExceeded: [(place: String, waited: TimeInterval, phase: EmbeddedBlockShowFailure.Phase)] = [] @Locked private(set) var addFailureCalls: [AddFailureCall] = [] @Locked private(set) var sentAtOnce: [AddFailureCall] = [] @@ -635,11 +669,15 @@ final class InappShowFailureManagerMock: InappShowFailureManagerProtocol { sentAtOnce.append(AddFailureCall(inappId: inappId, reason: reason, details: details, tags: tags)) } + func sendFailures() { + sendFailuresCallCount += 1 + } + func clearFailures() { clearFailuresCallCount += 1 } - func sendFailures() { - sendFailuresCallCount += 1 + func sendWaitBudgetExceeded(place: String, waited: TimeInterval, phase: EmbeddedBlockShowFailure.Phase) { + waitBudgetExceeded.append((place, waited, phase)) } } diff --git a/MindboxTests/InApp/Tests/InappSessionLedgerTests.swift b/MindboxTests/InApp/Tests/InappSessionLedgerTests.swift new file mode 100644 index 000000000..0936e73c2 --- /dev/null +++ b/MindboxTests/InApp/Tests/InappSessionLedgerTests.swift @@ -0,0 +1,96 @@ +// +// InappSessionLedgerTests.swift +// MindboxTests +// +// Created by Sergei Semko on 01.09.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@testable import Mindbox + +@Suite("In-app session ledger", .tags(.inappSelection)) +struct InappSessionLedgerTests { + + @Test("A winner takes the place's slot once, until another takes it over") + func winnerTakesTheSlotOnce() { + var ledger = InappSessionLedger() + + let taken = ledger.vouchWinner("inapp-1", at: "place") + let held = ledger.vouchWinner("inapp-1", at: "place") + let moved = ledger.vouchWinner("inapp-2", at: "place") + let returned = ledger.vouchWinner("inapp-1", at: "place") + + #expect(taken) + #expect(!held) + #expect(moved) + #expect(returned) + } + + @Test("A winner is vouched for along with its slot") + func winnerIsVouchedForAlongWithItsSlot() { + var ledger = InappSessionLedger() + + let taken = ledger.vouchWinner("inapp-1", at: "place") + let vouchedAgain = ledger.vouch("inapp-1") + + #expect(taken) + #expect(!vouchedAgain) + } + + @Test("An in-app is vouched for once per session") + func vouchIsOncePerSession() { + var ledger = InappSessionLedger() + + let first = ledger.vouch("inapp-1") + let second = ledger.vouch("inapp-1") + let another = ledger.vouch("inapp-2") + + #expect(first) + #expect(!second) + #expect(another) + } + + @Test("The same in-app offered by another block is a new offer") + func offersAreOncePerBlockAndInapp() { + var ledger = InappSessionLedger() + + let offered = ledger.vouchOffer(BlockOffer(blockInappId: "block-1", inappId: "inapp-1")) + let repeated = ledger.vouchOffer(BlockOffer(blockInappId: "block-1", inappId: "inapp-1")) + let otherBlock = ledger.vouchOffer(BlockOffer(blockInappId: "block-2", inappId: "inapp-1")) + + #expect(offered) + #expect(!repeated) + #expect(otherBlock) + } + + @Test("A show is recorded when the place shows something new, places independent") + func showsAreRecordedPerPlaceChange() { + var ledger = InappSessionLedger() + + let shown = ledger.recordShow("inapp-1", at: "place") + let held = ledger.recordShow("inapp-1", at: "place") + let changed = ledger.recordShow("inapp-2", at: "place") + let returned = ledger.recordShow("inapp-1", at: "place") + let otherPlace = ledger.recordShow("inapp-1", at: "other-place") + + #expect(shown) + #expect(!held) + #expect(changed) + #expect(returned) + #expect(otherPlace) + } + + @Test("A silent place is reported once per session") + func unansweredPlaceIsReportedOnce() { + var ledger = InappSessionLedger() + + let reported = ledger.recordUnanswered("place") + let repeated = ledger.recordUnanswered("place") + let otherPlace = ledger.recordUnanswered("other-place") + + #expect(reported) + #expect(!repeated) + #expect(otherPlace) + } +} diff --git a/MindboxTests/InApp/Tests/InappSessionManagerTests/InappSessionManagerTests.swift b/MindboxTests/InApp/Tests/InappSessionManagerTests/InappSessionManagerTests.swift index 53b2975af..feff5b1a9 100644 --- a/MindboxTests/InApp/Tests/InappSessionManagerTests/InappSessionManagerTests.swift +++ b/MindboxTests/InApp/Tests/InappSessionManagerTests/InappSessionManagerTests.swift @@ -173,6 +173,14 @@ final class InappSessionManagerTests: XCTestCase { SessionTemporaryStorage.shared.isPresentingInAppMessage = true SessionTemporaryStorage.shared.sessionShownInApps = ["1"] SessionTemporaryStorage.shared.inAppSettings = Settings.InAppSettings(maxInappsPerSession: 1, maxInappsPerDay: 2, minIntervalBetweenShows: "00:00:00") + SessionTemporaryStorage.shared.$ledger.mutate { + $0.vouchedInappIds = ["1"] + $0.placeTargetedInappId = ["place": "1"] + $0.vouchedBlockOffers = [BlockOffer(blockInappId: "block", inappId: "1")] + $0.placesReportedUnanswered = ["place"] + $0.servedPlaceDelays = [ServedPlaceDelay(place: "place", inappId: "1")] + $0.placeShownInappId = ["place": "1"] + } targetingChecker.context.isNeedGeoRequest = true targetingChecker.checkedSegmentations = [.init(segmentation: .init(ids: .init(externalId: "1")), segment: nil)] @@ -192,6 +200,7 @@ final class InappSessionManagerTests: XCTestCase { XCTAssertEqual(SessionTemporaryStorage.shared.isPresentingInAppMessage, false) XCTAssertEqual(SessionTemporaryStorage.shared.sessionShownInApps, []) XCTAssertNil(SessionTemporaryStorage.shared.inAppSettings) + XCTAssertEqual(SessionTemporaryStorage.shared.ledger, InappSessionLedger()) targetingChecker.context.isNeedGeoRequest = false targetingChecker.checkedSegmentations = nil diff --git a/MindboxTests/InApp/Tests/InappShowAccountantTests.swift b/MindboxTests/InApp/Tests/InappShowAccountantTests.swift new file mode 100644 index 000000000..4469d34e0 --- /dev/null +++ b/MindboxTests/InApp/Tests/InappShowAccountantTests.swift @@ -0,0 +1,111 @@ +// +// InappShowAccountantTests.swift +// MindboxTests +// +// Created by Sergei Semko on 25.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import Foundation +@testable import Mindbox + +@Suite("In-app show accountant", .tags(.inAppSchedule)) +struct InappShowAccountantTests { + + private let tracker = InAppMessagesTrackerSpyMock() + private let trackingService = InAppTrackingServiceMock() + private let accountant: InappShowAccountant + + init() { + SessionTemporaryStorage.shared.erase() + accountant = InappShowAccountant(tracker: tracker, trackingService: trackingService) + } + + private func show(_ frequency: InappFrequency, id: String = "inapp-1") -> InappShow { + InappShow(inAppId: id, frequency: frequency, tags: ["campaign": "spring"], timeToDisplay: 1.5) + } + + @Test("A show sends Inapp.Show with the time in the wire format") + func showSendsTheEvent() { + accountant.recordShow(show(.unlimited)) + + #expect(tracker.trackViewCallCount == 1) + #expect(tracker.lastTrackedId == "inapp-1") + #expect(tracker.lastTimeToDisplay == "00:00:01.5000000") + } + + @Test("A counted show writes the history") + func countedShowWritesHistory() { + accountant.recordShow(show(.once(OnceFrequency(kind: .session)))) + + #expect(trackingService.trackInAppShownCallCount == 1) + #expect(trackingService.lastTrackedInAppId == "inapp-1") + } + + @Test("An unlimited show sends the event and writes nothing") + func unlimitedShowWritesNothing() { + accountant.recordShow(show(.unlimited)) + + #expect(tracker.trackViewCallCount == 1) + #expect(trackingService.trackInAppShownCallCount == 0) + #expect(trackingService.saveInappStateChangeCallCount == 0) + } + + @Test("A counted show moves the cooldown") + func countedShowMovesTheCooldown() { + accountant.recordShow(show(.once(OnceFrequency(kind: .session)))) + + #expect(trackingService.saveInappStateChangeCallCount == 1) + } + + @Test("The same in-app at a place is accounted once") + func sameInappAtPlaceIsAccountedOnce() { + accountant.recordBlockShow(show(.unlimited), at: "place") + accountant.recordBlockShow(show(.unlimited), at: "place") + + #expect(tracker.trackViewCallCount == 1) + } + + @Test("Another in-app at the place is accounted") + func anotherInappAtPlaceIsAccounted() { + accountant.recordBlockShow(show(.unlimited, id: "inapp-1"), at: "place") + accountant.recordBlockShow(show(.unlimited, id: "inapp-2"), at: "place") + + #expect(tracker.trackViewCallCount == 2) + } + + @Test("Returning to the first in-app at the place is accounted again") + func returningInappAtPlaceIsAccountedAgain() { + accountant.recordBlockShow(show(.unlimited, id: "inapp-1"), at: "place") + accountant.recordBlockShow(show(.unlimited, id: "inapp-2"), at: "place") + accountant.recordBlockShow(show(.unlimited, id: "inapp-1"), at: "place") + + #expect(tracker.trackViewCallCount == 3) + } + + @Test("Two places showing the same in-app are accounted independently") + func twoPlacesAreAccountedIndependently() { + accountant.recordBlockShow(show(.unlimited), at: "first-place") + accountant.recordBlockShow(show(.unlimited), at: "second-place") + + #expect(tracker.trackViewCallCount == 2) + } + + @Test("A new session accounts the same in-app at the place again") + func newSessionAccountsThePlaceAgain() { + accountant.recordBlockShow(show(.unlimited), at: "place") + SessionTemporaryStorage.shared.erase() + accountant.recordBlockShow(show(.unlimited), at: "place") + + #expect(tracker.trackViewCallCount == 2) + } + + @Test("A counted cooldown is written, an unlimited one is not") + func cooldownFollowsTheFrequency() { + accountant.recordCooldown(frequency: .once(OnceFrequency(kind: .lifetime))) + accountant.recordCooldown(frequency: .unlimited) + + #expect(trackingService.saveInappStateChangeCallCount == 1) + } +} diff --git a/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift b/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift index 6a6e10141..6c260f50b 100644 --- a/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift +++ b/MindboxTests/InApp/Tests/InappShowFailureManagerTests.swift @@ -54,6 +54,55 @@ final class InappShowFailureManagerTests: XCTestCase { XCTAssertEqual(failures[0].errorDetails, "No window available") } + func testEveryInappFailure_goesOutInErrorsTypedInappShowFailure_neverInFailures() throws { + manager.addFailure(inappId: "inapp-1", reason: .geoRequestFailed, details: nil, tags: nil) + manager.addFailure(inappId: "inapp-2", reason: .imageDownloadFailed, details: nil, tags: ["a": "b"]) + + manager.sendFailures() + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + XCTAssertFalse(event.body.contains("\"failures\"")) + let errors = try XCTUnwrap(decodeFailures(from: event)) + XCTAssertEqual(errors.map(\.type), ["inappShowFailure", "inappShowFailure"]) + XCTAssertEqual(errors.map(\.inappId), ["inapp-1", "inapp-2"]) + XCTAssertEqual(errors.map(\.placeSystemName), [nil, nil]) + XCTAssertEqual(errors[1].tags, ["a": "b"]) + } + + func testSendWaitBudgetExceeded_namesThePlaceInAnEmbeddedBlockShowFailure() throws { + manager.sendWaitBudgetExceeded(place: "silent-place", waited: 30, phase: .configMissing) + + assertCreatedEventsCountEventually(1) + let event = try XCTUnwrap(databaseRepository.createdEvents.first) + XCTAssertEqual(event.type, .inAppShowFailureEvent) + XCTAssertFalse(event.body.contains("\"failures\"")) + let error = try XCTUnwrap(decodeFailures(from: event)?.first) + XCTAssertEqual(error.type, "embeddedBlockShowFailure") + XCTAssertEqual(error.placeSystemName, "silent-place") + XCTAssertEqual(error.failureReason, .waitBudgetExceeded) + XCTAssertEqual(error.errorDetails, "phase=config_missing; waited=00:00:30.0000000") + XCTAssertNil(error.inappId) + XCTAssertNil(error.tags) + XCTAssertFalse(error.dateTimeUtc.isEmpty) + } + + func testSendWaitBudgetExceeded_saysWhatTheSDKWasBusyWith() throws { + manager.sendWaitBudgetExceeded(place: "silent-place", waited: 12.5, phase: .resolvePending) + + assertCreatedEventsCountEventually(1) + let error = try XCTUnwrap(decodeFailures(from: try XCTUnwrap(databaseRepository.createdEvents.first))?.first) + XCTAssertEqual(error.errorDetails, "phase=resolve_pending; waited=00:00:12.5000000") + } + + func testSendWaitBudgetExceeded_whenFeatureDisabled_sendsNothing() { + applyFeatureToggle(shouldSendInAppShowError: false) + + manager.sendWaitBudgetExceeded(place: "silent-place", waited: 30, phase: .configMissing) + + assertCreatedEventsCountEventually(0) + } + func testAddFailure_setsDateTimeUtcInsideMethod() throws { manager.addFailure( inappId: "inapp-2", @@ -215,19 +264,6 @@ final class InappShowFailureManagerTests: XCTestCase { XCTAssertEqual(failures[0].errorDetails?.utf8.count, InappShowFailureManager.errorDetailsLimit) } - func testClearFailures_removesBufferedFailures() { - manager.addFailure( - inappId: "inapp-clear", - reason: .presentationFailed, - details: "clear me", - tags: nil - ) - manager.clearFailures() - manager.sendFailures() - - assertCreatedEventsCountEventually(0) - } - func testSendFailures_success_clearsBufferedFailures() { manager.addFailure( inappId: "inapp-send-success", @@ -242,6 +278,15 @@ final class InappShowFailureManagerTests: XCTestCase { assertCreatedEventsCountEventually(1) } + func testClearFailures_dropsTheBuffer_nothingIsSentAfterwards() { + manager.addFailure(inappId: "inapp-dropped", reason: .geoRequestFailed, details: nil, tags: nil) + + manager.clearFailures() + manager.sendFailures() + + assertCreatedEventsCountEventually(0) + } + func testSendFailures_createEventFails_keepsBufferedFailures() { manager.addFailure( inappId: "inapp-retry", @@ -481,12 +526,28 @@ final class InappShowFailureManagerTests: XCTestCase { } private extension InappShowFailureManagerTests { - struct InAppShowFailuresBody: Decodable { - let failures: [InAppShowFailure] + /// The wire shape, every `$type` flattened into one record: what the backend reads, not what the SDK built it from. + struct ShowError: Decodable { + let type: String + let inappId: String? + let placeSystemName: String? + let failureReason: InAppShowFailureReason + let errorDetails: String? + let dateTimeUtc: String + let tags: [String: String]? + + private enum CodingKeys: String, CodingKey { + case type = "$type" + case inappId, placeSystemName, failureReason, errorDetails, dateTimeUtc, tags + } + } + + struct InAppShowErrorsBody: Decodable { + let errors: [ShowError] } - func decodeFailures(from event: Event) -> [InAppShowFailure]? { - BodyDecoder(decodable: event.body)?.body.failures + func decodeFailures(from event: Event) -> [ShowError]? { + BodyDecoder(decodable: event.body)?.body.errors } func applyFeatureToggle(shouldSendInAppShowError: Bool) { diff --git a/MindboxTests/InApp/Tests/TimeToDisplayBackgroundTests.swift b/MindboxTests/InApp/Tests/TimeToDisplayBackgroundTests.swift index 87f9e3bae..90d678e95 100644 --- a/MindboxTests/InApp/Tests/TimeToDisplayBackgroundTests.swift +++ b/MindboxTests/InApp/Tests/TimeToDisplayBackgroundTests.swift @@ -10,13 +10,17 @@ import Testing import UIKit @testable import Mindbox +// The stopwatch listens on the main queue; posting from it keeps the delivery synchronous, so the +// clock is read after the observer ran and not before. @Suite("TimeToDisplay excludes background time") +@MainActor struct TimeToDisplayBackgroundTests { private var scheduleManager: InappScheduleManager private var presentationManagerMock: InAppPresentationManagerMock private var trackerMock: InAppMessagesTrackerSpyMock private let notificationCenter = NotificationCenter() + private let clock = TestClock() init() { TestConfiguration.configure() @@ -27,8 +31,7 @@ struct TimeToDisplayBackgroundTests { scheduleManager = InappScheduleManager( presentationManager: presentationManagerMock, presentationValidator: DI.injectOrFail(InAppPresentationValidatorProtocol.self), - trackingService: InAppTrackingServiceMock(), - tracker: trackerMock, + accountant: InappShowAccountant(tracker: trackerMock, trackingService: InAppTrackingServiceMock()), failureManager: InappShowFailureManagerMock() ) @@ -37,88 +40,59 @@ struct TimeToDisplayBackgroundTests { // MARK: - Tests - @Test("No background — timeToDisplay matches real elapsed time", .tags(.inAppSchedule)) + @Test("No background — timeToDisplay is the elapsed time", .tags(.inAppSchedule)) func timeToDisplay_noBackground_matchesElapsedTime() throws { - let stopwatch = ForegroundStopwatch(notificationCenter: notificationCenter) + let stopwatch = ForegroundStopwatch(notificationCenter: notificationCenter, now: { clock.now }) let inapp = createInAppFormData(id: "no-bg") - Thread.sleep(forTimeInterval: 0.1) + clock.advance(0.5) scheduleManager.presentInapp(inapp, stopwatch: stopwatch, processingDuration: 0) presentationManagerMock.receivedOnPresent?() - let seconds = try parseTimeToDisplay() - - #expect(seconds >= 0.1, "Expected timeToDisplay >= 0.1s, got \(seconds)s") - #expect(seconds < 0.2, "Expected timeToDisplay < 0.2s (no background time to subtract), got \(seconds)s") + #expect(try parseTimeToDisplay() == 0.5) } @Test("Single background session — background time is excluded from timeToDisplay", .tags(.inAppSchedule)) func timeToDisplay_singleBackground_excludesBackgroundTime() throws { - let stopwatch = ForegroundStopwatch(notificationCenter: notificationCenter) + let stopwatch = ForegroundStopwatch(notificationCenter: notificationCenter, now: { clock.now }) let inapp = createInAppFormData(id: "single-bg") - // ~0.05s foreground - Thread.sleep(forTimeInterval: 0.05) - - // ~0.3s background (should be excluded) - notificationCenter.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - Thread.sleep(forTimeInterval: 0.3) - notificationCenter.post(name: UIApplication.willEnterForegroundNotification, object: nil) - - // ~0.05s foreground - Thread.sleep(forTimeInterval: 0.05) + clock.advance(0.25) + enterBackground(for: 2) + clock.advance(0.25) scheduleManager.presentInapp(inapp, stopwatch: stopwatch, processingDuration: 0) presentationManagerMock.receivedOnPresent?() - let seconds = try parseTimeToDisplay() - - // Foreground time: ~0.05 + ~0.05 = ~0.1s - // Background time: ~0.3s (excluded) - // Total wall time: ~0.4s, but timeToDisplay should be ~0.1s - #expect(seconds >= 0.05, "Expected timeToDisplay >= 0.05s (foreground time), got \(seconds)s") - #expect(seconds < 0.2, "Expected timeToDisplay < 0.2s (excluding ~0.3s background), got \(seconds)s") + #expect(try parseTimeToDisplay() == 0.5) } @Test("Multiple background sessions — all background time excluded, all foreground time counted", .tags(.inAppSchedule)) func timeToDisplay_multipleBackgrounds_onlyForegroundCounted() throws { - let stopwatch = ForegroundStopwatch(notificationCenter: notificationCenter) + let stopwatch = ForegroundStopwatch(notificationCenter: notificationCenter, now: { clock.now }) let inapp = createInAppFormData(id: "multi-bg") - // ~0.05s foreground - Thread.sleep(forTimeInterval: 0.05) - - // ~0.2s background #1 (excluded) - notificationCenter.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - Thread.sleep(forTimeInterval: 0.2) - notificationCenter.post(name: UIApplication.willEnterForegroundNotification, object: nil) - - // ~0.05s foreground - Thread.sleep(forTimeInterval: 0.05) - - // ~0.2s background #2 (excluded) - notificationCenter.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - Thread.sleep(forTimeInterval: 0.2) - notificationCenter.post(name: UIApplication.willEnterForegroundNotification, object: nil) - - // ~0.05s foreground - Thread.sleep(forTimeInterval: 0.05) + clock.advance(0.25) + enterBackground(for: 2) + clock.advance(0.25) + enterBackground(for: 4) + clock.advance(0.25) scheduleManager.presentInapp(inapp, stopwatch: stopwatch, processingDuration: 0) presentationManagerMock.receivedOnPresent?() - let seconds = try parseTimeToDisplay() - - // Foreground time: ~0.05 + ~0.05 + ~0.05 = ~0.15s - // Background time: ~0.2 + ~0.2 = ~0.4s (excluded) - // Total wall time: ~0.55s, but timeToDisplay should be ~0.15s - #expect(seconds >= 0.1, "Expected timeToDisplay >= 0.1s (foreground time), got \(seconds)s") - #expect(seconds < 0.25, "Expected timeToDisplay < 0.25s (excluding ~0.4s background), got \(seconds)s") + #expect(try parseTimeToDisplay() == 0.75) } // MARK: - Helpers + private func enterBackground(for seconds: TimeInterval) { + notificationCenter.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + clock.advance(seconds) + notificationCenter.post(name: UIApplication.willEnterForegroundNotification, object: nil) + } + private func parseTimeToDisplay() throws -> Double { let timeToDisplayString = try #require(trackerMock.lastTimeToDisplay) let millis = try timeToDisplayString.parseTimeSpanToMillis() diff --git a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/FeedActionHandlersTests.swift b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/InappRequestActionHandlersTests.swift similarity index 86% rename from MindboxTests/InApp/Tests/WebView/BridgeHandlers/FeedActionHandlersTests.swift rename to MindboxTests/InApp/Tests/WebView/BridgeHandlers/InappRequestActionHandlersTests.swift index 06a98f385..7bf1c2db8 100644 --- a/MindboxTests/InApp/Tests/WebView/BridgeHandlers/FeedActionHandlersTests.swift +++ b/MindboxTests/InApp/Tests/WebView/BridgeHandlers/InappRequestActionHandlersTests.swift @@ -1,5 +1,5 @@ // -// FeedActionHandlersTests.swift +// InappRequestActionHandlersTests.swift // MindboxTests // // Created by Sergei Semko on 13.08.2026. @@ -17,9 +17,9 @@ struct FilterShowableInappsActionHandlerTests { #expect(FilterShowableInappsActionHandler().actions == [.filterShowableInapps]) } - @Test("The feed's answer travels back in the response") - func feedAnswerTravelsBack() throws { - let host = FeedHostSpy() + @Test("The in-app service's answer travels back in the response") + func serviceAnswerTravelsBack() throws { + let host = InappRequestHostSpy() host.allowed = ["id-1", "id-3"] let message = BridgeMessage.request(.filterShowableInapps, payload: .object(["inappIds": .array([.string("id-1"), @@ -37,7 +37,7 @@ struct FilterShowableInappsActionHandlerTests { @Test("A late answer from the selection still becomes the response") func lateAnswerStillResponds() { - let host = FeedHostSpy() + let host = InappRequestHostSpy() host.isDeferred = true FilterShowableInappsActionHandler().handle(.request(.filterShowableInapps, @@ -54,7 +54,7 @@ struct FilterShowableInappsActionHandlerTests { @Test("An empty question is asked and answered, not refused") func emptyRequestIsAnswered() { - let host = FeedHostSpy() + let host = InappRequestHostSpy() FilterShowableInappsActionHandler().handle(.request(.filterShowableInapps, payload: .object(["inappIds": .array([])])), @@ -66,7 +66,7 @@ struct FilterShowableInappsActionHandlerTests { @Test("Non-string entries are dropped instead of breaking the question") func nonStringEntriesAreDropped() { - let host = FeedHostSpy() + let host = InappRequestHostSpy() host.allowed = ["id-1"] FilterShowableInappsActionHandler().handle( @@ -78,9 +78,9 @@ struct FilterShowableInappsActionHandlerTests { #expect(host.sent.first?.payload == .object(["inappIds": .array([.string("id-1")])])) } - @Test("A payload without the array is refused before the feed is asked") + @Test("A payload without the array is refused before the service is asked") func missingArrayIsRefused() throws { - let host = FeedHostSpy() + let host = InappRequestHostSpy() FilterShowableInappsActionHandler().handle(.request(.filterShowableInapps, payload: .object([:])), host: host) @@ -92,7 +92,7 @@ struct FilterShowableInappsActionHandlerTests { @Test("A payload sent as a JSON string is understood too") func acceptsStringifiedPayload() { - let host = FeedHostSpy() + let host = InappRequestHostSpy() host.allowed = ["id-1"] FilterShowableInappsActionHandler().handle(.request(.filterShowableInapps, @@ -102,8 +102,8 @@ struct FilterShowableInappsActionHandlerTests { #expect(host.sent.first?.payload == .object(["inappIds": .array([.string("id-1")])])) } - @Test("A host without a feed leaves the question unanswered") - func hostWithoutFeedStaysSilent() { + @Test("A host without an in-app service leaves the question unanswered") + func hostWithoutInappServiceStaysSilent() { let host = HostSpy() FilterShowableInappsActionHandler().handle(.request(.filterShowableInapps, @@ -122,13 +122,13 @@ struct ShowInAppActionHandlerTests { #expect(ShowInAppActionHandler().actions == [.showInApp]) } - @Test("A well-formed request reaches the feed and is acknowledged") - func requestReachesTheFeed() throws { - let host = FeedHostSpy() + @Test("A well-formed request reaches the service and is acknowledged") + func requestReachesTheService() throws { + let host = InappRequestHostSpy() let message = BridgeMessage.request(.showInApp, payload: .object([ "inappId": .string("11111111-1111-1111-1111-111111111111"), "index": .int(0), - "sourceInappId": .string("feed"), + "sourceInappId": .string("block"), "params": .object(["title": .string("Сториз 1")]) ])) @@ -146,7 +146,7 @@ struct ShowInAppActionHandlerTests { @Test("Only the id is required") func onlyIdIsRequired() throws { - let host = FeedHostSpy() + let host = InappRequestHostSpy() ShowInAppActionHandler().handle(.request(.showInApp, payload: .object(["inappId": .string("some-id")])), host: host) @@ -163,7 +163,7 @@ struct ShowInAppActionHandlerTests { .object(["inappId": .int(1)]) ]) func missingIdIsRefused(payload: JSONValue) throws { - let host = FeedHostSpy() + let host = InappRequestHostSpy() ShowInAppActionHandler().handle(.request(.showInApp, payload: payload), host: host) @@ -173,8 +173,8 @@ struct ShowInAppActionHandlerTests { #expect(response.payload == .object(["error": .string("Invalid payload: missing or empty 'inappId'")])) } - @Test("A host without a feed leaves the request unanswered") - func hostWithoutFeedStaysSilent() { + @Test("A host without an in-app service leaves the request unanswered") + func hostWithoutInappServiceStaysSilent() { let host = HostSpy() ShowInAppActionHandler().handle(.request(.showInApp, payload: .object(["inappId": .string("some-id")])), @@ -184,7 +184,7 @@ struct ShowInAppActionHandlerTests { } } -private final class FeedHostSpy: HostSpy, WebBridgeFeedHosting { +private final class InappRequestHostSpy: HostSpy, WebBridgeInappRequestHosting { var allowed: [String] = [] diff --git a/MindboxTests/InApp/Tests/WebView/MindboxWebPageRegistryTests.swift b/MindboxTests/InApp/Tests/WebView/MindboxWebPageRegistryTests.swift index a8a0c93dd..156cb047e 100644 --- a/MindboxTests/InApp/Tests/WebView/MindboxWebPageRegistryTests.swift +++ b/MindboxTests/InApp/Tests/WebView/MindboxWebPageRegistryTests.swift @@ -18,15 +18,15 @@ struct MindboxWebPageRegistryTests { func broadcastReachesEveryoneButTheAuthor() { let registry = MindboxWebPageRegistry() let author = WebPageSpy() - let feed = WebPageSpy() + let block = WebPageSpy() let other = WebPageSpy() - [author, feed, other].forEach(registry.register) + [author, block, other].forEach(registry.register) registry.broadcast(.localStateChanged, payload: .object(["version": .int(1)]), excluding: author) #expect(author.received.isEmpty) - #expect(feed.received.map(\.action) == [.localStateChanged]) + #expect(block.received.map(\.action) == [.localStateChanged]) #expect(other.received.map(\.action) == [.localStateChanged]) } diff --git a/MindboxTests/Mock/MockInAppConfigurationDataFacade.swift b/MindboxTests/Mock/MockInAppConfigurationDataFacade.swift index 6a6042f4a..e95bbeaa7 100644 --- a/MindboxTests/Mock/MockInAppConfigurationDataFacade.swift +++ b/MindboxTests/Mock/MockInAppConfigurationDataFacade.swift @@ -29,6 +29,8 @@ class MockInAppConfigurationDataFacade: InAppConfigurationDataFacadeProtocol { @Locked public var collectedTargetingFailureIds: [Set] = [] @Locked public var collectedTagsByInappId: [[String: [String: String]]] = [] @Locked public var fetchDependenciesCalls = 0 + @Locked public var sendCollectedFailuresCalls = 0 + @Locked public var discardCollectedFailuresCalls = 0 init(segmentationService: SegmentationServiceProtocol, targetingChecker: InAppTargetingCheckerProtocol, @@ -75,6 +77,14 @@ class MockInAppConfigurationDataFacade: InAppConfigurationDataFacadeProtocol { collectedTagsByInappId.append(tagsByInappId) } + func discardCollectedFailures() { + discardCollectedFailuresCalls += 1 + } + + func sendCollectedFailures() { + sendCollectedFailuresCalls += 1 + } + func trackTargeting(id: String?, tags: [String: String]?) { trackTargetingCalls.append((id: id, tags: tags)) if let id = id { @@ -99,5 +109,7 @@ class MockInAppConfigurationDataFacade: InAppConfigurationDataFacadeProtocol { func cleanCollectedTargetingFailureIds() { collectedTargetingFailureIds = [] collectedTagsByInappId = [] + sendCollectedFailuresCalls = 0 + discardCollectedFailuresCalls = 0 } }