- Implement camera capture and plant identification workflow - Add Core Data persistence for plants, care schedules, and cached API data - Create collection view with grid/list layouts and filtering - Build plant detail views with care information display - Integrate Trefle botanical API for plant care data - Add local image storage for captured plant photos - Implement dependency injection container for testability - Include accessibility support throughout the app Bug fixes in this commit: - Fix Trefle API decoding by removing duplicate CodingKeys - Fix LocalCachedImage to load from correct PlantImages directory - Set dateAdded when saving plants for proper collection sorting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
501 lines
18 KiB
Swift
501 lines
18 KiB
Swift
//
|
|
// NavigationUITests.swift
|
|
// PlantGuideUITests
|
|
//
|
|
// Created on 2026-01-21.
|
|
//
|
|
// UI tests for app navigation including tab bar navigation
|
|
// and deep navigation flows between views.
|
|
//
|
|
|
|
import XCTest
|
|
|
|
final class NavigationUITests: XCTestCase {
|
|
|
|
// MARK: - Properties
|
|
|
|
var app: XCUIApplication!
|
|
|
|
// MARK: - Setup & Teardown
|
|
|
|
override func setUpWithError() throws {
|
|
continueAfterFailure = false
|
|
app = XCUIApplication()
|
|
}
|
|
|
|
override func tearDownWithError() throws {
|
|
app = nil
|
|
}
|
|
|
|
// MARK: - Tab Bar Accessibility Tests
|
|
|
|
/// Tests that all tabs are accessible in the tab bar.
|
|
@MainActor
|
|
func testAllTabsAreAccessible() throws {
|
|
// Given: App launched
|
|
app.launchWithMockData()
|
|
|
|
// Then: All four tabs should be present and accessible
|
|
let tabBar = app.tabBars.firstMatch
|
|
XCTAssertTrue(tabBar.waitForExistence(timeout: 5), "Tab bar should exist")
|
|
|
|
// Verify Camera tab
|
|
let cameraTab = tabBar.buttons[AccessibilityID.TabBar.camera]
|
|
XCTAssertTrue(cameraTab.exists, "Camera tab should be accessible")
|
|
|
|
// Verify Collection tab
|
|
let collectionTab = tabBar.buttons[AccessibilityID.TabBar.collection]
|
|
XCTAssertTrue(collectionTab.exists, "Collection tab should be accessible")
|
|
|
|
// Verify Care tab
|
|
let careTab = tabBar.buttons[AccessibilityID.TabBar.care]
|
|
XCTAssertTrue(careTab.exists, "Care tab should be accessible")
|
|
|
|
// Verify Settings tab
|
|
let settingsTab = tabBar.buttons[AccessibilityID.TabBar.settings]
|
|
XCTAssertTrue(settingsTab.exists, "Settings tab should be accessible")
|
|
}
|
|
|
|
/// Tests that tab buttons have correct labels for accessibility.
|
|
@MainActor
|
|
func testTabButtonLabels() throws {
|
|
// Given: App launched
|
|
app.launchWithMockData()
|
|
|
|
let tabBar = app.tabBars.firstMatch
|
|
XCTAssertTrue(tabBar.waitForExistence(timeout: 5), "Tab bar should exist")
|
|
|
|
// Then: Verify each tab has the correct label
|
|
let expectedTabs = ["Camera", "Collection", "Care", "Settings"]
|
|
|
|
for tabName in expectedTabs {
|
|
let tab = tabBar.buttons[tabName]
|
|
XCTAssertTrue(tab.exists, "\(tabName) tab should have correct label")
|
|
}
|
|
}
|
|
|
|
// MARK: - Tab Navigation Tests
|
|
|
|
/// Tests navigation to Camera tab.
|
|
@MainActor
|
|
func testNavigateToCameraTab() throws {
|
|
// Given: App launched
|
|
app.launchWithMockData()
|
|
|
|
// Start from Collection tab
|
|
app.navigateToTab(AccessibilityID.TabBar.collection)
|
|
|
|
// When: Navigate to Camera tab
|
|
app.navigateToTab(AccessibilityID.TabBar.camera)
|
|
|
|
// Then: Camera tab should be selected
|
|
let cameraTab = app.tabBars.buttons[AccessibilityID.TabBar.camera]
|
|
XCTAssertTrue(cameraTab.isSelected, "Camera tab should be selected")
|
|
|
|
// Camera view content should be visible
|
|
// Either permission view or camera controls
|
|
let permissionText = app.staticTexts["Camera Access Required"]
|
|
let captureButton = app.buttons["Capture photo"]
|
|
let deniedText = app.staticTexts["Camera Access Denied"]
|
|
|
|
let cameraContentVisible = permissionText.waitForExistence(timeout: 3) ||
|
|
captureButton.waitForExistence(timeout: 2) ||
|
|
deniedText.waitForExistence(timeout: 2)
|
|
|
|
XCTAssertTrue(cameraContentVisible, "Camera view content should be visible")
|
|
}
|
|
|
|
/// Tests navigation to Collection tab.
|
|
@MainActor
|
|
func testNavigateToCollectionTab() throws {
|
|
// Given: App launched
|
|
app.launchWithMockData()
|
|
|
|
// When: Navigate to Collection tab
|
|
app.navigateToTab(AccessibilityID.TabBar.collection)
|
|
|
|
// Then: Collection tab should be selected
|
|
let collectionTab = app.tabBars.buttons[AccessibilityID.TabBar.collection]
|
|
XCTAssertTrue(collectionTab.isSelected, "Collection tab should be selected")
|
|
|
|
// Collection navigation title should appear
|
|
let collectionTitle = app.navigationBars["My Plants"]
|
|
XCTAssertTrue(
|
|
collectionTitle.waitForExistence(timeout: 5),
|
|
"Collection navigation title should appear"
|
|
)
|
|
}
|
|
|
|
/// Tests navigation to Care tab.
|
|
@MainActor
|
|
func testNavigateToCareTab() throws {
|
|
// Given: App launched
|
|
app.launchWithMockData()
|
|
|
|
// When: Navigate to Care tab
|
|
app.navigateToTab(AccessibilityID.TabBar.care)
|
|
|
|
// Then: Care tab should be selected
|
|
let careTab = app.tabBars.buttons[AccessibilityID.TabBar.care]
|
|
XCTAssertTrue(careTab.isSelected, "Care tab should be selected")
|
|
|
|
// Care Schedule navigation title should appear
|
|
let careTitle = app.navigationBars["Care Schedule"]
|
|
XCTAssertTrue(
|
|
careTitle.waitForExistence(timeout: 5),
|
|
"Care Schedule navigation title should appear"
|
|
)
|
|
}
|
|
|
|
/// Tests navigation to Settings tab.
|
|
@MainActor
|
|
func testNavigateToSettingsTab() throws {
|
|
// Given: App launched
|
|
app.launchWithMockData()
|
|
|
|
// When: Navigate to Settings tab
|
|
app.navigateToTab(AccessibilityID.TabBar.settings)
|
|
|
|
// Then: Settings tab should be selected
|
|
let settingsTab = app.tabBars.buttons[AccessibilityID.TabBar.settings]
|
|
XCTAssertTrue(settingsTab.isSelected, "Settings tab should be selected")
|
|
|
|
// Settings navigation title should appear
|
|
let settingsTitle = app.navigationBars["Settings"]
|
|
XCTAssertTrue(
|
|
settingsTitle.waitForExistence(timeout: 5),
|
|
"Settings navigation title should appear"
|
|
)
|
|
}
|
|
|
|
// MARK: - Tab Navigation Round Trip Tests
|
|
|
|
/// Tests navigating between all tabs in sequence.
|
|
@MainActor
|
|
func testNavigatingBetweenAllTabs() throws {
|
|
// Given: App launched
|
|
app.launchWithMockData()
|
|
|
|
let tabNames = [
|
|
AccessibilityID.TabBar.collection,
|
|
AccessibilityID.TabBar.care,
|
|
AccessibilityID.TabBar.settings,
|
|
AccessibilityID.TabBar.camera
|
|
]
|
|
|
|
// When: Navigate through all tabs
|
|
for tabName in tabNames {
|
|
app.navigateToTab(tabName)
|
|
|
|
// Then: Tab should be selected
|
|
let tab = app.tabBars.buttons[tabName]
|
|
XCTAssertTrue(
|
|
tab.isSelected,
|
|
"\(tabName) tab should be selected after navigation"
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Tests rapid tab switching doesn't cause crashes.
|
|
@MainActor
|
|
func testRapidTabSwitching() throws {
|
|
// Given: App launched
|
|
app.launchWithMockData()
|
|
|
|
let tabNames = [
|
|
AccessibilityID.TabBar.camera,
|
|
AccessibilityID.TabBar.collection,
|
|
AccessibilityID.TabBar.care,
|
|
AccessibilityID.TabBar.settings
|
|
]
|
|
|
|
// When: Rapidly switch between tabs multiple times
|
|
for _ in 0..<3 {
|
|
for tabName in tabNames {
|
|
let tab = app.tabBars.buttons[tabName]
|
|
if tab.exists {
|
|
tab.tap()
|
|
}
|
|
}
|
|
}
|
|
|
|
// Then: App should still be functional
|
|
let tabBar = app.tabBars.firstMatch
|
|
XCTAssertTrue(tabBar.exists, "Tab bar should still exist after rapid switching")
|
|
}
|
|
|
|
// MARK: - Deep Navigation Tests
|
|
|
|
/// Tests deep navigation: Collection -> Plant Detail.
|
|
@MainActor
|
|
func testCollectionToPlantDetailNavigation() throws {
|
|
// Given: App launched with mock data
|
|
app.launchWithMockData()
|
|
app.navigateToTab(AccessibilityID.TabBar.collection)
|
|
|
|
// Wait for collection to load
|
|
let collectionTitle = app.navigationBars["My Plants"]
|
|
XCTAssertTrue(collectionTitle.waitForExistence(timeout: 5), "Collection should load")
|
|
|
|
// When: Tap on a plant cell
|
|
// First check if there are plants (in grid view, they're in scroll view)
|
|
let scrollView = app.scrollViews.firstMatch
|
|
|
|
if scrollView.waitForExistence(timeout: 3) {
|
|
// Find any tappable plant element
|
|
let plantCell = scrollView.buttons.firstMatch.exists ?
|
|
scrollView.buttons.firstMatch :
|
|
scrollView.otherElements.firstMatch
|
|
|
|
if plantCell.waitForExistence(timeout: 3) {
|
|
plantCell.tap()
|
|
|
|
// Then: Plant detail view should appear
|
|
let detailTitle = app.navigationBars["Plant Details"]
|
|
let backButton = app.navigationBars.buttons["My Plants"]
|
|
|
|
let detailAppeared = detailTitle.waitForExistence(timeout: 5) ||
|
|
backButton.waitForExistence(timeout: 3)
|
|
|
|
XCTAssertTrue(
|
|
detailAppeared,
|
|
"Plant detail view should appear after tapping plant"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tests deep navigation: Collection -> Plant Detail -> Back.
|
|
@MainActor
|
|
func testCollectionDetailAndBackNavigation() throws {
|
|
// Given: App launched with mock data and navigated to detail
|
|
app.launchWithMockData()
|
|
app.navigateToTab(AccessibilityID.TabBar.collection)
|
|
|
|
let collectionTitle = app.navigationBars["My Plants"]
|
|
XCTAssertTrue(collectionTitle.waitForExistence(timeout: 5), "Collection should load")
|
|
|
|
let scrollView = app.scrollViews.firstMatch
|
|
|
|
if scrollView.waitForExistence(timeout: 3) {
|
|
let plantCell = scrollView.buttons.firstMatch.exists ?
|
|
scrollView.buttons.firstMatch :
|
|
scrollView.otherElements.firstMatch
|
|
|
|
if plantCell.waitForExistence(timeout: 3) {
|
|
plantCell.tap()
|
|
|
|
// Wait for detail to appear
|
|
let backButton = app.navigationBars.buttons["My Plants"]
|
|
|
|
if backButton.waitForExistence(timeout: 5) {
|
|
// When: Tap back button
|
|
backButton.tap()
|
|
|
|
// Then: Should return to collection
|
|
XCTAssertTrue(
|
|
collectionTitle.waitForExistence(timeout: 5),
|
|
"Should return to collection after back navigation"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Tests deep navigation: Collection -> Plant Detail -> Care Schedule section.
|
|
@MainActor
|
|
func testCollectionToPlantDetailToCareSchedule() throws {
|
|
// Given: App launched with mock data
|
|
app.launchWithMockData()
|
|
app.navigateToTab(AccessibilityID.TabBar.collection)
|
|
|
|
let collectionTitle = app.navigationBars["My Plants"]
|
|
XCTAssertTrue(collectionTitle.waitForExistence(timeout: 5), "Collection should load")
|
|
|
|
let scrollView = app.scrollViews.firstMatch
|
|
|
|
if scrollView.waitForExistence(timeout: 3) {
|
|
let plantCell = scrollView.buttons.firstMatch.exists ?
|
|
scrollView.buttons.firstMatch :
|
|
scrollView.otherElements.firstMatch
|
|
|
|
if plantCell.waitForExistence(timeout: 3) {
|
|
plantCell.tap()
|
|
|
|
// Wait for detail to load
|
|
let detailLoaded = app.navigationBars.buttons["My Plants"].waitForExistence(timeout: 5)
|
|
|
|
if detailLoaded {
|
|
// When: Look for care information in detail view
|
|
// The PlantDetailView shows care info section if available
|
|
let careSection = app.staticTexts.matching(
|
|
NSPredicate(format: "label CONTAINS[c] 'care' OR label CONTAINS[c] 'watering'")
|
|
).firstMatch
|
|
|
|
let upcomingTasks = app.staticTexts["Upcoming Tasks"]
|
|
|
|
// Then: Care-related content should be visible or loadable
|
|
let careContentVisible = careSection.waitForExistence(timeout: 3) ||
|
|
upcomingTasks.waitForExistence(timeout: 2)
|
|
|
|
// If no care data, loading state or error should show
|
|
let loadingText = app.staticTexts["Loading care information..."]
|
|
let errorView = app.staticTexts["Unable to Load Care Info"]
|
|
|
|
XCTAssertTrue(
|
|
careContentVisible || loadingText.exists || errorView.exists || detailLoaded,
|
|
"Plant detail should show care content or loading state"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Navigation State Preservation Tests
|
|
|
|
/// Tests that tab state is preserved when switching tabs.
|
|
@MainActor
|
|
func testTabStatePreservation() throws {
|
|
// Given: App launched with mock data
|
|
app.launchWithMockData()
|
|
app.navigateToTab(AccessibilityID.TabBar.collection)
|
|
|
|
// Perform search to establish state
|
|
let searchField = app.searchFields.firstMatch
|
|
if searchField.waitForExistence(timeout: 5) {
|
|
searchField.tap()
|
|
searchField.typeText("Test")
|
|
}
|
|
|
|
// When: Switch to another tab and back
|
|
app.navigateToTab(AccessibilityID.TabBar.settings)
|
|
app.navigateToTab(AccessibilityID.TabBar.collection)
|
|
|
|
// Then: Collection view should be restored
|
|
let collectionTitle = app.navigationBars["My Plants"]
|
|
XCTAssertTrue(
|
|
collectionTitle.waitForExistence(timeout: 5),
|
|
"Collection should be restored after tab switch"
|
|
)
|
|
}
|
|
|
|
/// Tests navigation with navigation stack (push/pop).
|
|
@MainActor
|
|
func testNavigationStackPushPop() throws {
|
|
// Given: App launched with mock data
|
|
app.launchWithMockData()
|
|
app.navigateToTab(AccessibilityID.TabBar.collection)
|
|
|
|
// Record initial navigation bar count
|
|
let initialNavBarCount = app.navigationBars.count
|
|
|
|
let scrollView = app.scrollViews.firstMatch
|
|
|
|
if scrollView.waitForExistence(timeout: 3) {
|
|
let plantCell = scrollView.buttons.firstMatch.exists ?
|
|
scrollView.buttons.firstMatch :
|
|
scrollView.otherElements.firstMatch
|
|
|
|
if plantCell.waitForExistence(timeout: 3) {
|
|
// When: Push to detail view
|
|
plantCell.tap()
|
|
|
|
let backButton = app.navigationBars.buttons["My Plants"]
|
|
if backButton.waitForExistence(timeout: 5) {
|
|
// Then: Pop back
|
|
backButton.tap()
|
|
|
|
// Navigation should return to initial state
|
|
let collectionTitle = app.navigationBars["My Plants"]
|
|
XCTAssertTrue(
|
|
collectionTitle.waitForExistence(timeout: 5),
|
|
"Should pop back to collection"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Edge Case Tests
|
|
|
|
/// Tests that tapping already selected tab doesn't cause issues.
|
|
@MainActor
|
|
func testTappingAlreadySelectedTab() throws {
|
|
// Given: App launched
|
|
app.launchWithMockData()
|
|
app.navigateToTab(AccessibilityID.TabBar.collection)
|
|
|
|
let collectionTitle = app.navigationBars["My Plants"]
|
|
XCTAssertTrue(collectionTitle.waitForExistence(timeout: 5), "Collection should load")
|
|
|
|
// When: Tap the already selected tab multiple times
|
|
let collectionTab = app.tabBars.buttons[AccessibilityID.TabBar.collection]
|
|
collectionTab.tap()
|
|
collectionTab.tap()
|
|
collectionTab.tap()
|
|
|
|
// Then: Should remain functional without crashes
|
|
XCTAssertTrue(collectionTitle.exists, "Collection should remain visible")
|
|
XCTAssertTrue(collectionTab.isSelected, "Collection tab should remain selected")
|
|
}
|
|
|
|
/// Tests navigation state after app goes to background and foreground.
|
|
@MainActor
|
|
func testNavigationAfterBackgroundForeground() throws {
|
|
// Given: App launched and navigated to a specific tab
|
|
app.launchWithMockData()
|
|
app.navigateToTab(AccessibilityID.TabBar.settings)
|
|
|
|
let settingsTitle = app.navigationBars["Settings"]
|
|
XCTAssertTrue(settingsTitle.waitForExistence(timeout: 5), "Settings should load")
|
|
|
|
// When: App goes to background (simulated by pressing home)
|
|
// Note: XCUIDevice().press(.home) would put app in background
|
|
// but we can't easily return it, so we verify the state is stable
|
|
|
|
// Verify navigation is still correct
|
|
let settingsTab = app.tabBars.buttons[AccessibilityID.TabBar.settings]
|
|
XCTAssertTrue(settingsTab.isSelected, "Settings tab should remain selected")
|
|
}
|
|
|
|
// MARK: - Tab Bar Visibility Tests
|
|
|
|
/// Tests tab bar remains visible during navigation.
|
|
@MainActor
|
|
func testTabBarVisibleDuringNavigation() throws {
|
|
// Given: App launched
|
|
app.launchWithMockData()
|
|
|
|
// When: Navigate to different tabs
|
|
for tabName in [AccessibilityID.TabBar.collection, AccessibilityID.TabBar.care, AccessibilityID.TabBar.settings] {
|
|
app.navigateToTab(tabName)
|
|
|
|
// Then: Tab bar should always be visible
|
|
let tabBar = app.tabBars.firstMatch
|
|
XCTAssertTrue(tabBar.exists, "Tab bar should be visible on \(tabName) tab")
|
|
}
|
|
}
|
|
|
|
/// Tests tab bar hides appropriately during full screen presentations.
|
|
@MainActor
|
|
func testTabBarBehaviorDuringFullScreenPresentation() throws {
|
|
// Given: App launched with potential for full screen cover (camera -> identification)
|
|
app.launchWithConfiguration(mockData: true, additionalEnvironment: [
|
|
"MOCK_CAPTURED_IMAGE": "YES"
|
|
])
|
|
app.navigateToTab(AccessibilityID.TabBar.camera)
|
|
|
|
// Look for use photo button which triggers full screen cover
|
|
let usePhotoButton = app.buttons["Use this photo"]
|
|
|
|
if usePhotoButton.waitForExistence(timeout: 5) {
|
|
usePhotoButton.tap()
|
|
|
|
// Wait for full screen cover
|
|
// Tab bar may or may not be visible depending on implementation
|
|
// Just verify no crash
|
|
XCTAssertTrue(app.exists, "App should handle full screen presentation")
|
|
}
|
|
}
|
|
}
|