Skip to main content

ftui_text/
wrap.rs

1#![forbid(unsafe_code)]
2
3//! Text wrapping with Unicode correctness.
4//!
5//! This module provides width-correct text wrapping that respects:
6//! - Grapheme cluster boundaries (never break emoji, ZWJ sequences, etc.)
7//! - Cell widths (CJK characters are 2 cells wide)
8//! - Word boundaries when possible
9//!
10//! # Example
11//! ```
12//! use ftui_text::wrap::{wrap_text, WrapMode};
13//!
14//! // Word wrap
15//! let lines = wrap_text("Hello world foo bar", 10, WrapMode::Word);
16//! assert_eq!(lines, vec!["Hello", "world foo", "bar"]);
17//!
18//! // Character wrap (for long words)
19//! let lines = wrap_text("Supercalifragilistic", 10, WrapMode::Char);
20//! assert_eq!(lines.len(), 2);
21//! ```
22
23use unicode_segmentation::UnicodeSegmentation;
24
25/// Text wrapping mode.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
27pub enum WrapMode {
28    /// No wrapping - lines may exceed width.
29    None,
30    /// Wrap at word boundaries when possible.
31    #[default]
32    Word,
33    /// Wrap at character (grapheme) boundaries.
34    Char,
35    /// Word wrap with character fallback for long words.
36    WordChar,
37}
38
39/// Options for text wrapping.
40#[derive(Debug, Clone)]
41pub struct WrapOptions {
42    /// Maximum width in cells.
43    pub width: usize,
44    /// Wrapping mode.
45    pub mode: WrapMode,
46    /// Preserve leading whitespace on continued lines.
47    pub preserve_indent: bool,
48    /// Trim trailing whitespace from wrapped lines.
49    pub trim_trailing: bool,
50}
51
52impl WrapOptions {
53    /// Create new wrap options with the given width.
54    #[must_use]
55    pub fn new(width: usize) -> Self {
56        Self {
57            width,
58            mode: WrapMode::Word,
59            preserve_indent: false,
60            trim_trailing: true,
61        }
62    }
63
64    /// Set the wrap mode.
65    #[must_use]
66    pub fn mode(mut self, mode: WrapMode) -> Self {
67        self.mode = mode;
68        self
69    }
70
71    /// Set whether to preserve indentation.
72    #[must_use]
73    pub fn preserve_indent(mut self, preserve: bool) -> Self {
74        self.preserve_indent = preserve;
75        self
76    }
77
78    /// Set whether to trim trailing whitespace.
79    #[must_use]
80    pub fn trim_trailing(mut self, trim: bool) -> Self {
81        self.trim_trailing = trim;
82        self
83    }
84}
85
86impl Default for WrapOptions {
87    fn default() -> Self {
88        Self::new(80)
89    }
90}
91
92/// Wrap text to the specified width.
93///
94/// This is a convenience function using default word-wrap mode.
95#[must_use]
96pub fn wrap_text(text: &str, width: usize, mode: WrapMode) -> Vec<String> {
97    // Char mode should preserve leading whitespace since it's raw character-boundary wrapping
98    let preserve = mode == WrapMode::Char;
99    wrap_with_options(
100        text,
101        &WrapOptions::new(width).mode(mode).preserve_indent(preserve),
102    )
103}
104
105/// Wrap text with full options.
106#[must_use]
107pub fn wrap_with_options(text: &str, options: &WrapOptions) -> Vec<String> {
108    if options.width == 0 {
109        return vec![text.to_string()];
110    }
111
112    match options.mode {
113        WrapMode::None => vec![text.to_string()],
114        WrapMode::Char => wrap_chars(text, options),
115        WrapMode::Word => wrap_words(text, options, false),
116        WrapMode::WordChar => wrap_words(text, options, true),
117    }
118}
119
120/// Wrap at grapheme boundaries (character wrap).
121fn wrap_chars(text: &str, options: &WrapOptions) -> Vec<String> {
122    let mut lines = Vec::new();
123    let mut current_line = String::new();
124    let mut current_width = 0;
125
126    for grapheme in text.graphemes(true) {
127        // Handle newlines
128        if grapheme == "\n" || grapheme == "\r\n" {
129            lines.push(finalize_line(&current_line, options));
130            current_line.clear();
131            current_width = 0;
132            continue;
133        }
134
135        let grapheme_width = crate::wrap::grapheme_width(grapheme);
136
137        // Check if this grapheme fits
138        if current_width + grapheme_width > options.width && !current_line.is_empty() {
139            lines.push(finalize_line(&current_line, options));
140            current_line.clear();
141            current_width = 0;
142        }
143
144        // Add grapheme to current line
145        current_line.push_str(grapheme);
146        current_width += grapheme_width;
147    }
148
149    // Always push the pending line at the end.
150    // This handles the last segment of text, or the empty line after a trailing newline.
151    lines.push(finalize_line(&current_line, options));
152
153    lines
154}
155
156/// Wrap at word boundaries.
157fn wrap_words(text: &str, options: &WrapOptions, char_fallback: bool) -> Vec<String> {
158    let mut lines = Vec::new();
159
160    // Split by existing newlines first
161    for raw_paragraph in text.split('\n') {
162        let paragraph = raw_paragraph.strip_suffix('\r').unwrap_or(raw_paragraph);
163        let mut current_line = String::new();
164        let mut current_width = 0;
165
166        let len_before = lines.len();
167
168        wrap_paragraph(
169            paragraph,
170            options,
171            char_fallback,
172            &mut lines,
173            &mut current_line,
174            &mut current_width,
175        );
176
177        // Push the last line of the paragraph if non-empty, or if wrap_paragraph
178        // added no lines (empty paragraph from explicit newline).
179        if !current_line.is_empty() || lines.len() == len_before {
180            lines.push(finalize_line(&current_line, options));
181        }
182    }
183
184    lines
185}
186
187/// Wrap a single paragraph (no embedded newlines).
188fn wrap_paragraph(
189    text: &str,
190    options: &WrapOptions,
191    char_fallback: bool,
192    lines: &mut Vec<String>,
193    current_line: &mut String,
194    current_width: &mut usize,
195) {
196    for word in split_words(text) {
197        let word_width = display_width(&word);
198
199        // If word fits on current line
200        if *current_width + word_width <= options.width {
201            current_line.push_str(&word);
202            *current_width += word_width;
203            continue;
204        }
205
206        // Word doesn't fit - need to wrap
207        if !current_line.is_empty() {
208            lines.push(finalize_line(current_line, options));
209            current_line.clear();
210            *current_width = 0;
211
212            // If the word causing the wrap is just whitespace, discard it.
213            // It was effectively "trailing whitespace" of the previous line.
214            // Exception: if preserve_indent is true, we might want to keep it?
215            // Usually, inter-word spaces that wrap should be discarded regardless of indentation policy.
216            // Indentation policy applies to *explicit* leading whitespace of the paragraph.
217            if word.trim().is_empty() {
218                continue;
219            }
220        }
221
222        // Check if word itself exceeds width
223        if word_width > options.width {
224            if char_fallback {
225                // Break the long word into pieces
226                wrap_long_word(&word, options, lines, current_line, current_width);
227            } else {
228                // Just put the long word on its own line
229                lines.push(finalize_line(&word, options));
230            }
231        } else {
232            // Word fits on a fresh line
233            let (fragment, fragment_width) = if options.preserve_indent {
234                (word.as_str(), word_width)
235            } else {
236                let trimmed = word.trim_start();
237                (trimmed, display_width(trimmed))
238            };
239            if !fragment.is_empty() {
240                current_line.push_str(fragment);
241            }
242            *current_width = fragment_width;
243        }
244    }
245}
246
247/// Break a long word that exceeds the width limit.
248fn wrap_long_word(
249    word: &str,
250    options: &WrapOptions,
251    lines: &mut Vec<String>,
252    current_line: &mut String,
253    current_width: &mut usize,
254) {
255    for grapheme in word.graphemes(true) {
256        let grapheme_width = crate::wrap::grapheme_width(grapheme);
257
258        // Skip leading whitespace on new lines
259        if *current_width == 0 && grapheme.trim().is_empty() && !options.preserve_indent {
260            continue;
261        }
262
263        if *current_width + grapheme_width > options.width && !current_line.is_empty() {
264            lines.push(finalize_line(current_line, options));
265            current_line.clear();
266            *current_width = 0;
267
268            // Skip leading whitespace after wrap
269            if grapheme.trim().is_empty() && !options.preserve_indent {
270                continue;
271            }
272        }
273
274        current_line.push_str(grapheme);
275        *current_width += grapheme_width;
276    }
277}
278
279/// Split text into words (preserving whitespace with words).
280///
281/// Splits on whitespace boundaries, keeping whitespace-only segments
282/// separate from non-whitespace segments.
283fn split_words(text: &str) -> Vec<String> {
284    let mut words = Vec::new();
285    let mut current = String::new();
286    let mut in_whitespace = false;
287
288    for grapheme in text.graphemes(true) {
289        let is_ws = grapheme.chars().all(|c| c.is_whitespace());
290
291        if is_ws != in_whitespace && !current.is_empty() {
292            words.push(std::mem::take(&mut current));
293        }
294
295        current.push_str(grapheme);
296        in_whitespace = is_ws;
297    }
298
299    if !current.is_empty() {
300        words.push(current);
301    }
302
303    words
304}
305
306/// Finalize a line (apply trimming, etc.).
307fn finalize_line(line: &str, options: &WrapOptions) -> String {
308    let mut result = if options.trim_trailing {
309        line.trim_end().to_string()
310    } else {
311        line.to_string()
312    };
313
314    if !options.preserve_indent {
315        // We only trim start if the user explicitly opted out of preserving indent.
316        // However, standard wrapping usually preserves start indent of the first line
317        // and only indents continuations.
318        // The `preserve_indent` option in `WrapOptions` usually refers to *hanging* indent
319        // or preserving leading whitespace on new lines.
320        //
321        // In this implementation, `wrap_paragraph` logic trims start of *continuation* lines
322        // if they fit.
323        //
324        // But for `finalize_line`, which handles the *completed* line string,
325        // we generally don't want to aggressively strip leading whitespace unless
326        // it was a blank line.
327        //
328        // Let's stick to the requested change: trim start if not preserving indent.
329        // But wait, `line.trim_start()` would kill paragraph indentation.
330        //
331        // Re-reading intent: "trim leading indentation if preserve_indent is false".
332        // This implies that if `preserve_indent` is false, we want flush-left text.
333
334        let trimmed = result.trim_start();
335        if trimmed.len() != result.len() {
336            result = trimmed.to_string();
337        }
338    }
339
340    result
341}
342
343/// Truncate text to fit within a width, adding ellipsis if needed.
344///
345/// This function respects grapheme boundaries - it will never break
346/// an emoji, ZWJ sequence, or combining character sequence.
347#[must_use]
348pub fn truncate_with_ellipsis(text: &str, max_width: usize, ellipsis: &str) -> String {
349    let text_width = display_width(text);
350
351    if text_width <= max_width {
352        return text.to_string();
353    }
354
355    let ellipsis_width = display_width(ellipsis);
356
357    // If ellipsis alone exceeds width, just truncate without ellipsis
358    if ellipsis_width >= max_width {
359        return truncate_to_width(text, max_width);
360    }
361
362    let target_width = max_width - ellipsis_width;
363    let mut result = truncate_to_width(text, target_width);
364    result.push_str(ellipsis);
365    result
366}
367
368/// Truncate text to exactly fit within a width (no ellipsis).
369///
370/// Respects grapheme boundaries.
371#[must_use]
372pub fn truncate_to_width(text: &str, max_width: usize) -> String {
373    let mut result = String::new();
374    let mut current_width = 0;
375
376    for grapheme in text.graphemes(true) {
377        let grapheme_width = crate::wrap::grapheme_width(grapheme);
378
379        if current_width + grapheme_width > max_width {
380            break;
381        }
382
383        result.push_str(grapheme);
384        current_width += grapheme_width;
385    }
386
387    result
388}
389
390/// Returns `Some(width)` if text is printable ASCII only, `None` otherwise.
391///
392/// This is a fast-path optimization. For printable ASCII (0x20-0x7E), display width
393/// equals byte length, so we can avoid the full Unicode width calculation.
394///
395/// Returns `None` for:
396/// - Non-ASCII characters (multi-byte UTF-8)
397/// - ASCII control characters (0x00-0x1F, 0x7F) which have display width 0
398///
399/// # Example
400/// ```
401/// use ftui_text::wrap::ascii_width;
402///
403/// assert_eq!(ascii_width("hello"), Some(5));
404/// assert_eq!(ascii_width("你好"), None);  // Contains CJK
405/// assert_eq!(ascii_width(""), Some(0));
406/// assert_eq!(ascii_width("hello\tworld"), None);  // Contains tab (control char)
407/// ```
408#[inline]
409#[must_use]
410pub fn ascii_width(text: &str) -> Option<usize> {
411    ftui_core::text_width::ascii_width(text)
412}
413
414/// Calculate the display width of a single grapheme cluster.
415///
416/// Uses `unicode-display-width` so grapheme clusters (ZWJ emoji, flags, combining
417/// marks) are treated as a single glyph with correct terminal width.
418///
419/// If `FTUI_TEXT_CJK_WIDTH=1` (or `FTUI_CJK_WIDTH=1`) or a CJK locale is detected,
420/// ambiguous-width characters are treated as double-width.
421#[inline]
422#[must_use]
423pub fn grapheme_width(grapheme: &str) -> usize {
424    ftui_core::text_width::grapheme_width(grapheme)
425}
426
427/// Calculate the display width of text in cells.
428///
429/// Uses ASCII fast-path when possible, falling back to Unicode width calculation.
430///
431/// If `FTUI_TEXT_CJK_WIDTH=1` (or `FTUI_CJK_WIDTH=1`) or a CJK locale is detected,
432/// ambiguous-width characters are treated as double-width.
433///
434/// # Performance
435/// - ASCII text: O(n) byte scan, no allocations
436/// - Non-ASCII: Grapheme segmentation + per-grapheme width
437#[inline]
438#[must_use]
439pub fn display_width(text: &str) -> usize {
440    ftui_core::text_width::display_width(text)
441}
442
443/// Check if a string contains any wide characters (width > 1).
444#[must_use]
445pub fn has_wide_chars(text: &str) -> bool {
446    text.graphemes(true)
447        .any(|g| crate::wrap::grapheme_width(g) > 1)
448}
449
450/// Check if a string is ASCII-only (fast path possible).
451#[must_use]
452pub fn is_ascii_only(text: &str) -> bool {
453    text.is_ascii()
454}
455
456// =============================================================================
457// Grapheme Segmentation Helpers (bd-6e9.8)
458// =============================================================================
459
460/// Count the number of grapheme clusters in a string.
461///
462/// A grapheme cluster is a user-perceived character, which may consist of
463/// multiple Unicode code points (e.g., emoji with modifiers, combining marks).
464///
465/// # Example
466/// ```
467/// use ftui_text::wrap::grapheme_count;
468///
469/// assert_eq!(grapheme_count("hello"), 5);
470/// assert_eq!(grapheme_count("e\u{0301}"), 1);  // e + combining acute = 1 grapheme
471/// assert_eq!(grapheme_count("\u{1F468}\u{200D}\u{1F469}"), 1);  // ZWJ sequence = 1 grapheme
472/// ```
473#[inline]
474#[must_use]
475pub fn grapheme_count(text: &str) -> usize {
476    text.graphemes(true).count()
477}
478
479/// Iterate over grapheme clusters in a string.
480///
481/// Returns an iterator yielding `&str` slices for each grapheme cluster.
482/// Uses extended grapheme clusters (UAX #29).
483///
484/// # Example
485/// ```
486/// use ftui_text::wrap::graphemes;
487///
488/// let chars: Vec<&str> = graphemes("e\u{0301}bc").collect();
489/// assert_eq!(chars, vec!["e\u{0301}", "b", "c"]);
490/// ```
491#[inline]
492pub fn graphemes(text: &str) -> impl Iterator<Item = &str> {
493    text.graphemes(true)
494}
495
496/// Truncate text to fit within a maximum display width.
497///
498/// Returns a tuple of (truncated_text, actual_width) where:
499/// - `truncated_text` is the prefix that fits within `max_width`
500/// - `actual_width` is the display width of the truncated text
501///
502/// Respects grapheme boundaries - will never split an emoji, ZWJ sequence,
503/// or combining character sequence.
504///
505/// # Example
506/// ```
507/// use ftui_text::wrap::truncate_to_width_with_info;
508///
509/// let (text, width) = truncate_to_width_with_info("hello world", 5);
510/// assert_eq!(text, "hello");
511/// assert_eq!(width, 5);
512///
513/// // CJK characters are 2 cells wide
514/// let (text, width) = truncate_to_width_with_info("\u{4F60}\u{597D}", 3);
515/// assert_eq!(text, "\u{4F60}");  // Only first char fits
516/// assert_eq!(width, 2);
517/// ```
518#[must_use]
519pub fn truncate_to_width_with_info(text: &str, max_width: usize) -> (&str, usize) {
520    let mut byte_end = 0;
521    let mut current_width = 0;
522
523    for grapheme in text.graphemes(true) {
524        let grapheme_width = crate::wrap::grapheme_width(grapheme);
525
526        if current_width + grapheme_width > max_width {
527            break;
528        }
529
530        current_width += grapheme_width;
531        byte_end += grapheme.len();
532    }
533
534    (&text[..byte_end], current_width)
535}
536
537/// Find word boundary positions suitable for line breaking.
538///
539/// Returns byte indices where word breaks can occur. This is useful for
540/// implementing soft-wrap at word boundaries.
541///
542/// # Example
543/// ```
544/// use ftui_text::wrap::word_boundaries;
545///
546/// let breaks: Vec<usize> = word_boundaries("hello world foo").collect();
547/// // Breaks occur after spaces
548/// assert!(breaks.contains(&6));   // After "hello "
549/// assert!(breaks.contains(&12));  // After "world "
550/// ```
551pub fn word_boundaries(text: &str) -> impl Iterator<Item = usize> + '_ {
552    text.split_word_bound_indices().filter_map(|(idx, word)| {
553        // Return index at end of whitespace sequences (good break points)
554        if word.chars().all(|c| c.is_whitespace()) {
555            Some(idx + word.len())
556        } else {
557            None
558        }
559    })
560}
561
562/// Split text into word segments preserving boundaries.
563///
564/// Each segment is either a word or a whitespace sequence.
565/// Useful for word-based text processing.
566///
567/// # Example
568/// ```
569/// use ftui_text::wrap::word_segments;
570///
571/// let segments: Vec<&str> = word_segments("hello  world").collect();
572/// assert_eq!(segments, vec!["hello", "  ", "world"]);
573/// ```
574pub fn word_segments(text: &str) -> impl Iterator<Item = &str> {
575    text.split_word_bounds()
576}
577
578// =============================================================================
579// Knuth-Plass Optimal Line Breaking (bd-4kq0.5.1)
580// =============================================================================
581//
582// # Algorithm
583//
584// Classic Knuth-Plass DP for optimal paragraph line-breaking.
585// Given text split into words with measured widths, find line breaks
586// that minimize total "badness" across all lines.
587//
588// ## Badness Function
589//
590// For a line with slack `s = width - line_content_width`:
591//   badness(s, width) = (s / width)^3 * BADNESS_SCALE
592//
593// Badness is infinite (BADNESS_INF) for lines that overflow (s < 0).
594// The last line has badness 0 (TeX convention: last line is never penalized
595// for being short).
596//
597// ## Penalties
598//
599// - PENALTY_HYPHEN: cost for breaking at a hyphen (not yet used, reserved)
600// - PENALTY_FLAGGED: cost for consecutive flagged breaks
601// - PENALTY_FORCE_BREAK: large penalty for forcing a break mid-word
602//
603// ## DP Recurrence
604//
605// cost[j] = min over all valid i < j of:
606//   cost[i] + badness(line from word i to word j-1) + penalty(break at j)
607//
608// Backtrack via `from[j]` to recover the optimal break sequence.
609//
610// ## Tie-Breaking
611//
612// When two break sequences have equal cost, prefer:
613// 1. Fewer lines (later break)
614// 2. More balanced distribution (lower max badness)
615
616/// Scale factor for badness computation. Matches TeX convention.
617const BADNESS_SCALE: u64 = 10_000;
618
619/// Badness value for infeasible lines (overflow).
620const BADNESS_INF: u64 = u64::MAX / 2;
621
622/// Penalty for forcing a mid-word character break.
623const PENALTY_FORCE_BREAK: u64 = 5000;
624
625/// Maximum lookahead (words per line) for DP pruning.
626/// Limits worst-case to O(n × MAX_LOOKAHEAD) instead of O(n²).
627/// Any line with more than this many words will use the greedy breakpoint.
628const KP_MAX_LOOKAHEAD: usize = 64;
629
630/// Compute the badness of a line with the given slack.
631///
632/// Badness grows as the cube of the ratio `slack / width`, scaled by
633/// `BADNESS_SCALE`. This heavily penalizes very loose lines while being
634/// lenient on small amounts of slack.
635///
636/// Returns `BADNESS_INF` if the line overflows (`slack < 0`).
637/// Returns 0 for the last line (TeX convention).
638#[inline]
639fn knuth_plass_badness(slack: i64, width: usize, is_last_line: bool) -> u64 {
640    if slack < 0 {
641        return BADNESS_INF;
642    }
643    if is_last_line {
644        return 0;
645    }
646    if width == 0 {
647        return if slack == 0 { 0 } else { BADNESS_INF };
648    }
649    // badness = (slack/width)^3 * BADNESS_SCALE
650    // Use integer arithmetic to avoid floating point:
651    // (slack^3 * BADNESS_SCALE) / width^3
652    let s = slack as u64;
653    let w = width as u64;
654    // Prevent overflow: compute in stages
655    let s3 = s.saturating_mul(s).saturating_mul(s);
656    let w3 = w.saturating_mul(w).saturating_mul(w);
657    if w3 == 0 {
658        return BADNESS_INF;
659    }
660    s3.saturating_mul(BADNESS_SCALE) / w3
661}
662
663/// A word token with its measured cell width.
664#[derive(Debug, Clone)]
665struct KpWord {
666    /// The word text (including any trailing space).
667    text: String,
668    /// Cell width of the content (excluding trailing space for break purposes).
669    content_width: usize,
670    /// Cell width of the trailing space (0 if none).
671    space_width: usize,
672}
673
674/// Split text into KpWord tokens for Knuth-Plass processing.
675fn kp_tokenize(text: &str) -> Vec<KpWord> {
676    let mut words = Vec::new();
677    let raw_segments: Vec<&str> = text.split_word_bounds().collect();
678
679    let mut i = 0;
680    while i < raw_segments.len() {
681        let seg = raw_segments[i];
682        if seg.chars().all(|c| c.is_whitespace()) {
683            // Standalone whitespace — attach to previous word as trailing space
684            if let Some(last) = words.last_mut() {
685                let w: &mut KpWord = last;
686                w.text.push_str(seg);
687                w.space_width += display_width(seg);
688            } else {
689                // Handle leading whitespace as a word with 0 content width
690                words.push(KpWord {
691                    text: seg.to_string(),
692                    content_width: 0,
693                    space_width: display_width(seg),
694                });
695            }
696            i += 1;
697        } else {
698            let content_width = display_width(seg);
699            words.push(KpWord {
700                text: seg.to_string(),
701                content_width,
702                space_width: 0,
703            });
704            i += 1;
705        }
706    }
707
708    words
709}
710
711/// Result of optimal line breaking.
712#[derive(Debug, Clone)]
713pub struct KpBreakResult {
714    /// The wrapped lines.
715    pub lines: Vec<String>,
716    /// Total cost (sum of badness + penalties).
717    pub total_cost: u64,
718    /// Per-line badness values (for diagnostics).
719    pub line_badness: Vec<u64>,
720}
721
722/// Compute optimal line breaks using Knuth-Plass DP.
723///
724/// Given a paragraph of text and a target width, finds the set of line
725/// breaks that minimizes total badness (cubic slack penalty).
726///
727/// Falls back to greedy word-wrap if the DP cost is prohibitive (very
728/// long paragraphs), controlled by `max_words`.
729///
730/// # Arguments
731/// * `text` - The paragraph to wrap (no embedded newlines expected).
732/// * `width` - Target line width in cells.
733///
734/// # Returns
735/// `KpBreakResult` with optimal lines, total cost, and per-line badness.
736pub fn wrap_optimal(text: &str, width: usize) -> KpBreakResult {
737    if width == 0 || text.is_empty() {
738        return KpBreakResult {
739            lines: vec![text.to_string()],
740            total_cost: 0,
741            line_badness: vec![0],
742        };
743    }
744
745    let words = kp_tokenize(text);
746    if words.is_empty() {
747        return KpBreakResult {
748            lines: vec![text.to_string()],
749            total_cost: 0,
750            line_badness: vec![0],
751        };
752    }
753
754    let n = words.len();
755
756    // cost[j] = minimum cost to set words 0..j
757    // from[j] = index i such that line starts at word i for the break ending at j
758    let mut cost = vec![BADNESS_INF; n + 1];
759    let mut from = vec![0usize; n + 1];
760    cost[0] = 0;
761
762    for j in 1..=n {
763        let mut line_width: usize = 0;
764        // Try all possible line starts i (going backwards from j).
765        // Bounded by KP_MAX_LOOKAHEAD to keep runtime O(n × lookahead).
766        let earliest = j.saturating_sub(KP_MAX_LOOKAHEAD);
767        for i in (earliest..j).rev() {
768            // Add word i's width
769            line_width += words[i].content_width;
770            if i < j - 1 {
771                // Add space between words (from word i's trailing space)
772                line_width += words[i].space_width;
773            }
774
775            // Check if line overflows
776            if line_width > width && i < j - 1 {
777                // Can't fit — and we've already tried adding more words
778                break;
779            }
780
781            let slack = width as i64 - line_width as i64;
782            let is_last = j == n;
783            let badness = if line_width > width {
784                // Single word too wide — must force-break
785                PENALTY_FORCE_BREAK
786            } else {
787                knuth_plass_badness(slack, width, is_last)
788            };
789
790            let candidate = cost[i].saturating_add(badness);
791            // Tie-breaking: prefer later break (fewer lines)
792            if candidate < cost[j] || (candidate == cost[j] && i > from[j]) {
793                cost[j] = candidate;
794                from[j] = i;
795            }
796        }
797    }
798
799    // Backtrack to recover break positions
800    let mut breaks = Vec::new();
801    let mut pos = n;
802    while pos > 0 {
803        breaks.push(from[pos]);
804        pos = from[pos];
805    }
806    breaks.reverse();
807
808    // Build output lines
809    let mut lines = Vec::new();
810    let mut line_badness = Vec::new();
811    let break_count = breaks.len();
812
813    for (idx, &start) in breaks.iter().enumerate() {
814        let end = if idx + 1 < break_count {
815            breaks[idx + 1]
816        } else {
817            n
818        };
819
820        // Reconstruct line text
821        let mut line = String::new();
822        for word in words.iter().take(end).skip(start) {
823            line.push_str(&word.text);
824        }
825
826        // Trim trailing whitespace from each line
827        let trimmed = line.trim_end().to_string();
828
829        // Compute this line's badness for diagnostics
830        let line_w = display_width(trimmed.as_str());
831        let slack = width as i64 - line_w as i64;
832        let is_last = idx == break_count - 1;
833        let bad = if slack < 0 {
834            PENALTY_FORCE_BREAK
835        } else {
836            knuth_plass_badness(slack, width, is_last)
837        };
838
839        lines.push(trimmed);
840        line_badness.push(bad);
841    }
842
843    KpBreakResult {
844        lines,
845        total_cost: cost[n],
846        line_badness,
847    }
848}
849
850/// Wrap text optimally, returning just the lines (convenience wrapper).
851///
852/// Handles multiple paragraphs separated by `\n`.
853#[must_use]
854pub fn wrap_text_optimal(text: &str, width: usize) -> Vec<String> {
855    let mut result = Vec::new();
856    for raw_paragraph in text.split('\n') {
857        let paragraph = raw_paragraph.strip_suffix('\r').unwrap_or(raw_paragraph);
858        if paragraph.is_empty() {
859            result.push(String::new());
860            continue;
861        }
862        let kp = wrap_optimal(paragraph, width);
863        result.extend(kp.lines);
864    }
865    result
866}
867
868// =============================================================================
869// Formal Paragraph Objective (bd-2vr05.15.2.1)
870// =============================================================================
871//
872// Extends the basic Knuth-Plass badness model with:
873// - Configurable penalty and demerit weights
874// - Adjacency penalties (consecutive tight/loose lines, consecutive hyphens)
875// - Readability constraints (stretch/compress bounds, widow/orphan guards)
876// - Formal demerit computation as specified in The TeXbook Chapter 14
877//
878// # Demerit Formula (TeX-standard)
879//
880//   demerit(line) = (linepenalty + badness)^2 + penalty^2
881//                   + adjacency_demerit
882//
883// Where `adjacency_demerit` detects:
884// - Consecutive flagged breaks (e.g. two hyphens in a row)
885// - Fitness class transitions (tight→loose or vice-versa)
886//
887// # Fitness Classes (TeX §851)
888//
889//   0: tight     (adjustment_ratio < -0.5)
890//   1: normal    (-0.5 ≤ r < 0.5)
891//   2: loose     (0.5 ≤ r < 1.0)
892//   3: very loose (r ≥ 1.0)
893//
894// Transitions between non-adjacent classes incur `fitness_demerit`.
895
896/// Fitness class for a line based on its adjustment ratio.
897///
898/// The adjustment ratio `r = slack / stretch` (or `slack / shrink` for
899/// negative slack) determines how much a line differs from its natural width.
900#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
901#[repr(u8)]
902pub enum FitnessClass {
903    /// r < -0.5 (compressed line).
904    Tight = 0,
905    /// -0.5 ≤ r < 0.5 (well-set line).
906    Normal = 1,
907    /// 0.5 ≤ r < 1.0 (somewhat loose line).
908    Loose = 2,
909    /// r ≥ 1.0 (very loose line).
910    VeryLoose = 3,
911}
912
913impl FitnessClass {
914    /// Classify a line's fitness from its adjustment ratio.
915    ///
916    /// The ratio is `slack / width` for positive slack (stretch)
917    /// or `slack / width` for negative slack (shrink).
918    #[must_use]
919    pub fn from_ratio(ratio: f64) -> Self {
920        if ratio < -0.5 {
921            FitnessClass::Tight
922        } else if ratio < 0.5 {
923            FitnessClass::Normal
924        } else if ratio < 1.0 {
925            FitnessClass::Loose
926        } else {
927            FitnessClass::VeryLoose
928        }
929    }
930
931    /// Whether two consecutive fitness classes are incompatible
932    /// (differ by more than one level), warranting a fitness demerit.
933    #[must_use]
934    pub const fn incompatible(self, other: Self) -> bool {
935        let a = self as i8;
936        let b = other as i8;
937        // abs(a - b) > 1
938        (a - b > 1) || (b - a > 1)
939    }
940}
941
942/// Type of break point in the paragraph item stream.
943#[derive(Debug, Clone, Copy, PartialEq, Eq)]
944pub enum BreakKind {
945    /// Break at inter-word space (penalty = 0 by default).
946    Space,
947    /// Break at explicit hyphenation point (flagged break).
948    Hyphen,
949    /// Forced break (e.g. `\n`, end of paragraph).
950    Forced,
951    /// Emergency break mid-word when no feasible break exists.
952    Emergency,
953}
954
955/// Penalty value for a break point.
956///
957/// Penalties influence where breaks occur:
958/// - Negative penalty attracts breaks (e.g. after punctuation).
959/// - Positive penalty repels breaks (e.g. avoid breaking before "I").
960/// - `PENALTY_FORBIDDEN` (`i64::MAX`) makes the break infeasible.
961#[derive(Debug, Clone, Copy, PartialEq, Eq)]
962pub struct BreakPenalty {
963    /// The penalty value. Higher = less desirable break.
964    pub value: i64,
965    /// Whether this is a flagged break (e.g. hyphenation).
966    /// Two consecutive flagged breaks incur `double_hyphen_demerit`.
967    pub flagged: bool,
968}
969
970impl BreakPenalty {
971    /// Standard inter-word break (penalty 0, not flagged).
972    pub const SPACE: Self = Self {
973        value: 0,
974        flagged: false,
975    };
976
977    /// Hyphenation break (moderate penalty, flagged).
978    pub const HYPHEN: Self = Self {
979        value: 50,
980        flagged: true,
981    };
982
983    /// Forced break (negative infinity — must break here).
984    pub const FORCED: Self = Self {
985        value: i64::MIN,
986        flagged: false,
987    };
988
989    /// Emergency mid-word break (high penalty, not flagged).
990    pub const EMERGENCY: Self = Self {
991        value: 5000,
992        flagged: false,
993    };
994}
995
996/// Configuration for the paragraph objective function.
997///
998/// All weight values are in the same "demerit" unit space. Higher values
999/// mean stronger penalties. The TeX defaults are provided by `Default`.
1000#[derive(Debug, Clone, Copy, PartialEq)]
1001pub struct ParagraphObjective {
1002    /// Base penalty added to every line's badness before squaring (TeX `\linepenalty`).
1003    /// Higher values prefer fewer lines.
1004    /// Default: 10 (TeX standard).
1005    pub line_penalty: u64,
1006
1007    /// Additional demerit when consecutive lines have incompatible fitness classes.
1008    /// Default: 100 (TeX `\adjdemerits`).
1009    pub fitness_demerit: u64,
1010
1011    /// Additional demerit when two consecutive lines both end with flagged breaks
1012    /// (typically hyphens). Default: 100 (TeX `\doublehyphendemerits`).
1013    pub double_hyphen_demerit: u64,
1014
1015    /// Additional demerit when the penultimate line has a flagged break and the
1016    /// last line is short. Default: 100 (TeX `\finalhyphendemerits`).
1017    pub final_hyphen_demerit: u64,
1018
1019    /// Maximum allowed adjustment ratio before the line is considered infeasible.
1020    /// Lines looser than this threshold get `BADNESS_INF`.
1021    /// Default: 2.0 (generous for terminal rendering).
1022    pub max_adjustment_ratio: f64,
1023
1024    /// Minimum allowed adjustment ratio (negative = compression).
1025    /// Default: -1.0 (allow moderate compression).
1026    pub min_adjustment_ratio: f64,
1027
1028    /// Widow penalty: extra demerit if the last line of a paragraph has
1029    /// fewer than `widow_threshold` characters.
1030    /// Default: 150.
1031    pub widow_demerit: u64,
1032
1033    /// Character count below which the last line triggers `widow_demerit`.
1034    /// Default: 15 (approximately one short word).
1035    pub widow_threshold: usize,
1036
1037    /// Orphan penalty: extra demerit if the first line of a paragraph
1038    /// followed by a break has fewer than `orphan_threshold` characters.
1039    /// Default: 150.
1040    pub orphan_demerit: u64,
1041
1042    /// Character count below which a first-line break triggers `orphan_demerit`.
1043    /// Default: 20.
1044    pub orphan_threshold: usize,
1045
1046    /// Scale factor for badness computation. Matches TeX convention.
1047    /// Default: 10_000.
1048    pub badness_scale: u64,
1049}
1050
1051impl Default for ParagraphObjective {
1052    fn default() -> Self {
1053        Self {
1054            line_penalty: 10,
1055            fitness_demerit: 100,
1056            double_hyphen_demerit: 100,
1057            final_hyphen_demerit: 100,
1058            max_adjustment_ratio: 2.0,
1059            min_adjustment_ratio: -1.0,
1060            widow_demerit: 150,
1061            widow_threshold: 15,
1062            orphan_demerit: 150,
1063            orphan_threshold: 20,
1064            badness_scale: BADNESS_SCALE,
1065        }
1066    }
1067}
1068
1069impl ParagraphObjective {
1070    /// Preset optimized for terminal rendering where cells are monospaced
1071    /// and compression is not possible (no inter-character stretch).
1072    #[must_use]
1073    pub fn terminal() -> Self {
1074        Self {
1075            // Higher line penalty: terminals prefer fewer lines
1076            line_penalty: 20,
1077            // Lower fitness demerit: monospace can't adjust spacing
1078            fitness_demerit: 50,
1079            // No compression possible in monospace
1080            min_adjustment_ratio: 0.0,
1081            // Wider tolerance for loose lines
1082            max_adjustment_ratio: 3.0,
1083            // Relaxed widow/orphan since terminal is not print
1084            widow_demerit: 50,
1085            orphan_demerit: 50,
1086            ..Self::default()
1087        }
1088    }
1089
1090    /// Preset for high-quality proportional typography (closest to TeX defaults).
1091    #[must_use]
1092    pub fn typographic() -> Self {
1093        Self::default()
1094    }
1095
1096    /// Compute the badness of a line with the given slack and target width.
1097    ///
1098    /// Badness is `(|ratio|^3) * badness_scale` where `ratio = slack / width`.
1099    /// Returns `None` if the line is infeasible (ratio outside bounds).
1100    #[must_use]
1101    pub fn badness(&self, slack: i64, width: usize) -> Option<u64> {
1102        if width == 0 {
1103            return if slack == 0 { Some(0) } else { None };
1104        }
1105
1106        let ratio = slack as f64 / width as f64;
1107
1108        // Check feasibility against adjustment bounds
1109        if ratio < self.min_adjustment_ratio || ratio > self.max_adjustment_ratio {
1110            return None; // infeasible
1111        }
1112
1113        let abs_ratio = ratio.abs();
1114        let badness = (abs_ratio * abs_ratio * abs_ratio * self.badness_scale as f64) as u64;
1115        Some(badness)
1116    }
1117
1118    /// Compute the adjustment ratio for a line.
1119    #[must_use]
1120    pub fn adjustment_ratio(&self, slack: i64, width: usize) -> f64 {
1121        if width == 0 {
1122            return 0.0;
1123        }
1124        slack as f64 / width as f64
1125    }
1126
1127    /// Compute demerits for a single break point.
1128    ///
1129    /// This is the full TeX demerit formula:
1130    ///   demerit = (line_penalty + badness)^2 + penalty^2
1131    ///
1132    /// For forced breaks (negative penalty), the formula becomes:
1133    ///   demerit = (line_penalty + badness)^2 - penalty^2
1134    ///
1135    /// Returns `None` if the line is infeasible.
1136    #[must_use]
1137    pub fn demerits(&self, slack: i64, width: usize, penalty: &BreakPenalty) -> Option<u64> {
1138        let badness = self.badness(slack, width)?;
1139
1140        let base = self.line_penalty.saturating_add(badness);
1141        let base_sq = base.saturating_mul(base);
1142
1143        let pen_sq = (penalty.value.unsigned_abs()).saturating_mul(penalty.value.unsigned_abs());
1144
1145        if penalty.value >= 0 {
1146            Some(base_sq.saturating_add(pen_sq))
1147        } else if penalty.value > i64::MIN {
1148            // Forced/attractive break: subtract penalty²
1149            Some(base_sq.saturating_sub(pen_sq))
1150        } else {
1151            // Forced break: just base²
1152            Some(base_sq)
1153        }
1154    }
1155
1156    /// Compute adjacency demerits between two consecutive line breaks.
1157    ///
1158    /// Returns the additional demerit to add when `prev` and `curr` are
1159    /// consecutive break points.
1160    #[must_use]
1161    pub fn adjacency_demerits(
1162        &self,
1163        prev_fitness: FitnessClass,
1164        curr_fitness: FitnessClass,
1165        prev_flagged: bool,
1166        curr_flagged: bool,
1167    ) -> u64 {
1168        let mut extra = 0u64;
1169
1170        // Fitness class incompatibility
1171        if prev_fitness.incompatible(curr_fitness) {
1172            extra = extra.saturating_add(self.fitness_demerit);
1173        }
1174
1175        // Double flagged break (consecutive hyphens)
1176        if prev_flagged && curr_flagged {
1177            extra = extra.saturating_add(self.double_hyphen_demerit);
1178        }
1179
1180        extra
1181    }
1182
1183    /// Check if the last line triggers widow penalty.
1184    ///
1185    /// A "widow" here means the last line of a paragraph is very short,
1186    /// leaving a visually orphaned fragment.
1187    #[must_use]
1188    pub fn widow_demerits(&self, last_line_chars: usize) -> u64 {
1189        if last_line_chars < self.widow_threshold {
1190            self.widow_demerit
1191        } else {
1192            0
1193        }
1194    }
1195
1196    /// Check if the first line triggers orphan penalty.
1197    ///
1198    /// An "orphan" here means the first line before a break is very short.
1199    #[must_use]
1200    pub fn orphan_demerits(&self, first_line_chars: usize) -> u64 {
1201        if first_line_chars < self.orphan_threshold {
1202            self.orphan_demerit
1203        } else {
1204            0
1205        }
1206    }
1207}
1208
1209#[cfg(test)]
1210trait TestWidth {
1211    fn width(&self) -> usize;
1212}
1213
1214#[cfg(test)]
1215impl TestWidth for str {
1216    fn width(&self) -> usize {
1217        display_width(self)
1218    }
1219}
1220
1221#[cfg(test)]
1222impl TestWidth for String {
1223    fn width(&self) -> usize {
1224        display_width(self)
1225    }
1226}
1227
1228#[cfg(test)]
1229mod tests {
1230    use super::TestWidth;
1231    use super::*;
1232
1233    // ==========================================================================
1234    // wrap_text tests
1235    // ==========================================================================
1236
1237    #[test]
1238    fn wrap_text_no_wrap_needed() {
1239        let lines = wrap_text("hello", 10, WrapMode::Word);
1240        assert_eq!(lines, vec!["hello"]);
1241    }
1242
1243    #[test]
1244    fn wrap_text_single_word_wrap() {
1245        let lines = wrap_text("hello world", 5, WrapMode::Word);
1246        assert_eq!(lines, vec!["hello", "world"]);
1247    }
1248
1249    #[test]
1250    fn wrap_text_multiple_words() {
1251        let lines = wrap_text("hello world foo bar", 11, WrapMode::Word);
1252        assert_eq!(lines, vec!["hello world", "foo bar"]);
1253    }
1254
1255    #[test]
1256    fn wrap_text_preserves_newlines() {
1257        let lines = wrap_text("line1\nline2", 20, WrapMode::Word);
1258        assert_eq!(lines, vec!["line1", "line2"]);
1259    }
1260
1261    #[test]
1262    fn wrap_text_preserves_crlf_newlines() {
1263        let lines = wrap_text("line1\r\nline2\r\n", 20, WrapMode::Word);
1264        assert_eq!(lines, vec!["line1", "line2", ""]);
1265    }
1266
1267    #[test]
1268    fn wrap_text_trailing_newlines() {
1269        // "line1\n" -> ["line1", ""]
1270        let lines = wrap_text("line1\n", 20, WrapMode::Word);
1271        assert_eq!(lines, vec!["line1", ""]);
1272
1273        // "\n" -> ["", ""]
1274        let lines = wrap_text("\n", 20, WrapMode::Word);
1275        assert_eq!(lines, vec!["", ""]);
1276
1277        // Same for Char mode
1278        let lines = wrap_text("line1\n", 20, WrapMode::Char);
1279        assert_eq!(lines, vec!["line1", ""]);
1280    }
1281
1282    #[test]
1283    fn wrap_text_empty_string() {
1284        let lines = wrap_text("", 10, WrapMode::Word);
1285        assert_eq!(lines, vec![""]);
1286    }
1287
1288    #[test]
1289    fn wrap_text_long_word_no_fallback() {
1290        let lines = wrap_text("supercalifragilistic", 10, WrapMode::Word);
1291        // Without fallback, long word stays on its own line
1292        assert_eq!(lines, vec!["supercalifragilistic"]);
1293    }
1294
1295    #[test]
1296    fn wrap_text_long_word_with_fallback() {
1297        let lines = wrap_text("supercalifragilistic", 10, WrapMode::WordChar);
1298        // With fallback, long word is broken
1299        assert!(lines.len() > 1);
1300        for line in &lines {
1301            assert!(line.width() <= 10);
1302        }
1303    }
1304
1305    #[test]
1306    fn wrap_char_mode() {
1307        let lines = wrap_text("hello world", 5, WrapMode::Char);
1308        assert_eq!(lines, vec!["hello", " worl", "d"]);
1309    }
1310
1311    #[test]
1312    fn wrap_none_mode() {
1313        let lines = wrap_text("hello world", 5, WrapMode::None);
1314        assert_eq!(lines, vec!["hello world"]);
1315    }
1316
1317    // ==========================================================================
1318    // CJK wrapping tests
1319    // ==========================================================================
1320
1321    #[test]
1322    fn wrap_cjk_respects_width() {
1323        // Each CJK char is 2 cells
1324        let lines = wrap_text("你好世界", 4, WrapMode::Char);
1325        assert_eq!(lines, vec!["你好", "世界"]);
1326    }
1327
1328    #[test]
1329    fn wrap_cjk_odd_width() {
1330        // Width 5 can fit 2 CJK chars (4 cells)
1331        let lines = wrap_text("你好世", 5, WrapMode::Char);
1332        assert_eq!(lines, vec!["你好", "世"]);
1333    }
1334
1335    #[test]
1336    fn wrap_mixed_ascii_cjk() {
1337        let lines = wrap_text("hi你好", 4, WrapMode::Char);
1338        assert_eq!(lines, vec!["hi你", "好"]);
1339    }
1340
1341    // ==========================================================================
1342    // Emoji/ZWJ tests
1343    // ==========================================================================
1344
1345    #[test]
1346    fn wrap_emoji_as_unit() {
1347        // Emoji should not be broken
1348        let lines = wrap_text("😀😀😀", 4, WrapMode::Char);
1349        // Each emoji is typically 2 cells, so 2 per line
1350        assert_eq!(lines.len(), 2);
1351        for line in &lines {
1352            // No partial emoji
1353            assert!(!line.contains("\\u"));
1354        }
1355    }
1356
1357    #[test]
1358    fn wrap_zwj_sequence_as_unit() {
1359        // Family emoji (ZWJ sequence) - should stay together
1360        let text = "👨‍👩‍👧";
1361        let lines = wrap_text(text, 2, WrapMode::Char);
1362        // The ZWJ sequence should not be broken
1363        // It will exceed width but stay as one unit
1364        assert!(lines.iter().any(|l| l.contains("👨‍👩‍👧")));
1365    }
1366
1367    #[test]
1368    fn wrap_mixed_ascii_and_emoji_respects_width() {
1369        let lines = wrap_text("a😀b", 3, WrapMode::Char);
1370        assert_eq!(lines, vec!["a😀", "b"]);
1371    }
1372
1373    // ==========================================================================
1374    // Truncation tests
1375    // ==========================================================================
1376
1377    #[test]
1378    fn truncate_no_change_if_fits() {
1379        let result = truncate_with_ellipsis("hello", 10, "...");
1380        assert_eq!(result, "hello");
1381    }
1382
1383    #[test]
1384    fn truncate_with_ellipsis_ascii() {
1385        let result = truncate_with_ellipsis("hello world", 8, "...");
1386        assert_eq!(result, "hello...");
1387    }
1388
1389    #[test]
1390    fn truncate_cjk() {
1391        let result = truncate_with_ellipsis("你好世界", 6, "...");
1392        // 6 - 3 (ellipsis) = 3 cells for content
1393        // 你 = 2 cells fits, 好 = 2 cells doesn't fit
1394        assert_eq!(result, "你...");
1395    }
1396
1397    #[test]
1398    fn truncate_to_width_basic() {
1399        let result = truncate_to_width("hello world", 5);
1400        assert_eq!(result, "hello");
1401    }
1402
1403    #[test]
1404    fn truncate_to_width_cjk() {
1405        let result = truncate_to_width("你好世界", 4);
1406        assert_eq!(result, "你好");
1407    }
1408
1409    #[test]
1410    fn truncate_to_width_odd_boundary() {
1411        // Can't fit half a CJK char
1412        let result = truncate_to_width("你好", 3);
1413        assert_eq!(result, "你");
1414    }
1415
1416    #[test]
1417    fn truncate_combining_chars() {
1418        // e + combining acute accent
1419        let text = "e\u{0301}test";
1420        let result = truncate_to_width(text, 2);
1421        // Should keep é together and add 't'
1422        assert_eq!(result.chars().count(), 3); // e + combining + t
1423    }
1424
1425    // ==========================================================================
1426    // Helper function tests
1427    // ==========================================================================
1428
1429    #[test]
1430    fn display_width_ascii() {
1431        assert_eq!(display_width("hello"), 5);
1432    }
1433
1434    #[test]
1435    fn display_width_cjk() {
1436        assert_eq!(display_width("你好"), 4);
1437    }
1438
1439    #[test]
1440    fn display_width_emoji_sequences() {
1441        assert_eq!(display_width("👩‍🔬"), 2);
1442        assert_eq!(display_width("👨‍👩‍👧‍👦"), 2);
1443        assert_eq!(display_width("👩‍🚀x"), 3);
1444    }
1445
1446    #[test]
1447    fn display_width_misc_symbol_emoji() {
1448        assert_eq!(display_width("⏳"), 2);
1449        assert_eq!(display_width("⌛"), 2);
1450    }
1451
1452    #[test]
1453    fn display_width_emoji_presentation_selector() {
1454        // Text-default emoji + VS16: terminals render at width 1.
1455        assert_eq!(display_width("❤️"), 1);
1456        assert_eq!(display_width("⌨️"), 1);
1457        assert_eq!(display_width("⚠️"), 1);
1458    }
1459
1460    #[test]
1461    fn display_width_misc_symbol_ranges() {
1462        // Wide characters (east_asian_width=W) are always width 2
1463        assert_eq!(display_width("⌚"), 2); // U+231A WATCH, Wide
1464        assert_eq!(display_width("⭐"), 2); // U+2B50 WHITE MEDIUM STAR, Wide
1465
1466        // Neutral characters (east_asian_width=N): width depends on CJK mode
1467        let airplane_width = display_width("✈"); // U+2708 AIRPLANE, Neutral
1468        let arrow_width = display_width("⬆"); // U+2B06 UPWARDS BLACK ARROW, Neutral
1469        assert!(
1470            [1, 2].contains(&airplane_width),
1471            "airplane should be 1 (non-CJK) or 2 (CJK), got {airplane_width}"
1472        );
1473        assert_eq!(
1474            airplane_width, arrow_width,
1475            "both Neutral-width chars should have same width in any mode"
1476        );
1477    }
1478
1479    #[test]
1480    fn display_width_flags() {
1481        assert_eq!(display_width("🇺🇸"), 2);
1482        assert_eq!(display_width("🇯🇵"), 2);
1483        assert_eq!(display_width("🇺🇸🇯🇵"), 4);
1484    }
1485
1486    #[test]
1487    fn display_width_skin_tone_modifiers() {
1488        assert_eq!(display_width("👍🏻"), 2);
1489        assert_eq!(display_width("👍🏽"), 2);
1490    }
1491
1492    #[test]
1493    fn display_width_zwj_sequences() {
1494        assert_eq!(display_width("👩‍💻"), 2);
1495        assert_eq!(display_width("👨‍👩‍👧‍👦"), 2);
1496    }
1497
1498    #[test]
1499    fn display_width_mixed_ascii_and_emoji() {
1500        assert_eq!(display_width("A😀B"), 4);
1501        assert_eq!(display_width("A👩‍💻B"), 4);
1502        assert_eq!(display_width("ok ✅"), 5);
1503    }
1504
1505    #[test]
1506    fn display_width_file_icons() {
1507        // Inherently-wide emoji (Emoji_Presentation=Yes or EAW=W): width 2
1508        // ⚡️ (U+26A1+FE0F) has EAW=W, so remains wide after VS16 stripping.
1509        let wide_icons = ["📁", "🔗", "🦀", "🐍", "📜", "📝", "🎵", "🎬", "⚡️", "📄"];
1510        for icon in wide_icons {
1511            assert_eq!(display_width(icon), 2, "icon width mismatch: {icon}");
1512        }
1513        // Text-default (EAW=N) + VS16: terminals render at width 1
1514        let narrow_icons = ["⚙️", "🖼️"];
1515        for icon in narrow_icons {
1516            assert_eq!(display_width(icon), 1, "VS16 icon width mismatch: {icon}");
1517        }
1518    }
1519
1520    #[test]
1521    fn grapheme_width_emoji_sequence() {
1522        assert_eq!(grapheme_width("👩‍🔬"), 2);
1523    }
1524
1525    #[test]
1526    fn grapheme_width_flags_and_modifiers() {
1527        assert_eq!(grapheme_width("🇺🇸"), 2);
1528        assert_eq!(grapheme_width("👍🏽"), 2);
1529    }
1530
1531    #[test]
1532    fn display_width_empty() {
1533        assert_eq!(display_width(""), 0);
1534    }
1535
1536    // ==========================================================================
1537    // ASCII width fast-path tests
1538    // ==========================================================================
1539
1540    #[test]
1541    fn ascii_width_pure_ascii() {
1542        assert_eq!(ascii_width("hello"), Some(5));
1543        assert_eq!(ascii_width("hello world 123"), Some(15));
1544    }
1545
1546    #[test]
1547    fn ascii_width_empty() {
1548        assert_eq!(ascii_width(""), Some(0));
1549    }
1550
1551    #[test]
1552    fn ascii_width_non_ascii_returns_none() {
1553        assert_eq!(ascii_width("你好"), None);
1554        assert_eq!(ascii_width("héllo"), None);
1555        assert_eq!(ascii_width("hello😀"), None);
1556    }
1557
1558    #[test]
1559    fn ascii_width_mixed_returns_none() {
1560        assert_eq!(ascii_width("hi你好"), None);
1561        assert_eq!(ascii_width("caf\u{00e9}"), None); // café
1562    }
1563
1564    #[test]
1565    fn ascii_width_control_chars_returns_none() {
1566        // Control characters are ASCII but have display width 0, not byte length
1567        assert_eq!(ascii_width("\t"), None); // tab
1568        assert_eq!(ascii_width("\n"), None); // newline
1569        assert_eq!(ascii_width("\r"), None); // carriage return
1570        assert_eq!(ascii_width("\0"), None); // NUL
1571        assert_eq!(ascii_width("\x7F"), None); // DEL
1572        assert_eq!(ascii_width("hello\tworld"), None); // mixed with tab
1573        assert_eq!(ascii_width("line1\nline2"), None); // mixed with newline
1574    }
1575
1576    #[test]
1577    fn display_width_uses_ascii_fast_path() {
1578        // ASCII should work (implicitly tests fast path)
1579        assert_eq!(display_width("test"), 4);
1580        // Non-ASCII should also work (tests fallback)
1581        assert_eq!(display_width("你"), 2);
1582    }
1583
1584    #[test]
1585    fn has_wide_chars_true() {
1586        assert!(has_wide_chars("hi你好"));
1587    }
1588
1589    #[test]
1590    fn has_wide_chars_false() {
1591        assert!(!has_wide_chars("hello"));
1592    }
1593
1594    #[test]
1595    fn is_ascii_only_true() {
1596        assert!(is_ascii_only("hello world 123"));
1597    }
1598
1599    #[test]
1600    fn is_ascii_only_false() {
1601        assert!(!is_ascii_only("héllo"));
1602    }
1603
1604    // ==========================================================================
1605    // Grapheme helper tests (bd-6e9.8)
1606    // ==========================================================================
1607
1608    #[test]
1609    fn grapheme_count_ascii() {
1610        assert_eq!(grapheme_count("hello"), 5);
1611        assert_eq!(grapheme_count(""), 0);
1612    }
1613
1614    #[test]
1615    fn grapheme_count_combining() {
1616        // e + combining acute = 1 grapheme
1617        assert_eq!(grapheme_count("e\u{0301}"), 1);
1618        // Multiple combining marks
1619        assert_eq!(grapheme_count("e\u{0301}\u{0308}"), 1);
1620    }
1621
1622    #[test]
1623    fn grapheme_count_cjk() {
1624        assert_eq!(grapheme_count("你好"), 2);
1625    }
1626
1627    #[test]
1628    fn grapheme_count_emoji() {
1629        assert_eq!(grapheme_count("😀"), 1);
1630        // Emoji with skin tone modifier = 1 grapheme
1631        assert_eq!(grapheme_count("👍🏻"), 1);
1632    }
1633
1634    #[test]
1635    fn grapheme_count_zwj() {
1636        // Family emoji (ZWJ sequence) = 1 grapheme
1637        assert_eq!(grapheme_count("👨‍👩‍👧"), 1);
1638    }
1639
1640    #[test]
1641    fn graphemes_iteration() {
1642        let gs: Vec<&str> = graphemes("e\u{0301}bc").collect();
1643        assert_eq!(gs, vec!["e\u{0301}", "b", "c"]);
1644    }
1645
1646    #[test]
1647    fn graphemes_empty() {
1648        let gs: Vec<&str> = graphemes("").collect();
1649        assert!(gs.is_empty());
1650    }
1651
1652    #[test]
1653    fn graphemes_cjk() {
1654        let gs: Vec<&str> = graphemes("你好").collect();
1655        assert_eq!(gs, vec!["你", "好"]);
1656    }
1657
1658    #[test]
1659    fn truncate_to_width_with_info_basic() {
1660        let (text, width) = truncate_to_width_with_info("hello world", 5);
1661        assert_eq!(text, "hello");
1662        assert_eq!(width, 5);
1663    }
1664
1665    #[test]
1666    fn truncate_to_width_with_info_cjk() {
1667        let (text, width) = truncate_to_width_with_info("你好世界", 3);
1668        assert_eq!(text, "你");
1669        assert_eq!(width, 2);
1670    }
1671
1672    #[test]
1673    fn truncate_to_width_with_info_combining() {
1674        let (text, width) = truncate_to_width_with_info("e\u{0301}bc", 2);
1675        assert_eq!(text, "e\u{0301}b");
1676        assert_eq!(width, 2);
1677    }
1678
1679    #[test]
1680    fn truncate_to_width_with_info_fits() {
1681        let (text, width) = truncate_to_width_with_info("hi", 10);
1682        assert_eq!(text, "hi");
1683        assert_eq!(width, 2);
1684    }
1685
1686    #[test]
1687    fn word_boundaries_basic() {
1688        let breaks: Vec<usize> = word_boundaries("hello world").collect();
1689        assert!(breaks.contains(&6)); // After "hello "
1690    }
1691
1692    #[test]
1693    fn word_boundaries_multiple_spaces() {
1694        let breaks: Vec<usize> = word_boundaries("a  b").collect();
1695        assert!(breaks.contains(&3)); // After "a  "
1696    }
1697
1698    #[test]
1699    fn word_segments_basic() {
1700        let segs: Vec<&str> = word_segments("hello  world").collect();
1701        // split_word_bounds gives individual segments
1702        assert!(segs.contains(&"hello"));
1703        assert!(segs.contains(&"world"));
1704    }
1705
1706    // ==========================================================================
1707    // WrapOptions tests
1708    // ==========================================================================
1709
1710    #[test]
1711    fn wrap_options_builder() {
1712        let opts = WrapOptions::new(40)
1713            .mode(WrapMode::Char)
1714            .preserve_indent(true)
1715            .trim_trailing(false);
1716
1717        assert_eq!(opts.width, 40);
1718        assert_eq!(opts.mode, WrapMode::Char);
1719        assert!(opts.preserve_indent);
1720        assert!(!opts.trim_trailing);
1721    }
1722
1723    #[test]
1724    fn wrap_options_trim_trailing() {
1725        let opts = WrapOptions::new(10).trim_trailing(true);
1726        let lines = wrap_with_options("hello   world", &opts);
1727        // Trailing spaces should be trimmed
1728        assert!(!lines.iter().any(|l| l.ends_with(' ')));
1729    }
1730
1731    #[test]
1732    fn wrap_preserve_indent_keeps_leading_ws_on_new_line() {
1733        let opts = WrapOptions::new(7)
1734            .mode(WrapMode::Word)
1735            .preserve_indent(true);
1736        let lines = wrap_with_options("word12  abcde", &opts);
1737        assert_eq!(lines, vec!["word12", "  abcde"]);
1738    }
1739
1740    #[test]
1741    fn wrap_no_preserve_indent_trims_leading_ws_on_new_line() {
1742        let opts = WrapOptions::new(7)
1743            .mode(WrapMode::Word)
1744            .preserve_indent(false);
1745        let lines = wrap_with_options("word12  abcde", &opts);
1746        assert_eq!(lines, vec!["word12", "abcde"]);
1747    }
1748
1749    #[test]
1750    fn wrap_zero_width() {
1751        let lines = wrap_text("hello", 0, WrapMode::Word);
1752        // Zero width returns original text
1753        assert_eq!(lines, vec!["hello"]);
1754    }
1755
1756    // ==========================================================================
1757    // Additional coverage tests for width measurement
1758    // ==========================================================================
1759
1760    #[test]
1761    fn wrap_mode_default() {
1762        let mode = WrapMode::default();
1763        assert_eq!(mode, WrapMode::Word);
1764    }
1765
1766    #[test]
1767    fn wrap_options_default() {
1768        let opts = WrapOptions::default();
1769        assert_eq!(opts.width, 80);
1770        assert_eq!(opts.mode, WrapMode::Word);
1771        assert!(!opts.preserve_indent);
1772        assert!(opts.trim_trailing);
1773    }
1774
1775    #[test]
1776    fn display_width_emoji_skin_tone() {
1777        let width = display_width("👍🏻");
1778        assert_eq!(width, 2);
1779    }
1780
1781    #[test]
1782    fn display_width_flag_emoji() {
1783        let width = display_width("🇺🇸");
1784        assert_eq!(width, 2);
1785    }
1786
1787    #[test]
1788    fn display_width_zwj_family() {
1789        let width = display_width("👨‍👩‍👧");
1790        assert_eq!(width, 2);
1791    }
1792
1793    #[test]
1794    fn display_width_multiple_combining() {
1795        // e + combining acute + combining diaeresis = still 1 cell
1796        let width = display_width("e\u{0301}\u{0308}");
1797        assert_eq!(width, 1);
1798    }
1799
1800    #[test]
1801    fn ascii_width_printable_range() {
1802        // Test entire printable ASCII range (0x20-0x7E)
1803        let printable: String = (0x20u8..=0x7Eu8).map(|b| b as char).collect();
1804        assert_eq!(ascii_width(&printable), Some(printable.len()));
1805    }
1806
1807    #[test]
1808    fn ascii_width_newline_returns_none() {
1809        // Newline is a control character
1810        assert!(ascii_width("hello\nworld").is_none());
1811    }
1812
1813    #[test]
1814    fn ascii_width_tab_returns_none() {
1815        // Tab is a control character
1816        assert!(ascii_width("hello\tworld").is_none());
1817    }
1818
1819    #[test]
1820    fn ascii_width_del_returns_none() {
1821        // DEL (0x7F) is a control character
1822        assert!(ascii_width("hello\x7Fworld").is_none());
1823    }
1824
1825    #[test]
1826    fn has_wide_chars_cjk_mixed() {
1827        assert!(has_wide_chars("abc你def"));
1828        assert!(has_wide_chars("你"));
1829        assert!(!has_wide_chars("abc"));
1830    }
1831
1832    #[test]
1833    fn has_wide_chars_emoji() {
1834        assert!(has_wide_chars("😀"));
1835        assert!(has_wide_chars("hello😀"));
1836    }
1837
1838    #[test]
1839    fn grapheme_count_empty() {
1840        assert_eq!(grapheme_count(""), 0);
1841    }
1842
1843    #[test]
1844    fn grapheme_count_regional_indicators() {
1845        // US flag = 2 regional indicators = 1 grapheme
1846        assert_eq!(grapheme_count("🇺🇸"), 1);
1847    }
1848
1849    #[test]
1850    fn word_boundaries_no_spaces() {
1851        let breaks: Vec<usize> = word_boundaries("helloworld").collect();
1852        assert!(breaks.is_empty());
1853    }
1854
1855    #[test]
1856    fn word_boundaries_only_spaces() {
1857        let breaks: Vec<usize> = word_boundaries("   ").collect();
1858        assert!(!breaks.is_empty());
1859    }
1860
1861    #[test]
1862    fn word_segments_empty() {
1863        let segs: Vec<&str> = word_segments("").collect();
1864        assert!(segs.is_empty());
1865    }
1866
1867    #[test]
1868    fn word_segments_single_word() {
1869        let segs: Vec<&str> = word_segments("hello").collect();
1870        assert_eq!(segs.len(), 1);
1871        assert_eq!(segs[0], "hello");
1872    }
1873
1874    #[test]
1875    fn truncate_to_width_empty() {
1876        let result = truncate_to_width("", 10);
1877        assert_eq!(result, "");
1878    }
1879
1880    #[test]
1881    fn truncate_to_width_zero_width() {
1882        let result = truncate_to_width("hello", 0);
1883        assert_eq!(result, "");
1884    }
1885
1886    #[test]
1887    fn truncate_with_ellipsis_exact_fit() {
1888        // String exactly fits without needing truncation
1889        let result = truncate_with_ellipsis("hello", 5, "...");
1890        assert_eq!(result, "hello");
1891    }
1892
1893    #[test]
1894    fn truncate_with_ellipsis_empty_ellipsis() {
1895        let result = truncate_with_ellipsis("hello world", 5, "");
1896        assert_eq!(result, "hello");
1897    }
1898
1899    #[test]
1900    fn truncate_to_width_with_info_empty() {
1901        let (text, width) = truncate_to_width_with_info("", 10);
1902        assert_eq!(text, "");
1903        assert_eq!(width, 0);
1904    }
1905
1906    #[test]
1907    fn truncate_to_width_with_info_zero_width() {
1908        let (text, width) = truncate_to_width_with_info("hello", 0);
1909        assert_eq!(text, "");
1910        assert_eq!(width, 0);
1911    }
1912
1913    #[test]
1914    fn truncate_to_width_wide_char_boundary() {
1915        // Try to truncate at width 3 where a CJK char (width 2) would split
1916        let (text, width) = truncate_to_width_with_info("a你好", 2);
1917        // "a" is 1 cell, "你" is 2 cells, so only "a" fits in width 2
1918        assert_eq!(text, "a");
1919        assert_eq!(width, 1);
1920    }
1921
1922    #[test]
1923    fn wrap_mode_none() {
1924        let lines = wrap_text("hello world", 5, WrapMode::None);
1925        assert_eq!(lines, vec!["hello world"]);
1926    }
1927
1928    #[test]
1929    fn wrap_long_word_no_char_fallback() {
1930        // WordChar mode handles long words by falling back to char wrap
1931        let lines = wrap_text("supercalifragilistic", 10, WrapMode::WordChar);
1932        // Should wrap even the long word
1933        for line in &lines {
1934            assert!(line.width() <= 10);
1935        }
1936    }
1937
1938    // =========================================================================
1939    // Knuth-Plass Optimal Line Breaking Tests (bd-4kq0.5.1)
1940    // =========================================================================
1941
1942    #[test]
1943    fn unit_badness_monotone() {
1944        // Larger slack => higher badness (for non-last lines)
1945        let width = 80;
1946        let mut prev = knuth_plass_badness(0, width, false);
1947        for slack in 1..=80i64 {
1948            let bad = knuth_plass_badness(slack, width, false);
1949            assert!(
1950                bad >= prev,
1951                "badness must be monotonically non-decreasing: \
1952                 badness({slack}) = {bad} < badness({}) = {prev}",
1953                slack - 1
1954            );
1955            prev = bad;
1956        }
1957    }
1958
1959    #[test]
1960    fn unit_badness_zero_slack() {
1961        // Perfect fit: badness should be 0
1962        assert_eq!(knuth_plass_badness(0, 80, false), 0);
1963        assert_eq!(knuth_plass_badness(0, 80, true), 0);
1964    }
1965
1966    #[test]
1967    fn unit_badness_overflow_is_inf() {
1968        // Negative slack (overflow) => BADNESS_INF
1969        assert_eq!(knuth_plass_badness(-1, 80, false), BADNESS_INF);
1970        assert_eq!(knuth_plass_badness(-10, 80, false), BADNESS_INF);
1971    }
1972
1973    #[test]
1974    fn unit_badness_last_line_always_zero() {
1975        // Last line: badness is always 0 regardless of slack
1976        assert_eq!(knuth_plass_badness(0, 80, true), 0);
1977        assert_eq!(knuth_plass_badness(40, 80, true), 0);
1978        assert_eq!(knuth_plass_badness(79, 80, true), 0);
1979    }
1980
1981    #[test]
1982    fn unit_badness_cubic_growth() {
1983        let width = 100;
1984        let b10 = knuth_plass_badness(10, width, false);
1985        let b20 = knuth_plass_badness(20, width, false);
1986        let b40 = knuth_plass_badness(40, width, false);
1987
1988        // Doubling slack should ~8× badness (cubic)
1989        // Allow some tolerance for integer arithmetic
1990        assert!(
1991            b20 >= b10 * 6,
1992            "doubling slack 10→20: expected ~8× but got {}× (b10={b10}, b20={b20})",
1993            b20.checked_div(b10).unwrap_or(0)
1994        );
1995        assert!(
1996            b40 >= b20 * 6,
1997            "doubling slack 20→40: expected ~8× but got {}× (b20={b20}, b40={b40})",
1998            b40.checked_div(b20).unwrap_or(0)
1999        );
2000    }
2001
2002    #[test]
2003    fn unit_penalty_applied() {
2004        // A single word that's too wide incurs PENALTY_FORCE_BREAK
2005        let result = wrap_optimal("superlongwordthatcannotfit", 10);
2006        // The word can't fit in width=10, so it must force-break
2007        assert!(
2008            result.total_cost >= PENALTY_FORCE_BREAK,
2009            "force-break penalty should be applied: cost={}",
2010            result.total_cost
2011        );
2012    }
2013
2014    #[test]
2015    fn kp_simple_wrap() {
2016        let result = wrap_optimal("Hello world foo bar", 10);
2017        // All lines should fit within width
2018        for line in &result.lines {
2019            assert!(
2020                line.width() <= 10,
2021                "line '{line}' exceeds width 10 (width={})",
2022                line.width()
2023            );
2024        }
2025        // Should produce at least 2 lines
2026        assert!(result.lines.len() >= 2);
2027    }
2028
2029    #[test]
2030    fn kp_perfect_fit() {
2031        // Words that perfectly fill each line should have zero badness
2032        let result = wrap_optimal("aaaa bbbb", 9);
2033        // "aaaa bbbb" is 9 chars, fits in one line
2034        assert_eq!(result.lines.len(), 1);
2035        assert_eq!(result.total_cost, 0);
2036    }
2037
2038    #[test]
2039    fn kp_optimal_vs_greedy() {
2040        // Classic example where greedy is suboptimal:
2041        // "aaa bb cc ddddd" with width 6
2042        // Greedy: "aaa bb" / "cc" / "ddddd" → unbalanced (cc line has 4 slack)
2043        // Optimal: "aaa" / "bb cc" / "ddddd" → more balanced
2044        let result = wrap_optimal("aaa bb cc ddddd", 6);
2045
2046        // Verify all lines fit
2047        for line in &result.lines {
2048            assert!(line.width() <= 6, "line '{line}' exceeds width 6");
2049        }
2050
2051        // The greedy solution would put "aaa bb" on line 1.
2052        // The optimal solution should find a lower-cost arrangement.
2053        // Just verify it produces reasonable output.
2054        assert!(result.lines.len() >= 2);
2055    }
2056
2057    #[test]
2058    fn kp_empty_text() {
2059        let result = wrap_optimal("", 80);
2060        assert_eq!(result.lines, vec![""]);
2061        assert_eq!(result.total_cost, 0);
2062    }
2063
2064    #[test]
2065    fn kp_single_word() {
2066        let result = wrap_optimal("hello", 80);
2067        assert_eq!(result.lines, vec!["hello"]);
2068        assert_eq!(result.total_cost, 0); // last line, zero badness
2069    }
2070
2071    #[test]
2072    fn kp_multiline_preserves_newlines() {
2073        let lines = wrap_text_optimal("hello world\nfoo bar baz", 10);
2074        // Each paragraph wrapped independently
2075        assert!(lines.len() >= 2);
2076        // First paragraph lines
2077        assert!(lines[0].width() <= 10);
2078    }
2079
2080    #[test]
2081    fn kp_tokenize_basic() {
2082        let words = kp_tokenize("hello world foo");
2083        assert_eq!(words.len(), 3);
2084        assert_eq!(words[0].content_width, 5);
2085        assert_eq!(words[0].space_width, 1);
2086        assert_eq!(words[1].content_width, 5);
2087        assert_eq!(words[1].space_width, 1);
2088        assert_eq!(words[2].content_width, 3);
2089        assert_eq!(words[2].space_width, 0);
2090    }
2091
2092    #[test]
2093    fn kp_diagnostics_line_badness() {
2094        let result = wrap_optimal("short text here for testing the dp", 15);
2095        // Each line should have a badness value
2096        assert_eq!(result.line_badness.len(), result.lines.len());
2097        // Last line should have badness 0
2098        assert_eq!(
2099            *result.line_badness.last().unwrap(),
2100            0,
2101            "last line should have zero badness"
2102        );
2103    }
2104
2105    #[test]
2106    fn kp_deterministic() {
2107        let text = "The quick brown fox jumps over the lazy dog near a riverbank";
2108        let r1 = wrap_optimal(text, 20);
2109        let r2 = wrap_optimal(text, 20);
2110        assert_eq!(r1.lines, r2.lines);
2111        assert_eq!(r1.total_cost, r2.total_cost);
2112    }
2113
2114    // =========================================================================
2115    // Knuth-Plass Implementation + Pruning Tests (bd-4kq0.5.2)
2116    // =========================================================================
2117
2118    #[test]
2119    fn unit_dp_matches_known() {
2120        // Known optimal break for "aaa bb cc ddddd" at width 6:
2121        // Greedy: "aaa bb" / "cc" / "ddddd" — line "cc" has 4 slack → badness = (4/6)^3*10000 = 2962
2122        // Optimal: "aaa" / "bb cc" / "ddddd" — line "aaa" has 3 slack → 1250, "bb cc" has 1 slack → 4
2123        // So optimal total < greedy total.
2124        let result = wrap_optimal("aaa bb cc ddddd", 6);
2125
2126        // Verify all lines fit
2127        for line in &result.lines {
2128            assert!(line.width() <= 6, "line '{line}' exceeds width 6");
2129        }
2130
2131        // The optimal should produce: "aaa" / "bb cc" / "ddddd"
2132        assert_eq!(
2133            result.lines.len(),
2134            3,
2135            "expected 3 lines, got {:?}",
2136            result.lines
2137        );
2138        assert_eq!(result.lines[0], "aaa");
2139        assert_eq!(result.lines[1], "bb cc");
2140        assert_eq!(result.lines[2], "ddddd");
2141
2142        // Verify last line has zero badness
2143        assert_eq!(*result.line_badness.last().unwrap(), 0);
2144    }
2145
2146    #[test]
2147    fn unit_dp_known_two_line() {
2148        // "hello world" at width 11 → fits in one line
2149        let r1 = wrap_optimal("hello world", 11);
2150        assert_eq!(r1.lines, vec!["hello world"]);
2151        assert_eq!(r1.total_cost, 0);
2152
2153        // "hello world" at width 7 → must split
2154        let r2 = wrap_optimal("hello world", 7);
2155        assert_eq!(r2.lines.len(), 2);
2156        assert_eq!(r2.lines[0], "hello");
2157        assert_eq!(r2.lines[1], "world");
2158        // "hello" has 2 slack on width 7, badness = (2^3 * 10000) / 7^3 = 80000/343 = 233
2159        // "world" is last line, badness = 0
2160        assert!(
2161            r2.total_cost > 0 && r2.total_cost < 300,
2162            "expected cost ~233, got {}",
2163            r2.total_cost
2164        );
2165    }
2166
2167    #[test]
2168    fn unit_dp_optimal_beats_greedy() {
2169        // Construct a case where greedy produces worse results
2170        // "aa bb cc dd ee" at width 6
2171        // Greedy: "aa bb" / "cc dd" / "ee" → slacks: 1, 1, 4 → badness ~0 + 0 + 0(last)
2172        // vs: "aa bb" / "cc dd" / "ee" — actually greedy might be optimal here
2173        //
2174        // Better example: "xx yy zzz aa bbb" at width 7
2175        // Greedy: "xx yy" / "zzz aa" / "bbb" → slacks: 2, 1, 4(last=0)
2176        // Optimal might produce: "xx yy" / "zzz aa" / "bbb" (same)
2177        //
2178        // Use a real suboptimal greedy case:
2179        // "a bb ccc dddd" width 6
2180        // Greedy: "a bb" (slack 2) / "ccc" (slack 3) / "dddd" (slack 2, last=0)
2181        //   → badness: (2/6)^3*10000=370 + (3/6)^3*10000=1250 = 1620
2182        // Optimal: "a" (slack 5) / "bb ccc" (slack 0) / "dddd" (last=0)
2183        //   → badness: (5/6)^3*10000=5787 + 0 = 5787
2184        // Or: "a bb" (slack 2) / "ccc" (slack 3) / "dddd" (last=0)
2185        //   → 370 + 1250 + 0 = 1620 — actually greedy is better here!
2186        //
2187        // The classic example is when greedy makes a very short line mid-paragraph.
2188        // "the quick brown fox" width 10
2189        let greedy = wrap_text("the quick brown fox", 10, WrapMode::Word);
2190        let optimal = wrap_optimal("the quick brown fox", 10);
2191
2192        // Both should produce valid output
2193        for line in &greedy {
2194            assert!(line.width() <= 10);
2195        }
2196        for line in &optimal.lines {
2197            assert!(line.width() <= 10);
2198        }
2199
2200        // Optimal cost should be <= greedy cost (by definition)
2201        // Compute greedy cost for comparison
2202        let mut greedy_cost: u64 = 0;
2203        for (i, line) in greedy.iter().enumerate() {
2204            let slack = 10i64 - line.width() as i64;
2205            let is_last = i == greedy.len() - 1;
2206            greedy_cost += knuth_plass_badness(slack, 10, is_last);
2207        }
2208        assert!(
2209            optimal.total_cost <= greedy_cost,
2210            "optimal ({}) should be <= greedy ({}) for 'the quick brown fox' at width 10",
2211            optimal.total_cost,
2212            greedy_cost
2213        );
2214    }
2215
2216    #[test]
2217    fn perf_wrap_large() {
2218        use std::time::Instant;
2219
2220        // Generate a large paragraph (~1000 words)
2221        let words: Vec<&str> = [
2222            "the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then", "runs",
2223            "back", "to", "its", "den", "in",
2224        ]
2225        .to_vec();
2226
2227        let mut paragraph = String::new();
2228        for i in 0..1000 {
2229            if i > 0 {
2230                paragraph.push(' ');
2231            }
2232            paragraph.push_str(words[i % words.len()]);
2233        }
2234
2235        let iterations = 20;
2236        let start = Instant::now();
2237        for _ in 0..iterations {
2238            let result = wrap_optimal(&paragraph, 80);
2239            assert!(!result.lines.is_empty());
2240        }
2241        let elapsed = start.elapsed();
2242
2243        eprintln!(
2244            "{{\"test\":\"perf_wrap_large\",\"words\":1000,\"width\":80,\"iterations\":{},\"total_ms\":{},\"per_iter_us\":{}}}",
2245            iterations,
2246            elapsed.as_millis(),
2247            elapsed.as_micros() / iterations as u128
2248        );
2249
2250        // Budget: 1000 words × 20 iterations should complete in < 2s
2251        assert!(
2252            elapsed.as_secs() < 2,
2253            "Knuth-Plass DP too slow: {elapsed:?} for {iterations} iterations of 1000 words"
2254        );
2255    }
2256
2257    #[test]
2258    fn kp_pruning_lookahead_bound() {
2259        // Verify MAX_LOOKAHEAD doesn't break correctness for normal text
2260        let text = "a b c d e f g h i j k l m n o p q r s t u v w x y z";
2261        let result = wrap_optimal(text, 10);
2262        for line in &result.lines {
2263            assert!(line.width() <= 10, "line '{line}' exceeds width");
2264        }
2265        // All 26 letters should appear in output
2266        let joined: String = result.lines.join(" ");
2267        for ch in 'a'..='z' {
2268            assert!(joined.contains(ch), "missing letter '{ch}' in output");
2269        }
2270    }
2271
2272    #[test]
2273    fn kp_very_narrow_width() {
2274        // Width 1: every word must be on its own line (or force-broken)
2275        let result = wrap_optimal("ab cd ef", 2);
2276        assert_eq!(result.lines, vec!["ab", "cd", "ef"]);
2277    }
2278
2279    #[test]
2280    fn kp_wide_width_single_line() {
2281        // Width much larger than text: single line, zero cost
2282        let result = wrap_optimal("hello world", 1000);
2283        assert_eq!(result.lines, vec!["hello world"]);
2284        assert_eq!(result.total_cost, 0);
2285    }
2286
2287    // =========================================================================
2288    // Snapshot Wrap Quality (bd-4kq0.5.3)
2289    // =========================================================================
2290
2291    /// FNV-1a hash for deterministic checksums of line break positions.
2292    fn fnv1a_lines(lines: &[String]) -> u64 {
2293        let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
2294        for (i, line) in lines.iter().enumerate() {
2295            for byte in (i as u32)
2296                .to_le_bytes()
2297                .iter()
2298                .chain(line.as_bytes().iter())
2299            {
2300                hash ^= *byte as u64;
2301                hash = hash.wrapping_mul(0x0100_0000_01b3);
2302            }
2303        }
2304        hash
2305    }
2306
2307    #[test]
2308    fn snapshot_wrap_quality() {
2309        // Known paragraphs at multiple widths — verify deterministic and sensible output.
2310        let paragraphs = [
2311            "The quick brown fox jumps over the lazy dog near a riverbank while the sun sets behind the mountains in the distance",
2312            "To be or not to be that is the question whether tis nobler in the mind to suffer the slings and arrows of outrageous fortune",
2313            "aaa bb cc ddddd ee fff gg hhhh ii jjj kk llll mm nnn oo pppp qq rrr ss tttt",
2314        ];
2315
2316        let widths = [20, 40, 60, 80];
2317
2318        for paragraph in &paragraphs {
2319            for &width in &widths {
2320                let result = wrap_optimal(paragraph, width);
2321
2322                // Determinism: same input → same output
2323                let result2 = wrap_optimal(paragraph, width);
2324                assert_eq!(
2325                    fnv1a_lines(&result.lines),
2326                    fnv1a_lines(&result2.lines),
2327                    "non-deterministic wrap at width {width}"
2328                );
2329
2330                // All lines fit within width
2331                for line in &result.lines {
2332                    assert!(line.width() <= width, "line '{line}' exceeds width {width}");
2333                }
2334
2335                // No empty lines (except if paragraph is empty)
2336                if !paragraph.is_empty() {
2337                    for line in &result.lines {
2338                        assert!(!line.is_empty(), "empty line in output at width {width}");
2339                    }
2340                }
2341
2342                // All content preserved
2343                let original_words: Vec<&str> = paragraph.split_whitespace().collect();
2344                let result_words: Vec<&str> = result
2345                    .lines
2346                    .iter()
2347                    .flat_map(|l| l.split_whitespace())
2348                    .collect();
2349                assert_eq!(
2350                    original_words, result_words,
2351                    "content lost at width {width}"
2352                );
2353
2354                // Last line has zero badness
2355                assert_eq!(
2356                    *result.line_badness.last().unwrap(),
2357                    0,
2358                    "last line should have zero badness at width {width}"
2359                );
2360            }
2361        }
2362    }
2363
2364    // =========================================================================
2365    // Perf Wrap Bench with JSONL (bd-4kq0.5.3)
2366    // =========================================================================
2367
2368    #[test]
2369    fn perf_wrap_bench() {
2370        use std::time::Instant;
2371
2372        let sample_words = [
2373            "the", "quick", "brown", "fox", "jumps", "over", "lazy", "dog", "and", "then", "runs",
2374            "back", "to", "its", "den", "in", "forest", "while", "birds", "sing", "above", "trees",
2375            "near",
2376        ];
2377
2378        let scenarios: &[(usize, usize, &str)] = &[
2379            (50, 40, "short_40"),
2380            (50, 80, "short_80"),
2381            (200, 40, "medium_40"),
2382            (200, 80, "medium_80"),
2383            (500, 40, "long_40"),
2384            (500, 80, "long_80"),
2385        ];
2386
2387        for &(word_count, width, label) in scenarios {
2388            // Build paragraph
2389            let mut paragraph = String::new();
2390            for i in 0..word_count {
2391                if i > 0 {
2392                    paragraph.push(' ');
2393                }
2394                paragraph.push_str(sample_words[i % sample_words.len()]);
2395            }
2396
2397            let iterations = 30u32;
2398            let mut times_us = Vec::with_capacity(iterations as usize);
2399            let mut last_lines = 0usize;
2400            let mut last_cost = 0u64;
2401            let mut last_checksum = 0u64;
2402
2403            for _ in 0..iterations {
2404                let start = Instant::now();
2405                let result = wrap_optimal(&paragraph, width);
2406                let elapsed = start.elapsed();
2407
2408                last_lines = result.lines.len();
2409                last_cost = result.total_cost;
2410                last_checksum = fnv1a_lines(&result.lines);
2411                times_us.push(elapsed.as_micros() as u64);
2412            }
2413
2414            times_us.sort();
2415            let len = times_us.len();
2416            let p50 = times_us[len / 2];
2417            let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
2418
2419            // JSONL log
2420            eprintln!(
2421                "{{\"ts\":\"2026-02-03T00:00:00Z\",\"test\":\"perf_wrap_bench\",\"scenario\":\"{label}\",\"words\":{word_count},\"width\":{width},\"lines\":{last_lines},\"badness_total\":{last_cost},\"algorithm\":\"dp\",\"p50_us\":{p50},\"p95_us\":{p95},\"breaks_checksum\":\"0x{last_checksum:016x}\"}}"
2422            );
2423
2424            // Determinism across iterations
2425            let verify = wrap_optimal(&paragraph, width);
2426            assert_eq!(
2427                fnv1a_lines(&verify.lines),
2428                last_checksum,
2429                "non-deterministic: {label}"
2430            );
2431
2432            // Budget: 500 words at p95 should be < 5ms
2433            if word_count >= 500 && p95 > 5000 {
2434                eprintln!("WARN: {label} p95={p95}µs exceeds 5ms budget");
2435            }
2436        }
2437    }
2438}
2439
2440#[cfg(test)]
2441mod proptests {
2442    use super::TestWidth;
2443    use super::*;
2444    use proptest::prelude::*;
2445
2446    proptest! {
2447        #[test]
2448        fn wrapped_lines_never_exceed_width(s in "[a-zA-Z ]{1,100}", width in 5usize..50) {
2449            let lines = wrap_text(&s, width, WrapMode::Char);
2450            for line in &lines {
2451                prop_assert!(line.width() <= width, "Line '{}' exceeds width {}", line, width);
2452            }
2453        }
2454
2455        #[test]
2456        fn wrapped_content_preserved(s in "[a-zA-Z]{1,50}", width in 5usize..20) {
2457            let lines = wrap_text(&s, width, WrapMode::Char);
2458            let rejoined: String = lines.join("");
2459            // Content should be preserved (though whitespace may change)
2460            prop_assert_eq!(s.replace(" ", ""), rejoined.replace(" ", ""));
2461        }
2462
2463        #[test]
2464        fn truncate_never_exceeds_width(s in "[a-zA-Z0-9]{1,50}", width in 5usize..30) {
2465            let result = truncate_with_ellipsis(&s, width, "...");
2466            prop_assert!(result.width() <= width, "Result '{}' exceeds width {}", result, width);
2467        }
2468
2469        #[test]
2470        fn truncate_to_width_exact(s in "[a-zA-Z]{1,50}", width in 1usize..30) {
2471            let result = truncate_to_width(&s, width);
2472            prop_assert!(result.width() <= width);
2473            // If original was longer, result should be at max width or close
2474            if s.width() > width {
2475                // Should be close to width (may be less due to wide char at boundary)
2476                prop_assert!(result.width() >= width.saturating_sub(1) || s.width() <= width);
2477            }
2478        }
2479
2480        #[test]
2481        fn wordchar_mode_respects_width(s in "[a-zA-Z ]{1,100}", width in 5usize..30) {
2482            let lines = wrap_text(&s, width, WrapMode::WordChar);
2483            for line in &lines {
2484                prop_assert!(line.width() <= width, "Line '{}' exceeds width {}", line, width);
2485            }
2486        }
2487
2488        // =====================================================================
2489        // Knuth-Plass Property Tests (bd-4kq0.5.3)
2490        // =====================================================================
2491
2492        /// Property: DP optimal cost is never worse than greedy cost.
2493        #[test]
2494        fn property_dp_vs_greedy(
2495            text in "[a-zA-Z]{1,6}( [a-zA-Z]{1,6}){2,20}",
2496            width in 8usize..40,
2497        ) {
2498            let greedy = wrap_text(&text, width, WrapMode::Word);
2499            let optimal = wrap_optimal(&text, width);
2500
2501            // Compute greedy cost using same badness function
2502            let mut greedy_cost: u64 = 0;
2503            for (i, line) in greedy.iter().enumerate() {
2504                let lw = line.width();
2505                let slack = width as i64 - lw as i64;
2506                let is_last = i == greedy.len() - 1;
2507                if slack >= 0 {
2508                    greedy_cost = greedy_cost.saturating_add(
2509                        knuth_plass_badness(slack, width, is_last)
2510                    );
2511                } else {
2512                    greedy_cost = greedy_cost.saturating_add(PENALTY_FORCE_BREAK);
2513                }
2514            }
2515
2516            prop_assert!(
2517                optimal.total_cost <= greedy_cost,
2518                "DP ({}) should be <= greedy ({}) for width={}: {:?} vs {:?}",
2519                optimal.total_cost, greedy_cost, width, optimal.lines, greedy
2520            );
2521        }
2522
2523        /// Property: DP output lines never exceed width.
2524        #[test]
2525        fn property_dp_respects_width(
2526            text in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){1,15}",
2527            width in 6usize..30,
2528        ) {
2529            let result = wrap_optimal(&text, width);
2530            for line in &result.lines {
2531                prop_assert!(
2532                    line.width() <= width,
2533                    "DP line '{}' (width {}) exceeds target {}",
2534                    line, line.width(), width
2535                );
2536            }
2537        }
2538
2539        /// Property: DP preserves all non-whitespace content.
2540        #[test]
2541        fn property_dp_preserves_content(
2542            text in "[a-zA-Z]{1,5}( [a-zA-Z]{1,5}){1,10}",
2543            width in 8usize..30,
2544        ) {
2545            let result = wrap_optimal(&text, width);
2546            let original_words: Vec<&str> = text.split_whitespace().collect();
2547            let result_words: Vec<&str> = result.lines.iter()
2548                .flat_map(|l| l.split_whitespace())
2549                .collect();
2550            prop_assert_eq!(
2551                original_words, result_words,
2552                "DP should preserve all words"
2553            );
2554        }
2555    }
2556
2557    // ======================================================================
2558    // ParagraphObjective tests (bd-2vr05.15.2.1)
2559    // ======================================================================
2560
2561    #[test]
2562    fn fitness_class_from_ratio() {
2563        assert_eq!(FitnessClass::from_ratio(-0.8), FitnessClass::Tight);
2564        assert_eq!(FitnessClass::from_ratio(-0.5), FitnessClass::Normal);
2565        assert_eq!(FitnessClass::from_ratio(0.0), FitnessClass::Normal);
2566        assert_eq!(FitnessClass::from_ratio(0.49), FitnessClass::Normal);
2567        assert_eq!(FitnessClass::from_ratio(0.5), FitnessClass::Loose);
2568        assert_eq!(FitnessClass::from_ratio(0.99), FitnessClass::Loose);
2569        assert_eq!(FitnessClass::from_ratio(1.0), FitnessClass::VeryLoose);
2570        assert_eq!(FitnessClass::from_ratio(2.0), FitnessClass::VeryLoose);
2571    }
2572
2573    #[test]
2574    fn fitness_class_incompatible() {
2575        assert!(!FitnessClass::Tight.incompatible(FitnessClass::Tight));
2576        assert!(!FitnessClass::Tight.incompatible(FitnessClass::Normal));
2577        assert!(FitnessClass::Tight.incompatible(FitnessClass::Loose));
2578        assert!(FitnessClass::Tight.incompatible(FitnessClass::VeryLoose));
2579        assert!(!FitnessClass::Normal.incompatible(FitnessClass::Loose));
2580        assert!(FitnessClass::Normal.incompatible(FitnessClass::VeryLoose));
2581    }
2582
2583    #[test]
2584    fn objective_default_is_tex_standard() {
2585        let obj = ParagraphObjective::default();
2586        assert_eq!(obj.line_penalty, 10);
2587        assert_eq!(obj.fitness_demerit, 100);
2588        assert_eq!(obj.double_hyphen_demerit, 100);
2589        assert_eq!(obj.badness_scale, BADNESS_SCALE);
2590    }
2591
2592    #[test]
2593    fn objective_terminal_preset() {
2594        let obj = ParagraphObjective::terminal();
2595        assert_eq!(obj.line_penalty, 20);
2596        assert_eq!(obj.min_adjustment_ratio, 0.0);
2597        assert!(obj.max_adjustment_ratio > 2.0);
2598    }
2599
2600    #[test]
2601    fn badness_zero_slack_is_zero() {
2602        let obj = ParagraphObjective::default();
2603        assert_eq!(obj.badness(0, 80), Some(0));
2604    }
2605
2606    #[test]
2607    fn badness_moderate_slack() {
2608        let obj = ParagraphObjective::default();
2609        // 10 cells slack on 80-wide line: ratio = 0.125
2610        // badness = (0.125)^3 * 10000 ≈ 19
2611        let b = obj.badness(10, 80).unwrap();
2612        assert!(b > 0 && b < 100, "badness = {b}");
2613    }
2614
2615    #[test]
2616    fn badness_excessive_slack_infeasible() {
2617        let obj = ParagraphObjective::default();
2618        // ratio = 3.0, exceeds max_adjustment_ratio of 2.0
2619        assert!(obj.badness(240, 80).is_none());
2620    }
2621
2622    #[test]
2623    fn badness_negative_slack_within_bounds() {
2624        let obj = ParagraphObjective::default();
2625        // -40 slack on 80-wide: ratio = -0.5, within min_adjustment_ratio of -1.0
2626        let b = obj.badness(-40, 80);
2627        assert!(b.is_some());
2628    }
2629
2630    #[test]
2631    fn badness_negative_slack_beyond_bounds() {
2632        let obj = ParagraphObjective::default();
2633        // -100 slack on 80-wide: ratio = -1.25, exceeds min_adjustment_ratio of -1.0
2634        assert!(obj.badness(-100, 80).is_none());
2635    }
2636
2637    #[test]
2638    fn badness_terminal_no_compression() {
2639        let obj = ParagraphObjective::terminal();
2640        // Terminal preset: min_adjustment_ratio = 0.0, no compression
2641        assert!(obj.badness(-1, 80).is_none());
2642    }
2643
2644    #[test]
2645    fn demerits_space_break() {
2646        let obj = ParagraphObjective::default();
2647        let d = obj.demerits(10, 80, &BreakPenalty::SPACE).unwrap();
2648        // (line_penalty + badness)^2 + 0^2
2649        let badness = obj.badness(10, 80).unwrap();
2650        let expected = (obj.line_penalty + badness).pow(2);
2651        assert_eq!(d, expected);
2652    }
2653
2654    #[test]
2655    fn demerits_hyphen_break() {
2656        let obj = ParagraphObjective::default();
2657        let d_space = obj.demerits(10, 80, &BreakPenalty::SPACE).unwrap();
2658        let d_hyphen = obj.demerits(10, 80, &BreakPenalty::HYPHEN).unwrap();
2659        // Hyphen break should cost more than space break
2660        assert!(d_hyphen > d_space);
2661    }
2662
2663    #[test]
2664    fn demerits_forced_break() {
2665        let obj = ParagraphObjective::default();
2666        let d = obj.demerits(0, 80, &BreakPenalty::FORCED).unwrap();
2667        // Forced break: just (line_penalty + 0)^2
2668        assert_eq!(d, obj.line_penalty.pow(2));
2669    }
2670
2671    #[test]
2672    fn demerits_infeasible_returns_none() {
2673        let obj = ParagraphObjective::default();
2674        // Slack beyond bounds
2675        assert!(obj.demerits(300, 80, &BreakPenalty::SPACE).is_none());
2676    }
2677
2678    #[test]
2679    fn adjacency_fitness_incompatible() {
2680        let obj = ParagraphObjective::default();
2681        let d = obj.adjacency_demerits(FitnessClass::Tight, FitnessClass::Loose, false, false);
2682        assert_eq!(d, obj.fitness_demerit);
2683    }
2684
2685    #[test]
2686    fn adjacency_fitness_compatible() {
2687        let obj = ParagraphObjective::default();
2688        let d = obj.adjacency_demerits(FitnessClass::Normal, FitnessClass::Loose, false, false);
2689        assert_eq!(d, 0);
2690    }
2691
2692    #[test]
2693    fn adjacency_double_hyphen() {
2694        let obj = ParagraphObjective::default();
2695        let d = obj.adjacency_demerits(FitnessClass::Normal, FitnessClass::Normal, true, true);
2696        assert_eq!(d, obj.double_hyphen_demerit);
2697    }
2698
2699    #[test]
2700    fn adjacency_double_hyphen_plus_fitness() {
2701        let obj = ParagraphObjective::default();
2702        let d = obj.adjacency_demerits(FitnessClass::Tight, FitnessClass::VeryLoose, true, true);
2703        assert_eq!(d, obj.fitness_demerit + obj.double_hyphen_demerit);
2704    }
2705
2706    #[test]
2707    fn widow_penalty_short_last_line() {
2708        let obj = ParagraphObjective::default();
2709        assert_eq!(obj.widow_demerits(5), obj.widow_demerit);
2710        assert_eq!(obj.widow_demerits(14), obj.widow_demerit);
2711        assert_eq!(obj.widow_demerits(15), 0);
2712        assert_eq!(obj.widow_demerits(80), 0);
2713    }
2714
2715    #[test]
2716    fn orphan_penalty_short_first_line() {
2717        let obj = ParagraphObjective::default();
2718        assert_eq!(obj.orphan_demerits(10), obj.orphan_demerit);
2719        assert_eq!(obj.orphan_demerits(19), obj.orphan_demerit);
2720        assert_eq!(obj.orphan_demerits(20), 0);
2721        assert_eq!(obj.orphan_demerits(80), 0);
2722    }
2723
2724    #[test]
2725    fn adjustment_ratio_computation() {
2726        let obj = ParagraphObjective::default();
2727        let r = obj.adjustment_ratio(10, 80);
2728        assert!((r - 0.125).abs() < 1e-10);
2729    }
2730
2731    #[test]
2732    fn adjustment_ratio_zero_width() {
2733        let obj = ParagraphObjective::default();
2734        assert_eq!(obj.adjustment_ratio(5, 0), 0.0);
2735    }
2736
2737    #[test]
2738    fn badness_zero_width_zero_slack() {
2739        let obj = ParagraphObjective::default();
2740        assert_eq!(obj.badness(0, 0), Some(0));
2741    }
2742
2743    #[test]
2744    fn badness_zero_width_nonzero_slack() {
2745        let obj = ParagraphObjective::default();
2746        assert!(obj.badness(5, 0).is_none());
2747    }
2748}