provenance-cli 0.2.3

Command-line interface for Provenance requirements traceability
use super::*;

#[track_caller]
fn css_rule(selector: &str) -> &'static str {
    let prefix = format!("{selector} {{");
    let declarations = WIKI_CSS
        .split_once(&prefix)
        .unwrap_or_else(|| panic!("missing CSS rule: {selector}"))
        .1;

    declarations
        .split_once('}')
        .unwrap_or_else(|| panic!("unterminated CSS rule: {selector}"))
        .0
}

#[test]
fn css_carries_all_five_theme_token_blocks() {
    for theme in ["statesman", "piano", "latte", "mocha", "dracula"] {
        assert!(
            WIKI_CSS.contains(&format!("[data-theme=\"{theme}\"]")),
            "missing theme block: {theme}"
        );
    }
}

#[test]
fn css_defines_the_atlas_type_tokens() {
    for token in [
        "--pv-source:",
        "--pv-requirement:",
        "--pv-resolution:",
        "--pv-rule:",
        "--pv-thread:",
    ] {
        assert!(WIKI_CSS.contains(token), "missing token: {token}");
    }
}

#[test]
fn css_keeps_the_mockup_grammar() {
    for selector in [
        ".accent-bar",
        ".chrome-inner",
        ".body-grid",
        ".body-margin",
        ".territory-card",
        ".citation.gap",
        ".field-notes",
        ".role-badge",
        ".lineage",
    ] {
        assert!(WIKI_CSS.contains(selector), "missing selector: {selector}");
    }
}

#[test]
fn css_colors_traversal_links_with_theme_tokens() {
    let link_color = declaration_value(WIKI_CSS, ".link-list a", "color")
        .expect(".link-list a should declare its link color");
    assert!(
        link_color.contains("var(--pv-"),
        ".link-list a should use a --pv custom property for color, got {link_color}"
    );

    for (section_class, token) in [
        ("sh-requirement", "--pv-requirement"),
        ("sh-resolution", "--pv-resolution"),
        ("sh-rule", "--pv-rule"),
        ("sh-source", "--pv-source"),
    ] {
        let selector = format!("section:has(> .{section_class})");
        let link_color_override = declaration_value(WIKI_CSS, &selector, "--pv-link-color")
            .unwrap_or_else(|| panic!("{selector} should override --pv-link-color"));
        assert_eq!(link_color_override, format!("var({token})"));
    }
}

