1use std::fmt;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
30pub enum JustifyMode {
31 #[default]
33 Left,
34 Right,
36 Center,
38 Full,
41 Distributed,
44}
45
46impl JustifyMode {
47 #[must_use]
49 pub const fn requires_justification(&self) -> bool {
50 matches!(self, Self::Full | Self::Distributed)
51 }
52
53 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
81pub enum SpaceCategory {
82 #[default]
84 InterWord,
85 InterSentence,
88 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
117pub struct GlueSpec {
118 pub natural_subcell: u32,
120 pub stretch_subcell: u32,
122 pub shrink_subcell: u32,
124}
125
126pub const SUBCELL_SCALE: u32 = 256;
128
129impl GlueSpec {
130 pub const WORD_SPACE: Self = Self {
132 natural_subcell: SUBCELL_SCALE, stretch_subcell: SUBCELL_SCALE / 2, shrink_subcell: SUBCELL_SCALE / 3, };
136
137 pub const SENTENCE_SPACE: Self = Self {
139 natural_subcell: SUBCELL_SCALE * 3 / 2, stretch_subcell: SUBCELL_SCALE, shrink_subcell: SUBCELL_SCALE / 3, };
143
144 pub const FRENCH_SPACE: Self = Self::WORD_SPACE;
146
147 pub const INTER_CHAR: Self = Self {
149 natural_subcell: 0,
150 stretch_subcell: SUBCELL_SCALE / 16, shrink_subcell: SUBCELL_SCALE / 32, };
153
154 #[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 #[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 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 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 #[must_use]
191 pub const fn elasticity(&self) -> u32 {
192 self.stretch_subcell.saturating_add(self.shrink_subcell)
193 }
194
195 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
225pub struct SpacePenalty {
226 pub excessive_stretch: u64,
228 pub excessive_shrink: u64,
230 pub tracking_penalty: u64,
232}
233
234impl SpacePenalty {
235 pub const DEFAULT: Self = Self {
237 excessive_stretch: 50,
238 excessive_shrink: 80,
239 tracking_penalty: 200,
240 };
241
242 pub const PERMISSIVE: Self = Self {
244 excessive_stretch: 10,
245 excessive_shrink: 20,
246 tracking_penalty: 50,
247 };
248
249 pub const STRICT: Self = Self {
251 excessive_stretch: 200,
252 excessive_shrink: 300,
253 tracking_penalty: 1000,
254 };
255
256 #[must_use]
261 pub fn evaluate(&self, ratio_fixed: i32, category: SpaceCategory) -> u64 {
262 let mut penalty = 0u64;
263
264 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
297pub struct JustificationControl {
298 pub mode: JustifyMode,
300 pub word_space: GlueSpec,
302 pub sentence_space: GlueSpec,
304 pub char_space: GlueSpec,
306 pub penalties: SpacePenalty,
308 pub french_spacing: bool,
310 pub max_consecutive_hyphens: u8,
312 pub emergency_stretch_factor: u32,
315}
316
317impl JustificationControl {
318 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, emergency_stretch_factor: SUBCELL_SCALE,
328 };
329
330 pub const READABLE: Self = Self {
332 mode: JustifyMode::Full,
333 word_space: GlueSpec::WORD_SPACE,
334 sentence_space: GlueSpec::FRENCH_SPACE, char_space: GlueSpec::rigid(0), penalties: SpacePenalty::DEFAULT,
337 french_spacing: true,
338 max_consecutive_hyphens: 3,
339 emergency_stretch_factor: SUBCELL_SCALE * 3 / 2, };
341
342 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, };
353
354 #[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 #[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 #[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 #[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 #[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 if total_stretch == 0 {
418 return None; }
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 if total_shrink == 0 {
425 return None; }
427 let ratio = (slack_subcell as i64 * SUBCELL_SCALE as i64) / total_shrink as i64;
428 if ratio < -(SUBCELL_SCALE as i64) {
430 None } else {
432 Some(ratio as i32)
433 }
434 }
435 }
436
437 #[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 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 #[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 let base = badness.saturating_add(10); let demerits = base.saturating_mul(base);
478
479 let bp = break_penalty.unsigned_abs();
481 let demerits = demerits.saturating_add(bp.saturating_mul(bp));
482
483 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 #[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#[cfg(test)]
540mod tests {
541 use super::*;
542
543 #[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 #[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 #[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); }
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 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 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 let w = g.adjusted_width(128);
657 assert_eq!(w, g.natural_subcell + 64);
659 }
660
661 #[test]
662 fn adjusted_width_clamps_stretch() {
663 let g = GlueSpec::WORD_SPACE;
664 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 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 #[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 assert_eq!(p.evaluate(128, SpaceCategory::InterWord), 0);
720 }
721
722 #[test]
723 fn penalty_excessive_stretch() {
724 let p = SpacePenalty::DEFAULT;
725 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 #[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 #[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 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 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 assert_eq!(j.adjustment_ratio(-300, 100, 100), None);
859 }
860
861 #[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 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 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 #[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 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 #[test]
931 fn terminal_validates_clean() {
932 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 #[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 #[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}