rakata_formats/gff_compare.rs
1//! Comparing a GFF tree against the one it was derived from.
2//!
3//! A typed view reads a GFF, models some of it, and writes it back. Two
4//! questions decide whether that was faithful, and neither can be answered by
5//! looking at the view alone:
6//!
7//! - Did every field it rewrote keep the value the original had?
8//! - Did it write a label the original never had?
9//!
10//! Both are pure tree work: which labels are present, and whether the values
11//! under them match. Nothing here knows what a module or a creature is, which
12//! is why it sits in this crate rather than above it.
13//!
14//! Both the test suite and `vanilla-inspector` use these. That is the point:
15//! if the tool and the tests grew separate implementations, the tool's verdict
16//! would quietly stop meaning what the tests' verdict means, which is the same
17//! parallel-definition drift that has produced real defects here before.
18//!
19//! ## What this does not decide
20//!
21//! Whether a difference is a defect. A view is a projection and is entitled to
22//! drop fields, and a label written but absent from every file may be a field
23//! the engine omits at its default rather than a wrong label. The caller
24//! supplies that judgement; this module supplies the observation.
25
26use std::collections::{BTreeMap, BTreeSet};
27
28use crate::gff::{GffStruct, GffValue};
29use crate::gff_schema::{gff_value_type, GffType};
30use rakata_core::StrRef;
31
32/// One field whose value changed between the original and the rewrite.
33#[derive(Debug, Clone, PartialEq)]
34pub struct ValueDifference {
35 /// Label the difference was found under. Nested fields report the label
36 /// alone rather than a path, since GFF labels are the unit a reader looks
37 /// a field up by.
38 pub label: String,
39 /// Value the original carried.
40 pub before: GffValue,
41 /// Value the rewrite carried.
42 pub after: GffValue,
43}
44
45/// Reports every field the rewrite changed the value of.
46///
47/// Walks the rewritten tree and compares each field against the original's
48/// field of the same label, recursing into nested structs. A label the
49/// original does not carry is skipped here; that case is what [`LabelCensus`]
50/// exists for.
51///
52/// Lists are skipped entirely. Element order in a rewrite is the view's, not
53/// the file's, so a positional comparison reports differences that are not
54/// defects. A caller that models a list's contents compares them directly.
55pub fn value_differences(original: &GffStruct, rewritten: &GffStruct) -> Vec<ValueDifference> {
56 let mut out = Vec::new();
57 collect_differences(original, rewritten, &mut out);
58 out
59}
60
61fn collect_differences(
62 original: &GffStruct,
63 rewritten: &GffStruct,
64 out: &mut Vec<ValueDifference>,
65) {
66 for field in &rewritten.fields {
67 let Some(before) = original.field(field.label.as_str()) else {
68 continue;
69 };
70 match (before, &field.value) {
71 (GffValue::List(_), _) | (_, GffValue::List(_)) => {}
72 (GffValue::Struct(before_inner), GffValue::Struct(after_inner)) => {
73 collect_differences(before_inner, after_inner, out);
74 }
75 _ if *before != field.value => out.push(ValueDifference {
76 label: field.label.as_str().to_string(),
77 before: before.clone(),
78 after: field.value.clone(),
79 }),
80 _ => {}
81 }
82 }
83}
84
85/// Joins a parent path and a label into the dotted form the reports use.
86/// Top-level fields are bare labels; list elements carry `[]` on the list.
87fn child_path(parent: &str, label: &str) -> String {
88 if parent.is_empty() {
89 label.to_string()
90 } else {
91 format!("{parent}.{label}")
92 }
93}
94
95/// Collects every field path in a struct tree, nested structs and lists
96/// included.
97///
98/// Paths rather than bare labels, because a label alone cannot distinguish a
99/// field the corpus holds at one level from the same label a view reads at
100/// another. That difference is a defect neither a value comparison nor a
101/// symmetry check can see, since a view reading the wrong parent finds
102/// nothing there and writes nothing back, which is self-consistent.
103fn collect_paths(structure: &GffStruct, parent: &str, out: &mut BTreeSet<String>) {
104 for field in &structure.fields {
105 let path = child_path(parent, field.label.as_str());
106 out.insert(path.clone());
107 match &field.value {
108 GffValue::Struct(inner) => collect_paths(inner, &path, out),
109 GffValue::List(elements) => {
110 let element_path = format!("{path}[]");
111 for element in elements {
112 collect_paths(element, &element_path, out);
113 }
114 }
115 _ => {}
116 }
117 }
118}
119
120/// Collects every label in a struct tree alongside the types it was seen
121/// carrying. A label may hold different types in different files, so this
122/// accumulates a set rather than overwriting.
123fn collect_typed_paths(
124 structure: &GffStruct,
125 parent: &str,
126 out: &mut BTreeMap<String, (BTreeSet<GffType>, bool)>,
127) {
128 for field in &structure.fields {
129 let path = child_path(parent, field.label.as_str());
130 let entry = out.entry(path.clone()).or_default();
131 entry.0.insert(gff_value_type(&field.value));
132 entry.1 |= is_informative(&field.value);
133 match &field.value {
134 GffValue::Struct(inner) => collect_typed_paths(inner, &path, out),
135 GffValue::List(elements) => {
136 let element_path = format!("{path}[]");
137 for element in elements {
138 collect_typed_paths(element, &element_path, out);
139 }
140 }
141 _ => {}
142 }
143 }
144}
145
146/// The leaf label of a dotted path.
147fn leaf_of(path: &str) -> &str {
148 path.rsplit('.').next().unwrap_or(path)
149}
150
151/// Folds one file's labels into the running observation, counting each label
152/// once per file rather than once per occurrence.
153fn merge_observation(
154 into: &mut BTreeMap<String, LabelObservation>,
155 from: BTreeMap<String, (BTreeSet<GffType>, bool)>,
156) {
157 for (label, (types, informative)) in from {
158 let entry = into.entry(label).or_default();
159 entry.types.extend(types);
160 entry.files += 1;
161 if informative {
162 entry.files_with_value += 1;
163 }
164 }
165}
166
167/// Which labels a view writes that no file was seen to contain, accumulated
168/// over a corpus.
169///
170/// [`value_differences`] cannot see a label the original lacks: it walks the
171/// rewrite and skips anything the original has no field for. That means a
172/// label renamed on both the read and the write side passes it unremarked,
173/// which is the self-consistent case a faithfulness check exists to catch.
174/// Comparing what gets written against everything the corpus was ever seen to
175/// contain is what closes that.
176///
177/// This has to accumulate across files rather than judge one at a time,
178/// because any single file may legitimately omit a field another carries.
179#[derive(Debug, Clone, Default)]
180pub struct LabelCensus {
181 written: BTreeSet<String>,
182 seen: BTreeMap<String, LabelObservation>,
183 files: usize,
184}
185
186/// One leaf label the view models at some of its corpus paths and not others.
187///
188/// Produced by [`LabelCensus::partially_modelled_leaves`].
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct LeafPathSplit<'a> {
191 /// The label shared by every path below.
192 pub leaf: &'a str,
193 /// Paths the view reads and writes.
194 pub modelled: Vec<&'a str>,
195 /// Paths the corpus carries that the view does not, with what was seen at
196 /// each, so the reader can weigh them without a second pass.
197 pub unmodelled: Vec<(&'a str, &'a LabelObservation)>,
198}
199
200/// What a corpus was seen to carry under one label.
201#[derive(Debug, Clone, Default, PartialEq, Eq)]
202pub struct LabelObservation {
203 /// Types the label was seen holding. A label may hold different types in
204 /// different files, so this is a set rather than one value.
205 pub types: BTreeSet<GffType>,
206 /// How many files carried the label.
207 ///
208 /// This is what turns a label list into a priority order. A label in
209 /// every file and a label in one are very different propositions, and
210 /// without the count they read identically.
211 pub files: usize,
212 /// How many files carried the label holding something other than its
213 /// zero value.
214 ///
215 /// Prevalence alone misranks: a label present in every file and empty in
216 /// all of them carries no information, while a label in a handful of
217 /// files holding real values does. Ranking needs both.
218 ///
219 /// # This orders work; it does not decide what to model
220 ///
221 /// Neither this count nor the file count predicts whether a field is
222 /// worth modelling, and both have now been wrong in both directions:
223 ///
224 /// - A `.utc` `TemplateList` is in nearly every creature and empty in all
225 /// of them. High prevalence, no value, and no runtime consumer.
226 /// - A swoop-track `.are`'s rate-of-fire fields sit at their defaults in
227 /// every file that has them. No value, and read by real code.
228 /// - `module.ifo`'s `Mod_VO_ID` carries a string in most modules. High
229 /// value, and the label does not exist in the executable at all.
230 ///
231 /// Only what the loader does with a field settles it. These counts are
232 /// for deciding what to look at first.
233 pub files_with_value: usize,
234}
235
236/// Whether a value carries anything beyond the type's zero.
237///
238/// Deliberately shallow. This is a triage signal for deciding which labels
239/// are worth investigating, not a claim about what the engine treats as
240/// meaningful: a field's real default may be something other than zero, and
241/// only an audit of the loader can say.
242fn is_informative(value: &GffValue) -> bool {
243 match value {
244 GffValue::UInt8(v) => *v != 0,
245 GffValue::Int8(v) => *v != 0,
246 GffValue::UInt16(v) => *v != 0,
247 GffValue::Int16(v) => *v != 0,
248 GffValue::UInt32(v) => *v != 0,
249 GffValue::Int32(v) => *v != 0,
250 GffValue::UInt64(v) => *v != 0,
251 GffValue::Int64(v) => *v != 0,
252 GffValue::Single(v) => *v != 0.0,
253 GffValue::Double(v) => *v != 0.0,
254 GffValue::String(v) => !v.is_empty(),
255 GffValue::ResRef(v) => !v.as_bytes().is_empty(),
256 GffValue::LocalizedString(v) => {
257 v.string_ref != StrRef::invalid() || !v.substrings.is_empty()
258 }
259 GffValue::Binary(v) => v.iter().any(|byte| *byte != 0),
260 GffValue::Struct(v) => !v.fields.is_empty(),
261 GffValue::List(v) => !v.is_empty(),
262 GffValue::Vector3(v) => v.iter().any(|f| *f != 0.0),
263 GffValue::Vector4(v) => v.iter().any(|f| *f != 0.0),
264 GffValue::StrRef(v) => *v != StrRef::invalid(),
265 }
266}
267
268/// A family of labels that differ only by letter case.
269///
270/// GFF label lookup is case-sensitive, so a view reading one spelling sees
271/// nothing in a file that uses another. Vanilla does ship such families, and
272/// the crate already carries a hand-written tolerant read for one of them, so
273/// the question is how many more there are rather than whether any exist.
274#[derive(Debug, Clone, PartialEq, Eq)]
275pub struct CaseVariantGroup {
276 /// Each spelling found in the corpus, with what was seen under it.
277 pub spellings: Vec<(String, LabelObservation)>,
278 /// The spellings the view writes. A family where this holds fewer than
279 /// every spelling is one the view reads in only some of its forms.
280 pub written: BTreeSet<String>,
281}
282
283/// Whether a corpus can decide the invented-label question at all.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub enum InventedLabels {
286 /// The corpus held no files, so everything a view writes is trivially
287 /// "absent from the corpus" and the question is meaningless.
288 ///
289 /// This is derived from the corpus rather than configured, so it stops
290 /// being reported the moment a corpus with files is handed in, and it
291 /// cannot go stale the way a caller-set flag would.
292 Undecidable,
293 /// Labels written that no file in the corpus contained.
294 ///
295 /// Not a defect list. A view may write a field the engine omits when it
296 /// holds its default, and it may write a field belonging to a form the
297 /// corpus does not include. Judging those is the caller's.
298 Labels(Vec<String>),
299}
300
301impl LabelCensus {
302 /// Creates an empty census.
303 pub fn new() -> Self {
304 Self::default()
305 }
306
307 /// Records one file's original tree and the tree a view rewrote from it.
308 pub fn observe(&mut self, original: &GffStruct, rewritten: &GffStruct) {
309 let mut in_this_file = BTreeMap::new();
310 collect_typed_paths(original, "", &mut in_this_file);
311 merge_observation(&mut self.seen, in_this_file);
312 collect_paths(rewritten, "", &mut self.written);
313 self.files += 1;
314 }
315
316 /// Number of files observed.
317 pub fn files(&self) -> usize {
318 self.files
319 }
320
321 /// Labels written that the corpus never contained, or
322 /// [`InventedLabels::Undecidable`] when there was nothing to compare
323 /// against.
324 pub fn invented(&self) -> InventedLabels {
325 if self.files == 0 {
326 return InventedLabels::Undecidable;
327 }
328 InventedLabels::Labels(
329 self.written
330 .iter()
331 .filter(|label| !self.seen.contains_key(*label))
332 .cloned()
333 .collect(),
334 )
335 }
336
337 /// Families of labels in the corpus differing only by letter case.
338 ///
339 /// Returns only families with more than one spelling, since a lone
340 /// spelling has nothing to disagree with. What makes a family worth
341 /// attention is when [`CaseVariantGroup::written`] does not cover every
342 /// spelling: the view then reads some files' copy of that field and not
343 /// others'.
344 pub fn case_variants(&self) -> Vec<CaseVariantGroup> {
345 let mut families: BTreeMap<String, Vec<&String>> = BTreeMap::new();
346 for label in self.seen.keys() {
347 families
348 .entry(label.to_lowercase())
349 .or_default()
350 .push(label);
351 }
352
353 families
354 .into_values()
355 .filter(|spellings| spellings.len() > 1)
356 .map(|spellings| CaseVariantGroup {
357 written: spellings
358 .iter()
359 .filter(|label| self.written.contains(**label))
360 .map(|label| (*label).clone())
361 .collect(),
362 spellings: spellings
363 .into_iter()
364 .map(|label| {
365 (
366 label.clone(),
367 self.seen.get(label).cloned().unwrap_or_default(),
368 )
369 })
370 .collect(),
371 })
372 .collect()
373 }
374
375 /// Field paths whose leaf label appears in the corpus at one level and in
376 /// the view's output at another.
377 ///
378 /// This is the wiring-mismatch check, and it exists because nothing else
379 /// catches the shape. A view that reads a field off the wrong parent finds
380 /// nothing there, so it writes nothing back: the round-trip is stable, the
381 /// values that do coincide all match, and every field involved is
382 /// modelled. It looks like a projection dropping a field it never wanted.
383 ///
384 /// Reported as `(leaf, path_in_corpus, path_written)`. A leaf appearing at
385 /// several levels legitimately, which nested formats do, yields one entry
386 /// per crossing pair and is for a reader to judge rather than a verdict.
387 pub fn misplaced(&self) -> Vec<(&str, &str, &str)> {
388 let mut out = Vec::new();
389 for seen_path in self.seen.keys() {
390 if self.written.contains(seen_path) {
391 continue;
392 }
393 let leaf = leaf_of(seen_path);
394 for written_path in &self.written {
395 if written_path != seen_path
396 && leaf_of(written_path) == leaf
397 && !self.seen.contains_key(written_path)
398 {
399 out.push((leaf, seen_path.as_str(), written_path.as_str()));
400 }
401 }
402 }
403 out
404 }
405
406 /// Leaf labels the corpus carries at several paths, where the view models
407 /// some of those paths and not others.
408 ///
409 /// The blind spot this closes is a reviewer's, not a tool's. Scanning a
410 /// list of unmodelled labels, a leaf that also appears somewhere modelled
411 /// reads as covered: the eye matches the name, finds it in the view, and
412 /// moves on. `OnHeartbeat` survived exactly that way, modelled on the ARE
413 /// root and dropped inside every nested minigame `Scripts` struct, and the
414 /// inventory that was supposed to surface it counted the root's and called
415 /// the label done.
416 ///
417 /// Distinct from [`Self::misplaced`], which asks whether a path the view
418 /// writes exists in any file. Here every path involved is real; the
419 /// question is only whether the view covers all of them. A leaf covered at
420 /// every path it appears at is not reported, and neither is one covered
421 /// nowhere, since [`Self::unmodelled`] already lists that.
422 ///
423 /// A mixed result is not automatically a defect. Formats reuse labels at
424 /// unrelated levels, and a projection may want one and not the other. It
425 /// is a prompt to decide, with the paths side by side.
426 pub fn partially_modelled_leaves(&self) -> Vec<LeafPathSplit<'_>> {
427 let mut by_leaf: BTreeMap<&str, Vec<&String>> = BTreeMap::new();
428 for path in self.seen.keys() {
429 by_leaf.entry(leaf_of(path)).or_default().push(path);
430 }
431
432 by_leaf
433 .into_iter()
434 .filter_map(|(leaf, paths)| {
435 let (modelled, unmodelled): (Vec<_>, Vec<_>) = paths
436 .into_iter()
437 .partition(|path| self.written.contains(*path));
438 if modelled.is_empty() || unmodelled.is_empty() {
439 return None;
440 }
441 Some(LeafPathSplit {
442 leaf,
443 modelled: modelled.into_iter().map(String::as_str).collect(),
444 unmodelled: unmodelled
445 .into_iter()
446 .map(|path| (path.as_str(), &self.seen[path]))
447 .collect(),
448 })
449 })
450 .collect()
451 }
452
453 /// Field paths the corpus contained that the view never writes, each with
454 /// what was observed under it.
455 ///
456 /// Dropping a field is what a projection does, so this is a starting
457 /// point for deciding what is worth modelling rather than a defect list.
458 /// The types come along because they are what makes the follow-on
459 /// modelling cheap: a label alone still needs looking up.
460 pub fn unmodelled(&self) -> Vec<(&str, &LabelObservation)> {
461 self.seen
462 .iter()
463 .filter(|(label, _)| !self.written.contains(*label))
464 .map(|(label, observation)| (label.as_str(), observation))
465 .collect()
466 }
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472
473 fn scalar(label: &str, value: u8) -> GffStruct {
474 let mut s = GffStruct::new(0);
475 s.push_field(label, GffValue::UInt8(value));
476 s
477 }
478
479 #[test]
480 fn a_changed_value_is_reported_under_its_label() {
481 let differences = value_differences(&scalar("Tag", 1), &scalar("Tag", 2));
482
483 assert_eq!(differences.len(), 1);
484 assert_eq!(differences[0].label, "Tag");
485 assert_eq!(differences[0].before, GffValue::UInt8(1));
486 assert_eq!(differences[0].after, GffValue::UInt8(2));
487 }
488
489 #[test]
490 fn an_unchanged_value_is_not_reported() {
491 assert!(value_differences(&scalar("Tag", 1), &scalar("Tag", 1)).is_empty());
492 }
493
494 #[test]
495 fn a_label_the_original_lacks_is_left_to_the_census() {
496 // This is the blind spot that makes LabelCensus necessary: comparing
497 // values alone cannot see a label that was invented.
498 assert!(value_differences(&scalar("Tag", 1), &scalar("Taag", 1)).is_empty());
499 }
500
501 #[test]
502 fn nested_struct_fields_are_compared() {
503 let nest = |value| {
504 let mut root = GffStruct::new(0);
505 root.push_field("Inner", GffValue::Struct(Box::new(scalar("Deep", value))));
506 root
507 };
508
509 let differences = value_differences(&nest(1), &nest(2));
510
511 assert_eq!(differences.len(), 1);
512 assert_eq!(differences[0].label, "Deep");
513 }
514
515 #[test]
516 fn lists_are_skipped_because_their_order_is_the_writers() {
517 let list = |value| {
518 let mut root = GffStruct::new(0);
519 root.push_field("Items", GffValue::List(vec![scalar("Id", value)]));
520 root
521 };
522
523 assert!(value_differences(&list(1), &list(2)).is_empty());
524 }
525
526 #[test]
527 fn paths_reach_into_lists_and_structs() {
528 let mut root = GffStruct::new(0);
529 root.push_field("Items", GffValue::List(vec![scalar("Id", 1)]));
530 root.push_field("Inner", GffValue::Struct(Box::new(scalar("Deep", 1))));
531
532 let mut census = LabelCensus::new();
533 census.observe(&root, &GffStruct::new(0));
534
535 let found: Vec<&str> = census.unmodelled().into_iter().map(|(p, _)| p).collect();
536 // Paths, not bare labels: the level a field sits at is the whole
537 // point, and `Id` alone would not say which list it came from.
538 for path in ["Items", "Items[].Id", "Inner", "Inner.Deep"] {
539 assert!(
540 found.contains(&path),
541 "{path} should be collected, got {found:?}"
542 );
543 }
544 }
545
546 #[test]
547 fn an_invented_label_is_reported_once_a_corpus_exists() {
548 let mut census = LabelCensus::new();
549 census.observe(&scalar("Tag", 1), &scalar("Taag", 1));
550
551 assert_eq!(census.files(), 1);
552 assert_eq!(
553 census.invented(),
554 InventedLabels::Labels(vec!["Taag".to_string()])
555 );
556 let unmodelled = census.unmodelled();
557 assert_eq!(unmodelled.len(), 1);
558 assert_eq!(unmodelled[0].0, "Tag");
559 assert!(unmodelled[0].1.types.contains(&GffType::UInt8));
560 assert_eq!(unmodelled[0].1.files, 1);
561 assert_eq!(unmodelled[0].1.files_with_value, 1);
562 }
563
564 #[test]
565 fn an_empty_corpus_cannot_decide_the_question() {
566 // Everything a view writes is trivially absent from no files at all,
567 // so the answer is "cannot say" rather than "all of them".
568 assert_eq!(LabelCensus::new().invented(), InventedLabels::Undecidable);
569 }
570
571 #[test]
572 fn a_label_present_in_any_file_is_not_invented() {
573 // One file omitting a field another carries must not condemn it,
574 // which is why the census accumulates rather than judging per file.
575 let mut census = LabelCensus::new();
576 census.observe(&GffStruct::new(0), &scalar("Tag", 1));
577 census.observe(&scalar("Tag", 1), &scalar("Tag", 1));
578
579 assert_eq!(census.invented(), InventedLabels::Labels(Vec::new()));
580 }
581}
582
583#[cfg(test)]
584mod prevalence_tests {
585 use super::*;
586
587 #[test]
588 fn an_unmodelled_label_carries_how_many_files_had_it() {
589 // A label in one file of a thousand and a label in all thousand read
590 // identically without the count, and they are not the same finding.
591 let mut census = LabelCensus::new();
592 let mut common = GffStruct::new(0);
593 common.push_field("Everywhere", GffValue::UInt8(1));
594 let mut rare = GffStruct::new(0);
595 rare.push_field("Everywhere", GffValue::UInt8(1));
596 rare.push_field("Once", GffValue::UInt8(1));
597
598 census.observe(&common, &GffStruct::new(0));
599 census.observe(&common, &GffStruct::new(0));
600 census.observe(&rare, &GffStruct::new(0));
601
602 let counts: BTreeMap<&str, usize> = census
603 .unmodelled()
604 .into_iter()
605 .map(|(label, observation)| (label, observation.files))
606 .collect();
607
608 assert_eq!(counts.get("Everywhere"), Some(&3));
609 assert_eq!(counts.get("Once"), Some(&1));
610 }
611}
612
613#[cfg(test)]
614mod wiring_tests {
615 use super::*;
616
617 fn nested(parent: &str, child: &str) -> GffStruct {
618 let mut inner = GffStruct::new(0);
619 inner.push_field(child, GffValue::UInt8(1));
620 let mut root = GffStruct::new(0);
621 root.push_field(parent, GffValue::Struct(Box::new(inner)));
622 root
623 }
624
625 #[test]
626 fn a_field_read_off_the_wrong_parent_is_reported_as_misplaced() {
627 // The shape no other check sees. The corpus holds Thing at
628 // Game.Thing; the view looks under Game.Player and so writes
629 // Game.Player.Thing. Values never coincide, the round-trip is
630 // stable, and both labels are modelled.
631 let mut original = GffStruct::new(0);
632 let mut game = GffStruct::new(0);
633 game.push_field("Thing", GffValue::UInt8(7));
634 original.push_field("Game", GffValue::Struct(Box::new(game)));
635
636 let mut rewritten = GffStruct::new(0);
637 let mut game_out = GffStruct::new(0);
638 let mut player = GffStruct::new(0);
639 player.push_field("Thing", GffValue::UInt8(7));
640 game_out.push_field("Player", GffValue::Struct(Box::new(player)));
641 rewritten.push_field("Game", GffValue::Struct(Box::new(game_out)));
642
643 assert!(
644 value_differences(&original, &rewritten).is_empty(),
645 "a value comparison cannot see this, which is why the check exists"
646 );
647
648 let mut census = LabelCensus::new();
649 census.observe(&original, &rewritten);
650
651 assert_eq!(
652 census.misplaced(),
653 vec![("Thing", "Game.Thing", "Game.Player.Thing")]
654 );
655 }
656
657 #[test]
658 fn a_leaf_modelled_at_one_path_and_dropped_at_another_is_reported() {
659 // The `OnHeartbeat` shape. The corpus holds the label twice: once at
660 // the root, which the view models, and once nested, which it drops.
661 // Nothing else flags it. The nested path is in `unmodelled`, but a
662 // reviewer reading that list finds the label in the view, matches on
663 // the name and moves on, which is how the real one survived a scan
664 // built to catch it.
665 let mut original = GffStruct::new(0);
666 original.push_field("OnHeartbeat", GffValue::UInt8(1));
667 let mut scripts = GffStruct::new(0);
668 scripts.push_field("OnHeartbeat", GffValue::UInt8(2));
669 original.push_field("Scripts", GffValue::Struct(Box::new(scripts)));
670
671 // The view writes only the root one.
672 let mut rewritten = GffStruct::new(0);
673 rewritten.push_field("OnHeartbeat", GffValue::UInt8(1));
674
675 let mut census = LabelCensus::new();
676 census.observe(&original, &rewritten);
677
678 assert!(
679 census.misplaced().is_empty(),
680 "both paths are real, so this is not a wiring mismatch"
681 );
682
683 let split = census.partially_modelled_leaves();
684 assert_eq!(split.len(), 1);
685 assert_eq!(split[0].leaf, "OnHeartbeat");
686 assert_eq!(split[0].modelled, vec!["OnHeartbeat"]);
687 assert_eq!(
688 split[0]
689 .unmodelled
690 .iter()
691 .map(|(path, _)| *path)
692 .collect::<Vec<_>>(),
693 vec!["Scripts.OnHeartbeat"]
694 );
695 }
696
697 #[test]
698 fn a_leaf_covered_at_every_path_it_appears_at_is_not_reported() {
699 let mut structure = GffStruct::new(0);
700 structure.push_field("OnHeartbeat", GffValue::UInt8(1));
701 let mut scripts = GffStruct::new(0);
702 scripts.push_field("OnHeartbeat", GffValue::UInt8(2));
703 structure.push_field("Scripts", GffValue::Struct(Box::new(scripts)));
704
705 let mut census = LabelCensus::new();
706 census.observe(&structure, &structure);
707 assert!(census.partially_modelled_leaves().is_empty());
708 }
709
710 #[test]
711 fn a_leaf_covered_nowhere_is_left_to_the_unmodelled_list() {
712 // Reporting it here too would duplicate `unmodelled` and bury the
713 // mixed cases this query exists for.
714 let structure = nested("Game", "Thing");
715 let mut census = LabelCensus::new();
716 census.observe(&structure, &GffStruct::new(0));
717
718 assert!(census.partially_modelled_leaves().is_empty());
719 assert_eq!(census.unmodelled().len(), 2);
720 }
721
722 #[test]
723 fn a_field_at_the_same_path_is_not_misplaced() {
724 let structure = nested("Game", "Thing");
725 let mut census = LabelCensus::new();
726 census.observe(&structure, &structure);
727
728 assert!(census.misplaced().is_empty());
729 assert!(matches!(
730 census.invented(),
731 InventedLabels::Labels(ref labels) if labels.is_empty()
732 ));
733 }
734
735 #[test]
736 fn paths_rather_than_labels_are_reported() {
737 let structure = nested("Game", "Thing");
738 let mut census = LabelCensus::new();
739 census.observe(&structure, &GffStruct::new(0));
740
741 let paths: Vec<&str> = census.unmodelled().into_iter().map(|(p, _)| p).collect();
742 assert!(paths.contains(&"Game.Thing"), "got {paths:?}");
743 }
744}