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:
540
PlantGuideTests/TrefleDTOsTests.swift
Normal file
540
PlantGuideTests/TrefleDTOsTests.swift
Normal file
@@ -0,0 +1,540 @@
|
||||
//
|
||||
// TrefleDTOsTests.swift
|
||||
// PlantGuideTests
|
||||
//
|
||||
// Unit tests for Trefle API DTO decoding.
|
||||
// Tests JSON decoding of all DTOs using sample Trefle API responses.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import PlantGuide
|
||||
|
||||
final class TrefleDTOsTests: XCTestCase {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var decoder: JSONDecoder!
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
decoder = nil
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Sample JSON Data
|
||||
|
||||
/// Sample search response JSON from Trefle API documentation
|
||||
private let searchResponseJSON = """
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 834,
|
||||
"common_name": "Swiss cheese plant",
|
||||
"slug": "monstera-deliciosa",
|
||||
"scientific_name": "Monstera deliciosa",
|
||||
"year": 1849,
|
||||
"bibliography": "Vidensk. Meddel. Naturhist. Foren. Kjøbenhavn 1849: 19 (1849)",
|
||||
"author": "Liebm.",
|
||||
"family_common_name": "Arum family",
|
||||
"genus_id": 1254,
|
||||
"image_url": "https://bs.plantnet.org/image/o/abc123",
|
||||
"genus": "Monstera",
|
||||
"family": "Araceae"
|
||||
}
|
||||
],
|
||||
"links": {
|
||||
"self": "/api/v1/plants/search?q=monstera",
|
||||
"first": "/api/v1/plants/search?page=1&q=monstera",
|
||||
"last": "/api/v1/plants/search?page=1&q=monstera"
|
||||
},
|
||||
"meta": {
|
||||
"total": 12
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
/// Sample species detail response JSON from Trefle API documentation
|
||||
private let speciesResponseJSON = """
|
||||
{
|
||||
"data": {
|
||||
"id": 834,
|
||||
"common_name": "Swiss cheese plant",
|
||||
"slug": "monstera-deliciosa",
|
||||
"scientific_name": "Monstera deliciosa",
|
||||
"growth": {
|
||||
"light": 6,
|
||||
"atmospheric_humidity": 8,
|
||||
"minimum_temperature": {
|
||||
"deg_c": 15
|
||||
},
|
||||
"maximum_temperature": {
|
||||
"deg_c": 30
|
||||
},
|
||||
"soil_humidity": 7,
|
||||
"soil_nutriments": 5
|
||||
},
|
||||
"specifications": {
|
||||
"growth_rate": "moderate",
|
||||
"toxicity": "mild"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"last_modified": "2023-01-15T12:00:00Z"
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
// MARK: - TrefleSearchResponseDTO Tests
|
||||
|
||||
func testSearchResponseDecoding() throws {
|
||||
// When
|
||||
let response = try decoder.decode(TrefleSearchResponseDTO.self, from: searchResponseJSON)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(response.data.count, 1)
|
||||
XCTAssertEqual(response.meta.total, 12)
|
||||
XCTAssertEqual(response.links.selfLink, "/api/v1/plants/search?q=monstera")
|
||||
}
|
||||
|
||||
func testSearchResponseDataContainsPlantSummary() throws {
|
||||
// When
|
||||
let response = try decoder.decode(TrefleSearchResponseDTO.self, from: searchResponseJSON)
|
||||
let plant = response.data.first
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(plant)
|
||||
XCTAssertEqual(plant?.id, 834)
|
||||
XCTAssertEqual(plant?.commonName, "Swiss cheese plant")
|
||||
XCTAssertEqual(plant?.slug, "monstera-deliciosa")
|
||||
XCTAssertEqual(plant?.scientificName, "Monstera deliciosa")
|
||||
XCTAssertEqual(plant?.family, "Araceae")
|
||||
XCTAssertEqual(plant?.genus, "Monstera")
|
||||
XCTAssertEqual(plant?.imageUrl, "https://bs.plantnet.org/image/o/abc123")
|
||||
}
|
||||
|
||||
func testSearchResponseLinksDecoding() throws {
|
||||
// When
|
||||
let response = try decoder.decode(TrefleSearchResponseDTO.self, from: searchResponseJSON)
|
||||
let links = response.links
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(links.selfLink, "/api/v1/plants/search?q=monstera")
|
||||
XCTAssertEqual(links.first, "/api/v1/plants/search?page=1&q=monstera")
|
||||
XCTAssertEqual(links.last, "/api/v1/plants/search?page=1&q=monstera")
|
||||
XCTAssertNil(links.next)
|
||||
XCTAssertNil(links.prev)
|
||||
}
|
||||
|
||||
// MARK: - TrefleSpeciesResponseDTO Tests
|
||||
|
||||
func testSpeciesResponseDecoding() throws {
|
||||
// When
|
||||
let response = try decoder.decode(TrefleSpeciesResponseDTO.self, from: speciesResponseJSON)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(response.data.id, 834)
|
||||
XCTAssertEqual(response.data.scientificName, "Monstera deliciosa")
|
||||
XCTAssertEqual(response.meta.lastModified, "2023-01-15T12:00:00Z")
|
||||
}
|
||||
|
||||
func testSpeciesResponseGrowthDataDecoding() throws {
|
||||
// When
|
||||
let response = try decoder.decode(TrefleSpeciesResponseDTO.self, from: speciesResponseJSON)
|
||||
let growth = response.data.growth
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(growth)
|
||||
XCTAssertEqual(growth?.light, 6)
|
||||
XCTAssertEqual(growth?.atmosphericHumidity, 8)
|
||||
XCTAssertEqual(growth?.soilHumidity, 7)
|
||||
XCTAssertEqual(growth?.soilNutriments, 5)
|
||||
}
|
||||
|
||||
func testSpeciesResponseTemperatureDecoding() throws {
|
||||
// When
|
||||
let response = try decoder.decode(TrefleSpeciesResponseDTO.self, from: speciesResponseJSON)
|
||||
let growth = response.data.growth
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(growth?.minimumTemperature)
|
||||
XCTAssertEqual(growth?.minimumTemperature?.degC, 15)
|
||||
XCTAssertNotNil(growth?.maximumTemperature)
|
||||
XCTAssertEqual(growth?.maximumTemperature?.degC, 30)
|
||||
}
|
||||
|
||||
func testSpeciesResponseSpecificationsDecoding() throws {
|
||||
// When
|
||||
let response = try decoder.decode(TrefleSpeciesResponseDTO.self, from: speciesResponseJSON)
|
||||
let specifications = response.data.specifications
|
||||
|
||||
// Then
|
||||
XCTAssertNotNil(specifications)
|
||||
XCTAssertEqual(specifications?.growthRate, "moderate")
|
||||
XCTAssertEqual(specifications?.toxicity, "mild")
|
||||
}
|
||||
|
||||
// MARK: - TreflePlantSummaryDTO Tests
|
||||
|
||||
func testPlantSummaryDecodingWithAllFields() throws {
|
||||
// Given
|
||||
let json = """
|
||||
{
|
||||
"id": 123,
|
||||
"common_name": "Test Plant",
|
||||
"slug": "test-plant",
|
||||
"scientific_name": "Testus plantus",
|
||||
"family": "Testaceae",
|
||||
"genus": "Testus",
|
||||
"image_url": "https://example.com/image.jpg"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
// When
|
||||
let summary = try decoder.decode(TreflePlantSummaryDTO.self, from: json)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(summary.id, 123)
|
||||
XCTAssertEqual(summary.commonName, "Test Plant")
|
||||
XCTAssertEqual(summary.slug, "test-plant")
|
||||
XCTAssertEqual(summary.scientificName, "Testus plantus")
|
||||
XCTAssertEqual(summary.family, "Testaceae")
|
||||
XCTAssertEqual(summary.genus, "Testus")
|
||||
XCTAssertEqual(summary.imageUrl, "https://example.com/image.jpg")
|
||||
}
|
||||
|
||||
func testPlantSummaryDecodingWithNullOptionalFields() throws {
|
||||
// Given
|
||||
let json = """
|
||||
{
|
||||
"id": 456,
|
||||
"common_name": null,
|
||||
"slug": "unknown-plant",
|
||||
"scientific_name": "Unknown species",
|
||||
"family": null,
|
||||
"genus": null,
|
||||
"image_url": null
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
// When
|
||||
let summary = try decoder.decode(TreflePlantSummaryDTO.self, from: json)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(summary.id, 456)
|
||||
XCTAssertNil(summary.commonName)
|
||||
XCTAssertEqual(summary.slug, "unknown-plant")
|
||||
XCTAssertEqual(summary.scientificName, "Unknown species")
|
||||
XCTAssertNil(summary.family)
|
||||
XCTAssertNil(summary.genus)
|
||||
XCTAssertNil(summary.imageUrl)
|
||||
}
|
||||
|
||||
// MARK: - TrefleGrowthDTO Tests
|
||||
|
||||
func testGrowthDTODecodingWithAllFields() throws {
|
||||
// Given
|
||||
let json = """
|
||||
{
|
||||
"light": 7,
|
||||
"atmospheric_humidity": 5,
|
||||
"growth_months": ["mar", "apr", "may"],
|
||||
"bloom_months": ["jun", "jul"],
|
||||
"fruit_months": ["sep", "oct"],
|
||||
"minimum_precipitation": { "mm": 500 },
|
||||
"maximum_precipitation": { "mm": 1500 },
|
||||
"minimum_temperature": { "deg_c": 10, "deg_f": 50 },
|
||||
"maximum_temperature": { "deg_c": 35, "deg_f": 95 },
|
||||
"soil_nutriments": 6,
|
||||
"soil_humidity": 4,
|
||||
"ph_minimum": 5.5,
|
||||
"ph_maximum": 7.0
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
// When
|
||||
let growth = try decoder.decode(TrefleGrowthDTO.self, from: json)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(growth.light, 7)
|
||||
XCTAssertEqual(growth.atmosphericHumidity, 5)
|
||||
XCTAssertEqual(growth.growthMonths, ["mar", "apr", "may"])
|
||||
XCTAssertEqual(growth.bloomMonths, ["jun", "jul"])
|
||||
XCTAssertEqual(growth.fruitMonths, ["sep", "oct"])
|
||||
XCTAssertEqual(growth.minimumPrecipitation?.mm, 500)
|
||||
XCTAssertEqual(growth.maximumPrecipitation?.mm, 1500)
|
||||
XCTAssertEqual(growth.minimumTemperature?.degC, 10)
|
||||
XCTAssertEqual(growth.minimumTemperature?.degF, 50)
|
||||
XCTAssertEqual(growth.maximumTemperature?.degC, 35)
|
||||
XCTAssertEqual(growth.soilNutriments, 6)
|
||||
XCTAssertEqual(growth.soilHumidity, 4)
|
||||
XCTAssertEqual(growth.phMinimum, 5.5)
|
||||
XCTAssertEqual(growth.phMaximum, 7.0)
|
||||
}
|
||||
|
||||
func testGrowthDTODecodingWithMinimalData() throws {
|
||||
// Given
|
||||
let json = """
|
||||
{
|
||||
"light": null,
|
||||
"atmospheric_humidity": null
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
// When
|
||||
let growth = try decoder.decode(TrefleGrowthDTO.self, from: json)
|
||||
|
||||
// Then
|
||||
XCTAssertNil(growth.light)
|
||||
XCTAssertNil(growth.atmosphericHumidity)
|
||||
XCTAssertNil(growth.growthMonths)
|
||||
XCTAssertNil(growth.bloomMonths)
|
||||
XCTAssertNil(growth.minimumTemperature)
|
||||
XCTAssertNil(growth.maximumTemperature)
|
||||
}
|
||||
|
||||
// MARK: - TrefleMeasurementDTO Tests
|
||||
|
||||
func testMeasurementDTODecodingCelsius() throws {
|
||||
// Given
|
||||
let json = """
|
||||
{
|
||||
"deg_c": 25.5,
|
||||
"deg_f": 77.9
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
// When
|
||||
let measurement = try decoder.decode(TrefleMeasurementDTO.self, from: json)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(measurement.degC, 25.5)
|
||||
XCTAssertEqual(measurement.degF, 77.9)
|
||||
XCTAssertNil(measurement.cm)
|
||||
XCTAssertNil(measurement.mm)
|
||||
}
|
||||
|
||||
func testMeasurementDTODecodingHeight() throws {
|
||||
// Given
|
||||
let json = """
|
||||
{
|
||||
"cm": 150
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
// When
|
||||
let measurement = try decoder.decode(TrefleMeasurementDTO.self, from: json)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(measurement.cm, 150)
|
||||
XCTAssertNil(measurement.mm)
|
||||
XCTAssertNil(measurement.degC)
|
||||
XCTAssertNil(measurement.degF)
|
||||
}
|
||||
|
||||
// MARK: - TrefleSpecificationsDTO Tests
|
||||
|
||||
func testSpecificationsDTODecoding() throws {
|
||||
// Given
|
||||
let json = """
|
||||
{
|
||||
"growth_rate": "rapid",
|
||||
"toxicity": "high",
|
||||
"average_height": { "cm": 200 },
|
||||
"maximum_height": { "cm": 500 }
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
// When
|
||||
let specs = try decoder.decode(TrefleSpecificationsDTO.self, from: json)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(specs.growthRate, "rapid")
|
||||
XCTAssertEqual(specs.toxicity, "high")
|
||||
XCTAssertEqual(specs.averageHeight?.cm, 200)
|
||||
XCTAssertEqual(specs.maximumHeight?.cm, 500)
|
||||
}
|
||||
|
||||
// MARK: - TrefleImagesDTO Tests
|
||||
|
||||
func testImagesDTODecoding() throws {
|
||||
// Given
|
||||
let json = """
|
||||
{
|
||||
"flower": [
|
||||
{ "id": 1, "image_url": "https://example.com/flower1.jpg" },
|
||||
{ "id": 2, "image_url": "https://example.com/flower2.jpg" }
|
||||
],
|
||||
"leaf": [
|
||||
{ "id": 3, "image_url": "https://example.com/leaf1.jpg" }
|
||||
],
|
||||
"bark": null,
|
||||
"fruit": [],
|
||||
"habit": null
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
// When
|
||||
let images = try decoder.decode(TrefleImagesDTO.self, from: json)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(images.flower?.count, 2)
|
||||
XCTAssertEqual(images.flower?.first?.id, 1)
|
||||
XCTAssertEqual(images.flower?.first?.imageUrl, "https://example.com/flower1.jpg")
|
||||
XCTAssertEqual(images.leaf?.count, 1)
|
||||
XCTAssertNil(images.bark)
|
||||
XCTAssertEqual(images.fruit?.count, 0)
|
||||
XCTAssertNil(images.habit)
|
||||
}
|
||||
|
||||
// MARK: - TrefleLinksDTO Tests
|
||||
|
||||
func testLinksDTODecodingWithPagination() throws {
|
||||
// Given
|
||||
let json = """
|
||||
{
|
||||
"self": "/api/v1/plants/search?q=rose&page=2",
|
||||
"first": "/api/v1/plants/search?q=rose&page=1",
|
||||
"last": "/api/v1/plants/search?q=rose&page=10",
|
||||
"next": "/api/v1/plants/search?q=rose&page=3",
|
||||
"prev": "/api/v1/plants/search?q=rose&page=1"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
// When
|
||||
let links = try decoder.decode(TrefleLinksDTO.self, from: json)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(links.selfLink, "/api/v1/plants/search?q=rose&page=2")
|
||||
XCTAssertEqual(links.first, "/api/v1/plants/search?q=rose&page=1")
|
||||
XCTAssertEqual(links.last, "/api/v1/plants/search?q=rose&page=10")
|
||||
XCTAssertEqual(links.next, "/api/v1/plants/search?q=rose&page=3")
|
||||
XCTAssertEqual(links.prev, "/api/v1/plants/search?q=rose&page=1")
|
||||
}
|
||||
|
||||
// MARK: - TrefleMetaDTO Tests
|
||||
|
||||
func testMetaDTODecodingSearchResponse() throws {
|
||||
// Given
|
||||
let json = """
|
||||
{
|
||||
"total": 150
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
// When
|
||||
let meta = try decoder.decode(TrefleMetaDTO.self, from: json)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(meta.total, 150)
|
||||
XCTAssertNil(meta.lastModified)
|
||||
}
|
||||
|
||||
func testMetaDTODecodingSpeciesResponse() throws {
|
||||
// Given
|
||||
let json = """
|
||||
{
|
||||
"last_modified": "2024-06-15T10:30:00Z"
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
// When
|
||||
let meta = try decoder.decode(TrefleMetaDTO.self, from: json)
|
||||
|
||||
// Then
|
||||
XCTAssertNil(meta.total)
|
||||
XCTAssertEqual(meta.lastModified, "2024-06-15T10:30:00Z")
|
||||
}
|
||||
|
||||
// MARK: - Empty Response Tests
|
||||
|
||||
func testSearchResponseWithEmptyData() throws {
|
||||
// Given
|
||||
let json = """
|
||||
{
|
||||
"data": [],
|
||||
"links": {
|
||||
"self": "/api/v1/plants/search?q=xyznonexistent",
|
||||
"first": "/api/v1/plants/search?page=1&q=xyznonexistent"
|
||||
},
|
||||
"meta": {
|
||||
"total": 0
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
// When
|
||||
let response = try decoder.decode(TrefleSearchResponseDTO.self, from: json)
|
||||
|
||||
// Then
|
||||
XCTAssertTrue(response.data.isEmpty)
|
||||
XCTAssertEqual(response.meta.total, 0)
|
||||
}
|
||||
|
||||
// MARK: - TrefleSpeciesDTO Full Test
|
||||
|
||||
func testFullSpeciesDTODecoding() throws {
|
||||
// Given
|
||||
let json = """
|
||||
{
|
||||
"id": 999,
|
||||
"common_name": "Test Species",
|
||||
"slug": "test-species",
|
||||
"scientific_name": "Testus speciesus",
|
||||
"year": 2000,
|
||||
"bibliography": "Test Bibliography 2000: 1 (2000)",
|
||||
"author": "Test Author",
|
||||
"family_common_name": "Test Family",
|
||||
"family": "Testaceae",
|
||||
"genus": "Testus",
|
||||
"genus_id": 100,
|
||||
"image_url": "https://example.com/main.jpg",
|
||||
"images": {
|
||||
"flower": [{ "id": 1, "image_url": "https://example.com/flower.jpg" }]
|
||||
},
|
||||
"specifications": {
|
||||
"growth_rate": "slow",
|
||||
"toxicity": "none"
|
||||
},
|
||||
"growth": {
|
||||
"light": 5,
|
||||
"atmospheric_humidity": 6,
|
||||
"bloom_months": ["apr", "may", "jun"],
|
||||
"minimum_temperature": { "deg_c": 10 },
|
||||
"maximum_temperature": { "deg_c": 25 },
|
||||
"soil_nutriments": 4
|
||||
}
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
// When
|
||||
let species = try decoder.decode(TrefleSpeciesDTO.self, from: json)
|
||||
|
||||
// Then
|
||||
XCTAssertEqual(species.id, 999)
|
||||
XCTAssertEqual(species.commonName, "Test Species")
|
||||
XCTAssertEqual(species.slug, "test-species")
|
||||
XCTAssertEqual(species.scientificName, "Testus speciesus")
|
||||
XCTAssertEqual(species.year, 2000)
|
||||
XCTAssertEqual(species.bibliography, "Test Bibliography 2000: 1 (2000)")
|
||||
XCTAssertEqual(species.author, "Test Author")
|
||||
XCTAssertEqual(species.familyCommonName, "Test Family")
|
||||
XCTAssertEqual(species.family, "Testaceae")
|
||||
XCTAssertEqual(species.genus, "Testus")
|
||||
XCTAssertEqual(species.genusId, 100)
|
||||
XCTAssertEqual(species.imageUrl, "https://example.com/main.jpg")
|
||||
XCTAssertNotNil(species.images)
|
||||
XCTAssertEqual(species.images?.flower?.count, 1)
|
||||
XCTAssertNotNil(species.specifications)
|
||||
XCTAssertEqual(species.specifications?.growthRate, "slow")
|
||||
XCTAssertNotNil(species.growth)
|
||||
XCTAssertEqual(species.growth?.light, 5)
|
||||
XCTAssertEqual(species.growth?.bloomMonths, ["apr", "may", "jun"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user