1
//! CSS Counter Support
2
//!
3
//! Implements CSS counters for ordered lists and generated content as per CSS spec.
4
//! Counters are cached per-node in the `LayoutCache` and computed during layout traversal.
5
//!
6
//! This module is the single canonical home for numbering-system formatting
7
//! (decimal, roman, alphabetic, greek). The low-level converters
8
//! [`to_roman`], [`to_alphabetic`], and [`to_greek`] back both [`format_counter`]
9
//! (list markers) and `super::pagination::CounterFormat` (paged-media page counters),
10
//! so the same number renders identically in both contexts.
11

            
12
use alloc::string::String;
13

            
14
use azul_css::props::style::lists::StyleListStyleType;
15

            
16
/// Formats a counter value into a string based on the list style type.
17
///
18
/// Implements CSS counter styles for various numbering systems.
19
#[must_use]
20
2085
pub fn format_counter(value: i32, style: StyleListStyleType) -> String {
21
2085
    match style {
22
83
        StyleListStyleType::None => String::new(),
23
540
        StyleListStyleType::Disc => "•".to_string(),
24
77
        StyleListStyleType::Circle => "◦".to_string(),
25
77
        StyleListStyleType::Square => "▪".to_string(),
26
150
        StyleListStyleType::Decimal => value.to_string(),
27
127
        StyleListStyleType::DecimalLeadingZero => format!("{value:02}"),
28
186
        StyleListStyleType::LowerAlpha => decimal_fallback(value, with_sign(value, |n| to_alphabetic(n, false))),
29
130
        StyleListStyleType::UpperAlpha => decimal_fallback(value, with_sign(value, |n| to_alphabetic(n, true))),
30
377
        StyleListStyleType::LowerRoman => with_sign(value, |n| to_roman(n, false)),
31
139
        StyleListStyleType::UpperRoman => with_sign(value, |n| to_roman(n, true)),
32
113
        StyleListStyleType::LowerGreek => decimal_fallback(value, with_sign(value, |n| to_greek(n, false))),
33
86
        StyleListStyleType::UpperGreek => decimal_fallback(value, with_sign(value, |n| to_greek(n, true))),
34
    }
35
2085
}
36

            
37
/// CSS fallback: when an alphabetic/greek counter style cannot represent a value
38
/// (e.g. `value == 0`, where `to_alphabetic`/`to_greek` yield an empty string), the
39
/// spec falls back to `decimal` so the marker is never blank.
40
536
fn decimal_fallback(value: i32, formatted: String) -> String {
41
536
    if formatted.is_empty() {
42
50
        value.to_string()
43
    } else {
44
486
        formatted
45
    }
46
536
}
47

            
48
// --- Formatting Helpers ---
49

            
50
/// Formats the magnitude of `value`, prefixing `-` for negatives.
51
///
52
/// Avoids the lossy `value as u32` cast: a negative counter such as `-3` in
53
/// `lower-roman` formats as `-iii` instead of wrapping to a huge unsigned value.
54
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded graphics/coord/counter/fixed-point cast
55
1061
fn with_sign<F: Fn(usize) -> String>(value: i32, format: F) -> String {
56
1061
    if value < 0 {
57
190
        let magnitude = i64::from(value).unsigned_abs() as usize;
58
190
        format!("-{}", format(magnitude))
59
    } else {
60
871
        format(value as usize)
61
    }
62
1061
}
63

            
64
/// Converts a number to alphabetic representation (a, b, c, ..., z, aa, ab, ...).
65
///
66
/// This implements the CSS `lower-alpha` and `upper-alpha` counter styles.
67
#[allow(clippy::cast_possible_truncation)] // bounded graphics/coord/counter/fixed-point cast
68
20372
pub(crate) fn to_alphabetic(mut num: usize, uppercase: bool) -> String {
69
20372
    if num == 0 {
70
24
        return String::new();
71
20348
    }
72

            
73
20348
    let mut result = String::new();
74
20348
    let base = if uppercase { b'A' } else { b'a' };
75

            
76
75985
    while num > 0 {
77
55637
        let remainder = ((num - 1) % 26) as u8;
78
55637
        result.insert(0, (base + remainder) as char);
79
55637
        num = (num - 1) / 26;
80
55637
    }
81

            
82
20348
    result
83
20372
}
84

            
85
/// Converts a number to Roman numeral representation.
86
///
87
/// This implements the CSS `lower-roman` and `upper-roman` counter styles.
88
16564
pub(crate) fn to_roman(mut num: usize, uppercase: bool) -> String {
89
    const MAX_ROMAN: usize = 3999;
90
16564
    if num == 0 {
91
23
        return "0".to_string();
92
16541
    }
93
16541
    if num > MAX_ROMAN {
94
        // Roman numerals traditionally don't go beyond 3999
95
71
        return num.to_string();
96
16470
    }
97

            
98
16470
    let numerals = [
99
16470
        (1000, "m"),
100
16470
        (900, "cm"),
101
16470
        (500, "d"),
102
16470
        (400, "cd"),
103
16470
        (100, "c"),
104
16470
        (90, "xc"),
105
16470
        (50, "l"),
106
16470
        (40, "xl"),
107
16470
        (10, "x"),
108
16470
        (9, "ix"),
109
16470
        (5, "v"),
110
16470
        (4, "iv"),
111
16470
        (1, "i"),
112
16470
    ];
113

            
114
16470
    let mut result = String::new();
115
230580
    for (value, numeral) in &numerals {
116
325525
        while num >= *value {
117
111415
            result.push_str(numeral);
118
111415
            num -= value;
119
111415
        }
120
    }
121

            
122
16470
    if uppercase {
123
8123
        result.to_uppercase()
124
    } else {
125
8347
        result
126
    }
127
16564
}
128

            
129
/// Converts a number to Greek letter representation.
130
///
131
/// This implements the CSS `lower-greek` and `upper-greek` counter styles.
132
/// Supports α, β, γ, ... (24 letters). For numbers > 24, wraps as αα, αβ, etc.
133
14227
pub(crate) fn to_greek(num: usize, uppercase: bool) -> String {
134
    const GREEK_LOWER: &[char] = &[
135
        'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ', 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'ο', 'π', 'ρ', 'σ',
136
        'τ', 'υ', 'φ', 'χ', 'ψ', 'ω',
137
    ];
138
    const GREEK_UPPER: &[char] = &[
139
        'Α', 'Β', 'Γ', 'Δ', 'Ε', 'Ζ', 'Η', 'Θ', 'Ι', 'Κ', 'Λ', 'Μ', 'Ν', 'Ξ', 'Ο', 'Π', 'Ρ', 'Σ',
140
        'Τ', 'Υ', 'Φ', 'Χ', 'Ψ', 'Ω',
141
    ];
142

            
143
14227
    if num == 0 {
144
21
        return String::new();
145
14206
    }
146

            
147
14206
    let letters = if uppercase { GREEK_UPPER } else { GREEK_LOWER };
148

            
149
14206
    if num <= letters.len() {
150
211
        return letters[num - 1].to_string();
151
13995
    }
152

            
153
13995
    let mut result = String::new();
154
13995
    let mut remaining = num;
155
53773
    while remaining > 0 {
156
39778
        remaining -= 1;
157
39778
        result.insert(0, letters[remaining % letters.len()]);
158
39778
        remaining /= letters.len();
159
39778
    }
160
13995
    result
161
14227
}
162

            
163
#[cfg(test)]
164
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
165
mod autotest_generated {
166
    use super::*;
167

            
168
    // ------------------------------------------------------------------
169
    // Fixtures / helpers
170
    // ------------------------------------------------------------------
171

            
172
    /// Every variant of the enum under test — used for "no panic on any style"
173
    /// sweeps.
174
    const ALL_STYLES: [StyleListStyleType; 12] = [
175
        StyleListStyleType::None,
176
        StyleListStyleType::Disc,
177
        StyleListStyleType::Circle,
178
        StyleListStyleType::Square,
179
        StyleListStyleType::Decimal,
180
        StyleListStyleType::DecimalLeadingZero,
181
        StyleListStyleType::LowerRoman,
182
        StyleListStyleType::UpperRoman,
183
        StyleListStyleType::LowerGreek,
184
        StyleListStyleType::UpperGreek,
185
        StyleListStyleType::LowerAlpha,
186
        StyleListStyleType::UpperAlpha,
187
    ];
188

            
189
    /// Adversarial `i32` inputs: the saturation points, the sign boundary, and
190
    /// the roman-numeral cliff at 3999/4000.
191
    const EDGE_VALUES: [i32; 12] = [
192
        i32::MIN,
193
        i32::MIN + 1,
194
        -4000,
195
        -3999,
196
        -27,
197
        -1,
198
        0,
199
        1,
200
        26,
201
        3999,
202
        4000,
203
        i32::MAX,
204
    ];
205

            
206
    const GREEK_LOWER_LETTERS: [char; 24] = [
207
        'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ', 'ι', 'κ', 'λ', 'μ', 'ν', 'ξ', 'ο', 'π', 'ρ', 'σ',
208
        'τ', 'υ', 'φ', 'χ', 'ψ', 'ω',
209
    ];
210
    const GREEK_UPPER_LETTERS: [char; 24] = [
211
        'Α', 'Β', 'Γ', 'Δ', 'Ε', 'Ζ', 'Η', 'Θ', 'Ι', 'Κ', 'Λ', 'Μ', 'Ν', 'Ξ', 'Ο', 'Π', 'Ρ', 'Σ',
212
        'Τ', 'Υ', 'Φ', 'Χ', 'Ψ', 'Ω',
213
    ];
214

            
215
    /// Independent decoder for the bijective base-26 alphabetic system.
216
    /// Returns `None` if `s` contains a character outside the expected case.
217
    fn decode_alphabetic(s: &str, uppercase: bool) -> Option<u128> {
218
        if s.is_empty() {
219
            return None;
220
        }
221
        let base = if uppercase { b'A' } else { b'a' };
222
        let mut acc: u128 = 0;
223
        for b in s.bytes() {
224
            if b < base || b >= base + 26 {
225
                return None;
226
            }
227
            acc = acc * 26 + u128::from(b - base + 1);
228
        }
229
        Some(acc)
230
    }
231

            
232
    /// Independent decoder for the bijective base-24 greek system.
233
    fn decode_greek(s: &str, uppercase: bool) -> Option<u128> {
234
        let letters = if uppercase {
235
            &GREEK_UPPER_LETTERS
236
        } else {
237
            &GREEK_LOWER_LETTERS
238
        };
239
        if s.is_empty() {
240
            return None;
241
        }
242
        let mut acc: u128 = 0;
243
        for c in s.chars() {
244
            let idx = letters.iter().position(|l| *l == c)?;
245
            acc = acc * 24 + (idx as u128 + 1);
246
        }
247
        Some(acc)
248
    }
249

            
250
    /// Independent subtractive-notation roman decoder.
251
    fn decode_roman(s: &str) -> Option<u32> {
252
        fn digit(c: char) -> Option<i64> {
253
            match c {
254
                'i' => Some(1),
255
                'v' => Some(5),
256
                'x' => Some(10),
257
                'l' => Some(50),
258
                'c' => Some(100),
259
                'd' => Some(500),
260
                'm' => Some(1000),
261
                _ => None,
262
            }
263
        }
264
        if s.is_empty() {
265
            return None;
266
        }
267
        let digits: Option<Vec<i64>> = s.chars().map(digit).collect();
268
        let digits = digits?;
269
        // Accumulate signed: subtractive pairs go negative before the following
270
        // larger numeral is added, so an unsigned accumulator would underflow.
271
        let mut total: i64 = 0;
272
        for (i, d) in digits.iter().enumerate() {
273
            // A smaller numeral placed before a larger one is subtractive.
274
            if digits[i + 1..].iter().any(|next| next > d) {
275
                total -= *d;
276
            } else {
277
                total += *d;
278
            }
279
        }
280
        u32::try_from(total).ok()
281
    }
282

            
283
    // ------------------------------------------------------------------
284
    // to_alphabetic — numeric: zero / min_max / overflow / round-trip
285
    // ------------------------------------------------------------------
286

            
287
    #[test]
288
    fn to_alphabetic_zero_is_empty_not_a_panic() {
289
        // 0 is not representable in a bijective base — the function signals this
290
        // with an empty string (the caller is expected to `decimal_fallback`).
291
        assert_eq!(to_alphabetic(0, false), "");
292
        assert_eq!(to_alphabetic(0, true), "");
293
    }
294

            
295
    #[test]
296
    fn to_alphabetic_known_values() {
297
        assert_eq!(to_alphabetic(1, false), "a");
298
        assert_eq!(to_alphabetic(26, false), "z");
299
        // The carry boundary: 27 must roll over to two letters, not wrap to "a".
300
        assert_eq!(to_alphabetic(27, false), "aa");
301
        assert_eq!(to_alphabetic(28, false), "ab");
302
        assert_eq!(to_alphabetic(52, false), "az");
303
        assert_eq!(to_alphabetic(53, false), "ba");
304
        assert_eq!(to_alphabetic(702, false), "zz");
305
        assert_eq!(to_alphabetic(703, false), "aaa");
306
    }
307

            
308
    #[test]
309
    fn to_alphabetic_uppercase_only_shifts_case() {
310
        for n in 1..=1000usize {
311
            let lower = to_alphabetic(n, false);
312
            let upper = to_alphabetic(n, true);
313
            assert_eq!(upper, lower.to_uppercase(), "case mismatch at {n}");
314
            assert!(
315
                upper.bytes().all(|b| b.is_ascii_uppercase()),
316
                "non-uppercase byte at {n}: {upper}"
317
            );
318
            assert!(
319
                lower.bytes().all(|b| b.is_ascii_lowercase()),
320
                "non-lowercase byte at {n}: {lower}"
321
            );
322
        }
323
    }
324

            
325
    #[test]
326
    fn to_alphabetic_round_trips_through_an_independent_decoder() {
327
        for n in 1..=5000u128 {
328
            for uppercase in [false, true] {
329
                let encoded = to_alphabetic(n as usize, uppercase);
330
                assert_eq!(
331
                    decode_alphabetic(&encoded, uppercase),
332
                    Some(n),
333
                    "round-trip failed for {n} (uppercase={uppercase}) -> {encoded}"
334
                );
335
            }
336
        }
337
    }
338

            
339
    /// Asserts that no two inputs in `markers` produced the same string.
340
    fn assert_all_distinct(markers: &[String], what: &str) {
341
        let mut sorted: Vec<&String> = markers.iter().collect();
342
        sorted.sort();
343
        for pair in sorted.windows(2) {
344
            assert_ne!(pair[0], pair[1], "duplicate {what} marker: {}", pair[0]);
345
        }
346
    }
347

            
348
    #[test]
349
    fn to_alphabetic_is_injective() {
350
        // Two different counters must never render the same marker.
351
        let markers: Vec<String> = (1..=2000usize).map(|n| to_alphabetic(n, false)).collect();
352
        assert_all_distinct(&markers, "alphabetic");
353
    }
354

            
355
    #[test]
356
    fn to_alphabetic_usize_max_terminates_and_stays_ascii() {
357
        // `num = (num - 1) / 26` strictly decreases, so this must terminate;
358
        // the `base + remainder` byte add must not overflow past 'z'/'Z'.
359
        let lower = to_alphabetic(usize::MAX, false);
360
        let upper = to_alphabetic(usize::MAX, true);
361
        assert!(!lower.is_empty());
362
        assert!(lower.bytes().all(|b: u8| b.is_ascii_lowercase()));
363
        assert!(upper.bytes().all(|b: u8| b.is_ascii_uppercase()));
364
        // The extreme still encodes *exactly* — no digit dropped, no wrap.
365
        assert_eq!(decode_alphabetic(&lower, false), Some(usize::MAX as u128));
366
        assert_eq!(decode_alphabetic(&upper, true), Some(usize::MAX as u128));
367
        assert_eq!(lower.len(), upper.len());
368
    }
369

            
370
    #[test]
371
    fn to_alphabetic_magnitude_of_i32_min_does_not_panic() {
372
        // The magnitude that `with_sign` hands over for i32::MIN.
373
        let magnitude = 2_147_483_648usize;
374
        let s = to_alphabetic(magnitude, false);
375
        assert!(!s.is_empty());
376
        assert_eq!(decode_alphabetic(&s, false), Some(magnitude as u128));
377
    }
378

            
379
    // ------------------------------------------------------------------
380
    // to_roman — numeric: zero / limits / overflow / round-trip
381
    // ------------------------------------------------------------------
382

            
383
    #[test]
384
    fn to_roman_zero_falls_back_to_decimal_zero() {
385
        // Roman has no zero; the impl emits "0" rather than an empty marker.
386
        assert_eq!(to_roman(0, false), "0");
387
        assert_eq!(to_roman(0, true), "0");
388
    }
389

            
390
    #[test]
391
    fn to_roman_known_values() {
392
        assert_eq!(to_roman(1, false), "i");
393
        assert_eq!(to_roman(4, false), "iv");
394
        assert_eq!(to_roman(9, false), "ix");
395
        assert_eq!(to_roman(14, false), "xiv");
396
        assert_eq!(to_roman(40, false), "xl");
397
        assert_eq!(to_roman(90, false), "xc");
398
        assert_eq!(to_roman(400, false), "cd");
399
        assert_eq!(to_roman(900, false), "cm");
400
        assert_eq!(to_roman(1990, false), "mcmxc");
401
        assert_eq!(to_roman(2024, false), "mmxxiv");
402
        assert_eq!(to_roman(3999, false), "mmmcmxcix");
403
        assert_eq!(to_roman(2024, true), "MMXXIV");
404
        assert_eq!(to_roman(3999, true), "MMMCMXCIX");
405
    }
406

            
407
    #[test]
408
    fn to_roman_at_and_past_the_3999_cliff() {
409
        // 3999 is the last representable numeral...
410
        assert_eq!(to_roman(3999, false), "mmmcmxcix");
411
        // ...and 4000 must degrade to decimal instead of emitting "mmmm" or
412
        // looping forever.
413
        assert_eq!(to_roman(4000, false), "4000");
414
        assert_eq!(to_roman(4000, true), "4000");
415
        assert_eq!(to_roman(4001, false), "4001");
416
    }
417

            
418
    #[test]
419
    fn to_roman_usize_max_degrades_to_decimal() {
420
        assert_eq!(to_roman(usize::MAX, false), usize::MAX.to_string());
421
        assert_eq!(to_roman(usize::MAX, true), usize::MAX.to_string());
422
        // The i32::MIN magnitude handed over by `with_sign`.
423
        assert_eq!(to_roman(2_147_483_648, false), "2147483648");
424
    }
425

            
426
    #[test]
427
    fn to_roman_round_trips_over_the_whole_representable_range() {
428
        for n in 1..=3999u32 {
429
            let lower = to_roman(n as usize, false);
430
            let upper = to_roman(n as usize, true);
431
            assert_eq!(
432
                decode_roman(&lower),
433
                Some(n),
434
                "round-trip failed for {n} -> {lower}"
435
            );
436
            assert_eq!(upper, lower.to_uppercase(), "case mismatch at {n}");
437
            // No numeral may repeat more than 3 times in a row (mmm is the max).
438
            assert!(
439
                !lower.contains("iiii")
440
                    && !lower.contains("xxxx")
441
                    && !lower.contains("cccc")
442
                    && !lower.contains("mmmm"),
443
                "malformed numeral at {n}: {lower}"
444
            );
445
            assert!(lower.bytes().all(|b| b"ivxlcdm".contains(&b)));
446
        }
447
    }
448

            
449
    // ------------------------------------------------------------------
450
    // to_greek — numeric + unicode: zero / wrap / round-trip
451
    // ------------------------------------------------------------------
452

            
453
    #[test]
454
    fn to_greek_zero_is_empty_not_a_panic() {
455
        assert_eq!(to_greek(0, false), "");
456
        assert_eq!(to_greek(0, true), "");
457
    }
458

            
459
    #[test]
460
    fn to_greek_known_values_and_wrap_boundary() {
461
        assert_eq!(to_greek(1, false), "α");
462
        assert_eq!(to_greek(2, false), "β");
463
        assert_eq!(to_greek(24, false), "ω");
464
        // 24 letters, then the documented wrap to two letters.
465
        assert_eq!(to_greek(25, false), "αα");
466
        assert_eq!(to_greek(26, false), "αβ");
467
        assert_eq!(to_greek(48, false), "αω");
468
        assert_eq!(to_greek(49, false), "βα");
469
        assert_eq!(to_greek(1, true), "Α");
470
        assert_eq!(to_greek(24, true), "Ω");
471
        assert_eq!(to_greek(25, true), "ΑΑ");
472
    }
473

            
474
    #[test]
475
    fn to_greek_emits_multibyte_chars_without_slicing_bugs() {
476
        // Each greek letter is 2 bytes in UTF-8: byte len must be 2x char count,
477
        // and the string must survive a full char walk (i.e. `insert(0, ..)` never
478
        // split a code point).
479
        let s = to_greek(25, false);
480
        assert_eq!(s.chars().count(), 2);
481
        assert_eq!(s.len(), 4);
482
        assert!(s.chars().all(|c| GREEK_LOWER_LETTERS.contains(&c)));
483
        assert!(s.is_char_boundary(0) && s.is_char_boundary(2) && s.is_char_boundary(4));
484
    }
485

            
486
    #[test]
487
    fn to_greek_round_trips_through_an_independent_decoder() {
488
        for n in 1..=5000u128 {
489
            for uppercase in [false, true] {
490
                let encoded = to_greek(n as usize, uppercase);
491
                assert_eq!(
492
                    decode_greek(&encoded, uppercase),
493
                    Some(n),
494
                    "round-trip failed for {n} (uppercase={uppercase}) -> {encoded}"
495
                );
496
            }
497
        }
498
    }
499

            
500
    #[test]
501
    fn to_greek_is_injective() {
502
        let markers: Vec<String> = (1..=2000usize).map(|n| to_greek(n, true)).collect();
503
        assert_all_distinct(&markers, "greek");
504
    }
505

            
506
    #[test]
507
    fn to_greek_usize_max_terminates_and_stays_in_the_alphabet() {
508
        // `remaining = (remaining - 1) / 24` strictly decreases -> must terminate.
509
        let lower = to_greek(usize::MAX, false);
510
        let upper = to_greek(usize::MAX, true);
511
        assert!(!lower.is_empty());
512
        assert!(lower.chars().all(|c| GREEK_LOWER_LETTERS.contains(&c)));
513
        assert!(upper.chars().all(|c| GREEK_UPPER_LETTERS.contains(&c)));
514
        // The extreme still encodes *exactly* — no letter dropped, no wrap.
515
        assert_eq!(decode_greek(&lower, false), Some(usize::MAX as u128));
516
        assert_eq!(decode_greek(&upper, true), Some(usize::MAX as u128));
517
        assert_eq!(lower.chars().count(), upper.chars().count());
518
    }
519

            
520
    // ------------------------------------------------------------------
521
    // with_sign — numeric: sign handling, no lossy unsigned wrap
522
    // ------------------------------------------------------------------
523

            
524
    #[test]
525
    fn with_sign_passes_the_magnitude_not_a_wrapped_cast() {
526
        // The whole point of `with_sign`: -3 must hand `3` to the formatter,
527
        // NOT `(-3 as u32) == 4294967293`.
528
        assert_eq!(with_sign(-3, |n| n.to_string()), "-3");
529
        assert_eq!(with_sign(-1, |n| n.to_string()), "-1");
530
        assert_eq!(with_sign(0, |n| n.to_string()), "0");
531
        assert_eq!(with_sign(1, |n| n.to_string()), "1");
532
        assert_eq!(with_sign(i32::MAX, |n| n.to_string()), "2147483647");
533
    }
534

            
535
    #[test]
536
    fn with_sign_handles_i32_min_without_overflow() {
537
        // `-i32::MIN` overflows i32 — the impl must widen to i64 first.
538
        assert_eq!(with_sign(i32::MIN, |n| n.to_string()), "-2147483648");
539
        assert_eq!(with_sign(i32::MIN + 1, |n| n.to_string()), "-2147483647");
540
    }
541

            
542
    #[test]
543
    fn with_sign_zero_is_unsigned() {
544
        // 0 is not negative, so no "-0" marker may be produced.
545
        let s = with_sign(0, |n| to_alphabetic(n, false));
546
        assert!(!s.starts_with('-'), "produced a signed zero: {s}");
547
        assert_eq!(s, "");
548
    }
549

            
550
    #[test]
551
    fn with_sign_prefixes_exactly_one_minus() {
552
        for v in [-1, -26, -3999, -4000, i32::MIN] {
553
            let s = with_sign(v, |n| to_roman(n, false));
554
            assert!(s.starts_with('-'), "missing sign for {v}: {s}");
555
            assert_eq!(s.matches('-').count(), 1, "double sign for {v}: {s}");
556
            assert!(s.len() > 1, "sign with no magnitude for {v}");
557
        }
558
    }
559

            
560
    #[test]
561
    fn with_sign_is_transparent_to_the_formatter_output() {
562
        // A formatter returning an empty string yields just the sign — the sign is
563
        // never swallowed (`decimal_fallback` is what rescues the empty case).
564
        assert_eq!(with_sign(-5, |_| String::new()), "-");
565
        assert_eq!(with_sign(5, |_| String::new()), "");
566
        // Unicode from the formatter passes through byte-for-byte.
567
        assert_eq!(with_sign(-5, |_| "αβγ".to_string()), "-αβγ");
568
    }
569

            
570
    #[test]
571
    fn with_sign_negation_is_symmetric_across_the_range() {
572
        for v in [1i32, 2, 26, 27, 3999, 4000, i32::MAX] {
573
            let pos = with_sign(v, |n| to_alphabetic(n, false));
574
            let neg = with_sign(-v, |n| to_alphabetic(n, false));
575
            assert_eq!(neg, format!("-{pos}"), "asymmetric at {v}");
576
        }
577
    }
578

            
579
    // ------------------------------------------------------------------
580
    // decimal_fallback — numeric: the "never blank" invariant
581
    // ------------------------------------------------------------------
582

            
583
    #[test]
584
    fn decimal_fallback_replaces_empty_with_decimal() {
585
        assert_eq!(decimal_fallback(0, String::new()), "0");
586
        assert_eq!(decimal_fallback(-1, String::new()), "-1");
587
        assert_eq!(decimal_fallback(i32::MAX, String::new()), "2147483647");
588
        assert_eq!(decimal_fallback(i32::MIN, String::new()), "-2147483648");
589
    }
590

            
591
    #[test]
592
    fn decimal_fallback_passes_non_empty_through_untouched() {
593
        // Even a "wrong looking" formatted value is preserved: the fallback keys
594
        // off emptiness only, never off the numeric value.
595
        assert_eq!(decimal_fallback(5, "a".to_string()), "a");
596
        assert_eq!(decimal_fallback(0, "z".to_string()), "z");
597
        assert_eq!(decimal_fallback(0, "α".to_string()), "α");
598
        // Whitespace is NOT empty -> not replaced.
599
        assert_eq!(decimal_fallback(7, " ".to_string()), " ");
600
        // A lone NUL byte counts as non-empty too.
601
        assert_eq!(decimal_fallback(7, "\0".to_string()), "\0");
602
    }
603

            
604
    #[test]
605
    fn decimal_fallback_output_is_never_blank_for_any_i32() {
606
        for v in EDGE_VALUES {
607
            assert!(
608
                !decimal_fallback(v, String::new()).is_empty(),
609
                "blank marker for {v}"
610
            );
611
        }
612
    }
613

            
614
    // ------------------------------------------------------------------
615
    // format_counter — serializer: no panic, well-formed, spec-shaped
616
    // ------------------------------------------------------------------
617

            
618
    #[test]
619
    fn format_counter_no_panic_on_edge_values_for_every_style() {
620
        for style in ALL_STYLES {
621
            for v in EDGE_VALUES {
622
                let s = format_counter(v, style);
623
                if style == StyleListStyleType::None {
624
                    assert!(s.is_empty(), "`none` must render nothing, got {s:?}");
625
                } else {
626
                    // The core CSS invariant: a marker is never blank.
627
                    assert!(!s.is_empty(), "blank marker for {v} in {style:?}");
628
                    // ...and never blank-looking either.
629
                    assert!(
630
                        !s.chars().all(char::is_whitespace),
631
                        "whitespace-only marker for {v} in {style:?}"
632
                    );
633
                }
634
            }
635
        }
636
    }
637

            
638
    #[test]
639
    fn format_counter_is_deterministic() {
640
        for style in ALL_STYLES {
641
            for v in EDGE_VALUES {
642
                assert_eq!(format_counter(v, style), format_counter(v, style));
643
            }
644
        }
645
    }
646

            
647
    #[test]
648
    fn format_counter_default_style_is_disc_and_ignores_the_value() {
649
        assert_eq!(format_counter(0, StyleListStyleType::default()), "•");
650
        for v in EDGE_VALUES {
651
            assert_eq!(format_counter(v, StyleListStyleType::default()), "•");
652
        }
653
    }
654

            
655
    #[test]
656
    fn format_counter_bullet_styles_ignore_the_value() {
657
        for (style, expected) in [
658
            (StyleListStyleType::Disc, "•"),
659
            (StyleListStyleType::Circle, "◦"),
660
            (StyleListStyleType::Square, "▪"),
661
        ] {
662
            for v in EDGE_VALUES {
663
                let s = format_counter(v, style);
664
                assert_eq!(s, expected, "bullet changed with value {v}");
665
                assert_eq!(s.chars().count(), 1);
666
            }
667
        }
668
    }
669

            
670
    #[test]
671
    fn format_counter_decimal_matches_i32_display() {
672
        for v in EDGE_VALUES {
673
            assert_eq!(format_counter(v, StyleListStyleType::Decimal), v.to_string());
674
        }
675
    }
676

            
677
    #[test]
678
    fn format_counter_decimal_leading_zero_pads_single_digits() {
679
        assert_eq!(
680
            format_counter(0, StyleListStyleType::DecimalLeadingZero),
681
            "00"
682
        );
683
        assert_eq!(
684
            format_counter(5, StyleListStyleType::DecimalLeadingZero),
685
            "05"
686
        );
687
        assert_eq!(
688
            format_counter(9, StyleListStyleType::DecimalLeadingZero),
689
            "09"
690
        );
691
        assert_eq!(
692
            format_counter(10, StyleListStyleType::DecimalLeadingZero),
693
            "10"
694
        );
695
        assert_eq!(
696
            format_counter(100, StyleListStyleType::DecimalLeadingZero),
697
            "100"
698
        );
699
        assert_eq!(
700
            format_counter(i32::MAX, StyleListStyleType::DecimalLeadingZero),
701
            "2147483647"
702
        );
703
    }
704

            
705
    #[test]
706
    fn format_counter_decimal_leading_zero_negative_current_behavior() {
707
        // NOTE (reported, not weakened): `format!("{value:02}")` pads the *total*
708
        // width including the sign, so -5 renders as "-5". CSS
709
        // `decimal-leading-zero` pads the digits only, i.e. "-05". This test pins
710
        // the current behavior so the deviation is visible if/when it is fixed.
711
        assert_eq!(
712
            format_counter(-5, StyleListStyleType::DecimalLeadingZero),
713
            "-5"
714
        );
715
        assert_eq!(
716
            format_counter(i32::MIN, StyleListStyleType::DecimalLeadingZero),
717
            "-2147483648"
718
        );
719
    }
720

            
721
    #[test]
722
    fn format_counter_alpha_known_values_and_zero_fallback() {
723
        assert_eq!(format_counter(1, StyleListStyleType::LowerAlpha), "a");
724
        assert_eq!(format_counter(26, StyleListStyleType::LowerAlpha), "z");
725
        assert_eq!(format_counter(27, StyleListStyleType::LowerAlpha), "aa");
726
        assert_eq!(format_counter(1, StyleListStyleType::UpperAlpha), "A");
727
        assert_eq!(format_counter(27, StyleListStyleType::UpperAlpha), "AA");
728
        // 0 has no alphabetic representation -> decimal fallback, not a blank.
729
        assert_eq!(format_counter(0, StyleListStyleType::LowerAlpha), "0");
730
        assert_eq!(format_counter(0, StyleListStyleType::UpperAlpha), "0");
731
        // Negatives keep the sign instead of wrapping to a giant unsigned value.
732
        assert_eq!(format_counter(-1, StyleListStyleType::LowerAlpha), "-a");
733
        assert_eq!(format_counter(-3, StyleListStyleType::UpperAlpha), "-C");
734
    }
735

            
736
    #[test]
737
    fn format_counter_roman_known_values_and_limits() {
738
        assert_eq!(format_counter(1, StyleListStyleType::LowerRoman), "i");
739
        assert_eq!(format_counter(4, StyleListStyleType::LowerRoman), "iv");
740
        assert_eq!(format_counter(2024, StyleListStyleType::UpperRoman), "MMXXIV");
741
        assert_eq!(format_counter(3999, StyleListStyleType::LowerRoman), "mmmcmxcix");
742
        // Past the roman range -> decimal, never an unbounded "mmmm..." string.
743
        assert_eq!(format_counter(4000, StyleListStyleType::LowerRoman), "4000");
744
        assert_eq!(format_counter(4000, StyleListStyleType::UpperRoman), "4000");
745
        // No roman zero.
746
        assert_eq!(format_counter(0, StyleListStyleType::LowerRoman), "0");
747
        assert_eq!(format_counter(0, StyleListStyleType::UpperRoman), "0");
748
        assert_eq!(format_counter(-3, StyleListStyleType::LowerRoman), "-iii");
749
        assert_eq!(format_counter(-14, StyleListStyleType::UpperRoman), "-XIV");
750
    }
751

            
752
    #[test]
753
    fn format_counter_roman_at_i32_min_matches_signed_decimal() {
754
        // The magnitude of i32::MIN is far past MAX_ROMAN, so the marker degrades
755
        // to decimal — and must equal the plain decimal rendering, i.e. the
756
        // `unsigned_abs` widening must not have wrapped.
757
        assert_eq!(
758
            format_counter(i32::MIN, StyleListStyleType::LowerRoman),
759
            "-2147483648"
760
        );
761
        assert_eq!(
762
            format_counter(i32::MIN, StyleListStyleType::LowerRoman),
763
            format_counter(i32::MIN, StyleListStyleType::Decimal)
764
        );
765
    }
766

            
767
    #[test]
768
    fn format_counter_greek_known_values_and_zero_fallback() {
769
        assert_eq!(format_counter(1, StyleListStyleType::LowerGreek), "α");
770
        assert_eq!(format_counter(24, StyleListStyleType::LowerGreek), "ω");
771
        assert_eq!(format_counter(25, StyleListStyleType::LowerGreek), "αα");
772
        assert_eq!(format_counter(1, StyleListStyleType::UpperGreek), "Α");
773
        assert_eq!(format_counter(24, StyleListStyleType::UpperGreek), "Ω");
774
        // 0 -> decimal fallback, not a blank marker.
775
        assert_eq!(format_counter(0, StyleListStyleType::LowerGreek), "0");
776
        assert_eq!(format_counter(0, StyleListStyleType::UpperGreek), "0");
777
        assert_eq!(format_counter(-2, StyleListStyleType::LowerGreek), "-β");
778
        assert_eq!(format_counter(-1, StyleListStyleType::UpperGreek), "-Α");
779
    }
780

            
781
    #[test]
782
    fn format_counter_negative_is_positive_with_a_minus_for_letter_styles() {
783
        // Guards the regression the `with_sign` doc calls out: a lossy `as u32`
784
        // cast would make -3 render as a huge unsigned counter, not "-iii"/"-c".
785
        for style in [
786
            StyleListStyleType::LowerAlpha,
787
            StyleListStyleType::UpperAlpha,
788
            StyleListStyleType::LowerRoman,
789
            StyleListStyleType::UpperRoman,
790
            StyleListStyleType::LowerGreek,
791
            StyleListStyleType::UpperGreek,
792
        ] {
793
            for v in [1i32, 2, 3, 24, 25, 26, 27, 3999] {
794
                let pos = format_counter(v, style);
795
                let neg = format_counter(-v, style);
796
                assert_eq!(neg, format!("-{pos}"), "asymmetric at {v} in {style:?}");
797
                assert!(!pos.starts_with('-'), "positive gained a sign at {v}");
798
            }
799
        }
800
    }
801

            
802
    #[test]
803
    fn format_counter_letter_styles_never_leak_huge_unsigned_markers() {
804
        // A wrapped cast would produce a marker for -1 as long as the one for
805
        // 4294967295. Bound the length instead of trusting the exact string.
806
        for style in [
807
            StyleListStyleType::LowerAlpha,
808
            StyleListStyleType::UpperGreek,
809
        ] {
810
            let s = format_counter(-1, style);
811
            assert_eq!(s.chars().count(), 2, "suspiciously long marker: {s}");
812
        }
813
    }
814

            
815
    #[test]
816
    fn format_counter_marker_length_stays_bounded_at_i32_extremes() {
817
        // No style may blow up into a megabyte-long marker at the i32 extremes.
818
        for style in ALL_STYLES {
819
            for v in [i32::MIN, i32::MAX] {
820
                let s = format_counter(v, style);
821
                assert!(
822
                    s.chars().count() <= 32,
823
                    "marker for {v} in {style:?} is {} chars: {s}",
824
                    s.chars().count()
825
                );
826
            }
827
        }
828
    }
829
}