Skip to main content

ftui_text/
justification.rs

1//! Microtypographic justification controls for the Knuth-Plass pipeline.
2//!
3//! This module provides stretch/shrink/spacing penalty configuration that
4//! modulates how the line-break optimizer distributes white space across
5//! justified text. It complements [`super::vertical_metrics`] (vertical
6//! spacing) with horizontal spacing controls.
7//!
8//! # Design
9//!
10//! All widths are in **cell columns** (terminal-native units). The stretch
11//! and shrink values define the allowable range of space adjustment per
12//! space category. The optimizer penalizes deviations from the natural
13//! width according to a cubic badness function (TeX convention).
14//!
15//! # TeX heritage
16//!
17//! The glue model follows TeX's concept: each space has a natural width,
18//! a stretchability, and a shrinkability. The adjustment ratio `r` is
19//! computed as `slack / (total_stretch or total_shrink)`, and badness is
20//! `|r|³ × scale`.
21
22use std::fmt;
23
24// =========================================================================
25// JustifyMode
26// =========================================================================
27
28/// Text alignment mode.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
30pub enum JustifyMode {
31    /// Left-aligned (ragged right). No space stretching.
32    #[default]
33    Left,
34    /// Right-aligned (ragged left).
35    Right,
36    /// Centered text.
37    Center,
38    /// Fully justified: spaces are stretched/shrunk to fill the line width.
39    /// The last line of each paragraph is left-aligned (TeX default).
40    Full,
41    /// Distributed justification: like Full, but the last line is also
42    /// justified (CJK convention).
43    Distributed,
44}
45
46impl JustifyMode {
47    /// Whether this mode requires space modulation (stretch/shrink).
48    #[must_use]
49    pub const fn requires_justification(&self) -> bool {
50        matches!(self, Self::Full | Self::Distributed)
51    }
52
53    /// Whether the last line of a paragraph should be justified.
54    #[must_use]
55    pub const fn justify_last_line(&self) -> bool {
56        matches!(self, Self::Distributed)
57    }
58}
59
60impl fmt::Display for JustifyMode {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        match self {
63            Self::Left => write!(f, "left"),
64            Self::Right => write!(f, "right"),
65            Self::Center => write!(f, "center"),
66            Self::Full => write!(f, "full"),
67            Self::Distributed => write!(f, "distributed"),
68        }
69    }
70}
71
72// =========================================================================
73// SpaceCategory
74// =========================================================================
75
76/// Classification of horizontal space types.
77///
78/// Different categories have different stretch/shrink tolerances.
79/// Inter-sentence space is wider than inter-word (French spacing aside).
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
81pub enum SpaceCategory {
82    /// Normal inter-word space.
83    #[default]
84    InterWord,
85    /// Space after sentence-ending punctuation (wider in traditional
86    /// typography, same as inter-word in "French spacing" mode).
87    InterSentence,
88    /// Inter-character spacing (tracking/letter-spacing). Very small
89    /// adjustments; used sparingly for micro-justification.
90    InterCharacter,
91}
92
93impl fmt::Display for SpaceCategory {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        match self {
96            Self::InterWord => write!(f, "inter-word"),
97            Self::InterSentence => write!(f, "inter-sentence"),
98            Self::InterCharacter => write!(f, "inter-character"),
99        }
100    }
101}
102
103// =========================================================================
104// GlueSpec
105// =========================================================================
106
107/// TeX-style glue specification: natural width + stretch + shrink.
108///
109/// All values are in 1/256ths of a cell column (sub-cell units) to allow
110/// fine-grained control while remaining integer-only.
111///
112/// The optimizer computes an adjustment ratio `r`:
113/// - `r > 0`: stretch by `r × stretch_subcell`
114/// - `r < 0`: shrink by `|r| × shrink_subcell`
115/// - `r = 0`: use natural width
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
117pub struct GlueSpec {
118    /// Natural (ideal) width in sub-cell units (1/256 cell column).
119    pub natural_subcell: u32,
120    /// Maximum stretchability in sub-cell units.
121    pub stretch_subcell: u32,
122    /// Maximum shrinkability in sub-cell units.
123    pub shrink_subcell: u32,
124}
125
126/// Sub-cell units per cell column.
127pub const SUBCELL_SCALE: u32 = 256;
128
129impl GlueSpec {
130    /// A standard inter-word space: 1 cell, stretchable by 50%, shrinkable by 33%.
131    pub const WORD_SPACE: Self = Self {
132        natural_subcell: SUBCELL_SCALE,     // 1.0 cell
133        stretch_subcell: SUBCELL_SCALE / 2, // 0.5 cell
134        shrink_subcell: SUBCELL_SCALE / 3,  // ~0.33 cell
135    };
136
137    /// Inter-sentence space: 1.5 cells, more stretchable.
138    pub const SENTENCE_SPACE: Self = Self {
139        natural_subcell: SUBCELL_SCALE * 3 / 2, // 1.5 cells
140        stretch_subcell: SUBCELL_SCALE,         // 1.0 cell
141        shrink_subcell: SUBCELL_SCALE / 3,      // ~0.33 cell
142    };
143
144    /// French spacing: same as word space (no extra after sentences).
145    pub const FRENCH_SPACE: Self = Self::WORD_SPACE;
146
147    /// Zero-width glue (for inter-character micro-adjustments).
148    pub const INTER_CHAR: Self = Self {
149        natural_subcell: 0,
150        stretch_subcell: SUBCELL_SCALE / 16, // 1/16 cell max
151        shrink_subcell: SUBCELL_SCALE / 32,  // 1/32 cell max
152    };
153
154    /// Rigid glue: no stretch, no shrink.
155    #[must_use]
156    pub const fn rigid(width_subcell: u32) -> Self {
157        Self {
158            natural_subcell: width_subcell,
159            stretch_subcell: 0,
160            shrink_subcell: 0,
161        }
162    }
163
164    /// Compute the effective width at a given adjustment ratio.
165    ///
166    /// `ratio` is in 1/256ths: positive = stretch, negative = shrink.
167    /// Returns the adjusted width in sub-cell units, clamped to
168    /// `[natural - shrink, natural + stretch]`.
169    #[must_use]
170    pub fn adjusted_width(&self, ratio_fixed: i32) -> u32 {
171        if ratio_fixed == 0 {
172            return self.natural_subcell;
173        }
174
175        if ratio_fixed > 0 {
176            // Stretch: natural + stretch * ratio / 256
177            let delta = (self.stretch_subcell as u64 * ratio_fixed as u64) / SUBCELL_SCALE as u64;
178            self.natural_subcell
179                .saturating_add(delta.min(self.stretch_subcell as u64) as u32)
180        } else {
181            // Shrink: natural - shrink * |ratio| / 256
182            let abs_ratio = ratio_fixed.unsigned_abs();
183            let delta = (self.shrink_subcell as u64 * abs_ratio as u64) / SUBCELL_SCALE as u64;
184            self.natural_subcell
185                .saturating_sub(delta.min(self.shrink_subcell as u64) as u32)
186        }
187    }
188
189    /// Total elasticity (stretch + shrink) in sub-cell units.
190    #[must_use]
191    pub const fn elasticity(&self) -> u32 {
192        self.stretch_subcell.saturating_add(self.shrink_subcell)
193    }
194
195    /// Whether this glue is fully rigid (no stretch or shrink).
196    #[must_use]
197    pub const fn is_rigid(&self) -> bool {
198        self.stretch_subcell == 0 && self.shrink_subcell == 0
199    }
200}
201
202impl Default for GlueSpec {
203    fn default() -> Self {
204        Self::WORD_SPACE
205    }
206}
207
208impl fmt::Display for GlueSpec {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        let nat = self.natural_subcell as f64 / SUBCELL_SCALE as f64;
211        let st = self.stretch_subcell as f64 / SUBCELL_SCALE as f64;
212        let sh = self.shrink_subcell as f64 / SUBCELL_SCALE as f64;
213        write!(f, "{nat:.2} +{st:.2} -{sh:.2}")
214    }
215}
216
217// =========================================================================
218// SpacePenalty
219// =========================================================================
220
221/// Penalty modifiers for space adjustment quality.
222///
223/// Higher values make the optimizer work harder to avoid that adjustment.
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
225pub struct SpacePenalty {
226    /// Extra demerit for stretching beyond 75% of max stretch.
227    pub excessive_stretch: u64,
228    /// Extra demerit for shrinking beyond 75% of max shrink.
229    pub excessive_shrink: u64,
230    /// Extra demerit for any inter-character spacing adjustment.
231    pub tracking_penalty: u64,
232}
233
234impl SpacePenalty {
235    /// Default penalties: moderate discouragement of extreme adjustments.
236    pub const DEFAULT: Self = Self {
237        excessive_stretch: 50,
238        excessive_shrink: 80,
239        tracking_penalty: 200,
240    };
241
242    /// Permissive: allow aggressive adjustments with low penalty.
243    pub const PERMISSIVE: Self = Self {
244        excessive_stretch: 10,
245        excessive_shrink: 20,
246        tracking_penalty: 50,
247    };
248
249    /// Strict: strongly discourage any visible adjustment.
250    pub const STRICT: Self = Self {
251        excessive_stretch: 200,
252        excessive_shrink: 300,
253        tracking_penalty: 1000,
254    };
255
256    /// Evaluate the penalty for a given adjustment ratio and space category.
257    ///
258    /// `ratio_fixed` is in 1/256ths (positive = stretch, negative = shrink).
259    /// Returns additional demerits beyond the base badness.
260    #[must_use]
261    pub fn evaluate(&self, ratio_fixed: i32, category: SpaceCategory) -> u64 {
262        let mut penalty = 0u64;
263
264        // Threshold: 75% of max (192/256)
265        const THRESHOLD: i32 = 192;
266
267        if ratio_fixed > THRESHOLD {
268            penalty = penalty.saturating_add(self.excessive_stretch);
269        } else if ratio_fixed < -THRESHOLD {
270            penalty = penalty.saturating_add(self.excessive_shrink);
271        }
272
273        if category == SpaceCategory::InterCharacter && ratio_fixed != 0 {
274            penalty = penalty.saturating_add(self.tracking_penalty);
275        }
276
277        penalty
278    }
279}
280
281impl Default for SpacePenalty {
282    fn default() -> Self {
283        Self::DEFAULT
284    }
285}
286
287// =========================================================================
288// JustificationControl
289// =========================================================================
290
291/// Unified justification configuration.
292///
293/// Combines alignment mode, glue specs per space category, and penalty
294/// modifiers into a single configuration object that can be passed to
295/// the line-break optimizer.
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
297pub struct JustificationControl {
298    /// Text alignment mode.
299    pub mode: JustifyMode,
300    /// Glue spec for inter-word spaces.
301    pub word_space: GlueSpec,
302    /// Glue spec for inter-sentence spaces.
303    pub sentence_space: GlueSpec,
304    /// Glue spec for inter-character adjustments (tracking).
305    pub char_space: GlueSpec,
306    /// Penalty modifiers for adjustment quality.
307    pub penalties: SpacePenalty,
308    /// Whether to use French spacing (same space after sentences as words).
309    pub french_spacing: bool,
310    /// Maximum consecutive hyphens before incurring extra penalty.
311    pub max_consecutive_hyphens: u8,
312    /// Emergency stretch multiplier (1/256ths): applied when no feasible
313    /// break exists. 256 = 1x (no extra), 512 = 2x emergency stretch.
314    pub emergency_stretch_factor: u32,
315}
316
317impl JustificationControl {
318    /// Terminal default: left-aligned, rigid spaces.
319    pub const TERMINAL: Self = Self {
320        mode: JustifyMode::Left,
321        word_space: GlueSpec::rigid(SUBCELL_SCALE),
322        sentence_space: GlueSpec::rigid(SUBCELL_SCALE),
323        char_space: GlueSpec::rigid(0),
324        penalties: SpacePenalty::DEFAULT,
325        french_spacing: true,
326        max_consecutive_hyphens: 0, // no limit in terminal
327        emergency_stretch_factor: SUBCELL_SCALE,
328    };
329
330    /// Readable: fully justified with moderate elasticity.
331    pub const READABLE: Self = Self {
332        mode: JustifyMode::Full,
333        word_space: GlueSpec::WORD_SPACE,
334        sentence_space: GlueSpec::FRENCH_SPACE, // French spacing
335        char_space: GlueSpec::rigid(0),         // No tracking
336        penalties: SpacePenalty::DEFAULT,
337        french_spacing: true,
338        max_consecutive_hyphens: 3,
339        emergency_stretch_factor: SUBCELL_SCALE * 3 / 2, // 1.5x
340    };
341
342    /// Typographic: full justification with fine-grained controls.
343    pub const TYPOGRAPHIC: Self = Self {
344        mode: JustifyMode::Full,
345        word_space: GlueSpec::WORD_SPACE,
346        sentence_space: GlueSpec::SENTENCE_SPACE,
347        char_space: GlueSpec::INTER_CHAR,
348        penalties: SpacePenalty::STRICT,
349        french_spacing: false,
350        max_consecutive_hyphens: 2,
351        emergency_stretch_factor: SUBCELL_SCALE * 2, // 2x
352    };
353
354    /// Look up the glue spec for a given space category.
355    #[must_use]
356    pub const fn glue_for(&self, category: SpaceCategory) -> GlueSpec {
357        match category {
358            SpaceCategory::InterWord => self.word_space,
359            SpaceCategory::InterSentence => {
360                if self.french_spacing {
361                    self.word_space
362                } else {
363                    self.sentence_space
364                }
365            }
366            SpaceCategory::InterCharacter => self.char_space,
367        }
368    }
369
370    /// Compute total natural width for a sequence of space categories.
371    #[must_use]
372    pub fn total_natural(&self, spaces: &[SpaceCategory]) -> u32 {
373        spaces
374            .iter()
375            .map(|cat| self.glue_for(*cat).natural_subcell)
376            .fold(0u32, u32::saturating_add)
377    }
378
379    /// Compute total stretchability for a sequence of space categories.
380    #[must_use]
381    pub fn total_stretch(&self, spaces: &[SpaceCategory]) -> u32 {
382        spaces
383            .iter()
384            .map(|cat| self.glue_for(*cat).stretch_subcell)
385            .fold(0u32, u32::saturating_add)
386    }
387
388    /// Compute total shrinkability for a sequence of space categories.
389    #[must_use]
390    pub fn total_shrink(&self, spaces: &[SpaceCategory]) -> u32 {
391        spaces
392            .iter()
393            .map(|cat| self.glue_for(*cat).shrink_subcell)
394            .fold(0u32, u32::saturating_add)
395    }
396
397    /// Compute the adjustment ratio for a line.
398    ///
399    /// `slack_subcell` = desired_width - content_width - natural_space_width
400    /// (positive means line is too short, needs stretching).
401    ///
402    /// Returns ratio in 1/256ths, or `None` if adjustment is impossible
403    /// (shrink required exceeds total shrinkability).
404    #[must_use]
405    pub fn adjustment_ratio(
406        &self,
407        slack_subcell: i32,
408        total_stretch: u32,
409        total_shrink: u32,
410    ) -> Option<i32> {
411        if slack_subcell == 0 {
412            return Some(0);
413        }
414
415        if slack_subcell > 0 {
416            // Need to stretch
417            if total_stretch == 0 {
418                return None; // Cannot stretch rigid glue
419            }
420            let ratio = (slack_subcell as i64 * SUBCELL_SCALE as i64) / total_stretch as i64;
421            Some(ratio.min(i32::MAX as i64) as i32)
422        } else {
423            // Need to shrink
424            if total_shrink == 0 {
425                return None; // Cannot shrink rigid glue
426            }
427            let ratio = (slack_subcell as i64 * SUBCELL_SCALE as i64) / total_shrink as i64;
428            // Shrink ratio must not exceed -256 (100% of shrinkability)
429            if ratio < -(SUBCELL_SCALE as i64) {
430                None // Over-shrunk
431            } else {
432                Some(ratio as i32)
433            }
434        }
435    }
436
437    /// Compute badness for a given adjustment ratio.
438    ///
439    /// Uses the standard TeX cubic formula: `|r/256|³ × 10000`.
440    /// Returns `u64::MAX` for infeasible adjustments.
441    #[must_use]
442    pub fn badness(ratio_fixed: i32) -> u64 {
443        const BADNESS_SCALE: u64 = 10_000;
444
445        if ratio_fixed == 0 {
446            return 0;
447        }
448
449        let abs_r = ratio_fixed.unsigned_abs() as u64;
450        // r_frac = abs_r / 256 (the true ratio)
451        // badness = r_frac³ × BADNESS_SCALE
452        //         = abs_r³ / 256³ × BADNESS_SCALE
453        //         = abs_r³ × BADNESS_SCALE / 16_777_216
454        let cube = abs_r.saturating_mul(abs_r).saturating_mul(abs_r);
455        cube.saturating_mul(BADNESS_SCALE) / (SUBCELL_SCALE as u64).pow(3)
456    }
457
458    /// Compute total demerits for a line, combining badness and penalties.
459    ///
460    /// `ratio_fixed`: adjustment ratio in 1/256ths.
461    /// `spaces`: the space categories on this line.
462    /// `break_penalty`: penalty from the break point itself.
463    #[must_use]
464    pub fn line_demerits(
465        &self,
466        ratio_fixed: i32,
467        spaces: &[SpaceCategory],
468        break_penalty: i64,
469    ) -> u64 {
470        let badness = Self::badness(ratio_fixed);
471        if badness == u64::MAX {
472            return u64::MAX;
473        }
474
475        // Base demerits: (line_penalty + badness)² + break_penalty
476        let base = badness.saturating_add(10); // line_penalty = 10
477        let demerits = base.saturating_mul(base);
478
479        // Add break penalty magnitude
480        let bp = break_penalty.unsigned_abs();
481        let demerits = demerits.saturating_add(bp.saturating_mul(bp));
482
483        // Add space quality penalties
484        let space_penalty: u64 = spaces
485            .iter()
486            .map(|cat| self.penalties.evaluate(ratio_fixed, *cat))
487            .sum();
488
489        demerits.saturating_add(space_penalty)
490    }
491
492    /// Validate that the configuration is internally consistent.
493    ///
494    /// Returns a list of warnings (empty = valid).
495    #[must_use]
496    pub fn validate(&self) -> Vec<&'static str> {
497        let mut warnings = Vec::new();
498
499        if self.mode.requires_justification() && self.word_space.is_rigid() {
500            warnings.push("justified mode with rigid word space cannot modulate spacing");
501        }
502
503        if self.word_space.shrink_subcell > self.word_space.natural_subcell {
504            warnings.push("word space shrink exceeds natural width (would go negative)");
505        }
506
507        if self.sentence_space.shrink_subcell > self.sentence_space.natural_subcell {
508            warnings.push("sentence space shrink exceeds natural width (would go negative)");
509        }
510
511        if self.emergency_stretch_factor == 0 {
512            warnings.push("emergency stretch factor is zero (no emergency fallback)");
513        }
514
515        warnings
516    }
517}
518
519impl Default for JustificationControl {
520    fn default() -> Self {
521        Self::TERMINAL
522    }
523}
524
525impl fmt::Display for JustificationControl {
526    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
527        write!(
528            f,
529            "mode={} word=[{}] french={}",
530            self.mode, self.word_space, self.french_spacing
531        )
532    }
533}
534
535// =========================================================================
536// Tests
537// =========================================================================
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    // ── JustifyMode ──────────────────────────────────────────────────
544
545    #[test]
546    fn left_does_not_require_justification() {
547        assert!(!JustifyMode::Left.requires_justification());
548    }
549
550    #[test]
551    fn full_requires_justification() {
552        assert!(JustifyMode::Full.requires_justification());
553    }
554
555    #[test]
556    fn distributed_requires_justification() {
557        assert!(JustifyMode::Distributed.requires_justification());
558    }
559
560    #[test]
561    fn full_does_not_justify_last_line() {
562        assert!(!JustifyMode::Full.justify_last_line());
563    }
564
565    #[test]
566    fn distributed_justifies_last_line() {
567        assert!(JustifyMode::Distributed.justify_last_line());
568    }
569
570    #[test]
571    fn default_mode_is_left() {
572        assert_eq!(JustifyMode::default(), JustifyMode::Left);
573    }
574
575    #[test]
576    fn mode_display() {
577        assert_eq!(format!("{}", JustifyMode::Full), "full");
578        assert_eq!(format!("{}", JustifyMode::Center), "center");
579    }
580
581    // ── SpaceCategory ────────────────────────────────────────────────
582
583    #[test]
584    fn default_category_is_inter_word() {
585        assert_eq!(SpaceCategory::default(), SpaceCategory::InterWord);
586    }
587
588    #[test]
589    fn category_display() {
590        assert_eq!(format!("{}", SpaceCategory::InterWord), "inter-word");
591        assert_eq!(
592            format!("{}", SpaceCategory::InterSentence),
593            "inter-sentence"
594        );
595        assert_eq!(
596            format!("{}", SpaceCategory::InterCharacter),
597            "inter-character"
598        );
599    }
600
601    // ── GlueSpec ─────────────────────────────────────────────────────
602
603    #[test]
604    fn word_space_constants() {
605        let g = GlueSpec::WORD_SPACE;
606        assert_eq!(g.natural_subcell, 256);
607        assert_eq!(g.stretch_subcell, 128);
608        assert_eq!(g.shrink_subcell, 85); // 256/3 = 85
609    }
610
611    #[test]
612    fn sentence_space_wider() {
613        let sentence = GlueSpec::SENTENCE_SPACE;
614        let word = GlueSpec::WORD_SPACE;
615        assert!(sentence.natural_subcell > word.natural_subcell);
616    }
617
618    #[test]
619    fn rigid_has_no_elasticity() {
620        let g = GlueSpec::rigid(256);
621        assert!(g.is_rigid());
622        assert_eq!(g.elasticity(), 0);
623    }
624
625    #[test]
626    fn word_space_is_not_rigid() {
627        assert!(!GlueSpec::WORD_SPACE.is_rigid());
628    }
629
630    #[test]
631    fn adjusted_width_at_zero_is_natural() {
632        let g = GlueSpec::WORD_SPACE;
633        assert_eq!(g.adjusted_width(0), g.natural_subcell);
634    }
635
636    #[test]
637    fn adjusted_width_full_stretch() {
638        let g = GlueSpec::WORD_SPACE;
639        // ratio = 256 (100% stretch)
640        let w = g.adjusted_width(256);
641        assert_eq!(w, g.natural_subcell + g.stretch_subcell);
642    }
643
644    #[test]
645    fn adjusted_width_full_shrink() {
646        let g = GlueSpec::WORD_SPACE;
647        // ratio = -256 (100% shrink)
648        let w = g.adjusted_width(-256);
649        assert_eq!(w, g.natural_subcell - g.shrink_subcell);
650    }
651
652    #[test]
653    fn adjusted_width_partial_stretch() {
654        let g = GlueSpec::WORD_SPACE;
655        // ratio = 128 (50% stretch)
656        let w = g.adjusted_width(128);
657        // stretch = 128 * 128 / 256 = 64
658        assert_eq!(w, g.natural_subcell + 64);
659    }
660
661    #[test]
662    fn adjusted_width_clamps_stretch() {
663        let g = GlueSpec::WORD_SPACE;
664        // ratio = 1024 (way over 100%) — should clamp to max stretch
665        let w = g.adjusted_width(1024);
666        assert_eq!(w, g.natural_subcell + g.stretch_subcell);
667    }
668
669    #[test]
670    fn adjusted_width_clamps_shrink() {
671        let g = GlueSpec::WORD_SPACE;
672        // ratio = -1024 (way over 100%) — should clamp to max shrink
673        let w = g.adjusted_width(-1024);
674        assert_eq!(w, g.natural_subcell - g.shrink_subcell);
675    }
676
677    #[test]
678    fn rigid_adjusted_width_ignores_ratio() {
679        let g = GlueSpec::rigid(512);
680        assert_eq!(g.adjusted_width(256), 512);
681        assert_eq!(g.adjusted_width(-256), 512);
682    }
683
684    #[test]
685    fn elasticity_is_sum() {
686        let g = GlueSpec::WORD_SPACE;
687        assert_eq!(g.elasticity(), g.stretch_subcell + g.shrink_subcell);
688    }
689
690    #[test]
691    fn glue_display() {
692        let s = format!("{}", GlueSpec::WORD_SPACE);
693        assert!(s.contains('+'));
694        assert!(s.contains('-'));
695    }
696
697    #[test]
698    fn default_glue_is_word_space() {
699        assert_eq!(GlueSpec::default(), GlueSpec::WORD_SPACE);
700    }
701
702    #[test]
703    fn french_space_equals_word_space() {
704        assert_eq!(GlueSpec::FRENCH_SPACE, GlueSpec::WORD_SPACE);
705    }
706
707    // ── SpacePenalty ─────────────────────────────────────────────────
708
709    #[test]
710    fn penalty_no_adjustment_is_zero() {
711        let p = SpacePenalty::DEFAULT;
712        assert_eq!(p.evaluate(0, SpaceCategory::InterWord), 0);
713    }
714
715    #[test]
716    fn penalty_moderate_stretch_is_zero() {
717        let p = SpacePenalty::DEFAULT;
718        // 128 is within threshold (192)
719        assert_eq!(p.evaluate(128, SpaceCategory::InterWord), 0);
720    }
721
722    #[test]
723    fn penalty_excessive_stretch() {
724        let p = SpacePenalty::DEFAULT;
725        // 200 exceeds threshold (192)
726        let d = p.evaluate(200, SpaceCategory::InterWord);
727        assert_eq!(d, p.excessive_stretch);
728    }
729
730    #[test]
731    fn penalty_excessive_shrink() {
732        let p = SpacePenalty::DEFAULT;
733        let d = p.evaluate(-200, SpaceCategory::InterWord);
734        assert_eq!(d, p.excessive_shrink);
735    }
736
737    #[test]
738    fn penalty_tracking_always_penalized() {
739        let p = SpacePenalty::DEFAULT;
740        let d = p.evaluate(1, SpaceCategory::InterCharacter);
741        assert_eq!(d, p.tracking_penalty);
742    }
743
744    #[test]
745    fn penalty_tracking_plus_excessive() {
746        let p = SpacePenalty::DEFAULT;
747        let d = p.evaluate(200, SpaceCategory::InterCharacter);
748        assert_eq!(d, p.excessive_stretch + p.tracking_penalty);
749    }
750
751    #[test]
752    fn penalty_zero_tracking_no_penalty() {
753        let p = SpacePenalty::DEFAULT;
754        assert_eq!(p.evaluate(0, SpaceCategory::InterCharacter), 0);
755    }
756
757    // ── JustificationControl ────────────────────────────────────────
758
759    #[test]
760    fn terminal_is_left_rigid() {
761        let j = JustificationControl::TERMINAL;
762        assert_eq!(j.mode, JustifyMode::Left);
763        assert!(j.word_space.is_rigid());
764    }
765
766    #[test]
767    fn readable_is_full_elastic() {
768        let j = JustificationControl::READABLE;
769        assert_eq!(j.mode, JustifyMode::Full);
770        assert!(!j.word_space.is_rigid());
771    }
772
773    #[test]
774    fn typographic_has_tracking() {
775        let j = JustificationControl::TYPOGRAPHIC;
776        assert!(!j.char_space.is_rigid());
777    }
778
779    #[test]
780    fn french_spacing_overrides_sentence() {
781        let j = JustificationControl::READABLE;
782        assert!(j.french_spacing);
783        assert_eq!(
784            j.glue_for(SpaceCategory::InterSentence),
785            j.glue_for(SpaceCategory::InterWord)
786        );
787    }
788
789    #[test]
790    fn non_french_uses_sentence_space() {
791        let j = JustificationControl::TYPOGRAPHIC;
792        assert!(!j.french_spacing);
793        assert_ne!(
794            j.glue_for(SpaceCategory::InterSentence).natural_subcell,
795            j.glue_for(SpaceCategory::InterWord).natural_subcell
796        );
797    }
798
799    #[test]
800    fn total_natural_sums() {
801        let j = JustificationControl::READABLE;
802        let spaces = vec![SpaceCategory::InterWord; 5];
803        assert_eq!(j.total_natural(&spaces), 5 * j.word_space.natural_subcell);
804    }
805
806    #[test]
807    fn total_stretch_sums() {
808        let j = JustificationControl::READABLE;
809        let spaces = vec![SpaceCategory::InterWord; 3];
810        assert_eq!(j.total_stretch(&spaces), 3 * j.word_space.stretch_subcell);
811    }
812
813    #[test]
814    fn total_shrink_sums() {
815        let j = JustificationControl::READABLE;
816        let spaces = vec![SpaceCategory::InterWord; 4];
817        assert_eq!(j.total_shrink(&spaces), 4 * j.word_space.shrink_subcell);
818    }
819
820    // ── Adjustment ratio ─────────────────────────────────────────────
821
822    #[test]
823    fn ratio_zero_slack() {
824        let j = JustificationControl::READABLE;
825        assert_eq!(j.adjustment_ratio(0, 100, 100), Some(0));
826    }
827
828    #[test]
829    fn ratio_positive_stretch() {
830        let j = JustificationControl::READABLE;
831        // slack = 128, stretch = 256 → ratio = 128 * 256 / 256 = 128
832        assert_eq!(j.adjustment_ratio(128, 256, 100), Some(128));
833    }
834
835    #[test]
836    fn ratio_negative_shrink() {
837        let j = JustificationControl::READABLE;
838        // slack = -64, shrink = 128 → ratio = -64 * 256 / 128 = -128
839        assert_eq!(j.adjustment_ratio(-64, 100, 128), Some(-128));
840    }
841
842    #[test]
843    fn ratio_no_stretch_returns_none() {
844        let j = JustificationControl::READABLE;
845        assert_eq!(j.adjustment_ratio(100, 0, 100), None);
846    }
847
848    #[test]
849    fn ratio_no_shrink_returns_none() {
850        let j = JustificationControl::READABLE;
851        assert_eq!(j.adjustment_ratio(-100, 100, 0), None);
852    }
853
854    #[test]
855    fn ratio_over_shrink_returns_none() {
856        let j = JustificationControl::READABLE;
857        // slack = -300, shrink = 100 → ratio = -300 * 256 / 100 = -768 < -256
858        assert_eq!(j.adjustment_ratio(-300, 100, 100), None);
859    }
860
861    // ── Badness ──────────────────────────────────────────────────────
862
863    #[test]
864    fn badness_zero_ratio() {
865        assert_eq!(JustificationControl::badness(0), 0);
866    }
867
868    #[test]
869    fn badness_ratio_256_is_scale() {
870        // |256/256|³ × 10000 = 10000
871        assert_eq!(JustificationControl::badness(256), 10_000);
872    }
873
874    #[test]
875    fn badness_negative_same_as_positive() {
876        assert_eq!(
877            JustificationControl::badness(128),
878            JustificationControl::badness(-128)
879        );
880    }
881
882    #[test]
883    fn badness_half_ratio() {
884        // |128/256|³ × 10000 = 0.125 × 10000 = 1250
885        assert_eq!(JustificationControl::badness(128), 1250);
886    }
887
888    #[test]
889    fn badness_monotonically_increasing() {
890        let b0 = JustificationControl::badness(0);
891        let b1 = JustificationControl::badness(64);
892        let b2 = JustificationControl::badness(128);
893        let b3 = JustificationControl::badness(256);
894        assert!(b0 < b1);
895        assert!(b1 < b2);
896        assert!(b2 < b3);
897    }
898
899    // ── Demerits ─────────────────────────────────────────────────────
900
901    #[test]
902    fn demerits_zero_ratio_minimal() {
903        let j = JustificationControl::READABLE;
904        let spaces = vec![SpaceCategory::InterWord; 3];
905        let d = j.line_demerits(0, &spaces, 0);
906        // badness = 0, base = 10, demerits = 100
907        assert_eq!(d, 100);
908    }
909
910    #[test]
911    fn demerits_increase_with_ratio() {
912        let j = JustificationControl::READABLE;
913        let spaces = vec![SpaceCategory::InterWord; 3];
914        let d1 = j.line_demerits(64, &spaces, 0);
915        let d2 = j.line_demerits(128, &spaces, 0);
916        assert!(d2 > d1);
917    }
918
919    #[test]
920    fn demerits_include_break_penalty() {
921        let j = JustificationControl::READABLE;
922        let spaces = vec![SpaceCategory::InterWord; 3];
923        let d0 = j.line_demerits(0, &spaces, 0);
924        let d1 = j.line_demerits(0, &spaces, 50);
925        assert!(d1 > d0);
926    }
927
928    // ── Validation ───────────────────────────────────────────────────
929
930    #[test]
931    fn terminal_validates_clean() {
932        // Terminal is left-aligned, so rigid word space is fine
933        assert!(JustificationControl::TERMINAL.validate().is_empty());
934    }
935
936    #[test]
937    fn readable_validates_clean() {
938        assert!(JustificationControl::READABLE.validate().is_empty());
939    }
940
941    #[test]
942    fn typographic_validates_clean() {
943        assert!(JustificationControl::TYPOGRAPHIC.validate().is_empty());
944    }
945
946    #[test]
947    fn full_mode_rigid_warns() {
948        let mut j = JustificationControl::TERMINAL;
949        j.mode = JustifyMode::Full;
950        let warnings = j.validate();
951        assert!(!warnings.is_empty());
952    }
953
954    #[test]
955    fn shrink_exceeds_natural_warns() {
956        let mut j = JustificationControl::READABLE;
957        j.word_space.shrink_subcell = j.word_space.natural_subcell + 1;
958        let warnings = j.validate();
959        assert!(
960            warnings
961                .iter()
962                .any(|w| w.contains("shrink exceeds natural"))
963        );
964    }
965
966    #[test]
967    fn zero_emergency_factor_warns() {
968        let mut j = JustificationControl::READABLE;
969        j.emergency_stretch_factor = 0;
970        let warnings = j.validate();
971        assert!(warnings.iter().any(|w| w.contains("emergency")));
972    }
973
974    // ── Display ──────────────────────────────────────────────────────
975
976    #[test]
977    fn control_display() {
978        let s = format!("{}", JustificationControl::READABLE);
979        assert!(s.contains("full"));
980        assert!(s.contains("french=true"));
981    }
982
983    #[test]
984    fn default_control_is_terminal() {
985        assert_eq!(
986            JustificationControl::default(),
987            JustificationControl::TERMINAL
988        );
989    }
990
991    // ── Determinism ──────────────────────────────────────────────────
992
993    #[test]
994    fn same_inputs_same_badness() {
995        assert_eq!(
996            JustificationControl::badness(200),
997            JustificationControl::badness(200)
998        );
999    }
1000
1001    #[test]
1002    fn same_inputs_same_demerits() {
1003        let j = JustificationControl::TYPOGRAPHIC;
1004        let spaces = vec![SpaceCategory::InterWord; 5];
1005        let d1 = j.line_demerits(150, &spaces, 50);
1006        let d2 = j.line_demerits(150, &spaces, 50);
1007        assert_eq!(d1, d2);
1008    }
1009
1010    #[test]
1011    fn same_inputs_same_ratio() {
1012        let j = JustificationControl::READABLE;
1013        assert_eq!(
1014            j.adjustment_ratio(100, 200, 100),
1015            j.adjustment_ratio(100, 200, 100)
1016        );
1017    }
1018}