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