use std::collections::HashMap;
use regex::Regex;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchCriteria {
#[serde(skip_serializing_if = "Option::is_none")]
pub breed_group_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub breed_id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub born_after: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub born_before: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub proven_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flock_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trait_ranges: Option<HashMap<String, TraitRangeFilter>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraitRangeFilter {
pub min: f64,
pub max: f64,
}
impl SearchCriteria {
#[must_use]
pub const fn new() -> Self {
Self {
breed_group_id: None,
breed_id: None,
born_after: None,
born_before: None,
gender: None,
proven_only: None,
status: None,
flock_id: None,
trait_ranges: None,
}
}
#[must_use]
pub const fn with_breed_group_id(mut self, id: i64) -> Self {
self.breed_group_id = Some(id);
self
}
#[must_use]
pub const fn with_breed_id(mut self, id: i64) -> Self {
self.breed_id = Some(id);
self
}
#[must_use]
pub fn with_born_after(mut self, date: impl Into<String>) -> Self {
self.born_after = Some(date.into());
self
}
#[must_use]
pub fn with_born_before(mut self, date: impl Into<String>) -> Self {
self.born_before = Some(date.into());
self
}
#[must_use]
pub fn with_gender(mut self, gender: impl Into<String>) -> Self {
self.gender = Some(gender.into());
self
}
#[must_use]
pub const fn with_proven_only(mut self, proven: bool) -> Self {
self.proven_only = Some(proven);
self
}
#[must_use]
pub fn with_status(mut self, status: impl Into<String>) -> Self {
self.status = Some(status.into());
self
}
#[must_use]
pub fn with_flock_id(mut self, flock_id: impl Into<String>) -> Self {
self.flock_id = Some(flock_id.into());
self
}
#[must_use]
pub fn with_trait_ranges(mut self, ranges: HashMap<String, TraitRangeFilter>) -> Self {
self.trait_ranges = Some(ranges);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Breed {
pub id: i64,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BreedGroup {
pub id: i64,
pub name: String,
pub breeds: Vec<Breed>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Trait {
pub name: String,
pub value: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub accuracy: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub units: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraitRange {
pub trait_name: String,
pub min_value: f64,
pub max_value: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ContactInfo {
#[serde(skip_serializing_if = "Option::is_none")]
pub farm_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub address: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub city: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub zip_code: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnimalDetails {
pub lpn_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub breed: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub breed_group: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date_of_birth: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sire: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dam: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registration_number: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub total_progeny: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flock_count: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub genotyped: Option<String>,
pub traits: HashMap<String, Trait>,
#[serde(skip_serializing_if = "Option::is_none")]
pub contact_info: Option<ContactInfo>,
}
const TRAIT_MAPPING: &[(&str, &str, &str)] = &[
("bwt", "BWT", "accbwt"),
("wwt", "WWT", "accwwt"),
("pwwt", "PWWT", "accpwwt"),
("ywt", "YWT", "accywt"),
("fat", "FAT", "accfat"),
("emd", "EMD", "accemd"),
("nlb", "NLB", "accnlb"),
("nwt", "NWT", "accnwt"),
("pwt", "PWT", "accpwt"),
("dag", "DAG", "accdag"),
("wgr", "WGR", "accwgr"),
("wec", "WEC", "accwec"),
("fec", "FEC", "accfec"),
];
#[allow(clippy::cast_possible_truncation)]
fn convert_accuracy(acc: f64) -> i32 {
if acc <= 1.0 {
(acc * 100.0) as i32
} else {
acc as i32
}
}
fn extract_traits_nested(
sr: &serde_json::Map<String, serde_json::Value>,
) -> HashMap<String, Trait> {
let mut traits = HashMap::new();
for &(trait_key, trait_name, acc_key) in TRAIT_MAPPING {
let Some(val) = sr.get(trait_key).and_then(serde_json::Value::as_f64) else {
continue;
};
let accuracy = sr
.get(acc_key)
.and_then(serde_json::Value::as_f64)
.map(convert_accuracy);
traits.insert(
trait_name.to_string(),
Trait {
name: trait_name.to_string(),
value: val,
accuracy,
units: None,
},
);
}
traits
}
fn extract_contact_info(c: &serde_json::Value) -> Option<ContactInfo> {
if c.is_null() || !c.is_object() {
return None;
}
Some(ContactInfo {
farm_name: c
.get("farmName")
.or_else(|| c.get("FarmName"))
.and_then(serde_json::Value::as_str)
.map(String::from),
contact_name: c
.get("customerName")
.or_else(|| c.get("ContactName"))
.and_then(serde_json::Value::as_str)
.map(String::from),
phone: c
.get("phone")
.or_else(|| c.get("Phone"))
.and_then(serde_json::Value::as_str)
.map(String::from),
email: c
.get("email")
.or_else(|| c.get("Email"))
.and_then(serde_json::Value::as_str)
.map(String::from),
address: c
.get("address")
.or_else(|| c.get("Address"))
.and_then(serde_json::Value::as_str)
.map(String::from),
city: c
.get("city")
.or_else(|| c.get("City"))
.and_then(serde_json::Value::as_str)
.map(String::from),
state: c
.get("state")
.or_else(|| c.get("State"))
.and_then(serde_json::Value::as_str)
.map(String::from),
zip_code: c
.get("zipCode")
.or_else(|| c.get("ZipCode"))
.and_then(serde_json::Value::as_str)
.map(String::from),
})
}
#[allow(clippy::cast_possible_truncation)]
fn extract_traits_legacy(data: &serde_json::Value) -> HashMap<String, Trait> {
let Some(obj) = data.get("Traits").and_then(serde_json::Value::as_object) else {
return HashMap::new();
};
let mut traits = HashMap::new();
for (name, td) in obj {
let Some(td_obj) = td.as_object() else {
continue;
};
let value = td_obj
.get("Value")
.and_then(serde_json::Value::as_f64)
.unwrap_or(0.0);
let accuracy = td_obj
.get("Accuracy")
.and_then(|a| a.as_f64().map(|v| v as i32));
traits.insert(
name.clone(),
Trait {
name: name.clone(),
value,
accuracy,
units: None,
},
);
}
traits
}
fn extract_progeny_traits(item: &serde_json::Value) -> HashMap<String, f64> {
let Some(obj) = item.get("Traits").and_then(serde_json::Value::as_object) else {
return HashMap::new();
};
obj.iter()
.filter_map(|(k, v)| v.as_f64().map(|f| (k.clone(), f)))
.collect()
}
impl AnimalDetails {
pub fn from_api_response(data: &serde_json::Value) -> crate::Result<Self> {
let is_nested = data
.get("data")
.and_then(serde_json::Value::as_object)
.is_some();
if is_nested {
Ok(Self::from_nested_format(data))
} else if data.get("lpnId").is_some() {
Ok(Self::from_search_result(data))
} else {
Ok(Self::from_legacy_format(data))
}
}
fn from_nested_format(data: &serde_json::Value) -> Self {
let section = &data["data"];
let sr = §ion["searchResultViewModel"];
let breed_obj = §ion["breed"];
let contact_obj = section
.get("contactInfo")
.or_else(|| section.get("ContactInfo"));
let lpn_id = sr["lpnId"].as_str().unwrap_or_default().to_string();
let breed = breed_obj
.get("breedName")
.and_then(serde_json::Value::as_str)
.map(String::from);
let date_of_birth = section
.get("dateOfBirth")
.and_then(serde_json::Value::as_str)
.map(String::from);
let gender = section
.get("gender")
.and_then(serde_json::Value::as_str)
.map(String::from);
let status = sr
.get("status")
.and_then(serde_json::Value::as_str)
.map(String::from);
let sire = sr
.get("lpnSre")
.and_then(serde_json::Value::as_str)
.map(String::from);
let dam = sr
.get("lpnDam")
.and_then(serde_json::Value::as_str)
.map(String::from);
let registration_number = sr
.get("regNumber")
.and_then(serde_json::Value::as_str)
.map(String::from);
let total_progeny = section
.get("progenyCount")
.and_then(serde_json::Value::as_i64);
let genotyped = section
.get("genotyped")
.and_then(serde_json::Value::as_str)
.map(String::from);
let flock_count = section.get("flockCount").and_then(|v| {
v.as_i64()
.or_else(|| v.as_str().and_then(|s| s.parse::<i64>().ok()))
});
let traits = sr
.as_object()
.map(extract_traits_nested)
.unwrap_or_default();
let contact_info = contact_obj.and_then(extract_contact_info);
Self {
lpn_id,
breed,
breed_group: None,
date_of_birth,
gender,
status,
sire,
dam,
registration_number,
total_progeny,
flock_count,
genotyped,
traits,
contact_info,
}
}
fn from_search_result(data: &serde_json::Value) -> Self {
let lpn_id = data
.get("lpnId")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let traits = data
.as_object()
.map(extract_traits_nested)
.unwrap_or_default();
Self {
lpn_id,
breed: None,
breed_group: None,
date_of_birth: data
.get("dateOfBirth")
.and_then(serde_json::Value::as_str)
.map(String::from),
gender: data
.get("gender")
.and_then(serde_json::Value::as_str)
.map(String::from),
status: data
.get("status")
.and_then(serde_json::Value::as_str)
.map(String::from),
sire: data
.get("lpnSre")
.and_then(serde_json::Value::as_str)
.map(String::from),
dam: data
.get("lpnDam")
.and_then(serde_json::Value::as_str)
.map(String::from),
registration_number: data
.get("regNumber")
.and_then(serde_json::Value::as_str)
.map(String::from),
total_progeny: None,
flock_count: None,
genotyped: None,
traits,
contact_info: None,
}
}
fn from_legacy_format(data: &serde_json::Value) -> Self {
let lpn_id = data
.get("LpnId")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let traits = extract_traits_legacy(data);
let contact_info = data.get("ContactInfo").and_then(extract_contact_info);
Self {
lpn_id,
breed: data
.get("Breed")
.and_then(serde_json::Value::as_str)
.map(String::from),
breed_group: data
.get("BreedGroup")
.and_then(serde_json::Value::as_str)
.map(String::from),
date_of_birth: data
.get("DateOfBirth")
.and_then(serde_json::Value::as_str)
.map(String::from),
gender: data
.get("Gender")
.and_then(serde_json::Value::as_str)
.map(String::from),
status: data
.get("Status")
.and_then(serde_json::Value::as_str)
.map(String::from),
sire: data
.get("Sire")
.and_then(serde_json::Value::as_str)
.map(String::from),
dam: data
.get("Dam")
.and_then(serde_json::Value::as_str)
.map(String::from),
registration_number: data
.get("RegistrationNumber")
.and_then(serde_json::Value::as_str)
.map(String::from),
total_progeny: data.get("TotalProgeny").and_then(serde_json::Value::as_i64),
flock_count: data.get("FlockCount").and_then(serde_json::Value::as_i64),
genotyped: data
.get("Genotyped")
.and_then(serde_json::Value::as_str)
.map(String::from),
traits,
contact_info,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProgenyAnimal {
pub lpn_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub sex: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date_of_birth: Option<String>,
pub traits: HashMap<String, f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Progeny {
pub total_count: i64,
pub animals: Vec<ProgenyAnimal>,
pub page: u32,
pub page_size: u32,
}
impl Progeny {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn from_api_response(
data: &serde_json::Value,
page: u32,
page_size: u32,
) -> crate::Result<Self> {
let records = data
.get("records")
.or_else(|| data.get("Results"))
.and_then(serde_json::Value::as_array);
let mut animals = Vec::new();
if let Some(arr) = records {
for item in arr {
let lpn_id = item
.get("lpnId")
.or_else(|| item.get("LpnId"))
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
let sex = item
.get("sex")
.or_else(|| item.get("Sex"))
.and_then(serde_json::Value::as_str)
.map(String::from);
let date_of_birth = item
.get("dob")
.or_else(|| item.get("DateOfBirth"))
.and_then(serde_json::Value::as_str)
.map(String::from);
let traits = extract_progeny_traits(item);
animals.push(ProgenyAnimal {
lpn_id,
sex,
date_of_birth,
traits,
});
}
}
let total_count = data
.get("recordCount")
.or_else(|| data.get("TotalCount"))
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
Ok(Self {
total_count,
animals,
page,
page_size,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LineageAnimal {
pub lpn_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub farm_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub us_index: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub src_index: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date_of_birth: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sex: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Lineage {
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<LineageAnimal>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sire: Option<LineageAnimal>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dam: Option<LineageAnimal>,
pub generations: Vec<Vec<LineageAnimal>>,
}
fn parse_lineage_content(content: &str) -> ParsedLineageContent {
let re_farm = Regex::new(r"<div>([^<]+)</div>").ok();
let re_us = Regex::new(r"US (?:Hair )?Index: ([\d.]+)").ok();
let re_src = Regex::new(r"SRC\$ Index: ([\d.]+)").ok();
let re_dob = Regex::new(r"DOB: ([^<]+)").ok();
let re_sex = Regex::new(r"Sex: ([^<]+)").ok();
let re_status = Regex::new(r"Status: ([^<]+)").ok();
ParsedLineageContent {
farm_name: re_farm
.and_then(|r| r.captures(content))
.map(|c| c[1].to_string()),
us_index: re_us
.and_then(|r| r.captures(content))
.and_then(|c| c[1].parse().ok()),
src_index: re_src
.and_then(|r| r.captures(content))
.and_then(|c| c[1].parse().ok()),
date_of_birth: re_dob
.and_then(|r| r.captures(content))
.map(|c| c[1].trim().to_string()),
sex: re_sex
.and_then(|r| r.captures(content))
.map(|c| c[1].trim().to_string()),
status: re_status
.and_then(|r| r.captures(content))
.map(|c| c[1].trim().to_string()),
}
}
struct ParsedLineageContent {
farm_name: Option<String>,
us_index: Option<f64>,
src_index: Option<f64>,
date_of_birth: Option<String>,
sex: Option<String>,
status: Option<String>,
}
fn parse_lineage_node(node: &serde_json::Value) -> Option<LineageAnimal> {
let lpn_id = node.get("lpnId")?.as_str()?;
let content = node
.get("content")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
let parsed = parse_lineage_content(content);
Some(LineageAnimal {
lpn_id: lpn_id.to_string(),
farm_name: parsed.farm_name,
us_index: parsed.us_index,
src_index: parsed.src_index,
date_of_birth: parsed.date_of_birth,
sex: parsed.sex,
status: parsed.status,
})
}
fn collect_generations(
node: &serde_json::Value,
generations: &mut Vec<Vec<LineageAnimal>>,
depth: usize,
) {
let Some(children) = node.get("children").and_then(serde_json::Value::as_array) else {
return;
};
if children.is_empty() {
return;
}
while generations.len() <= depth {
generations.push(Vec::new());
}
for child in children {
if let Some(animal) = parse_lineage_node(child) {
generations[depth].push(animal);
}
collect_generations(child, generations, depth + 1);
}
}
impl Lineage {
pub fn from_api_response(data: &serde_json::Value) -> crate::Result<Self> {
let node = if data
.get("data")
.and_then(serde_json::Value::as_object)
.is_some()
{
&data["data"]
} else {
data
};
let subject = parse_lineage_node(node);
let children = node.get("children").and_then(serde_json::Value::as_array);
let sire = children
.and_then(|c| c.first())
.and_then(parse_lineage_node);
let dam = children.and_then(|c| c.get(1)).and_then(parse_lineage_node);
let mut generations = Vec::new();
collect_generations(node, &mut generations, 0);
Ok(Self {
subject,
sire,
dam,
generations,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResults {
pub total_count: i64,
pub results: Vec<serde_json::Value>,
pub page: u32,
pub page_size: u32,
}
impl SearchResults {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn from_api_response(
data: &serde_json::Value,
page: u32,
page_size: u32,
) -> crate::Result<Self> {
let total_count = data
.get("TotalCount")
.or_else(|| data.get("recordCount"))
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
let results = data
.get("Results")
.or_else(|| data.get("records"))
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
Ok(Self {
total_count,
results,
page,
page_size,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DateLastUpdated {
pub data: serde_json::Value,
}
#[derive(Deserialize)]
pub(crate) struct RawBreedGroupResponse {
#[serde(default)]
pub data: Option<Vec<RawBreedGroup>>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RawBreedGroup {
#[serde(alias = "Id", alias = "id")]
pub breed_group_id: Option<i64>,
#[serde(alias = "Name", alias = "name")]
pub breed_group_name: Option<String>,
#[serde(default)]
pub breeds: Vec<RawBreed>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RawBreed {
#[serde(alias = "id")]
pub breed_id: Option<i64>,
#[serde(alias = "name")]
pub breed_name: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AnimalProfile {
pub details: AnimalDetails,
pub lineage: Lineage,
pub progeny: Progeny,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn search_criteria_builder_round_trip() {
let criteria = SearchCriteria::new()
.with_breed_id(486)
.with_status("CURRENT")
.with_gender("Female")
.with_proven_only(true);
assert_eq!(criteria.breed_id, Some(486));
assert_eq!(criteria.status.as_deref(), Some("CURRENT"));
assert_eq!(criteria.gender.as_deref(), Some("Female"));
assert_eq!(criteria.proven_only, Some(true));
assert!(criteria.breed_group_id.is_none());
}
#[test]
fn search_criteria_serializes_to_camel_case() {
let criteria = SearchCriteria::new()
.with_breed_group_id(61)
.with_breed_id(486);
let json = serde_json::to_value(&criteria).unwrap();
assert_eq!(json["breedGroupId"], 61);
assert_eq!(json["breedId"], 486);
assert!(json.get("gender").is_none());
}
#[test]
fn animal_details_from_nested_response() {
let json = serde_json::json!({
"success": true,
"data": {
"progenyCount": 6,
"dateOfBirth": "01/15/2020",
"gender": "Female",
"genotyped": "Yes",
"flockCount": "2",
"breed": { "breedName": "Katahdin", "breedId": 640 },
"searchResultViewModel": {
"lpnId": "6####92020###249",
"lpnSre": "SIRE123",
"lpnDam": "DAM456",
"status": "CURRENT",
"regNumber": "REG789",
"bwt": 0.246,
"accbwt": 0.80
},
"contactInfo": {
"farmName": "Test Farm",
"customerName": "John Doe"
}
}
});
let details = AnimalDetails::from_api_response(&json).unwrap();
assert_eq!(details.lpn_id, "6####92020###249");
assert_eq!(details.breed.as_deref(), Some("Katahdin"));
assert_eq!(details.gender.as_deref(), Some("Female"));
assert_eq!(details.total_progeny, Some(6));
assert_eq!(details.flock_count, Some(2));
assert_eq!(details.sire.as_deref(), Some("SIRE123"));
let bwt = details.traits.get("BWT").unwrap();
assert!((bwt.value - 0.246).abs() < f64::EPSILON);
assert_eq!(bwt.accuracy, Some(80));
let contact = details.contact_info.unwrap();
assert_eq!(contact.farm_name.as_deref(), Some("Test Farm"));
assert_eq!(contact.contact_name.as_deref(), Some("John Doe"));
}
#[test]
fn animal_details_from_legacy_response() {
let json = serde_json::json!({
"LpnId": "LEGACY123",
"Breed": "Targhee",
"Gender": "Male",
"Status": "SOLD",
"Traits": {
"BWT": { "Value": 1.5, "Accuracy": 90 }
}
});
let details = AnimalDetails::from_api_response(&json).unwrap();
assert_eq!(details.lpn_id, "LEGACY123");
assert_eq!(details.breed.as_deref(), Some("Targhee"));
let bwt = details.traits.get("BWT").unwrap();
assert!((bwt.value - 1.5).abs() < f64::EPSILON);
assert_eq!(bwt.accuracy, Some(90));
}
#[test]
fn animal_details_from_search_result() {
let json = serde_json::json!({
"lpnId": "6400012006BWR107",
"lpnSre": "SIRE_SR",
"lpnDam": "DAM_SR",
"gender": "Male",
"status": "CURRENT",
"dateOfBirth": "3/15/2022",
"regNumber": "SR_REG",
"bwt": 0.35,
"accbwt": 72,
"wwt": 2.5,
"accwwt": 68,
"pwwt": 4.1,
"accpwwt": 65,
"ywt": 3.8,
"accywt": 55,
"nlb": 0.15,
"accnlb": 40
});
let details = AnimalDetails::from_api_response(&json).unwrap();
assert_eq!(details.lpn_id, "6400012006BWR107");
assert_eq!(details.sire.as_deref(), Some("SIRE_SR"));
assert_eq!(details.dam.as_deref(), Some("DAM_SR"));
assert_eq!(details.gender.as_deref(), Some("Male"));
assert_eq!(details.status.as_deref(), Some("CURRENT"));
assert_eq!(details.date_of_birth.as_deref(), Some("3/15/2022"));
assert_eq!(details.registration_number.as_deref(), Some("SR_REG"));
let bwt = details.traits.get("BWT").unwrap();
assert!((bwt.value - 0.35).abs() < f64::EPSILON);
assert_eq!(bwt.accuracy, Some(72));
let wwt = details.traits.get("WWT").unwrap();
assert!((wwt.value - 2.5).abs() < f64::EPSILON);
assert_eq!(wwt.accuracy, Some(68));
let pwwt = details.traits.get("PWWT").unwrap();
assert!((pwwt.value - 4.1).abs() < f64::EPSILON);
let nlb = details.traits.get("NLB").unwrap();
assert!((nlb.value - 0.15).abs() < f64::EPSILON);
assert_eq!(nlb.accuracy, Some(40));
assert_eq!(details.traits.len(), 5);
}
#[test]
fn progeny_from_api_response() {
let json = serde_json::json!({
"recordCount": 3,
"records": [
{ "lpnId": "P1", "sex": "Male", "dob": "03/10/2022" },
{ "lpnId": "P2", "sex": "Female", "dob": "04/01/2022" }
]
});
let progeny = Progeny::from_api_response(&json, 0, 10).unwrap();
assert_eq!(progeny.total_count, 3);
assert_eq!(progeny.animals.len(), 2);
assert_eq!(progeny.animals[0].lpn_id, "P1");
assert_eq!(progeny.animals[1].sex.as_deref(), Some("Female"));
}
#[test]
fn lineage_from_api_response() {
let json = serde_json::json!({
"data": {
"lpnId": "SUBJECT1",
"content": "<div>My Farm</div><div>US Hair Index: 105.2</div><div>DOB: 1/1/2020</div><div>Sex: Female</div>",
"children": [
{
"lpnId": "SIRE1",
"content": "<div>Sire Farm</div><div>DOB: 3/15/2018</div><div>Sex: Male</div>",
"children": []
},
{
"lpnId": "DAM1",
"content": "<div>Dam Farm</div><div>DOB: 6/20/2017</div><div>Sex: Female</div>",
"children": []
}
]
}
});
let lineage = Lineage::from_api_response(&json).unwrap();
let subject = lineage.subject.unwrap();
assert_eq!(subject.lpn_id, "SUBJECT1");
assert_eq!(subject.farm_name.as_deref(), Some("My Farm"));
assert!((subject.us_index.unwrap() - 105.2).abs() < f64::EPSILON);
assert_eq!(subject.sex.as_deref(), Some("Female"));
let sire = lineage.sire.unwrap();
assert_eq!(sire.lpn_id, "SIRE1");
assert_eq!(sire.sex.as_deref(), Some("Male"));
let dam = lineage.dam.unwrap();
assert_eq!(dam.lpn_id, "DAM1");
}
#[test]
fn search_results_from_api_response() {
let json = serde_json::json!({
"TotalCount": 42,
"Results": [
{ "LpnId": "A1" },
{ "LpnId": "A2" }
]
});
let results = SearchResults::from_api_response(&json, 0, 15).unwrap();
assert_eq!(results.total_count, 42);
assert_eq!(results.results.len(), 2);
assert_eq!(results.page, 0);
assert_eq!(results.page_size, 15);
}
#[test]
fn parse_lineage_html_content() {
let content = "<div>Happy Acres</div><div>US Hair Index: 98.5</div><div>SRC$ Index: 102.3</div><div>DOB: 2/13/2024</div><div>Sex: Male</div><div>Status: CURRENT</div>";
let parsed = parse_lineage_content(content);
assert_eq!(parsed.farm_name.as_deref(), Some("Happy Acres"));
assert!((parsed.us_index.unwrap() - 98.5).abs() < f64::EPSILON);
assert!((parsed.src_index.unwrap() - 102.3).abs() < f64::EPSILON);
assert_eq!(parsed.date_of_birth.as_deref(), Some("2/13/2024"));
assert_eq!(parsed.sex.as_deref(), Some("Male"));
assert_eq!(parsed.status.as_deref(), Some("CURRENT"));
}
#[test]
fn trait_range_filter_serializes() {
let filter = TraitRangeFilter {
min: -1.0,
max: 1.0,
};
let json = serde_json::to_value(&filter).unwrap();
assert_eq!(json["min"], -1.0);
assert_eq!(json["max"], 1.0);
}
#[test]
fn search_criteria_with_trait_ranges() {
let mut ranges = std::collections::HashMap::new();
ranges.insert(
"BWT".to_string(),
TraitRangeFilter {
min: -1.0,
max: 1.0,
},
);
let criteria = SearchCriteria::new().with_trait_ranges(ranges);
let json = serde_json::to_value(&criteria).unwrap();
let tr = &json["traitRanges"]["BWT"];
assert_eq!(tr["min"], -1.0);
assert_eq!(tr["max"], 1.0);
}
#[test]
fn extract_contact_info_null_returns_none() {
let json = serde_json::json!({
"data": {
"gender": "Male",
"searchResultViewModel": { "lpnId": "X1" },
"contactInfo": null
}
});
let details = AnimalDetails::from_api_response(&json).unwrap();
assert!(details.contact_info.is_none());
}
#[test]
fn legacy_format_missing_traits_key() {
let json = serde_json::json!({
"LpnId": "LEG_NO_TRAITS",
"Breed": "Suffolk",
"Gender": "Female"
});
let details = AnimalDetails::from_api_response(&json).unwrap();
assert_eq!(details.lpn_id, "LEG_NO_TRAITS");
assert!(details.traits.is_empty());
}
#[test]
fn legacy_format_non_object_trait_value() {
let json = serde_json::json!({
"LpnId": "LEG_BAD_TRAIT",
"Traits": {
"BWT": "not an object",
"WWT": { "Value": 2.0, "Accuracy": 70 }
}
});
let details = AnimalDetails::from_api_response(&json).unwrap();
assert!(!details.traits.contains_key("BWT"));
assert!(details.traits.contains_key("WWT"));
}
#[test]
fn lineage_node_without_children() {
let json = serde_json::json!({
"data": {
"lpnId": "NOCHILDREN",
"content": "<div>Farm</div>"
}
});
let lineage = Lineage::from_api_response(&json).unwrap();
assert!(lineage.sire.is_none());
assert!(lineage.dam.is_none());
}
#[test]
fn breed_group_serializes() {
let bg = BreedGroup {
id: 61,
name: "Range".to_string(),
breeds: vec![Breed {
id: 486,
name: "South African Meat Merino".to_string(),
}],
};
let json = serde_json::to_value(&bg).unwrap();
assert_eq!(json["id"], 61);
assert_eq!(json["breeds"][0]["name"], "South African Meat Merino");
}
}