Files
PlantGuide/PlantGuideTests/TrefleDTOsTests.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

541 lines
17 KiB
Swift

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