pdf_xfa/adobe_compat/rules.rs
1//! Executable Adobe compatibility rules.
2//!
3//! This module turns documented compat-rule metadata into running code. Each
4//! rule has:
5//!
6//! - A pure decision function that takes inputs by value and returns a typed
7//! outcome. No I/O, no engine state mutation.
8//! - A `(Phase, Reason)` trace anchor emitted when the rule's decision is
9//! non-trivial.
10//! - Unit tests that pin the decision logic against the rule's
11//! `positive_examples` and `counterexamples` from the registry.
12//! - A regression guard hooked into an existing corpus test.
13//!
14//! ## Migrated rules (UX1, M5.2, M5.2b, M5.3)
15//!
16//! - `CapSuppressionByFormDomPageAreaCount` (UX1) — `cap_suppression_by_form_dom`.
17//! - `SuppressEmptyPagesOnlyWhenRealDataBound` (M5.2) —
18//! `suppress_empty_pages_only_when_real_data_bound`.
19//! - `ExcludeBindNoneFieldsFromPageDataSuppression` (M5.2) —
20//! `exclude_bind_none_fields_from_page_data_suppression`.
21//! - `TrimStaticXfafExcessPagesWhenLayoutIsSinglePageAndFormDomAllows`
22//! (M5.2; brief alias *StaticXfafExcessPageTrimWithFormDomGuard*) —
23//! `static_xfaf_excess_page_trim_with_form_dom_guard`.
24//! - `IgnoreInvisibleServerMetadataBindingsForDataBoundSignal` (M5.2b) —
25//! `ignore_invisible_server_metadata_bindings`.
26//! - `ExcludeNonDataWidgetsFromPageSuppression` (M5.2b) —
27//! `exclude_non_data_widgets_from_page_suppression`.
28//! - `RepeatingSubformInstanceCountClampedToOccurRange` (M5.3) —
29//! `repeating_subform_instance_count_clamped_to_occur_range`.
30//!
31//! Every documented Adobe-compat rule in the registry has an executable
32//! companion.
33
34use xfa_layout_engine::trace::{sites as trace_sites, Reason};
35
36use super::registry::AdobeCompatRuleId;
37
38/// Result of the `CapSuppressionByFormDomPageAreaCount` rule.
39///
40/// Encodes how many pages page-suppression may drop while preserving the
41/// invariant that the surviving page count is at least the form DOM's
42/// declared page-area count.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub struct CapDecision {
45 /// Upper bound on the number of pages page-suppression may drop.
46 ///
47 /// `usize::MAX` when the form DOM provides no guidance (uncapped).
48 pub max_suppress: usize,
49 /// Stable rule identifier for trace and telemetry consumers.
50 pub rule: AdobeCompatRuleId,
51}
52
53impl CapDecision {
54 /// Constructor for the uncapped (no form-DOM guidance) case.
55 pub const fn uncapped() -> Self {
56 Self {
57 max_suppress: usize::MAX,
58 rule: AdobeCompatRuleId::CapSuppressionByFormDomPageAreaCount,
59 }
60 }
61
62 /// Constructor for a numerically capped value.
63 pub const fn capped_at(max_suppress: usize) -> Self {
64 Self {
65 max_suppress,
66 rule: AdobeCompatRuleId::CapSuppressionByFormDomPageAreaCount,
67 }
68 }
69}
70
71/// Apply rule `cap_suppression_by_form_dom_page_area_count`.
72///
73/// Inputs:
74///
75/// - `layout_pages` — the number of pages the layout engine produced
76/// before suppression runs.
77/// - `form_dom_page_count` — the number of `<pageArea>` elements found in
78/// the saved form DOM, when present. `None` means the PDF carries no
79/// form DOM (no Adobe Reader saved-state), so the cap does not apply.
80///
81/// Output:
82///
83/// - `max_suppress` — upper bound on the count of pages the suppression
84/// loop may drop. The loop is responsible for comparing
85/// `suppressed < max_suppress` before each drop.
86///
87/// ## Trace anchor
88///
89/// When the form-DOM cap fires (i.e. `form_dom_page_count` is `Some` and
90/// would constrain suppression), the function emits one
91/// `(suppress, SuppressCappedByFormDom)` event with the inputs and the
92/// resulting `max_suppress`. When the rule is inactive (uncapped, no
93/// form DOM, or already under-paginated), no event is emitted — silence
94/// is the trace anchor that says "rule did not fire".
95///
96/// ## Behaviour preservation
97///
98/// The decision is bit-for-bit identical to the prior inline match in
99/// `pdf_xfa::flatten`: this function is a refactor, not a fix. The
100/// regression guard `corpus_322faac4_seventeen_pages` continues to
101/// produce the same page count after the migration.
102pub fn cap_suppression_by_form_dom(
103 layout_pages: usize,
104 form_dom_page_count: Option<usize>,
105) -> CapDecision {
106 match form_dom_page_count {
107 Some(target) if target < layout_pages => {
108 let max = layout_pages - target;
109 trace_sites::suppress(
110 Reason::SuppressCappedByFormDom,
111 u32::try_from(target).unwrap_or(u32::MAX),
112 format!("form_dom_pages={target} layout_pages={layout_pages} max_suppress={max}"),
113 );
114 CapDecision::capped_at(max)
115 }
116 Some(target) => {
117 // Form DOM declares >= layout pages — already at or above the
118 // target. Suppressing further would widen the gap. The cap is
119 // effectively 0; emit the trace anchor so consumers can prove
120 // the rule was consulted.
121 trace_sites::suppress(
122 Reason::SuppressCappedByFormDom,
123 u32::try_from(target).unwrap_or(u32::MAX),
124 format!("form_dom_pages={target} layout_pages={layout_pages} max_suppress=0"),
125 );
126 CapDecision::capped_at(0)
127 }
128 None => CapDecision::uncapped(),
129 }
130}
131
132// ── M5.2 — SuppressEmptyPagesOnlyWhenRealDataBound ─────────────────────────
133
134/// Result of the `SuppressEmptyPagesOnlyWhenRealDataBound` preflight rule.
135///
136/// Encodes whether the page-suppression heuristic is allowed to run on this
137/// flatten. The rule fires once, before the per-page scan.
138#[derive(Debug, Clone, Copy, PartialEq, Eq)]
139pub struct SuppressPreflightDecision {
140 /// `true` when suppression is allowed to inspect pages and drop the
141 /// data-empty ones; `false` when no real data binding occurred and
142 /// every template page must be preserved.
143 pub run_suppression: bool,
144 /// Stable rule identifier.
145 pub rule: AdobeCompatRuleId,
146}
147
148/// Apply rule `suppress_empty_pages_only_when_real_data_bound`.
149///
150/// Inputs:
151///
152/// - `layout_pages` — number of pages the layout engine produced. The
153/// suppression heuristic only runs when more than one page exists (a
154/// one-page form has nothing to drop).
155/// - `any_data_bound` — whether `FormMerger` bound at least one visible
156/// data field from the DataDom during the bind phase. When false, the
157/// "data-empty page" signal is meaningless because every field's value
158/// comes from a template default.
159///
160/// Output:
161///
162/// - `run_suppression` — `true` iff `layout_pages > 1 && any_data_bound`.
163///
164/// ## Trace anchor
165///
166/// Emits exactly one event:
167///
168/// - `(suppress, suppress_gated_by_data_bound_signal)` when the rule
169/// allows the heuristic to run.
170/// - `(suppress, suppress_skipped_no_data_bound_signal)` when the rule
171/// blocks the heuristic (one-page form or no real data binding).
172///
173/// ## Behaviour preservation
174///
175/// The decision is bit-for-bit identical to the inline guard
176/// `if layout.pages.len() > 1 && tree.any_data_bound { … }` previously
177/// living in `pdf_xfa::flatten`. Corpus regression guards (e.g.
178/// `bind_none_page_is_not_dropped`, `corpus_322faac4_seventeen_pages`)
179/// continue to produce the same page counts after the migration.
180pub fn suppress_empty_pages_only_when_real_data_bound(
181 layout_pages: usize,
182 any_data_bound: bool,
183) -> SuppressPreflightDecision {
184 let run = layout_pages > 1 && any_data_bound;
185 let reason = if run {
186 Reason::SuppressGatedByDataBoundSignal
187 } else {
188 Reason::SuppressSkippedNoDataBoundSignal
189 };
190 trace_sites::suppress(
191 reason,
192 u32::try_from(layout_pages).unwrap_or(u32::MAX),
193 format!(
194 "layout_pages={layout_pages} any_data_bound={any_data_bound} run_suppression={run}"
195 ),
196 );
197 SuppressPreflightDecision {
198 run_suppression: run,
199 rule: AdobeCompatRuleId::SuppressEmptyPagesOnlyWhenRealDataBound,
200 }
201}
202
203// ── M5.2 — ExcludeBindNoneFieldsFromPageDataSuppression ────────────────────
204
205/// A field's classification under the bind-none rule.
206#[derive(Debug, Clone, Copy, PartialEq, Eq)]
207pub enum BindNoneClassification {
208 /// Field counts as a data field for page-suppression purposes.
209 DataField,
210 /// Field has `<bind match="none">` and is excluded from the
211 /// page data-field count. Static template defaults only.
212 ExcludedBindNone,
213 /// Field is a non-data widget (signature, button, barcode) — handled
214 /// by the sibling rule `ExcludeNonDataWidgetsFromPageSuppression`.
215 /// This rule does not own that decision but reports it for caller
216 /// convenience so the page-scan can short-circuit.
217 ExcludedNonDataWidget,
218}
219
220/// Apply rule `exclude_bind_none_fields_from_page_data_suppression` to a
221/// single field's metadata snapshot.
222///
223/// Inputs:
224///
225/// - `is_field_node` — true when the FormTree node is `FormNodeType::Field`;
226/// otherwise the rule is not consulted (returns `DataField` so the
227/// caller's recursion proceeds, but no field-level decision is made —
228/// non-fields are filtered by the outer loop).
229/// - `is_non_data_widget` — true for signature / button / barcode field
230/// kinds (handled by the sibling rule; surfaced here for clarity).
231/// - `data_bind_none` — value of `FormNodeMeta::data_bind_none`.
232///
233/// Output:
234///
235/// - `BindNoneClassification`.
236///
237/// ## Trace anchor
238///
239/// This rule fires per field; emitting on every call would flood the
240/// sink. The rule itself stays silent. Callers are expected to maintain a
241/// per-flatten counter and emit one summary event
242/// `(suppress, bind_none_field_excluded_from_data_check)` with the total
243/// exclusion count once the page scan completes — that summary lives in
244/// `pdf_xfa::flatten`. Tests verify both halves.
245///
246/// ## Behaviour preservation
247///
248/// Equivalent to the inline expression in `page_has_fields` (`flatten.rs`):
249///
250/// ```text
251/// matches!(node.node_type, FormNodeType::Field { .. })
252/// && !matches!(meta.field_kind, Signature | Button | Barcode)
253/// && !meta.data_bind_none
254/// ```
255///
256/// `DataField` ⇔ that expression is `true`; otherwise it is `false`.
257pub fn exclude_bind_none_fields_from_page_data_suppression(
258 is_field_node: bool,
259 is_non_data_widget: bool,
260 data_bind_none: bool,
261) -> BindNoneClassification {
262 if !is_field_node {
263 // Caller invariant: only consulted for field nodes. Treat
264 // non-fields as "data" so the recursive walk falls through to
265 // children (matches the original `is_data_field || …` shape).
266 return BindNoneClassification::DataField;
267 }
268 if is_non_data_widget {
269 return BindNoneClassification::ExcludedNonDataWidget;
270 }
271 if data_bind_none {
272 return BindNoneClassification::ExcludedBindNone;
273 }
274 BindNoneClassification::DataField
275}
276
277/// Emit the once-per-flatten summary event for rule
278/// `ExcludeBindNoneFieldsFromPageDataSuppression`.
279///
280/// Caller pattern (from `pdf_xfa::flatten`):
281///
282/// 1. Initialise a counter to zero.
283/// 2. For each field inspected during the page-suppression scan,
284/// increment when [`exclude_bind_none_fields_from_page_data_suppression`]
285/// returns `ExcludedBindNone`.
286/// 3. After the scan, call [`emit_bind_none_summary`] with the count.
287///
288/// The summary fires only when at least one exclusion occurred — silence
289/// is the trace anchor for "rule did not fire on this document".
290pub fn emit_bind_none_summary(excluded_count: usize) {
291 if excluded_count == 0 {
292 return;
293 }
294 trace_sites::suppress(
295 Reason::BindNoneFieldExcludedFromDataCheck,
296 u32::try_from(excluded_count).unwrap_or(u32::MAX),
297 format!("excluded_count={excluded_count}"),
298 );
299}
300
301// ── M5.2 — StaticXfafExcessPageTrimWithFormDomGuard ────────────────────────
302//
303// The brief calls this rule "StaticXfafExcessPageTrimWithFormDomGuard". In
304// the registry it is named
305// `TrimStaticXfafExcessPagesWhenLayoutIsSinglePageAndFormDomAllows`
306// (already present, locked by the pinned-summary test). Same behaviour,
307// two names. The migration uses the existing registry RuleId so the
308// pinned summary stays stable.
309
310/// Result of the static-XFAF excess-trim rule.
311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
312pub struct StaticTrimDecision {
313 /// `true` when surplus host pages may be deleted from the host PDF
314 /// after layout; `false` when the form-DOM guard blocks the trim.
315 pub allow_trim: bool,
316 /// Whether the conservative "single-page collapse" relaxation
317 /// applies (only meaningful when `allow_trim` is true).
318 pub single_page_collapse: bool,
319 /// Stable rule identifier.
320 pub rule: AdobeCompatRuleId,
321}
322
323/// Apply rule `static_xfaf_excess_page_trim_with_form_dom_guard`.
324///
325/// Inputs:
326///
327/// - `is_static_form` — `baseProfile="interactiveForms"` was detected on
328/// the template (XFA-F §7.6).
329/// - `template_has_dynamic_logic` — template contains `<script>` or a
330/// FormCalc content type.
331/// - `n_layout` — number of pages the layout engine produced.
332/// - `form_dom_page_count` — `<pageArea>` count from the saved form DOM,
333/// or `None` if no form DOM is present.
334///
335/// Output:
336///
337/// - `allow_trim` — true iff `is_static_form` AND the form-DOM guard
338/// permits trimming AND (form DOM is absent OR `fdp <= n_layout` OR
339/// the single-page-collapse relaxation fires).
340///
341/// ## Trace anchor
342///
343/// - `(suppress, static_xfaf_trim_allowed)` when the rule allows the
344/// trim. The phase is `suppress` rather than `emit` because trimming
345/// removes pages from the output, matching the M1 trace vocabulary's
346/// page-suppression semantics. The decision string records which
347/// branch fired.
348/// - `(suppress, static_xfaf_trim_blocked)` when the form DOM (or the
349/// dynamic-template hint) blocks the trim. Useful for documenting that
350/// the rule was consulted and chose not to fire.
351///
352/// Silence (returned `allow_trim=false`) only occurs when the form is
353/// not static at all — the rule short-circuits without consulting any
354/// guard. That branch emits nothing because the rule is structurally
355/// inapplicable.
356///
357/// ## Behaviour preservation
358///
359/// The decision is bit-identical to the prior inline expression in
360/// `pdf_xfa::flatten`:
361///
362/// ```text
363/// let template_has_dynamic_logic = template_xml.contains("<script")
364/// || template_xml.contains(r#"contentType=\"application/x-formcalc\""#);
365/// let static_collapses_to_one_page =
366/// is_static_form && !template_has_dynamic_logic && n_layout == 1;
367/// let static_can_trim = is_static_form
368/// && match form_dom_pages {
369/// Some(fdp) => fdp <= n_layout || static_collapses_to_one_page,
370/// None => true,
371/// };
372/// ```
373pub fn static_xfaf_excess_page_trim_with_form_dom_guard(
374 is_static_form: bool,
375 template_has_dynamic_logic: bool,
376 n_layout: usize,
377 form_dom_page_count: Option<usize>,
378) -> StaticTrimDecision {
379 let rule_id =
380 AdobeCompatRuleId::TrimStaticXfafExcessPagesWhenLayoutIsSinglePageAndFormDomAllows;
381 if !is_static_form {
382 // Rule structurally inapplicable — not a static XFAF form.
383 return StaticTrimDecision {
384 allow_trim: false,
385 single_page_collapse: false,
386 rule: rule_id,
387 };
388 }
389 let single_page_collapse = !template_has_dynamic_logic && n_layout == 1;
390 // Phase 2 Wave 9 — `None`-arm hardened to `false`. Wave 7 changed
391 // the `None` arm from unconditional `true` to `n_layout == 1`
392 // because the M7.1 case `corpus_7dbbe9d9_static_xfaf_one_page`
393 // appeared to require trim when the layout collapsed to a single
394 // page. Wave 9 re-checked 7dbbe9d9 against the corpus manifest
395 // and the pdfRest oracle:
396 //
397 // - corpus manifest: `oracle_page_count = 2`, `page_count_input = 2`
398 // - pdfRest mirror : both `pdfrest_page-1.png` and
399 // `pdfrest_page-2.png` exist for 7dbbe9d9
400 //
401 // The M7.1 case was authored to pin the engine's then-current
402 // behaviour, not the oracle truth. Across **all four** static-
403 // XFAF docs that hit `form_dom == None && n_layout == 1`
404 // (01bf93cc, 0b86389a, 141f12df, 7dbbe9d9), pdfRest preserves
405 // host PDF pages 2..N. The "stale placeholder" assumption that
406 // motivated the Wave 7 single-page exception was empirically
407 // wrong.
408 //
409 // With no form DOM to corroborate trim intent, the safe default
410 // is to **preserve every host page**. The rule still trims when
411 // an explicit form DOM declares fewer pages than the host PDF
412 // carries (the `Some(fdp)` arm is unchanged).
413 //
414 // M7.1's `corpus_7dbbe9d9_static_xfaf_one_page` GateCase has been
415 // updated to align with the corpus manifest + pdfRest oracle:
416 // expected page count is now 2 and the rule expectation is
417 // `static_xfaf_trim_blocked must_fire=true` rather than `_allowed`.
418 let allow_trim = match form_dom_page_count {
419 Some(fdp) => fdp <= n_layout || single_page_collapse,
420 None => false,
421 };
422 let reason = if allow_trim {
423 Reason::StaticXfafTrimAllowed
424 } else {
425 Reason::StaticXfafTrimBlocked
426 };
427 trace_sites::suppress(
428 reason,
429 u32::try_from(n_layout).unwrap_or(u32::MAX),
430 format!(
431 "is_static={is_static_form} dynamic_logic={template_has_dynamic_logic} n_layout={n_layout} form_dom={form_dom_page_count:?} single_page_collapse={single_page_collapse} allow_trim={allow_trim}"
432 ),
433 );
434 StaticTrimDecision {
435 allow_trim,
436 single_page_collapse,
437 rule: rule_id,
438 }
439}
440
441// ── M5.2b — IgnoreInvisibleServerMetadataBindingsForDataBoundSignal ────────
442
443/// Decision returned by the invisible-binding rule.
444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445pub enum InvisibleBindingDecision {
446 /// Field is visible (or `presence` attribute is absent / unknown).
447 /// Binding sets the global `any_data_bound` signal.
448 AcceptedVisible,
449 /// Field's `presence` attribute is `invisible`, `hidden`, or
450 /// `inactive`. The binding does NOT set the global data-bound
451 /// signal — Acrobat treats such bindings as server-infrastructure
452 /// metadata, not real user input.
453 IgnoredInvisible,
454}
455
456/// Apply rule `ignore_invisible_server_metadata_bindings_for_data_bound_signal`.
457///
458/// Inputs:
459///
460/// - `presence_attr` — raw value of the XFA `presence` attribute. Empty
461/// string when the attribute is absent.
462///
463/// Output:
464///
465/// - `IgnoredInvisible` when `presence_attr` is one of
466/// `"invisible" | "hidden" | "inactive"`.
467/// - `AcceptedVisible` otherwise.
468///
469/// The function itself is silent on the trace channel; the per-field
470/// outcome is too chatty for a meaningful anchor. Callers accumulate a
471/// per-merge counter and call [`emit_invisible_binding_summary`] once
472/// `merge()` finishes. The summary is the rule's trace anchor.
473///
474/// ## Behaviour preservation
475///
476/// Equivalent to the inline expression in `pdf_xfa::merger::parse_field`:
477///
478/// ```text
479/// let presence = attr(elem, "presence").unwrap_or("");
480/// if !matches!(presence, "invisible" | "hidden" | "inactive") {
481/// self.form_tree.any_data_bound = true;
482/// }
483/// ```
484///
485/// The rule returns `AcceptedVisible` iff the inline `!matches!(…)`
486/// branch fires.
487pub fn ignore_invisible_server_metadata_bindings(presence_attr: &str) -> InvisibleBindingDecision {
488 if matches!(presence_attr, "invisible" | "hidden" | "inactive") {
489 InvisibleBindingDecision::IgnoredInvisible
490 } else {
491 InvisibleBindingDecision::AcceptedVisible
492 }
493}
494
495/// Emit the once-per-merge summary event for rule
496/// `IgnoreInvisibleServerMetadataBindingsForDataBoundSignal`.
497///
498/// Caller pattern (from `pdf_xfa::merger::FormMerger::merge`):
499///
500/// 1. Initialise a counter to zero.
501/// 2. For each field whose `presence` is checked during the merge,
502/// increment when [`ignore_invisible_server_metadata_bindings`]
503/// returns `IgnoredInvisible`.
504/// 3. Just before returning from `merge`, call
505/// [`emit_invisible_binding_summary`] with the count.
506///
507/// Silence on zero count means the rule did not fire.
508pub fn emit_invisible_binding_summary(ignored_count: usize) {
509 if ignored_count == 0 {
510 return;
511 }
512 trace_sites::bind(
513 "root",
514 Reason::InvisibleFieldBindingIgnored,
515 format!("ignored_count={ignored_count}"),
516 );
517}
518
519// ── M5.2b — ExcludeNonDataWidgetsFromPageSuppression ───────────────────────
520
521/// Classification under the non-data-widget rule.
522#[derive(Debug, Clone, Copy, PartialEq, Eq)]
523pub enum WidgetClassification {
524 /// Field carries a user-editable data value — counts as a data
525 /// field for page-suppression purposes.
526 DataField,
527 /// Field is a signature, button, or barcode widget — Acrobat
528 /// treats it as structural. Excluded from the per-page data-field
529 /// count so a signature-only page is never dropped as
530 /// "data-empty".
531 ExcludedNonDataWidget,
532}
533
534/// Apply rule `exclude_non_data_widgets_from_page_suppression`.
535///
536/// Inputs:
537///
538/// - `field_kind_is_non_data_widget` — `true` when the field's
539/// `FieldKind` is `Signature`, `Button`, or `Barcode`. Caller
540/// computes this boolean to avoid the rule taking a dependency on the
541/// `xfa-layout-engine::form::FieldKind` enum.
542///
543/// Output:
544///
545/// - `ExcludedNonDataWidget` when the field is a non-data widget.
546/// - `DataField` otherwise.
547///
548/// Silent per-call; caller emits the per-flatten summary via
549/// [`emit_non_data_widget_summary`].
550///
551/// ## Behaviour preservation
552///
553/// Equivalent to the inline expression in `pdf_xfa::flatten::page_has_fields`:
554///
555/// ```text
556/// matches!(meta.field_kind, FieldKind::Signature | FieldKind::Button | FieldKind::Barcode)
557/// ```
558///
559/// The rule's `ExcludedNonDataWidget` outcome corresponds to the
560/// inline expression being `true`.
561pub fn exclude_non_data_widgets_from_page_suppression(
562 field_kind_is_non_data_widget: bool,
563) -> WidgetClassification {
564 if field_kind_is_non_data_widget {
565 WidgetClassification::ExcludedNonDataWidget
566 } else {
567 WidgetClassification::DataField
568 }
569}
570
571/// Emit the once-per-flatten summary event for rule
572/// `ExcludeNonDataWidgetsFromPageSuppression`. Silent on zero count.
573pub fn emit_non_data_widget_summary(excluded_count: usize) {
574 if excluded_count == 0 {
575 return;
576 }
577 trace_sites::suppress(
578 Reason::NonDataWidgetExcludedFromDataCheck,
579 u32::try_from(excluded_count).unwrap_or(u32::MAX),
580 format!("excluded_count={excluded_count}"),
581 );
582}
583
584// ── M5.3 — RepeatingSubformInstanceCountClampedToOccurRange ────────────────
585
586/// Outcome of the occur-range clamp rule on a single repeating-subform
587/// expansion.
588#[derive(Debug, Clone, Copy, PartialEq, Eq)]
589pub enum OccurClampOutcome {
590 /// `data_count > occur.max` → instance count was reduced to `occur.max`.
591 ClampedByMax,
592 /// `data_count < occur.min` → instance count was raised to `occur.min`.
593 LiftedByMin,
594 /// `occur.min <= data_count <= occur.max` (or `occur.max` was
595 /// `None`/`-1` and `data_count >= occur.min`) — the rule returned
596 /// the data-driven count unchanged.
597 PassthroughInRange,
598}
599
600/// Apply rule `repeating_subform_instance_count_clamped_to_occur_range`.
601///
602/// Inputs:
603///
604/// - `data_count` — number of dataset records matched for the repeating
605/// subform (after SOM resolution).
606/// - `occur_min` — value of the template's `<occur min="…">` attribute
607/// (defaults to 1 per XFA §4.4.3).
608/// - `occur_max` — value of `<occur max="…">`. `None` represents
609/// `max="-1"` (unbounded); any concrete value is the cap.
610///
611/// Output:
612///
613/// - `(count, outcome)` — `count` is the final instance count to
614/// materialise. `outcome` reports which branch fired.
615///
616/// ## Trace anchor
617///
618/// Per-call. Reuses the pre-existing M1.5 occur-phase Reason tags:
619///
620/// - `(occur, data_count_clamped_by_occur_max)` when the cap fires.
621/// - `(occur, data_count_lifted_by_occur_min)` when the floor fires.
622/// - Silent when the rule passes the data count through unchanged.
623///
624/// Silence on the pass-through branch is intentional: the M1.5
625/// vocabulary has no "in-range" reason variant, and the rule's value
626/// signal is precisely when it deviates from the data-driven count.
627///
628/// ## Behaviour preservation
629///
630/// Bit-identical to the inline expression in `pdf_xfa::merger::expand_repeating_subform_instances`:
631///
632/// ```text
633/// let min = occur.min;
634/// let max = occur.max.unwrap_or(data_count).max(min);
635/// let count = data_count.clamp(min, max);
636/// ```
637///
638/// Regression guards `merger::tests::repeating_subform_clamps_to_occur_max`,
639/// `merger::tests::repeating_subform_unbounded_uses_data_count`, and
640/// `merger::tests::repeating_subform_respects_occur_min` continue to
641/// produce their original instance counts.
642pub fn repeating_subform_instance_count_clamped_to_occur_range(
643 som: &str,
644 data_count: u32,
645 occur_min: u32,
646 occur_max: Option<u32>,
647) -> (u32, OccurClampOutcome) {
648 let effective_max = occur_max.unwrap_or(data_count).max(occur_min);
649 let count = data_count.clamp(occur_min, effective_max);
650
651 let outcome = if data_count > effective_max {
652 OccurClampOutcome::ClampedByMax
653 } else if data_count < occur_min {
654 OccurClampOutcome::LiftedByMin
655 } else {
656 OccurClampOutcome::PassthroughInRange
657 };
658
659 match outcome {
660 OccurClampOutcome::ClampedByMax => {
661 trace_sites::occur(som, Reason::DataCountClampedByOccurMax, count as i64);
662 }
663 OccurClampOutcome::LiftedByMin => {
664 trace_sites::occur(som, Reason::DataCountLiftedByOccurMin, count as i64);
665 }
666 OccurClampOutcome::PassthroughInRange => {
667 // Silent on pass-through; the rule had no effect.
668 }
669 }
670
671 (count, outcome)
672}
673
674// ── M5.3b — BindNoneSubformDoesNotAutoExpand ───────────────────────────────
675
676/// Decision returned by the bind-none occur rule.
677#[derive(Debug, Clone, Copy, PartialEq, Eq)]
678pub enum BindNoneExpansionGate {
679 /// Subform has `<bind match="none">`. The rule blocks dataset-driven
680 /// expansion; the caller must keep the subform as a single template
681 /// instance.
682 Blocked,
683 /// Subform does not have `<bind match="none">`. The rule allows
684 /// dataset-driven expansion. Other gates (name empty, non-repeating
685 /// occur) may still cause the caller to skip expansion.
686 Allowed,
687}
688
689/// Apply rule `bind_none_subform_does_not_auto_expand`.
690///
691/// Inputs:
692///
693/// - `som` — SOM path / name of the subform being inspected (used as
694/// the trace event's `som` field).
695/// - `occur_is_repeating` — whether `<occur>` permits multiple
696/// instances (`max > 1` or `max = -1`).
697/// - `name_is_empty` — whether the subform's `name` attribute is empty.
698/// An unnamed subform is never auto-expanded regardless of this rule.
699/// - `bind_none` — whether the subform carries `<bind match="none">`.
700///
701/// Output:
702///
703/// - `Blocked` when `bind_none == true`; `Allowed` otherwise.
704///
705/// ## Trace anchor
706///
707/// `(occur, bind_none_subform_expansion_skipped)` fires per subform
708/// **only when the rule actively prevents expansion** that would
709/// otherwise have run — i.e. `bind_none && occur_is_repeating && !name_is_empty`.
710/// The rule stays silent when:
711///
712/// - `bind_none == false` (rule structurally inapplicable).
713/// - `bind_none == true` but `occur` is not repeating, or `name` is empty
714/// (rule applied but had no observable effect because the caller
715/// would have skipped expansion anyway).
716///
717/// ## Behaviour preservation
718///
719/// The caller's existing guard
720/// `if occur.is_repeating() && !name.is_empty() && !bind_none { … }`
721/// (`crates/pdf-xfa/src/merger.rs`, child-iteration loop) is replaced
722/// by `gate == Allowed && occur.is_repeating() && !name.is_empty()`.
723/// The defensive backstop inside `expand_repeating_subform_instances`
724/// (early-return when `bind_none == true`) is preserved unchanged; it
725/// can no longer fire on the standard call path, but the inside-out
726/// check remains as a hardening guard.
727pub fn bind_none_subform_does_not_auto_expand(
728 som: &str,
729 occur_is_repeating: bool,
730 name_is_empty: bool,
731 bind_none: bool,
732) -> BindNoneExpansionGate {
733 if bind_none {
734 if occur_is_repeating && !name_is_empty {
735 // The rule actively prevents an expansion that would have
736 // produced multiple instances. Emit the trace anchor.
737 trace_sites::occur(som, Reason::BindNoneSubformExpansionSkipped, 1);
738 }
739 BindNoneExpansionGate::Blocked
740 } else {
741 BindNoneExpansionGate::Allowed
742 }
743}
744
745// ── M5.3d — FormDomDrivenRepeatInstanceReplication ─────────────────────────
746
747/// Decision returned by the form-DOM-driven repeat replication rule.
748#[derive(Debug, Clone, Copy, PartialEq, Eq)]
749pub struct FormDomReplicationDecision {
750 /// Number of template instances to clone to match the form DOM's
751 /// recorded instance count. Zero when the form DOM does not request
752 /// additional instances (form DOM count `<=` template default).
753 pub clones_to_add: usize,
754}
755
756/// Apply rule `form_dom_driven_repeat_instance_replication`.
757///
758/// Inputs:
759///
760/// - `som` — SOM-style name / SOM path of the repeating subform group.
761/// - `form_dom_instance_count` — number of `<subform name="X">` siblings
762/// recorded in the saved form DOM for this name.
763/// - `template_default_count` — number of instances the template's
764/// initial expansion produced (data-driven count after `<occur>`
765/// clamping).
766///
767/// Output:
768///
769/// - `clones_to_add` = `max(0, form_dom_count - template_default_count)`
770/// when `template_default_count > 0`. Returns 0 when the template
771/// produced no instance at all (the caller's existing guard) or when
772/// the form DOM does not exceed the default count.
773///
774/// ## Trace anchor
775///
776/// `(occur, subform_materialised_from_data)` fires when the rule adds
777/// at least one clone, with `count` set to the *final* instance count
778/// (`form_dom_instance_count`). This reuses the existing M1.5 Reason
779/// tag — the rule reports each "instance materialised from form-DOM
780/// data" outcome under the canonical XFA Reason vocabulary. Silence
781/// on zero-clones is intentional: the rule had no value-add over the
782/// template default.
783///
784/// ## Behaviour preservation
785///
786/// Bit-identical to the inline expression in
787/// `pdf_xfa::flatten::apply_form_dom_presence`:
788///
789/// ```text
790/// if xml_count > existing_count && existing_count > 0 {
791/// let clones_needed = xml_count - existing_count;
792/// // … clone subtree clones_needed times …
793/// trace_sites::occur(gname, SubformMaterialisedFromData, xml_count);
794/// }
795/// ```
796///
797/// The rule wraps the `if`/`clones_needed`/trace block into a typed
798/// decision. The caller still owns the actual `clone_subtree` /
799/// `form_children.insert(…)` mutations.
800pub fn form_dom_driven_repeat_instance_replication(
801 som: &str,
802 form_dom_instance_count: usize,
803 template_default_count: usize,
804) -> FormDomReplicationDecision {
805 let clones_to_add =
806 if form_dom_instance_count > template_default_count && template_default_count > 0 {
807 form_dom_instance_count - template_default_count
808 } else {
809 0
810 };
811
812 if clones_to_add > 0 {
813 trace_sites::occur(
814 som,
815 Reason::SubformMaterialisedFromData,
816 form_dom_instance_count as i64,
817 );
818 }
819
820 FormDomReplicationDecision { clones_to_add }
821}
822
823#[cfg(test)]
824mod tests {
825 use super::*;
826 use std::rc::Rc;
827 use xfa_layout_engine::trace::{with_sink, RecordingSink};
828
829 #[test]
830 fn no_form_dom_is_uncapped() {
831 let decision = cap_suppression_by_form_dom(7, None);
832 assert_eq!(decision.max_suppress, usize::MAX);
833 assert_eq!(
834 decision.rule,
835 AdobeCompatRuleId::CapSuppressionByFormDomPageAreaCount
836 );
837 }
838
839 #[test]
840 fn form_dom_smaller_than_layout_caps_to_difference() {
841 // Positive example doc 322faac4: form DOM declares 17 pages, layout
842 // produced 7 — the cap fires at max_suppress = 17 - 7? No: the cap
843 // semantics are "form_dom_pages < layout_pages", so this branch is
844 // the inverse: form DOM declares fewer pages than layout.
845 let decision = cap_suppression_by_form_dom(10, Some(7));
846 assert_eq!(decision.max_suppress, 3);
847 }
848
849 #[test]
850 fn form_dom_at_or_above_layout_caps_to_zero() {
851 // The 322faac4 case: form DOM declares 17, layout produced 7 — the
852 // rule's "otherwise" branch fires and caps at 0 so suppression
853 // never reduces the page count further.
854 let decision = cap_suppression_by_form_dom(7, Some(17));
855 assert_eq!(decision.max_suppress, 0);
856 }
857
858 #[test]
859 fn form_dom_equal_to_layout_caps_to_zero() {
860 let decision = cap_suppression_by_form_dom(7, Some(7));
861 assert_eq!(decision.max_suppress, 0);
862 }
863
864 #[test]
865 fn trace_anchor_fires_when_form_dom_present() {
866 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
867 with_sink(sink.clone(), || {
868 let _ = cap_suppression_by_form_dom(7, Some(17));
869 });
870 let events = sink.events();
871 assert_eq!(events.len(), 1, "expected one trace event");
872 assert_eq!(events[0].phase.tag(), "suppress");
873 assert_eq!(events[0].reason.tag(), "suppress_capped_by_form_dom");
874 let decision = events[0].decision.as_deref().unwrap_or("");
875 assert!(
876 decision.contains("form_dom_pages=17"),
877 "decision should report form_dom_pages: {decision}"
878 );
879 assert!(
880 decision.contains("max_suppress=0"),
881 "decision should report max_suppress: {decision}"
882 );
883 }
884
885 #[test]
886 fn trace_anchor_silent_when_no_form_dom() {
887 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
888 with_sink(sink.clone(), || {
889 let _ = cap_suppression_by_form_dom(7, None);
890 });
891 assert!(
892 sink.events().is_empty(),
893 "rule must not emit when form DOM is absent"
894 );
895 }
896
897 // ── SuppressEmptyPagesOnlyWhenRealDataBound ────────────────────────
898
899 #[test]
900 fn suppress_preflight_allows_when_multi_page_and_data_bound() {
901 let d = suppress_empty_pages_only_when_real_data_bound(7, true);
902 assert!(d.run_suppression);
903 assert_eq!(
904 d.rule,
905 AdobeCompatRuleId::SuppressEmptyPagesOnlyWhenRealDataBound
906 );
907 }
908
909 #[test]
910 fn suppress_preflight_blocks_when_no_data_bound() {
911 // Bind-only template (no datasets) — XDP_BIND_NONE_… shape:
912 // suppression must not run even with multi-page layout.
913 let d = suppress_empty_pages_only_when_real_data_bound(7, false);
914 assert!(!d.run_suppression);
915 }
916
917 #[test]
918 fn suppress_preflight_blocks_when_single_page() {
919 let d = suppress_empty_pages_only_when_real_data_bound(1, true);
920 assert!(!d.run_suppression);
921 }
922
923 #[test]
924 fn suppress_preflight_trace_fires_on_both_branches() {
925 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
926 with_sink(sink.clone(), || {
927 let _ = suppress_empty_pages_only_when_real_data_bound(7, true);
928 let _ = suppress_empty_pages_only_when_real_data_bound(7, false);
929 });
930 let events = sink.events();
931 assert_eq!(events.len(), 2);
932 assert_eq!(
933 events[0].reason.tag(),
934 "suppress_gated_by_data_bound_signal"
935 );
936 assert_eq!(
937 events[1].reason.tag(),
938 "suppress_skipped_no_data_bound_signal"
939 );
940 }
941
942 // ── ExcludeBindNoneFieldsFromPageDataSuppression ───────────────────
943
944 #[test]
945 fn bind_none_classifies_regular_field_as_data() {
946 let c = exclude_bind_none_fields_from_page_data_suppression(true, false, false);
947 assert_eq!(c, BindNoneClassification::DataField);
948 }
949
950 #[test]
951 fn bind_none_classifies_bind_none_field_as_excluded() {
952 let c = exclude_bind_none_fields_from_page_data_suppression(true, false, true);
953 assert_eq!(c, BindNoneClassification::ExcludedBindNone);
954 }
955
956 #[test]
957 fn bind_none_classifies_non_data_widget_first() {
958 // Signature/button/barcode short-circuits before bind=none. The
959 // classification is `ExcludedNonDataWidget` regardless of
960 // `data_bind_none`.
961 let c = exclude_bind_none_fields_from_page_data_suppression(true, true, true);
962 assert_eq!(c, BindNoneClassification::ExcludedNonDataWidget);
963 }
964
965 #[test]
966 fn bind_none_summary_silent_when_count_zero() {
967 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
968 with_sink(sink.clone(), || {
969 emit_bind_none_summary(0);
970 });
971 assert!(
972 sink.events().is_empty(),
973 "summary must be silent when no field was excluded"
974 );
975 }
976
977 #[test]
978 fn bind_none_summary_fires_when_count_positive() {
979 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
980 with_sink(sink.clone(), || {
981 emit_bind_none_summary(3);
982 });
983 let events = sink.events();
984 assert_eq!(events.len(), 1);
985 assert_eq!(events[0].phase.tag(), "suppress");
986 assert_eq!(
987 events[0].reason.tag(),
988 "bind_none_field_excluded_from_data_check"
989 );
990 let decision = events[0].decision.as_deref().unwrap_or("");
991 assert!(
992 decision.contains("excluded_count=3"),
993 "summary must report exclusion count: {decision}"
994 );
995 }
996
997 // ── StaticXfafExcessPageTrimWithFormDomGuard ───────────────────────
998
999 #[test]
1000 fn static_trim_short_circuits_when_not_static() {
1001 let d = static_xfaf_excess_page_trim_with_form_dom_guard(
1002 /*is_static_form=*/ false, /*template_has_dynamic_logic=*/ false,
1003 /*n_layout=*/ 5, /*form_dom=*/ None,
1004 );
1005 assert!(!d.allow_trim);
1006 assert!(!d.single_page_collapse);
1007 }
1008
1009 #[test]
1010 fn static_trim_blocked_when_no_form_dom_present() {
1011 // Phase 2 Wave 9: when no form DOM is present, the rule must
1012 // never trim host pages — host PDF pages are authoritative
1013 // without a saved form DOM to corroborate trim intent. The
1014 // function still emits the `static_xfaf_trim_blocked` trace
1015 // anchor so consumers can prove the rule was consulted.
1016 //
1017 // Replaces Wave 7's `n_layout == 1` floor. Wave 7's exception
1018 // was authored to preserve M7.1's
1019 // `corpus_7dbbe9d9_static_xfaf_one_page` expectation
1020 // (expected_pages=1, _allowed must_fire=true). Wave 9
1021 // re-checked 7dbbe9d9 against the corpus manifest and the
1022 // pdfRest oracle (both say 2 pages) and concluded the M7.1
1023 // expectation was misaligned with the oracle truth; the
1024 // M7.1 GateCase has been updated to expected_pages=2 +
1025 // _blocked must_fire=true.
1026 for n_layout in [1_usize, 2, 3, 5, 11] {
1027 let d_static =
1028 static_xfaf_excess_page_trim_with_form_dom_guard(true, false, n_layout, None);
1029 assert!(
1030 !d_static.allow_trim,
1031 "n_layout={n_layout} (static, no form DOM): expected no trim, got allow_trim=true"
1032 );
1033 let d_dynamic =
1034 static_xfaf_excess_page_trim_with_form_dom_guard(true, true, n_layout, None);
1035 assert!(
1036 !d_dynamic.allow_trim,
1037 "n_layout={n_layout} (dynamic-logic substring, no form DOM): expected no trim"
1038 );
1039 }
1040 }
1041
1042 #[test]
1043 fn static_trim_allows_when_form_dom_matches_or_under() {
1044 // Form DOM declares <= n_layout pages — trim is safe.
1045 let d = static_xfaf_excess_page_trim_with_form_dom_guard(true, false, 3, Some(2));
1046 assert!(d.allow_trim);
1047 }
1048
1049 #[test]
1050 fn static_trim_blocked_when_form_dom_demands_more_pages() {
1051 // 322faac4 shape: form DOM declares 17, layout produced 7. The
1052 // form DOM is authoritative; trim must not fire.
1053 let d = static_xfaf_excess_page_trim_with_form_dom_guard(true, false, 7, Some(17));
1054 assert!(!d.allow_trim);
1055 }
1056
1057 #[test]
1058 fn static_trim_single_page_collapse_relaxation() {
1059 // fe5de953 shape: form DOM declares 2 pages, layout collapses to 1,
1060 // no dynamic logic in template — the narrow one-page-collapse
1061 // relaxation allows trim.
1062 let d = static_xfaf_excess_page_trim_with_form_dom_guard(true, false, 1, Some(2));
1063 assert!(d.allow_trim);
1064 assert!(d.single_page_collapse);
1065 }
1066
1067 #[test]
1068 fn static_trim_single_page_collapse_blocked_by_dynamic_logic() {
1069 // Single-page layout but template has dynamic logic (script /
1070 // FormCalc): the relaxation does NOT apply, and the form DOM's
1071 // higher page count blocks the trim.
1072 let d = static_xfaf_excess_page_trim_with_form_dom_guard(true, true, 1, Some(2));
1073 assert!(!d.allow_trim);
1074 }
1075
1076 #[test]
1077 fn static_trim_trace_anchor_fires_on_allow_and_block() {
1078 // After Wave 9 the `None` arm always blocks, so an "allow"
1079 // case must come from a `Some(fdp)` where `fdp <= n_layout`.
1080 // The "block" case stays as `Some(fdp) where fdp > n_layout`.
1081 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1082 with_sink(sink.clone(), || {
1083 let _ = static_xfaf_excess_page_trim_with_form_dom_guard(true, false, 7, Some(2));
1084 let _ = static_xfaf_excess_page_trim_with_form_dom_guard(true, false, 7, Some(17));
1085 });
1086 let events = sink.events();
1087 assert_eq!(events.len(), 2);
1088 assert_eq!(events[0].reason.tag(), "static_xfaf_trim_allowed");
1089 assert_eq!(events[1].reason.tag(), "static_xfaf_trim_blocked");
1090 }
1091
1092 #[test]
1093 fn static_trim_silent_when_rule_inapplicable() {
1094 // Not a static form — rule short-circuits without emitting.
1095 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1096 with_sink(sink.clone(), || {
1097 let _ = static_xfaf_excess_page_trim_with_form_dom_guard(false, false, 3, Some(7));
1098 });
1099 assert!(sink.events().is_empty());
1100 }
1101
1102 // ── IgnoreInvisibleServerMetadataBindingsForDataBoundSignal ────────
1103
1104 #[test]
1105 fn invisible_binding_accepts_visible() {
1106 // Empty `presence` attribute → visible by default.
1107 assert_eq!(
1108 ignore_invisible_server_metadata_bindings(""),
1109 InvisibleBindingDecision::AcceptedVisible
1110 );
1111 // Explicit visible.
1112 assert_eq!(
1113 ignore_invisible_server_metadata_bindings("visible"),
1114 InvisibleBindingDecision::AcceptedVisible
1115 );
1116 // Unknown value falls through as visible — matches the inline
1117 // expression's `!matches!(…)` behaviour.
1118 assert_eq!(
1119 ignore_invisible_server_metadata_bindings("future-value"),
1120 InvisibleBindingDecision::AcceptedVisible
1121 );
1122 }
1123
1124 #[test]
1125 fn invisible_binding_ignores_invisible_hidden_inactive() {
1126 for presence in ["invisible", "hidden", "inactive"] {
1127 assert_eq!(
1128 ignore_invisible_server_metadata_bindings(presence),
1129 InvisibleBindingDecision::IgnoredInvisible,
1130 "presence={presence} must be IgnoredInvisible"
1131 );
1132 }
1133 }
1134
1135 #[test]
1136 fn invisible_binding_rule_per_call_silent() {
1137 // Per-field calls do not emit; only the summary helper does.
1138 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1139 with_sink(sink.clone(), || {
1140 let _ = ignore_invisible_server_metadata_bindings("invisible");
1141 let _ = ignore_invisible_server_metadata_bindings("visible");
1142 });
1143 assert!(
1144 sink.events().is_empty(),
1145 "per-field decisions must not emit; summary helper is the trace anchor"
1146 );
1147 }
1148
1149 #[test]
1150 fn invisible_binding_summary_silent_on_zero() {
1151 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1152 with_sink(sink.clone(), || {
1153 emit_invisible_binding_summary(0);
1154 });
1155 assert!(sink.events().is_empty());
1156 }
1157
1158 #[test]
1159 fn invisible_binding_summary_fires_on_positive() {
1160 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1161 with_sink(sink.clone(), || {
1162 emit_invisible_binding_summary(4);
1163 });
1164 let events = sink.events();
1165 assert_eq!(events.len(), 1);
1166 assert_eq!(events[0].phase.tag(), "bind");
1167 assert_eq!(events[0].reason.tag(), "invisible_field_binding_ignored");
1168 assert!(events[0]
1169 .decision
1170 .as_deref()
1171 .unwrap_or("")
1172 .contains("ignored_count=4"));
1173 }
1174
1175 // ── ExcludeNonDataWidgetsFromPageSuppression ───────────────────────
1176
1177 #[test]
1178 fn non_data_widget_classifies_data_field() {
1179 let c = exclude_non_data_widgets_from_page_suppression(false);
1180 assert_eq!(c, WidgetClassification::DataField);
1181 }
1182
1183 #[test]
1184 fn non_data_widget_classifies_widget() {
1185 let c = exclude_non_data_widgets_from_page_suppression(true);
1186 assert_eq!(c, WidgetClassification::ExcludedNonDataWidget);
1187 }
1188
1189 #[test]
1190 fn non_data_widget_per_call_silent() {
1191 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1192 with_sink(sink.clone(), || {
1193 let _ = exclude_non_data_widgets_from_page_suppression(true);
1194 let _ = exclude_non_data_widgets_from_page_suppression(false);
1195 });
1196 assert!(sink.events().is_empty());
1197 }
1198
1199 #[test]
1200 fn non_data_widget_summary_silent_on_zero() {
1201 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1202 with_sink(sink.clone(), || {
1203 emit_non_data_widget_summary(0);
1204 });
1205 assert!(sink.events().is_empty());
1206 }
1207
1208 #[test]
1209 fn non_data_widget_summary_fires_on_positive() {
1210 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1211 with_sink(sink.clone(), || {
1212 emit_non_data_widget_summary(2);
1213 });
1214 let events = sink.events();
1215 assert_eq!(events.len(), 1);
1216 assert_eq!(events[0].phase.tag(), "suppress");
1217 assert_eq!(
1218 events[0].reason.tag(),
1219 "non_data_widget_excluded_from_data_check"
1220 );
1221 assert!(events[0]
1222 .decision
1223 .as_deref()
1224 .unwrap_or("")
1225 .contains("excluded_count=2"));
1226 }
1227
1228 // ── RepeatingSubformInstanceCountClampedToOccurRange ───────────────
1229
1230 #[test]
1231 fn occur_clamp_passthrough_when_in_range_with_bounded_max() {
1232 // 2 data records, min=0, max=5 → in range, passthrough.
1233 let (count, outcome) = repeating_subform_instance_count_clamped_to_occur_range(
1234 "form1.Orders.Order",
1235 2,
1236 0,
1237 Some(5),
1238 );
1239 assert_eq!(count, 2);
1240 assert_eq!(outcome, OccurClampOutcome::PassthroughInRange);
1241 }
1242
1243 #[test]
1244 fn occur_clamp_passthrough_when_unbounded() {
1245 // 5 data records, min=0, max=None (unbounded) → passthrough.
1246 let (count, outcome) =
1247 repeating_subform_instance_count_clamped_to_occur_range("Orders.Order", 5, 0, None);
1248 assert_eq!(count, 5);
1249 assert_eq!(outcome, OccurClampOutcome::PassthroughInRange);
1250 }
1251
1252 #[test]
1253 fn occur_clamp_caps_when_data_exceeds_max() {
1254 // 5 data records, min=0, max=2 → ClampedByMax, count=2.
1255 let (count, outcome) =
1256 repeating_subform_instance_count_clamped_to_occur_range("Orders.Order", 5, 0, Some(2));
1257 assert_eq!(count, 2);
1258 assert_eq!(outcome, OccurClampOutcome::ClampedByMax);
1259 }
1260
1261 #[test]
1262 fn occur_clamp_lifts_when_data_below_min() {
1263 // 1 data record, min=3, max=None → LiftedByMin, count=3.
1264 let (count, outcome) =
1265 repeating_subform_instance_count_clamped_to_occur_range("Orders.Order", 1, 3, None);
1266 assert_eq!(count, 3);
1267 assert_eq!(outcome, OccurClampOutcome::LiftedByMin);
1268 }
1269
1270 #[test]
1271 fn occur_clamp_lifts_when_data_zero_and_min_one() {
1272 // 0 data records, min=1, max=None → LiftedByMin, count=1.
1273 let (count, outcome) =
1274 repeating_subform_instance_count_clamped_to_occur_range("Orders.Order", 0, 1, None);
1275 assert_eq!(count, 1);
1276 assert_eq!(outcome, OccurClampOutcome::LiftedByMin);
1277 }
1278
1279 #[test]
1280 fn occur_clamp_max_below_min_still_honours_min() {
1281 // Pathological template: occur.max=0 but data_count=1 and min=1.
1282 // The effective_max is `max.unwrap_or(data_count).max(min)` =
1283 // `0.max(1)` = 1, so the count stays at 1 and the rule classifies
1284 // the input as PassthroughInRange (data_count is within
1285 // [min, effective_max]).
1286 let (count, outcome) =
1287 repeating_subform_instance_count_clamped_to_occur_range("Orders.Order", 1, 1, Some(0));
1288 assert_eq!(count, 1);
1289 assert_eq!(outcome, OccurClampOutcome::PassthroughInRange);
1290 }
1291
1292 #[test]
1293 fn occur_clamp_trace_anchor_fires_clamped_by_max() {
1294 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1295 with_sink(sink.clone(), || {
1296 let _ = repeating_subform_instance_count_clamped_to_occur_range(
1297 "Orders.Order",
1298 5,
1299 0,
1300 Some(2),
1301 );
1302 });
1303 let events = sink.events();
1304 assert_eq!(events.len(), 1);
1305 assert_eq!(events[0].phase.tag(), "occur");
1306 assert_eq!(events[0].reason.tag(), "data_count_clamped_by_occur_max");
1307 assert_eq!(events[0].som.as_deref(), Some("Orders.Order"));
1308 assert!(events[0].input.as_deref().unwrap_or("").contains("count=2"));
1309 }
1310
1311 #[test]
1312 fn occur_clamp_trace_anchor_fires_lifted_by_min() {
1313 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1314 with_sink(sink.clone(), || {
1315 let _ =
1316 repeating_subform_instance_count_clamped_to_occur_range("Orders.Order", 1, 3, None);
1317 });
1318 let events = sink.events();
1319 assert_eq!(events.len(), 1);
1320 assert_eq!(events[0].reason.tag(), "data_count_lifted_by_occur_min");
1321 assert!(events[0].input.as_deref().unwrap_or("").contains("count=3"));
1322 }
1323
1324 #[test]
1325 fn occur_clamp_trace_silent_on_passthrough() {
1326 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1327 with_sink(sink.clone(), || {
1328 let _ = repeating_subform_instance_count_clamped_to_occur_range(
1329 "Orders.Order",
1330 2,
1331 0,
1332 Some(5),
1333 );
1334 let _ =
1335 repeating_subform_instance_count_clamped_to_occur_range("Orders.Order", 3, 0, None);
1336 });
1337 assert!(
1338 sink.events().is_empty(),
1339 "rule must be silent when data count is in range and not clamped"
1340 );
1341 }
1342
1343 // ── BindNoneSubformDoesNotAutoExpand ───────────────────────────────
1344
1345 #[test]
1346 fn bind_none_allows_when_bind_not_none() {
1347 let g = bind_none_subform_does_not_auto_expand("Orders.Order", true, false, false);
1348 assert_eq!(g, BindNoneExpansionGate::Allowed);
1349 }
1350
1351 #[test]
1352 fn bind_none_blocks_when_bind_none() {
1353 let g = bind_none_subform_does_not_auto_expand("Orders.Order", true, false, true);
1354 assert_eq!(g, BindNoneExpansionGate::Blocked);
1355 }
1356
1357 #[test]
1358 fn bind_none_blocks_even_when_unnamed() {
1359 // The rule still returns Blocked structurally; the caller's
1360 // structural gates (name empty, occur not repeating) are
1361 // separate concerns. The rule's outcome is only "is the
1362 // subform bind=none, yes or no".
1363 let g = bind_none_subform_does_not_auto_expand("Orders.Order", true, true, true);
1364 assert_eq!(g, BindNoneExpansionGate::Blocked);
1365 }
1366
1367 #[test]
1368 fn bind_none_trace_fires_only_when_expansion_would_have_run() {
1369 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1370 with_sink(sink.clone(), || {
1371 // Repeating + named + bind=none → rule actively blocks.
1372 let _ = bind_none_subform_does_not_auto_expand("Orders.Order", true, false, true);
1373 });
1374 let events = sink.events();
1375 assert_eq!(events.len(), 1);
1376 assert_eq!(events[0].phase.tag(), "occur");
1377 assert_eq!(
1378 events[0].reason.tag(),
1379 "bind_none_subform_expansion_skipped"
1380 );
1381 assert_eq!(events[0].som.as_deref(), Some("Orders.Order"));
1382 }
1383
1384 #[test]
1385 fn bind_none_trace_silent_when_bind_not_none() {
1386 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1387 with_sink(sink.clone(), || {
1388 let _ = bind_none_subform_does_not_auto_expand("Orders.Order", true, false, false);
1389 });
1390 assert!(sink.events().is_empty());
1391 }
1392
1393 #[test]
1394 fn bind_none_trace_silent_when_occur_not_repeating() {
1395 // bind=none but non-repeating occur → rule applies structurally
1396 // but had no observable effect (caller would have skipped
1397 // expansion anyway). Silent.
1398 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1399 with_sink(sink.clone(), || {
1400 let _ = bind_none_subform_does_not_auto_expand("Orders.Order", false, false, true);
1401 });
1402 assert!(sink.events().is_empty());
1403 }
1404
1405 #[test]
1406 fn bind_none_trace_silent_when_name_empty() {
1407 // bind=none on unnamed subform → caller skipped anyway. Silent.
1408 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1409 with_sink(sink.clone(), || {
1410 let _ = bind_none_subform_does_not_auto_expand("", true, true, true);
1411 });
1412 assert!(sink.events().is_empty());
1413 }
1414
1415 // ── FormDomDrivenRepeatInstanceReplication ─────────────────────────
1416
1417 #[test]
1418 fn form_dom_replication_clones_difference_when_form_dom_higher() {
1419 let d = form_dom_driven_repeat_instance_replication("Orders.Order", 5, 2);
1420 assert_eq!(d.clones_to_add, 3);
1421 }
1422
1423 #[test]
1424 fn form_dom_replication_zero_when_equal() {
1425 let d = form_dom_driven_repeat_instance_replication("Orders.Order", 3, 3);
1426 assert_eq!(d.clones_to_add, 0);
1427 }
1428
1429 #[test]
1430 fn form_dom_replication_zero_when_form_dom_smaller() {
1431 let d = form_dom_driven_repeat_instance_replication("Orders.Order", 1, 4);
1432 assert_eq!(d.clones_to_add, 0);
1433 }
1434
1435 #[test]
1436 fn form_dom_replication_zero_when_template_default_is_zero() {
1437 // Mirrors the existing caller guard `existing_count > 0`. When
1438 // the template produced zero instances (e.g. a bind=none
1439 // subform handled by another rule), the form-DOM replication
1440 // rule must not fire because there is no template to clone.
1441 let d = form_dom_driven_repeat_instance_replication("Orders.Order", 5, 0);
1442 assert_eq!(d.clones_to_add, 0);
1443 }
1444
1445 #[test]
1446 fn form_dom_replication_trace_fires_on_positive_clones() {
1447 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1448 with_sink(sink.clone(), || {
1449 let _ = form_dom_driven_repeat_instance_replication("Orders.Order", 5, 2);
1450 });
1451 let events = sink.events();
1452 assert_eq!(events.len(), 1);
1453 assert_eq!(events[0].phase.tag(), "occur");
1454 assert_eq!(events[0].reason.tag(), "subform_materialised_from_data");
1455 assert_eq!(events[0].som.as_deref(), Some("Orders.Order"));
1456 // The trace event reports the final instance count (form DOM
1457 // count), not just the number of clones added — matches the
1458 // UX1 wire pattern.
1459 assert!(events[0].input.as_deref().unwrap_or("").contains("count=5"));
1460 }
1461
1462 #[test]
1463 fn form_dom_replication_trace_silent_on_zero_clones() {
1464 let sink: Rc<RecordingSink> = Rc::new(RecordingSink::new());
1465 with_sink(sink.clone(), || {
1466 let _ = form_dom_driven_repeat_instance_replication("Orders.Order", 3, 3);
1467 let _ = form_dom_driven_repeat_instance_replication("Orders.Order", 1, 4);
1468 let _ = form_dom_driven_repeat_instance_replication("Orders.Order", 5, 0);
1469 });
1470 assert!(
1471 sink.events().is_empty(),
1472 "rule must stay silent when no clones are required"
1473 );
1474 }
1475}