use ftui_text::{Segment, WidthCache, display_width, grapheme_width};
trait DisplayWidthExt {
fn width(&self) -> usize;
}
impl DisplayWidthExt for str {
fn width(&self) -> usize {
display_width(self)
}
}
impl DisplayWidthExt for String {
fn width(&self) -> usize {
display_width(self)
}
}
#[derive(Debug, Clone)]
struct WidthTestCase {
input: &'static str,
description: &'static str,
expected_unicode_width: usize,
terminal_notes: Option<&'static str>,
}
impl WidthTestCase {
const fn new(input: &'static str, description: &'static str, expected: usize) -> Self {
Self {
input,
description,
expected_unicode_width: expected,
terminal_notes: None,
}
}
const fn with_notes(
input: &'static str,
description: &'static str,
expected: usize,
notes: &'static str,
) -> Self {
Self {
input,
description,
expected_unicode_width: expected,
terminal_notes: Some(notes),
}
}
}
const ASCII_TESTS: &[WidthTestCase] = &[
WidthTestCase::new("a", "lowercase letter", 1),
WidthTestCase::new("Z", "uppercase letter", 1),
WidthTestCase::new("0", "digit", 1),
WidthTestCase::new(" ", "space", 1),
WidthTestCase::new("!", "punctuation", 1),
WidthTestCase::new("~", "tilde", 1),
WidthTestCase::new("hello", "word", 5),
WidthTestCase::new("Hello, World!", "sentence", 13),
WidthTestCase::new(" ", "multiple spaces", 4),
WidthTestCase::new("abc123", "alphanumeric", 6),
WidthTestCase::new("{}[]()<>", "brackets", 8),
WidthTestCase::new("@#$%^&*", "symbols", 7),
WidthTestCase::new("hello world foo bar", "multi-word", 19),
];
#[test]
fn ascii_width_tests() {
for case in ASCII_TESTS {
let width = case.input.width();
assert_eq!(
width, case.expected_unicode_width,
"ASCII test '{}' ({}) - expected {}, got {}",
case.input, case.description, case.expected_unicode_width, width
);
}
}
const CJK_TESTS: &[WidthTestCase] = &[
WidthTestCase::new("\u{4E00}", "CJK U+4E00 (one)", 2),
WidthTestCase::new("\u{4E2D}", "CJK U+4E2D (middle/China)", 2),
WidthTestCase::new("\u{6587}", "CJK U+6587 (text/writing)", 2),
WidthTestCase::new("\u{5B57}", "CJK U+5B57 (character)", 2),
WidthTestCase::new("\u{4F60}\u{597D}", "ni hao (hello)", 4),
WidthTestCase::new("\u{8C22}\u{8C22}", "xie xie (thank you)", 4),
WidthTestCase::new("\u{65E5}\u{672C}", "nihon (Japan)", 4),
WidthTestCase::new("\u{8A9E}", "go (language)", 2),
WidthTestCase::new("\u{D55C}\u{AE00}", "hangul (Korean script)", 4),
WidthTestCase::new("\u{C548}\u{B155}", "annyeong (hello)", 4),
WidthTestCase::new("\u{4E2D}\u{65E5}\u{D55C}", "Chinese+Japanese+Korean", 6),
WidthTestCase::new("\u{20000}", "CJK Extension B char", 2),
];
#[test]
fn cjk_width_tests() {
for case in CJK_TESTS {
let width = case.input.width();
assert_eq!(
width, case.expected_unicode_width,
"CJK test '{}' ({}) - expected {}, got {}",
case.input, case.description, case.expected_unicode_width, width
);
}
}
const FULLWIDTH_TESTS: &[WidthTestCase] = &[
WidthTestCase::new("\u{FF21}", "fullwidth A", 2),
WidthTestCase::new("\u{FF3A}", "fullwidth Z", 2),
WidthTestCase::new("\u{FF41}", "fullwidth a", 2),
WidthTestCase::new("\u{FF5A}", "fullwidth z", 2),
WidthTestCase::new("\u{FF10}", "fullwidth 0", 2),
WidthTestCase::new("\u{FF19}", "fullwidth 9", 2),
WidthTestCase::new("\u{FF01}", "fullwidth !", 2),
WidthTestCase::new("\u{FF1F}", "fullwidth ?", 2),
WidthTestCase::new("\u{FF08}\u{FF09}", "fullwidth ()", 4),
WidthTestCase::new("\u{FFE5}", "fullwidth yen sign", 2),
WidthTestCase::new("\u{FFE1}", "fullwidth pound sign", 2),
WidthTestCase::new("\u{FF21}\u{FF22}\u{FF23}", "fullwidth ABC", 6),
];
#[test]
fn fullwidth_width_tests() {
for case in FULLWIDTH_TESTS {
let width = case.input.width();
assert_eq!(
width, case.expected_unicode_width,
"Fullwidth test '{}' ({}) - expected {}, got {}",
case.input, case.description, case.expected_unicode_width, width
);
}
}
const HALFWIDTH_TESTS: &[WidthTestCase] = &[
WidthTestCase::new("\u{FF66}", "halfwidth wo", 1),
WidthTestCase::new("\u{FF67}", "halfwidth small a", 1),
WidthTestCase::new("\u{FF71}", "halfwidth a", 1),
WidthTestCase::new("\u{FF72}", "halfwidth i", 1),
WidthTestCase::new("\u{FF73}", "halfwidth u", 1),
WidthTestCase::new("\u{FF71}\u{FF72}\u{FF73}", "halfwidth aiu", 3),
WidthTestCase::new("\u{FF64}", "halfwidth comma", 1),
WidthTestCase::new("\u{FF65}", "halfwidth middle dot", 1),
];
#[test]
fn halfwidth_width_tests() {
for case in HALFWIDTH_TESTS {
let width = case.input.width();
assert_eq!(
width, case.expected_unicode_width,
"Halfwidth test '{}' ({}) - expected {}, got {}",
case.input, case.description, case.expected_unicode_width, width
);
}
}
const EMOJI_BASIC_TESTS: &[WidthTestCase] = &[
WidthTestCase::new("\u{1F600}", "grinning face", 2),
WidthTestCase::new("\u{1F602}", "tears of joy", 2),
WidthTestCase::new("\u{1F44D}", "thumbs up", 2),
WidthTestCase::new("\u{2764}", "red heart", 1), WidthTestCase::new("\u{2764}\u{FE0F}", "red heart with VS16", 1),
WidthTestCase::new("\u{1F389}", "party popper", 2),
WidthTestCase::new("\u{1F680}", "rocket", 2),
WidthTestCase::new("\u{1F4BB}", "laptop", 2),
WidthTestCase::new("\u{1F3E0}", "house", 2),
WidthTestCase::new("\u{26A1}\u{FE0F}", "high voltage (emoji)", 2),
WidthTestCase::new("\u{1F436}", "dog face", 2),
WidthTestCase::new("\u{1F431}", "cat face", 2),
WidthTestCase::new("\u{1F98A}", "fox face", 2),
WidthTestCase::new("\u{1F355}", "pizza", 2),
WidthTestCase::new("\u{1F354}", "hamburger", 2),
WidthTestCase::new("\u{1F31F}", "glowing star", 2),
WidthTestCase::new("\u{1F308}", "rainbow", 2),
];
#[test]
fn text_default_emoji_with_vs16_width_is_narrow() {
let cases = ["⚙️", "🖼️"];
for case in cases {
let width = grapheme_width(case);
assert_eq!(
width, 1,
"Expected text-default+VS16 '{}' to be width 1 (terminal-realistic)",
case
);
}
}
#[test]
fn file_browser_icons_inherently_wide() {
let cases = [
"📁", "🔗", "🦀", "🐍", "📜", "📝", "🎵", "🎬", "⚡️", "📄", "🏠",
];
for case in cases {
let width = grapheme_width(case);
assert_eq!(
width, 2,
"Expected inherently-wide icon '{}' to be width 2",
case
);
}
}
#[test]
fn file_browser_icons_text_default_vs16() {
let cases = ["⚙️", "🖼️"];
for case in cases {
let width = grapheme_width(case);
assert_eq!(
width, 1,
"Expected text-default+VS16 icon '{}' to be width 1 (terminal-realistic)",
case
);
}
}
#[test]
fn emoji_clusters_clamp_to_two_cells() {
let cases = [
"\u{1F1FA}\u{1F1F8}", "\u{1F44D}\u{1F3FB}", "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}", ];
for case in cases {
let width = grapheme_width(case);
assert_eq!(
width, 2,
"Expected emoji cluster '{}' to clamp to width 2",
case
);
}
}
#[test]
fn emoji_basic_width_tests() {
for case in EMOJI_BASIC_TESTS {
let width = case.input.width();
assert_eq!(
width, case.expected_unicode_width,
"Emoji test '{}' ({}) - expected {}, got {}",
case.input, case.description, case.expected_unicode_width, width
);
}
}
const EMOJI_SKIN_TONE_TESTS: &[WidthTestCase] = &[
WidthTestCase::with_notes(
"\u{1F44D}\u{1F3FB}",
"thumbs up light skin",
4,
"Terminal may render as 2",
),
WidthTestCase::with_notes(
"\u{1F44D}\u{1F3FC}",
"thumbs up med-light skin",
4,
"Terminal may render as 2",
),
WidthTestCase::with_notes(
"\u{1F44D}\u{1F3FD}",
"thumbs up medium skin",
4,
"Terminal may render as 2",
),
WidthTestCase::with_notes(
"\u{1F44D}\u{1F3FE}",
"thumbs up med-dark skin",
4,
"Terminal may render as 2",
),
WidthTestCase::with_notes(
"\u{1F44D}\u{1F3FF}",
"thumbs up dark skin",
4,
"Terminal may render as 2",
),
WidthTestCase::with_notes(
"\u{1F44B}\u{1F3FB}",
"waving hand light skin",
4,
"Terminal may render as 2",
),
WidthTestCase::with_notes(
"\u{1F9D1}\u{1F3FD}",
"person medium skin",
4,
"Terminal may render as 2",
),
];
#[test]
fn emoji_skin_tone_width_tests() {
for case in EMOJI_SKIN_TONE_TESTS {
let width = case.input.width();
assert!(
width >= 2,
"Emoji with skin tone '{}' ({}) should be at least 2, got {}. Notes: {:?}",
case.input,
case.description,
width,
case.terminal_notes
);
}
}
const ZWJ_SEQUENCE_TESTS: &[WidthTestCase] = &[
WidthTestCase::with_notes(
"\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}",
"family MWG",
6,
"Terminal typically renders as 2 cells",
),
WidthTestCase::with_notes(
"\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}\u{200D}\u{1F466}",
"family MWGB",
8,
"Terminal typically renders as 2 cells",
),
WidthTestCase::with_notes(
"\u{1F469}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F468}",
"couple with heart",
6,
"Complex ZWJ sequence",
),
WidthTestCase::with_notes(
"\u{1F468}\u{200D}\u{1F4BB}",
"man technologist",
4,
"Terminal may render as 2",
),
WidthTestCase::with_notes(
"\u{1F469}\u{200D}\u{1F52C}",
"woman scientist",
4,
"Terminal may render as 2",
),
WidthTestCase::with_notes(
"\u{1F469}\u{200D}\u{1F3A8}",
"woman artist",
4,
"Terminal may render as 2",
),
WidthTestCase::with_notes(
"\u{1F3F3}\u{FE0F}\u{200D}\u{1F308}",
"rainbow flag",
4,
"Terminal often renders as 2",
),
WidthTestCase::with_notes(
"\u{1F3F4}\u{200D}\u{2620}\u{FE0F}",
"pirate flag",
3,
"Terminal may vary",
),
];
#[test]
fn zwj_sequence_width_tests() {
for case in ZWJ_SEQUENCE_TESTS {
let width = case.input.width();
assert!(
width >= 1,
"ZWJ sequence '{}' ({}) should have width >= 1, got {}. Notes: {:?}",
case.input,
case.description,
width,
case.terminal_notes
);
}
}
const FLAG_TESTS: &[WidthTestCase] = &[
WidthTestCase::with_notes(
"\u{1F1FA}\u{1F1F8}",
"US flag",
4,
"Terminal usually renders as 2",
),
WidthTestCase::with_notes(
"\u{1F1EF}\u{1F1F5}",
"Japan flag",
4,
"Terminal usually renders as 2",
),
WidthTestCase::with_notes(
"\u{1F1EC}\u{1F1E7}",
"UK flag",
4,
"Terminal usually renders as 2",
),
WidthTestCase::with_notes(
"\u{1F1EB}\u{1F1F7}",
"France flag",
4,
"Terminal usually renders as 2",
),
WidthTestCase::new("\u{1F1FA}", "single regional A", 1),
];
#[test]
fn flag_width_tests() {
for case in FLAG_TESTS {
let width = case.input.width();
assert!(
width >= 1,
"Flag '{}' ({}) should have width >= 1, got {}",
case.input,
case.description,
width
);
}
}
const COMBINING_TESTS: &[WidthTestCase] = &[
WidthTestCase::new("e\u{0301}", "e with combining acute (e)", 1),
WidthTestCase::new("a\u{0300}", "a with combining grave (a)", 1),
WidthTestCase::new("u\u{0308}", "u with combining diaeresis (u)", 1),
WidthTestCase::new("o\u{0302}\u{0323}", "o with circumflex + dot below", 1),
WidthTestCase::new("\u{0301}", "standalone combining acute", 0),
WidthTestCase::new("\u{0308}", "standalone combining diaeresis", 0),
WidthTestCase::new("a\u{0302}\u{0301}", "a with circumflex and acute", 1),
WidthTestCase::new("\u{0915}\u{093F}", "ka + i vowel sign", 1),
WidthTestCase::new("\u{05D0}\u{05B8}", "alef with qamats", 1),
];
#[test]
fn combining_character_width_tests() {
for case in COMBINING_TESTS {
let width = case.input.width();
assert_eq!(
width, case.expected_unicode_width,
"Combining test '{}' ({}) - expected {}, got {}",
case.input, case.description, case.expected_unicode_width, width
);
}
}
const CONTROL_TESTS: &[WidthTestCase] = &[
WidthTestCase::new("\n", "newline", 1),
WidthTestCase::new("\r", "carriage return", 1),
WidthTestCase::new("\x00", "null", 0),
WidthTestCase::new("\x07", "bell", 0),
WidthTestCase::new("\x08", "backspace", 0),
WidthTestCase::new("\x1B", "escape", 0),
WidthTestCase::new("\t", "tab", 1),
WidthTestCase::new("\x7F", "delete", 0),
WidthTestCase::new("\u{0080}", "padding character", 0),
WidthTestCase::new("\u{0085}", "next line", 0),
WidthTestCase::new("\u{009F}", "application program command", 0),
];
#[test]
fn control_character_width_tests() {
for case in CONTROL_TESTS {
let width = case.input.width();
assert_eq!(
width,
case.expected_unicode_width,
"Control char '{}' ({}) - expected {}, got {}",
case.input.escape_unicode(),
case.description,
case.expected_unicode_width,
width
);
}
}
const VARIATION_SELECTOR_TESTS: &[WidthTestCase] = &[
WidthTestCase::new("\u{2764}\u{FE0E}", "heart with VS15 (text)", 1),
WidthTestCase::new("\u{2764}\u{FE0F}", "heart with VS16 (emoji)", 1),
WidthTestCase::new("#\u{FE0F}\u{20E3}", "keycap #", 1),
WidthTestCase::new("1\u{FE0F}\u{20E3}", "keycap 1", 1),
WidthTestCase::new("\u{2B50}\u{FE0F}", "star with VS16", 2),
WidthTestCase::new("\u{FE0F}", "standalone VS16", 0),
WidthTestCase::new("\u{FE0E}", "standalone VS15", 0),
];
#[test]
fn variation_selector_width_tests() {
for case in VARIATION_SELECTOR_TESTS {
let width = case.input.width();
assert_eq!(
width,
case.expected_unicode_width,
"Variation selector test '{}' ({}) - expected {}, got {}",
case.input.escape_unicode(),
case.description,
case.expected_unicode_width,
width
);
}
}
const AMBIGUOUS_TESTS: &[WidthTestCase] = &[
WidthTestCase::new("\u{03B1}", "alpha", 1),
WidthTestCase::new("\u{03B2}", "beta", 1),
WidthTestCase::new("\u{03C0}", "pi", 1),
WidthTestCase::new("\u{221E}", "infinity", 1),
WidthTestCase::new("\u{2211}", "summation", 1),
WidthTestCase::new("\u{221A}", "square root", 1),
WidthTestCase::new("\u{2190}", "left arrow", 1),
WidthTestCase::new("\u{2192}", "right arrow", 1),
WidthTestCase::new("\u{2191}", "up arrow", 1),
WidthTestCase::new("\u{2500}", "box drawing horizontal", 1),
WidthTestCase::new("\u{2502}", "box drawing vertical", 1),
WidthTestCase::new("\u{250C}", "box drawing corner", 1),
WidthTestCase::new("\u{20AC}", "euro sign", 1),
WidthTestCase::new("\u{00A3}", "pound sign", 1),
WidthTestCase::new("\u{00A5}", "yen sign", 1),
WidthTestCase::new("\u{00A9}", "copyright", 1),
WidthTestCase::new("\u{00AE}", "registered", 1),
WidthTestCase::new("\u{2122}", "trademark", 1),
];
#[test]
fn ambiguous_width_tests() {
for case in AMBIGUOUS_TESTS {
let width = case.input.width();
assert_eq!(
width, case.expected_unicode_width,
"Ambiguous test '{}' ({}) - expected {}, got {}",
case.input, case.description, case.expected_unicode_width, width
);
}
}
const PUA_TESTS: &[WidthTestCase] = &[
WidthTestCase::new("\u{E000}", "PUA start", 1),
WidthTestCase::new("\u{F8FF}", "Apple logo (PUA)", 1),
WidthTestCase::new("\u{F000}", "PUA middle", 1),
WidthTestCase::new("\u{100000}", "Supplementary PUA-A", 1),
];
#[test]
fn pua_width_tests() {
for case in PUA_TESTS {
let width = case.input.width();
assert_eq!(
width,
case.expected_unicode_width,
"PUA test '{}' ({}) - expected {}, got {}",
case.input.escape_unicode(),
case.description,
case.expected_unicode_width,
width
);
}
}
const ZERO_WIDTH_TESTS: &[WidthTestCase] = &[
WidthTestCase::new("\u{200B}", "zero-width space", 0),
WidthTestCase::new("\u{200C}", "zero-width non-joiner", 0),
WidthTestCase::new("\u{200D}", "zero-width joiner", 0),
WidthTestCase::new("\u{FEFF}", "byte order mark", 0),
WidthTestCase::new("\u{2060}", "word joiner", 0),
WidthTestCase::new("\u{00AD}", "soft hyphen", 0),
];
#[test]
fn zero_width_tests() {
for case in ZERO_WIDTH_TESTS {
let width = case.input.width();
assert_eq!(
width,
case.expected_unicode_width,
"Zero-width test '{}' ({}) - expected {}, got {}",
case.input.escape_unicode(),
case.description,
case.expected_unicode_width,
width
);
}
}
const MIXED_TESTS: &[WidthTestCase] = &[
WidthTestCase::new("Hello\u{4E16}\u{754C}", "Hello + world (CJK)", 9),
WidthTestCase::new("Hi \u{1F44B}", "Hi + wave", 5),
WidthTestCase::new("\u{4F60}\u{597D}\u{1F600}", "nihao + grinning", 6),
WidthTestCase::new(
"Test: \u{4E2D}\u{6587} (\u{1F600})",
"Test: Chinese (emoji)",
15,
),
WidthTestCase::new("", "empty string", 0),
WidthTestCase::new(" ", "spaces only", 3),
];
#[test]
fn mixed_content_width_tests() {
for case in MIXED_TESTS {
let width = case.input.width();
assert_eq!(
width, case.expected_unicode_width,
"Mixed test '{}' ({}) - expected {}, got {}",
case.input, case.description, case.expected_unicode_width, width
);
}
}
#[test]
fn segment_width_matches_display_width() {
let test_strings = [
"Hello",
"\u{4E2D}\u{6587}",
"\u{1F600}",
"a\u{0301}",
"test \u{1F44D} ok",
];
for s in test_strings {
let segment = Segment::text(s);
let segment_width = segment.cell_length();
let display_width = s.width();
assert_eq!(
segment_width, display_width,
"Segment width should match display width for '{}'",
s
);
}
}
#[test]
fn width_cache_consistency() {
let mut cache = WidthCache::new(1000);
let test_strings = [
"hello",
"\u{4E2D}\u{6587}",
"\u{1F600}\u{1F602}",
"test\u{0301}",
"\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}",
];
for s in test_strings {
let cached = cache.get_or_compute(s);
let direct = s.width();
assert_eq!(
cached,
direct,
"Cached width should match display width for '{}'",
s.escape_unicode()
);
let cached2 = cache.get_or_compute(s);
assert_eq!(cached, cached2, "Cache should return consistent values");
}
}
mod proptests {
use super::DisplayWidthExt;
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn width_never_panics(s in "\\PC{1,100}") {
let _width = s.width();
}
#[test]
fn width_is_additive_for_ascii(a in "[a-zA-Z0-9 ]{1,20}", b in "[a-zA-Z0-9 ]{1,20}") {
let combined = format!("{}{}", a, b);
let expected = a.width() + b.width();
prop_assert_eq!(combined.width(), expected);
}
#[test]
fn empty_string_width_zero(_dummy in Just(())) {
prop_assert_eq!("".width(), 0);
}
#[test]
fn ascii_printable_width_one(c in prop::char::range(' ', '~')) {
let s = c.to_string();
prop_assert_eq!(s.width(), 1, "ASCII char '{}' should have width 1", c);
}
#[test]
fn control_chars_width_one(c in prop::char::range('\x00', '\x1F')) {
let s = c.to_string();
let expected = match c {
'\t' | '\n' | '\r' => 1,
_ => 0,
};
prop_assert_eq!(
s.width(),
expected,
"Control char {:?} has width {} in display width",
c,
expected
);
}
#[test]
fn cjk_ideograph_width_two(c in prop::char::range('\u{4E00}', '\u{9FFF}')) {
let s = c.to_string();
prop_assert_eq!(s.width(), 2, "CJK char {} should have width 2", c);
}
#[test]
fn segment_width_consistency(s in "[a-zA-Z0-9 \u{4E00}-\u{4E10}]{1,30}") {
let segment = Segment::text(s.as_str());
let segment_width = segment.cell_length();
let direct_width = s.width();
prop_assert_eq!(segment_width, direct_width);
}
#[test]
fn cache_consistency(s in "[a-zA-Z0-9 ]{1,30}") {
let mut cache = WidthCache::new(100);
let first = cache.get_or_compute(&s);
let second = cache.get_or_compute(&s);
prop_assert_eq!(first, second);
}
}
}
#[test]
fn stress_test_many_graphemes() {
let mut s = String::new();
for _ in 0..100 {
s.push('\u{1F600}'); s.push('\u{4E2D}'); s.push_str("abc"); s.push_str("e\u{0301}"); }
let width = s.width();
assert_eq!(width, 800);
}
#[test]
fn stress_test_zwj_chain() {
let mut s = String::new();
for _ in 0..10 {
s.push('\u{1F468}');
s.push('\u{200D}');
}
s.push('\u{1F468}');
let _width = s.width();
}
#[test]
fn stress_test_combining_chain() {
let mut s = String::from("a");
for _ in 0..50 {
s.push('\u{0301}'); }
let width = s.width();
assert_eq!(width, 1);
}
#[test]
fn tag_sequence_flag_england() {
let england = "\u{1F3F4}\u{E0067}\u{E0062}\u{E0065}\u{E006E}\u{E0067}\u{E007F}";
let width = grapheme_width(england);
assert!(
width >= 1,
"Tag sequence flag (England) must not panic and should have width >= 1, got {}",
width
);
}
#[test]
fn victory_hand_skin_tone_vs16() {
let s = "\u{270C}\u{1F3FD}\u{FE0F}";
let width = grapheme_width(s);
assert!(
width >= 1,
"Victory hand + skin tone + VS16 must not panic, got width {}",
width
);
}
#[test]
fn writing_hand_skin_tone() {
let s = "\u{270D}\u{1F3FB}";
let width = grapheme_width(s);
assert!(
width >= 1,
"Writing hand + skin tone must not panic, got width {}",
width
);
}
#[test]
fn couple_with_heart_skin_tones() {
let s = "\u{1F469}\u{1F3FD}\u{200D}\u{2764}\u{FE0F}\u{200D}\u{1F468}\u{1F3FB}";
let width = grapheme_width(s);
assert!(
width >= 1,
"Couple with heart + skin tones must not panic, got width {}",
width
);
}
#[test]
fn multiple_consecutive_vs16() {
let s = "\u{2764}\u{FE0F}\u{FE0F}";
let width = grapheme_width(s);
assert!(
width <= 2,
"Heart + double VS16 should have width <= 2, got {}",
width
);
}
#[test]
fn mixed_vs15_then_vs16() {
let s = "\u{2764}\u{FE0E}\u{FE0F}";
let _width = grapheme_width(s);
}
#[test]
fn bare_vs16_grapheme_width() {
let width = grapheme_width("\u{FE0F}");
assert_eq!(width, 0, "Bare VS16 should be zero-width");
}
#[test]
fn bare_vs15_grapheme_width() {
let width = grapheme_width("\u{FE0E}");
assert_eq!(width, 0, "Bare VS15 should be zero-width");
}
#[test]
fn keycap_digit_sequences() {
for digit in '0'..='9' {
let s = format!("{}\u{FE0F}\u{20E3}", digit);
let width = grapheme_width(&s);
assert!(
width >= 1,
"Keycap '{}' must not panic, got width {}",
digit,
width
);
}
}
#[test]
fn keycap_star_sequence() {
let s = "*\u{FE0F}\u{20E3}";
let width = grapheme_width(s);
assert!(width >= 1, "Keycap '*' must not panic, got width {}", width);
}
#[test]
fn emoji_presentation_yes_unaffected_by_vs16_strip() {
let with_vs16 = "\u{1F600}\u{FE0F}";
let without = "\u{1F600}";
let w1 = grapheme_width(with_vs16);
let w2 = grapheme_width(without);
assert_eq!(
w1, w2,
"VS16 on Emoji_Presentation=Yes should not change width"
);
assert_eq!(w2, 2);
}
#[test]
fn document_terminal_differences() {
let cases = [
(
"\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}",
"family emoji",
),
("\u{1F44D}\u{1F3FB}", "thumbs up with skin tone"),
("\u{1F1FA}\u{1F1F8}", "US flag"),
];
for (s, desc) in cases {
let display_width = s.width();
println!("{}: display-width={}", desc, display_width);
}
}
#[test]
fn corpus_has_sufficient_coverage() {
let total_cases = ASCII_TESTS.len()
+ CJK_TESTS.len()
+ FULLWIDTH_TESTS.len()
+ HALFWIDTH_TESTS.len()
+ EMOJI_BASIC_TESTS.len()
+ EMOJI_SKIN_TONE_TESTS.len()
+ ZWJ_SEQUENCE_TESTS.len()
+ FLAG_TESTS.len()
+ COMBINING_TESTS.len()
+ CONTROL_TESTS.len()
+ VARIATION_SELECTOR_TESTS.len()
+ AMBIGUOUS_TESTS.len()
+ PUA_TESTS.len()
+ ZERO_WIDTH_TESTS.len()
+ MIXED_TESTS.len();
println!("Explicit test cases: {}", total_cases);
assert!(
total_cases >= 100,
"Should have at least 100 explicit test cases, have {}",
total_cases
);
}