diff --git a/iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift b/iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift new file mode 100644 index 0000000..a9699ab --- /dev/null +++ b/iosApp/HoneyDueUITests/Suite11_TaskCacheRegressionTests.swift @@ -0,0 +1,221 @@ +import XCTest + +/// Suite 11 — captures the gitea#2 regression at the user-visible level: +/// after onboarding (register → name residence → bulk-create tasks → land +/// on home), tapping the residence cell shows "no tasks" even though the +/// server has them. Restarting the app fixes it. This test reproduces the +/// flow without an app restart and asserts that tasks render on the +/// residence detail screen. +/// +/// CRITICAL: this test must FAIL at the cache-unification fix's first +/// commit and must PASS after Phase 1-3 lands. The failing assertion is +/// pinned to a specific message so the regression is unambiguous. +/// +/// The test deliberately does NOT visit the Tasks tab between onboarding +/// and tapping the residence cell. Visiting the Tasks tab would prime +/// `_allTasks` and mask the bug — the bug is that residence detail +/// cannot recover from the empty-cache + sink-timing window on its own. +final class Suite11_TaskCacheRegressionTests: BaseUITestCase { + // We need to start at the onboarding welcome screen, not the standalone + // login screen — `completeOnboarding` would skip the entire flow. + override var completeOnboarding: Bool { false } + // Single test in this suite — relaunch isn't necessary, but we want a + // clean state every time (handled by the default --reset-state). + override var relaunchBetweenTests: Bool { true } + + // MARK: - Constants + + /// DEBUG_FIXED_CODES=true on the local Go API hardcodes this code. + private let debugVerificationCode = "123456" + + /// Stable name for the residence we create in onboarding. Used both for + /// the form input and to address the cell on the home screen via + /// `app.staticTexts[residenceName]` if the id-based identifier doesn't + /// resolve in time. + private let residenceName = "UI Test Property" + + // MARK: - Test + + /// Reproduces gitea#2: tasks created via the onboarding bulk endpoint + /// must appear on the residence detail screen without an app restart + /// and without first visiting the Tasks tab. + @MainActor + func test_tasksAppearOnResidenceDetail_afterOnboarding_withoutRestart() throws { + // Step 1 — Register a fresh user via the onboarding Start Fresh flow. + // The flow is: Welcome → ValueProps → NameResidence → CreateAccount + // → VerifyEmail → HomeProfile → FirstTask → main app. + let createAccount = TestFlows.navigateStartFreshToCreateAccount( + app: app, + residenceName: residenceName + ) + createAccount.waitForLoad(timeout: navigationTimeout) + + // Step 2 — Fill the create-account form. We address the onboarding + // form's fields (not the standalone register sheet's fields). + let creds = TestAccountManager.uniqueCredentials(prefix: "gitea2") + + createAccount.expandEmailSignup() + + // Use the same focusAndType path that OnboardingTests uses — it + // already handles SecureTextField + iOS strong-password panel. + // Under --ui-testing, OrganicOnboardingSecureField defaults to + // visibility=ON (renders as TextField) to dodge the iOS 26 SecureField + // keyboard bug. Query textFields, not secureTextFields. + let usernameField = app.textFields[AccessibilityIdentifiers.Onboarding.usernameField] + let emailField = app.textFields[AccessibilityIdentifiers.Onboarding.emailField] + let passwordField = app.textFields[AccessibilityIdentifiers.Onboarding.passwordField] + let confirmPasswordField = app.textFields[AccessibilityIdentifiers.Onboarding.confirmPasswordField] + + usernameField.waitForExistenceOrFail(timeout: navigationTimeout) + usernameField.focusAndType(creds.username, app: app) + emailField.waitForExistenceOrFail(timeout: navigationTimeout) + emailField.focusAndType(creds.email, app: app) + passwordField.waitForExistenceOrFail(timeout: navigationTimeout) + passwordField.focusAndType(creds.password, app: app) + confirmPasswordField.waitForExistenceOrFail(timeout: navigationTimeout) + confirmPasswordField.focusAndType(creds.password, app: app) + + let createAccountButton = app.descendants(matching: .any) + .matching(identifier: AccessibilityIdentifiers.Onboarding.createAccountButton) + .firstMatch + createAccountButton.waitForExistenceOrFail(timeout: navigationTimeout) + createAccountButton.forceTap() + + // Step 3 — Verify email with the debug fixed code. + let verification = VerificationScreen(app: app) + verification.waitForLoad(timeout: loginTimeout) + verification.enterCode(debugVerificationCode) + // Many onboarding verification screens auto-submit on a 6-digit + // code. If a verify button still exists and a code field is still + // visible, tap it to push past edge cases. + if verification.codeField.waitForExistence(timeout: 1) && verification.verifyButton.exists { + verification.submitCode() + } + + // Step 4 — Skip the home-profile step. The home-profile screen has + // its own Skip button (the shared onboarding skip in the nav bar) + // which routes to the first-task step without making us pick climate + // / appliance fields. + let onboardingSkipButton = app.buttons[AccessibilityIdentifiers.Onboarding.skipButton] + XCTAssertTrue( + onboardingSkipButton.waitForExistence(timeout: loginTimeout), + "Onboarding skip button should exist on the home-profile screen" + ) + // The skip button can briefly be non-hittable during the screen-in + // transition. Use forceTap() to bypass the strict hittable check. + // We confirmed existence above; if the tap doesn't land on the + // intended button the next assertion (Browse All tab) will catch it. + onboardingSkipButton.forceTap() + + // Step 5 — Switch to the "Browse All" tab on the First-Task screen. + // "For You" suggestions can be empty for a fresh residence with no + // home-profile data, so deterministic browsing is required. + // The tab bar is a SwiftUI segmented Picker — its segments are + // exposed as buttons with the segment label, regardless of an + // identifier on the parent. + let browseAllTab = app.buttons["Browse All"] + XCTAssertTrue( + browseAllTab.waitForExistence(timeout: loginTimeout), + "Browse All tab should appear on the first-task screen" + ) + browseAllTab.tap() + + // Step 6 — Pick 3 templates by accessibility identifier prefix. + // The catalog is loaded via GET /api/tasks/templates/grouped/, so + // we need to wait for at least one row to render before tapping. + let templateRowQuery = app.buttons.matching( + NSPredicate(format: "identifier BEGINSWITH %@", + AccessibilityIdentifiers.Onboarding.templateRowPrefix) + ) + + // Wait for the catalog to load. The grouped endpoint returns first + // category expanded by default in the view, so rows should appear + // shortly after Browse All becomes visible. Network call: 10s. + let firstRow = templateRowQuery.element(boundBy: 0) + XCTAssertTrue( + firstRow.waitForExistence(timeout: loginTimeout), + "At least one template row must render on the Browse All tab. " + + "If no rows appear, the catalog endpoint failed — bug repro is invalid." + ) + + // Tap the first 3 visible rows. Some categories may collapse rows + // we never see; we only need at least 1, so the floor is 1 with a + // soft cap of 3. + let rowCount = templateRowQuery.count + let toPick = min(3, rowCount) + XCTAssertGreaterThanOrEqual(toPick, 1, "Expected at least one template row") + for index in 0..