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