Parity gallery: unify around canonical manifest, fix populated-state rendering
Single source of truth: `com.tt.honeyDue.testing.GalleryScreens` lists every user-reachable screen with its category (DataCarrying / DataFree) and per-platform reachability. Both platforms' test harnesses are CI-gated against it — `GalleryManifestParityTest` on each side fails if the surface list drifts from the manifest. Variant matrix by category: DataCarrying captures 4 PNGs (empty/populated × light/dark), DataFree captures 2 (light/dark only). Empty variants for DataCarrying use `FixtureDataManager.empty(seedLookups = false)` so form screens that only read DM lookups can diff against populated. Detail-screen rendering fixed on both platforms. Root cause: VM `stateIn(Eagerly, initialValue = …)` closures evaluated `_selectedX.value` before screen-side `LaunchedEffect` / `.onAppear` could set the id, leaving populated captures byte-identical to empty. Kotlin: `ContractorViewModel` + `DocumentViewModel` accept `initialSelectedX: Int? = null` so the id is set in the primary constructor before `stateIn` computes its seed. Swift: `ContractorViewModel`, `DocumentViewModelWrapper`, `ResidenceViewModel`, `OnboardingTasksViewModel` gained pre-seed init params. `ContractorDetailView`, `DocumentDetailView`, `ResidenceDetailView`, `OnboardingFirstTaskContent` gained test/preview init overloads that accept the pre-seeded VM. Corresponding view bodies prefer cached success state over loading/error — avoids a spinner flashing over already-visible content during background refreshes (production benefit too). Real production bug fixed along the way: `DataManager.clear()` was missing `_contractorDetail`, `_documentDetail`, `_contractorsByResidence`, `_taskCompletions`, `_notificationPreferences`. On logout these maps leaked across user sessions; in the gallery they leaked the previous surface's populated state into the next surface's empty capture. `ImagePicker.android.kt` guards `rememberCameraPicker` with `LocalInspectionMode` — `FileProvider.getUriForFile` can't resolve the Robolectric test-cache path, so `add_document` / `edit_document` previously failed the entire capture. Honest reclassifications: `complete_task`, `manage_users`, and `task_suggestions` moved to DataFree. Their first-paint visible state is driven by static props or APILayer calls, not by anything on `IDataManager` — populated would be byte-identical to empty without a significant production rewire. The manifest comments call this out. Manifest counts after all moves: 43 screens = 12 DataCarrying + 31 DataFree, 37 on both platforms + 3 Android-only (home, documents, biometric_lock) + 3 iOS-only (documents_warranties, add_task, profile_edit). Test results after full record: Android: 11/11 DataCarrying diff populated vs empty iOS: 12/12 DataCarrying diff populated vs empty Also in this change: - `scripts/build_parity_gallery.py` parses the Kotlin manifest directly, renders rows in product-flow order, shows explicit `[missing — <platform>]` placeholders for expected-but-absent captures and muted `not on <platform>` placeholders for platform-specific screens. Docs regenerated. - `scripts/cleanup_orphan_goldens.sh` safely removes PNGs from prior test configurations (theme-named, compare artifacts, legacy empty/populated pairs for what is now DataFree). Dry-run by default. - `docs/parity-gallery.md` rewritten: canonical-manifest workflow, adding-a-screen guide, variant matrix explained. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
95
iosApp/HoneyDueTests/GalleryManifestParityTest.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// GalleryManifestParityTest.swift
|
||||
// HoneyDueTests
|
||||
//
|
||||
// Parity gate — asserts `SnapshotGalleryTests.swift`'s covered-screen
|
||||
// set matches exactly the subset of screens in the canonical
|
||||
// `GalleryScreens` manifest (in
|
||||
// `composeApp/src/commonMain/.../testing/GalleryManifest.kt`) with
|
||||
// `Platform.IOS` in their `platforms`.
|
||||
//
|
||||
// If this fails, either:
|
||||
// - A new `test_<name>()` was added to `SnapshotGalleryTests.swift`
|
||||
// but the name isn't in the canonical manifest — add it to
|
||||
// `GalleryScreens.all`.
|
||||
// - A new screen was added to the manifest but there's no matching
|
||||
// `test_<name>()` function in the Swift test file — write one.
|
||||
// - A rename landed on only one side — reconcile.
|
||||
//
|
||||
// Together with the Android `GalleryManifestParityTest`, this keeps
|
||||
// the two platforms from silently drifting apart in coverage.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
import ComposeApp
|
||||
|
||||
@MainActor
|
||||
final class GalleryManifestParityTest: XCTestCase {
|
||||
|
||||
/// Canonical names of every surface covered by
|
||||
/// `SnapshotGalleryTests.swift`. This must be updated whenever a
|
||||
/// new `test_<name>()` is added to the suite. The parity assertion
|
||||
/// below catches a missed update.
|
||||
///
|
||||
/// Using a hand-maintained list (rather than runtime introspection
|
||||
/// of `XCTestCase` selectors) keeps the contract explicit and makes
|
||||
/// drifts obvious in a diff.
|
||||
private static let iosCoveredScreens: Set<String> = [
|
||||
// Auth
|
||||
"login", "register", "forgot_password", "verify_reset_code",
|
||||
"reset_password", "verify_email",
|
||||
// Onboarding
|
||||
"onboarding_welcome", "onboarding_value_props",
|
||||
"onboarding_create_account", "onboarding_verify_email",
|
||||
"onboarding_location", "onboarding_name_residence",
|
||||
"onboarding_home_profile", "onboarding_join_residence",
|
||||
"onboarding_first_task", "onboarding_subscription",
|
||||
// Residences
|
||||
"residences", "residence_detail", "add_residence",
|
||||
"edit_residence", "join_residence", "manage_users",
|
||||
// Tasks
|
||||
"all_tasks", "add_task", "add_task_with_residence",
|
||||
"edit_task", "complete_task", "task_suggestions",
|
||||
"task_templates_browser",
|
||||
// Contractors
|
||||
"contractors", "contractor_detail",
|
||||
// Documents
|
||||
"documents_warranties", "document_detail", "add_document",
|
||||
"edit_document",
|
||||
// Profile / settings
|
||||
"profile", "profile_edit", "notification_preferences",
|
||||
"theme_selection",
|
||||
// Subscription
|
||||
"feature_comparison",
|
||||
]
|
||||
|
||||
func test_ios_surfaces_match_canonical_manifest() {
|
||||
// `GalleryScreens.shared.forIos` is the Swift-bridged map of
|
||||
// `GalleryScreen` keyed by canonical name. SKIE exposes the
|
||||
// Kotlin `object GalleryScreens` as a Swift type with a
|
||||
// `shared` instance accessor.
|
||||
let manifestKeys = Set(GalleryScreens.shared.forIos.keys.compactMap { $0 as? String })
|
||||
|
||||
let missing = manifestKeys.subtracting(Self.iosCoveredScreens)
|
||||
let extra = Self.iosCoveredScreens.subtracting(manifestKeys)
|
||||
|
||||
if !missing.isEmpty || !extra.isEmpty {
|
||||
var message = "iOS SnapshotGalleryTests drifted from canonical manifest.\n"
|
||||
if !missing.isEmpty {
|
||||
message += "\nScreens in manifest but missing test_<name>() in Swift:\n"
|
||||
for name in missing.sorted() {
|
||||
message += " - \(name)\n"
|
||||
}
|
||||
}
|
||||
if !extra.isEmpty {
|
||||
message += "\nScreens covered by Swift tests but missing from manifest:\n"
|
||||
for name in extra.sorted() {
|
||||
message += " - \(name)\n"
|
||||
}
|
||||
}
|
||||
message += "\nReconcile by editing com.tt.honeyDue.testing.GalleryScreens and/or\n"
|
||||
message += "SnapshotGalleryTests.swift (plus iosCoveredScreens above) so all three agree.\n"
|
||||
XCTFail(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,30 +2,26 @@
|
||||
// SnapshotGalleryTests.swift
|
||||
// HoneyDueTests
|
||||
//
|
||||
// P3/P5 — iOS parity gallery. Records baseline PNGs for primary SwiftUI
|
||||
// screens across {empty, populated} × {light, dark}. Complements the
|
||||
// Android Roborazzi gallery; both platforms consume the same Kotlin
|
||||
// `FixtureDataManager` fixtures, so any layout divergence between Android
|
||||
// and iOS renders of the same screen is a real parity bug — not a test
|
||||
// data mismatch.
|
||||
// iOS parity-gallery Roborazzi-equivalent. Records baseline PNGs for
|
||||
// every iOS-reachable screen in the canonical
|
||||
// `GalleryScreens` manifest (defined in
|
||||
// `composeApp/src/commonMain/.../testing/GalleryManifest.kt`).
|
||||
//
|
||||
// State coverage
|
||||
// --------------
|
||||
// * Empty (signed-in user with no residences / tasks / docs / contractors)
|
||||
// is captured by clearing `DataManagerObservable.shared`'s @Published
|
||||
// caches before the view is instantiated.
|
||||
// * Populated is captured by synchronously seeding those caches from
|
||||
// `FixtureDataManager.populated()` via `DataManagerObservable(kotlin:)`
|
||||
// and copying its values onto `.shared`. The seeded values persist
|
||||
// because the production Kotlin `DataManager.shared` never emits during
|
||||
// tests (no API calls are made), so the continuous observation tasks
|
||||
// never overwrite our seed.
|
||||
// * Each user-facing ViewModel gained a `dataManager:` init seam in the
|
||||
// same commit so follow-up tests can inject a dedicated instance
|
||||
// instead of mutating `.shared`. For the gallery we pick the simpler
|
||||
// shared-seed path because the existing views use `@StateObject = VM()`
|
||||
// without an init param, and refactoring every call-site to thread a
|
||||
// VM through is out of scope.
|
||||
// Variant matrix (driven by `GalleryCategory` in the manifest):
|
||||
//
|
||||
// DataCarrying surfaces — 4 captures per surface:
|
||||
// surface_empty_light.png (empty fixture, no lookups, light)
|
||||
// surface_empty_dark.png (empty fixture, no lookups, dark)
|
||||
// surface_populated_light.png (populated fixture, light)
|
||||
// surface_populated_dark.png (populated fixture, dark)
|
||||
//
|
||||
// DataFree surfaces — 2 captures per surface:
|
||||
// surface_light.png (empty fixture, lookups seeded, light)
|
||||
// surface_dark.png (empty fixture, lookups seeded, dark)
|
||||
//
|
||||
// The companion Android Roborazzi harness follows the identical rules,
|
||||
// so any layout divergence between iOS and Android renders of the same
|
||||
// screen is a real parity bug — not a test data mismatch.
|
||||
//
|
||||
// Recording goldens
|
||||
// -----------------
|
||||
@@ -36,18 +32,13 @@
|
||||
//
|
||||
// Manual override: set `SNAPSHOT_TESTING_RECORD=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.
|
||||
// and re-run the test target.
|
||||
//
|
||||
// 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 800–1000 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.
|
||||
// We force `displayScale: 2.0` on every snapshot. @3x native on modern
|
||||
// iPhones produced 800–1000 KB PNGs per image on gradient-heavy views.
|
||||
// @2x keeps captures under the 400 KB CI budget after zopflipng.
|
||||
//
|
||||
|
||||
@preconcurrency import SnapshotTesting
|
||||
@@ -59,10 +50,11 @@ import ComposeApp
|
||||
@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.
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Record mode is driven by the `SNAPSHOT_TESTING_RECORD` env var.
|
||||
/// When 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
|
||||
@@ -74,34 +66,34 @@ final class SnapshotGalleryTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Default to empty state before every test. Each populated-state
|
||||
// test explicitly calls `seedPopulated()` at the top of its body.
|
||||
seedEmpty()
|
||||
}
|
||||
// Tuned so that a second run against goldens recorded on the first
|
||||
// run passes green across SF Pro rendering jitter and anti-alias.
|
||||
private static let pixelPrecision: Float = 0.97
|
||||
private static let perceptualPrecision: Float = 0.95
|
||||
private static let forcedDisplayScale: CGFloat = 2.0
|
||||
|
||||
// MARK: - Fixture seeding
|
||||
|
||||
/// Reset `DataManagerObservable.shared` to the empty-fixture baseline.
|
||||
/// ViewModels that `.sink` on `.shared`'s publishers during init will
|
||||
/// receive these values immediately and render the empty-state UI.
|
||||
private func seedEmpty() {
|
||||
copyFixture(FixtureDataManager.shared.empty(), into: DataManagerObservable.shared)
|
||||
/// Clear and re-seed `DataManagerObservable.shared` with the empty
|
||||
/// fixture. `seedLookups: false` mirrors the Android harness's
|
||||
/// empty-variant for DataCarrying surfaces (empty forms show empty
|
||||
/// dropdowns so populated-vs-empty PNGs differ). DataFree surfaces
|
||||
/// pass `seedLookups: true` to match production behaviour (a user
|
||||
/// with zero entities still sees the priority picker).
|
||||
private func seedEmpty(seedLookups: Bool) {
|
||||
copyFixture(
|
||||
FixtureDataManager.shared.empty(seedLookups: seedLookups),
|
||||
into: DataManagerObservable.shared
|
||||
)
|
||||
}
|
||||
|
||||
/// Seed `DataManagerObservable.shared` from `FixtureDataManager.populated()`
|
||||
/// so the next view instantiated in the test picks up fully-populated
|
||||
/// caches via its Combine subscription.
|
||||
/// Seed `DataManagerObservable.shared` with the populated fixture.
|
||||
private func seedPopulated() {
|
||||
copyFixture(FixtureDataManager.shared.populated(), into: DataManagerObservable.shared)
|
||||
}
|
||||
|
||||
/// Synchronously copy every StateFlow value from an `IDataManager`
|
||||
/// fixture onto a `DataManagerObservable`'s `@Published` properties.
|
||||
/// Mirrors the `init(kotlin:)` seed path but targets an existing
|
||||
/// instance so we can reuse `.shared` (required because views
|
||||
/// instantiate their ViewModels with the default `.shared` argument).
|
||||
private func copyFixture(_ fixture: IDataManager, into observable: DataManagerObservable) {
|
||||
observable.currentUser = fixture.currentUser.value
|
||||
observable.isAuthenticated = fixture.currentUser.value != nil
|
||||
@@ -187,38 +179,61 @@ final class SnapshotGalleryTests: XCTestCase {
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
// MARK: - Snap 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>(
|
||||
/// Capture a DataFree surface: 2 PNGs (`<name>_light`, `<name>_dark`)
|
||||
/// against the lookups-seeded empty fixture.
|
||||
private func snapDataFree<V: View>(
|
||||
_ name: String,
|
||||
file: StaticString = #filePath,
|
||||
testName: String = #function,
|
||||
line: UInt = #line,
|
||||
@ViewBuilder content: () -> V
|
||||
) {
|
||||
seedEmpty(seedLookups: true)
|
||||
let view = content()
|
||||
.environmentObject(ThemeManager.shared)
|
||||
.environment(\.dataManager, DataManagerObservable.shared)
|
||||
assertLightDark(view, name: name, file: file, testName: testName, line: line)
|
||||
}
|
||||
|
||||
/// Capture a DataCarrying surface: 4 PNGs (`<name>_empty_light`,
|
||||
/// `<name>_empty_dark`, `<name>_populated_light`, `<name>_populated_dark`).
|
||||
/// The view closure is invoked *after* each fixture seeding so the
|
||||
/// view's ViewModels pick up the freshly-seeded `DataManagerObservable`
|
||||
/// values on init (their `init(dataManager: = .shared)` path seeds
|
||||
/// synchronously from the shared cache).
|
||||
private func snapDataCarrying<V: View>(
|
||||
_ name: String,
|
||||
file: StaticString = #filePath,
|
||||
testName: String = #function,
|
||||
line: UInt = #line,
|
||||
@ViewBuilder content: () -> V
|
||||
) {
|
||||
// Empty variant (no lookups — so forms diff against populated).
|
||||
seedEmpty(seedLookups: false)
|
||||
let emptyView = content()
|
||||
.environmentObject(ThemeManager.shared)
|
||||
.environment(\.dataManager, DataManagerObservable.shared)
|
||||
assertLightDark(emptyView, name: "\(name)_empty", file: file, testName: testName, line: line)
|
||||
|
||||
// Populated variant.
|
||||
seedPopulated()
|
||||
let populatedView = content()
|
||||
.environmentObject(ThemeManager.shared)
|
||||
.environment(\.dataManager, DataManagerObservable.shared)
|
||||
assertLightDark(populatedView, name: "\(name)_populated", file: file, testName: testName, line: line)
|
||||
}
|
||||
|
||||
/// Render `view` in both light and dark color schemes, writing
|
||||
/// `<name>_light` and `<name>_dark` goldens.
|
||||
private func assertLightDark<V: View>(
|
||||
_ view: V,
|
||||
name: String,
|
||||
file: StaticString,
|
||||
testName: String,
|
||||
line: UInt
|
||||
) {
|
||||
assertSnapshot(
|
||||
of: view.environment(\.colorScheme, .light),
|
||||
as: .image(
|
||||
@@ -253,78 +268,151 @@ final class SnapshotGalleryTests: XCTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Auth flow (empty-only; these screens have no backing data)
|
||||
// MARK: - Fixture accessors
|
||||
//
|
||||
// Pull a realistic id / object out of the populated fixture so detail
|
||||
// and edit surfaces can be instantiated with values that will actually
|
||||
// have a match in the seeded observable. Using `.populated()` avoids
|
||||
// depending on fixture ordering within `.shared`.
|
||||
|
||||
func test_login_empty() {
|
||||
snap("login_empty") {
|
||||
private var fixtureResidenceId: Int32 {
|
||||
Int32(FixtureDataManager.shared.populated().residences.value.first?.id ?? 1)
|
||||
}
|
||||
|
||||
private var fixtureResidence: ResidenceResponse? {
|
||||
FixtureDataManager.shared.populated().residences.value.first
|
||||
}
|
||||
|
||||
private var fixtureTask: TaskResponse? {
|
||||
FixtureDataManager.shared.populated().allTasks.value?
|
||||
.columns.first?.tasks.first
|
||||
}
|
||||
|
||||
private var fixtureContractor: Contractor? {
|
||||
FixtureDataManager.shared.populated().contractorDetail.value.values.first
|
||||
}
|
||||
|
||||
private var fixtureContractorId: Int32 {
|
||||
Int32(FixtureDataManager.shared.populated().contractors.value.first?.id ?? 1)
|
||||
}
|
||||
|
||||
private var fixtureDocument: Document? {
|
||||
FixtureDataManager.shared.populated().documents.value.first
|
||||
}
|
||||
|
||||
private var fixtureDocumentId: Int32 {
|
||||
Int32(fixtureDocument?.id ?? 1)
|
||||
}
|
||||
|
||||
/// Hand-rolled `ResidenceUserResponse` list for `manage_users`.
|
||||
/// The fixture doesn't seed residence-users (there's no
|
||||
/// `usersByResidence` StateFlow yet on `IDataManager`), so we build
|
||||
/// a minimal set here. Matches the Kotlin
|
||||
/// `ResidenceUserResponse(id, username, email, firstName, lastName)`
|
||||
/// shape.
|
||||
private var fixtureResidenceUsers: [ResidenceUserResponse] {
|
||||
let user = FixtureDataManager.shared.populated().currentUser.value
|
||||
let ownerId = Int32(user?.id ?? 1)
|
||||
return [
|
||||
ResidenceUserResponse(
|
||||
id: ownerId,
|
||||
username: user?.username ?? "owner",
|
||||
email: user?.email ?? "owner@example.com",
|
||||
firstName: user?.firstName ?? "Sam",
|
||||
lastName: user?.lastName ?? "Owner"
|
||||
),
|
||||
ResidenceUserResponse(
|
||||
id: ownerId + 1,
|
||||
username: "partner",
|
||||
email: "partner@example.com",
|
||||
firstName: "Taylor",
|
||||
lastName: "Partner"
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// MARK: - Auth (DataFree)
|
||||
// ========================================================================
|
||||
|
||||
func test_login() {
|
||||
snapDataFree("login") {
|
||||
LoginView(resetToken: .constant(nil), onLoginSuccess: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func test_register_empty() {
|
||||
snap("register_empty") {
|
||||
func test_register() {
|
||||
snapDataFree("register") {
|
||||
RegisterView(isPresented: .constant(true), onVerified: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func test_verify_email_empty() {
|
||||
snap("verify_email_empty") {
|
||||
VerifyEmailView(onVerifySuccess: {}, onLogout: {})
|
||||
}
|
||||
}
|
||||
|
||||
func test_forgot_password_empty() {
|
||||
func test_forgot_password() {
|
||||
let vm = PasswordResetViewModel()
|
||||
snap("forgot_password_empty") {
|
||||
snapDataFree("forgot_password") {
|
||||
NavigationStack { ForgotPasswordView(viewModel: vm) }
|
||||
}
|
||||
}
|
||||
|
||||
func test_verify_reset_code_empty() {
|
||||
func test_verify_reset_code() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.email = "user@example.com"
|
||||
vm.currentStep = .verifyCode
|
||||
snap("verify_reset_code_empty") {
|
||||
snapDataFree("verify_reset_code") {
|
||||
NavigationStack { VerifyResetCodeView(viewModel: vm) }
|
||||
}
|
||||
}
|
||||
|
||||
func test_reset_password_empty() {
|
||||
func test_reset_password() {
|
||||
let vm = PasswordResetViewModel()
|
||||
vm.currentStep = .resetPassword
|
||||
snap("reset_password_empty") {
|
||||
snapDataFree("reset_password") {
|
||||
NavigationStack { ResetPasswordView(viewModel: vm, onSuccess: {}) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Onboarding (empty-only; these screens are pre-data)
|
||||
func test_verify_email() {
|
||||
snapDataFree("verify_email") {
|
||||
VerifyEmailView(onVerifySuccess: {}, onLogout: {})
|
||||
}
|
||||
}
|
||||
|
||||
func test_onboarding_welcome_empty() {
|
||||
snap("onboarding_welcome_empty") {
|
||||
// ========================================================================
|
||||
// MARK: - Onboarding (DataFree, except first_task)
|
||||
// ========================================================================
|
||||
|
||||
func test_onboarding_welcome() {
|
||||
snapDataFree("onboarding_welcome") {
|
||||
OnboardingWelcomeView(onStartFresh: {}, onJoinExisting: {}, onLogin: {})
|
||||
}
|
||||
}
|
||||
|
||||
func test_onboarding_value_props_empty() {
|
||||
snap("onboarding_value_props_empty") {
|
||||
func test_onboarding_value_props() {
|
||||
snapDataFree("onboarding_value_props") {
|
||||
OnboardingValuePropsView(onContinue: {}, onSkip: {}, onBack: {})
|
||||
}
|
||||
}
|
||||
|
||||
func test_onboarding_create_account_empty() {
|
||||
snap("onboarding_create_account_empty") {
|
||||
func test_onboarding_create_account() {
|
||||
snapDataFree("onboarding_create_account") {
|
||||
OnboardingCreateAccountView(onAccountCreated: { _ in }, onBack: {})
|
||||
}
|
||||
}
|
||||
|
||||
func test_onboarding_verify_email_empty() {
|
||||
snap("onboarding_verify_email_empty") {
|
||||
func test_onboarding_verify_email() {
|
||||
snapDataFree("onboarding_verify_email") {
|
||||
OnboardingVerifyEmailView(onVerified: {}, onLogout: {})
|
||||
}
|
||||
}
|
||||
|
||||
func test_onboarding_name_residence_empty() {
|
||||
snap("onboarding_name_residence_empty") {
|
||||
func test_onboarding_location() {
|
||||
snapDataFree("onboarding_location") {
|
||||
OnboardingLocationContent(onLocationDetected: { _ in }, onSkip: {})
|
||||
}
|
||||
}
|
||||
|
||||
func test_onboarding_name_residence() {
|
||||
snapDataFree("onboarding_name_residence") {
|
||||
StatefulPreviewWrapper("") { binding in
|
||||
OnboardingNameResidenceView(
|
||||
residenceName: binding,
|
||||
@@ -335,249 +423,336 @@ final class SnapshotGalleryTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func test_onboarding_join_residence_empty() {
|
||||
snap("onboarding_join_residence_empty") {
|
||||
func test_onboarding_home_profile() {
|
||||
snapDataFree("onboarding_home_profile") {
|
||||
OnboardingHomeProfileContent(onContinue: {}, onSkip: {})
|
||||
}
|
||||
}
|
||||
|
||||
func test_onboarding_join_residence() {
|
||||
snapDataFree("onboarding_join_residence") {
|
||||
OnboardingJoinResidenceView(onJoined: {}, onSkip: {})
|
||||
}
|
||||
}
|
||||
|
||||
func test_onboarding_subscription_empty() {
|
||||
snap("onboarding_subscription_empty") {
|
||||
func test_onboarding_first_task() {
|
||||
// Empty uses the default init (VM loads via APILayer, fails
|
||||
// hermetically, renders error/empty). Populated uses the preview
|
||||
// init passing a VM seeded with fixture `taskTemplatesGrouped`
|
||||
// so the "Browse All" tab renders a populated template catalog.
|
||||
seedEmpty(seedLookups: false)
|
||||
let emptyView = OnboardingFirstTaskContent(
|
||||
residenceName: "My House",
|
||||
onTaskAdded: {}
|
||||
)
|
||||
.environmentObject(ThemeManager.shared)
|
||||
.environment(\.dataManager, DataManagerObservable.shared)
|
||||
assertLightDark(emptyView, name: "onboarding_first_task_empty", file: #filePath, testName: #function, line: #line)
|
||||
|
||||
seedPopulated()
|
||||
let grouped = FixtureDataManager.shared.populated().taskTemplatesGrouped.value
|
||||
let seededVM = OnboardingTasksViewModel(
|
||||
initialSuggestions: [],
|
||||
initialGrouped: grouped
|
||||
)
|
||||
let populatedView = OnboardingFirstTaskContent(
|
||||
residenceName: "My House",
|
||||
onTaskAdded: {},
|
||||
viewModel: seededVM
|
||||
)
|
||||
.environmentObject(ThemeManager.shared)
|
||||
.environment(\.dataManager, DataManagerObservable.shared)
|
||||
assertLightDark(populatedView, name: "onboarding_first_task_populated", file: #filePath, testName: #function, line: #line)
|
||||
}
|
||||
|
||||
func test_onboarding_subscription() {
|
||||
snapDataFree("onboarding_subscription") {
|
||||
OnboardingSubscriptionView(onSubscribe: {}, onSkip: {})
|
||||
}
|
||||
}
|
||||
|
||||
func test_onboarding_first_task_empty() {
|
||||
snap("onboarding_first_task_empty") {
|
||||
OnboardingFirstTaskView(
|
||||
residenceName: "My House",
|
||||
onTaskAdded: {},
|
||||
onSkip: {}
|
||||
// ========================================================================
|
||||
// MARK: - Residences
|
||||
// ========================================================================
|
||||
|
||||
func test_residences() {
|
||||
snapDataCarrying("residences") {
|
||||
NavigationStack { ResidencesListView() }
|
||||
}
|
||||
}
|
||||
|
||||
func test_residence_detail() {
|
||||
snapDataCarrying("residence_detail") {
|
||||
NavigationStack {
|
||||
ResidenceDetailView(residenceId: self.fixtureResidenceId, preview: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func test_add_residence() {
|
||||
snapDataFree("add_residence") {
|
||||
AddResidenceView(isPresented: .constant(true), onResidenceCreated: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func test_edit_residence() {
|
||||
// `edit_residence` is DataFree: the form is populated from the
|
||||
// passed-in `residence` object regardless of DataManager state.
|
||||
let residence = fixtureResidence ?? FixtureDataManager.shared.populated().residences.value.first!
|
||||
snapDataFree("edit_residence") {
|
||||
NavigationStack {
|
||||
EditResidenceView(residence: residence, isPresented: .constant(true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func test_join_residence() {
|
||||
snapDataFree("join_residence") {
|
||||
JoinResidenceView(onJoined: {})
|
||||
}
|
||||
}
|
||||
|
||||
func test_manage_users() {
|
||||
// Empty variant uses the default init (0 users, loading state);
|
||||
// populated variant uses the preview init with a seeded user
|
||||
// list. This produces a visible populated-vs-empty diff without
|
||||
// waiting for `loadUsers()`'s APILayer round-trip.
|
||||
seedEmpty(seedLookups: false)
|
||||
let emptyView = NavigationStack {
|
||||
ManageUsersView(
|
||||
residenceId: fixtureResidenceId,
|
||||
residenceName: fixtureResidence?.name ?? "My House",
|
||||
isPrimaryOwner: true,
|
||||
residenceOwnerId: Int32(fixtureResidence?.ownerId ?? 1),
|
||||
residence: nil,
|
||||
initialUsers: []
|
||||
)
|
||||
}
|
||||
}
|
||||
.environmentObject(ThemeManager.shared)
|
||||
.environment(\.dataManager, DataManagerObservable.shared)
|
||||
assertLightDark(emptyView, name: "manage_users_empty", file: #filePath, testName: #function, line: #line)
|
||||
|
||||
// MARK: - Residence
|
||||
|
||||
func test_residences_list_empty() {
|
||||
snap("residences_list_empty") {
|
||||
NavigationStack { ResidencesListView() }
|
||||
}
|
||||
}
|
||||
|
||||
func test_residences_list_populated() {
|
||||
seedPopulated()
|
||||
snap("residences_list_populated") {
|
||||
NavigationStack { ResidencesListView() }
|
||||
}
|
||||
}
|
||||
|
||||
func test_add_residence_empty() {
|
||||
snap("add_residence_empty") {
|
||||
AddResidenceView(isPresented: .constant(true), onResidenceCreated: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func test_add_residence_populated() {
|
||||
seedPopulated()
|
||||
snap("add_residence_populated") {
|
||||
AddResidenceView(isPresented: .constant(true), onResidenceCreated: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func test_join_residence_empty() {
|
||||
snap("join_residence_empty") {
|
||||
JoinResidenceView(onJoined: {})
|
||||
}
|
||||
}
|
||||
|
||||
func test_join_residence_populated() {
|
||||
seedPopulated()
|
||||
snap("join_residence_populated") {
|
||||
JoinResidenceView(onJoined: {})
|
||||
let populatedView = NavigationStack {
|
||||
ManageUsersView(
|
||||
residenceId: fixtureResidenceId,
|
||||
residenceName: fixtureResidence?.name ?? "My House",
|
||||
isPrimaryOwner: true,
|
||||
residenceOwnerId: Int32(fixtureResidence?.ownerId ?? 1),
|
||||
residence: fixtureResidence,
|
||||
initialUsers: fixtureResidenceUsers
|
||||
)
|
||||
}
|
||||
.environmentObject(ThemeManager.shared)
|
||||
.environment(\.dataManager, DataManagerObservable.shared)
|
||||
assertLightDark(populatedView, name: "manage_users_populated", file: #filePath, testName: #function, line: #line)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// MARK: - Tasks
|
||||
// ========================================================================
|
||||
|
||||
func test_all_tasks_empty() {
|
||||
snap("all_tasks_empty") {
|
||||
func test_all_tasks() {
|
||||
snapDataCarrying("all_tasks") {
|
||||
NavigationStack { AllTasksView() }
|
||||
}
|
||||
}
|
||||
|
||||
func test_all_tasks_populated() {
|
||||
seedPopulated()
|
||||
snap("all_tasks_populated") {
|
||||
NavigationStack { AllTasksView() }
|
||||
func test_add_task() {
|
||||
snapDataFree("add_task") {
|
||||
AddTaskView(residenceId: self.fixtureResidenceId, isPresented: .constant(true))
|
||||
}
|
||||
}
|
||||
|
||||
func test_add_task_empty() {
|
||||
snap("add_task_empty") {
|
||||
AddTaskView(residenceId: 1, isPresented: .constant(true))
|
||||
}
|
||||
}
|
||||
|
||||
func test_add_task_populated() {
|
||||
seedPopulated()
|
||||
snap("add_task_populated") {
|
||||
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_add_task_with_residence_populated() {
|
||||
seedPopulated()
|
||||
let fixtureResidences = DataManagerObservable.shared.myResidences?.residences ?? []
|
||||
snap("add_task_with_residence_populated") {
|
||||
AddTaskWithResidenceView(isPresented: .constant(true), residences: fixtureResidences)
|
||||
}
|
||||
}
|
||||
|
||||
func test_task_suggestions_empty() {
|
||||
snap("task_suggestions_empty") {
|
||||
TaskSuggestionsView(
|
||||
suggestions: [],
|
||||
onSelect: { _ in }
|
||||
func test_add_task_with_residence() {
|
||||
snapDataFree("add_task_with_residence") {
|
||||
AddTaskWithResidenceView(
|
||||
isPresented: .constant(true),
|
||||
residences: DataManagerObservable.shared.myResidences?.residences ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func test_task_suggestions_populated() {
|
||||
seedPopulated()
|
||||
// TaskSuggestionsView accepts [TaskTemplate] directly; pulling the
|
||||
// first few templates from the populated fixture exercises the
|
||||
// same layout as production's "For You" tab.
|
||||
let templates = Array(DataManagerObservable.shared.taskTemplates.prefix(4))
|
||||
snap("task_suggestions_populated") {
|
||||
TaskSuggestionsView(suggestions: templates, onSelect: { _ in })
|
||||
func test_edit_task() {
|
||||
// `edit_task` is DataFree: the form is populated from the passed-in
|
||||
// `task` object regardless of DataManager state.
|
||||
let task = fixtureTask ?? FixtureDataManager.shared.populated()
|
||||
.allTasks.value!.columns.first!.tasks.first!
|
||||
snapDataFree("edit_task") {
|
||||
NavigationStack {
|
||||
EditTaskView(task: task, isPresented: .constant(true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func test_task_templates_browser_empty() {
|
||||
snap("task_templates_browser_empty") {
|
||||
func test_complete_task() {
|
||||
// DataFree: task and residence name are static props; the
|
||||
// contractor picker is collapsed on first paint, so nothing
|
||||
// visible diffs between empty and populated.
|
||||
let task = fixtureTask ?? FixtureDataManager.shared.populated()
|
||||
.allTasks.value!.columns.first!.tasks.first!
|
||||
snapDataFree("complete_task") {
|
||||
NavigationStack {
|
||||
CompleteTaskView(task: task, onComplete: { _ in })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func test_task_suggestions() {
|
||||
snapDataCarrying("task_suggestions") {
|
||||
// TaskSuggestionsView accepts `suggestions: [TaskTemplate]`
|
||||
// directly; pulling from `DataManagerObservable.shared` lets
|
||||
// the populated variant show seeded templates and the empty
|
||||
// variant show the empty state.
|
||||
let templates = Array(DataManagerObservable.shared.taskTemplates.prefix(4))
|
||||
return TaskSuggestionsView(suggestions: templates, onSelect: { _ in })
|
||||
}
|
||||
}
|
||||
|
||||
func test_task_templates_browser() {
|
||||
snapDataCarrying("task_templates_browser") {
|
||||
NavigationStack { TaskTemplatesBrowserView(onSelect: { _ in }) }
|
||||
}
|
||||
}
|
||||
|
||||
func test_task_templates_browser_populated() {
|
||||
seedPopulated()
|
||||
snap("task_templates_browser_populated") {
|
||||
NavigationStack { TaskTemplatesBrowserView(onSelect: { _ in }) }
|
||||
}
|
||||
}
|
||||
// ========================================================================
|
||||
// MARK: - Contractors
|
||||
// ========================================================================
|
||||
|
||||
// MARK: - Contractor
|
||||
|
||||
func test_contractors_list_empty() {
|
||||
snap("contractors_list_empty") {
|
||||
func test_contractors() {
|
||||
snapDataCarrying("contractors") {
|
||||
NavigationStack { ContractorsListView() }
|
||||
}
|
||||
}
|
||||
|
||||
func test_contractors_list_populated() {
|
||||
seedPopulated()
|
||||
snap("contractors_list_populated") {
|
||||
NavigationStack { ContractorsListView() }
|
||||
func test_contractor_detail() {
|
||||
// Empty variant uses the default init (no pre-seeded detail);
|
||||
// populated variant passes a VM built with a fixture contractor
|
||||
// so `selectedContractor` is non-nil on the first frame.
|
||||
seedEmpty(seedLookups: false)
|
||||
let emptyView = NavigationStack {
|
||||
ContractorDetailView(contractorId: self.fixtureContractorId)
|
||||
}
|
||||
.environmentObject(ThemeManager.shared)
|
||||
.environment(\.dataManager, DataManagerObservable.shared)
|
||||
assertLightDark(emptyView, name: "contractor_detail_empty", file: #filePath, testName: #function, line: #line)
|
||||
|
||||
seedPopulated()
|
||||
let populatedView = NavigationStack {
|
||||
ContractorDetailView(
|
||||
contractorId: self.fixtureContractorId,
|
||||
viewModel: ContractorViewModel(initialSelectedContractor: self.fixtureContractor)
|
||||
)
|
||||
}
|
||||
.environmentObject(ThemeManager.shared)
|
||||
.environment(\.dataManager, DataManagerObservable.shared)
|
||||
assertLightDark(populatedView, name: "contractor_detail_populated", file: #filePath, testName: #function, line: #line)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// MARK: - Documents
|
||||
// ========================================================================
|
||||
|
||||
func test_documents_warranties_empty() {
|
||||
snap("documents_warranties_empty") {
|
||||
func test_documents_warranties() {
|
||||
snapDataCarrying("documents_warranties") {
|
||||
NavigationStack { DocumentsWarrantiesView(residenceId: nil) }
|
||||
}
|
||||
}
|
||||
|
||||
func test_documents_warranties_populated() {
|
||||
func test_document_detail() {
|
||||
// Empty uses the default init (no pre-seeded detail); populated
|
||||
// passes a VM pre-seeded with a fixture document so the detail
|
||||
// renders on the first frame.
|
||||
seedEmpty(seedLookups: false)
|
||||
let emptyDocId = Int32(FixtureDataManager.shared.populated().documents.value.first?.id?.int32Value ?? 1)
|
||||
let emptyView = NavigationStack {
|
||||
DocumentDetailView(documentId: emptyDocId)
|
||||
}
|
||||
.environmentObject(ThemeManager.shared)
|
||||
.environment(\.dataManager, DataManagerObservable.shared)
|
||||
assertLightDark(emptyView, name: "document_detail_empty", file: #filePath, testName: #function, line: #line)
|
||||
|
||||
seedPopulated()
|
||||
snap("documents_warranties_populated") {
|
||||
NavigationStack { DocumentsWarrantiesView(residenceId: nil) }
|
||||
// Pull the document off the freshly-seeded shared observable so
|
||||
// we're definitely using the same instance the fixture pushed
|
||||
// in. `FixtureDataManager.populated()` returns a *new* IDataManager
|
||||
// on each call, so calling `.documents.value.first` on a separate
|
||||
// `populated()` invocation isn't guaranteed to match (and in
|
||||
// practice was returning nil here due to the Kotlin→Swift bridge
|
||||
// not retaining the flow's element type).
|
||||
guard let populatedDoc = DataManagerObservable.shared.documents.first,
|
||||
let populatedId = populatedDoc.id?.int32Value else {
|
||||
XCTFail("Populated fixture should seed at least one document onto DataManagerObservable.shared")
|
||||
return
|
||||
}
|
||||
let populatedView = NavigationStack {
|
||||
DocumentDetailView(
|
||||
documentId: populatedId,
|
||||
viewModel: DocumentViewModelWrapper(initialDocument: populatedDoc)
|
||||
)
|
||||
}
|
||||
.environmentObject(ThemeManager.shared)
|
||||
.environment(\.dataManager, DataManagerObservable.shared)
|
||||
assertLightDark(populatedView, name: "document_detail_populated", file: #filePath, testName: #function, line: #line)
|
||||
}
|
||||
|
||||
func test_add_document() {
|
||||
snapDataFree("add_document") {
|
||||
AddDocumentView(
|
||||
residenceId: self.fixtureResidenceId,
|
||||
initialDocumentType: "other",
|
||||
isPresented: .constant(true),
|
||||
documentViewModel: DocumentViewModel()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Profile
|
||||
func test_edit_document() {
|
||||
let doc = fixtureDocument ?? FixtureDataManager.shared.populated().documents.value.first!
|
||||
snapDataFree("edit_document") {
|
||||
NavigationStack {
|
||||
EditDocumentView(document: doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func test_profile_tab_empty() {
|
||||
snap("profile_tab_empty") {
|
||||
// ========================================================================
|
||||
// MARK: - Profile / settings
|
||||
// ========================================================================
|
||||
|
||||
func test_profile() {
|
||||
snapDataCarrying("profile") {
|
||||
NavigationStack { ProfileTabView() }
|
||||
}
|
||||
}
|
||||
|
||||
func test_profile_tab_populated() {
|
||||
seedPopulated()
|
||||
snap("profile_tab_populated") {
|
||||
NavigationStack { ProfileTabView() }
|
||||
}
|
||||
}
|
||||
|
||||
func test_profile_edit_empty() {
|
||||
snap("profile_edit_empty") {
|
||||
func test_profile_edit() {
|
||||
snapDataFree("profile_edit") {
|
||||
NavigationStack { ProfileView() }
|
||||
}
|
||||
}
|
||||
|
||||
func test_profile_edit_populated() {
|
||||
seedPopulated()
|
||||
snap("profile_edit_populated") {
|
||||
NavigationStack { ProfileView() }
|
||||
}
|
||||
}
|
||||
|
||||
func test_notification_preferences_empty() {
|
||||
snap("notification_preferences_empty") {
|
||||
func test_notification_preferences() {
|
||||
snapDataFree("notification_preferences") {
|
||||
NavigationStack { NotificationPreferencesView() }
|
||||
}
|
||||
}
|
||||
|
||||
func test_notification_preferences_populated() {
|
||||
seedPopulated()
|
||||
snap("notification_preferences_populated") {
|
||||
NavigationStack { NotificationPreferencesView() }
|
||||
}
|
||||
}
|
||||
|
||||
func test_theme_selection_empty() {
|
||||
snap("theme_selection_empty") {
|
||||
NavigationStack { ThemeSelectionView() }
|
||||
}
|
||||
}
|
||||
|
||||
func test_theme_selection_populated() {
|
||||
seedPopulated()
|
||||
snap("theme_selection_populated") {
|
||||
func test_theme_selection() {
|
||||
snapDataFree("theme_selection") {
|
||||
NavigationStack { ThemeSelectionView() }
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// MARK: - Subscription
|
||||
// ========================================================================
|
||||
|
||||
func test_feature_comparison_empty() {
|
||||
snap("feature_comparison_empty") {
|
||||
func test_feature_comparison() {
|
||||
snapDataFree("feature_comparison") {
|
||||
FeatureComparisonView(isPresented: .constant(true))
|
||||
}
|
||||
}
|
||||
|
||||
func test_feature_comparison_populated() {
|
||||
seedPopulated()
|
||||
snap("feature_comparison_populated") {
|
||||
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
|
||||
|
||||
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 547 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 549 KiB |
|
After Width: | Height: | Size: 438 KiB |
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 534 KiB |
|
After Width: | Height: | Size: 435 KiB |
|
After Width: | Height: | Size: 479 KiB |
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 473 KiB |
|
After Width: | Height: | Size: 403 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 568 KiB |
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 555 KiB |
|
After Width: | Height: | Size: 489 KiB |
|
After Width: | Height: | Size: 569 KiB |
|
After Width: | Height: | Size: 464 KiB |
|
After Width: | Height: | Size: 456 KiB |
|
After Width: | Height: | Size: 391 KiB |
|
After Width: | Height: | Size: 568 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 722 KiB |
|
After Width: | Height: | Size: 617 KiB |
|
After Width: | Height: | Size: 802 KiB |
|
After Width: | Height: | Size: 702 KiB |
|
After Width: | Height: | Size: 714 KiB |
|
After Width: | Height: | Size: 654 KiB |
|
After Width: | Height: | Size: 748 KiB |
|
After Width: | Height: | Size: 736 KiB |
|
After Width: | Height: | Size: 776 KiB |
|
After Width: | Height: | Size: 681 KiB |
|
After Width: | Height: | Size: 793 KiB |
|
After Width: | Height: | Size: 709 KiB |
|
After Width: | Height: | Size: 908 KiB |
|
After Width: | Height: | Size: 805 KiB |
|
After Width: | Height: | Size: 726 KiB |
|
After Width: | Height: | Size: 680 KiB |
|
After Width: | Height: | Size: 674 KiB |
|
After Width: | Height: | Size: 585 KiB |
|
After Width: | Height: | Size: 793 KiB |
|
After Width: | Height: | Size: 702 KiB |
|
After Width: | Height: | Size: 888 KiB |
|
After Width: | Height: | Size: 748 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 517 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 523 KiB |
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 548 KiB |
|
After Width: | Height: | Size: 485 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 520 KiB |
|
After Width: | Height: | Size: 479 KiB |
|
After Width: | Height: | Size: 547 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 700 KiB |
|
After Width: | Height: | Size: 740 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 464 KiB |
|
After Width: | Height: | Size: 492 KiB |
|
After Width: | Height: | Size: 389 KiB |