From 9758078ddb13f6e182c2e397f8c3aa40dfa49aa7 Mon Sep 17 00:00:00 2001 From: Vailence Date: Mon, 10 Aug 2026 17:00:48 +0500 Subject: [PATCH 1/8] 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 00000000..db25eba4 --- /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 2/8] 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 00000000..9989486d --- /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 3/8] 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 56067b1e..2a3edae2 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 4/8] 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 00000000..78c47a87 --- /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 5/8] 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 00000000..cf81cefc --- /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 6/8] 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 db25eba4..0e4e4aae 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 d7b9665c16ee429e23a6aa4c04c244c017bff0a3 Mon Sep 17 00:00:00 2001 From: Vailence Date: Tue, 11 Aug 2026 18:41:57 +0500 Subject: [PATCH 7/8] MOBILE-323 PR Fix --- .../Resolver/EmbeddedBlockResolver.swift | 32 ++++- .../EmbeddedBlockWebViewProvider.swift | 5 +- .../EmbeddedBlockResolverTests.swift | 109 ++++++++++++++++++ 3 files changed, 136 insertions(+), 10 deletions(-) diff --git a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift index 202e9689..efcc1b4d 100644 --- a/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift +++ b/Mindbox/EmbeddedBlocks/Resolver/EmbeddedBlockResolver.swift @@ -22,8 +22,7 @@ enum EmbeddedBlockResolution: Equatable { /// Отвечает на единственный вопрос: что показывает блок с данным id. /// /// Резолвер — общая точка всех контейнеров: несколько блоков с одним id разрешаются одними -/// данными, при этом вью, страница и состояние у каждого блока остаются своими. Работает на -/// главном потоке; completion может прийти как синхронно (кэш), так и позже (сетевой конфиг). +/// данными, при этом вью, страница и состояние у каждого блока остаются своими. protocol EmbeddedBlockResolving: AnyObject { /// - Parameter forceRefresh: `true` — не брать кэш, спросить данные заново. Нужно перезагрузке @@ -42,6 +41,7 @@ extension EmbeddedBlockResolving { /// /// Сейчас это заглушка со статической страницей. Когда появится конфиг из админки, здесь окажется /// настоящая загрузка, а кэш и очередь ожидающих в резолвере не изменятся. + typealias EmbeddedBlockContentLoading = (String, @escaping (EmbeddedBlockResolution) -> Void) -> Void final class EmbeddedBlockResolver: EmbeddedBlockResolving { @@ -67,6 +67,16 @@ final class EmbeddedBlockResolver: EmbeddedBlockResolving { } func resolve(_ id: String, forceRefresh: Bool, completion: @escaping (EmbeddedBlockResolution) -> Void) { + 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 + } + // Отладочная подмена сильнее и данных, и кэша: приёмка переключает сценарий на ходу, и // закэшированный ответ мешал бы этому. if let overridden = overrides.resolution(for: id) { @@ -89,11 +99,21 @@ final class EmbeddedBlockResolver: EmbeddedBlockResolving { waiting[id] = [completion] load(id) { [weak self] resolution in - guard let self else { return } + EmbeddedBlockResolver.onMain { + guard let self else { return } + + self.cache[id] = resolution + let completions = self.waiting.removeValue(forKey: id) ?? [] + completions.forEach { $0(resolution) } + } + } + } - self.cache[id] = resolution - let completions = self.waiting.removeValue(forKey: id) ?? [] - completions.forEach { $0(resolution) } + private static func onMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) } } diff --git a/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift b/Mindbox/EmbeddedBlocks/WebView/EmbeddedBlockWebViewProvider.swift index 0e4e4aae..d5a92cad 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 a67c0bfd..22a4dcbd 100644 --- a/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift +++ b/MindboxTests/EmbeddedBlocks/EmbeddedBlockResolverTests.swift @@ -79,6 +79,67 @@ struct EmbeddedBlockResolverTests { #expect(cached == .empty) } + // MARK: - Threading + + /// Настоящий конфиг ответит с очереди, на которой его разбирали, а за completion стоят создание + /// вебвью и правка вёрстки контейнера. Возвращаться на главный поток обязан сам резолвер: иначе + /// это пришлось бы помнить каждому вызывающему, а забытый хоп проявился бы порчей вёрстки. + @Test("An answer from a background queue reaches every waiting block on the main thread") + func backgroundAnswerIsDeliveredOnMain() async { + let loader = ContentLoaderSpy() + let resolver = EmbeddedBlockResolver(load: loader.load) + let delivery = DeliveryRecorder() + + resolver.resolve("promo") { delivery.record($0) } + resolver.resolve("promo") { delivery.record($0) } + + await delivery.waitForAnswers(count: 2) { + loader.answerOffMain(.content(.stub)) + } + + #expect(delivery.answers == [.content(.stub), .content(.stub)]) + #expect(delivery.threads == [.main, .main]) + } + + /// Кэш заполняется в том же прыжке, что и доставка, поэтому пришедший с фона ответ виден + /// следующим блокам как обычное попадание в кэш — синхронно и без второй загрузки. + @Test("An answer from a background queue lands in the cache as usual") + func backgroundAnswerIsCached() async { + let loader = ContentLoaderSpy() + let resolver = EmbeddedBlockResolver(load: loader.load) + let delivery = DeliveryRecorder() + + resolver.resolve("promo") { delivery.record($0) } + await delivery.waitForAnswers(count: 1) { + loader.answerOffMain(.content(.stub)) + } + + var cached: EmbeddedBlockResolution? + resolver.resolve("promo") { cached = $0 } + + #expect(cached == .content(.stub)) + #expect(loader.requestedIds == ["promo"]) + } + + /// Спрашивать резолвер положено с главного потока, но чужой вызов с другого не должен ни ронять + /// хост, ни утаскивать кэш и доставку на постороннюю очередь. + @Test("A resolve asked off the main thread still answers on it") + func offMainResolveAnswersOnMain() async { + // Вызов со стороны — то, что тест и проверяет, поэтому проверку Sendable для этих двух + // ссылок снимаем вручную: после прыжка обе снова трогаются только с главного потока. + nonisolated(unsafe) let resolver = EmbeddedBlockResolver(load: { _, completion in completion(.content(.stub)) }) + nonisolated(unsafe) let delivery = DeliveryRecorder() + + await delivery.waitForAnswers(count: 1) { + DispatchQueue.global().async { + resolver.resolve("promo") { delivery.record($0) } + } + } + + #expect(delivery.answers == [.content(.stub)]) + #expect(delivery.threads == [.main]) + } + // MARK: - Debug overrides /// Приёмка переключает сценарий на ходу, поэтому подмена сильнее и загрузки, и кэша. @@ -179,4 +240,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 e28f69a8e33bf46dcd882d2f78443485cd39d4a5 Mon Sep 17 00:00:00 2001 From: Vailence Date: Wed, 12 Aug 2026 13:52:47 +0500 Subject: [PATCH 8/8] MOBILE-323 Remove unused tests --- ...ddedBlockContentProviderFactoryTests.swift | 76 --- .../EmbeddedBlockWebViewProviderTests.swift | 555 ------------------ 2 files changed, 631 deletions(-) delete mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift delete mode 100644 MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift diff --git a/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockContentProviderFactoryTests.swift deleted file mode 100644 index cf81cefc..00000000 --- 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/EmbeddedBlockWebViewProviderTests.swift b/MindboxTests/EmbeddedBlocks/EmbeddedBlockWebViewProviderTests.swift deleted file mode 100644 index 78c47a87..00000000 --- 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) - } -}