Skip to main content

ripbi_core/
report.rs

1//! Format-agnostic report AST: the normalized shape every report source format
2//! (PBIR `definition/` folders, PBIR-Legacy `report.json` Layout) is parsed into.
3//!
4//! The types here are plain data with no parsing or I/O behaviour. Their only logic
5//! is the enumeration at the bottom of this module ([`ReportModel::bindings`] and
6//! [`ReportModel::dax_expressions`]), which is the single place that knows where
7//! report-side reachability roots and report-owned DAX live. The graph layer
8//! consumes those two functions instead of walking the AST itself, so a new
9//! binding-bearing field cannot be silently omitted from reachability analysis.
10//!
11//! Bindings hold references *as written* — structured entity trees in PBIR,
12//! written names in legacy Layout — never resolved model objects. Resolution
13//! against the semantic model is the graph layer's job, via
14//! [`ModelIndex`](crate::ModelIndex); keeping the written form is what lets both
15//! source formats populate the same structures.
16
17use std::fmt;
18
19use crate::identity::{FieldRef, NameKey, Quoted};
20use crate::model::{DaxExpressionKind, DaxExpressionRef, ExpressionOwner};
21
22/// Normalized report definition, regardless of source format (PBIR, PBIR-Legacy
23/// Layout). One instance per report: an analysis runs one
24/// [`TabularDatabase`](crate::TabularDatabase) against the reports that share it,
25/// and each report's `name` completes its bindings' provenance.
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub struct ReportModel {
28    /// Report identity for provenance: the `.platform` display name, or the report
29    /// folder's name when the source records none.
30    pub name: Option<String>,
31    /// The semantic model this report connects to (PBIR `datasetReference`).
32    pub dataset: DatasetReference,
33    /// Report-level filters (PBIR `report.json` filterConfig).
34    pub filters: Vec<Filter>,
35    /// Pages in source order.
36    pub pages: Vec<Page>,
37    /// Pages of the phone layout (PBIR `definition.mobile/`), in source order.
38    ///
39    /// The phone layout mirrors the desktop tree and binds the same model: a
40    /// field referenced only there still renders — on phones — so its bindings
41    /// are reachability roots like any other (issue #49). Report-level state
42    /// (`report.json`, report measures, bookmarks) is desktop-only and is not
43    /// read from the mobile tree.
44    pub mobile_pages: Vec<Page>,
45    /// Bookmarks in source order.
46    pub bookmarks: Vec<Bookmark>,
47    /// Report-level measures (PBIR `reportExtensions.json`): DAX that lives in the
48    /// report, not the model.
49    pub measures: Vec<ReportMeasure>,
50}
51
52/// How a report reaches its semantic model (PBIR `datasetReference`).
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum DatasetReference {
55    /// Relative path to a sibling semantic-model folder (`byPath`). Forward
56    /// slashes are the Power BI-written form; hand-edited files may carry
57    /// backslashes, so the CLI accepts both when resolving.
58    ByPath {
59        /// Path as written, e.g. `../Sales.SemanticModel`.
60        path: String,
61    },
62    /// Live connection to a remote semantic model (`byConnection`).
63    ByConnection {
64        /// Connection string as written.
65        connection_string: String,
66    },
67    /// Absent, unrecognized, or not yet parsed.
68    Unresolved,
69}
70
71impl Default for DatasetReference {
72    /// An unparsed reference is `Unresolved`, never a path or a connection,
73    /// mirroring [`PartitionSource::Other`](crate::PartitionSource): schema drift
74    /// must never fabricate a report↔model pairing.
75    fn default() -> Self {
76        Self::Unresolved
77    }
78}
79
80/// One page of a report.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Page {
83    /// Page object name, e.g. `ReportSectionacd41c847407a998c130`. PBIR keys its
84    /// folders and files by it, and bookmarks reference pages by it.
85    pub name: NameKey,
86    /// Author-facing name, e.g. `Overview`.
87    pub display_name: Option<String>,
88    /// Hidden pages still bind fields — their visuals render on demand — so this
89    /// flag is display-only, never liveness.
90    pub is_hidden: bool,
91    /// Filters applied to the whole page.
92    pub filters: Vec<Filter>,
93    /// The page's drillthrough/tooltip role, if it has one.
94    pub binding: Option<PageBinding>,
95    /// Visuals on the page, in source order.
96    pub visuals: Vec<Visual>,
97}
98
99/// The role a page plays in drillthrough and tooltips (PBIR `pageBinding.type`).
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
101pub enum PageBindingKind {
102    /// An ordinary page. The default.
103    #[default]
104    Default,
105    /// Reached via drillthrough; its parameters bind fields.
106    Drillthrough,
107    /// Rendered as a tooltip for other visuals.
108    Tooltip,
109}
110
111/// A page's drillthrough/tooltip configuration (PBIR `pageBinding`).
112#[derive(Debug, Clone, PartialEq, Eq, Default)]
113pub struct PageBinding {
114    /// What kind of page binding this is.
115    pub kind: PageBindingKind,
116    /// Fields a drillthrough caller must supply, in source order.
117    pub parameters: Vec<DrillthroughParameter>,
118}
119
120/// One drillthrough field (PBIR `pageBinding.parameters[]`).
121#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct DrillthroughParameter {
123    /// Parameter name as written, e.g. `Param_Filter5`.
124    pub name: Option<NameKey>,
125    /// The bound field (PBIR `fieldExpr`).
126    pub target: FieldTarget,
127}
128
129/// One visual on a page.
130///
131/// Slicers are not a separate kind: a slicer is a visual with `visual_type`
132/// `"slicer"`, and its field wells carry the binding. Saved slicer *selections*
133/// are literal values, not references, and are not modeled.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct Visual {
136    /// Visual object name — the PBIR folder name and bookmarks' key.
137    pub name: NameKey,
138    /// Visual type as written, e.g. `"donutChart"`, `"slicer"`, `"card"`.
139    pub visual_type: String,
140    /// Field wells (PBIR `query.queryState`): role → projections.
141    pub wells: Vec<FieldWell>,
142    /// Filters applied to this visual only.
143    pub filters: Vec<Filter>,
144    /// Sort-by fields (PBIR `sortDefinition`), in sort order.
145    pub sorts: Vec<FieldTarget>,
146    /// Fields driving conditional-formatting rules.
147    pub conditional_formatting: Vec<FieldTarget>,
148    /// Fields referenced by the visual's accessibility alt text
149    /// (`visualContainerObjects.general.altText`): a screen reader reads it,
150    /// so dropping the field breaks the visual.
151    pub alt_text: Vec<FieldTarget>,
152    /// Page used as this visual's tooltip, by page object name. A report-internal
153    /// reference: it keeps the page reachable, not a model object.
154    pub tooltip_page: Option<NameKey>,
155}
156
157/// One field well of a visual: everything projected into a single role.
158#[derive(Debug, Clone, PartialEq, Eq, Default)]
159pub struct FieldWell {
160    /// Role name as written, e.g. `"Category"`, `"Y"`, `"Tooltips"`, `"Values"`.
161    pub role: String,
162    /// Projections in the well, in source order.
163    pub projections: Vec<Projection>,
164}
165
166/// One field projected into a well.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub struct Projection {
169    /// The projected field.
170    pub target: FieldTarget,
171    /// Written display form as it appears in the file (PBIR `queryRef`), e.g.
172    /// `Sales.Customers % of Total`. Diagnostics only — `target` is authoritative.
173    pub query_ref: Option<String>,
174    /// Whether the projection is active. Inactive projections still bind: they are
175    /// one toggle away from live, and dropping them would under-count roots.
176    pub active: bool,
177}
178
179/// A filter at report, page, visual, or bookmark level.
180///
181/// The filtered *values* (the condition tree's literals) are data, not references,
182/// and are not modeled — only the fields a filter touches can keep objects alive.
183#[derive(Debug, Clone, PartialEq, Eq, Default)]
184pub struct Filter {
185    /// Filter name within its scope, e.g. `Filter5`. Drillthrough parameters bind
186    /// to filters by this name (PBIR `boundFilter`).
187    pub name: Option<NameKey>,
188    /// The filtered field itself (PBIR `filterConfig.filters[].field`).
189    pub target: Option<FieldTarget>,
190    /// Further fields referenced by the filter's condition tree, with query aliases
191    /// (`SourceRef.Source`) already resolved to entities by the parser.
192    pub references: Vec<FieldTarget>,
193}
194
195/// A saved exploration state, restorable by a reader.
196///
197/// Bookmark bindings are enumerated like any other: applying a bookmark re-applies
198/// its saved filters and projections, so a field kept alive only by a bookmark is
199/// still alive.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct Bookmark {
202    /// Bookmark object name — the PBIR file name, and the provenance key.
203    pub name: NameKey,
204    /// Author-facing name.
205    pub display_name: Option<String>,
206    /// Filters saved at report level (`explorationState.filters`).
207    pub filters: Vec<Filter>,
208    /// Captured state, per page it spans (usually one).
209    pub sections: Vec<BookmarkSection>,
210}
211
212/// The slice of a bookmark's state belonging to one page.
213#[derive(Debug, Clone, PartialEq, Eq)]
214pub struct BookmarkSection {
215    /// The captured page, by object name.
216    pub page: NameKey,
217    /// Saved filters (`byName` and `byExpr`).
218    pub filters: Vec<Filter>,
219    /// Saved per-visual state, in source order.
220    pub visuals: Vec<BookmarkVisual>,
221}
222
223/// A bookmark's saved state for one visual.
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct BookmarkVisual {
226    /// The visual, by object name.
227    pub visual: NameKey,
228    /// Fields active when the bookmark was captured, as wells by role.
229    pub wells: Vec<FieldWell>,
230    /// Filters saved for this visual (`visualContainers.<id>.filters`).
231    pub filters: Vec<Filter>,
232}
233
234/// A DAX measure defined in the report (PBIR `reportExtensions.json`), not the
235/// model.
236///
237/// A report measure bridges usage in both directions: its body references model
238/// objects (so it is an expression source the graph must consume), and visuals
239/// reference it by name (so it is a reachability root of its own). Name lookup
240/// should try report measures before model measures — within its report, a report
241/// measure shadows a model measure of the same name.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct ReportMeasure {
244    /// Measure name, unique within its report.
245    pub name: NameKey,
246    /// The measure's DAX expression.
247    pub expression: String,
248    /// Dynamic format string (DAX).
249    pub format_string: Option<String>,
250}
251
252/// A model-object reference as written in a report binding, before resolution.
253///
254/// PBIR bindings are structured JSON entity trees (`Column`, `Measure`,
255/// `HierarchyLevel`, `Aggregation`); legacy Layout binds written names. Both
256/// normalize here, so downstream code never branches on source format.
257///
258/// The column/measure discrimination is kept rather than collapsed into
259/// [`FieldRef`], because the binding states it outright and resolution would
260/// otherwise be guessing.
261#[derive(Debug, Clone, PartialEq, Eq, Hash)]
262pub enum FieldTarget {
263    /// A column, by its table (`SourceRef.Entity`) and name (`Property`).
264    Column {
265        /// Owning table as written.
266        table: NameKey,
267        /// Column name as written.
268        column: NameKey,
269    },
270    /// A measure, by name. Measures are model-global; the entity PBIR writes
271    /// alongside them is the home table as displayed, carried for provenance only.
272    Measure {
273        /// Home table as written, if any.
274        home_table: Option<NameKey>,
275        /// Measure name as written.
276        measure: NameKey,
277    },
278    /// A hierarchy level: the level a visual drills to, which keeps the whole
279    /// hierarchy (and its level columns) alive.
280    HierarchyLevel {
281        /// Owning table as written. For a hierarchy reached over a column
282        /// variation this is the *varied* (base) table; the hierarchy itself
283        /// lives on the variation's target table.
284        table: NameKey,
285        /// Hierarchy name as written.
286        hierarchy: NameKey,
287        /// Level name as written.
288        level: NameKey,
289        /// The varied column (PBIR `PropertyVariationSource.Property`), when
290        /// the hierarchy is reached over a column variation — the key that
291        /// joins this binding to the model-side
292        /// [`crate::model::Column::variations`] declaration.
293        via_column: Option<NameKey>,
294        /// The variation's name (PBIR `PropertyVariationSource.Name`), which
295        /// picks between a column's variations when there are several.
296        via_variation: Option<NameKey>,
297    },
298    /// An aggregation over an inner reference, e.g. Sum of `'Sales'[Units]`.
299    /// The inner target is what stays alive; the function is diagnostics.
300    Aggregation {
301        /// Aggregation function as written, e.g. `"Sum"`.
302        function: Option<String>,
303        /// The aggregated field.
304        inner: Box<FieldTarget>,
305    },
306    /// A written name the parser could not structure — legacy Layout strings,
307    /// unresolved query aliases. Kept anyway: a binding we cannot read is still a
308    /// binding, and dropping it would under-count roots.
309    Written(FieldRef),
310}
311
312impl fmt::Display for FieldTarget {
313    /// Human-readable form for diagnostics, quoting names the way [`FieldRef`]
314    /// does. The hierarchy-level and aggregation forms are illustrative, not valid
315    /// DAX — level references have no DAX syntax.
316    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
317        match self {
318            FieldTarget::Column { table, column } => {
319                write!(f, "{}[{}]", Quoted(table.as_str()), column.as_str())
320            }
321            FieldTarget::Measure {
322                home_table,
323                measure,
324            } => match home_table {
325                Some(table) => write!(f, "{}[{}]", Quoted(table.as_str()), measure.as_str()),
326                None => write!(f, "[{}]", measure.as_str()),
327            },
328            FieldTarget::HierarchyLevel {
329                table,
330                hierarchy,
331                level,
332                ..
333            } => write!(
334                f,
335                "hierarchy {}[{}] level {}",
336                Quoted(table.as_str()),
337                hierarchy.as_str(),
338                Quoted(level.as_str())
339            ),
340            FieldTarget::Aggregation { function, inner } => match function {
341                Some(function) => write!(f, "{function}({inner})"),
342                None => write!(f, "Aggregation({inner})"),
343            },
344            FieldTarget::Written(reference) => write!(f, "{reference}"),
345        }
346    }
347}
348
349/// What kind of report-side usage a binding represents.
350///
351/// The kind powers "used by" explanations (`'Sales'[Amount]` ← filter on
352/// *Overview* ← page 2) and groups bindings for reporting.
353#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
354pub enum BindingKind<'a> {
355    /// A field projected into a visual's field well, with the well's role.
356    FieldWell {
357        /// Role name as written, e.g. `"Category"`, `"Y"`, `"Tooltips"`.
358        role: &'a str,
359    },
360    /// A filter at report, page, visual, or bookmark level.
361    Filter,
362    /// A visual's sort-by field.
363    Sort,
364    /// A drillthrough parameter's bound field.
365    Drillthrough,
366    /// A field driving a conditional-formatting rule.
367    ConditionalFormatting,
368    /// A visual's accessibility alt text (`general.altText`).
369    AltText,
370}
371
372/// Borrowed view of one report binding, with its provenance.
373///
374/// The page/visual/bookmark fields answer *where* the binding lives — the `None`s
375/// narrow it: a report-level filter has neither page nor visual, a page filter has
376/// no visual. Which *report* a binding came from is answered by the
377/// [`ReportModel`] it was enumerated from, so the report name is not repeated here.
378#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
379pub struct BindingRef<'a> {
380    /// Page the binding lives on; `None` for report-level bindings.
381    pub page: Option<&'a NameKey>,
382    /// Visual the binding lives in; `None` outside a visual.
383    pub visual: Option<&'a NameKey>,
384    /// Bookmark whose saved state carries the binding; `None` for live bindings.
385    pub bookmark: Option<&'a NameKey>,
386    /// Whether the binding lives in the phone layout (`definition.mobile/`)
387    /// rather than the desktop tree. Pure provenance — both layouts bind
388    /// identically — but it tells a user auditing a survivor which surface to
389    /// look at (issue #49).
390    pub mobile: bool,
391    /// What kind of binding this is.
392    pub kind: BindingKind<'a>,
393    /// The model object referenced, as written.
394    pub target: &'a FieldTarget,
395}
396
397impl ReportModel {
398    /// Every model-object reference the report makes, with its provenance — the
399    /// reachability roots the graph's BFS starts from.
400    ///
401    /// Order follows report order (report filters, then per page: drillthrough
402    /// parameters, page filters, and each visual's wells, filters, sorts, and
403    /// conditional formatting; then per bookmark: report-level filters, and per
404    /// section: section filters and each visual's wells and filters), so the
405    /// result is deterministic for a given report and diffable across runs.
406    /// Mobile-layout pages ([`ReportModel::mobile_pages`]) enumerate after the
407    /// desktop pages, same shape, tagged [`BindingRef::mobile`].
408    ///
409    /// Everything borrows from the report, so this allocates only the returned
410    /// `Vec`.
411    ///
412    /// # Examples
413    ///
414    /// ```
415    /// use ripbi_core::report::{BindingKind, FieldTarget, Filter, ReportModel};
416    /// use ripbi_core::NameKey;
417    ///
418    /// let report = ReportModel {
419    ///     filters: vec![Filter {
420    ///         target: Some(FieldTarget::Column {
421    ///             table: NameKey::new("Product"),
422    ///             column: NameKey::new("Category"),
423    ///         }),
424    ///         ..Default::default()
425    ///     }],
426    ///     ..Default::default()
427    /// };
428    ///
429    /// let bindings = report.bindings();
430    /// assert_eq!(bindings.len(), 1);
431    /// assert_eq!(bindings[0].kind, BindingKind::Filter);
432    /// // A report-level filter belongs to no page and no visual.
433    /// assert_eq!(bindings[0].page, None);
434    /// assert_eq!(bindings[0].visual, None);
435    /// ```
436    #[must_use]
437    pub fn bindings(&self) -> Vec<BindingRef<'_>> {
438        let mut out = Vec::new();
439
440        for filter in &self.filters {
441            extend_with_filter(&mut out, None, None, None, false, filter);
442        }
443
444        for page in &self.pages {
445            extend_with_page(&mut out, page, false);
446        }
447
448        for page in &self.mobile_pages {
449            extend_with_page(&mut out, page, true);
450        }
451
452        for bookmark in &self.bookmarks {
453            let bookmark_id = Some(&bookmark.name);
454
455            for filter in &bookmark.filters {
456                extend_with_filter(&mut out, None, None, bookmark_id, false, filter);
457            }
458
459            for section in &bookmark.sections {
460                let page_id = Some(&section.page);
461
462                for filter in &section.filters {
463                    extend_with_filter(&mut out, page_id, None, bookmark_id, false, filter);
464                }
465
466                for visual in &section.visuals {
467                    let visual_id = Some(&visual.visual);
468                    extend_with_wells(
469                        &mut out,
470                        page_id,
471                        visual_id,
472                        bookmark_id,
473                        false,
474                        &visual.wells,
475                    );
476
477                    for filter in &visual.filters {
478                        extend_with_filter(
479                            &mut out,
480                            page_id,
481                            visual_id,
482                            bookmark_id,
483                            false,
484                            filter,
485                        );
486                    }
487                }
488            }
489        }
490
491        out
492    }
493
494    /// Every DAX expression defined report-side — report-level measures — on top of
495    /// [`TabularDatabase::dax_expressions`](crate::TabularDatabase::dax_expressions),
496    /// which covers the model side.
497    ///
498    /// A report measure has no home table, so `home_table` is always `None`:
499    /// unqualified `[Name]` references in its body can only be measures — of the
500    /// report first, then the model.
501    ///
502    /// Owners borrow their names, so this allocates only the returned `Vec`.
503    #[must_use]
504    pub fn dax_expressions(&self) -> Vec<DaxExpressionRef<'_>> {
505        let mut out = Vec::new();
506
507        for measure in &self.measures {
508            let owner = ExpressionOwner::ReportMeasure {
509                measure: measure.name.as_str(),
510            };
511            out.push(DaxExpressionRef {
512                owner,
513                kind: DaxExpressionKind::ReportMeasure,
514                home_table: None,
515                text: &measure.expression,
516            });
517            if let Some(text) = &measure.format_string {
518                out.push(DaxExpressionRef {
519                    owner,
520                    kind: DaxExpressionKind::ReportMeasureFormatString,
521                    home_table: None,
522                    text,
523                });
524            }
525        }
526
527        out
528    }
529}
530
531/// Appends the bindings of one page — drillthrough parameters, page filters,
532/// and each visual's wells, filters, sorts, conditional formatting, and alt
533/// text — in report order. `mobile` tags every binding with the layout it came
534/// from; the two layouts parse to the same shapes and bind identically.
535fn extend_with_page<'a>(out: &mut Vec<BindingRef<'a>>, page: &'a Page, mobile: bool) {
536    let page_id = Some(&page.name);
537
538    if let Some(binding) = &page.binding {
539        for parameter in &binding.parameters {
540            out.push(BindingRef {
541                page: page_id,
542                visual: None,
543                bookmark: None,
544                mobile,
545                kind: BindingKind::Drillthrough,
546                target: &parameter.target,
547            });
548        }
549    }
550
551    for filter in &page.filters {
552        extend_with_filter(out, page_id, None, None, mobile, filter);
553    }
554
555    for visual in &page.visuals {
556        let visual_id = Some(&visual.name);
557        extend_with_wells(out, page_id, visual_id, None, mobile, &visual.wells);
558
559        for filter in &visual.filters {
560            extend_with_filter(out, page_id, visual_id, None, mobile, filter);
561        }
562
563        for target in &visual.sorts {
564            out.push(BindingRef {
565                page: page_id,
566                visual: visual_id,
567                bookmark: None,
568                mobile,
569                kind: BindingKind::Sort,
570                target,
571            });
572        }
573
574        for target in &visual.conditional_formatting {
575            out.push(BindingRef {
576                page: page_id,
577                visual: visual_id,
578                bookmark: None,
579                mobile,
580                kind: BindingKind::ConditionalFormatting,
581                target,
582            });
583        }
584
585        for target in &visual.alt_text {
586            out.push(BindingRef {
587                page: page_id,
588                visual: visual_id,
589                bookmark: None,
590                mobile,
591                kind: BindingKind::AltText,
592                target,
593            });
594        }
595    }
596}
597
598/// Appends one [`BindingRef`] per field a filter carries, all tagged
599/// [`BindingKind::Filter`]: the declared `target` first, then the condition tree's
600/// `references`, preserving file order for stable diffs.
601fn extend_with_filter<'a>(
602    out: &mut Vec<BindingRef<'a>>,
603    page: Option<&'a NameKey>,
604    visual: Option<&'a NameKey>,
605    bookmark: Option<&'a NameKey>,
606    mobile: bool,
607    filter: &'a Filter,
608) {
609    for target in filter.target.iter().chain(&filter.references) {
610        out.push(BindingRef {
611            page,
612            visual,
613            bookmark,
614            mobile,
615            kind: BindingKind::Filter,
616            target,
617        });
618    }
619}
620
621/// Appends one [`BindingRef`] per projection in the wells, tagged with its well's
622/// role. Inactive projections bind too: they are one toggle away from live.
623fn extend_with_wells<'a>(
624    out: &mut Vec<BindingRef<'a>>,
625    page: Option<&'a NameKey>,
626    visual: Option<&'a NameKey>,
627    bookmark: Option<&'a NameKey>,
628    mobile: bool,
629    wells: &'a [FieldWell],
630) {
631    for well in wells {
632        for projection in &well.projections {
633            out.push(BindingRef {
634                page,
635                visual,
636                bookmark,
637                mobile,
638                kind: BindingKind::FieldWell {
639                    role: well.role.as_str(),
640                },
641                target: &projection.target,
642            });
643        }
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use rstest::rstest;
651
652    fn column_target(table: &str, column: &str) -> FieldTarget {
653        FieldTarget::Column {
654            table: NameKey::new(table),
655            column: NameKey::new(column),
656        }
657    }
658
659    fn measure_target(home_table: Option<&str>, measure: &str) -> FieldTarget {
660        FieldTarget::Measure {
661            home_table: home_table.map(NameKey::new),
662            measure: NameKey::new(measure),
663        }
664    }
665
666    fn filter_on(target: FieldTarget) -> Filter {
667        Filter {
668            target: Some(target),
669            ..Default::default()
670        }
671    }
672
673    fn page(name: &str) -> Page {
674        Page {
675            name: NameKey::new(name),
676            display_name: None,
677            is_hidden: false,
678            filters: Vec::new(),
679            binding: None,
680            visuals: Vec::new(),
681        }
682    }
683
684    fn visual(name: &str, visual_type: &str) -> Visual {
685        Visual {
686            name: NameKey::new(name),
687            visual_type: visual_type.to_string(),
688            wells: Vec::new(),
689            filters: Vec::new(),
690            sorts: Vec::new(),
691            conditional_formatting: Vec::new(),
692            alt_text: Vec::new(),
693            tooltip_page: None,
694        }
695    }
696
697    fn well(role: &str, targets: &[FieldTarget]) -> FieldWell {
698        FieldWell {
699            role: role.to_string(),
700            projections: targets
701                .iter()
702                .cloned()
703                .map(|target| Projection {
704                    target,
705                    query_ref: None,
706                    active: true,
707                })
708                .collect(),
709        }
710    }
711
712    mod field_target {
713        use super::*;
714
715        #[rstest]
716        #[case::column(column_target("Product", "Category"), "'Product'[Category]")]
717        #[case::measure_with_home_table(measure_target(Some("Sales"), "Cost"), "'Sales'[Cost]")]
718        #[case::measure_without_home_table(measure_target(None, "Cost"), "[Cost]")]
719        #[case::hierarchy_level(
720            FieldTarget::HierarchyLevel {
721                table: NameKey::new("Accounts"),
722                hierarchy: NameKey::new("Street Hierarchy"),
723                level: NameKey::new("State or Province"),
724                via_column: None,
725                via_variation: None,
726            },
727            "hierarchy 'Accounts'[Street Hierarchy] level 'State or Province'"
728        )]
729        #[case::aggregation(
730            FieldTarget::Aggregation {
731                function: Some("Sum".to_string()),
732                inner: Box::new(column_target("Sales", "Units")),
733            },
734            "Sum('Sales'[Units])"
735        )]
736        #[case::aggregation_without_function(
737            FieldTarget::Aggregation {
738                function: None,
739                inner: Box::new(column_target("Sales", "Units")),
740            },
741            "Aggregation('Sales'[Units])"
742        )]
743        #[case::written(
744            FieldTarget::Written(FieldRef {
745                table: Some(NameKey::new("Sales")),
746                name: NameKey::new("Amount"),
747            }),
748            "'Sales'[Amount]"
749        )]
750        fn displays_for_diagnostics(#[case] target: FieldTarget, #[case] expected: &str) {
751            assert_eq!(target.to_string(), expected);
752        }
753
754        #[test]
755        fn compares_equal_ignoring_case() {
756            assert_eq!(
757                column_target("Product", "Category"),
758                column_target("PRODUCT", "CATEGORY")
759            );
760            assert_eq!(
761                measure_target(Some("Sales"), "Cost"),
762                measure_target(Some("sales"), "COST")
763            );
764        }
765
766        /// The binding states column-or-measure outright; losing it would make
767        /// resolution guess where it currently knows.
768        #[test]
769        fn distinguishes_a_column_from_a_measure_with_the_same_names() {
770            assert_ne!(
771                column_target("Sales", "Cost"),
772                measure_target(Some("Sales"), "Cost")
773            );
774        }
775    }
776
777    mod bindings {
778        use super::*;
779
780        /// The common report identity; each test adds the binding site it checks.
781        fn sample() -> ReportModel {
782            ReportModel {
783                name: Some("Sales overview".to_string()),
784                dataset: DatasetReference::ByPath {
785                    path: "../Sales.SemanticModel".to_string(),
786                },
787                ..Default::default()
788            }
789        }
790
791        fn sample_with_page(page: Page) -> ReportModel {
792            ReportModel {
793                pages: vec![page],
794                ..sample()
795            }
796        }
797
798        fn sample_with_mobile_page(page: Page) -> ReportModel {
799            ReportModel {
800                mobile_pages: vec![page],
801                ..sample()
802            }
803        }
804
805        /// One binding's full provenance: page, visual, bookmark, layout,
806        /// kind, and the target as `Display` — what resolution consumes.
807        type Provenance<'a> = (
808            Option<&'a str>,
809            Option<&'a str>,
810            Option<&'a str>,
811            bool,
812            BindingKind<'a>,
813            String,
814        );
815
816        /// `Provenance` per binding, in enumeration order — the full
817        /// specification `bindings()` must satisfy.
818        fn provenance(report: &ReportModel) -> Vec<Provenance<'_>> {
819            report
820                .bindings()
821                .into_iter()
822                .map(|binding| {
823                    (
824                        binding.page.map(NameKey::as_str),
825                        binding.visual.map(NameKey::as_str),
826                        binding.bookmark.map(NameKey::as_str),
827                        binding.mobile,
828                        binding.kind,
829                        binding.target.to_string(),
830                    )
831                })
832                .collect()
833        }
834
835        #[test]
836        fn a_report_filter_has_no_page_visual_or_bookmark() {
837            let report = ReportModel {
838                filters: vec![filter_on(column_target("Product", "Category"))],
839                ..sample()
840            };
841            let bindings = report.bindings();
842
843            assert_eq!(bindings.len(), 1);
844            assert_eq!(bindings[0].page, None);
845            assert_eq!(bindings[0].visual, None);
846            assert_eq!(bindings[0].bookmark, None);
847            assert_eq!(bindings[0].kind, BindingKind::Filter);
848        }
849
850        #[test]
851        fn a_drillthrough_parameter_is_tagged_on_its_page() {
852            let report = sample_with_page(Page {
853                binding: Some(PageBinding {
854                    kind: PageBindingKind::Drillthrough,
855                    parameters: vec![DrillthroughParameter {
856                        name: Some(NameKey::new("Param_Filter5")),
857                        target: column_target("Industries", "Industry"),
858                    }],
859                }),
860                ..page("ReportSection1")
861            });
862
863            let bindings = report.bindings();
864            assert_eq!(bindings.len(), 1);
865            assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
866            assert_eq!(bindings[0].visual, None);
867            assert_eq!(bindings[0].kind, BindingKind::Drillthrough);
868        }
869
870        #[test]
871        fn a_page_filter_carries_its_page_but_no_visual() {
872            let report = sample_with_page(Page {
873                filters: vec![filter_on(column_target("Owners", "Sales owner"))],
874                ..page("ReportSection1")
875            });
876
877            let bindings = report.bindings();
878            assert_eq!(bindings.len(), 1);
879            assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
880            assert_eq!(bindings[0].visual, None);
881            assert_eq!(bindings[0].kind, BindingKind::Filter);
882        }
883
884        #[test]
885        fn a_visual_well_carries_role_page_and_visual() {
886            let report = sample_with_page(Page {
887                visuals: vec![Visual {
888                    wells: vec![well("Category", &[column_target("Product", "Category")])],
889                    ..visual("visual1", "donutChart")
890                }],
891                ..page("ReportSection1")
892            });
893
894            let bindings = report.bindings();
895            assert_eq!(bindings.len(), 1);
896            assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
897            assert_eq!(bindings[0].visual.unwrap().as_str(), "visual1");
898            assert_eq!(bindings[0].bookmark, None);
899            assert_eq!(
900                bindings[0].kind,
901                BindingKind::FieldWell { role: "Category" }
902            );
903        }
904
905        #[test]
906        fn sorts_and_conditional_formatting_are_tagged_as_such() {
907            let report = sample_with_page(Page {
908                visuals: vec![Visual {
909                    sorts: vec![column_target("Product", "Category")],
910                    conditional_formatting: vec![measure_target(Some("Sales"), "Margin")],
911                    ..visual("visual1", "tableEx")
912                }],
913                ..page("ReportSection1")
914            });
915
916            let bindings = report.bindings();
917            assert_eq!(
918                bindings.iter().map(|b| b.kind).collect::<Vec<_>>(),
919                vec![BindingKind::Sort, BindingKind::ConditionalFormatting]
920            );
921            // Both bindings belong to their visual, at page level.
922            for binding in &bindings {
923                assert_eq!(binding.page.unwrap().as_str(), "ReportSection1");
924                assert_eq!(binding.visual.unwrap().as_str(), "visual1");
925            }
926        }
927
928        #[test]
929        fn a_bookmark_filter_carries_bookmark_and_page() {
930            let report = ReportModel {
931                bookmarks: vec![Bookmark {
932                    name: NameKey::new("Bookmark1"),
933                    display_name: Some("FY24".to_string()),
934                    filters: Vec::new(),
935                    sections: vec![BookmarkSection {
936                        page: NameKey::new("ReportSection1"),
937                        filters: vec![filter_on(column_target("Products", "Product category"))],
938                        visuals: Vec::new(),
939                    }],
940                }],
941                ..sample()
942            };
943
944            let bindings = report.bindings();
945            assert_eq!(bindings.len(), 1);
946            assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
947            assert_eq!(bindings[0].visual, None);
948            assert_eq!(bindings[0].bookmark.unwrap().as_str(), "Bookmark1");
949            assert_eq!(bindings[0].kind, BindingKind::Filter);
950        }
951
952        #[test]
953        fn bookmark_wells_carry_bookmark_page_and_visual() {
954            let report = ReportModel {
955                bookmarks: vec![Bookmark {
956                    name: NameKey::new("Bookmark1"),
957                    display_name: None,
958                    filters: Vec::new(),
959                    sections: vec![BookmarkSection {
960                        page: NameKey::new("ReportSection1"),
961                        filters: Vec::new(),
962                        visuals: vec![BookmarkVisual {
963                            visual: NameKey::new("visual1"),
964                            wells: vec![well("Rows", &[column_target("Product", "Subcategory")])],
965                            filters: Vec::new(),
966                        }],
967                    }],
968                }],
969                ..sample()
970            };
971
972            let bindings = report.bindings();
973            assert_eq!(bindings.len(), 1);
974            assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
975            assert_eq!(bindings[0].visual.unwrap().as_str(), "visual1");
976            assert_eq!(bindings[0].bookmark.unwrap().as_str(), "Bookmark1");
977            assert_eq!(bindings[0].kind, BindingKind::FieldWell { role: "Rows" });
978        }
979
980        /// Enumeration walks report order — report filters, page (parameters,
981        /// filters, visuals: wells, filters, sorts, conditional formatting),
982        /// mobile-layout pages, then bookmarks — so runs are diffable. Every
983        /// binding's full provenance is pinned, not just its kind: a slipped
984        /// page, visual, bookmark, or layout flag on any site must fail here.
985        #[test]
986        fn order_follows_report_structure() {
987            let report = ReportModel {
988                filters: vec![filter_on(column_target("Product", "Category"))],
989                pages: vec![
990                    Page {
991                        binding: Some(PageBinding {
992                            kind: PageBindingKind::Drillthrough,
993                            parameters: vec![DrillthroughParameter {
994                                name: None,
995                                target: column_target("Industries", "Industry"),
996                            }],
997                        }),
998                        filters: vec![filter_on(column_target("Owners", "Sales owner"))],
999                        visuals: vec![Visual {
1000                            wells: vec![well("Category", &[column_target("Product", "Category")])],
1001                            filters: vec![filter_on(column_target("Region", "Country"))],
1002                            sorts: vec![measure_target(Some("Sales"), "Sales")],
1003                            conditional_formatting: vec![measure_target(Some("Sales"), "Margin")],
1004                            ..visual("visual1", "donutChart")
1005                        }],
1006                        ..page("ReportSection1")
1007                    },
1008                    Page {
1009                        visuals: vec![Visual {
1010                            wells: vec![well(
1011                                "Tooltips",
1012                                &[measure_target(Some("Sales"), "Customers %")],
1013                            )],
1014                            ..visual("visual2", "slicer")
1015                        }],
1016                        ..page("ReportSection2")
1017                    },
1018                ],
1019                // The phone layout mirrors page 1; its bindings enumerate after
1020                // every desktop page.
1021                mobile_pages: vec![Page {
1022                    visuals: vec![Visual {
1023                        wells: vec![well("Values", &[measure_target(Some("Sales"), "Total")])],
1024                        ..visual("visual1", "card")
1025                    }],
1026                    ..page("ReportSection1")
1027                }],
1028                bookmarks: vec![Bookmark {
1029                    name: NameKey::new("Bookmark1"),
1030                    display_name: None,
1031                    filters: vec![filter_on(measure_target(None, "Total Units"))],
1032                    sections: vec![BookmarkSection {
1033                        page: NameKey::new("ReportSection1"),
1034                        filters: vec![filter_on(column_target("Products", "Product category"))],
1035                        visuals: vec![BookmarkVisual {
1036                            visual: NameKey::new("visual1"),
1037                            wells: vec![well("Rows", &[column_target("Product", "Subcategory")])],
1038                            filters: vec![filter_on(column_target("Product", "Color"))],
1039                        }],
1040                    }],
1041                }],
1042                measures: Vec::new(),
1043                ..sample()
1044            };
1045
1046            assert_eq!(
1047                provenance(&report),
1048                vec![
1049                    // Report filter.
1050                    (
1051                        None,
1052                        None,
1053                        None,
1054                        false,
1055                        BindingKind::Filter,
1056                        "'Product'[Category]".to_string(),
1057                    ),
1058                    // Page 1 drillthrough parameter.
1059                    (
1060                        Some("ReportSection1"),
1061                        None,
1062                        None,
1063                        false,
1064                        BindingKind::Drillthrough,
1065                        "'Industries'[Industry]".to_string(),
1066                    ),
1067                    // Page 1 filter.
1068                    (
1069                        Some("ReportSection1"),
1070                        None,
1071                        None,
1072                        false,
1073                        BindingKind::Filter,
1074                        "'Owners'[Sales owner]".to_string(),
1075                    ),
1076                    // Visual 1 well.
1077                    (
1078                        Some("ReportSection1"),
1079                        Some("visual1"),
1080                        None,
1081                        false,
1082                        BindingKind::FieldWell { role: "Category" },
1083                        "'Product'[Category]".to_string(),
1084                    ),
1085                    // Visual 1 filter.
1086                    (
1087                        Some("ReportSection1"),
1088                        Some("visual1"),
1089                        None,
1090                        false,
1091                        BindingKind::Filter,
1092                        "'Region'[Country]".to_string(),
1093                    ),
1094                    // Visual 1 sort.
1095                    (
1096                        Some("ReportSection1"),
1097                        Some("visual1"),
1098                        None,
1099                        false,
1100                        BindingKind::Sort,
1101                        "'Sales'[Sales]".to_string(),
1102                    ),
1103                    // Visual 1 conditional formatting.
1104                    (
1105                        Some("ReportSection1"),
1106                        Some("visual1"),
1107                        None,
1108                        false,
1109                        BindingKind::ConditionalFormatting,
1110                        "'Sales'[Margin]".to_string(),
1111                    ),
1112                    // Visual 2 well.
1113                    (
1114                        Some("ReportSection2"),
1115                        Some("visual2"),
1116                        None,
1117                        false,
1118                        BindingKind::FieldWell { role: "Tooltips" },
1119                        "'Sales'[Customers %]".to_string(),
1120                    ),
1121                    // Mobile-layout page (mirror of page 1) well.
1122                    (
1123                        Some("ReportSection1"),
1124                        Some("visual1"),
1125                        None,
1126                        true,
1127                        BindingKind::FieldWell { role: "Values" },
1128                        "'Sales'[Total]".to_string(),
1129                    ),
1130                    // Bookmark report-level filter: no page, no visual.
1131                    (
1132                        None,
1133                        None,
1134                        Some("Bookmark1"),
1135                        false,
1136                        BindingKind::Filter,
1137                        "[Total Units]".to_string(),
1138                    ),
1139                    // Bookmark section filter.
1140                    (
1141                        Some("ReportSection1"),
1142                        None,
1143                        Some("Bookmark1"),
1144                        false,
1145                        BindingKind::Filter,
1146                        "'Products'[Product category]".to_string(),
1147                    ),
1148                    // Bookmark well.
1149                    (
1150                        Some("ReportSection1"),
1151                        Some("visual1"),
1152                        Some("Bookmark1"),
1153                        false,
1154                        BindingKind::FieldWell { role: "Rows" },
1155                        "'Product'[Subcategory]".to_string(),
1156                    ),
1157                    // Bookmark visual filter.
1158                    (
1159                        Some("ReportSection1"),
1160                        Some("visual1"),
1161                        Some("Bookmark1"),
1162                        false,
1163                        BindingKind::Filter,
1164                        "'Product'[Color]".to_string(),
1165                    ),
1166                ]
1167            );
1168        }
1169
1170        /// The declared target comes first, then the condition tree's references,
1171        /// in file order.
1172        #[test]
1173        fn a_filter_yields_target_then_references_in_order() {
1174            let report = sample_with_page(Page {
1175                visuals: vec![Visual {
1176                    filters: vec![Filter {
1177                        name: Some(NameKey::new("Filter5")),
1178                        target: Some(column_target("Product", "Category")),
1179                        references: vec![
1180                            column_target("Product", "Subcategory"),
1181                            measure_target(None, "Units"),
1182                        ],
1183                    }],
1184                    ..visual("visual1", "donutChart")
1185                }],
1186                ..page("ReportSection1")
1187            });
1188
1189            let targets: Vec<&FieldTarget> =
1190                report.bindings().into_iter().map(|b| b.target).collect();
1191            assert_eq!(
1192                targets,
1193                vec![
1194                    &column_target("Product", "Category"),
1195                    &column_target("Product", "Subcategory"),
1196                    &measure_target(None, "Units"),
1197                ]
1198            );
1199        }
1200
1201        /// A filter the parser could not give a structured target still binds:
1202        /// its references are roots even when `target` is `None`.
1203        #[test]
1204        fn a_filter_without_a_target_still_binds_its_references() {
1205            let report = sample_with_page(Page {
1206                visuals: vec![Visual {
1207                    filters: vec![Filter {
1208                        name: Some(NameKey::new("Filter5")),
1209                        target: None,
1210                        references: vec![
1211                            column_target("Product", "Subcategory"),
1212                            measure_target(None, "Units"),
1213                        ],
1214                    }],
1215                    ..visual("visual1", "donutChart")
1216                }],
1217                ..page("ReportSection1")
1218            });
1219
1220            let targets: Vec<&FieldTarget> =
1221                report.bindings().into_iter().map(|b| b.target).collect();
1222            assert_eq!(
1223                targets,
1224                vec![
1225                    &column_target("Product", "Subcategory"),
1226                    &measure_target(None, "Units"),
1227                ]
1228            );
1229        }
1230
1231        /// An inactive projection is one toggle away from live; dropping it would
1232        /// under-count roots and report live code as unused.
1233        #[test]
1234        fn an_inactive_projection_still_binds() {
1235            let report = sample_with_page(Page {
1236                visuals: vec![Visual {
1237                    wells: vec![FieldWell {
1238                        role: "Y".to_string(),
1239                        projections: vec![Projection {
1240                            target: column_target("Sales", "Units"),
1241                            query_ref: None,
1242                            active: false,
1243                        }],
1244                    }],
1245                    ..visual("visual1", "lineChart")
1246                }],
1247                ..page("ReportSection1")
1248            });
1249
1250            assert_eq!(report.bindings().len(), 1);
1251        }
1252
1253        /// Hidden is not dead: a hidden page's visuals render on demand, so their
1254        /// wells bind like any other page's. Skipping hidden pages would
1255        /// under-count roots and report live code as unused.
1256        #[test]
1257        fn a_hidden_pages_visuals_still_bind() {
1258            let report = sample_with_page(Page {
1259                is_hidden: true,
1260                visuals: vec![Visual {
1261                    wells: vec![well("Values", &[column_target("Sales", "Units")])],
1262                    ..visual("visual1", "card")
1263                }],
1264                ..page("ReportSection1")
1265            });
1266
1267            let bindings = report.bindings();
1268            assert_eq!(bindings.len(), 1);
1269            assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
1270            assert_eq!(bindings[0].kind, BindingKind::FieldWell { role: "Values" });
1271        }
1272
1273        /// The phone layout binds exactly like the desktop tree — same shapes,
1274        /// same liveness — with only the provenance flag differing (issue #49).
1275        #[test]
1276        fn a_mobile_page_visual_binds_and_is_tagged_mobile() {
1277            let report = sample_with_mobile_page(Page {
1278                visuals: vec![Visual {
1279                    wells: vec![well("Values", &[column_target("Sales", "Units")])],
1280                    ..visual("visual1", "card")
1281                }],
1282                ..page("ReportSection1")
1283            });
1284
1285            let bindings = report.bindings();
1286            assert_eq!(bindings.len(), 1);
1287            assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
1288            assert_eq!(bindings[0].visual.unwrap().as_str(), "visual1");
1289            assert_eq!(bindings[0].bookmark, None);
1290            assert!(bindings[0].mobile);
1291            assert_eq!(bindings[0].kind, BindingKind::FieldWell { role: "Values" });
1292        }
1293
1294        /// Visibility is display-only in both layouts: a hidden phone page still
1295        /// renders on demand, so skipping it would under-count roots.
1296        #[test]
1297        fn a_hidden_mobile_pages_visuals_still_bind() {
1298            let report = sample_with_mobile_page(Page {
1299                is_hidden: true,
1300                visuals: vec![Visual {
1301                    wells: vec![well("Values", &[column_target("Sales", "Units")])],
1302                    ..visual("visual1", "card")
1303                }],
1304                ..page("ReportSection1")
1305            });
1306
1307            let bindings = report.bindings();
1308            assert_eq!(bindings.len(), 1);
1309            assert!(bindings[0].mobile);
1310        }
1311
1312        /// A mobile page's non-well sites (filters, sorts) carry the flag too.
1313        #[test]
1314        fn a_mobile_page_filter_is_tagged_mobile() {
1315            let report = sample_with_mobile_page(Page {
1316                filters: vec![filter_on(column_target("Sales", "Units"))],
1317                ..page("ReportSection1")
1318            });
1319
1320            let bindings = report.bindings();
1321            assert_eq!(bindings.len(), 1);
1322            assert!(bindings[0].mobile);
1323            assert_eq!(bindings[0].kind, BindingKind::Filter);
1324        }
1325    }
1326
1327    /// The same field, bound through PBIR's structured entities and through legacy
1328    /// Layout's written names, must enumerate to the same provenance: this
1329    /// equivalence is the AST's whole reason to exist.
1330    mod format_agnostic {
1331        use super::*;
1332
1333        fn report_with(well_target: FieldTarget) -> ReportModel {
1334            ReportModel {
1335                pages: vec![Page {
1336                    visuals: vec![Visual {
1337                        wells: vec![well("Y", &[well_target])],
1338                        ..visual("visual1", "clusteredColumnChart")
1339                    }],
1340                    ..page("ReportSection1")
1341                }],
1342                ..Default::default()
1343            }
1344        }
1345
1346        #[test]
1347        fn structured_and_written_targets_bind_alike() {
1348            let pbir = report_with(FieldTarget::Measure {
1349                home_table: Some(NameKey::new("Sales")),
1350                measure: NameKey::new("Cost"),
1351            });
1352            let legacy = report_with(FieldTarget::Written(FieldRef {
1353                table: Some(NameKey::new("Sales")),
1354                name: NameKey::new("Cost"),
1355            }));
1356
1357            let pbir_bindings = pbir.bindings();
1358            let legacy_bindings = legacy.bindings();
1359            assert_eq!(pbir_bindings.len(), 1);
1360            assert_eq!(legacy_bindings.len(), 1);
1361
1362            // Provenance and kind are identical; only the target's variant differs.
1363            assert_eq!(pbir_bindings[0].page, legacy_bindings[0].page);
1364            assert_eq!(pbir_bindings[0].visual, legacy_bindings[0].visual);
1365            assert_eq!(pbir_bindings[0].bookmark, legacy_bindings[0].bookmark);
1366            assert_eq!(pbir_bindings[0].kind, legacy_bindings[0].kind);
1367        }
1368    }
1369
1370    mod dax_expressions {
1371        use super::*;
1372
1373        #[test]
1374        fn enumerates_a_report_measures_body_and_format_string() {
1375            let report = ReportModel {
1376                measures: vec![ReportMeasure {
1377                    name: NameKey::new("Growth %"),
1378                    expression: "DIVIDE([Sales] - [Prior Sales], [Prior Sales])".to_string(),
1379                    format_string: Some("0.0%;-0.0%;0.0%".to_string()),
1380                }],
1381                ..Default::default()
1382            };
1383
1384            let expressions = report.dax_expressions();
1385            assert_eq!(expressions.len(), 2);
1386
1387            assert_eq!(
1388                expressions[0],
1389                DaxExpressionRef {
1390                    owner: ExpressionOwner::ReportMeasure {
1391                        measure: "Growth %"
1392                    },
1393                    kind: DaxExpressionKind::ReportMeasure,
1394                    home_table: None,
1395                    text: "DIVIDE([Sales] - [Prior Sales], [Prior Sales])",
1396                }
1397            );
1398            assert_eq!(
1399                expressions[1],
1400                DaxExpressionRef {
1401                    owner: ExpressionOwner::ReportMeasure {
1402                        measure: "Growth %"
1403                    },
1404                    kind: DaxExpressionKind::ReportMeasureFormatString,
1405                    home_table: None,
1406                    text: "0.0%;-0.0%;0.0%",
1407                }
1408            );
1409        }
1410
1411        /// Most report measures carry no dynamic format string; only the body is
1412        /// then an expression source.
1413        #[test]
1414        fn a_measure_without_a_format_string_enumerates_only_its_body() {
1415            let report = ReportModel {
1416                measures: vec![ReportMeasure {
1417                    name: NameKey::new("Total Units"),
1418                    expression: "SUM('Sales'[Units])".to_string(),
1419                    format_string: None,
1420                }],
1421                ..Default::default()
1422            };
1423
1424            let expressions = report.dax_expressions();
1425            assert_eq!(expressions.len(), 1);
1426            assert_eq!(expressions[0].kind, DaxExpressionKind::ReportMeasure);
1427        }
1428
1429        #[test]
1430        fn a_report_without_measures_has_none() {
1431            assert!(ReportModel::default().dax_expressions().is_empty());
1432        }
1433
1434        /// The owner is the measure's reachability identity: visuals reference it,
1435        /// its body references model objects, and the graph node must match both.
1436        /// Compared through `Display`: `ObjectId` equality ignores case, so it
1437        /// could never catch a lowercased or rewritten name.
1438        #[test]
1439        fn owner_materializes_a_report_measure_object_id() {
1440            let owner = ExpressionOwner::ReportMeasure {
1441                measure: "Growth %",
1442            };
1443            assert_eq!(
1444                owner.to_object_id().to_string(),
1445                "report measure 'Growth %'"
1446            );
1447        }
1448    }
1449
1450    mod expression_views {
1451        use super::*;
1452
1453        /// Mirrors the model-side guarantee: bindings enumerate without allocating
1454        /// beyond the returned `Vec`, which holds only while every field borrows.
1455        #[test]
1456        fn are_copy_so_enumeration_borrows_everything() {
1457            fn assert_copy<T: Copy>() {}
1458            assert_copy::<BindingRef<'_>>();
1459            assert_copy::<BindingKind<'_>>();
1460        }
1461    }
1462
1463    mod defaults {
1464        use super::*;
1465
1466        /// An unparsed dataset reference must never masquerade as a path or a
1467        /// connection, or a wrong report↔model pairing would reach the graph.
1468        #[test]
1469        fn a_dataset_reference_is_unresolved() {
1470            assert_eq!(DatasetReference::default(), DatasetReference::Unresolved);
1471            assert_eq!(ReportModel::default().dataset, DatasetReference::Unresolved);
1472        }
1473
1474        /// Most pages are plain pages; PBIR omits the binding for them entirely.
1475        #[test]
1476        fn a_page_binding_kind_is_default() {
1477            assert_eq!(PageBindingKind::default(), PageBindingKind::Default);
1478            assert_eq!(PageBinding::default().kind, PageBindingKind::Default);
1479        }
1480    }
1481}