Current section

Files

Jump to
rbt_weave crates weave-content src html tests.rs
Raw

crates/weave-content/src/html/tests.rs

use super::*;
use crate::domain::Jurisdiction;
use crate::output::{CaseOutput, NodeOutput, RelOutput};
use crate::parser::SourceEntry;
use escape::{escape, format_enum, render_inline_markdown, truncate};
use thumbnail::{THUMB_KEY_HEX_LEN, rewrite_thumbnail_url, thumbnail_key};
#[allow(clippy::too_many_lines)]
fn make_case() -> CaseOutput {
CaseOutput {
id: "01TESTCASE0000000000000000".into(),
case_id: "test-case".into(),
title: "Test Corruption Case".into(),
summary: "A politician was caught accepting bribes.".into(),
tags: vec!["bribery".into(), "government".into()],
slug: None,
case_type: None,
amounts: vec![],
status: None,
tagline: None,
nodes: vec![
NodeOutput {
id: "01AAA".into(),
label: "person".into(),
name: "John Doe".into(),
slug: Some("people/id/john-doe--governor-of-test-province".into()),
qualifier: Some("Governor of Test Province".into()),
description: None,
thumbnail: Some("https://files.example.com/thumb.webp".into()),
aliases: vec![],
urls: vec![],
role: vec!["politician".into()],
nationality: vec!["ID".into()],
date_of_birth: None,
date_of_death: None,
place_of_birth: None,
status: Some("convicted".into()),
org_type: None,
jurisdiction: None,
headquarters: None,
founded_date: None,
registration_number: None,
event_type: None,
occurred_at: None,
severity: None,
doc_type: None,
issued_at: None,
issuing_authority: None,
case_number: None,
case_type: None,
tagline: None,
amounts: vec![],
asset_type: None,
value: None,
tags: vec![],
},
NodeOutput {
id: "01BBB".into(),
label: "organization".into(),
name: "KPK".into(),
slug: Some("organizations/id/kpk--anti-corruption-commission".into()),
qualifier: Some("Anti-Corruption Commission".into()),
description: None,
thumbnail: None,
aliases: vec![],
urls: vec![],
role: vec![],
nationality: Vec::new(),
date_of_birth: None,
date_of_death: None,
place_of_birth: None,
status: None,
org_type: Some("government_agency".into()),
jurisdiction: Some(Jurisdiction {
country: "ID".into(),
subdivision: None,
}),
headquarters: None,
founded_date: None,
registration_number: None,
event_type: None,
occurred_at: None,
severity: None,
doc_type: None,
issued_at: None,
issuing_authority: None,
case_number: None,
case_type: None,
tagline: None,
amounts: vec![],
asset_type: None,
value: None,
tags: vec![],
},
NodeOutput {
id: "01CCC".into(),
label: "event".into(),
name: "Arrest".into(),
slug: None,
qualifier: None,
description: Some("John Doe arrested by KPK.".into()),
thumbnail: None,
aliases: vec![],
urls: vec![],
role: vec![],
nationality: Vec::new(),
date_of_birth: None,
date_of_death: None,
place_of_birth: None,
status: None,
org_type: None,
jurisdiction: None,
headquarters: None,
founded_date: None,
registration_number: None,
event_type: Some("arrest".into()),
occurred_at: Some("2024-03-15".into()),
severity: None,
doc_type: None,
issued_at: None,
issuing_authority: None,
case_number: None,
case_type: None,
tagline: None,
amounts: vec![],
asset_type: None,
value: None,
tags: vec![],
},
],
relationships: vec![RelOutput {
id: "01DDD".into(),
rel_type: "investigated_by".into(),
source_id: "01BBB".into(),
source_label: "person".into(),
target_id: "01CCC".into(),
target_label: "organization".into(),
source_urls: vec![],
description: None,
amounts: vec![],
valid_from: None,
valid_until: None,
}],
sources: vec![SourceEntry::Url("https://example.com/article".into())],
}
}
#[test]
fn render_case_produces_valid_html() {
let case = make_case();
let config = HtmlConfig::default();
let html = render_case(&case, &config).unwrap();
assert!(html.starts_with("<article"));
assert!(html.ends_with("</article>\n"));
assert!(html.contains("data-og-title=\"Test Corruption Case\""));
assert!(html.contains("data-og-description="));
assert!(html.contains("<h1 itemprop=\"headline\">Test Corruption Case</h1>"));
assert!(html.contains("loom-tag"));
assert!(html.contains("bribery"));
assert!(html.contains("John Doe"));
assert!(html.contains("KPK"));
assert!(html.contains("Arrest"));
assert!(html.contains("2024-03-15"));
assert!(html.contains("application/ld+json"));
// Case header has wall link
assert!(html.contains("View on the wall"));
assert!(html.contains("/walls/01TESTCASE0000000000000000"));
}
#[test]
fn render_case_with_country_slug() {
let mut case = make_case();
case.slug = Some("cases/id/corruption/2024/test-case".into());
let config = HtmlConfig::default();
let html = render_case(&case, &config).unwrap();
// Country badge in header
assert!(html.contains("loom-country-badge"));
assert!(html.contains("Indonesia"));
// OG title includes country
assert!(html.contains("data-og-title=\"Test Corruption Case — Indonesia\""));
}
#[test]
fn render_case_with_multinational_slug() {
let mut case = make_case();
case.slug = Some("cases/multinational/money-laundering/2023/test-case".into());
let config = HtmlConfig::default();
let html = render_case(&case, &config).unwrap();
assert!(html.contains("loom-country-badge"));
assert!(html.contains("Multinational"));
assert!(html.contains("data-og-title=\"Test Corruption Case — Multinational\""));
}
#[test]
fn render_case_without_slug_has_no_country() {
let case = make_case();
let config = HtmlConfig::default();
let html = render_case(&case, &config).unwrap();
assert!(!html.contains("loom-country-badge"));
}
#[test]
fn render_cases_list_omits_country_badge() {
let case = make_case();
let config = HtmlConfig::default();
let person = &case.nodes[0];
let cases_list = vec![(
"cases/id/corruption/2024/test-case".into(),
"Test Corruption Case".into(),
)];
let html = render_person(person, &cases_list, &config).unwrap();
// Case list should NOT include country badges (redundant)
assert!(!html.contains("loom-country-badge"));
assert!(html.contains("Test Corruption Case"));
assert!(html.contains("loom-case-list"));
}
#[test]
fn render_case_summary_renders_inline_markdown() {
let mut case = make_case();
case.summary = "**Amount not publicly documented.**".into();
let config = HtmlConfig::default();
let html = render_case(&case, &config).unwrap();
assert!(html.contains(
"<p class=\"loom-summary\" itemprop=\"description\"><strong>Amount not publicly documented.</strong></p>"
));
}
#[test]
fn render_case_has_sources() {
let case = make_case();
let config = HtmlConfig::default();
let html = render_case(&case, &config).unwrap();
assert!(html.contains("Sources"));
assert!(html.contains("https://example.com/article"));
}
#[test]
fn render_case_entity_cards_link_to_static_views() {
let case = make_case();
let config = HtmlConfig::default();
let html = render_case(&case, &config).unwrap();
// Entity cards should link to static views, not wall
assert!(html.contains("href=\"/people/id/john-doe--governor-of-test-province\""));
assert!(html.contains("href=\"/organizations/id/kpk--anti-corruption-commission\""));
// Should NOT link to /walls/ for entities with slugs
assert!(!html.contains("href=\"/walls/01AAA\""));
assert!(!html.contains("href=\"/walls/01BBB\""));
}
#[test]
fn render_case_entity_cards_fallback_to_walls() {
let mut case = make_case();
let config = HtmlConfig::default();
// Remove slugs from entities
for node in &mut case.nodes {
node.slug = None;
}
let html = render_case(&case, &config).unwrap();
// Without slugs, entity cards fall back to wall links
assert!(html.contains("href=\"/walls/01AAA\""));
assert!(html.contains("href=\"/walls/01BBB\""));
}
#[test]
fn render_case_omits_connections_table() {
let case = make_case();
let config = HtmlConfig::default();
let html = render_case(&case, &config).unwrap();
// Connections table is intentionally omitted — relationships are
// already expressed in People/Organizations cards and Timeline
assert!(!html.contains("Connections"));
assert!(!html.contains("loom-rel-table"));
}
#[test]
fn render_person_page() {
let case = make_case();
let config = HtmlConfig::default();
let person = &case.nodes[0];
let cases_list = vec![("test-case".into(), "Test Corruption Case".into())];
let html = render_person(person, &cases_list, &config).unwrap();
assert!(html.contains("itemtype=\"https://schema.org/Person\""));
assert!(html.contains("John Doe"));
assert!(html.contains("Governor of Test Province"));
assert!(html.contains("/walls/01AAA"));
assert!(html.contains("Test Corruption Case"));
assert!(html.contains("application/ld+json"));
// Nationality links to country page
assert!(html.contains("loom-country-link"));
assert!(html.contains("<a href=\"/countries/id\""));
}
#[test]
fn render_organization_page() {
let case = make_case();
let config = HtmlConfig::default();
let org = &case.nodes[1];
let cases_list = vec![("test-case".into(), "Test Corruption Case".into())];
let html = render_organization(org, &cases_list, &config).unwrap();
assert!(html.contains("itemtype=\"https://schema.org/Organization\""));
assert!(html.contains("KPK"));
// Jurisdiction links to country page
assert!(html.contains("loom-country-link"));
assert!(html.contains("<a href=\"/countries/id\""));
}
#[test]
fn render_sitemap_includes_all_urls() {
let cases = vec![("cases/id/corruption/2024/test-case".into(), "Case 1".into())];
let people = vec![("people/id/john-doe".into(), "John".into())];
let orgs = vec![("organizations/id/test-corp".into(), "Corp".into())];
let xml = render_sitemap(
&cases,
&people,
&orgs,
&["id".into()],
&["bribery".into()],
"https://redberrythread.org",
);
assert!(xml.contains("<?xml"));
assert!(xml.contains("/cases/id/corruption/2024/test-case"));
assert!(xml.contains("/people/id/john-doe"));
assert!(xml.contains("/organizations/id/test-corp"));
assert!(xml.contains("/countries/id"));
assert!(xml.contains("/tags/bribery"));
assert!(xml.contains("<url><loc>https://redberrythread.org/countries</loc></url>"));
assert!(xml.contains("<url><loc>https://redberrythread.org/tags</loc></url>"));
}
#[test]
fn escape_html_special_chars() {
assert_eq!(escape("<script>"), "&lt;script&gt;");
assert_eq!(escape("AT&T"), "AT&amp;T");
assert_eq!(escape("\"quoted\""), "&quot;quoted&quot;");
}
#[test]
fn render_inline_markdown_bold_and_italic() {
assert_eq!(
render_inline_markdown("**Amount not publicly documented.**"),
"<strong>Amount not publicly documented.</strong>"
);
assert_eq!(
render_inline_markdown("Some *italic* text"),
"Some <em>italic</em> text"
);
}
#[test]
fn render_inline_markdown_escapes_plain_text() {
// pulldown-cmark decodes HTML entities, then we re-escape
assert_eq!(render_inline_markdown("A & B"), "A &amp; B");
assert_eq!(
render_inline_markdown("use `x < y`"),
"use <code>x &lt; y</code>"
);
}
#[test]
fn render_inline_markdown_links() {
assert_eq!(
render_inline_markdown("[KPK](https://kpk.go.id) arrested him"),
"<a href=\"https://kpk.go.id\" rel=\"noopener noreferrer\">KPK</a> arrested him"
);
}
#[test]
fn truncate_short_string() {
assert_eq!(truncate("hello", 10), "hello");
}
#[test]
fn truncate_long_string() {
let long = "a".repeat(200);
let result = truncate(&long, 120);
assert!(result.len() <= 120);
assert!(result.ends_with("..."));
}
#[test]
fn format_enum_underscore() {
assert_eq!(format_enum("investigated_by"), "Investigated By");
assert_eq!(format_enum("custom:Special Type"), "Special Type");
}
#[test]
fn thumbnail_key_deterministic() {
let k1 = thumbnail_key("https://example.com/photo.jpg");
let k2 = thumbnail_key("https://example.com/photo.jpg");
assert_eq!(k1, k2);
assert!(k1.starts_with("thumbnails/"));
assert!(
std::path::Path::new(&k1)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("webp")),
"key should end with .webp: {k1}"
);
// Key hex part is 32 chars
let hex_part = k1
.strip_prefix("thumbnails/")
.and_then(|s| s.strip_suffix(".webp"))
.unwrap_or("");
assert_eq!(hex_part.len(), THUMB_KEY_HEX_LEN);
}
#[test]
fn thumbnail_key_different_urls_differ() {
let k1 = thumbnail_key("https://example.com/a.jpg");
let k2 = thumbnail_key("https://example.com/b.jpg");
assert_ne!(k1, k2);
}
#[test]
fn rewrite_thumbnail_url_no_config() {
let config = HtmlConfig::default();
let result = rewrite_thumbnail_url("https://example.com/photo.jpg", &config);
assert_eq!(result, "https://example.com/photo.jpg");
}
#[test]
fn rewrite_thumbnail_url_with_base() {
let config = HtmlConfig {
thumbnail_base_url: Some("http://files.garage.local:3902/files".into()),
};
let result = rewrite_thumbnail_url("https://example.com/photo.jpg", &config);
assert!(result.starts_with("http://files.garage.local:3902/files/thumbnails/"));
assert!(
std::path::Path::new(&result)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("webp")),
"result should end with .webp: {result}"
);
assert!(!result.contains("example.com"));
}
#[test]
fn rewrite_thumbnail_url_already_rewritten() {
let config = HtmlConfig {
thumbnail_base_url: Some("https://files.redberrythread.org".into()),
};
let already =
"https://files.redberrythread.org/thumbnails/6fc3a49567393053be6138aa346fa97a.webp";
let result = rewrite_thumbnail_url(already, &config);
assert_eq!(
result, already,
"should not double-hash already-rewritten URLs"
);
}
#[test]
fn render_case_rewrites_thumbnails() {
let case = make_case();
let config = HtmlConfig {
thumbnail_base_url: Some("http://garage.local/files".into()),
};
let html = render_case(&case, &config).unwrap();
// Original URL should not appear in img src
assert!(!html.contains("src=\"https://files.example.com/thumb.webp\""));
// Rewritten URL should appear
assert!(html.contains("src=\"http://garage.local/files/thumbnails/"));
// OG image should also be rewritten
assert!(html.contains("data-og-image=\"http://garage.local/files/thumbnails/"));
}
#[test]
fn render_person_rewrites_thumbnails() {
let case = make_case();
let person = &case.nodes[0];
let config = HtmlConfig {
thumbnail_base_url: Some("http://garage.local/files".into()),
};
let html = render_person(person, &[], &config).unwrap();
assert!(!html.contains("src=\"https://files.example.com/thumb.webp\""));
assert!(html.contains("src=\"http://garage.local/files/thumbnails/"));
}
#[test]
fn render_case_with_related_cases() {
let mut case = make_case();
// Add a related_to relationship and target case node
case.relationships.push(RelOutput {
id: "01RELID".into(),
rel_type: "related_to".into(),
source_id: "01TESTCASE0000000000000000".into(),
source_label: "case".into(),
target_id: "01TARGETCASE000000000000000".into(),
target_label: "case".into(),
source_urls: vec![],
description: Some("Connected bribery scandal".into()),
amounts: vec![],
valid_from: None,
valid_until: None,
});
case.nodes.push(NodeOutput {
id: "01TARGETCASE000000000000000".into(),
label: "case".into(),
name: "Target Scandal Case".into(),
slug: Some("cases/id/corruption/2002/target-scandal".into()),
qualifier: None,
description: None,
thumbnail: None,
aliases: vec![],
urls: vec![],
role: vec![],
nationality: Vec::new(),
date_of_birth: None,
date_of_death: None,
place_of_birth: None,
status: None,
org_type: None,
jurisdiction: None,
headquarters: None,
founded_date: None,
registration_number: None,
event_type: None,
occurred_at: None,
severity: None,
doc_type: None,
issued_at: None,
issuing_authority: None,
case_number: None,
case_type: None,
tagline: None,
amounts: vec![],
asset_type: None,
value: None,
tags: vec![],
});
let config = HtmlConfig::default();
let html = render_case(&case, &config).unwrap();
assert!(html.contains("loom-related-cases"));
assert!(html.contains("Related Cases"));
assert!(html.contains("Target Scandal Case"));
assert!(html.contains("loom-related-card"));
assert!(html.contains("Connected bribery scandal"));
}
#[test]
fn render_tag_page_shows_country() {
let cases = vec![TagCaseEntry {
slug: "cases/id/corruption/2024/test-case".into(),
title: "Test Corruption Case".into(),
amounts: vec![],
}];
let html = render_tag_page("bribery", &cases).unwrap();
assert!(html.contains("loom-country-badge"));
assert!(html.contains("Indonesia"));
}
#[test]
fn render_case_without_related_cases() {
let case = make_case();
let config = HtmlConfig::default();
let html = render_case(&case, &config).unwrap();
assert!(!html.contains("loom-related-cases"));
}
#[test]
fn render_country_page_lists_cases() {
let cases = vec![
TagCaseEntry {
slug: "cases/id/corruption/2024/test-case".into(),
title: "Test Case".into(),
amounts: vec![],
},
TagCaseEntry {
slug: "cases/id/corruption/2024/another-case".into(),
title: "Another Case".into(),
amounts: vec![],
},
];
let html = render_country_page("id", &cases).unwrap();
assert!(html.contains("loom-country-page"));
assert!(html.contains("Indonesia"));
assert!(html.contains("2 cases"));
assert!(html.contains("Test Case"));
assert!(html.contains("Another Case"));
assert!(html.contains("/countries/id"));
assert!(!html.contains("loom-country-badge"));
}
#[test]
fn country_badge_is_link() {
let cases = vec![TagCaseEntry {
slug: "cases/id/corruption/2024/test-case".into(),
title: "Test".into(),
amounts: vec![],
}];
let html = render_tag_page("bribery", &cases).unwrap();
assert!(html.contains("<a href=\"/countries/id\" class=\"loom-country-badge\">"));
}
#[test]
fn render_countries_index_lists_countries() {
let countries = vec![
CountryIndexEntry {
code: "id".into(),
name: "Indonesia".into(),
case_count: 5,
},
CountryIndexEntry {
code: "my".into(),
name: "Malaysia".into(),
case_count: 3,
},
];
let html = render_countries_index(&countries).unwrap();
assert!(html.contains("loom-countries-index"));
assert!(html.contains("Countries"));
assert!(html.contains("8 cases across 2 countries"));
assert!(html.contains("<a href=\"/countries/id\">Indonesia</a>"));
assert!(html.contains("<a href=\"/countries/my\">Malaysia</a>"));
assert!(html.contains("loom-count-badge"));
assert!(html.contains("data-og-url=\"/countries\""));
}
#[test]
fn render_tags_index_lists_tags() {
let tags = vec![
TagIndexEntry {
tag: "bribery".into(),
case_count: 10,
},
TagIndexEntry {
tag: "money-laundering".into(),
case_count: 7,
},
];
let html = render_tags_index(&tags).unwrap();
assert!(html.contains("loom-tags-index"));
assert!(html.contains("Tags"));
assert!(html.contains("17 cases across 2 tags"));
assert!(html.contains("<a href=\"/tags/bribery\">bribery</a>"));
assert!(html.contains("<a href=\"/tags/money-laundering\">money laundering</a>"));
assert!(html.contains("loom-count-badge"));
assert!(html.contains("data-og-url=\"/tags\""));
}