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(§ion.page);
461
462 for filter in §ion.filters {
463 extend_with_filter(&mut out, page_id, None, bookmark_id, false, filter);
464 }
465
466 for visual in §ion.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: ¶meter.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}