Skip to main content

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}