Files
honeyDueKMP/iosApp/HoneyDueTests/SnapshotGalleryTests.swift
Trey T 3bac38449c P3.1: iOS goldens @2x + PNG optimizer + Makefile record/verify targets
- SnapshotGalleryTests rendered at displayScale: 2.0 (was native 3.0)
  → 49MB → 15MB (~69% reduction)
- Records via SNAPSHOT_TESTING_RECORD=1 env var (no code edits needed)
- scripts/optimize_goldens.sh runs zopflipng (or pngcrush fallback)
  over both iOS and Android golden dirs
- scripts/{record,verify}_snapshots.sh one-command wrappers
- Makefile targets: make {record,verify,optimize}-snapshots

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:45:02 -05:00

375 lines
12 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// SnapshotGalleryTests.swift
// HoneyDueTests
//
// P3 iOS parity gallery. Records baseline PNGs for primary SwiftUI screens
// across {empty-state} × {light, dark}. Complements the Android Roborazzi
// gallery (P2); both platforms consume the same Kotlin FixtureDataManager
// fixtures (P1), so any layout divergence between Android + iOS renders of
// the same screen is a real parity bug not a test-data mismatch.
//
// Current state coverage
// ----------------------
// * Empty (signed-in user with no residences / tasks / docs / contractors)
// is captured for every screen below via the default
// `DataManagerObservable.shared`, which has no data loaded when tests run
// because no login has occurred in the test host.
// * Populated state is BLOCKED on a follow-up ViewModel-injection refactor:
// current SwiftUI screens instantiate their ViewModels via
// `@StateObject private var viewModel = FooViewModel()`, and those
// ViewModels read directly from `DataManagerObservable.shared` rather
// than an injected `IDataManager`. Swapping the singleton is unsafe in
// parallel tests. Follow-up PR: add an optional `dataManager:` init param
// to each `*ViewModel.swift` and thread it from here via
// `.environment(\.dataManager, ...)`.
//
// Recording goldens
// -----------------
// Preferred: `make record-snapshots` (or `./scripts/record_snapshots.sh
// --ios-only`). The script exports `SNAPSHOT_TESTING_RECORD=1` in the
// xcodebuild env, deletes the old `__Snapshots__/SnapshotGalleryTests`
// directory, runs the target, then invokes the shared PNG optimizer.
//
// Manual override: set the `SNAPSHOT_TESTING_RECORD` env var to `1` in
// the Xcode scheme's Test action (Edit Scheme Test Arguments
// Environment Variables) and re-run the test target. CI fails the
// build if a screen diverges from its golden by more than the
// precision threshold.
//
// Rendering scale
// ---------------
// We force `displayScale: 2.0` on every snapshot. The iPhone 15 /
// iPhone 13 simulators default to the device's native 3x, which on a
// full-screen gradient-heavy SwiftUI view produced 8001000 KB PNGs
// per image. @2x halves the linear dimensions (2.25x fewer pixels) and
// is still plenty to catch layout regressions. Combined with
// `scripts/optimize_goldens.sh` (zopflipng / pngcrush) this keeps us
// under the 150 KB per-image budget enforced by CI.
//
@preconcurrency import SnapshotTesting
import SwiftUI
import XCTest
import ComposeApp
@testable import honeyDue
@MainActor
final class SnapshotGalleryTests: XCTestCase {
// Record mode is driven by the `SNAPSHOT_TESTING_RECORD` env var so
// `scripts/record_snapshots.sh` can flip it without mutating this file.
// When the var is unset/empty we only write missing goldens (`.missing`)
// so local dev runs never silently overwrite committed PNGs.
private static var recordMode: SnapshotTestingConfiguration.Record {
let env = ProcessInfo.processInfo.environment["SNAPSHOT_TESTING_RECORD"] ?? ""
return (env == "1" || env.lowercased() == "true") ? .all : .missing
}
override func invokeTest() {
withSnapshotTesting(record: Self.recordMode) {
super.invokeTest()
}
}
// MARK: - Helpers
/// Snapshot a SwiftUI view in both light + dark modes under a stable
/// iPhone 13 layout. The screen is wrapped in a `NavigationStack` so that
/// any `.navigationTitle`/`.toolbar` chrome the view declares is rendered
/// identically to how it ships to users.
// Most HoneyDue screens animate on appear (OrganicBlobShape gradients,
// subtle pulses, TextField focus rings). A tiny amount of pixel drift
// between runs is expected; the goal of parity snapshots is to catch
// *structural* regressions, not single-pixel anti-alias differences.
// These thresholds were tuned so that a second run against goldens
// recorded on the first run passes green.
private static let pixelPrecision: Float = 0.97
private static let perceptualPrecision: Float = 0.95
/// Force @2x rendering regardless of the simulator device's native
/// `displayScale`. See the class-level "Rendering scale" comment for
/// rationale. 2.0 keeps PNGs under the size budget without sacrificing
/// the structural detail our parity gallery cares about.
private static let forcedDisplayScale: CGFloat = 2.0
private func snap<V: View>(
_ name: String,
file: StaticString = #filePath,
testName: String = #function,
line: UInt = #line,
@ViewBuilder content: () -> V
) {
let view = content()
.environmentObject(ThemeManager.shared)
.environment(\.dataManager, DataManagerObservable.shared)
assertSnapshot(
of: view.environment(\.colorScheme, .light),
as: .image(
precision: Self.pixelPrecision,
perceptualPrecision: Self.perceptualPrecision,
layout: .device(config: .iPhone13),
traits: .init(traitsFrom: [
UITraitCollection(userInterfaceStyle: .light),
UITraitCollection(displayScale: Self.forcedDisplayScale),
])
),
named: "\(name)_light",
file: file,
testName: testName,
line: line
)
assertSnapshot(
of: view.environment(\.colorScheme, .dark),
as: .image(
precision: Self.pixelPrecision,
perceptualPrecision: Self.perceptualPrecision,
layout: .device(config: .iPhone13),
traits: .init(traitsFrom: [
UITraitCollection(userInterfaceStyle: .dark),
UITraitCollection(displayScale: Self.forcedDisplayScale),
])
),
named: "\(name)_dark",
file: file,
testName: testName,
line: line
)
}
// MARK: - Auth flow
func test_login_empty() {
snap("login_empty") {
LoginView(resetToken: .constant(nil), onLoginSuccess: nil)
}
}
func test_register_empty() {
snap("register_empty") {
RegisterView(isPresented: .constant(true), onVerified: nil)
}
}
func test_verify_email_empty() {
snap("verify_email_empty") {
VerifyEmailView(onVerifySuccess: {}, onLogout: {})
}
}
func test_forgot_password_empty() {
let vm = PasswordResetViewModel()
snap("forgot_password_empty") {
NavigationStack { ForgotPasswordView(viewModel: vm) }
}
}
func test_verify_reset_code_empty() {
let vm = PasswordResetViewModel()
vm.email = "user@example.com"
vm.currentStep = .verifyCode
snap("verify_reset_code_empty") {
NavigationStack { VerifyResetCodeView(viewModel: vm) }
}
}
func test_reset_password_empty() {
let vm = PasswordResetViewModel()
vm.currentStep = .resetPassword
snap("reset_password_empty") {
NavigationStack { ResetPasswordView(viewModel: vm, onSuccess: {}) }
}
}
// MARK: - Onboarding
func test_onboarding_welcome_empty() {
snap("onboarding_welcome_empty") {
OnboardingWelcomeView(onStartFresh: {}, onJoinExisting: {}, onLogin: {})
}
}
func test_onboarding_value_props_empty() {
snap("onboarding_value_props_empty") {
OnboardingValuePropsView(onContinue: {}, onSkip: {}, onBack: {})
}
}
func test_onboarding_create_account_empty() {
snap("onboarding_create_account_empty") {
OnboardingCreateAccountView(onAccountCreated: { _ in }, onBack: {})
}
}
func test_onboarding_verify_email_empty() {
snap("onboarding_verify_email_empty") {
OnboardingVerifyEmailView(onVerified: {}, onLogout: {})
}
}
func test_onboarding_name_residence_empty() {
snap("onboarding_name_residence_empty") {
StatefulPreviewWrapper("") { binding in
OnboardingNameResidenceView(
residenceName: binding,
onContinue: {},
onBack: {}
)
}
}
}
func test_onboarding_join_residence_empty() {
snap("onboarding_join_residence_empty") {
OnboardingJoinResidenceView(onJoined: {}, onSkip: {})
}
}
func test_onboarding_subscription_empty() {
snap("onboarding_subscription_empty") {
OnboardingSubscriptionView(onSubscribe: {}, onSkip: {})
}
}
func test_onboarding_first_task_empty() {
snap("onboarding_first_task_empty") {
OnboardingFirstTaskView(
residenceName: "My House",
onTaskAdded: {},
onSkip: {}
)
}
}
// MARK: - Residence
func test_residences_list_empty() {
snap("residences_list_empty") {
NavigationStack { ResidencesListView() }
}
}
func test_add_residence_empty() {
snap("add_residence_empty") {
AddResidenceView(isPresented: .constant(true), onResidenceCreated: nil)
}
}
func test_join_residence_empty() {
snap("join_residence_empty") {
JoinResidenceView(onJoined: {})
}
}
// MARK: - Tasks
func test_all_tasks_empty() {
snap("all_tasks_empty") {
NavigationStack { AllTasksView() }
}
}
func test_add_task_empty() {
snap("add_task_empty") {
AddTaskView(residenceId: 1, isPresented: .constant(true))
}
}
func test_add_task_with_residence_empty() {
snap("add_task_with_residence_empty") {
AddTaskWithResidenceView(isPresented: .constant(true), residences: [])
}
}
func test_task_suggestions_empty() {
snap("task_suggestions_empty") {
TaskSuggestionsView(
suggestions: [],
onSelect: { _ in }
)
}
}
func test_task_templates_browser_empty() {
snap("task_templates_browser_empty") {
NavigationStack { TaskTemplatesBrowserView(onSelect: { _ in }) }
}
}
// MARK: - Contractor
func test_contractors_list_empty() {
snap("contractors_list_empty") {
NavigationStack { ContractorsListView() }
}
}
// MARK: - Documents
func test_documents_warranties_empty() {
snap("documents_warranties_empty") {
NavigationStack { DocumentsWarrantiesView(residenceId: nil) }
}
}
// MARK: - Profile
func test_profile_tab_empty() {
snap("profile_tab_empty") {
NavigationStack { ProfileTabView() }
}
}
func test_profile_edit_empty() {
snap("profile_edit_empty") {
NavigationStack { ProfileView() }
}
}
func test_notification_preferences_empty() {
snap("notification_preferences_empty") {
NavigationStack { NotificationPreferencesView() }
}
}
func test_theme_selection_empty() {
snap("theme_selection_empty") {
NavigationStack { ThemeSelectionView() }
}
}
// MARK: - Subscription
func test_feature_comparison_empty() {
snap("feature_comparison_empty") {
FeatureComparisonView(isPresented: .constant(true))
}
}
// NOTE: UpgradeFeatureView is intentionally excluded from the parity
// gallery. It reads `SubscriptionCacheWrapper.shared` and
// `StoreKitManager.shared` on appear, both of which populate
// asynchronously in a way that isn't deterministic inside a snapshot
// test. Follow-up PR should add a fixture-friendly init override
// mirroring the `dataManager:` pattern.
}
// MARK: - StatefulPreviewWrapper
/// Lets us hand a view a `@Binding` backed by a local `@State` so that
/// views which mutate bindings (e.g. `OnboardingNameResidenceView`) render
/// correctly inside a snapshot test which has no surrounding state host.
private struct StatefulPreviewWrapper<Value, Content: View>: View {
@State private var value: Value
let content: (Binding<Value>) -> Content
init(_ initial: Value, @ViewBuilder content: @escaping (Binding<Value>) -> Content) {
_value = State(initialValue: initial)
self.content = content
}
var body: some View {
content($value)
}
}