From c273bd446a83c7505b46bebe76ae5adad8a8c317 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 16:33:47 +0500 Subject: [PATCH 01/47] MOBILE-323: Add the embeddedBlocks log category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Своя категория логов у встроенных блоков: их путь — резолв id, страница, бюджет ожидания — читается в логах отдельно от инаппов. Счётчик категорий в LogPrimitivesTests обновлён здесь же: это не новый тест, а утверждение, которое обязано ехать вместе с самим enum. --- MindboxLogger/Shared/Group/LogCategory.swift | 3 +++ MindboxLoggerTests/LogPrimitivesTests.swift | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/MindboxLogger/Shared/Group/LogCategory.swift b/MindboxLogger/Shared/Group/LogCategory.swift index 2f40d1b48..17f271e88 100644 --- a/MindboxLogger/Shared/Group/LogCategory.swift +++ b/MindboxLogger/Shared/Group/LogCategory.swift @@ -23,6 +23,7 @@ public enum LogCategory: String, CaseIterable { case inAppMessages case webViewInAppMessages case appDelegate + case embeddedBlocks var emoji: String { switch self { @@ -52,6 +53,8 @@ public enum LogCategory: String, CaseIterable { return "🕸️" case .appDelegate: return "🪄" + case .embeddedBlocks: + return "📚" } } } diff --git a/MindboxLoggerTests/LogPrimitivesTests.swift b/MindboxLoggerTests/LogPrimitivesTests.swift index 5b71e62b3..550f3659e 100644 --- a/MindboxLoggerTests/LogPrimitivesTests.swift +++ b/MindboxLoggerTests/LogPrimitivesTests.swift @@ -14,7 +14,7 @@ struct LogPrimitivesTests { @Test("Every LogCategory exposes a non-empty emoji") func categoryEmoji() { - #expect(LogCategory.allCases.count == 13) + #expect(LogCategory.allCases.count == 14) for category in LogCategory.allCases { #expect(!category.emoji.isEmpty, "\(category) has no emoji") } From 2629d8b153fa90d822eda30b25153ddaa753b9fc Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 16:35:44 +0500 Subject: [PATCH 02/47] MOBILE-323: Add the embedded block web contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Словарь, на котором страница блока разговаривает с нативной стороной, и типы, которыми говорят между собой все остальные слои. Ядро разбирает только core-слой — ready, heightChanged и empty, они нужны любому блоку. Всё остальное с валидным конвертом уходит механике как action: ядро не знает и не должно знать словарь конкретной механики. Состояния контейнера намеренно internal: хост узнаёт только исход — показан блок или нет, — поэтому менять сам путь к исходу можно без изменения публичного API. Папка Mindbox/EmbeddedBlocks заведена синхронизируемой группой, поэтому файлы в ней дальше подхватываются без правок проекта. --- Mindbox.xcodeproj/project.pbxproj | 5 + .../Container/EmbeddedBlockState.swift | 31 +++++++ .../Resolver/EmbeddedBlockWebContent.swift | 41 +++++++++ .../WebView/EmbeddedBlockPageMessage.swift | 91 +++++++++++++++++++ 4 files changed, 168 insertions(+) create mode 100644 Mindbox/EmbeddedBlocks/Container/EmbeddedBlockState.swift create mode 100644 Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift create mode 100644 Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 369cf72c4..863531d03 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -1501,6 +1501,7 @@ 17B157F9FE3789FA97A7085E /* MindboxLoggerTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = MindboxLoggerTests; sourceTree = ""; }; 471D4C2D2FEECF8800856EA5 /* MindboxNotificationsTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = MindboxNotificationsTests; sourceTree = ""; }; 471D4C3B2FEED16200856EA5 /* TestPlans */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = TestPlans; sourceTree = ""; }; + 57A1B3230000000000000300 /* EmbeddedBlocks */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EmbeddedBlocks; sourceTree = ""; }; F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappConfigurationDataFacade; sourceTree = ""; }; F397DE1C2CFF568800B72DA9 /* JSONs */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = JSONs; sourceTree = ""; }; F3DEB38C2D47CBA200D0EFA4 /* InappSessionManagerTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappSessionManagerTests; sourceTree = ""; }; @@ -1646,6 +1647,7 @@ 31A20D4225B6B6D700AAA0A3 /* PersistenceStorage */, A179589C2979708B00609E91 /* Resources */, 33EBF0AE264E6269002A35D5 /* SessionManager */, + 57A1B3230000000000000300 /* EmbeddedBlocks */, 84F5655C2628433900269FD6 /* TrackVisitManager */, D2F7E2402BADB89900B24BB8 /* UserVisitManager */, 31A20D4A25B6E09900AAA0A3 /* Utilities */, @@ -3955,6 +3957,9 @@ dependencies = ( A170EDDF29B0883800CE547F /* PBXTargetDependency */, ); + fileSystemSynchronizedGroups = ( + 57A1B3230000000000000300 /* EmbeddedBlocks */, + ); name = Mindbox; productName = MindBox; productReference = 313B233025ADEA0F00A1CB72 /* Mindbox.framework */; diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockState.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockState.swift new file mode 100644 index 000000000..67ef28dbd --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockState.swift @@ -0,0 +1,31 @@ +// +// EmbeddedBlockState.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +/// The container's own view of the block content. +/// +/// Deliberately internal: the host app only learns whether the block ended up shown or not, +/// never the intermediate progress, so the SDK stays free to change the flow later. +/// +/// The states carry no height: the container is always as tall as the host asked at creation, +/// except for `failed` and `empty`, where it collapses to zero. +enum EmbeddedBlockState: Equatable { + + /// The content has not resolved yet. + case loading + + /// The content is renderable. + case ready + + /// The content failed to resolve — a load error, a timeout or broken content. Collapses + /// the container. + case failed + + /// There is genuinely nothing to show — for instance, the block is disabled in the admin + /// panel. Not a failure, but collapses the container the same way. + case empty +} diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift new file mode 100644 index 000000000..77dbb05c1 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift @@ -0,0 +1,41 @@ +// +// EmbeddedBlockWebContent.swift +// Mindbox +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// Что именно показывает встроенный блок. +/// +/// Контент блока всегда веб: SDK ничего не рисует сам, он показывает страницу, закреплённую за id +/// блока. Меняется внутри — адрес, вёрстка, механика на странице, — но не вид контента, поэтому +/// дескриптор описывает веб-страницу прямо, без промежуточного «вида контента». +/// +/// Сюда же приедут остальные поля конфига, когда он появится: версия веб-контракта, +/// зарезервированная высота, параметры страницы. +struct EmbeddedBlockWebContent: Equatable { + + /// Чем задана страница. + enum Source: Equatable { + + /// Боевой случай: адрес, который приедет из конфига. + case url(URL) + + /// Разметка вместо адреса. Нужна отладочной подмене контента: сценарии приёмки — пустая + /// страница, молчащая страница, ответ уже после таймаута — в сеть не выкладываются. + case html(String) + } + + let source: Source + + init(url: URL) { + source = .url(url) + } + + init(html: String) { + source = .html(html) + } +} diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift new file mode 100644 index 000000000..b5d5087f0 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift @@ -0,0 +1,91 @@ +// +// EmbeddedBlockPageMessage.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import CoreGraphics +import Foundation + +/// Что страница встроенного блока сообщает нативной стороне. +/// +/// Ядро разбирает только core-слой — `ready`, `heightChanged` и `empty`, они нужны любому блоку. Всё +/// остальное с валидным конвертом уходит в механику как `action`: ядро не знает и не должно +/// знать словарь конкретной механики. +/// +/// Формат пока свой и минимальный: страница шлёт `{"type": ..., ...}`. Сведение с общим +/// JS-мостом инаппов (`MindboxWebBridge`) — отдельная задача, до неё этот разбор трогать не нужно. +enum EmbeddedBlockPageMessage: Equatable { + + /// Страница отрисовалась и просит контейнер стать `height` точек высотой. + case ready(height: CGFloat) + + /// Страница перемерилась уже после показа — например, подгрузился контент. + case heightChanged(height: CGFloat) + + /// Странице нечего показать — например, блок выключен в админке. Это не ошибка. + case empty + + /// Действие сверх core-слоя — его смысл знает механика блока. + case action(EmbeddedBlockPageAction) + + /// Тело сообщения приходит из WebKit как `Any`. Строку разбираем как JSON, словарь берём как + /// есть: страница может присылать и то и другое, а падать на форме сообщения тут незачем. + init?(body: Any) { + let payload: [String: Any] + + if let dictionary = body as? [String: Any] { + payload = dictionary + } else if let json = body as? String, + let data = json.data(using: .utf8), + let decoded = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + payload = decoded + } else { + return nil + } + + guard let type = payload["type"] as? String else { + return nil + } + + switch type { + case "ready": + guard let height = EmbeddedBlockPageMessage.height(from: payload) else { return nil } + self = .ready(height: height) + case "heightChanged": + guard let height = EmbeddedBlockPageMessage.height(from: payload) else { return nil } + self = .heightChanged(height: height) + case "empty": + self = .empty + default: + self = .action(EmbeddedBlockPageAction(type: type, payload: payload)) + } + } + + /// JS отдаёт число как `Double`, но целые значения могут прийти и как `Int` — берём оба. + private static func height(from payload: [String: Any]) -> CGFloat? { + if let height = payload["height"] as? Double { + return CGFloat(height) + } + + if let height = payload["height"] as? Int { + return CGFloat(height) + } + + return nil + } +} + +/// Конверт действия, которое ядро не разбирает, а передаёт механике: тип и весь payload +/// сообщения как есть. +struct EmbeddedBlockPageAction: Equatable { + + let type: String + let payload: [String: Any] + + static func == (lhs: EmbeddedBlockPageAction, rhs: EmbeddedBlockPageAction) -> Bool { + lhs.type == rhs.type && (lhs.payload as NSDictionary).isEqual(to: rhs.payload) + } +} From 1db47158071b5054b3d34d076a81e155df8881ee Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 16:35:57 +0500 Subject: [PATCH 03/47] MOBILE-323: Add the embedded block content resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отвечает на единственный вопрос: что показывает блок с данным id. Резолвер — общая точка всех контейнеров: несколько блоков с одним id разрешаются одними данными, за которыми ходим один раз. Кэш на id и очередь ожидающих — это и есть «одна загрузка на id»: второй блок встаёт в очередь, а не идёт за данными сам. forceRefresh нужен перезагрузке блока: переехавший или выключенный блок иначе вечно доставал бы из кэша прежний адрес. Пока конфига из админки нет, любой id разрешается в статическую страницу ленты сторизов. Это единственное место, которое заменит настоящий конфиг: кэш и очередь при этом не изменятся. Рядом — подмена содержимого по id: она встаёт ровно на место будущего конфига и сильнее кэша, потому что приёмка переключает сценарий на ходу. --- .../EmbeddedBlockContentOverrides.swift | 84 +++++++++++++ .../Resolver/EmbeddedBlockResolver.swift | 114 ++++++++++++++++++ 2 files changed, 198 insertions(+) create mode 100644 Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockContentOverrides.swift create mode 100644 Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockContentOverrides.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockContentOverrides.swift new file mode 100644 index 000000000..20148a442 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockContentOverrides.swift @@ -0,0 +1,84 @@ +// +// EmbeddedBlockContentOverrides.swift +// Mindbox +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// Что подставить вместо контента, закреплённого за id блока. +protocol EmbeddedBlockContentOverriding: AnyObject { + + func resolution(for id: String) -> EmbeddedBlockResolution? +} + +/// Отладочная подмена контента блока — то, чем приёмка воспроизводит сценарии, которые в сети не +/// выложены: пустой блок, молчащая страница, ответ уже после таймаута, незнакомое сообщение. +/// +/// Подмена сидит на месте конфига, поэтому весь путь ниже — резолвер, провайдер, страница, таймаут +/// контейнера — работает по-настоящему; меняется только источник данных о блоке. Кэш резолвера для +/// подменённого id не используется, чтобы переключение сценария применялось сразу. +/// +/// Спрятана за `@_spi(Internal)`: в обычном API её нет, но и не вырезана из релизных сборок — QA +/// проверяет то, что уходит клиентам. Каждая установка пишется в лог, чтобы включённую подмену было +/// невозможно не заметить. +final class EmbeddedBlockContentOverrides: EmbeddedBlockContentOverriding { + + static let shared = EmbeddedBlockContentOverrides() + + /// Подмену ставят из QA-кода приложения, а читает её резолвер на главном потоке — потоки могут + /// не совпасть. + private let lock = NSLock() + + private var overrides: [String: EmbeddedBlockResolution] = [:] + + func set(_ resolution: EmbeddedBlockResolution, for id: String) { + lock.lock() + overrides[id] = resolution + lock.unlock() + + Logger.common(message: "[EmbeddedBlock] Debug override is ON for block id '\(id)': \(describe(resolution))", + level: .default, + category: .embeddedBlocks) + } + + func remove(for id: String) { + lock.lock() + let removed = overrides.removeValue(forKey: id) != nil + lock.unlock() + + guard removed else { return } + Logger.common(message: "[EmbeddedBlock] Debug override is OFF for block id '\(id)'", category: .embeddedBlocks) + } + + func removeAll() { + lock.lock() + let hadAny = !overrides.isEmpty + overrides = [:] + lock.unlock() + + guard hadAny else { return } + Logger.common(message: "[EmbeddedBlock] All debug overrides are OFF", category: .embeddedBlocks) + } + + func resolution(for id: String) -> EmbeddedBlockResolution? { + lock.lock() + defer { lock.unlock() } + return overrides[id] + } + + private func describe(_ resolution: EmbeddedBlockResolution) -> String { + switch resolution { + case .empty: + return "empty" + case .content(let content): + switch content.source { + case .url(let url): return url.absoluteString + case .html: return "inline html" + } + } + } +} diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift new file mode 100644 index 000000000..202e9689b --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift @@ -0,0 +1,114 @@ +// +// EmbeddedBlockResolver.swift +// Mindbox +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// Во что разрешается id встроенного блока. +enum EmbeddedBlockResolution: Equatable { + + /// За id закреплён контент — блок грузит его. + case content(EmbeddedBlockWebContent) + + /// За id ничего нет — блок выключен в админке или id неизвестен. Не ошибка. + case empty +} + +/// Отвечает на единственный вопрос: что показывает блок с данным id. +/// +/// Резолвер — общая точка всех контейнеров: несколько блоков с одним id разрешаются одними +/// данными, при этом вью, страница и состояние у каждого блока остаются своими. Работает на +/// главном потоке; completion может прийти как синхронно (кэш), так и позже (сетевой конфиг). +protocol EmbeddedBlockResolving: AnyObject { + + /// - Parameter forceRefresh: `true` — не брать кэш, спросить данные заново. Нужно перезагрузке + /// блока: переехавший или выключенный блок иначе вечно доставал бы из кэша прежний адрес. + func resolve(_ id: String, forceRefresh: Bool, completion: @escaping (EmbeddedBlockResolution) -> Void) +} + +extension EmbeddedBlockResolving { + + func resolve(_ id: String, completion: @escaping (EmbeddedBlockResolution) -> Void) { + resolve(id, forceRefresh: false, completion: completion) + } +} + +/// Откуда резолвер узнаёт, что стоит за id блока. +/// +/// Сейчас это заглушка со статической страницей. Когда появится конфиг из админки, здесь окажется +/// настоящая загрузка, а кэш и очередь ожидающих в резолвере не изменятся. +typealias EmbeddedBlockContentLoading = (String, @escaping (EmbeddedBlockResolution) -> Void) -> Void + +final class EmbeddedBlockResolver: EmbeddedBlockResolving { + + /// Страница ленты сторизов на статике. Временно захардкожена: когда появится конфиг из + /// админки, адрес приедет оттуда вместе с маппингом id → контент. + private static let storiesPageURL = "https://mobile-static.mindbox.ru/beta/inapps/webview/content/stories.html" + + private let load: EmbeddedBlockContentLoading + private let overrides: EmbeddedBlockContentOverriding + + /// Кэш на id: ответ, полученный один раз, достаётся всем следующим блокам сразу. + private var cache: [String: EmbeddedBlockResolution] = [:] + + /// Кто уже ждёт ответ по этому id. «Одна загрузка данных на id» — это про то, что второй блок + /// с тем же id встаёт в эту очередь, а не идёт за данными сам. + private var waiting: [String: [(EmbeddedBlockResolution) -> Void]] = [:] + + init(load: @escaping EmbeddedBlockContentLoading = EmbeddedBlockResolver.loadStubbedStoriesPage, + overrides: EmbeddedBlockContentOverriding = EmbeddedBlockContentOverrides.shared) { + self.load = load + self.overrides = overrides + } + + func resolve(_ id: String, forceRefresh: Bool, completion: @escaping (EmbeddedBlockResolution) -> Void) { + // Отладочная подмена сильнее и данных, и кэша: приёмка переключает сценарий на ходу, и + // закэшированный ответ мешал бы этому. + if let overridden = overrides.resolution(for: id) { + completion(overridden) + return + } + + if !forceRefresh, let cached = cache[id] { + completion(cached) + return + } + + // Загрузка по этому id уже идёт. Присоединиться к ней правильно и для `forceRefresh`: + // ответ, который она вот-вот принесёт, свежий по определению. + if waiting[id] != nil { + waiting[id]?.append(completion) + return + } + + waiting[id] = [completion] + + load(id) { [weak self] resolution in + guard let self else { return } + + self.cache[id] = resolution + let completions = self.waiting.removeValue(forKey: id) ?? [] + completions.forEach { $0(resolution) } + } + } + + /// Конфига ещё нет, поэтому любой id разрешается в страницу ленты сторизов. Это единственное + /// место, которое заменит настоящий конфиг из админки: id → контент блока, выключенный или + /// неизвестный блок → `.empty`. + static func loadStubbedStoriesPage(_ id: String, completion: @escaping (EmbeddedBlockResolution) -> Void) { + guard let url = URL(string: storiesPageURL) else { + Logger.common(message: "[EmbeddedBlock] Invalid stories page URL, resolving id '\(id)' as empty", + category: .embeddedBlocks) + completion(.empty) + return + } + + Logger.common(message: "[EmbeddedBlock] Resolved block id '\(id)' to \(url.absoluteString)", category: .embeddedBlocks) + completion(.content(EmbeddedBlockWebContent(url: url))) + } +} From 057b2763a61d76ec40438d3f174ab55263b139c9 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 16:36:14 +0500 Subject: [PATCH 04/47] MOBILE-323: Add the embedded block page in WKWebView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Страница блока и шов, за которым живёт весь WebKit: перевод её сообщений в состояния блока дальше проверяется без реального вебвью и без сети. Вебвью берётся из InAppWebViewFactory — того же места, где настраиваются вебвью инаппов, — поэтому блок получает тот же user agent, тот же WKWebsiteDataStore и общий с инаппами HTTP-кеш. Навигация судит исключительно о своём: загрузка не состоялась или документ доехал. Готовность блока из этого не следует — о ней говорит сама страница своим ready. Отменённая навигация при этом провалом не считается: WebKit отдаёт NSURLErrorCancelled и когда навигацию вытеснил клиентский редирект, и когда её остановил наш собственный cancel() на уехавшем с экрана блоке. Выдать это за провал значит свернуть исправный блок насовсем. Мост живёт столько же, сколько страница, и держится слабым прокси: WKUserContent- Controller держит обработчик сильно, иначе страница и вебвью не освободятся. Отладочная подмена готовности — для страниц, которые веб-контракт ещё не умеют. --- .../WebView/EmbeddedBlockPageHosting.swift | 38 ++++ .../EmbeddedBlockReadinessOverrides.swift | 61 +++++++ .../WebView/EmbeddedBlockWebViewPage.swift | 162 ++++++++++++++++++ 3 files changed, 261 insertions(+) create mode 100644 Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift create mode 100644 Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockReadinessOverrides.swift create mode 100644 Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift new file mode 100644 index 000000000..c2f1afead --- /dev/null +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift @@ -0,0 +1,38 @@ +// +// EmbeddedBlockPageHosting.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit + +/// Страница встроенного блока — всё, что провайдеру нужно от вебвью. +/// +/// Единственный шов внутри блока и единственное место, где живёт WebKit: перевод сообщений +/// страницы в состояния блока так проверяется без реального вебвью и без сети. +protocol EmbeddedBlockPageHosting: AnyObject { + + /// Вью страницы. Провайдер отдаёт её контейнеру как контент блока. + var view: UIView { get } + + /// Сообщения от страницы. Приходят на главном потоке. + var onMessage: ((EmbeddedBlockPageMessage) -> Void)? { get set } + + /// Загрузка страницы не состоялась — соединение, домен, отменённая навигация. Только про это + /// навигация и сообщает: готова ли страница, решает сама страница своим `ready`. + /// Приходит на главном потоке. + var onLoadFailure: (() -> Void)? { get set } + + /// Документ загрузился. Готовности блока это не значит — страница может быть пустой или + /// сломанной, — поэтому обычный путь этот сигнал игнорирует: его слушает только отладочная + /// подмена готовности для страниц без веб-контракта. Приходит на главном потоке. + var onLoadFinish: (() -> Void)? { get set } + + func load() + + /// Останавливает загрузку. Страница и её мост остаются на месте: блок может вернуться в окно, + /// и тогда уже отрендеренная страница показывается снова без перезагрузки. + func cancel() +} diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockReadinessOverrides.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockReadinessOverrides.swift new file mode 100644 index 000000000..d081158cc --- /dev/null +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockReadinessOverrides.swift @@ -0,0 +1,61 @@ +// +// EmbeddedBlockReadinessOverrides.swift +// Mindbox +// +// Created by vailence on 07.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +import MindboxLogger + +/// Отладочная подмена условия готовности блока. +protocol EmbeddedBlockReadinessOverriding: AnyObject { + + /// `true` — блок становится готовым по факту загруженного документа, не дожидаясь `ready` от + /// страницы. + var treatsLoadedPageAsReady: Bool { get } +} + +/// Временный костыль для страниц, которые ещё не умеют веб-контракт. +/// +/// Обычное правило блока — готовность объявляет только сама страница: загруженный документ ничего +/// не говорит о том, есть ли блоку что показать, поэтому молчащую страницу добивает таймаут +/// контейнера. Пока контракт не реализован на вебе, проверить вёрстку блока этим правилом +/// невозможно: любая страница сворачивается в ноль через таймаут. +/// +/// Подмена снимает ровно это ограничение и ничего больше: загрузился документ — показываем. Она +/// выключена по умолчанию и включается только явно из кода приложения, потому что со включённой +/// подменой сломанная страница выглядит как рабочая — а это ровно то, от чего защищает обычное +/// правило. +/// +/// Уедет вместе с первой страницей, которая научится присылать `ready`. +final class EmbeddedBlockReadinessOverrides: EmbeddedBlockReadinessOverriding { + + static let shared = EmbeddedBlockReadinessOverrides() + + /// Флаг ставят из кода приложения, а читает его провайдер на главном потоке — потоки могут не + /// совпасть. + private let lock = NSLock() + + private var isLoadedPageTreatedAsReady = false + + var treatsLoadedPageAsReady: Bool { + lock.lock() + defer { lock.unlock() } + return isLoadedPageTreatedAsReady + } + + func setTreatsLoadedPageAsReady(_ isEnabled: Bool) { + lock.lock() + let didChange = isLoadedPageTreatedAsReady != isEnabled + isLoadedPageTreatedAsReady = isEnabled + lock.unlock() + + guard didChange else { return } + + Logger.common(message: "[EmbeddedBlock] Debug readiness is \(isEnabled ? "ON" : "OFF"): a loaded page \(isEnabled ? "is" : "is no longer") treated as ready without the page contract", + level: .default, + category: .embeddedBlocks) + } +} diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift new file mode 100644 index 000000000..671e2e473 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift @@ -0,0 +1,162 @@ +// +// EmbeddedBlockWebViewPage.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import WebKit +import MindboxLogger + +/// Страница встроенного блока в WKWebView. +/// +/// Вебвью берётся из `InAppWebViewFactory` — того же места, где настраиваются вебвью инаппов: +/// блок получает тот же user agent и тот же `WKWebsiteDataStore`, а значит и общий HTTP-кеш. +final class EmbeddedBlockWebViewPage: NSObject, EmbeddedBlockPageHosting { + + /// Имя обработчика своё, пока блоки не переехали на общий мост инаппов. + private enum Constants { + static let handlerName = "mindboxEmbeddedBlock" + } + + let webView: WKWebView + + var view: UIView { webView } + + var onMessage: ((EmbeddedBlockPageMessage) -> Void)? + + var onLoadFailure: (() -> Void)? + + var onLoadFinish: (() -> Void)? + + private let content: EmbeddedBlockWebContent + + init(content: EmbeddedBlockWebContent, webView: WKWebView = InAppWebViewFactory.make()) { + self.content = content + self.webView = webView + super.init() + + setUpWebView() + attachBridge() + } + + deinit { + detachBridge() + } + + func load() { + switch content.source { + case .url(let url): + webView.load(URLRequest(url: url)) + case .html(let html): + webView.loadHTMLString(html, baseURL: nil) + } + } + + func cancel() { + webView.stopLoading() + } + + private func setUpWebView() { + webView.navigationDelegate = self + + // Фон прозрачный: сквозь зазоры в контенте должен просвечивать фон приложения, а не белый лист. + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + + // Высота контейнера равна высоте контента, вертикально скроллить нечего — иначе блок + // пружинил бы под пальцем на каждом горизонтальном свайпе. + webView.scrollView.bounces = false + webView.scrollView.alwaysBounceVertical = false + webView.scrollView.showsVerticalScrollIndicator = false + webView.scrollView.contentInsetAdjustmentBehavior = .never + } + + /// Мост живёт столько же, сколько страница: он ставится один раз и снимается только вместе с + /// ней. Раньше его снимала `cancel()` — из-за этого вернуть страницу в окно можно было только + /// перезагрузкой, иначе она оставалась глухой. От сообщений остановленной страницы защищает + /// провайдер, а не отсутствие моста. + private func attachBridge() { + let controller = webView.configuration.userContentController + // Идемпотентно: вебвью может прийти из переиспользования и нести обработчик с этим именем + // от прошлого владельца. + controller.removeScriptMessageHandler(forName: Constants.handlerName) + // WKUserContentController держит обработчик сильно, поэтому в него идёт слабый прокси — + // иначе страница и вебвью не освободятся никогда. + controller.add(EmbeddedBlockWebViewMessageProxy(receiver: self), name: Constants.handlerName) + } + + private func detachBridge() { + webView.configuration.userContentController.removeScriptMessageHandler(forName: Constants.handlerName) + } + + fileprivate func receive(body: Any) { + guard let message = EmbeddedBlockPageMessage(body: body) else { + Logger.common(message: "[EmbeddedBlock] Unknown page message: \(body)", category: .embeddedBlocks) + return + } + + onMessage?(message) + } +} + +/// Навигация судит только о своём: загрузка провалилась или документ доехал. Готовность блока из +/// этого не следует — о ней говорит сама страница своим `ready`, а загруженный документ слушает +/// одна лишь отладочная подмена готовности. +extension EmbeddedBlockWebViewPage: WKNavigationDelegate { + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + onLoadFinish?() + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + reportLoadFailure(error, phase: "navigation") + } + + func webView(_ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error) { + reportLoadFailure(error, phase: "provisional navigation") + } +} + +private extension EmbeddedBlockWebViewPage { + + /// Отменённая навигация — не провал загрузки, и выдавать её за провал нельзя: блок схлопнулся бы + /// на ровном месте и остался бы дыркой нулевой высоты до конца жизни экрана. WebKit отдаёт + /// `NSURLErrorCancelled` в двух совершенно обычных случаях: навигацию вытеснила следующая — + /// клиентский редирект, страница загрузится сама, — и навигацию остановили мы, вызвав `cancel()` + /// на уехавшем с экрана блоке. Второй случай к тому же приходит уже после того, как блок + /// вернулся в окно, поэтому провайдер его своим `isStarted` не отфильтрует. + func reportLoadFailure(_ error: Error, phase: String) { + let error = error as NSError + + guard !(error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled) else { + Logger.common(message: "[EmbeddedBlock] Page \(phase) was cancelled, not a load failure", + category: .embeddedBlocks) + return + } + + Logger.common(message: "[EmbeddedBlock] Page \(phase) failed: \(error.localizedDescription)", + category: .embeddedBlocks) + onLoadFailure?() + } +} + +/// Слабая прослойка между `WKUserContentController` и страницей. +private final class EmbeddedBlockWebViewMessageProxy: NSObject, WKScriptMessageHandler { + + private weak var receiver: EmbeddedBlockWebViewPage? + + init(receiver: EmbeddedBlockWebViewPage) { + self.receiver = receiver + super.init() + } + + func userContentController(_ userContentController: WKUserContentController, + didReceive message: WKScriptMessage) { + receiver?.receive(body: message.body) + } +} From 6c35062196238b03d7cfd1d162d35dd7ee1ad617 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 16:36:27 +0500 Subject: [PATCH 05/47] MOBILE-323: Add the embedded block page action router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Универсальный словарь действий страницы — один на все механики. Блок не знает, какая механика внутри, поэтому любая страница, говорящая этим словарём, получает нативное поведение без нового кода в SDK. Незнакомое действие — не ошибка: словарь у веб-стороны может быть новее, чем у SDK. openUrl открывает не что попало. Страница блока приезжает из сети, поэтому решать за пользователя, что откроет система, ей не положено: tel:, sms:, itms-apps: и схемы чужих приложений — это уже не переход по контенту, а действие от его имени, и canOpenURL для них проходит. Разрешено то, что никуда пользователя не увозит: веб-адреса и диплинки в само это приложение из CFBundleURLTypes. Открытие вынесено за шов EmbeddedBlockURLOpening — и чтобы политику можно было проверить тестами, и на будущее: в SDK открытие ссылок уже живёт в MindboxURLHandlerDelegate, и когда блоки поедут на общий мост инаппов, здесь окажется он, а не UIApplication напрямую. --- .../Actions/EmbeddedBlockActionRouter.swift | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift diff --git a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift new file mode 100644 index 000000000..926e2281e --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift @@ -0,0 +1,132 @@ +// +// EmbeddedBlockActionRouter.swift +// Mindbox +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import MindboxLogger + +/// Обработчик действий страницы сверх core-слоя. +protocol EmbeddedBlockActionHandling: AnyObject { + + func handle(_ action: EmbeddedBlockPageAction) +} + +/// Кто на самом деле открывает ссылку. +/// +/// Шов нужен и тестам, и на будущее: открытие ссылок в SDK уже живёт в `MindboxURLHandlerDelegate`, +/// и когда блоки поедут на общий мост инаппов, здесь окажется он, а не `UIApplication` напрямую. +protocol EmbeddedBlockURLOpening { + + func canOpen(_ url: URL) -> Bool + + func open(_ url: URL) +} + +final class EmbeddedBlockSystemURLOpener: EmbeddedBlockURLOpening { + + func canOpen(_ url: URL) -> Bool { + UIApplication.shared.canOpenURL(url) + } + + func open(_ url: URL) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } +} + +/// Универсальный словарь действий страницы — один на все механики. +/// +/// Блок не знает, какая механика внутри, поэтому и действия у страниц общие: любая страница, +/// говорящая этим словарём, получает нативное поведение без нового кода в SDK. Незнакомое +/// действие — не ошибка: словарь у веб-стороны может быть новее, чем у SDK, тогда действие +/// просто логируется. +/// +/// [WIP] +final class EmbeddedBlockActionRouter: EmbeddedBlockActionHandling { + + private enum ActionType { + static let openUrl = "openUrl" + } + + /// Веб-адрес — это переход по контенту, и его странице позволено открывать всегда. + private enum WebScheme { + static let all: Set = ["http", "https"] + } + + private let urlOpener: EmbeddedBlockURLOpening + + /// Схемы, которые хост объявил своими. Читаются один раз: Info.plist по ходу работы не меняется. + private let hostAppSchemes: Set + + init(urlOpener: EmbeddedBlockURLOpening = EmbeddedBlockSystemURLOpener(), + hostAppSchemes: Set = EmbeddedBlockActionRouter.hostAppSchemes(in: Bundle.main.infoDictionary)) { + self.urlOpener = urlOpener + self.hostAppSchemes = hostAppSchemes + } + + func handle(_ action: EmbeddedBlockPageAction) { + switch action.type { + case ActionType.openUrl: + openUrl(from: action) + default: + Logger.common(message: "[EmbeddedBlock] Unknown page action: \(action.type)", + category: .embeddedBlocks) + } + } + + /// Схемы из `CFBundleURLTypes` — те, по которым система вернёт пользователя в это же приложение. + /// + /// На вход идёт сам `infoDictionary`, а не `Bundle`: подменить бандлу его Info.plist в тесте + /// нельзя, а разбор проверить надо. + static func hostAppSchemes(in infoDictionary: [String: Any]?) -> Set { + let types = infoDictionary?["CFBundleURLTypes"] as? [[String: Any]] ?? [] + let schemes = types + .compactMap { $0["CFBundleURLSchemes"] as? [String] } + .flatMap { $0 } + .map { $0.lowercased() } + + return Set(schemes) + } + + private func openUrl(from action: EmbeddedBlockPageAction) { + guard let raw = action.payload["url"] as? String, + let url = URL(string: raw) else { + Logger.common(message: "[EmbeddedBlock] openUrl with an invalid url: \(action.payload)", + category: .embeddedBlocks) + return + } + + guard isAllowed(url) else { + Logger.common(message: """ + [EmbeddedBlock] openUrl refused for scheme '\(url.scheme ?? "none")': a block page may open \ + web addresses and this app's own deep links, but not system-level actions. + """, category: .embeddedBlocks) + return + } + + guard urlOpener.canOpen(url) else { + Logger.common(message: "[EmbeddedBlock] openUrl cannot be opened by the system: \(url.absoluteString)", + category: .embeddedBlocks) + return + } + + Logger.common(message: "[EmbeddedBlock] Opening url: \(url.absoluteString)", category: .embeddedBlocks) + urlOpener.open(url) + } + + /// Страница блока приезжает из сети, поэтому решать за пользователя, что откроет система, ей не + /// положено: `tel:`, `sms:`, `itms-apps:` и схемы чужих приложений — это уже не переход по + /// контенту, а действие от его имени, и `canOpenURL` для них проходит. + /// + /// Разрешено поэтому ровно то, что никуда пользователя не увозит: веб-адреса и диплинки в само + /// это приложение. Понадобится большее — это отдельное явное согласие хоста, а не молчаливое + /// право страницы. + private func isAllowed(_ url: URL) -> Bool { + guard let scheme = url.scheme?.lowercased() else { return false } + + return WebScheme.all.contains(scheme) || hostAppSchemes.contains(scheme) + } +} From 348eeb016e02e7939a7b8c30a883bbde332903af Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 16:36:39 +0500 Subject: [PATCH 06/47] MOBILE-323: Add the debug content override SPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Отладочное управление содержимым блоков — для тестового приложения и приёмки. Подменяет ответ на вопрос «что стоит за этим id», то есть встаёт ровно на место конфига из админки. Всё ниже — резолвер, страница, а дальше провайдер и бюджет ожидания у контейнера — работает без изменений, поэтому приёмка проверяет боевой путь, а не отдельный тестовый режим. Разметкой задаются сценарии, которых в сети нет: страница, сообщающая «пусто», молчащая страница, страница с ответом после таймаута. Не часть публичного API: доступно только через @_spi(Internal) import Mindbox. Из релизных сборок не вырезано намеренно — QA проверяет ровно то, что уходит клиентам, — поэтому каждая установка подмены пишется в лог. --- .../Public/MindboxEmbeddedBlockDebug.swift | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockDebug.swift diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockDebug.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockDebug.swift new file mode 100644 index 000000000..0ca8d9a20 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockDebug.swift @@ -0,0 +1,83 @@ +// +// MindboxEmbeddedBlockDebug.swift +// Mindbox +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// Отладочное управление содержимым встроенных блоков — для тестового приложения и приёмки. +/// +/// Подменяет ответ на вопрос «что стоит за этим id», то есть встаёт ровно на место конфига из +/// админки. Всё, что ниже — резолвер, провайдер, страница, бюджет ожидания у контейнера — работает +/// без изменений, поэтому приёмка проверяет боевой путь, а не отдельный тестовый режим. +/// +/// Не часть публичного API: доступно только через `@_spi(Internal) import Mindbox`. Из релизных +/// сборок не вырезано намеренно — QA проверяет ровно то, что уходит клиентам, — поэтому каждая +/// установка подмены пишется в лог. +@_spi(Internal) +public enum MindboxEmbeddedBlockDebug { + + /// Чем подменить содержимое блока. + public enum Content { + + /// Адрес страницы. Так гоняются сценарии на реальной сети — включая заведомо недоступный + /// адрес, чтобы получить провал загрузки. + case url(URL) + + /// Готовая разметка. Так задаются сценарии, которых в сети нет: страница, сообщающая + /// «пусто», молчащая страница, страница с ответом после таймаута. + case html(String) + + /// За id ничего не закреплено: блок выключен в админке или id неизвестен. + case empty + } + + /// Подменяет содержимое блока с этим id. Действует на блоки, которые начнут загрузку после + /// вызова: уже показанный блок надо перезагрузить или заново открыть экран. + public static func setContent(_ content: Content, for id: String) { + EmbeddedBlockContentOverrides.shared.set(content.resolution, for: id) + } + + /// Возвращает блоку его обычное содержимое. + public static func removeContent(for id: String) { + EmbeddedBlockContentOverrides.shared.remove(for: id) + } + + /// Снимает все подмены сразу. + public static func removeAllContent() { + EmbeddedBlockContentOverrides.shared.removeAll() + } + + /// Показывать блок, как только загрузился документ, не дожидаясь `ready` от страницы. + /// + /// Нужно ровно одному сценарию: посмотреть, как блок выглядит и ведёт себя в вёрстке хоста, + /// пока веб-контракт не реализован на странице. По обычному правилу такая страница молчит, + /// а значит сворачивается по таймауту контейнера, и увидеть в блоке нечего. + /// + /// Выключено по умолчанию и ставится один раз при старте приложения. Держать включённым + /// дольше проверки UI не стоит: со включённым флагом сломанная страница выглядит как рабочая. + /// `ready` от страницы флаг не отменяет — он лишь добавляет второй повод показать блок, + /// поэтому страница, которая контракт умеет, ведёт себя одинаково с ним и без него. + public static var treatsLoadedPageAsReady: Bool { + get { EmbeddedBlockReadinessOverrides.shared.treatsLoadedPageAsReady } + set { EmbeddedBlockReadinessOverrides.shared.setTreatsLoadedPageAsReady(newValue) } + } +} + +@_spi(Internal) +extension MindboxEmbeddedBlockDebug.Content { + + var resolution: EmbeddedBlockResolution { + switch self { + case .url(let url): + return .content(EmbeddedBlockWebContent(url: url)) + case .html(let html): + return .content(EmbeddedBlockWebContent(html: html)) + case .empty: + return .empty + } + } +} From 415603a5eacccdbb98f16484b691a44ab425f7ba Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 16:37:29 +0500 Subject: [PATCH 07/47] MOBILE-323: Add test infrastructure for embedded blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тег для сьютов блоков, папка MindboxTests/EmbeddedBlocks синхронизируемой группой и заготовка моков. В моках пока только то, что нужно тестам этой части: адрес страницы-заготовка и открыватель ссылок, который ничего не открывает, — политику схем иначе не проверить, canOpenURL в тестовом окружении пропускает системные схемы. --- Mindbox.xcodeproj/project.pbxproj | 3 ++ .../EmbeddedBlocks/EmbeddedBlockMocks.swift | 33 +++++++++++++++++++ MindboxTests/Extensions/Tag+Extensions.swift | 1 + 3 files changed, 37 insertions(+) create mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 863531d03..2170fcb07 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -1501,6 +1501,7 @@ 17B157F9FE3789FA97A7085E /* MindboxLoggerTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = MindboxLoggerTests; sourceTree = ""; }; 471D4C2D2FEECF8800856EA5 /* MindboxNotificationsTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = MindboxNotificationsTests; sourceTree = ""; }; 471D4C3B2FEED16200856EA5 /* TestPlans */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = TestPlans; sourceTree = ""; }; + 57A1B3230000000000000203 /* EmbeddedBlocks */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EmbeddedBlocks; sourceTree = ""; }; 57A1B3230000000000000300 /* EmbeddedBlocks */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EmbeddedBlocks; sourceTree = ""; }; F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappConfigurationDataFacade; sourceTree = ""; }; F397DE1C2CFF568800B72DA9 /* JSONs */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = JSONs; sourceTree = ""; }; @@ -1678,6 +1679,7 @@ 84B625F525C98EE000AB6228 /* DI */, 84B625EE25C98A8000AB6228 /* Validators */, F3CD20282F600A800065392A /* Configuration */, + 57A1B3230000000000000203 /* EmbeddedBlocks */, 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */, A1B2C3D4E5F60718293A4B02 /* MindboxOperationsTests.swift */, 84DC49D525D185A600D5D758 /* Supporting Files */, @@ -3979,6 +3981,7 @@ 313B233C25ADEA0F00A1CB72 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( + 57A1B3230000000000000203 /* EmbeddedBlocks */, A8C878878353491FA01AC096 /* WebViewPrewarmTests */, F385631E2DB6729000D91208 /* InappConfigurationDataFacade */, F397DE1C2CFF568800B72DA9 /* JSONs */, diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift new file mode 100644 index 000000000..56067b1e3 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift @@ -0,0 +1,33 @@ +// +// EmbeddedBlockMocks.swift +// MindboxTests +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation +@testable import Mindbox + +extension EmbeddedBlockWebContent { + + static let stub = EmbeddedBlockWebContent(url: URL(string: "https://mindbox.ru/block.html")!) +} + +/// Открыватель ссылок, который ничего не открывает: тесты смотрят, что до системы дошло, а что нет. +final class EmbeddedBlockURLOpenerMock: EmbeddedBlockURLOpening { + + /// Что отвечать на вопрос «система это откроет?». `canOpenURL` пропускает системные схемы, и + /// тесты политики схем должны проверять именно политику, а не этот ответ. + var canOpenAnything = true + + private(set) var openedURLs: [URL] = [] + + func canOpen(_ url: URL) -> Bool { + canOpenAnything + } + + func open(_ url: URL) { + openedURLs.append(url) + } +} diff --git a/MindboxTests/Extensions/Tag+Extensions.swift b/MindboxTests/Extensions/Tag+Extensions.swift index 27c57670c..e0f045eda 100644 --- a/MindboxTests/Extensions/Tag+Extensions.swift +++ b/MindboxTests/Extensions/Tag+Extensions.swift @@ -30,4 +30,5 @@ extension Tag { @Tag static var inAppTags: Self @Tag static var userAgent: Self @Tag static var dependencyInjection: Self + @Tag static var embeddedBlocks: Self } From d29eac919fdf89eea012573e98a26c8130b8de74 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 16:47:55 +0500 Subject: [PATCH 08/47] MOBILE-323: Add tests for the embedded block content resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Резолвер проверяется главным своим обещанием: сколько блоков ни спросило бы про один id, за данными идём один раз. Пока конфиг синхронный это незаметно, с сетью это разница между одним запросом и N. Рядом — кэш, обход кэша перезагрузкой и то, что отладочная подмена сильнее и данных, и кэша: приёмка переключает сценарий на ходу. --- .../EmbeddedBlockResolverTests.swift | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift new file mode 100644 index 000000000..a67c0bfd2 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift @@ -0,0 +1,182 @@ +// +// EmbeddedBlockResolverTests.swift +// MindboxTests +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@testable import Mindbox + +@Suite("Embedded block resolver", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockResolverTests { + + /// Главное обещание резолвера: сколько блоков ни спросило бы про один id, за данными идём один + /// раз. Пока конфиг синхронный это незаметно, с сетью — это разница между одним и N запросами. + @Test("Blocks asking for the same id at once share a single load") + func concurrentResolvesShareOneLoad() { + let loader = ContentLoaderSpy() + let resolver = EmbeddedBlockResolver(load: loader.load) + var answers: [EmbeddedBlockResolution] = [] + + resolver.resolve("promo") { answers.append($0) } + resolver.resolve("promo") { answers.append($0) } + resolver.resolve("promo") { answers.append($0) } + + #expect(loader.requestedIds == ["promo"]) + #expect(answers.isEmpty) + + loader.answer(.content(.stub)) + + #expect(answers == [.content(.stub), .content(.stub), .content(.stub)]) + } + + @Test("Different ids are loaded separately") + func differentIdsAreLoadedSeparately() { + let loader = ContentLoaderSpy() + let resolver = EmbeddedBlockResolver(load: loader.load) + + resolver.resolve("promo") { _ in } + resolver.resolve("stories") { _ in } + + #expect(loader.requestedIds == ["promo", "stories"]) + } + + @Test("Answered id comes from the cache next time") + func answeredIdIsCached() { + let loader = ContentLoaderSpy() + let resolver = EmbeddedBlockResolver(load: loader.load) + resolver.resolve("promo") { _ in } + loader.answer(.content(.stub)) + + var answer: EmbeddedBlockResolution? + resolver.resolve("promo") { answer = $0 } + + #expect(loader.requestedIds == ["promo"]) + #expect(answer == .content(.stub)) + } + + /// Перезагрузка блока не должна вечно брать из кэша прежний адрес: выключенный или + /// переехавший блок иначе не починится до перезапуска приложения. + @Test("Force refresh asks for the data again and replaces the cache") + func forceRefreshBypassesTheCache() { + let loader = ContentLoaderSpy() + let resolver = EmbeddedBlockResolver(load: loader.load) + resolver.resolve("promo") { _ in } + loader.answer(.content(.stub)) + + var refreshed: EmbeddedBlockResolution? + resolver.resolve("promo", forceRefresh: true) { refreshed = $0 } + loader.answer(.empty) + + #expect(loader.requestedIds == ["promo", "promo"]) + #expect(refreshed == .empty) + + var cached: EmbeddedBlockResolution? + resolver.resolve("promo") { cached = $0 } + #expect(cached == .empty) + } + + // MARK: - Debug overrides + + /// Приёмка переключает сценарий на ходу, поэтому подмена сильнее и загрузки, и кэша. + @Test("Debug override answers instead of the data and outranks the cache") + func overrideOutranksEverything() { + let loader = ContentLoaderSpy() + let overrides = EmbeddedBlockContentOverrides() + let resolver = EmbeddedBlockResolver(load: loader.load, overrides: overrides) + resolver.resolve("promo") { _ in } + loader.answer(.content(.stub)) + + overrides.set(.empty, for: "promo") + var answers: [EmbeddedBlockResolution] = [] + resolver.resolve("promo") { answers.append($0) } + resolver.resolve("promo") { answers.append($0) } + + #expect(answers == [.empty, .empty]) + // За данными резолвер не ходил: ответ пришёл из подмены. + #expect(loader.requestedIds == ["promo"]) + } + + @Test("Removing the override brings the real content back") + func removingOverrideRestoresContent() { + let loader = ContentLoaderSpy() + let overrides = EmbeddedBlockContentOverrides() + let resolver = EmbeddedBlockResolver(load: loader.load, overrides: overrides) + overrides.set(.empty, for: "promo") + resolver.resolve("promo") { _ in } + + overrides.remove(for: "promo") + var answer: EmbeddedBlockResolution? + resolver.resolve("promo") { answer = $0 } + loader.answer(.content(.stub)) + + #expect(loader.requestedIds == ["promo"]) + #expect(answer == .content(.stub)) + } + + @Test("Override applies only to its own id") + func overrideAppliesToItsIdOnly() { + let loader = ContentLoaderSpy() + let overrides = EmbeddedBlockContentOverrides() + let resolver = EmbeddedBlockResolver(load: loader.load, overrides: overrides) + overrides.set(.empty, for: "promo") + + var answer: EmbeddedBlockResolution? + resolver.resolve("promo") { _ in } + resolver.resolve("stories") { answer = $0 } + loader.answer(.content(.stub)) + + #expect(loader.requestedIds == ["stories"]) + #expect(answer == .content(.stub)) + } + + @Test("Overridden content carries the page markup as it was given") + func overrideCarriesMarkup() { + let overrides = EmbeddedBlockContentOverrides() + overrides.set(.content(EmbeddedBlockWebContent(html: "empty page")), for: "promo") + + guard case .content(let content) = overrides.resolution(for: "promo"), + case .html(let html) = content.source else { + Issue.record("Expected the override to carry inline html") + return + } + #expect(html == "empty page") + } + + /// Заглушка на месте конфига: пока его нет, любой id ведёт на страницу ленты сторизов. + @Test("The stubbed loader resolves any id to the stories page") + func stubbedLoaderResolvesToTheStoriesPage() { + var resolution: EmbeddedBlockResolution? + + EmbeddedBlockResolver.loadStubbedStoriesPage("whatever") { resolution = $0 } + + guard case .content(let content) = resolution, case .url(let url) = content.source else { + Issue.record("Expected the stub to resolve into a page url, got \(String(describing: resolution))") + return + } + #expect(url.absoluteString.hasSuffix("stories.html")) + } +} + +/// Загрузчик, который отвечает только когда его попросят: так проверяется поведение резолвера, пока +/// загрузка ещё идёт. +private final class ContentLoaderSpy { + + private(set) var requestedIds: [String] = [] + + private var completions: [(EmbeddedBlockResolution) -> Void] = [] + + func load(_ id: String, completion: @escaping (EmbeddedBlockResolution) -> Void) { + requestedIds.append(id) + completions.append(completion) + } + + func answer(_ resolution: EmbeddedBlockResolution) { + let pending = completions + completions = [] + pending.forEach { $0(resolution) } + } +} From ef79d280c18da17a77382a1c3e0df4867e37f2fc Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 16:48:07 +0500 Subject: [PATCH 09/47] MOBILE-323: Add tests for the embedded block page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Страница судит только о своём, и проверяется именно это разделение: настоящая ошибка сети — провал, а отменённая навигация — нет. Отмена при этом страницу не глушит: следующая настоящая ошибка приходит как обычно. --- .../EmbeddedBlockWebViewPageTests.swift | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift new file mode 100644 index 000000000..403529756 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift @@ -0,0 +1,123 @@ +// +// EmbeddedBlockWebViewPageTests.swift +// MindboxTests +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import WebKit +@testable import Mindbox + +/// Страница судит только о своём: загрузка не состоялась или документ доехал. Здесь проверяется +/// именно это разделение — и главное, что отменённая навигация в провалы не попадает. +@Suite("Embedded block web view page", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockWebViewPageTests { + + /// Навигацию отменяют в двух совершенно обычных случаях: её вытеснил клиентский редирект и её + /// остановил наш собственный `cancel()` на уехавшем с экрана блоке. Ни то, ни другое не значит, + /// что блок сломан, — а провал сворачивает его насовсем. + @Test("A cancelled provisional navigation is not a load failure") + func cancelledProvisionalNavigationIsNotAFailure() { + let bed = PageBed() + + bed.failProvisionalNavigation(with: bed.cancellationError) + + #expect(bed.failures == 0) + } + + @Test("A cancelled navigation is not a load failure either") + func cancelledNavigationIsNotAFailure() { + let bed = PageBed() + + bed.failNavigation(with: bed.cancellationError) + + #expect(bed.failures == 0) + } + + /// А настоящая ошибка сети — это ровно то, о чём страница обязана сказать. + @Test("A real provisional navigation error is a load failure") + func realProvisionalErrorIsAFailure() { + let bed = PageBed() + + bed.failProvisionalNavigation(with: bed.error(code: NSURLErrorNotConnectedToInternet)) + + #expect(bed.failures == 1) + } + + @Test("A real navigation error is a load failure") + func realNavigationErrorIsAFailure() { + let bed = PageBed() + + bed.failNavigation(with: bed.error(code: NSURLErrorTimedOut)) + + #expect(bed.failures == 1) + } + + /// Отмена одной навигации не должна глушить страницу: следующая настоящая ошибка приходит как + /// обычно. + @Test("A cancellation does not swallow the failure that comes after it") + func cancellationDoesNotSwallowLaterFailures() { + let bed = PageBed() + + bed.failProvisionalNavigation(with: bed.cancellationError) + bed.failProvisionalNavigation(with: bed.error(code: NSURLErrorCannotFindHost)) + + #expect(bed.failures == 1) + } + + @Test("A finished document is reported as a finish, not as a failure") + func finishedDocumentIsReportedAsFinish() { + let bed = PageBed() + + bed.finishNavigation() + + #expect(bed.finishes == 1) + #expect(bed.failures == 0) + } +} + +/// Настоящая страница с настоящим вебвью, но без сети: тесты сами зовут методы навигационного +/// делегата — именно их разбор здесь и проверяется. +@MainActor +private final class PageBed { + + let page: EmbeddedBlockWebViewPage + + private(set) var failures = 0 + private(set) var finishes = 0 + + var cancellationError: Error { error(code: NSURLErrorCancelled) } + + /// Через протокол, а не напрямую: у страницы есть и свойство `webView`, и методы делегата с тем + /// же именем, и вызывать их стоит там, где имя однозначно. + private var navigation: WKNavigationDelegate { page } + + init() { + page = EmbeddedBlockWebViewPage(content: .stub, webView: WKWebView()) + page.onLoadFailure = { [weak self] in + self?.failures += 1 + } + page.onLoadFinish = { [weak self] in + self?.finishes += 1 + } + } + + func error(code: Int) -> Error { + NSError(domain: NSURLErrorDomain, code: code) + } + + func failProvisionalNavigation(with error: Error) { + navigation.webView?(page.webView, didFailProvisionalNavigation: nil, withError: error) + } + + func failNavigation(with error: Error) { + navigation.webView?(page.webView, didFail: nil, withError: error) + } + + func finishNavigation() { + navigation.webView?(page.webView, didFinish: nil) + } +} From 51956ac722bdd30302a1d2e074020a7fa877a381 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 16:48:07 +0500 Subject: [PATCH 10/47] MOBILE-323: Add tests for the page action router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Обе ветки handle и вся политика схем: веб-адреса и диплинки хоста открываются, tel:, sms:, itms-apps:, mailto: и схемы чужих приложений — нет, причём отказ не отменяется тем, что система такую ссылку умеет открыть. Плюс кривые payload'ы, незнакомое действие и разбор CFBundleURLTypes. --- .../EmbeddedBlockActionRouterTests.swift | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockActionRouterTests.swift diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockActionRouterTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockActionRouterTests.swift new file mode 100644 index 000000000..e469ed6ba --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockActionRouterTests.swift @@ -0,0 +1,194 @@ +// +// EmbeddedBlockActionRouterTests.swift +// MindboxTests +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import Foundation +@testable import Mindbox + +@Suite("Embedded block action router", .tags(.embeddedBlocks)) +struct EmbeddedBlockActionRouterTests { + + // MARK: - openUrl + + @Test("A web address from the page is opened") + func webAddressIsOpened() { + let opener = EmbeddedBlockURLOpenerMock() + let router = makeRouter(opener: opener) + + router.handle(openUrl("https://mindbox.ru/promo")) + + #expect(opener.openedURLs.map(\.absoluteString) == ["https://mindbox.ru/promo"]) + } + + @Test("Plain http is opened too") + func httpIsOpened() { + let opener = EmbeddedBlockURLOpenerMock() + let router = makeRouter(opener: opener) + + router.handle(openUrl("http://mindbox.ru")) + + #expect(opener.openedURLs.count == 1) + } + + /// Диплинк в само это приложение никуда пользователя не увозит, поэтому он разрешён — но только + /// если хост действительно объявил эту схему своей. + @Test("A deep link into the host app itself is opened") + func hostAppDeepLinkIsOpened() { + let opener = EmbeddedBlockURLOpenerMock() + let router = makeRouter(opener: opener, hostAppSchemes: ["myshop"]) + + router.handle(openUrl("myshop://cart")) + + #expect(opener.openedURLs.count == 1) + } + + @Test("Scheme matching ignores case") + func schemeMatchingIgnoresCase() { + let opener = EmbeddedBlockURLOpenerMock() + let router = makeRouter(opener: opener, hostAppSchemes: ["myshop"]) + + router.handle(openUrl("MyShop://cart")) + router.handle(openUrl("HTTPS://mindbox.ru")) + + #expect(opener.openedURLs.count == 2) + } + + // MARK: - Schemes the page may not open + + /// Системное действие — это уже не переход по контенту: страница блока приезжает из сети, и + /// звонить за пользователя ей не положено. + @Test("A tel: link from the page is refused") + func telLinkIsRefused() { + let opener = EmbeddedBlockURLOpenerMock() + let router = makeRouter(opener: opener) + + router.handle(openUrl("tel://+79001234567")) + + #expect(opener.openedURLs.isEmpty) + } + + @Test("System and third-party app schemes are refused") + func foreignSchemesAreRefused() { + let opener = EmbeddedBlockURLOpenerMock() + let router = makeRouter(opener: opener, hostAppSchemes: ["myshop"]) + + for raw in ["sms://+79001234567", + "itms-apps://apps.apple.com/app/id1", + "app-settings://", + "mailto:hi@mindbox.ru", + "someotherapp://pay"] { + router.handle(openUrl(raw)) + } + + #expect(opener.openedURLs.isEmpty) + } + + /// Схема чужого приложения не становится разрешённой от того, что система умеет её открыть, — + /// именно это `canOpenURL` и говорит. + @Test("A refused scheme is not saved by canOpenURL saying yes") + func canOpenDoesNotOverridePolicy() { + let opener = EmbeddedBlockURLOpenerMock() + opener.canOpenAnything = true + let router = makeRouter(opener: opener) + + router.handle(openUrl("tel://+79001234567")) + + #expect(opener.openedURLs.isEmpty) + } + + @Test("A url without a scheme is refused") + func schemelessUrlIsRefused() { + let opener = EmbeddedBlockURLOpenerMock() + let router = makeRouter(opener: opener) + + router.handle(openUrl("mindbox.ru/promo")) + + #expect(opener.openedURLs.isEmpty) + } + + // MARK: - Malformed actions + + @Test("An allowed url the system cannot open is not opened") + func unopenableUrlIsNotOpened() { + let opener = EmbeddedBlockURLOpenerMock() + opener.canOpenAnything = false + let router = makeRouter(opener: opener) + + router.handle(openUrl("https://mindbox.ru")) + + #expect(opener.openedURLs.isEmpty) + } + + @Test("openUrl without a url payload opens nothing") + func missingUrlOpensNothing() { + let opener = EmbeddedBlockURLOpenerMock() + let router = makeRouter(opener: opener) + + router.handle(EmbeddedBlockPageAction(type: "openUrl", payload: ["type": "openUrl"])) + + #expect(opener.openedURLs.isEmpty) + } + + @Test("openUrl with a non-string url opens nothing") + func nonStringUrlOpensNothing() { + let opener = EmbeddedBlockURLOpenerMock() + let router = makeRouter(opener: opener) + + router.handle(EmbeddedBlockPageAction(type: "openUrl", payload: ["url": 42])) + + #expect(opener.openedURLs.isEmpty) + } + + /// Словарь у веб-стороны может быть новее, чем у SDK: незнакомое действие — не ошибка. + @Test("An unknown action is ignored without side effects") + func unknownActionIsIgnored() { + let opener = EmbeddedBlockURLOpenerMock() + let router = makeRouter(opener: opener) + + router.handle(EmbeddedBlockPageAction(type: "shareSomethingNew", payload: ["url": "https://mindbox.ru"])) + + #expect(opener.openedURLs.isEmpty) + } + + // MARK: - Host app schemes + + @Test("Every scheme of every declared url type counts as the host's own") + func allDeclaredSchemesAreCollected() { + let info: [String: Any] = [ + "CFBundleURLTypes": [ + ["CFBundleURLName": "main", "CFBundleURLSchemes": ["MyShop", "myshop-dev"]], + ["CFBundleURLName": "legacy", "CFBundleURLSchemes": ["oldshop"]] + ] + ] + + let schemes = EmbeddedBlockActionRouter.hostAppSchemes(in: info) + + #expect(schemes == ["myshop", "myshop-dev", "oldshop"]) + } + + /// Хост может не объявлять схем вообще, а объявленное — быть неполным: разбор Info.plist не + /// должен ни падать, ни придумывать схемы, которых там нет. + @Test("A missing or malformed CFBundleURLTypes yields no schemes") + func malformedBundleYieldsNoSchemes() { + #expect(EmbeddedBlockActionRouter.hostAppSchemes(in: nil).isEmpty) + #expect(EmbeddedBlockActionRouter.hostAppSchemes(in: [:]).isEmpty) + #expect(EmbeddedBlockActionRouter.hostAppSchemes(in: ["CFBundleURLTypes": "myshop"]).isEmpty) + #expect(EmbeddedBlockActionRouter.hostAppSchemes(in: ["CFBundleURLTypes": [["CFBundleURLName": "main"]]]).isEmpty) + } + + // MARK: - Helpers + + private func makeRouter(opener: EmbeddedBlockURLOpening, + hostAppSchemes: Set = []) -> EmbeddedBlockActionRouter { + EmbeddedBlockActionRouter(urlOpener: opener, hostAppSchemes: hostAppSchemes) + } + + private func openUrl(_ raw: String) -> EmbeddedBlockPageAction { + EmbeddedBlockPageAction(type: "openUrl", payload: ["type": "openUrl", "url": raw]) + } +} From 9758078ddb13f6e182c2e397f8c3aa40dfa49aa7 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:00:48 +0500 Subject: [PATCH 11/47] MOBILE-323: Add the embedded block content provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Переводит сообщения страницы в состояния блока. Не рисует контент и не знает механик: спрашивает у резолвера, что стоит за id, разбирает core-слой, а действия сверх него отдаёт универсальному обработчику. Готовность определяет только сама страница: ready — показываем, empty — показывать нечего. Навигация судит исключительно о своём, молчащая страница готовой не становится — её добьёт бюджет ожидания у контейнера. Нулевая высота в ready значит сломанную вёрстку: «показывать нечего» страница сообщает явным empty. Исход попытки хранится явно, а не пачкой флагов. Он переживает stop(), потому что это свойство страницы, а не факта нахождения в окне: уход блока с экрана не выбрасывает уже отрендеренную страницу, и возврат показывает её снова, без сети и без шиммера. При этом провал и empty страницу не убивают — она жива и может продолжать говорить, — поэтому известный исход служит и признаком того, что блока на экране больше нет: действия от невидимого блока не выполняются. За ним не стоит ни одного касания пользователя, а openUrl увёл бы человека из приложения на пустом месте. Экземпляр принадлежит одному контейнеру: ничего общего между контейнерами здесь нет, это и делает возможными несколько независимых блоков с одним id. Номер попытки отсекает резолв, доехавший уже после остановки или перезагрузки. Счётчик живых блоков на id — диагностика, а не механика: два блока с одним id законны, но чаще это скопированный id или переиспользованная ячейка, а у обоих случаев нет симптомов кроме «блок оказался не там, где ждали». --- .../EmbeddedBlockWebViewProvider.swift | 300 ++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift new file mode 100644 index 000000000..db25eba41 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift @@ -0,0 +1,300 @@ +// +// EmbeddedBlockWebViewProvider.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import MindboxLogger + +/// Контент встроенного блока — веб-страница, найденная по id блока. +/// +/// Провайдер не рисует контент и не знает механик: он спрашивает у резолвера, что стоит за id, +/// переводит core-сообщения страницы в состояния контейнера, а действия сверх core-слоя отдаёт +/// универсальному обработчику. +/// +/// Готовность блока определяет только сама страница: `ready` — показываем, `empty` — показывать +/// нечего. Навигация судит исключительно о своём — о том, что загрузка не состоялась; молчащая +/// страница готовой не становится, её добьёт таймаут контейнера. Единственное исключение — +/// отладочная подмена готовности (`EmbeddedBlockReadinessOverrides`) для страниц, которые +/// веб-контракт ещё не умеют. +/// +/// Высоту контейнера назначает хост при создании блока: высота из сообщений страницы на вёрстку +/// не влияет, но нулевая в `ready` по-прежнему значит, что страница сломана. +/// +/// Экземпляр принадлежит одному контейнеру — ничего общего между контейнерами здесь нет, это и +/// делает возможными несколько независимых блоков. `start()` и `stop()` повторяют видимость +/// контейнера и могут вызываться по кругу; после `stop()` провайдер обязан молчать до следующего +/// `start()` — на это опирается контейнер, когда сворачивает просроченный блок. +/// +/// Уход из окна не выбрасывает уже отрендеренную страницу: возврат блока в окно показывает её +/// снова, без сети и без шиммера. Обновить содержимое можно только явно — через `reload()`. +final class EmbeddedBlockWebViewProvider { + + /// Сообщает каждую смену состояния на главном потоке. Ставится контейнером. + var onStateChange: ((EmbeddedBlockState) -> Void)? + + /// Вью с контентом блока. Контейнер читает её, когда состояние стало `.ready`, и растягивает + /// по своим краям. + var contentView: UIView? { isReady ? page?.view : nil } + + private let id: String + private let resolver: EmbeddedBlockResolving + private let actionHandler: EmbeddedBlockActionHandling + private let readinessOverrides: EmbeddedBlockReadinessOverriding + private let makePage: (EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting + + /// Страница переживает рестарты: контейнер стартует и останавливает блок по видимости, и + /// пересоздавать вебвью на каждое возвращение в окно незачем. + private var page: EmbeddedBlockPageHosting? + + private var isStarted = false + + /// Чем кончилась текущая попытка: `nil` — ещё ничем. + /// + /// Исход переживает `stop()`: он свойство страницы, а не факта нахождения в окне. Провал и + /// `empty` при этом не убивают страницу — она жива и может продолжать говорить, — поэтому + /// известный исход нужен и как признак того, что блока на экране больше нет. + private var outcome: EmbeddedBlockState? + + private var isReady: Bool { outcome == .ready } + + /// Номер текущей попытки загрузки. Резолв может ответить уже после `stop()` или после + /// перезагрузки — по номеру видно, что ответ относится к прошлой попытке, и его надо выбросить. + private var loadGeneration = 0 + + init(id: String, + resolver: EmbeddedBlockResolving, + actionHandler: EmbeddedBlockActionHandling, + readinessOverrides: EmbeddedBlockReadinessOverriding = EmbeddedBlockReadinessOverrides.shared, + makePage: @escaping (EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting) { + self.id = id + self.resolver = resolver + self.actionHandler = actionHandler + self.readinessOverrides = readinessOverrides + self.makePage = makePage + + EmbeddedBlockWebViewProvider.blockCreated(id: id) + } + + deinit { + EmbeddedBlockWebViewProvider.blockReleased(id: id) + } + + func start() { + start(forceRefresh: false) + } + + func stop() { + guard isStarted else { return } + + isStarted = false + // Исход не сбрасываем: он свойство страницы, а не факта нахождения в окне. Иначе каждый + // проход блока по экрану стоил бы полной перезагрузки. + loadGeneration += 1 + page?.cancel() + } + + /// Начинает загрузку с нуля: страница выбрасывается, а адрес запрашивается заново в обход кэша + /// резолвера — иначе переехавший или выключенный блок вечно доставал бы прежний адрес. + /// + /// Путь тот же, что и у первого запуска, поэтому плейсхолдер, таймаут контейнера и события + /// хосту работают одинаково: второй ветки жизненного цикла у блока нет. + func reload() { + Logger.common(message: "[EmbeddedBlock] Block '\(id)' is reloading", category: .embeddedBlocks) + + // Прежняя страница больше не имеет отношения к делу — сначала отключаем её от себя, чтобы + // её запоздавшие сообщения не попали в новую попытку. + page?.onMessage = nil + page?.onLoadFailure = nil + page?.onLoadFinish = nil + page?.cancel() + page = nil + + isStarted = false + outcome = nil + loadGeneration += 1 + + start(forceRefresh: true) + } + + func handle(_ message: EmbeddedBlockPageMessage) { + guard isStarted else { return } + + switch message { + case .ready(let height): + apply(height: height) + case .heightChanged(let height): + // Высотой владеет хост — сообщение остаётся в контракте страницы, но на нативной + // стороне ни на что не влияет. + Logger.common(message: "[EmbeddedBlock] Ignored heightChanged(\(height)): the host owns the container height", category: .embeddedBlocks) + case .empty: + outcome = .empty + onStateChange?(.empty) + case .action(let action): + // Блока на экране нет, а страница жива и продолжает работать — например, досылает то, + // что запланировал её `setTimeout`. Выполнять её действия в этот момент нельзя: за + // невидимым блоком не стоит ни одного касания пользователя, а `openUrl` увёл бы его из + // приложения на пустом месте. + guard isShown else { + Logger.common(message: "[EmbeddedBlock] Block '\(id)': ignored action '\(action.type)' from a block that is not shown", + category: .embeddedBlocks) + return + } + + actionHandler.handle(action) + } + } + + func handleLoadFailure() { + guard isStarted else { return } + + outcome = .failed + onStateChange?(.failed) + } + + /// Показан ли блок сейчас — то есть может ли пользователь вообще что-то в нём нажать. Пока + /// исхода нет, страница ещё грузится, и её сообщения относятся к живому блоку. + private var isShown: Bool { + outcome == nil || outcome == .ready + } + + /// Загруженный документ сам по себе ничего не значит: показывать блок по нему можно только со + /// включённой отладочной подменой, пока страница не умеет присылать `ready`. Своё `ready` + /// страницы сильнее — если оно уже пришло, здесь делать нечего. + func handleLoadFinish() { + guard isStarted, !isReady, readinessOverrides.treatsLoadedPageAsReady else { return } + + Logger.common(message: "[EmbeddedBlock] Block '\(id)': debug readiness is ON, showing the loaded page without a 'ready' from it", + level: .default, + category: .embeddedBlocks) + outcome = .ready + onStateChange?(.ready) + } + + private func start(forceRefresh: Bool) { + guard !isStarted else { return } + + isStarted = true + + // Страница уже отрендерилась и никуда не делась — показываем её как есть. Возврат блока + // в окно не стоит ни сети, ни шиммера, ни повторных событий хосту. + if isReady, page != nil { + Logger.common(message: "[EmbeddedBlock] Block '\(id)': showing the page rendered earlier", + category: .embeddedBlocks) + onStateChange?(.ready) + return + } + + onStateChange?(.loading) + // Началась новая попытка: чем кончилась прошлая, больше не важно — в том числе и для того, + // выполнять ли действия страницы. + outcome = nil + + if let page { + page.load() + return + } + + let generation = loadGeneration + resolver.resolve(id, forceRefresh: forceRefresh) { [weak self] resolution in + guard let self, self.isStarted, self.loadGeneration == generation else { return } + + switch resolution { + case .empty: + Logger.common(message: "[EmbeddedBlock] Block id '\(self.id)' resolved as empty", + category: .embeddedBlocks) + self.onStateChange?(.empty) + case .content(let content): + let page = self.makePage(content) + page.onMessage = { [weak self] message in + self?.handle(message) + } + page.onLoadFailure = { [weak self] in + self?.handleLoadFailure() + } + page.onLoadFinish = { [weak self] in + self?.handleLoadFinish() + } + self.page = page + page.load() + } + } + } + + private func apply(height: CGFloat) { + // «Показывать нечего» страница сообщает явным `empty`, поэтому нулевая высота — это + // сломанная вёрстка, то есть ошибка. + guard height > 0 else { + Logger.common(message: "[EmbeddedBlock] Block '\(id)': page reported zero height, treating as broken", category: .embeddedBlocks) + outcome = .failed + onStateChange?(.failed) + return + } + + Logger.common(message: "[EmbeddedBlock] Block '\(id)': page is ready", category: .embeddedBlocks) + outcome = .ready + onStateChange?(.ready) + } +} + +// MARK: - Live blocks + +/// Сколько блоков с каждым id живо прямо сейчас. +/// +/// Диагностика, а не механика: два блока с одним id — законный случай, оба покажут один и тот же +/// контент. Но чаще это либо скопированный id, либо переиспользованная ячейка, в которую попал +/// контейнер от другой строки, — а у обоих случаев нет заметных симптомов, кроме «блок оказался не +/// там, где ждали». Поэтому SDK говорит об этом в лог. +/// +/// Счётчик общий на процесс, потому что вопрос тоже общий: одинаковые id ищутся не внутри блока, а +/// между блоками. Живых блоков он не удерживает — хранит только числа. +extension EmbeddedBlockWebViewProvider { + + private static var liveBlocks: [String: Int] = [:] + + /// Блоки создаются и умирают с UIKit-вью, то есть на главном потоке. Замок стоит на случай, если + /// это когда-нибудь перестанет быть правдой: диагностика не должна ронять SDK. + private static let liveBlocksLock = NSLock() + + static func liveCount(for id: String) -> Int { + liveBlocksLock.lock() + defer { liveBlocksLock.unlock() } + + return liveBlocks[id] ?? 0 + } + + fileprivate static func blockCreated(id: String) { + liveBlocksLock.lock() + let count = (liveBlocks[id] ?? 0) + 1 + liveBlocks[id] = count + liveBlocksLock.unlock() + + Logger.common(message: "[EmbeddedBlock] Block '\(id)' is created, \(count) live with this id", + category: .embeddedBlocks) + + guard count > 1 else { return } + + Logger.common(message: """ + [EmbeddedBlock] \(count) live blocks share id '\(id)'. They show the same content, \ + each rendered on its own. If that is unexpected, check that a reusable cell is not carrying \ + a block container from another row: a block is created for one id and cannot be repointed. + """, category: .embeddedBlocks) + } + + fileprivate static func blockReleased(id: String) { + liveBlocksLock.lock() + let remaining = max(0, (liveBlocks[id] ?? 1) - 1) + if remaining > 0 { + liveBlocks[id] = remaining + } else { + liveBlocks.removeValue(forKey: id) + } + liveBlocksLock.unlock() + + Logger.common(message: "[EmbeddedBlock] Block '\(id)' is released, \(remaining) live with this id", + category: .embeddedBlocks) + } +} From 36c936850b626ca0256fb86923f31bec7f32b908 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:00:58 +0500 Subject: [PATCH 12/47] MOBILE-323: Add the content provider factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Собирает провайдер под конкретный блок: резолвер и обработчик действий общие на все блоки, а провайдер — свой на каждый. Это и делает блоки с одинаковым id независимыми друг от друга. Потребителей у фабрики появится два, и оба в следующей части: DI-регистрация и публичный init контейнера. Здесь она едет вместе с провайдером, потому что описывает его модель владения, а не способ его достать. --- .../EmbeddedBlockContentProviderFactory.swift | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift new file mode 100644 index 000000000..9989486d4 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift @@ -0,0 +1,36 @@ +// +// EmbeddedBlockContentProviderFactory.swift +// Mindbox +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// Собирает провайдер контента под конкретный блок. +/// +/// Провайдер принадлежит одному контейнеру, поэтому создаётся на каждый блок заново — это и +/// делает блоки с одинаковым id независимыми. +protocol EmbeddedBlockContentProviderMaking { + func makeProvider(id: String) -> EmbeddedBlockWebViewProvider +} + +final class EmbeddedBlockContentProviderFactory: EmbeddedBlockContentProviderMaking { + + private let resolver: EmbeddedBlockResolving + private let actionHandler: EmbeddedBlockActionHandling + + init(resolver: EmbeddedBlockResolving, + actionHandler: EmbeddedBlockActionHandling) { + self.resolver = resolver + self.actionHandler = actionHandler + } + + func makeProvider(id: String) -> EmbeddedBlockWebViewProvider { + EmbeddedBlockWebViewProvider(id: id, + resolver: resolver, + actionHandler: actionHandler, + makePage: { EmbeddedBlockWebViewPage(content: $0) }) + } +} From 3b3e726ba4ca3f99d49560a96d00137e1b2b8abb Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:01:35 +0500 Subject: [PATCH 13/47] MOBILE-323: Add test doubles for the block content provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дописывает моки до среза этой части: страница без WebKit, фабрика страниц со счётчиком созданных, резолвер с отложенным ответом, обработчик действий и общая заготовка провайдера со всеми подменёнными зависимостями. Резолвер умеет держать ответ до отдельной команды: так проверяется резолв, доехавший уже после остановки или перезагрузки блока. Фабрика считает страницы, потому что перезагрузка обязана создать новую, а возврат блока в окно — нет. --- .../EmbeddedBlocks/EmbeddedBlockMocks.swift | 148 +++++++++++++++++- 1 file changed, 147 insertions(+), 1 deletion(-) diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift index 56067b1e3..2a3edae29 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift @@ -6,12 +6,125 @@ // Copyright © 2026 Mindbox. All rights reserved. // -import Foundation +import UIKit @testable import Mindbox extension EmbeddedBlockWebContent { static let stub = EmbeddedBlockWebContent(url: URL(string: "https://mindbox.ru/block.html")!) + + static let other = EmbeddedBlockWebContent(url: URL(string: "https://mindbox.ru/another-block.html")!) +} + +extension EmbeddedBlockPageAction { + + static let openUrlStub = EmbeddedBlockPageAction(type: "openUrl", payload: ["url": "https://mindbox.ru"]) +} + +/// Страница без WebKit: тесты сами решают, что и когда она скажет нативной стороне. +final class EmbeddedBlockPageMock: EmbeddedBlockPageHosting { + + let view = UIView() + + var onMessage: ((EmbeddedBlockPageMessage) -> Void)? + + var onLoadFailure: (() -> Void)? + + var onLoadFinish: (() -> Void)? + + var loadCount = 0 + var cancelCount = 0 + + func load() { + loadCount += 1 + } + + func cancel() { + cancelCount += 1 + } + + func send(_ message: EmbeddedBlockPageMessage) { + onMessage?(message) + } + + func failLoad() { + onLoadFailure?() + } + + func finishLoad() { + onLoadFinish?() + } +} + +final class EmbeddedBlockReadinessOverridesMock: EmbeddedBlockReadinessOverriding { + + var treatsLoadedPageAsReady: Bool + + init(treatsLoadedPageAsReady: Bool = false) { + self.treatsLoadedPageAsReady = treatsLoadedPageAsReady + } +} + +/// Считает, сколько страниц было создано и с каким контентом: перезагрузка обязана создать новую. +final class EmbeddedBlockPageFactoryMock { + + private(set) var pages: [EmbeddedBlockPageMock] = [] + private(set) var contents: [EmbeddedBlockWebContent] = [] + + var page: EmbeddedBlockPageMock? { pages.last } + + func make(_ content: EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting { + contents.append(content) + let page = EmbeddedBlockPageMock() + pages.append(page) + return page + } +} + +final class EmbeddedBlockResolverMock: EmbeddedBlockResolving { + + var resolution: EmbeddedBlockResolution + + /// `true` — ответ не приходит, пока тест не позовёт `flush()`: так проверяется резолв, + /// доехавший уже после остановки или перезагрузки блока. + var isDeferred = false + + private(set) var resolvedIds: [String] = [] + private(set) var forceRefreshHistory: [Bool] = [] + + var resolveCount: Int { resolvedIds.count } + + private var pending: [(EmbeddedBlockResolution) -> Void] = [] + + init(resolution: EmbeddedBlockResolution = .content(.stub)) { + self.resolution = resolution + } + + func resolve(_ id: String, forceRefresh: Bool, completion: @escaping (EmbeddedBlockResolution) -> Void) { + resolvedIds.append(id) + forceRefreshHistory.append(forceRefresh) + + if isDeferred { + pending.append(completion) + } else { + completion(resolution) + } + } + + func flush() { + let completions = pending + pending = [] + completions.forEach { $0(resolution) } + } +} + +final class EmbeddedBlockActionHandlerMock: EmbeddedBlockActionHandling { + + private(set) var handledActions: [EmbeddedBlockPageAction] = [] + + func handle(_ action: EmbeddedBlockPageAction) { + handledActions.append(action) + } } /// Открыватель ссылок, который ничего не открывает: тесты смотрят, что до системы дошло, а что нет. @@ -31,3 +144,36 @@ final class EmbeddedBlockURLOpenerMock: EmbeddedBlockURLOpening { openedURLs.append(url) } } + +/// Провайдер со всеми подменёнными зависимостями — общая заготовка для тестов провайдера и +/// контейнера. Контейнер тестируется через настоящий провайдер: единственный шов внутри блока — +/// страница, и подменять больше нечего. +final class EmbeddedBlockTestBed { + + let resolver: EmbeddedBlockResolverMock + let actionHandler: EmbeddedBlockActionHandlerMock + let readinessOverrides: EmbeddedBlockReadinessOverridesMock + let pageFactory: EmbeddedBlockPageFactoryMock + let provider: EmbeddedBlockWebViewProvider + + var page: EmbeddedBlockPageMock? { pageFactory.page } + + init(id: String = "block-id", + resolution: EmbeddedBlockResolution = .content(.stub), + treatsLoadedPageAsReady: Bool = false) { + let resolver = EmbeddedBlockResolverMock(resolution: resolution) + let actionHandler = EmbeddedBlockActionHandlerMock() + let readinessOverrides = EmbeddedBlockReadinessOverridesMock(treatsLoadedPageAsReady: treatsLoadedPageAsReady) + let pageFactory = EmbeddedBlockPageFactoryMock() + + self.resolver = resolver + self.actionHandler = actionHandler + self.readinessOverrides = readinessOverrides + self.pageFactory = pageFactory + self.provider = EmbeddedBlockWebViewProvider(id: id, + resolver: resolver, + actionHandler: actionHandler, + readinessOverrides: readinessOverrides, + makePage: { pageFactory.make($0) }) + } +} From 009314181077b1a035d051df7e94d91401b8089e Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:01:52 +0500 Subject: [PATCH 14/47] MOBILE-323: Add tests for the embedded block content provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Весь путь блока без WebKit и без сети: резолв, показ по ready, пустой блок, сломанная нулевая высота, провал загрузки, действия страницы, отладочная подмена готовности, остановка и перезапуск, перезагрузка. Отдельно закреплено то, на что опираются соседние слои: после stop() провайдер молчит целиком — иначе контейнер не смог бы свернуть просроченный блок; уже отрендеренная страница на возврате в окно показывается как есть, без сети и шиммера, а блок, который показать не удалось, получает новую попытку; выброшенная перезагрузкой страница не может доложить в новую попытку ни сообщением, ни провалом, ни через подмену готовности. Действия проверяются с обеих сторон: из показанного блока доходят до обработчика, из схлопнутого — нет, ни после empty, ни после провала, ни после нулевой высоты, — а новая попытка снова их принимает. Счётчику живых блоков в каждом тесте свой id: он общий на процесс, иначе тесты, идущие параллельно, считали бы блоки друг друга. --- .../EmbeddedBlockWebViewProviderTests.swift | 555 ++++++++++++++++++ 1 file changed, 555 insertions(+) create mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift new file mode 100644 index 000000000..78c47a877 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift @@ -0,0 +1,555 @@ +// +// EmbeddedBlockWebViewProviderTests.swift +// MindboxTests +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import UIKit +@testable import Mindbox + +@Suite("Embedded block web view provider", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockWebViewProviderTests { + + // MARK: - Loading + + @Test("Start resolves the id and loads the resolved content") + func startResolvesAndLoads() { + let bed = EmbeddedBlockTestBed(id: "promo") + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + + #expect(bed.resolver.resolvedIds == ["promo"]) + #expect(bed.pageFactory.contents == [.stub]) + #expect(bed.page?.loadCount == 1) + #expect(states == [.loading]) + // До готовности страницы контента нет: контейнеру нечего показывать. + #expect(bed.provider.contentView == nil) + } + + @Test("Second start does not resolve or load again") + func secondStartDoesNothing() { + let bed = EmbeddedBlockTestBed() + + bed.provider.start() + bed.provider.start() + + #expect(bed.resolver.resolveCount == 1) + #expect(bed.page?.loadCount == 1) + } + + /// Выключенный в админке или неизвестный блок — не ошибка: страницу для него даже не создаём. + @Test("Empty resolution needs no page at all") + func emptyResolutionCreatesNoPage() { + let bed = EmbeddedBlockTestBed(resolution: .empty) + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + + #expect(states == [.loading, .empty]) + #expect(bed.pageFactory.pages.isEmpty) + #expect(bed.provider.contentView == nil) + } + + // MARK: - Readiness + + /// О готовности говорит только сама страница — это единственный источник истины. + @Test("Page ready makes the content available") + func pageReadyMakesContentAvailable() { + let bed = EmbeddedBlockTestBed() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.send(.ready(height: 104)) + + #expect(states == [.loading, .ready]) + #expect(bed.provider.contentView === bed.page?.view) + } + + /// Молчащая страница готовой не становится: загруженный документ ничего не говорит о том, есть + /// ли блоку что показать. Такой блок добьёт таймаут контейнера. + @Test("Silent page never becomes ready on its own") + func silentPageStaysLoading() { + let bed = EmbeddedBlockTestBed() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + + #expect(states == [.loading]) + #expect(bed.provider.contentView == nil) + } + + /// Странице без контента честнее сказать `empty`, поэтому нулевая высота — сломанная вёрстка. + @Test("Zero height in ready is a failure") + func zeroHeightIsFailure() { + let bed = EmbeddedBlockTestBed() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.send(.ready(height: 0)) + + #expect(states.last == .failed) + #expect(bed.provider.contentView == nil) + } + + /// Высотой владеет хост: сообщение в контракте есть, но вёрстку оно не трогает. + @Test("Height change leaves the state alone") + func heightChangeChangesNothing() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.send(.ready(height: 104)) + bed.page?.send(.heightChanged(height: 132)) + + #expect(states == [.ready]) + } + + @Test("Page empty collapses the block") + func pageEmptyCollapsesTheBlock() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.send(.ready(height: 104)) + bed.page?.send(.empty) + + #expect(states == [.ready, .empty]) + #expect(bed.provider.contentView == nil) + } + + // MARK: - Debug readiness + + /// Обычное правило: загруженный документ ничего не говорит о том, есть ли блоку что показать. + @Test("Loaded document alone does not make the block ready") + func loadFinishAloneChangesNothing() { + let bed = EmbeddedBlockTestBed() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.finishLoad() + + #expect(states == [.loading]) + #expect(bed.provider.contentView == nil) + } + + /// Со включённой подменой блок показывается по загруженному документу — так проверяется UI, + /// пока страница не умеет присылать `ready`. + @Test("With the debug override a loaded document shows the block") + func loadFinishMakesBlockReadyWithOverride() { + let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.finishLoad() + + #expect(states == [.loading, .ready]) + #expect(bed.provider.contentView === bed.page?.view) + } + + /// Страница, которая контракт умеет, ведёт себя с подменой так же, как без неё: `ready` уже + /// показал блок, и второго показа документ не добавляет. + @Test("A page that sent ready is not shown twice by the override") + func readyBeforeLoadFinishIsNotDuplicated() { + let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.send(.ready(height: 104)) + bed.page?.finishLoad() + + #expect(states == [.loading, .ready]) + } + + /// Подмена не сильнее страницы: сказанное ей «показывать нечего» сворачивает блок и со + /// включённым флагом. + @Test("The override does not swallow an empty from the page") + func overrideDoesNotSwallowEmpty() { + let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.finishLoad() + bed.page?.send(.empty) + + #expect(states == [.loading, .ready, .empty]) + #expect(bed.provider.contentView == nil) + } + + /// После `stop()` провайдер молчит целиком — подмена этого не меняет. + @Test("Loaded document after a stop is ignored even with the override") + func loadFinishAfterStopIsIgnored() { + let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) + bed.provider.start() + bed.provider.stop() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.finishLoad() + + #expect(states.isEmpty) + #expect(bed.provider.contentView == nil) + } + + /// Выброшенная перезагрузкой страница не должна показать себя и через подмену. + @Test("The dropped page cannot show itself through the override") + func droppedPageCannotFinishIntoTheNewAttempt() { + let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) + bed.provider.start() + let firstPage = bed.page + bed.provider.reload() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + firstPage?.finishLoad() + + #expect(states.isEmpty) + #expect(bed.provider.contentView == nil) + } + + // MARK: - Load failure + + /// Провал загрузки — единственное, о чём судит навигация. + @Test("Load failure fails the block") + func loadFailureFailsTheBlock() { + let bed = EmbeddedBlockTestBed() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.failLoad() + + #expect(states == [.loading, .failed]) + #expect(bed.provider.contentView == nil) + } + + @Test("Load failure after a stop is ignored") + func loadFailureAfterStopIsIgnored() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.provider.stop() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.failLoad() + + #expect(states.isEmpty) + } + + // MARK: - Page actions + + /// Ядро словаря страницы не знает: всё сверх core-слоя уходит обработчику как есть и состояние + /// контейнера не трогает. + @Test("Page action is routed to the handler and changes no state") + func actionIsRouted() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + let action = EmbeddedBlockPageAction(type: "openUrl", payload: ["url": "https://mindbox.ru"]) + bed.page?.send(.action(action)) + + #expect(bed.actionHandler.handledActions == [action]) + #expect(states.isEmpty) + } + + /// Остановленный провайдер молчит целиком — в том числе не будит обработчик действий. + @Test("Actions after a stop do not reach the handler") + func actionsAfterStopAreIgnored() { + let bed = EmbeddedBlockTestBed() + + bed.provider.start() + bed.provider.stop() + bed.page?.send(.action(EmbeddedBlockPageAction(type: "openUrl", payload: [:]))) + + #expect(bed.actionHandler.handledActions.isEmpty) + } + + @Test("Action from a shown block is routed") + func actionFromShownBlockIsRouted() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + + bed.page?.send(.action(.openUrlStub)) + + #expect(bed.actionHandler.handledActions == [.openUrlStub]) + } + + /// Схлопнутый блок не убивает страницу — она жива и может досылать то, что запланировала. Но за + /// невидимым блоком не стоит ни одного касания пользователя, а `openUrl` увёл бы его из + /// приложения на пустом месте. + @Test("Actions from a block collapsed as empty do not reach the handler") + func actionsAfterEmptyAreIgnored() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + + bed.page?.send(.empty) + bed.page?.send(.action(.openUrlStub)) + + #expect(bed.actionHandler.handledActions.isEmpty) + } + + @Test("Actions from a failed block do not reach the handler") + func actionsAfterFailureAreIgnored() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + + bed.page?.failLoad() + bed.page?.send(.action(.openUrlStub)) + + #expect(bed.actionHandler.handledActions.isEmpty) + } + + /// Сломанная вёрстка — тот же непоказанный блок: действия из него тоже не выполняются. + @Test("Actions from a block broken by a zero height do not reach the handler") + func actionsAfterZeroHeightAreIgnored() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + + bed.page?.send(.ready(height: 0)) + bed.page?.send(.action(.openUrlStub)) + + #expect(bed.actionHandler.handledActions.isEmpty) + } + + /// Запрет держится на исходе попытки, а не на странице: новая попытка снова живая. + @Test("A new attempt after a failure accepts actions again") + func retryAfterFailureAcceptsActions() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.failLoad() + + bed.provider.stop() + bed.provider.start() + bed.page?.send(.action(.openUrlStub)) + + #expect(bed.actionHandler.handledActions == [.openUrlStub]) + } + + // MARK: - Stop and restart + + /// После `stop()` провайдер обязан молчать — на это опирается контейнер, когда сворачивает + /// просроченный контент по своему таймауту. + @Test("Stop cancels the page and ignores what it says afterwards") + func stopCancelsThePage() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.stop() + bed.page?.send(.ready(height: 104)) + + #expect(bed.page?.cancelCount == 1) + #expect(states.isEmpty) + #expect(bed.provider.contentView == nil) + } + + /// Контейнер зовёт `start()` каждый раз, когда возвращается в окно: пересоздавать вебвью и + /// заново спрашивать резолвер на каждое возвращение незачем. + @Test("Restart reuses the same page without resolving again") + func restartReusesThePage() { + let bed = EmbeddedBlockTestBed() + + bed.provider.start() + bed.provider.stop() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + + #expect(bed.resolver.resolveCount == 1) + #expect(bed.pageFactory.pages.count == 1) + #expect(bed.page?.loadCount == 2) + #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() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + bed.provider.stop() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + + #expect(states == [.ready]) + #expect(bed.page?.loadCount == 1) + #expect(bed.resolver.resolveCount == 1) + #expect(bed.provider.contentView === bed.page?.view) + } + + /// А вот блок, который показать не удалось, получает на возврате новую попытку — это + /// единственный ретрай, который у блока пока есть. + @Test("Failed block tries again when it comes back") + func failedBlockTriesAgainOnReturn() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.failLoad() + bed.provider.stop() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + + #expect(states == [.loading]) + #expect(bed.page?.loadCount == 2) + } + + // MARK: - Live blocks + + /// Счётчик живых блоков общий на процесс, поэтому у каждого теста свой id: иначе тесты, идущие + /// параллельно, считали бы блоки друг друга. + @Test("Live count follows the life of a block") + func liveCountFollowsBlockLife() { + let id = "live-count-single" + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 0) + + do { + let provider = makeProvider(id: id) + withExtendedLifetime(provider) { + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 1) + } + } + + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 0) + } + + @Test("Blocks sharing an id are counted together") + func liveCountSumsBlocksOfTheSameId() { + let id = "live-count-shared" + + do { + let first = makeProvider(id: id) + let second = makeProvider(id: id) + withExtendedLifetime((first, second)) { + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 2) + } + } + + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 0) + } + + @Test("Blocks with different ids are counted apart") + func liveCountKeepsIdsApart() { + let promo = "live-count-promo" + let stories = "live-count-stories" + + let provider = makeProvider(id: promo) + withExtendedLifetime(provider) { + #expect(EmbeddedBlockWebViewProvider.liveCount(for: promo) == 1) + #expect(EmbeddedBlockWebViewProvider.liveCount(for: stories) == 0) + } + } + + private func makeProvider(id: String) -> EmbeddedBlockWebViewProvider { + EmbeddedBlockWebViewProvider(id: id, + resolver: EmbeddedBlockResolverMock(), + actionHandler: EmbeddedBlockActionHandlerMock(), + makePage: { _ in EmbeddedBlockPageMock() }) + } + + /// Резолв мог доехать уже после остановки — тогда он относится к прошлой попытке. + @Test("Resolution arriving after a stop creates nothing") + func lateResolutionAfterStopIsIgnored() { + let bed = EmbeddedBlockTestBed() + bed.resolver.isDeferred = true + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.provider.stop() + bed.resolver.flush() + + #expect(bed.pageFactory.pages.isEmpty) + #expect(states == [.loading]) + } + + // MARK: - Reload + + @Test("Reload asks for the content again bypassing the cache and builds a new page") + func reloadRefetchesTheContent() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + let firstPage = bed.page + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.resolver.resolution = .content(.other) + bed.provider.reload() + + #expect(bed.resolver.forceRefreshHistory == [false, true]) + #expect(bed.pageFactory.contents == [.stub, .other]) + #expect(bed.pageFactory.pages.count == 2) + #expect(bed.page !== firstPage) + #expect(firstPage?.cancelCount == 1) + #expect(states == [.loading]) + // Готовность начинается с нуля: новая страница ещё ничего не сказала. + #expect(bed.provider.contentView == nil) + } + + /// Прежняя страница уже не имеет отношения к делу — её запоздавшие сообщения не должны + /// показать выброшенный контент. + @Test("The dropped page cannot report into the new attempt") + func droppedPageIsSilenced() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + let firstPage = bed.page + bed.provider.reload() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + firstPage?.send(.ready(height: 104)) + firstPage?.failLoad() + + #expect(states.isEmpty) + #expect(bed.provider.contentView == nil) + } + + /// Резолв прошлой попытки не должен подменить страницу новой. + @Test("Resolution arriving after a reload does not add a second page") + func lateResolutionAfterReloadIsIgnored() { + let bed = EmbeddedBlockTestBed() + bed.resolver.isDeferred = true + bed.provider.start() + + bed.provider.reload() + bed.resolver.flush() + + #expect(bed.resolver.resolveCount == 2) + #expect(bed.pageFactory.pages.count == 1) + } + + @Test("Reloaded block becomes ready through the same path") + func reloadedBlockBecomesReady() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + + bed.provider.reload() + bed.page?.send(.ready(height: 104)) + + #expect(bed.provider.contentView === bed.page?.view) + } +} From 9b6037b08a49e7651c1da8a2ac0cb414b2750e42 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:02:36 +0500 Subject: [PATCH 15/47] MOBILE-323: Add tests for the content provider factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фабрика приехала без тестов, а её обещание — то, на чём держится независимость блоков с одинаковым id: провайдер свой на каждый блок, резолвер общий на все. Проверяется и то и другое, плюс что провайдер собран под запрошенный id. Резолвер в тестах отвечает «пусто»: страницу для такого блока не создают, поэтому настоящий вебвью фабрике здесь не нужен. --- ...ddedBlockContentProviderFactoryTests.swift | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift new file mode 100644 index 000000000..cf81cefc4 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift @@ -0,0 +1,76 @@ +// +// EmbeddedBlockContentProviderFactoryTests.swift +// MindboxTests +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@testable import Mindbox + +/// У фабрики одно обещание: провайдер — свой на каждый блок, а резолвер и обработчик действий — +/// общие. На нём держится независимость блоков с одинаковым id, поэтому оно проверяется отдельно. +/// +/// Счётчик живых блоков общий на процесс, поэтому у каждого теста свой id: иначе тесты, идущие +/// параллельно, считали бы блоки друг друга. +@Suite("Embedded block content provider factory", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockContentProviderFactoryTests { + + /// Два блока с одним id — законный случай, и каждый обязан получить собственный провайдер: + /// общий сделал бы их состояние и страницу одной на двоих. + @Test("Every call makes its own provider") + func eachCallMakesItsOwnProvider() { + let id = "factory-independent-blocks" + let factory = makeFactory() + + let first = factory.makeProvider(id: id) + let second = factory.makeProvider(id: id) + + #expect(first !== second) + withExtendedLifetime((first, second)) { + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 2) + } + } + + @Test("The provider is made for the requested id") + func providerIsMadeForTheRequestedId() { + let id = "factory-carries-the-id" + let other = "factory-some-other-id" + let factory = makeFactory() + + let provider = factory.makeProvider(id: id) + + withExtendedLifetime(provider) { + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 1) + #expect(EmbeddedBlockWebViewProvider.liveCount(for: other) == 0) + } + } + + /// Резолвер общий именно для того, чтобы несколько блоков с одним id разрешались одной загрузкой + /// данных. Проверяется, что фабрика действительно передаёт провайдеру тот резолвер, а не заводит + /// ему свой. + @Test("The provider asks the shared resolver for its own id") + func providerAsksTheSharedResolver() { + let resolver = EmbeddedBlockResolverMock(resolution: .empty) + let factory = EmbeddedBlockContentProviderFactory(resolver: resolver, + actionHandler: EmbeddedBlockActionHandlerMock()) + + let provider = factory.makeProvider(id: "factory-shared-resolver") + withExtendedLifetime(provider) { + provider.start() + } + + #expect(resolver.resolvedIds == ["factory-shared-resolver"]) + } + + // MARK: - Helpers + + /// Резолвер отвечает «пусто»: страницу для такого блока не создают, поэтому тестам фабрики не + /// нужен настоящий вебвью. + private func makeFactory() -> EmbeddedBlockContentProviderFactory { + EmbeddedBlockContentProviderFactory(resolver: EmbeddedBlockResolverMock(resolution: .empty), + actionHandler: EmbeddedBlockActionHandlerMock()) + } +} From e79ebf213a50ffc9955e72b1c30f461e35f3cad2 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:19:04 +0500 Subject: [PATCH 16/47] MOBILE-323: Trim duplicated comments in the content provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Док класса пересказывал то, что уже сказано ниже по файлу: правила готовности — у apply(height:) и handleLoadFinish, владение высотой — у heightChanged, а мысль «уход из окна не выбрасывает страницу» шла трижды — в доке класса, в доке свойства page и во встроенном комментарии в start(). Осталось два абзаца: чем провайдер является и что после stop() он обязан молчать — это межтиповой инвариант, из одного файла его не видно. Убраны и три дока, ушедшие из своей ответственности или пересказывавшие подпись: contentView рассказывал, как контейнер растягивает вью; второй абзац reload() — про плейсхолдер и события хосту; isShown повторял собственное имя. Встроенные комментарии не тронуты: каждый объясняет отсутствующую строку, развилку или внешнюю причину — то, чего в коде не прочитать. --- .../EmbeddedBlockWebViewProvider.swift | 30 ++++--------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift index db25eba41..0e4e4aaec 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift @@ -15,29 +15,14 @@ import MindboxLogger /// переводит core-сообщения страницы в состояния контейнера, а действия сверх core-слоя отдаёт /// универсальному обработчику. /// -/// Готовность блока определяет только сама страница: `ready` — показываем, `empty` — показывать -/// нечего. Навигация судит исключительно о своём — о том, что загрузка не состоялась; молчащая -/// страница готовой не становится, её добьёт таймаут контейнера. Единственное исключение — -/// отладочная подмена готовности (`EmbeddedBlockReadinessOverrides`) для страниц, которые -/// веб-контракт ещё не умеют. -/// -/// Высоту контейнера назначает хост при создании блока: высота из сообщений страницы на вёрстку -/// не влияет, но нулевая в `ready` по-прежнему значит, что страница сломана. -/// -/// Экземпляр принадлежит одному контейнеру — ничего общего между контейнерами здесь нет, это и -/// делает возможными несколько независимых блоков. `start()` и `stop()` повторяют видимость -/// контейнера и могут вызываться по кругу; после `stop()` провайдер обязан молчать до следующего +/// Экземпляр принадлежит одному контейнеру, поэтому `start()` и `stop()` просто повторяют его +/// видимость и могут вызываться по кругу. После `stop()` провайдер обязан молчать до следующего /// `start()` — на это опирается контейнер, когда сворачивает просроченный блок. -/// -/// Уход из окна не выбрасывает уже отрендеренную страницу: возврат блока в окно показывает её -/// снова, без сети и без шиммера. Обновить содержимое можно только явно — через `reload()`. final class EmbeddedBlockWebViewProvider { /// Сообщает каждую смену состояния на главном потоке. Ставится контейнером. var onStateChange: ((EmbeddedBlockState) -> Void)? - /// Вью с контентом блока. Контейнер читает её, когда состояние стало `.ready`, и растягивает - /// по своим краям. var contentView: UIView? { isReady ? page?.view : nil } private let id: String @@ -99,9 +84,6 @@ final class EmbeddedBlockWebViewProvider { /// Начинает загрузку с нуля: страница выбрасывается, а адрес запрашивается заново в обход кэша /// резолвера — иначе переехавший или выключенный блок вечно доставал бы прежний адрес. - /// - /// Путь тот же, что и у первого запуска, поэтому плейсхолдер, таймаут контейнера и события - /// хосту работают одинаково: второй ветки жизненного цикла у блока нет. func reload() { Logger.common(message: "[EmbeddedBlock] Block '\(id)' is reloading", category: .embeddedBlocks) @@ -155,15 +137,13 @@ final class EmbeddedBlockWebViewProvider { onStateChange?(.failed) } - /// Показан ли блок сейчас — то есть может ли пользователь вообще что-то в нём нажать. Пока - /// исхода нет, страница ещё грузится, и её сообщения относятся к живому блоку. + /// Пока исхода нет, страница ещё грузится — её сообщения относятся к живому блоку. private var isShown: Bool { outcome == nil || outcome == .ready } - /// Загруженный документ сам по себе ничего не значит: показывать блок по нему можно только со - /// включённой отладочной подменой, пока страница не умеет присылать `ready`. Своё `ready` - /// страницы сильнее — если оно уже пришло, здесь делать нечего. + /// Загруженный документ сам по себе ничего не значит: показать блок по нему разрешает только + /// отладочная подмена — для страниц, которые ещё не умеют присылать `ready`. func handleLoadFinish() { guard isStarted, !isReady, readinessOverrides.treatsLoadedPageAsReady else { return } From 42b04e227f785527b8c745e60fb330dbe7538b5d Mon Sep 17 00:00:00 2001 From: Akylbek Utekeshev Date: Mon, 10 Aug 2026 17:28:35 +0500 Subject: [PATCH 17/47] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift index 671e2e473..7e69678db 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift @@ -6,6 +6,7 @@ // Copyright © 2026 Mindbox. All rights reserved. // +import UIKit import WebKit import MindboxLogger From 0820c45d860461e67a2ac9aceb6fb8161b425a92 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:41:17 +0500 Subject: [PATCH 18/47] MOBILE-323: Add the block layer host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Держит в контейнере ровно одну вью, растянутую по его краям. Слои блока — плейсхолдер, контент, экран ошибки — взаимоисключающие: показать новый значит снять прежний. Показанная вью запоминается отдельно от свойств контейнера, потому что подменить её хост может в любой момент, а снимать надо ту, что действительно висит, а не ту, что лежит в свойстве сейчас. --- .../Container/EmbeddedBlockLayerHost.swift | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift new file mode 100644 index 000000000..87619dc17 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift @@ -0,0 +1,46 @@ +// +// EmbeddedBlockLayerHost.swift +// Mindbox +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit + +/// Держит в контейнере ровно одну вью, растянутую по его краям. +/// +/// Слои блока — плейсхолдер, контент, экран ошибки — взаимоисключающие: показать новый значит снять +/// прежний. Показанная вью запоминается отдельно от свойств контейнера, потому что подменить её хост +/// может в любой момент, а снимать надо ту, что действительно висит, а не ту, что лежит в свойстве +/// сейчас. +final class EmbeddedBlockLayerHost { + + /// Владелец держит хост, поэтому обратная ссылка не считается — иначе контейнер не умрёт никогда. + private unowned let container: UIView + + private var attachedView: UIView? + + init(container: UIView) { + self.container = container + } + + /// Показывает вью вместо той, что висит сейчас. `nil` — не показывать ничего. + func show(_ view: UIView?) { + guard attachedView !== view || view?.superview !== container else { return } + + attachedView?.removeFromSuperview() + attachedView = view + + guard let view else { return } + + view.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(view) + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: container.topAnchor), + view.leadingAnchor.constraint(equalTo: container.leadingAnchor), + view.trailingAnchor.constraint(equalTo: container.trailingAnchor), + view.bottomAnchor.constraint(equalTo: container.bottomAnchor) + ]) + } +} From e15ed406651489cd65bf905f9e6cff58356cc985 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:41:33 +0500 Subject: [PATCH 19/47] MOBILE-323: Add the block ready timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Бюджет на то, чтобы показаться, принадлежит контейнеру, а не контенту: чем бы блок ни оказался внутри, вёрстка хоста не ждёт его вечно. Считается время ожидания пользователя, а не календарное: пока блока никто не ждёт — приложение в фоне, контейнер вне окна — отсчёт стоит. Но именно стоит, а не начинается заново: потраченное запоминается, и попытка продолжает бюджет с того места, где её прервали. Пауза, отдающая полный бюджет заново, не заканчивается никогда — пользователь, переключающийся между приложениями каждые пять секунд, продлевал бы ожидание блока бесконечно, и вёрстка ждала бы его вечно. Ровно то, против чего бюджет и заведён. Полный бюджет получает только новая попытка. Часы вынесены отдельным швом: считать потраченное без них нельзя, а тесты не могут ждать бюджет целиком — им нужно уметь сказать, что время прошло. Загрузку пауза не трогает: она идёт своим чередом, в фоне её тормозит система, а не SDK. --- .../Container/EmbeddedBlockReadyTimeout.swift | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift new file mode 100644 index 000000000..85b8bbbe3 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift @@ -0,0 +1,131 @@ +// +// EmbeddedBlockReadyTimeout.swift +// Mindbox +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import MindboxLogger + +/// Сколько блоку дано на то, чтобы показаться, — и учёт этого времени. +/// +/// Бюджет принадлежит контейнеру, а не контенту: чем бы блок ни оказался внутри, вёрстка хоста не +/// ждёт его вечно. Не уложился — контейнер сворачивает блок. +/// +/// Считается время ожидания пользователя, а не календарное: пока блока никто не ждёт — приложение +/// в фоне, контейнер вне окна — отсчёт стоит. Иначе пользователь возвращался бы в приложение к +/// блоку, который сдался, пока его никто не видел. +/// +/// Но именно стоит, а не начинается заново: потраченное запоминается, и попытка продолжает бюджет +/// с того места, где её прервали. Пауза, отдающая полный бюджет заново, не заканчивается никогда — +/// пользователь, переключающийся между приложениями каждые пять секунд, продлевал бы ожидание +/// блока бесконечно, и вёрстка хоста ждала бы его вечно. Ровно то, против чего бюджет и заведён. +/// Полный бюджет заново получает только новая попытка — `reset()`. +/// +/// Загрузку пауза не трогает: она идёт своим чередом, в фоне её тормозит система, а не SDK. +final class EmbeddedBlockReadyTimeout { + + /// Нужен ли отсчёт прямо сейчас: исход ещё неизвестен и блок на виду. Спрашивается заново на + /// каждом заводе, потому что за время паузы могло измениться и то, и другое. + var isNeeded: () -> Bool = { false } + + /// Время вышло. Вызывается на главном потоке. + var onExpire: () -> Void = {} + + var isRunning: Bool { workItem != nil } + + private let blockId: String + private let duration: TimeInterval + + /// Часы отдельным швом: считать потраченное без них нельзя, а тесты не могут ждать бюджет + /// целиком — им нужно уметь сказать, что время прошло. + private let now: () -> Date + + private var workItem: DispatchWorkItem? + + /// Сколько бюджета съели прошлые отрезки ожидания. + private var consumed: TimeInterval = 0 + + /// Когда начался идущий отрезок. `nil` — отсчёт не идёт. + private var resumedAt: Date? + + private var remaining: TimeInterval { max(0, duration - consumed) } + + init(blockId: String, duration: TimeInterval, now: @escaping () -> Date = { Date() }) { + self.blockId = blockId + self.duration = duration + self.now = now + + NotificationCenter.default.addObserver(self, + selector: #selector(applicationDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(applicationWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + workItem?.cancel() + } + + /// Заводит отсчёт на остаток бюджета, если он нужен и ещё не идёт. Звать можно сколько угодно + /// раз: лишние вызовы ничего не делают, поэтому вход в окно, возврат из фона и перезагрузка + /// обходятся одним и тем же вызовом. + func armIfNeeded() { + guard workItem == nil, isNeeded() else { return } + + let work = DispatchWorkItem { [weak self] in + guard let self else { return } + + self.workItem = nil + self.resumedAt = nil + // Бюджет израсходован целиком: если блок почему-то заведут снова, ждать ему уже нечего. + self.consumed = self.duration + + Logger.common(message: "[EmbeddedBlock] Block '\(self.blockId)' timed out after \(self.duration)s of waiting", + category: .embeddedBlocks) + self.onExpire() + } + + resumedAt = now() + workItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + remaining, execute: work) + } + + /// Останавливает отсчёт, запомнив потраченное. Попытку это не отменяет: `armIfNeeded()` + /// продолжит её с остатка. + func pause() { + guard let resumedAt else { return } + + consumed += now().timeIntervalSince(resumedAt) + self.resumedAt = nil + workItem?.cancel() + workItem = nil + } + + /// Останавливает отсчёт и возвращает бюджет в полный: прошлая попытка кончилась — исходом или + /// тем, что началась следующая, — и её остаток к новой отношения не имеет. + func reset() { + pause() + consumed = 0 + } + + @objc + private func applicationDidEnterBackground() { + guard isRunning else { return } + + Logger.common(message: "[EmbeddedBlock] Block '\(blockId)' went to the background while loading, pausing the timeout", + category: .embeddedBlocks) + pause() + } + + @objc + private func applicationWillEnterForeground() { + armIfNeeded() + } +} From 6e775e823fd29c33bdc1706a5b44f13a77073d65 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:41:33 +0500 Subject: [PATCH 20/47] MOBILE-323: Add the default loading shimmer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Плейсхолдер, который блок показывает, пока грузится, если хост не дал своего. Занимает весь контейнер: место под блок должно быть занято сразу, а занятое место не должно выглядеть пустым. --- .../Container/EmbeddedBlockShimmerView.swift | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift new file mode 100644 index 000000000..5c0ea7fcc --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift @@ -0,0 +1,118 @@ +// +// EmbeddedBlockShimmerView.swift +// Mindbox +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit + +/// Дефолтный плейсхолдер встроенного блока — нейтральная плашка с бегущим бликом. +/// +/// Заливает контейнер целиком: у SDK нет знания о вёрстке будущего контента, поэтому плейсхолдер +/// не изображает её, а просто помечает зарезервированное место как «грузится». Хост, которому +/// нужен скелет своей вёрстки, задаёт `placeholderView` контейнера. +/// +/// Анимация живёт ровно столько, сколько вью видна: запускается при входе в окно и гасится при +/// выходе, включая уход приложения в фон (система снимает CA-анимации, поэтому на возврат в +/// foreground блик перезапускается). +final class EmbeddedBlockShimmerView: UIView { + + private enum Shimmer { + static let animationKey = "embeddedBlockShimmer" + static let animationDuration: CFTimeInterval = 1.4 + } + + private let gradientLayer = CAGradientLayer() + + private var baseColor: UIColor { + if #available(iOS 13.0, *) { + return .systemGray5 + } + return UIColor(white: 0.90, alpha: 1.0) + } + + private var highlightColor: UIColor { + if #available(iOS 13.0, *) { + return .systemGray6 + } + return UIColor(white: 0.96, alpha: 1.0) + } + + override init(frame: CGRect) { + super.init(frame: frame) + setUp() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setUp() + } + + override func layoutSubviews() { + super.layoutSubviews() + gradientLayer.frame = bounds + } + + override func didMoveToWindow() { + super.didMoveToWindow() + + if window == nil { + stopShimmering() + } else { + startShimmering() + } + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + applyColors() + } + + private func setUp() { + isUserInteractionEnabled = false + + gradientLayer.startPoint = CGPoint(x: 0.0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1.0, y: 0.5) + gradientLayer.locations = [-1.0, -0.5, 0.0] + applyColors() + layer.addSublayer(gradientLayer) + + NotificationCenter.default.addObserver(self, + selector: #selector(applicationWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil) + } + + private func applyColors() { + gradientLayer.colors = [ + baseColor.cgColor, + highlightColor.cgColor, + baseColor.cgColor + ] + } + + private func startShimmering() { + guard gradientLayer.animation(forKey: Shimmer.animationKey) == nil else { return } + + let animation = CABasicAnimation(keyPath: "locations") + animation.fromValue = [-1.0, -0.5, 0.0] + animation.toValue = [1.0, 1.5, 2.0] + animation.duration = Shimmer.animationDuration + animation.repeatCount = .infinity + gradientLayer.add(animation, forKey: Shimmer.animationKey) + } + + private func stopShimmering() { + gradientLayer.removeAnimation(forKey: Shimmer.animationKey) + } + + /// Система снимает бесконечные CA-анимации при уходе в фон — после возврата блик нужно + /// запустить заново. + @objc + private func applicationWillEnterForeground() { + guard window != nil else { return } + startShimmering() + } +} From be5b4fe164131c802a7fba8b6212094337429acf Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:41:56 +0500 Subject: [PATCH 21/47] MOBILE-323: Add the UIKit embedded block container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Публичный API: контейнер создаётся с id блока из админки и высотой, которую блок должен занять. Хост ставит его куда угодно и задаёт только положение и ширину — высоту контейнер заявляет сам через intrinsicContentSize: данную при создании, пока контент грузится и показан, и 0, когда показывать нечего. Оба исхода настраиваются: placeholderView заменяет штатный шиммер, errorView — это согласие показать провал вместо схлопывания. Жизненным циклом владеет SDK: контент стартует, когда блок попадает в окно, и останавливается, когда уходит. Публичного способа запустить его руками нет. Внутри — машина из четырёх состояний контента и одного видимого слоя на каждое. Три решения, которые стоят за ней: Место, единожды отданное хосту, назад не забирается. Блок, который показать не удалось, на возврате в окно пробует снова, но контейнер под эту попытку места уже не занимает и шиммером не мигает — иначе он дёргал бы вёрстку на свою высоту и мигал на каждый свой проход по экрану, ничего в итоге не показывая. Разворачивает блок только показанный контент или явная перезагрузка. Тот же делегат — не новый подписчик. Хост штатно переприсваивает его на каждой переиспользованной ячейке, и отдавать ему на это уже услышанный исход нельзя: на исход он перестраивает вёрстку, а перестройка вёрстки снова переприсваивает делегата. Исходы отдаются на следующем витке главной очереди: состояние может измениться посреди прохода layout, и заходить оттуда в код хоста — верный способ сломать его вёрстку. EmbeddedBlockPresentation — снимок показа для SwiftUI-обёртки: она сама назначает себе высоту и сама рисует слои хоста, потому что вью, отданная контейнеру через отдельный UIHostingController, выпадает из дерева SwiftUI и теряет его окружение. --- .../EmbeddedBlockPresentation.swift | 43 +++ .../Public/MindboxEmbeddedBlockView.swift | 360 ++++++++++++++++++ .../MindboxEmbeddedBlockViewDelegate.swift | 38 ++ Mindbox/Utilities/Constants.swift | 9 + 4 files changed, 450 insertions(+) create mode 100644 Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/EmbeddedBlockPresentation.swift create mode 100644 Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift create mode 100644 Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockViewDelegate.swift diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/EmbeddedBlockPresentation.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/EmbeddedBlockPresentation.swift new file mode 100644 index 000000000..725c6c262 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/EmbeddedBlockPresentation.swift @@ -0,0 +1,43 @@ +// +// EmbeddedBlockPresentation.swift +// Mindbox +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import CoreGraphics + +/// Что контейнер показывает прямо сейчас — снимок для SwiftUI-обёртки. +/// +/// UIKit-хосту такой тип не нужен: контейнер сам заявляет высоту через `intrinsicContentSize` и сам +/// держит внутри нужный слой. SwiftUI не умеет ни того, ни другого. Высоту представимой вью +/// назначает обёртка, а плейсхолдер и экран ошибки хоста — это SwiftUI-вью, и рисовать их обязана +/// тоже она: вью, отданная контейнеру через отдельный `UIHostingController`, выпадает из дерева +/// SwiftUI и теряет его окружение. Поэтому обёртке нужен не только размер, но и текущий слой. +/// +/// Deliberately internal, как и `EmbeddedBlockState`: хост знает только исход, а не то, как +/// контейнер к нему пришёл. +struct EmbeddedBlockPresentation: Equatable { + + /// Слой, видимый в контейнере. + enum Layer { + + /// Идёт загрузка: показан плейсхолдер — хоста или дефолтный шиммер SDK. + case placeholder + + /// Показано содержимое блока. + case content + + /// Показан экран ошибки, на который хост согласился явно. + case errorView + + /// Блок схлопнут: провал без экрана ошибки или пустой блок. + case nothing + } + + let layer: Layer + + /// Высота, которую контейнер занимает с этим слоем. + let height: CGFloat +} diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift new file mode 100644 index 000000000..5e8717efc --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift @@ -0,0 +1,360 @@ +// +// MindboxEmbeddedBlockView.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import MindboxLogger + +/// A drop-in container for a Mindbox embedded block. +/// +/// Created with the block `id` from the admin panel and the `height` the block should occupy. +/// Put it anywhere in the app and constrain its position and width only — the height is applied +/// by the container itself through `intrinsicContentSize`: the one given at creation while the +/// content is loading and shown, and 0 when there is nothing to show (a failure or an empty +/// block), so the block takes no space and is invisible in the host layout. Both outcomes can be +/// customized: `placeholderView` replaces the stock loading shimmer, and `errorView` opts into +/// showing a failure instead of collapsing. +/// +/// What exactly lives inside is decided by the SDK from the block `id`, not by the host. The +/// block flow belongs to the SDK too: the container starts its content when it enters a window +/// and stops it when it leaves. The host app observes the outcome through `delegate` and nothing +/// else. +/// +/// Сам контейнер — это машина из четырёх состояний контента и одного видимого слоя на каждое из +/// них. Всё, что можно было унести из него целиком, унесено: показ слоёв — в +/// `EmbeddedBlockLayerHost`, бюджет ожидания вместе с его паузой в фоне — в +/// `EmbeddedBlockReadyTimeout`. +public final class MindboxEmbeddedBlockView: UIView { + + // MARK: - Host API + + /// The block id from the admin panel, given at creation. Decides what content the SDK puts + /// inside. + public let id: String + + /// Receives the block events. Assigning a delegate after the content already resolved still + /// delivers that outcome, so subscribing late cannot lose it. + public weak var delegate: MindboxEmbeddedBlockViewDelegate? { + didSet { + // Тот же делегат — не новый подписчик. Хост штатно переприсваивает его на каждой + // переиспользованной ячейке, и отдавать ему на это уже услышанный исход нельзя: он на + // исход перестраивает вёрстку, а перестройка вёрстки снова переприсваивает делегата. + guard delegate !== oldValue else { return } + + deliveredEvent = nil + scheduleDelivery() + } + } + + /// The view shown in place of the content while it is loading. `nil` — the default — means + /// the SDK's own shimmer. The placeholder fills the whole container, so it is laid out to the + /// container's width and the height given at creation. Can be swapped at any moment, including + /// mid-loading. + public var placeholderView: UIView? { + didSet { + guard placeholderView !== oldValue else { return } + refreshPlaceholder() + } + } + + /// The view shown when the block fails. `nil` — the default — keeps the failure invisible: + /// the container collapses to zero height. Setting a view opts into showing the failure + /// instead: the container keeps the height given at creation and fills itself with this view. + /// The SDK ships no stock error screen — what a failed block looks like is the host's design + /// decision. Applies only to failures; an empty block always collapses. + /// + /// A view assigned mid-failure swaps the error screen that is already shown, but never + /// expands a block that has already collapsed — reopening space the host layout has + /// reclaimed would make the layout jump. Such a view is remembered and takes effect on + /// the next load. + public var errorView: UIView? { + didSet { + guard errorView !== oldValue else { return } + refreshErrorView() + } + } + + /// What the container shows, for the SwiftUI wrapper: it sizes the representable itself instead + /// of relying on `intrinsicContentSize`, and it draws the host's placeholder and error screen + /// itself instead of handing them over as `UIView`s. Internal: not part of the public API. + var onPresentationChange: ((EmbeddedBlockPresentation) -> Void)? + + // MARK: - State + + private let contentProvider: EmbeddedBlockWebViewProvider + + /// The height the host reserved for the block at creation. The container holds it while the + /// content is loading and shown, and drops to 0 when there is nothing to show. + private let preferredHeight: CGFloat + + private let timeout: EmbeddedBlockReadyTimeout + + private lazy var layers = EmbeddedBlockLayerHost(container: self) + + private lazy var defaultPlaceholder = EmbeddedBlockShimmerView() + + private var state: EmbeddedBlockState = .loading { + didSet { + updateTimeout(from: oldValue) + apply(state) + } + } + + /// Схлопывался ли блок с прошлой явной перезагрузки. + /// + /// Место, единожды отданное хосту, назад не забирается: повторная попытка — а её блок получает + /// на каждом возвращении в окно — не разворачивает контейнер обратно под плейсхолдер. Иначе + /// блок, который показать не удалось, дёргал бы вёрстку хоста на свою высоту и мигал шиммером + /// на каждый свой проход по экрану, ничего в итоге не показывая. Разворачивает его только + /// показанный контент — или явная перезагрузка, которая и есть согласие на полный цикл заново. + private var hasCollapsed = false + + /// Слой, показанный прямо сейчас. Хранится, а не вычисляется на лету: он один источник и для + /// высоты, и для отчёта обёртке — иначе они могли бы разойтись. И он же отделяет показанный + /// экран ошибки от просто назначенного: `errorView`, отданный после схлопывания, не показан. + private var shownLayer: EmbeddedBlockPresentation.Layer = .placeholder + + /// Публичных исходов два: показан или не показан. «Пусто» для хоста — тот же непоказ, что и + /// ошибка, разница живёт только внутри контейнера (пустой блок не показывает `errorView`). + private enum BlockEvent { + case loaded + case failed + } + + /// The last event handed to the current delegate — keeps the same outcome from being reported + /// twice, for instance when restarted content fails again. + private var deliveredEvent: BlockEvent? + + private var isDeliveryScheduled = false + + // MARK: - Life cycle + + /// - Parameters: + /// - id: The block id from the admin panel. + /// - height: The height the block occupies while loading and shown. + public convenience init(id: String, height: CGFloat) { + self.init(id: id, + height: height, + contentProvider: DI.injectOrFail(EmbeddedBlockContentProviderMaking.self).makeProvider(id: id)) + } + + /// Blocks are not created from storyboards: the id and the height are required and have no + /// sensible defaults. Create the view in code with `init(id:height:)`. + @available(*, unavailable, message: "Use init(id:height:) instead") + public required init?(coder: NSCoder) { + return nil + } + + init(id: String, + height: CGFloat, + contentProvider: EmbeddedBlockWebViewProvider, + readyTimeout: TimeInterval = TimeInterval(Constants.EmbeddedBlock.readyTimeoutSeconds)) { + self.id = id + self.preferredHeight = height + self.contentProvider = contentProvider + self.timeout = EmbeddedBlockReadyTimeout(blockId: id, duration: readyTimeout) + super.init(frame: .zero) + setUpContainer() + } + + deinit { + timeout.pause() + contentProvider.stop() + } + + private func setUpContainer() { + clipsToBounds = true + backgroundColor = .clear + + contentProvider.onStateChange = { [weak self] state in + self?.state = state + } + + timeout.isNeeded = { [weak self] in + guard let self else { return false } + return self.window != nil && self.state == .loading + } + timeout.onExpire = { [weak self] in + self?.handleTimeout() + } + + // The block reserves its space right away, before any loading starts, and reserved space + // must not look blank. + layers.show(view(for: shownLayer)) + } + + // MARK: - Layout + + override public var intrinsicContentSize: CGSize { + CGSize(width: UIView.noIntrinsicMetric, height: contentHeight) + } + + /// Hosts that lay out by frames rather than by constraints get the same height here. + override public func sizeThatFits(_ size: CGSize) -> CGSize { + CGSize(width: size.width, height: contentHeight) + } + + private var contentHeight: CGFloat { + switch shownLayer { + case .placeholder, .content, .errorView: return max(0, preferredHeight) + case .nothing: return 0 + } + } + + // MARK: - Visibility + + /// Visibility drives the content: there is no reason to hold content for a container that is + /// not in a window, and no public way for the host to start or stop it by hand. + override public func didMoveToWindow() { + super.didMoveToWindow() + + if window == nil { + Logger.common(message: "[EmbeddedBlock] Block '\(id)' left the window, stopping content", category: .embeddedBlocks) + // Пауза, а не сброс: вне окна блока никто не ждёт, но начатую попытку это не отменяет — + // вернувшись, она досчитает свой остаток. + timeout.pause() + contentProvider.stop() + } else { + Logger.common(message: "[EmbeddedBlock] Block '\(id)' entered the window, starting content", category: .embeddedBlocks) + contentProvider.start() + timeout.armIfNeeded() + } + } + + /// Перезагружает блок: контент начинает загрузку с нуля, адрес запрашивается заново в обход + /// кэша, блок возвращается в состояние загрузки со своим плейсхолдером и новым таймаутом. + /// + /// Internal и без публичной обёртки: автоматические перезагрузки — по ошибке, по возвращению + /// в приложение — будут строиться на этом методе, а хосту решать, когда обновлять блок, пока + /// незачем. + func reload() { + guard window != nil else { + // Контент живёт только пока блок в окне; перезагружать невидимый блок нечего — он + // сам загрузится заново, когда вернётся в окно. + Logger.common(message: "[EmbeddedBlock] Block '\(id)' reload skipped: the block is not in a window", + category: .embeddedBlocks) + return + } + + Logger.common(message: "[EmbeddedBlock] Block '\(id)' reload requested", category: .embeddedBlocks) + timeout.reset() + // Новая попытка — новый исход: хост должен услышать его целиком, даже если он совпадёт + // с прошлым. + deliveredEvent = nil + // И новая попытка вправе снова занять место: перезагрузка — это явное согласие хоста на + // полный цикл с плейсхолдером, в отличие от молчаливого возвращения блока в окно. + hasCollapsed = false + contentProvider.reload() + timeout.armIfNeeded() + } + + private func handleTimeout() { + // Stop first: the provider must not resurrect content the container already gave up on. + contentProvider.stop() + state = .failed + } + + // MARK: - Layers + + /// Бюджет ожидания живёт по попыткам, а не по состояниям: продолжающаяся загрузка досчитывает + /// свой остаток, а всё остальное — известный исход или начатая заново загрузка — счёт обнуляет. + /// Заводить его снова решает тот, кто знает, ждут ли блок: вход в окно и возврат из фона. + private func updateTimeout(from previous: EmbeddedBlockState) { + guard state != .loading || previous != .loading else { return } + + timeout.reset() + } + + private func apply(_ state: EmbeddedBlockState) { + shownLayer = layer(for: state) + + if shownLayer == .nothing { + hasCollapsed = true + } + + layers.show(view(for: shownLayer)) + + invalidateIntrinsicContentSize() + onPresentationChange?(EmbeddedBlockPresentation(layer: shownLayer, height: contentHeight)) + scheduleDelivery() + } + + private func layer(for state: EmbeddedBlockState) -> EmbeddedBlockPresentation.Layer { + switch state { + // Схлопнутый блок остаётся свёрнутым и пока грузится заново: место, которое хост уже + // забрал, повторная попытка назад не отыгрывает. + case .loading: return hasCollapsed ? .nothing : .placeholder + case .ready: return .content + // Провал показывают только тем, кто согласился на это явно; остальным блок сворачивается. + case .failed: return errorView == nil ? .nothing : .errorView + case .empty: return .nothing + } + } + + private func view(for layer: EmbeddedBlockPresentation.Layer) -> UIView? { + switch layer { + case .placeholder: return placeholderView ?? defaultPlaceholder + case .content: return contentProvider.contentView + case .errorView: return errorView + case .nothing: return nil + } + } + + /// A placeholder swap takes effect immediately, but only while the placeholder is what the + /// container shows — in any other state the new view is simply remembered for the next load. + private func refreshPlaceholder() { + guard shownLayer == .placeholder else { return } + layers.show(view(for: .placeholder)) + } + + /// Swapping a shown error screen takes effect immediately, but a collapsed block is not + /// reopened retroactively: the host layout already reclaimed the space, and expanding it out + /// of nowhere would make the layout jump. The new view is remembered for the next load. In + /// any other state the view is simply remembered too. + private func refreshErrorView() { + guard state == .failed, shownLayer == .errorView else { return } + apply(state) + } + + // MARK: - Host events + + /// Events are handed over on the next main-queue turn: the state can flip in the middle of a + /// layout pass, and re-entering host code from there is a good way to break the host's layout. + private func scheduleDelivery() { + guard !isDeliveryScheduled else { return } + + isDeliveryScheduled = true + DispatchQueue.main.async { [weak self] in + self?.deliverPendingEvent() + } + } + + private func deliverPendingEvent() { + isDeliveryScheduled = false + + guard let delegate = delegate, + let event = event(for: state), + event != deliveredEvent else { + return + } + + deliveredEvent = event + + switch event { + case .loaded: delegate.mindboxEmbeddedBlockViewDidLoad(self) + case .failed: delegate.mindboxEmbeddedBlockViewDidFail(self) + } + } + + private func event(for state: EmbeddedBlockState) -> BlockEvent? { + switch state { + case .loading: return nil + case .ready: return .loaded + case .failed, .empty: return .failed + } + } +} diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockViewDelegate.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockViewDelegate.swift new file mode 100644 index 000000000..1b352c1f9 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockViewDelegate.swift @@ -0,0 +1,38 @@ +// +// MindboxEmbeddedBlockViewDelegate.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// The events a host app can observe on `MindboxEmbeddedBlockView`. +/// +/// The block resolves into one of two outcomes: it is either shown or it is not. Everything the +/// host can react to fits these two calls, so the API has no more of them — intermediate states +/// like "started loading" stay internal. +/// +/// Every method has an empty default implementation, so only the interesting ones have to be +/// written. Calls always arrive on the main thread. Every method hands back the view that fired +/// it — with several blocks on screen, compare it against your own references (or give each block +/// its own delegate) to tell them apart. +public protocol MindboxEmbeddedBlockViewDelegate: AnyObject { + + /// The block content is shown: the container has taken its own height and is visible. + func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) + + /// The block cannot be shown. Covers failures — the load broke, timed out or the content is + /// malformed — and also the empty block, one that has nothing behind its id. On a failure the + /// container collapses to zero height, or keeps its height and shows `errorView` when one is + /// set; an empty block always collapses, `errorView` does not apply to it. + func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView) +} + +public extension MindboxEmbeddedBlockViewDelegate { + + func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) {} + + func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView) {} +} diff --git a/Mindbox/Utilities/Constants.swift b/Mindbox/Utilities/Constants.swift index a33b88335..534f369ed 100644 --- a/Mindbox/Utilities/Constants.swift +++ b/Mindbox/Utilities/Constants.swift @@ -115,6 +115,15 @@ enum Constants { static let timeoutSeconds = 7 } + enum EmbeddedBlock { + /// Сколько встроенный блок ждёт, пока страница объявит себя готовой, прежде чем свернуться. + /// + /// Бюджет свой, а не общий с инаппами, даже при совпадающем значении: блок стоит в вёрстке + /// хоста, и его терпение — самостоятельное продуктовое решение, а не следствие таймаута + /// инаппов. + static let readyTimeoutSeconds = 7 + } + enum MagicNumbers { static let daysToKeepInappShowTimes = 2 } From db6a73d113fb279c1f9a1a8df1c1fc9b1b92be6e Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:48:24 +0500 Subject: [PATCH 22/47] MOBILE-323: Register embedded blocks in DI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Резолвер общий: его кэш на id и очередь ожидающих — это и есть «одна загрузка данных на id» для всех контейнеров сразу. Обработчик действий общий потому, что не имеет состояния. Провайдеры, наоборот, фабрика делает на каждый блок свой — так блоки остаются независимыми друг от друга. Файл прописан в проект вручную: Mindbox/DI/Injections — обычная группа, а не синхронизируемая, в отличие от папок самих блоков. --- Mindbox.xcodeproj/project.pbxproj | 4 +++ .../DI/Injections/InjectEmbeddedBlocks.swift | 32 +++++++++++++++++++ Mindbox/DI/MBInject.swift | 1 + 3 files changed, 37 insertions(+) create mode 100644 Mindbox/DI/Injections/InjectEmbeddedBlocks.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 2170fcb07..9fab9782c 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -265,6 +265,7 @@ 47FDF0BA2C5BDAB80051F08C /* MigrationManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47FDF0B92C5BDAB80051F08C /* MigrationManagerProtocol.swift */; }; 47FDF0BC2C5BE8BB0051F08C /* MigrationProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47FDF0BB2C5BE8BB0051F08C /* MigrationProtocol.swift */; }; 4841683F6839440F84477966 /* OperationNameValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF1D88A18D64D60BD03ABB8 /* OperationNameValidator.swift */; }; + 57A1B3230000000000000007 /* InjectEmbeddedBlocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57A1B3230000000000000107 /* InjectEmbeddedBlocks.swift */; }; 5D3FCB95C2AF59DF36A61254 /* WebViewLocalStateStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFCC82B8014DE7276C217CD /* WebViewLocalStateStorageTests.swift */; }; 6182078DDFC681D168546DAD /* HapticService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1BE43AA9EAEA03F8ED4008 /* HapticService.swift */; }; 6182078DDFC681D168546DAE /* HapticRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1BE43AA9EAEA03F8ED4009 /* HapticRequest.swift */; }; @@ -783,6 +784,7 @@ 7A1E4C0DA3B24F6E90C11A07 /* WebViewNoCacheRetryPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewNoCacheRetryPolicyTests.swift; sourceTree = ""; }; 4D716044131845AEA04A9858 /* WebViewReadyCheckerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewReadyCheckerTests.swift; sourceTree = ""; }; F2DAFC2435264F15B8033CCE /* WebViewReadyChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewReadyChecker.swift; sourceTree = ""; }; + 57A1B3230000000000000107 /* InjectEmbeddedBlocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectEmbeddedBlocks.swift; sourceTree = ""; }; 68184C936B4243C28CC10829 /* SDKUserAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKUserAgent.swift; sourceTree = ""; }; 0475E8755F63483597539A50 /* TrackVisitManagerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrackVisitManagerTests.swift; sourceTree = ""; }; 0A3D04592BC6803E00E1FC52 /* ImageFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFormat.swift; sourceTree = ""; }; @@ -3901,6 +3903,7 @@ F3FEEAAC2C25FD1F000E9D0F /* InjectABTestUtilities.swift */, F33087652C37590600F8DF10 /* InjectInappTools.swift */, F3FEEAAA2C25D874000E9D0F /* InjectReplaceable.swift */, + 57A1B3230000000000000107 /* InjectEmbeddedBlocks.swift */, ); path = Injections; sourceTree = ""; @@ -4381,6 +4384,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 57A1B3230000000000000007 /* InjectEmbeddedBlocks.swift in Sources */, A881A16C702E41AE93848BD3 /* InAppWebViewLearnedHostsStore.swift in Sources */, F389B3A639904206A3DADFFA /* InAppWebViewPrewarmPlanner.swift in Sources */, 8A94D721DAEF4395A360C5B2 /* InAppWebViewPrewarmNavigationPolicy.swift in Sources */, diff --git a/Mindbox/DI/Injections/InjectEmbeddedBlocks.swift b/Mindbox/DI/Injections/InjectEmbeddedBlocks.swift new file mode 100644 index 000000000..2d2a7bdba --- /dev/null +++ b/Mindbox/DI/Injections/InjectEmbeddedBlocks.swift @@ -0,0 +1,32 @@ +// +// InjectEmbeddedBlocks.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +extension MBContainer { + func registerEmbeddedBlocks() -> Self { + // The resolver is shared: its per-id cache and its queue of waiting blocks are what make + // several blocks with the same id resolve through a single load of the data. The action + // router is shared because it is stateless. Providers, in contrast, are made per block by + // the factory, so blocks stay independent of each other. + register(EmbeddedBlockResolving.self) { + EmbeddedBlockResolver() + } + + register(EmbeddedBlockActionHandling.self) { + EmbeddedBlockActionRouter() + } + + register(EmbeddedBlockContentProviderMaking.self) { + EmbeddedBlockContentProviderFactory(resolver: DI.injectOrFail(EmbeddedBlockResolving.self), + actionHandler: DI.injectOrFail(EmbeddedBlockActionHandling.self)) + } + + return self + } +} diff --git a/Mindbox/DI/MBInject.swift b/Mindbox/DI/MBInject.swift index 27c6a7312..c5e0a14ec 100644 --- a/Mindbox/DI/MBInject.swift +++ b/Mindbox/DI/MBInject.swift @@ -51,6 +51,7 @@ enum MBInject { .registerReplaceableUtilities() .registerInappTools() .registerInappPresentation() + .registerEmbeddedBlocks() } public static var buildTestContainer: () -> MBContainer = { From daea6fba244a267c19804a63aebc2b5e077375c4 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:48:24 +0500 Subject: [PATCH 23/47] MOBILE-323: Add tests for the block layer host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хост слоёв приехал без прямого покрытия: контейнер задевает его косвенно, но собственное обещание — ровно одна вью, растянутая по краям, — проверить надо отдельно. Кроме подмены и снятия закреплены две тонкости: повторный показ той же вью констрейнты не пересобирает (контейнер зовёт show на каждую смену состояния), а вью, снятую снаружи, показ обязан вернуть на место. --- .../EmbeddedBlockLayerHostTests.swift | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockLayerHostTests.swift diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockLayerHostTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockLayerHostTests.swift new file mode 100644 index 000000000..2a23a3b31 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockLayerHostTests.swift @@ -0,0 +1,94 @@ +// +// EmbeddedBlockLayerHostTests.swift +// MindboxTests +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import UIKit +@testable import Mindbox + +/// У хоста слоёв одно обещание: в контейнере ровно одна вью и она растянута по его краям. Слои блока +/// взаимоисключающие, поэтому показать новый значит снять прежний. +@Suite("Embedded block layer host", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockLayerHostTests { + + @Test("Shown view fills the container") + func shownViewFillsTheContainer() { + let container = UIView() + let host = EmbeddedBlockLayerHost(container: container) + let layer = UIView() + + host.show(layer) + + #expect(layer.superview === container) + #expect(layer.translatesAutoresizingMaskIntoConstraints == false) + // Четыре края: слой всегда заполняет контейнер, который ему дали. + #expect(container.constraints.count == 4) + } + + @Test("Showing another view replaces the first") + func showingAnotherViewReplacesTheFirst() { + let container = UIView() + let host = EmbeddedBlockLayerHost(container: container) + let first = UIView() + let second = UIView() + + host.show(first) + host.show(second) + + #expect(first.superview == nil) + #expect(second.superview === container) + #expect(container.subviews.count == 1) + #expect(container.constraints.count == 4) + } + + @Test("Showing nothing detaches the current view") + func showingNothingDetachesTheCurrentView() { + let container = UIView() + let host = EmbeddedBlockLayerHost(container: container) + let layer = UIView() + + host.show(layer) + host.show(nil) + + #expect(layer.superview == nil) + #expect(container.subviews.isEmpty) + #expect(container.constraints.isEmpty) + } + + /// Контейнер зовёт `show` на каждую смену состояния, и часть этих вызовов приходит с той же вью. + /// Пересобирать под неё констрейнты незачем — их бы просто становилось больше. + @Test("Showing the same view again changes nothing") + func showingTheSameViewAgainChangesNothing() { + let container = UIView() + let host = EmbeddedBlockLayerHost(container: container) + let layer = UIView() + + host.show(layer) + host.show(layer) + host.show(layer) + + #expect(container.subviews.count == 1) + #expect(container.constraints.count == 4) + } + + /// Снимать надо ту вью, что действительно висит: если её убрали снаружи, повторный показ обязан + /// вернуть её на место, а не решить, что она и так там. + @Test("A view detached from outside is attached again") + func viewDetachedFromOutsideIsAttachedAgain() { + let container = UIView() + let host = EmbeddedBlockLayerHost(container: container) + let layer = UIView() + + host.show(layer) + layer.removeFromSuperview() + host.show(layer) + + #expect(layer.superview === container) + #expect(container.constraints.count == 4) + } +} From 52aa966b52a951353082cd658c2a4bd29762ff23 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:48:24 +0500 Subject: [PATCH 24/47] MOBILE-323: Add tests for the block ready timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сколько бюджета «уже потрачено», тесты задают подменёнными часами, а ждут только тот огрызок, который остался. Иначе проверка «продолжается остаток, а не выдаётся полный бюджет» сводилась бы к измерению задержек секундомером. Главный тест — пять пауз по четверти бюджета не растягивают его сверх срока: это ровно тот сценарий, из-за которого пауза со сбросом непригодна. Уход в фон и возврат здесь не проверяются: это глобальные нотификации, они долетят до блоков из тестов, идущих рядом. Провод от них к паузе проверяет контейнер, у которого блок один. --- .../EmbeddedBlockReadyTimeoutTests.swift | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift new file mode 100644 index 000000000..c51a61b8b --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift @@ -0,0 +1,176 @@ +// +// EmbeddedBlockReadyTimeoutTests.swift +// MindboxTests +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import Foundation +@testable import Mindbox + +/// Сколько бюджета «уже потрачено», тесты задают подменёнными часами, а ждут только тот огрызок, +/// который остался. Иначе проверка «продолжается остаток, а не выдаётся полный бюджет» сводилась бы +/// к измерению задержек секундомером. +/// +/// Уход в фон и возврат из него здесь не проверяются: это глобальные нотификации, они долетят до +/// блоков из тестов, идущих рядом. Провод от них к паузе проверяет контейнер, у которого блок один. +private let budget: TimeInterval = 0.4 + +@Suite("Embedded block ready timeout", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockReadyTimeoutTests { + + @Test("A budget that is never paused expires on its own") + func unpausedBudgetExpires() async throws { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + try await Task.sleep(nanoseconds: 600_000_000) + + #expect(bed.expirations == 1) + } + + /// Пока блока никто не ждёт, бюджет не тратится и не истекает. + @Test("A paused budget does not expire") + func pausedBudgetDoesNotExpire() async throws { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.timeout.pause() + try await Task.sleep(nanoseconds: 600_000_000) + + #expect(bed.expirations == 0) + #expect(bed.timeout.isRunning == false) + } + + /// Главное: пауза останавливает счёт, а не начинает его заново. Потрачено почти всё, поэтому + /// после возобновления блоку остаётся крохотный остаток — а не полный бюджет. + @Test("Resuming continues the remaining budget instead of granting a new one") + func resumeContinuesTheRemainder() async throws { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.clock.advance(budget - 0.02) + bed.timeout.pause() + + bed.timeout.armIfNeeded() + // Ждём меньше полного бюджета: он к этому моменту истечь ещё не успел бы. + try await Task.sleep(nanoseconds: 200_000_000) + + #expect(bed.expirations == 1) + } + + /// Ровно тот сценарий, из-за которого пауза со сбросом непригодна: пользователь, дёргающий + /// приложение туда-обратно, не должен уметь продлевать ожидание блока бесконечно. + @Test("Repeated pause and resume cannot stretch the budget past its duration") + func repeatedPausesCannotStretchTheBudget() async throws { + let bed = TimeoutBed() + + for _ in 0..<5 { + bed.timeout.armIfNeeded() + bed.clock.advance(budget / 4) + bed.timeout.pause() + } + + #expect(bed.expirations == 0) + + // Пять отрезков по четверти — бюджет выбран целиком, и следующий завод не даёт блоку больше + // ни секунды. + bed.timeout.armIfNeeded() + try await Task.sleep(nanoseconds: 200_000_000) + + #expect(bed.expirations == 1) + } + + /// А новая попытка — другое дело: её ждут с полного бюджета. + @Test("Reset gives the next attempt a full budget again") + func resetGrantsAFullBudget() async throws { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.clock.advance(budget - 0.02) + bed.timeout.reset() + + bed.timeout.armIfNeeded() + try await Task.sleep(nanoseconds: 200_000_000) + + // Полного бюджета ещё не прошло — попытка жива. + #expect(bed.expirations == 0) + + try await Task.sleep(nanoseconds: 400_000_000) + + #expect(bed.expirations == 1) + } + + @Test("Reset stops a running budget") + func resetStopsTheCountdown() async throws { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.timeout.reset() + try await Task.sleep(nanoseconds: 600_000_000) + + #expect(bed.expirations == 0) + } + + /// Бюджет нужен только пока исход неизвестен и блок на виду — это знает контейнер, и его ответ + /// спрашивается на каждом заводе. + @Test("A budget nobody needs is not armed at all") + func unneededBudgetIsNotArmed() async throws { + let bed = TimeoutBed(isNeeded: false) + + bed.timeout.armIfNeeded() + + #expect(bed.timeout.isRunning == false) + + try await Task.sleep(nanoseconds: 600_000_000) + + #expect(bed.expirations == 0) + } + + /// Завод идемпотентен: вход в окно, возврат из фона и перезагрузка зовут его как попало, и + /// второй вызов не должен ставить второй отсчёт. + @Test("Arming twice runs a single countdown") + func armingTwiceRunsOneCountdown() async throws { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.timeout.armIfNeeded() + try await Task.sleep(nanoseconds: 600_000_000) + + #expect(bed.expirations == 1) + } +} + +/// Бюджет с управляемыми часами и счётчиком истечений. +@MainActor +private final class TimeoutBed { + + let clock = TestClock() + let timeout: EmbeddedBlockReadyTimeout + + private(set) var expirations = 0 + + init(isNeeded: Bool = true) { + let clock = self.clock + timeout = EmbeddedBlockReadyTimeout(blockId: "block-id", + duration: budget, + now: { clock.now }) + timeout.isNeeded = { isNeeded } + timeout.onExpire = { [weak self] in + self?.expirations += 1 + } + } +} + +/// Часы, которые идут только когда их просят. +private final class TestClock { + + private(set) var now = Date(timeIntervalSince1970: 1_000_000) + + func advance(_ seconds: TimeInterval) { + now = now.addingTimeInterval(seconds) + } +} From e110415e61c364a0b117634e507a65b6df4bd242 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:48:24 +0500 Subject: [PATCH 25/47] MOBILE-323: Add tests for the UIKit embedded block container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Высота, слои, события хосту и жизненный цикл по окну — всё через настоящий провайдер: единственный шов внутри блока это страница, и подменять больше нечего. Закреплены три решения, из-за которых контейнер и выглядит так: Блок, который показать не удалось, на возврате в окно пробует снова, но остаётся свёрнутым и шиммером не мигает; разворачивает его только показанный контент или явная перезагрузка. Пустой блок ведёт себя так же. Тот же делегат, присвоенный повторно, уже услышанный исход не получает — иначе хост, перестраивающий вёрстку в onFail, укатился бы в цикл на скролле. А другой делегат исход обязан услышать, даже если тот случился до подписки. Бюджет ожидания в фоне встаёт и на возврате продолжается: пользователь не должен возвращаться в приложение к блоку, который сдался, ни разу не побывав на экране. Моки дописаны последним нужным дублёром — делегатом со списком услышанных событий. --- .../EmbeddedBlocks/EmbeddedBlockMocks.swift | 18 + .../MindboxEmbeddedBlockViewTests.swift | 698 ++++++++++++++++++ 2 files changed, 716 insertions(+) create mode 100644 MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift index 2a3edae29..edc476c82 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift @@ -177,3 +177,21 @@ final class EmbeddedBlockTestBed { makePage: { pageFactory.make($0) }) } } + +final class EmbeddedBlockViewDelegateMock: MindboxEmbeddedBlockViewDelegate { + + enum Event: Equatable { + case loaded + case failed + } + + private(set) var events: [Event] = [] + + func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) { + events.append(.loaded) + } + + func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView) { + events.append(.failed) + } +} diff --git a/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift new file mode 100644 index 000000000..aaf00a539 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift @@ -0,0 +1,698 @@ +// +// MindboxEmbeddedBlockViewTests.swift +// MindboxTests +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import UIKit +@testable import Mindbox + +@Suite("MindboxEmbeddedBlockView container", .tags(.embeddedBlocks)) +@MainActor +struct MindboxEmbeddedBlockViewTests { + + // MARK: - Height + + /// Место под блок занимается сразу: высоту назначил хост, и до исхода загрузки она не меняется — + /// иначе контейнер прыгал бы в вёрстке хоста. + @Test("Loading block keeps the height given at creation") + func loadingKeepsGivenHeight() { + let block = BlockFixture() + + #expect(block.view.intrinsicContentSize.height == 120) + // Ширина — дело хоста, контейнер её не заявляет. + #expect(block.view.intrinsicContentSize.width == UIView.noIntrinsicMetric) + } + + @Test("Shown block keeps the same height") + func shownBlockKeepsHeight() { + let block = BlockFixture() + block.attachToWindow() + + block.page?.send(.ready(height: 96)) + + #expect(block.view.intrinsicContentSize.height == 120) + } + + /// Контейнер — единственный источник высоты, поэтому хост на фреймах обязан получить то же + /// число через ту точку, которой пользуется он. + @Test("sizeThatFits reports the same height as intrinsicContentSize") + func sizeThatFitsMatchesIntrinsicHeight() { + let block = BlockFixture(height: 96) + + let fitted = block.view.sizeThatFits(CGSize(width: 320, height: CGFloat.greatestFiniteMagnitude)) + + #expect(fitted.height == 96) + #expect(fitted.width == 320) + } + + @Test("Failed block collapses the container") + func failedBlockCollapses() { + let block = BlockFixture() + block.attachToWindow() + + block.page?.failLoad() + + #expect(block.view.intrinsicContentSize.height == 0) + } + + /// Ошибку можно показать вместо схлопывания — тогда блок остаётся той же высоты. + @Test("Failed block with an error view keeps its height") + func failedBlockWithErrorViewKeepsHeight() { + let block = BlockFixture() + let errorView = UIView() + block.view.errorView = errorView + block.attachToWindow() + + block.page?.failLoad() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(errorView.superview === block.view) + } + + /// Хост уже забрал место схлопнутого блока — раскрывать его задним числом значит дёргать + /// вёрстку. Поздний errorView только запоминается. + @Test("Error view assigned after the collapse does not expand the block") + func lateErrorViewDoesNotExpandCollapsedBlock() { + let block = BlockFixture() + block.attachToWindow() + block.page?.failLoad() + + let errorView = UIView() + block.view.errorView = errorView + + #expect(block.view.intrinsicContentSize.height == 0) + #expect(errorView.superview == nil) + } + + /// Запомненный errorView вступает в силу со следующей загрузки: новая попытка, снова провал — + /// и теперь блок показывает ошибку вместо схлопывания. + @Test("Error view assigned after the collapse applies on the next load") + func lateErrorViewAppliesOnNextLoad() { + let block = BlockFixture() + block.attachToWindow() + block.page?.failLoad() + let errorView = UIView() + block.view.errorView = errorView + + block.view.reload() + block.page?.failLoad() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(errorView.superview === block.view) + } + + /// Пустой блок сворачивается всегда: показывать нечего, а ошибки не было. + @Test("Empty block collapses even with an error view set") + func emptyBlockAlwaysCollapses() { + let block = BlockFixture() + block.view.errorView = UIView() + block.attachToWindow() + + block.page?.send(.empty) + + #expect(block.view.intrinsicContentSize.height == 0) + } + + /// Хост, попросивший отрицательную высоту, не должен получить неразрешимый набор констрейнтов. + @Test("Negative height given by the host is clamped to zero") + func negativeHeightIsClamped() { + let block = BlockFixture(height: -50) + + #expect(block.view.intrinsicContentSize.height == 0) + } + + // MARK: - Content view + + @Test("Shown content is attached and pinned to the container") + func shownContentIsPinned() throws { + let block = BlockFixture() + block.attachToWindow() + + block.page?.send(.ready(height: 96)) + + let content = try #require(block.page?.view) + #expect(content.superview === block.view) + #expect(content.translatesAutoresizingMaskIntoConstraints == false) + // Четыре края: контент всегда заполняет контейнер, который ему дали. + #expect(block.view.constraints.count == 4) + } + + @Test("Failed content is detached") + func failedContentIsDetached() throws { + let block = BlockFixture() + block.attachToWindow() + + block.page?.send(.ready(height: 96)) + let content = try #require(block.page?.view) + block.page?.failLoad() + + #expect(content.superview == nil) + #expect(block.view.intrinsicContentSize.height == 0) + } + + @Test("Empty content is detached") + func emptyContentIsDetached() throws { + let block = BlockFixture() + block.attachToWindow() + + block.page?.send(.ready(height: 96)) + let content = try #require(block.page?.view) + block.page?.send(.empty) + + #expect(content.superview == nil) + } + + /// Перезагруженный блок не должен тащить в новую попытку вью выброшенной страницы. + @Test("Reload detaches the content of the dropped page") + func reloadDetachesOldContent() throws { + let block = BlockFixture() + block.attachToWindow() + block.page?.send(.ready(height: 96)) + let oldContent = try #require(block.page?.view) + + block.view.reload() + + #expect(oldContent.superview == nil) + } + + // MARK: - Events + + /// Публичных исходов два — показан и не показан; загрузка не исход, и хост о ней не слышит. + @Test("Loading is silent: the delegate hears only outcomes") + func loadingReportsNothing() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.attachToWindow() + await mainQueueTurn() + + #expect(delegate.events.isEmpty) + } + + /// Блок, который так и не попал в окно, ничего не грузит — и сообщать ему нечего. + @Test("Block outside a window reports nothing and loads nothing") + func blockOutsideWindowDoesNothing() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + await mainQueueTurn() + + #expect(delegate.events.isEmpty) + #expect(block.bed.resolver.resolveCount == 0) + } + + @Test("Shown block reports didLoad") + func shownBlockReportsDidLoad() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + + #expect(delegate.events == [.loaded]) + } + + @Test("Failed block reports didFail") + func failedBlockReportsDidFail() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + + block.page?.failLoad() + await mainQueueTurn() + + #expect(delegate.events == [.failed]) + } + + /// «Пусто» для хоста — тот же непоказ, что и провал: отдельного события у него нет. + @Test("Empty block reports didFail") + func emptyBlockReportsDidFail() async { + let block = BlockFixture(resolution: .empty) + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.attachToWindow() + await mainQueueTurn() + + #expect(delegate.events == [.failed]) + } + + /// Хост, назначающий делегата в `viewDidLoad`, иначе пропустил бы уже случившийся исход. + @Test("Delegate assigned after the outcome still receives it") + func lateDelegateStillReceivesOutcome() async { + let block = BlockFixture() + block.attachToWindow() + block.page?.failLoad() + await mainQueueTurn() + + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + await mainQueueTurn() + + #expect(delegate.events == [.failed]) + } + + /// Хост штатно переприсваивает делегата на каждой переиспользованной ячейке. Отдавать ему на это + /// уже услышанный исход нельзя: на исход он перестраивает вёрстку, а перестройка вёрстки снова + /// переприсваивает делегата — и блок укатился бы в цикл на скролле. + @Test("Reassigning the same delegate does not repeat the outcome") + func sameDelegateReassignedHearsTheOutcomeOnce() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + block.view.delegate = delegate + await mainQueueTurn() + block.view.delegate = delegate + await mainQueueTurn() + + #expect(delegate.events == [.failed]) + } + + /// А другой делегат — это другой подписчик, и уже случившийся исход он обязан услышать. + @Test("A delegate replacing another one still receives the outcome") + func replacingDelegateReceivesTheOutcome() async { + let block = BlockFixture() + let first = EmbeddedBlockViewDelegateMock() + block.view.delegate = first + block.attachToWindow() + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + let second = EmbeddedBlockViewDelegateMock() + block.view.delegate = second + await mainQueueTurn() + + #expect(first.events == [.failed]) + #expect(second.events == [.failed]) + } + + /// Контент может упасть снова на возвращении в окно — это не повод превращать исход в поток + /// одинаковых событий. + @Test("Repeated failure is reported once") + func repeatedFailureIsReportedOnce() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + + block.page?.failLoad() + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + #expect(delegate.events == [.failed]) + } + + @Test("Block that fails after being shown reports both outcomes in order") + func failureAfterLoadReportsBothOutcomes() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + #expect(delegate.events == [.loaded, .failed]) + } + + // MARK: - Presentation for the SwiftUI wrapper + + /// SwiftUI не читает `intrinsicContentSize` у представимой вью и рисует слои хоста сам, поэтому + /// обёртке нужны и высота, и слой — и то, что она получает, обязано совпадать с тем, что + /// контейнер действительно показывает. + @Test("Every change is pushed to the SwiftUI wrapper as a layer and a height") + func presentationChangesArePushedToWrapper() { + let block = BlockFixture() + block.attachToWindow() + var reported: [EmbeddedBlockPresentation] = [] + block.view.onPresentationChange = { reported.append($0) } + + block.page?.send(.ready(height: 96)) + block.page?.send(.empty) + + #expect(reported == [EmbeddedBlockPresentation(layer: .content, height: 120), + EmbeddedBlockPresentation(layer: .nothing, height: 0)]) + } + + /// Провал без экрана ошибки для обёртки — тот же схлопнутый блок, что и пустой: рисовать нечего. + @Test("Failed block without an error view reports nothing to show") + func failedBlockReportsNothingToShow() { + let block = BlockFixture() + block.attachToWindow() + var reported: [EmbeddedBlockPresentation] = [] + block.view.onPresentationChange = { reported.append($0) } + + block.page?.failLoad() + + #expect(reported == [EmbeddedBlockPresentation(layer: .nothing, height: 0)]) + } + + /// Согласие на экран ошибки контейнер видит по назначенному `errorView` — и только тогда просит + /// обёртку нарисовать её слой. + @Test("Failed block with an error view reports the error layer") + func failedBlockWithErrorViewReportsErrorLayer() { + let block = BlockFixture() + block.view.errorView = UIView() + block.attachToWindow() + var reported: [EmbeddedBlockPresentation] = [] + block.view.onPresentationChange = { reported.append($0) } + + block.page?.failLoad() + + #expect(reported == [EmbeddedBlockPresentation(layer: .errorView, height: 120)]) + } + + /// Перезагрузка возвращает блок в загрузку — обёртка обязана снова показать плейсхолдер. + @Test("Reload reports the placeholder layer again") + func reloadReportsPlaceholderLayer() { + let block = BlockFixture() + block.attachToWindow() + block.page?.send(.ready(height: 96)) + var reported: [EmbeddedBlockPresentation] = [] + block.view.onPresentationChange = { reported.append($0) } + + block.view.reload() + + #expect(reported == [EmbeddedBlockPresentation(layer: .placeholder, height: 120)]) + } + + // MARK: - Lifecycle + + /// Хост никогда не запускает и не останавливает контент руками: единственный триггер — окно. + @Test("Entering and leaving a window starts and stops the content") + func windowMembershipDrivesTheContent() { + let block = BlockFixture() + + #expect(block.bed.resolver.resolveCount == 0) + + block.attachToWindow() + #expect(block.page?.loadCount == 1) + #expect(block.page?.cancelCount == 0) + + block.removeFromWindow() + #expect(block.page?.cancelCount == 1) + } + + /// Блок ездит по экрану в ленте и переживает переключение табов: каждый такой проход не должен + /// стоить перезагрузки, мигания шиммером и повторных событий хосту. + @Test("Block returning to the window keeps its content as it was") + func returningBlockKeepsItsContent() async throws { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + let content = try #require(block.page?.view) + + block.removeFromWindow() + block.attachToWindow() + await mainQueueTurn() + + #expect(block.page?.loadCount == 1) + #expect(content.superview === block.view) + #expect(block.view.subviews.contains { $0 is EmbeddedBlockShimmerView } == false) + #expect(block.view.intrinsicContentSize.height == 120) + #expect(delegate.events == [.loaded]) + } + + /// Блок, который показать не удалось, на возвращении в окно пробует снова — но место, которое + /// хост у него уже забрал, попытка назад не отыгрывает. Иначе схлопнутый блок дёргал бы вёрстку + /// на свою высоту и мигал шиммером на каждый свой проход по экрану, ничего в итоге не показывая. + @Test("Collapsed block stays collapsed while it tries again") + func collapsedBlockDoesNotReExpandWhileRetrying() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + block.removeFromWindow() + block.attachToWindow() + await mainQueueTurn() + + // Попытка действительно новая — страница грузится заново... + #expect(block.page?.loadCount == 2) + // ...но контейнер под неё места не занимает и шиммером не мигает. + #expect(block.view.intrinsicContentSize.height == 0) + #expect(block.view.subviews.isEmpty) + #expect(delegate.events == [.failed]) + } + + /// Разворачивает блок только показанный контент — и тогда высота возвращается, а хост слышит, + /// что блок наконец появился. + @Test("Retry that succeeds gives the block its height back") + func successfulRetryExpandsTheBlock() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + block.removeFromWindow() + block.attachToWindow() + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(delegate.events == [.failed, .loaded]) + } + + /// Перезагрузка — явное согласие хоста на полный цикл заново, поэтому она место занимает: блок + /// снова показывает плейсхолдер, даже если до неё был схлопнут. + @Test("Reload after a collapse shows the placeholder again") + func reloadAfterCollapseShowsThePlaceholder() { + let block = BlockFixture() + block.attachToWindow() + block.page?.failLoad() + + block.view.reload() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(block.view.subviews.contains { $0 is EmbeddedBlockShimmerView }) + } + + /// Пустой блок сворачивается так же — и так же не отыгрывает место назад. + @Test("Empty block stays collapsed when it returns to the window") + func emptyBlockStaysCollapsedOnReturn() async { + let block = BlockFixture(resolution: .empty) + block.attachToWindow() + await mainQueueTurn() + + block.removeFromWindow() + block.attachToWindow() + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 0) + #expect(block.view.subviews.isEmpty) + } + + // MARK: - Timeout + + /// Контейнер, а не контент, гарантирует, что вёрстка хоста не будет ждать вечно: молчащая + /// страница за бюджетом сворачивается и сообщает об ошибке. + @Test("Silent block times out, collapses and reports didFail") + func silentBlockTimesOut() async throws { + let block = BlockFixture(readyTimeout: 0.05) + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.attachToWindow() + try await Task.sleep(nanoseconds: 200_000_000) + + #expect(block.view.intrinsicContentSize.height == 0) + #expect(delegate.events == [.failed]) + // Контент остановлен, поэтому оживить просроченный блок он уже не может. + #expect(block.page?.cancelCount == 1) + } + + @Test("Block shown in time is not failed by the timeout") + func shownBlockIsNotTimedOut() async throws { + let block = BlockFixture(readyTimeout: 0.05) + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.attachToWindow() + block.page?.send(.ready(height: 96)) + try await Task.sleep(nanoseconds: 200_000_000) + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(delegate.events == [.loaded]) + #expect(block.page?.cancelCount == 0) + } + + /// Уход из окна уже остановил контент — снятый таймаут не должен валить то, что не работает. + @Test("Leaving the window disarms the timeout") + func leavingWindowDisarmsTimeout() async throws { + let block = BlockFixture(readyTimeout: 0.05) + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.attachToWindow() + block.removeFromWindow() + try await Task.sleep(nanoseconds: 200_000_000) + + #expect(delegate.events.isEmpty) + #expect(block.view.intrinsicContentSize.height == 120) + } + + /// Бюджет на загрузку считает время ожидания пользователя, а не календарное: в фоне блока никто + /// не ждёт, и схлопывать его там незачем — иначе пользователь вернётся к блоку, который сдался, + /// ни разу не побывав на экране. Что бюджет при этом продолжается с остатка, а не выдаётся + /// заново, проверяют тесты самого `EmbeddedBlockReadyTimeout`. + @Test("Timeout pauses in the background and resumes on return") + func timeoutPausesInBackground() async throws { + let block = BlockFixture(readyTimeout: 0.05) + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + + NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + try await Task.sleep(nanoseconds: 200_000_000) + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(delegate.events.isEmpty) + + NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + try await Task.sleep(nanoseconds: 200_000_000) + + #expect(block.view.intrinsicContentSize.height == 0) + #expect(delegate.events == [.failed]) + } + + /// Блок вне окна ничего не грузит, поэтому и бюджет ему не нужен. + @Test("Returning from the background does not arm a timeout outside a window") + func foregroundOutsideWindowArmsNothing() async throws { + let block = BlockFixture(readyTimeout: 0.05) + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) + try await Task.sleep(nanoseconds: 200_000_000) + + #expect(delegate.events.isEmpty) + #expect(block.view.intrinsicContentSize.height == 120) + } + + // MARK: - Reload + + /// Перезагрузка идёт тем же путём, что и первый запуск: блок возвращается в загрузку, а хост + /// слышит новый исход целиком, даже если он совпал с прошлым. + @Test("Reload restarts the block and reports the outcome again") + func reloadRestartsTheBlock() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + + block.view.reload() + await mainQueueTurn() + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + + #expect(block.bed.resolver.forceRefreshHistory == [false, true]) + #expect(delegate.events == [.loaded, .loaded]) + #expect(block.view.intrinsicContentSize.height == 120) + } + + /// Контент живёт только пока блок в окне: перезагружать невидимый блок нечего. + @Test("Reload outside a window does nothing") + func reloadOutsideWindowDoesNothing() { + let block = BlockFixture() + + block.view.reload() + + #expect(block.bed.resolver.resolveCount == 0) + #expect(block.bed.pageFactory.pages.isEmpty) + } + + /// Новая попытка получает и новый бюджет — иначе перезагруженный блок висел бы в загрузке вечно. + @Test("Reload arms the timeout again") + func reloadArmsTheTimeoutAgain() async throws { + let block = BlockFixture(readyTimeout: 0.05) + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + block.page?.send(.ready(height: 96)) + + block.view.reload() + try await Task.sleep(nanoseconds: 200_000_000) + + #expect(block.view.intrinsicContentSize.height == 0) + #expect(delegate.events.last == .failed) + } + + // MARK: - Helpers + + /// Исходы отдаются на следующем витке главной очереди, поэтому блок, поставленный в очередь + /// после них, продолжится только когда они отработают — очередь последовательная и FIFO. + private func mainQueueTurn() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } + } +} + +/// Блок со всеми подменёнными зависимостями и живым окном: окно обязано жить не меньше теста, +/// иначе вью вылетит из окна на середине проверки и контент остановится сам собой. +@MainActor +private final class BlockFixture { + + let bed: EmbeddedBlockTestBed + let view: MindboxEmbeddedBlockView + + private let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + + var page: EmbeddedBlockPageMock? { bed.page } + + init(height: CGFloat = 120, + readyTimeout: TimeInterval = 5, + resolution: EmbeddedBlockResolution = .content(.stub)) { + let bed = EmbeddedBlockTestBed(resolution: resolution) + self.bed = bed + self.view = MindboxEmbeddedBlockView(id: "block-id", + height: height, + contentProvider: bed.provider, + readyTimeout: readyTimeout) + } + + func attachToWindow() { + window.addSubview(view) + } + + func removeFromWindow() { + view.removeFromSuperview() + } +} From de08d91697657caba0decbcd78e4ff550741329a Mon Sep 17 00:00:00 2001 From: Akylbek Utekeshev Date: Tue, 11 Aug 2026 12:39:32 +0500 Subject: [PATCH 26/47] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift index 5c0ea7fcc..da5297a11 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift @@ -85,7 +85,11 @@ final class EmbeddedBlockShimmerView: UIView { object: nil) } - private func applyColors() { + deinit { + NotificationCenter.default.removeObserver(self) + } + + private func applyColors() {}}]}คิดเห็น to=multi_tool_use.parallel output format? tool returns nothing? let's check. gradientLayer.colors = [ baseColor.cgColor, highlightColor.cgColor, From 250447cf153537e13d187b816b8a05321a0b6ecf Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 11 Aug 2026 12:59:10 +0500 Subject: [PATCH 27/47] MOBILE-323 Less comments --- Mindbox.xcodeproj/project.pbxproj | 150 +++++++------- .../Actions/EmbeddedBlockActionRouter.swift | 83 +------- .../Resolver/EmbeddedBlockWebContent.swift | 11 - .../EmbeddedBlockActionRouterTests.swift | 194 ------------------ 4 files changed, 81 insertions(+), 357 deletions(-) delete mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockActionRouterTests.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 2170fcb07..ef4f579fb 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -7,30 +7,18 @@ objects = { /* Begin PBXBuildFile section */ - A881A16C702E41AE93848BD3 /* InAppWebViewLearnedHostsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */; }; - F389B3A639904206A3DADFFA /* InAppWebViewPrewarmPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */; }; - 8A94D721DAEF4395A360C5B2 /* InAppWebViewPrewarmNavigationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */; }; - 23335221BB1D4F8C8D5C864D /* InAppWebViewPrewarmService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */; }; - 23E4A509BFE94104BD01B61A /* MindboxWebBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC5ED4F76B046798978B4B9 /* MindboxWebBridgeTests.swift */; }; - AE190755CA97473B83E1568D /* MBContainerConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4F5CEE405E4BE29F97E43B /* MBContainerConcurrencyTests.swift */; }; - 3FDBA6E4D39A49369A335CF4 /* SDKUserAgentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E45E06561604E6185C33366 /* SDKUserAgentTests.swift */; }; - 16311BEB069E4B3983FAF594 /* InAppWebViewCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895F8C05EA8B4716B2A2941A /* InAppWebViewCacheTests.swift */; }; - 28375259024D42658E146900 /* InAppWebViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1627DAC8D54E448C6D7BF0 /* InAppWebViewFactory.swift */; }; - B58D5207F49F4531AA1C516B /* InAppWebViewHTMLFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85CCBEB1D874078954BB771 /* InAppWebViewHTMLFetcher.swift */; }; - 2DBF19F24FEA48AAA39C33E6 /* InAppWebViewDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D096F433EBB44C8B3490CF6 /* InAppWebViewDataStore.swift */; }; - 7A1E4C0DA3B24F6E90C11A02 /* InAppWebViewHTTPError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A01 /* InAppWebViewHTTPError.swift */; }; - 7A1E4C0DA3B24F6E90C11A04 /* WebViewNoCacheRetryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A03 /* WebViewNoCacheRetryPolicy.swift */; }; - B9025268DDC24820B95ADE10 /* WebViewTimeoutErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E54FE3B51649DCBD5ABE33 /* WebViewTimeoutErrorTests.swift */; }; - 7A1E4C0DA3B24F6E90C11A06 /* InAppWebViewHTTPErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A05 /* InAppWebViewHTTPErrorTests.swift */; }; - 7A1E4C0DA3B24F6E90C11A08 /* WebViewNoCacheRetryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A07 /* WebViewNoCacheRetryPolicyTests.swift */; }; - CB91323FAD66404B9422B6E0 /* WebViewReadyCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D716044131845AEA04A9858 /* WebViewReadyCheckerTests.swift */; }; - C830BF4C287849CE95BB4ED9 /* WebViewReadyChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2DAFC2435264F15B8033CCE /* WebViewReadyChecker.swift */; }; - 46E95250B5904CC18E079C54 /* SDKUserAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68184C936B4243C28CC10829 /* SDKUserAgent.swift */; }; 0A3D045A2BC6803E00E1FC52 /* ImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3D04592BC6803E00E1FC52 /* ImageFormat.swift */; }; 0E7A224A082FA2DA35706CC7 /* MotionServiceResolvePositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36B /* MotionServiceResolvePositionTests.swift */; }; 0E7A224A082FA2DA35706CC8 /* MotionServiceShakeToEditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36C /* MotionServiceShakeToEditTests.swift */; }; + 0FB55CC82EEA4F7CA568A02E /* MD5Hash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */; }; + 16311BEB069E4B3983FAF594 /* InAppWebViewCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895F8C05EA8B4716B2A2941A /* InAppWebViewCacheTests.swift */; }; 1E28BAF922E64B9CB6E22A0B /* DateFormatMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1A11C4A4B940898BA80035 /* DateFormatMigrationTests.swift */; }; + 1E316CC518D9111F0BD25590 /* InAppMessagesTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60DC2E1435521EBF0E2C5E4 /* InAppMessagesTrackerTests.swift */; }; 1E3BD63AB3F1521C253CB818 /* MBNetworkFetcherResponseHandlingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */; }; + 23335221BB1D4F8C8D5C864D /* InAppWebViewPrewarmService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */; }; + 23E4A509BFE94104BD01B61A /* MindboxWebBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC5ED4F76B046798978B4B9 /* MindboxWebBridgeTests.swift */; }; + 28375259024D42658E146900 /* InAppWebViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1627DAC8D54E448C6D7BF0 /* InAppWebViewFactory.swift */; }; + 2DBF19F24FEA48AAA39C33E6 /* InAppWebViewDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D096F433EBB44C8B3490CF6 /* InAppWebViewDataStore.swift */; }; 302E35788CBDA959283569F4 /* MotionServiceBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DB93A7997961CA7C2BE917 /* MotionServiceBehaviorTests.swift */; }; 313B233A25ADEA0F00A1CB72 /* Mindbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 313B233025ADEA0F00A1CB72 /* Mindbox.framework */; }; 313B233F25ADEA0F00A1CB72 /* MindboxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */; }; @@ -119,7 +107,8 @@ 33E42E5C268323E60046CBCB /* CashdeskRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33E42E5B268323E60046CBCB /* CashdeskRequest.swift */; }; 33EBF0B0264E6283002A35D5 /* MBSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33EBF0AF264E6283002A35D5 /* MBSessionManager.swift */; }; 3FB93AC126964AD3A061B4A7 /* FeatureToggleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3456BAC56F984378B6CED7CB /* FeatureToggleManager.swift */; }; - B202CDC049DDB7A2F9833ADF /* InAppTagsGating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8D4000B12B31961251F6C7 /* InAppTagsGating.swift */; }; + 3FDBA6E4D39A49369A335CF4 /* SDKUserAgentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E45E06561604E6185C33366 /* SDKUserAgentTests.swift */; }; + 46E95250B5904CC18E079C54 /* SDKUserAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68184C936B4243C28CC10829 /* SDKUserAgent.swift */; }; 472179A72C80755A00C15E7F /* ShownInAppsIDsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472179A62C80755A00C15E7F /* ShownInAppsIDsMigration.swift */; }; 472765522E0D52A500A5A060 /* RemoveBackgroundTaskDataMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472765512E0D52A500A5A060 /* RemoveBackgroundTaskDataMigration.swift */; }; 472F549E2C6E272A0008C465 /* MBPushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472F549D2C6E272A0008C465 /* MBPushNotification.swift */; }; @@ -179,8 +168,6 @@ 4731A81A2F447C3100CBE1E5 /* ConfigMonitoringError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A79D2F447C3100CBE1E5 /* ConfigMonitoringError.json */; }; 4731A81B2F447C3100CBE1E5 /* ConfigSettingsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7A02F447C3100CBE1E5 /* ConfigSettingsTypeError.json */; }; 4731A81C2F447C3100CBE1E5 /* MonitoringConfig.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7BA2F447C3100CBE1E5 /* MonitoringConfig.json */; }; - AA996F8AEEB14DB8BF0BE326 /* MonitoringLogsOldDeviceUuidFormat.json in Resources */ = {isa = PBXBuildFile; fileRef = EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */; }; - 5D4C060B41804FC7B3F791E9 /* MonitoringLogsBothFieldsFormat.json in Resources */ = {isa = PBXBuildFile; fileRef = 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */; }; 4731A81D2F447C3100CBE1E5 /* InAppFrequencyError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7AA2F447C3100CBE1E5 /* InAppFrequencyError.json */; }; 4731A81E2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7BB2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json */; }; 4731A81F2F447C3100CBE1E5 /* SettingsInAppSettingsAllValid.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7E82F447C3100CBE1E5 /* SettingsInAppSettingsAllValid.json */; }; @@ -266,6 +253,7 @@ 47FDF0BC2C5BE8BB0051F08C /* MigrationProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47FDF0BB2C5BE8BB0051F08C /* MigrationProtocol.swift */; }; 4841683F6839440F84477966 /* OperationNameValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF1D88A18D64D60BD03ABB8 /* OperationNameValidator.swift */; }; 5D3FCB95C2AF59DF36A61254 /* WebViewLocalStateStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFCC82B8014DE7276C217CD /* WebViewLocalStateStorageTests.swift */; }; + 5D4C060B41804FC7B3F791E9 /* MonitoringLogsBothFieldsFormat.json in Resources */ = {isa = PBXBuildFile; fileRef = 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */; }; 6182078DDFC681D168546DAD /* HapticService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1BE43AA9EAEA03F8ED4008 /* HapticService.swift */; }; 6182078DDFC681D168546DAE /* HapticRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1BE43AA9EAEA03F8ED4009 /* HapticRequest.swift */; }; 6182078DDFC681D168546DAF /* HapticRequestParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1BE43AA9EAEA03F8ED400A /* HapticRequestParser.swift */; }; @@ -296,6 +284,12 @@ 6FDD1463266F7CED00A50C35 /* ProductElementReponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDD1462266F7CED00A50C35 /* ProductElementReponse.swift */; }; 6FDD1465266F7CFE00A50C35 /* ItemProductResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDD1464266F7CFE00A50C35 /* ItemProductResponse.swift */; }; 7764BA10E25046CEA7035ED8 /* DateFormatMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8755088A5E704C65A1C3F6DB /* DateFormatMigration.swift */; }; + 7A1E4C0DA3B24F6E90C11A02 /* InAppWebViewHTTPError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A01 /* InAppWebViewHTTPError.swift */; }; + 7A1E4C0DA3B24F6E90C11A04 /* WebViewNoCacheRetryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A03 /* WebViewNoCacheRetryPolicy.swift */; }; + 7A1E4C0DA3B24F6E90C11A06 /* InAppWebViewHTTPErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A05 /* InAppWebViewHTTPErrorTests.swift */; }; + 7A1E4C0DA3B24F6E90C11A08 /* WebViewNoCacheRetryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A07 /* WebViewNoCacheRetryPolicyTests.swift */; }; + 7A3F1B2C9D4E5F60718293A5 /* TransparentViewJSBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3F1B2C9D4E5F60718293A4 /* TransparentViewJSBridgeTests.swift */; }; + 7A3F1B2C9D4E5F60718293A7 /* Tags-FailedTargeting.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A3F1B2C9D4E5F60718293A6 /* Tags-FailedTargeting.json */; }; 813FE960DC0543F681C94275 /* SettingsRequestParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30FC619AC23245CC8CD45E63 /* SettingsRequestParser.swift */; }; 840042A12614CE0000CA17C5 /* ClickNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840042A02614CE0000CA17C5 /* ClickNotificationManager.swift */; }; 840C387225CC1AF200D50183 /* CDEvent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840C387025CC1AF200D50183 /* CDEvent+CoreDataClass.swift */; }; @@ -353,6 +347,8 @@ 84FCD3B525CA0FD300D1E574 /* MockPersistenceStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FCD3B425CA0FD300D1E574 /* MockPersistenceStorage.swift */; }; 84FCD3B925CA109E00D1E574 /* MockNetworkFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FCD3B825CA109E00D1E574 /* MockNetworkFetcher.swift */; }; 84FCD3BD25CA10F600D1E574 /* SuccessResponse.json in Resources */ = {isa = PBXBuildFile; fileRef = 84FCD3BC25CA10F600D1E574 /* SuccessResponse.json */; }; + 8A94D721DAEF4395A360C5B2 /* InAppWebViewPrewarmNavigationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */; }; + 8BED120CA34F48F2969E6AC8 /* MD5HashTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A183433F63D47F684846B0F /* MD5HashTests.swift */; }; 9B24FAAC28C74B8300F10B5D /* InAppConfigurationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B24FAAB28C74B8300F10B5D /* InAppConfigurationRepository.swift */; }; 9B24FAAE28C74BA500F10B5D /* InAppCoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B24FAAD28C74BA500F10B5D /* InAppCoreManager.swift */; }; 9B24FAB128C74BD200F10B5D /* InAppConfigurationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B24FAB028C74BD200F10B5D /* InAppConfigurationManager.swift */; }; @@ -365,6 +361,7 @@ 9B4F9DF928D088A9002C9CF0 /* InAppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B4F9DF628D088A9002C9CF0 /* InAppConfig.swift */; }; 9B4F9E0528D0945F002C9CF0 /* InAppCoreManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B4F9E0428D0945F002C9CF0 /* InAppCoreManagerMock.swift */; }; 9B52570728D1AF880029B1BC /* InAppPresentationManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B52570628D1AF880029B1BC /* InAppPresentationManagerMock.swift */; }; + 9B8670F8E39535C1264CC855 /* OperationResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */; }; 9B9C95312921116F00BB29DA /* UUIDDebugService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9C952F2921116F00BB29DA /* UUIDDebugService.swift */; }; 9B9C95322921116F00BB29DA /* PasteboardUUIDDebugService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9C95302921116F00BB29DA /* PasteboardUUIDDebugService.swift */; }; 9B9C9538292111A700BB29DA /* MockUUIDDebugService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9C9534292111A700BB29DA /* MockUUIDDebugService.swift */; }; @@ -385,12 +382,10 @@ A153E03F29BB002A003C34D4 /* SessionTemporaryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A153E03E29BB002A003C34D4 /* SessionTemporaryStorage.swift */; }; A153E04129BB0A8B003C34D4 /* InAppConfigurationWithOperations.json in Resources */ = {isa = PBXBuildFile; fileRef = A153E04029BB0A8B003C34D4 /* InAppConfigurationWithOperations.json */; }; A154E32E299E0D8900F8F074 /* SDKLogManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E32D299E0D8900F8F074 /* SDKLogManagerTests.swift */; }; - 8BED120CA34F48F2969E6AC8 /* MD5HashTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A183433F63D47F684846B0F /* MD5HashTests.swift */; }; A154E330299E0F1600F8F074 /* InAppGeoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E32F299E0F1600F8F074 /* InAppGeoResponse.swift */; }; A154E334299E110E00F8F074 /* EventRepositoryMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E333299E110E00F8F074 /* EventRepositoryMock.swift */; }; A15D701629AF810E007131E7 /* SDKLogsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E381299E5B7500F8F074 /* SDKLogsRequest.swift */; }; A15D701A29AF8142007131E7 /* SDKLogsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E382299E5B7500F8F074 /* SDKLogsManager.swift */; }; - 0FB55CC82EEA4F7CA568A02E /* MD5Hash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */; }; A15D704729AF81DC007131E7 /* MBLoggerCoreDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E34F299E5B6D00F8F074 /* MBLoggerCoreDataManager.swift */; }; A170EDDC29B0883800CE547F /* MindboxLogger.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A17853BE29AF7E940072578F /* MindboxLogger.framework */; }; A170EDE229B08A2700CE547F /* MindboxLogger.h in Headers */ = {isa = PBXBuildFile; fileRef = A170EDE129B08A2700CE547F /* MindboxLogger.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -457,8 +452,12 @@ A1D017F22976CC9400CD9F99 /* SegmentTargeting.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D017F12976CC9400CD9F99 /* SegmentTargeting.swift */; }; A1D017F52976FC2B00CD9F99 /* InternalTargetingChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D017F42976FC2B00CD9F99 /* InternalTargetingChecker.swift */; }; A1D23AF029DE082E00A75179 /* InAppProductSegmentResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D23AEF29DE082E00A75179 /* InAppProductSegmentResponse.swift */; }; + A881A16C702E41AE93848BD3 /* InAppWebViewLearnedHostsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */; }; + AA996F8AEEB14DB8BF0BE326 /* MonitoringLogsOldDeviceUuidFormat.json in Resources */ = {isa = PBXBuildFile; fileRef = EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */; }; + AB0E83C770AAB9A2DC8E9BF5 /* JSONValueTagsMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E67F6257F15F143DEA87DE /* JSONValueTagsMergeTests.swift */; }; + AE190755CA97473B83E1568D /* MBContainerConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4F5CEE405E4BE29F97E43B /* MBContainerConcurrencyTests.swift */; }; AF174B2121221D323FB95EF0 /* MBEventRepositorySendRawTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */; }; - 9B8670F8E39535C1264CC855 /* OperationResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */; }; + B202CDC049DDB7A2F9833ADF /* InAppTagsGating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8D4000B12B31961251F6C7 /* InAppTagsGating.swift */; }; B36D57852696E59400FEDFD6 /* RetailOrderStatisticsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36D57842696E59400FEDFD6 /* RetailOrderStatisticsResponse.swift */; }; B3A6254C2689F83100B6A3B7 /* PersonalOffersResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A6254B2689F83100B6A3B7 /* PersonalOffersResponse.swift */; }; B3A625502689F8B600B6A3B7 /* BenefitResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A6254F2689F8B600B6A3B7 /* BenefitResponse.swift */; }; @@ -475,11 +474,15 @@ B4E438702D8AFA5700603F3A /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E4386E2D8AFA5700603F3A /* WebViewController.swift */; }; B4E438722D8AFA6700603F3A /* WebViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E438712D8AFA6700603F3A /* WebViewFactory.swift */; }; B4E438742D8AFAA700603F3A /* WebviewPresentationStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E438732D8AFAA700603F3A /* WebviewPresentationStrategy.swift */; }; + B58D5207F49F4531AA1C516B /* InAppWebViewHTMLFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85CCBEB1D874078954BB771 /* InAppWebViewHTMLFetcher.swift */; }; B7705CA22E0A1F0000C0FFEE /* AppGroupUnavailableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7705CA12E0A1F0000C0FFEE /* AppGroupUnavailableTests.swift */; }; + B9025268DDC24820B95ADE10 /* WebViewTimeoutErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E54FE3B51649DCBD5ABE33 /* WebViewTimeoutErrorTests.swift */; }; BB4D7CC72BDEC51D008E3AB8 /* Notification+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB4D7CC62BDEC51D008E3AB8 /* Notification+Extensions.swift */; }; BB6563102BE3BA430090C473 /* UIApplication+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB65630F2BE3BA430090C473 /* UIApplication+Extensions.swift */; }; BBAAC17C2BB2FC9100E1E25E /* MockEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBAAC17B2BB2FC9100E1E25E /* MockEvent.swift */; }; C0360B4CF720E2CBDC488E82 /* TrackVisitManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475E8755F63483597539A50 /* TrackVisitManagerTests.swift */; }; + C830BF4C287849CE95BB4ED9 /* WebViewReadyChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2DAFC2435264F15B8033CCE /* WebViewReadyChecker.swift */; }; + CB91323FAD66404B9422B6E0 /* WebViewReadyCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D716044131845AEA04A9858 /* WebViewReadyCheckerTests.swift */; }; D216DE512C0716B70020F58A /* StringExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D216DE502C0716B70020F58A /* StringExtensionsTests.swift */; }; D216DE532C0716B80020F58A /* TimeIntervalTimeSpanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D216DE522C0716B80020F58A /* TimeIntervalTimeSpanTests.swift */; }; D2F7E2432BADB89900B24BB8 /* UserVisitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F7E2412BADB89900B24BB8 /* UserVisitManager.swift */; }; @@ -580,17 +583,16 @@ F34A103D2F455B840065392A /* FeatureTogglesConfigParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34A103A2F455B840065392A /* FeatureTogglesConfigParsingTests.swift */; }; F34A10442F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10402F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json */; }; F34A10452F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A103F2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json */; }; - F34A104B2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10492F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json */; }; - F34A104C2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A104A2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json */; }; F34A10462F455C5B0065392A /* SettingsFeatureTogglesError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A103E2F455C5B0065392A /* SettingsFeatureTogglesError.json */; }; F34A10472F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10412F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json */; }; F34A10482F455C5B0065392A /* SettingsFeatureTogglesTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10422F455C5B0065392A /* SettingsFeatureTogglesTypeError.json */; }; + F34A104B2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10492F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json */; }; + F34A104C2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A104A2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json */; }; 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 */; }; F351F1C22CE5F23A0053423E /* InappMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F351F1C12CE5F23A0053423E /* InappMapperTests.swift */; }; F351F1C42CE60CA90053423E /* 1-Targeting.json in Resources */ = {isa = PBXBuildFile; fileRef = F351F1C32CE60CA90053423E /* 1-Targeting.json */; }; - 7A3F1B2C9D4E5F60718293A7 /* Tags-FailedTargeting.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A3F1B2C9D4E5F60718293A6 /* Tags-FailedTargeting.json */; }; F351F1C62CE626450053423E /* 15-Targeting.json in Resources */ = {isa = PBXBuildFile; fileRef = F351F1C52CE626450053423E /* 15-Targeting.json */; }; F351F1C82CE72B300053423E /* 44-Targeting.json in Resources */ = {isa = PBXBuildFile; fileRef = F351F1C72CE72B300053423E /* 44-Targeting.json */; }; F351F1CB2CE72D460053423E /* 46-Targeting.json in Resources */ = {isa = PBXBuildFile; fileRef = F351F1CA2CE72D460053423E /* 46-Targeting.json */; }; @@ -609,6 +611,7 @@ F382F20D2BAC548900BC97FF /* VisitTargetingChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382F20C2BAC548900BC97FF /* VisitTargetingChecker.swift */; }; F382F2112BAC6AD100BC97FF /* UNAuthorizationStatus+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382F2102BAC6AD100BC97FF /* UNAuthorizationStatus+Extensions.swift */; }; F38562FF2DB66CB600D91208 /* DictionaryKeyValueModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38562FE2DB66CB600D91208 /* DictionaryKeyValueModel.swift */; }; + F389B3A639904206A3DADFFA /* InAppWebViewPrewarmPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */; }; F39116EE2AA53EE400852298 /* VariantImageUrlExtractorServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39116ED2AA53EE400852298 /* VariantImageUrlExtractorServiceTests.swift */; }; F39116F52AA9AF7A00852298 /* InappFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39116F42AA9AF7A00852298 /* InappFilter.swift */; }; F39116F82AA9B04E00852298 /* VariantsFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39116F72AA9B04E00852298 /* VariantsFilter.swift */; }; @@ -690,10 +693,6 @@ F3C1A0022F5B100100ABC001 /* InappShowFailureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C1A0012F5B100100ABC001 /* InappShowFailureManager.swift */; }; F3C1A0042F5B100100ABC001 /* InAppShowFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C1A0032F5B100100ABC001 /* InAppShowFailure.swift */; }; F3C1A0062F5B100100ABC001 /* InappShowFailureManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C1A0052F5B100100ABC001 /* InappShowFailureManagerTests.swift */; }; - FA39EE97042009DCEC24E971 /* InAppTagsGatingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045EA348681AB859FB298A57 /* InAppTagsGatingTests.swift */; }; - 1E316CC518D9111F0BD25590 /* InAppMessagesTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60DC2E1435521EBF0E2C5E4 /* InAppMessagesTrackerTests.swift */; }; - AB0E83C770AAB9A2DC8E9BF5 /* JSONValueTagsMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E67F6257F15F143DEA87DE /* JSONValueTagsMergeTests.swift */; }; - 7A3F1B2C9D4E5F60718293A5 /* TransparentViewJSBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3F1B2C9D4E5F60718293A4 /* TransparentViewJSBridgeTests.swift */; }; F3CD20262F600A800065392A /* MBConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD20272F600A800065392A /* MBConfigurationTests.swift */; }; F3CD20292F600A800065392A /* HostNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202A2F600A800065392A /* HostNormalizer.swift */; }; F3CD202B2F600A800065392A /* HostNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202C2F600A800065392A /* HostNormalizerTests.swift */; }; @@ -731,6 +730,7 @@ F3FEEAAB2C25D874000E9D0F /* InjectReplaceable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3FEEAAA2C25D874000E9D0F /* InjectReplaceable.swift */; }; F3FEEAAD2C25FD1F000E9D0F /* InjectABTestUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3FEEAAC2C25FD1F000E9D0F /* InjectABTestUtilities.swift */; }; F78E92EF282E63320003B4A3 /* DispatchSemaphore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78E92EE282E63320003B4A3 /* DispatchSemaphore.swift */; }; + FA39EE97042009DCEC24E971 /* InAppTagsGatingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045EA348681AB859FB298A57 /* InAppTagsGatingTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -765,29 +765,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewLearnedHostsStore.swift; sourceTree = ""; }; - DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmPlanner.swift; sourceTree = ""; }; - 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmNavigationPolicy.swift; sourceTree = ""; }; - 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmService.swift; sourceTree = ""; }; - 9CC5ED4F76B046798978B4B9 /* MindboxWebBridgeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MindboxWebBridgeTests.swift; sourceTree = ""; }; - 4B4F5CEE405E4BE29F97E43B /* MBContainerConcurrencyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBContainerConcurrencyTests.swift; sourceTree = ""; }; - 4E45E06561604E6185C33366 /* SDKUserAgentTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SDKUserAgentTests.swift; sourceTree = ""; }; - 895F8C05EA8B4716B2A2941A /* InAppWebViewCacheTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InAppWebViewCacheTests.swift; sourceTree = ""; }; - FD1627DAC8D54E448C6D7BF0 /* InAppWebViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewFactory.swift; sourceTree = ""; }; - 7A1E4C0DA3B24F6E90C11A01 /* InAppWebViewHTTPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTTPError.swift; sourceTree = ""; }; - 7A1E4C0DA3B24F6E90C11A03 /* WebViewNoCacheRetryPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNoCacheRetryPolicy.swift; sourceTree = ""; }; - A85CCBEB1D874078954BB771 /* InAppWebViewHTMLFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTMLFetcher.swift; sourceTree = ""; }; - 6D096F433EBB44C8B3490CF6 /* InAppWebViewDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewDataStore.swift; sourceTree = ""; }; - 11E54FE3B51649DCBD5ABE33 /* WebViewTimeoutErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewTimeoutErrorTests.swift; sourceTree = ""; }; - 7A1E4C0DA3B24F6E90C11A05 /* InAppWebViewHTTPErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTTPErrorTests.swift; sourceTree = ""; }; - 7A1E4C0DA3B24F6E90C11A07 /* WebViewNoCacheRetryPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewNoCacheRetryPolicyTests.swift; sourceTree = ""; }; - 4D716044131845AEA04A9858 /* WebViewReadyCheckerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewReadyCheckerTests.swift; sourceTree = ""; }; - F2DAFC2435264F15B8033CCE /* WebViewReadyChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewReadyChecker.swift; sourceTree = ""; }; - 68184C936B4243C28CC10829 /* SDKUserAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKUserAgent.swift; sourceTree = ""; }; + 045EA348681AB859FB298A57 /* InAppTagsGatingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppTagsGatingTests.swift; sourceTree = ""; }; 0475E8755F63483597539A50 /* TrackVisitManagerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrackVisitManagerTests.swift; sourceTree = ""; }; 0A3D04592BC6803E00E1FC52 /* ImageFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFormat.swift; sourceTree = ""; }; 0CFCC82B8014DE7276C217CD /* WebViewLocalStateStorageTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewLocalStateStorageTests.swift; sourceTree = ""; }; 0EF1D88A18D64D60BD03ABB8 /* OperationNameValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationNameValidator.swift; sourceTree = ""; }; + 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OperationResponseTests.swift; sourceTree = ""; }; + 11E54FE3B51649DCBD5ABE33 /* WebViewTimeoutErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewTimeoutErrorTests.swift; sourceTree = ""; }; + 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewLearnedHostsStore.swift; sourceTree = ""; }; + 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmService.swift; sourceTree = ""; }; + 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsBothFieldsFormat.json; sourceTree = ""; }; + 2A183433F63D47F684846B0F /* MD5HashTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5HashTests.swift; sourceTree = ""; }; 30FC619AC23245CC8CD45E63 /* SettingsRequestParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsRequestParser.swift; sourceTree = ""; }; 313B233025ADEA0F00A1CB72 /* Mindbox.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Mindbox.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 313B233325ADEA0F00A1CB72 /* Mindbox.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Mindbox.h; sourceTree = ""; }; @@ -931,8 +919,6 @@ 4731A7B62F447C3100CBE1E5 /* InAppTargetingError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = InAppTargetingError.json; sourceTree = ""; }; 4731A7B72F447C3100CBE1E5 /* InAppTargetingTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = InAppTargetingTypeError.json; sourceTree = ""; }; 4731A7BA2F447C3100CBE1E5 /* MonitoringConfig.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringConfig.json; sourceTree = ""; }; - EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsOldDeviceUuidFormat.json; sourceTree = ""; }; - 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsBothFieldsFormat.json; sourceTree = ""; }; 4731A7BB2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsElementsMixedError.json; sourceTree = ""; }; 4731A7BC2F447C3100CBE1E5 /* MonitoringLogsError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsError.json; sourceTree = ""; }; 4731A7BD2F447C3100CBE1E5 /* MonitoringLogsOneElementError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsOneElementError.json; sourceTree = ""; }; @@ -1024,7 +1010,14 @@ 47EFF0FC2E8D85B700E72D0A /* DatabaseMetadataMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseMetadataMigrationTests.swift; sourceTree = ""; }; 47FDF0B92C5BDAB80051F08C /* MigrationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationManagerProtocol.swift; sourceTree = ""; }; 47FDF0BB2C5BE8BB0051F08C /* MigrationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationProtocol.swift; sourceTree = ""; }; + 4B4F5CEE405E4BE29F97E43B /* MBContainerConcurrencyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBContainerConcurrencyTests.swift; sourceTree = ""; }; 4B7DAFAB687945FA908DB1AC /* TransparentViewSyncOperationResponseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransparentViewSyncOperationResponseTests.swift; sourceTree = ""; }; + 4D716044131845AEA04A9858 /* WebViewReadyCheckerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewReadyCheckerTests.swift; sourceTree = ""; }; + 4E45E06561604E6185C33366 /* SDKUserAgentTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SDKUserAgentTests.swift; sourceTree = ""; }; + 55E67F6257F15F143DEA87DE /* JSONValueTagsMergeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValueTagsMergeTests.swift; sourceTree = ""; }; + 68184C936B4243C28CC10829 /* SDKUserAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKUserAgent.swift; sourceTree = ""; }; + 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5Hash.swift; sourceTree = ""; }; + 6D096F433EBB44C8B3490CF6 /* InAppWebViewDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewDataStore.swift; sourceTree = ""; }; 6F1EAA15266A670E007A335B /* ProductListItemsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListItemsResponse.swift; sourceTree = ""; }; 6FDD143A266F7BD900A50C35 /* ProcessingStatusResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessingStatusResponse.swift; sourceTree = ""; }; 6FDD143C266F7BEB00A50C35 /* ItemResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemResponse.swift; sourceTree = ""; }; @@ -1048,6 +1041,12 @@ 6FDD1460266F7CE300A50C35 /* DiscountAmountTypeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscountAmountTypeResponse.swift; sourceTree = ""; }; 6FDD1462266F7CED00A50C35 /* ProductElementReponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductElementReponse.swift; sourceTree = ""; }; 6FDD1464266F7CFE00A50C35 /* ItemProductResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemProductResponse.swift; sourceTree = ""; }; + 7A1E4C0DA3B24F6E90C11A01 /* InAppWebViewHTTPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTTPError.swift; sourceTree = ""; }; + 7A1E4C0DA3B24F6E90C11A03 /* WebViewNoCacheRetryPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNoCacheRetryPolicy.swift; sourceTree = ""; }; + 7A1E4C0DA3B24F6E90C11A05 /* InAppWebViewHTTPErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTTPErrorTests.swift; sourceTree = ""; }; + 7A1E4C0DA3B24F6E90C11A07 /* WebViewNoCacheRetryPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewNoCacheRetryPolicyTests.swift; sourceTree = ""; }; + 7A3F1B2C9D4E5F60718293A4 /* TransparentViewJSBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentViewJSBridgeTests.swift; sourceTree = ""; }; + 7A3F1B2C9D4E5F60718293A6 /* Tags-FailedTargeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Tags-FailedTargeting.json"; sourceTree = ""; }; 7C8192B8B7043EF74D05B36B /* MotionServiceResolvePositionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MotionServiceResolvePositionTests.swift; sourceTree = ""; }; 7C8192B8B7043EF74D05B36C /* MotionServiceShakeToEditTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MotionServiceShakeToEditTests.swift; sourceTree = ""; }; 840042A02614CE0000CA17C5 /* ClickNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickNotificationManager.swift; sourceTree = ""; }; @@ -1106,6 +1105,8 @@ 84FCD3B825CA109E00D1E574 /* MockNetworkFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkFetcher.swift; sourceTree = ""; }; 84FCD3BC25CA10F600D1E574 /* SuccessResponse.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = SuccessResponse.json; sourceTree = ""; }; 8755088A5E704C65A1C3F6DB /* DateFormatMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatMigration.swift; sourceTree = ""; }; + 895F8C05EA8B4716B2A2941A /* InAppWebViewCacheTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InAppWebViewCacheTests.swift; sourceTree = ""; }; + 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmNavigationPolicy.swift; sourceTree = ""; }; 9778038796A8426ABDED1E97 /* FeatureTogglesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureTogglesModel.swift; sourceTree = ""; }; 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBNetworkFetcherResponseHandlingTests.swift; sourceTree = ""; }; 9B24FAAB28C74B8300F10B5D /* InAppConfigurationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppConfigurationRepository.swift; sourceTree = ""; }; @@ -1131,13 +1132,13 @@ 9BC24E7228F6953D00C2619C /* InAppConfigurationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppConfigurationAPI.swift; sourceTree = ""; }; 9BC24E7328F6953D00C2619C /* ConfigResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigResponse.swift; sourceTree = ""; }; 9BC24E7928F6C08700C2619C /* InAppConfiguration.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = InAppConfiguration.json; sourceTree = ""; }; + 9CC5ED4F76B046798978B4B9 /* MindboxWebBridgeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MindboxWebBridgeTests.swift; sourceTree = ""; }; A11FBE8829DD76BF00F5FB7B /* InAppMessagesEventSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessagesEventSender.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; - 2A183433F63D47F684846B0F /* MD5HashTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5HashTests.swift; sourceTree = ""; }; A154E32F299E0F1600F8F074 /* InAppGeoResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppGeoResponse.swift; sourceTree = ""; }; A154E333299E110E00F8F074 /* EventRepositoryMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventRepositoryMock.swift; sourceTree = ""; }; A154E33A299E5B6D00F8F074 /* LogLevel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogLevel.swift; sourceTree = ""; }; @@ -1158,7 +1159,6 @@ A154E37F299E5B7500F8F074 /* SDKLogsStatus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKLogsStatus.swift; sourceTree = ""; }; A154E381299E5B7500F8F074 /* SDKLogsRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKLogsRequest.swift; sourceTree = ""; }; A154E382299E5B7500F8F074 /* SDKLogsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKLogsManager.swift; sourceTree = ""; }; - 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5Hash.swift; sourceTree = ""; }; A170EDE129B08A2700CE547F /* MindboxLogger.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MindboxLogger.h; sourceTree = ""; }; A17853BE29AF7E940072578F /* MindboxLogger.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MindboxLogger.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A17853C529AF7E950072578F /* MindboxLoggerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MindboxLoggerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1209,6 +1209,7 @@ A1D017F12976CC9400CD9F99 /* SegmentTargeting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentTargeting.swift; sourceTree = ""; }; A1D017F42976FC2B00CD9F99 /* InternalTargetingChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalTargetingChecker.swift; sourceTree = ""; }; A1D23AEF29DE082E00A75179 /* InAppProductSegmentResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppProductSegmentResponse.swift; sourceTree = ""; }; + A85CCBEB1D874078954BB771 /* InAppWebViewHTMLFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTMLFetcher.swift; sourceTree = ""; }; B11C2D3E4F5566778899AA01 /* FirstInitializationDateTimeRuntimeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FirstInitializationDateTimeRuntimeTests.swift; sourceTree = ""; }; B11C2D3E4F5566778899AA02 /* FirstInitializationDateTimeMigrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FirstInitializationDateTimeMigrationTests.swift; sourceTree = ""; }; B11C2D3E4F5566778899AA11 /* DeviceUUIDInitializationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceUUIDInitializationTests.swift; sourceTree = ""; }; @@ -1242,7 +1243,6 @@ BD1BE43AA9EAEA03F8ED400C /* HapticRequestParserTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HapticRequestParserTests.swift; sourceTree = ""; }; BD1BE43AA9EAEA03F8ED400D /* HapticRequestValidatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HapticRequestValidatorTests.swift; sourceTree = ""; }; BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBEventRepositorySendRawTests.swift; sourceTree = ""; }; - 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OperationResponseTests.swift; sourceTree = ""; }; BF1A11C4A4B940898BA80035 /* DateFormatMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatMigrationTests.swift; sourceTree = ""; }; D216DE502C0716B70020F58A /* StringExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringExtensionsTests.swift; sourceTree = ""; }; D216DE522C0716B80020F58A /* TimeIntervalTimeSpanTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimeIntervalTimeSpanTests.swift; sourceTree = ""; }; @@ -1250,7 +1250,10 @@ D2F7E2462BADB9EF00B24BB8 /* UserVisitManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserVisitManagerTests.swift; sourceTree = ""; }; D2F7E2492BADC2AB00B24BB8 /* SessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManager.swift; sourceTree = ""; }; D2F7E24B2BADC4CA00B24BB8 /* MockSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSessionManager.swift; sourceTree = ""; }; + DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmPlanner.swift; sourceTree = ""; }; + EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsOldDeviceUuidFormat.json; sourceTree = ""; }; F0DB93A7997961CA7C2BE917 /* MotionServiceBehaviorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MotionServiceBehaviorTests.swift; sourceTree = ""; }; + F2DAFC2435264F15B8033CCE /* WebViewReadyChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewReadyChecker.swift; sourceTree = ""; }; F30005432CFF3F7D004BE915 /* ABTestStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ABTestStubs.swift; sourceTree = ""; }; F30629192BD27D7500EF6609 /* InappFrequencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappFrequencyTests.swift; sourceTree = ""; }; F30654BA2F1A83520058808C /* MindboxWebViewFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxWebViewFacade.swift; sourceTree = ""; }; @@ -1343,17 +1346,16 @@ F34A103B2F455B840065392A /* SettingsConfigParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsConfigParsingTests.swift; sourceTree = ""; }; F34A103E2F455C5B0065392A /* SettingsFeatureTogglesError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesError.json; sourceTree = ""; }; F34A103F2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json; sourceTree = ""; }; - F34A10492F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppTagsFalse.json; sourceTree = ""; }; - F34A104A2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppTagsTypeError.json; sourceTree = ""; }; F34A10402F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json; sourceTree = ""; }; F34A10412F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json; sourceTree = ""; }; F34A10422F455C5B0065392A /* SettingsFeatureTogglesTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesTypeError.json; sourceTree = ""; }; + F34A10492F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppTagsFalse.json; sourceTree = ""; }; + F34A104A2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppTagsTypeError.json; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; - 7A3F1B2C9D4E5F60718293A6 /* Tags-FailedTargeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Tags-FailedTargeting.json"; sourceTree = ""; }; F351F1C52CE626450053423E /* 15-Targeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "15-Targeting.json"; sourceTree = ""; }; F351F1C72CE72B300053423E /* 44-Targeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "44-Targeting.json"; sourceTree = ""; }; F351F1C92CE72D460053423E /* 45-Targeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "45-Targeting.json"; sourceTree = ""; }; @@ -1453,10 +1455,6 @@ F3C1A0012F5B100100ABC001 /* InappShowFailureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappShowFailureManager.swift; sourceTree = ""; }; F3C1A0032F5B100100ABC001 /* InAppShowFailure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppShowFailure.swift; sourceTree = ""; }; F3C1A0052F5B100100ABC001 /* InappShowFailureManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappShowFailureManagerTests.swift; sourceTree = ""; }; - 045EA348681AB859FB298A57 /* InAppTagsGatingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppTagsGatingTests.swift; sourceTree = ""; }; - F60DC2E1435521EBF0E2C5E4 /* InAppMessagesTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessagesTrackerTests.swift; sourceTree = ""; }; - 55E67F6257F15F143DEA87DE /* JSONValueTagsMergeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValueTagsMergeTests.swift; sourceTree = ""; }; - 7A3F1B2C9D4E5F60718293A4 /* TransparentViewJSBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentViewJSBridgeTests.swift; sourceTree = ""; }; F3CD20272F600A800065392A /* MBConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfigurationTests.swift; sourceTree = ""; }; F3CD202A2F600A800065392A /* HostNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizer.swift; sourceTree = ""; }; F3CD202C2F600A800065392A /* HostNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizerTests.swift; sourceTree = ""; }; @@ -1494,7 +1492,9 @@ F3FEEAA82C25CC9F000E9D0F /* InjectionMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectionMocks.swift; sourceTree = ""; }; F3FEEAAA2C25D874000E9D0F /* InjectReplaceable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectReplaceable.swift; sourceTree = ""; }; F3FEEAAC2C25FD1F000E9D0F /* InjectABTestUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectABTestUtilities.swift; sourceTree = ""; }; + F60DC2E1435521EBF0E2C5E4 /* InAppMessagesTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessagesTrackerTests.swift; sourceTree = ""; }; F78E92EE282E63320003B4A3 /* DispatchSemaphore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchSemaphore.swift; sourceTree = ""; }; + FD1627DAC8D54E448C6D7BF0 /* InAppWebViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewFactory.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -1503,10 +1503,10 @@ 471D4C3B2FEED16200856EA5 /* TestPlans */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = TestPlans; sourceTree = ""; }; 57A1B3230000000000000203 /* EmbeddedBlocks */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EmbeddedBlocks; sourceTree = ""; }; 57A1B3230000000000000300 /* EmbeddedBlocks */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EmbeddedBlocks; sourceTree = ""; }; + A8C878878353491FA01AC096 /* WebViewPrewarmTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = WebViewPrewarmTests; sourceTree = ""; }; F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappConfigurationDataFacade; sourceTree = ""; }; F397DE1C2CFF568800B72DA9 /* JSONs */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = JSONs; sourceTree = ""; }; F3DEB38C2D47CBA200D0EFA4 /* InappSessionManagerTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappSessionManagerTests; sourceTree = ""; }; - A8C878878353491FA01AC096 /* WebViewPrewarmTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = WebViewPrewarmTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1568,6 +1568,17 @@ path = FeatureToggleManager; sourceTree = ""; }; + 0E8BCE24EE9540D6BD7F655D /* Prewarm */ = { + isa = PBXGroup; + children = ( + 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */, + DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */, + 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */, + 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */, + ); + path = Prewarm; + sourceTree = ""; + }; 2B4C84F6EDD4B7D977F67A95 /* WebView */ = { isa = PBXGroup; children = ( @@ -3102,17 +3113,6 @@ path = InAppTargetingChecker; sourceTree = ""; }; - 0E8BCE24EE9540D6BD7F655D /* Prewarm */ = { - isa = PBXGroup; - children = ( - 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */, - DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */, - 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */, - 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */, - ); - path = Prewarm; - sourceTree = ""; - }; B4E4386F2D8AFA5700603F3A /* WebView */ = { isa = PBXGroup; children = ( diff --git a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift index 926e2281e..d1bd60d85 100644 --- a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift +++ b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift @@ -9,25 +9,16 @@ import UIKit import MindboxLogger -/// Обработчик действий страницы сверх core-слоя. protocol EmbeddedBlockActionHandling: AnyObject { - func handle(_ action: EmbeddedBlockPageAction) } -/// Кто на самом деле открывает ссылку. -/// -/// Шов нужен и тестам, и на будущее: открытие ссылок в SDK уже живёт в `MindboxURLHandlerDelegate`, -/// и когда блоки поедут на общий мост инаппов, здесь окажется он, а не `UIApplication` напрямую. protocol EmbeddedBlockURLOpening { - func canOpen(_ url: URL) -> Bool - func open(_ url: URL) } final class EmbeddedBlockSystemURLOpener: EmbeddedBlockURLOpening { - func canOpen(_ url: URL) -> Bool { UIApplication.shared.canOpenURL(url) } @@ -37,14 +28,6 @@ final class EmbeddedBlockSystemURLOpener: EmbeddedBlockURLOpening { } } -/// Универсальный словарь действий страницы — один на все механики. -/// -/// Блок не знает, какая механика внутри, поэтому и действия у страниц общие: любая страница, -/// говорящая этим словарём, получает нативное поведение без нового кода в SDK. Незнакомое -/// действие — не ошибка: словарь у веб-стороны может быть новее, чем у SDK, тогда действие -/// просто логируется. -/// -/// [WIP] final class EmbeddedBlockActionRouter: EmbeddedBlockActionHandling { private enum ActionType { @@ -53,80 +36,26 @@ final class EmbeddedBlockActionRouter: EmbeddedBlockActionHandling { /// Веб-адрес — это переход по контенту, и его странице позволено открывать всегда. private enum WebScheme { - static let all: Set = ["http", "https"] + static let all: Set = ["https"] } private let urlOpener: EmbeddedBlockURLOpening - /// Схемы, которые хост объявил своими. Читаются один раз: Info.plist по ходу работы не меняется. - private let hostAppSchemes: Set - - init(urlOpener: EmbeddedBlockURLOpening = EmbeddedBlockSystemURLOpener(), - hostAppSchemes: Set = EmbeddedBlockActionRouter.hostAppSchemes(in: Bundle.main.infoDictionary)) { + init(urlOpener: EmbeddedBlockURLOpening = EmbeddedBlockSystemURLOpener()) { self.urlOpener = urlOpener - self.hostAppSchemes = hostAppSchemes } func handle(_ action: EmbeddedBlockPageAction) { switch action.type { case ActionType.openUrl: - openUrl(from: action) + print("embeddedBlock action") + // TODO: - Add action here later +// openUrl(from: action) default: Logger.common(message: "[EmbeddedBlock] Unknown page action: \(action.type)", category: .embeddedBlocks) } } - /// Схемы из `CFBundleURLTypes` — те, по которым система вернёт пользователя в это же приложение. - /// - /// На вход идёт сам `infoDictionary`, а не `Bundle`: подменить бандлу его Info.plist в тесте - /// нельзя, а разбор проверить надо. - static func hostAppSchemes(in infoDictionary: [String: Any]?) -> Set { - let types = infoDictionary?["CFBundleURLTypes"] as? [[String: Any]] ?? [] - let schemes = types - .compactMap { $0["CFBundleURLSchemes"] as? [String] } - .flatMap { $0 } - .map { $0.lowercased() } - - return Set(schemes) - } - - private func openUrl(from action: EmbeddedBlockPageAction) { - guard let raw = action.payload["url"] as? String, - let url = URL(string: raw) else { - Logger.common(message: "[EmbeddedBlock] openUrl with an invalid url: \(action.payload)", - category: .embeddedBlocks) - return - } - - guard isAllowed(url) else { - Logger.common(message: """ - [EmbeddedBlock] openUrl refused for scheme '\(url.scheme ?? "none")': a block page may open \ - web addresses and this app's own deep links, but not system-level actions. - """, category: .embeddedBlocks) - return - } - - guard urlOpener.canOpen(url) else { - Logger.common(message: "[EmbeddedBlock] openUrl cannot be opened by the system: \(url.absoluteString)", - category: .embeddedBlocks) - return - } - - Logger.common(message: "[EmbeddedBlock] Opening url: \(url.absoluteString)", category: .embeddedBlocks) - urlOpener.open(url) - } - - /// Страница блока приезжает из сети, поэтому решать за пользователя, что откроет система, ей не - /// положено: `tel:`, `sms:`, `itms-apps:` и схемы чужих приложений — это уже не переход по - /// контенту, а действие от его имени, и `canOpenURL` для них проходит. - /// - /// Разрешено поэтому ровно то, что никуда пользователя не увозит: веб-адреса и диплинки в само - /// это приложение. Понадобится большее — это отдельное явное согласие хоста, а не молчаливое - /// право страницы. - private func isAllowed(_ url: URL) -> Bool { - guard let scheme = url.scheme?.lowercased() else { return false } - - return WebScheme.all.contains(scheme) || hostAppSchemes.contains(scheme) - } + // TODO: - Will reuse webView route logic from inapps } diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift index 77dbb05c1..e0174f0e3 100644 --- a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift @@ -8,20 +8,9 @@ import Foundation -/// Что именно показывает встроенный блок. -/// -/// Контент блока всегда веб: SDK ничего не рисует сам, он показывает страницу, закреплённую за id -/// блока. Меняется внутри — адрес, вёрстка, механика на странице, — но не вид контента, поэтому -/// дескриптор описывает веб-страницу прямо, без промежуточного «вида контента». -/// -/// Сюда же приедут остальные поля конфига, когда он появится: версия веб-контракта, -/// зарезервированная высота, параметры страницы. struct EmbeddedBlockWebContent: Equatable { - /// Чем задана страница. enum Source: Equatable { - - /// Боевой случай: адрес, который приедет из конфига. case url(URL) /// Разметка вместо адреса. Нужна отладочной подмене контента: сценарии приёмки — пустая diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockActionRouterTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockActionRouterTests.swift deleted file mode 100644 index e469ed6ba..000000000 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockActionRouterTests.swift +++ /dev/null @@ -1,194 +0,0 @@ -// -// EmbeddedBlockActionRouterTests.swift -// MindboxTests -// -// Created by vailence on 10.08.2026. -// Copyright © 2026 Mindbox. All rights reserved. -// - -import Testing -import Foundation -@testable import Mindbox - -@Suite("Embedded block action router", .tags(.embeddedBlocks)) -struct EmbeddedBlockActionRouterTests { - - // MARK: - openUrl - - @Test("A web address from the page is opened") - func webAddressIsOpened() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener) - - router.handle(openUrl("https://mindbox.ru/promo")) - - #expect(opener.openedURLs.map(\.absoluteString) == ["https://mindbox.ru/promo"]) - } - - @Test("Plain http is opened too") - func httpIsOpened() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener) - - router.handle(openUrl("http://mindbox.ru")) - - #expect(opener.openedURLs.count == 1) - } - - /// Диплинк в само это приложение никуда пользователя не увозит, поэтому он разрешён — но только - /// если хост действительно объявил эту схему своей. - @Test("A deep link into the host app itself is opened") - func hostAppDeepLinkIsOpened() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener, hostAppSchemes: ["myshop"]) - - router.handle(openUrl("myshop://cart")) - - #expect(opener.openedURLs.count == 1) - } - - @Test("Scheme matching ignores case") - func schemeMatchingIgnoresCase() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener, hostAppSchemes: ["myshop"]) - - router.handle(openUrl("MyShop://cart")) - router.handle(openUrl("HTTPS://mindbox.ru")) - - #expect(opener.openedURLs.count == 2) - } - - // MARK: - Schemes the page may not open - - /// Системное действие — это уже не переход по контенту: страница блока приезжает из сети, и - /// звонить за пользователя ей не положено. - @Test("A tel: link from the page is refused") - func telLinkIsRefused() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener) - - router.handle(openUrl("tel://+79001234567")) - - #expect(opener.openedURLs.isEmpty) - } - - @Test("System and third-party app schemes are refused") - func foreignSchemesAreRefused() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener, hostAppSchemes: ["myshop"]) - - for raw in ["sms://+79001234567", - "itms-apps://apps.apple.com/app/id1", - "app-settings://", - "mailto:hi@mindbox.ru", - "someotherapp://pay"] { - router.handle(openUrl(raw)) - } - - #expect(opener.openedURLs.isEmpty) - } - - /// Схема чужого приложения не становится разрешённой от того, что система умеет её открыть, — - /// именно это `canOpenURL` и говорит. - @Test("A refused scheme is not saved by canOpenURL saying yes") - func canOpenDoesNotOverridePolicy() { - let opener = EmbeddedBlockURLOpenerMock() - opener.canOpenAnything = true - let router = makeRouter(opener: opener) - - router.handle(openUrl("tel://+79001234567")) - - #expect(opener.openedURLs.isEmpty) - } - - @Test("A url without a scheme is refused") - func schemelessUrlIsRefused() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener) - - router.handle(openUrl("mindbox.ru/promo")) - - #expect(opener.openedURLs.isEmpty) - } - - // MARK: - Malformed actions - - @Test("An allowed url the system cannot open is not opened") - func unopenableUrlIsNotOpened() { - let opener = EmbeddedBlockURLOpenerMock() - opener.canOpenAnything = false - let router = makeRouter(opener: opener) - - router.handle(openUrl("https://mindbox.ru")) - - #expect(opener.openedURLs.isEmpty) - } - - @Test("openUrl without a url payload opens nothing") - func missingUrlOpensNothing() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener) - - router.handle(EmbeddedBlockPageAction(type: "openUrl", payload: ["type": "openUrl"])) - - #expect(opener.openedURLs.isEmpty) - } - - @Test("openUrl with a non-string url opens nothing") - func nonStringUrlOpensNothing() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener) - - router.handle(EmbeddedBlockPageAction(type: "openUrl", payload: ["url": 42])) - - #expect(opener.openedURLs.isEmpty) - } - - /// Словарь у веб-стороны может быть новее, чем у SDK: незнакомое действие — не ошибка. - @Test("An unknown action is ignored without side effects") - func unknownActionIsIgnored() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener) - - router.handle(EmbeddedBlockPageAction(type: "shareSomethingNew", payload: ["url": "https://mindbox.ru"])) - - #expect(opener.openedURLs.isEmpty) - } - - // MARK: - Host app schemes - - @Test("Every scheme of every declared url type counts as the host's own") - func allDeclaredSchemesAreCollected() { - let info: [String: Any] = [ - "CFBundleURLTypes": [ - ["CFBundleURLName": "main", "CFBundleURLSchemes": ["MyShop", "myshop-dev"]], - ["CFBundleURLName": "legacy", "CFBundleURLSchemes": ["oldshop"]] - ] - ] - - let schemes = EmbeddedBlockActionRouter.hostAppSchemes(in: info) - - #expect(schemes == ["myshop", "myshop-dev", "oldshop"]) - } - - /// Хост может не объявлять схем вообще, а объявленное — быть неполным: разбор Info.plist не - /// должен ни падать, ни придумывать схемы, которых там нет. - @Test("A missing or malformed CFBundleURLTypes yields no schemes") - func malformedBundleYieldsNoSchemes() { - #expect(EmbeddedBlockActionRouter.hostAppSchemes(in: nil).isEmpty) - #expect(EmbeddedBlockActionRouter.hostAppSchemes(in: [:]).isEmpty) - #expect(EmbeddedBlockActionRouter.hostAppSchemes(in: ["CFBundleURLTypes": "myshop"]).isEmpty) - #expect(EmbeddedBlockActionRouter.hostAppSchemes(in: ["CFBundleURLTypes": [["CFBundleURLName": "main"]]]).isEmpty) - } - - // MARK: - Helpers - - private func makeRouter(opener: EmbeddedBlockURLOpening, - hostAppSchemes: Set = []) -> EmbeddedBlockActionRouter { - EmbeddedBlockActionRouter(urlOpener: opener, hostAppSchemes: hostAppSchemes) - } - - private func openUrl(_ raw: String) -> EmbeddedBlockPageAction { - EmbeddedBlockPageAction(type: "openUrl", payload: ["type": "openUrl", "url": raw]) - } -} From 1b750bb2ce011c0ebec1671353eb3ff834b45a4d Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 11 Aug 2026 17:51:08 +0500 Subject: [PATCH 28/47] MOBILE-323: Translate embedded block comments to English Also carries the pending working-tree changes: the URL opener is dropped from the action router until the in-app web view route logic is reused, resolver answers are delivered on the main thread, and the page no longer detaches its bridge in deinit. --- .../Actions/EmbeddedBlockActionRouter.swift | 26 ------ .../Public/MindboxEmbeddedBlockDebug.swift | 53 ++++++------ .../EmbeddedBlockContentOverrides.swift | 24 +++--- .../Resolver/EmbeddedBlockResolver.swift | 82 ++++++++++++------- .../Resolver/EmbeddedBlockWebContent.swift | 6 +- .../WebView/EmbeddedBlockPageHosting.swift | 27 +++--- .../WebView/EmbeddedBlockPageMessage.swift | 33 ++++---- .../EmbeddedBlockReadinessOverrides.swift | 30 +++---- .../WebView/EmbeddedBlockWebViewPage.swift | 57 ++++++------- .../EmbeddedBlocks/EmbeddedBlockMocks.swift | 18 ---- .../EmbeddedBlockResolverTests.swift | 46 +++++++++-- .../EmbeddedBlockWebViewPageTests.swift | 24 +++--- 12 files changed, 218 insertions(+), 208 deletions(-) diff --git a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift index d1bd60d85..7488266d1 100644 --- a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift +++ b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift @@ -13,38 +13,12 @@ protocol EmbeddedBlockActionHandling: AnyObject { func handle(_ action: EmbeddedBlockPageAction) } -protocol EmbeddedBlockURLOpening { - func canOpen(_ url: URL) -> Bool - func open(_ url: URL) -} - -final class EmbeddedBlockSystemURLOpener: EmbeddedBlockURLOpening { - func canOpen(_ url: URL) -> Bool { - UIApplication.shared.canOpenURL(url) - } - - func open(_ url: URL) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } -} - final class EmbeddedBlockActionRouter: EmbeddedBlockActionHandling { private enum ActionType { static let openUrl = "openUrl" } - /// Веб-адрес — это переход по контенту, и его странице позволено открывать всегда. - private enum WebScheme { - static let all: Set = ["https"] - } - - private let urlOpener: EmbeddedBlockURLOpening - - init(urlOpener: EmbeddedBlockURLOpening = EmbeddedBlockSystemURLOpener()) { - self.urlOpener = urlOpener - } - func handle(_ action: EmbeddedBlockPageAction) { switch action.type { case ActionType.openUrl: diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockDebug.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockDebug.swift index 0ca8d9a20..3e404a0fc 100644 --- a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockDebug.swift +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockDebug.swift @@ -8,59 +8,62 @@ import Foundation -/// Отладочное управление содержимым встроенных блоков — для тестового приложения и приёмки. +/// Debug control over embedded block content — for the test app and acceptance testing. /// -/// Подменяет ответ на вопрос «что стоит за этим id», то есть встаёт ровно на место конфига из -/// админки. Всё, что ниже — резолвер, провайдер, страница, бюджет ожидания у контейнера — работает -/// без изменений, поэтому приёмка проверяет боевой путь, а не отдельный тестовый режим. +/// Overrides the answer to "what stands behind this id", that is, it takes exactly the place of the +/// admin panel config. Everything below — the resolver, the provider, the page, the container's +/// waiting budget — works unchanged, so acceptance testing exercises the production path rather +/// than a separate test mode. /// -/// Не часть публичного API: доступно только через `@_spi(Internal) import Mindbox`. Из релизных -/// сборок не вырезано намеренно — QA проверяет ровно то, что уходит клиентам, — поэтому каждая -/// установка подмены пишется в лог. +/// Not part of the public API: available only via `@_spi(Internal) import Mindbox`. Deliberately +/// not stripped from release builds — QA checks exactly what ships to clients — which is why every +/// override that gets set is written to the log. @_spi(Internal) public enum MindboxEmbeddedBlockDebug { - /// Чем подменить содержимое блока. + /// What to replace the block content with. public enum Content { - /// Адрес страницы. Так гоняются сценарии на реальной сети — включая заведомо недоступный - /// адрес, чтобы получить провал загрузки. + /// A page url. This is how scenarios are run against the real network — including a + /// knowingly unreachable address, to get a load failure. case url(URL) - /// Готовая разметка. Так задаются сценарии, которых в сети нет: страница, сообщающая - /// «пусто», молчащая страница, страница с ответом после таймаута. + /// Ready-made markup. This is how scenarios that do not exist on the network are set up: a + /// page reporting "empty", a silent page, a page that answers after the timeout. case html(String) - /// За id ничего не закреплено: блок выключен в админке или id неизвестен. + /// Nothing is attached to the id: the block is turned off in the admin panel or the id is + /// unknown. case empty } - /// Подменяет содержимое блока с этим id. Действует на блоки, которые начнут загрузку после - /// вызова: уже показанный блок надо перезагрузить или заново открыть экран. + /// Overrides the content of the block with this id. Applies to blocks that start loading after + /// the call: a block that is already shown has to be reloaded or its screen reopened. public static func setContent(_ content: Content, for id: String) { EmbeddedBlockContentOverrides.shared.set(content.resolution, for: id) } - /// Возвращает блоку его обычное содержимое. + /// Gives the block its usual content back. public static func removeContent(for id: String) { EmbeddedBlockContentOverrides.shared.remove(for: id) } - /// Снимает все подмены сразу. + /// Drops every override at once. public static func removeAllContent() { EmbeddedBlockContentOverrides.shared.removeAll() } - /// Показывать блок, как только загрузился документ, не дожидаясь `ready` от страницы. + /// Show the block as soon as the document has loaded, without waiting for `ready` from the page. /// - /// Нужно ровно одному сценарию: посмотреть, как блок выглядит и ведёт себя в вёрстке хоста, - /// пока веб-контракт не реализован на странице. По обычному правилу такая страница молчит, - /// а значит сворачивается по таймауту контейнера, и увидеть в блоке нечего. + /// Needed for exactly one scenario: seeing how the block looks and behaves inside the host + /// layout while the web contract is not implemented on the page yet. Under the usual rule such + /// a page stays silent, which means it collapses on the container timeout and there is nothing + /// to see in the block. /// - /// Выключено по умолчанию и ставится один раз при старте приложения. Держать включённым - /// дольше проверки UI не стоит: со включённым флагом сломанная страница выглядит как рабочая. - /// `ready` от страницы флаг не отменяет — он лишь добавляет второй повод показать блок, - /// поэтому страница, которая контракт умеет, ведёт себя одинаково с ним и без него. + /// Off by default and set once at app startup. Keeping it on for longer than the UI check is a + /// bad idea: with the flag on, a broken page looks like a working one. The flag does not cancel + /// `ready` from the page — it only adds a second reason to show the block, so a page that does + /// implement the contract behaves the same with it and without it. public static var treatsLoadedPageAsReady: Bool { get { EmbeddedBlockReadinessOverrides.shared.treatsLoadedPageAsReady } set { EmbeddedBlockReadinessOverrides.shared.setTreatsLoadedPageAsReady(newValue) } diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockContentOverrides.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockContentOverrides.swift index 20148a442..e0a6731cc 100644 --- a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockContentOverrides.swift +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockContentOverrides.swift @@ -9,28 +9,30 @@ import Foundation import MindboxLogger -/// Что подставить вместо контента, закреплённого за id блока. +/// What to substitute for the content attached to a block id. protocol EmbeddedBlockContentOverriding: AnyObject { func resolution(for id: String) -> EmbeddedBlockResolution? } -/// Отладочная подмена контента блока — то, чем приёмка воспроизводит сценарии, которые в сети не -/// выложены: пустой блок, молчащая страница, ответ уже после таймаута, незнакомое сообщение. +/// A debug override of the block content — what acceptance testing uses to reproduce scenarios that +/// are not published on the network: an empty block, a silent page, an answer that comes after the +/// timeout, an unknown message. /// -/// Подмена сидит на месте конфига, поэтому весь путь ниже — резолвер, провайдер, страница, таймаут -/// контейнера — работает по-настоящему; меняется только источник данных о блоке. Кэш резолвера для -/// подменённого id не используется, чтобы переключение сценария применялось сразу. +/// The override sits in the place of the config, so the whole path below it — the resolver, the +/// provider, the page, the container timeout — works for real; only the source of the block data +/// changes. The resolver cache is not used for an overridden id, so that switching a scenario +/// applies right away. /// -/// Спрятана за `@_spi(Internal)`: в обычном API её нет, но и не вырезана из релизных сборок — QA -/// проверяет то, что уходит клиентам. Каждая установка пишется в лог, чтобы включённую подмену было -/// невозможно не заметить. +/// Hidden behind `@_spi(Internal)`: it is absent from the regular API, but it is not stripped from +/// release builds either — QA checks what ships to clients. Every override that gets set is written +/// to the log, so an enabled override is impossible to miss. final class EmbeddedBlockContentOverrides: EmbeddedBlockContentOverriding { static let shared = EmbeddedBlockContentOverrides() - /// Подмену ставят из QA-кода приложения, а читает её резолвер на главном потоке — потоки могут - /// не совпасть. + /// The override is set from the app's QA code, while the resolver reads it on the main thread — + /// the threads may differ. private let lock = NSLock() private var overrides: [String: EmbeddedBlockResolution] = [:] diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift index 202e9689b..85c571244 100644 --- a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift @@ -9,25 +9,28 @@ import Foundation import MindboxLogger -/// Во что разрешается id встроенного блока. +/// What an embedded block id resolves into. enum EmbeddedBlockResolution: Equatable { - /// За id закреплён контент — блок грузит его. + /// There is content attached to the id — the block loads it. case content(EmbeddedBlockWebContent) - /// За id ничего нет — блок выключен в админке или id неизвестен. Не ошибка. + /// There is nothing behind the id — the block is turned off in the admin panel or the id is + /// unknown. Not an error. case empty } -/// Отвечает на единственный вопрос: что показывает блок с данным id. +/// Answers a single question: what does the block with this id show. /// -/// Резолвер — общая точка всех контейнеров: несколько блоков с одним id разрешаются одними -/// данными, при этом вью, страница и состояние у каждого блока остаются своими. Работает на -/// главном потоке; completion может прийти как синхронно (кэш), так и позже (сетевой конфиг). +/// The resolver is the shared point for every container: several blocks with the same id resolve +/// with the same data, while the view, the page and the state stay per block. Works on the main +/// thread; the completion may arrive either synchronously (cache) or later (remote config). protocol EmbeddedBlockResolving: AnyObject { - /// - Parameter forceRefresh: `true` — не брать кэш, спросить данные заново. Нужно перезагрузке - /// блока: переехавший или выключенный блок иначе вечно доставал бы из кэша прежний адрес. + /// - Parameter forceRefresh: `true` — skip the cache, ask for the data again. Needed by a block + /// reload: a block that moved or was turned off would otherwise keep pulling the old address + /// from the cache forever. Laid down in advance and deliberately not exposed: while "once per + /// SDK initialization" holds, a reload cannot be triggered from the app. func resolve(_ id: String, forceRefresh: Bool, completion: @escaping (EmbeddedBlockResolution) -> Void) } @@ -38,26 +41,36 @@ extension EmbeddedBlockResolving { } } -/// Откуда резолвер узнаёт, что стоит за id блока. +/// Where the resolver learns what stands behind a block id. /// -/// Сейчас это заглушка со статической страницей. Когда появится конфиг из админки, здесь окажется -/// настоящая загрузка, а кэш и очередь ожидающих в резолвере не изменятся. +/// For now it is a stub with a static page. Once the admin panel config arrives, the real loading +/// will live here, while the cache and the queue of waiters in the resolver stay unchanged. +/// +/// It may answer from any thread: the resolver moves the answer to the main thread itself, because +/// that is where it updates the cache and the queue of waiters, and where the block views wait for +/// the answer. typealias EmbeddedBlockContentLoading = (String, @escaping (EmbeddedBlockResolution) -> Void) -> Void final class EmbeddedBlockResolver: EmbeddedBlockResolving { - /// Страница ленты сторизов на статике. Временно захардкожена: когда появится конфиг из - /// админки, адрес приедет оттуда вместе с маппингом id → контент. + /// The stories feed page on static hosting. Hardcoded for now: once the admin panel config + /// arrives, the address will come from there together with the id → content mapping. private static let storiesPageURL = "https://mobile-static.mindbox.ru/beta/inapps/webview/content/stories.html" private let load: EmbeddedBlockContentLoading private let overrides: EmbeddedBlockContentOverriding - /// Кэш на id: ответ, полученный один раз, достаётся всем следующим блокам сразу. + /// A cache per id: an answer received once is handed to every following block immediately. + /// + /// Lives until the end of the process and is never invalidated — including `.empty`. That is a + /// decision, not an oversight: a block resolves once per SDK initialization, full stop. It + /// follows that a block turned off in the admin panel, or one that did not make it at app + /// startup, will not appear until a restart — and that is by design. This may change later, but + /// for now it is so. private var cache: [String: EmbeddedBlockResolution] = [:] - /// Кто уже ждёт ответ по этому id. «Одна загрузка данных на id» — это про то, что второй блок - /// с тем же id встаёт в эту очередь, а не идёт за данными сам. + /// Who is already waiting for the answer for this id. "One data load per id" means that a + /// second block with the same id joins this queue instead of going for the data itself. private var waiting: [String: [(EmbeddedBlockResolution) -> Void]] = [:] init(load: @escaping EmbeddedBlockContentLoading = EmbeddedBlockResolver.loadStubbedStoriesPage, @@ -67,8 +80,8 @@ final class EmbeddedBlockResolver: EmbeddedBlockResolving { } func resolve(_ id: String, forceRefresh: Bool, completion: @escaping (EmbeddedBlockResolution) -> Void) { - // Отладочная подмена сильнее и данных, и кэша: приёмка переключает сценарий на ходу, и - // закэшированный ответ мешал бы этому. + // The debug override outranks both the data and the cache: acceptance testing switches + // scenarios on the fly, and a cached answer would get in the way. if let overridden = overrides.resolution(for: id) { completion(overridden) return @@ -79,8 +92,8 @@ final class EmbeddedBlockResolver: EmbeddedBlockResolving { return } - // Загрузка по этому id уже идёт. Присоединиться к ней правильно и для `forceRefresh`: - // ответ, который она вот-вот принесёт, свежий по определению. + // A load for this id is already in flight. Joining it is right for `forceRefresh` too: the + // answer it is about to bring is fresh by definition. if waiting[id] != nil { waiting[id]?.append(completion) return @@ -89,17 +102,28 @@ final class EmbeddedBlockResolver: EmbeddedBlockResolving { waiting[id] = [completion] load(id) { [weak self] resolution in - guard let self else { return } - - self.cache[id] = resolution - let completions = self.waiting.removeValue(forKey: id) ?? [] - completions.forEach { $0(resolution) } + guard Thread.isMainThread else { + DispatchQueue.main.async { + self?.finish(id, with: resolution) + } + return + } + + self?.finish(id, with: resolution) } } - /// Конфига ещё нет, поэтому любой id разрешается в страницу ленты сторизов. Это единственное - /// место, которое заменит настоящий конфиг из админки: id → контент блока, выключенный или - /// неизвестный блок → `.empty`. + /// The answer has arrived: it goes into the cache and is handed to the whole queue that waited + /// for it. + private func finish(_ id: String, with resolution: EmbeddedBlockResolution) { + cache[id] = resolution + let completions = waiting.removeValue(forKey: id) ?? [] + completions.forEach { $0(resolution) } + } + + /// There is no config yet, so any id resolves into the stories feed page. This is the single + /// place the real admin panel config will replace: id → block content, a turned off or unknown + /// block → `.empty`. static func loadStubbedStoriesPage(_ id: String, completion: @escaping (EmbeddedBlockResolution) -> Void) { guard let url = URL(string: storiesPageURL) else { Logger.common(message: "[EmbeddedBlock] Invalid stories page URL, resolving id '\(id)' as empty", diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift index e0174f0e3..efc92d9e2 100644 --- a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift @@ -13,8 +13,10 @@ struct EmbeddedBlockWebContent: Equatable { enum Source: Equatable { case url(URL) - /// Разметка вместо адреса. Нужна отладочной подмене контента: сценарии приёмки — пустая - /// страница, молчащая страница, ответ уже после таймаута — в сеть не выкладываются. + /// Markup instead of an address. Needed by the debug content override: acceptance + /// scenarios — an empty page, a silent page, an answer that comes after the timeout — are + /// not published to the network. + /// TODO: - Remove this once we parse the url from the config case html(String) } diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift index c2f1afead..ff573be8a 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift @@ -8,31 +8,32 @@ import UIKit -/// Страница встроенного блока — всё, что провайдеру нужно от вебвью. +/// The embedded block page — everything the provider needs from the web view. /// -/// Единственный шов внутри блока и единственное место, где живёт WebKit: перевод сообщений -/// страницы в состояния блока так проверяется без реального вебвью и без сети. +/// The single seam inside the block and the single place where WebKit lives: this is what lets the +/// translation of page messages into block states be tested without a real web view and without +/// the network. protocol EmbeddedBlockPageHosting: AnyObject { - /// Вью страницы. Провайдер отдаёт её контейнеру как контент блока. + /// The page view. The provider hands it to the container as the block content. var view: UIView { get } - /// Сообщения от страницы. Приходят на главном потоке. + /// Messages from the page. Delivered on the main thread. var onMessage: ((EmbeddedBlockPageMessage) -> Void)? { get set } - /// Загрузка страницы не состоялась — соединение, домен, отменённая навигация. Только про это - /// навигация и сообщает: готова ли страница, решает сама страница своим `ready`. - /// Приходит на главном потоке. + /// The page failed to load — connection, domain, a cancelled navigation. That is all navigation + /// reports: whether the page is ready is decided by the page itself with its `ready`. + /// Delivered on the main thread. var onLoadFailure: (() -> Void)? { get set } - /// Документ загрузился. Готовности блока это не значит — страница может быть пустой или - /// сломанной, — поэтому обычный путь этот сигнал игнорирует: его слушает только отладочная - /// подмена готовности для страниц без веб-контракта. Приходит на главном потоке. + /// The document has loaded. That does not mean the block is ready — the page may be empty or + /// broken — so the regular path ignores this signal: the only listener is the debug readiness + /// override for pages without the web contract. Delivered on the main thread. var onLoadFinish: (() -> Void)? { get set } func load() - /// Останавливает загрузку. Страница и её мост остаются на месте: блок может вернуться в окно, - /// и тогда уже отрендеренная страница показывается снова без перезагрузки. + /// Stops the loading. The page and its bridge stay in place: the block may come back into the + /// window, and then the already rendered page is shown again without a reload. func cancel() } diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift index b5d5087f0..edc13a94d 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift @@ -9,30 +9,33 @@ import CoreGraphics import Foundation -/// Что страница встроенного блока сообщает нативной стороне. +/// What the embedded block page reports to the native side. /// -/// Ядро разбирает только core-слой — `ready`, `heightChanged` и `empty`, они нужны любому блоку. Всё -/// остальное с валидным конвертом уходит в механику как `action`: ядро не знает и не должно -/// знать словарь конкретной механики. +/// The core parses only the core layer — `ready`, `heightChanged` and `empty`, which every block +/// needs. Everything else with a valid envelope goes to the mechanic as an `action`: the core does +/// not know, and must not know, the vocabulary of a particular mechanic. /// -/// Формат пока свой и минимальный: страница шлёт `{"type": ..., ...}`. Сведение с общим -/// JS-мостом инаппов (`MindboxWebBridge`) — отдельная задача, до неё этот разбор трогать не нужно. +/// The format is our own and minimal for now: the page sends `{"type": ..., ...}`. Converging with +/// the shared in-app JS bridge (`MindboxWebBridge`) is a separate task; until then this parsing does +/// not need to be touched. enum EmbeddedBlockPageMessage: Equatable { - /// Страница отрисовалась и просит контейнер стать `height` точек высотой. + /// The page has rendered and asks the container to become `height` points tall. case ready(height: CGFloat) - /// Страница перемерилась уже после показа — например, подгрузился контент. + /// The page re-measured itself after it was shown — for example, more content was loaded. case heightChanged(height: CGFloat) - /// Странице нечего показать — например, блок выключен в админке. Это не ошибка. + /// The page has nothing to show — for example, the block is turned off in the admin panel. This + /// is not an error. case empty - /// Действие сверх core-слоя — его смысл знает механика блока. + /// An action beyond the core layer — its meaning is known to the block mechanic. case action(EmbeddedBlockPageAction) - /// Тело сообщения приходит из WebKit как `Any`. Строку разбираем как JSON, словарь берём как - /// есть: страница может присылать и то и другое, а падать на форме сообщения тут незачем. + /// The message body arrives from WebKit as `Any`. A string is parsed as JSON, a dictionary is + /// taken as is: the page may send either one, and there is no reason to fail on the message + /// shape here. init?(body: Any) { let payload: [String: Any] @@ -64,7 +67,7 @@ enum EmbeddedBlockPageMessage: Equatable { } } - /// JS отдаёт число как `Double`, но целые значения могут прийти и как `Int` — берём оба. + /// JS gives a number as a `Double`, but whole values may also arrive as an `Int` — take both. private static func height(from payload: [String: Any]) -> CGFloat? { if let height = payload["height"] as? Double { return CGFloat(height) @@ -78,8 +81,8 @@ enum EmbeddedBlockPageMessage: Equatable { } } -/// Конверт действия, которое ядро не разбирает, а передаёт механике: тип и весь payload -/// сообщения как есть. +/// The envelope of an action the core does not parse but passes to the mechanic: the type and the +/// whole message payload as is. struct EmbeddedBlockPageAction: Equatable { let type: String diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockReadinessOverrides.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockReadinessOverrides.swift index d081158cc..585037ad4 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockReadinessOverrides.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockReadinessOverrides.swift @@ -9,33 +9,33 @@ import Foundation import MindboxLogger -/// Отладочная подмена условия готовности блока. +/// A debug override of the block readiness condition. protocol EmbeddedBlockReadinessOverriding: AnyObject { - /// `true` — блок становится готовым по факту загруженного документа, не дожидаясь `ready` от - /// страницы. + /// `true` — the block becomes ready on the fact of a loaded document, without waiting for + /// `ready` from the page. var treatsLoadedPageAsReady: Bool { get } } -/// Временный костыль для страниц, которые ещё не умеют веб-контракт. +/// A temporary crutch for pages that do not implement the web contract yet. /// -/// Обычное правило блока — готовность объявляет только сама страница: загруженный документ ничего -/// не говорит о том, есть ли блоку что показать, поэтому молчащую страницу добивает таймаут -/// контейнера. Пока контракт не реализован на вебе, проверить вёрстку блока этим правилом -/// невозможно: любая страница сворачивается в ноль через таймаут. +/// The block's usual rule is that only the page itself declares readiness: a loaded document says +/// nothing about whether the block has anything to show, so a silent page is finished off by the +/// container timeout. While the contract is not implemented on the web side, checking the block +/// layout under this rule is impossible: any page collapses to zero on the timeout. /// -/// Подмена снимает ровно это ограничение и ничего больше: загрузился документ — показываем. Она -/// выключена по умолчанию и включается только явно из кода приложения, потому что со включённой -/// подменой сломанная страница выглядит как рабочая — а это ровно то, от чего защищает обычное -/// правило. +/// The override lifts exactly this restriction and nothing more: the document has loaded — we show +/// it. It is off by default and turned on only explicitly from the app code, because with the +/// override on a broken page looks like a working one — which is exactly what the usual rule +/// protects against. /// -/// Уедет вместе с первой страницей, которая научится присылать `ready`. +/// Goes away together with the first page that learns to send `ready`. final class EmbeddedBlockReadinessOverrides: EmbeddedBlockReadinessOverriding { static let shared = EmbeddedBlockReadinessOverrides() - /// Флаг ставят из кода приложения, а читает его провайдер на главном потоке — потоки могут не - /// совпасть. + /// The flag is set from the app code, while the provider reads it on the main thread — the + /// threads may differ. private let lock = NSLock() private var isLoadedPageTreatedAsReady = false diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift index 7e69678db..17850c32d 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift @@ -10,13 +10,14 @@ import UIKit import WebKit import MindboxLogger -/// Страница встроенного блока в WKWebView. +/// The embedded block page in a WKWebView. /// -/// Вебвью берётся из `InAppWebViewFactory` — того же места, где настраиваются вебвью инаппов: -/// блок получает тот же user agent и тот же `WKWebsiteDataStore`, а значит и общий HTTP-кеш. +/// The web view comes from `InAppWebViewFactory` — the same place where in-app web views are +/// configured: the block gets the same user agent and the same `WKWebsiteDataStore`, and therefore +/// a shared HTTP cache. final class EmbeddedBlockWebViewPage: NSObject, EmbeddedBlockPageHosting { - /// Имя обработчика своё, пока блоки не переехали на общий мост инаппов. + /// The handler name is our own until blocks move to the shared in-app bridge. private enum Constants { static let handlerName = "mindboxEmbeddedBlock" } @@ -42,10 +43,6 @@ final class EmbeddedBlockWebViewPage: NSObject, EmbeddedBlockPageHosting { attachBridge() } - deinit { - detachBridge() - } - func load() { switch content.source { case .url(let url): @@ -62,37 +59,30 @@ final class EmbeddedBlockWebViewPage: NSObject, EmbeddedBlockPageHosting { private func setUpWebView() { webView.navigationDelegate = self - // Фон прозрачный: сквозь зазоры в контенте должен просвечивать фон приложения, а не белый лист. + // The background is transparent: the app background, not a white sheet, should show through + // the gaps in the content. webView.isOpaque = false webView.backgroundColor = .clear webView.scrollView.backgroundColor = .clear - // Высота контейнера равна высоте контента, вертикально скроллить нечего — иначе блок - // пружинил бы под пальцем на каждом горизонтальном свайпе. + // 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. webView.scrollView.bounces = false webView.scrollView.alwaysBounceVertical = false webView.scrollView.showsVerticalScrollIndicator = false webView.scrollView.contentInsetAdjustmentBehavior = .never } - /// Мост живёт столько же, сколько страница: он ставится один раз и снимается только вместе с - /// ней. Раньше его снимала `cancel()` — из-за этого вернуть страницу в окно можно было только - /// перезагрузкой, иначе она оставалась глухой. От сообщений остановленной страницы защищает - /// провайдер, а не отсутствие моста. private func attachBridge() { let controller = webView.configuration.userContentController - // Идемпотентно: вебвью может прийти из переиспользования и нести обработчик с этим именем - // от прошлого владельца. + // Idempotent: the web view may come from reuse and carry a handler with this name from its + // previous owner. controller.removeScriptMessageHandler(forName: Constants.handlerName) - // WKUserContentController держит обработчик сильно, поэтому в него идёт слабый прокси — - // иначе страница и вебвью не освободятся никогда. + // WKUserContentController holds the handler strongly, so a weak proxy goes into it — + // otherwise the page and the web view would never be released. controller.add(EmbeddedBlockWebViewMessageProxy(receiver: self), name: Constants.handlerName) } - private func detachBridge() { - webView.configuration.userContentController.removeScriptMessageHandler(forName: Constants.handlerName) - } - fileprivate func receive(body: Any) { guard let message = EmbeddedBlockPageMessage(body: body) else { Logger.common(message: "[EmbeddedBlock] Unknown page message: \(body)", category: .embeddedBlocks) @@ -103,9 +93,9 @@ final class EmbeddedBlockWebViewPage: NSObject, EmbeddedBlockPageHosting { } } -/// Навигация судит только о своём: загрузка провалилась или документ доехал. Готовность блока из -/// этого не следует — о ней говорит сама страница своим `ready`, а загруженный документ слушает -/// одна лишь отладочная подмена готовности. +/// Navigation only judges its own business: the load failed or the document arrived. Block +/// readiness does not follow from that — it is declared by the page itself with its `ready`, and +/// the only listener of a loaded document is the debug readiness override. extension EmbeddedBlockWebViewPage: WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { @@ -125,12 +115,13 @@ extension EmbeddedBlockWebViewPage: WKNavigationDelegate { private extension EmbeddedBlockWebViewPage { - /// Отменённая навигация — не провал загрузки, и выдавать её за провал нельзя: блок схлопнулся бы - /// на ровном месте и остался бы дыркой нулевой высоты до конца жизни экрана. WebKit отдаёт - /// `NSURLErrorCancelled` в двух совершенно обычных случаях: навигацию вытеснила следующая — - /// клиентский редирект, страница загрузится сама, — и навигацию остановили мы, вызвав `cancel()` - /// на уехавшем с экрана блоке. Второй случай к тому же приходит уже после того, как блок - /// вернулся в окно, поэтому провайдер его своим `isStarted` не отфильтрует. + /// A cancelled navigation is not a load failure, and passing it off as one is not allowed: the + /// block would collapse out of nowhere and stay a zero-height hole until the end of the screen's + /// life. WebKit returns `NSURLErrorCancelled` in two perfectly ordinary cases: the navigation + /// was superseded by the next one — a client-side redirect, the page will load on its own — and + /// the navigation was stopped by us, by calling `cancel()` on a block that went off screen. The + /// second case also arrives after the block is back in the window, so the provider will not + /// filter it out with its `isStarted`. func reportLoadFailure(_ error: Error, phase: String) { let error = error as NSError @@ -146,7 +137,7 @@ private extension EmbeddedBlockWebViewPage { } } -/// Слабая прослойка между `WKUserContentController` и страницей. +/// A weak layer between `WKUserContentController` and the page. private final class EmbeddedBlockWebViewMessageProxy: NSObject, WKScriptMessageHandler { private weak var receiver: EmbeddedBlockWebViewPage? diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift index 56067b1e3..bd6cdf158 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift @@ -13,21 +13,3 @@ extension EmbeddedBlockWebContent { static let stub = EmbeddedBlockWebContent(url: URL(string: "https://mindbox.ru/block.html")!) } - -/// Открыватель ссылок, который ничего не открывает: тесты смотрят, что до системы дошло, а что нет. -final class EmbeddedBlockURLOpenerMock: EmbeddedBlockURLOpening { - - /// Что отвечать на вопрос «система это откроет?». `canOpenURL` пропускает системные схемы, и - /// тесты политики схем должны проверять именно политику, а не этот ответ. - var canOpenAnything = true - - private(set) var openedURLs: [URL] = [] - - func canOpen(_ url: URL) -> Bool { - canOpenAnything - } - - func open(_ url: URL) { - openedURLs.append(url) - } -} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift index a67c0bfd2..ed879bd01 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift @@ -13,8 +13,9 @@ import Testing @MainActor struct EmbeddedBlockResolverTests { - /// Главное обещание резолвера: сколько блоков ни спросило бы про один id, за данными идём один - /// раз. Пока конфиг синхронный это незаметно, с сетью — это разница между одним и N запросами. + /// The resolver's main promise: however many blocks ask about one id, we go for the data once. + /// While the config is synchronous this is invisible; over the network it is the difference + /// between one request and N. @Test("Blocks asking for the same id at once share a single load") func concurrentResolvesShareOneLoad() { let loader = ContentLoaderSpy() @@ -58,8 +59,8 @@ struct EmbeddedBlockResolverTests { #expect(answer == .content(.stub)) } - /// Перезагрузка блока не должна вечно брать из кэша прежний адрес: выключенный или - /// переехавший блок иначе не починится до перезапуска приложения. + /// A block reload must not keep pulling the old address from the cache: a block that was turned + /// off or moved would otherwise stay broken until the app is restarted. @Test("Force refresh asks for the data again and replaces the cache") func forceRefreshBypassesTheCache() { let loader = ContentLoaderSpy() @@ -79,9 +80,36 @@ struct EmbeddedBlockResolverTests { #expect(cached == .empty) } + /// The real config will answer from a background thread. The cache, the queue of waiters and the + /// block view live on the main one, so the answer has to move there instead of being handled + /// wherever it was delivered. + @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)) } + }, + overrides: EmbeddedBlockContentOverrides() + ) + + let deliveredOnMainThread: Bool = await withCheckedContinuation { continuation in + resolver.resolve("promo") { _ in + continuation.resume(returning: Thread.isMainThread) + } + } + + #expect(deliveredOnMainThread) + + // And the cache is already filled on the main thread: the next block gets the answer at once. + var cached: EmbeddedBlockResolution? + resolver.resolve("promo") { cached = $0 } + #expect(cached == .content(.stub)) + } + // MARK: - Debug overrides - /// Приёмка переключает сценарий на ходу, поэтому подмена сильнее и загрузки, и кэша. + /// Acceptance testing switches scenarios on the fly, so the override outranks both the load and + /// the cache. @Test("Debug override answers instead of the data and outranks the cache") func overrideOutranksEverything() { let loader = ContentLoaderSpy() @@ -96,7 +124,7 @@ struct EmbeddedBlockResolverTests { resolver.resolve("promo") { answers.append($0) } #expect(answers == [.empty, .empty]) - // За данными резолвер не ходил: ответ пришёл из подмены. + // The resolver did not go for the data: the answer came from the override. #expect(loader.requestedIds == ["promo"]) } @@ -146,7 +174,7 @@ struct EmbeddedBlockResolverTests { #expect(html == "empty page") } - /// Заглушка на месте конфига: пока его нет, любой id ведёт на страницу ленты сторизов. + /// The stub in place of the config: while there is none, any id leads to the stories feed page. @Test("The stubbed loader resolves any id to the stories page") func stubbedLoaderResolvesToTheStoriesPage() { var resolution: EmbeddedBlockResolution? @@ -161,8 +189,8 @@ struct EmbeddedBlockResolverTests { } } -/// Загрузчик, который отвечает только когда его попросят: так проверяется поведение резолвера, пока -/// загрузка ещё идёт. +/// A loader that answers only when asked to: this is how the resolver's behaviour while a load is +/// still in flight gets tested. private final class ContentLoaderSpy { private(set) var requestedIds: [String] = [] diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift index 403529756..f8579a7c1 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift @@ -10,15 +10,16 @@ import Testing import WebKit @testable import Mindbox -/// Страница судит только о своём: загрузка не состоялась или документ доехал. Здесь проверяется -/// именно это разделение — и главное, что отменённая навигация в провалы не попадает. +/// The page only judges its own business: the load failed or the document arrived. It is exactly +/// this separation that is checked here — and above all, that a cancelled navigation does not end up +/// among the failures. @Suite("Embedded block web view page", .tags(.embeddedBlocks)) @MainActor struct EmbeddedBlockWebViewPageTests { - /// Навигацию отменяют в двух совершенно обычных случаях: её вытеснил клиентский редирект и её - /// остановил наш собственный `cancel()` на уехавшем с экрана блоке. Ни то, ни другое не значит, - /// что блок сломан, — а провал сворачивает его насовсем. + /// A navigation is cancelled in two perfectly ordinary cases: it was superseded by a client-side + /// redirect, and it was stopped by our own `cancel()` on a block that went off screen. Neither + /// means the block is broken — while a failure collapses it for good. @Test("A cancelled provisional navigation is not a load failure") func cancelledProvisionalNavigationIsNotAFailure() { let bed = PageBed() @@ -37,7 +38,7 @@ struct EmbeddedBlockWebViewPageTests { #expect(bed.failures == 0) } - /// А настоящая ошибка сети — это ровно то, о чём страница обязана сказать. + /// A real network error, on the other hand, is exactly what the page must report. @Test("A real provisional navigation error is a load failure") func realProvisionalErrorIsAFailure() { let bed = PageBed() @@ -56,8 +57,7 @@ struct EmbeddedBlockWebViewPageTests { #expect(bed.failures == 1) } - /// Отмена одной навигации не должна глушить страницу: следующая настоящая ошибка приходит как - /// обычно. + /// Cancelling one navigation must not mute the page: the next real error arrives as usual. @Test("A cancellation does not swallow the failure that comes after it") func cancellationDoesNotSwallowLaterFailures() { let bed = PageBed() @@ -79,8 +79,8 @@ struct EmbeddedBlockWebViewPageTests { } } -/// Настоящая страница с настоящим вебвью, но без сети: тесты сами зовут методы навигационного -/// делегата — именно их разбор здесь и проверяется. +/// A real page with a real web view, but without the network: the tests call the navigation delegate +/// methods themselves — it is their handling that is checked here. @MainActor private final class PageBed { @@ -91,8 +91,8 @@ private final class PageBed { var cancellationError: Error { error(code: NSURLErrorCancelled) } - /// Через протокол, а не напрямую: у страницы есть и свойство `webView`, и методы делегата с тем - /// же именем, и вызывать их стоит там, где имя однозначно. + /// Through the protocol rather than directly: the page has both a `webView` property and + /// delegate methods with the same name, so they are better called where the name is unambiguous. private var navigation: WKNavigationDelegate { page } init() { From 2900d77378273cf3d8e0459d2ad050c5a70752f4 Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 11 Aug 2026 19:46:29 +0500 Subject: [PATCH 29/47] MOBILE-323: Fix the broken applyColors signature in the shimmer A stray text fragment had been committed into the declaration line of applyColors(), leaving the Mindbox target uncompilable since de08d916. Every commit after it, tests included, was stacked on code that never built. Restores the signature and keeps the deinit that the same change added. --- Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift index da5297a11..3ce835743 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift @@ -89,7 +89,7 @@ final class EmbeddedBlockShimmerView: UIView { NotificationCenter.default.removeObserver(self) } - private func applyColors() {}}]}คิดเห็น to=multi_tool_use.parallel output format? tool returns nothing? let's check. + private func applyColors() { gradientLayer.colors = [ baseColor.cgColor, highlightColor.cgColor, From e29768e233587bf0325a854144ca25f3b2c01d70 Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 11 Aug 2026 19:46:37 +0500 Subject: [PATCH 30/47] MOBILE-323: Split the nothing-to-show branch out of the layer host Showing nothing was falling through the same guard as showing a view, where the superview comparison against a nil view is always true and the condition therefore read as the opposite of what it meant. Behaviour is unchanged; adds the missing test for showing nothing on a host that already shows nothing. --- .../Container/EmbeddedBlockLayerHost.swift | 13 ++++++++++--- .../EmbeddedBlockLayerHostTests.swift | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift index 87619dc17..ee8e4bd67 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift @@ -27,13 +27,20 @@ final class EmbeddedBlockLayerHost { /// Показывает вью вместо той, что висит сейчас. `nil` — не показывать ничего. func show(_ view: UIView?) { - guard attachedView !== view || view?.superview !== container else { return } + // «Не показывать ничего» — это просто снять текущую вью: сравнивать здесь нечего, и общее + // условие ниже на nil читалось бы не тем, чем оно является. + guard let view else { + attachedView?.removeFromSuperview() + attachedView = nil + return + } + + // Та же вью и висит действительно у нас — пересобирать под неё констрейнты незачем. + guard attachedView !== view || view.superview !== container else { return } attachedView?.removeFromSuperview() attachedView = view - guard let view else { return } - view.translatesAutoresizingMaskIntoConstraints = false container.addSubview(view) NSLayoutConstraint.activate([ diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockLayerHostTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockLayerHostTests.swift index 2a23a3b31..afc796eb9 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockLayerHostTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockLayerHostTests.swift @@ -60,6 +60,20 @@ struct EmbeddedBlockLayerHostTests { #expect(container.constraints.isEmpty) } + /// Схлопнутый блок остаётся схлопнутым и продолжает получать `show(nil)` на каждую смену + /// состояния: снимать нечего, и трогать контейнер хост не должен. + @Test("Showing nothing when nothing is shown changes nothing") + func showingNothingOnEmptyHostChangesNothing() { + let container = UIView() + let host = EmbeddedBlockLayerHost(container: container) + + host.show(nil) + host.show(nil) + + #expect(container.subviews.isEmpty) + #expect(container.constraints.isEmpty) + } + /// Контейнер зовёт `show` на каждую смену состояния, и часть этих вызовов приходит с той же вью. /// Пересобирать под неё констрейнты незачем — их бы просто становилось больше. @Test("Showing the same view again changes nothing") From da5a0a85d23eb9a4b01f0e0b009538f1c9086f14 Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 11 Aug 2026 19:46:51 +0500 Subject: [PATCH 31/47] MOBILE-323: Make the ready timeout testable and report a zero height The timeout kept its notification centre and its scheduler hardwired, so the pause in the background was untestable and every budget test had to wait real time. Both become injectable, as the clock already was, and the container takes the whole budget instead of its duration. Drops all 20 sleeps from the embedded block tests, which now declare that time is up and assert the delay a countdown was armed with. Adds the background and foreground cases the consumed accounting exists for. A block created with a height of zero or less also reserves no space and stays invisible while still reporting its outcome, which is the likeliest integration mistake and now goes to the log as an error. --- .../Container/EmbeddedBlockReadyTimeout.swift | 47 +++-- .../Public/MindboxEmbeddedBlockView.swift | 27 ++- .../EmbeddedBlocks/EmbeddedBlockMocks.swift | 74 ++++++++ .../EmbeddedBlockReadyTimeoutTests.swift | 167 +++++++++++++----- .../MindboxEmbeddedBlockViewTests.swift | 77 +++++--- 5 files changed, 308 insertions(+), 84 deletions(-) diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift index 85b8bbbe3..fa378c8c2 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift @@ -25,6 +25,10 @@ import MindboxLogger /// Полный бюджет заново получает только новая попытка — `reset()`. /// /// Загрузку пауза не трогает: она идёт своим чередом, в фоне её тормозит система, а не SDK. + +/// Кто выполнит работу, когда истечёт заданный остаток бюджета. +typealias EmbeddedBlockTimeoutScheduling = (TimeInterval, DispatchWorkItem) -> Void + final class EmbeddedBlockReadyTimeout { /// Нужен ли отсчёт прямо сейчас: исход ещё неизвестен и блок на виду. Спрашивается заново на @@ -43,6 +47,15 @@ final class EmbeddedBlockReadyTimeout { /// целиком — им нужно уметь сказать, что время прошло. private let now: () -> Date + /// Нотификации отдельным швом по той же причине, что и часы: на глобальном центре уход в фон + /// проверить нельзя — тестовое уведомление долетело бы до блоков из тестов, идущих рядом. + private let notificationCenter: NotificationCenter + + /// Планировщик тем же швом и по той же причине: зашитая очередь заставляла бы тесты бюджета + /// ждать его настоящим временем — то есть спать на каждую проверку и флакать на нагруженной + /// машине. + private let schedule: EmbeddedBlockTimeoutScheduling + private var workItem: DispatchWorkItem? /// Сколько бюджета съели прошлые отрезки ожидания. @@ -53,23 +66,31 @@ final class EmbeddedBlockReadyTimeout { private var remaining: TimeInterval { max(0, duration - consumed) } - init(blockId: String, duration: TimeInterval, now: @escaping () -> Date = { Date() }) { + init(blockId: String, + duration: TimeInterval, + now: @escaping () -> Date = { Date() }, + notificationCenter: NotificationCenter = .default, + schedule: @escaping EmbeddedBlockTimeoutScheduling = { delay, work in + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + }) { self.blockId = blockId self.duration = duration self.now = now - - NotificationCenter.default.addObserver(self, - selector: #selector(applicationDidEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil) - NotificationCenter.default.addObserver(self, - selector: #selector(applicationWillEnterForeground), - name: UIApplication.willEnterForegroundNotification, - object: nil) + self.notificationCenter = notificationCenter + self.schedule = schedule + + notificationCenter.addObserver(self, + selector: #selector(applicationDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil) + notificationCenter.addObserver(self, + selector: #selector(applicationWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil) } deinit { - NotificationCenter.default.removeObserver(self) + notificationCenter.removeObserver(self) workItem?.cancel() } @@ -92,9 +113,11 @@ final class EmbeddedBlockReadyTimeout { self.onExpire() } + // Работа записывается в свойство до того, как её заведут: планировщик вправе выполнить её + // тут же, и она должна застать бюджет в согласованном состоянии. resumedAt = now() workItem = work - DispatchQueue.main.asyncAfter(deadline: .now() + remaining, execute: work) + schedule(remaining, work) } /// Останавливает отсчёт, запомнив потраченное. Попытку это не отменяет: `armIfNeeded()` diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift index 5e8717efc..a4d915795 100644 --- a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift @@ -135,7 +135,9 @@ public final class MindboxEmbeddedBlockView: UIView { /// - Parameters: /// - id: The block id from the admin panel. - /// - height: The height the block occupies while loading and shown. + /// - 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. public convenience init(id: String, height: CGFloat) { self.init(id: id, height: height, @@ -149,18 +151,37 @@ public final class MindboxEmbeddedBlockView: UIView { return nil } + /// - Parameter timeout: Бюджет ожидания целиком, а не одна его длительность: внутри него живут + /// и часы, и планировщик, и подписка на фон, а подменять их поодиночке через контейнер значило + /// бы протащить сквозь него три параметра ради тестов. init(id: String, height: CGFloat, contentProvider: EmbeddedBlockWebViewProvider, - readyTimeout: TimeInterval = TimeInterval(Constants.EmbeddedBlock.readyTimeoutSeconds)) { + timeout: EmbeddedBlockReadyTimeout? = nil) { self.id = id self.preferredHeight = height self.contentProvider = contentProvider - self.timeout = EmbeddedBlockReadyTimeout(blockId: id, duration: readyTimeout) + self.timeout = timeout ?? EmbeddedBlockReadyTimeout( + blockId: id, + duration: TimeInterval(Constants.EmbeddedBlock.readyTimeoutSeconds) + ) super.init(frame: .zero) + warnIfHeightReservesNothing() setUpContainer() } + /// Высоту блока задаёт хост, и нулевая — это не «блок схлопнут», а «место под него не выделено»: + /// блок отработает весь цикл, отдаст хосту свои события и останется невидимым. Симптомов у этого + /// нет никаких — блока просто не видно, — а причина самая частая из возможных, поэтому SDK + /// говорит о ней вслух. + private func warnIfHeightReservesNothing() { + guard preferredHeight <= 0 else { return } + + Logger.common(message: "[EmbeddedBlock] Block '\(id)' was created with height \(preferredHeight): it reserves no space and stays invisible even when its content loads. Pass the height the block should occupy.", + level: .error, + category: .embeddedBlocks) + } + deinit { timeout.pause() contentProvider.stop() diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift index edc476c82..3c19105a5 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift @@ -145,6 +145,80 @@ final class EmbeddedBlockURLOpenerMock: EmbeddedBlockURLOpening { } } +/// Часы, которые идут только когда их просят. +final class TestClock { + + private(set) var now = Date(timeIntervalSince1970: 1_000_000) + + func advance(_ seconds: TimeInterval) { + now = now.addingTimeInterval(seconds) + } +} + +/// Планировщик, который сам не срабатывает никогда: «время вышло» объявляет тест. +/// +/// Благодаря ему бюджет ожидания проверяется без единого сна: и его собственные тесты, и тесты +/// контейнера, которому бюджет отдают снаружи. +final class TestScheduler { + + /// Задержка последнего завода — она же остаток бюджета, отданный отсчёту. + private(set) var lastDelay: TimeInterval? + + private var pending: [DispatchWorkItem] = [] + + func schedule(_ delay: TimeInterval, _ work: DispatchWorkItem) { + lastDelay = delay + pending.append(work) + } + + /// Выполняет заведённую работу, пропуская отменённую: `pause()` и `reset()` отменяют её ровно + /// так же, как отменяли бы работу настоящей очереди. + func fireAll() { + let scheduled = pending + pending = [] + scheduled.forEach { work in + guard !work.isCancelled else { return } + + work.perform() + } + } +} + +/// Бюджет ожидания с подменёнными часами, планировщиком и центром нотификаций — всё, чем он +/// отличается от настоящего, собрано в одном месте. +final class EmbeddedBlockTimeoutBed { + + let clock: TestClock + let scheduler: TestScheduler + + /// Свой на каждый стенд: фон и возврат из него должны доставаться только этому бюджету. + let center: NotificationCenter + + let timeout: EmbeddedBlockReadyTimeout + + init(blockId: String = "block-id", duration: TimeInterval = 5) { + let clock = TestClock() + let scheduler = TestScheduler() + let center = NotificationCenter() + self.clock = clock + self.scheduler = scheduler + self.center = center + timeout = EmbeddedBlockReadyTimeout(blockId: blockId, + duration: duration, + now: { clock.now }, + notificationCenter: center, + schedule: { scheduler.schedule($0, $1) }) + } + + func enterBackground() { + center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + func enterForeground() { + center.post(name: UIApplication.willEnterForegroundNotification, object: nil) + } +} + /// Провайдер со всеми подменёнными зависимостями — общая заготовка для тестов провайдера и /// контейнера. Контейнер тестируется через настоящий провайдер: единственный шов внутри блока — /// страница, и подменять больше нечего. diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift index c51a61b8b..47f6a8434 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift @@ -8,14 +8,19 @@ import Testing import Foundation +import UIKit @testable import Mindbox -/// Сколько бюджета «уже потрачено», тесты задают подменёнными часами, а ждут только тот огрызок, -/// который остался. Иначе проверка «продолжается остаток, а не выдаётся полный бюджет» сводилась бы -/// к измерению задержек секундомером. +/// Ни часы, ни планировщик здесь не настоящие. Сколько бюджета «уже потрачено», тесты задают +/// подменёнными часами, а момент «время вышло» наступает по их команде. Реальным временем не +/// ждётся ничего: бюджет — это арифметика над потраченным, и проверять её секундомером значило бы +/// платить полсекунды за тест и флакать на загруженном раннере. /// -/// Уход в фон и возврат из него здесь не проверяются: это глобальные нотификации, они долетят до -/// блоков из тестов, идущих рядом. Провод от них к паузе проверяет контейнер, у которого блок один. +/// Отсюда же главная проверка большинства тестов — не «истёк или нет», а с какой задержкой завели +/// отсчёт: именно она и есть остаток бюджета. +/// +/// Уход в фон и возврат из него идут через свой центр нотификаций у каждого стенда: на глобальном +/// такое уведомление долетело бы до блоков из тестов, идущих рядом. private let budget: TimeInterval = 0.4 @Suite("Embedded block ready timeout", .tags(.embeddedBlocks)) @@ -23,32 +28,35 @@ private let budget: TimeInterval = 0.4 struct EmbeddedBlockReadyTimeoutTests { @Test("A budget that is never paused expires on its own") - func unpausedBudgetExpires() async throws { + func unpausedBudgetExpires() { let bed = TimeoutBed() bed.timeout.armIfNeeded() - try await Task.sleep(nanoseconds: 600_000_000) + + #expect(bed.scheduler.lastDelay == budget) + + bed.scheduler.fireAll() #expect(bed.expirations == 1) } /// Пока блока никто не ждёт, бюджет не тратится и не истекает. @Test("A paused budget does not expire") - func pausedBudgetDoesNotExpire() async throws { + func pausedBudgetDoesNotExpire() { let bed = TimeoutBed() bed.timeout.armIfNeeded() bed.timeout.pause() - try await Task.sleep(nanoseconds: 600_000_000) + bed.scheduler.fireAll() #expect(bed.expirations == 0) #expect(bed.timeout.isRunning == false) } /// Главное: пауза останавливает счёт, а не начинает его заново. Потрачено почти всё, поэтому - /// после возобновления блоку остаётся крохотный остаток — а не полный бюджет. + /// после возобновления отсчёт заводится на крохотный остаток — а не на полный бюджет. @Test("Resuming continues the remaining budget instead of granting a new one") - func resumeContinuesTheRemainder() async throws { + func resumeContinuesTheRemainder() { let bed = TimeoutBed() bed.timeout.armIfNeeded() @@ -56,8 +64,10 @@ struct EmbeddedBlockReadyTimeoutTests { bed.timeout.pause() bed.timeout.armIfNeeded() - // Ждём меньше полного бюджета: он к этому моменту истечь ещё не успел бы. - try await Task.sleep(nanoseconds: 200_000_000) + + #expect(isClose(bed.scheduler.lastDelay, to: 0.02)) + + bed.scheduler.fireAll() #expect(bed.expirations == 1) } @@ -65,7 +75,7 @@ struct EmbeddedBlockReadyTimeoutTests { /// Ровно тот сценарий, из-за которого пауза со сбросом непригодна: пользователь, дёргающий /// приложение туда-обратно, не должен уметь продлевать ожидание блока бесконечно. @Test("Repeated pause and resume cannot stretch the budget past its duration") - func repeatedPausesCannotStretchTheBudget() async throws { + func repeatedPausesCannotStretchTheBudget() { let bed = TimeoutBed() for _ in 0..<5 { @@ -79,14 +89,17 @@ struct EmbeddedBlockReadyTimeoutTests { // Пять отрезков по четверти — бюджет выбран целиком, и следующий завод не даёт блоку больше // ни секунды. bed.timeout.armIfNeeded() - try await Task.sleep(nanoseconds: 200_000_000) + + #expect(bed.scheduler.lastDelay == 0) + + bed.scheduler.fireAll() #expect(bed.expirations == 1) } /// А новая попытка — другое дело: её ждут с полного бюджета. @Test("Reset gives the next attempt a full budget again") - func resetGrantsAFullBudget() async throws { + func resetGrantsAFullBudget() { let bed = TimeoutBed() bed.timeout.armIfNeeded() @@ -94,23 +107,17 @@ struct EmbeddedBlockReadyTimeoutTests { bed.timeout.reset() bed.timeout.armIfNeeded() - try await Task.sleep(nanoseconds: 200_000_000) - // Полного бюджета ещё не прошло — попытка жива. - #expect(bed.expirations == 0) - - try await Task.sleep(nanoseconds: 400_000_000) - - #expect(bed.expirations == 1) + #expect(bed.scheduler.lastDelay == budget) } @Test("Reset stops a running budget") - func resetStopsTheCountdown() async throws { + func resetStopsTheCountdown() { let bed = TimeoutBed() bed.timeout.armIfNeeded() bed.timeout.reset() - try await Task.sleep(nanoseconds: 600_000_000) + bed.scheduler.fireAll() #expect(bed.expirations == 0) } @@ -118,59 +125,129 @@ struct EmbeddedBlockReadyTimeoutTests { /// Бюджет нужен только пока исход неизвестен и блок на виду — это знает контейнер, и его ответ /// спрашивается на каждом заводе. @Test("A budget nobody needs is not armed at all") - func unneededBudgetIsNotArmed() async throws { + func unneededBudgetIsNotArmed() { let bed = TimeoutBed(isNeeded: false) bed.timeout.armIfNeeded() #expect(bed.timeout.isRunning == false) + #expect(bed.scheduler.lastDelay == nil) - try await Task.sleep(nanoseconds: 600_000_000) + bed.scheduler.fireAll() #expect(bed.expirations == 0) } + // MARK: - Background + + /// Пока приложение в фоне, блока никто не ждёт — значит и бюджет тратиться не должен. + @Test("Going to the background pauses a running budget") + func backgroundPausesTheCountdown() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.enterBackground() + + #expect(bed.timeout.isRunning == false) + + bed.scheduler.fireAll() + + #expect(bed.expirations == 0) + } + + /// И ровно то, ради чего заведён учёт потраченного: возврат из фона продолжает бюджет с остатка, + /// а не выдаёт его заново. + @Test("Returning from the background continues the remaining budget") + func foregroundContinuesTheRemainder() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.clock.advance(budget - 0.02) + bed.enterBackground() + bed.enterForeground() + + #expect(isClose(bed.scheduler.lastDelay, to: 0.02)) + + bed.scheduler.fireAll() + + #expect(bed.expirations == 1) + } + + /// Фон, заставший блок вне отсчёта, тратить не может ничего: следующая попытка получает бюджет + /// целиком. + @Test("Going to the background outside a countdown consumes nothing") + func backgroundOutsideCountdownConsumesNothing() { + let bed = TimeoutBed() + + bed.enterBackground() + bed.clock.advance(budget) + + bed.timeout.armIfNeeded() + + #expect(bed.scheduler.lastDelay == budget) + } + + /// Возврат из фона к блоку, которого уже никто не ждёт, отсчёт не воскрешает: нужен ли он, + /// решает контейнер, и его ответ спрашивается на каждом заводе. + @Test("Returning from the background does not arm a budget nobody needs") + func foregroundDoesNotArmAnUnneededBudget() { + let bed = TimeoutBed(isNeeded: false) + + bed.enterForeground() + + #expect(bed.timeout.isRunning == false) + #expect(bed.scheduler.lastDelay == nil) + } + + // MARK: - Arming + /// Завод идемпотентен: вход в окно, возврат из фона и перезагрузка зовут его как попало, и /// второй вызов не должен ставить второй отсчёт. @Test("Arming twice runs a single countdown") - func armingTwiceRunsOneCountdown() async throws { + func armingTwiceRunsOneCountdown() { let bed = TimeoutBed() bed.timeout.armIfNeeded() bed.timeout.armIfNeeded() - try await Task.sleep(nanoseconds: 600_000_000) + bed.scheduler.fireAll() + // Завелись бы два отсчёта — истечений было бы столько же. #expect(bed.expirations == 1) } } -/// Бюджет с управляемыми часами и счётчиком истечений. +/// Остаток бюджета — арифметика над `Double`, поэтому сравнивается с допуском. +private func isClose(_ value: TimeInterval?, to expected: TimeInterval) -> Bool { + guard let value else { return false } + + return abs(value - expected) < 0.0001 +} + +/// Общий стенд бюджета плюс счётчик истечений: здесь бюджет проверяется сам по себе, поэтому +/// `isNeeded` задаётся тестом напрямую, а не спрашивается у контейнера. @MainActor private final class TimeoutBed { - let clock = TestClock() - let timeout: EmbeddedBlockReadyTimeout + private let bed = EmbeddedBlockTimeoutBed(duration: budget) private(set) var expirations = 0 + var timeout: EmbeddedBlockReadyTimeout { bed.timeout } + var clock: TestClock { bed.clock } + var scheduler: TestScheduler { bed.scheduler } + init(isNeeded: Bool = true) { - let clock = self.clock - timeout = EmbeddedBlockReadyTimeout(blockId: "block-id", - duration: budget, - now: { clock.now }) - timeout.isNeeded = { isNeeded } - timeout.onExpire = { [weak self] in + bed.timeout.isNeeded = { isNeeded } + bed.timeout.onExpire = { [weak self] in self?.expirations += 1 } } -} - -/// Часы, которые идут только когда их просят. -private final class TestClock { - private(set) var now = Date(timeIntervalSince1970: 1_000_000) + func enterBackground() { + bed.enterBackground() + } - func advance(_ seconds: TimeInterval) { - now = now.addingTimeInterval(seconds) + func enterForeground() { + bed.enterForeground() } } diff --git a/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift index aaf00a539..64b00ce3d 100644 --- a/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift +++ b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift @@ -519,13 +519,14 @@ struct MindboxEmbeddedBlockViewTests { /// Контейнер, а не контент, гарантирует, что вёрстка хоста не будет ждать вечно: молчащая /// страница за бюджетом сворачивается и сообщает об ошибке. @Test("Silent block times out, collapses and reports didFail") - func silentBlockTimesOut() async throws { - let block = BlockFixture(readyTimeout: 0.05) + func silentBlockTimesOut() async { + let block = BlockFixture() let delegate = EmbeddedBlockViewDelegateMock() block.view.delegate = delegate block.attachToWindow() - try await Task.sleep(nanoseconds: 200_000_000) + block.expireTimeout() + await mainQueueTurn() #expect(block.view.intrinsicContentSize.height == 0) #expect(delegate.events == [.failed]) @@ -534,14 +535,16 @@ struct MindboxEmbeddedBlockViewTests { } @Test("Block shown in time is not failed by the timeout") - func shownBlockIsNotTimedOut() async throws { - let block = BlockFixture(readyTimeout: 0.05) + func shownBlockIsNotTimedOut() async { + let block = BlockFixture() let delegate = EmbeddedBlockViewDelegateMock() block.view.delegate = delegate block.attachToWindow() block.page?.send(.ready(height: 96)) - try await Task.sleep(nanoseconds: 200_000_000) + // Показанный блок снял бюджет, поэтому объявленное «время вышло» его уже не касается. + block.expireTimeout() + await mainQueueTurn() #expect(block.view.intrinsicContentSize.height == 120) #expect(delegate.events == [.loaded]) @@ -550,14 +553,15 @@ struct MindboxEmbeddedBlockViewTests { /// Уход из окна уже остановил контент — снятый таймаут не должен валить то, что не работает. @Test("Leaving the window disarms the timeout") - func leavingWindowDisarmsTimeout() async throws { - let block = BlockFixture(readyTimeout: 0.05) + func leavingWindowDisarmsTimeout() async { + let block = BlockFixture() let delegate = EmbeddedBlockViewDelegateMock() block.view.delegate = delegate block.attachToWindow() block.removeFromWindow() - try await Task.sleep(nanoseconds: 200_000_000) + block.expireTimeout() + await mainQueueTurn() #expect(delegate.events.isEmpty) #expect(block.view.intrinsicContentSize.height == 120) @@ -568,20 +572,22 @@ struct MindboxEmbeddedBlockViewTests { /// ни разу не побывав на экране. Что бюджет при этом продолжается с остатка, а не выдаётся /// заново, проверяют тесты самого `EmbeddedBlockReadyTimeout`. @Test("Timeout pauses in the background and resumes on return") - func timeoutPausesInBackground() async throws { - let block = BlockFixture(readyTimeout: 0.05) + func timeoutPausesInBackground() async { + let block = BlockFixture() let delegate = EmbeddedBlockViewDelegateMock() block.view.delegate = delegate block.attachToWindow() - NotificationCenter.default.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - try await Task.sleep(nanoseconds: 200_000_000) + block.enterBackground() + block.expireTimeout() + await mainQueueTurn() #expect(block.view.intrinsicContentSize.height == 120) #expect(delegate.events.isEmpty) - NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) - try await Task.sleep(nanoseconds: 200_000_000) + block.enterForeground() + block.expireTimeout() + await mainQueueTurn() #expect(block.view.intrinsicContentSize.height == 0) #expect(delegate.events == [.failed]) @@ -589,14 +595,17 @@ struct MindboxEmbeddedBlockViewTests { /// Блок вне окна ничего не грузит, поэтому и бюджет ему не нужен. @Test("Returning from the background does not arm a timeout outside a window") - func foregroundOutsideWindowArmsNothing() async throws { - let block = BlockFixture(readyTimeout: 0.05) + func foregroundOutsideWindowArmsNothing() async { + let block = BlockFixture() let delegate = EmbeddedBlockViewDelegateMock() block.view.delegate = delegate - NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: nil) - try await Task.sleep(nanoseconds: 200_000_000) + block.enterForeground() + block.expireTimeout() + await mainQueueTurn() + // Отсчёт не просто не сработал — его вовсе не заводили. + #expect(block.timeoutBed.scheduler.lastDelay == nil) #expect(delegate.events.isEmpty) #expect(block.view.intrinsicContentSize.height == 120) } @@ -638,15 +647,16 @@ struct MindboxEmbeddedBlockViewTests { /// Новая попытка получает и новый бюджет — иначе перезагруженный блок висел бы в загрузке вечно. @Test("Reload arms the timeout again") - func reloadArmsTheTimeoutAgain() async throws { - let block = BlockFixture(readyTimeout: 0.05) + func reloadArmsTheTimeoutAgain() async { + let block = BlockFixture() let delegate = EmbeddedBlockViewDelegateMock() block.view.delegate = delegate block.attachToWindow() block.page?.send(.ready(height: 96)) block.view.reload() - try await Task.sleep(nanoseconds: 200_000_000) + block.expireTimeout() + await mainQueueTurn() #expect(block.view.intrinsicContentSize.height == 0) #expect(delegate.events.last == .failed) @@ -671,6 +681,11 @@ struct MindboxEmbeddedBlockViewTests { private final class BlockFixture { let bed: EmbeddedBlockTestBed + + /// Бюджет отдаётся вью снаружи, поэтому «время вышло» здесь наступает по команде теста, а не + /// через сон: `expireTimeout()`. + let timeoutBed: EmbeddedBlockTimeoutBed + let view: MindboxEmbeddedBlockView private let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) @@ -678,14 +693,15 @@ private final class BlockFixture { var page: EmbeddedBlockPageMock? { bed.page } init(height: CGFloat = 120, - readyTimeout: TimeInterval = 5, resolution: EmbeddedBlockResolution = .content(.stub)) { let bed = EmbeddedBlockTestBed(resolution: resolution) + let timeoutBed = EmbeddedBlockTimeoutBed() self.bed = bed + self.timeoutBed = timeoutBed self.view = MindboxEmbeddedBlockView(id: "block-id", height: height, contentProvider: bed.provider, - readyTimeout: readyTimeout) + timeout: timeoutBed.timeout) } func attachToWindow() { @@ -695,4 +711,17 @@ private final class BlockFixture { func removeFromWindow() { view.removeFromSuperview() } + + /// Объявляет, что бюджет ожидания вышел. + func expireTimeout() { + timeoutBed.scheduler.fireAll() + } + + func enterBackground() { + timeoutBed.enterBackground() + } + + func enterForeground() { + timeoutBed.enterForeground() + } } From 03633ddc54279f53ec6fc86507ac83bfc0ff198b Mon Sep 17 00:00:00 2001 From: Akylbek Utekeshev Date: Wed, 12 Aug 2026 01:31:15 +0500 Subject: [PATCH 32/47] Update Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift Co-authored-by: Sergei Semko <28645140+justSmK@users.noreply.github.com> --- .../Resolver/EmbeddedBlockResolver.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift index 85c571244..58ae4db55 100644 --- a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift @@ -80,6 +80,18 @@ final class EmbeddedBlockResolver: EmbeddedBlockResolving { } func resolve(_ id: String, forceRefresh: Bool, completion: @escaping (EmbeddedBlockResolution) -> Void) { + // The cache and the queue of waiters are plain dictionaries: every path through them has to + // run on one thread — the same one the block views wait on. + guard Thread.isMainThread else { + Logger.common(message: "[EmbeddedBlock] Resolver was asked about id '\(id)' off the main thread, continuing on it", + level: .error, + category: .embeddedBlocks) + DispatchQueue.main.async { [weak self] in + self?.resolve(id, forceRefresh: forceRefresh, completion: completion) + } + return + } + // The debug override outranks both the data and the cache: acceptance testing switches // scenarios on the fly, and a cached answer would get in the way. if let overridden = overrides.resolution(for: id) { From e7590ab9a6c7968d354bfd1ad6c9f05923f4b02e Mon Sep 17 00:00:00 2001 From: Vailence Date: Wed, 12 Aug 2026 01:32:23 +0500 Subject: [PATCH 33/47] MOBILE-323 PR Fix --- .../EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift | 6 +++--- .../EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift index 7488266d1..97aa344c1 100644 --- a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift +++ b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift @@ -22,9 +22,9 @@ final class EmbeddedBlockActionRouter: EmbeddedBlockActionHandling { func handle(_ action: EmbeddedBlockPageAction) { switch action.type { case ActionType.openUrl: - print("embeddedBlock action") - // TODO: - Add action here later -// openUrl(from: action) + // TODO(MOBILE-328): open the url once blocks move to the shared in-app bridge. + Logger.common(message: "[EmbeddedBlock] openUrl is not handled yet, ignoring: \(action.type)", + category: .embeddedBlocks) default: Logger.common(message: "[EmbeddedBlock] Unknown page action: \(action.type)", category: .embeddedBlocks) diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift index 17850c32d..7facff686 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift @@ -48,6 +48,7 @@ final class EmbeddedBlockWebViewPage: NSObject, EmbeddedBlockPageHosting { case .url(let url): webView.load(URLRequest(url: url)) case .html(let html): + // у страницы, поданной разметкой, origin about:blank, поэтому ни localStorage, ни сетевых запросов на свой домен у неё не будет. В (MOBILE-328) изменится или полностью удалится этот кейс webView.loadHTMLString(html, baseURL: nil) } } From ac03b30980805ceee5fb469b8aa84c38b3482478 Mon Sep 17 00:00:00 2001 From: Akylbek Utekeshev Date: Wed, 12 Aug 2026 14:02:58 +0500 Subject: [PATCH 34/47] MOBILE-323: Embedded block content provider (#754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * MOBILE-323: Add the embedded block content provider Переводит сообщения страницы в состояния блока. Не рисует контент и не знает механик: спрашивает у резолвера, что стоит за id, разбирает core-слой, а действия сверх него отдаёт универсальному обработчику. Готовность определяет только сама страница: ready — показываем, empty — показывать нечего. Навигация судит исключительно о своём, молчащая страница готовой не становится — её добьёт бюджет ожидания у контейнера. Нулевая высота в ready значит сломанную вёрстку: «показывать нечего» страница сообщает явным empty. Исход попытки хранится явно, а не пачкой флагов. Он переживает stop(), потому что это свойство страницы, а не факта нахождения в окне: уход блока с экрана не выбрасывает уже отрендеренную страницу, и возврат показывает её снова, без сети и без шиммера. При этом провал и empty страницу не убивают — она жива и может продолжать говорить, — поэтому известный исход служит и признаком того, что блока на экране больше нет: действия от невидимого блока не выполняются. За ним не стоит ни одного касания пользователя, а openUrl увёл бы человека из приложения на пустом месте. Экземпляр принадлежит одному контейнеру: ничего общего между контейнерами здесь нет, это и делает возможными несколько независимых блоков с одним id. Номер попытки отсекает резолв, доехавший уже после остановки или перезагрузки. Счётчик живых блоков на id — диагностика, а не механика: два блока с одним id законны, но чаще это скопированный id или переиспользованная ячейка, а у обоих случаев нет симптомов кроме «блок оказался не там, где ждали». * MOBILE-323: Add the content provider factory Собирает провайдер под конкретный блок: резолвер и обработчик действий общие на все блоки, а провайдер — свой на каждый. Это и делает блоки с одинаковым id независимыми друг от друга. Потребителей у фабрики появится два, и оба в следующей части: DI-регистрация и публичный init контейнера. Здесь она едет вместе с провайдером, потому что описывает его модель владения, а не способ его достать. * MOBILE-323: Add test doubles for the block content provider Дописывает моки до среза этой части: страница без WebKit, фабрика страниц со счётчиком созданных, резолвер с отложенным ответом, обработчик действий и общая заготовка провайдера со всеми подменёнными зависимостями. Резолвер умеет держать ответ до отдельной команды: так проверяется резолв, доехавший уже после остановки или перезагрузки блока. Фабрика считает страницы, потому что перезагрузка обязана создать новую, а возврат блока в окно — нет. * MOBILE-323: Add tests for the embedded block content provider Весь путь блока без WebKit и без сети: резолв, показ по ready, пустой блок, сломанная нулевая высота, провал загрузки, действия страницы, отладочная подмена готовности, остановка и перезапуск, перезагрузка. Отдельно закреплено то, на что опираются соседние слои: после stop() провайдер молчит целиком — иначе контейнер не смог бы свернуть просроченный блок; уже отрендеренная страница на возврате в окно показывается как есть, без сети и шиммера, а блок, который показать не удалось, получает новую попытку; выброшенная перезагрузкой страница не может доложить в новую попытку ни сообщением, ни провалом, ни через подмену готовности. Действия проверяются с обеих сторон: из показанного блока доходят до обработчика, из схлопнутого — нет, ни после empty, ни после провала, ни после нулевой высоты, — а новая попытка снова их принимает. Счётчику живых блоков в каждом тесте свой id: он общий на процесс, иначе тесты, идущие параллельно, считали бы блоки друг друга. * MOBILE-323: Add tests for the content provider factory Фабрика приехала без тестов, а её обещание — то, на чём держится независимость блоков с одинаковым id: провайдер свой на каждый блок, резолвер общий на все. Проверяется и то и другое, плюс что провайдер собран под запрошенный id. Резолвер в тестах отвечает «пусто»: страницу для такого блока не создают, поэтому настоящий вебвью фабрике здесь не нужен. * MOBILE-323: Trim duplicated comments in the content provider Док класса пересказывал то, что уже сказано ниже по файлу: правила готовности — у apply(height:) и handleLoadFinish, владение высотой — у heightChanged, а мысль «уход из окна не выбрасывает страницу» шла трижды — в доке класса, в доке свойства page и во встроенном комментарии в start(). Осталось два абзаца: чем провайдер является и что после stop() он обязан молчать — это межтиповой инвариант, из одного файла его не видно. Убраны и три дока, ушедшие из своей ответственности или пересказывавшие подпись: contentView рассказывал, как контейнер растягивает вью; второй абзац reload() — про плейсхолдер и события хосту; isShown повторял собственное имя. Встроенные комментарии не тронуты: каждый объясняет отсутствующую строку, развилку или внешнюю причину — то, чего в коде не прочитать. * MOBILE-323 PR Fix * MOBILE-323 Remove unused tests --------- Co-authored-by: Vailence --- .../EmbeddedBlockContentProviderFactory.swift | 36 +++ .../EmbeddedBlockWebViewProvider.swift | 277 ++++++++++++++++++ .../EmbeddedBlocks/EmbeddedBlockMocks.swift | 4 +- .../EmbeddedBlockResolverTests.swift | 48 +++ 4 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift create mode 100644 Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift new file mode 100644 index 000000000..9989486d4 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift @@ -0,0 +1,36 @@ +// +// EmbeddedBlockContentProviderFactory.swift +// Mindbox +// +// Created by vailence on 06.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Foundation + +/// Собирает провайдер контента под конкретный блок. +/// +/// Провайдер принадлежит одному контейнеру, поэтому создаётся на каждый блок заново — это и +/// делает блоки с одинаковым id независимыми. +protocol EmbeddedBlockContentProviderMaking { + func makeProvider(id: String) -> EmbeddedBlockWebViewProvider +} + +final class EmbeddedBlockContentProviderFactory: EmbeddedBlockContentProviderMaking { + + private let resolver: EmbeddedBlockResolving + private let actionHandler: EmbeddedBlockActionHandling + + init(resolver: EmbeddedBlockResolving, + actionHandler: EmbeddedBlockActionHandling) { + self.resolver = resolver + self.actionHandler = actionHandler + } + + func makeProvider(id: String) -> EmbeddedBlockWebViewProvider { + EmbeddedBlockWebViewProvider(id: id, + resolver: resolver, + actionHandler: actionHandler, + makePage: { EmbeddedBlockWebViewPage(content: $0) }) + } +} diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift new file mode 100644 index 000000000..d5a92cadd --- /dev/null +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift @@ -0,0 +1,277 @@ +// +// EmbeddedBlockWebViewProvider.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import UIKit +import MindboxLogger + +/// Контент встроенного блока — веб-страница, найденная по id блока. +/// +/// Провайдер не рисует контент и не знает механик: он спрашивает у резолвера, что стоит за id, +/// переводит core-сообщения страницы в состояния контейнера, а действия сверх core-слоя отдаёт +/// универсальному обработчику. +/// +/// Экземпляр принадлежит одному контейнеру, поэтому `start()` и `stop()` просто повторяют его +/// видимость и могут вызываться по кругу. После `stop()` провайдер обязан молчать до следующего +/// `start()` — на это опирается контейнер, когда сворачивает просроченный блок. +final class EmbeddedBlockWebViewProvider { + + /// Сообщает каждую смену состояния на главном потоке. Ставится контейнером. + var onStateChange: ((EmbeddedBlockState) -> Void)? + + var contentView: UIView? { isReady ? page?.view : nil } + + private let id: String + private let resolver: EmbeddedBlockResolving + private let actionHandler: EmbeddedBlockActionHandling + private let readinessOverrides: EmbeddedBlockReadinessOverriding + private let makePage: (EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting + + /// Страница переживает рестарты: контейнер стартует и останавливает блок по видимости, и + /// пересоздавать вебвью на каждое возвращение в окно незачем. + private var page: EmbeddedBlockPageHosting? + + private var isStarted = false + + /// Чем кончилась текущая попытка: `nil` — ещё ничем. + /// + /// Исход переживает `stop()`: он свойство страницы, а не факта нахождения в окне. Провал и + /// `empty` при этом не убивают страницу — она жива и может продолжать говорить, — поэтому + /// известный исход нужен и как признак того, что блока на экране больше нет. + private var outcome: EmbeddedBlockState? + + private var isReady: Bool { outcome == .ready } + + /// Номер текущей попытки загрузки. Резолв может ответить уже после `stop()` или после + /// перезагрузки — по номеру видно, что ответ относится к прошлой попытке, и его надо выбросить. + private var loadGeneration = 0 + + init(id: String, + resolver: EmbeddedBlockResolving, + actionHandler: EmbeddedBlockActionHandling, + readinessOverrides: EmbeddedBlockReadinessOverriding = EmbeddedBlockReadinessOverrides.shared, + makePage: @escaping (EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting) { + self.id = id + self.resolver = resolver + self.actionHandler = actionHandler + self.readinessOverrides = readinessOverrides + self.makePage = makePage + + EmbeddedBlockWebViewProvider.blockCreated(id: id) + } + + deinit { + EmbeddedBlockWebViewProvider.blockReleased(id: id) + } + + func start() { + start(forceRefresh: false) + } + + func stop() { + guard isStarted else { return } + + isStarted = false + // Исход не сбрасываем: он свойство страницы, а не факта нахождения в окне. Иначе каждый + // проход блока по экрану стоил бы полной перезагрузки. + loadGeneration += 1 + page?.cancel() + } + + /// Начинает загрузку с нуля: страница выбрасывается, а адрес запрашивается заново в обход кэша + /// резолвера — иначе переехавший или выключенный блок вечно доставал бы прежний адрес. + func reload() { + Logger.common(message: "[EmbeddedBlock] Block '\(id)' is reloading", category: .embeddedBlocks) + + // Прежняя страница больше не имеет отношения к делу — сначала отключаем её от себя, чтобы + // её запоздавшие сообщения не попали в новую попытку. + page?.onMessage = nil + page?.onLoadFailure = nil + page?.onLoadFinish = nil + page?.cancel() + page = nil + + isStarted = false + outcome = nil + loadGeneration += 1 + + start(forceRefresh: true) + } + + func handle(_ message: EmbeddedBlockPageMessage) { + guard isStarted else { return } + + switch message { + case .ready(let height): + apply(height: height) + case .heightChanged(let height): + // Высотой владеет хост — сообщение остаётся в контракте страницы, но на нативной + // стороне ни на что не влияет. + Logger.common(message: "[EmbeddedBlock] Ignored heightChanged(\(height)): the host owns the container height", category: .embeddedBlocks) + case .empty: + outcome = .empty + onStateChange?(.empty) + case .action(let action): + // Блока на экране нет, а страница жива и продолжает работать — например, досылает то, + // что запланировал её `setTimeout`. Выполнять её действия в этот момент нельзя: за + // невидимым блоком не стоит ни одного касания пользователя, а `openUrl` увёл бы его из + // приложения на пустом месте. + guard isShown else { + Logger.common(message: "[EmbeddedBlock] Block '\(id)': ignored action '\(action.type)' from a block that is not shown", + category: .embeddedBlocks) + return + } + + actionHandler.handle(action) + } + } + + func handleLoadFailure() { + guard isStarted else { return } + + outcome = .failed + onStateChange?(.failed) + } + + /// Пока исхода нет, страница ещё грузится — её сообщения относятся к живому блоку. + private var isShown: Bool { + outcome == nil || outcome == .ready + } + + /// Загруженный документ сам по себе ничего не значит: показать блок по нему разрешает только + /// отладочная подмена — для страниц, которые ещё не умеют присылать `ready`. + func handleLoadFinish() { + guard isStarted, !isReady, readinessOverrides.treatsLoadedPageAsReady else { return } + + Logger.common(message: "[EmbeddedBlock] Block '\(id)': debug readiness is ON, showing the loaded page without a 'ready' from it", + level: .default, + category: .embeddedBlocks) + outcome = .ready + onStateChange?(.ready) + } + + private func start(forceRefresh: Bool) { + guard !isStarted else { return } + + isStarted = true + + // Страница уже отрендерилась и никуда не делась — показываем её как есть. Возврат блока + // в окно не стоит ни сети, ни шиммера, ни повторных событий хосту. + if isReady, page != nil { + Logger.common(message: "[EmbeddedBlock] Block '\(id)': showing the page rendered earlier", + category: .embeddedBlocks) + onStateChange?(.ready) + return + } + + onStateChange?(.loading) + // Началась новая попытка: чем кончилась прошлая, больше не важно — в том числе и для того, + // выполнять ли действия страницы. + outcome = nil + + if let page { + page.load() + return + } + + let generation = loadGeneration + resolver.resolve(id, forceRefresh: forceRefresh) { [weak self] resolution in + guard let self, self.isStarted, self.loadGeneration == generation else { return } + + switch resolution { + case .empty: + Logger.common(message: "[EmbeddedBlock] Block id '\(self.id)' resolved as empty", + category: .embeddedBlocks) + self.onStateChange?(.empty) + case .content(let content): + let page = self.makePage(content) + page.onMessage = { [weak self] message in + self?.handle(message) + } + page.onLoadFailure = { [weak self] in + self?.handleLoadFailure() + } + page.onLoadFinish = { [weak self] in + self?.handleLoadFinish() + } + self.page = page + page.load() + } + } + } + + private func apply(height: CGFloat) { + // «Показывать нечего» страница сообщает явным `empty`, поэтому нулевая высота — это + // сломанная вёрстка, то есть ошибка. + guard height > 0 else { + Logger.common(message: "[EmbeddedBlock] Block '\(id)': page reported zero height, treating as broken", category: .embeddedBlocks) + outcome = .empty + onStateChange?(.failed) + return + } + + Logger.common(message: "[EmbeddedBlock] Block '\(id)': page is ready", category: .embeddedBlocks) + outcome = .ready + onStateChange?(.ready) + } +} + +// MARK: - Live blocks + +/// Сколько блоков с каждым id живо прямо сейчас. +/// +/// Диагностика, а не механика: два блока с одним id — законный случай, оба покажут один и тот же +/// контент. Но чаще это либо скопированный id, либо переиспользованная ячейка, в которую попал +/// контейнер от другой строки, — а у обоих случаев нет заметных симптомов, кроме «блок оказался не +/// там, где ждали». Поэтому SDK говорит об этом в лог. +/// +/// Счётчик общий на процесс, потому что вопрос тоже общий: одинаковые id ищутся не внутри блока, а +/// между блоками. Живых блоков он не удерживает — хранит только числа. +extension EmbeddedBlockWebViewProvider { + + private static var liveBlocks: [String: Int] = [:] + + /// Блоки создаются и умирают с UIKit-вью, то есть на главном потоке. Замок стоит на случай, если + /// это когда-нибудь перестанет быть правдой: диагностика не должна ронять SDK. + private static let liveBlocksLock = NSLock() + + static func liveCount(for id: String) -> Int { + liveBlocksLock.lock() + defer { liveBlocksLock.unlock() } + + return liveBlocks[id] ?? 0 + } + + fileprivate static func blockCreated(id: String) { + liveBlocksLock.lock() + let count = (liveBlocks[id] ?? 0) + 1 + liveBlocks[id] = count + liveBlocksLock.unlock() + + guard count > 1 else { return } + + Logger.common(message: """ + [EmbeddedBlock] \(count) live blocks share id '\(id)'. They show the same content, \ + each rendered on its own. If that is unexpected, check that a reusable cell is not carrying \ + a block container from another row: a block is created for one id and cannot be repointed. + """, category: .embeddedBlocks) + } + + fileprivate static func blockReleased(id: String) { + liveBlocksLock.lock() + let remaining = max(0, (liveBlocks[id] ?? 1) - 1) + if remaining > 0 { + liveBlocks[id] = remaining + } else { + liveBlocks.removeValue(forKey: id) + } + liveBlocksLock.unlock() + + Logger.common(message: "[EmbeddedBlock] Block '\(id)' is released, \(remaining) live with this id", + category: .embeddedBlocks) + } +} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift index bd6cdf158..ad9ec46a2 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift @@ -6,10 +6,12 @@ // Copyright © 2026 Mindbox. All rights reserved. // -import Foundation +import UIKit @testable import Mindbox extension EmbeddedBlockWebContent { static let stub = EmbeddedBlockWebContent(url: URL(string: "https://mindbox.ru/block.html")!) + + static let other = EmbeddedBlockWebContent(url: URL(string: "https://mindbox.ru/another-block.html")!) } diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift index ed879bd01..3a5c8e32d 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift @@ -207,4 +207,52 @@ private final class ContentLoaderSpy { completions = [] pending.forEach { $0(resolution) } } + + /// Отвечает с фоновой очереди — так ответит настоящий конфиг, разобранный не на главном потоке. + func answerOffMain(_ resolution: EmbeddedBlockResolution) { + let pending = completions + completions = [] + DispatchQueue.global().async { + pending.forEach { $0(resolution) } + } + } +} + +/// На каком потоке резолвер отдал ответ. Отдельный тип вместо `Bool` — чтобы упавший тест сразу +/// говорил, что именно разъехалось. +private enum DeliveryThread { + case main + case other +} + +/// Ждёт ответов резолвера и запоминает, на каком потоке каждый пришёл. +/// +/// Читают и пишут его только с главного потока — если это перестанет быть правдой, тест как раз и +/// упадёт на `threads`. +private final class DeliveryRecorder { + + private(set) var answers: [EmbeddedBlockResolution] = [] + private(set) var threads: [DeliveryThread] = [] + + private var expectedCount = 0 + private var continuation: CheckedContinuation? + + func record(_ resolution: EmbeddedBlockResolution) { + answers.append(resolution) + threads.append(Thread.isMainThread ? .main : .other) + + guard answers.count >= expectedCount, let continuation else { return } + self.continuation = nil + continuation.resume() + } + + /// Загрузку запускает сам ожидающий: начни её раньше — и ответ мог бы приехать до того, как + /// тест встал ждать, а ожидание повисло бы навсегда. + func waitForAnswers(count: Int, _ startLoading: () -> Void) async { + expectedCount = count + await withCheckedContinuation { continuation in + self.continuation = continuation + startLoading() + } + } } From e03edcf2ef6f215231b1bb32c88c99f9967c45d1 Mon Sep 17 00:00:00 2001 From: Akylbek Utekeshev Date: Mon, 10 Aug 2026 17:28:35 +0500 Subject: [PATCH 35/47] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift index 671e2e473..7e69678db 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift @@ -6,6 +6,7 @@ // Copyright © 2026 Mindbox. All rights reserved. // +import UIKit import WebKit import MindboxLogger From eba5501b730ba08c0b7888acf6321bf3cf892187 Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 11 Aug 2026 12:59:10 +0500 Subject: [PATCH 36/47] MOBILE-323 Less comments --- Mindbox.xcodeproj/project.pbxproj | 131 +++++++----- .../Actions/EmbeddedBlockActionRouter.swift | 83 +------- .../Resolver/EmbeddedBlockWebContent.swift | 11 - .../EmbeddedBlockActionRouterTests.swift | 194 ------------------ 4 files changed, 81 insertions(+), 338 deletions(-) delete mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockActionRouterTests.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index 9fab9782c..f00b00fec 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -7,30 +7,18 @@ objects = { /* Begin PBXBuildFile section */ - A881A16C702E41AE93848BD3 /* InAppWebViewLearnedHostsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */; }; - F389B3A639904206A3DADFFA /* InAppWebViewPrewarmPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */; }; - 8A94D721DAEF4395A360C5B2 /* InAppWebViewPrewarmNavigationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */; }; - 23335221BB1D4F8C8D5C864D /* InAppWebViewPrewarmService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */; }; - 23E4A509BFE94104BD01B61A /* MindboxWebBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC5ED4F76B046798978B4B9 /* MindboxWebBridgeTests.swift */; }; - AE190755CA97473B83E1568D /* MBContainerConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4F5CEE405E4BE29F97E43B /* MBContainerConcurrencyTests.swift */; }; - 3FDBA6E4D39A49369A335CF4 /* SDKUserAgentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E45E06561604E6185C33366 /* SDKUserAgentTests.swift */; }; - 16311BEB069E4B3983FAF594 /* InAppWebViewCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895F8C05EA8B4716B2A2941A /* InAppWebViewCacheTests.swift */; }; - 28375259024D42658E146900 /* InAppWebViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1627DAC8D54E448C6D7BF0 /* InAppWebViewFactory.swift */; }; - B58D5207F49F4531AA1C516B /* InAppWebViewHTMLFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85CCBEB1D874078954BB771 /* InAppWebViewHTMLFetcher.swift */; }; - 2DBF19F24FEA48AAA39C33E6 /* InAppWebViewDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D096F433EBB44C8B3490CF6 /* InAppWebViewDataStore.swift */; }; - 7A1E4C0DA3B24F6E90C11A02 /* InAppWebViewHTTPError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A01 /* InAppWebViewHTTPError.swift */; }; - 7A1E4C0DA3B24F6E90C11A04 /* WebViewNoCacheRetryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A03 /* WebViewNoCacheRetryPolicy.swift */; }; - B9025268DDC24820B95ADE10 /* WebViewTimeoutErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E54FE3B51649DCBD5ABE33 /* WebViewTimeoutErrorTests.swift */; }; - 7A1E4C0DA3B24F6E90C11A06 /* InAppWebViewHTTPErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A05 /* InAppWebViewHTTPErrorTests.swift */; }; - 7A1E4C0DA3B24F6E90C11A08 /* WebViewNoCacheRetryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A07 /* WebViewNoCacheRetryPolicyTests.swift */; }; - CB91323FAD66404B9422B6E0 /* WebViewReadyCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D716044131845AEA04A9858 /* WebViewReadyCheckerTests.swift */; }; - C830BF4C287849CE95BB4ED9 /* WebViewReadyChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2DAFC2435264F15B8033CCE /* WebViewReadyChecker.swift */; }; - 46E95250B5904CC18E079C54 /* SDKUserAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68184C936B4243C28CC10829 /* SDKUserAgent.swift */; }; 0A3D045A2BC6803E00E1FC52 /* ImageFormat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A3D04592BC6803E00E1FC52 /* ImageFormat.swift */; }; 0E7A224A082FA2DA35706CC7 /* MotionServiceResolvePositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36B /* MotionServiceResolvePositionTests.swift */; }; 0E7A224A082FA2DA35706CC8 /* MotionServiceShakeToEditTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C8192B8B7043EF74D05B36C /* MotionServiceShakeToEditTests.swift */; }; + 0FB55CC82EEA4F7CA568A02E /* MD5Hash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */; }; + 16311BEB069E4B3983FAF594 /* InAppWebViewCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 895F8C05EA8B4716B2A2941A /* InAppWebViewCacheTests.swift */; }; 1E28BAF922E64B9CB6E22A0B /* DateFormatMigrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF1A11C4A4B940898BA80035 /* DateFormatMigrationTests.swift */; }; + 1E316CC518D9111F0BD25590 /* InAppMessagesTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60DC2E1435521EBF0E2C5E4 /* InAppMessagesTrackerTests.swift */; }; 1E3BD63AB3F1521C253CB818 /* MBNetworkFetcherResponseHandlingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */; }; + 23335221BB1D4F8C8D5C864D /* InAppWebViewPrewarmService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */; }; + 23E4A509BFE94104BD01B61A /* MindboxWebBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC5ED4F76B046798978B4B9 /* MindboxWebBridgeTests.swift */; }; + 28375259024D42658E146900 /* InAppWebViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1627DAC8D54E448C6D7BF0 /* InAppWebViewFactory.swift */; }; + 2DBF19F24FEA48AAA39C33E6 /* InAppWebViewDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D096F433EBB44C8B3490CF6 /* InAppWebViewDataStore.swift */; }; 302E35788CBDA959283569F4 /* MotionServiceBehaviorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0DB93A7997961CA7C2BE917 /* MotionServiceBehaviorTests.swift */; }; 313B233A25ADEA0F00A1CB72 /* Mindbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 313B233025ADEA0F00A1CB72 /* Mindbox.framework */; }; 313B233F25ADEA0F00A1CB72 /* MindboxTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 313B233E25ADEA0F00A1CB72 /* MindboxTests.swift */; }; @@ -119,7 +107,8 @@ 33E42E5C268323E60046CBCB /* CashdeskRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33E42E5B268323E60046CBCB /* CashdeskRequest.swift */; }; 33EBF0B0264E6283002A35D5 /* MBSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33EBF0AF264E6283002A35D5 /* MBSessionManager.swift */; }; 3FB93AC126964AD3A061B4A7 /* FeatureToggleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3456BAC56F984378B6CED7CB /* FeatureToggleManager.swift */; }; - B202CDC049DDB7A2F9833ADF /* InAppTagsGating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8D4000B12B31961251F6C7 /* InAppTagsGating.swift */; }; + 3FDBA6E4D39A49369A335CF4 /* SDKUserAgentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E45E06561604E6185C33366 /* SDKUserAgentTests.swift */; }; + 46E95250B5904CC18E079C54 /* SDKUserAgent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68184C936B4243C28CC10829 /* SDKUserAgent.swift */; }; 472179A72C80755A00C15E7F /* ShownInAppsIDsMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472179A62C80755A00C15E7F /* ShownInAppsIDsMigration.swift */; }; 472765522E0D52A500A5A060 /* RemoveBackgroundTaskDataMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472765512E0D52A500A5A060 /* RemoveBackgroundTaskDataMigration.swift */; }; 472F549E2C6E272A0008C465 /* MBPushNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 472F549D2C6E272A0008C465 /* MBPushNotification.swift */; }; @@ -179,8 +168,6 @@ 4731A81A2F447C3100CBE1E5 /* ConfigMonitoringError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A79D2F447C3100CBE1E5 /* ConfigMonitoringError.json */; }; 4731A81B2F447C3100CBE1E5 /* ConfigSettingsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7A02F447C3100CBE1E5 /* ConfigSettingsTypeError.json */; }; 4731A81C2F447C3100CBE1E5 /* MonitoringConfig.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7BA2F447C3100CBE1E5 /* MonitoringConfig.json */; }; - AA996F8AEEB14DB8BF0BE326 /* MonitoringLogsOldDeviceUuidFormat.json in Resources */ = {isa = PBXBuildFile; fileRef = EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */; }; - 5D4C060B41804FC7B3F791E9 /* MonitoringLogsBothFieldsFormat.json in Resources */ = {isa = PBXBuildFile; fileRef = 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */; }; 4731A81D2F447C3100CBE1E5 /* InAppFrequencyError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7AA2F447C3100CBE1E5 /* InAppFrequencyError.json */; }; 4731A81E2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7BB2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json */; }; 4731A81F2F447C3100CBE1E5 /* SettingsInAppSettingsAllValid.json in Resources */ = {isa = PBXBuildFile; fileRef = 4731A7E82F447C3100CBE1E5 /* SettingsInAppSettingsAllValid.json */; }; @@ -267,6 +254,7 @@ 4841683F6839440F84477966 /* OperationNameValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0EF1D88A18D64D60BD03ABB8 /* OperationNameValidator.swift */; }; 57A1B3230000000000000007 /* InjectEmbeddedBlocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57A1B3230000000000000107 /* InjectEmbeddedBlocks.swift */; }; 5D3FCB95C2AF59DF36A61254 /* WebViewLocalStateStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFCC82B8014DE7276C217CD /* WebViewLocalStateStorageTests.swift */; }; + 5D4C060B41804FC7B3F791E9 /* MonitoringLogsBothFieldsFormat.json in Resources */ = {isa = PBXBuildFile; fileRef = 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */; }; 6182078DDFC681D168546DAD /* HapticService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1BE43AA9EAEA03F8ED4008 /* HapticService.swift */; }; 6182078DDFC681D168546DAE /* HapticRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1BE43AA9EAEA03F8ED4009 /* HapticRequest.swift */; }; 6182078DDFC681D168546DAF /* HapticRequestParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1BE43AA9EAEA03F8ED400A /* HapticRequestParser.swift */; }; @@ -297,6 +285,12 @@ 6FDD1463266F7CED00A50C35 /* ProductElementReponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDD1462266F7CED00A50C35 /* ProductElementReponse.swift */; }; 6FDD1465266F7CFE00A50C35 /* ItemProductResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6FDD1464266F7CFE00A50C35 /* ItemProductResponse.swift */; }; 7764BA10E25046CEA7035ED8 /* DateFormatMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8755088A5E704C65A1C3F6DB /* DateFormatMigration.swift */; }; + 7A1E4C0DA3B24F6E90C11A02 /* InAppWebViewHTTPError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A01 /* InAppWebViewHTTPError.swift */; }; + 7A1E4C0DA3B24F6E90C11A04 /* WebViewNoCacheRetryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A03 /* WebViewNoCacheRetryPolicy.swift */; }; + 7A1E4C0DA3B24F6E90C11A06 /* InAppWebViewHTTPErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A05 /* InAppWebViewHTTPErrorTests.swift */; }; + 7A1E4C0DA3B24F6E90C11A08 /* WebViewNoCacheRetryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A1E4C0DA3B24F6E90C11A07 /* WebViewNoCacheRetryPolicyTests.swift */; }; + 7A3F1B2C9D4E5F60718293A5 /* TransparentViewJSBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3F1B2C9D4E5F60718293A4 /* TransparentViewJSBridgeTests.swift */; }; + 7A3F1B2C9D4E5F60718293A7 /* Tags-FailedTargeting.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A3F1B2C9D4E5F60718293A6 /* Tags-FailedTargeting.json */; }; 813FE960DC0543F681C94275 /* SettingsRequestParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 30FC619AC23245CC8CD45E63 /* SettingsRequestParser.swift */; }; 840042A12614CE0000CA17C5 /* ClickNotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840042A02614CE0000CA17C5 /* ClickNotificationManager.swift */; }; 840C387225CC1AF200D50183 /* CDEvent+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 840C387025CC1AF200D50183 /* CDEvent+CoreDataClass.swift */; }; @@ -354,6 +348,8 @@ 84FCD3B525CA0FD300D1E574 /* MockPersistenceStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FCD3B425CA0FD300D1E574 /* MockPersistenceStorage.swift */; }; 84FCD3B925CA109E00D1E574 /* MockNetworkFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FCD3B825CA109E00D1E574 /* MockNetworkFetcher.swift */; }; 84FCD3BD25CA10F600D1E574 /* SuccessResponse.json in Resources */ = {isa = PBXBuildFile; fileRef = 84FCD3BC25CA10F600D1E574 /* SuccessResponse.json */; }; + 8A94D721DAEF4395A360C5B2 /* InAppWebViewPrewarmNavigationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */; }; + 8BED120CA34F48F2969E6AC8 /* MD5HashTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A183433F63D47F684846B0F /* MD5HashTests.swift */; }; 9B24FAAC28C74B8300F10B5D /* InAppConfigurationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B24FAAB28C74B8300F10B5D /* InAppConfigurationRepository.swift */; }; 9B24FAAE28C74BA500F10B5D /* InAppCoreManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B24FAAD28C74BA500F10B5D /* InAppCoreManager.swift */; }; 9B24FAB128C74BD200F10B5D /* InAppConfigurationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B24FAB028C74BD200F10B5D /* InAppConfigurationManager.swift */; }; @@ -366,6 +362,7 @@ 9B4F9DF928D088A9002C9CF0 /* InAppConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B4F9DF628D088A9002C9CF0 /* InAppConfig.swift */; }; 9B4F9E0528D0945F002C9CF0 /* InAppCoreManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B4F9E0428D0945F002C9CF0 /* InAppCoreManagerMock.swift */; }; 9B52570728D1AF880029B1BC /* InAppPresentationManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B52570628D1AF880029B1BC /* InAppPresentationManagerMock.swift */; }; + 9B8670F8E39535C1264CC855 /* OperationResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */; }; 9B9C95312921116F00BB29DA /* UUIDDebugService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9C952F2921116F00BB29DA /* UUIDDebugService.swift */; }; 9B9C95322921116F00BB29DA /* PasteboardUUIDDebugService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9C95302921116F00BB29DA /* PasteboardUUIDDebugService.swift */; }; 9B9C9538292111A700BB29DA /* MockUUIDDebugService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B9C9534292111A700BB29DA /* MockUUIDDebugService.swift */; }; @@ -386,12 +383,10 @@ A153E03F29BB002A003C34D4 /* SessionTemporaryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A153E03E29BB002A003C34D4 /* SessionTemporaryStorage.swift */; }; A153E04129BB0A8B003C34D4 /* InAppConfigurationWithOperations.json in Resources */ = {isa = PBXBuildFile; fileRef = A153E04029BB0A8B003C34D4 /* InAppConfigurationWithOperations.json */; }; A154E32E299E0D8900F8F074 /* SDKLogManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E32D299E0D8900F8F074 /* SDKLogManagerTests.swift */; }; - 8BED120CA34F48F2969E6AC8 /* MD5HashTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A183433F63D47F684846B0F /* MD5HashTests.swift */; }; A154E330299E0F1600F8F074 /* InAppGeoResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E32F299E0F1600F8F074 /* InAppGeoResponse.swift */; }; A154E334299E110E00F8F074 /* EventRepositoryMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E333299E110E00F8F074 /* EventRepositoryMock.swift */; }; A15D701629AF810E007131E7 /* SDKLogsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E381299E5B7500F8F074 /* SDKLogsRequest.swift */; }; A15D701A29AF8142007131E7 /* SDKLogsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E382299E5B7500F8F074 /* SDKLogsManager.swift */; }; - 0FB55CC82EEA4F7CA568A02E /* MD5Hash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */; }; A15D704729AF81DC007131E7 /* MBLoggerCoreDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A154E34F299E5B6D00F8F074 /* MBLoggerCoreDataManager.swift */; }; A170EDDC29B0883800CE547F /* MindboxLogger.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A17853BE29AF7E940072578F /* MindboxLogger.framework */; }; A170EDE229B08A2700CE547F /* MindboxLogger.h in Headers */ = {isa = PBXBuildFile; fileRef = A170EDE129B08A2700CE547F /* MindboxLogger.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -458,8 +453,12 @@ A1D017F22976CC9400CD9F99 /* SegmentTargeting.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D017F12976CC9400CD9F99 /* SegmentTargeting.swift */; }; A1D017F52976FC2B00CD9F99 /* InternalTargetingChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D017F42976FC2B00CD9F99 /* InternalTargetingChecker.swift */; }; A1D23AF029DE082E00A75179 /* InAppProductSegmentResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1D23AEF29DE082E00A75179 /* InAppProductSegmentResponse.swift */; }; + A881A16C702E41AE93848BD3 /* InAppWebViewLearnedHostsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */; }; + AA996F8AEEB14DB8BF0BE326 /* MonitoringLogsOldDeviceUuidFormat.json in Resources */ = {isa = PBXBuildFile; fileRef = EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */; }; + AB0E83C770AAB9A2DC8E9BF5 /* JSONValueTagsMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E67F6257F15F143DEA87DE /* JSONValueTagsMergeTests.swift */; }; + AE190755CA97473B83E1568D /* MBContainerConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B4F5CEE405E4BE29F97E43B /* MBContainerConcurrencyTests.swift */; }; AF174B2121221D323FB95EF0 /* MBEventRepositorySendRawTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */; }; - 9B8670F8E39535C1264CC855 /* OperationResponseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */; }; + B202CDC049DDB7A2F9833ADF /* InAppTagsGating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E8D4000B12B31961251F6C7 /* InAppTagsGating.swift */; }; B36D57852696E59400FEDFD6 /* RetailOrderStatisticsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B36D57842696E59400FEDFD6 /* RetailOrderStatisticsResponse.swift */; }; B3A6254C2689F83100B6A3B7 /* PersonalOffersResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A6254B2689F83100B6A3B7 /* PersonalOffersResponse.swift */; }; B3A625502689F8B600B6A3B7 /* BenefitResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3A6254F2689F8B600B6A3B7 /* BenefitResponse.swift */; }; @@ -476,11 +475,15 @@ B4E438702D8AFA5700603F3A /* WebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E4386E2D8AFA5700603F3A /* WebViewController.swift */; }; B4E438722D8AFA6700603F3A /* WebViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E438712D8AFA6700603F3A /* WebViewFactory.swift */; }; B4E438742D8AFAA700603F3A /* WebviewPresentationStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4E438732D8AFAA700603F3A /* WebviewPresentationStrategy.swift */; }; + B58D5207F49F4531AA1C516B /* InAppWebViewHTMLFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = A85CCBEB1D874078954BB771 /* InAppWebViewHTMLFetcher.swift */; }; B7705CA22E0A1F0000C0FFEE /* AppGroupUnavailableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7705CA12E0A1F0000C0FFEE /* AppGroupUnavailableTests.swift */; }; + B9025268DDC24820B95ADE10 /* WebViewTimeoutErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11E54FE3B51649DCBD5ABE33 /* WebViewTimeoutErrorTests.swift */; }; BB4D7CC72BDEC51D008E3AB8 /* Notification+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB4D7CC62BDEC51D008E3AB8 /* Notification+Extensions.swift */; }; BB6563102BE3BA430090C473 /* UIApplication+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB65630F2BE3BA430090C473 /* UIApplication+Extensions.swift */; }; BBAAC17C2BB2FC9100E1E25E /* MockEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBAAC17B2BB2FC9100E1E25E /* MockEvent.swift */; }; C0360B4CF720E2CBDC488E82 /* TrackVisitManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475E8755F63483597539A50 /* TrackVisitManagerTests.swift */; }; + C830BF4C287849CE95BB4ED9 /* WebViewReadyChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2DAFC2435264F15B8033CCE /* WebViewReadyChecker.swift */; }; + CB91323FAD66404B9422B6E0 /* WebViewReadyCheckerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D716044131845AEA04A9858 /* WebViewReadyCheckerTests.swift */; }; D216DE512C0716B70020F58A /* StringExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D216DE502C0716B70020F58A /* StringExtensionsTests.swift */; }; D216DE532C0716B80020F58A /* TimeIntervalTimeSpanTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D216DE522C0716B80020F58A /* TimeIntervalTimeSpanTests.swift */; }; D2F7E2432BADB89900B24BB8 /* UserVisitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F7E2412BADB89900B24BB8 /* UserVisitManager.swift */; }; @@ -581,17 +584,16 @@ F34A103D2F455B840065392A /* FeatureTogglesConfigParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F34A103A2F455B840065392A /* FeatureTogglesConfigParsingTests.swift */; }; F34A10442F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10402F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json */; }; F34A10452F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A103F2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json */; }; - F34A104B2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10492F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json */; }; - F34A104C2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A104A2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json */; }; F34A10462F455C5B0065392A /* SettingsFeatureTogglesError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A103E2F455C5B0065392A /* SettingsFeatureTogglesError.json */; }; F34A10472F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10412F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json */; }; F34A10482F455C5B0065392A /* SettingsFeatureTogglesTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10422F455C5B0065392A /* SettingsFeatureTogglesTypeError.json */; }; + F34A104B2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A10492F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json */; }; + F34A104C2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json in Resources */ = {isa = PBXBuildFile; fileRef = F34A104A2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json */; }; 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 */; }; F351F1C22CE5F23A0053423E /* InappMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F351F1C12CE5F23A0053423E /* InappMapperTests.swift */; }; F351F1C42CE60CA90053423E /* 1-Targeting.json in Resources */ = {isa = PBXBuildFile; fileRef = F351F1C32CE60CA90053423E /* 1-Targeting.json */; }; - 7A3F1B2C9D4E5F60718293A7 /* Tags-FailedTargeting.json in Resources */ = {isa = PBXBuildFile; fileRef = 7A3F1B2C9D4E5F60718293A6 /* Tags-FailedTargeting.json */; }; F351F1C62CE626450053423E /* 15-Targeting.json in Resources */ = {isa = PBXBuildFile; fileRef = F351F1C52CE626450053423E /* 15-Targeting.json */; }; F351F1C82CE72B300053423E /* 44-Targeting.json in Resources */ = {isa = PBXBuildFile; fileRef = F351F1C72CE72B300053423E /* 44-Targeting.json */; }; F351F1CB2CE72D460053423E /* 46-Targeting.json in Resources */ = {isa = PBXBuildFile; fileRef = F351F1CA2CE72D460053423E /* 46-Targeting.json */; }; @@ -610,6 +612,7 @@ F382F20D2BAC548900BC97FF /* VisitTargetingChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382F20C2BAC548900BC97FF /* VisitTargetingChecker.swift */; }; F382F2112BAC6AD100BC97FF /* UNAuthorizationStatus+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F382F2102BAC6AD100BC97FF /* UNAuthorizationStatus+Extensions.swift */; }; F38562FF2DB66CB600D91208 /* DictionaryKeyValueModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38562FE2DB66CB600D91208 /* DictionaryKeyValueModel.swift */; }; + F389B3A639904206A3DADFFA /* InAppWebViewPrewarmPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */; }; F39116EE2AA53EE400852298 /* VariantImageUrlExtractorServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39116ED2AA53EE400852298 /* VariantImageUrlExtractorServiceTests.swift */; }; F39116F52AA9AF7A00852298 /* InappFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39116F42AA9AF7A00852298 /* InappFilter.swift */; }; F39116F82AA9B04E00852298 /* VariantsFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39116F72AA9B04E00852298 /* VariantsFilter.swift */; }; @@ -691,10 +694,6 @@ F3C1A0022F5B100100ABC001 /* InappShowFailureManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C1A0012F5B100100ABC001 /* InappShowFailureManager.swift */; }; F3C1A0042F5B100100ABC001 /* InAppShowFailure.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C1A0032F5B100100ABC001 /* InAppShowFailure.swift */; }; F3C1A0062F5B100100ABC001 /* InappShowFailureManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3C1A0052F5B100100ABC001 /* InappShowFailureManagerTests.swift */; }; - FA39EE97042009DCEC24E971 /* InAppTagsGatingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045EA348681AB859FB298A57 /* InAppTagsGatingTests.swift */; }; - 1E316CC518D9111F0BD25590 /* InAppMessagesTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60DC2E1435521EBF0E2C5E4 /* InAppMessagesTrackerTests.swift */; }; - AB0E83C770AAB9A2DC8E9BF5 /* JSONValueTagsMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E67F6257F15F143DEA87DE /* JSONValueTagsMergeTests.swift */; }; - 7A3F1B2C9D4E5F60718293A5 /* TransparentViewJSBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3F1B2C9D4E5F60718293A4 /* TransparentViewJSBridgeTests.swift */; }; F3CD20262F600A800065392A /* MBConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD20272F600A800065392A /* MBConfigurationTests.swift */; }; F3CD20292F600A800065392A /* HostNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202A2F600A800065392A /* HostNormalizer.swift */; }; F3CD202B2F600A800065392A /* HostNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3CD202C2F600A800065392A /* HostNormalizerTests.swift */; }; @@ -732,6 +731,7 @@ F3FEEAAB2C25D874000E9D0F /* InjectReplaceable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3FEEAAA2C25D874000E9D0F /* InjectReplaceable.swift */; }; F3FEEAAD2C25FD1F000E9D0F /* InjectABTestUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3FEEAAC2C25FD1F000E9D0F /* InjectABTestUtilities.swift */; }; F78E92EF282E63320003B4A3 /* DispatchSemaphore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78E92EE282E63320003B4A3 /* DispatchSemaphore.swift */; }; + FA39EE97042009DCEC24E971 /* InAppTagsGatingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 045EA348681AB859FB298A57 /* InAppTagsGatingTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -786,10 +786,17 @@ F2DAFC2435264F15B8033CCE /* WebViewReadyChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewReadyChecker.swift; sourceTree = ""; }; 57A1B3230000000000000107 /* InjectEmbeddedBlocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectEmbeddedBlocks.swift; sourceTree = ""; }; 68184C936B4243C28CC10829 /* SDKUserAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKUserAgent.swift; sourceTree = ""; }; + 045EA348681AB859FB298A57 /* InAppTagsGatingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppTagsGatingTests.swift; sourceTree = ""; }; 0475E8755F63483597539A50 /* TrackVisitManagerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrackVisitManagerTests.swift; sourceTree = ""; }; 0A3D04592BC6803E00E1FC52 /* ImageFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFormat.swift; sourceTree = ""; }; 0CFCC82B8014DE7276C217CD /* WebViewLocalStateStorageTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewLocalStateStorageTests.swift; sourceTree = ""; }; 0EF1D88A18D64D60BD03ABB8 /* OperationNameValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OperationNameValidator.swift; sourceTree = ""; }; + 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OperationResponseTests.swift; sourceTree = ""; }; + 11E54FE3B51649DCBD5ABE33 /* WebViewTimeoutErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewTimeoutErrorTests.swift; sourceTree = ""; }; + 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewLearnedHostsStore.swift; sourceTree = ""; }; + 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmService.swift; sourceTree = ""; }; + 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsBothFieldsFormat.json; sourceTree = ""; }; + 2A183433F63D47F684846B0F /* MD5HashTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5HashTests.swift; sourceTree = ""; }; 30FC619AC23245CC8CD45E63 /* SettingsRequestParser.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SettingsRequestParser.swift; sourceTree = ""; }; 313B233025ADEA0F00A1CB72 /* Mindbox.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Mindbox.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 313B233325ADEA0F00A1CB72 /* Mindbox.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Mindbox.h; sourceTree = ""; }; @@ -933,8 +940,6 @@ 4731A7B62F447C3100CBE1E5 /* InAppTargetingError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = InAppTargetingError.json; sourceTree = ""; }; 4731A7B72F447C3100CBE1E5 /* InAppTargetingTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = InAppTargetingTypeError.json; sourceTree = ""; }; 4731A7BA2F447C3100CBE1E5 /* MonitoringConfig.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringConfig.json; sourceTree = ""; }; - EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsOldDeviceUuidFormat.json; sourceTree = ""; }; - 23E426DECA664254B2B3A29D /* MonitoringLogsBothFieldsFormat.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsBothFieldsFormat.json; sourceTree = ""; }; 4731A7BB2F447C3100CBE1E5 /* MonitoringLogsElementsMixedError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsElementsMixedError.json; sourceTree = ""; }; 4731A7BC2F447C3100CBE1E5 /* MonitoringLogsError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsError.json; sourceTree = ""; }; 4731A7BD2F447C3100CBE1E5 /* MonitoringLogsOneElementError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsOneElementError.json; sourceTree = ""; }; @@ -1026,7 +1031,14 @@ 47EFF0FC2E8D85B700E72D0A /* DatabaseMetadataMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseMetadataMigrationTests.swift; sourceTree = ""; }; 47FDF0B92C5BDAB80051F08C /* MigrationManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationManagerProtocol.swift; sourceTree = ""; }; 47FDF0BB2C5BE8BB0051F08C /* MigrationProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrationProtocol.swift; sourceTree = ""; }; + 4B4F5CEE405E4BE29F97E43B /* MBContainerConcurrencyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBContainerConcurrencyTests.swift; sourceTree = ""; }; 4B7DAFAB687945FA908DB1AC /* TransparentViewSyncOperationResponseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TransparentViewSyncOperationResponseTests.swift; sourceTree = ""; }; + 4D716044131845AEA04A9858 /* WebViewReadyCheckerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewReadyCheckerTests.swift; sourceTree = ""; }; + 4E45E06561604E6185C33366 /* SDKUserAgentTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SDKUserAgentTests.swift; sourceTree = ""; }; + 55E67F6257F15F143DEA87DE /* JSONValueTagsMergeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValueTagsMergeTests.swift; sourceTree = ""; }; + 68184C936B4243C28CC10829 /* SDKUserAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKUserAgent.swift; sourceTree = ""; }; + 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5Hash.swift; sourceTree = ""; }; + 6D096F433EBB44C8B3490CF6 /* InAppWebViewDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewDataStore.swift; sourceTree = ""; }; 6F1EAA15266A670E007A335B /* ProductListItemsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListItemsResponse.swift; sourceTree = ""; }; 6FDD143A266F7BD900A50C35 /* ProcessingStatusResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessingStatusResponse.swift; sourceTree = ""; }; 6FDD143C266F7BEB00A50C35 /* ItemResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemResponse.swift; sourceTree = ""; }; @@ -1050,6 +1062,12 @@ 6FDD1460266F7CE300A50C35 /* DiscountAmountTypeResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscountAmountTypeResponse.swift; sourceTree = ""; }; 6FDD1462266F7CED00A50C35 /* ProductElementReponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductElementReponse.swift; sourceTree = ""; }; 6FDD1464266F7CFE00A50C35 /* ItemProductResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemProductResponse.swift; sourceTree = ""; }; + 7A1E4C0DA3B24F6E90C11A01 /* InAppWebViewHTTPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTTPError.swift; sourceTree = ""; }; + 7A1E4C0DA3B24F6E90C11A03 /* WebViewNoCacheRetryPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNoCacheRetryPolicy.swift; sourceTree = ""; }; + 7A1E4C0DA3B24F6E90C11A05 /* InAppWebViewHTTPErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTTPErrorTests.swift; sourceTree = ""; }; + 7A1E4C0DA3B24F6E90C11A07 /* WebViewNoCacheRetryPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewNoCacheRetryPolicyTests.swift; sourceTree = ""; }; + 7A3F1B2C9D4E5F60718293A4 /* TransparentViewJSBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentViewJSBridgeTests.swift; sourceTree = ""; }; + 7A3F1B2C9D4E5F60718293A6 /* Tags-FailedTargeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Tags-FailedTargeting.json"; sourceTree = ""; }; 7C8192B8B7043EF74D05B36B /* MotionServiceResolvePositionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MotionServiceResolvePositionTests.swift; sourceTree = ""; }; 7C8192B8B7043EF74D05B36C /* MotionServiceShakeToEditTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MotionServiceShakeToEditTests.swift; sourceTree = ""; }; 840042A02614CE0000CA17C5 /* ClickNotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClickNotificationManager.swift; sourceTree = ""; }; @@ -1108,6 +1126,8 @@ 84FCD3B825CA109E00D1E574 /* MockNetworkFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockNetworkFetcher.swift; sourceTree = ""; }; 84FCD3BC25CA10F600D1E574 /* SuccessResponse.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = SuccessResponse.json; sourceTree = ""; }; 8755088A5E704C65A1C3F6DB /* DateFormatMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatMigration.swift; sourceTree = ""; }; + 895F8C05EA8B4716B2A2941A /* InAppWebViewCacheTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InAppWebViewCacheTests.swift; sourceTree = ""; }; + 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmNavigationPolicy.swift; sourceTree = ""; }; 9778038796A8426ABDED1E97 /* FeatureTogglesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureTogglesModel.swift; sourceTree = ""; }; 97FEDDEB5F71A67F1C4C675F /* MBNetworkFetcherResponseHandlingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBNetworkFetcherResponseHandlingTests.swift; sourceTree = ""; }; 9B24FAAB28C74B8300F10B5D /* InAppConfigurationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppConfigurationRepository.swift; sourceTree = ""; }; @@ -1133,13 +1153,13 @@ 9BC24E7228F6953D00C2619C /* InAppConfigurationAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppConfigurationAPI.swift; sourceTree = ""; }; 9BC24E7328F6953D00C2619C /* ConfigResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigResponse.swift; sourceTree = ""; }; 9BC24E7928F6C08700C2619C /* InAppConfiguration.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = InAppConfiguration.json; sourceTree = ""; }; + 9CC5ED4F76B046798978B4B9 /* MindboxWebBridgeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MindboxWebBridgeTests.swift; sourceTree = ""; }; A11FBE8829DD76BF00F5FB7B /* InAppMessagesEventSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessagesEventSender.swift; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; - 2A183433F63D47F684846B0F /* MD5HashTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5HashTests.swift; sourceTree = ""; }; A154E32F299E0F1600F8F074 /* InAppGeoResponse.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InAppGeoResponse.swift; sourceTree = ""; }; A154E333299E110E00F8F074 /* EventRepositoryMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventRepositoryMock.swift; sourceTree = ""; }; A154E33A299E5B6D00F8F074 /* LogLevel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogLevel.swift; sourceTree = ""; }; @@ -1160,7 +1180,6 @@ A154E37F299E5B7500F8F074 /* SDKLogsStatus.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKLogsStatus.swift; sourceTree = ""; }; A154E381299E5B7500F8F074 /* SDKLogsRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKLogsRequest.swift; sourceTree = ""; }; A154E382299E5B7500F8F074 /* SDKLogsManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SDKLogsManager.swift; sourceTree = ""; }; - 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5Hash.swift; sourceTree = ""; }; A170EDE129B08A2700CE547F /* MindboxLogger.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = MindboxLogger.h; sourceTree = ""; }; A17853BE29AF7E940072578F /* MindboxLogger.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = MindboxLogger.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A17853C529AF7E950072578F /* MindboxLoggerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MindboxLoggerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -1211,6 +1230,7 @@ A1D017F12976CC9400CD9F99 /* SegmentTargeting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentTargeting.swift; sourceTree = ""; }; A1D017F42976FC2B00CD9F99 /* InternalTargetingChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalTargetingChecker.swift; sourceTree = ""; }; A1D23AEF29DE082E00A75179 /* InAppProductSegmentResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppProductSegmentResponse.swift; sourceTree = ""; }; + A85CCBEB1D874078954BB771 /* InAppWebViewHTMLFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTMLFetcher.swift; sourceTree = ""; }; B11C2D3E4F5566778899AA01 /* FirstInitializationDateTimeRuntimeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FirstInitializationDateTimeRuntimeTests.swift; sourceTree = ""; }; B11C2D3E4F5566778899AA02 /* FirstInitializationDateTimeMigrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FirstInitializationDateTimeMigrationTests.swift; sourceTree = ""; }; B11C2D3E4F5566778899AA11 /* DeviceUUIDInitializationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DeviceUUIDInitializationTests.swift; sourceTree = ""; }; @@ -1244,7 +1264,6 @@ BD1BE43AA9EAEA03F8ED400C /* HapticRequestParserTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HapticRequestParserTests.swift; sourceTree = ""; }; BD1BE43AA9EAEA03F8ED400D /* HapticRequestValidatorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HapticRequestValidatorTests.swift; sourceTree = ""; }; BECF3D292B29C1894F80948F /* MBEventRepositorySendRawTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBEventRepositorySendRawTests.swift; sourceTree = ""; }; - 10CBD02CEBA456777876ACF8 /* OperationResponseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OperationResponseTests.swift; sourceTree = ""; }; BF1A11C4A4B940898BA80035 /* DateFormatMigrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateFormatMigrationTests.swift; sourceTree = ""; }; D216DE502C0716B70020F58A /* StringExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringExtensionsTests.swift; sourceTree = ""; }; D216DE522C0716B80020F58A /* TimeIntervalTimeSpanTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimeIntervalTimeSpanTests.swift; sourceTree = ""; }; @@ -1252,7 +1271,10 @@ D2F7E2462BADB9EF00B24BB8 /* UserVisitManagerTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserVisitManagerTests.swift; sourceTree = ""; }; D2F7E2492BADC2AB00B24BB8 /* SessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionManager.swift; sourceTree = ""; }; D2F7E24B2BADC4CA00B24BB8 /* MockSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSessionManager.swift; sourceTree = ""; }; + DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmPlanner.swift; sourceTree = ""; }; + EEF72962233546298FD552C9 /* MonitoringLogsOldDeviceUuidFormat.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MonitoringLogsOldDeviceUuidFormat.json; sourceTree = ""; }; F0DB93A7997961CA7C2BE917 /* MotionServiceBehaviorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MotionServiceBehaviorTests.swift; sourceTree = ""; }; + F2DAFC2435264F15B8033CCE /* WebViewReadyChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewReadyChecker.swift; sourceTree = ""; }; F30005432CFF3F7D004BE915 /* ABTestStubs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ABTestStubs.swift; sourceTree = ""; }; F30629192BD27D7500EF6609 /* InappFrequencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappFrequencyTests.swift; sourceTree = ""; }; F30654BA2F1A83520058808C /* MindboxWebViewFacade.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindboxWebViewFacade.swift; sourceTree = ""; }; @@ -1345,17 +1367,16 @@ F34A103B2F455B840065392A /* SettingsConfigParsingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsConfigParsingTests.swift; sourceTree = ""; }; F34A103E2F455C5B0065392A /* SettingsFeatureTogglesError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesError.json; sourceTree = ""; }; F34A103F2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorFalse.json; sourceTree = ""; }; - F34A10492F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppTagsFalse.json; sourceTree = ""; }; - F34A104A2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppTagsTypeError.json; sourceTree = ""; }; F34A10402F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorMissing.json; sourceTree = ""; }; F34A10412F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppShowErrorTypeError.json; sourceTree = ""; }; F34A10422F455C5B0065392A /* SettingsFeatureTogglesTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesTypeError.json; sourceTree = ""; }; + F34A10492F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsFalse.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppTagsFalse.json; sourceTree = ""; }; + F34A104A2F455C5B0065392A /* SettingsFeatureTogglesShouldSendInAppTagsTypeError.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = SettingsFeatureTogglesShouldSendInAppTagsTypeError.json; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; - 7A3F1B2C9D4E5F60718293A6 /* Tags-FailedTargeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Tags-FailedTargeting.json"; sourceTree = ""; }; F351F1C52CE626450053423E /* 15-Targeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "15-Targeting.json"; sourceTree = ""; }; F351F1C72CE72B300053423E /* 44-Targeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "44-Targeting.json"; sourceTree = ""; }; F351F1C92CE72D460053423E /* 45-Targeting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "45-Targeting.json"; sourceTree = ""; }; @@ -1455,10 +1476,6 @@ F3C1A0012F5B100100ABC001 /* InappShowFailureManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappShowFailureManager.swift; sourceTree = ""; }; F3C1A0032F5B100100ABC001 /* InAppShowFailure.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppShowFailure.swift; sourceTree = ""; }; F3C1A0052F5B100100ABC001 /* InappShowFailureManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InappShowFailureManagerTests.swift; sourceTree = ""; }; - 045EA348681AB859FB298A57 /* InAppTagsGatingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppTagsGatingTests.swift; sourceTree = ""; }; - F60DC2E1435521EBF0E2C5E4 /* InAppMessagesTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessagesTrackerTests.swift; sourceTree = ""; }; - 55E67F6257F15F143DEA87DE /* JSONValueTagsMergeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValueTagsMergeTests.swift; sourceTree = ""; }; - 7A3F1B2C9D4E5F60718293A4 /* TransparentViewJSBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransparentViewJSBridgeTests.swift; sourceTree = ""; }; F3CD20272F600A800065392A /* MBConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MBConfigurationTests.swift; sourceTree = ""; }; F3CD202A2F600A800065392A /* HostNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizer.swift; sourceTree = ""; }; F3CD202C2F600A800065392A /* HostNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostNormalizerTests.swift; sourceTree = ""; }; @@ -1496,7 +1513,9 @@ F3FEEAA82C25CC9F000E9D0F /* InjectionMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectionMocks.swift; sourceTree = ""; }; F3FEEAAA2C25D874000E9D0F /* InjectReplaceable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectReplaceable.swift; sourceTree = ""; }; F3FEEAAC2C25FD1F000E9D0F /* InjectABTestUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectABTestUtilities.swift; sourceTree = ""; }; + F60DC2E1435521EBF0E2C5E4 /* InAppMessagesTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppMessagesTrackerTests.swift; sourceTree = ""; }; F78E92EE282E63320003B4A3 /* DispatchSemaphore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DispatchSemaphore.swift; sourceTree = ""; }; + FD1627DAC8D54E448C6D7BF0 /* InAppWebViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewFactory.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -1505,10 +1524,10 @@ 471D4C3B2FEED16200856EA5 /* TestPlans */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = TestPlans; sourceTree = ""; }; 57A1B3230000000000000203 /* EmbeddedBlocks */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EmbeddedBlocks; sourceTree = ""; }; 57A1B3230000000000000300 /* EmbeddedBlocks */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EmbeddedBlocks; sourceTree = ""; }; + A8C878878353491FA01AC096 /* WebViewPrewarmTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = WebViewPrewarmTests; sourceTree = ""; }; F385631E2DB6729000D91208 /* InappConfigurationDataFacade */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappConfigurationDataFacade; sourceTree = ""; }; F397DE1C2CFF568800B72DA9 /* JSONs */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = JSONs; sourceTree = ""; }; F3DEB38C2D47CBA200D0EFA4 /* InappSessionManagerTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = InappSessionManagerTests; sourceTree = ""; }; - A8C878878353491FA01AC096 /* WebViewPrewarmTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = WebViewPrewarmTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1570,6 +1589,17 @@ path = FeatureToggleManager; sourceTree = ""; }; + 0E8BCE24EE9540D6BD7F655D /* Prewarm */ = { + isa = PBXGroup; + children = ( + 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */, + DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */, + 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */, + 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */, + ); + path = Prewarm; + sourceTree = ""; + }; 2B4C84F6EDD4B7D977F67A95 /* WebView */ = { isa = PBXGroup; children = ( @@ -3104,17 +3134,6 @@ path = InAppTargetingChecker; sourceTree = ""; }; - 0E8BCE24EE9540D6BD7F655D /* Prewarm */ = { - isa = PBXGroup; - children = ( - 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */, - DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */, - 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */, - 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */, - ); - path = Prewarm; - sourceTree = ""; - }; B4E4386F2D8AFA5700603F3A /* WebView */ = { isa = PBXGroup; children = ( diff --git a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift index 926e2281e..d1bd60d85 100644 --- a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift +++ b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift @@ -9,25 +9,16 @@ import UIKit import MindboxLogger -/// Обработчик действий страницы сверх core-слоя. protocol EmbeddedBlockActionHandling: AnyObject { - func handle(_ action: EmbeddedBlockPageAction) } -/// Кто на самом деле открывает ссылку. -/// -/// Шов нужен и тестам, и на будущее: открытие ссылок в SDK уже живёт в `MindboxURLHandlerDelegate`, -/// и когда блоки поедут на общий мост инаппов, здесь окажется он, а не `UIApplication` напрямую. protocol EmbeddedBlockURLOpening { - func canOpen(_ url: URL) -> Bool - func open(_ url: URL) } final class EmbeddedBlockSystemURLOpener: EmbeddedBlockURLOpening { - func canOpen(_ url: URL) -> Bool { UIApplication.shared.canOpenURL(url) } @@ -37,14 +28,6 @@ final class EmbeddedBlockSystemURLOpener: EmbeddedBlockURLOpening { } } -/// Универсальный словарь действий страницы — один на все механики. -/// -/// Блок не знает, какая механика внутри, поэтому и действия у страниц общие: любая страница, -/// говорящая этим словарём, получает нативное поведение без нового кода в SDK. Незнакомое -/// действие — не ошибка: словарь у веб-стороны может быть новее, чем у SDK, тогда действие -/// просто логируется. -/// -/// [WIP] final class EmbeddedBlockActionRouter: EmbeddedBlockActionHandling { private enum ActionType { @@ -53,80 +36,26 @@ final class EmbeddedBlockActionRouter: EmbeddedBlockActionHandling { /// Веб-адрес — это переход по контенту, и его странице позволено открывать всегда. private enum WebScheme { - static let all: Set = ["http", "https"] + static let all: Set = ["https"] } private let urlOpener: EmbeddedBlockURLOpening - /// Схемы, которые хост объявил своими. Читаются один раз: Info.plist по ходу работы не меняется. - private let hostAppSchemes: Set - - init(urlOpener: EmbeddedBlockURLOpening = EmbeddedBlockSystemURLOpener(), - hostAppSchemes: Set = EmbeddedBlockActionRouter.hostAppSchemes(in: Bundle.main.infoDictionary)) { + init(urlOpener: EmbeddedBlockURLOpening = EmbeddedBlockSystemURLOpener()) { self.urlOpener = urlOpener - self.hostAppSchemes = hostAppSchemes } func handle(_ action: EmbeddedBlockPageAction) { switch action.type { case ActionType.openUrl: - openUrl(from: action) + print("embeddedBlock action") + // TODO: - Add action here later +// openUrl(from: action) default: Logger.common(message: "[EmbeddedBlock] Unknown page action: \(action.type)", category: .embeddedBlocks) } } - /// Схемы из `CFBundleURLTypes` — те, по которым система вернёт пользователя в это же приложение. - /// - /// На вход идёт сам `infoDictionary`, а не `Bundle`: подменить бандлу его Info.plist в тесте - /// нельзя, а разбор проверить надо. - static func hostAppSchemes(in infoDictionary: [String: Any]?) -> Set { - let types = infoDictionary?["CFBundleURLTypes"] as? [[String: Any]] ?? [] - let schemes = types - .compactMap { $0["CFBundleURLSchemes"] as? [String] } - .flatMap { $0 } - .map { $0.lowercased() } - - return Set(schemes) - } - - private func openUrl(from action: EmbeddedBlockPageAction) { - guard let raw = action.payload["url"] as? String, - let url = URL(string: raw) else { - Logger.common(message: "[EmbeddedBlock] openUrl with an invalid url: \(action.payload)", - category: .embeddedBlocks) - return - } - - guard isAllowed(url) else { - Logger.common(message: """ - [EmbeddedBlock] openUrl refused for scheme '\(url.scheme ?? "none")': a block page may open \ - web addresses and this app's own deep links, but not system-level actions. - """, category: .embeddedBlocks) - return - } - - guard urlOpener.canOpen(url) else { - Logger.common(message: "[EmbeddedBlock] openUrl cannot be opened by the system: \(url.absoluteString)", - category: .embeddedBlocks) - return - } - - Logger.common(message: "[EmbeddedBlock] Opening url: \(url.absoluteString)", category: .embeddedBlocks) - urlOpener.open(url) - } - - /// Страница блока приезжает из сети, поэтому решать за пользователя, что откроет система, ей не - /// положено: `tel:`, `sms:`, `itms-apps:` и схемы чужих приложений — это уже не переход по - /// контенту, а действие от его имени, и `canOpenURL` для них проходит. - /// - /// Разрешено поэтому ровно то, что никуда пользователя не увозит: веб-адреса и диплинки в само - /// это приложение. Понадобится большее — это отдельное явное согласие хоста, а не молчаливое - /// право страницы. - private func isAllowed(_ url: URL) -> Bool { - guard let scheme = url.scheme?.lowercased() else { return false } - - return WebScheme.all.contains(scheme) || hostAppSchemes.contains(scheme) - } + // TODO: - Will reuse webView route logic from inapps } diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift index 77dbb05c1..e0174f0e3 100644 --- a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift @@ -8,20 +8,9 @@ import Foundation -/// Что именно показывает встроенный блок. -/// -/// Контент блока всегда веб: SDK ничего не рисует сам, он показывает страницу, закреплённую за id -/// блока. Меняется внутри — адрес, вёрстка, механика на странице, — но не вид контента, поэтому -/// дескриптор описывает веб-страницу прямо, без промежуточного «вида контента». -/// -/// Сюда же приедут остальные поля конфига, когда он появится: версия веб-контракта, -/// зарезервированная высота, параметры страницы. struct EmbeddedBlockWebContent: Equatable { - /// Чем задана страница. enum Source: Equatable { - - /// Боевой случай: адрес, который приедет из конфига. case url(URL) /// Разметка вместо адреса. Нужна отладочной подмене контента: сценарии приёмки — пустая diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockActionRouterTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockActionRouterTests.swift deleted file mode 100644 index e469ed6ba..000000000 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockActionRouterTests.swift +++ /dev/null @@ -1,194 +0,0 @@ -// -// EmbeddedBlockActionRouterTests.swift -// MindboxTests -// -// Created by vailence on 10.08.2026. -// Copyright © 2026 Mindbox. All rights reserved. -// - -import Testing -import Foundation -@testable import Mindbox - -@Suite("Embedded block action router", .tags(.embeddedBlocks)) -struct EmbeddedBlockActionRouterTests { - - // MARK: - openUrl - - @Test("A web address from the page is opened") - func webAddressIsOpened() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener) - - router.handle(openUrl("https://mindbox.ru/promo")) - - #expect(opener.openedURLs.map(\.absoluteString) == ["https://mindbox.ru/promo"]) - } - - @Test("Plain http is opened too") - func httpIsOpened() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener) - - router.handle(openUrl("http://mindbox.ru")) - - #expect(opener.openedURLs.count == 1) - } - - /// Диплинк в само это приложение никуда пользователя не увозит, поэтому он разрешён — но только - /// если хост действительно объявил эту схему своей. - @Test("A deep link into the host app itself is opened") - func hostAppDeepLinkIsOpened() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener, hostAppSchemes: ["myshop"]) - - router.handle(openUrl("myshop://cart")) - - #expect(opener.openedURLs.count == 1) - } - - @Test("Scheme matching ignores case") - func schemeMatchingIgnoresCase() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener, hostAppSchemes: ["myshop"]) - - router.handle(openUrl("MyShop://cart")) - router.handle(openUrl("HTTPS://mindbox.ru")) - - #expect(opener.openedURLs.count == 2) - } - - // MARK: - Schemes the page may not open - - /// Системное действие — это уже не переход по контенту: страница блока приезжает из сети, и - /// звонить за пользователя ей не положено. - @Test("A tel: link from the page is refused") - func telLinkIsRefused() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener) - - router.handle(openUrl("tel://+79001234567")) - - #expect(opener.openedURLs.isEmpty) - } - - @Test("System and third-party app schemes are refused") - func foreignSchemesAreRefused() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener, hostAppSchemes: ["myshop"]) - - for raw in ["sms://+79001234567", - "itms-apps://apps.apple.com/app/id1", - "app-settings://", - "mailto:hi@mindbox.ru", - "someotherapp://pay"] { - router.handle(openUrl(raw)) - } - - #expect(opener.openedURLs.isEmpty) - } - - /// Схема чужого приложения не становится разрешённой от того, что система умеет её открыть, — - /// именно это `canOpenURL` и говорит. - @Test("A refused scheme is not saved by canOpenURL saying yes") - func canOpenDoesNotOverridePolicy() { - let opener = EmbeddedBlockURLOpenerMock() - opener.canOpenAnything = true - let router = makeRouter(opener: opener) - - router.handle(openUrl("tel://+79001234567")) - - #expect(opener.openedURLs.isEmpty) - } - - @Test("A url without a scheme is refused") - func schemelessUrlIsRefused() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener) - - router.handle(openUrl("mindbox.ru/promo")) - - #expect(opener.openedURLs.isEmpty) - } - - // MARK: - Malformed actions - - @Test("An allowed url the system cannot open is not opened") - func unopenableUrlIsNotOpened() { - let opener = EmbeddedBlockURLOpenerMock() - opener.canOpenAnything = false - let router = makeRouter(opener: opener) - - router.handle(openUrl("https://mindbox.ru")) - - #expect(opener.openedURLs.isEmpty) - } - - @Test("openUrl without a url payload opens nothing") - func missingUrlOpensNothing() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener) - - router.handle(EmbeddedBlockPageAction(type: "openUrl", payload: ["type": "openUrl"])) - - #expect(opener.openedURLs.isEmpty) - } - - @Test("openUrl with a non-string url opens nothing") - func nonStringUrlOpensNothing() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener) - - router.handle(EmbeddedBlockPageAction(type: "openUrl", payload: ["url": 42])) - - #expect(opener.openedURLs.isEmpty) - } - - /// Словарь у веб-стороны может быть новее, чем у SDK: незнакомое действие — не ошибка. - @Test("An unknown action is ignored without side effects") - func unknownActionIsIgnored() { - let opener = EmbeddedBlockURLOpenerMock() - let router = makeRouter(opener: opener) - - router.handle(EmbeddedBlockPageAction(type: "shareSomethingNew", payload: ["url": "https://mindbox.ru"])) - - #expect(opener.openedURLs.isEmpty) - } - - // MARK: - Host app schemes - - @Test("Every scheme of every declared url type counts as the host's own") - func allDeclaredSchemesAreCollected() { - let info: [String: Any] = [ - "CFBundleURLTypes": [ - ["CFBundleURLName": "main", "CFBundleURLSchemes": ["MyShop", "myshop-dev"]], - ["CFBundleURLName": "legacy", "CFBundleURLSchemes": ["oldshop"]] - ] - ] - - let schemes = EmbeddedBlockActionRouter.hostAppSchemes(in: info) - - #expect(schemes == ["myshop", "myshop-dev", "oldshop"]) - } - - /// Хост может не объявлять схем вообще, а объявленное — быть неполным: разбор Info.plist не - /// должен ни падать, ни придумывать схемы, которых там нет. - @Test("A missing or malformed CFBundleURLTypes yields no schemes") - func malformedBundleYieldsNoSchemes() { - #expect(EmbeddedBlockActionRouter.hostAppSchemes(in: nil).isEmpty) - #expect(EmbeddedBlockActionRouter.hostAppSchemes(in: [:]).isEmpty) - #expect(EmbeddedBlockActionRouter.hostAppSchemes(in: ["CFBundleURLTypes": "myshop"]).isEmpty) - #expect(EmbeddedBlockActionRouter.hostAppSchemes(in: ["CFBundleURLTypes": [["CFBundleURLName": "main"]]]).isEmpty) - } - - // MARK: - Helpers - - private func makeRouter(opener: EmbeddedBlockURLOpening, - hostAppSchemes: Set = []) -> EmbeddedBlockActionRouter { - EmbeddedBlockActionRouter(urlOpener: opener, hostAppSchemes: hostAppSchemes) - } - - private func openUrl(_ raw: String) -> EmbeddedBlockPageAction { - EmbeddedBlockPageAction(type: "openUrl", payload: ["type": "openUrl", "url": raw]) - } -} From 7c974da1d3860a4b2a83f40d50c33e38307c707c Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 11 Aug 2026 17:51:08 +0500 Subject: [PATCH 37/47] MOBILE-323: Translate embedded block comments to English Also carries the pending working-tree changes: the URL opener is dropped from the action router until the in-app web view route logic is reused, resolver answers are delivered on the main thread, and the page no longer detaches its bridge in deinit. --- .../Actions/EmbeddedBlockActionRouter.swift | 26 -- .../Public/MindboxEmbeddedBlockDebug.swift | 53 ++-- .../EmbeddedBlockContentOverrides.swift | 24 +- .../Resolver/EmbeddedBlockResolver.swift | 82 ++++-- .../Resolver/EmbeddedBlockWebContent.swift | 6 +- .../WebView/EmbeddedBlockPageHosting.swift | 27 +- .../WebView/EmbeddedBlockPageMessage.swift | 33 +-- .../EmbeddedBlockReadinessOverrides.swift | 30 +-- .../WebView/EmbeddedBlockWebViewPage.swift | 57 ++-- .../EmbeddedBlocks/EmbeddedBlockMocks.swift | 254 ------------------ .../EmbeddedBlockResolverTests.swift | 46 +++- .../EmbeddedBlockWebViewPageTests.swift | 24 +- 12 files changed, 218 insertions(+), 444 deletions(-) diff --git a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift index d1bd60d85..7488266d1 100644 --- a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift +++ b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift @@ -13,38 +13,12 @@ protocol EmbeddedBlockActionHandling: AnyObject { func handle(_ action: EmbeddedBlockPageAction) } -protocol EmbeddedBlockURLOpening { - func canOpen(_ url: URL) -> Bool - func open(_ url: URL) -} - -final class EmbeddedBlockSystemURLOpener: EmbeddedBlockURLOpening { - func canOpen(_ url: URL) -> Bool { - UIApplication.shared.canOpenURL(url) - } - - func open(_ url: URL) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) - } -} - final class EmbeddedBlockActionRouter: EmbeddedBlockActionHandling { private enum ActionType { static let openUrl = "openUrl" } - /// Веб-адрес — это переход по контенту, и его странице позволено открывать всегда. - private enum WebScheme { - static let all: Set = ["https"] - } - - private let urlOpener: EmbeddedBlockURLOpening - - init(urlOpener: EmbeddedBlockURLOpening = EmbeddedBlockSystemURLOpener()) { - self.urlOpener = urlOpener - } - func handle(_ action: EmbeddedBlockPageAction) { switch action.type { case ActionType.openUrl: diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockDebug.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockDebug.swift index 0ca8d9a20..3e404a0fc 100644 --- a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockDebug.swift +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockDebug.swift @@ -8,59 +8,62 @@ import Foundation -/// Отладочное управление содержимым встроенных блоков — для тестового приложения и приёмки. +/// Debug control over embedded block content — for the test app and acceptance testing. /// -/// Подменяет ответ на вопрос «что стоит за этим id», то есть встаёт ровно на место конфига из -/// админки. Всё, что ниже — резолвер, провайдер, страница, бюджет ожидания у контейнера — работает -/// без изменений, поэтому приёмка проверяет боевой путь, а не отдельный тестовый режим. +/// Overrides the answer to "what stands behind this id", that is, it takes exactly the place of the +/// admin panel config. Everything below — the resolver, the provider, the page, the container's +/// waiting budget — works unchanged, so acceptance testing exercises the production path rather +/// than a separate test mode. /// -/// Не часть публичного API: доступно только через `@_spi(Internal) import Mindbox`. Из релизных -/// сборок не вырезано намеренно — QA проверяет ровно то, что уходит клиентам, — поэтому каждая -/// установка подмены пишется в лог. +/// Not part of the public API: available only via `@_spi(Internal) import Mindbox`. Deliberately +/// not stripped from release builds — QA checks exactly what ships to clients — which is why every +/// override that gets set is written to the log. @_spi(Internal) public enum MindboxEmbeddedBlockDebug { - /// Чем подменить содержимое блока. + /// What to replace the block content with. public enum Content { - /// Адрес страницы. Так гоняются сценарии на реальной сети — включая заведомо недоступный - /// адрес, чтобы получить провал загрузки. + /// A page url. This is how scenarios are run against the real network — including a + /// knowingly unreachable address, to get a load failure. case url(URL) - /// Готовая разметка. Так задаются сценарии, которых в сети нет: страница, сообщающая - /// «пусто», молчащая страница, страница с ответом после таймаута. + /// Ready-made markup. This is how scenarios that do not exist on the network are set up: a + /// page reporting "empty", a silent page, a page that answers after the timeout. case html(String) - /// За id ничего не закреплено: блок выключен в админке или id неизвестен. + /// Nothing is attached to the id: the block is turned off in the admin panel or the id is + /// unknown. case empty } - /// Подменяет содержимое блока с этим id. Действует на блоки, которые начнут загрузку после - /// вызова: уже показанный блок надо перезагрузить или заново открыть экран. + /// Overrides the content of the block with this id. Applies to blocks that start loading after + /// the call: a block that is already shown has to be reloaded or its screen reopened. public static func setContent(_ content: Content, for id: String) { EmbeddedBlockContentOverrides.shared.set(content.resolution, for: id) } - /// Возвращает блоку его обычное содержимое. + /// Gives the block its usual content back. public static func removeContent(for id: String) { EmbeddedBlockContentOverrides.shared.remove(for: id) } - /// Снимает все подмены сразу. + /// Drops every override at once. public static func removeAllContent() { EmbeddedBlockContentOverrides.shared.removeAll() } - /// Показывать блок, как только загрузился документ, не дожидаясь `ready` от страницы. + /// Show the block as soon as the document has loaded, without waiting for `ready` from the page. /// - /// Нужно ровно одному сценарию: посмотреть, как блок выглядит и ведёт себя в вёрстке хоста, - /// пока веб-контракт не реализован на странице. По обычному правилу такая страница молчит, - /// а значит сворачивается по таймауту контейнера, и увидеть в блоке нечего. + /// Needed for exactly one scenario: seeing how the block looks and behaves inside the host + /// layout while the web contract is not implemented on the page yet. Under the usual rule such + /// a page stays silent, which means it collapses on the container timeout and there is nothing + /// to see in the block. /// - /// Выключено по умолчанию и ставится один раз при старте приложения. Держать включённым - /// дольше проверки UI не стоит: со включённым флагом сломанная страница выглядит как рабочая. - /// `ready` от страницы флаг не отменяет — он лишь добавляет второй повод показать блок, - /// поэтому страница, которая контракт умеет, ведёт себя одинаково с ним и без него. + /// Off by default and set once at app startup. Keeping it on for longer than the UI check is a + /// bad idea: with the flag on, a broken page looks like a working one. The flag does not cancel + /// `ready` from the page — it only adds a second reason to show the block, so a page that does + /// implement the contract behaves the same with it and without it. public static var treatsLoadedPageAsReady: Bool { get { EmbeddedBlockReadinessOverrides.shared.treatsLoadedPageAsReady } set { EmbeddedBlockReadinessOverrides.shared.setTreatsLoadedPageAsReady(newValue) } diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockContentOverrides.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockContentOverrides.swift index 20148a442..e0a6731cc 100644 --- a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockContentOverrides.swift +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockContentOverrides.swift @@ -9,28 +9,30 @@ import Foundation import MindboxLogger -/// Что подставить вместо контента, закреплённого за id блока. +/// What to substitute for the content attached to a block id. protocol EmbeddedBlockContentOverriding: AnyObject { func resolution(for id: String) -> EmbeddedBlockResolution? } -/// Отладочная подмена контента блока — то, чем приёмка воспроизводит сценарии, которые в сети не -/// выложены: пустой блок, молчащая страница, ответ уже после таймаута, незнакомое сообщение. +/// A debug override of the block content — what acceptance testing uses to reproduce scenarios that +/// are not published on the network: an empty block, a silent page, an answer that comes after the +/// timeout, an unknown message. /// -/// Подмена сидит на месте конфига, поэтому весь путь ниже — резолвер, провайдер, страница, таймаут -/// контейнера — работает по-настоящему; меняется только источник данных о блоке. Кэш резолвера для -/// подменённого id не используется, чтобы переключение сценария применялось сразу. +/// The override sits in the place of the config, so the whole path below it — the resolver, the +/// provider, the page, the container timeout — works for real; only the source of the block data +/// changes. The resolver cache is not used for an overridden id, so that switching a scenario +/// applies right away. /// -/// Спрятана за `@_spi(Internal)`: в обычном API её нет, но и не вырезана из релизных сборок — QA -/// проверяет то, что уходит клиентам. Каждая установка пишется в лог, чтобы включённую подмену было -/// невозможно не заметить. +/// Hidden behind `@_spi(Internal)`: it is absent from the regular API, but it is not stripped from +/// release builds either — QA checks what ships to clients. Every override that gets set is written +/// to the log, so an enabled override is impossible to miss. final class EmbeddedBlockContentOverrides: EmbeddedBlockContentOverriding { static let shared = EmbeddedBlockContentOverrides() - /// Подмену ставят из QA-кода приложения, а читает её резолвер на главном потоке — потоки могут - /// не совпасть. + /// The override is set from the app's QA code, while the resolver reads it on the main thread — + /// the threads may differ. private let lock = NSLock() private var overrides: [String: EmbeddedBlockResolution] = [:] diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift index 202e9689b..85c571244 100644 --- a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift @@ -9,25 +9,28 @@ import Foundation import MindboxLogger -/// Во что разрешается id встроенного блока. +/// What an embedded block id resolves into. enum EmbeddedBlockResolution: Equatable { - /// За id закреплён контент — блок грузит его. + /// There is content attached to the id — the block loads it. case content(EmbeddedBlockWebContent) - /// За id ничего нет — блок выключен в админке или id неизвестен. Не ошибка. + /// There is nothing behind the id — the block is turned off in the admin panel or the id is + /// unknown. Not an error. case empty } -/// Отвечает на единственный вопрос: что показывает блок с данным id. +/// Answers a single question: what does the block with this id show. /// -/// Резолвер — общая точка всех контейнеров: несколько блоков с одним id разрешаются одними -/// данными, при этом вью, страница и состояние у каждого блока остаются своими. Работает на -/// главном потоке; completion может прийти как синхронно (кэш), так и позже (сетевой конфиг). +/// The resolver is the shared point for every container: several blocks with the same id resolve +/// with the same data, while the view, the page and the state stay per block. Works on the main +/// thread; the completion may arrive either synchronously (cache) or later (remote config). protocol EmbeddedBlockResolving: AnyObject { - /// - Parameter forceRefresh: `true` — не брать кэш, спросить данные заново. Нужно перезагрузке - /// блока: переехавший или выключенный блок иначе вечно доставал бы из кэша прежний адрес. + /// - Parameter forceRefresh: `true` — skip the cache, ask for the data again. Needed by a block + /// reload: a block that moved or was turned off would otherwise keep pulling the old address + /// from the cache forever. Laid down in advance and deliberately not exposed: while "once per + /// SDK initialization" holds, a reload cannot be triggered from the app. func resolve(_ id: String, forceRefresh: Bool, completion: @escaping (EmbeddedBlockResolution) -> Void) } @@ -38,26 +41,36 @@ extension EmbeddedBlockResolving { } } -/// Откуда резолвер узнаёт, что стоит за id блока. +/// Where the resolver learns what stands behind a block id. /// -/// Сейчас это заглушка со статической страницей. Когда появится конфиг из админки, здесь окажется -/// настоящая загрузка, а кэш и очередь ожидающих в резолвере не изменятся. +/// For now it is a stub with a static page. Once the admin panel config arrives, the real loading +/// will live here, while the cache and the queue of waiters in the resolver stay unchanged. +/// +/// It may answer from any thread: the resolver moves the answer to the main thread itself, because +/// that is where it updates the cache and the queue of waiters, and where the block views wait for +/// the answer. typealias EmbeddedBlockContentLoading = (String, @escaping (EmbeddedBlockResolution) -> Void) -> Void final class EmbeddedBlockResolver: EmbeddedBlockResolving { - /// Страница ленты сторизов на статике. Временно захардкожена: когда появится конфиг из - /// админки, адрес приедет оттуда вместе с маппингом id → контент. + /// The stories feed page on static hosting. Hardcoded for now: once the admin panel config + /// arrives, the address will come from there together with the id → content mapping. private static let storiesPageURL = "https://mobile-static.mindbox.ru/beta/inapps/webview/content/stories.html" private let load: EmbeddedBlockContentLoading private let overrides: EmbeddedBlockContentOverriding - /// Кэш на id: ответ, полученный один раз, достаётся всем следующим блокам сразу. + /// A cache per id: an answer received once is handed to every following block immediately. + /// + /// Lives until the end of the process and is never invalidated — including `.empty`. That is a + /// decision, not an oversight: a block resolves once per SDK initialization, full stop. It + /// follows that a block turned off in the admin panel, or one that did not make it at app + /// startup, will not appear until a restart — and that is by design. This may change later, but + /// for now it is so. private var cache: [String: EmbeddedBlockResolution] = [:] - /// Кто уже ждёт ответ по этому id. «Одна загрузка данных на id» — это про то, что второй блок - /// с тем же id встаёт в эту очередь, а не идёт за данными сам. + /// Who is already waiting for the answer for this id. "One data load per id" means that a + /// second block with the same id joins this queue instead of going for the data itself. private var waiting: [String: [(EmbeddedBlockResolution) -> Void]] = [:] init(load: @escaping EmbeddedBlockContentLoading = EmbeddedBlockResolver.loadStubbedStoriesPage, @@ -67,8 +80,8 @@ final class EmbeddedBlockResolver: EmbeddedBlockResolving { } func resolve(_ id: String, forceRefresh: Bool, completion: @escaping (EmbeddedBlockResolution) -> Void) { - // Отладочная подмена сильнее и данных, и кэша: приёмка переключает сценарий на ходу, и - // закэшированный ответ мешал бы этому. + // The debug override outranks both the data and the cache: acceptance testing switches + // scenarios on the fly, and a cached answer would get in the way. if let overridden = overrides.resolution(for: id) { completion(overridden) return @@ -79,8 +92,8 @@ final class EmbeddedBlockResolver: EmbeddedBlockResolving { return } - // Загрузка по этому id уже идёт. Присоединиться к ней правильно и для `forceRefresh`: - // ответ, который она вот-вот принесёт, свежий по определению. + // A load for this id is already in flight. Joining it is right for `forceRefresh` too: the + // answer it is about to bring is fresh by definition. if waiting[id] != nil { waiting[id]?.append(completion) return @@ -89,17 +102,28 @@ final class EmbeddedBlockResolver: EmbeddedBlockResolving { waiting[id] = [completion] load(id) { [weak self] resolution in - guard let self else { return } - - self.cache[id] = resolution - let completions = self.waiting.removeValue(forKey: id) ?? [] - completions.forEach { $0(resolution) } + guard Thread.isMainThread else { + DispatchQueue.main.async { + self?.finish(id, with: resolution) + } + return + } + + self?.finish(id, with: resolution) } } - /// Конфига ещё нет, поэтому любой id разрешается в страницу ленты сторизов. Это единственное - /// место, которое заменит настоящий конфиг из админки: id → контент блока, выключенный или - /// неизвестный блок → `.empty`. + /// The answer has arrived: it goes into the cache and is handed to the whole queue that waited + /// for it. + private func finish(_ id: String, with resolution: EmbeddedBlockResolution) { + cache[id] = resolution + let completions = waiting.removeValue(forKey: id) ?? [] + completions.forEach { $0(resolution) } + } + + /// There is no config yet, so any id resolves into the stories feed page. This is the single + /// place the real admin panel config will replace: id → block content, a turned off or unknown + /// block → `.empty`. static func loadStubbedStoriesPage(_ id: String, completion: @escaping (EmbeddedBlockResolution) -> Void) { guard let url = URL(string: storiesPageURL) else { Logger.common(message: "[EmbeddedBlock] Invalid stories page URL, resolving id '\(id)' as empty", diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift index e0174f0e3..efc92d9e2 100644 --- a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockWebContent.swift @@ -13,8 +13,10 @@ struct EmbeddedBlockWebContent: Equatable { enum Source: Equatable { case url(URL) - /// Разметка вместо адреса. Нужна отладочной подмене контента: сценарии приёмки — пустая - /// страница, молчащая страница, ответ уже после таймаута — в сеть не выкладываются. + /// Markup instead of an address. Needed by the debug content override: acceptance + /// scenarios — an empty page, a silent page, an answer that comes after the timeout — are + /// not published to the network. + /// TODO: - Remove this once we parse the url from the config case html(String) } diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift index c2f1afead..ff573be8a 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageHosting.swift @@ -8,31 +8,32 @@ import UIKit -/// Страница встроенного блока — всё, что провайдеру нужно от вебвью. +/// The embedded block page — everything the provider needs from the web view. /// -/// Единственный шов внутри блока и единственное место, где живёт WebKit: перевод сообщений -/// страницы в состояния блока так проверяется без реального вебвью и без сети. +/// The single seam inside the block and the single place where WebKit lives: this is what lets the +/// translation of page messages into block states be tested without a real web view and without +/// the network. protocol EmbeddedBlockPageHosting: AnyObject { - /// Вью страницы. Провайдер отдаёт её контейнеру как контент блока. + /// The page view. The provider hands it to the container as the block content. var view: UIView { get } - /// Сообщения от страницы. Приходят на главном потоке. + /// Messages from the page. Delivered on the main thread. var onMessage: ((EmbeddedBlockPageMessage) -> Void)? { get set } - /// Загрузка страницы не состоялась — соединение, домен, отменённая навигация. Только про это - /// навигация и сообщает: готова ли страница, решает сама страница своим `ready`. - /// Приходит на главном потоке. + /// The page failed to load — connection, domain, a cancelled navigation. That is all navigation + /// reports: whether the page is ready is decided by the page itself with its `ready`. + /// Delivered on the main thread. var onLoadFailure: (() -> Void)? { get set } - /// Документ загрузился. Готовности блока это не значит — страница может быть пустой или - /// сломанной, — поэтому обычный путь этот сигнал игнорирует: его слушает только отладочная - /// подмена готовности для страниц без веб-контракта. Приходит на главном потоке. + /// The document has loaded. That does not mean the block is ready — the page may be empty or + /// broken — so the regular path ignores this signal: the only listener is the debug readiness + /// override for pages without the web contract. Delivered on the main thread. var onLoadFinish: (() -> Void)? { get set } func load() - /// Останавливает загрузку. Страница и её мост остаются на месте: блок может вернуться в окно, - /// и тогда уже отрендеренная страница показывается снова без перезагрузки. + /// Stops the loading. The page and its bridge stay in place: the block may come back into the + /// window, and then the already rendered page is shown again without a reload. func cancel() } diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift index b5d5087f0..edc13a94d 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockPageMessage.swift @@ -9,30 +9,33 @@ import CoreGraphics import Foundation -/// Что страница встроенного блока сообщает нативной стороне. +/// What the embedded block page reports to the native side. /// -/// Ядро разбирает только core-слой — `ready`, `heightChanged` и `empty`, они нужны любому блоку. Всё -/// остальное с валидным конвертом уходит в механику как `action`: ядро не знает и не должно -/// знать словарь конкретной механики. +/// The core parses only the core layer — `ready`, `heightChanged` and `empty`, which every block +/// needs. Everything else with a valid envelope goes to the mechanic as an `action`: the core does +/// not know, and must not know, the vocabulary of a particular mechanic. /// -/// Формат пока свой и минимальный: страница шлёт `{"type": ..., ...}`. Сведение с общим -/// JS-мостом инаппов (`MindboxWebBridge`) — отдельная задача, до неё этот разбор трогать не нужно. +/// The format is our own and minimal for now: the page sends `{"type": ..., ...}`. Converging with +/// the shared in-app JS bridge (`MindboxWebBridge`) is a separate task; until then this parsing does +/// not need to be touched. enum EmbeddedBlockPageMessage: Equatable { - /// Страница отрисовалась и просит контейнер стать `height` точек высотой. + /// The page has rendered and asks the container to become `height` points tall. case ready(height: CGFloat) - /// Страница перемерилась уже после показа — например, подгрузился контент. + /// The page re-measured itself after it was shown — for example, more content was loaded. case heightChanged(height: CGFloat) - /// Странице нечего показать — например, блок выключен в админке. Это не ошибка. + /// The page has nothing to show — for example, the block is turned off in the admin panel. This + /// is not an error. case empty - /// Действие сверх core-слоя — его смысл знает механика блока. + /// An action beyond the core layer — its meaning is known to the block mechanic. case action(EmbeddedBlockPageAction) - /// Тело сообщения приходит из WebKit как `Any`. Строку разбираем как JSON, словарь берём как - /// есть: страница может присылать и то и другое, а падать на форме сообщения тут незачем. + /// The message body arrives from WebKit as `Any`. A string is parsed as JSON, a dictionary is + /// taken as is: the page may send either one, and there is no reason to fail on the message + /// shape here. init?(body: Any) { let payload: [String: Any] @@ -64,7 +67,7 @@ enum EmbeddedBlockPageMessage: Equatable { } } - /// JS отдаёт число как `Double`, но целые значения могут прийти и как `Int` — берём оба. + /// JS gives a number as a `Double`, but whole values may also arrive as an `Int` — take both. private static func height(from payload: [String: Any]) -> CGFloat? { if let height = payload["height"] as? Double { return CGFloat(height) @@ -78,8 +81,8 @@ enum EmbeddedBlockPageMessage: Equatable { } } -/// Конверт действия, которое ядро не разбирает, а передаёт механике: тип и весь payload -/// сообщения как есть. +/// The envelope of an action the core does not parse but passes to the mechanic: the type and the +/// whole message payload as is. struct EmbeddedBlockPageAction: Equatable { let type: String diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockReadinessOverrides.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockReadinessOverrides.swift index d081158cc..585037ad4 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockReadinessOverrides.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockReadinessOverrides.swift @@ -9,33 +9,33 @@ import Foundation import MindboxLogger -/// Отладочная подмена условия готовности блока. +/// A debug override of the block readiness condition. protocol EmbeddedBlockReadinessOverriding: AnyObject { - /// `true` — блок становится готовым по факту загруженного документа, не дожидаясь `ready` от - /// страницы. + /// `true` — the block becomes ready on the fact of a loaded document, without waiting for + /// `ready` from the page. var treatsLoadedPageAsReady: Bool { get } } -/// Временный костыль для страниц, которые ещё не умеют веб-контракт. +/// A temporary crutch for pages that do not implement the web contract yet. /// -/// Обычное правило блока — готовность объявляет только сама страница: загруженный документ ничего -/// не говорит о том, есть ли блоку что показать, поэтому молчащую страницу добивает таймаут -/// контейнера. Пока контракт не реализован на вебе, проверить вёрстку блока этим правилом -/// невозможно: любая страница сворачивается в ноль через таймаут. +/// The block's usual rule is that only the page itself declares readiness: a loaded document says +/// nothing about whether the block has anything to show, so a silent page is finished off by the +/// container timeout. While the contract is not implemented on the web side, checking the block +/// layout under this rule is impossible: any page collapses to zero on the timeout. /// -/// Подмена снимает ровно это ограничение и ничего больше: загрузился документ — показываем. Она -/// выключена по умолчанию и включается только явно из кода приложения, потому что со включённой -/// подменой сломанная страница выглядит как рабочая — а это ровно то, от чего защищает обычное -/// правило. +/// The override lifts exactly this restriction and nothing more: the document has loaded — we show +/// it. It is off by default and turned on only explicitly from the app code, because with the +/// override on a broken page looks like a working one — which is exactly what the usual rule +/// protects against. /// -/// Уедет вместе с первой страницей, которая научится присылать `ready`. +/// Goes away together with the first page that learns to send `ready`. final class EmbeddedBlockReadinessOverrides: EmbeddedBlockReadinessOverriding { static let shared = EmbeddedBlockReadinessOverrides() - /// Флаг ставят из кода приложения, а читает его провайдер на главном потоке — потоки могут не - /// совпасть. + /// The flag is set from the app code, while the provider reads it on the main thread — the + /// threads may differ. private let lock = NSLock() private var isLoadedPageTreatedAsReady = false diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift index 7e69678db..17850c32d 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift @@ -10,13 +10,14 @@ import UIKit import WebKit import MindboxLogger -/// Страница встроенного блока в WKWebView. +/// The embedded block page in a WKWebView. /// -/// Вебвью берётся из `InAppWebViewFactory` — того же места, где настраиваются вебвью инаппов: -/// блок получает тот же user agent и тот же `WKWebsiteDataStore`, а значит и общий HTTP-кеш. +/// The web view comes from `InAppWebViewFactory` — the same place where in-app web views are +/// configured: the block gets the same user agent and the same `WKWebsiteDataStore`, and therefore +/// a shared HTTP cache. final class EmbeddedBlockWebViewPage: NSObject, EmbeddedBlockPageHosting { - /// Имя обработчика своё, пока блоки не переехали на общий мост инаппов. + /// The handler name is our own until blocks move to the shared in-app bridge. private enum Constants { static let handlerName = "mindboxEmbeddedBlock" } @@ -42,10 +43,6 @@ final class EmbeddedBlockWebViewPage: NSObject, EmbeddedBlockPageHosting { attachBridge() } - deinit { - detachBridge() - } - func load() { switch content.source { case .url(let url): @@ -62,37 +59,30 @@ final class EmbeddedBlockWebViewPage: NSObject, EmbeddedBlockPageHosting { private func setUpWebView() { webView.navigationDelegate = self - // Фон прозрачный: сквозь зазоры в контенте должен просвечивать фон приложения, а не белый лист. + // The background is transparent: the app background, not a white sheet, should show through + // the gaps in the content. webView.isOpaque = false webView.backgroundColor = .clear webView.scrollView.backgroundColor = .clear - // Высота контейнера равна высоте контента, вертикально скроллить нечего — иначе блок - // пружинил бы под пальцем на каждом горизонтальном свайпе. + // 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. webView.scrollView.bounces = false webView.scrollView.alwaysBounceVertical = false webView.scrollView.showsVerticalScrollIndicator = false webView.scrollView.contentInsetAdjustmentBehavior = .never } - /// Мост живёт столько же, сколько страница: он ставится один раз и снимается только вместе с - /// ней. Раньше его снимала `cancel()` — из-за этого вернуть страницу в окно можно было только - /// перезагрузкой, иначе она оставалась глухой. От сообщений остановленной страницы защищает - /// провайдер, а не отсутствие моста. private func attachBridge() { let controller = webView.configuration.userContentController - // Идемпотентно: вебвью может прийти из переиспользования и нести обработчик с этим именем - // от прошлого владельца. + // Idempotent: the web view may come from reuse and carry a handler with this name from its + // previous owner. controller.removeScriptMessageHandler(forName: Constants.handlerName) - // WKUserContentController держит обработчик сильно, поэтому в него идёт слабый прокси — - // иначе страница и вебвью не освободятся никогда. + // WKUserContentController holds the handler strongly, so a weak proxy goes into it — + // otherwise the page and the web view would never be released. controller.add(EmbeddedBlockWebViewMessageProxy(receiver: self), name: Constants.handlerName) } - private func detachBridge() { - webView.configuration.userContentController.removeScriptMessageHandler(forName: Constants.handlerName) - } - fileprivate func receive(body: Any) { guard let message = EmbeddedBlockPageMessage(body: body) else { Logger.common(message: "[EmbeddedBlock] Unknown page message: \(body)", category: .embeddedBlocks) @@ -103,9 +93,9 @@ final class EmbeddedBlockWebViewPage: NSObject, EmbeddedBlockPageHosting { } } -/// Навигация судит только о своём: загрузка провалилась или документ доехал. Готовность блока из -/// этого не следует — о ней говорит сама страница своим `ready`, а загруженный документ слушает -/// одна лишь отладочная подмена готовности. +/// Navigation only judges its own business: the load failed or the document arrived. Block +/// readiness does not follow from that — it is declared by the page itself with its `ready`, and +/// the only listener of a loaded document is the debug readiness override. extension EmbeddedBlockWebViewPage: WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { @@ -125,12 +115,13 @@ extension EmbeddedBlockWebViewPage: WKNavigationDelegate { private extension EmbeddedBlockWebViewPage { - /// Отменённая навигация — не провал загрузки, и выдавать её за провал нельзя: блок схлопнулся бы - /// на ровном месте и остался бы дыркой нулевой высоты до конца жизни экрана. WebKit отдаёт - /// `NSURLErrorCancelled` в двух совершенно обычных случаях: навигацию вытеснила следующая — - /// клиентский редирект, страница загрузится сама, — и навигацию остановили мы, вызвав `cancel()` - /// на уехавшем с экрана блоке. Второй случай к тому же приходит уже после того, как блок - /// вернулся в окно, поэтому провайдер его своим `isStarted` не отфильтрует. + /// A cancelled navigation is not a load failure, and passing it off as one is not allowed: the + /// block would collapse out of nowhere and stay a zero-height hole until the end of the screen's + /// life. WebKit returns `NSURLErrorCancelled` in two perfectly ordinary cases: the navigation + /// was superseded by the next one — a client-side redirect, the page will load on its own — and + /// the navigation was stopped by us, by calling `cancel()` on a block that went off screen. The + /// second case also arrives after the block is back in the window, so the provider will not + /// filter it out with its `isStarted`. func reportLoadFailure(_ error: Error, phase: String) { let error = error as NSError @@ -146,7 +137,7 @@ private extension EmbeddedBlockWebViewPage { } } -/// Слабая прослойка между `WKUserContentController` и страницей. +/// A weak layer between `WKUserContentController` and the page. private final class EmbeddedBlockWebViewMessageProxy: NSObject, WKScriptMessageHandler { private weak var receiver: EmbeddedBlockWebViewPage? diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift index 3c19105a5..ad9ec46a2 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift @@ -15,257 +15,3 @@ extension EmbeddedBlockWebContent { static let other = EmbeddedBlockWebContent(url: URL(string: "https://mindbox.ru/another-block.html")!) } - -extension EmbeddedBlockPageAction { - - static let openUrlStub = EmbeddedBlockPageAction(type: "openUrl", payload: ["url": "https://mindbox.ru"]) -} - -/// Страница без WebKit: тесты сами решают, что и когда она скажет нативной стороне. -final class EmbeddedBlockPageMock: EmbeddedBlockPageHosting { - - let view = UIView() - - var onMessage: ((EmbeddedBlockPageMessage) -> Void)? - - var onLoadFailure: (() -> Void)? - - var onLoadFinish: (() -> Void)? - - var loadCount = 0 - var cancelCount = 0 - - func load() { - loadCount += 1 - } - - func cancel() { - cancelCount += 1 - } - - func send(_ message: EmbeddedBlockPageMessage) { - onMessage?(message) - } - - func failLoad() { - onLoadFailure?() - } - - func finishLoad() { - onLoadFinish?() - } -} - -final class EmbeddedBlockReadinessOverridesMock: EmbeddedBlockReadinessOverriding { - - var treatsLoadedPageAsReady: Bool - - init(treatsLoadedPageAsReady: Bool = false) { - self.treatsLoadedPageAsReady = treatsLoadedPageAsReady - } -} - -/// Считает, сколько страниц было создано и с каким контентом: перезагрузка обязана создать новую. -final class EmbeddedBlockPageFactoryMock { - - private(set) var pages: [EmbeddedBlockPageMock] = [] - private(set) var contents: [EmbeddedBlockWebContent] = [] - - var page: EmbeddedBlockPageMock? { pages.last } - - func make(_ content: EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting { - contents.append(content) - let page = EmbeddedBlockPageMock() - pages.append(page) - return page - } -} - -final class EmbeddedBlockResolverMock: EmbeddedBlockResolving { - - var resolution: EmbeddedBlockResolution - - /// `true` — ответ не приходит, пока тест не позовёт `flush()`: так проверяется резолв, - /// доехавший уже после остановки или перезагрузки блока. - var isDeferred = false - - private(set) var resolvedIds: [String] = [] - private(set) var forceRefreshHistory: [Bool] = [] - - var resolveCount: Int { resolvedIds.count } - - private var pending: [(EmbeddedBlockResolution) -> Void] = [] - - init(resolution: EmbeddedBlockResolution = .content(.stub)) { - self.resolution = resolution - } - - func resolve(_ id: String, forceRefresh: Bool, completion: @escaping (EmbeddedBlockResolution) -> Void) { - resolvedIds.append(id) - forceRefreshHistory.append(forceRefresh) - - if isDeferred { - pending.append(completion) - } else { - completion(resolution) - } - } - - func flush() { - let completions = pending - pending = [] - completions.forEach { $0(resolution) } - } -} - -final class EmbeddedBlockActionHandlerMock: EmbeddedBlockActionHandling { - - private(set) var handledActions: [EmbeddedBlockPageAction] = [] - - func handle(_ action: EmbeddedBlockPageAction) { - handledActions.append(action) - } -} - -/// Открыватель ссылок, который ничего не открывает: тесты смотрят, что до системы дошло, а что нет. -final class EmbeddedBlockURLOpenerMock: EmbeddedBlockURLOpening { - - /// Что отвечать на вопрос «система это откроет?». `canOpenURL` пропускает системные схемы, и - /// тесты политики схем должны проверять именно политику, а не этот ответ. - var canOpenAnything = true - - private(set) var openedURLs: [URL] = [] - - func canOpen(_ url: URL) -> Bool { - canOpenAnything - } - - func open(_ url: URL) { - openedURLs.append(url) - } -} - -/// Часы, которые идут только когда их просят. -final class TestClock { - - private(set) var now = Date(timeIntervalSince1970: 1_000_000) - - func advance(_ seconds: TimeInterval) { - now = now.addingTimeInterval(seconds) - } -} - -/// Планировщик, который сам не срабатывает никогда: «время вышло» объявляет тест. -/// -/// Благодаря ему бюджет ожидания проверяется без единого сна: и его собственные тесты, и тесты -/// контейнера, которому бюджет отдают снаружи. -final class TestScheduler { - - /// Задержка последнего завода — она же остаток бюджета, отданный отсчёту. - private(set) var lastDelay: TimeInterval? - - private var pending: [DispatchWorkItem] = [] - - func schedule(_ delay: TimeInterval, _ work: DispatchWorkItem) { - lastDelay = delay - pending.append(work) - } - - /// Выполняет заведённую работу, пропуская отменённую: `pause()` и `reset()` отменяют её ровно - /// так же, как отменяли бы работу настоящей очереди. - func fireAll() { - let scheduled = pending - pending = [] - scheduled.forEach { work in - guard !work.isCancelled else { return } - - work.perform() - } - } -} - -/// Бюджет ожидания с подменёнными часами, планировщиком и центром нотификаций — всё, чем он -/// отличается от настоящего, собрано в одном месте. -final class EmbeddedBlockTimeoutBed { - - let clock: TestClock - let scheduler: TestScheduler - - /// Свой на каждый стенд: фон и возврат из него должны доставаться только этому бюджету. - let center: NotificationCenter - - let timeout: EmbeddedBlockReadyTimeout - - init(blockId: String = "block-id", duration: TimeInterval = 5) { - let clock = TestClock() - let scheduler = TestScheduler() - let center = NotificationCenter() - self.clock = clock - self.scheduler = scheduler - self.center = center - timeout = EmbeddedBlockReadyTimeout(blockId: blockId, - duration: duration, - now: { clock.now }, - notificationCenter: center, - schedule: { scheduler.schedule($0, $1) }) - } - - func enterBackground() { - center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) - } - - func enterForeground() { - center.post(name: UIApplication.willEnterForegroundNotification, object: nil) - } -} - -/// Провайдер со всеми подменёнными зависимостями — общая заготовка для тестов провайдера и -/// контейнера. Контейнер тестируется через настоящий провайдер: единственный шов внутри блока — -/// страница, и подменять больше нечего. -final class EmbeddedBlockTestBed { - - let resolver: EmbeddedBlockResolverMock - let actionHandler: EmbeddedBlockActionHandlerMock - let readinessOverrides: EmbeddedBlockReadinessOverridesMock - let pageFactory: EmbeddedBlockPageFactoryMock - let provider: EmbeddedBlockWebViewProvider - - var page: EmbeddedBlockPageMock? { pageFactory.page } - - init(id: String = "block-id", - resolution: EmbeddedBlockResolution = .content(.stub), - treatsLoadedPageAsReady: Bool = false) { - let resolver = EmbeddedBlockResolverMock(resolution: resolution) - let actionHandler = EmbeddedBlockActionHandlerMock() - let readinessOverrides = EmbeddedBlockReadinessOverridesMock(treatsLoadedPageAsReady: treatsLoadedPageAsReady) - let pageFactory = EmbeddedBlockPageFactoryMock() - - self.resolver = resolver - self.actionHandler = actionHandler - self.readinessOverrides = readinessOverrides - self.pageFactory = pageFactory - self.provider = EmbeddedBlockWebViewProvider(id: id, - resolver: resolver, - actionHandler: actionHandler, - readinessOverrides: readinessOverrides, - makePage: { pageFactory.make($0) }) - } -} - -final class EmbeddedBlockViewDelegateMock: MindboxEmbeddedBlockViewDelegate { - - enum Event: Equatable { - case loaded - case failed - } - - private(set) var events: [Event] = [] - - func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) { - events.append(.loaded) - } - - func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView) { - events.append(.failed) - } -} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift index a67c0bfd2..ed879bd01 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift @@ -13,8 +13,9 @@ import Testing @MainActor struct EmbeddedBlockResolverTests { - /// Главное обещание резолвера: сколько блоков ни спросило бы про один id, за данными идём один - /// раз. Пока конфиг синхронный это незаметно, с сетью — это разница между одним и N запросами. + /// The resolver's main promise: however many blocks ask about one id, we go for the data once. + /// While the config is synchronous this is invisible; over the network it is the difference + /// between one request and N. @Test("Blocks asking for the same id at once share a single load") func concurrentResolvesShareOneLoad() { let loader = ContentLoaderSpy() @@ -58,8 +59,8 @@ struct EmbeddedBlockResolverTests { #expect(answer == .content(.stub)) } - /// Перезагрузка блока не должна вечно брать из кэша прежний адрес: выключенный или - /// переехавший блок иначе не починится до перезапуска приложения. + /// A block reload must not keep pulling the old address from the cache: a block that was turned + /// off or moved would otherwise stay broken until the app is restarted. @Test("Force refresh asks for the data again and replaces the cache") func forceRefreshBypassesTheCache() { let loader = ContentLoaderSpy() @@ -79,9 +80,36 @@ struct EmbeddedBlockResolverTests { #expect(cached == .empty) } + /// The real config will answer from a background thread. The cache, the queue of waiters and the + /// block view live on the main one, so the answer has to move there instead of being handled + /// wherever it was delivered. + @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)) } + }, + overrides: EmbeddedBlockContentOverrides() + ) + + let deliveredOnMainThread: Bool = await withCheckedContinuation { continuation in + resolver.resolve("promo") { _ in + continuation.resume(returning: Thread.isMainThread) + } + } + + #expect(deliveredOnMainThread) + + // And the cache is already filled on the main thread: the next block gets the answer at once. + var cached: EmbeddedBlockResolution? + resolver.resolve("promo") { cached = $0 } + #expect(cached == .content(.stub)) + } + // MARK: - Debug overrides - /// Приёмка переключает сценарий на ходу, поэтому подмена сильнее и загрузки, и кэша. + /// Acceptance testing switches scenarios on the fly, so the override outranks both the load and + /// the cache. @Test("Debug override answers instead of the data and outranks the cache") func overrideOutranksEverything() { let loader = ContentLoaderSpy() @@ -96,7 +124,7 @@ struct EmbeddedBlockResolverTests { resolver.resolve("promo") { answers.append($0) } #expect(answers == [.empty, .empty]) - // За данными резолвер не ходил: ответ пришёл из подмены. + // The resolver did not go for the data: the answer came from the override. #expect(loader.requestedIds == ["promo"]) } @@ -146,7 +174,7 @@ struct EmbeddedBlockResolverTests { #expect(html == "empty page") } - /// Заглушка на месте конфига: пока его нет, любой id ведёт на страницу ленты сторизов. + /// The stub in place of the config: while there is none, any id leads to the stories feed page. @Test("The stubbed loader resolves any id to the stories page") func stubbedLoaderResolvesToTheStoriesPage() { var resolution: EmbeddedBlockResolution? @@ -161,8 +189,8 @@ struct EmbeddedBlockResolverTests { } } -/// Загрузчик, который отвечает только когда его попросят: так проверяется поведение резолвера, пока -/// загрузка ещё идёт. +/// A loader that answers only when asked to: this is how the resolver's behaviour while a load is +/// still in flight gets tested. private final class ContentLoaderSpy { private(set) var requestedIds: [String] = [] diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift index 403529756..f8579a7c1 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewPageTests.swift @@ -10,15 +10,16 @@ import Testing import WebKit @testable import Mindbox -/// Страница судит только о своём: загрузка не состоялась или документ доехал. Здесь проверяется -/// именно это разделение — и главное, что отменённая навигация в провалы не попадает. +/// The page only judges its own business: the load failed or the document arrived. It is exactly +/// this separation that is checked here — and above all, that a cancelled navigation does not end up +/// among the failures. @Suite("Embedded block web view page", .tags(.embeddedBlocks)) @MainActor struct EmbeddedBlockWebViewPageTests { - /// Навигацию отменяют в двух совершенно обычных случаях: её вытеснил клиентский редирект и её - /// остановил наш собственный `cancel()` на уехавшем с экрана блоке. Ни то, ни другое не значит, - /// что блок сломан, — а провал сворачивает его насовсем. + /// A navigation is cancelled in two perfectly ordinary cases: it was superseded by a client-side + /// redirect, and it was stopped by our own `cancel()` on a block that went off screen. Neither + /// means the block is broken — while a failure collapses it for good. @Test("A cancelled provisional navigation is not a load failure") func cancelledProvisionalNavigationIsNotAFailure() { let bed = PageBed() @@ -37,7 +38,7 @@ struct EmbeddedBlockWebViewPageTests { #expect(bed.failures == 0) } - /// А настоящая ошибка сети — это ровно то, о чём страница обязана сказать. + /// A real network error, on the other hand, is exactly what the page must report. @Test("A real provisional navigation error is a load failure") func realProvisionalErrorIsAFailure() { let bed = PageBed() @@ -56,8 +57,7 @@ struct EmbeddedBlockWebViewPageTests { #expect(bed.failures == 1) } - /// Отмена одной навигации не должна глушить страницу: следующая настоящая ошибка приходит как - /// обычно. + /// Cancelling one navigation must not mute the page: the next real error arrives as usual. @Test("A cancellation does not swallow the failure that comes after it") func cancellationDoesNotSwallowLaterFailures() { let bed = PageBed() @@ -79,8 +79,8 @@ struct EmbeddedBlockWebViewPageTests { } } -/// Настоящая страница с настоящим вебвью, но без сети: тесты сами зовут методы навигационного -/// делегата — именно их разбор здесь и проверяется. +/// A real page with a real web view, but without the network: the tests call the navigation delegate +/// methods themselves — it is their handling that is checked here. @MainActor private final class PageBed { @@ -91,8 +91,8 @@ private final class PageBed { var cancellationError: Error { error(code: NSURLErrorCancelled) } - /// Через протокол, а не напрямую: у страницы есть и свойство `webView`, и методы делегата с тем - /// же именем, и вызывать их стоит там, где имя однозначно. + /// Through the protocol rather than directly: the page has both a `webView` property and + /// delegate methods with the same name, so they are better called where the name is unambiguous. private var navigation: WKNavigationDelegate { page } init() { From 19a089d645ea4740192692c978a3f9804fc72384 Mon Sep 17 00:00:00 2001 From: Akylbek Utekeshev Date: Wed, 12 Aug 2026 01:31:15 +0500 Subject: [PATCH 38/47] Update Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift Co-authored-by: Sergei Semko <28645140+justSmK@users.noreply.github.com> --- .../Resolver/EmbeddedBlockResolver.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift index 85c571244..58ae4db55 100644 --- a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift @@ -80,6 +80,18 @@ final class EmbeddedBlockResolver: EmbeddedBlockResolving { } func resolve(_ id: String, forceRefresh: Bool, completion: @escaping (EmbeddedBlockResolution) -> Void) { + // The cache and the queue of waiters are plain dictionaries: every path through them has to + // run on one thread — the same one the block views wait on. + guard Thread.isMainThread else { + Logger.common(message: "[EmbeddedBlock] Resolver was asked about id '\(id)' off the main thread, continuing on it", + level: .error, + category: .embeddedBlocks) + DispatchQueue.main.async { [weak self] in + self?.resolve(id, forceRefresh: forceRefresh, completion: completion) + } + return + } + // The debug override outranks both the data and the cache: acceptance testing switches // scenarios on the fly, and a cached answer would get in the way. if let overridden = overrides.resolution(for: id) { From e1bd0122c679a124a4c365459b59a2d6cfb4e60f Mon Sep 17 00:00:00 2001 From: Vailence Date: Wed, 12 Aug 2026 01:32:23 +0500 Subject: [PATCH 39/47] MOBILE-323 PR Fix --- .../EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift | 6 +++--- .../EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift index 7488266d1..97aa344c1 100644 --- a/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift +++ b/Mindbox/EmbeddedBlocks/Actions/EmbeddedBlockActionRouter.swift @@ -22,9 +22,9 @@ final class EmbeddedBlockActionRouter: EmbeddedBlockActionHandling { func handle(_ action: EmbeddedBlockPageAction) { switch action.type { case ActionType.openUrl: - print("embeddedBlock action") - // TODO: - Add action here later -// openUrl(from: action) + // TODO(MOBILE-328): open the url once blocks move to the shared in-app bridge. + Logger.common(message: "[EmbeddedBlock] openUrl is not handled yet, ignoring: \(action.type)", + category: .embeddedBlocks) default: Logger.common(message: "[EmbeddedBlock] Unknown page action: \(action.type)", category: .embeddedBlocks) diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift index 17850c32d..7facff686 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift @@ -48,6 +48,7 @@ final class EmbeddedBlockWebViewPage: NSObject, EmbeddedBlockPageHosting { case .url(let url): webView.load(URLRequest(url: url)) case .html(let html): + // у страницы, поданной разметкой, origin about:blank, поэтому ни localStorage, ни сетевых запросов на свой домен у неё не будет. В (MOBILE-328) изменится или полностью удалится этот кейс webView.loadHTMLString(html, baseURL: nil) } } From f50758acdf8067c3e74901f20b7351ff655f98ac Mon Sep 17 00:00:00 2001 From: Akylbek Utekeshev Date: Wed, 12 Aug 2026 14:02:58 +0500 Subject: [PATCH 40/47] MOBILE-323: Embedded block content provider (#754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * MOBILE-323: Add the embedded block content provider Переводит сообщения страницы в состояния блока. Не рисует контент и не знает механик: спрашивает у резолвера, что стоит за id, разбирает core-слой, а действия сверх него отдаёт универсальному обработчику. Готовность определяет только сама страница: ready — показываем, empty — показывать нечего. Навигация судит исключительно о своём, молчащая страница готовой не становится — её добьёт бюджет ожидания у контейнера. Нулевая высота в ready значит сломанную вёрстку: «показывать нечего» страница сообщает явным empty. Исход попытки хранится явно, а не пачкой флагов. Он переживает stop(), потому что это свойство страницы, а не факта нахождения в окне: уход блока с экрана не выбрасывает уже отрендеренную страницу, и возврат показывает её снова, без сети и без шиммера. При этом провал и empty страницу не убивают — она жива и может продолжать говорить, — поэтому известный исход служит и признаком того, что блока на экране больше нет: действия от невидимого блока не выполняются. За ним не стоит ни одного касания пользователя, а openUrl увёл бы человека из приложения на пустом месте. Экземпляр принадлежит одному контейнеру: ничего общего между контейнерами здесь нет, это и делает возможными несколько независимых блоков с одним id. Номер попытки отсекает резолв, доехавший уже после остановки или перезагрузки. Счётчик живых блоков на id — диагностика, а не механика: два блока с одним id законны, но чаще это скопированный id или переиспользованная ячейка, а у обоих случаев нет симптомов кроме «блок оказался не там, где ждали». * MOBILE-323: Add the content provider factory Собирает провайдер под конкретный блок: резолвер и обработчик действий общие на все блоки, а провайдер — свой на каждый. Это и делает блоки с одинаковым id независимыми друг от друга. Потребителей у фабрики появится два, и оба в следующей части: DI-регистрация и публичный init контейнера. Здесь она едет вместе с провайдером, потому что описывает его модель владения, а не способ его достать. * MOBILE-323: Add test doubles for the block content provider Дописывает моки до среза этой части: страница без WebKit, фабрика страниц со счётчиком созданных, резолвер с отложенным ответом, обработчик действий и общая заготовка провайдера со всеми подменёнными зависимостями. Резолвер умеет держать ответ до отдельной команды: так проверяется резолв, доехавший уже после остановки или перезагрузки блока. Фабрика считает страницы, потому что перезагрузка обязана создать новую, а возврат блока в окно — нет. * MOBILE-323: Add tests for the embedded block content provider Весь путь блока без WebKit и без сети: резолв, показ по ready, пустой блок, сломанная нулевая высота, провал загрузки, действия страницы, отладочная подмена готовности, остановка и перезапуск, перезагрузка. Отдельно закреплено то, на что опираются соседние слои: после stop() провайдер молчит целиком — иначе контейнер не смог бы свернуть просроченный блок; уже отрендеренная страница на возврате в окно показывается как есть, без сети и шиммера, а блок, который показать не удалось, получает новую попытку; выброшенная перезагрузкой страница не может доложить в новую попытку ни сообщением, ни провалом, ни через подмену готовности. Действия проверяются с обеих сторон: из показанного блока доходят до обработчика, из схлопнутого — нет, ни после empty, ни после провала, ни после нулевой высоты, — а новая попытка снова их принимает. Счётчику живых блоков в каждом тесте свой id: он общий на процесс, иначе тесты, идущие параллельно, считали бы блоки друг друга. * MOBILE-323: Add tests for the content provider factory Фабрика приехала без тестов, а её обещание — то, на чём держится независимость блоков с одинаковым id: провайдер свой на каждый блок, резолвер общий на все. Проверяется и то и другое, плюс что провайдер собран под запрошенный id. Резолвер в тестах отвечает «пусто»: страницу для такого блока не создают, поэтому настоящий вебвью фабрике здесь не нужен. * MOBILE-323: Trim duplicated comments in the content provider Док класса пересказывал то, что уже сказано ниже по файлу: правила готовности — у apply(height:) и handleLoadFinish, владение высотой — у heightChanged, а мысль «уход из окна не выбрасывает страницу» шла трижды — в доке класса, в доке свойства page и во встроенном комментарии в start(). Осталось два абзаца: чем провайдер является и что после stop() он обязан молчать — это межтиповой инвариант, из одного файла его не видно. Убраны и три дока, ушедшие из своей ответственности или пересказывавшие подпись: contentView рассказывал, как контейнер растягивает вью; второй абзац reload() — про плейсхолдер и события хосту; isShown повторял собственное имя. Встроенные комментарии не тронуты: каждый объясняет отсутствующую строку, развилку или внешнюю причину — то, чего в коде не прочитать. * MOBILE-323 PR Fix * MOBILE-323 Remove unused tests --------- Co-authored-by: Vailence --- .../EmbeddedBlockWebViewProvider.swift | 5 +- .../EmbeddedBlockResolverTests.swift | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift index 0e4e4aaec..d5a92cadd 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift @@ -209,7 +209,7 @@ final class EmbeddedBlockWebViewProvider { // сломанная вёрстка, то есть ошибка. guard height > 0 else { Logger.common(message: "[EmbeddedBlock] Block '\(id)': page reported zero height, treating as broken", category: .embeddedBlocks) - outcome = .failed + outcome = .empty onStateChange?(.failed) return } @@ -252,9 +252,6 @@ extension EmbeddedBlockWebViewProvider { liveBlocks[id] = count liveBlocksLock.unlock() - Logger.common(message: "[EmbeddedBlock] Block '\(id)' is created, \(count) live with this id", - category: .embeddedBlocks) - guard count > 1 else { return } Logger.common(message: """ diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift index ed879bd01..3a5c8e32d 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift @@ -207,4 +207,52 @@ private final class ContentLoaderSpy { completions = [] pending.forEach { $0(resolution) } } + + /// Отвечает с фоновой очереди — так ответит настоящий конфиг, разобранный не на главном потоке. + func answerOffMain(_ resolution: EmbeddedBlockResolution) { + let pending = completions + completions = [] + DispatchQueue.global().async { + pending.forEach { $0(resolution) } + } + } +} + +/// На каком потоке резолвер отдал ответ. Отдельный тип вместо `Bool` — чтобы упавший тест сразу +/// говорил, что именно разъехалось. +private enum DeliveryThread { + case main + case other +} + +/// Ждёт ответов резолвера и запоминает, на каком потоке каждый пришёл. +/// +/// Читают и пишут его только с главного потока — если это перестанет быть правдой, тест как раз и +/// упадёт на `threads`. +private final class DeliveryRecorder { + + private(set) var answers: [EmbeddedBlockResolution] = [] + private(set) var threads: [DeliveryThread] = [] + + private var expectedCount = 0 + private var continuation: CheckedContinuation? + + func record(_ resolution: EmbeddedBlockResolution) { + answers.append(resolution) + threads.append(Thread.isMainThread ? .main : .other) + + guard answers.count >= expectedCount, let continuation else { return } + self.continuation = nil + continuation.resume() + } + + /// Загрузку запускает сам ожидающий: начни её раньше — и ответ мог бы приехать до того, как + /// тест встал ждать, а ожидание повисло бы навсегда. + func waitForAnswers(count: Int, _ startLoading: () -> Void) async { + expectedCount = count + await withCheckedContinuation { continuation in + self.continuation = continuation + startLoading() + } + } } From 23551477feedc27f53ed7f12108702f78c499f20 Mon Sep 17 00:00:00 2001 From: Vailence Date: Wed, 12 Aug 2026 14:09:27 +0500 Subject: [PATCH 41/47] MOBILE-323 Unused tests removed --- Mindbox.xcodeproj/project.pbxproj | 21 +- ...ddedBlockContentProviderFactoryTests.swift | 76 -- .../EmbeddedBlockReadyTimeoutTests.swift | 253 ------ .../EmbeddedBlockWebViewProviderTests.swift | 555 ------------- .../MindboxEmbeddedBlockViewTests.swift | 727 ------------------ 5 files changed, 1 insertion(+), 1631 deletions(-) delete mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift delete mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift delete mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift delete mode 100644 MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift diff --git a/Mindbox.xcodeproj/project.pbxproj b/Mindbox.xcodeproj/project.pbxproj index f00b00fec..ee87f7b2d 100644 --- a/Mindbox.xcodeproj/project.pbxproj +++ b/Mindbox.xcodeproj/project.pbxproj @@ -766,26 +766,6 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 1B76146361C3402193A00F88 /* InAppWebViewLearnedHostsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewLearnedHostsStore.swift; sourceTree = ""; }; - DDCC6F9CEEF24C2BAB624DD5 /* InAppWebViewPrewarmPlanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmPlanner.swift; sourceTree = ""; }; - 96A50D5650094A23A2C667CE /* InAppWebViewPrewarmNavigationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmNavigationPolicy.swift; sourceTree = ""; }; - 1F423FA6C2E243F68A76E9C2 /* InAppWebViewPrewarmService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewPrewarmService.swift; sourceTree = ""; }; - 9CC5ED4F76B046798978B4B9 /* MindboxWebBridgeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MindboxWebBridgeTests.swift; sourceTree = ""; }; - 4B4F5CEE405E4BE29F97E43B /* MBContainerConcurrencyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MBContainerConcurrencyTests.swift; sourceTree = ""; }; - 4E45E06561604E6185C33366 /* SDKUserAgentTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SDKUserAgentTests.swift; sourceTree = ""; }; - 895F8C05EA8B4716B2A2941A /* InAppWebViewCacheTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InAppWebViewCacheTests.swift; sourceTree = ""; }; - FD1627DAC8D54E448C6D7BF0 /* InAppWebViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewFactory.swift; sourceTree = ""; }; - 7A1E4C0DA3B24F6E90C11A01 /* InAppWebViewHTTPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTTPError.swift; sourceTree = ""; }; - 7A1E4C0DA3B24F6E90C11A03 /* WebViewNoCacheRetryPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewNoCacheRetryPolicy.swift; sourceTree = ""; }; - A85CCBEB1D874078954BB771 /* InAppWebViewHTMLFetcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTMLFetcher.swift; sourceTree = ""; }; - 6D096F433EBB44C8B3490CF6 /* InAppWebViewDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewDataStore.swift; sourceTree = ""; }; - 11E54FE3B51649DCBD5ABE33 /* WebViewTimeoutErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewTimeoutErrorTests.swift; sourceTree = ""; }; - 7A1E4C0DA3B24F6E90C11A05 /* InAppWebViewHTTPErrorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = InAppWebViewHTTPErrorTests.swift; sourceTree = ""; }; - 7A1E4C0DA3B24F6E90C11A07 /* WebViewNoCacheRetryPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewNoCacheRetryPolicyTests.swift; sourceTree = ""; }; - 4D716044131845AEA04A9858 /* WebViewReadyCheckerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewReadyCheckerTests.swift; sourceTree = ""; }; - F2DAFC2435264F15B8033CCE /* WebViewReadyChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebViewReadyChecker.swift; sourceTree = ""; }; - 57A1B3230000000000000107 /* InjectEmbeddedBlocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectEmbeddedBlocks.swift; sourceTree = ""; }; - 68184C936B4243C28CC10829 /* SDKUserAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKUserAgent.swift; sourceTree = ""; }; 045EA348681AB859FB298A57 /* InAppTagsGatingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppTagsGatingTests.swift; sourceTree = ""; }; 0475E8755F63483597539A50 /* TrackVisitManagerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TrackVisitManagerTests.swift; sourceTree = ""; }; 0A3D04592BC6803E00E1FC52 /* ImageFormat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageFormat.swift; sourceTree = ""; }; @@ -1036,6 +1016,7 @@ 4D716044131845AEA04A9858 /* WebViewReadyCheckerTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WebViewReadyCheckerTests.swift; sourceTree = ""; }; 4E45E06561604E6185C33366 /* SDKUserAgentTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SDKUserAgentTests.swift; sourceTree = ""; }; 55E67F6257F15F143DEA87DE /* JSONValueTagsMergeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValueTagsMergeTests.swift; sourceTree = ""; }; + 57A1B3230000000000000107 /* InjectEmbeddedBlocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InjectEmbeddedBlocks.swift; sourceTree = ""; }; 68184C936B4243C28CC10829 /* SDKUserAgent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SDKUserAgent.swift; sourceTree = ""; }; 6C96F9B9CE3C4AA2AA975DB3 /* MD5Hash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MD5Hash.swift; sourceTree = ""; }; 6D096F433EBB44C8B3490CF6 /* InAppWebViewDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InAppWebViewDataStore.swift; sourceTree = ""; }; diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift deleted file mode 100644 index cf81cefc4..000000000 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift +++ /dev/null @@ -1,76 +0,0 @@ -// -// EmbeddedBlockContentProviderFactoryTests.swift -// MindboxTests -// -// Created by vailence on 10.08.2026. -// Copyright © 2026 Mindbox. All rights reserved. -// - -import Testing -@testable import Mindbox - -/// У фабрики одно обещание: провайдер — свой на каждый блок, а резолвер и обработчик действий — -/// общие. На нём держится независимость блоков с одинаковым id, поэтому оно проверяется отдельно. -/// -/// Счётчик живых блоков общий на процесс, поэтому у каждого теста свой id: иначе тесты, идущие -/// параллельно, считали бы блоки друг друга. -@Suite("Embedded block content provider factory", .tags(.embeddedBlocks)) -@MainActor -struct EmbeddedBlockContentProviderFactoryTests { - - /// Два блока с одним id — законный случай, и каждый обязан получить собственный провайдер: - /// общий сделал бы их состояние и страницу одной на двоих. - @Test("Every call makes its own provider") - func eachCallMakesItsOwnProvider() { - let id = "factory-independent-blocks" - let factory = makeFactory() - - let first = factory.makeProvider(id: id) - let second = factory.makeProvider(id: id) - - #expect(first !== second) - withExtendedLifetime((first, second)) { - #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 2) - } - } - - @Test("The provider is made for the requested id") - func providerIsMadeForTheRequestedId() { - let id = "factory-carries-the-id" - let other = "factory-some-other-id" - let factory = makeFactory() - - let provider = factory.makeProvider(id: id) - - withExtendedLifetime(provider) { - #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 1) - #expect(EmbeddedBlockWebViewProvider.liveCount(for: other) == 0) - } - } - - /// Резолвер общий именно для того, чтобы несколько блоков с одним id разрешались одной загрузкой - /// данных. Проверяется, что фабрика действительно передаёт провайдеру тот резолвер, а не заводит - /// ему свой. - @Test("The provider asks the shared resolver for its own id") - func providerAsksTheSharedResolver() { - let resolver = EmbeddedBlockResolverMock(resolution: .empty) - let factory = EmbeddedBlockContentProviderFactory(resolver: resolver, - actionHandler: EmbeddedBlockActionHandlerMock()) - - let provider = factory.makeProvider(id: "factory-shared-resolver") - withExtendedLifetime(provider) { - provider.start() - } - - #expect(resolver.resolvedIds == ["factory-shared-resolver"]) - } - - // MARK: - Helpers - - /// Резолвер отвечает «пусто»: страницу для такого блока не создают, поэтому тестам фабрики не - /// нужен настоящий вебвью. - private func makeFactory() -> EmbeddedBlockContentProviderFactory { - EmbeddedBlockContentProviderFactory(resolver: EmbeddedBlockResolverMock(resolution: .empty), - actionHandler: EmbeddedBlockActionHandlerMock()) - } -} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift deleted file mode 100644 index 47f6a8434..000000000 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift +++ /dev/null @@ -1,253 +0,0 @@ -// -// EmbeddedBlockReadyTimeoutTests.swift -// MindboxTests -// -// Created by vailence on 10.08.2026. -// Copyright © 2026 Mindbox. All rights reserved. -// - -import Testing -import Foundation -import UIKit -@testable import Mindbox - -/// Ни часы, ни планировщик здесь не настоящие. Сколько бюджета «уже потрачено», тесты задают -/// подменёнными часами, а момент «время вышло» наступает по их команде. Реальным временем не -/// ждётся ничего: бюджет — это арифметика над потраченным, и проверять её секундомером значило бы -/// платить полсекунды за тест и флакать на загруженном раннере. -/// -/// Отсюда же главная проверка большинства тестов — не «истёк или нет», а с какой задержкой завели -/// отсчёт: именно она и есть остаток бюджета. -/// -/// Уход в фон и возврат из него идут через свой центр нотификаций у каждого стенда: на глобальном -/// такое уведомление долетело бы до блоков из тестов, идущих рядом. -private let budget: TimeInterval = 0.4 - -@Suite("Embedded block ready timeout", .tags(.embeddedBlocks)) -@MainActor -struct EmbeddedBlockReadyTimeoutTests { - - @Test("A budget that is never paused expires on its own") - func unpausedBudgetExpires() { - let bed = TimeoutBed() - - bed.timeout.armIfNeeded() - - #expect(bed.scheduler.lastDelay == budget) - - bed.scheduler.fireAll() - - #expect(bed.expirations == 1) - } - - /// Пока блока никто не ждёт, бюджет не тратится и не истекает. - @Test("A paused budget does not expire") - func pausedBudgetDoesNotExpire() { - let bed = TimeoutBed() - - bed.timeout.armIfNeeded() - bed.timeout.pause() - bed.scheduler.fireAll() - - #expect(bed.expirations == 0) - #expect(bed.timeout.isRunning == false) - } - - /// Главное: пауза останавливает счёт, а не начинает его заново. Потрачено почти всё, поэтому - /// после возобновления отсчёт заводится на крохотный остаток — а не на полный бюджет. - @Test("Resuming continues the remaining budget instead of granting a new one") - func resumeContinuesTheRemainder() { - let bed = TimeoutBed() - - bed.timeout.armIfNeeded() - bed.clock.advance(budget - 0.02) - bed.timeout.pause() - - bed.timeout.armIfNeeded() - - #expect(isClose(bed.scheduler.lastDelay, to: 0.02)) - - bed.scheduler.fireAll() - - #expect(bed.expirations == 1) - } - - /// Ровно тот сценарий, из-за которого пауза со сбросом непригодна: пользователь, дёргающий - /// приложение туда-обратно, не должен уметь продлевать ожидание блока бесконечно. - @Test("Repeated pause and resume cannot stretch the budget past its duration") - func repeatedPausesCannotStretchTheBudget() { - let bed = TimeoutBed() - - for _ in 0..<5 { - bed.timeout.armIfNeeded() - bed.clock.advance(budget / 4) - bed.timeout.pause() - } - - #expect(bed.expirations == 0) - - // Пять отрезков по четверти — бюджет выбран целиком, и следующий завод не даёт блоку больше - // ни секунды. - bed.timeout.armIfNeeded() - - #expect(bed.scheduler.lastDelay == 0) - - bed.scheduler.fireAll() - - #expect(bed.expirations == 1) - } - - /// А новая попытка — другое дело: её ждут с полного бюджета. - @Test("Reset gives the next attempt a full budget again") - func resetGrantsAFullBudget() { - let bed = TimeoutBed() - - bed.timeout.armIfNeeded() - bed.clock.advance(budget - 0.02) - bed.timeout.reset() - - bed.timeout.armIfNeeded() - - #expect(bed.scheduler.lastDelay == budget) - } - - @Test("Reset stops a running budget") - func resetStopsTheCountdown() { - let bed = TimeoutBed() - - bed.timeout.armIfNeeded() - bed.timeout.reset() - bed.scheduler.fireAll() - - #expect(bed.expirations == 0) - } - - /// Бюджет нужен только пока исход неизвестен и блок на виду — это знает контейнер, и его ответ - /// спрашивается на каждом заводе. - @Test("A budget nobody needs is not armed at all") - func unneededBudgetIsNotArmed() { - let bed = TimeoutBed(isNeeded: false) - - bed.timeout.armIfNeeded() - - #expect(bed.timeout.isRunning == false) - #expect(bed.scheduler.lastDelay == nil) - - bed.scheduler.fireAll() - - #expect(bed.expirations == 0) - } - - // MARK: - Background - - /// Пока приложение в фоне, блока никто не ждёт — значит и бюджет тратиться не должен. - @Test("Going to the background pauses a running budget") - func backgroundPausesTheCountdown() { - let bed = TimeoutBed() - - bed.timeout.armIfNeeded() - bed.enterBackground() - - #expect(bed.timeout.isRunning == false) - - bed.scheduler.fireAll() - - #expect(bed.expirations == 0) - } - - /// И ровно то, ради чего заведён учёт потраченного: возврат из фона продолжает бюджет с остатка, - /// а не выдаёт его заново. - @Test("Returning from the background continues the remaining budget") - func foregroundContinuesTheRemainder() { - let bed = TimeoutBed() - - bed.timeout.armIfNeeded() - bed.clock.advance(budget - 0.02) - bed.enterBackground() - bed.enterForeground() - - #expect(isClose(bed.scheduler.lastDelay, to: 0.02)) - - bed.scheduler.fireAll() - - #expect(bed.expirations == 1) - } - - /// Фон, заставший блок вне отсчёта, тратить не может ничего: следующая попытка получает бюджет - /// целиком. - @Test("Going to the background outside a countdown consumes nothing") - func backgroundOutsideCountdownConsumesNothing() { - let bed = TimeoutBed() - - bed.enterBackground() - bed.clock.advance(budget) - - bed.timeout.armIfNeeded() - - #expect(bed.scheduler.lastDelay == budget) - } - - /// Возврат из фона к блоку, которого уже никто не ждёт, отсчёт не воскрешает: нужен ли он, - /// решает контейнер, и его ответ спрашивается на каждом заводе. - @Test("Returning from the background does not arm a budget nobody needs") - func foregroundDoesNotArmAnUnneededBudget() { - let bed = TimeoutBed(isNeeded: false) - - bed.enterForeground() - - #expect(bed.timeout.isRunning == false) - #expect(bed.scheduler.lastDelay == nil) - } - - // MARK: - Arming - - /// Завод идемпотентен: вход в окно, возврат из фона и перезагрузка зовут его как попало, и - /// второй вызов не должен ставить второй отсчёт. - @Test("Arming twice runs a single countdown") - func armingTwiceRunsOneCountdown() { - let bed = TimeoutBed() - - bed.timeout.armIfNeeded() - bed.timeout.armIfNeeded() - bed.scheduler.fireAll() - - // Завелись бы два отсчёта — истечений было бы столько же. - #expect(bed.expirations == 1) - } -} - -/// Остаток бюджета — арифметика над `Double`, поэтому сравнивается с допуском. -private func isClose(_ value: TimeInterval?, to expected: TimeInterval) -> Bool { - guard let value else { return false } - - return abs(value - expected) < 0.0001 -} - -/// Общий стенд бюджета плюс счётчик истечений: здесь бюджет проверяется сам по себе, поэтому -/// `isNeeded` задаётся тестом напрямую, а не спрашивается у контейнера. -@MainActor -private final class TimeoutBed { - - private let bed = EmbeddedBlockTimeoutBed(duration: budget) - - private(set) var expirations = 0 - - var timeout: EmbeddedBlockReadyTimeout { bed.timeout } - var clock: TestClock { bed.clock } - var scheduler: TestScheduler { bed.scheduler } - - init(isNeeded: Bool = true) { - bed.timeout.isNeeded = { isNeeded } - bed.timeout.onExpire = { [weak self] in - self?.expirations += 1 - } - } - - func enterBackground() { - bed.enterBackground() - } - - func enterForeground() { - bed.enterForeground() - } -} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift deleted file mode 100644 index 78c47a877..000000000 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift +++ /dev/null @@ -1,555 +0,0 @@ -// -// EmbeddedBlockWebViewProviderTests.swift -// MindboxTests -// -// Created by vailence on 03.08.2026. -// Copyright © 2026 Mindbox. All rights reserved. -// - -import Testing -import UIKit -@testable import Mindbox - -@Suite("Embedded block web view provider", .tags(.embeddedBlocks)) -@MainActor -struct EmbeddedBlockWebViewProviderTests { - - // MARK: - Loading - - @Test("Start resolves the id and loads the resolved content") - func startResolvesAndLoads() { - let bed = EmbeddedBlockTestBed(id: "promo") - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.provider.start() - - #expect(bed.resolver.resolvedIds == ["promo"]) - #expect(bed.pageFactory.contents == [.stub]) - #expect(bed.page?.loadCount == 1) - #expect(states == [.loading]) - // До готовности страницы контента нет: контейнеру нечего показывать. - #expect(bed.provider.contentView == nil) - } - - @Test("Second start does not resolve or load again") - func secondStartDoesNothing() { - let bed = EmbeddedBlockTestBed() - - bed.provider.start() - bed.provider.start() - - #expect(bed.resolver.resolveCount == 1) - #expect(bed.page?.loadCount == 1) - } - - /// Выключенный в админке или неизвестный блок — не ошибка: страницу для него даже не создаём. - @Test("Empty resolution needs no page at all") - func emptyResolutionCreatesNoPage() { - let bed = EmbeddedBlockTestBed(resolution: .empty) - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.provider.start() - - #expect(states == [.loading, .empty]) - #expect(bed.pageFactory.pages.isEmpty) - #expect(bed.provider.contentView == nil) - } - - // MARK: - Readiness - - /// О готовности говорит только сама страница — это единственный источник истины. - @Test("Page ready makes the content available") - func pageReadyMakesContentAvailable() { - let bed = EmbeddedBlockTestBed() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.provider.start() - bed.page?.send(.ready(height: 104)) - - #expect(states == [.loading, .ready]) - #expect(bed.provider.contentView === bed.page?.view) - } - - /// Молчащая страница готовой не становится: загруженный документ ничего не говорит о том, есть - /// ли блоку что показать. Такой блок добьёт таймаут контейнера. - @Test("Silent page never becomes ready on its own") - func silentPageStaysLoading() { - let bed = EmbeddedBlockTestBed() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.provider.start() - - #expect(states == [.loading]) - #expect(bed.provider.contentView == nil) - } - - /// Странице без контента честнее сказать `empty`, поэтому нулевая высота — сломанная вёрстка. - @Test("Zero height in ready is a failure") - func zeroHeightIsFailure() { - let bed = EmbeddedBlockTestBed() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.provider.start() - bed.page?.send(.ready(height: 0)) - - #expect(states.last == .failed) - #expect(bed.provider.contentView == nil) - } - - /// Высотой владеет хост: сообщение в контракте есть, но вёрстку оно не трогает. - @Test("Height change leaves the state alone") - func heightChangeChangesNothing() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.page?.send(.ready(height: 104)) - bed.page?.send(.heightChanged(height: 132)) - - #expect(states == [.ready]) - } - - @Test("Page empty collapses the block") - func pageEmptyCollapsesTheBlock() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.page?.send(.ready(height: 104)) - bed.page?.send(.empty) - - #expect(states == [.ready, .empty]) - #expect(bed.provider.contentView == nil) - } - - // MARK: - Debug readiness - - /// Обычное правило: загруженный документ ничего не говорит о том, есть ли блоку что показать. - @Test("Loaded document alone does not make the block ready") - func loadFinishAloneChangesNothing() { - let bed = EmbeddedBlockTestBed() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.provider.start() - bed.page?.finishLoad() - - #expect(states == [.loading]) - #expect(bed.provider.contentView == nil) - } - - /// Со включённой подменой блок показывается по загруженному документу — так проверяется UI, - /// пока страница не умеет присылать `ready`. - @Test("With the debug override a loaded document shows the block") - func loadFinishMakesBlockReadyWithOverride() { - let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.provider.start() - bed.page?.finishLoad() - - #expect(states == [.loading, .ready]) - #expect(bed.provider.contentView === bed.page?.view) - } - - /// Страница, которая контракт умеет, ведёт себя с подменой так же, как без неё: `ready` уже - /// показал блок, и второго показа документ не добавляет. - @Test("A page that sent ready is not shown twice by the override") - func readyBeforeLoadFinishIsNotDuplicated() { - let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.provider.start() - bed.page?.send(.ready(height: 104)) - bed.page?.finishLoad() - - #expect(states == [.loading, .ready]) - } - - /// Подмена не сильнее страницы: сказанное ей «показывать нечего» сворачивает блок и со - /// включённым флагом. - @Test("The override does not swallow an empty from the page") - func overrideDoesNotSwallowEmpty() { - let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.provider.start() - bed.page?.finishLoad() - bed.page?.send(.empty) - - #expect(states == [.loading, .ready, .empty]) - #expect(bed.provider.contentView == nil) - } - - /// После `stop()` провайдер молчит целиком — подмена этого не меняет. - @Test("Loaded document after a stop is ignored even with the override") - func loadFinishAfterStopIsIgnored() { - let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) - bed.provider.start() - bed.provider.stop() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.page?.finishLoad() - - #expect(states.isEmpty) - #expect(bed.provider.contentView == nil) - } - - /// Выброшенная перезагрузкой страница не должна показать себя и через подмену. - @Test("The dropped page cannot show itself through the override") - func droppedPageCannotFinishIntoTheNewAttempt() { - let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) - bed.provider.start() - let firstPage = bed.page - bed.provider.reload() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - firstPage?.finishLoad() - - #expect(states.isEmpty) - #expect(bed.provider.contentView == nil) - } - - // MARK: - Load failure - - /// Провал загрузки — единственное, о чём судит навигация. - @Test("Load failure fails the block") - func loadFailureFailsTheBlock() { - let bed = EmbeddedBlockTestBed() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.provider.start() - bed.page?.failLoad() - - #expect(states == [.loading, .failed]) - #expect(bed.provider.contentView == nil) - } - - @Test("Load failure after a stop is ignored") - func loadFailureAfterStopIsIgnored() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - bed.provider.stop() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.page?.failLoad() - - #expect(states.isEmpty) - } - - // MARK: - Page actions - - /// Ядро словаря страницы не знает: всё сверх core-слоя уходит обработчику как есть и состояние - /// контейнера не трогает. - @Test("Page action is routed to the handler and changes no state") - func actionIsRouted() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - let action = EmbeddedBlockPageAction(type: "openUrl", payload: ["url": "https://mindbox.ru"]) - bed.page?.send(.action(action)) - - #expect(bed.actionHandler.handledActions == [action]) - #expect(states.isEmpty) - } - - /// Остановленный провайдер молчит целиком — в том числе не будит обработчик действий. - @Test("Actions after a stop do not reach the handler") - func actionsAfterStopAreIgnored() { - let bed = EmbeddedBlockTestBed() - - bed.provider.start() - bed.provider.stop() - bed.page?.send(.action(EmbeddedBlockPageAction(type: "openUrl", payload: [:]))) - - #expect(bed.actionHandler.handledActions.isEmpty) - } - - @Test("Action from a shown block is routed") - func actionFromShownBlockIsRouted() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - bed.page?.send(.ready(height: 104)) - - bed.page?.send(.action(.openUrlStub)) - - #expect(bed.actionHandler.handledActions == [.openUrlStub]) - } - - /// Схлопнутый блок не убивает страницу — она жива и может досылать то, что запланировала. Но за - /// невидимым блоком не стоит ни одного касания пользователя, а `openUrl` увёл бы его из - /// приложения на пустом месте. - @Test("Actions from a block collapsed as empty do not reach the handler") - func actionsAfterEmptyAreIgnored() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - - bed.page?.send(.empty) - bed.page?.send(.action(.openUrlStub)) - - #expect(bed.actionHandler.handledActions.isEmpty) - } - - @Test("Actions from a failed block do not reach the handler") - func actionsAfterFailureAreIgnored() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - - bed.page?.failLoad() - bed.page?.send(.action(.openUrlStub)) - - #expect(bed.actionHandler.handledActions.isEmpty) - } - - /// Сломанная вёрстка — тот же непоказанный блок: действия из него тоже не выполняются. - @Test("Actions from a block broken by a zero height do not reach the handler") - func actionsAfterZeroHeightAreIgnored() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - - bed.page?.send(.ready(height: 0)) - bed.page?.send(.action(.openUrlStub)) - - #expect(bed.actionHandler.handledActions.isEmpty) - } - - /// Запрет держится на исходе попытки, а не на странице: новая попытка снова живая. - @Test("A new attempt after a failure accepts actions again") - func retryAfterFailureAcceptsActions() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - bed.page?.failLoad() - - bed.provider.stop() - bed.provider.start() - bed.page?.send(.action(.openUrlStub)) - - #expect(bed.actionHandler.handledActions == [.openUrlStub]) - } - - // MARK: - Stop and restart - - /// После `stop()` провайдер обязан молчать — на это опирается контейнер, когда сворачивает - /// просроченный контент по своему таймауту. - @Test("Stop cancels the page and ignores what it says afterwards") - func stopCancelsThePage() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.provider.stop() - bed.page?.send(.ready(height: 104)) - - #expect(bed.page?.cancelCount == 1) - #expect(states.isEmpty) - #expect(bed.provider.contentView == nil) - } - - /// Контейнер зовёт `start()` каждый раз, когда возвращается в окно: пересоздавать вебвью и - /// заново спрашивать резолвер на каждое возвращение незачем. - @Test("Restart reuses the same page without resolving again") - func restartReusesThePage() { - let bed = EmbeddedBlockTestBed() - - bed.provider.start() - bed.provider.stop() - bed.provider.start() - bed.page?.send(.ready(height: 104)) - - #expect(bed.resolver.resolveCount == 1) - #expect(bed.pageFactory.pages.count == 1) - #expect(bed.page?.loadCount == 2) - #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() - bed.provider.start() - bed.page?.send(.ready(height: 104)) - bed.provider.stop() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.provider.start() - - #expect(states == [.ready]) - #expect(bed.page?.loadCount == 1) - #expect(bed.resolver.resolveCount == 1) - #expect(bed.provider.contentView === bed.page?.view) - } - - /// А вот блок, который показать не удалось, получает на возврате новую попытку — это - /// единственный ретрай, который у блока пока есть. - @Test("Failed block tries again when it comes back") - func failedBlockTriesAgainOnReturn() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - bed.page?.failLoad() - bed.provider.stop() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.provider.start() - - #expect(states == [.loading]) - #expect(bed.page?.loadCount == 2) - } - - // MARK: - Live blocks - - /// Счётчик живых блоков общий на процесс, поэтому у каждого теста свой id: иначе тесты, идущие - /// параллельно, считали бы блоки друг друга. - @Test("Live count follows the life of a block") - func liveCountFollowsBlockLife() { - let id = "live-count-single" - #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 0) - - do { - let provider = makeProvider(id: id) - withExtendedLifetime(provider) { - #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 1) - } - } - - #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 0) - } - - @Test("Blocks sharing an id are counted together") - func liveCountSumsBlocksOfTheSameId() { - let id = "live-count-shared" - - do { - let first = makeProvider(id: id) - let second = makeProvider(id: id) - withExtendedLifetime((first, second)) { - #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 2) - } - } - - #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 0) - } - - @Test("Blocks with different ids are counted apart") - func liveCountKeepsIdsApart() { - let promo = "live-count-promo" - let stories = "live-count-stories" - - let provider = makeProvider(id: promo) - withExtendedLifetime(provider) { - #expect(EmbeddedBlockWebViewProvider.liveCount(for: promo) == 1) - #expect(EmbeddedBlockWebViewProvider.liveCount(for: stories) == 0) - } - } - - private func makeProvider(id: String) -> EmbeddedBlockWebViewProvider { - EmbeddedBlockWebViewProvider(id: id, - resolver: EmbeddedBlockResolverMock(), - actionHandler: EmbeddedBlockActionHandlerMock(), - makePage: { _ in EmbeddedBlockPageMock() }) - } - - /// Резолв мог доехать уже после остановки — тогда он относится к прошлой попытке. - @Test("Resolution arriving after a stop creates nothing") - func lateResolutionAfterStopIsIgnored() { - let bed = EmbeddedBlockTestBed() - bed.resolver.isDeferred = true - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.provider.start() - bed.provider.stop() - bed.resolver.flush() - - #expect(bed.pageFactory.pages.isEmpty) - #expect(states == [.loading]) - } - - // MARK: - Reload - - @Test("Reload asks for the content again bypassing the cache and builds a new page") - func reloadRefetchesTheContent() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - bed.page?.send(.ready(height: 104)) - let firstPage = bed.page - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - bed.resolver.resolution = .content(.other) - bed.provider.reload() - - #expect(bed.resolver.forceRefreshHistory == [false, true]) - #expect(bed.pageFactory.contents == [.stub, .other]) - #expect(bed.pageFactory.pages.count == 2) - #expect(bed.page !== firstPage) - #expect(firstPage?.cancelCount == 1) - #expect(states == [.loading]) - // Готовность начинается с нуля: новая страница ещё ничего не сказала. - #expect(bed.provider.contentView == nil) - } - - /// Прежняя страница уже не имеет отношения к делу — её запоздавшие сообщения не должны - /// показать выброшенный контент. - @Test("The dropped page cannot report into the new attempt") - func droppedPageIsSilenced() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - bed.page?.send(.ready(height: 104)) - let firstPage = bed.page - bed.provider.reload() - var states: [EmbeddedBlockState] = [] - bed.provider.onStateChange = { states.append($0) } - - firstPage?.send(.ready(height: 104)) - firstPage?.failLoad() - - #expect(states.isEmpty) - #expect(bed.provider.contentView == nil) - } - - /// Резолв прошлой попытки не должен подменить страницу новой. - @Test("Resolution arriving after a reload does not add a second page") - func lateResolutionAfterReloadIsIgnored() { - let bed = EmbeddedBlockTestBed() - bed.resolver.isDeferred = true - bed.provider.start() - - bed.provider.reload() - bed.resolver.flush() - - #expect(bed.resolver.resolveCount == 2) - #expect(bed.pageFactory.pages.count == 1) - } - - @Test("Reloaded block becomes ready through the same path") - func reloadedBlockBecomesReady() { - let bed = EmbeddedBlockTestBed() - bed.provider.start() - bed.page?.send(.ready(height: 104)) - - bed.provider.reload() - bed.page?.send(.ready(height: 104)) - - #expect(bed.provider.contentView === bed.page?.view) - } -} diff --git a/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift deleted file mode 100644 index 64b00ce3d..000000000 --- a/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift +++ /dev/null @@ -1,727 +0,0 @@ -// -// MindboxEmbeddedBlockViewTests.swift -// MindboxTests -// -// Created by vailence on 03.08.2026. -// Copyright © 2026 Mindbox. All rights reserved. -// - -import Testing -import UIKit -@testable import Mindbox - -@Suite("MindboxEmbeddedBlockView container", .tags(.embeddedBlocks)) -@MainActor -struct MindboxEmbeddedBlockViewTests { - - // MARK: - Height - - /// Место под блок занимается сразу: высоту назначил хост, и до исхода загрузки она не меняется — - /// иначе контейнер прыгал бы в вёрстке хоста. - @Test("Loading block keeps the height given at creation") - func loadingKeepsGivenHeight() { - let block = BlockFixture() - - #expect(block.view.intrinsicContentSize.height == 120) - // Ширина — дело хоста, контейнер её не заявляет. - #expect(block.view.intrinsicContentSize.width == UIView.noIntrinsicMetric) - } - - @Test("Shown block keeps the same height") - func shownBlockKeepsHeight() { - let block = BlockFixture() - block.attachToWindow() - - block.page?.send(.ready(height: 96)) - - #expect(block.view.intrinsicContentSize.height == 120) - } - - /// Контейнер — единственный источник высоты, поэтому хост на фреймах обязан получить то же - /// число через ту точку, которой пользуется он. - @Test("sizeThatFits reports the same height as intrinsicContentSize") - func sizeThatFitsMatchesIntrinsicHeight() { - let block = BlockFixture(height: 96) - - let fitted = block.view.sizeThatFits(CGSize(width: 320, height: CGFloat.greatestFiniteMagnitude)) - - #expect(fitted.height == 96) - #expect(fitted.width == 320) - } - - @Test("Failed block collapses the container") - func failedBlockCollapses() { - let block = BlockFixture() - block.attachToWindow() - - block.page?.failLoad() - - #expect(block.view.intrinsicContentSize.height == 0) - } - - /// Ошибку можно показать вместо схлопывания — тогда блок остаётся той же высоты. - @Test("Failed block with an error view keeps its height") - func failedBlockWithErrorViewKeepsHeight() { - let block = BlockFixture() - let errorView = UIView() - block.view.errorView = errorView - block.attachToWindow() - - block.page?.failLoad() - - #expect(block.view.intrinsicContentSize.height == 120) - #expect(errorView.superview === block.view) - } - - /// Хост уже забрал место схлопнутого блока — раскрывать его задним числом значит дёргать - /// вёрстку. Поздний errorView только запоминается. - @Test("Error view assigned after the collapse does not expand the block") - func lateErrorViewDoesNotExpandCollapsedBlock() { - let block = BlockFixture() - block.attachToWindow() - block.page?.failLoad() - - let errorView = UIView() - block.view.errorView = errorView - - #expect(block.view.intrinsicContentSize.height == 0) - #expect(errorView.superview == nil) - } - - /// Запомненный errorView вступает в силу со следующей загрузки: новая попытка, снова провал — - /// и теперь блок показывает ошибку вместо схлопывания. - @Test("Error view assigned after the collapse applies on the next load") - func lateErrorViewAppliesOnNextLoad() { - let block = BlockFixture() - block.attachToWindow() - block.page?.failLoad() - let errorView = UIView() - block.view.errorView = errorView - - block.view.reload() - block.page?.failLoad() - - #expect(block.view.intrinsicContentSize.height == 120) - #expect(errorView.superview === block.view) - } - - /// Пустой блок сворачивается всегда: показывать нечего, а ошибки не было. - @Test("Empty block collapses even with an error view set") - func emptyBlockAlwaysCollapses() { - let block = BlockFixture() - block.view.errorView = UIView() - block.attachToWindow() - - block.page?.send(.empty) - - #expect(block.view.intrinsicContentSize.height == 0) - } - - /// Хост, попросивший отрицательную высоту, не должен получить неразрешимый набор констрейнтов. - @Test("Negative height given by the host is clamped to zero") - func negativeHeightIsClamped() { - let block = BlockFixture(height: -50) - - #expect(block.view.intrinsicContentSize.height == 0) - } - - // MARK: - Content view - - @Test("Shown content is attached and pinned to the container") - func shownContentIsPinned() throws { - let block = BlockFixture() - block.attachToWindow() - - block.page?.send(.ready(height: 96)) - - let content = try #require(block.page?.view) - #expect(content.superview === block.view) - #expect(content.translatesAutoresizingMaskIntoConstraints == false) - // Четыре края: контент всегда заполняет контейнер, который ему дали. - #expect(block.view.constraints.count == 4) - } - - @Test("Failed content is detached") - func failedContentIsDetached() throws { - let block = BlockFixture() - block.attachToWindow() - - block.page?.send(.ready(height: 96)) - let content = try #require(block.page?.view) - block.page?.failLoad() - - #expect(content.superview == nil) - #expect(block.view.intrinsicContentSize.height == 0) - } - - @Test("Empty content is detached") - func emptyContentIsDetached() throws { - let block = BlockFixture() - block.attachToWindow() - - block.page?.send(.ready(height: 96)) - let content = try #require(block.page?.view) - block.page?.send(.empty) - - #expect(content.superview == nil) - } - - /// Перезагруженный блок не должен тащить в новую попытку вью выброшенной страницы. - @Test("Reload detaches the content of the dropped page") - func reloadDetachesOldContent() throws { - let block = BlockFixture() - block.attachToWindow() - block.page?.send(.ready(height: 96)) - let oldContent = try #require(block.page?.view) - - block.view.reload() - - #expect(oldContent.superview == nil) - } - - // MARK: - Events - - /// Публичных исходов два — показан и не показан; загрузка не исход, и хост о ней не слышит. - @Test("Loading is silent: the delegate hears only outcomes") - func loadingReportsNothing() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - - block.attachToWindow() - await mainQueueTurn() - - #expect(delegate.events.isEmpty) - } - - /// Блок, который так и не попал в окно, ничего не грузит — и сообщать ему нечего. - @Test("Block outside a window reports nothing and loads nothing") - func blockOutsideWindowDoesNothing() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - - await mainQueueTurn() - - #expect(delegate.events.isEmpty) - #expect(block.bed.resolver.resolveCount == 0) - } - - @Test("Shown block reports didLoad") - func shownBlockReportsDidLoad() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - block.attachToWindow() - await mainQueueTurn() - - block.page?.send(.ready(height: 96)) - await mainQueueTurn() - - #expect(delegate.events == [.loaded]) - } - - @Test("Failed block reports didFail") - func failedBlockReportsDidFail() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - block.attachToWindow() - await mainQueueTurn() - - block.page?.failLoad() - await mainQueueTurn() - - #expect(delegate.events == [.failed]) - } - - /// «Пусто» для хоста — тот же непоказ, что и провал: отдельного события у него нет. - @Test("Empty block reports didFail") - func emptyBlockReportsDidFail() async { - let block = BlockFixture(resolution: .empty) - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - - block.attachToWindow() - await mainQueueTurn() - - #expect(delegate.events == [.failed]) - } - - /// Хост, назначающий делегата в `viewDidLoad`, иначе пропустил бы уже случившийся исход. - @Test("Delegate assigned after the outcome still receives it") - func lateDelegateStillReceivesOutcome() async { - let block = BlockFixture() - block.attachToWindow() - block.page?.failLoad() - await mainQueueTurn() - - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - await mainQueueTurn() - - #expect(delegate.events == [.failed]) - } - - /// Хост штатно переприсваивает делегата на каждой переиспользованной ячейке. Отдавать ему на это - /// уже услышанный исход нельзя: на исход он перестраивает вёрстку, а перестройка вёрстки снова - /// переприсваивает делегата — и блок укатился бы в цикл на скролле. - @Test("Reassigning the same delegate does not repeat the outcome") - func sameDelegateReassignedHearsTheOutcomeOnce() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - block.attachToWindow() - await mainQueueTurn() - block.page?.failLoad() - await mainQueueTurn() - - block.view.delegate = delegate - await mainQueueTurn() - block.view.delegate = delegate - await mainQueueTurn() - - #expect(delegate.events == [.failed]) - } - - /// А другой делегат — это другой подписчик, и уже случившийся исход он обязан услышать. - @Test("A delegate replacing another one still receives the outcome") - func replacingDelegateReceivesTheOutcome() async { - let block = BlockFixture() - let first = EmbeddedBlockViewDelegateMock() - block.view.delegate = first - block.attachToWindow() - await mainQueueTurn() - block.page?.failLoad() - await mainQueueTurn() - - let second = EmbeddedBlockViewDelegateMock() - block.view.delegate = second - await mainQueueTurn() - - #expect(first.events == [.failed]) - #expect(second.events == [.failed]) - } - - /// Контент может упасть снова на возвращении в окно — это не повод превращать исход в поток - /// одинаковых событий. - @Test("Repeated failure is reported once") - func repeatedFailureIsReportedOnce() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - block.attachToWindow() - await mainQueueTurn() - - block.page?.failLoad() - await mainQueueTurn() - block.page?.failLoad() - await mainQueueTurn() - - #expect(delegate.events == [.failed]) - } - - @Test("Block that fails after being shown reports both outcomes in order") - func failureAfterLoadReportsBothOutcomes() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - block.attachToWindow() - await mainQueueTurn() - - block.page?.send(.ready(height: 96)) - await mainQueueTurn() - block.page?.failLoad() - await mainQueueTurn() - - #expect(delegate.events == [.loaded, .failed]) - } - - // MARK: - Presentation for the SwiftUI wrapper - - /// SwiftUI не читает `intrinsicContentSize` у представимой вью и рисует слои хоста сам, поэтому - /// обёртке нужны и высота, и слой — и то, что она получает, обязано совпадать с тем, что - /// контейнер действительно показывает. - @Test("Every change is pushed to the SwiftUI wrapper as a layer and a height") - func presentationChangesArePushedToWrapper() { - let block = BlockFixture() - block.attachToWindow() - var reported: [EmbeddedBlockPresentation] = [] - block.view.onPresentationChange = { reported.append($0) } - - block.page?.send(.ready(height: 96)) - block.page?.send(.empty) - - #expect(reported == [EmbeddedBlockPresentation(layer: .content, height: 120), - EmbeddedBlockPresentation(layer: .nothing, height: 0)]) - } - - /// Провал без экрана ошибки для обёртки — тот же схлопнутый блок, что и пустой: рисовать нечего. - @Test("Failed block without an error view reports nothing to show") - func failedBlockReportsNothingToShow() { - let block = BlockFixture() - block.attachToWindow() - var reported: [EmbeddedBlockPresentation] = [] - block.view.onPresentationChange = { reported.append($0) } - - block.page?.failLoad() - - #expect(reported == [EmbeddedBlockPresentation(layer: .nothing, height: 0)]) - } - - /// Согласие на экран ошибки контейнер видит по назначенному `errorView` — и только тогда просит - /// обёртку нарисовать её слой. - @Test("Failed block with an error view reports the error layer") - func failedBlockWithErrorViewReportsErrorLayer() { - let block = BlockFixture() - block.view.errorView = UIView() - block.attachToWindow() - var reported: [EmbeddedBlockPresentation] = [] - block.view.onPresentationChange = { reported.append($0) } - - block.page?.failLoad() - - #expect(reported == [EmbeddedBlockPresentation(layer: .errorView, height: 120)]) - } - - /// Перезагрузка возвращает блок в загрузку — обёртка обязана снова показать плейсхолдер. - @Test("Reload reports the placeholder layer again") - func reloadReportsPlaceholderLayer() { - let block = BlockFixture() - block.attachToWindow() - block.page?.send(.ready(height: 96)) - var reported: [EmbeddedBlockPresentation] = [] - block.view.onPresentationChange = { reported.append($0) } - - block.view.reload() - - #expect(reported == [EmbeddedBlockPresentation(layer: .placeholder, height: 120)]) - } - - // MARK: - Lifecycle - - /// Хост никогда не запускает и не останавливает контент руками: единственный триггер — окно. - @Test("Entering and leaving a window starts and stops the content") - func windowMembershipDrivesTheContent() { - let block = BlockFixture() - - #expect(block.bed.resolver.resolveCount == 0) - - block.attachToWindow() - #expect(block.page?.loadCount == 1) - #expect(block.page?.cancelCount == 0) - - block.removeFromWindow() - #expect(block.page?.cancelCount == 1) - } - - /// Блок ездит по экрану в ленте и переживает переключение табов: каждый такой проход не должен - /// стоить перезагрузки, мигания шиммером и повторных событий хосту. - @Test("Block returning to the window keeps its content as it was") - func returningBlockKeepsItsContent() async throws { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - block.attachToWindow() - await mainQueueTurn() - block.page?.send(.ready(height: 96)) - await mainQueueTurn() - let content = try #require(block.page?.view) - - block.removeFromWindow() - block.attachToWindow() - await mainQueueTurn() - - #expect(block.page?.loadCount == 1) - #expect(content.superview === block.view) - #expect(block.view.subviews.contains { $0 is EmbeddedBlockShimmerView } == false) - #expect(block.view.intrinsicContentSize.height == 120) - #expect(delegate.events == [.loaded]) - } - - /// Блок, который показать не удалось, на возвращении в окно пробует снова — но место, которое - /// хост у него уже забрал, попытка назад не отыгрывает. Иначе схлопнутый блок дёргал бы вёрстку - /// на свою высоту и мигал шиммером на каждый свой проход по экрану, ничего в итоге не показывая. - @Test("Collapsed block stays collapsed while it tries again") - func collapsedBlockDoesNotReExpandWhileRetrying() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - block.attachToWindow() - await mainQueueTurn() - block.page?.failLoad() - await mainQueueTurn() - - block.removeFromWindow() - block.attachToWindow() - await mainQueueTurn() - - // Попытка действительно новая — страница грузится заново... - #expect(block.page?.loadCount == 2) - // ...но контейнер под неё места не занимает и шиммером не мигает. - #expect(block.view.intrinsicContentSize.height == 0) - #expect(block.view.subviews.isEmpty) - #expect(delegate.events == [.failed]) - } - - /// Разворачивает блок только показанный контент — и тогда высота возвращается, а хост слышит, - /// что блок наконец появился. - @Test("Retry that succeeds gives the block its height back") - func successfulRetryExpandsTheBlock() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - block.attachToWindow() - await mainQueueTurn() - block.page?.failLoad() - await mainQueueTurn() - - block.removeFromWindow() - block.attachToWindow() - block.page?.send(.ready(height: 96)) - await mainQueueTurn() - - #expect(block.view.intrinsicContentSize.height == 120) - #expect(delegate.events == [.failed, .loaded]) - } - - /// Перезагрузка — явное согласие хоста на полный цикл заново, поэтому она место занимает: блок - /// снова показывает плейсхолдер, даже если до неё был схлопнут. - @Test("Reload after a collapse shows the placeholder again") - func reloadAfterCollapseShowsThePlaceholder() { - let block = BlockFixture() - block.attachToWindow() - block.page?.failLoad() - - block.view.reload() - - #expect(block.view.intrinsicContentSize.height == 120) - #expect(block.view.subviews.contains { $0 is EmbeddedBlockShimmerView }) - } - - /// Пустой блок сворачивается так же — и так же не отыгрывает место назад. - @Test("Empty block stays collapsed when it returns to the window") - func emptyBlockStaysCollapsedOnReturn() async { - let block = BlockFixture(resolution: .empty) - block.attachToWindow() - await mainQueueTurn() - - block.removeFromWindow() - block.attachToWindow() - await mainQueueTurn() - - #expect(block.view.intrinsicContentSize.height == 0) - #expect(block.view.subviews.isEmpty) - } - - // MARK: - Timeout - - /// Контейнер, а не контент, гарантирует, что вёрстка хоста не будет ждать вечно: молчащая - /// страница за бюджетом сворачивается и сообщает об ошибке. - @Test("Silent block times out, collapses and reports didFail") - func silentBlockTimesOut() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - - block.attachToWindow() - block.expireTimeout() - await mainQueueTurn() - - #expect(block.view.intrinsicContentSize.height == 0) - #expect(delegate.events == [.failed]) - // Контент остановлен, поэтому оживить просроченный блок он уже не может. - #expect(block.page?.cancelCount == 1) - } - - @Test("Block shown in time is not failed by the timeout") - func shownBlockIsNotTimedOut() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - - block.attachToWindow() - block.page?.send(.ready(height: 96)) - // Показанный блок снял бюджет, поэтому объявленное «время вышло» его уже не касается. - block.expireTimeout() - await mainQueueTurn() - - #expect(block.view.intrinsicContentSize.height == 120) - #expect(delegate.events == [.loaded]) - #expect(block.page?.cancelCount == 0) - } - - /// Уход из окна уже остановил контент — снятый таймаут не должен валить то, что не работает. - @Test("Leaving the window disarms the timeout") - func leavingWindowDisarmsTimeout() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - - block.attachToWindow() - block.removeFromWindow() - block.expireTimeout() - await mainQueueTurn() - - #expect(delegate.events.isEmpty) - #expect(block.view.intrinsicContentSize.height == 120) - } - - /// Бюджет на загрузку считает время ожидания пользователя, а не календарное: в фоне блока никто - /// не ждёт, и схлопывать его там незачем — иначе пользователь вернётся к блоку, который сдался, - /// ни разу не побывав на экране. Что бюджет при этом продолжается с остатка, а не выдаётся - /// заново, проверяют тесты самого `EmbeddedBlockReadyTimeout`. - @Test("Timeout pauses in the background and resumes on return") - func timeoutPausesInBackground() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - block.attachToWindow() - - block.enterBackground() - block.expireTimeout() - await mainQueueTurn() - - #expect(block.view.intrinsicContentSize.height == 120) - #expect(delegate.events.isEmpty) - - block.enterForeground() - block.expireTimeout() - await mainQueueTurn() - - #expect(block.view.intrinsicContentSize.height == 0) - #expect(delegate.events == [.failed]) - } - - /// Блок вне окна ничего не грузит, поэтому и бюджет ему не нужен. - @Test("Returning from the background does not arm a timeout outside a window") - func foregroundOutsideWindowArmsNothing() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - - block.enterForeground() - block.expireTimeout() - await mainQueueTurn() - - // Отсчёт не просто не сработал — его вовсе не заводили. - #expect(block.timeoutBed.scheduler.lastDelay == nil) - #expect(delegate.events.isEmpty) - #expect(block.view.intrinsicContentSize.height == 120) - } - - // MARK: - Reload - - /// Перезагрузка идёт тем же путём, что и первый запуск: блок возвращается в загрузку, а хост - /// слышит новый исход целиком, даже если он совпал с прошлым. - @Test("Reload restarts the block and reports the outcome again") - func reloadRestartsTheBlock() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - block.attachToWindow() - await mainQueueTurn() - block.page?.send(.ready(height: 96)) - await mainQueueTurn() - - block.view.reload() - await mainQueueTurn() - block.page?.send(.ready(height: 96)) - await mainQueueTurn() - - #expect(block.bed.resolver.forceRefreshHistory == [false, true]) - #expect(delegate.events == [.loaded, .loaded]) - #expect(block.view.intrinsicContentSize.height == 120) - } - - /// Контент живёт только пока блок в окне: перезагружать невидимый блок нечего. - @Test("Reload outside a window does nothing") - func reloadOutsideWindowDoesNothing() { - let block = BlockFixture() - - block.view.reload() - - #expect(block.bed.resolver.resolveCount == 0) - #expect(block.bed.pageFactory.pages.isEmpty) - } - - /// Новая попытка получает и новый бюджет — иначе перезагруженный блок висел бы в загрузке вечно. - @Test("Reload arms the timeout again") - func reloadArmsTheTimeoutAgain() async { - let block = BlockFixture() - let delegate = EmbeddedBlockViewDelegateMock() - block.view.delegate = delegate - block.attachToWindow() - block.page?.send(.ready(height: 96)) - - block.view.reload() - block.expireTimeout() - await mainQueueTurn() - - #expect(block.view.intrinsicContentSize.height == 0) - #expect(delegate.events.last == .failed) - } - - // MARK: - Helpers - - /// Исходы отдаются на следующем витке главной очереди, поэтому блок, поставленный в очередь - /// после них, продолжится только когда они отработают — очередь последовательная и FIFO. - private func mainQueueTurn() async { - await withCheckedContinuation { continuation in - DispatchQueue.main.async { - continuation.resume() - } - } - } -} - -/// Блок со всеми подменёнными зависимостями и живым окном: окно обязано жить не меньше теста, -/// иначе вью вылетит из окна на середине проверки и контент остановится сам собой. -@MainActor -private final class BlockFixture { - - let bed: EmbeddedBlockTestBed - - /// Бюджет отдаётся вью снаружи, поэтому «время вышло» здесь наступает по команде теста, а не - /// через сон: `expireTimeout()`. - let timeoutBed: EmbeddedBlockTimeoutBed - - let view: MindboxEmbeddedBlockView - - private let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) - - var page: EmbeddedBlockPageMock? { bed.page } - - init(height: CGFloat = 120, - resolution: EmbeddedBlockResolution = .content(.stub)) { - let bed = EmbeddedBlockTestBed(resolution: resolution) - let timeoutBed = EmbeddedBlockTimeoutBed() - self.bed = bed - self.timeoutBed = timeoutBed - self.view = MindboxEmbeddedBlockView(id: "block-id", - height: height, - contentProvider: bed.provider, - timeout: timeoutBed.timeout) - } - - func attachToWindow() { - window.addSubview(view) - } - - func removeFromWindow() { - view.removeFromSuperview() - } - - /// Объявляет, что бюджет ожидания вышел. - func expireTimeout() { - timeoutBed.scheduler.fireAll() - } - - func enterBackground() { - timeoutBed.enterBackground() - } - - func enterForeground() { - timeoutBed.enterForeground() - } -} From 0a4b51a7a42eec0cca3f61003cf4c333af829f7e Mon Sep 17 00:00:00 2001 From: Akylbek Utekeshev Date: Wed, 12 Aug 2026 16:10:25 +0500 Subject: [PATCH 42/47] MOBILE-323: Add the SwiftUI embedded block (#756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * MOBILE-323: Add the SwiftUI embedded block Тот же блок в SwiftUI: создаётся с id и высотой, ставится куда угодно, а плейсхолдер и экран ошибки задаются модификаторами на самом блоке. Слои хоста рисует обёртка, а не контейнер. Вью, отданная контейнеру через отдельный UIHostingController, не входит в дерево SwiftUI и не видит его окружения: плейсхолдер с @EnvironmentObject просто падает, а заданные хостом шрифт, цвет и локаль до него не доезжают. Поэтому контейнер получает под каждый заявленный слой прозрачную заглушку — держать место, — а красит это место SwiftUI поверх. Отсюда и снимок показа: обёртке нужен не только размер, но и текущий слой. И id, и высоту контейнер получает при создании и потом не меняет, поэтому другое значение любого из них — это другой блок, который надо собрать заново. Без явной идентичности хост, подставивший другой id, продолжал бы видеть содержимое прежнего: SwiftUI переиспользовал бы уже созданный контейнер. Координатор переставляется на свежие замыкания на каждом проходе body, а заглушки ставятся и снимаются на каждом обновлении: модификатор мог быть применён по условию, поэтому слой может появиться после первого прохода — и точно так же исчезнуть. * MOBILE-323: Add tests for the SwiftUI embedded block Модификаторы, идентичность блока и мост между контейнером и обёрткой: заглушки под заявленные слои ставятся и снимаются по состоянию модификаторов, а показ и исход доезжают до обёртки. Идентичность проверяется отдельно, потому что на ней держится главное: другой id или другая высота — это другой блок, а не обновление текущего. --------- Co-authored-by: Vailence --- .../MindboxEmbeddedBlock.swift | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift new file mode 100644 index 000000000..a83308719 --- /dev/null +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift @@ -0,0 +1,292 @@ +// +// MindboxEmbeddedBlock.swift +// Mindbox +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +#if canImport(SwiftUI) +import SwiftUI + +/// SwiftUI wrapper over `MindboxEmbeddedBlockView`. +/// +/// Created with the block `id` from the admin panel and the `height` the block should occupy. +/// Place it anywhere in a layout — the caller decides only the position and the width. The block +/// keeps the given height while its content is loading and shown; a block with nothing to show +/// collapses to zero height. +/// +/// Both outcomes can be customized the same way as in UIKit, through modifiers on the block +/// itself: `placeholder` replaces the stock loading shimmer, and `errorView` opts into showing a +/// failure instead of collapsing. Both stay ordinary SwiftUI views drawn in place, so they see the +/// environment of the tree they were written in — objects, fonts, locale, color scheme. +/// +/// ```swift +/// MindboxEmbeddedBlock(id: "stories", height: 104, onFail: hideSection) +/// .placeholder { StoriesSkeleton() } +/// .errorView { StoriesUnavailable() } +/// ``` +/// +/// Both modifiers return the block itself, so they come before any SwiftUI modifier: after +/// `.frame(…)` or `.padding(…)` the value is no longer a `MindboxEmbeddedBlock`. +/// +/// A collapsed block is zero points tall, but a stack still pays its spacing around it. To hand the +/// space back completely, drop the whole section from the layout in `onFail` — as in the example +/// above. +@available(iOS 13.0, *) +public struct MindboxEmbeddedBlock: View { + + private let id: String + private let height: CGFloat + private let onLoad: (() -> Void)? + private let onFail: (() -> Void)? + + private(set) var placeholderBuilder: (() -> AnyView)? + private(set) var errorBuilder: (() -> AnyView)? + + /// - Parameters: + /// - id: The block id from the admin panel. + /// - height: The height the block occupies while loading and shown. + /// - onLoad: The block content is shown and the container is visible. + /// - onFail: The block cannot be shown — a failure or an empty block. + public init(id: String, + height: CGFloat, + onLoad: (() -> Void)? = nil, + onFail: (() -> Void)? = nil) { + self.id = id + self.height = height + self.onLoad = onLoad + self.onFail = onFail + } + + /// Shows this view instead of the SDK shimmer while the block is loading. + /// + /// Called again, it replaces the previous placeholder. + public func placeholder(@ViewBuilder _ build: @escaping () -> Content) -> Self { + var block = self + block.placeholderBuilder = { AnyView(build()) } + return block + } + + /// Shows this view instead of collapsing when the block cannot be shown. + /// + /// Applies only to failures: an empty block — one with nothing behind its id — always + /// collapses, so a host cannot fill the space of a block that was never meant to be there. + public func errorView(@ViewBuilder _ build: @escaping () -> Content) -> Self { + var block = self + block.errorBuilder = { AnyView(build()) } + return block + } + + public var body: some View { + EmbeddedBlockBody(id: id, + height: height, + onLoad: onLoad, + onFail: onFail, + placeholder: placeholderBuilder, + errorContent: errorBuilder) + .id(identity) + } + + /// Идентичность блока в дереве SwiftUI. + /// + /// И `id`, и высоту контейнер получает при создании и потом не меняет, поэтому другое значение + /// любого из них — это другой блок, который надо собрать заново, а не обновление текущего. Без + /// этого хост, подставивший в блок другой id, продолжал бы видеть содержимое прежнего: SwiftUI + /// переиспользовал бы уже созданный контейнер. + var identity: Identity { + Identity(id: id, height: height) + } + + struct Identity: Hashable { + let id: String + let height: CGFloat + } +} + +/// Хранит текущий показ и рисует слои хоста поверх контейнера. +/// +/// Отдельная вью, а не тело `MindboxEmbeddedBlock`: состояние обязано сбрасываться вместе с +/// контейнером при смене id или высоты, а сбрасывает его `.id(…)` — и только у той вью, к которой +/// применён. +@available(iOS 13.0, *) +private struct EmbeddedBlockBody: View { + + let id: String + let height: CGFloat + let onLoad: (() -> Void)? + let onFail: (() -> Void)? + let placeholder: (() -> AnyView)? + let errorContent: (() -> AnyView)? + + /// Стартует с того же, с чего стартует контейнер: место занято, показан плейсхолдер. Блок + /// занимает свою высоту сразу, а не с первого отчёта от контейнера. + @State private var presentation: EmbeddedBlockPresentation + + init(id: String, + height: CGFloat, + onLoad: (() -> Void)?, + onFail: (() -> Void)?, + placeholder: (() -> AnyView)?, + errorContent: (() -> AnyView)?) { + self.id = id + self.height = height + self.onLoad = onLoad + self.onFail = onFail + self.placeholder = placeholder + self.errorContent = errorContent + _presentation = State(initialValue: EmbeddedBlockPresentation(layer: .placeholder, + height: max(0, height))) + } + + var body: some View { + ZStack { + EmbeddedBlockRepresentable(id: id, + height: height, + presentation: $presentation, + onLoad: onLoad, + onFail: onFail, + hasPlaceholder: placeholder != nil, + hasErrorView: errorContent != nil) + hostLayer + } + .frame(height: presentation.height) + } + + /// Слой хоста рисуется здесь, а не отдаётся контейнеру как `UIView` из `UIHostingController`: + /// такой контроллер не входит в дерево SwiftUI, поэтому вью внутри него не видит его окружения — + /// плейсхолдер с `@EnvironmentObject` просто падает, а заданные хостом шрифт, цвет и локаль до + /// него не доезжают. + @ViewBuilder private var hostLayer: some View { + switch presentation.layer { + case .placeholder: + if let placeholder { + placeholder() + } + case .errorView: + if let errorContent { + errorContent() + } + case .content, .nothing: + EmptyView() + } + } +} + +@available(iOS 13.0, *) +struct EmbeddedBlockRepresentable: UIViewRepresentable { + + let id: String + let height: CGFloat + + @Binding var presentation: EmbeddedBlockPresentation + + let onLoad: (() -> Void)? + let onFail: (() -> Void)? + + /// Есть ли у обёртки свой плейсхолдер и свой экран ошибки. Сами вью контейнеру не отдаются — + /// только факт: под каждый заявленный слой он получает прозрачную заглушку, чтобы держать место + /// и не рисовать своё, а красит это место SwiftUI поверх. + let hasPlaceholder: Bool + let hasErrorView: Bool + + func makeCoordinator() -> Coordinator { + Coordinator(presentation: $presentation, onLoad: onLoad, onFail: onFail) + } + + func makeUIView(context: Context) -> MindboxEmbeddedBlockView { + let blockView = MindboxEmbeddedBlockView(id: id, height: height) + let coordinator = context.coordinator + blockView.delegate = coordinator + blockView.onPresentationChange = { presentation in + coordinator.update(presentation) + } + syncStandIns(in: blockView) + return blockView + } + + func updateUIView(_ uiView: MindboxEmbeddedBlockView, context: Context) { + // Замыкания и биндинг захватываются заново на каждый проход body, поэтому координатор надо + // переставлять на свежие, а не оставлять ему ту тройку, с которой его создали. + let coordinator = context.coordinator + coordinator.presentation = $presentation + coordinator.onLoad = onLoad + coordinator.onFail = onFail + syncStandIns(in: uiView) + } + + static func dismantleUIView(_ uiView: MindboxEmbeddedBlockView, coordinator: Coordinator) { + // Вью ушла из дерева: докладывать о слоях и исходах некому, а состояние обёртки уже + // выброшено вместе с ней. + uiView.onPresentationChange = nil + uiView.delegate = nil + } + + /// Заглушки ставятся и снимаются на каждом обновлении, а не только при создании: модификатор мог + /// быть применён по условию, поэтому слой может появиться после первого прохода — и точно так же + /// исчезнуть, и тогда держать под него место больше не за что. + func syncStandIns(in blockView: MindboxEmbeddedBlockView) { + // Уже стоящую заглушку не подменяем: назначение нового вью пересобирало бы констрейнты + // контейнера на каждом проходе body. + if hasPlaceholder { + if blockView.placeholderView == nil { + blockView.placeholderView = Self.makeStandIn() + } + } else { + blockView.placeholderView = nil + } + + if hasErrorView { + if blockView.errorView == nil { + blockView.errorView = Self.makeStandIn() + } + } else { + blockView.errorView = nil + } + } + + /// Прозрачная заглушка: контейнер держит под слой место, но ничего в нём не рисует и не + /// перехватывает касания — и то и другое дело SwiftUI-слоя поверх. + /// + /// Размером она во весь контейнер: слои он прибивает к своим четырём краям сам. На что-то + /// меньшее её не свести, да и незачем — пустой слой ничем не платит за свой размер. + private static func makeStandIn() -> UIView { + let standIn = UIView() + standIn.backgroundColor = .clear + standIn.isUserInteractionEnabled = false + return standIn + } + + final class Coordinator: MindboxEmbeddedBlockViewDelegate { + + var presentation: Binding + var onLoad: (() -> Void)? + var onFail: (() -> Void)? + + init(presentation: Binding, + onLoad: (() -> Void)?, + onFail: (() -> Void)?) { + self.presentation = presentation + self.onLoad = onLoad + self.onFail = onFail + } + + /// Пишется на следующем витке главной очереди: контейнер может доложить о смене слоя прямо + /// посреди прохода body, а менять состояние в этот момент нельзя. + func update(_ newPresentation: EmbeddedBlockPresentation) { + DispatchQueue.main.async { [weak self] in + guard let self, self.presentation.wrappedValue != newPresentation else { return } + self.presentation.wrappedValue = newPresentation + } + } + + func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) { + onLoad?() + } + + func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView) { + onFail?() + } + } +} +#endif From 4410e96a52920b7f4c6017aedf3adac05e04a7ff Mon Sep 17 00:00:00 2001 From: Vailence Date: Wed, 12 Aug 2026 18:09:30 +0500 Subject: [PATCH 43/47] MOBILE-323 Remove Identity --- .../MindboxEmbeddedBlock.swift | 64 ++++++------------- 1 file changed, 18 insertions(+), 46 deletions(-) diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift index a83308719..67e6934ed 100644 --- a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift @@ -8,6 +8,7 @@ #if canImport(SwiftUI) import SwiftUI +import MindboxLogger /// SwiftUI wrapper over `MindboxEmbeddedBlockView`. /// @@ -16,6 +17,10 @@ import SwiftUI /// keeps the given height while its content is loading and shown; a block with nothing to show /// collapses to zero height. /// +/// Both values are fixed at creation. A different `id` is a different block, built from scratch in +/// place of the old one. A different `height` changes nothing: the block keeps the height it was +/// created with and reports the ignored value to the log. +/// /// Both outcomes can be customized the same way as in UIKit, through modifiers on the block /// itself: `placeholder` replaces the stock loading shimmer, and `errorView` opts into showing a /// failure instead of collapsing. Both stay ordinary SwiftUI views drawn in place, so they see the @@ -46,7 +51,8 @@ public struct MindboxEmbeddedBlock: View { /// - Parameters: /// - id: The block id from the admin panel. - /// - height: The height the block occupies while loading and shown. + /// - height: The height the block occupies while loading and shown. Fixed at creation: + /// a new value given to a live block is ignored. /// - onLoad: The block content is shown and the container is visible. /// - onFail: The block cannot be shown — a failure or an empty block. public init(id: String, @@ -85,30 +91,10 @@ public struct MindboxEmbeddedBlock: View { onFail: onFail, placeholder: placeholderBuilder, errorContent: errorBuilder) - .id(identity) - } - - /// Идентичность блока в дереве SwiftUI. - /// - /// И `id`, и высоту контейнер получает при создании и потом не меняет, поэтому другое значение - /// любого из них — это другой блок, который надо собрать заново, а не обновление текущего. Без - /// этого хост, подставивший в блок другой id, продолжал бы видеть содержимое прежнего: SwiftUI - /// переиспользовал бы уже созданный контейнер. - var identity: Identity { - Identity(id: id, height: height) - } - - struct Identity: Hashable { - let id: String - let height: CGFloat + .id(id) } } -/// Хранит текущий показ и рисует слои хоста поверх контейнера. -/// -/// Отдельная вью, а не тело `MindboxEmbeddedBlock`: состояние обязано сбрасываться вместе с -/// контейнером при смене id или высоты, а сбрасывает его `.id(…)` — и только у той вью, к которой -/// применён. @available(iOS 13.0, *) private struct EmbeddedBlockBody: View { @@ -153,10 +139,6 @@ private struct EmbeddedBlockBody: View { .frame(height: presentation.height) } - /// Слой хоста рисуется здесь, а не отдаётся контейнеру как `UIView` из `UIHostingController`: - /// такой контроллер не входит в дерево SwiftUI, поэтому вью внутри него не видит его окружения — - /// плейсхолдер с `@EnvironmentObject` просто падает, а заданные хостом шрифт, цвет и локаль до - /// него не доезжают. @ViewBuilder private var hostLayer: some View { switch presentation.layer { case .placeholder: @@ -184,14 +166,14 @@ struct EmbeddedBlockRepresentable: UIViewRepresentable { let onLoad: (() -> Void)? let onFail: (() -> Void)? - /// Есть ли у обёртки свой плейсхолдер и свой экран ошибки. Сами вью контейнеру не отдаются — - /// только факт: под каждый заявленный слой он получает прозрачную заглушку, чтобы держать место - /// и не рисовать своё, а красит это место SwiftUI поверх. let hasPlaceholder: Bool let hasErrorView: Bool func makeCoordinator() -> Coordinator { - Coordinator(presentation: $presentation, onLoad: onLoad, onFail: onFail) + Coordinator(presentation: $presentation, + creationHeight: height, + onLoad: onLoad, + onFail: onFail) } func makeUIView(context: Context) -> MindboxEmbeddedBlockView { @@ -206,8 +188,6 @@ struct EmbeddedBlockRepresentable: UIViewRepresentable { } func updateUIView(_ uiView: MindboxEmbeddedBlockView, context: Context) { - // Замыкания и биндинг захватываются заново на каждый проход body, поэтому координатор надо - // переставлять на свежие, а не оставлять ему ту тройку, с которой его создали. let coordinator = context.coordinator coordinator.presentation = $presentation coordinator.onLoad = onLoad @@ -216,18 +196,11 @@ struct EmbeddedBlockRepresentable: UIViewRepresentable { } static func dismantleUIView(_ uiView: MindboxEmbeddedBlockView, coordinator: Coordinator) { - // Вью ушла из дерева: докладывать о слоях и исходах некому, а состояние обёртки уже - // выброшено вместе с ней. uiView.onPresentationChange = nil uiView.delegate = nil } - /// Заглушки ставятся и снимаются на каждом обновлении, а не только при создании: модификатор мог - /// быть применён по условию, поэтому слой может появиться после первого прохода — и точно так же - /// исчезнуть, и тогда держать под него место больше не за что. func syncStandIns(in blockView: MindboxEmbeddedBlockView) { - // Уже стоящую заглушку не подменяем: назначение нового вью пересобирало бы констрейнты - // контейнера на каждом проходе body. if hasPlaceholder { if blockView.placeholderView == nil { blockView.placeholderView = Self.makeStandIn() @@ -245,11 +218,6 @@ struct EmbeddedBlockRepresentable: UIViewRepresentable { } } - /// Прозрачная заглушка: контейнер держит под слой место, но ничего в нём не рисует и не - /// перехватывает касания — и то и другое дело SwiftUI-слоя поверх. - /// - /// Размером она во весь контейнер: слои он прибивает к своим четырём краям сам. На что-то - /// меньшее её не свести, да и незачем — пустой слой ничем не платит за свой размер. private static func makeStandIn() -> UIView { let standIn = UIView() standIn.backgroundColor = .clear @@ -263,16 +231,20 @@ struct EmbeddedBlockRepresentable: UIViewRepresentable { var onLoad: (() -> Void)? var onFail: (() -> Void)? + private let creationHeight: CGFloat + + private var hasWarnedAboutIgnoredHeight = false + init(presentation: Binding, + creationHeight: CGFloat, onLoad: (() -> Void)?, onFail: (() -> Void)?) { self.presentation = presentation + self.creationHeight = creationHeight self.onLoad = onLoad self.onFail = onFail } - /// Пишется на следующем витке главной очереди: контейнер может доложить о смене слоя прямо - /// посреди прохода body, а менять состояние в этот момент нельзя. func update(_ newPresentation: EmbeddedBlockPresentation) { DispatchQueue.main.async { [weak self] in guard let self, self.presentation.wrappedValue != newPresentation else { return } From afa5cc08dc900150f3421b43262894b5b160e7a6 Mon Sep 17 00:00:00 2001 From: Vailence Date: Wed, 12 Aug 2026 18:30:55 +0500 Subject: [PATCH 44/47] MOBILE-323 isDetached --- .../MindboxEmbeddedBlock.swift | 22 +++++- .../EmbeddedBlockCoordinatorTests.swift | 74 +++++++++++++++++++ 2 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockCoordinatorTests.swift diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift index 67e6934ed..64f48d2ea 100644 --- a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift @@ -198,6 +198,7 @@ struct EmbeddedBlockRepresentable: UIViewRepresentable { static func dismantleUIView(_ uiView: MindboxEmbeddedBlockView, coordinator: Coordinator) { uiView.onPresentationChange = nil uiView.delegate = nil + coordinator.detach() } func syncStandIns(in blockView: MindboxEmbeddedBlockView) { @@ -235,23 +236,38 @@ struct EmbeddedBlockRepresentable: UIViewRepresentable { private var hasWarnedAboutIgnoredHeight = false + private var isDetached = false + + /// Куда `update` откладывает запись — `DispatchQueue.main` вне тестов. + private let schedule: (@escaping () -> Void) -> Void + init(presentation: Binding, creationHeight: CGFloat, onLoad: (() -> Void)?, - onFail: (() -> Void)?) { + onFail: (() -> Void)?, + schedule: @escaping (@escaping () -> Void) -> Void = { work in DispatchQueue.main.async { work() } }) { self.presentation = presentation self.creationHeight = creationHeight self.onLoad = onLoad self.onFail = onFail + self.schedule = schedule } func update(_ newPresentation: EmbeddedBlockPresentation) { - DispatchQueue.main.async { [weak self] in - guard let self, self.presentation.wrappedValue != newPresentation else { return } + schedule { [weak self] in + guard let self, !self.isDetached, + self.presentation.wrappedValue != newPresentation else { return } self.presentation.wrappedValue = newPresentation } } + /// Вью снята с дерева: гасит запись, уже поставленную `update` в очередь, — обнуление + /// колбэков в `dismantleUIView` её не отзывает, а `weak self` не гарантия: когда SwiftUI + /// отпустит координатор после демонтажа, не специфицировано. + func detach() { + isDetached = true + } + func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) { onLoad?() } diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockCoordinatorTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockCoordinatorTests.swift new file mode 100644 index 000000000..5e566fc16 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockCoordinatorTests.swift @@ -0,0 +1,74 @@ +// +// EmbeddedBlockCoordinatorTests.swift +// MindboxTests +// +// Created by vailence on 12.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import SwiftUI +@testable import Mindbox + +/// Координатор пишет доложенную контейнером презентацию не сразу, а отложенно — на следующем витке +/// главной очереди. `dismantleUIView` глушит колбэки контейнера, но уже поставленную запись из +/// очереди не достать — её отменяет `detach()`: подписка перепроверяется в момент исполнения. +/// +/// Сьют не помечен `@available(iOS 13.0, *)` — макросы `@Suite`/`@Test` не применяются к таким +/// объявлениям. Таргет тестов собирается под iOS 12, поэтому доступность SwiftUI-типов каждый тест +/// открывает себе сам через `guard #available`. +@Suite("Embedded block coordinator", .tags(.embeddedBlocks)) +struct EmbeddedBlockCoordinatorTests { + + /// Запись отложена: контейнер может доложить о смене слоя посреди прохода body, а менять + /// состояние в этот момент нельзя. Записывается на витке планировщика — и ровно то, что доложено. + @Test("Update writes on the scheduled turn, not synchronously") + func updateWritesOnScheduledTurn() { + guard #available(iOS 13.0, *) else { return } + + var written = [EmbeddedBlockPresentation]() + var scheduled = [() -> Void]() + let coordinator = EmbeddedBlockRepresentable.Coordinator( + presentation: Binding(get: { EmbeddedBlockPresentation(layer: .placeholder, height: 104) }, + set: { written.append($0) }), + creationHeight: 104, + onLoad: nil, + onFail: nil, + schedule: { scheduled.append($0) } + ) + + let content = EmbeddedBlockPresentation(layer: .content, height: 104) + coordinator.update(content) + + #expect(written.isEmpty) + + scheduled.forEach { $0() } + + #expect(written == [content]) + } + + /// Гонка демонтажа: запись уже в очереди, вью снимается с дерева до её исполнения. После + /// `detach()` блок обязан промолчать — состояние снятой вью ему больше не принадлежит. + @Test("Detach drops a write that was already scheduled") + func detachDropsScheduledWrite() { + guard #available(iOS 13.0, *) else { return } + + var written = [EmbeddedBlockPresentation]() + var scheduled = [() -> Void]() + let coordinator = EmbeddedBlockRepresentable.Coordinator( + presentation: Binding(get: { EmbeddedBlockPresentation(layer: .placeholder, height: 104) }, + set: { written.append($0) }), + creationHeight: 104, + onLoad: nil, + onFail: nil, + schedule: { scheduled.append($0) } + ) + + coordinator.update(EmbeddedBlockPresentation(layer: .content, height: 104)) + coordinator.detach() + + scheduled.forEach { $0() } + + #expect(written.isEmpty) + } +} From 89bbe9ac16830383e96fcf9b9f4219734985c0e8 Mon Sep 17 00:00:00 2001 From: Vailence Date: Wed, 12 Aug 2026 19:34:00 +0500 Subject: [PATCH 45/47] MOBILE-323: Translate embedded block comments and restore test coverage Translate all remaining Russian comments in EmbeddedBlocks sources and tests to English. Fix the ready timeout's clock to be monotonic so an NTP correction or manual clock change cannot shrink or negate spent budget. Restore the previously deleted container, provider, factory and SwiftUI wrapper test suites, adapted to the current identity and timeout seams. --- .../EmbeddedBlockContentProviderFactory.swift | 6 +- .../Container/EmbeddedBlockLayerHost.swift | 20 +- .../Container/EmbeddedBlockReadyTimeout.swift | 86 +- .../Container/EmbeddedBlockShimmerView.swift | 18 +- .../EmbeddedBlockPresentation.swift | 29 +- .../MindboxEmbeddedBlock.swift | 12 +- .../Public/MindboxEmbeddedBlockView.swift | 87 ++- .../WebView/EmbeddedBlockWebViewPage.swift | 2 +- .../EmbeddedBlockWebViewProvider.swift | 92 +-- ...ddedBlockContentProviderFactoryTests.swift | 77 ++ .../EmbeddedBlockCoordinatorTests.swift | 23 +- .../EmbeddedBlockLayerHostTests.swift | 18 +- .../EmbeddedBlocks/EmbeddedBlockMocks.swift | 237 ++++++ .../EmbeddedBlockReadyTimeoutTests.swift | 272 +++++++ .../EmbeddedBlockResolverTests.swift | 16 +- .../EmbeddedBlockWebViewProviderTests.swift | 556 +++++++++++++ .../MindboxEmbeddedBlockTests.swift | 244 ++++++ .../MindboxEmbeddedBlockViewTests.swift | 736 ++++++++++++++++++ 18 files changed, 2333 insertions(+), 198 deletions(-) create mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift create mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift create mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift create mode 100644 MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockTests.swift create mode 100644 MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift index 9989486d4..da423e7e7 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockContentProviderFactory.swift @@ -8,10 +8,10 @@ import Foundation -/// Собирает провайдер контента под конкретный блок. +/// Builds a content provider for a specific block. /// -/// Провайдер принадлежит одному контейнеру, поэтому создаётся на каждый блок заново — это и -/// делает блоки с одинаковым id независимыми. +/// A provider belongs to one container, so it is created anew for every block — this is what +/// makes blocks with the same id independent. protocol EmbeddedBlockContentProviderMaking { func makeProvider(id: String) -> EmbeddedBlockWebViewProvider } diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift index ee8e4bd67..4bcf82fcd 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockLayerHost.swift @@ -8,15 +8,15 @@ import UIKit -/// Держит в контейнере ровно одну вью, растянутую по его краям. +/// Holds exactly one view in the container, stretched to its edges. /// -/// Слои блока — плейсхолдер, контент, экран ошибки — взаимоисключающие: показать новый значит снять -/// прежний. Показанная вью запоминается отдельно от свойств контейнера, потому что подменить её хост -/// может в любой момент, а снимать надо ту, что действительно висит, а не ту, что лежит в свойстве -/// сейчас. +/// The block's layers — placeholder, content, error screen — are mutually exclusive: showing a new +/// one means removing the previous one. The shown view is remembered separately from the +/// container's properties, because the host can swap it at any moment, and the view to remove is +/// the one that is actually attached, not the one sitting in a property right now. final class EmbeddedBlockLayerHost { - /// Владелец держит хост, поэтому обратная ссылка не считается — иначе контейнер не умрёт никогда. + /// The owner holds the host, so the back reference must not count — or the container never dies. private unowned let container: UIView private var attachedView: UIView? @@ -25,17 +25,17 @@ final class EmbeddedBlockLayerHost { self.container = container } - /// Показывает вью вместо той, что висит сейчас. `nil` — не показывать ничего. + /// Shows the view in place of the one attached now. `nil` — show nothing. func show(_ view: UIView?) { - // «Не показывать ничего» — это просто снять текущую вью: сравнивать здесь нечего, и общее - // условие ниже на nil читалось бы не тем, чем оно является. + // "Show nothing" is simply removing the current view: there is nothing to compare here, and + // the shared condition below, made to cover nil, would read as something it is not. guard let view else { attachedView?.removeFromSuperview() attachedView = nil return } - // Та же вью и висит действительно у нас — пересобирать под неё констрейнты незачем. + // The same view, and it really is attached to us — no reason to rebuild its constraints. guard attachedView !== view || view.superview !== container else { return } attachedView?.removeFromSuperview() diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift index fa378c8c2..1f9807558 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockReadyTimeout.swift @@ -7,35 +7,38 @@ // import UIKit +import QuartzCore import MindboxLogger -/// Сколько блоку дано на то, чтобы показаться, — и учёт этого времени. +/// How long a block is given to show up — and the accounting of that time. /// -/// Бюджет принадлежит контейнеру, а не контенту: чем бы блок ни оказался внутри, вёрстка хоста не -/// ждёт его вечно. Не уложился — контейнер сворачивает блок. +/// The budget belongs to the container, not to the content: whatever the block turns out to be, +/// the host layout does not wait for it forever. A block that misses the budget is collapsed by +/// the container. /// -/// Считается время ожидания пользователя, а не календарное: пока блока никто не ждёт — приложение -/// в фоне, контейнер вне окна — отсчёт стоит. Иначе пользователь возвращался бы в приложение к -/// блоку, который сдался, пока его никто не видел. +/// What is counted is the user's waiting time, not calendar time: while nobody is waiting for the +/// block — the app is in the background, the container is out of the window — the count stands +/// still. Otherwise the user would come back to a block that gave up while nobody was looking. /// -/// Но именно стоит, а не начинается заново: потраченное запоминается, и попытка продолжает бюджет -/// с того места, где её прервали. Пауза, отдающая полный бюджет заново, не заканчивается никогда — -/// пользователь, переключающийся между приложениями каждые пять секунд, продлевал бы ожидание -/// блока бесконечно, и вёрстка хоста ждала бы его вечно. Ровно то, против чего бюджет и заведён. -/// Полный бюджет заново получает только новая попытка — `reset()`. +/// Stands still, but does not start over: what was spent is remembered, and the attempt continues +/// its budget from where it was interrupted. A pause that handed the full budget back would never +/// end — a user switching between apps every five seconds would extend the wait indefinitely, and +/// the host layout would wait forever. Exactly what the budget exists to prevent. Only a new +/// attempt — `reset()` — gets the full budget again. /// -/// Загрузку пауза не трогает: она идёт своим чередом, в фоне её тормозит система, а не SDK. +/// Loading is not affected by the pause: it runs its own course; in the background the system +/// throttles it, not the SDK. -/// Кто выполнит работу, когда истечёт заданный остаток бюджета. +/// Runs the work when the given remainder of the budget expires. typealias EmbeddedBlockTimeoutScheduling = (TimeInterval, DispatchWorkItem) -> Void final class EmbeddedBlockReadyTimeout { - /// Нужен ли отсчёт прямо сейчас: исход ещё неизвестен и блок на виду. Спрашивается заново на - /// каждом заводе, потому что за время паузы могло измениться и то, и другое. + /// Whether the count is needed right now: the outcome is still unknown and the block is + /// visible. Asked again on every arm, because both may have changed while paused. var isNeeded: () -> Bool = { false } - /// Время вышло. Вызывается на главном потоке. + /// Time is up. Called on the main thread. var onExpire: () -> Void = {} var isRunning: Bool { workItem != nil } @@ -43,32 +46,35 @@ final class EmbeddedBlockReadyTimeout { private let blockId: String private let duration: TimeInterval - /// Часы отдельным швом: считать потраченное без них нельзя, а тесты не могут ждать бюджет - /// целиком — им нужно уметь сказать, что время прошло. - private let now: () -> Date + /// The clock is its own seam: spent time cannot be counted without it, and tests cannot wait + /// out the budget for real — they need a way to say that time has passed. The clock is + /// monotonic, not `Date`: an NTP correction or a manual clock change would make the spent + /// delta negative and stretch the wait past the budget. + private let now: () -> TimeInterval - /// Нотификации отдельным швом по той же причине, что и часы: на глобальном центре уход в фон - /// проверить нельзя — тестовое уведомление долетело бы до блоков из тестов, идущих рядом. + /// Notifications are their own seam for the same reason as the clock: going to the background + /// cannot be tested on the global center — a test notification would reach the blocks of tests + /// running next to it. private let notificationCenter: NotificationCenter - /// Планировщик тем же швом и по той же причине: зашитая очередь заставляла бы тесты бюджета - /// ждать его настоящим временем — то есть спать на каждую проверку и флакать на нагруженной - /// машине. + /// The scheduler is the same kind of seam for the same reason: a hard-wired queue would force + /// budget tests to wait it out in real time — sleeping on every check and flaking on a busy + /// machine. private let schedule: EmbeddedBlockTimeoutScheduling private var workItem: DispatchWorkItem? - /// Сколько бюджета съели прошлые отрезки ожидания. + /// How much of the budget past waiting stretches have consumed. private var consumed: TimeInterval = 0 - /// Когда начался идущий отрезок. `nil` — отсчёт не идёт. - private var resumedAt: Date? + /// When the current stretch started. `nil` — the count is not running. + private var resumedAt: TimeInterval? private var remaining: TimeInterval { max(0, duration - consumed) } init(blockId: String, duration: TimeInterval, - now: @escaping () -> Date = { Date() }, + now: @escaping () -> TimeInterval = { CACurrentMediaTime() }, notificationCenter: NotificationCenter = .default, schedule: @escaping EmbeddedBlockTimeoutScheduling = { delay, work in DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) @@ -94,9 +100,9 @@ final class EmbeddedBlockReadyTimeout { workItem?.cancel() } - /// Заводит отсчёт на остаток бюджета, если он нужен и ещё не идёт. Звать можно сколько угодно - /// раз: лишние вызовы ничего не делают, поэтому вход в окно, возврат из фона и перезагрузка - /// обходятся одним и тем же вызовом. + /// Arms the count for the remainder of the budget, if it is needed and not already running. + /// Call as often as convenient: extra calls do nothing, so entering the window, returning from + /// the background, and reloading all share the same call. func armIfNeeded() { guard workItem == nil, isNeeded() else { return } @@ -105,7 +111,8 @@ final class EmbeddedBlockReadyTimeout { self.workItem = nil self.resumedAt = nil - // Бюджет израсходован целиком: если блок почему-то заведут снова, ждать ему уже нечего. + // The budget is spent in full: if the block is somehow armed again, there is nothing + // left to wait for. self.consumed = self.duration Logger.common(message: "[EmbeddedBlock] Block '\(self.blockId)' timed out after \(self.duration)s of waiting", @@ -113,26 +120,27 @@ final class EmbeddedBlockReadyTimeout { self.onExpire() } - // Работа записывается в свойство до того, как её заведут: планировщик вправе выполнить её - // тут же, и она должна застать бюджет в согласованном состоянии. + // The work is stored in the property before it is scheduled: the scheduler is free to run + // it right away, and it must find the budget in a consistent state. resumedAt = now() workItem = work schedule(remaining, work) } - /// Останавливает отсчёт, запомнив потраченное. Попытку это не отменяет: `armIfNeeded()` - /// продолжит её с остатка. + /// Stops the count, remembering what was spent. The attempt is not cancelled: `armIfNeeded()` + /// continues it from the remainder. func pause() { guard let resumedAt else { return } - consumed += now().timeIntervalSince(resumedAt) + consumed += max(0, now() - resumedAt) self.resumedAt = nil workItem?.cancel() workItem = nil } - /// Останавливает отсчёт и возвращает бюджет в полный: прошлая попытка кончилась — исходом или - /// тем, что началась следующая, — и её остаток к новой отношения не имеет. + /// Stops the count and restores the full budget: the previous attempt is over — with an + /// outcome, or because the next one started — and its remainder has nothing to do with the + /// new one. func reset() { pause() consumed = 0 diff --git a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift index 3ce835743..d95337925 100644 --- a/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift +++ b/Mindbox/EmbeddedBlocks/Container/EmbeddedBlockShimmerView.swift @@ -8,15 +8,15 @@ import UIKit -/// Дефолтный плейсхолдер встроенного блока — нейтральная плашка с бегущим бликом. +/// The default embedded block placeholder — a neutral tile with a sweeping highlight. /// -/// Заливает контейнер целиком: у SDK нет знания о вёрстке будущего контента, поэтому плейсхолдер -/// не изображает её, а просто помечает зарезервированное место как «грузится». Хост, которому -/// нужен скелет своей вёрстки, задаёт `placeholderView` контейнера. +/// Fills the container entirely: the SDK knows nothing about the layout of the content to come, so +/// the placeholder does not depict it and simply marks the reserved spot as "loading". A host that +/// needs a skeleton of its own layout sets the container's `placeholderView`. /// -/// Анимация живёт ровно столько, сколько вью видна: запускается при входе в окно и гасится при -/// выходе, включая уход приложения в фон (система снимает CA-анимации, поэтому на возврат в -/// foreground блик перезапускается). +/// The animation lives exactly as long as the view is visible: it starts on entering a window and +/// stops on leaving it, including the app going to the background (the system removes CA +/// animations, so the highlight is restarted on return to the foreground). final class EmbeddedBlockShimmerView: UIView { private enum Shimmer { @@ -112,8 +112,8 @@ final class EmbeddedBlockShimmerView: UIView { gradientLayer.removeAnimation(forKey: Shimmer.animationKey) } - /// Система снимает бесконечные CA-анимации при уходе в фон — после возврата блик нужно - /// запустить заново. + /// The system removes infinite CA animations when the app goes to the background — after + /// coming back the highlight has to be started again. @objc private func applicationWillEnterForeground() { guard window != nil else { return } diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/EmbeddedBlockPresentation.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/EmbeddedBlockPresentation.swift index 725c6c262..4ef47e02c 100644 --- a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/EmbeddedBlockPresentation.swift +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/EmbeddedBlockPresentation.swift @@ -8,36 +8,37 @@ import CoreGraphics -/// Что контейнер показывает прямо сейчас — снимок для SwiftUI-обёртки. +/// What the container shows right now — a snapshot for the SwiftUI wrapper. /// -/// UIKit-хосту такой тип не нужен: контейнер сам заявляет высоту через `intrinsicContentSize` и сам -/// держит внутри нужный слой. SwiftUI не умеет ни того, ни другого. Высоту представимой вью -/// назначает обёртка, а плейсхолдер и экран ошибки хоста — это SwiftUI-вью, и рисовать их обязана -/// тоже она: вью, отданная контейнеру через отдельный `UIHostingController`, выпадает из дерева -/// SwiftUI и теряет его окружение. Поэтому обёртке нужен не только размер, но и текущий слой. +/// A UIKit host needs no such type: the container declares its height through `intrinsicContentSize` +/// and holds the right layer inside, both on its own. SwiftUI can do neither. The wrapper assigns +/// the representable's height, and the host's placeholder and error screen are SwiftUI views the +/// wrapper must draw too: a view handed to the container through a separate `UIHostingController` +/// falls out of the SwiftUI tree and loses its environment. So the wrapper needs not only the +/// size but the current layer too. /// -/// Deliberately internal, как и `EmbeddedBlockState`: хост знает только исход, а не то, как -/// контейнер к нему пришёл. +/// Deliberately internal, like `EmbeddedBlockState`: the host knows only the outcome, not how the +/// container arrived at it. struct EmbeddedBlockPresentation: Equatable { - /// Слой, видимый в контейнере. + /// The layer visible in the container. enum Layer { - /// Идёт загрузка: показан плейсхолдер — хоста или дефолтный шиммер SDK. + /// Loading is underway: the placeholder is shown — the host's or the SDK's default shimmer. case placeholder - /// Показано содержимое блока. + /// The block content is shown. case content - /// Показан экран ошибки, на который хост согласился явно. + /// The error screen the host explicitly opted into is shown. case errorView - /// Блок схлопнут: провал без экрана ошибки или пустой блок. + /// The block is collapsed: a failure without an error screen, or an empty block. case nothing } let layer: Layer - /// Высота, которую контейнер занимает с этим слоем. + /// The height the container occupies with this layer. let height: CGFloat } diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift index 64f48d2ea..b6479c624 100644 --- a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift @@ -105,8 +105,8 @@ private struct EmbeddedBlockBody: View { let placeholder: (() -> AnyView)? let errorContent: (() -> AnyView)? - /// Стартует с того же, с чего стартует контейнер: место занято, показан плейсхолдер. Блок - /// занимает свою высоту сразу, а не с первого отчёта от контейнера. + /// Starts from the same point the container starts from: the space is taken, the placeholder + /// is shown. The block occupies its height right away, not from the container's first report. @State private var presentation: EmbeddedBlockPresentation init(id: String, @@ -238,7 +238,7 @@ struct EmbeddedBlockRepresentable: UIViewRepresentable { private var isDetached = false - /// Куда `update` откладывает запись — `DispatchQueue.main` вне тестов. + /// Where `update` defers its write — `DispatchQueue.main` outside tests. private let schedule: (@escaping () -> Void) -> Void init(presentation: Binding, @@ -261,9 +261,9 @@ struct EmbeddedBlockRepresentable: UIViewRepresentable { } } - /// Вью снята с дерева: гасит запись, уже поставленную `update` в очередь, — обнуление - /// колбэков в `dismantleUIView` её не отзывает, а `weak self` не гарантия: когда SwiftUI - /// отпустит координатор после демонтажа, не специфицировано. + /// The view left the tree: silences the write `update` has already queued — nulling the + /// callbacks in `dismantleUIView` cannot recall it, and `weak self` is no guarantee: when + /// SwiftUI releases the coordinator after dismantling is unspecified. func detach() { isDetached = true } diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift index a4d915795..20742e58d 100644 --- a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlockView.swift @@ -24,9 +24,9 @@ import MindboxLogger /// and stops it when it leaves. The host app observes the outcome through `delegate` and nothing /// else. /// -/// Сам контейнер — это машина из четырёх состояний контента и одного видимого слоя на каждое из -/// них. Всё, что можно было унести из него целиком, унесено: показ слоёв — в -/// `EmbeddedBlockLayerHost`, бюджет ожидания вместе с его паузой в фоне — в +/// The container itself is a machine of four content states and one visible layer for each of +/// them. Everything that could be moved out of it wholesale has been: showing the layers went to +/// `EmbeddedBlockLayerHost`, the waiting budget together with its background pause to /// `EmbeddedBlockReadyTimeout`. public final class MindboxEmbeddedBlockView: UIView { @@ -40,9 +40,9 @@ public final class MindboxEmbeddedBlockView: UIView { /// delivers that outcome, so subscribing late cannot lose it. public weak var delegate: MindboxEmbeddedBlockViewDelegate? { didSet { - // Тот же делегат — не новый подписчик. Хост штатно переприсваивает его на каждой - // переиспользованной ячейке, и отдавать ему на это уже услышанный исход нельзя: он на - // исход перестраивает вёрстку, а перестройка вёрстки снова переприсваивает делегата. + // The same delegate is not a new subscriber. The host routinely reassigns it on every + // reused cell, and answering that with an already heard outcome is not allowed: the + // host rebuilds its layout on the outcome, and the rebuild reassigns the delegate again. guard delegate !== oldValue else { return } deliveredEvent = nil @@ -104,22 +104,22 @@ public final class MindboxEmbeddedBlockView: UIView { } } - /// Схлопывался ли блок с прошлой явной перезагрузки. + /// Whether the block has collapsed since the last explicit reload. /// - /// Место, единожды отданное хосту, назад не забирается: повторная попытка — а её блок получает - /// на каждом возвращении в окно — не разворачивает контейнер обратно под плейсхолдер. Иначе - /// блок, который показать не удалось, дёргал бы вёрстку хоста на свою высоту и мигал шиммером - /// на каждый свой проход по экрану, ничего в итоге не показывая. Разворачивает его только - /// показанный контент — или явная перезагрузка, которая и есть согласие на полный цикл заново. + /// Space once ceded to the host is not taken back: a retry — and the block gets one on every + /// return to a window — does not expand the container back for the placeholder. Otherwise a + /// block that failed to show would jerk the host layout by its height and flash the shimmer + /// on every pass across the screen, showing nothing in the end. Only shown content expands it + /// back — or an explicit reload, which is precisely consent to the full cycle anew. private var hasCollapsed = false - /// Слой, показанный прямо сейчас. Хранится, а не вычисляется на лету: он один источник и для - /// высоты, и для отчёта обёртке — иначе они могли бы разойтись. И он же отделяет показанный - /// экран ошибки от просто назначенного: `errorView`, отданный после схлопывания, не показан. + /// The layer shown right now. Stored, not computed on the fly: it is the one source for both + /// the height and the report to the wrapper — otherwise they could diverge. It also tells a + /// shown error screen from one merely assigned: an `errorView` given after collapse is not shown. private var shownLayer: EmbeddedBlockPresentation.Layer = .placeholder - /// Публичных исходов два: показан или не показан. «Пусто» для хоста — тот же непоказ, что и - /// ошибка, разница живёт только внутри контейнера (пустой блок не показывает `errorView`). + /// There are two public outcomes: shown or not shown. To the host "empty" is the same non-show + /// as a failure; the difference lives only in the container (an empty block shows no `errorView`). private enum BlockEvent { case loaded case failed @@ -151,9 +151,9 @@ public final class MindboxEmbeddedBlockView: UIView { return nil } - /// - Parameter timeout: Бюджет ожидания целиком, а не одна его длительность: внутри него живут - /// и часы, и планировщик, и подписка на фон, а подменять их поодиночке через контейнер значило - /// бы протащить сквозь него три параметра ради тестов. + /// - Parameter timeout: The waiting budget as a whole, not just its duration: the clock, the + /// scheduler, and the background subscription all live inside it, and swapping them one by + /// one through the container would mean threading three parameters through it just for tests. init(id: String, height: CGFloat, contentProvider: EmbeddedBlockWebViewProvider, @@ -170,10 +170,10 @@ public final class MindboxEmbeddedBlockView: UIView { setUpContainer() } - /// Высоту блока задаёт хост, и нулевая — это не «блок схлопнут», а «место под него не выделено»: - /// блок отработает весь цикл, отдаст хосту свои события и останется невидимым. Симптомов у этого - /// нет никаких — блока просто не видно, — а причина самая частая из возможных, поэтому SDK - /// говорит о ней вслух. + /// The host sets the block height, and zero means not "the block collapsed" but "no space was + /// reserved for it": the block runs its whole cycle, hands the host its events and stays + /// invisible. There are no symptoms at all — the block is simply not visible — and the cause is + /// the most common one possible, so the SDK says it out loud. private func warnIfHeightReservesNothing() { guard preferredHeight <= 0 else { return } @@ -235,8 +235,8 @@ public final class MindboxEmbeddedBlockView: UIView { if window == nil { Logger.common(message: "[EmbeddedBlock] Block '\(id)' left the window, stopping content", category: .embeddedBlocks) - // Пауза, а не сброс: вне окна блока никто не ждёт, но начатую попытку это не отменяет — - // вернувшись, она досчитает свой остаток. + // A pause, not a reset: nobody waits for a block outside a window, but that does not + // cancel an attempt already started — once back, it counts down its remainder. timeout.pause() contentProvider.stop() } else { @@ -246,16 +246,16 @@ public final class MindboxEmbeddedBlockView: UIView { } } - /// Перезагружает блок: контент начинает загрузку с нуля, адрес запрашивается заново в обход - /// кэша, блок возвращается в состояние загрузки со своим плейсхолдером и новым таймаутом. + /// Reloads the block: the content starts loading from scratch, the address is requested anew + /// bypassing the cache, the block returns to loading with its placeholder and a fresh timeout. /// - /// Internal и без публичной обёртки: автоматические перезагрузки — по ошибке, по возвращению - /// в приложение — будут строиться на этом методе, а хосту решать, когда обновлять блок, пока - /// незачем. + /// Internal and without a public wrapper: automatic reloads — on failure, on returning to the + /// app — will be built on this method, and there is no reason yet for the host to decide when + /// to refresh the block. func reload() { guard window != nil else { - // Контент живёт только пока блок в окне; перезагружать невидимый блок нечего — он - // сам загрузится заново, когда вернётся в окно. + // Content lives only while the block is in a window; reloading an invisible block is + // pointless — it loads anew by itself once it returns to a window. Logger.common(message: "[EmbeddedBlock] Block '\(id)' reload skipped: the block is not in a window", category: .embeddedBlocks) return @@ -263,11 +263,11 @@ public final class MindboxEmbeddedBlockView: UIView { Logger.common(message: "[EmbeddedBlock] Block '\(id)' reload requested", category: .embeddedBlocks) timeout.reset() - // Новая попытка — новый исход: хост должен услышать его целиком, даже если он совпадёт - // с прошлым. + // A new attempt means a new outcome: the host must hear it in full, even if it matches + // the previous one. deliveredEvent = nil - // И новая попытка вправе снова занять место: перезагрузка — это явное согласие хоста на - // полный цикл с плейсхолдером, в отличие от молчаливого возвращения блока в окно. + // And a new attempt is entitled to take space again: a reload is the host's explicit consent + // to the full cycle with the placeholder, unlike the block's silent return to a window. hasCollapsed = false contentProvider.reload() timeout.armIfNeeded() @@ -281,9 +281,10 @@ public final class MindboxEmbeddedBlockView: UIView { // MARK: - Layers - /// Бюджет ожидания живёт по попыткам, а не по состояниям: продолжающаяся загрузка досчитывает - /// свой остаток, а всё остальное — известный исход или начатая заново загрузка — счёт обнуляет. - /// Заводить его снова решает тот, кто знает, ждут ли блок: вход в окно и возврат из фона. + /// The waiting budget lives per attempt, not per state: an ongoing load counts down its + /// remainder, while everything else — a known outcome or a load started anew — resets the count. + /// Arming it again is up to whoever knows whether the block is awaited: entering a window and + /// returning from the background. private func updateTimeout(from previous: EmbeddedBlockState) { guard state != .loading || previous != .loading else { return } @@ -306,11 +307,11 @@ public final class MindboxEmbeddedBlockView: UIView { private func layer(for state: EmbeddedBlockState) -> EmbeddedBlockPresentation.Layer { switch state { - // Схлопнутый блок остаётся свёрнутым и пока грузится заново: место, которое хост уже - // забрал, повторная попытка назад не отыгрывает. + // A collapsed block stays collapsed even while it loads anew: a retry does not win back + // the space the host has already reclaimed. case .loading: return hasCollapsed ? .nothing : .placeholder case .ready: return .content - // Провал показывают только тем, кто согласился на это явно; остальным блок сворачивается. + // A failure is shown only to those who opted in explicitly; for the rest the block collapses. case .failed: return errorView == nil ? .nothing : .errorView case .empty: return .nothing } diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift index 7facff686..d079d8485 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewPage.swift @@ -48,7 +48,7 @@ final class EmbeddedBlockWebViewPage: NSObject, EmbeddedBlockPageHosting { case .url(let url): webView.load(URLRequest(url: url)) case .html(let html): - // у страницы, поданной разметкой, origin about:blank, поэтому ни localStorage, ни сетевых запросов на свой домен у неё не будет. В (MOBILE-328) изменится или полностью удалится этот кейс + // A page supplied as markup has an about:blank origin, so it will have neither localStorage nor network requests to its own domain. This case will change or be removed entirely in (MOBILE-328) webView.loadHTMLString(html, baseURL: nil) } } diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift index d5a92cadd..bc7a30502 100644 --- a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift +++ b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift @@ -9,18 +9,18 @@ import UIKit import MindboxLogger -/// Контент встроенного блока — веб-страница, найденная по id блока. +/// Embedded block content — a web page found by the block id. /// -/// Провайдер не рисует контент и не знает механик: он спрашивает у резолвера, что стоит за id, -/// переводит core-сообщения страницы в состояния контейнера, а действия сверх core-слоя отдаёт -/// универсальному обработчику. +/// The provider does not draw content and knows no mechanics: it asks the resolver what stands +/// behind the id, translates the page's core messages into container states, and hands actions +/// beyond the core layer to the universal handler. /// -/// Экземпляр принадлежит одному контейнеру, поэтому `start()` и `stop()` просто повторяют его -/// видимость и могут вызываться по кругу. После `stop()` провайдер обязан молчать до следующего -/// `start()` — на это опирается контейнер, когда сворачивает просроченный блок. +/// An instance belongs to one container, so `start()` and `stop()` simply mirror its visibility +/// and can be called in cycles. After `stop()` the provider must stay silent until the next +/// `start()` — the container relies on this when it collapses an expired block. final class EmbeddedBlockWebViewProvider { - /// Сообщает каждую смену состояния на главном потоке. Ставится контейнером. + /// Reports every state change on the main thread. Set by the container. var onStateChange: ((EmbeddedBlockState) -> Void)? var contentView: UIView? { isReady ? page?.view : nil } @@ -31,23 +31,23 @@ final class EmbeddedBlockWebViewProvider { private let readinessOverrides: EmbeddedBlockReadinessOverriding private let makePage: (EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting - /// Страница переживает рестарты: контейнер стартует и останавливает блок по видимости, и - /// пересоздавать вебвью на каждое возвращение в окно незачем. + /// The page survives restarts: the container starts and stops the block by visibility, and + /// there is no reason to recreate the web view on every return to the window. private var page: EmbeddedBlockPageHosting? private var isStarted = false - /// Чем кончилась текущая попытка: `nil` — ещё ничем. + /// How the current attempt ended: `nil` — nothing yet. /// - /// Исход переживает `stop()`: он свойство страницы, а не факта нахождения в окне. Провал и - /// `empty` при этом не убивают страницу — она жива и может продолжать говорить, — поэтому - /// известный исход нужен и как признак того, что блока на экране больше нет. + /// The outcome survives `stop()`: it is a property of the page, not of being in the window. + /// A failure and `empty` do not kill the page — it is alive and can keep talking — so a known + /// outcome is also needed as a sign that the block is no longer on screen. private var outcome: EmbeddedBlockState? private var isReady: Bool { outcome == .ready } - /// Номер текущей попытки загрузки. Резолв может ответить уже после `stop()` или после - /// перезагрузки — по номеру видно, что ответ относится к прошлой попытке, и его надо выбросить. + /// The number of the current load attempt. A resolve may answer after `stop()` or after a + /// reload — the number shows that the answer belongs to a past attempt and must be thrown away. private var loadGeneration = 0 init(id: String, @@ -76,19 +76,19 @@ final class EmbeddedBlockWebViewProvider { guard isStarted else { return } isStarted = false - // Исход не сбрасываем: он свойство страницы, а не факта нахождения в окне. Иначе каждый - // проход блока по экрану стоил бы полной перезагрузки. + // The outcome is not reset: it is a property of the page, not of being in the window. + // Otherwise every pass of the block across the screen would cost a full reload. loadGeneration += 1 page?.cancel() } - /// Начинает загрузку с нуля: страница выбрасывается, а адрес запрашивается заново в обход кэша - /// резолвера — иначе переехавший или выключенный блок вечно доставал бы прежний адрес. + /// Starts the load from scratch: drops the page and requests the address again, bypassing the + /// resolver cache — otherwise a moved or disabled block would forever get its previous address. func reload() { Logger.common(message: "[EmbeddedBlock] Block '\(id)' is reloading", category: .embeddedBlocks) - // Прежняя страница больше не имеет отношения к делу — сначала отключаем её от себя, чтобы - // её запоздавшие сообщения не попали в новую попытку. + // The previous page is no longer relevant — detach it from us first so that its late + // messages do not end up in the new attempt. page?.onMessage = nil page?.onLoadFailure = nil page?.onLoadFinish = nil @@ -109,17 +109,17 @@ final class EmbeddedBlockWebViewProvider { case .ready(let height): apply(height: height) case .heightChanged(let height): - // Высотой владеет хост — сообщение остаётся в контракте страницы, но на нативной - // стороне ни на что не влияет. + // The host owns the height — the message stays in the page contract but affects + // nothing on the native side. Logger.common(message: "[EmbeddedBlock] Ignored heightChanged(\(height)): the host owns the container height", category: .embeddedBlocks) case .empty: outcome = .empty onStateChange?(.empty) case .action(let action): - // Блока на экране нет, а страница жива и продолжает работать — например, досылает то, - // что запланировал её `setTimeout`. Выполнять её действия в этот момент нельзя: за - // невидимым блоком не стоит ни одного касания пользователя, а `openUrl` увёл бы его из - // приложения на пустом месте. + // The block is not on screen, but the page is alive and keeps working — for example, + // delivering what its `setTimeout` scheduled. Its actions must not run at this moment: + // not a single user touch stands behind an invisible block, and `openUrl` would take + // the user out of the app out of nowhere. guard isShown else { Logger.common(message: "[EmbeddedBlock] Block '\(id)': ignored action '\(action.type)' from a block that is not shown", category: .embeddedBlocks) @@ -137,13 +137,13 @@ final class EmbeddedBlockWebViewProvider { onStateChange?(.failed) } - /// Пока исхода нет, страница ещё грузится — её сообщения относятся к живому блоку. + /// While there is no outcome, the page is still loading — its messages belong to a live block. private var isShown: Bool { outcome == nil || outcome == .ready } - /// Загруженный документ сам по себе ничего не значит: показать блок по нему разрешает только - /// отладочная подмена — для страниц, которые ещё не умеют присылать `ready`. + /// A loaded document means nothing by itself: showing the block on it is allowed only by the + /// debug override — for pages that cannot send `ready` yet. func handleLoadFinish() { guard isStarted, !isReady, readinessOverrides.treatsLoadedPageAsReady else { return } @@ -159,8 +159,8 @@ final class EmbeddedBlockWebViewProvider { isStarted = true - // Страница уже отрендерилась и никуда не делась — показываем её как есть. Возврат блока - // в окно не стоит ни сети, ни шиммера, ни повторных событий хосту. + // The page has already rendered and is still around — show it as is. Returning the block + // to the window costs no network, no shimmer, no repeated events to the host. if isReady, page != nil { Logger.common(message: "[EmbeddedBlock] Block '\(id)': showing the page rendered earlier", category: .embeddedBlocks) @@ -169,8 +169,8 @@ final class EmbeddedBlockWebViewProvider { } onStateChange?(.loading) - // Началась новая попытка: чем кончилась прошлая, больше не важно — в том числе и для того, - // выполнять ли действия страницы. + // A new attempt has started: how the previous one ended no longer matters — including for + // deciding whether to run the page's actions. outcome = nil if let page { @@ -205,8 +205,8 @@ final class EmbeddedBlockWebViewProvider { } private func apply(height: CGFloat) { - // «Показывать нечего» страница сообщает явным `empty`, поэтому нулевая высота — это - // сломанная вёрстка, то есть ошибка. + // The page reports "nothing to show" with an explicit `empty`, so zero height means + // broken layout, that is, a failure. guard height > 0 else { Logger.common(message: "[EmbeddedBlock] Block '\(id)': page reported zero height, treating as broken", category: .embeddedBlocks) outcome = .empty @@ -222,21 +222,21 @@ final class EmbeddedBlockWebViewProvider { // MARK: - Live blocks -/// Сколько блоков с каждым id живо прямо сейчас. +/// How many blocks with each id are alive right now. /// -/// Диагностика, а не механика: два блока с одним id — законный случай, оба покажут один и тот же -/// контент. Но чаще это либо скопированный id, либо переиспользованная ячейка, в которую попал -/// контейнер от другой строки, — а у обоих случаев нет заметных симптомов, кроме «блок оказался не -/// там, где ждали». Поэтому SDK говорит об этом в лог. +/// Diagnostics, not mechanics: two blocks with one id are a legitimate case, both will show the +/// same content. But more often it is either a copied id or a reused cell that got a block +/// container from another row — and neither case has noticeable symptoms beyond "the block ended +/// up not where it was expected". So the SDK reports it to the log. /// -/// Счётчик общий на процесс, потому что вопрос тоже общий: одинаковые id ищутся не внутри блока, а -/// между блоками. Живых блоков он не удерживает — хранит только числа. +/// The counter is process-wide because the question is too: identical ids are looked for not +/// inside one block but across blocks. It does not retain live blocks — it stores only counts. extension EmbeddedBlockWebViewProvider { private static var liveBlocks: [String: Int] = [:] - /// Блоки создаются и умирают с UIKit-вью, то есть на главном потоке. Замок стоит на случай, если - /// это когда-нибудь перестанет быть правдой: диагностика не должна ронять SDK. + /// Blocks are created and die with UIKit views, that is, on the main thread. The lock is here + /// in case that ever stops being true: diagnostics must not crash the SDK. private static let liveBlocksLock = NSLock() static func liveCount(for id: String) -> Int { diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift new file mode 100644 index 000000000..8b2dd49a9 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift @@ -0,0 +1,77 @@ +// +// EmbeddedBlockContentProviderFactoryTests.swift +// MindboxTests +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +@testable import Mindbox + +/// The factory keeps one promise: the provider is its own for every block, while the resolver and +/// the action handler are shared. The independence of blocks sharing an id rests on this, so it is +/// checked on its own. +/// +/// The live-block counter is shared across the process, so each test uses its own id: otherwise +/// tests running in parallel would count each other's blocks. +@Suite("Embedded block content provider factory", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockContentProviderFactoryTests { + + /// Two blocks sharing an id are a legitimate case, and each must get its own provider: a + /// shared one would make their state and page one for both. + @Test("Every call makes its own provider") + func eachCallMakesItsOwnProvider() { + let id = "factory-independent-blocks" + let factory = makeFactory() + + let first = factory.makeProvider(id: id) + let second = factory.makeProvider(id: id) + + #expect(first !== second) + withExtendedLifetime((first, second)) { + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 2) + } + } + + @Test("The provider is made for the requested id") + func providerIsMadeForTheRequestedId() { + let id = "factory-carries-the-id" + let other = "factory-some-other-id" + let factory = makeFactory() + + let provider = factory.makeProvider(id: id) + + withExtendedLifetime(provider) { + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 1) + #expect(EmbeddedBlockWebViewProvider.liveCount(for: other) == 0) + } + } + + /// The resolver is shared exactly so that several blocks with the same id are resolved by one + /// data fetch. This checks that the factory really hands the provider that resolver instead + /// of making its own. + @Test("The provider asks the shared resolver for its own id") + func providerAsksTheSharedResolver() { + let resolver = EmbeddedBlockResolverMock(resolution: .empty) + let factory = EmbeddedBlockContentProviderFactory(resolver: resolver, + actionHandler: EmbeddedBlockActionHandlerMock()) + + let provider = factory.makeProvider(id: "factory-shared-resolver") + withExtendedLifetime(provider) { + provider.start() + } + + #expect(resolver.resolvedIds == ["factory-shared-resolver"]) + } + + // MARK: - Helpers + + /// The resolver answers "empty": no page is created for such a block, so the factory's tests + /// need no real web view. + private func makeFactory() -> EmbeddedBlockContentProviderFactory { + EmbeddedBlockContentProviderFactory(resolver: EmbeddedBlockResolverMock(resolution: .empty), + actionHandler: EmbeddedBlockActionHandlerMock()) + } +} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockCoordinatorTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockCoordinatorTests.swift index 5e566fc16..5f96a9a06 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockCoordinatorTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockCoordinatorTests.swift @@ -10,18 +10,20 @@ import Testing import SwiftUI @testable import Mindbox -/// Координатор пишет доложенную контейнером презентацию не сразу, а отложенно — на следующем витке -/// главной очереди. `dismantleUIView` глушит колбэки контейнера, но уже поставленную запись из -/// очереди не достать — её отменяет `detach()`: подписка перепроверяется в момент исполнения. +/// The coordinator writes the presentation reported by the container deferred — on the next turn +/// of the main queue. `dismantleUIView` silences the container's callbacks, but a write already +/// queued cannot be recalled — `detach()` cancels it: the subscription is re-checked at execution +/// time. /// -/// Сьют не помечен `@available(iOS 13.0, *)` — макросы `@Suite`/`@Test` не применяются к таким -/// объявлениям. Таргет тестов собирается под iOS 12, поэтому доступность SwiftUI-типов каждый тест -/// открывает себе сам через `guard #available`. +/// The suite is not marked `@available(iOS 13.0, *)` — the `@Suite`/`@Test` macros reject such +/// declarations. The test target builds for iOS 12, so each test opens SwiftUI availability for +/// itself with `guard #available`. @Suite("Embedded block coordinator", .tags(.embeddedBlocks)) struct EmbeddedBlockCoordinatorTests { - /// Запись отложена: контейнер может доложить о смене слоя посреди прохода body, а менять - /// состояние в этот момент нельзя. Записывается на витке планировщика — и ровно то, что доложено. + /// The write is deferred: the container may report a layer change in the middle of a body + /// pass, and state must not change at that moment. It lands on the scheduler's turn — and is + /// exactly what was reported. @Test("Update writes on the scheduled turn, not synchronously") func updateWritesOnScheduledTurn() { guard #available(iOS 13.0, *) else { return } @@ -47,8 +49,9 @@ struct EmbeddedBlockCoordinatorTests { #expect(written == [content]) } - /// Гонка демонтажа: запись уже в очереди, вью снимается с дерева до её исполнения. После - /// `detach()` блок обязан промолчать — состояние снятой вью ему больше не принадлежит. + /// The dismantle race: the write is already queued, the view leaves the tree before it runs. + /// After `detach()` the block must stay silent — the removed view's state is no longer its + /// to write. @Test("Detach drops a write that was already scheduled") func detachDropsScheduledWrite() { guard #available(iOS 13.0, *) else { return } diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockLayerHostTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockLayerHostTests.swift index afc796eb9..294e248b2 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockLayerHostTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockLayerHostTests.swift @@ -10,8 +10,8 @@ import Testing import UIKit @testable import Mindbox -/// У хоста слоёв одно обещание: в контейнере ровно одна вью и она растянута по его краям. Слои блока -/// взаимоисключающие, поэтому показать новый значит снять прежний. +/// The layer host has one promise: the container holds exactly one view, stretched to its edges. The +/// block's layers are mutually exclusive, so showing a new one means removing the previous one. @Suite("Embedded block layer host", .tags(.embeddedBlocks)) @MainActor struct EmbeddedBlockLayerHostTests { @@ -26,7 +26,7 @@ struct EmbeddedBlockLayerHostTests { #expect(layer.superview === container) #expect(layer.translatesAutoresizingMaskIntoConstraints == false) - // Четыре края: слой всегда заполняет контейнер, который ему дали. + // Four edges: the layer always fills the container it was given. #expect(container.constraints.count == 4) } @@ -60,8 +60,8 @@ struct EmbeddedBlockLayerHostTests { #expect(container.constraints.isEmpty) } - /// Схлопнутый блок остаётся схлопнутым и продолжает получать `show(nil)` на каждую смену - /// состояния: снимать нечего, и трогать контейнер хост не должен. + /// A collapsed block stays collapsed and keeps receiving `show(nil)` on every state change: + /// there is nothing to remove, and the host must not touch the container. @Test("Showing nothing when nothing is shown changes nothing") func showingNothingOnEmptyHostChangesNothing() { let container = UIView() @@ -74,8 +74,8 @@ struct EmbeddedBlockLayerHostTests { #expect(container.constraints.isEmpty) } - /// Контейнер зовёт `show` на каждую смену состояния, и часть этих вызовов приходит с той же вью. - /// Пересобирать под неё констрейнты незачем — их бы просто становилось больше. + /// The container calls `show` on every state change, and some of those calls come with the same + /// view. There is no reason to rebuild its constraints — they would simply keep piling up. @Test("Showing the same view again changes nothing") func showingTheSameViewAgainChangesNothing() { let container = UIView() @@ -90,8 +90,8 @@ struct EmbeddedBlockLayerHostTests { #expect(container.constraints.count == 4) } - /// Снимать надо ту вью, что действительно висит: если её убрали снаружи, повторный показ обязан - /// вернуть её на место, а не решить, что она и так там. + /// The view to remove is the one that is actually attached: if it was detached from outside, + /// showing it again must put it back rather than decide it is already there. @Test("A view detached from outside is attached again") func viewDetachedFromOutsideIsAttachedAgain() { let container = UIView() diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift index ad9ec46a2..6262910de 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockMocks.swift @@ -15,3 +15,240 @@ extension EmbeddedBlockWebContent { static let other = EmbeddedBlockWebContent(url: URL(string: "https://mindbox.ru/another-block.html")!) } + +extension EmbeddedBlockPageAction { + + static let openUrlStub = EmbeddedBlockPageAction(type: "openUrl", payload: ["url": "https://mindbox.ru"]) +} + +/// A page without WebKit: tests decide what it tells the native side and when. +final class EmbeddedBlockPageMock: EmbeddedBlockPageHosting { + + let view = UIView() + + var onMessage: ((EmbeddedBlockPageMessage) -> Void)? + + var onLoadFailure: (() -> Void)? + + var onLoadFinish: (() -> Void)? + + var loadCount = 0 + var cancelCount = 0 + + func load() { + loadCount += 1 + } + + func cancel() { + cancelCount += 1 + } + + func send(_ message: EmbeddedBlockPageMessage) { + onMessage?(message) + } + + func failLoad() { + onLoadFailure?() + } + + func finishLoad() { + onLoadFinish?() + } +} + +final class EmbeddedBlockReadinessOverridesMock: EmbeddedBlockReadinessOverriding { + + var treatsLoadedPageAsReady: Bool + + init(treatsLoadedPageAsReady: Bool = false) { + self.treatsLoadedPageAsReady = treatsLoadedPageAsReady + } +} + +/// Counts how many pages were made and with what content: a reload must make a new one. +final class EmbeddedBlockPageFactoryMock { + + private(set) var pages: [EmbeddedBlockPageMock] = [] + private(set) var contents: [EmbeddedBlockWebContent] = [] + + var page: EmbeddedBlockPageMock? { pages.last } + + func make(_ content: EmbeddedBlockWebContent) -> EmbeddedBlockPageHosting { + contents.append(content) + let page = EmbeddedBlockPageMock() + pages.append(page) + return page + } +} + +final class EmbeddedBlockResolverMock: EmbeddedBlockResolving { + + var resolution: EmbeddedBlockResolution + + /// `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 + + private(set) var resolvedIds: [String] = [] + private(set) var forceRefreshHistory: [Bool] = [] + + var resolveCount: Int { resolvedIds.count } + + private var pending: [(EmbeddedBlockResolution) -> Void] = [] + + init(resolution: EmbeddedBlockResolution = .content(.stub)) { + self.resolution = resolution + } + + func resolve(_ id: String, forceRefresh: Bool, completion: @escaping (EmbeddedBlockResolution) -> Void) { + resolvedIds.append(id) + forceRefreshHistory.append(forceRefresh) + + if isDeferred { + pending.append(completion) + } else { + completion(resolution) + } + } + + func flush() { + let completions = pending + pending = [] + completions.forEach { $0(resolution) } + } +} + +final class EmbeddedBlockActionHandlerMock: EmbeddedBlockActionHandling { + + private(set) var handledActions: [EmbeddedBlockPageAction] = [] + + func handle(_ action: EmbeddedBlockPageAction) { + handledActions.append(action) + } +} + +/// A clock that moves only when asked to. Monotonic seconds, matching the timeout's clock seam; +/// a negative `advance` models the backward jump a monotonic clock never makes. +final class TestClock { + + private(set) var now: TimeInterval = 1_000_000 + + func advance(_ seconds: TimeInterval) { + now += seconds + } +} + +/// A scheduler that never fires on its own: "time is up" is declared by the test. +/// +/// Thanks to it the waiting budget is checked without a single sleep: both in its own tests and in +/// the tests of the container, which is handed the budget from outside. +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 var pending: [DispatchWorkItem] = [] + + func schedule(_ delay: TimeInterval, _ work: DispatchWorkItem) { + lastDelay = delay + pending.append(work) + } + + /// Performs the armed work, skipping what was cancelled: `pause()` and `reset()` cancel it + /// exactly the way they would cancel work on a real queue. + func fireAll() { + let scheduled = pending + pending = [] + scheduled.forEach { work in + guard !work.isCancelled else { return } + + work.perform() + } + } +} + +/// The waiting budget with a substituted clock, scheduler and notification center — everything +/// that makes it different from the real one, gathered in one place. +final class EmbeddedBlockTimeoutBed { + + let clock: TestClock + let scheduler: TestScheduler + + /// One per bed: the background and the return from it must reach only this budget. + let center: NotificationCenter + + let timeout: EmbeddedBlockReadyTimeout + + init(blockId: String = "block-id", duration: TimeInterval = 5) { + let clock = TestClock() + let scheduler = TestScheduler() + let center = NotificationCenter() + self.clock = clock + self.scheduler = scheduler + self.center = center + timeout = EmbeddedBlockReadyTimeout(blockId: blockId, + duration: duration, + now: { clock.now }, + notificationCenter: center, + schedule: { scheduler.schedule($0, $1) }) + } + + func enterBackground() { + center.post(name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + func enterForeground() { + center.post(name: UIApplication.willEnterForegroundNotification, object: nil) + } +} + +/// The provider with all dependencies substituted — the shared rig for the provider and container +/// tests. The container is tested through a real provider: the single seam inside the block is the +/// page, and there is nothing else to substitute. +final class EmbeddedBlockTestBed { + + let resolver: EmbeddedBlockResolverMock + let actionHandler: EmbeddedBlockActionHandlerMock + let readinessOverrides: EmbeddedBlockReadinessOverridesMock + let pageFactory: EmbeddedBlockPageFactoryMock + let provider: EmbeddedBlockWebViewProvider + + var page: EmbeddedBlockPageMock? { pageFactory.page } + + init(id: String = "block-id", + resolution: EmbeddedBlockResolution = .content(.stub), + treatsLoadedPageAsReady: Bool = false) { + let resolver = EmbeddedBlockResolverMock(resolution: resolution) + let actionHandler = EmbeddedBlockActionHandlerMock() + let readinessOverrides = EmbeddedBlockReadinessOverridesMock(treatsLoadedPageAsReady: treatsLoadedPageAsReady) + let pageFactory = EmbeddedBlockPageFactoryMock() + + self.resolver = resolver + self.actionHandler = actionHandler + self.readinessOverrides = readinessOverrides + self.pageFactory = pageFactory + self.provider = EmbeddedBlockWebViewProvider(id: id, + resolver: resolver, + actionHandler: actionHandler, + readinessOverrides: readinessOverrides, + makePage: { pageFactory.make($0) }) + } +} + +final class EmbeddedBlockViewDelegateMock: MindboxEmbeddedBlockViewDelegate { + + enum Event: Equatable { + case loaded + case failed + } + + private(set) var events: [Event] = [] + + func mindboxEmbeddedBlockViewDidLoad(_ blockView: MindboxEmbeddedBlockView) { + events.append(.loaded) + } + + func mindboxEmbeddedBlockViewDidFail(_ blockView: MindboxEmbeddedBlockView) { + events.append(.failed) + } +} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift new file mode 100644 index 000000000..b3884d84b --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockReadyTimeoutTests.swift @@ -0,0 +1,272 @@ +// +// EmbeddedBlockReadyTimeoutTests.swift +// MindboxTests +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import Foundation +import UIKit +@testable import Mindbox + +/// Neither the clock nor the scheduler here is real. How much of the budget is "already spent" the +/// tests dictate with the substituted clock, and "time is up" happens on their command. Nothing is +/// waited out in real time: the budget is arithmetic over what was spent, and checking it with a +/// stopwatch would mean paying half a second per test and flaking on a busy runner. +/// +/// Hence the main assertion of most tests — not "expired or not", but the delay the countdown was +/// armed with: that delay is the remainder of the budget. +/// +/// Going to the background and returning go through each bed's own notification center: on the +/// global one such a notification would reach the blocks of tests running next to it. +private let budget: TimeInterval = 0.4 + +@Suite("Embedded block ready timeout", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockReadyTimeoutTests { + + @Test("A budget that is never paused expires on its own") + func unpausedBudgetExpires() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + + #expect(bed.scheduler.lastDelay == budget) + + bed.scheduler.fireAll() + + #expect(bed.expirations == 1) + } + + /// While nobody is waiting for the block, the budget is not spent and does not expire. + @Test("A paused budget does not expire") + func pausedBudgetDoesNotExpire() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.timeout.pause() + bed.scheduler.fireAll() + + #expect(bed.expirations == 0) + #expect(bed.timeout.isRunning == false) + } + + /// The main point: a pause stops the count, it does not start it over. Almost everything is + /// spent, so after resuming the countdown is armed for the tiny remainder — not the full budget. + @Test("Resuming continues the remaining budget instead of granting a new one") + func resumeContinuesTheRemainder() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.clock.advance(budget - 0.02) + bed.timeout.pause() + + bed.timeout.armIfNeeded() + + #expect(isClose(bed.scheduler.lastDelay, to: 0.02)) + + bed.scheduler.fireAll() + + #expect(bed.expirations == 1) + } + + /// Exactly the scenario that makes a resetting pause unusable: a user flipping between apps + /// must not be able to stretch the block's wait indefinitely. + @Test("Repeated pause and resume cannot stretch the budget past its duration") + func repeatedPausesCannotStretchTheBudget() { + let bed = TimeoutBed() + + for _ in 0..<5 { + bed.timeout.armIfNeeded() + bed.clock.advance(budget / 4) + bed.timeout.pause() + } + + #expect(bed.expirations == 0) + + // Five stretches of a quarter each — the budget is spent in full, and the next arm does + // not give the block a single second more. + bed.timeout.armIfNeeded() + + #expect(bed.scheduler.lastDelay == 0) + + bed.scheduler.fireAll() + + #expect(bed.expirations == 1) + } + + /// A new attempt is another matter: it is waited for with the full budget. + @Test("Reset gives the next attempt a full budget again") + func resetGrantsAFullBudget() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.clock.advance(budget - 0.02) + bed.timeout.reset() + + bed.timeout.armIfNeeded() + + #expect(bed.scheduler.lastDelay == budget) + } + + @Test("Reset stops a running budget") + func resetStopsTheCountdown() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.timeout.reset() + bed.scheduler.fireAll() + + #expect(bed.expirations == 0) + } + + /// The budget is needed only while the outcome is unknown and the block is visible — the + /// container knows that, and its answer is asked on every arm. + @Test("A budget nobody needs is not armed at all") + func unneededBudgetIsNotArmed() { + let bed = TimeoutBed(isNeeded: false) + + bed.timeout.armIfNeeded() + + #expect(bed.timeout.isRunning == false) + #expect(bed.scheduler.lastDelay == nil) + + bed.scheduler.fireAll() + + #expect(bed.expirations == 0) + } + + // MARK: - Clock + + /// A clock jumping backwards must not shrink what was spent: the stretch counts as zero at + /// worst, and the block never waits past its budget. The real clock is monotonic and cannot + /// jump, so the guard matters for the seam and against regressions back to `Date`. + @Test("A clock that jumps backwards does not shrink the spent budget") + func backwardClockJumpDoesNotShrinkSpentBudget() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.clock.advance(-100) + bed.timeout.pause() + + bed.timeout.armIfNeeded() + + #expect(bed.scheduler.lastDelay == budget) + } + + // MARK: - Background + + /// While the app is in the background nobody waits for the block — so the budget must not be + /// spent either. + @Test("Going to the background pauses a running budget") + func backgroundPausesTheCountdown() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.enterBackground() + + #expect(bed.timeout.isRunning == false) + + bed.scheduler.fireAll() + + #expect(bed.expirations == 0) + } + + /// And exactly what the spent-time accounting exists for: returning from the background + /// continues the budget from the remainder instead of granting it anew. + @Test("Returning from the background continues the remaining budget") + func foregroundContinuesTheRemainder() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.clock.advance(budget - 0.02) + bed.enterBackground() + bed.enterForeground() + + #expect(isClose(bed.scheduler.lastDelay, to: 0.02)) + + bed.scheduler.fireAll() + + #expect(bed.expirations == 1) + } + + /// A background that finds the block outside a countdown cannot spend anything: the next + /// attempt gets the budget in full. + @Test("Going to the background outside a countdown consumes nothing") + func backgroundOutsideCountdownConsumesNothing() { + let bed = TimeoutBed() + + bed.enterBackground() + bed.clock.advance(budget) + + bed.timeout.armIfNeeded() + + #expect(bed.scheduler.lastDelay == budget) + } + + /// Returning from the background to a block nobody waits for does not resurrect the countdown: + /// whether it is needed is the container's call, asked on every arm. + @Test("Returning from the background does not arm a budget nobody needs") + func foregroundDoesNotArmAnUnneededBudget() { + let bed = TimeoutBed(isNeeded: false) + + bed.enterForeground() + + #expect(bed.timeout.isRunning == false) + #expect(bed.scheduler.lastDelay == nil) + } + + // MARK: - Arming + + /// Arming is idempotent: entering the window, returning from the background and reloading call + /// it in any order, and a second call must not start a second countdown. + @Test("Arming twice runs a single countdown") + func armingTwiceRunsOneCountdown() { + let bed = TimeoutBed() + + bed.timeout.armIfNeeded() + bed.timeout.armIfNeeded() + bed.scheduler.fireAll() + + // Had two countdowns been armed, there would be as many expirations. + #expect(bed.expirations == 1) + } +} + +/// The budget remainder is arithmetic over `Double`, so it is compared with a tolerance. +private func isClose(_ value: TimeInterval?, to expected: TimeInterval) -> Bool { + guard let value else { return false } + + return abs(value - expected) < 0.0001 +} + +/// The shared budget bed plus an expiration counter: here the budget is tested on its own, so +/// `isNeeded` is set by the test directly instead of being asked from the container. +@MainActor +private final class TimeoutBed { + + private let bed = EmbeddedBlockTimeoutBed(duration: budget) + + private(set) var expirations = 0 + + var timeout: EmbeddedBlockReadyTimeout { bed.timeout } + var clock: TestClock { bed.clock } + var scheduler: TestScheduler { bed.scheduler } + + init(isNeeded: Bool = true) { + bed.timeout.isNeeded = { isNeeded } + bed.timeout.onExpire = { [weak self] in + self?.expirations += 1 + } + } + + func enterBackground() { + bed.enterBackground() + } + + func enterForeground() { + bed.enterForeground() + } +} diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift index 3a5c8e32d..4cc2be4d1 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift @@ -208,7 +208,7 @@ private final class ContentLoaderSpy { pending.forEach { $0(resolution) } } - /// Отвечает с фоновой очереди — так ответит настоящий конфиг, разобранный не на главном потоке. + /// Answers from a background queue — how the real config, parsed off the main thread, will answer. func answerOffMain(_ resolution: EmbeddedBlockResolution) { let pending = completions completions = [] @@ -218,17 +218,17 @@ private final class ContentLoaderSpy { } } -/// На каком потоке резолвер отдал ответ. Отдельный тип вместо `Bool` — чтобы упавший тест сразу -/// говорил, что именно разъехалось. +/// Which thread the resolver delivered the answer on. A separate type instead of `Bool` — so that +/// a failing test says right away what exactly diverged. private enum DeliveryThread { case main case other } -/// Ждёт ответов резолвера и запоминает, на каком потоке каждый пришёл. +/// Waits for the resolver's answers and remembers which thread each one arrived on. /// -/// Читают и пишут его только с главного потока — если это перестанет быть правдой, тест как раз и -/// упадёт на `threads`. +/// It is read and written only from the main thread — if that stops being true, the test will fail +/// precisely on `threads`. private final class DeliveryRecorder { private(set) var answers: [EmbeddedBlockResolution] = [] @@ -246,8 +246,8 @@ private final class DeliveryRecorder { continuation.resume() } - /// Загрузку запускает сам ожидающий: начни её раньше — и ответ мог бы приехать до того, как - /// тест встал ждать, а ожидание повисло бы навсегда. + /// The waiter itself starts the load: start it any earlier — and the answer could arrive before + /// the test started waiting, and the wait would hang forever. func waitForAnswers(count: Int, _ startLoading: () -> Void) async { expectedCount = count await withCheckedContinuation { continuation in diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift new file mode 100644 index 000000000..29e7eb910 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift @@ -0,0 +1,556 @@ +// +// EmbeddedBlockWebViewProviderTests.swift +// MindboxTests +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import UIKit +@testable import Mindbox + +@Suite("Embedded block web view provider", .tags(.embeddedBlocks)) +@MainActor +struct EmbeddedBlockWebViewProviderTests { + + // MARK: - Loading + + @Test("Start resolves the id and loads the resolved content") + func startResolvesAndLoads() { + let bed = EmbeddedBlockTestBed(id: "promo") + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + + #expect(bed.resolver.resolvedIds == ["promo"]) + #expect(bed.pageFactory.contents == [.stub]) + #expect(bed.page?.loadCount == 1) + #expect(states == [.loading]) + // Before the page is ready there is no content: the container has nothing to show. + #expect(bed.provider.contentView == nil) + } + + @Test("Second start does not resolve or load again") + func secondStartDoesNothing() { + let bed = EmbeddedBlockTestBed() + + bed.provider.start() + bed.provider.start() + + #expect(bed.resolver.resolveCount == 1) + #expect(bed.page?.loadCount == 1) + } + + /// A block switched off in the admin panel or unknown to it is not an error: no page is even + /// created for it. + @Test("Empty resolution needs no page at all") + func emptyResolutionCreatesNoPage() { + let bed = EmbeddedBlockTestBed(resolution: .empty) + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + + #expect(states == [.loading, .empty]) + #expect(bed.pageFactory.pages.isEmpty) + #expect(bed.provider.contentView == nil) + } + + // MARK: - Readiness + + /// Only the page itself reports readiness — it is the single source of truth. + @Test("Page ready makes the content available") + func pageReadyMakesContentAvailable() { + let bed = EmbeddedBlockTestBed() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.send(.ready(height: 104)) + + #expect(states == [.loading, .ready]) + #expect(bed.provider.contentView === bed.page?.view) + } + + /// A silent page never becomes ready on its own: a loaded document says nothing about whether + /// the block has anything to show. Such a block will be finished off by the container's + /// timeout. + @Test("Silent page never becomes ready on its own") + func silentPageStaysLoading() { + let bed = EmbeddedBlockTestBed() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + + #expect(states == [.loading]) + #expect(bed.provider.contentView == nil) + } + + /// A page with no content is more honest saying `empty`, so a zero height is broken layout. + @Test("Zero height in ready is a failure") + func zeroHeightIsFailure() { + let bed = EmbeddedBlockTestBed() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.send(.ready(height: 0)) + + #expect(states.last == .failed) + #expect(bed.provider.contentView == nil) + } + + /// The host owns the height: the message exists in the contract, but it does not touch layout. + @Test("Height change leaves the state alone") + func heightChangeChangesNothing() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.send(.ready(height: 104)) + bed.page?.send(.heightChanged(height: 132)) + + #expect(states == [.ready]) + } + + @Test("Page empty collapses the block") + func pageEmptyCollapsesTheBlock() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.send(.ready(height: 104)) + bed.page?.send(.empty) + + #expect(states == [.ready, .empty]) + #expect(bed.provider.contentView == nil) + } + + // MARK: - Debug readiness + + /// The usual rule: a loaded document says nothing about whether the block has anything to show. + @Test("Loaded document alone does not make the block ready") + func loadFinishAloneChangesNothing() { + let bed = EmbeddedBlockTestBed() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.finishLoad() + + #expect(states == [.loading]) + #expect(bed.provider.contentView == nil) + } + + /// With the override on the block is shown on a loaded document — this is how UI is checked + /// while the page cannot yet send `ready`. + @Test("With the debug override a loaded document shows the block") + func loadFinishMakesBlockReadyWithOverride() { + let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.finishLoad() + + #expect(states == [.loading, .ready]) + #expect(bed.provider.contentView === bed.page?.view) + } + + /// A page that implements the contract behaves the same with the override as without it: + /// `ready` has already shown the block, and the document does not add a second showing. + @Test("A page that sent ready is not shown twice by the override") + func readyBeforeLoadFinishIsNotDuplicated() { + let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.send(.ready(height: 104)) + bed.page?.finishLoad() + + #expect(states == [.loading, .ready]) + } + + /// The override is not stronger than the page: its "nothing to show" collapses the block even + /// with the flag on. + @Test("The override does not swallow an empty from the page") + func overrideDoesNotSwallowEmpty() { + let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.finishLoad() + bed.page?.send(.empty) + + #expect(states == [.loading, .ready, .empty]) + #expect(bed.provider.contentView == nil) + } + + /// After `stop()` the provider stays silent entirely — the override does not change that. + @Test("Loaded document after a stop is ignored even with the override") + func loadFinishAfterStopIsIgnored() { + let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) + bed.provider.start() + bed.provider.stop() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.finishLoad() + + #expect(states.isEmpty) + #expect(bed.provider.contentView == nil) + } + + /// A page dropped by a reload must not show itself through the override either. + @Test("The dropped page cannot show itself through the override") + func droppedPageCannotFinishIntoTheNewAttempt() { + let bed = EmbeddedBlockTestBed(treatsLoadedPageAsReady: true) + bed.provider.start() + let firstPage = bed.page + bed.provider.reload() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + firstPage?.finishLoad() + + #expect(states.isEmpty) + #expect(bed.provider.contentView == nil) + } + + // MARK: - Load failure + + /// A load failure is the only thing navigation judges. + @Test("Load failure fails the block") + func loadFailureFailsTheBlock() { + let bed = EmbeddedBlockTestBed() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.page?.failLoad() + + #expect(states == [.loading, .failed]) + #expect(bed.provider.contentView == nil) + } + + @Test("Load failure after a stop is ignored") + func loadFailureAfterStopIsIgnored() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.provider.stop() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.page?.failLoad() + + #expect(states.isEmpty) + } + + // MARK: - Page actions + + /// The core does not know the page's dictionary: everything above the core layer goes to the + /// handler as is and does not touch the container's state. + @Test("Page action is routed to the handler and changes no state") + func actionIsRouted() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + let action = EmbeddedBlockPageAction(type: "openUrl", payload: ["url": "https://mindbox.ru"]) + bed.page?.send(.action(action)) + + #expect(bed.actionHandler.handledActions == [action]) + #expect(states.isEmpty) + } + + /// A stopped provider stays silent entirely — including not waking the action handler. + @Test("Actions after a stop do not reach the handler") + func actionsAfterStopAreIgnored() { + let bed = EmbeddedBlockTestBed() + + bed.provider.start() + bed.provider.stop() + bed.page?.send(.action(EmbeddedBlockPageAction(type: "openUrl", payload: [:]))) + + #expect(bed.actionHandler.handledActions.isEmpty) + } + + @Test("Action from a shown block is routed") + func actionFromShownBlockIsRouted() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + + bed.page?.send(.action(.openUrlStub)) + + #expect(bed.actionHandler.handledActions == [.openUrlStub]) + } + + /// A collapsed block does not kill the page — it stays alive and may still deliver what it + /// scheduled. But there is no user touch behind an invisible block, and `openUrl` would take + /// the user out of the app for no reason. + @Test("Actions from a block collapsed as empty do not reach the handler") + func actionsAfterEmptyAreIgnored() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + + bed.page?.send(.empty) + bed.page?.send(.action(.openUrlStub)) + + #expect(bed.actionHandler.handledActions.isEmpty) + } + + @Test("Actions from a failed block do not reach the handler") + func actionsAfterFailureAreIgnored() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + + bed.page?.failLoad() + bed.page?.send(.action(.openUrlStub)) + + #expect(bed.actionHandler.handledActions.isEmpty) + } + + /// Broken layout is the same as a block not shown: its actions are not executed either. + @Test("Actions from a block broken by a zero height do not reach the handler") + func actionsAfterZeroHeightAreIgnored() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + + bed.page?.send(.ready(height: 0)) + bed.page?.send(.action(.openUrlStub)) + + #expect(bed.actionHandler.handledActions.isEmpty) + } + + /// The ban rests on the attempt's outcome, not on the page: a new attempt is live again. + @Test("A new attempt after a failure accepts actions again") + func retryAfterFailureAcceptsActions() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.failLoad() + + bed.provider.stop() + bed.provider.start() + bed.page?.send(.action(.openUrlStub)) + + #expect(bed.actionHandler.handledActions == [.openUrlStub]) + } + + // MARK: - Stop and restart + + /// After `stop()` the provider must stay silent — the container relies on this when it + /// collapses expired content on its own timeout. + @Test("Stop cancels the page and ignores what it says afterwards") + func stopCancelsThePage() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.stop() + bed.page?.send(.ready(height: 104)) + + #expect(bed.page?.cancelCount == 1) + #expect(states.isEmpty) + #expect(bed.provider.contentView == nil) + } + + /// The container calls `start()` every time it returns to the window: there is no need to + /// recreate the web view and ask the resolver again on every return. + @Test("Restart reuses the same page without resolving again") + func restartReusesThePage() { + let bed = EmbeddedBlockTestBed() + + bed.provider.start() + bed.provider.stop() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + + #expect(bed.resolver.resolveCount == 1) + #expect(bed.pageFactory.pages.count == 1) + #expect(bed.page?.loadCount == 2) + #expect(bed.provider.contentView === bed.page?.view) + } + + /// The block left the screen already shown — on return it must not load again: the page + /// remained in memory, and it is shown as is. + @Test("Page rendered before the block left the window is shown again without a reload") + func renderedPageIsShownAgainWithoutReload() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + bed.provider.stop() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + + #expect(states == [.ready]) + #expect(bed.page?.loadCount == 1) + #expect(bed.resolver.resolveCount == 1) + #expect(bed.provider.contentView === bed.page?.view) + } + + /// But a block that failed to show gets a new attempt on return — this is the only retry the + /// block has for now. + @Test("Failed block tries again when it comes back") + func failedBlockTriesAgainOnReturn() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.failLoad() + bed.provider.stop() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + + #expect(states == [.loading]) + #expect(bed.page?.loadCount == 2) + } + + // MARK: - Live blocks + + /// The live-block counter is shared across the process, so each test uses its own id: + /// otherwise tests running in parallel would count each other's blocks. + @Test("Live count follows the life of a block") + func liveCountFollowsBlockLife() { + let id = "live-count-single" + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 0) + + do { + let provider = makeProvider(id: id) + withExtendedLifetime(provider) { + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 1) + } + } + + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 0) + } + + @Test("Blocks sharing an id are counted together") + func liveCountSumsBlocksOfTheSameId() { + let id = "live-count-shared" + + do { + let first = makeProvider(id: id) + let second = makeProvider(id: id) + withExtendedLifetime((first, second)) { + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 2) + } + } + + #expect(EmbeddedBlockWebViewProvider.liveCount(for: id) == 0) + } + + @Test("Blocks with different ids are counted apart") + func liveCountKeepsIdsApart() { + let promo = "live-count-promo" + let stories = "live-count-stories" + + let provider = makeProvider(id: promo) + withExtendedLifetime(provider) { + #expect(EmbeddedBlockWebViewProvider.liveCount(for: promo) == 1) + #expect(EmbeddedBlockWebViewProvider.liveCount(for: stories) == 0) + } + } + + private func makeProvider(id: String) -> EmbeddedBlockWebViewProvider { + EmbeddedBlockWebViewProvider(id: id, + resolver: EmbeddedBlockResolverMock(), + actionHandler: EmbeddedBlockActionHandlerMock(), + makePage: { _ in EmbeddedBlockPageMock() }) + } + + /// The resolve may have arrived after the stop — then it belongs to the previous attempt. + @Test("Resolution arriving after a stop creates nothing") + func lateResolutionAfterStopIsIgnored() { + let bed = EmbeddedBlockTestBed() + bed.resolver.isDeferred = true + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.provider.start() + bed.provider.stop() + bed.resolver.flush() + + #expect(bed.pageFactory.pages.isEmpty) + #expect(states == [.loading]) + } + + // MARK: - Reload + + @Test("Reload asks for the content again bypassing the cache and builds a new page") + func reloadRefetchesTheContent() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + let firstPage = bed.page + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + bed.resolver.resolution = .content(.other) + bed.provider.reload() + + #expect(bed.resolver.forceRefreshHistory == [false, true]) + #expect(bed.pageFactory.contents == [.stub, .other]) + #expect(bed.pageFactory.pages.count == 2) + #expect(bed.page !== firstPage) + #expect(firstPage?.cancelCount == 1) + #expect(states == [.loading]) + // Readiness starts from zero: the new page has said nothing yet. + #expect(bed.provider.contentView == nil) + } + + /// The old page is no longer relevant — its late messages must not show the dropped content. + @Test("The dropped page cannot report into the new attempt") + func droppedPageIsSilenced() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + let firstPage = bed.page + bed.provider.reload() + var states: [EmbeddedBlockState] = [] + bed.provider.onStateChange = { states.append($0) } + + firstPage?.send(.ready(height: 104)) + firstPage?.failLoad() + + #expect(states.isEmpty) + #expect(bed.provider.contentView == nil) + } + + /// The previous attempt's resolve must not replace the new page. + @Test("Resolution arriving after a reload does not add a second page") + func lateResolutionAfterReloadIsIgnored() { + let bed = EmbeddedBlockTestBed() + bed.resolver.isDeferred = true + bed.provider.start() + + bed.provider.reload() + bed.resolver.flush() + + #expect(bed.resolver.resolveCount == 2) + #expect(bed.pageFactory.pages.count == 1) + } + + @Test("Reloaded block becomes ready through the same path") + func reloadedBlockBecomesReady() { + let bed = EmbeddedBlockTestBed() + bed.provider.start() + bed.page?.send(.ready(height: 104)) + + bed.provider.reload() + bed.page?.send(.ready(height: 104)) + + #expect(bed.provider.contentView === bed.page?.view) + } +} diff --git a/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockTests.swift b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockTests.swift new file mode 100644 index 000000000..9367fbd9a --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockTests.swift @@ -0,0 +1,244 @@ +// +// MindboxEmbeddedBlockTests.swift +// MindboxTests +// +// Created by vailence on 10.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +#if canImport(SwiftUI) +import Testing +import SwiftUI +@testable import Mindbox + +/// The block logic lives in the UIKit container, so only what the SwiftUI wrapper owns is checked +/// here: the modifier contract and the way the wrapper sets the container up for the layers it +/// draws itself. The deferred presentation writes are covered by `EmbeddedBlockCoordinatorTests`. +/// +/// The suite is not marked `@available(iOS 13.0, *)` — the `@Suite`/`@Test` macros reject such +/// declarations. The test target builds for iOS 12, so each test opens SwiftUI availability for +/// itself with `guard #available`, and helpers carry the annotation. +@Suite("MindboxEmbeddedBlock SwiftUI wrapper", .tags(.embeddedBlocks)) +@MainActor +struct MindboxEmbeddedBlockTests { + + // MARK: - Modifiers + + /// The modifier contract rests on value semantics: a modifier must return a new block, not + /// mutate the one it was applied to, otherwise the same block reused in a layout with + /// different dressing would drag someone else's placeholder along. + @Test("Bare block has neither a placeholder nor an error view") + func bareBlockHasNoCustomViews() { + guard #available(iOS 13.0, *) else { return } + + let block = MindboxEmbeddedBlock(id: "stories", height: 104) + + #expect(block.placeholderBuilder == nil) + #expect(block.errorBuilder == nil) + } + + @Test("Placeholder modifier sets the placeholder and leaves the error view alone") + func placeholderModifierSetsOnlyThePlaceholder() { + guard #available(iOS 13.0, *) else { return } + + let block = MindboxEmbeddedBlock(id: "stories", height: 104) + .placeholder { Color.gray } + + #expect(block.placeholderBuilder != nil) + #expect(block.errorBuilder == nil) + } + + @Test("Error view modifier sets the error view and leaves the placeholder alone") + func errorViewModifierSetsOnlyTheErrorView() { + guard #available(iOS 13.0, *) else { return } + + let block = MindboxEmbeddedBlock(id: "stories", height: 104) + .errorView { Text("no stories") } + + #expect(block.errorBuilder != nil) + #expect(block.placeholderBuilder == nil) + } + + @Test("Both modifiers compose in either order") + func bothModifiersCompose() { + guard #available(iOS 13.0, *) else { return } + + let placeholderFirst = MindboxEmbeddedBlock(id: "stories", height: 104) + .placeholder { Color.gray } + .errorView { Text("no stories") } + + let errorFirst = MindboxEmbeddedBlock(id: "stories", height: 104) + .errorView { Text("no stories") } + .placeholder { Color.gray } + + #expect(placeholderFirst.placeholderBuilder != nil) + #expect(placeholderFirst.errorBuilder != nil) + #expect(errorFirst.placeholderBuilder != nil) + #expect(errorFirst.errorBuilder != nil) + } + + @Test("Modifier returns a copy and does not touch the block it was applied to") + func modifierDoesNotMutateTheOriginal() { + guard #available(iOS 13.0, *) else { return } + + let bare = MindboxEmbeddedBlock(id: "stories", height: 104) + + let decorated = bare + .placeholder { Color.gray } + .errorView { Text("no stories") } + + #expect(bare.placeholderBuilder == nil) + #expect(bare.errorBuilder == nil) + #expect(decorated.placeholderBuilder != nil) + #expect(decorated.errorBuilder != nil) + } + + @Test("Applying a modifier twice keeps the last view") + func repeatedModifierKeepsTheLastView() { + guard #available(iOS 13.0, *) else { return } + + let log = BuildLog() + + let block = MindboxEmbeddedBlock(id: "stories", height: 104) + .placeholder { ProbeView("first", log: log) } + .placeholder { ProbeView("second", log: log) } + + _ = block.placeholderBuilder?() + + #expect(log.tags == ["second"]) + } + + // MARK: - Host layers + + /// The wrapper draws its own placeholder itself, so the container gets a transparent stand-in: + /// otherwise the SDK shimmer would show through under the host's placeholder. + @Test("Custom placeholder replaces the SDK shimmer with a transparent stand-in") + func customPlaceholderReplacesTheShimmer() throws { + guard #available(iOS 13.0, *) else { return } + + let blockView = makeBlockView() + + makeRepresentable(hasPlaceholder: true).syncStandIns(in: blockView) + + let standIn = try #require(blockView.placeholderView) + #expect(standIn.superview === blockView) + #expect(blockView.subviews.contains { $0 is EmbeddedBlockShimmerView } == false) + // Touches go to the SwiftUI layer above, not to the stand-in below it. + #expect(standIn.isUserInteractionEnabled == false) + } + + @Test("Block without a custom placeholder keeps the SDK shimmer") + func bareBlockKeepsTheShimmer() { + guard #available(iOS 13.0, *) else { return } + + let blockView = makeBlockView() + + makeRepresentable().syncStandIns(in: blockView) + + #expect(blockView.placeholderView == nil) + #expect(blockView.subviews.contains { $0 is EmbeddedBlockShimmerView }) + } + + /// The container agrees to keep its height on a failure only by an assigned `errorView`, so a + /// stand-in is needed here too — otherwise the SwiftUI error screen would be drawn in a + /// collapsed block. + @Test("Custom error view opts the container into showing the failure") + func customErrorViewOptsIntoShowingTheFailure() { + guard #available(iOS 13.0, *) else { return } + + let blockView = makeBlockView() + + makeRepresentable(hasErrorView: true).syncStandIns(in: blockView) + + #expect(blockView.errorView != nil) + } + + @Test("Block without a custom error view leaves the container collapsing") + func bareBlockLeavesTheContainerCollapsing() { + guard #available(iOS 13.0, *) else { return } + + let blockView = makeBlockView() + + makeRepresentable().syncStandIns(in: blockView) + + #expect(blockView.errorView == nil) + } + + /// A modifier may be applied conditionally: a layer that appeared after the block was created + /// must reach the container, and one that disappeared must stop holding its space. + @Test("Layers added and dropped after creation take effect") + func layersAddedAndDroppedAfterCreationTakeEffect() { + guard #available(iOS 13.0, *) else { return } + + let blockView = makeBlockView() + makeRepresentable().syncStandIns(in: blockView) + + makeRepresentable(hasPlaceholder: true, hasErrorView: true).syncStandIns(in: blockView) + + #expect(blockView.placeholderView != nil) + #expect(blockView.errorView != nil) + + makeRepresentable().syncStandIns(in: blockView) + + #expect(blockView.placeholderView == nil) + #expect(blockView.errorView == nil) + } + + /// An update without changes must not cost the container a rebuild of layers and constraints: + /// `updateUIView` is called on every pass of the host's body. + @Test("Repeated updates keep the very same stand-ins") + func repeatedUpdatesKeepTheSameStandIns() throws { + guard #available(iOS 13.0, *) else { return } + + let blockView = makeBlockView() + let representable = makeRepresentable(hasPlaceholder: true, hasErrorView: true) + representable.syncStandIns(in: blockView) + let placeholder = try #require(blockView.placeholderView) + let errorView = try #require(blockView.errorView) + + representable.syncStandIns(in: blockView) + + #expect(blockView.placeholderView === placeholder) + #expect(blockView.errorView === errorView) + } + + // MARK: - Helpers + + /// A container with substituted dependencies: the wrapper loads nothing itself, its job is to + /// set the container up correctly, so no window or live content is needed here. + private func makeBlockView() -> MindboxEmbeddedBlockView { + MindboxEmbeddedBlockView(id: "stories", + height: 104, + contentProvider: EmbeddedBlockTestBed().provider) + } + + @available(iOS 13.0, *) + private func makeRepresentable(hasPlaceholder: Bool = false, + hasErrorView: Bool = false) -> EmbeddedBlockRepresentable { + let presentation = EmbeddedBlockPresentation(layer: .placeholder, height: 104) + return EmbeddedBlockRepresentable(id: "stories", + height: 104, + presentation: .constant(presentation), + onLoad: nil, + onFail: nil, + hasPlaceholder: hasPlaceholder, + hasErrorView: hasErrorView) + } +} + +/// Which views the block actually built. `AnyView` cannot be looked into from outside, so the view +/// itself leaves the mark at the moment it is created. +private final class BuildLog { + var tags: [String] = [] +} + +@available(iOS 13.0, *) +private struct ProbeView: View { + + init(_ tag: String, log: BuildLog) { + log.tags.append(tag) + } + + var body: some View { Color.clear } +} +#endif diff --git a/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift new file mode 100644 index 000000000..7eb03ac69 --- /dev/null +++ b/MindboxTests/EmbeddedBlocks/MindboxEmbeddedBlockViewTests.swift @@ -0,0 +1,736 @@ +// +// MindboxEmbeddedBlockViewTests.swift +// MindboxTests +// +// Created by vailence on 03.08.2026. +// Copyright © 2026 Mindbox. All rights reserved. +// + +import Testing +import UIKit +@testable import Mindbox + +@Suite("MindboxEmbeddedBlockView container", .tags(.embeddedBlocks)) +@MainActor +struct MindboxEmbeddedBlockViewTests { + + // MARK: - Height + + /// The block's space is claimed right away: the host set the height, and it does not change + /// before the load outcome — otherwise the container would jump in the host's layout. + @Test("Loading block keeps the height given at creation") + func loadingKeepsGivenHeight() { + let block = BlockFixture() + + #expect(block.view.intrinsicContentSize.height == 120) + // Width is the host's business, the container does not declare it. + #expect(block.view.intrinsicContentSize.width == UIView.noIntrinsicMetric) + } + + @Test("Shown block keeps the same height") + func shownBlockKeepsHeight() { + let block = BlockFixture() + block.attachToWindow() + + block.page?.send(.ready(height: 96)) + + #expect(block.view.intrinsicContentSize.height == 120) + } + + /// The container is the only source of height, so a host laying out by frames must get the + /// same number through the entry point it actually uses. + @Test("sizeThatFits reports the same height as intrinsicContentSize") + func sizeThatFitsMatchesIntrinsicHeight() { + let block = BlockFixture(height: 96) + + let fitted = block.view.sizeThatFits(CGSize(width: 320, height: CGFloat.greatestFiniteMagnitude)) + + #expect(fitted.height == 96) + #expect(fitted.width == 320) + } + + @Test("Failed block collapses the container") + func failedBlockCollapses() { + let block = BlockFixture() + block.attachToWindow() + + block.page?.failLoad() + + #expect(block.view.intrinsicContentSize.height == 0) + } + + /// The failure can be shown instead of collapsing — then the block stays the same height. + @Test("Failed block with an error view keeps its height") + func failedBlockWithErrorViewKeepsHeight() { + let block = BlockFixture() + let errorView = UIView() + block.view.errorView = errorView + block.attachToWindow() + + block.page?.failLoad() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(errorView.superview === block.view) + } + + /// The host has already reclaimed the collapsed block's space — reopening it after the fact + /// would jerk the layout. A late `errorView` is only remembered. + @Test("Error view assigned after the collapse does not expand the block") + func lateErrorViewDoesNotExpandCollapsedBlock() { + let block = BlockFixture() + block.attachToWindow() + block.page?.failLoad() + + let errorView = UIView() + block.view.errorView = errorView + + #expect(block.view.intrinsicContentSize.height == 0) + #expect(errorView.superview == nil) + } + + /// A remembered `errorView` takes effect on the next load: a new attempt, a failure again — + /// and now the block shows the error instead of collapsing. + @Test("Error view assigned after the collapse applies on the next load") + func lateErrorViewAppliesOnNextLoad() { + let block = BlockFixture() + block.attachToWindow() + block.page?.failLoad() + let errorView = UIView() + block.view.errorView = errorView + + block.view.reload() + block.page?.failLoad() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(errorView.superview === block.view) + } + + /// An empty block always collapses: there is nothing to show, and there was no failure. + @Test("Empty block collapses even with an error view set") + func emptyBlockAlwaysCollapses() { + let block = BlockFixture() + block.view.errorView = UIView() + block.attachToWindow() + + block.page?.send(.empty) + + #expect(block.view.intrinsicContentSize.height == 0) + } + + /// A host that asked for a negative height must not get an unsatisfiable set of constraints. + @Test("Negative height given by the host is clamped to zero") + func negativeHeightIsClamped() { + let block = BlockFixture(height: -50) + + #expect(block.view.intrinsicContentSize.height == 0) + } + + // MARK: - Content view + + @Test("Shown content is attached and pinned to the container") + func shownContentIsPinned() throws { + let block = BlockFixture() + block.attachToWindow() + + block.page?.send(.ready(height: 96)) + + let content = try #require(block.page?.view) + #expect(content.superview === block.view) + #expect(content.translatesAutoresizingMaskIntoConstraints == false) + // Four edges: the content always fills the container it was given. + #expect(block.view.constraints.count == 4) + } + + @Test("Failed content is detached") + func failedContentIsDetached() throws { + let block = BlockFixture() + block.attachToWindow() + + block.page?.send(.ready(height: 96)) + let content = try #require(block.page?.view) + block.page?.failLoad() + + #expect(content.superview == nil) + #expect(block.view.intrinsicContentSize.height == 0) + } + + @Test("Empty content is detached") + func emptyContentIsDetached() throws { + let block = BlockFixture() + block.attachToWindow() + + block.page?.send(.ready(height: 96)) + let content = try #require(block.page?.view) + block.page?.send(.empty) + + #expect(content.superview == nil) + } + + /// A reloaded block must not drag the dropped page's view into the new attempt. + @Test("Reload detaches the content of the dropped page") + func reloadDetachesOldContent() throws { + let block = BlockFixture() + block.attachToWindow() + block.page?.send(.ready(height: 96)) + let oldContent = try #require(block.page?.view) + + block.view.reload() + + #expect(oldContent.superview == nil) + } + + // MARK: - Events + + /// There are two public outcomes — shown and not shown; loading is not an outcome, and the + /// host does not hear about it. + @Test("Loading is silent: the delegate hears only outcomes") + func loadingReportsNothing() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.attachToWindow() + await mainQueueTurn() + + #expect(delegate.events.isEmpty) + } + + /// A block that never entered a window loads nothing — and has nothing to report. + @Test("Block outside a window reports nothing and loads nothing") + func blockOutsideWindowDoesNothing() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + await mainQueueTurn() + + #expect(delegate.events.isEmpty) + #expect(block.bed.resolver.resolveCount == 0) + } + + @Test("Shown block reports didLoad") + func shownBlockReportsDidLoad() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + + #expect(delegate.events == [.loaded]) + } + + @Test("Failed block reports didFail") + func failedBlockReportsDidFail() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + + block.page?.failLoad() + await mainQueueTurn() + + #expect(delegate.events == [.failed]) + } + + /// "Empty" is the same not-shown outcome for the host as a failure: it has no event of its own. + @Test("Empty block reports didFail") + func emptyBlockReportsDidFail() async { + let block = BlockFixture(resolution: .empty) + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.attachToWindow() + await mainQueueTurn() + + #expect(delegate.events == [.failed]) + } + + /// A host assigning the delegate in `viewDidLoad` would otherwise miss an outcome that already + /// happened. + @Test("Delegate assigned after the outcome still receives it") + func lateDelegateStillReceivesOutcome() async { + let block = BlockFixture() + block.attachToWindow() + block.page?.failLoad() + await mainQueueTurn() + + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + await mainQueueTurn() + + #expect(delegate.events == [.failed]) + } + + /// A host routinely reassigns the delegate on every reused cell. It must not be handed an + /// outcome already heard: on the outcome it rebuilds the layout, and rebuilding the layout + /// reassigns the delegate again — the block would spin in a loop while scrolling. + @Test("Reassigning the same delegate does not repeat the outcome") + func sameDelegateReassignedHearsTheOutcomeOnce() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + block.view.delegate = delegate + await mainQueueTurn() + block.view.delegate = delegate + await mainQueueTurn() + + #expect(delegate.events == [.failed]) + } + + /// A different delegate, however, is a different subscriber, and it must hear an outcome that + /// already happened. + @Test("A delegate replacing another one still receives the outcome") + func replacingDelegateReceivesTheOutcome() async { + let block = BlockFixture() + let first = EmbeddedBlockViewDelegateMock() + block.view.delegate = first + block.attachToWindow() + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + let second = EmbeddedBlockViewDelegateMock() + block.view.delegate = second + await mainQueueTurn() + + #expect(first.events == [.failed]) + #expect(second.events == [.failed]) + } + + /// Content may fail again on returning to the window — that is no reason to turn the outcome + /// into a stream of identical events. + @Test("Repeated failure is reported once") + func repeatedFailureIsReportedOnce() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + + block.page?.failLoad() + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + #expect(delegate.events == [.failed]) + } + + @Test("Block that fails after being shown reports both outcomes in order") + func failureAfterLoadReportsBothOutcomes() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + #expect(delegate.events == [.loaded, .failed]) + } + + // MARK: - Presentation for the SwiftUI wrapper + + /// SwiftUI does not read `intrinsicContentSize` on the representable view and draws the host's + /// layers itself, so the wrapper needs both the height and the layer — and what it receives + /// must match what the container actually shows. + @Test("Every change is pushed to the SwiftUI wrapper as a layer and a height") + func presentationChangesArePushedToWrapper() { + let block = BlockFixture() + block.attachToWindow() + var reported: [EmbeddedBlockPresentation] = [] + block.view.onPresentationChange = { reported.append($0) } + + block.page?.send(.ready(height: 96)) + block.page?.send(.empty) + + #expect(reported == [EmbeddedBlockPresentation(layer: .content, height: 120), + EmbeddedBlockPresentation(layer: .nothing, height: 0)]) + } + + /// A failure without an error screen is, for the wrapper, the same collapsed block as an empty + /// one: there is nothing to draw. + @Test("Failed block without an error view reports nothing to show") + func failedBlockReportsNothingToShow() { + let block = BlockFixture() + block.attachToWindow() + var reported: [EmbeddedBlockPresentation] = [] + block.view.onPresentationChange = { reported.append($0) } + + block.page?.failLoad() + + #expect(reported == [EmbeddedBlockPresentation(layer: .nothing, height: 0)]) + } + + /// The container sees the agreement to show an error screen by the assigned `errorView` — and + /// only then asks the wrapper to draw its layer. + @Test("Failed block with an error view reports the error layer") + func failedBlockWithErrorViewReportsErrorLayer() { + let block = BlockFixture() + block.view.errorView = UIView() + block.attachToWindow() + var reported: [EmbeddedBlockPresentation] = [] + block.view.onPresentationChange = { reported.append($0) } + + block.page?.failLoad() + + #expect(reported == [EmbeddedBlockPresentation(layer: .errorView, height: 120)]) + } + + /// A reload returns the block to loading — the wrapper must show the placeholder again. + @Test("Reload reports the placeholder layer again") + func reloadReportsPlaceholderLayer() { + let block = BlockFixture() + block.attachToWindow() + block.page?.send(.ready(height: 96)) + var reported: [EmbeddedBlockPresentation] = [] + block.view.onPresentationChange = { reported.append($0) } + + block.view.reload() + + #expect(reported == [EmbeddedBlockPresentation(layer: .placeholder, height: 120)]) + } + + // MARK: - Lifecycle + + /// The host never starts or stops content by hand: the only trigger is the window. + @Test("Entering and leaving a window starts and stops the content") + func windowMembershipDrivesTheContent() { + let block = BlockFixture() + + #expect(block.bed.resolver.resolveCount == 0) + + block.attachToWindow() + #expect(block.page?.loadCount == 1) + #expect(block.page?.cancelCount == 0) + + block.removeFromWindow() + #expect(block.page?.cancelCount == 1) + } + + /// The block scrolls across the screen in a feed and survives switching tabs: every such pass + /// must not cost a reload, a flash of the shimmer, or repeated events to the host. + @Test("Block returning to the window keeps its content as it was") + func returningBlockKeepsItsContent() async throws { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + let content = try #require(block.page?.view) + + block.removeFromWindow() + block.attachToWindow() + await mainQueueTurn() + + #expect(block.page?.loadCount == 1) + #expect(content.superview === block.view) + #expect(block.view.subviews.contains { $0 is EmbeddedBlockShimmerView } == false) + #expect(block.view.intrinsicContentSize.height == 120) + #expect(delegate.events == [.loaded]) + } + + /// A block that failed to show tries again on returning to the window — but the attempt does + /// not reclaim the space the host already took back. Otherwise a collapsed block would jerk + /// the layout to its height and flash the shimmer on every pass across the screen, showing + /// nothing in the end. + @Test("Collapsed block stays collapsed while it tries again") + func collapsedBlockDoesNotReExpandWhileRetrying() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + block.removeFromWindow() + block.attachToWindow() + await mainQueueTurn() + + // The attempt is genuinely new — the page loads again... + #expect(block.page?.loadCount == 2) + // ...but the container does not claim space for it and does not flash the shimmer. + #expect(block.view.intrinsicContentSize.height == 0) + #expect(block.view.subviews.isEmpty) + #expect(delegate.events == [.failed]) + } + + /// Only shown content expands the block — and then the height comes back, and the host hears + /// that the block has finally appeared. + @Test("Retry that succeeds gives the block its height back") + func successfulRetryExpandsTheBlock() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + block.page?.failLoad() + await mainQueueTurn() + + block.removeFromWindow() + block.attachToWindow() + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(delegate.events == [.failed, .loaded]) + } + + /// A reload is the host's explicit consent to a full cycle again, so it claims space: the + /// block shows the placeholder again even if it was collapsed before the reload. + @Test("Reload after a collapse shows the placeholder again") + func reloadAfterCollapseShowsThePlaceholder() { + let block = BlockFixture() + block.attachToWindow() + block.page?.failLoad() + + block.view.reload() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(block.view.subviews.contains { $0 is EmbeddedBlockShimmerView }) + } + + /// An empty block collapses the same way — and just as much does not reclaim its space back. + @Test("Empty block stays collapsed when it returns to the window") + func emptyBlockStaysCollapsedOnReturn() async { + let block = BlockFixture(resolution: .empty) + block.attachToWindow() + await mainQueueTurn() + + block.removeFromWindow() + block.attachToWindow() + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 0) + #expect(block.view.subviews.isEmpty) + } + + // MARK: - Timeout + + /// The container, not the content, guarantees the host's layout will not wait forever: a + /// silent page past its budget collapses and reports a failure. + @Test("Silent block times out, collapses and reports didFail") + func silentBlockTimesOut() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.attachToWindow() + block.expireTimeout() + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 0) + #expect(delegate.events == [.failed]) + // Content is stopped, so it can no longer revive the expired block. + #expect(block.page?.cancelCount == 1) + } + + @Test("Block shown in time is not failed by the timeout") + func shownBlockIsNotTimedOut() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.attachToWindow() + block.page?.send(.ready(height: 96)) + // A shown block has disarmed the budget, so a declared "time is up" no longer concerns it. + block.expireTimeout() + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(delegate.events == [.loaded]) + #expect(block.page?.cancelCount == 0) + } + + /// Leaving the window has already stopped the content — a disarmed timeout must not fail + /// something that is not running. + @Test("Leaving the window disarms the timeout") + func leavingWindowDisarmsTimeout() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.attachToWindow() + block.removeFromWindow() + block.expireTimeout() + await mainQueueTurn() + + #expect(delegate.events.isEmpty) + #expect(block.view.intrinsicContentSize.height == 120) + } + + /// The loading budget counts the user's waiting time, not calendar time: nobody waits for the + /// block in the background, and collapsing it there makes no sense — otherwise the user would + /// come back to a block that gave up without ever being on screen. That the budget then + /// continues from the remainder instead of being granted anew is checked by the tests of + /// `EmbeddedBlockReadyTimeout` itself. + @Test("Timeout pauses in the background and resumes on return") + func timeoutPausesInBackground() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + + block.enterBackground() + block.expireTimeout() + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 120) + #expect(delegate.events.isEmpty) + + block.enterForeground() + block.expireTimeout() + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 0) + #expect(delegate.events == [.failed]) + } + + /// A block outside a window loads nothing, so it needs no budget either. + @Test("Returning from the background does not arm a timeout outside a window") + func foregroundOutsideWindowArmsNothing() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + + block.enterForeground() + block.expireTimeout() + await mainQueueTurn() + + // The countdown did not just fail to fire — it was never armed at all. + #expect(block.timeoutBed.scheduler.lastDelay == nil) + #expect(delegate.events.isEmpty) + #expect(block.view.intrinsicContentSize.height == 120) + } + + // MARK: - Reload + + /// A reload takes the same path as the first start: the block returns to loading, and the + /// host hears the new outcome in full, even if it matches the previous one. + @Test("Reload restarts the block and reports the outcome again") + func reloadRestartsTheBlock() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + await mainQueueTurn() + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + + block.view.reload() + await mainQueueTurn() + block.page?.send(.ready(height: 96)) + await mainQueueTurn() + + #expect(block.bed.resolver.forceRefreshHistory == [false, true]) + #expect(delegate.events == [.loaded, .loaded]) + #expect(block.view.intrinsicContentSize.height == 120) + } + + /// Content lives only while the block is in a window: there is nothing to reload on an + /// invisible block. + @Test("Reload outside a window does nothing") + func reloadOutsideWindowDoesNothing() { + let block = BlockFixture() + + block.view.reload() + + #expect(block.bed.resolver.resolveCount == 0) + #expect(block.bed.pageFactory.pages.isEmpty) + } + + /// A new attempt also gets a new budget — otherwise a reloaded block would hang loading + /// forever. + @Test("Reload arms the timeout again") + func reloadArmsTheTimeoutAgain() async { + let block = BlockFixture() + let delegate = EmbeddedBlockViewDelegateMock() + block.view.delegate = delegate + block.attachToWindow() + block.page?.send(.ready(height: 96)) + + block.view.reload() + block.expireTimeout() + await mainQueueTurn() + + #expect(block.view.intrinsicContentSize.height == 0) + #expect(delegate.events.last == .failed) + } + + // MARK: - Helpers + + /// Outcomes are delivered on the next turn of the main queue, so a block queued after them + /// continues only once they have run — the queue is serial and FIFO. + private func mainQueueTurn() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } + } +} + +/// A block with every dependency substituted and a live window: the window must outlive the test, +/// otherwise the view would fly out of the window mid-check and the content would stop on its own. +@MainActor +private final class BlockFixture { + + let bed: EmbeddedBlockTestBed + + /// The budget is handed to the view from outside, so "time is up" here happens on the test's + /// command rather than through a sleep: `expireTimeout()`. + let timeoutBed: EmbeddedBlockTimeoutBed + + let view: MindboxEmbeddedBlockView + + private let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + + var page: EmbeddedBlockPageMock? { bed.page } + + init(height: CGFloat = 120, + resolution: EmbeddedBlockResolution = .content(.stub)) { + let bed = EmbeddedBlockTestBed(resolution: resolution) + let timeoutBed = EmbeddedBlockTimeoutBed() + self.bed = bed + self.timeoutBed = timeoutBed + self.view = MindboxEmbeddedBlockView(id: "block-id", + height: height, + contentProvider: bed.provider, + timeout: timeoutBed.timeout) + } + + func attachToWindow() { + window.addSubview(view) + } + + func removeFromWindow() { + view.removeFromSuperview() + } + + /// Declares that the waiting budget has run out. + func expireTimeout() { + timeoutBed.scheduler.fireAll() + } + + func enterBackground() { + timeoutBed.enterBackground() + } + + func enterForeground() { + timeoutBed.enterForeground() + } +} From 751451bef23e2f10bf95364db01f389e60b3736a Mon Sep 17 00:00:00 2001 From: Akylbek Utekeshev Date: Wed, 12 Aug 2026 20:07:38 +0500 Subject: [PATCH 46/47] Update Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift Co-authored-by: Sergei Semko <28645140+justSmK@users.noreply.github.com> --- .../MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift index b6479c624..e8942ea74 100644 --- a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift @@ -261,6 +261,18 @@ struct EmbeddedBlockRepresentable: UIViewRepresentable { } } + /// The height is fixed when the block is created: a new value given to a live block is + /// ignored, and the host has no other way to notice — the block simply keeps standing at + /// its old height. + func warnIfHeightIsIgnored(_ newHeight: CGFloat, id: String) { + guard !hasWarnedAboutIgnoredHeight, newHeight != creationHeight else { return } + + hasWarnedAboutIgnoredHeight = true + Logger.common(message: "[EmbeddedBlock] Block '\(id)' was given height \(newHeight) after creation and keeps \(creationHeight): the height is fixed when the block is created.", + level: .error, + category: .embeddedBlocks) + } + /// The view left the tree: silences the write `update` has already queued — nulling the /// callbacks in `dismantleUIView` cannot recall it, and `weak self` is no guarantee: when /// SwiftUI releases the coordinator after dismantling is unspecified. From 621fd8e3adfd9452b6c468be09d1a934d33185fc Mon Sep 17 00:00:00 2001 From: Akylbek Utekeshev Date: Wed, 12 Aug 2026 20:07:55 +0500 Subject: [PATCH 47/47] Update Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift Co-authored-by: Sergei Semko <28645140+justSmK@users.noreply.github.com> --- .../Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift index e8942ea74..887ef6298 100644 --- a/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift +++ b/Mindbox/EmbeddedBlocks/Public/MindboxEmbeddedBlock/MindboxEmbeddedBlock.swift @@ -192,6 +192,7 @@ struct EmbeddedBlockRepresentable: UIViewRepresentable { coordinator.presentation = $presentation coordinator.onLoad = onLoad coordinator.onFail = onFail + coordinator.warnIfHeightIsIgnored(height, id: id) syncStandIns(in: uiView) }