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