Add PlantGuide iOS app with plant identification and care management

- 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>
This commit is contained in:
Trey t
2026-01-23 12:18:01 -06:00
parent d3ab29eb84
commit 136dfbae33
187 changed files with 69001 additions and 0 deletions

View File

@@ -0,0 +1,500 @@
//
// 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")
}
}
}