From 23431b9f7d943977b85b39ad787f1367da822cbd Mon Sep 17 00:00:00 2001 From: NachoSoto Date: Fri, 1 Sep 2023 15:59:55 -0700 Subject: [PATCH] `Paywalls`: `{{ price_per_period }}` now takes `SubscriptionPeriod.value` into account (#3133) This was incorrect for `PackageType.threeMonth` and `PackageType.sixMonth`. ![image](https://github.com/RevenueCat/purchases-ios/assets/685609/87b1ec00-04a0-4baa-ba73-edc4ce138388) --- RevenueCatUI/Data/Localization.swift | 20 ++-- RevenueCatUI/Data/TestData.swift | 23 +++- .../Package+VariableDataProvider.swift | 5 +- .../Data/PackageVariablesTests.swift | 102 ++++++++++++++++++ .../RevenueCatUITests/LocalizationTests.swift | 93 +++++++++++++++- 5 files changed, 229 insertions(+), 14 deletions(-) diff --git a/RevenueCatUI/Data/Localization.swift b/RevenueCatUI/Data/Localization.swift index 9c5940e2b3..43f6b7d16c 100644 --- a/RevenueCatUI/Data/Localization.swift +++ b/RevenueCatUI/Data/Localization.swift @@ -18,10 +18,10 @@ enum Localization { /// - Returns: an appropriately short abbreviation for the given `unit`. static func abbreviatedUnitLocalizedString( - for unit: SubscriptionPeriod.Unit, + for period: SubscriptionPeriod, locale: Locale = .current ) -> String { - let (full, brief, abbreviated) = self.unitLocalizedString(for: unit.calendarUnit, locale: locale) + let (full, brief, abbreviated) = self.unitLocalizedString(for: period, locale: locale) let options = [ full, @@ -123,15 +123,16 @@ enum Localization { private extension Localization { static func unitLocalizedString( - for unit: NSCalendar.Unit, + for period: SubscriptionPeriod, locale: Locale = .current ) -> (full: String, brief: String, abbreviated: String) { var calendar: Calendar = .current calendar.locale = locale let date = Date() - let value = 1 + let unit = period.unit.calendarUnit let component = unit.component + let value = period.value guard let sinceUnits = calendar.date(byAdding: component, value: value, @@ -145,9 +146,14 @@ private extension Localization { formatter.unitsStyle = style guard let string = formatter.string(from: date, to: sinceUnits) else { return "" } - return string - .replacingOccurrences(of: String(value), with: "") - .trimmingCharacters(in: .whitespaces) + if value == 1 { + return string + .replacingOccurrences(of: String(value), with: "") + .trimmingCharacters(in: .whitespaces) + } else { + return string + .replacingOccurrences(of: " ", with: "") + } } return (full: result(for: .full), diff --git a/RevenueCatUI/Data/TestData.swift b/RevenueCatUI/Data/TestData.swift index b28aa6c4f2..55723595fc 100644 --- a/RevenueCatUI/Data/TestData.swift +++ b/RevenueCatUI/Data/TestData.swift @@ -36,10 +36,21 @@ internal enum TestData { subscriptionPeriod: .init(value: 1, unit: .month), introductoryDiscount: Self.intro(7, .day) ) + static let threeMonthProduct = TestStoreProduct( + localizedTitle: "3 months", + price: 4.99, + localizedPriceString: "$4.99", + productIdentifier: "com.revenuecat.product_5", + productType: .autoRenewableSubscription, + localizedDescription: "PRO monthly", + subscriptionGroupIdentifier: "group", + subscriptionPeriod: .init(value: 3, unit: .month), + introductoryDiscount: Self.intro(7, .day) + ) static let sixMonthProduct = TestStoreProduct( - localizedTitle: "Monthly", - price: 34.99, - localizedPriceString: "$34.99", + localizedTitle: "6 months", + price: 7.99, + localizedPriceString: "$7.99", productIdentifier: "com.revenuecat.product_5", productType: .autoRenewableSubscription, localizedDescription: "PRO monthly", @@ -112,6 +123,12 @@ internal enum TestData { storeProduct: Self.monthlyProduct.toStoreProduct(), offeringIdentifier: Self.offeringIdentifier ) + static let threeMonthPackage = Package( + identifier: PackageType.threeMonth.identifier, + packageType: .threeMonth, + storeProduct: Self.threeMonthProduct.toStoreProduct(), + offeringIdentifier: Self.offeringIdentifier + ) static let sixMonthPackage = Package( identifier: PackageType.sixMonth.identifier, packageType: .sixMonth, diff --git a/RevenueCatUI/Helpers/Package+VariableDataProvider.swift b/RevenueCatUI/Helpers/Package+VariableDataProvider.swift index 991122ee00..fbb38fa354 100644 --- a/RevenueCatUI/Helpers/Package+VariableDataProvider.swift +++ b/RevenueCatUI/Helpers/Package+VariableDataProvider.swift @@ -53,7 +53,7 @@ extension Package: VariableDataProvider { return self.localizedPrice } - let unit = Localization.abbreviatedUnitLocalizedString(for: period.unit, locale: locale) + let unit = Localization.abbreviatedUnitLocalizedString(for: period, locale: locale) return "\(self.localizedPrice)/\(unit)" } @@ -61,7 +61,8 @@ extension Package: VariableDataProvider { if !self.isSubscription || self.isMonthly { return self.localizedPricePerPeriod(locale) } else { - let unit = Localization.abbreviatedUnitLocalizedString(for: .month, locale: locale) + let unit = Localization.abbreviatedUnitLocalizedString(for: .init(value: 1, unit: .month), + locale: locale) return "\(self.localizedPrice) (\(self.localizedPricePerMonth)/\(unit))" } } diff --git a/Tests/RevenueCatUITests/Data/PackageVariablesTests.swift b/Tests/RevenueCatUITests/Data/PackageVariablesTests.swift index bcb8f84a86..d31209f1e9 100644 --- a/Tests/RevenueCatUITests/Data/PackageVariablesTests.swift +++ b/Tests/RevenueCatUITests/Data/PackageVariablesTests.swift @@ -33,6 +33,8 @@ class PackageVariablesTests: TestCase { func testLocalizedPricePerMonth() { expect(TestData.weeklyPackage.localizedPricePerMonth) == "$7.96" expect(TestData.monthlyPackage.localizedPricePerMonth) == "$6.99" + expect(TestData.threeMonthPackage.localizedPricePerMonth) == "$1.66" + expect(TestData.sixMonthPackage.localizedPricePerMonth) == "$1.33" expect(TestData.annualPackage.localizedPricePerMonth) == "$4.49" expect(TestData.lifetimePackage.localizedPricePerMonth) == "$119.49" } @@ -40,6 +42,8 @@ class PackageVariablesTests: TestCase { func testEnglishLocalizedPricePerPeriod() { expect(TestData.weeklyPackage.localizedPricePerPeriod(Self.english)) == "$1.99/wk" expect(TestData.monthlyPackage.localizedPricePerPeriod(Self.english)) == "$6.99/mo" + expect(TestData.threeMonthPackage.localizedPricePerPeriod(Self.english)) == "$4.99/3mo" + expect(TestData.sixMonthPackage.localizedPricePerPeriod(Self.english)) == "$7.99/6mo" expect(TestData.annualPackage.localizedPricePerPeriod(Self.english)) == "$53.99/yr" expect(TestData.lifetimePackage.localizedPricePerPeriod(Self.english)) == "$119.49" } @@ -47,13 +51,34 @@ class PackageVariablesTests: TestCase { func testSpanishLocalizedPricePerPeriod() { expect(TestData.weeklyPackage.localizedPricePerPeriod(Self.spanish)) == "$1.99/sem" expect(TestData.monthlyPackage.localizedPricePerPeriod(Self.spanish)) == "$6.99/m." + expect(TestData.threeMonthPackage.localizedPricePerPeriod(Self.spanish)) == "$4.99/3m" + expect(TestData.sixMonthPackage.localizedPricePerPeriod(Self.spanish)) == "$7.99/6m" expect(TestData.annualPackage.localizedPricePerPeriod(Self.spanish)) == "$53.99/año" expect(TestData.lifetimePackage.localizedPricePerPeriod(Self.spanish)) == "$119.49" } + func testArabicLocalizedPricePerPeriod() { + let arabicPrice = "٣.٩٩ درهم" + + expect(TestData.weeklyPackage.with(arabicPrice, Self.arabic).localizedPricePerPeriod(Self.arabic)) + == "٣.٩٩ درهم/أسبوع" + expect(TestData.monthlyPackage.with(arabicPrice, Self.arabic).localizedPricePerPeriod(Self.arabic)) + == "٣.٩٩ درهم/شهر" + expect(TestData.threeMonthPackage.with(arabicPrice, Self.arabic).localizedPricePerPeriod(Self.arabic)) + == "٣.٩٩ درهم/3شهر" + expect(TestData.sixMonthPackage.with(arabicPrice, Self.arabic).localizedPricePerPeriod(Self.arabic)) + == "٣.٩٩ درهم/6شهر" + expect(TestData.annualPackage.with(arabicPrice, Self.arabic).localizedPricePerPeriod(Self.arabic)) + == "٣.٩٩ درهم/سنة" + expect(TestData.lifetimePackage.with(arabicPrice, Self.arabic).localizedPricePerPeriod(Self.arabic)) + == "٣.٩٩ درهم" + } + func testEnglishLocalizedPriceAndPerMonth() { expect(TestData.weeklyPackage.localizedPriceAndPerMonth(Self.english)) == "$1.99 ($7.96/mo)" expect(TestData.monthlyPackage.localizedPriceAndPerMonth(Self.english)) == "$6.99/mo" + expect(TestData.threeMonthPackage.localizedPriceAndPerMonth(Self.english)) == "$4.99 ($1.66/mo)" + expect(TestData.sixMonthPackage.localizedPriceAndPerMonth(Self.english)) == "$7.99 ($1.33/mo)" expect(TestData.annualPackage.localizedPriceAndPerMonth(Self.english)) == "$53.99 ($4.49/mo)" expect(TestData.lifetimePackage.localizedPriceAndPerMonth(Self.english)) == "$119.49" } @@ -61,13 +86,34 @@ class PackageVariablesTests: TestCase { func testSpanishLocalizedPriceAndPerMonth() { expect(TestData.weeklyPackage.localizedPriceAndPerMonth(Self.spanish)) == "$1.99 ($7.96/m.)" expect(TestData.monthlyPackage.localizedPriceAndPerMonth(Self.spanish)) == "$6.99/m." + expect(TestData.threeMonthPackage.localizedPriceAndPerMonth(Self.spanish)) == "$4.99 ($1.66/m.)" + expect(TestData.sixMonthPackage.localizedPriceAndPerMonth(Self.spanish)) == "$7.99 ($1.33/m.)" expect(TestData.annualPackage.localizedPriceAndPerMonth(Self.spanish)) == "$53.99 ($4.49/m.)" expect(TestData.lifetimePackage.localizedPriceAndPerMonth(Self.spanish)) == "$119.49" } + func testArabicLocalizedPriceAndPerMonth() { + let arabicPrice = "٣.٩٩ درهم" + + expect(TestData.weeklyPackage.with(arabicPrice, Self.arabic).localizedPriceAndPerMonth(Self.arabic)) + .to(equalIgnoringRTL("٣.٩٩ درهم (‏7.96 ‏د.إ.‏/شهر)")) + expect(TestData.monthlyPackage.with(arabicPrice, Self.arabic).localizedPriceAndPerMonth(Self.arabic)) + .to(equalIgnoringRTL("٣.٩٩ درهم/شهر")) + expect(TestData.threeMonthPackage.with(arabicPrice, Self.arabic).localizedPriceAndPerMonth(Self.arabic)) + .to(equalIgnoringRTL("٣.٩٩ درهم (‏1.66 ‏د.إ.‏/شهر)")) + expect(TestData.sixMonthPackage.with(arabicPrice, Self.arabic).localizedPriceAndPerMonth(Self.arabic)) + .to(equalIgnoringRTL("٣.٩٩ درهم (‏1.33 ‏د.إ.‏/شهر)")) + expect(TestData.annualPackage.with(arabicPrice, Self.arabic).localizedPriceAndPerMonth(Self.arabic)) + .to(equalIgnoringRTL("٣.٩٩ درهم (‏4.49 ‏د.إ.‏/شهر)")) + expect(TestData.lifetimePackage.with(arabicPrice, Self.arabic).localizedPriceAndPerMonth(Self.arabic)) + == arabicPrice + } + func testProductName() { expect(TestData.weeklyPackage.productName) == "Weekly" expect(TestData.monthlyPackage.productName) == "Monthly" + expect(TestData.threeMonthPackage.productName) == "3 months" + expect(TestData.sixMonthPackage.productName) == "6 months" expect(TestData.annualPackage.productName) == "Annual" expect(TestData.lifetimePackage.productName) == "Lifetime" } @@ -75,6 +121,8 @@ class PackageVariablesTests: TestCase { func testEnglishPeriodName() { expect(TestData.weeklyPackage.periodName(Self.english)) == "Weekly" expect(TestData.monthlyPackage.periodName(Self.english)) == "Monthly" + expect(TestData.threeMonthPackage.periodName(Self.english)) == "3 Month" + expect(TestData.sixMonthPackage.periodName(Self.english)) == "6 Month" expect(TestData.annualPackage.periodName(Self.english)) == "Annual" expect(TestData.lifetimePackage.periodName(Self.english)) == "Lifetime" } @@ -82,6 +130,8 @@ class PackageVariablesTests: TestCase { func testSpanishPeriodName() { expect(TestData.weeklyPackage.periodName(Self.spanish)) == "Semanalmente" expect(TestData.monthlyPackage.periodName(Self.spanish)) == "Mensual" + expect(TestData.threeMonthPackage.periodName(Self.spanish)) == "3 meses" + expect(TestData.sixMonthPackage.periodName(Self.spanish)) == "6 meses" expect(TestData.annualPackage.periodName(Self.spanish)) == "Anual" expect(TestData.lifetimePackage.periodName(Self.spanish)) == "Toda la vida" } @@ -114,5 +164,57 @@ private extension PackageVariablesTests { static let english: Locale = .init(identifier: "en_US") static let spanish: Locale = .init(identifier: "es_ES") + static let arabic: Locale = .init(identifier: "ar_AE") + +} + +// MARK: - + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension Package { + + func with(_ newLocalizedPrice: String, _ locale: Locale) -> Package { + return .init( + identifier: self.identifier, + packageType: self.packageType, + storeProduct: self.storeProduct + .toTestProduct() + .with(newLocalizedPrice, locale) + .toStoreProduct(), + offeringIdentifier: self.offeringIdentifier + ) + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension TestStoreProduct { + + func with(_ newLocalizedPrice: String, _ locale: Locale) -> Self { + var copy = self + copy.localizedPriceString = newLocalizedPrice + copy.locale = locale + + return copy + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +private extension StoreProduct { + + func toTestProduct() -> TestStoreProduct { + return .init( + localizedTitle: self.localizedTitle, + price: self.price, + localizedPriceString: self.localizedPriceString, + productIdentifier: self.productIdentifier, + productType: self.productType, + localizedDescription: self.localizedDescription, + subscriptionGroupIdentifier: self.subscriptionGroupIdentifier, + subscriptionPeriod: self.subscriptionPeriod, + isFamilyShareable: self.isFamilyShareable + ) + } } diff --git a/Tests/RevenueCatUITests/LocalizationTests.swift b/Tests/RevenueCatUITests/LocalizationTests.swift index d4204de4d0..87e7c60b18 100644 --- a/Tests/RevenueCatUITests/LocalizationTests.swift +++ b/Tests/RevenueCatUITests/LocalizationTests.swift @@ -16,7 +16,7 @@ import RevenueCat @testable import RevenueCatUI import XCTest -// swiftlint:disable type_name +// swiftlint:disable type_name file_length class BaseLocalizationTests: TestCase { @@ -43,6 +43,18 @@ class AbbreviatedUnitEnglishLocalizationTests: BaseLocalizationTests { verify(.month, "mo") } + func testTwoMonths() { + verify(.init(2, .month), "2mo") + } + + func testThreeMonths() { + verify(.init(3, .month), "3mo") + } + + func testSixMonths() { + verify(.init(6, .month), "6mo") + } + func testYear() { verify(.year, "yr") } @@ -66,6 +78,18 @@ class AbbreviatedUnitSpanishLocalizationTests: BaseLocalizationTests { verify(.month, "m.") } + func testTwoMonths() { + verify(.init(2, .month), "2m") + } + + func testThreeMonths() { + verify(.init(3, .month), "3m") + } + + func testSixMonths() { + verify(.init(6, .month), "6m") + } + func testYear() { verify(.year, "año") } @@ -92,6 +116,7 @@ class SubscriptionPeriodEnglishLocalizationTests: BaseLocalizationTests { func testMonthPeriod() { verify(1, .month, "1 month") verify(3, .month, "3 months") + verify(6, .month, "6 months") } func testYearPeriod() { @@ -263,8 +288,34 @@ class DiscountOtherLanguageLocalizationTests: BaseLocalizationTests { } +// MARK: - Helpers + +/// iOS 16 and 17 differ slightly in how Arabic strings are encoded, iOS 16 having an additional RTL marker (U+200F) +/// This allows comparing strings ignoring those markers. +func equalIgnoringRTL(_ expectedValue: String?) -> Nimble.Predicate { + let escapedValue = stringify(expectedValue?.escapedUnicode) + return Predicate.define("equal to <\(escapedValue)> ignoring RTL marks") { actualExpression, msg in + if let actual = try actualExpression.evaluate(), let expected = expectedValue { + return PredicateResult( + bool: actual.removingRTLMarkers == expected.removingRTLMarkers, + message: msg.appended(details: "Escaped: <\(actual.escapedUnicode)>") + ) + } + + return PredicateResult(status: .fail, message: msg) + } +} + // MARK: - Private +private extension SubscriptionPeriod { + + convenience init(_ value: Int, _ unit: Unit) { + self.init(value: value, unit: unit) + } + +} + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) private extension BaseLocalizationTests { @@ -286,7 +337,16 @@ private extension BaseLocalizationTests { file: StaticString = #file, line: UInt = #line ) { - let result = Localization.abbreviatedUnitLocalizedString(for: unit, + self.verify(.init(1, unit), expected, file: file, line: line) + } + + func verify( + _ period: SubscriptionPeriod, + _ expected: String, + file: StaticString = #file, + line: UInt = #line + ) { + let result = Localization.abbreviatedUnitLocalizedString(for: period, locale: self.locale) expect(file: file, line: line, result) == expected } @@ -314,3 +374,32 @@ private extension BaseLocalizationTests { } } + +private extension String { + + var escapedUnicode: String { + var escapedString = "" + escapedString.reserveCapacity(self.unicodeScalars.count) + + for scalar in self.unicodeScalars { + if scalar.isASCII { + escapedString.append(String(scalar)) + } else { + escapedString.append("\\u{\(String(scalar.value, radix: 16))}") + } + } + + return escapedString + } + + var removingRTLMarkers: Self { + return self.removeCharacters(from: Self.rtlMarker) + } + + private func removeCharacters(from set: CharacterSet) -> String { + return String(self.unicodeScalars.filter { !set.contains($0) }) + } + + private static let rtlMarker: CharacterSet = .init(charactersIn: "\u{200f}") + +}