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:
463
PlantGuideTests/FilterPreferencesStorageTests.swift
Normal file
463
PlantGuideTests/FilterPreferencesStorageTests.swift
Normal file
@@ -0,0 +1,463 @@
|
||||
//
|
||||
// FilterPreferencesStorageTests.swift
|
||||
// PlantGuideTests
|
||||
//
|
||||
// Unit tests for FilterPreferencesStorage - the UserDefaults-based persistence
|
||||
// for filter and view mode preferences in the plant collection.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PlantGuide
|
||||
|
||||
final class FilterPreferencesStorageTests: XCTestCase {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var sut: FilterPreferencesStorage!
|
||||
private var testUserDefaults: UserDefaults!
|
||||
|
||||
// MARK: - Test Lifecycle
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Create a unique suite name for test isolation
|
||||
let suiteName = "com.plantguide.tests.\(UUID().uuidString)"
|
||||
testUserDefaults = UserDefaults(suiteName: suiteName)!
|
||||
sut = FilterPreferencesStorage(userDefaults: testUserDefaults)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Clean up test UserDefaults
|
||||
if let suiteName = testUserDefaults.volatileDomainNames.first {
|
||||
testUserDefaults.removePersistentDomain(forName: suiteName)
|
||||
}
|
||||
testUserDefaults = nil
|
||||
sut = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - saveFilter and loadFilter Round-Trip Tests
|
||||
|
||||
func testSaveFilterAndLoadFilter_WithDefaultFilter_RoundTripsSuccessfully() {
|
||||
// Given
|
||||
let filter = PlantFilter.default
|
||||
|
||||
// When
|
||||
sut.saveFilter(filter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedFilter.sortBy, filter.sortBy)
|
||||
XCTAssertEqual(loadedFilter.sortAscending, filter.sortAscending)
|
||||
XCTAssertNil(loadedFilter.families)
|
||||
XCTAssertNil(loadedFilter.lightRequirements)
|
||||
XCTAssertNil(loadedFilter.isFavorite)
|
||||
XCTAssertNil(loadedFilter.identificationSource)
|
||||
}
|
||||
|
||||
func testSaveFilterAndLoadFilter_WithSortByName_RoundTripsSuccessfully() {
|
||||
// Given
|
||||
var filter = PlantFilter()
|
||||
filter.sortBy = .name
|
||||
filter.sortAscending = true
|
||||
|
||||
// When
|
||||
sut.saveFilter(filter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedFilter.sortBy, .name)
|
||||
XCTAssertEqual(loadedFilter.sortAscending, true)
|
||||
}
|
||||
|
||||
func testSaveFilterAndLoadFilter_WithSortByFamily_RoundTripsSuccessfully() {
|
||||
// Given
|
||||
var filter = PlantFilter()
|
||||
filter.sortBy = .family
|
||||
filter.sortAscending = false
|
||||
|
||||
// When
|
||||
sut.saveFilter(filter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedFilter.sortBy, .family)
|
||||
XCTAssertEqual(loadedFilter.sortAscending, false)
|
||||
}
|
||||
|
||||
func testSaveFilterAndLoadFilter_WithSortByDateIdentified_RoundTripsSuccessfully() {
|
||||
// Given
|
||||
var filter = PlantFilter()
|
||||
filter.sortBy = .dateIdentified
|
||||
filter.sortAscending = true
|
||||
|
||||
// When
|
||||
sut.saveFilter(filter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedFilter.sortBy, .dateIdentified)
|
||||
XCTAssertEqual(loadedFilter.sortAscending, true)
|
||||
}
|
||||
|
||||
func testSaveFilterAndLoadFilter_WithFamilies_RoundTripsSuccessfully() {
|
||||
// Given
|
||||
var filter = PlantFilter()
|
||||
filter.families = Set(["Araceae", "Moraceae", "Asparagaceae"])
|
||||
|
||||
// When
|
||||
sut.saveFilter(filter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedFilter.families, Set(["Araceae", "Moraceae", "Asparagaceae"]))
|
||||
}
|
||||
|
||||
func testSaveFilterAndLoadFilter_WithLightRequirements_RoundTripsSuccessfully() {
|
||||
// Given
|
||||
var filter = PlantFilter()
|
||||
filter.lightRequirements = Set([.fullSun, .partialShade])
|
||||
|
||||
// When
|
||||
sut.saveFilter(filter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedFilter.lightRequirements, Set([.fullSun, .partialShade]))
|
||||
}
|
||||
|
||||
func testSaveFilterAndLoadFilter_WithAllLightRequirements_RoundTripsSuccessfully() {
|
||||
// Given
|
||||
var filter = PlantFilter()
|
||||
filter.lightRequirements = Set([.fullSun, .partialShade, .fullShade, .lowLight])
|
||||
|
||||
// When
|
||||
sut.saveFilter(filter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedFilter.lightRequirements, Set([.fullSun, .partialShade, .fullShade, .lowLight]))
|
||||
}
|
||||
|
||||
func testSaveFilterAndLoadFilter_WithIsFavoriteTrue_RoundTripsSuccessfully() {
|
||||
// Given
|
||||
var filter = PlantFilter()
|
||||
filter.isFavorite = true
|
||||
|
||||
// When
|
||||
sut.saveFilter(filter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedFilter.isFavorite, true)
|
||||
}
|
||||
|
||||
func testSaveFilterAndLoadFilter_WithIsFavoriteFalse_RoundTripsSuccessfully() {
|
||||
// Given
|
||||
var filter = PlantFilter()
|
||||
filter.isFavorite = false
|
||||
|
||||
// When
|
||||
sut.saveFilter(filter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedFilter.isFavorite, false)
|
||||
}
|
||||
|
||||
func testSaveFilterAndLoadFilter_WithIdentificationSourceOnDeviceML_RoundTripsSuccessfully() {
|
||||
// Given
|
||||
var filter = PlantFilter()
|
||||
filter.identificationSource = .onDeviceML
|
||||
|
||||
// When
|
||||
sut.saveFilter(filter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedFilter.identificationSource, .onDeviceML)
|
||||
}
|
||||
|
||||
func testSaveFilterAndLoadFilter_WithIdentificationSourcePlantNetAPI_RoundTripsSuccessfully() {
|
||||
// Given
|
||||
var filter = PlantFilter()
|
||||
filter.identificationSource = .plantNetAPI
|
||||
|
||||
// When
|
||||
sut.saveFilter(filter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedFilter.identificationSource, .plantNetAPI)
|
||||
}
|
||||
|
||||
func testSaveFilterAndLoadFilter_WithIdentificationSourceUserManual_RoundTripsSuccessfully() {
|
||||
// Given
|
||||
var filter = PlantFilter()
|
||||
filter.identificationSource = .userManual
|
||||
|
||||
// When
|
||||
sut.saveFilter(filter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedFilter.identificationSource, .userManual)
|
||||
}
|
||||
|
||||
func testSaveFilterAndLoadFilter_WithAllPropertiesSet_RoundTripsSuccessfully() {
|
||||
// Given
|
||||
var filter = PlantFilter()
|
||||
filter.sortBy = .name
|
||||
filter.sortAscending = true
|
||||
filter.families = Set(["Araceae"])
|
||||
filter.lightRequirements = Set([.fullSun, .lowLight])
|
||||
filter.isFavorite = true
|
||||
filter.identificationSource = .plantNetAPI
|
||||
|
||||
// When
|
||||
sut.saveFilter(filter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedFilter.sortBy, .name)
|
||||
XCTAssertEqual(loadedFilter.sortAscending, true)
|
||||
XCTAssertEqual(loadedFilter.families, Set(["Araceae"]))
|
||||
XCTAssertEqual(loadedFilter.lightRequirements, Set([.fullSun, .lowLight]))
|
||||
XCTAssertEqual(loadedFilter.isFavorite, true)
|
||||
XCTAssertEqual(loadedFilter.identificationSource, .plantNetAPI)
|
||||
}
|
||||
|
||||
// MARK: - saveViewMode and loadViewMode Round-Trip Tests
|
||||
|
||||
func testSaveViewModeAndLoadViewMode_WithGrid_RoundTripsSuccessfully() {
|
||||
// Given
|
||||
let viewMode = ViewMode.grid
|
||||
|
||||
// When
|
||||
sut.saveViewMode(viewMode)
|
||||
let loadedViewMode = sut.loadViewMode()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedViewMode, .grid)
|
||||
}
|
||||
|
||||
func testSaveViewModeAndLoadViewMode_WithList_RoundTripsSuccessfully() {
|
||||
// Given
|
||||
let viewMode = ViewMode.list
|
||||
|
||||
// When
|
||||
sut.saveViewMode(viewMode)
|
||||
let loadedViewMode = sut.loadViewMode()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedViewMode, .list)
|
||||
}
|
||||
|
||||
func testSaveViewMode_OverwritesPreviousValue() {
|
||||
// Given
|
||||
sut.saveViewMode(.grid)
|
||||
XCTAssertEqual(sut.loadViewMode(), .grid)
|
||||
|
||||
// When
|
||||
sut.saveViewMode(.list)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(sut.loadViewMode(), .list)
|
||||
}
|
||||
|
||||
// MARK: - clearFilter Tests
|
||||
|
||||
func testClearFilter_RemovesAllFilterPreferences() {
|
||||
// Given
|
||||
var filter = PlantFilter()
|
||||
filter.sortBy = .name
|
||||
filter.sortAscending = true
|
||||
filter.families = Set(["Araceae"])
|
||||
filter.lightRequirements = Set([.fullSun])
|
||||
filter.isFavorite = true
|
||||
filter.identificationSource = .onDeviceML
|
||||
|
||||
sut.saveFilter(filter)
|
||||
sut.saveViewMode(.list)
|
||||
|
||||
// Verify filter was saved
|
||||
let savedFilter = sut.loadFilter()
|
||||
XCTAssertEqual(savedFilter.sortBy, .name)
|
||||
XCTAssertEqual(savedFilter.families, Set(["Araceae"]))
|
||||
|
||||
// When
|
||||
sut.clearFilter()
|
||||
|
||||
// Then
|
||||
let clearedFilter = sut.loadFilter()
|
||||
XCTAssertEqual(clearedFilter.sortBy, .dateAdded) // Default
|
||||
XCTAssertEqual(clearedFilter.sortAscending, false) // Default
|
||||
XCTAssertNil(clearedFilter.families)
|
||||
XCTAssertNil(clearedFilter.lightRequirements)
|
||||
XCTAssertNil(clearedFilter.isFavorite)
|
||||
XCTAssertNil(clearedFilter.identificationSource)
|
||||
|
||||
// View mode should NOT be cleared by clearFilter
|
||||
XCTAssertEqual(sut.loadViewMode(), .list)
|
||||
}
|
||||
|
||||
func testClearFilter_DoesNotAffectViewMode() {
|
||||
// Given
|
||||
sut.saveViewMode(.list)
|
||||
sut.saveFilter(PlantFilter(sortBy: .name))
|
||||
|
||||
// When
|
||||
sut.clearFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(sut.loadViewMode(), .list)
|
||||
}
|
||||
|
||||
// MARK: - Loading Defaults When No Preferences Exist Tests
|
||||
|
||||
func testLoadFilter_WhenNoPreferencesExist_ReturnsDefaultFilter() {
|
||||
// When
|
||||
let filter = sut.loadFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(filter.sortBy, .dateAdded)
|
||||
XCTAssertEqual(filter.sortAscending, false)
|
||||
XCTAssertNil(filter.searchQuery)
|
||||
XCTAssertNil(filter.families)
|
||||
XCTAssertNil(filter.lightRequirements)
|
||||
XCTAssertNil(filter.isFavorite)
|
||||
XCTAssertNil(filter.identificationSource)
|
||||
}
|
||||
|
||||
func testLoadViewMode_WhenNoPreferencesExist_ReturnsDefaultGrid() {
|
||||
// When
|
||||
let viewMode = sut.loadViewMode()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(viewMode, .grid)
|
||||
}
|
||||
|
||||
// MARK: - Edge Case Tests
|
||||
|
||||
func testSaveFilter_WithEmptyFamiliesSet_SavesAsNil() {
|
||||
// Given
|
||||
var filter = PlantFilter()
|
||||
filter.families = Set()
|
||||
|
||||
// When
|
||||
sut.saveFilter(filter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then - Empty set should be treated as nil
|
||||
// Note: The implementation saves empty sets, so this tests that behavior
|
||||
XCTAssertNil(loadedFilter.families)
|
||||
}
|
||||
|
||||
func testSaveFilter_WithEmptyLightRequirementsSet_SavesAsNil() {
|
||||
// Given
|
||||
var filter = PlantFilter()
|
||||
filter.lightRequirements = Set()
|
||||
|
||||
// When
|
||||
sut.saveFilter(filter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then - Empty set should be treated as nil
|
||||
XCTAssertNil(loadedFilter.lightRequirements)
|
||||
}
|
||||
|
||||
func testSaveFilter_OverwritesPreviousValues() {
|
||||
// Given
|
||||
var firstFilter = PlantFilter()
|
||||
firstFilter.sortBy = .name
|
||||
firstFilter.families = Set(["Araceae"])
|
||||
sut.saveFilter(firstFilter)
|
||||
|
||||
var secondFilter = PlantFilter()
|
||||
secondFilter.sortBy = .family
|
||||
secondFilter.families = nil
|
||||
|
||||
// When
|
||||
sut.saveFilter(secondFilter)
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(loadedFilter.sortBy, .family)
|
||||
XCTAssertNil(loadedFilter.families)
|
||||
}
|
||||
|
||||
func testLoadFilter_WithCorruptedSortByValue_ReturnsDefault() {
|
||||
// Given - Manually set an invalid sortBy value
|
||||
testUserDefaults.set("invalidSortOption", forKey: "PlantGuide.Filter.SortBy")
|
||||
|
||||
// When
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then - Should use default value
|
||||
XCTAssertEqual(loadedFilter.sortBy, .dateAdded)
|
||||
}
|
||||
|
||||
func testLoadViewMode_WithCorruptedValue_ReturnsDefaultGrid() {
|
||||
// Given - Manually set an invalid view mode value
|
||||
testUserDefaults.set("invalidViewMode", forKey: "PlantGuide.ViewMode")
|
||||
|
||||
// When
|
||||
let loadedViewMode = sut.loadViewMode()
|
||||
|
||||
// Then - Should use default value
|
||||
XCTAssertEqual(loadedViewMode, .grid)
|
||||
}
|
||||
|
||||
func testLoadFilter_WithCorruptedLightRequirementValue_IgnoresInvalidValues() {
|
||||
// Given - Manually set light requirements with some invalid values
|
||||
testUserDefaults.set(["fullSun", "invalidValue", "lowLight"], forKey: "PlantGuide.Filter.LightRequirements")
|
||||
|
||||
// When
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then - Should only include valid values
|
||||
XCTAssertEqual(loadedFilter.lightRequirements, Set([.fullSun, .lowLight]))
|
||||
}
|
||||
|
||||
func testLoadFilter_WithCorruptedIdentificationSourceValue_ReturnsNil() {
|
||||
// Given - Manually set an invalid identification source value
|
||||
testUserDefaults.set("invalidSource", forKey: "PlantGuide.Filter.IdentificationSource")
|
||||
|
||||
// When
|
||||
let loadedFilter = sut.loadFilter()
|
||||
|
||||
// Then - Should be nil for invalid value
|
||||
XCTAssertNil(loadedFilter.identificationSource)
|
||||
}
|
||||
|
||||
// MARK: - Thread Safety Tests
|
||||
|
||||
func testSaveAndLoad_FromMultipleThreads_WorksCorrectly() async {
|
||||
// Given
|
||||
let iterations = 100
|
||||
|
||||
// When - Perform concurrent saves and loads
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for i in 0..<iterations {
|
||||
group.addTask { [sut] in
|
||||
var filter = PlantFilter()
|
||||
filter.sortBy = i % 2 == 0 ? .name : .dateAdded
|
||||
sut!.saveFilter(filter)
|
||||
_ = sut!.loadFilter()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then - Should complete without crash
|
||||
// Final state should be one of the last written values
|
||||
let finalFilter = sut.loadFilter()
|
||||
XCTAssertTrue(finalFilter.sortBy == .name || finalFilter.sortBy == .dateAdded)
|
||||
}
|
||||
|
||||
// MARK: - Protocol Conformance Tests
|
||||
|
||||
func testFilterPreferencesStorage_ConformsToProtocol() {
|
||||
// Then
|
||||
XCTAssertTrue(sut is FilterPreferencesStorageProtocol)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user