Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions src/dqe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ pub struct DqeEstimateItem {
pub unit: String,
/// DQE estimate basis carried as metadata, for example `avant-metre`.
pub estimate_basis: String,
/// Source/default locale for fixture display overlays such as `pt-PT`.
pub source_locale: Option<String>,
/// Version-pinned DQE release year in `YYYY` form.
pub release_year: Option<String>,
/// Regional or national jurisdiction code such as `PT`.
pub jurisdiction: Option<String>,
/// Source/access tier for fixture evidence, for example `license_cleared` or `synthetic`.
pub source_tier: Option<String>,
/// Optional calculation note preserved as evidence; it is not evaluated.
pub calculation_note: Option<String>,
/// Optional loss-finding code to attach when the fixture intentionally records a gap.
Expand All @@ -68,11 +76,22 @@ struct DqeEstimateItemFixture {
quantity: String,
unit: String,
estimate_basis: String,
source_locale: Option<String>,
release_year: Option<String>,
jurisdiction: Option<String>,
source_tier: Option<String>,
calculation_note: Option<String>,
loss_finding_code: Option<String>,
loss_finding_message: Option<String>,
}

struct DqeProfileFields {
source_locale: Option<String>,
release_year: Option<String>,
jurisdiction: Option<String>,
source_tier: Option<String>,
}

impl DqeEstimateTable {
/// Loads a DQE quantity-estimate table from a deterministic JSON fixture.
///
Expand Down Expand Up @@ -173,6 +192,8 @@ fn parse_fixture_item(
&format!("{location}.estimate_basis"),
)?;

let profile = parse_profile_fields(&item, &location)?;

Ok(DqeEstimateItem {
match_text,
dqe_code: dqe_code.to_owned(),
Expand All @@ -181,6 +202,10 @@ fn parse_fixture_item(
quantity,
unit,
estimate_basis,
source_locale: profile.source_locale,
release_year: profile.release_year,
jurisdiction: profile.jurisdiction,
source_tier: profile.source_tier,
calculation_note: optional_trimmed(item.calculation_note),
loss_finding_code: optional_trimmed(item.loss_finding_code),
loss_finding_message: optional_trimmed(item.loss_finding_message),
Expand All @@ -206,6 +231,69 @@ fn optional_trimmed(value: Option<String>) -> Option<String> {
.filter(|value| !value.is_empty())
}

fn parse_profile_fields(
item: &DqeEstimateItemFixture,
location: &str,
) -> Result<DqeProfileFields, ValidationFinding> {
let source_locale = optional_trimmed(item.source_locale.clone());
let release_year = optional_trimmed(item.release_year.clone());
validate_profile_value(
release_year.as_deref(),
is_valid_release_year,
"dqe_estimate_invalid_release_year",
"DQE release_year must use YYYY form",
&format!("{location}.release_year"),
)?;
let jurisdiction = optional_trimmed(item.jurisdiction.clone());
validate_profile_value(
jurisdiction.as_deref(),
is_valid_jurisdiction,
"dqe_estimate_invalid_jurisdiction",
"DQE jurisdiction must be an uppercase ASCII code",
&format!("{location}.jurisdiction"),
)?;
let source_tier = optional_trimmed(item.source_tier.clone());
validate_profile_value(
source_tier.as_deref(),
is_valid_source_tier,
"dqe_estimate_invalid_source_tier",
"DQE source_tier must be license_cleared, redacted, or synthetic",
&format!("{location}.source_tier"),
)?;

Ok(DqeProfileFields {
source_locale,
release_year,
jurisdiction,
source_tier,
})
}

fn validate_profile_value(
value: Option<&str>,
predicate: impl Fn(&str) -> bool,
code: &'static str,
message: &'static str,
location: &str,
) -> Result<(), ValidationFinding> {
if value.is_some_and(|value| !predicate(value)) {
return Err(ValidationFinding::warning(code, message.to_owned()).at(location.to_owned()));
}
Ok(())
}

fn is_valid_release_year(value: &str) -> bool {
value.len() == 4 && value.bytes().all(|byte| byte.is_ascii_digit())
}

fn is_valid_jurisdiction(value: &str) -> bool {
matches!(value.len(), 2 | 3) && value.bytes().all(|byte| byte.is_ascii_uppercase())
}

fn is_valid_source_tier(value: &str) -> bool {
matches!(value, "license_cleared" | "redacted" | "synthetic")
}

/// Applies a fixture-backed DQE overlay to a parsed document.
///
/// The document support status and capabilities are left unchanged. Any loss
Expand Down Expand Up @@ -320,6 +408,7 @@ fn dqe_classification(
"estimate_basis".to_owned(),
serde_json::json!(item.estimate_basis),
);
insert_profile_metadata(&mut metadata, item);

ClassificationReference {
system: ClassificationSystem::Dqe,
Expand All @@ -343,6 +432,7 @@ fn quantity_reference(
"estimate_basis".to_owned(),
serde_json::json!(item.estimate_basis),
);
insert_profile_metadata(&mut metadata, item);
if let Some(note) = &item.calculation_note {
metadata.insert("calculation_note".to_owned(), serde_json::json!(note));
}
Expand Down Expand Up @@ -396,6 +486,24 @@ fn rich_text_search_text(value: &RichText) -> String {
}
}

fn insert_profile_metadata(
metadata: &mut BTreeMap<String, serde_json::Value>,
item: &DqeEstimateItem,
) {
if let Some(value) = &item.source_locale {
metadata.insert("source_locale".to_owned(), serde_json::json!(value));
}
if let Some(value) = &item.release_year {
metadata.insert("release_year".to_owned(), serde_json::json!(value));
}
if let Some(value) = &item.jurisdiction {
metadata.insert("jurisdiction".to_owned(), serde_json::json!(value));
}
if let Some(value) = &item.source_tier {
metadata.insert("source_tier".to_owned(), serde_json::json!(value));
}
}

fn matches_dqe_item(text: &str, item: &DqeEstimateItem) -> bool {
text.to_lowercase()
.contains(&item.match_text.to_lowercase())
Expand Down
50 changes: 50 additions & 0 deletions tests/dqe_quantity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,22 @@ fn dqe_fixture_attaches_classification_quantity_and_loss_without_support_promoti
classification.metadata.get("estimate_basis"),
Some(&json!("avant-metre"))
);
assert_eq!(
classification.metadata.get("source_locale"),
Some(&json!("pt-PT"))
);
assert_eq!(
classification.metadata.get("release_year"),
Some(&json!("2026"))
);
assert_eq!(
classification.metadata.get("jurisdiction"),
Some(&json!("PT"))
);
assert_eq!(
classification.metadata.get("source_tier"),
Some(&json!("license_cleared"))
);

let quantity = annotations
.quantity_references
Expand All @@ -71,6 +87,17 @@ fn dqe_fixture_attaches_classification_quantity_and_loss_without_support_promoti
quantity.metadata.get("calculation_note"),
Some(&json!("longueur * largeur * hauteur"))
);
assert_eq!(
quantity.metadata.get("source_locale"),
Some(&json!("pt-PT"))
);
assert_eq!(quantity.metadata.get("release_year"), Some(&json!("2026")));
assert_eq!(quantity.metadata.get("jurisdiction"), Some(&json!("PT")));
assert_eq!(
quantity.metadata.get("source_tier"),
Some(&json!("license_cleared"))
);

assert!(
quantity
.findings
Expand Down Expand Up @@ -171,6 +198,18 @@ fn invalid_dqe_fixture_variants_are_reported_explicitly() {
r#"{"source_uri":"fixture","items":[{"match_text":"Concrete","dqe_code":"DQE-SYN-001","label":"Estimate","quantity_reference":"DQE-QTY-SYN-001","quantity":"3.75","unit":"m3","estimate_basis":" "}]}"#,
"dqe_estimate_empty_basis",
),
(
r#"{"source_uri":"fixture","items":[{"match_text":"Concrete","dqe_code":"DQE-SYN-001","label":"Estimate","quantity_reference":"DQE-QTY-SYN-001","quantity":"3.75","unit":"m3","estimate_basis":"avant-metre","release_year":"26"}]}"#,
"dqe_estimate_invalid_release_year",
),
(
r#"{"source_uri":"fixture","items":[{"match_text":"Concrete","dqe_code":"DQE-SYN-001","label":"Estimate","quantity_reference":"DQE-QTY-SYN-001","quantity":"3.75","unit":"m3","estimate_basis":"avant-metre","jurisdiction":"pt"}]}"#,
"dqe_estimate_invalid_jurisdiction",
),
(
r#"{"source_uri":"fixture","items":[{"match_text":"Concrete","dqe_code":"DQE-SYN-001","label":"Estimate","quantity_reference":"DQE-QTY-SYN-001","quantity":"3.75","unit":"m3","estimate_basis":"avant-metre","source_tier":"unclear"}]}"#,
"dqe_estimate_invalid_source_tier",
),
];

for (input, expected_code) in cases {
Expand Down Expand Up @@ -287,6 +326,17 @@ fn overlay_matches_xhtml_long_text_case_insensitively() {
assert_eq!(annotations.classifications[0].code, "DQE-SYN-001");
}

#[test]
fn fixture_exposes_dqe_profile_metadata() {
let table = dqe_table();
let item = &table.items()[0];

assert_eq!(item.source_locale.as_deref(), Some("pt-PT"));
assert_eq!(item.release_year.as_deref(), Some("2026"));
assert_eq!(item.jurisdiction.as_deref(), Some("PT"));
assert_eq!(item.source_tier.as_deref(), Some("license_cleared"));
}

#[test]
fn malformed_existing_annotations_are_reported_not_hidden() {
let mut document = supported_ava_document();
Expand Down
6 changes: 5 additions & 1 deletion tests/fixtures/synthetic/dqe_quantity.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
"estimate_basis": "avant-metre",
"calculation_note": "longueur * largeur * hauteur",
"loss_finding_code": "dqe_quantity_method_preserved_not_evaluated",
"loss_finding_message": "Synthetic DQE calculation note was preserved as evidence but not evaluated as official French quantity-estimate support"
"loss_finding_message": "Synthetic DQE calculation note was preserved as evidence but not evaluated as official French quantity-estimate support",
"source_locale": "pt-PT",
"release_year": "2026",
"jurisdiction": "PT",
"source_tier": "license_cleared"
}
]
}
Loading