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:
493
PlantGuideTests/ImageCacheTests.swift
Normal file
493
PlantGuideTests/ImageCacheTests.swift
Normal file
@@ -0,0 +1,493 @@
|
||||
//
|
||||
// ImageCacheTests.swift
|
||||
// PlantGuideTests
|
||||
//
|
||||
// Unit tests for ImageCache - the actor-based image cache service
|
||||
// that provides both memory and disk caching for plant images.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PlantGuide
|
||||
|
||||
// MARK: - MockURLSession
|
||||
|
||||
/// Mock URL session for testing image downloads
|
||||
final class MockURLSessionForCache: URLSession, @unchecked Sendable {
|
||||
var dataToReturn: Data?
|
||||
var errorToThrow: Error?
|
||||
var downloadCallCount = 0
|
||||
var lastRequestedURL: URL?
|
||||
|
||||
override func data(from url: URL) async throws -> (Data, URLResponse) {
|
||||
downloadCallCount += 1
|
||||
lastRequestedURL = url
|
||||
|
||||
if let error = errorToThrow {
|
||||
throw error
|
||||
}
|
||||
|
||||
let response = HTTPURLResponse(
|
||||
url: url,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: nil
|
||||
)!
|
||||
|
||||
return (dataToReturn ?? Data(), response)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ImageCacheTests
|
||||
|
||||
final class ImageCacheTests: XCTestCase {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var sut: ImageCache!
|
||||
private var mockSession: MockURLSessionForCache!
|
||||
private var testDirectory: URL!
|
||||
private var fileManager: FileManager!
|
||||
|
||||
// MARK: - Test Lifecycle
|
||||
|
||||
override func setUp() async throws {
|
||||
try await super.setUp()
|
||||
|
||||
fileManager = FileManager.default
|
||||
mockSession = MockURLSessionForCache()
|
||||
|
||||
// Create a unique test directory for each test
|
||||
let tempDir = fileManager.temporaryDirectory
|
||||
testDirectory = tempDir.appendingPathComponent("ImageCacheTests_\(UUID().uuidString)")
|
||||
try fileManager.createDirectory(at: testDirectory, withIntermediateDirectories: true)
|
||||
|
||||
sut = ImageCache(
|
||||
fileManager: fileManager,
|
||||
urlSession: mockSession
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() async throws {
|
||||
// Clean up test directory
|
||||
if let testDirectory = testDirectory, fileManager.fileExists(atPath: testDirectory.path) {
|
||||
try? fileManager.removeItem(at: testDirectory)
|
||||
}
|
||||
|
||||
sut = nil
|
||||
mockSession = nil
|
||||
testDirectory = nil
|
||||
fileManager = nil
|
||||
|
||||
try await super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Test Helpers
|
||||
|
||||
private func createTestImageData() -> Data {
|
||||
let size = CGSize(width: 100, height: 100)
|
||||
let renderer = UIGraphicsImageRenderer(size: size)
|
||||
let image = renderer.image { context in
|
||||
UIColor.green.setFill()
|
||||
context.fill(CGRect(origin: .zero, size: size))
|
||||
}
|
||||
return image.jpegData(compressionQuality: 0.8)!
|
||||
}
|
||||
|
||||
private func createInvalidImageData() -> Data {
|
||||
return "This is not valid image data".data(using: .utf8)!
|
||||
}
|
||||
|
||||
// MARK: - cacheImage() Tests
|
||||
|
||||
func testCacheImage_WhenDownloadSucceeds_StoresImageInCache() async throws {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let url = URL(string: "https://example.com/plant.jpg")!
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// When
|
||||
try await sut.cacheImage(from: url, for: plantID)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(mockSession.downloadCallCount, 1)
|
||||
XCTAssertEqual(mockSession.lastRequestedURL, url)
|
||||
}
|
||||
|
||||
func testCacheImage_WhenDownloadFails_ThrowsDownloadFailed() async {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let url = URL(string: "https://example.com/plant.jpg")!
|
||||
mockSession.errorToThrow = URLError(.notConnectedToInternet)
|
||||
|
||||
// When/Then
|
||||
do {
|
||||
try await sut.cacheImage(from: url, for: plantID)
|
||||
XCTFail("Expected downloadFailed error to be thrown")
|
||||
} catch let error as ImageCacheError {
|
||||
switch error {
|
||||
case .downloadFailed:
|
||||
break // Expected
|
||||
default:
|
||||
XCTFail("Expected downloadFailed error, got \(error)")
|
||||
}
|
||||
} catch {
|
||||
XCTFail("Expected ImageCacheError, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testCacheImage_WhenInvalidImageData_ThrowsInvalidImageData() async {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let url = URL(string: "https://example.com/plant.jpg")!
|
||||
mockSession.dataToReturn = createInvalidImageData()
|
||||
|
||||
// When/Then
|
||||
do {
|
||||
try await sut.cacheImage(from: url, for: plantID)
|
||||
XCTFail("Expected invalidImageData error to be thrown")
|
||||
} catch let error as ImageCacheError {
|
||||
XCTAssertEqual(error, .invalidImageData)
|
||||
} catch {
|
||||
XCTFail("Expected ImageCacheError, got \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func testCacheImage_WhenAlreadyCached_DoesNotDownloadAgain() async throws {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let url = URL(string: "https://example.com/plant.jpg")!
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// When - Cache twice
|
||||
try await sut.cacheImage(from: url, for: plantID)
|
||||
try await sut.cacheImage(from: url, for: plantID)
|
||||
|
||||
// Then - Should only download once
|
||||
XCTAssertEqual(mockSession.downloadCallCount, 1)
|
||||
}
|
||||
|
||||
// MARK: - getCachedImage() Tests
|
||||
|
||||
func testGetCachedImage_WhenNotCached_ReturnsNil() async {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let url = URL(string: "https://example.com/plant.jpg")!
|
||||
|
||||
// When
|
||||
let result = await sut.getCachedImage(for: plantID, url: url)
|
||||
|
||||
// Then
|
||||
XCTAssertNil(result)
|
||||
}
|
||||
|
||||
func testGetCachedImage_WhenCached_ReturnsImage() async throws {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let url = URL(string: "https://example.com/plant.jpg")!
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// Cache the image first
|
||||
try await sut.cacheImage(from: url, for: plantID)
|
||||
|
||||
// When
|
||||
let result = await sut.getCachedImage(for: plantID, url: url)
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(result)
|
||||
}
|
||||
|
||||
func testGetCachedImage_WithDifferentPlantID_ReturnsNil() async throws {
|
||||
// Given
|
||||
let plantID1 = UUID()
|
||||
let plantID2 = UUID()
|
||||
let url = URL(string: "https://example.com/plant.jpg")!
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// Cache for plant 1
|
||||
try await sut.cacheImage(from: url, for: plantID1)
|
||||
|
||||
// When - Try to get for plant 2
|
||||
let result = await sut.getCachedImage(for: plantID2, url: url)
|
||||
|
||||
// Then
|
||||
XCTAssertNil(result)
|
||||
}
|
||||
|
||||
func testGetCachedImage_WithDifferentURL_ReturnsNil() async throws {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let url1 = URL(string: "https://example.com/plant1.jpg")!
|
||||
let url2 = URL(string: "https://example.com/plant2.jpg")!
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// Cache url1
|
||||
try await sut.cacheImage(from: url1, for: plantID)
|
||||
|
||||
// When - Try to get url2
|
||||
let result = await sut.getCachedImage(for: plantID, url: url2)
|
||||
|
||||
// Then
|
||||
XCTAssertNil(result)
|
||||
}
|
||||
|
||||
func testGetCachedImage_ByURLHash_ReturnsImage() async throws {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let url = URL(string: "https://example.com/plant.jpg")!
|
||||
let urlHash = url.absoluteString.sha256Hash
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// Cache the image
|
||||
try await sut.cacheImage(from: url, for: plantID)
|
||||
|
||||
// When
|
||||
let result = await sut.getCachedImage(for: plantID, urlHash: urlHash)
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(result)
|
||||
}
|
||||
|
||||
// MARK: - clearCache() Tests
|
||||
|
||||
func testClearCache_ForSpecificPlant_RemovesOnlyThatPlantsImages() async throws {
|
||||
// Given
|
||||
let plantID1 = UUID()
|
||||
let plantID2 = UUID()
|
||||
let url1 = URL(string: "https://example.com/plant1.jpg")!
|
||||
let url2 = URL(string: "https://example.com/plant2.jpg")!
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// Cache images for both plants
|
||||
try await sut.cacheImage(from: url1, for: plantID1)
|
||||
try await sut.cacheImage(from: url2, for: plantID2)
|
||||
|
||||
// When - Clear only plant 1's cache
|
||||
await sut.clearCache(for: plantID1)
|
||||
|
||||
// Then - Plant 1's image should be gone, plant 2's should still exist
|
||||
// Note: Due to memory cache clearing behavior, we need to re-cache
|
||||
// This test verifies the disk cache behavior
|
||||
mockSession.downloadCallCount = 0
|
||||
try await sut.cacheImage(from: url1, for: plantID1)
|
||||
XCTAssertEqual(mockSession.downloadCallCount, 1) // Had to redownload
|
||||
}
|
||||
|
||||
func testClearCache_ForPlant_RemovesMultipleImages() async throws {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let url1 = URL(string: "https://example.com/plant1.jpg")!
|
||||
let url2 = URL(string: "https://example.com/plant2.jpg")!
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// Cache multiple images for the same plant
|
||||
try await sut.cacheImage(from: url1, for: plantID)
|
||||
try await sut.cacheImage(from: url2, for: plantID)
|
||||
|
||||
// When
|
||||
await sut.clearCache(for: plantID)
|
||||
|
||||
// Then - Both images should be gone
|
||||
let result1 = await sut.getCachedImage(for: plantID, url: url1)
|
||||
let result2 = await sut.getCachedImage(for: plantID, url: url2)
|
||||
XCTAssertNil(result1)
|
||||
XCTAssertNil(result2)
|
||||
}
|
||||
|
||||
// MARK: - clearAllCache() Tests
|
||||
|
||||
func testClearAllCache_RemovesAllImages() async throws {
|
||||
// Given
|
||||
let plantID1 = UUID()
|
||||
let plantID2 = UUID()
|
||||
let url1 = URL(string: "https://example.com/plant1.jpg")!
|
||||
let url2 = URL(string: "https://example.com/plant2.jpg")!
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// Cache images for multiple plants
|
||||
try await sut.cacheImage(from: url1, for: plantID1)
|
||||
try await sut.cacheImage(from: url2, for: plantID2)
|
||||
|
||||
// When
|
||||
await sut.clearAllCache()
|
||||
|
||||
// Then - All images should be gone
|
||||
let result1 = await sut.getCachedImage(for: plantID1, url: url1)
|
||||
let result2 = await sut.getCachedImage(for: plantID2, url: url2)
|
||||
XCTAssertNil(result1)
|
||||
XCTAssertNil(result2)
|
||||
}
|
||||
|
||||
// MARK: - getCacheSize() Tests
|
||||
|
||||
func testGetCacheSize_WhenEmpty_ReturnsZero() async {
|
||||
// Given - New cache
|
||||
|
||||
// When
|
||||
let size = await sut.getCacheSize()
|
||||
|
||||
// Then
|
||||
XCTAssertGreaterThanOrEqual(size, 0)
|
||||
}
|
||||
|
||||
func testGetCacheSize_AfterCaching_ReturnsNonZero() async throws {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let url = URL(string: "https://example.com/plant.jpg")!
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// Cache an image
|
||||
try await sut.cacheImage(from: url, for: plantID)
|
||||
|
||||
// When
|
||||
let size = await sut.getCacheSize()
|
||||
|
||||
// Then
|
||||
XCTAssertGreaterThan(size, 0)
|
||||
}
|
||||
|
||||
func testGetCacheSize_AfterClearing_ReturnsZeroOrLess() async throws {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let url = URL(string: "https://example.com/plant.jpg")!
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// Cache an image
|
||||
try await sut.cacheImage(from: url, for: plantID)
|
||||
|
||||
// Clear cache
|
||||
await sut.clearAllCache()
|
||||
|
||||
// When
|
||||
let size = await sut.getCacheSize()
|
||||
|
||||
// Then - Size should be back to zero (or minimal)
|
||||
XCTAssertGreaterThanOrEqual(size, 0) // At least not negative
|
||||
}
|
||||
|
||||
// MARK: - Memory Cache Tests
|
||||
|
||||
func testMemoryCache_HitOnSecondAccess_NoReload() async throws {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let url = URL(string: "https://example.com/plant.jpg")!
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// Cache the image
|
||||
try await sut.cacheImage(from: url, for: plantID)
|
||||
|
||||
// When - Access twice
|
||||
let _ = await sut.getCachedImage(for: plantID, url: url)
|
||||
let result2 = await sut.getCachedImage(for: plantID, url: url)
|
||||
|
||||
// Then - Image should still be available
|
||||
XCTAssertNotNil(result2)
|
||||
}
|
||||
|
||||
// MARK: - Error Description Tests
|
||||
|
||||
func testImageCacheError_InvalidImageData_HasCorrectDescription() {
|
||||
// Given
|
||||
let error = ImageCacheError.invalidImageData
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(error.errorDescription)
|
||||
XCTAssertTrue(error.errorDescription?.contains("invalid") ?? false)
|
||||
}
|
||||
|
||||
func testImageCacheError_CompressionFailed_HasCorrectDescription() {
|
||||
// Given
|
||||
let error = ImageCacheError.compressionFailed
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(error.errorDescription)
|
||||
XCTAssertTrue(error.errorDescription?.contains("compress") ?? false)
|
||||
}
|
||||
|
||||
func testImageCacheError_WriteFailed_HasCorrectDescription() {
|
||||
// Given
|
||||
let error = ImageCacheError.writeFailed
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(error.errorDescription)
|
||||
XCTAssertTrue(error.errorDescription?.contains("write") ?? false)
|
||||
}
|
||||
|
||||
func testImageCacheError_DownloadFailed_HasCorrectDescription() {
|
||||
// Given
|
||||
let underlyingError = URLError(.notConnectedToInternet)
|
||||
let error = ImageCacheError.downloadFailed(underlyingError)
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(error.errorDescription)
|
||||
XCTAssertTrue(error.errorDescription?.contains("download") ?? false)
|
||||
}
|
||||
|
||||
// MARK: - Protocol Conformance Tests
|
||||
|
||||
func testImageCache_ConformsToProtocol() {
|
||||
XCTAssertTrue(sut is ImageCacheProtocol)
|
||||
}
|
||||
|
||||
// MARK: - Edge Cases
|
||||
|
||||
func testCacheImage_WithLongURL_Works() async throws {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let longPath = String(repeating: "path/", count: 50) + "image.jpg"
|
||||
let url = URL(string: "https://example.com/\(longPath)")!
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// When/Then - Should not throw
|
||||
try await sut.cacheImage(from: url, for: plantID)
|
||||
}
|
||||
|
||||
func testCacheImage_WithSpecialCharactersInURL_Works() async throws {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let url = URL(string: "https://example.com/plant%20image%231.jpg")!
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// When/Then - Should not throw
|
||||
try await sut.cacheImage(from: url, for: plantID)
|
||||
}
|
||||
|
||||
func testCacheImage_MultipleImagesForSamePlant_AllCached() async throws {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let urls = (0..<5).map { URL(string: "https://example.com/plant\($0).jpg")! }
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// When - Cache all images
|
||||
for url in urls {
|
||||
try await sut.cacheImage(from: url, for: plantID)
|
||||
}
|
||||
|
||||
// Then - All should be retrievable
|
||||
for url in urls {
|
||||
let result = await sut.getCachedImage(for: plantID, url: url)
|
||||
XCTAssertNotNil(result, "Image for \(url) should be cached")
|
||||
}
|
||||
}
|
||||
|
||||
func testCacheImage_ConcurrentAccess_HandledCorrectly() async throws {
|
||||
// Given
|
||||
let plantID = UUID()
|
||||
let urls = (0..<10).map { URL(string: "https://example.com/plant\($0).jpg")! }
|
||||
mockSession.dataToReturn = createTestImageData()
|
||||
|
||||
// When - Cache concurrently
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
for url in urls {
|
||||
group.addTask {
|
||||
try? await self.sut.cacheImage(from: url, for: plantID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Then - All should be retrievable (no crashes)
|
||||
for url in urls {
|
||||
let result = await sut.getCachedImage(for: plantID, url: url)
|
||||
XCTAssertNotNil(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user