#[test]
fn light_theme_link_tokens_meet_body_text_contrast() {
    for (theme, selector) in [
        ("statesman", r#":root, :root[data-theme="statesman"]"#),
        ("piano", r#":root[data-theme="piano"]"#),
        ("latte", r#":root[data-theme="latte"]"#),
    ] {
        let background = declaration_value(WIKI_CSS, selector, "--pv-canvas-bg")
            .unwrap_or_else(|| panic!("{theme} should declare --pv-canvas-bg"));
        for token in [
            "--pv-source",
            "--pv-requirement",
            "--pv-resolution",
            "--pv-rule",
        ] {
            let foreground = declaration_value(WIKI_CSS, selector, token)
                .unwrap_or_else(|| panic!("{theme} should declare {token}"));
            let contrast = contrast_ratio(foreground, background)
                .expect("light theme link tokens should be hex colors");
            assert!(
                contrast >= 4.5,
                "{theme} {token} contrast against {background} is {contrast:.2}:1"
            );
        }
    }
}

fn declaration_value<'a>(css: &'a str, selector: &str, property: &str) -> Option<&'a str> {
    let selector_start = css.find(selector)?;
    let after_selector = &css[selector_start + selector.len()..];
    let block_start = after_selector.find('{')?;
    let block = &after_selector[block_start + 1..];
    let block_end = block.find('}')?;

    block[..block_end].split(';').find_map(|declaration| {
        let (name, value) = declaration.split_once(':')?;
        (name.trim() == property).then_some(value.trim())
    })
}

fn contrast_ratio(foreground: &str, background: &str) -> Option<f64> {
    let foreground_luminance = relative_luminance(hex_rgb(foreground)?);
    let background_luminance = relative_luminance(hex_rgb(background)?);
    let lighter = foreground_luminance.max(background_luminance);
    let darker = foreground_luminance.min(background_luminance);

    Some((lighter + 0.05) / (darker + 0.05))
}

fn relative_luminance([red, green, blue]: [u8; 3]) -> f64 {
    let red = linearized_srgb(red);
    let green = linearized_srgb(green);
    let blue = linearized_srgb(blue);

    0.0722_f64.mul_add(blue, 0.2126_f64.mul_add(red, 0.7152 * green))
}

fn linearized_srgb(channel: u8) -> f64 {
    let normalized = f64::from(channel) / 255.0;
    if normalized <= 0.040_45 {
        normalized / 12.92
    } else {
        ((normalized + 0.055) / 1.055).powf(2.4)
    }
}

fn hex_rgb(hex: &str) -> Option<[u8; 3]> {
    let hex = hex.strip_prefix('#')?;
    if hex.len() != 6 {
        return None;
    }

    Some([
        u8::from_str_radix(hex.get(0..2)?, 16).ok()?,
        u8::from_str_radix(hex.get(2..4)?, 16).ok()?,
        u8::from_str_radix(hex.get(4..6)?, 16).ok()?,
    ])
}

#[test]
fn css_allows_deep_breadcrumbs_to_wrap_in_chrome() {
    assert!(
        css_rule(".chrome-inner").contains("align-items: flex-start;"),
        "chrome header should align cleanly when breadcrumbs wrap"
    );
    assert!(
        WIKI_CSS.contains(".chrome nav { display: flex; flex: 1 1 auto; min-width: 0;"),
        "breadcrumb nav should be shrink-safe inside the chrome flex row"
    );
    assert!(
        css_rule(".chrome nav a").contains("overflow-wrap: anywhere;"),
        "breadcrumb links should not force horizontal overflow"
    );
}

#[test]
fn css_wraps_long_classification_values_in_the_margin() {
    let value_rule = css_rule(".classification .v");
    assert!(
        value_rule.contains("min-width: 0;"),
        "classification values should be shrink-safe inside their flex row"
    );
    assert!(
        value_rule.contains("overflow-wrap: anywhere;"),
        "classification values should wrap instead of widening the fixed margin"
    );
    assert!(
        css_rule(".classification .v.mono").contains("word-break: break-all;"),
        "mono classification values should wrap long document paths like citation references"
    );
}

#[test]
fn css_makes_no_external_requests() {
    assert!(!WIKI_CSS.contains("http://"));
    assert!(!WIKI_CSS.contains("https://"));
    assert!(!WIKI_CSS.contains("@import"));
    assert!(!WIKI_CSS.contains("url("));
}

#[test]
fn theme_script_persists_the_choice_to_local_storage() {
    assert!(THEME_SCRIPT.contains("provenance-wiki-theme"));
    assert!(THEME_SCRIPT.contains("localStorage"));
    assert!(THEME_SCRIPT.contains("data-theme"));
}

#[test]
fn search_script_filters_rendered_nodes_without_html_injection_or_fetching() {
    assert!(SEARCH_SCRIPT.contains("data-search-title"));
    assert!(SEARCH_SCRIPT.contains("data-search-statement"));
    assert!(SEARCH_SCRIPT.contains("terms.every"));
    assert!(SEARCH_SCRIPT.contains("textContent"));
    assert!(!SEARCH_SCRIPT.contains("innerHTML"));
    assert!(!SEARCH_SCRIPT.contains("fetch("));
}

#[test]
fn css_styles_domain_navigation_and_search_responsively() {
    for selector in [
        ".global-nav",
        ".domain-group",
        ".domain-records",
        ".search-box",
        ".search-results",
        ".data-note",
    ] {
        assert!(WIKI_CSS.contains(selector), "missing selector: {selector}");
    }
    assert!(WIKI_CSS.contains("@media (max-width: 760px)"));
}

#[test]
fn global_navigation_wraps_below_the_reader_viewport_width() {
    assert!(WIKI_CSS.contains("@media (max-width: 520px)"));
    assert!(WIKI_CSS.contains(".global-nav { order: 2; width: 100%;"));
}

#[test]
fn css_uses_readable_base_type_with_an_eleven_pixel_floor() {
    assert!(css_rule("body").contains("font-size: 15px;"));
    for declaration in WIKI_CSS.split("font-size:").skip(1) {
        let value = declaration.split(';').next().unwrap_or_default();
        for token in
            value.split(|character: char| !(character.is_ascii_digit() || character == '.'))
        {
            if token.is_empty() || !value.contains(&format!("{token}px")) {
                continue;
            }
            let pixels: f64 = token.parse().expect("pixel font sizes should be numeric");
            assert!(
                pixels >= 11.0,
                "font-size {value:?} falls below the 11px floor"
            );
        }
    }
}

#[test]
fn icon_defs_cover_the_symbols_the_renderer_uses() {
    for symbol in [
        "i-git-branch",
        "i-scale",
        "i-book-open",
        "i-search",
        "i-check-circle",
        "i-arrow-left",
        "i-message-square",
        "i-user",
        "i-calendar",
        "i-shield",
        "i-gauge",
        "i-bot",
    ] {
        assert!(
            ICON_DEFS.contains(&format!("id=\"{symbol}\"")),
            "missing icon: {symbol}"
        );
    }
}