From 888e91d1251714e1e3aa8a504a6b60170d4c6b16 Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Fri, 11 Sep 2026 11:44:49 +0530 Subject: [PATCH] fix: let host-app touches through when the survey has no overlay [ENG-1818] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A survey configured with `overlay: none` painted nothing over the host app but still took every touch, so the app appeared frozen until the survey closed. The renderer already sets `pointer-events: none` outside the card, but that is a web hit test: `WKWebView` hit-tests its whole rectangle and UIKit never sees it, and the survey was presented as a full-screen view controller on top. `overlay: none` now gets its own `PassthroughWindow`, masked to the card's rect as the renderer reports it over `onCardRectChange`. Declining a touch there lets UIKit carry on to the host app's window underneath, which a presented view controller cannot do — UIKit's transition container answers for the whole screen even when the content declines. `light` and `dark` keep the existing modal presentation: a visible backdrop is meant to block the host app. No native overlay is painted. That stayed the renderer's job in #39 and still is; this changes hit testing only. Three states, not two, because "no rect has ever arrived" and "the card is not on screen" need different answers. An older self-hosted server serves a renderer that never calls `onCardRectChange`, and the SDK has to keep taking every touch exactly as it used to. Treating that as "claim nothing" is what made the Flutter SDK's survey card untappable when its DOM probe stopped matching. The window is shown rather than made key: taking key status would pull the caret out of whatever the host app has focused, which is the complaint this fixes. UIKit promotes it once the user taps into the survey, so text input still works. --- .../Manager/PresentSurveyManager.swift | 154 ++++++++++++++---- .../FormbricksSDK/Manager/SurveyManager.swift | 22 ++- .../Model/Javascript/CardRectMessage.swift | 29 ++++ .../Model/Javascript/EventType.swift | 3 + .../WebView/FormbricksView.swift | 7 +- .../WebView/FormbricksViewModel.swift | 12 ++ .../WebView/SurveyTouchRegion.swift | 76 +++++++++ .../FormbricksSDK/WebView/SurveyWebView.swift | 15 +- .../SurveyTouchRegionTests.swift | 101 ++++++++++++ 9 files changed, 377 insertions(+), 42 deletions(-) create mode 100644 Sources/FormbricksSDK/Model/Javascript/CardRectMessage.swift create mode 100644 Sources/FormbricksSDK/WebView/SurveyTouchRegion.swift create mode 100644 Tests/FormbricksSDKTests/SurveyTouchRegionTests.swift diff --git a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift index 327da99..8a6670a 100644 --- a/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/PresentSurveyManager.swift @@ -12,6 +12,10 @@ final class PresentSurveyManager { /// The view controller that will present the survey window. private weak var viewController: UIViewController? + /// Held strongly: a window with no other owner is released and the survey disappears. Only used + /// for the no-overlay path, where the survey cannot be a presented view controller. + private var passthroughWindow: PassthroughWindow? + /// Walks the active presentation/navigation/tab hierarchy and returns the leaf VC. /// Mirrors UIKit's own `presentedViewController` traversal so a single walker is enough. private func topMostViewController(from viewController: UIViewController) -> UIViewController { @@ -33,53 +37,131 @@ final class PresentSurveyManager { return viewController } - /// Present the webview as a page sheet over the current top-most view controller. + /// Shows the survey. + /// + /// A `light` or `dark` overlay is presented as a modal over the top-most view controller: the + /// backdrop is meant to block the host app, so a full-screen barrier is correct. `overlay: none` + /// cannot work that way — the survey is a corner card over a page the user is still using, and a + /// presented view controller answers the hit test for the whole screen even when its content + /// declines the touch. That case gets its own window instead, which can decline a touch and let + /// UIKit carry on to the host app's window underneath. func present( - workspaceResponse: WorkspaceResponse, id: String, completion: ((Bool) -> Void)? = nil + workspaceResponse: WorkspaceResponse, id: String, overlay: SurveyOverlay = .none, + completion: ((Bool) -> Void)? = nil ) { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - - guard let window = UIApplication.safeKeyWindow, - let rootVC = window.rootViewController - else { - Formbricks.logger?.error( - "Survey present aborted: no key window or root view controller available.") - completion?(false) - return + if overlay == .none { + self.presentPassthrough( + workspaceResponse: workspaceResponse, id: id, completion: completion) + } else { + self.presentModal( + workspaceResponse: workspaceResponse, id: id, completion: completion) } + } + } - let presenter = self.topMostViewController(from: rootVC) - - // UIAlertController/action-sheets/popovers cannot host a modal sheet — presenting on them either - // crops the survey to the alert frame or is rejected by UIKit. Bail with a clear log so the host - // app can dismiss the alert before triggering the survey. - if presenter is UIAlertController { - Formbricks.logger?.warning( - "Survey present aborted: top-most VC is a UIAlertController. Dismiss it before triggering the survey." - ) - completion?(false) - return - } + /// The no-overlay path: its own window, masked to the card's rect. + private func presentPassthrough( + workspaceResponse: WorkspaceResponse, id: String, completion: ((Bool) -> Void)? + ) { + guard let scene = UIApplication.safeKeyWindow?.windowScene else { + Formbricks.logger?.error( + "Survey present aborted: no window scene available.") + completion?(false) + return + } + + let relay = SurveyLayoutRelay() + let view = FormbricksView( + viewModel: FormbricksViewModel(workspaceResponse: workspaceResponse, surveyId: id), + layoutRelay: relay) + let hosting = UIHostingController(rootView: view) + hosting.view.backgroundColor = .clear + + let window = PassthroughWindow(windowScene: scene) + window.rootViewController = hosting + window.backgroundColor = .clear + window.isOpaque = false + // Above the app's own windows but below system UI like alerts and the status bar. + window.windowLevel = .normal + 1 - let view = FormbricksView( - viewModel: FormbricksViewModel(workspaceResponse: workspaceResponse, surveyId: id)) - let vc = UIHostingController(rootView: view) - vc.modalPresentationStyle = .overFullScreen - vc.modalTransitionStyle = .crossDissolve - vc.view.backgroundColor = .clear - self.viewController = vc - presenter.present( - vc, animated: true, - completion: { - completion?(true) - }) + // The reported rect is relative to the WebView's viewport. The WebView fills the hosting + // controller, which fills this window, which covers the screen — and `ignoresSafeArea()` + // means no inset shifts the origin — so viewport points and window points are the same + // coordinate space and the rect needs no translation. + relay.onCardRectChange = { [weak window] rect in + window?.touchRegion = SurveyTouchRegion.forReported(rect: rect) } + + // Deliberately `isHidden`, not `makeKeyAndVisible()`. Taking key status would pull the + // caret out of whatever the host app has focused — a survey appearing mid-form must not do + // that, which is the entire complaint this fixes. UIKit promotes this window to key on its + // own once the user actually taps into the survey, so text input still works. + window.isHidden = false + + self.passthroughWindow = window + self.viewController = hosting + completion?(true) } - /// Dismiss the webview + /// The overlay path, unchanged: a modal over the top-most view controller. + private func presentModal( + workspaceResponse: WorkspaceResponse, id: String, completion: ((Bool) -> Void)? + ) { + guard let window = UIApplication.safeKeyWindow, + let rootVC = window.rootViewController + else { + Formbricks.logger?.error( + "Survey present aborted: no key window or root view controller available.") + completion?(false) + return + } + + let presenter = self.topMostViewController(from: rootVC) + + // UIAlertController/action-sheets/popovers cannot host a modal sheet — presenting on them either + // crops the survey to the alert frame or is rejected by UIKit. Bail with a clear log so the host + // app can dismiss the alert before triggering the survey. + if presenter is UIAlertController { + Formbricks.logger?.warning( + "Survey present aborted: top-most VC is a UIAlertController. Dismiss it before triggering the survey." + ) + completion?(false) + return + } + + let view = FormbricksView( + viewModel: FormbricksViewModel(workspaceResponse: workspaceResponse, surveyId: id)) + let vc = UIHostingController(rootView: view) + vc.modalPresentationStyle = .overFullScreen + vc.modalTransitionStyle = .crossDissolve + vc.view.backgroundColor = .clear + self.viewController = vc + presenter.present( + vc, animated: true, + completion: { + completion?(true) + }) + } + + /// Dismiss the webview, whichever way it was shown. func dismissView() { - viewController?.dismiss(animated: true) + let tearDown = { [weak self] in + guard let self = self else { return } + self.viewController?.dismiss(animated: true) + // Drop the window as well, or a no-overlay survey leaves an invisible one over the app. + // Clearing `rootViewController` first releases the hosting controller and the WebView. + self.passthroughWindow?.isHidden = true + self.passthroughWindow?.rootViewController = nil + self.passthroughWindow = nil + } + + if Thread.isMainThread { + tearDown() + } else { + DispatchQueue.main.async(execute: tearDown) + } } deinit { diff --git a/Sources/FormbricksSDK/Manager/SurveyManager.swift b/Sources/FormbricksSDK/Manager/SurveyManager.swift index e0965f4..66b8c4c 100644 --- a/Sources/FormbricksSDK/Manager/SurveyManager.swift +++ b/Sources/FormbricksSDK/Manager/SurveyManager.swift @@ -129,7 +129,7 @@ final class SurveyManager { DispatchQueue.global().asyncAfter(deadline: .now() + Double(timeout)) { [weak self] in guard let self = self else { return } if let workspaceResponse = self.workspaceResponse { - self.presentSurveyManager.present(workspaceResponse: workspaceResponse, id: survey.id) { success in + self.presentSurveyManager.present(workspaceResponse: workspaceResponse, id: survey.id, overlay: self.resolveOverlay(for: survey)) { success in if !success { self.isShowingSurvey = false } @@ -208,7 +208,9 @@ private extension SurveyManager { /// The view controller is presented over the current context. func showSurvey(withId id: String) { if let workspaceResponse = workspaceResponse { - presentSurveyManager.present(workspaceResponse: workspaceResponse, id: id) + let survey = workspaceResponse.data.data.surveys?.first(where: { $0.id == id }) + presentSurveyManager.present( + workspaceResponse: workspaceResponse, id: id, overlay: resolveOverlay(for: survey)) } } @@ -385,6 +387,22 @@ extension SurveyManager { return entry.language.code } + /// The overlay this survey will actually render with. + /// + /// Deliberately the same precedence as the WebView payload builds + /// (`FormbricksViewModel.WebViewData`): survey override, then workspace setting, then `none`. + /// The two have to agree — the payload decides what the renderer paints, this decides whether + /// the native side blocks touches, and a mismatch means either a backdrop you can tap through + /// or a corner card that freezes the app. + /// + /// Note `none` is the default, so most workspaces take the pass-through path. + func resolveOverlay(for survey: Survey?) -> SurveyOverlay { + if let surveyOverlay = survey?.projectOverwrites?.overlay { + return surveyOverlay + } + return workspaceResponse?.data.data.settings.overlay ?? .none + } + /// Filters the surveys based on the user's segments. func filterSurveysBasedOnSegments(_ surveys: [Survey], segments: [String]) -> [Survey] { return surveys.filter { survey in diff --git a/Sources/FormbricksSDK/Model/Javascript/CardRectMessage.swift b/Sources/FormbricksSDK/Model/Javascript/CardRectMessage.swift new file mode 100644 index 0000000..6a82918 --- /dev/null +++ b/Sources/FormbricksSDK/Model/Javascript/CardRectMessage.swift @@ -0,0 +1,29 @@ +import CoreGraphics +import Foundation + +/// Where the survey card is, as the shared renderer measures it. +/// +/// A `WKWebView` hit-tests its whole rectangle and ignores the `pointer-events: none` the renderer +/// puts outside the card, so a full-screen WebView swallows every touch even when nothing is +/// painted. To let touches through, the native side has to mask them itself — and only the web +/// layer knows where the card is, because CSS decides that. +/// +/// Values are CSS pixels relative to the viewport. The WebView's viewport is pinned at +/// `initial-scale=1.0, maximum-scale=1.0` (see `FormbricksViewModel.htmlTemplate`), so one CSS +/// pixel is one point and the rect needs no conversion. +struct CardRect: Codable { + let x: Double + let y: Double + let width: Double + let height: Double + + var cgRect: CGRect { + CGRect(x: x, y: y, width: width, height: height) + } +} + +/// `onCardRectChange` payload. `rect` is absent or null when no card is on screen — while it +/// animates out, or before the first paint. +struct CardRectMessage: Codable { + let rect: CardRect? +} diff --git a/Sources/FormbricksSDK/Model/Javascript/EventType.swift b/Sources/FormbricksSDK/Model/Javascript/EventType.swift index ed49412..588fef1 100644 --- a/Sources/FormbricksSDK/Model/Javascript/EventType.swift +++ b/Sources/FormbricksSDK/Model/Javascript/EventType.swift @@ -5,4 +5,7 @@ enum EventType: String, Codable { case onFinished = "onFinished" case onOpenExternalURL = "onOpenExternalURL" case onSurveyLibraryLoadError = "onSurveyLibraryLoadError" + /// The survey card moved or resized. Carries the card's rect so the native side can let + /// touches outside it reach the host app — see `CardRect`. + case onCardRectChange = "onCardRectChange" } diff --git a/Sources/FormbricksSDK/WebView/FormbricksView.swift b/Sources/FormbricksSDK/WebView/FormbricksView.swift index f38c8c1..6dd6cfe 100644 --- a/Sources/FormbricksSDK/WebView/FormbricksView.swift +++ b/Sources/FormbricksSDK/WebView/FormbricksView.swift @@ -3,10 +3,13 @@ import SwiftUI /// SwiftUI view for the Formbricks survey webview. struct FormbricksView: View { @ObservedObject var viewModel: FormbricksViewModel - + /// Present only for a no-overlay survey, which needs the card's rect to decide which touches + /// reach the host app. Nil for an overlaid survey, and then nothing is measured or reported. + var layoutRelay: SurveyLayoutRelay? + var body: some View { if let htmlString = viewModel.htmlString { - SurveyWebView(surveyId: viewModel.surveyId, htmlString: htmlString) + SurveyWebView(surveyId: viewModel.surveyId, htmlString: htmlString, layoutRelay: layoutRelay) .ignoresSafeArea() } } diff --git a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift index d01e646..7fa428c 100644 --- a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift +++ b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift @@ -66,6 +66,17 @@ private extension FormbricksViewModel { window.webkit.messageHandlers.jsMessage.postMessage(JSON.stringify({ event: "onOpenExternalURL", onOpenExternalURLParams: { url: url } })); }; + // Where the survey card is. The native side cannot work this out for itself — CSS + // decides it inside the page — and it needs it to pass touches outside the card + // through to the host app. `rect` is null when no card is on screen. + // + // Only the renderer shipped with Formbricks 6.0+ calls this. Against an older + // self-hosted server it simply never fires, and the native side keeps its previous + // behaviour of taking every touch. + function onCardRectChange(rect) { + window.webkit.messageHandlers.jsMessage.postMessage(JSON.stringify({ event: "onCardRectChange", rect: rect })); + }; + let setResponseFinished = null; function getSetIsResponseSendingFinished(callback) { setResponseFinished = callback; @@ -81,6 +92,7 @@ private extension FormbricksViewModel { onFinished, onClose, onOpenExternalURL, + onCardRectChange, }; window.formbricksSurveys.renderSurvey(surveyProps); } diff --git a/Sources/FormbricksSDK/WebView/SurveyTouchRegion.swift b/Sources/FormbricksSDK/WebView/SurveyTouchRegion.swift new file mode 100644 index 0000000..7005fa9 --- /dev/null +++ b/Sources/FormbricksSDK/WebView/SurveyTouchRegion.swift @@ -0,0 +1,76 @@ +import UIKit + +/// Which touches over the survey's full-screen WebView belong to the survey, and which should fall +/// through to the host app underneath. +/// +/// A `WKWebView` hit-tests its entire rectangle. The shared renderer already sets +/// `pointer-events: none` outside the card, but that is a *web* hit test — UIKit never sees it, so a +/// transparent full-screen WebView still swallows every touch and the host app appears frozen. +enum SurveyTouchRegion: Equatable { + /// Every touch belongs to the survey. + /// + /// Correct for a `light` or `dark` overlay, where a visible backdrop is *supposed* to block the + /// host app. Also the starting state for a no-overlay survey, and it stays that way if the + /// renderer never reports a rect — an older self-hosted server serves a bundle without + /// `onCardRectChange`, and behaving exactly as the SDK always did is the safe answer there. + case everything + + /// Only touches inside this rect belong to the survey; everything else reaches the host app. + /// The rect is in window points, which the reported CSS-pixel rect maps onto 1:1 (see `CardRect`). + case card(CGRect) + + /// Nothing belongs to the survey, because no card is on screen. + /// + /// The renderer reports this while the card animates out, and the card is hidden for a full + /// second before `onClose` arrives. Without this state the SDK leaves a dead patch over a host + /// app that looks perfectly usable. + case nothing + + /// Whether a touch at `point` (in window coordinates) belongs to the survey. + func accepts(_ point: CGPoint) -> Bool { + switch self { + case .everything: + return true + case .card(let rect): + return rect.contains(point) + case .nothing: + return false + } + } + + /// Maps a rect reported by the renderer onto a region. A missing rect means the card is not on + /// screen — deliberately *not* "block everything", which is the trap the Flutter SDK fell into + /// when its DOM probe stopped matching: a null rect there meant the survey itself became + /// untappable. Absence of a card and absence of the feature are different things, and only the + /// latter keeps `everything`. + static func forReported(rect: CardRect?) -> SurveyTouchRegion { + guard let rect = rect else { return .nothing } + return .card(rect.cgRect) + } +} + +/// Hosts a no-overlay survey in its own window so touches outside the card reach the host app. +/// +/// Returning `nil` from `hitTest` makes UIKit continue to the next window down, which is the host +/// app's. A presented view controller cannot do this reliably: UIKit's own transition container +/// answers the hit test for the whole screen even when the content declines it. +final class PassthroughWindow: UIWindow { + /// Starts at `.everything`, so the SDK blocks touches exactly as it used to until the renderer + /// tells us where the card is. + var touchRegion: SurveyTouchRegion = .everything + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard touchRegion.accepts(point) else { return nil } + return super.hitTest(point, with: event) + } +} + +/// Carries card rects from the JS bridge to whoever is doing the hit testing. +/// +/// A plain box rather than an `ObservableObject`: nothing here drives SwiftUI, and re-rendering the +/// WebView on every frame of the card's open animation is the opposite of what we want. +final class SurveyLayoutRelay { + /// Called on the main thread each time the renderer reports the card's rect, `nil` when no card + /// is on screen. + var onCardRectChange: ((CardRect?) -> Void)? +} diff --git a/Sources/FormbricksSDK/WebView/SurveyWebView.swift b/Sources/FormbricksSDK/WebView/SurveyWebView.swift index 7892686..40af5ec 100644 --- a/Sources/FormbricksSDK/WebView/SurveyWebView.swift +++ b/Sources/FormbricksSDK/WebView/SurveyWebView.swift @@ -7,6 +7,8 @@ import SafariServices struct SurveyWebView: UIViewRepresentable { let surveyId: String let htmlString: String + /// Only set for a no-overlay survey — see `FormbricksView`. + var layoutRelay: SurveyLayoutRelay? /// Assemble the WKWebView with the necessary configuration. public func makeUIView(context: Context) -> WKWebView { @@ -15,7 +17,7 @@ struct SurveyWebView: UIViewRepresentable { // Add javascript message handlers let userContentController = WKUserContentController() userContentController.add(LoggingMessageHandler(), name: "logging") - userContentController.add(JsMessageHandler(surveyId: surveyId), name: "jsMessage") + userContentController.add(JsMessageHandler(surveyId: surveyId, layoutRelay: layoutRelay), name: "jsMessage") userContentController.addUserScript(WKUserScript(source: overrideConsole, injectionTime: .atDocumentStart, forMainFrameOnly: true)) let webViewConfig = WKWebViewConfiguration() @@ -129,6 +131,8 @@ extension SurveyWebView { final class JsMessageHandler: NSObject, WKScriptMessageHandler { let surveyId: String + /// Where card rects go. Nil for an overlaid survey, whose backdrop blocks the host app anyway. + private let layoutRelay: SurveyLayoutRelay? /// Interaction sources already refreshed during this presentation. One handler is created /// per WebView, so this is scoped to a single survey showing. The surveys library guards @@ -137,8 +141,9 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler { /// refresh is gated; the existing displays/responses bookkeeping keeps its behaviour. private var refreshedSources: Set = [] - init(surveyId: String) { + init(surveyId: String, layoutRelay: SurveyLayoutRelay? = nil) { self.surveyId = surveyId + self.layoutRelay = layoutRelay } /// Whether an external URL from survey content is safe to hand to the OS. @@ -204,6 +209,12 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler { /// Happens when the survey library fails to load. case .onSurveyLibraryLoadError: Formbricks.surveyManager?.dismissSurveyWebView() + + /// Happens whenever the survey card moves or resizes, and once more with no rect when it + /// leaves the screen. Only a no-overlay survey acts on it; see `SurveyTouchRegion`. + case .onCardRectChange: + let rect = (try? JSONDecoder().decode(CardRectMessage.self, from: data))?.rect + layoutRelay?.onCardRectChange?(rect) } } else { diff --git a/Tests/FormbricksSDKTests/SurveyTouchRegionTests.swift b/Tests/FormbricksSDKTests/SurveyTouchRegionTests.swift new file mode 100644 index 0000000..82312f8 --- /dev/null +++ b/Tests/FormbricksSDKTests/SurveyTouchRegionTests.swift @@ -0,0 +1,101 @@ +import XCTest +@testable import FormbricksSDK + +/// A transparent full-screen WebView still swallows every touch — `pointer-events: none` is a web +/// hit test that UIKit never sees. These pin which touches the survey claims in each overlay mode, +/// because getting it wrong is invisible in code review and obvious to a user: either the host app +/// freezes, or the survey itself stops responding. +final class SurveyTouchRegionTests: XCTestCase { + + private let card = CGRect(x: 0, y: 600, width: 390, height: 240) + private var insideCard: CGPoint { CGPoint(x: 195, y: 700) } + private var outsideCard: CGPoint { CGPoint(x: 195, y: 200) } + + // MARK: - overlay: light / dark + + /// A visible backdrop is meant to block the host app, so the survey claims the whole screen. + func testAnOverlaidSurveyClaimsEveryTouch() { + let region = SurveyTouchRegion.everything + + XCTAssertTrue(region.accepts(insideCard)) + XCTAssertTrue(region.accepts(outsideCard)) + XCTAssertTrue(region.accepts(.zero)) + } + + // MARK: - overlay: none + + /// The card takes its own touches; everything else falls through to the host app. + func testANoOverlaySurveyClaimsOnlyTheCard() { + let region = SurveyTouchRegion.forReported( + rect: CardRect(x: 0, y: 600, width: 390, height: 240)) + + XCTAssertEqual(region, .card(card)) + XCTAssertTrue(region.accepts(insideCard), "The survey must stay usable") + XCTAssertFalse(region.accepts(outsideCard), "The host app must stay usable") + } + + /// The renderer reports the card's absence, and the SDK must stop claiming touches at that + /// point. The card is hidden for a full second before `onClose` arrives, so without this the + /// SDK leaves a dead patch over a host app that looks perfectly usable. + func testNoCardOnScreenClaimsNothing() { + let region = SurveyTouchRegion.forReported(rect: nil) + + XCTAssertEqual(region, .nothing) + XCTAssertFalse(region.accepts(insideCard)) + XCTAssertFalse(region.accepts(outsideCard)) + } + + /// An older self-hosted server serves a renderer that never calls `onCardRectChange`, so no + /// rect ever arrives. The SDK has to keep behaving exactly as it used to rather than guess. + /// + /// This is the distinction the Flutter SDK got wrong: there, a missing rect meant "claim + /// nothing", so when its DOM probe stopped matching, the survey became untappable. Absence of a + /// card and absence of the feature are different things. + func testTheDefaultBeforeAnyRectArrivesBlocksLikeBefore() { + let window = PassthroughWindow(frame: .zero) + + XCTAssertEqual(window.touchRegion, .everything) + XCTAssertTrue(window.touchRegion.accepts(outsideCard)) + } + + // MARK: - Boundaries + + /// `CGRect.contains` excludes the far edges, so a tap on the card's bottom-right corner pixel + /// belongs to the host app. Pinned because a future switch to `insetBy` or a rounding change + /// would move this silently. + func testCardEdgesFollowCGRectContains() { + let region = SurveyTouchRegion.card(card) + + XCTAssertTrue(region.accepts(CGPoint(x: card.minX, y: card.minY)), "Top-left is inside") + XCTAssertFalse(region.accepts(CGPoint(x: card.maxX, y: card.maxY)), "Bottom-right is not") + XCTAssertFalse(region.accepts(CGPoint(x: card.minX - 1, y: card.minY))) + } + + /// A zero-area rect would claim nothing anyway, but the renderer already reports absence rather + /// than a degenerate rect — assert the mapping keeps its shape if that ever changes. + func testAZeroAreaCardClaimsNothing() { + let region = SurveyTouchRegion.forReported(rect: CardRect(x: 10, y: 10, width: 0, height: 0)) + + XCTAssertFalse(region.accepts(CGPoint(x: 10, y: 10))) + } + + // MARK: - Decoding the bridge payload + + /// The rect arrives as JSON over the JS bridge, so the decode is part of the contract. + func testDecodesAReportedRect() throws { + let json = #"{"event":"onCardRectChange","rect":{"x":12.5,"y":600,"width":390,"height":240.25}}"# + let message = try JSONDecoder().decode(CardRectMessage.self, from: Data(json.utf8)) + + XCTAssertEqual(message.rect?.cgRect, CGRect(x: 12.5, y: 600, width: 390, height: 240.25)) + } + + /// `rect: null` is how the renderer says the card has gone. It must decode, not throw — a throw + /// would leave the SDK masking touches to a card that is no longer there. + func testDecodesAnAbsentRect() throws { + let json = #"{"event":"onCardRectChange","rect":null}"# + let message = try JSONDecoder().decode(CardRectMessage.self, from: Data(json.utf8)) + + XCTAssertNil(message.rect) + XCTAssertEqual(SurveyTouchRegion.forReported(rect: message.rect), .nothing) + } +}