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:
Trey t
2026-01-23 12:18:01 -06:00
parent d3ab29eb84
commit 136dfbae33
187 changed files with 69001 additions and 0 deletions

View File

@@ -0,0 +1,797 @@
//
// TrefleMapperTests.swift
// PlantGuideTests
//
// Unit tests for TrefleMapper functions.
// Tests all mapping functions with various input values and edge cases.
//
import XCTest
@testable import PlantGuide
final class TrefleMapperTests: XCTestCase {
// MARK: - mapToLightRequirement Tests
func testMapToLightRequirement_WithZero_ReturnsFullShade() {
// When
let result = TrefleMapper.mapToLightRequirement(from: 0)
// Then
XCTAssertEqual(result, .fullShade)
}
func testMapToLightRequirement_WithOne_ReturnsFullShade() {
// When
let result = TrefleMapper.mapToLightRequirement(from: 1)
// Then
XCTAssertEqual(result, .fullShade)
}
func testMapToLightRequirement_WithTwo_ReturnsFullShade() {
// When
let result = TrefleMapper.mapToLightRequirement(from: 2)
// Then
XCTAssertEqual(result, .fullShade)
}
func testMapToLightRequirement_WithThree_ReturnsLowLight() {
// When
let result = TrefleMapper.mapToLightRequirement(from: 3)
// Then
XCTAssertEqual(result, .lowLight)
}
func testMapToLightRequirement_WithFour_ReturnsLowLight() {
// When
let result = TrefleMapper.mapToLightRequirement(from: 4)
// Then
XCTAssertEqual(result, .lowLight)
}
func testMapToLightRequirement_WithFive_ReturnsPartialShade() {
// When
let result = TrefleMapper.mapToLightRequirement(from: 5)
// Then
XCTAssertEqual(result, .partialShade)
}
func testMapToLightRequirement_WithSix_ReturnsPartialShade() {
// When
let result = TrefleMapper.mapToLightRequirement(from: 6)
// Then
XCTAssertEqual(result, .partialShade)
}
func testMapToLightRequirement_WithSeven_ReturnsFullSun() {
// When
let result = TrefleMapper.mapToLightRequirement(from: 7)
// Then
XCTAssertEqual(result, .fullSun)
}
func testMapToLightRequirement_WithEight_ReturnsFullSun() {
// When
let result = TrefleMapper.mapToLightRequirement(from: 8)
// Then
XCTAssertEqual(result, .fullSun)
}
func testMapToLightRequirement_WithNine_ReturnsFullSun() {
// When
let result = TrefleMapper.mapToLightRequirement(from: 9)
// Then
XCTAssertEqual(result, .fullSun)
}
func testMapToLightRequirement_WithTen_ReturnsFullSun() {
// When
let result = TrefleMapper.mapToLightRequirement(from: 10)
// Then
XCTAssertEqual(result, .fullSun)
}
func testMapToLightRequirement_WithNil_ReturnsPartialShadeDefault() {
// When
let result = TrefleMapper.mapToLightRequirement(from: nil)
// Then
XCTAssertEqual(result, .partialShade)
}
func testMapToLightRequirement_WithOutOfRangePositive_ReturnsPartialShadeDefault() {
// When
let result = TrefleMapper.mapToLightRequirement(from: 15)
// Then
XCTAssertEqual(result, .partialShade)
}
func testMapToLightRequirement_WithNegative_ReturnsPartialShadeDefault() {
// When
let result = TrefleMapper.mapToLightRequirement(from: -1)
// Then
XCTAssertEqual(result, .partialShade)
}
// MARK: - mapToWateringSchedule Tests
func testMapToWateringSchedule_WithNilGrowth_ReturnsWeeklyModerateDefault() {
// When
let result = TrefleMapper.mapToWateringSchedule(from: nil)
// Then
XCTAssertEqual(result.frequency, .weekly)
XCTAssertEqual(result.amount, .moderate)
}
func testMapToWateringSchedule_WithHighAtmosphericHumidity_ReturnsWeeklyLight() {
// Given
let growth = createGrowthDTO(atmosphericHumidity: 8, soilHumidity: nil)
// When
let result = TrefleMapper.mapToWateringSchedule(from: growth)
// Then
XCTAssertEqual(result.frequency, .weekly)
XCTAssertEqual(result.amount, .light)
}
func testMapToWateringSchedule_WithHighHumidity10_ReturnsWeeklyLight() {
// Given
let growth = createGrowthDTO(atmosphericHumidity: 10, soilHumidity: nil)
// When
let result = TrefleMapper.mapToWateringSchedule(from: growth)
// Then
XCTAssertEqual(result.frequency, .weekly)
XCTAssertEqual(result.amount, .light)
}
func testMapToWateringSchedule_WithMediumHumidity_ReturnsTwiceWeeklyModerate() {
// Given
let growth = createGrowthDTO(atmosphericHumidity: 5, soilHumidity: nil)
// When
let result = TrefleMapper.mapToWateringSchedule(from: growth)
// Then
XCTAssertEqual(result.frequency, .twiceWeekly)
XCTAssertEqual(result.amount, .moderate)
}
func testMapToWateringSchedule_WithMediumHumidity4_ReturnsTwiceWeeklyModerate() {
// Given
let growth = createGrowthDTO(atmosphericHumidity: 4, soilHumidity: nil)
// When
let result = TrefleMapper.mapToWateringSchedule(from: growth)
// Then
XCTAssertEqual(result.frequency, .twiceWeekly)
XCTAssertEqual(result.amount, .moderate)
}
func testMapToWateringSchedule_WithMediumHumidity6_ReturnsTwiceWeeklyModerate() {
// Given
let growth = createGrowthDTO(atmosphericHumidity: 6, soilHumidity: nil)
// When
let result = TrefleMapper.mapToWateringSchedule(from: growth)
// Then
XCTAssertEqual(result.frequency, .twiceWeekly)
XCTAssertEqual(result.amount, .moderate)
}
func testMapToWateringSchedule_WithLowHumidity_ReturnsWeeklyThorough() {
// Given
let growth = createGrowthDTO(atmosphericHumidity: 2, soilHumidity: nil)
// When
let result = TrefleMapper.mapToWateringSchedule(from: growth)
// Then
XCTAssertEqual(result.frequency, .weekly)
XCTAssertEqual(result.amount, .thorough)
}
func testMapToWateringSchedule_WithLowHumidity0_ReturnsWeeklyThorough() {
// Given
let growth = createGrowthDTO(atmosphericHumidity: 0, soilHumidity: nil)
// When
let result = TrefleMapper.mapToWateringSchedule(from: growth)
// Then
XCTAssertEqual(result.frequency, .weekly)
XCTAssertEqual(result.amount, .thorough)
}
func testMapToWateringSchedule_WithNoAtmosphericHumidity_FallsBackToSoilHumidity() {
// Given
let growth = createGrowthDTO(atmosphericHumidity: nil, soilHumidity: 8)
// When
let result = TrefleMapper.mapToWateringSchedule(from: growth)
// Then
XCTAssertEqual(result.frequency, .weekly)
XCTAssertEqual(result.amount, .light)
}
func testMapToWateringSchedule_WithNoHumidityValues_ReturnsWeeklyModerateDefault() {
// Given
let growth = createGrowthDTO(atmosphericHumidity: nil, soilHumidity: nil)
// When
let result = TrefleMapper.mapToWateringSchedule(from: growth)
// Then
XCTAssertEqual(result.frequency, .weekly)
XCTAssertEqual(result.amount, .moderate)
}
// MARK: - mapToTemperatureRange Tests
func testMapToTemperatureRange_WithNilGrowth_ReturnsDefaultRange() {
// When
let result = TrefleMapper.mapToTemperatureRange(from: nil)
// Then
XCTAssertEqual(result.minimumCelsius, 15.0)
XCTAssertEqual(result.maximumCelsius, 30.0)
XCTAssertNil(result.optimalCelsius)
XCTAssertFalse(result.frostTolerant)
}
func testMapToTemperatureRange_WithValidTemperatures_ReturnsCorrectRange() {
// Given
let growth = createGrowthDTO(minTempC: 10.0, maxTempC: 35.0)
// When
let result = TrefleMapper.mapToTemperatureRange(from: growth)
// Then
XCTAssertEqual(result.minimumCelsius, 10.0)
XCTAssertEqual(result.maximumCelsius, 35.0)
XCTAssertEqual(result.optimalCelsius, 22.5) // (10 + 35) / 2
XCTAssertFalse(result.frostTolerant)
}
func testMapToTemperatureRange_WithNegativeMinTemp_SetsFrostTolerantTrue() {
// Given
let growth = createGrowthDTO(minTempC: -5.0, maxTempC: 25.0)
// When
let result = TrefleMapper.mapToTemperatureRange(from: growth)
// Then
XCTAssertEqual(result.minimumCelsius, -5.0)
XCTAssertEqual(result.maximumCelsius, 25.0)
XCTAssertTrue(result.frostTolerant)
}
func testMapToTemperatureRange_WithZeroMinTemp_SetsFrostTolerantFalse() {
// Given
let growth = createGrowthDTO(minTempC: 0.0, maxTempC: 30.0)
// When
let result = TrefleMapper.mapToTemperatureRange(from: growth)
// Then
XCTAssertEqual(result.minimumCelsius, 0.0)
XCTAssertFalse(result.frostTolerant)
}
func testMapToTemperatureRange_WithOnlyMinTemp_ReturnsDefaultMaxAndNoOptimal() {
// Given
let growth = createGrowthDTO(minTempC: 5.0, maxTempC: nil)
// When
let result = TrefleMapper.mapToTemperatureRange(from: growth)
// Then
XCTAssertEqual(result.minimumCelsius, 5.0)
XCTAssertEqual(result.maximumCelsius, 30.0) // Default
XCTAssertNil(result.optimalCelsius) // No optimal when missing data
}
func testMapToTemperatureRange_WithOnlyMaxTemp_ReturnsDefaultMinAndNoOptimal() {
// Given
let growth = createGrowthDTO(minTempC: nil, maxTempC: 40.0)
// When
let result = TrefleMapper.mapToTemperatureRange(from: growth)
// Then
XCTAssertEqual(result.minimumCelsius, 15.0) // Default
XCTAssertEqual(result.maximumCelsius, 40.0)
XCTAssertNil(result.optimalCelsius) // No optimal when missing data
}
// MARK: - mapToFertilizerSchedule Tests
func testMapToFertilizerSchedule_WithNilGrowth_ReturnsNil() {
// When
let result = TrefleMapper.mapToFertilizerSchedule(from: nil)
// Then
XCTAssertNil(result)
}
func testMapToFertilizerSchedule_WithNilSoilNutriments_ReturnsNil() {
// Given
let growth = createGrowthDTO(soilNutriments: nil)
// When
let result = TrefleMapper.mapToFertilizerSchedule(from: growth)
// Then
XCTAssertNil(result)
}
func testMapToFertilizerSchedule_WithHighNutrients_ReturnsBiweeklyBalanced() {
// Given
let growth = createGrowthDTO(soilNutriments: 8)
// When
let result = TrefleMapper.mapToFertilizerSchedule(from: growth)
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result?.frequency, .biweekly)
XCTAssertEqual(result?.type, .balanced)
}
func testMapToFertilizerSchedule_WithHighNutrients10_ReturnsBiweeklyBalanced() {
// Given
let growth = createGrowthDTO(soilNutriments: 10)
// When
let result = TrefleMapper.mapToFertilizerSchedule(from: growth)
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result?.frequency, .biweekly)
XCTAssertEqual(result?.type, .balanced)
}
func testMapToFertilizerSchedule_WithHighNutrients7_ReturnsBiweeklyBalanced() {
// Given
let growth = createGrowthDTO(soilNutriments: 7)
// When
let result = TrefleMapper.mapToFertilizerSchedule(from: growth)
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result?.frequency, .biweekly)
XCTAssertEqual(result?.type, .balanced)
}
func testMapToFertilizerSchedule_WithMediumNutrients_ReturnsMonthlyBalanced() {
// Given
let growth = createGrowthDTO(soilNutriments: 5)
// When
let result = TrefleMapper.mapToFertilizerSchedule(from: growth)
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result?.frequency, .monthly)
XCTAssertEqual(result?.type, .balanced)
}
func testMapToFertilizerSchedule_WithMediumNutrients4_ReturnsMonthlyBalanced() {
// Given
let growth = createGrowthDTO(soilNutriments: 4)
// When
let result = TrefleMapper.mapToFertilizerSchedule(from: growth)
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result?.frequency, .monthly)
XCTAssertEqual(result?.type, .balanced)
}
func testMapToFertilizerSchedule_WithMediumNutrients6_ReturnsMonthlyBalanced() {
// Given
let growth = createGrowthDTO(soilNutriments: 6)
// When
let result = TrefleMapper.mapToFertilizerSchedule(from: growth)
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result?.frequency, .monthly)
XCTAssertEqual(result?.type, .balanced)
}
func testMapToFertilizerSchedule_WithLowNutrients_ReturnsQuarterlyOrganic() {
// Given
let growth = createGrowthDTO(soilNutriments: 2)
// When
let result = TrefleMapper.mapToFertilizerSchedule(from: growth)
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result?.frequency, .quarterly)
XCTAssertEqual(result?.type, .organic)
}
func testMapToFertilizerSchedule_WithLowNutrients0_ReturnsQuarterlyOrganic() {
// Given
let growth = createGrowthDTO(soilNutriments: 0)
// When
let result = TrefleMapper.mapToFertilizerSchedule(from: growth)
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result?.frequency, .quarterly)
XCTAssertEqual(result?.type, .organic)
}
func testMapToFertilizerSchedule_WithLowNutrients3_ReturnsQuarterlyOrganic() {
// Given
let growth = createGrowthDTO(soilNutriments: 3)
// When
let result = TrefleMapper.mapToFertilizerSchedule(from: growth)
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result?.frequency, .quarterly)
XCTAssertEqual(result?.type, .organic)
}
// MARK: - mapToBloomingSeason Tests
func testMapToBloomingSeason_WithNilBloomMonths_ReturnsNil() {
// When
let result = TrefleMapper.mapToBloomingSeason(from: nil)
// Then
XCTAssertNil(result)
}
func testMapToBloomingSeason_WithEmptyArray_ReturnsNil() {
// When
let result = TrefleMapper.mapToBloomingSeason(from: [])
// Then
XCTAssertNil(result)
}
func testMapToBloomingSeason_WithSpringMonths_ReturnsSpring() {
// When
let result = TrefleMapper.mapToBloomingSeason(from: ["mar", "apr", "may"])
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result, [.spring])
}
func testMapToBloomingSeason_WithSummerMonths_ReturnsSummer() {
// When
let result = TrefleMapper.mapToBloomingSeason(from: ["jun", "jul", "aug"])
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result, [.summer])
}
func testMapToBloomingSeason_WithFallMonths_ReturnsFall() {
// When
let result = TrefleMapper.mapToBloomingSeason(from: ["sep", "oct", "nov"])
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result, [.fall])
}
func testMapToBloomingSeason_WithWinterMonths_ReturnsWinter() {
// When
let result = TrefleMapper.mapToBloomingSeason(from: ["dec", "jan", "feb"])
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result, [.winter])
}
func testMapToBloomingSeason_WithMultipleSeasons_ReturnsOrderedSeasons() {
// When
let result = TrefleMapper.mapToBloomingSeason(from: ["apr", "may", "jun", "jul"])
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result, [.spring, .summer])
}
func testMapToBloomingSeason_WithMixedSeasons_ReturnsAllSeasonsOrdered() {
// When
let result = TrefleMapper.mapToBloomingSeason(from: ["jan", "apr", "jul", "oct"])
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result, [.spring, .summer, .fall, .winter])
}
func testMapToBloomingSeason_WithUppercaseMonths_ReturnsCorrectSeasons() {
// When
let result = TrefleMapper.mapToBloomingSeason(from: ["MAR", "APR", "MAY"])
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result, [.spring])
}
func testMapToBloomingSeason_WithMixedCaseMonths_ReturnsCorrectSeasons() {
// When
let result = TrefleMapper.mapToBloomingSeason(from: ["Mar", "Apr", "May"])
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result, [.spring])
}
func testMapToBloomingSeason_WithInvalidMonths_ReturnsNil() {
// When
let result = TrefleMapper.mapToBloomingSeason(from: ["xyz", "abc", "123"])
// Then
XCTAssertNil(result)
}
func testMapToBloomingSeason_WithMixedValidAndInvalidMonths_ReturnsValidOnly() {
// When
let result = TrefleMapper.mapToBloomingSeason(from: ["apr", "xyz", "may"])
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result, [.spring])
}
func testMapToBloomingSeason_WithSingleMonth_ReturnsSingleSeason() {
// When
let result = TrefleMapper.mapToBloomingSeason(from: ["jul"])
// Then
XCTAssertNotNil(result)
XCTAssertEqual(result, [.summer])
}
// MARK: - mapToHumidityLevel Tests
func testMapToHumidityLevel_WithNil_ReturnsNil() {
// When
let result = TrefleMapper.mapToHumidityLevel(from: nil)
// Then
XCTAssertNil(result)
}
func testMapToHumidityLevel_WithLowValue_ReturnsLow() {
// When
let result = TrefleMapper.mapToHumidityLevel(from: 1)
// Then
XCTAssertEqual(result, .low)
}
func testMapToHumidityLevel_WithModerateValue_ReturnsModerate() {
// When
let result = TrefleMapper.mapToHumidityLevel(from: 4)
// Then
XCTAssertEqual(result, .moderate)
}
func testMapToHumidityLevel_WithHighValue_ReturnsHigh() {
// When
let result = TrefleMapper.mapToHumidityLevel(from: 7)
// Then
XCTAssertEqual(result, .high)
}
func testMapToHumidityLevel_WithVeryHighValue_ReturnsVeryHigh() {
// When
let result = TrefleMapper.mapToHumidityLevel(from: 9)
// Then
XCTAssertEqual(result, .veryHigh)
}
// MARK: - mapToGrowthRate Tests
func testMapToGrowthRate_WithNil_ReturnsNil() {
// When
let result = TrefleMapper.mapToGrowthRate(from: nil)
// Then
XCTAssertNil(result)
}
func testMapToGrowthRate_WithSlow_ReturnsSlow() {
// When
let result = TrefleMapper.mapToGrowthRate(from: "slow")
// Then
XCTAssertEqual(result, .slow)
}
func testMapToGrowthRate_WithModerate_ReturnsModerate() {
// When
let result = TrefleMapper.mapToGrowthRate(from: "moderate")
// Then
XCTAssertEqual(result, .moderate)
}
func testMapToGrowthRate_WithMedium_ReturnsModerate() {
// When
let result = TrefleMapper.mapToGrowthRate(from: "medium")
// Then
XCTAssertEqual(result, .moderate)
}
func testMapToGrowthRate_WithRapid_ReturnsFast() {
// When
let result = TrefleMapper.mapToGrowthRate(from: "rapid")
// Then
XCTAssertEqual(result, .fast)
}
func testMapToGrowthRate_WithFast_ReturnsFast() {
// When
let result = TrefleMapper.mapToGrowthRate(from: "fast")
// Then
XCTAssertEqual(result, .fast)
}
func testMapToGrowthRate_WithUnknownValue_ReturnsNil() {
// When
let result = TrefleMapper.mapToGrowthRate(from: "unknown")
// Then
XCTAssertNil(result)
}
// MARK: - mapToPlantCareInfo Integration Test
func testMapToPlantCareInfo_CreatesCompleteEntity() {
// Given
let species = createSpeciesDTO(
id: 834,
commonName: "Swiss cheese plant",
scientificName: "Monstera deliciosa",
light: 6,
atmosphericHumidity: 8,
minTempC: 15,
maxTempC: 30,
soilNutriments: 5,
growthRate: "moderate",
bloomMonths: ["may", "jun"]
)
// When
let result = TrefleMapper.mapToPlantCareInfo(from: species)
// Then
XCTAssertEqual(result.scientificName, "Monstera deliciosa")
XCTAssertEqual(result.commonName, "Swiss cheese plant")
XCTAssertEqual(result.lightRequirement, .partialShade) // light 6 -> partialShade
XCTAssertEqual(result.wateringSchedule.frequency, .weekly) // humidity 8 -> weekly
XCTAssertEqual(result.wateringSchedule.amount, .light) // humidity 8 -> light
XCTAssertEqual(result.temperatureRange.minimumCelsius, 15)
XCTAssertEqual(result.temperatureRange.maximumCelsius, 30)
XCTAssertFalse(result.temperatureRange.frostTolerant)
XCTAssertNotNil(result.fertilizerSchedule)
XCTAssertEqual(result.fertilizerSchedule?.frequency, .monthly) // nutrients 5 -> monthly
XCTAssertEqual(result.humidity, .high) // humidity 8 -> high
XCTAssertEqual(result.growthRate, .moderate)
XCTAssertEqual(result.bloomingSeason, [.spring, .summer])
XCTAssertEqual(result.trefleID, 834)
}
// MARK: - Helper Methods
private func createGrowthDTO(
atmosphericHumidity: Int? = nil,
soilHumidity: Int? = nil,
soilNutriments: Int? = nil,
minTempC: Double? = nil,
maxTempC: Double? = nil,
bloomMonths: [String]? = nil
) -> TrefleGrowthDTO {
// Use JSON decoding to create DTO since it has no public init
var json: [String: Any] = [:]
if let humidity = atmosphericHumidity {
json["atmospheric_humidity"] = humidity
}
if let soil = soilHumidity {
json["soil_humidity"] = soil
}
if let nutrients = soilNutriments {
json["soil_nutriments"] = nutrients
}
if let minTemp = minTempC {
json["minimum_temperature"] = ["deg_c": minTemp]
}
if let maxTemp = maxTempC {
json["maximum_temperature"] = ["deg_c": maxTemp]
}
if let months = bloomMonths {
json["bloom_months"] = months
}
let data = try! JSONSerialization.data(withJSONObject: json)
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try! decoder.decode(TrefleGrowthDTO.self, from: data)
}
private func createSpeciesDTO(
id: Int,
commonName: String?,
scientificName: String,
light: Int? = nil,
atmosphericHumidity: Int? = nil,
minTempC: Double? = nil,
maxTempC: Double? = nil,
soilNutriments: Int? = nil,
growthRate: String? = nil,
bloomMonths: [String]? = nil
) -> TrefleSpeciesDTO {
var json: [String: Any] = [
"id": id,
"slug": scientificName.lowercased().replacingOccurrences(of: " ", with: "-"),
"scientific_name": scientificName
]
if let name = commonName {
json["common_name"] = name
}
var growthJson: [String: Any] = [:]
if let l = light { growthJson["light"] = l }
if let h = atmosphericHumidity { growthJson["atmospheric_humidity"] = h }
if let min = minTempC { growthJson["minimum_temperature"] = ["deg_c": min] }
if let max = maxTempC { growthJson["maximum_temperature"] = ["deg_c": max] }
if let n = soilNutriments { growthJson["soil_nutriments"] = n }
if let months = bloomMonths { growthJson["bloom_months"] = months }
if !growthJson.isEmpty {
json["growth"] = growthJson
}
if let rate = growthRate {
json["specifications"] = ["growth_rate": rate]
}
let data = try! JSONSerialization.data(withJSONObject: json)
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try! decoder.decode(TrefleSpeciesDTO.self, from: data)
}
}