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