1use std::collections::{HashMap, HashSet};
4
5use crate::data::HDict;
6use crate::kinds::{HRef, Kind};
7use crate::ontology::DefNamespace;
8use crate::ontology::validation::FitIssue;
9
10use super::spec::Spec;
11
12pub type EntityResolver = dyn Fn(&HRef) -> Option<HDict>;
15
16pub fn fits(
26 entity: &HDict,
27 spec_qname: &str,
28 ns: &mut DefNamespace,
29 resolver: Option<&EntityResolver>,
30) -> bool {
31 fits_explain(entity, spec_qname, ns, resolver).is_empty()
32}
33
34pub fn fits_explain(
38 entity: &HDict,
39 spec_qname: &str,
40 ns: &mut DefNamespace,
41 resolver: Option<&EntityResolver>,
42) -> Vec<FitIssue> {
43 let spec = resolve_spec(spec_qname, ns);
47 match spec {
48 Some(spec) => explain_against_spec_with_specs(entity, &spec, &HashMap::new(), resolver),
49 None => {
50 let bare_name = spec_qname.split("::").last().unwrap_or(spec_qname);
53 ns.fits_explain(entity, bare_name)
54 }
55 }
56}
57
58fn resolve_spec(spec_qname: &str, ns: &mut DefNamespace) -> Option<Spec> {
63 let bare_name = spec_qname.split("::").last().unwrap_or(spec_qname);
65
66 let def = ns.get_def(bare_name)?;
68 let doc = def.doc.clone();
69 let lib = def.lib.clone();
70
71 let mandatory = ns.mandatory_tags(bare_name);
73 let mut spec = Spec {
74 qname: spec_qname.to_string(),
75 name: bare_name.to_string(),
76 lib,
77 base: None,
78 meta: std::collections::HashMap::new(),
79 slots: Vec::new(),
80 is_abstract: false,
81 doc,
82 };
83
84 for tag in &mandatory {
86 spec.slots.push(super::spec::Slot {
87 name: tag.clone(),
88 type_ref: None,
89 meta: std::collections::HashMap::new(),
90 default: None,
91 is_marker: true,
92 is_query: false,
93 children: Vec::new(),
94 });
95 }
96
97 Some(spec)
98}
99
100#[cfg(test)]
102fn explain_against_spec(entity: &HDict, spec: &Spec) -> Vec<FitIssue> {
103 explain_against_spec_with_specs(entity, spec, &HashMap::new(), None)
104}
105
106fn explain_against_spec_with_specs(
109 entity: &HDict,
110 spec: &Spec,
111 specs: &HashMap<String, Spec>,
112 resolver: Option<&EntityResolver>,
113) -> Vec<FitIssue> {
114 let mut issues = Vec::new();
115
116 check_mandatory_markers(entity, spec, specs, &mut issues);
118
119 check_slot_types(entity, spec, &mut issues);
121
122 check_value_constraints(entity, spec, &mut issues);
124
125 if let Some(resolver) = resolver {
127 check_query_slots(entity, spec, resolver, &mut issues);
128 }
129
130 issues
131}
132
133fn check_mandatory_markers(
136 entity: &HDict,
137 spec: &Spec,
138 specs: &HashMap<String, Spec>,
139 issues: &mut Vec<FitIssue>,
140) {
141 let mut all_mandatory: HashSet<String> = HashSet::new();
142
143 for name in spec.mandatory_markers() {
145 all_mandatory.insert(name.to_string());
146 }
147
148 let mut base = spec.base.clone();
150 let mut visited = HashSet::new();
151 while let Some(base_name) = base {
152 if !visited.insert(base_name.clone()) {
153 break;
154 }
155 if let Some(base_spec) = specs.get(&base_name) {
156 for name in base_spec.mandatory_markers() {
157 all_mandatory.insert(name.to_string());
158 }
159 base = base_spec.base.clone();
160 } else {
161 break;
162 }
163 }
164
165 for tag in &all_mandatory {
166 if entity.missing(tag) {
167 issues.push(FitIssue::MissingMarker {
168 tag: tag.clone(),
169 spec: spec.qname.clone(),
170 });
171 }
172 }
173}
174
175fn check_slot_types(entity: &HDict, spec: &Spec, issues: &mut Vec<FitIssue>) {
177 for slot in &spec.slots {
178 if slot.is_marker || slot.is_query {
179 continue;
180 }
181 if slot.is_maybe() {
183 continue;
184 }
185 let type_ref = match &slot.type_ref {
186 Some(t) => t.as_str(),
187 None => continue,
188 };
189
190 if let Some(val) = entity.get(&slot.name) {
191 let ok = match type_ref {
192 "Str" => matches!(val, Kind::Str(_)),
193 "Number" => matches!(val, Kind::Number(_)),
194 "Ref" => matches!(val, Kind::Ref(_)),
195 "Bool" => matches!(val, Kind::Bool(_)),
196 "Date" => matches!(val, Kind::Date(_)),
197 "Time" => matches!(val, Kind::Time(_)),
198 "DateTime" => matches!(val, Kind::DateTime(_)),
199 "Uri" => matches!(val, Kind::Uri(_)),
200 "Coord" => matches!(val, Kind::Coord(_)),
201 "List" => matches!(val, Kind::List(_)),
202 "Dict" => matches!(val, Kind::Dict(_)),
203 "Grid" => matches!(val, Kind::Grid(_)),
204 "Marker" => matches!(val, Kind::Marker),
205 _ => true, };
207 if !ok {
208 issues.push(FitIssue::WrongType {
209 tag: slot.name.clone(),
210 expected: type_ref.to_string(),
211 actual: kind_type_name(val).to_string(),
212 });
213 }
214 }
215 }
218}
219
220fn check_value_constraints(entity: &HDict, spec: &Spec, issues: &mut Vec<FitIssue>) {
222 for slot in &spec.slots {
223 if slot.is_marker || slot.is_query {
224 continue;
225 }
226 let val = match entity.get(&slot.name) {
227 Some(v) => v,
228 None => continue,
229 };
230
231 if let Kind::Number(num) = val {
233 if let Some(Kind::Number(min)) = slot.meta.get("minVal")
234 && num.val < min.val
235 {
236 issues.push(FitIssue::ConstraintViolation {
237 tag: slot.name.clone(),
238 constraint: "minVal".into(),
239 detail: format!("{} < {}", num.val, min.val),
240 });
241 }
242 if let Some(Kind::Number(max)) = slot.meta.get("maxVal")
243 && num.val > max.val
244 {
245 issues.push(FitIssue::ConstraintViolation {
246 tag: slot.name.clone(),
247 constraint: "maxVal".into(),
248 detail: format!("{} > {}", num.val, max.val),
249 });
250 }
251 if slot.meta.contains_key("unitless")
253 && let Some(unit) = &num.unit
254 {
255 issues.push(FitIssue::ConstraintViolation {
256 tag: slot.name.clone(),
257 constraint: "unitless".into(),
258 detail: format!("expected no unit, got '{}'", unit),
259 });
260 }
261 if let Some(Kind::Str(expected_unit)) = slot.meta.get("unit") {
263 match &num.unit {
264 Some(u) if u != expected_unit => {
265 issues.push(FitIssue::ConstraintViolation {
266 tag: slot.name.clone(),
267 constraint: "unit".into(),
268 detail: format!("expected unit '{}', got '{}'", expected_unit, u),
269 });
270 }
271 None => {
272 issues.push(FitIssue::ConstraintViolation {
273 tag: slot.name.clone(),
274 constraint: "unit".into(),
275 detail: format!("expected unit '{}', got unitless", expected_unit),
276 });
277 }
278 _ => {}
279 }
280 }
281 }
282
283 if let Kind::Str(s) = val {
285 if let Some(Kind::Number(min)) = slot.meta.get("minSize")
286 && (s.len() as f64) < min.val
287 {
288 issues.push(FitIssue::ConstraintViolation {
289 tag: slot.name.clone(),
290 constraint: "minSize".into(),
291 detail: format!("length {} < {}", s.len(), min.val),
292 });
293 }
294 if let Some(Kind::Number(max)) = slot.meta.get("maxSize")
295 && (s.len() as f64) > max.val
296 {
297 issues.push(FitIssue::ConstraintViolation {
298 tag: slot.name.clone(),
299 constraint: "maxSize".into(),
300 detail: format!("length {} > {}", s.len(), max.val),
301 });
302 }
303 if slot.meta.contains_key("nonEmpty") && s.trim().is_empty() {
304 issues.push(FitIssue::ConstraintViolation {
305 tag: slot.name.clone(),
306 constraint: "nonEmpty".into(),
307 detail: "string is empty or whitespace only".into(),
308 });
309 }
310 if let Some(Kind::Str(pattern)) = slot.meta.get("pattern") {
311 match regex::Regex::new(pattern) {
312 Ok(re) => {
313 if !re.is_match(s) {
314 issues.push(FitIssue::ConstraintViolation {
315 tag: slot.name.clone(),
316 constraint: "pattern".into(),
317 detail: format!("'{}' does not match pattern '{}'", s, pattern),
318 });
319 }
320 }
321 Err(e) => {
322 issues.push(FitIssue::ConstraintViolation {
323 tag: slot.name.clone(),
324 constraint: "pattern".into(),
325 detail: format!("invalid regex pattern '{}': {}", pattern, e),
326 });
327 }
328 }
329 }
330 }
331
332 if let Kind::List(items) = val {
334 if let Some(Kind::Number(min)) = slot.meta.get("minSize")
335 && (items.len() as f64) < min.val
336 {
337 issues.push(FitIssue::ConstraintViolation {
338 tag: slot.name.clone(),
339 constraint: "minSize".into(),
340 detail: format!("list length {} < {}", items.len(), min.val),
341 });
342 }
343 if let Some(Kind::Number(max)) = slot.meta.get("maxSize")
344 && (items.len() as f64) > max.val
345 {
346 issues.push(FitIssue::ConstraintViolation {
347 tag: slot.name.clone(),
348 constraint: "maxSize".into(),
349 detail: format!("list length {} > {}", items.len(), max.val),
350 });
351 }
352 }
353 }
354}
355
356fn check_query_slots(
358 entity: &HDict,
359 spec: &Spec,
360 resolver: &EntityResolver,
361 issues: &mut Vec<FitIssue>,
362) {
363 for slot in &spec.slots {
364 if !slot.is_query {
365 continue;
366 }
367 let of_type = slot.meta.get("of").and_then(|v| {
369 if let Kind::Str(s) = v {
370 Some(s.as_str())
371 } else {
372 None
373 }
374 });
375
376 let via_path = slot.meta.get("via").and_then(|v| {
377 if let Kind::Str(s) = v {
378 Some(s.as_str())
379 } else {
380 None
381 }
382 });
383
384 if let (Some(_of_type), Some(via)) = (of_type, via_path) {
385 let (ref_tag, transitive) = if let Some(stripped) = via.strip_suffix('+') {
387 (stripped, true)
388 } else {
389 (via, false)
390 };
391
392 let reachable = traverse_refs(entity, ref_tag, transitive, resolver);
394
395 if reachable.is_empty() && !slot.is_maybe() {
397 issues.push(FitIssue::ConstraintViolation {
398 tag: slot.name.clone(),
399 constraint: "query".into(),
400 detail: format!("no entities reachable via '{}'", via),
401 });
402 }
403 }
404 }
405}
406
407fn traverse_refs(
409 entity: &HDict,
410 ref_tag: &str,
411 transitive: bool,
412 resolver: &EntityResolver,
413) -> Vec<HDict> {
414 let mut results = Vec::new();
415 let mut visited = std::collections::HashSet::new();
416 let mut queue = Vec::new();
417
418 if let Some(Kind::Ref(r)) = entity.get(ref_tag) {
420 queue.push(r.clone());
421 }
422
423 while let Some(ref_val) = queue.pop() {
424 if !visited.insert(ref_val.val.clone()) {
425 continue;
426 }
427 if let Some(target) = resolver(&ref_val) {
428 if transitive && let Some(Kind::Ref(next)) = target.get(ref_tag) {
429 queue.push(next.clone());
430 }
431 results.push(target);
432 }
433 }
434 results
435}
436
437fn kind_type_name(val: &Kind) -> &'static str {
439 match val {
440 Kind::Null => "Null",
441 Kind::Marker => "Marker",
442 Kind::NA => "NA",
443 Kind::Remove => "Remove",
444 Kind::Bool(_) => "Bool",
445 Kind::Number(_) => "Number",
446 Kind::Str(_) => "Str",
447 Kind::Ref(_) => "Ref",
448 Kind::Uri(_) => "Uri",
449 Kind::Symbol(_) => "Symbol",
450 Kind::Date(_) => "Date",
451 Kind::Time(_) => "Time",
452 Kind::DateTime(_) => "DateTime",
453 Kind::Coord(_) => "Coord",
454 Kind::XStr(_) => "XStr",
455 Kind::List(_) => "List",
456 Kind::Dict(_) => "Dict",
457 Kind::Grid(_) => "Grid",
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464 use crate::kinds::{HRef, Number};
465 use crate::ontology::trio_loader::load_trio;
466
467 fn build_test_ns() -> DefNamespace {
469 let trio = "\
470def:^marker
471doc:\"Marker type\"
472is:[^marker]
473lib:^lib:ph
474---
475def:^entity
476doc:\"Top-level entity\"
477is:[^marker]
478lib:^lib:ph
479---
480def:^equip
481doc:\"Equipment\"
482is:[^entity]
483lib:^lib:phIoT
484mandatory
485---
486def:^point
487doc:\"Data point\"
488is:[^entity]
489lib:^lib:phIoT
490---
491def:^ahu
492doc:\"Air Handling Unit\"
493is:[^equip]
494lib:^lib:phIoT
495mandatory
496---
497def:^site
498doc:\"A site\"
499is:[^entity]
500lib:^lib:ph
501---
502def:^lib:ph
503doc:\"Project Haystack core\"
504is:[^lib]
505lib:^lib:ph
506version:\"4.0.0\"
507---
508def:^lib:phIoT
509doc:\"Project Haystack IoT\"
510is:[^lib]
511lib:^lib:phIoT
512version:\"4.0.0\"
513depends:[^lib:ph]
514";
515 let mut ns = DefNamespace::new();
516 let libs = load_trio(trio).unwrap();
517 for lib in libs {
518 ns.register_lib(lib);
519 }
520 ns
521 }
522
523 #[test]
524 fn entity_fits_with_all_markers() {
525 let mut ns = build_test_ns();
526 let mut entity = HDict::new();
527 entity.set("id", Kind::Ref(HRef::from_val("ahu-1")));
528 entity.set("ahu", Kind::Marker);
529 entity.set("equip", Kind::Marker);
530
531 assert!(fits(&entity, "ahu", &mut ns, None));
532 }
533
534 #[test]
535 fn entity_missing_mandatory_marker_fails() {
536 let mut ns = build_test_ns();
537 let mut entity = HDict::new();
538 entity.set("id", Kind::Ref(HRef::from_val("ahu-1")));
539 entity.set("ahu", Kind::Marker);
540 assert!(!fits(&entity, "ahu", &mut ns, None));
543 }
544
545 #[test]
546 fn fits_explain_returns_missing_marker_issues() {
547 let mut ns = build_test_ns();
548 let mut entity = HDict::new();
549 entity.set("id", Kind::Ref(HRef::from_val("ahu-1")));
550 entity.set("ahu", Kind::Marker);
551 let issues = fits_explain(&entity, "ahu", &mut ns, None);
554 assert!(!issues.is_empty());
555
556 let has_equip_issue = issues
557 .iter()
558 .any(|i| matches!(i, FitIssue::MissingMarker { tag, .. } if tag == "equip"));
559 assert!(has_equip_issue);
560 }
561
562 #[test]
563 fn fits_explain_empty_when_valid() {
564 let mut ns = build_test_ns();
565 let mut entity = HDict::new();
566 entity.set("ahu", Kind::Marker);
567 entity.set("equip", Kind::Marker);
568
569 let issues = fits_explain(&entity, "ahu", &mut ns, None);
570 assert!(issues.is_empty());
571 }
572
573 #[test]
574 fn type_checking_wrong_type() {
575 let spec = Spec {
577 qname: "test::Foo".to_string(),
578 name: "Foo".to_string(),
579 lib: "test".to_string(),
580 base: None,
581 meta: std::collections::HashMap::new(),
582 slots: vec![super::super::spec::Slot {
583 name: "name".to_string(),
584 type_ref: Some("Str".to_string()),
585 meta: std::collections::HashMap::new(),
586 default: None,
587 is_marker: false,
588 is_query: false,
589 children: Vec::new(),
590 }],
591 is_abstract: false,
592 doc: String::new(),
593 };
594
595 let mut entity = HDict::new();
596 entity.set("name", Kind::Number(Number::unitless(42.0))); let issues = explain_against_spec(&entity, &spec);
599 assert!(!issues.is_empty());
600 let has_wrong_type = issues.iter().any(|i| {
601 matches!(i, FitIssue::WrongType { tag, expected, actual }
602 if tag == "name" && expected == "Str" && actual == "Number")
603 });
604 assert!(has_wrong_type);
605 }
606
607 #[test]
608 fn type_checking_correct_type() {
609 let spec = Spec {
610 qname: "test::Foo".to_string(),
611 name: "Foo".to_string(),
612 lib: "test".to_string(),
613 base: None,
614 meta: std::collections::HashMap::new(),
615 slots: vec![
616 super::super::spec::Slot {
617 name: "name".to_string(),
618 type_ref: Some("Str".to_string()),
619 meta: std::collections::HashMap::new(),
620 default: None,
621 is_marker: false,
622 is_query: false,
623 children: Vec::new(),
624 },
625 super::super::spec::Slot {
626 name: "area".to_string(),
627 type_ref: Some("Number".to_string()),
628 meta: std::collections::HashMap::new(),
629 default: None,
630 is_marker: false,
631 is_query: false,
632 children: Vec::new(),
633 },
634 super::super::spec::Slot {
635 name: "siteRef".to_string(),
636 type_ref: Some("Ref".to_string()),
637 meta: std::collections::HashMap::new(),
638 default: None,
639 is_marker: false,
640 is_query: false,
641 children: Vec::new(),
642 },
643 ],
644 is_abstract: false,
645 doc: String::new(),
646 };
647
648 let mut entity = HDict::new();
649 entity.set("name", Kind::Str("Test".to_string()));
650 entity.set("area", Kind::Number(Number::unitless(1000.0)));
651 entity.set("siteRef", Kind::Ref(HRef::from_val("site-1")));
652
653 let issues = explain_against_spec(&entity, &spec);
654 assert!(issues.is_empty());
655 }
656
657 #[test]
658 fn maybe_slots_are_skipped() {
659 let mut meta = std::collections::HashMap::new();
660 meta.insert("maybe".to_string(), Kind::Marker);
661
662 let spec = Spec {
663 qname: "test::Foo".to_string(),
664 name: "Foo".to_string(),
665 lib: "test".to_string(),
666 base: None,
667 meta: std::collections::HashMap::new(),
668 slots: vec![
669 super::super::spec::Slot {
670 name: "optional".to_string(),
671 type_ref: None,
672 meta: meta.clone(),
673 default: None,
674 is_marker: true,
675 is_query: false,
676 children: Vec::new(),
677 },
678 super::super::spec::Slot {
679 name: "optionalStr".to_string(),
680 type_ref: Some("Str".to_string()),
681 meta,
682 default: None,
683 is_marker: false,
684 is_query: false,
685 children: Vec::new(),
686 },
687 ],
688 is_abstract: false,
689 doc: String::new(),
690 };
691
692 let entity = HDict::new(); let issues = explain_against_spec(&entity, &spec);
695 assert!(issues.is_empty()); }
697
698 #[test]
699 fn kind_type_name_coverage() {
700 assert_eq!(kind_type_name(&Kind::Null), "Null");
701 assert_eq!(kind_type_name(&Kind::Marker), "Marker");
702 assert_eq!(kind_type_name(&Kind::Bool(true)), "Bool");
703 assert_eq!(kind_type_name(&Kind::Str("x".into())), "Str");
704 assert_eq!(
705 kind_type_name(&Kind::Number(Number::unitless(1.0))),
706 "Number"
707 );
708 assert_eq!(kind_type_name(&Kind::Ref(HRef::from_val("x"))), "Ref");
709 }
710
711 #[test]
712 fn fitting_checks_inherited_markers() {
713 let mut parent = Spec::new("test::Equip", "test", "Equip");
715 parent.slots.push(super::super::spec::Slot {
716 name: "equip".to_string(),
717 type_ref: None,
718 meta: std::collections::HashMap::new(),
719 default: None,
720 is_marker: true,
721 is_query: false,
722 children: Vec::new(),
723 });
724
725 let mut child = Spec::new("test::Ahu", "test", "Ahu");
727 child.base = Some("test::Equip".to_string());
728 child.slots.push(super::super::spec::Slot {
729 name: "ahu".to_string(),
730 type_ref: None,
731 meta: std::collections::HashMap::new(),
732 default: None,
733 is_marker: true,
734 is_query: false,
735 children: Vec::new(),
736 });
737
738 let mut specs = HashMap::new();
739 specs.insert("test::Equip".to_string(), parent);
740 specs.insert("test::Ahu".to_string(), child.clone());
741
742 let mut entity = HDict::new();
744 entity.set("ahu", Kind::Marker);
745
746 let issues = explain_against_spec_with_specs(&entity, &child, &specs, None);
747 assert!(!issues.is_empty());
748 let has_equip_issue = issues
749 .iter()
750 .any(|i| matches!(i, FitIssue::MissingMarker { tag, .. } if tag == "equip"));
751 assert!(
752 has_equip_issue,
753 "should report missing inherited 'equip' marker"
754 );
755
756 let mut entity2 = HDict::new();
758 entity2.set("ahu", Kind::Marker);
759 entity2.set("equip", Kind::Marker);
760
761 let issues2 = explain_against_spec_with_specs(&entity2, &child, &specs, None);
762 assert!(issues2.is_empty(), "should pass with all markers present");
763 }
764
765 #[test]
766 fn constraint_min_val() {
767 let mut meta = HashMap::new();
768 meta.insert("minVal".to_string(), Kind::Number(Number::unitless(0.0)));
769 let spec = Spec {
770 qname: "test::Temp".into(),
771 name: "Temp".into(),
772 lib: "test".into(),
773 base: None,
774 meta: HashMap::new(),
775 is_abstract: false,
776 doc: String::new(),
777 slots: vec![super::super::spec::Slot {
778 name: "value".into(),
779 type_ref: Some("Number".into()),
780 meta,
781 default: None,
782 is_marker: false,
783 is_query: false,
784 children: vec![],
785 }],
786 };
787 let mut entity = HDict::new();
788 entity.set("value", Kind::Number(Number::unitless(-5.0)));
789 let issues = explain_against_spec(&entity, &spec);
790 assert!(issues.iter().any(|i| matches!(
791 i,
792 FitIssue::ConstraintViolation { constraint, .. } if constraint == "minVal"
793 )));
794 }
795
796 #[test]
797 fn constraint_max_val() {
798 let mut meta = HashMap::new();
799 meta.insert("maxVal".to_string(), Kind::Number(Number::unitless(100.0)));
800 let spec = Spec {
801 qname: "test::Pct".into(),
802 name: "Pct".into(),
803 lib: "test".into(),
804 base: None,
805 meta: HashMap::new(),
806 is_abstract: false,
807 doc: String::new(),
808 slots: vec![super::super::spec::Slot {
809 name: "pct".into(),
810 type_ref: Some("Number".into()),
811 meta,
812 default: None,
813 is_marker: false,
814 is_query: false,
815 children: vec![],
816 }],
817 };
818 let mut entity = HDict::new();
819 entity.set("pct", Kind::Number(Number::unitless(150.0)));
820 let issues = explain_against_spec(&entity, &spec);
821 assert!(issues.iter().any(|i| matches!(
822 i,
823 FitIssue::ConstraintViolation { constraint, .. } if constraint == "maxVal"
824 )));
825 }
826
827 #[test]
828 fn constraint_pattern() {
829 let mut meta = HashMap::new();
830 meta.insert(
831 "pattern".to_string(),
832 Kind::Str(r"^\d{4}-\d{2}-\d{2}$".into()),
833 );
834 let spec = Spec {
835 qname: "test::Dated".into(),
836 name: "Dated".into(),
837 lib: "test".into(),
838 base: None,
839 meta: HashMap::new(),
840 is_abstract: false,
841 doc: String::new(),
842 slots: vec![super::super::spec::Slot {
843 name: "dateStr".into(),
844 type_ref: Some("Str".into()),
845 meta,
846 default: None,
847 is_marker: false,
848 is_query: false,
849 children: vec![],
850 }],
851 };
852 let mut entity = HDict::new();
853 entity.set("dateStr", Kind::Str("not-a-date".into()));
854 let issues = explain_against_spec(&entity, &spec);
855 assert!(issues.iter().any(|i| matches!(
856 i,
857 FitIssue::ConstraintViolation { constraint, .. } if constraint == "pattern"
858 )));
859
860 let mut entity2 = HDict::new();
862 entity2.set("dateStr", Kind::Str("2025-01-15".into()));
863 assert!(explain_against_spec(&entity2, &spec).is_empty());
864 }
865
866 #[test]
867 fn constraint_non_empty() {
868 let mut meta = HashMap::new();
869 meta.insert("nonEmpty".to_string(), Kind::Marker);
870 let spec = Spec {
871 qname: "test::Named".into(),
872 name: "Named".into(),
873 lib: "test".into(),
874 base: None,
875 meta: HashMap::new(),
876 is_abstract: false,
877 doc: String::new(),
878 slots: vec![super::super::spec::Slot {
879 name: "dis".into(),
880 type_ref: Some("Str".into()),
881 meta,
882 default: None,
883 is_marker: false,
884 is_query: false,
885 children: vec![],
886 }],
887 };
888 let mut entity = HDict::new();
889 entity.set("dis", Kind::Str(" ".into()));
890 let issues = explain_against_spec(&entity, &spec);
891 assert!(issues.iter().any(|i| matches!(
892 i,
893 FitIssue::ConstraintViolation { constraint, .. } if constraint == "nonEmpty"
894 )));
895 }
896
897 #[test]
898 fn constraint_unitless() {
899 let mut meta = HashMap::new();
900 meta.insert("unitless".to_string(), Kind::Marker);
901 let spec = Spec {
902 qname: "test::Count".into(),
903 name: "Count".into(),
904 lib: "test".into(),
905 base: None,
906 meta: HashMap::new(),
907 is_abstract: false,
908 doc: String::new(),
909 slots: vec![super::super::spec::Slot {
910 name: "count".into(),
911 type_ref: Some("Number".into()),
912 meta,
913 default: None,
914 is_marker: false,
915 is_query: false,
916 children: vec![],
917 }],
918 };
919 let mut entity = HDict::new();
920 entity.set("count", Kind::Number(Number::new(5.0, Some("kg".into()))));
921 let issues = explain_against_spec(&entity, &spec);
922 assert!(issues.iter().any(|i| matches!(
923 i,
924 FitIssue::ConstraintViolation { constraint, .. } if constraint == "unitless"
925 )));
926 }
927
928 #[test]
929 fn constraint_list_max_size() {
930 let mut meta = HashMap::new();
931 meta.insert("maxSize".to_string(), Kind::Number(Number::unitless(3.0)));
932 let spec = Spec {
933 qname: "test::Limited".into(),
934 name: "Limited".into(),
935 lib: "test".into(),
936 base: None,
937 meta: HashMap::new(),
938 is_abstract: false,
939 doc: String::new(),
940 slots: vec![super::super::spec::Slot {
941 name: "items".into(),
942 type_ref: Some("List".into()),
943 meta,
944 default: None,
945 is_marker: false,
946 is_query: false,
947 children: vec![],
948 }],
949 };
950 let mut entity = HDict::new();
951 entity.set("items", Kind::List(vec![Kind::Marker; 5]));
952 let issues = explain_against_spec(&entity, &spec);
953 assert!(issues.iter().any(|i| matches!(
954 i,
955 FitIssue::ConstraintViolation { constraint, .. } if constraint == "maxSize"
956 )));
957 }
958
959 #[test]
960 fn valid_constraints_produce_no_issues() {
961 let mut meta = HashMap::new();
962 meta.insert("minVal".to_string(), Kind::Number(Number::unitless(0.0)));
963 meta.insert("maxVal".to_string(), Kind::Number(Number::unitless(100.0)));
964 let spec = Spec {
965 qname: "test::Pct".into(),
966 name: "Pct".into(),
967 lib: "test".into(),
968 base: None,
969 meta: HashMap::new(),
970 is_abstract: false,
971 doc: String::new(),
972 slots: vec![super::super::spec::Slot {
973 name: "pct".into(),
974 type_ref: Some("Number".into()),
975 meta,
976 default: None,
977 is_marker: false,
978 is_query: false,
979 children: vec![],
980 }],
981 };
982 let mut entity = HDict::new();
983 entity.set("pct", Kind::Number(Number::unitless(50.0)));
984 assert!(explain_against_spec(&entity, &spec).is_empty());
985 }
986
987 #[test]
988 fn query_traversal_follows_refs() {
989 let mut parent = HDict::new();
990 parent.set("id", Kind::Ref(HRef::from_val("parent")));
991 parent.set("equip", Kind::Marker);
992
993 let mut child = HDict::new();
994 child.set("id", Kind::Ref(HRef::from_val("child")));
995 child.set("equipRef", Kind::Ref(HRef::from_val("parent")));
996
997 let entities: HashMap<String, HDict> =
998 vec![("parent".into(), parent), ("child".into(), child.clone())]
999 .into_iter()
1000 .collect();
1001
1002 let resolver = move |r: &HRef| -> Option<HDict> { entities.get(&r.val).cloned() };
1003
1004 let reachable = traverse_refs(&child, "equipRef", false, &resolver);
1005 assert_eq!(reachable.len(), 1);
1006 }
1007
1008 #[test]
1009 fn query_traversal_transitive() {
1010 let mut a = HDict::new();
1011 a.set("id", Kind::Ref(HRef::from_val("a")));
1012 a.set("siteRef", Kind::Ref(HRef::from_val("b")));
1013
1014 let mut b = HDict::new();
1015 b.set("id", Kind::Ref(HRef::from_val("b")));
1016 b.set("siteRef", Kind::Ref(HRef::from_val("c")));
1017
1018 let mut c = HDict::new();
1019 c.set("id", Kind::Ref(HRef::from_val("c")));
1020
1021 let entities: HashMap<String, HDict> =
1022 vec![("a".into(), a.clone()), ("b".into(), b), ("c".into(), c)]
1023 .into_iter()
1024 .collect();
1025
1026 let resolver = move |r: &HRef| -> Option<HDict> { entities.get(&r.val).cloned() };
1027
1028 let reachable = traverse_refs(&a, "siteRef", true, &resolver);
1029 assert_eq!(reachable.len(), 2); }
1031
1032 #[test]
1033 fn traverse_refs_handles_cycles() {
1034 let mut a = HDict::new();
1035 a.set("id", Kind::Ref(HRef::from_val("a")));
1036 a.set("equipRef", Kind::Ref(HRef::from_val("b")));
1037
1038 let mut b = HDict::new();
1039 b.set("id", Kind::Ref(HRef::from_val("b")));
1040 b.set("equipRef", Kind::Ref(HRef::from_val("a")));
1041
1042 let entities: HashMap<String, HDict> =
1043 vec![("a".into(), a), ("b".into(), b)].into_iter().collect();
1044
1045 let resolver = move |r: &HRef| -> Option<HDict> { entities.get(&r.val).cloned() };
1046
1047 let mut entity = HDict::new();
1048 entity.set("equipRef", Kind::Ref(HRef::from_val("a")));
1049
1050 let reachable = traverse_refs(&entity, "equipRef", true, &resolver);
1051 assert_eq!(reachable.len(), 2); }
1053
1054 #[test]
1055 fn fits_with_resolver_none_works() {
1056 let mut ns = build_test_ns();
1057 let mut entity = HDict::new();
1058 entity.set("ahu", Kind::Marker);
1059 entity.set("equip", Kind::Marker);
1060 assert!(fits(&entity, "ahu", &mut ns, None));
1061 }
1062
1063 #[test]
1064 fn invalid_regex_pattern_produces_constraint_violation() {
1065 let mut meta = HashMap::new();
1066 meta.insert("pattern".to_string(), Kind::Str(r"(\d+".into()));
1068 let spec = Spec {
1069 qname: "test::BadPattern".into(),
1070 name: "BadPattern".into(),
1071 lib: "test".into(),
1072 base: None,
1073 meta: HashMap::new(),
1074 is_abstract: false,
1075 doc: String::new(),
1076 slots: vec![super::super::spec::Slot {
1077 name: "code".into(),
1078 type_ref: Some("Str".into()),
1079 meta,
1080 default: None,
1081 is_marker: false,
1082 is_query: false,
1083 children: vec![],
1084 }],
1085 };
1086 let mut entity = HDict::new();
1087 entity.set("code", Kind::Str("anything".into()));
1088 let issues = explain_against_spec(&entity, &spec);
1089 assert!(
1090 issues.iter().any(|i| matches!(
1091 i,
1092 FitIssue::ConstraintViolation { constraint, detail, .. }
1093 if constraint == "pattern" && detail.contains("invalid regex")
1094 )),
1095 "should report invalid regex pattern as constraint violation"
1096 );
1097 }
1098}