Skip to main content

ftui_text/
layout_policy.rs

1//! User-facing layout policy presets and deterministic fallback contract.
2//!
3//! This module ties together the three layout subsystems:
4//! - [`super::wrap::ParagraphObjective`] — Knuth-Plass line-break tuning
5//! - [`super::vertical_metrics::VerticalPolicy`] — leading, baseline grid, paragraph spacing
6//! - [`super::justification::JustificationControl`] — stretch/shrink/spacing modulation
7//!
8//! It provides three named quality tiers (`Quality`, `Balanced`, `Fast`) and a
9//! deterministic fallback contract that maps runtime capabilities to the
10//! highest achievable tier.
11//!
12//! # Fallback semantics
13//!
14//! Given a [`RuntimeCapability`] descriptor, [`LayoutPolicy::resolve`] returns
15//! a fully-resolved [`ResolvedPolicy`] that may have been degraded from the
16//! requested tier. The degradation path is:
17//!
18//! ```text
19//!   Quality → Balanced → Fast
20//! ```
21//!
22//! Each step disables features that require capabilities not available at
23//! runtime (e.g., proportional fonts, sub-pixel rendering, hyphenation dict).
24
25use std::fmt;
26
27use crate::justification::{JustificationControl, JustifyMode};
28use crate::vertical_metrics::{VerticalMetrics, VerticalPolicy};
29use crate::wrap::ParagraphObjective;
30
31// =========================================================================
32// LayoutTier
33// =========================================================================
34
35/// Named quality tiers for text layout.
36///
37/// Higher tiers produce better output but require more computation and
38/// richer runtime capabilities (proportional fonts, sub-pixel positioning).
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
40pub enum LayoutTier {
41    /// Minimal: greedy wrapping, no justification, no leading.
42    /// Suitable for raw terminal output where every cell counts.
43    Fast = 0,
44    /// Moderate: optimal line-breaking, French spacing, moderate leading.
45    /// Good default for terminal UIs with readable text.
46    #[default]
47    Balanced = 1,
48    /// Full: TeX-class typography with baseline grid, microtypographic
49    /// justification, hyphenation, and fine-grained spacing.
50    Quality = 2,
51}
52
53impl LayoutTier {
54    /// The tier one step below, or `None` if already at `Fast`.
55    #[must_use]
56    pub const fn degrade(&self) -> Option<Self> {
57        match self {
58            Self::Quality => Some(Self::Balanced),
59            Self::Balanced => Some(Self::Fast),
60            Self::Fast => None,
61        }
62    }
63
64    /// All tiers from this one down to Fast (inclusive), in degradation order.
65    #[must_use]
66    pub fn degradation_chain(&self) -> Vec<Self> {
67        let mut chain = vec![*self];
68        let mut current = *self;
69        while let Some(next) = current.degrade() {
70            chain.push(next);
71            current = next;
72        }
73        chain
74    }
75}
76
77impl fmt::Display for LayoutTier {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        match self {
80            Self::Fast => write!(f, "fast"),
81            Self::Balanced => write!(f, "balanced"),
82            Self::Quality => write!(f, "quality"),
83        }
84    }
85}
86
87// =========================================================================
88// RuntimeCapability
89// =========================================================================
90
91/// Descriptor of available runtime capabilities.
92///
93/// The fallback logic inspects these flags to determine which features
94/// can be activated at a given tier.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
96pub struct RuntimeCapability {
97    /// Whether proportional (variable-width) fonts are available.
98    /// If false, all justification stretch/shrink is meaningless.
99    pub proportional_fonts: bool,
100
101    /// Whether sub-pixel positioning is available (e.g., WebGPU renderer).
102    /// If false, baseline grid snapping and sub-cell glue are inert.
103    pub subpixel_positioning: bool,
104
105    /// Whether a hyphenation dictionary is loaded.
106    /// If false, hyphenation break points are not generated.
107    pub hyphenation_available: bool,
108
109    /// Whether inter-character spacing (tracking) is supported by the renderer.
110    pub tracking_support: bool,
111
112    /// Maximum paragraph length (in words) that can be processed by the
113    /// optimal breaker within the frame budget. 0 = unlimited.
114    pub max_paragraph_words: usize,
115}
116
117impl RuntimeCapability {
118    /// Full capabilities: everything available.
119    pub const FULL: Self = Self {
120        proportional_fonts: true,
121        subpixel_positioning: true,
122        hyphenation_available: true,
123        tracking_support: true,
124        max_paragraph_words: 0,
125    };
126
127    /// Terminal-only: monospace, no sub-pixel, no hyphenation.
128    pub const TERMINAL: Self = Self {
129        proportional_fonts: false,
130        subpixel_positioning: false,
131        hyphenation_available: false,
132        tracking_support: false,
133        max_paragraph_words: 0,
134    };
135
136    /// Web renderer: proportional fonts but potentially limited tracking.
137    pub const WEB: Self = Self {
138        proportional_fonts: true,
139        subpixel_positioning: true,
140        hyphenation_available: true,
141        tracking_support: false,
142        max_paragraph_words: 0,
143    };
144
145    /// Check if the given tier's features are supportable.
146    #[must_use]
147    pub fn supports_tier(&self, tier: LayoutTier) -> bool {
148        match tier {
149            LayoutTier::Fast => true,     // Always supportable
150            LayoutTier::Balanced => true, // Works in monospace too (just less impactful)
151            LayoutTier::Quality => {
152                // Quality requires proportional fonts for meaningful justification
153                self.proportional_fonts
154            }
155        }
156    }
157
158    /// Find the highest tier this capability set can support.
159    #[must_use]
160    pub fn best_tier(&self) -> LayoutTier {
161        if self.supports_tier(LayoutTier::Quality) {
162            LayoutTier::Quality
163        } else if self.supports_tier(LayoutTier::Balanced) {
164            LayoutTier::Balanced
165        } else {
166            LayoutTier::Fast
167        }
168    }
169}
170
171impl fmt::Display for RuntimeCapability {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        write!(
174            f,
175            "proportional={} subpixel={} hyphen={} tracking={}",
176            self.proportional_fonts,
177            self.subpixel_positioning,
178            self.hyphenation_available,
179            self.tracking_support
180        )
181    }
182}
183
184// =========================================================================
185// LayoutPolicy
186// =========================================================================
187
188/// User-facing layout policy configuration.
189///
190/// Combines a desired tier with optional overrides. Call [`resolve`] with
191/// a [`RuntimeCapability`] to get a fully-resolved configuration.
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
193pub struct LayoutPolicy {
194    /// Desired quality tier.
195    pub tier: LayoutTier,
196    /// If true, allow automatic degradation when capabilities are
197    /// insufficient. If false, resolution fails with an error.
198    pub allow_degradation: bool,
199    /// Override the justify mode (ignoring the tier's default).
200    pub justify_override: Option<JustifyMode>,
201    /// Override the vertical policy (ignoring the tier's default).
202    pub vertical_override: Option<VerticalPolicy>,
203    /// Line height in sub-pixel units (1/256 px) for vertical metrics.
204    /// 0 = use default (16px = 4096 subpx).
205    pub line_height_subpx: u32,
206}
207
208/// Default line height: 16px in sub-pixel units.
209const DEFAULT_LINE_HEIGHT_SUBPX: u32 = 16 * 256;
210
211impl LayoutPolicy {
212    /// Quick preset: terminal-optimized.
213    pub const FAST: Self = Self {
214        tier: LayoutTier::Fast,
215        allow_degradation: true,
216        justify_override: None,
217        vertical_override: None,
218        line_height_subpx: 0,
219    };
220
221    /// Balanced preset: good for general use.
222    pub const BALANCED: Self = Self {
223        tier: LayoutTier::Balanced,
224        allow_degradation: true,
225        justify_override: None,
226        vertical_override: None,
227        line_height_subpx: 0,
228    };
229
230    /// Quality preset: best output.
231    pub const QUALITY: Self = Self {
232        tier: LayoutTier::Quality,
233        allow_degradation: true,
234        justify_override: None,
235        vertical_override: None,
236        line_height_subpx: 0,
237    };
238
239    /// The effective line height (using default if unset).
240    #[must_use]
241    pub const fn effective_line_height(&self) -> u32 {
242        if self.line_height_subpx == 0 {
243            DEFAULT_LINE_HEIGHT_SUBPX
244        } else {
245            self.line_height_subpx
246        }
247    }
248
249    /// Resolve this policy against runtime capabilities.
250    ///
251    /// Returns a fully-resolved configuration, potentially degraded to
252    /// a lower tier if capabilities are insufficient.
253    ///
254    /// # Errors
255    ///
256    /// Returns `PolicyError::CapabilityInsufficient` if `allow_degradation`
257    /// is false and the requested tier cannot be supported.
258    pub fn resolve(&self, caps: &RuntimeCapability) -> Result<ResolvedPolicy, PolicyError> {
259        let mut effective_tier = self.tier;
260
261        // Degrade if necessary
262        if !caps.supports_tier(effective_tier) {
263            if self.allow_degradation {
264                effective_tier = caps.best_tier();
265            } else {
266                return Err(PolicyError::CapabilityInsufficient {
267                    requested: self.tier,
268                    best_available: caps.best_tier(),
269                });
270            }
271        }
272
273        let line_h = self.effective_line_height();
274
275        // Build the three subsystem configs from the effective tier
276        let objective = match effective_tier {
277            LayoutTier::Fast => ParagraphObjective::terminal(),
278            LayoutTier::Balanced => ParagraphObjective::default(),
279            LayoutTier::Quality => ParagraphObjective::typographic(),
280        };
281
282        let vertical_policy = self.vertical_override.unwrap_or(match effective_tier {
283            LayoutTier::Fast => VerticalPolicy::Compact,
284            LayoutTier::Balanced => VerticalPolicy::Readable,
285            LayoutTier::Quality => VerticalPolicy::Typographic,
286        });
287
288        let vertical = vertical_policy.resolve(line_h);
289
290        let mut justification = match effective_tier {
291            LayoutTier::Fast => JustificationControl::TERMINAL,
292            LayoutTier::Balanced => JustificationControl::READABLE,
293            LayoutTier::Quality => JustificationControl::TYPOGRAPHIC,
294        };
295
296        // Apply justify mode override
297        if let Some(mode) = self.justify_override {
298            justification.mode = mode;
299        }
300
301        // Capability-driven adjustments
302        if !caps.tracking_support {
303            // Disable inter-character glue if renderer can't do it
304            justification.char_space = crate::justification::GlueSpec::rigid(0);
305        }
306
307        if !caps.proportional_fonts {
308            // Monospace: make all spaces rigid (1 cell)
309            justification.word_space =
310                crate::justification::GlueSpec::rigid(crate::justification::SUBCELL_SCALE);
311            justification.sentence_space =
312                crate::justification::GlueSpec::rigid(crate::justification::SUBCELL_SCALE);
313            justification.char_space = crate::justification::GlueSpec::rigid(0);
314        }
315
316        let degraded = effective_tier != self.tier;
317
318        Ok(ResolvedPolicy {
319            requested_tier: self.tier,
320            effective_tier,
321            degraded,
322            objective,
323            vertical,
324            justification,
325            use_hyphenation: caps.hyphenation_available && effective_tier >= LayoutTier::Balanced,
326            use_optimal_breaking: effective_tier >= LayoutTier::Balanced,
327            line_height_subpx: line_h,
328        })
329    }
330}
331
332impl Default for LayoutPolicy {
333    fn default() -> Self {
334        Self::BALANCED
335    }
336}
337
338impl fmt::Display for LayoutPolicy {
339    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
340        write!(f, "tier={} degrade={}", self.tier, self.allow_degradation)
341    }
342}
343
344// =========================================================================
345// ResolvedPolicy
346// =========================================================================
347
348/// Fully-resolved layout configuration ready for use by the layout engine.
349///
350/// All three subsystem configs are populated and compatible with the
351/// available runtime capabilities.
352#[derive(Debug, Clone, PartialEq)]
353pub struct ResolvedPolicy {
354    /// What the user originally requested.
355    pub requested_tier: LayoutTier,
356    /// What was actually activated (may differ if degraded).
357    pub effective_tier: LayoutTier,
358    /// Whether degradation occurred.
359    pub degraded: bool,
360
361    /// Knuth-Plass line-break tuning.
362    pub objective: ParagraphObjective,
363    /// Resolved vertical metrics (leading, spacing, grid).
364    pub vertical: VerticalMetrics,
365    /// Justification controls (stretch/shrink/penalties).
366    pub justification: JustificationControl,
367
368    /// Whether hyphenation should be used for line breaking.
369    pub use_hyphenation: bool,
370    /// Whether optimal (Knuth-Plass) breaking should be used.
371    /// False = greedy wrapping.
372    pub use_optimal_breaking: bool,
373    /// Line height in sub-pixel units.
374    pub line_height_subpx: u32,
375}
376
377impl ResolvedPolicy {
378    /// Whether justification (space stretching) is active.
379    #[must_use]
380    pub fn is_justified(&self) -> bool {
381        self.justification.mode.requires_justification()
382    }
383
384    /// Human-readable summary of what features are active.
385    #[must_use]
386    pub fn feature_summary(&self) -> Vec<&'static str> {
387        let mut features = Vec::new();
388
389        if self.use_optimal_breaking {
390            features.push("optimal-breaking");
391        } else {
392            features.push("greedy-wrapping");
393        }
394
395        if self.is_justified() {
396            features.push("justified");
397        }
398
399        if self.use_hyphenation {
400            features.push("hyphenation");
401        }
402
403        if self.vertical.baseline_grid.is_active() {
404            features.push("baseline-grid");
405        }
406
407        if self.vertical.first_line_indent_subpx > 0 {
408            features.push("first-line-indent");
409        }
410
411        if !self.justification.char_space.is_rigid() {
412            features.push("tracking");
413        }
414
415        features
416    }
417}
418
419impl fmt::Display for ResolvedPolicy {
420    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
421        write!(
422            f,
423            "{} (requested {}{})",
424            self.effective_tier,
425            self.requested_tier,
426            if self.degraded { ", degraded" } else { "" }
427        )
428    }
429}
430
431// =========================================================================
432// PolicyError
433// =========================================================================
434
435/// Errors from policy resolution.
436#[derive(Debug, Clone, PartialEq, Eq)]
437pub enum PolicyError {
438    /// The requested tier cannot be supported and degradation is disabled.
439    CapabilityInsufficient {
440        /// What was requested.
441        requested: LayoutTier,
442        /// The best the runtime can do.
443        best_available: LayoutTier,
444    },
445}
446
447impl fmt::Display for PolicyError {
448    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
449        match self {
450            Self::CapabilityInsufficient {
451                requested,
452                best_available,
453            } => write!(
454                f,
455                "requested tier '{}' not supported; best available is '{}'",
456                requested, best_available
457            ),
458        }
459    }
460}
461
462impl std::error::Error for PolicyError {}
463
464// =========================================================================
465// Tests
466// =========================================================================
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use crate::justification::JustifyMode;
472    use crate::vertical_metrics::VerticalPolicy;
473
474    // ── LayoutTier ───────────────────────────────────────────────────
475
476    #[test]
477    fn tier_ordering() {
478        assert!(LayoutTier::Fast < LayoutTier::Balanced);
479        assert!(LayoutTier::Balanced < LayoutTier::Quality);
480    }
481
482    #[test]
483    fn tier_degrade_quality() {
484        assert_eq!(LayoutTier::Quality.degrade(), Some(LayoutTier::Balanced));
485    }
486
487    #[test]
488    fn tier_degrade_balanced() {
489        assert_eq!(LayoutTier::Balanced.degrade(), Some(LayoutTier::Fast));
490    }
491
492    #[test]
493    fn tier_degrade_fast_is_none() {
494        assert_eq!(LayoutTier::Fast.degrade(), None);
495    }
496
497    #[test]
498    fn tier_degradation_chain_quality() {
499        let chain = LayoutTier::Quality.degradation_chain();
500        assert_eq!(
501            chain,
502            vec![LayoutTier::Quality, LayoutTier::Balanced, LayoutTier::Fast]
503        );
504    }
505
506    #[test]
507    fn tier_degradation_chain_fast() {
508        let chain = LayoutTier::Fast.degradation_chain();
509        assert_eq!(chain, vec![LayoutTier::Fast]);
510    }
511
512    #[test]
513    fn tier_default_is_balanced() {
514        assert_eq!(LayoutTier::default(), LayoutTier::Balanced);
515    }
516
517    #[test]
518    fn tier_display() {
519        assert_eq!(format!("{}", LayoutTier::Quality), "quality");
520        assert_eq!(format!("{}", LayoutTier::Balanced), "balanced");
521        assert_eq!(format!("{}", LayoutTier::Fast), "fast");
522    }
523
524    // ── RuntimeCapability ────────────────────────────────────────────
525
526    #[test]
527    fn terminal_caps_support_fast() {
528        assert!(RuntimeCapability::TERMINAL.supports_tier(LayoutTier::Fast));
529    }
530
531    #[test]
532    fn terminal_caps_support_balanced() {
533        assert!(RuntimeCapability::TERMINAL.supports_tier(LayoutTier::Balanced));
534    }
535
536    #[test]
537    fn terminal_caps_not_quality() {
538        assert!(!RuntimeCapability::TERMINAL.supports_tier(LayoutTier::Quality));
539    }
540
541    #[test]
542    fn full_caps_support_all() {
543        assert!(RuntimeCapability::FULL.supports_tier(LayoutTier::Quality));
544    }
545
546    #[test]
547    fn terminal_best_tier_is_balanced() {
548        assert_eq!(
549            RuntimeCapability::TERMINAL.best_tier(),
550            LayoutTier::Balanced
551        );
552    }
553
554    #[test]
555    fn full_best_tier_is_quality() {
556        assert_eq!(RuntimeCapability::FULL.best_tier(), LayoutTier::Quality);
557    }
558
559    #[test]
560    fn web_best_tier_is_quality() {
561        assert_eq!(RuntimeCapability::WEB.best_tier(), LayoutTier::Quality);
562    }
563
564    #[test]
565    fn default_caps_are_terminal() {
566        let caps = RuntimeCapability::default();
567        assert!(!caps.proportional_fonts);
568        assert!(!caps.subpixel_positioning);
569    }
570
571    #[test]
572    fn capability_display() {
573        let s = format!("{}", RuntimeCapability::FULL);
574        assert!(s.contains("proportional=true"));
575    }
576
577    // ── LayoutPolicy resolve ─────────────────────────────────────────
578
579    #[test]
580    fn fast_resolves_with_terminal_caps() {
581        let result = LayoutPolicy::FAST.resolve(&RuntimeCapability::TERMINAL);
582        let resolved = result.unwrap();
583        assert_eq!(resolved.effective_tier, LayoutTier::Fast);
584        assert!(!resolved.degraded);
585        assert!(!resolved.use_optimal_breaking);
586    }
587
588    #[test]
589    fn balanced_resolves_with_terminal_caps() {
590        let result = LayoutPolicy::BALANCED.resolve(&RuntimeCapability::TERMINAL);
591        let resolved = result.unwrap();
592        assert_eq!(resolved.effective_tier, LayoutTier::Balanced);
593        assert!(!resolved.degraded);
594        assert!(resolved.use_optimal_breaking);
595    }
596
597    #[test]
598    fn quality_degrades_on_terminal() {
599        let result = LayoutPolicy::QUALITY.resolve(&RuntimeCapability::TERMINAL);
600        let resolved = result.unwrap();
601        assert!(resolved.degraded);
602        assert_eq!(resolved.effective_tier, LayoutTier::Balanced);
603        assert_eq!(resolved.requested_tier, LayoutTier::Quality);
604    }
605
606    #[test]
607    fn quality_resolves_with_full_caps() {
608        let result = LayoutPolicy::QUALITY.resolve(&RuntimeCapability::FULL);
609        let resolved = result.unwrap();
610        assert_eq!(resolved.effective_tier, LayoutTier::Quality);
611        assert!(!resolved.degraded);
612        assert!(resolved.is_justified());
613        assert!(resolved.use_hyphenation);
614    }
615
616    #[test]
617    fn degradation_disabled_returns_error() {
618        let policy = LayoutPolicy {
619            tier: LayoutTier::Quality,
620            allow_degradation: false,
621            ..LayoutPolicy::QUALITY
622        };
623        let result = policy.resolve(&RuntimeCapability::TERMINAL);
624        assert!(result.is_err());
625        if let Err(PolicyError::CapabilityInsufficient {
626            requested,
627            best_available,
628        }) = result
629        {
630            assert_eq!(requested, LayoutTier::Quality);
631            assert_eq!(best_available, LayoutTier::Balanced);
632        }
633    }
634
635    // ── Overrides ────────────────────────────────────────────────────
636
637    #[test]
638    fn justify_override_applied() {
639        let policy = LayoutPolicy {
640            justify_override: Some(JustifyMode::Center),
641            ..LayoutPolicy::BALANCED
642        };
643        let resolved = policy.resolve(&RuntimeCapability::TERMINAL).unwrap();
644        assert_eq!(resolved.justification.mode, JustifyMode::Center);
645    }
646
647    #[test]
648    fn vertical_override_applied() {
649        let policy = LayoutPolicy {
650            vertical_override: Some(VerticalPolicy::Typographic),
651            ..LayoutPolicy::FAST
652        };
653        let resolved = policy.resolve(&RuntimeCapability::TERMINAL).unwrap();
654        // Typographic policy activates baseline grid
655        assert!(resolved.vertical.baseline_grid.is_active());
656    }
657
658    #[test]
659    fn custom_line_height() {
660        let policy = LayoutPolicy {
661            line_height_subpx: 20 * 256, // 20px
662            ..LayoutPolicy::BALANCED
663        };
664        let resolved = policy.resolve(&RuntimeCapability::TERMINAL).unwrap();
665        assert_eq!(resolved.line_height_subpx, 20 * 256);
666    }
667
668    #[test]
669    fn default_line_height_is_16px() {
670        let policy = LayoutPolicy::BALANCED;
671        assert_eq!(policy.effective_line_height(), 16 * 256);
672    }
673
674    // ── Capability-driven adjustments ────────────────────────────────
675
676    #[test]
677    fn no_tracking_disables_char_space() {
678        let caps = RuntimeCapability {
679            proportional_fonts: true,
680            tracking_support: false,
681            ..RuntimeCapability::FULL
682        };
683        let resolved = LayoutPolicy::QUALITY.resolve(&caps).unwrap();
684        assert!(resolved.justification.char_space.is_rigid());
685    }
686
687    #[test]
688    fn monospace_makes_spaces_rigid() {
689        let resolved = LayoutPolicy::BALANCED
690            .resolve(&RuntimeCapability::TERMINAL)
691            .unwrap();
692        assert!(resolved.justification.word_space.is_rigid());
693        assert!(resolved.justification.sentence_space.is_rigid());
694    }
695
696    #[test]
697    fn no_hyphenation_dict_disables_hyphenation() {
698        let caps = RuntimeCapability {
699            proportional_fonts: true,
700            hyphenation_available: false,
701            ..RuntimeCapability::FULL
702        };
703        let resolved = LayoutPolicy::QUALITY.resolve(&caps).unwrap();
704        assert!(!resolved.use_hyphenation);
705    }
706
707    // ── ResolvedPolicy ───────────────────────────────────────────────
708
709    #[test]
710    fn fast_not_justified() {
711        let resolved = LayoutPolicy::FAST
712            .resolve(&RuntimeCapability::TERMINAL)
713            .unwrap();
714        assert!(!resolved.is_justified());
715    }
716
717    #[test]
718    fn quality_is_justified() {
719        let resolved = LayoutPolicy::QUALITY
720            .resolve(&RuntimeCapability::FULL)
721            .unwrap();
722        assert!(resolved.is_justified());
723    }
724
725    #[test]
726    fn feature_summary_fast() {
727        let resolved = LayoutPolicy::FAST
728            .resolve(&RuntimeCapability::TERMINAL)
729            .unwrap();
730        let features = resolved.feature_summary();
731        assert!(features.contains(&"greedy-wrapping"));
732        assert!(!features.contains(&"justified"));
733    }
734
735    #[test]
736    fn feature_summary_quality() {
737        let resolved = LayoutPolicy::QUALITY
738            .resolve(&RuntimeCapability::FULL)
739            .unwrap();
740        let features = resolved.feature_summary();
741        assert!(features.contains(&"optimal-breaking"));
742        assert!(features.contains(&"justified"));
743        assert!(features.contains(&"hyphenation"));
744        assert!(features.contains(&"baseline-grid"));
745        assert!(features.contains(&"first-line-indent"));
746        assert!(features.contains(&"tracking"));
747    }
748
749    #[test]
750    fn resolved_display_no_degradation() {
751        let resolved = LayoutPolicy::BALANCED
752            .resolve(&RuntimeCapability::TERMINAL)
753            .unwrap();
754        let s = format!("{resolved}");
755        assert!(s.contains("balanced"));
756        assert!(!s.contains("degraded"));
757    }
758
759    #[test]
760    fn resolved_display_with_degradation() {
761        let resolved = LayoutPolicy::QUALITY
762            .resolve(&RuntimeCapability::TERMINAL)
763            .unwrap();
764        let s = format!("{resolved}");
765        assert!(s.contains("degraded"));
766    }
767
768    // ── PolicyError ──────────────────────────────────────────────────
769
770    #[test]
771    fn error_display() {
772        let err = PolicyError::CapabilityInsufficient {
773            requested: LayoutTier::Quality,
774            best_available: LayoutTier::Fast,
775        };
776        let s = format!("{err}");
777        assert!(s.contains("quality"));
778        assert!(s.contains("fast"));
779    }
780
781    #[test]
782    fn error_is_error_trait() {
783        let err = PolicyError::CapabilityInsufficient {
784            requested: LayoutTier::Quality,
785            best_available: LayoutTier::Fast,
786        };
787        let _: &dyn std::error::Error = &err;
788    }
789
790    // ── Default ──────────────────────────────────────────────────────
791
792    #[test]
793    fn default_policy_is_balanced() {
794        assert_eq!(LayoutPolicy::default(), LayoutPolicy::BALANCED);
795    }
796
797    #[test]
798    fn policy_display() {
799        let s = format!("{}", LayoutPolicy::QUALITY);
800        assert!(s.contains("quality"));
801    }
802
803    // ── Determinism ──────────────────────────────────────────────────
804
805    #[test]
806    fn same_inputs_same_resolution() {
807        let p1 = LayoutPolicy::QUALITY
808            .resolve(&RuntimeCapability::FULL)
809            .unwrap();
810        let p2 = LayoutPolicy::QUALITY
811            .resolve(&RuntimeCapability::FULL)
812            .unwrap();
813        assert_eq!(p1, p2);
814    }
815
816    #[test]
817    fn same_degradation_same_result() {
818        let p1 = LayoutPolicy::QUALITY
819            .resolve(&RuntimeCapability::TERMINAL)
820            .unwrap();
821        let p2 = LayoutPolicy::QUALITY
822            .resolve(&RuntimeCapability::TERMINAL)
823            .unwrap();
824        assert_eq!(p1, p2);
825    }
826
827    // ── Edge cases ───────────────────────────────────────────────────
828
829    #[test]
830    fn fast_with_full_caps_stays_fast() {
831        let resolved = LayoutPolicy::FAST
832            .resolve(&RuntimeCapability::FULL)
833            .unwrap();
834        assert_eq!(resolved.effective_tier, LayoutTier::Fast);
835        assert!(!resolved.degraded);
836    }
837
838    #[test]
839    fn quality_with_justify_left_override() {
840        let policy = LayoutPolicy {
841            justify_override: Some(JustifyMode::Left),
842            ..LayoutPolicy::QUALITY
843        };
844        let resolved = policy.resolve(&RuntimeCapability::FULL).unwrap();
845        assert!(!resolved.is_justified());
846        // Still quality tier even though not justified
847        assert_eq!(resolved.effective_tier, LayoutTier::Quality);
848    }
849}