Files
PlantGuide/PlantGuideTests/FilterPreferencesStorageTests.swift
Trey t 136dfbae33 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>
2026-01-23 12:18:01 -06:00

464 lines
14 KiB
Swift

//
// 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)
}
}