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 */,