From ff2091ec65fe34eab8913ae0e30a7b3c393e9766 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:48:36 +0300 Subject: [PATCH 01/10] MOBILE-419: Zero-pad TimeSpan hours and read delayTime values Android has been sending 00:-padded hours in production; iOS now writes the same shape into every Inapp.Show. Parsing gains the delay(fromTimeSpan:) reading the embedded delayTime rides on. --- Mindbox/Utilities/TimeInterval+TimeSpan.swift | 7 ++- .../TimeIntervalTimeSpanTests.swift | 54 ++++++++++++------- 2 files changed, 41 insertions(+), 20 deletions(-) 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/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") } } } From 01193c8366a220b321d7e35fd73026edccf9a80e Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:48:37 +0300 Subject: [PATCH 02/10] MOBILE-419: Gather the session's in-app slate into one ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six independent fields with a hand-maintained erase() become one value the new session resets by a single assignment — nothing to forget. --- Mindbox/Utilities/InappSessionLedger.swift | 45 +++++++++++++++++++ .../Utilities/SessionTemporaryStorage.swift | 9 +--- .../InappSessionManagerTests.swift | 9 ++++ 3 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 Mindbox/Utilities/InappSessionLedger.swift diff --git a/Mindbox/Utilities/InappSessionLedger.swift b/Mindbox/Utilities/InappSessionLedger.swift new file mode 100644 index 000000000..a81312a7e --- /dev/null +++ b/Mindbox/Utilities/InappSessionLedger.swift @@ -0,0 +1,45 @@ +// +// 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] = [:] +} 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/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 From 1894bc5d6f38cf549067bd0b5cdde5f2caf3eef2 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:48:37 +0300 Subject: [PATCH 03/10] MOBILE-419: One accountant for every shown in-app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inapp.Show, the local show history and the cooldown were settled separately by the overlay and the block; the accountant is the single place now, with a per-place slot so a rebuilt page does not double-count. The stopwatch takes its clock as a seam — timing tests run on a test clock, not on sleeps. --- .../InAppMessages/ForegroundStopwatch.swift | 16 ++- .../InAppMessages/InappScheduleManager.swift | 71 ++++------- .../InAppMessages/InappShowAccountant.swift | 73 ++++++++++++ .../Tests/ForegroundStopwatchTests.swift | 94 +++++++-------- .../Tests/InappScheduleManagerTests.swift | 88 ++++++++++---- .../Tests/InappShowAccountantTests.swift | 111 ++++++++++++++++++ .../Tests/TimeToDisplayBackgroundTests.swift | 80 +++++-------- 7 files changed, 346 insertions(+), 187 deletions(-) create mode 100644 Mindbox/InAppMessages/InappShowAccountant.swift create mode 100644 MindboxTests/InApp/Tests/InappShowAccountantTests.swift 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/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..6c4a9b7a9 --- /dev/null +++ b/Mindbox/InAppMessages/InappShowAccountant.swift @@ -0,0 +1,73 @@ +// +// 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.placeShownInappId[place] != show.inAppId else { + Logger.common(message: "[InappShowAccountant] Place '\(place)' shows in-app \(show.inAppId) again — nothing new to account for", + level: .debug, category: .inAppMessages) + return + } + + SessionTemporaryStorage.shared.$ledger.mutate { $0.placeShownInappId[place] = show.inAppId } + recordShow(show) + } +} 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/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/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/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() From 0ed2b7181d3d6528519c90067ee63c7c04cd926b Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:48:54 +0300 Subject: [PATCH 04/10] MOBILE-419: Polymorphic Inapp.ShowFailure errors with a $type tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flat failures list becomes errors of two shapes — inappShowFailure and embeddedBlockShowFailure with placeSystemName, phase and waited — in sync with Android and the server. --- .../InappShowFailureManager.swift | 56 ++++++++--- .../Models/InAppShowFailure.swift | 50 ++++++++++ .../Tests/InappShowFailureManagerTests.swift | 95 +++++++++++++++---- 3 files changed, 171 insertions(+), 30 deletions(-) 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/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) { From 63b4c3bfa5a21aa31fc5ecdd76ce181c7299abc5 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:48:54 +0300 Subject: [PATCH 05/10] MOBILE-419: Serial passes with buffered refusals for places and pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every selection — the trigger's, a place's, a page's — runs as one pass on one queue with a finish that fires exactly once; only a pass that picked nobody sends the refusals it buffered. Form building moves out of the mapper, config place names are trimmed, and the checker's event travels with the pass instead of living in shared state. --- .../Configuration/API/TargetingModel.swift | 17 + .../InappFormBuilder.swift | 88 +++ .../InappMapper.swift | 652 ++++++++++-------- .../InAppConfigurationDataFacade.swift | 14 + .../InappFilterService/InappFilter.swift | 89 +-- .../InappFilterService/VariantsFilter.swift | 5 +- Mindbox/InAppMessages/InAppCoreManager.swift | 4 +- .../CustomOperationChecker.swift | 6 +- .../ConfigJsonStubs/EmbeddedBlockConfig.json | 162 ++++- .../InappMapperTests.swift | 39 ++ .../ConfigCandidatesTests.swift | 6 +- .../EmbeddedFormVariantTests.swift | 16 +- .../InappFilterServiceTests.swift | 30 + .../InappOverlayFilterTests.swift | 4 +- .../InappPlaceFilterTests.swift | 23 +- .../InApp/Tests/InAppCoreManagerTests.swift | 13 +- .../InappConfigurationDataFacadeTests.swift | 14 +- .../MockInAppConfigurationDataFacade.swift | 12 + 18 files changed, 805 insertions(+), 389 deletions(-) create mode 100644 Mindbox/InAppMessages/InAppConfigurationMapper/InappFormBuilder.swift 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/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..8af689e8c 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 + private func transition(forId id: String, in candidates: ConfigCandidates) -> InAppTransitionData? { + guard let inapp = inappFilterService.filter(id: id, in: candidates) else { return nil } - let event: ApplicationEvent? - - /// 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 }) + } + + 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 inappFilterService.filterInappsByOperationForShow( - event: event, - operationInapps: targetingChecker.context.operationInapps, - 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 + ) + } + + /// 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 + ) } - private func buildInAppByEvent(inapps: [InAppTransitionData], - completion: @escaping (InAppFormData?) -> Void) { + // 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,72 @@ 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) - } + 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 urlExtractorService = DI.injectOrFail(VariantImageUrlExtractorServiceProtocol.self) - let imageValues = urlExtractorService.extractImageURL(from: inapp.content) + let hash = Self.eventHash(event) + for inapp in suitable where shownInappIDWithHashValue[inapp.inAppId] != hash { + dataFacade.trackTargeting(id: inapp.inAppId, tags: inapp.tags) + } + } - let group = DispatchGroup() - let imageDictQueue = DispatchQueue(label: "com.mindbox.imagedict.queue", attributes: .concurrent) - var imageDict: [String: UIImage] = [:] - var gotError = false + /// 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 + } - 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() - } + guard SessionTemporaryStorage.shared.ledger.placeTargetedInappId[place] != inapp.inAppId 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 + } - switch result { - case .success(let image): - imageDictQueue.async(flags: .barrier) { - imageDict[imageValue] = image - } - case .failure: - gotError = true - } + SessionTemporaryStorage.shared.$ledger.mutate { + $0.placeTargetedInappId[place] = inapp.inAppId + $0.vouchedInappIds.insert(inapp.inAppId) } + dataFacade.trackTargeting(id: inapp.inAppId, tags: inapp.tags) } + } - 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) + /// 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.vouchedBlockOffers.contains(offer) else { continue } + + SessionTemporaryStorage.shared.$ledger.mutate { $0.vouchedBlockOffers.insert(offer) } + 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) - - DispatchQueue.main.async { - completion(formData) + /// 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.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 } + + SessionTemporaryStorage.shared.$ledger.mutate { $0.vouchedInappIds.insert(inapp.inAppId) } + 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 +498,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/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/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/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/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 } } From 0342d6ff99115fc73793d35ff5296d75b85ba7a3 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:49:09 +0300 Subject: [PATCH 06/10] MOBILE-419: Places wait for the first config instead of failing early MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit selectInappForPlace answers when the config arrives or the 30 s waiter gives up — an early nothing would collapse the block for the screen's whole life. The manager's clock is a seam; getEmbeddedPlaces trims the raw scan the way the selection trims. --- .../InAppConfigurationManager.swift | 46 +++- .../EmbeddedBlockResolveTests.swift | 259 +++++++++++++++--- .../InAppConfigurationManagerTests.swift | 131 +++++---- 3 files changed, 324 insertions(+), 112 deletions(-) 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/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/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) } From ede0ec5022757fd61acf3d8a944c6743f3434808 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:49:09 +0300 Subject: [PATCH 07/10] MOBILE-419: Answer the page's questions in the page's own terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit filterShowableInapps mirrors the asked ids — order and duplicates included — each answered by the full list's verdict and vouched per offer; showInApp is honored only from a live block. --- .../Views/WebView/Bridge/BridgeMessage.swift | 16 +++---- .../FilterShowableInappsActionHandler.swift | 8 ++-- .../Handlers/ShowInAppActionHandler.swift | 8 ++-- .../Bridge/Handlers/WebBridgeHost.swift | 2 +- .../Prewarm/InAppWebViewPrewarmPlanner.swift | 2 +- ... => InappRequestActionHandlersTests.swift} | 42 +++++++++---------- .../WebView/MindboxWebPageRegistryTests.swift | 6 +-- 7 files changed, 42 insertions(+), 42 deletions(-) rename MindboxTests/InApp/Tests/WebView/BridgeHandlers/{FeedActionHandlersTests.swift => InappRequestActionHandlersTests.swift} (86%) 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/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]) } From ec6b5f3a79ec3d9543ae531ddeb1f3d102256589 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:49:10 +0300 Subject: [PATCH 08/10] MOBILE-419: Halve the in-app window reveal after the page's init The fade ran 300 ms after the page had already drawn itself, so every tap on a story paid it on top of the load. 150 ms still hides the composite jump. --- .../Presentation/Views/WebView/WebViewController.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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() From c476382d9545e6f18881fbb71847a52523125026 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:49:21 +0300 Subject: [PATCH 09/10] MOBILE-419: Put the embedded block on the in-app event pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block reports what the overlay reports: Inapp.Show with a timeToDisplay frozen at the moment the page draws, refusals with a phase and the waited budget, a delayTime served once per place, and a show accounted once per place. Pauses hold what they held — the ack budget keeps its remainder, a failure off screen waits until the block is looked at. --- Mindbox.xcodeproj/project.pbxproj | 16 + .../DI/Injections/InjectEmbeddedBlocks.swift | 9 +- Mindbox/DI/Injections/InjectInappTools.swift | 22 +- .../EmbeddedBlockContentProviderFactory.swift | 31 +- .../Container/EmbeddedBlockWaitBudget.swift | 3 +- .../Feed/EmbeddedBlockFeedService.swift | 85 ---- .../MindboxEmbeddedBlock.swift | 7 +- .../Public/MindboxEmbeddedBlockView.swift | 52 ++- .../EmbeddedBlockDelayedDelivery.swift | 112 +++++ .../Resolver/EmbeddedBlockPlaceRegistry.swift | 72 ++- .../Resolver/EmbeddedBlockResolver.swift | 34 +- .../Resolver/EmbeddedBlockWebContent.swift | 19 + .../WebView/EmbeddedBlockAckBudget.swift | 49 ++ .../WebView/EmbeddedBlockInappService.swift | 90 ++++ .../WebView/EmbeddedBlockPageHosting.swift | 12 + .../WebView/EmbeddedBlockWebViewPage.swift | 6 +- .../EmbeddedBlockWebViewProvider.swift | 227 ++++++---- ...ddedBlockContentProviderFactoryTests.swift | 39 +- .../EmbeddedBlockDelayedDeliveryTests.swift | 115 +++++ ...t => EmbeddedBlockInappServiceTests.swift} | 94 ++-- .../EmbeddedBlocks/EmbeddedBlockMocks.swift | 134 ++++-- .../EmbeddedBlockPlaceRegistryTests.swift | 154 ++++++- .../EmbeddedBlockResolverTests.swift | 28 +- .../EmbeddedBlockWebViewPageTests.swift | 22 +- .../EmbeddedBlockWebViewProviderTests.swift | 419 +++++++++++------- .../MindboxEmbeddedBlockViewTests.swift | 112 +++-- 26 files changed, 1404 insertions(+), 559 deletions(-) delete mode 100644 Mindbox/EmbeddedBlocks/Feed/EmbeddedBlockFeedService.swift create mode 100644 Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockDelayedDelivery.swift create mode 100644 Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockAckBudget.swift create mode 100644 Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockInappService.swift create mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockDelayedDeliveryTests.swift rename MindboxTests/EmbeddedBlocks/{EmbeddedBlockFeedServiceTests.swift => EmbeddedBlockInappServiceTests.swift} (56%) diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 5578b8869..85e697414 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,7 @@ 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 */; }; + 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 +609,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 +673,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 +1172,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 +1315,7 @@ 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 = ""; }; + 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 +1392,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 +1455,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 +1794,7 @@ 3328FE4326303F30000A30D0 /* TimeInterval+TimeSpan.swift */, F78E92EE282E63320003B4A3 /* DispatchSemaphore.swift */, A153E03E29BB002A003C34D4 /* SessionTemporaryStorage.swift */, + E4ADBBEBCF7E68E56565FEA9 /* InappSessionLedger.swift */, ); path = Utilities; sourceTree = ""; @@ -2735,6 +2744,7 @@ 9B24FAB328C751CB00F10B5D /* Images */, F34975692DEF2C8400BEC667 /* InappTrackingService.swift */, F3A22CB92E169F3F005817F2 /* InappScheduleManager.swift */, + B8A674E9219591468A9FE335 /* InappShowAccountant.swift */, F3B70A002F250A0100AABB01 /* ForegroundStopwatch.swift */, F3C1A0012F5B100100ABC001 /* InappShowFailureManager.swift */, A1B2C3D400000016E1010101 /* Permissions */, @@ -2822,6 +2832,7 @@ 9BC24E7828F6BFEC00C2619C /* InAppConfigResponseTests */, F3D925AC2A1236F400135C87 /* URLSessionImageDownloaderTests.swift */, F32CA1752E17625200CE7E63 /* InappScheduleManagerTests.swift */, + AA57EA9FD870B3E7E22BE75D /* InappShowAccountantTests.swift */, 327A85474E7AB7AC1884A890 /* InAppConfigurationManagerTests.swift */, D55C134D9E499E94D099CD09 /* InAppCoreManagerTests.swift */, F3B70A022F250A0100AABB02 /* ForegroundStopwatchTests.swift */, @@ -3813,6 +3824,7 @@ children = ( F3A8B9562A389D9300E9C055 /* Services */, F351F1BF2CE380A40053423E /* InappMapper.swift */, + 4761BB2323EB5B1C72894561 /* InappFormBuilder.swift */, ); path = InAppConfigurationMapper; sourceTree = ""; @@ -4492,6 +4504,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 +4574,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 +4583,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 +4893,7 @@ 47B90E312C626B9300BD93E7 /* TestProtocolMigrations.swift in Sources */, F3A8B9982A3A421C00E9C055 /* SDKVersionValidatorTests.swift in Sources */, F32CA1762E17625200CE7E63 /* InappScheduleManagerTests.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..ca4344086 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,21 @@ 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.placesReportedUnanswered.contains(placeSystemName) else { + Logger.common(message: "[EmbeddedBlock] Block '\(placeSystemName)': the SDK stayed silent again this session — already reported", + category: .embeddedBlocks) + return + } + + SessionTemporaryStorage.shared.$ledger.mutate { $0.placesReportedUnanswered.insert(placeSystemName) } + 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 +477,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 +517,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/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() { From 881c56420309ab238b152a86869b4d760efbb333 Mon Sep 17 00:00:00 2001 From: Sergei Semko <28645140+justSmK@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:59:45 +0300 Subject: [PATCH 10/10] MOBILE-419: Fuse the ledger's checks with their writes under one lock Each dedup was a check and a write as two separate atomic operations: on different queues the pair could straddle the session reset and carry one stale entry into the fresh ledger. The pairs now run inside a single mutate, answering whether the record is news; the answers are pinned by a ledger suite. --- Mindbox.xcodeproj/project.pbxproj | 4 + .../EmbeddedBlockWebViewProvider.swift | 3 +- .../InappMapper.swift | 12 +-- .../InAppMessages/InappShowAccountant.swift | 3 +- Mindbox/Utilities/InappSessionLedger.swift | 34 +++++++ .../InApp/Tests/InappSessionLedgerTests.swift | 96 +++++++++++++++++++ 6 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 MindboxTests/InApp/Tests/InappSessionLedgerTests.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 85e697414..6db14646e 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -532,6 +532,7 @@ 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 */; }; @@ -1315,6 +1316,7 @@ 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 = ""; }; @@ -2832,6 +2834,7 @@ 9BC24E7828F6BFEC00C2619C /* InAppConfigResponseTests */, F3D925AC2A1236F400135C87 /* URLSessionImageDownloaderTests.swift */, F32CA1752E17625200CE7E63 /* InappScheduleManagerTests.swift */, + B7A74E65031C00E64367BA99 /* InappSessionLedgerTests.swift */, AA57EA9FD870B3E7E22BE75D /* InappShowAccountantTests.swift */, 327A85474E7AB7AC1884A890 /* InAppConfigurationManagerTests.swift */, D55C134D9E499E94D099CD09 /* InAppCoreManagerTests.swift */, @@ -4893,6 +4896,7 @@ 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 */, diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift index ca4344086..50bbf227c 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift @@ -424,13 +424,12 @@ final class EmbeddedBlockWebViewProvider { /// 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.placesReportedUnanswered.contains(placeSystemName) else { + 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 } - SessionTemporaryStorage.shared.$ledger.mutate { $0.placesReportedUnanswered.insert(placeSystemName) } 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) diff --git a/Mindbox/InAppMessages/InAppConfigurationMapper/InappMapper.swift b/Mindbox/InAppMessages/InAppConfigurationMapper/InappMapper.swift index 8af689e8c..ba2426e94 100644 --- a/Mindbox/InAppMessages/InAppConfigurationMapper/InappMapper.swift +++ b/Mindbox/InAppMessages/InAppConfigurationMapper/InappMapper.swift @@ -443,16 +443,12 @@ class InappMapper: InappMapperProtocol { continue } - guard SessionTemporaryStorage.shared.ledger.placeTargetedInappId[place] != inapp.inAppId else { + 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 } - SessionTemporaryStorage.shared.$ledger.mutate { - $0.placeTargetedInappId[place] = inapp.inAppId - $0.vouchedInappIds.insert(inapp.inAppId) - } dataFacade.trackTargeting(id: inapp.inAppId, tags: inapp.tags) } } @@ -461,9 +457,8 @@ class InappMapper: InappMapperProtocol { private func vouchOffers(_ offered: [InAppTransitionData], by blockInappId: String) { for inapp in offered { let offer = BlockOffer(blockInappId: blockInappId, inappId: inapp.inAppId) - guard !SessionTemporaryStorage.shared.ledger.vouchedBlockOffers.contains(offer) else { continue } + guard SessionTemporaryStorage.shared.$ledger.mutate({ $0.vouchOffer(offer) }) else { continue } - SessionTemporaryStorage.shared.$ledger.mutate { $0.vouchedBlockOffers.insert(offer) } dataFacade.trackTargeting(id: inapp.inAppId, tags: inapp.tags) } } @@ -471,13 +466,12 @@ class InappMapper: InappMapperProtocol { /// 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.vouchedInappIds.contains(inapp.inAppId) else { + 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 } - SessionTemporaryStorage.shared.$ledger.mutate { $0.vouchedInappIds.insert(inapp.inAppId) } self.dataFacade.trackTargeting(id: inapp.inAppId, tags: inapp.tags) } } diff --git a/Mindbox/InAppMessages/InappShowAccountant.swift b/Mindbox/InAppMessages/InappShowAccountant.swift index 6c4a9b7a9..d13b910d7 100644 --- a/Mindbox/InAppMessages/InappShowAccountant.swift +++ b/Mindbox/InAppMessages/InappShowAccountant.swift @@ -61,13 +61,12 @@ final class InappShowAccountant: InappShowAccounting { } func recordBlockShow(_ show: InappShow, at place: String) { - guard SessionTemporaryStorage.shared.ledger.placeShownInappId[place] != show.inAppId else { + 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 } - SessionTemporaryStorage.shared.$ledger.mutate { $0.placeShownInappId[place] = show.inAppId } recordShow(show) } } diff --git a/Mindbox/Utilities/InappSessionLedger.swift b/Mindbox/Utilities/InappSessionLedger.swift index a81312a7e..6444edc8b 100644 --- a/Mindbox/Utilities/InappSessionLedger.swift +++ b/Mindbox/Utilities/InappSessionLedger.swift @@ -43,3 +43,37 @@ struct InappSessionLedger: Equatable { /// 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/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) + } +}