From 8984913adc773a58dc0c032770296c1bea741db7 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Mon, 30 Dec 2024 14:19:08 -0600 Subject: [PATCH] [Paywalls V2] Added fallback components (#4621) * Added fallback components * Fixed a test * Added swiftui preview for fallback * Way more better testing --- RevenueCat.xcodeproj/project.pbxproj | 8 + .../FallbackComponentPreview.swift | 161 +++++++++++++++ .../Common/PaywallComponentBase.swift | 65 +++++- .../Components/ButtonComponentTests.swift | 43 +++- .../Components/FallbackComponentTests.swift | 187 ++++++++++++++++++ .../Components/PartialComponentTests.swift | 12 +- 6 files changed, 454 insertions(+), 22 deletions(-) create mode 100644 RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/FallbackComponentPreview.swift create mode 100644 Tests/UnitTests/Paywalls/Components/FallbackComponentTests.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index f7dc0ac191..283a106afa 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ 0313FD41268A506400168386 /* DateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313FD40268A506400168386 /* DateProvider.swift */; }; + 03A98CEF2D1EE048009BCA61 /* FallbackComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A98CEE2D1EE040009BCA61 /* FallbackComponentTests.swift */; }; + 03A98CF12D222F5F009BCA61 /* FallbackComponentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A98CF02D222F53009BCA61 /* FallbackComponentPreview.swift */; }; 1E2F910B2CC8FE5600BDB016 /* ContactSupportUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E2F910A2CC8FE5600BDB016 /* ContactSupportUtilities.swift */; }; 1E2F911B2CC918ED00BDB016 /* ContactSupportUtilitiesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E2F911A2CC918ED00BDB016 /* ContactSupportUtilitiesTests.swift */; }; 1E2F91722CCFA98C00BDB016 /* WebRedemptionStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E2F91712CCFA98C00BDB016 /* WebRedemptionStrings.swift */; }; @@ -1237,6 +1239,8 @@ /* Begin PBXFileReference section */ 0313FD40268A506400168386 /* DateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateProvider.swift; sourceTree = ""; }; + 03A98CEE2D1EE040009BCA61 /* FallbackComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackComponentTests.swift; sourceTree = ""; }; + 03A98CF02D222F53009BCA61 /* FallbackComponentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackComponentPreview.swift; sourceTree = ""; }; 1E2F910A2CC8FE5600BDB016 /* ContactSupportUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSupportUtilities.swift; sourceTree = ""; }; 1E2F911A2CC918ED00BDB016 /* ContactSupportUtilitiesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSupportUtilitiesTests.swift; sourceTree = ""; }; 1E2F91712CCFA98C00BDB016 /* WebRedemptionStrings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRedemptionStrings.swift; sourceTree = ""; }; @@ -2470,6 +2474,7 @@ 2C2AEB0D2CA64DA900A50F38 /* TemplateComponentsViewPreviews */ = { isa = PBXGroup; children = ( + 03A98CF02D222F53009BCA61 /* FallbackComponentPreview.swift */, 2C2AEB0E2CA64E0E00A50F38 /* Template1Preview.swift */, 2C8EC6DA2CCC23B700D6CCF8 /* Template5Preview.swift */, ); @@ -2540,6 +2545,7 @@ 2C8EC6AD2CCBD33800D6CCF8 /* Components */ = { isa = PBXGroup; children = ( + 03A98CEE2D1EE040009BCA61 /* FallbackComponentTests.swift */, 2C08B2E82CD40DBF0024857B /* ButtonComponentTests.swift */, 2C8EC6DE2CCD27A100D6CCF8 /* PartialComponentTests.swift */, ); @@ -6300,6 +6306,7 @@ 2C8EC6DF2CCD27A500D6CCF8 /* PartialComponentTests.swift in Sources */, 351B51A326D450BC00BD2BD7 /* DictionaryExtensionsTests.swift in Sources */, 4FB2B5512AA7DBA40087EDB5 /* MockFileHandler.swift in Sources */, + 03A98CEF2D1EE048009BCA61 /* FallbackComponentTests.swift in Sources */, 573A10D92800ADCD00F976E5 /* StoreKitErrorTests.swift in Sources */, 5766AABF283E80B500FA6091 /* BasePurchasesTests.swift in Sources */, 351B515826D44B3E00BD2BD7 /* MockOfferingsFactory.swift in Sources */, @@ -6553,6 +6560,7 @@ 2C7457482CEA66AB004ACE52 /* ComponentsView.swift in Sources */, 353756722C382C2800A1B8D6 /* URLUtilities.swift in Sources */, 353FDC0F2CA446FA0055F328 /* StoreProductDiscount+Extensions.swift in Sources */, + 03A98CF12D222F5F009BCA61 /* FallbackComponentPreview.swift in Sources */, 887A60862C1D037000E1A461 /* FooterHidingModifier.swift in Sources */, FDAD6AC72D132DD900FB047E /* CustomerCenterStoreKitUtilitiesType.swift in Sources */, 887A60C02C1D037000E1A461 /* AsyncButton.swift in Sources */, diff --git a/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/FallbackComponentPreview.swift b/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/FallbackComponentPreview.swift new file mode 100644 index 0000000000..7825338bc4 --- /dev/null +++ b/RevenueCatUI/Templates/V2/Previews/TemplateComponentsViewPreviews/FallbackComponentPreview.swift @@ -0,0 +1,161 @@ +// +// Copyright RevenueCat Inc. All Rights Reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// FallbackComponentPreview.swift +// +// Created by Josh Holtz on 12/29/24. + +import RevenueCat +import SwiftUI + +// swiftlint:disable force_try + +#if PAYWALL_COMPONENTS + +#if DEBUG + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct FallbackComponentPreview_Previews: PreviewProvider { + + static let jsonStringDefaultStack = """ + { + "type": "stack", + "dimension": { + "type": "vertical", + "alignment": "center", + "distribution": "center" + }, + "size": { + "width": { "type": "fixed", "value": 200 }, + "height": { "type": "fixed", "value": 100 } + }, + "padding": { + "top": 0, + "bottom": 0, + "leading": 0, + "trailing": 0 + }, + "margin": { + "top": 0, + "bottom": 0, + "leading": 0, + "trailing": 0 + }, + "background_color": { + "light": { + "type": "hex", + "value": "#ffcc00" + } + }, + "components": [ + { + "type": "text", + "text_lid": "text1", + "font_weight": "regular", + "color": { + "light": { + "type": "hex", + "value": "#000000" + } + }, + "font_size": "body_m", + "horizontal_alignment": "center", + "padding": { + "top": 0, + "bottom": 0, + "leading": 0, + "trailing": 0 + }, + "margin": { + "top": 0, + "bottom": 0, + "leading": 0, + "trailing": 0 + }, + "size": { + "width": { "type": "fit" }, + "height": { "type": "fit" } + } + } + ] + } + """ + + static let jsonStringComponentWithFallback = """ + { + "type": "super_new_type", + "unknown_property": { + "type": "more_unknown" + }, + "fallback": \(jsonStringDefaultStack) + } + """ + + static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .iso8601 + + return decoder + }() + + static let componentWithFallback = { + let jsonData = jsonStringComponentWithFallback.data(using: .utf8)! + return try! decoder.decode(PaywallComponent.self, from: jsonData) + }() + + static func toViewModels( + component: PaywallComponent, + packageValidator: PackageValidator, + offering: Offering, + localizationProvider: LocalizationProvider + ) -> PaywallComponentViewModel { + let factory = ViewModelFactory() + return try! factory.toViewModel( + component: component, + packageValidator: packageValidator, + offering: offering, + localizationProvider: localizationProvider + ) + } + + static let localizationProvider = LocalizationProvider( + locale: .init(identifier: "en_US"), + localizedStrings: [ + "text1": .string("Fallback is showing") + ] + ) + + static let offering = Offering(identifier: "default", + serverDescription: "", + availablePackages: []) + + static var previews: some View { + + // Component With Fallback + ComponentsView( + componentViewModels: [ + toViewModels( + component: componentWithFallback, + packageValidator: PackageValidator(), + offering: offering, + localizationProvider: localizationProvider + ) + ], + onDismiss: {} + ) + .previewRequiredEnvironmentProperties() + .previewLayout(.sizeThatFits) + .previewDisplayName("Component With Fallback") + } +} + +#endif + +#endif diff --git a/Sources/Paywalls/Components/Common/PaywallComponentBase.swift b/Sources/Paywalls/Components/Common/PaywallComponentBase.swift index d3161c2d3d..3c03129888 100644 --- a/Sources/Paywalls/Components/Common/PaywallComponentBase.swift +++ b/Sources/Paywalls/Components/Common/PaywallComponentBase.swift @@ -51,6 +51,7 @@ extension PaywallComponent: Codable { enum CodingKeys: String, CodingKey { case type + case fallback } @@ -90,27 +91,71 @@ extension PaywallComponent: Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(ComponentType.self, forKey: .type) + // Decode the raw string for the `type` field + let typeString = try container.decode(String.self, forKey: .type) + + // Attempt to convert raw string into our `ComponentType` enum + if let type = ComponentType(rawValue: typeString) { + self = try Self.decodeType(from: decoder, type: type) + } else { + if !container.contains(.fallback) { + let context = DecodingError.Context( + codingPath: container.codingPath, + debugDescription: + """ + Failed to decode unknown type "\(typeString)" without a fallback. + """ + ) + throw DecodingError.dataCorrupted(context) + } + + do { + // If `typeString` is unknown, try to decode the fallback + self = try container.decode(PaywallComponent.self, forKey: .fallback) + } catch DecodingError.valueNotFound { + let context = DecodingError.Context( + codingPath: container.codingPath, + debugDescription: + """ + Failed to decode unknown type "\(typeString)" without a fallback. + """ + ) + throw DecodingError.dataCorrupted(context) + } catch { + let context = DecodingError.Context( + codingPath: container.codingPath, + debugDescription: + """ + Failed to decode fallback for unknown type "\(typeString)". + """, + underlyingError: error + ) + throw DecodingError.dataCorrupted(context) + } + } + } + + private static func decodeType(from decoder: Decoder, type: ComponentType) throws -> PaywallComponent { switch type { case .text: - self = .text(try TextComponent(from: decoder)) + return .text(try TextComponent(from: decoder)) case .image: - self = .image(try ImageComponent(from: decoder)) + return .image(try ImageComponent(from: decoder)) case .spacer: - self = .spacer(try SpacerComponent(from: decoder)) + return .spacer(try SpacerComponent(from: decoder)) case .stack: - self = .stack(try StackComponent(from: decoder)) + return .stack(try StackComponent(from: decoder)) case .linkButton: - self = .linkButton(try LinkButtonComponent(from: decoder)) + return .linkButton(try LinkButtonComponent(from: decoder)) case .button: - self = .button(try ButtonComponent(from: decoder)) + return .button(try ButtonComponent(from: decoder)) case .package: - self = .package(try PackageComponent(from: decoder)) + return .package(try PackageComponent(from: decoder)) case .purchaseButton: - self = .purchaseButton(try PurchaseButtonComponent(from: decoder)) + return .purchaseButton(try PurchaseButtonComponent(from: decoder)) case .stickyFooter: - self = .stickyFooter(try StickyFooterComponent(from: decoder)) + return .stickyFooter(try StickyFooterComponent(from: decoder)) } } diff --git a/Tests/UnitTests/Paywalls/Components/ButtonComponentTests.swift b/Tests/UnitTests/Paywalls/Components/ButtonComponentTests.swift index bc872ce7aa..66b0393fb2 100644 --- a/Tests/UnitTests/Paywalls/Components/ButtonComponentTests.swift +++ b/Tests/UnitTests/Paywalls/Components/ButtonComponentTests.swift @@ -11,7 +11,12 @@ class ButtonComponentCodableTests: TestCase { "type": "stack", "dimension": { "type": "vertical", - "alignment": "center" + "alignment": "center", + "distribution": "start" + }, + "size": { + "width": { "type": "fill" }, + "height": { "type": "fill" } }, "padding": { "top": 0, @@ -44,7 +49,11 @@ class ButtonComponentCodableTests: TestCase { let buttonComponent = PaywallComponent.ButtonComponent( action: .restorePurchases, - stack: .init(components: []) + stack: .init( + components: [], + dimension: .vertical(.center, .start), + size: .init(width: .fill, height: .fill) + ) ) XCTAssertEqual(decodedButton, buttonComponent) @@ -65,7 +74,11 @@ class ButtonComponentCodableTests: TestCase { let buttonComponent = PaywallComponent.ButtonComponent( action: .navigateBack, - stack: .init(components: []) + stack: .init( + components: [], + dimension: .vertical(.center, .start), + size: .init(width: .fill, height: .fill) + ) ) XCTAssertEqual(decodedButton, buttonComponent) @@ -87,7 +100,11 @@ class ButtonComponentCodableTests: TestCase { let buttonComponent = PaywallComponent.ButtonComponent( action: .navigateTo(destination: .customerCenter), - stack: .init(components: []) + stack: .init( + components: [], + dimension: .vertical(.center, .start), + size: .init(width: .fill, height: .fill) + ) ) XCTAssertEqual(decodedButton, buttonComponent) @@ -116,7 +133,11 @@ class ButtonComponentCodableTests: TestCase { destination: .terms(urlLid: "re45", method: .inAppBrowser) ), - stack: .init(components: []) + stack: .init( + components: [], + dimension: .vertical(.center, .start), + size: .init(width: .fill, height: .fill) + ) ) XCTAssertEqual(decodedButton, buttonComponent) @@ -145,7 +166,11 @@ class ButtonComponentCodableTests: TestCase { destination: .privacyPolicy(urlLid: "re45", method: .externalBrowser) ), - stack: .init(components: []) + stack: .init( + components: [], + dimension: .vertical(.center, .start), + size: .init(width: .fill, height: .fill) + ) ) XCTAssertEqual(decodedButton, buttonComponent) @@ -174,7 +199,11 @@ class ButtonComponentCodableTests: TestCase { destination: .url(urlLid: "re45", method: .deepLink) ), - stack: .init(components: []) + stack: .init( + components: [], + dimension: .vertical(.center, .start), + size: .init(width: .fill, height: .fill) + ) ) XCTAssertEqual(decodedButton, buttonComponent) diff --git a/Tests/UnitTests/Paywalls/Components/FallbackComponentTests.swift b/Tests/UnitTests/Paywalls/Components/FallbackComponentTests.swift new file mode 100644 index 0000000000..6a6915cb1d --- /dev/null +++ b/Tests/UnitTests/Paywalls/Components/FallbackComponentTests.swift @@ -0,0 +1,187 @@ +import Nimble +@testable import RevenueCat +import XCTest + +#if PAYWALL_COMPONENTS + +class FallbackComponentTests: TestCase { + + let jsonStringDefaultStack = """ + { + "type": "stack", + "dimension": { + "type": "vertical", + "alignment": "center", + "distribution": "start" + }, + "size": { + "width": { "type": "fill" }, + "height": { "type": "fill" } + }, + "padding": { + "top": 0, + "bottom": 0, + "leading": 0, + "trailing": 0 + }, + "margin": { + "top": 0, + "bottom": 0, + "leading": 0, + "trailing": 0 + }, + "components": [] + } + """ + + func testUnknownTypeWithSuccessfulFallbackDecodingAndEncodesAndRedecodes() throws { + let jsonString = """ + { + "type": "super_new_type", + "unknown_property": { + "type": "more_unknown" + }, + "fallback": \(jsonStringDefaultStack) + } + """ + let jsonData = jsonString.data(using: .utf8)! + let decodedComponent = try JSONDecoder.default.decode(PaywallComponent.self, from: jsonData) + + let fallbackStackComponent = try JSONDecoder.default.decode( + PaywallComponent.StackComponent.self, + from: jsonStringDefaultStack.data(using: .utf8)! + ) + + // Step 1: Decodes correctly + switch decodedComponent { + case .stack(let stackComponent): + XCTAssertEqual(stackComponent, fallbackStackComponent) + default: + XCTFail("Did not fallback to any component") + } + + // Step 2: Encodes + let encodedComponent = try JSONEncoder.default.encode(decodedComponent) + + // Step 3: Decodes correctly (verifying encoding) + let redecodedComponent = try JSONDecoder.default.decode(PaywallComponent.self, from: encodedComponent) + switch redecodedComponent { + case .stack(let stackComponent): + XCTAssertEqual(stackComponent, fallbackStackComponent) + default: + XCTFail("Did not fallback to any component") + } + } + + func testUnknownTypeWithFailedFallbackDecoding() throws { + let jsonString = """ + { + "type": "super_new_type", + "unknown_property": { + "type": "more_unknown" + }, + "fallback": { + "type": "less_new_but_still_new_type", + "unknown_property": { + "type": "more_unknown" + } + } + } + """ + let jsonData = jsonString.data(using: .utf8)! + + do { + _ = try JSONDecoder.default.decode(PaywallComponent.self, from: jsonData) + XCTFail("Should have failed to decode fallback property") + } catch DecodingError.dataCorrupted(let context) { + expect(context.debugDescription).to( + contain("Failed to decode fallback for unknown type \"super_new_type\".") + ) + expect(context.underlyingError.debugDescription).to( + contain("Failed to decode unknown type \\\"less_new_but_still_new_type\\\" without a fallback.") + ) + } catch { + XCTFail("Should have caught DecodingError.dataCorrupted") + } + } + + func testUnknownTypeWithInvalidExistingTypeDecoding() throws { + let jsonString = """ + { + "type": "super_new_type", + "unknown_property": { + "type": "more_unknown" + }, + "fallback": { + "type": "text", + "wrong": "property" + } + } + """ + let jsonData = jsonString.data(using: .utf8)! + + do { + _ = try JSONDecoder.default.decode(PaywallComponent.self, from: jsonData) + XCTFail("Should have failed to decode fallback property") + } catch DecodingError.dataCorrupted(let context) { + expect(context.debugDescription).to( + contain("Failed to decode fallback for unknown type \"super_new_type\".") + ) + expect(context.underlyingError.debugDescription).to( + contain("No value associated with key CodingKeys(stringValue: \\\"textLid\\\"") + ) + } catch { + XCTFail("Should have caught DecodingError.dataCorrupted") + } + } + + func testUnknownTypeNoFallbackDecoding() throws { + let jsonString = """ + { + "type": "super_new_type", + "unknown_property": { + "type": "more_unknown" + } + } + """ + let jsonData = jsonString.data(using: .utf8)! + + do { + _ = try JSONDecoder.default.decode(PaywallComponent.self, from: jsonData) + XCTFail("Should have failed to decode fallback property") + } catch DecodingError.dataCorrupted(let context) { + expect(context.debugDescription).to( + contain("Failed to decode unknown type \"super_new_type\" without a fallback.") + ) + } catch { + XCTFail("Should have caught DecodingError.dataCorrupted") + } + } + + func testUnknownTypeNullFallbackDecoding() throws { + let jsonString = """ + { + "type": "super_new_type", + "unknown_property": { + "type": "more_unknown" + }, + "fallback": null + } + """ + let jsonData = jsonString.data(using: .utf8)! + + do { + _ = try JSONDecoder.default.decode(PaywallComponent.self, from: jsonData) + XCTFail("Should have failed to decode fallback property") + } catch DecodingError.dataCorrupted(let context) { + expect(context.debugDescription).to( + contain("Failed to decode unknown type \"super_new_type\" without a fallback.") + ) + } catch { + XCTFail("Should have caught DecodingError.dataCorrupted") + } + } + +} + +#endif diff --git a/Tests/UnitTests/Paywalls/Components/PartialComponentTests.swift b/Tests/UnitTests/Paywalls/Components/PartialComponentTests.swift index 32fd2bda49..68efafa990 100644 --- a/Tests/UnitTests/Paywalls/Components/PartialComponentTests.swift +++ b/Tests/UnitTests/Paywalls/Components/PartialComponentTests.swift @@ -30,19 +30,21 @@ final class PartialComponentTests: TestCase { // TextComponent (PaywallComponent.TextComponent( text: "Test", - fontFamily: "Arial", + fontName: "Arial", fontWeight: .bold, - color: .init(light: "#000000"), - backgroundColor: .init(light: "#FFFFFF"), + color: .init(light: .hex("#000000")), + backgroundColor: .init(light: .hex("#FFFFFF")), padding: .init(top: 10, bottom: 10, leading: 10, trailing: 10), margin: .init(top: 5, bottom: 5, leading: 5, trailing: 5), - textStyle: .title, + fontSize: .bodyM, horizontalAlignment: .leading ), PaywallComponent.PartialTextComponent()), // ImageComponent (PaywallComponent.ImageComponent(source: .init( - light: .init(original: sampleURL, + light: .init(width: 1, + height: 1, + original: sampleURL, heic: sampleURL, heicLowRes: sampleURL)) ), PaywallComponent.PartialImageComponent()),