Skip to main content

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}