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