diff --git a/Kickstarter-iOS/Features/LoginTout/Controller/LoginToutViewControllerTests.swift b/Kickstarter-iOS/Features/LoginTout/Controller/LoginToutViewControllerTests.swift index a3b080576d..5283816b2f 100644 --- a/Kickstarter-iOS/Features/LoginTout/Controller/LoginToutViewControllerTests.swift +++ b/Kickstarter-iOS/Features/LoginTout/Controller/LoginToutViewControllerTests.swift @@ -5,43 +5,11 @@ import XCTest internal final class LoginToutViewControllerTests: TestCase { func testLoginToutView() { - let devices = [Device.phone4_7inch, Device.phone5_8inch, Device.pad] let intents = [LoginIntent.generic, .starProject, .messageCreator, .backProject] - orthogonalCombos(Language.allLanguages, devices, intents).forEach { language, device, intent in - withEnvironment(language: language) { - let controller = LoginToutViewController.configuredWith(loginIntent: intent) - let (parent, _) = traitControllers(device: device, orientation: .portrait, child: controller) - - self.scheduler.run() - - assertSnapshot( - matching: parent.view, - as: .image, - named: "intent_\(intent)_lang_\(language)_device_\(device)" - ) - } - } - } - - func testDarkMode() { - let language = Language.en - let device = Device.phone5_8inch - let intent = LoginIntent.generic - - withEnvironment(language: language) { + forEachScreenshotType(withData: intents) { type, intent in let controller = LoginToutViewController.configuredWith(loginIntent: intent) - let (parent, _) = traitControllers(device: device, orientation: .portrait, child: controller) - - controller.overrideUserInterfaceStyle = .dark - - self.scheduler.run() - - assertSnapshot( - matching: parent.view, - as: .image, - named: "intent_\(intent)_lang_\(language)_device_\(device)_dark" - ) + assertSnapshot(forController: controller, withType: type) } } diff --git a/Kickstarter-iOS/Features/LoginTout/Controller/__Snapshots__/testLoginToutView.4-7in_en_dk_aXXXL_pt.png b/Kickstarter-iOS/Features/LoginTout/Controller/__Snapshots__/testLoginToutView.4-7in_en_dk_aXXXL_pt.png new file mode 100644 index 0000000000..1449ef351f Binary files /dev/null and b/Kickstarter-iOS/Features/LoginTout/Controller/__Snapshots__/testLoginToutView.4-7in_en_dk_aXXXL_pt.png differ diff --git a/Kickstarter-iOS/Features/LoginTout/Controller/__Snapshots__/testLoginToutView.4in_de_lt_md_pt.png b/Kickstarter-iOS/Features/LoginTout/Controller/__Snapshots__/testLoginToutView.4in_de_lt_md_pt.png new file mode 100644 index 0000000000..0ac01a6b02 Binary files /dev/null and b/Kickstarter-iOS/Features/LoginTout/Controller/__Snapshots__/testLoginToutView.4in_de_lt_md_pt.png differ diff --git a/Kickstarter-iOS/Features/LoginTout/Controller/__Snapshots__/testLoginToutView.5-5in_es_lt_md_pt.png b/Kickstarter-iOS/Features/LoginTout/Controller/__Snapshots__/testLoginToutView.5-5in_es_lt_md_pt.png new file mode 100644 index 0000000000..ffcca9856e Binary files /dev/null and b/Kickstarter-iOS/Features/LoginTout/Controller/__Snapshots__/testLoginToutView.5-5in_es_lt_md_pt.png differ diff --git a/Kickstarter-iOS/Features/LoginTout/Controller/__Snapshots__/testLoginToutView.5-8in_fr_dk_aXXXL_pt.png b/Kickstarter-iOS/Features/LoginTout/Controller/__Snapshots__/testLoginToutView.5-8in_fr_dk_aXXXL_pt.png new file mode 100644 index 0000000000..f7e637d7d2 Binary files /dev/null and b/Kickstarter-iOS/Features/LoginTout/Controller/__Snapshots__/testLoginToutView.5-8in_fr_dk_aXXXL_pt.png differ diff --git a/Kickstarter-iOS/Features/LoginTout/Controller/__Snapshots__/testLoginToutView.pad_ja_lt_md_pt.png b/Kickstarter-iOS/Features/LoginTout/Controller/__Snapshots__/testLoginToutView.pad_ja_lt_md_pt.png new file mode 100644 index 0000000000..4c932754ab Binary files /dev/null and b/Kickstarter-iOS/Features/LoginTout/Controller/__Snapshots__/testLoginToutView.pad_ja_lt_md_pt.png differ diff --git a/Kickstarter-iOS/TestHelpers/Combos.swift b/Kickstarter-iOS/TestHelpers/Combos.swift index 71b04c411b..1a3e9eb761 100644 --- a/Kickstarter-iOS/TestHelpers/Combos.swift +++ b/Kickstarter-iOS/TestHelpers/Combos.swift @@ -51,3 +51,52 @@ internal func orthogonalCombos(_ xs: [A], _ ys: [B], _ zs: [C]) -> [(A, } } } + +// Combine four arrays by creating an array where each element is represented at least once. +// Result consists of `max(A.count, B.count, C.count, D.count)` tuples. +// swiftlint:disable large_tuple +internal func orthogonalCombos( + _ xs: [A], + _ ys: [B], + _ zs: [C], + _ ws: [D] +) -> [(A, B, C, D)] { + let count = max(xs.count, ys.count, zs.count, ws.count) + + guard count > 0 else { return [] } + + return (0..( + _ xs: [A], + _ ys: [B], + _ zs: [C], + _ ws: [D], + _ vs: [E] +) -> [(A, B, C, D, E)] { + let count = max(xs.count, ys.count, zs.count, ws.count, vs.count) + + guard count > 0 else { return [] } + + return (0.. Void +) { + orthogonalCombos(devices, languages, styles, contentSizes).forEach { + device, language, style, contentSize in + body( + ScreenshotType( + device: device, + language: language, + style: style, + contentSizeCategory: contentSize, + orientation: orientation + ) + ) + } +} + +/// Iterates over screenshot configs plus an additional data set, using orthogonal sampling to keep counts low. +internal func forEachScreenshotType( + withData data: [T], + devices: [Device] = Device.allCases, + languages: [Language] = Language.allLanguages, + styles: [UIUserInterfaceStyle] = [.light, .dark], + contentSizes: [UIContentSizeCategory] = [ + .medium, + .accessibilityExtraExtraExtraLarge + ], + orientation: Orientation = .portrait, + body: (ScreenshotType, T) -> Void +) { + orthogonalCombos(devices, languages, styles, contentSizes, data).forEach { + device, language, style, contentSize, datum in + body( + ScreenshotType( + device: device, + language: language, + style: style, + contentSizeCategory: contentSize, + orientation: orientation + ), + datum + ) + } +} + +/// Asserts snapshots for all provided (or default) screenshot types, creating a fresh controller each time. +internal func assertAllSnapshots( + forController controllerProvider: () -> UIViewController, + types: [ScreenshotType]? = nil, + perceptualPrecision: Float? = nil, + record: Bool = false, + file: StaticString = #file, + testName: String = #function, + line: UInt = #line +) { + if let types = types { + types.forEach { type in + assertSnapshot( + forController: controllerProvider(), + withType: type, + perceptualPrecision: perceptualPrecision, + record: record, + file: file, + testName: testName, + line: line + ) + } + } else { + forEachScreenshotType { type in + assertSnapshot( + forController: controllerProvider(), + withType: type, + perceptualPrecision: perceptualPrecision, + record: record, + file: file, + testName: testName, + line: line + ) + } + } +} + +/// Configures environment, traits, style, font size, and naming before asserting a snapshot. +internal func assertSnapshot( + forController controller: UIViewController, + withType type: ScreenshotType, + perceptualPrecision: Float? = nil, + record: Bool = false, + file: StaticString = #file, + testName: String = #function, + line: UInt = #line +) { + let contentSizeTraits = UITraitCollection( + preferredContentSizeCategory: type.contentSizeCategory + ) + + withLanguage(type.language) { + let (parent, _) = traitControllers( + device: type.device, + orientation: type.orientation, + child: controller, + additionalTraits: contentSizeTraits + ) + + controller.overrideUserInterfaceStyle = type.style + + if let testScheduler = AppEnvironment.current.scheduler as? TestScheduler { + testScheduler.run() + } + + let name = snapshotName( + file: file, + function: testName, + type: type + ) + + let strategy: Snapshotting = { + if let precision = perceptualPrecision { + return .image(perceptualPrecision: precision) + } + return .image + }() + + let directory = snapshotDirectory(for: file) + + if let failure = verifySnapshot( + of: parent.view, + as: strategy, + named: name, + record: record, + snapshotDirectory: directory, + file: file, + testName: testName, + line: line + ) { + XCTFail( + """ + Snapshot failed for \(name) + device=\(type.device.snapshotDescription), + lang=\(type.language.rawValue), + style=\(type.style.snapshotDescription), + font=\(type.contentSizeCategory.snapshotDescription), + orientation=\(type.orientation.snapshotDescription) + \(failure) + """, + file: file, + line: line + ) + } + } +} + +/// Configures environment, traits, style, font size, and naming before asserting a snapshot of a UIView. +internal func assertSnapshot( + forView view: UIView, + withType type: ScreenshotType, + size: CGSize? = nil, + useIntrinsicSize: Bool = false, + perceptualPrecision: Float? = nil, + record: Bool = false, + file: StaticString = #file, + testName: String = #function, + line: UInt = #line +) { + let contentSizeTraits = UITraitCollection( + preferredContentSizeCategory: type.contentSizeCategory + ) + + let containerController = UIViewController() + containerController.view.addSubview(view) + view.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: containerController.view.leadingAnchor), + view.trailingAnchor.constraint(equalTo: containerController.view.trailingAnchor), + view.topAnchor.constraint(equalTo: containerController.view.topAnchor), + view.bottomAnchor.constraint(equalTo: containerController.view.bottomAnchor) + ]) + + withLanguage(type.language) { + let (parent, _) = traitControllers( + device: type.device, + orientation: type.orientation, + child: containerController, + additionalTraits: contentSizeTraits + ) + + containerController.overrideUserInterfaceStyle = type.style + + let targetSize: CGSize = { + if let size = size { + return size + } else if useIntrinsicSize { + containerController.view.setNeedsLayout() + containerController.view.layoutIfNeeded() + let fitting = containerController.view.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize) + if fitting.width > 0, fitting.height > 0 { + return fitting + } + } + return type.device.deviceSize(in: type.orientation) + }() + + parent.view.frame.size = targetSize + containerController.view.frame.size = targetSize + + if let testScheduler = AppEnvironment.current.scheduler as? TestScheduler { + testScheduler.run() + } + + let name = snapshotName( + file: file, + function: testName, + type: type + ) + + let strategy: Snapshotting = { + if let precision = perceptualPrecision { + return .image(perceptualPrecision: precision) + } + return .image + }() + + let directory = snapshotDirectory(for: file) + + if let failure = verifySnapshot( + of: parent.view, + as: strategy, + named: name, + record: record, + snapshotDirectory: directory, + file: file, + testName: testName, + line: line + ) { + XCTFail( + """ + Snapshot failed for \(name) + device=\(type.device.snapshotDescription), + lang=\(type.language.rawValue), + style=\(type.style.snapshotDescription), + font=\(type.contentSizeCategory.snapshotDescription), + orientation=\(type.orientation.snapshotDescription) + \(failure) + """, + file: file, + line: line + ) + } + } +} + +// Configures environment, traits, style, font size, and naming before asserting a snapshot of a SwiftUI View. +internal func assertSnapshot( + forSwiftUIView view: Content, + withType type: ScreenshotType, + size: CGSize? = nil, + useIntrinsicSize: Bool = false, + perceptualPrecision: Float? = nil, + record: Bool = false, + file: StaticString = #file, + testName: String = #function, + line: UInt = #line +) { + let hosting = UIHostingController(rootView: view) + + let targetSize: CGSize = { + if let size = size { + return size + } else if useIntrinsicSize { + let proposed = type.device.deviceSize(in: type.orientation) + let fitting = hosting.sizeThatFits(in: CGSize(width: proposed.width, height: .greatestFiniteMagnitude)) + if fitting.width > 0, fitting.height > 0 { + return fitting + } + } + return type.device.deviceSize(in: type.orientation) + }() + + hosting.view.frame = CGRect(origin: .zero, size: targetSize) + + assertSnapshot( + forController: hosting, + withType: type, + perceptualPrecision: perceptualPrecision, + record: record, + file: file, + testName: testName, + line: line + ) +} + +/// Builds a consistent snapshot name from file, function, and the screenshot configuration. +private func snapshotName( + file: StaticString, + function: String, + type: ScreenshotType +) -> String { + let fileComponent = sanitizeSnapshotComponent( + URL(fileURLWithPath: "\(file)").deletingPathExtension().lastPathComponent + ) + + let functionComponent = sanitizeSnapshotComponent( + function.replacingOccurrences(of: "()", with: "") + ) + + let deviceComponent = sanitizeSnapshotComponent(type.device.snapshotDescription) + let languageComponent = sanitizeSnapshotComponent(type.language.rawValue) + let styleComponent = sanitizeSnapshotComponent(type.style.snapshotDescription) + let fontComponent = sanitizeSnapshotComponent(type.contentSizeCategory.snapshotDescription) + let orientationComponent = sanitizeSnapshotComponent(type.orientation.snapshotDescription) + return [ + deviceComponent, + languageComponent, + styleComponent, + fontComponent, + orientationComponent + ] + .joined(separator: "_") +} + +/// Makes a snapshot name component safe by allowing only alphanumerics. +private func sanitizeSnapshotComponent(_ value: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-")) + return value + .replacingOccurrences(of: " ", with: "-") + .unicodeScalars + .map { allowed.contains($0) ? Character($0) : "-" } + .reduce("") { $0 + String($1) } +} + +private extension Device { + var snapshotDescription: String { + switch self { + case .phone4inch: return "4in" + case .phone4_7inch: return "4-7in" + case .phone5_5inch: return "5-5in" + case .phone5_8inch: return "5-8in" + case .pad: return "pad" + } + } +} + +private extension Orientation { + var snapshotDescription: String { + switch self { + case .portrait: return "pt" + case .landscape: return "ls" + } + } +} + +private extension UIUserInterfaceStyle { + var snapshotDescription: String { + switch self { + case .light: return "lt" + case .dark: return "dk" + default: return "un" + } + } +} + +private extension UIContentSizeCategory { + var snapshotDescription: String { + switch self { + case .extraSmall: return "xs" + case .small: return "s" + case .medium: return "md" + case .large: return "lg" + case .extraLarge: return "xl" + case .extraExtraLarge: return "xxl" + case .extraExtraExtraLarge: return "xxxl" + case .accessibilityMedium: return "aM" + case .accessibilityLarge: return "aL" + case .accessibilityExtraLarge: return "aXL" + case .accessibilityExtraExtraLarge: return "aXXL" + case .accessibilityExtraExtraExtraLarge: return "aXXXL" + default: return self.rawValue + } + } +} + +// Naming legend: +// device: phone4inch, phone4_7inch, phone5_5inch, phone5_8inch, pad +// lang: language rawValue (en, es, fr, de, ja) +// style: lt (light), dk (dark), un (unspecified) +// font: xs, s, md, lg, xl, xxl, xxxl, aM, aL, aXL, aXXL, aXXXL +// orient: pt (portrait), ls (landscape) + +private func snapshotDirectory(for file: StaticString) -> String { + let fileURL = URL(fileURLWithPath: "\(file)") + return fileURL.deletingLastPathComponent().appendingPathComponent("__Snapshots__").path +} + +private func withLanguage(_ language: Language, body: () -> Void) { + AppEnvironment.pushEnvironment(language: language) + body() + AppEnvironment.popEnvironment() +} diff --git a/Kickstarter.xcodeproj/project.pbxproj b/Kickstarter.xcodeproj/project.pbxproj index 40216056c6..5973fd4ab8 100644 --- a/Kickstarter.xcodeproj/project.pbxproj +++ b/Kickstarter.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 013744F81D99A39B00E50C78 /* EmptyStatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013744F71D99A39B00E50C78 /* EmptyStatesViewModel.swift */; }; 013F2FDC1D66243E0066DB77 /* DiscoveryNavigationHeaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013F2FDB1D66243E0066DB77 /* DiscoveryNavigationHeaderViewModel.swift */; }; 0146E3231CC0296900082C5B /* FacebookConfirmationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0146E3211CC0296900082C5B /* FacebookConfirmationViewController.swift */; }; + 01483F222F0DDFF900A1D833 /* ScreenshotTestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01483F1F2F0DDFF900A1D833 /* ScreenshotTestHelpers.swift */; }; 0148EF911CDD2879000DEFF8 /* ThanksViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0148EF8F1CDD2879000DEFF8 /* ThanksViewController.swift */; }; 014A8DEB1CE3C350003BF51C /* ThanksProjectsDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 014A8DE91CE3C350003BF51C /* ThanksProjectsDataSource.swift */; }; 014A8E1C1CE3CE34003BF51C /* ThanksCategoryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 014A8E1A1CE3CE34003BF51C /* ThanksCategoryCell.swift */; }; @@ -1861,6 +1862,7 @@ 013744F71D99A39B00E50C78 /* EmptyStatesViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EmptyStatesViewModel.swift; sourceTree = ""; }; 013F2FDB1D66243E0066DB77 /* DiscoveryNavigationHeaderViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiscoveryNavigationHeaderViewModel.swift; sourceTree = ""; }; 0146E3211CC0296900082C5B /* FacebookConfirmationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FacebookConfirmationViewController.swift; sourceTree = ""; }; + 01483F1F2F0DDFF900A1D833 /* ScreenshotTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotTestHelpers.swift; sourceTree = ""; }; 0148EF8F1CDD2879000DEFF8 /* ThanksViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThanksViewController.swift; sourceTree = ""; }; 014A8DE91CE3C350003BF51C /* ThanksProjectsDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThanksProjectsDataSource.swift; sourceTree = ""; }; 014A8E1A1CE3CE34003BF51C /* ThanksCategoryCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThanksCategoryCell.swift; sourceTree = ""; }; @@ -6794,6 +6796,7 @@ A7ED20201E83237F00BFFA01 /* TestHelpers */ = { isa = PBXGroup; children = ( + 01483F1F2F0DDFF900A1D833 /* ScreenshotTestHelpers.swift */, A7ED20211E83237F00BFFA01 /* Combos.swift */, 37096C3122BC23AD003D1F40 /* MockAppEnvironment.swift */, 37096C2F22BC238C003D1F40 /* MockFeedbackGenerator.swift */, @@ -9518,6 +9521,7 @@ 37CA16B323304DD9006044F9 /* ToggleViewControllerTests.swift in Sources */, A7ED20231E83237F00BFFA01 /* Combos.swift in Sources */, D6F741A821836E5700C2DDA2 /* PaymentMethodSettingsViewControllerTests.swift in Sources */, + 01483F222F0DDFF900A1D833 /* ScreenshotTestHelpers.swift in Sources */, 33AB202B2D268EE500B8C047 /* PledgeRewardsSummaryTotalViewControllerTests.swift in Sources */, A7ED20621E83256700BFFA01 /* AppDelegateViewModelTests.swift in Sources */, 8A49395D24B5394700C3C3CE /* RewardAddOnSelectionDataSourceTests.swift in Sources */,