- 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>
494 lines
15 KiB
Swift
494 lines
15 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|
|
}
|