provenance-cli 0.2.1

Command-line interface for Provenance requirements traceability
use crate::wiki::links::InlineRef;
use crate::wiki::model::{GapKind, PageKind, PageLink};

use super::super::html::{escape_attr, escape_html, linkify_body, PageLinksRenderer};
use super::super::labels::{format_confidence, format_date_ms};
use super::fixtures::{colliding_requirement_links, unique_requirement_links};

/// A page holding one list and nothing else.
fn link_list(links: &[PageLink]) -> String {
    PageLinksRenderer::new(links).link_list(links)
}

#[test]
fn escape_html_escapes_markup_characters() {
    assert_eq!(escape_html("a < b & c > d"), "a &lt; b &amp; c &gt; d");
}

#[test]
fn escape_attr_also_escapes_quotes() {
    assert_eq!(
        escape_attr("say \"hi\" & <go>"),
        "say &quot;hi&quot; &amp; &lt;go&gt;"
    );
}

#[test]
fn format_date_ms_renders_epoch_milliseconds_as_a_civil_date() {
    assert_eq!(format_date_ms(1_714_780_800_000), "4 May 2024");
    assert_eq!(format_date_ms(0), "1 Jan 1970");
}

#[test]
fn format_date_ms_handles_dates_before_the_epoch() {
    assert_eq!(format_date_ms(-86_400_000), "31 Dec 1969");
}

#[test]
fn format_confidence_renders_a_percentage() {
    assert_eq!(format_confidence(0.97), "97%");
    assert_eq!(format_confidence(1.0), "100%");
}

#[test]
fn linkify_body_wraps_ref_spans_in_anchors_and_escapes_the_rest() {
    let body = "See src/UseCase.php:153-156 & friends";
    let refs = vec![InlineRef {
        start: 4,
        end: 27,
        label: "src/UseCase.php:153-156".to_string(),
        href: Some("https://example.test/blob".to_string()),
        snippet: None,
    }];
    assert_eq!(
        linkify_body(body, &refs),
        "See <a class=\"src\" href=\"https://example.test/blob\">src/UseCase.php:153-156</a> &amp; friends"
    );
}

#[test]
fn linkify_body_escapes_plain_text_when_there_are_no_refs() {
    assert_eq!(linkify_body("a < b", &[]), "a &lt; b");
}

#[test]
fn link_list_disambiguates_identical_titles_with_stable_id_chips() {
    let html = link_list(&colliding_requirement_links());

    assert!(html.contains(
        "Participant budget summary shall pro-rate services <span class=\"id-chip\">…hall_pro</span></a>"
    ));
    assert!(html.contains(
        "Participant budget summary shall pro-rate services <span class=\"id-chip\">…ll_pro_2</span></a>"
    ));
}

#[test]
fn link_list_leaves_unique_titles_unchanged() {
    assert_eq!(
        link_list(&unique_requirement_links()),
        "<ul class=\"link-list\">\n\
<li><a href=\"/requirements/req_budget_split/\">Budget portions shall reconcile</a></li>\n\
<li><a href=\"/requirements/req_zero_suppression/\">Zero claim items shall be suppressed</a></li>\n\
</ul>\n"
    );
}

#[test]
fn repeated_links_to_the_same_record_do_not_collide() {
    let link = colliding_requirement_links().remove(0);

    assert!(!link_list(&[link.clone(), link]).contains("class=\"id-chip\""));
}

#[test]
fn link_list_disambiguates_same_id_across_page_kinds() {
    let links = vec![
        super::fixtures::link(
            crate::wiki::model::PageKind::Requirement,
            "shared_id",
            "Shared title",
        ),
        super::fixtures::link(
            crate::wiki::model::PageKind::Resolution,
            "shared_id",
            "Shared title",
        ),
        super::fixtures::link(
            crate::wiki::model::PageKind::Source,
            "shared_id",
            "Shared title",
        ),
    ];

    let html = link_list(&links);
    assert!(html.contains("<span class=\"id-chip\">Requirement · shared_id</span>"));
    assert!(html.contains("<span class=\"id-chip\">Resolution · shared_id</span>"));
    assert!(html.contains("<span class=\"id-chip\">Source · shared_id</span>"));
}

#[test]
fn short_ids_use_the_shortest_suffix_that_distinguishes_records() {
    let links = vec![
        super::fixtures::link(crate::wiki::model::PageKind::Requirement, "req_a1", "Same"),
        super::fixtures::link(crate::wiki::model::PageKind::Requirement, "req_b1", "Same"),
    ];

    let html = link_list(&links);
    assert!(html.contains("<span class=\"id-chip\">req_a1</span>"));
    assert!(html.contains("<span class=\"id-chip\">req_b1</span>"));
}

#[test]
fn gap_placeholders_cannot_rewrite_text_inside_inserted_links() {
    let subject_token = concat!("{", "subject", "}");
    let related_token = concat!("{", "related", "}");
    let subject = super::fixtures::link(
        PageKind::Requirement,
        "req_subject",
        &["Keep ", related_token].concat(),
    );
    let related = super::fixtures::link(PageKind::Requirement, "req_related", "Other requirement");
    let gap = crate::wiki::model::GapNotice {
        kind: GapKind::UnresolvedContradictsPair,
        subject: Some(subject.clone()),
        related: Some(related.clone()),
        detail: [subject_token, " conflicts with ", related_token, "."].concat(),
    };
    let links = PageLinksRenderer::new([&subject, &related]);

    let html = super::super::citations::gap_detail_html(&links, &gap);

    assert_eq!(
        html,
        [
            "<a href=\"/requirements/req_subject/\">Keep ",
            related_token,
            "</a> conflicts with <a href=\"/requirements/req_related/\">Other requirement</a>."
        ]
        .concat()
    );
    assert_eq!(html.matches("<a ").count(), 2);
}