1#![allow(clippy::format_push_string)]
9
10use crate::domain::Jurisdiction;
11use crate::output::{CaseOutput, NodeOutput, RelOutput};
12use crate::parser::SourceEntry;
13
14const MAX_FRAGMENT_BYTES: usize = 512_000;
16
17pub fn render_case(case: &CaseOutput) -> Result<String, String> {
23 let mut html = String::with_capacity(8192);
24
25 let og_title = truncate(&case.title, 120);
26 let og_description = build_case_og_description(case);
27
28 html.push_str(&format!(
30 "<article class=\"loom-case\" itemscope itemtype=\"https://schema.org/Article\" \
31 data-og-title=\"{}\" \
32 data-og-description=\"{}\" \
33 data-og-type=\"article\" \
34 data-og-url=\"/{}\"{}>\n",
35 escape_attr(&og_title),
36 escape_attr(&og_description),
37 escape_attr(case.slug.as_deref().unwrap_or(&case.case_id)),
38 og_image_attr(case_hero_image(case).as_deref()),
39 ));
40
41 render_case_header(&mut html, case);
43
44 render_sources(&mut html, &case.sources);
46
47 let people: Vec<&NodeOutput> = case.nodes.iter().filter(|n| n.label == "person").collect();
49 if !people.is_empty() {
50 render_entity_section(&mut html, "People", &people);
51 }
52
53 let orgs: Vec<&NodeOutput> = case
55 .nodes
56 .iter()
57 .filter(|n| n.label == "organization")
58 .collect();
59 if !orgs.is_empty() {
60 render_entity_section(&mut html, "Organizations", &orgs);
61 }
62
63 let mut events: Vec<&NodeOutput> = case.nodes.iter().filter(|n| n.label == "event").collect();
65 events.sort_by(|a, b| a.occurred_at.cmp(&b.occurred_at));
66 if !events.is_empty() {
67 render_timeline(&mut html, &events);
68 }
69
70 if !case.relationships.is_empty() {
72 render_connections(&mut html, &case.relationships, &case.nodes);
73 }
74
75 render_case_json_ld(&mut html, case);
77
78 html.push_str("</article>\n");
79
80 if html.len() > MAX_FRAGMENT_BYTES {
81 return Err(format!(
82 "HTML fragment exceeds {MAX_FRAGMENT_BYTES} bytes ({} bytes)",
83 html.len()
84 ));
85 }
86
87 Ok(html)
88}
89
90pub fn render_person(
96 node: &NodeOutput,
97 cases: &[(String, String)], ) -> Result<String, String> {
99 let mut html = String::with_capacity(4096);
100
101 let og_title = truncate(&node.name, 120);
102 let og_description = build_person_og_description(node);
103
104 html.push_str(&format!(
105 "<article class=\"loom-person\" itemscope itemtype=\"https://schema.org/Person\" \
106 data-og-title=\"{}\" \
107 data-og-description=\"{}\" \
108 data-og-type=\"profile\" \
109 data-og-url=\"/{}\"{}>\n",
110 escape_attr(&og_title),
111 escape_attr(&og_description),
112 escape_attr(node.slug.as_deref().unwrap_or(&node.id)),
113 og_image_attr(node.thumbnail.as_deref()),
114 ));
115
116 render_entity_detail(&mut html, node);
117 render_cases_list(&mut html, cases);
118 render_person_json_ld(&mut html, node);
119
120 html.push_str("</article>\n");
121
122 check_size(&html)
123}
124
125pub fn render_organization(
131 node: &NodeOutput,
132 cases: &[(String, String)],
133) -> Result<String, String> {
134 let mut html = String::with_capacity(4096);
135
136 let og_title = truncate(&node.name, 120);
137 let og_description = build_org_og_description(node);
138
139 html.push_str(&format!(
140 "<article class=\"loom-organization\" itemscope itemtype=\"https://schema.org/Organization\" \
141 data-og-title=\"{}\" \
142 data-og-description=\"{}\" \
143 data-og-type=\"profile\" \
144 data-og-url=\"/{}\"{}>\n",
145 escape_attr(&og_title),
146 escape_attr(&og_description),
147 escape_attr(node.slug.as_deref().unwrap_or(&node.id)),
148 og_image_attr(node.thumbnail.as_deref()),
149 ));
150
151 render_entity_detail(&mut html, node);
152 render_cases_list(&mut html, cases);
153 render_org_json_ld(&mut html, node);
154
155 html.push_str("</article>\n");
156
157 check_size(&html)
158}
159
160fn render_case_header(html: &mut String, case: &CaseOutput) {
163 html.push_str(&format!(
164 " <header class=\"loom-case-header\">\n <h1 itemprop=\"headline\">{}</h1>\n",
165 escape(&case.title)
166 ));
167
168 if !case.tags.is_empty() {
169 html.push_str(" <div class=\"loom-tags\">\n");
170 for tag in &case.tags {
171 html.push_str(&format!(
172 " <a href=\"/tags/{}\" class=\"loom-tag\">{}</a>\n",
173 escape_attr(tag),
174 escape(tag)
175 ));
176 }
177 html.push_str(" </div>\n");
178 }
179
180 if !case.summary.is_empty() {
181 html.push_str(&format!(
182 " <p class=\"loom-summary\" itemprop=\"description\">{}</p>\n",
183 escape(&case.summary)
184 ));
185 }
186
187 html.push_str(" </header>\n");
188}
189
190fn render_sources(html: &mut String, sources: &[SourceEntry]) {
191 if sources.is_empty() {
192 return;
193 }
194 html.push_str(" <section class=\"loom-sources\">\n <h2>Sources</h2>\n <ol>\n");
195 for source in sources {
196 match source {
197 SourceEntry::Url(url) => {
198 html.push_str(&format!(
199 " <li><a href=\"{}\" rel=\"noopener noreferrer\" target=\"_blank\">{}</a></li>\n",
200 escape_attr(url),
201 escape(url)
202 ));
203 }
204 SourceEntry::Structured { url, title, .. } => {
205 let display = title.as_deref().unwrap_or(url.as_str());
206 html.push_str(&format!(
207 " <li><a href=\"{}\" rel=\"noopener noreferrer\" target=\"_blank\">{}</a></li>\n",
208 escape_attr(url),
209 escape(display)
210 ));
211 }
212 }
213 }
214 html.push_str(" </ol>\n </section>\n");
215}
216
217fn render_entity_section(html: &mut String, title: &str, nodes: &[&NodeOutput]) {
218 html.push_str(&format!(
219 " <section class=\"loom-entities loom-entities-{}\">\n <h2>{title}</h2>\n <div class=\"loom-entity-cards\">\n",
220 title.to_lowercase()
221 ));
222 for node in nodes {
223 render_entity_card(html, node);
224 }
225 html.push_str(" </div>\n </section>\n");
226}
227
228fn render_entity_card(html: &mut String, node: &NodeOutput) {
229 let schema_type = match node.label.as_str() {
230 "person" => "Person",
231 "organization" => "Organization",
232 _ => "Thing",
233 };
234 html.push_str(&format!(
235 " <div class=\"loom-entity-card\" itemscope itemtype=\"https://schema.org/{schema_type}\">\n"
236 ));
237
238 if let Some(thumb) = &node.thumbnail {
239 html.push_str(&format!(
240 " <img src=\"{}\" alt=\"{}\" class=\"loom-thumbnail\" itemprop=\"image\" loading=\"lazy\" width=\"64\" height=\"64\" />\n",
241 escape_attr(thumb),
242 escape_attr(&node.name)
243 ));
244 }
245
246 html.push_str(&format!(
247 " <div class=\"loom-entity-info\">\n \
248 <a href=\"/canvas/{}\" class=\"loom-entity-name\" itemprop=\"name\">{}</a>\n",
249 escape_attr(&node.id),
250 escape(&node.name)
251 ));
252
253 if let Some(q) = &node.qualifier {
254 html.push_str(&format!(
255 " <span class=\"loom-qualifier\">{}</span>\n",
256 escape(q)
257 ));
258 }
259
260 match node.label.as_str() {
262 "person" => {
263 render_dl_field(html, "Role", &node.role.join(", "));
264 render_dl_opt(html, "Nationality", node.nationality.as_ref());
265 }
266 "organization" => {
267 render_dl_opt(html, "Type", node.org_type.as_ref());
268 if let Some(j) = &node.jurisdiction {
269 render_dl_field(html, "Jurisdiction", &format_jurisdiction(j));
270 }
271 }
272 _ => {}
273 }
274
275 html.push_str(" </div>\n </div>\n");
276}
277
278fn render_timeline(html: &mut String, events: &[&NodeOutput]) {
279 html.push_str(
280 " <section class=\"loom-timeline\">\n <h2>Timeline</h2>\n <ol class=\"loom-events\">\n",
281 );
282 for event in events {
283 html.push_str(" <li class=\"loom-event\">\n");
284 if let Some(date) = &event.occurred_at {
285 html.push_str(&format!(
286 " <time datetime=\"{}\" class=\"loom-event-date\">{}</time>\n",
287 escape_attr(date),
288 escape(date)
289 ));
290 }
291 html.push_str(&format!(
292 " <span class=\"loom-event-name\">{}</span>\n",
293 escape(&event.name)
294 ));
295 if let Some(et) = &event.event_type {
296 html.push_str(&format!(
297 " <span class=\"loom-event-type\">{}</span>\n",
298 escape(&format_enum(et))
299 ));
300 }
301 if let Some(desc) = &event.description {
302 html.push_str(&format!(
303 " <p class=\"loom-event-description\">{}</p>\n",
304 escape(desc)
305 ));
306 }
307 html.push_str(" </li>\n");
308 }
309 html.push_str(" </ol>\n </section>\n");
310}
311
312fn render_connections(html: &mut String, rels: &[RelOutput], nodes: &[NodeOutput]) {
313 html.push_str(
314 " <section class=\"loom-connections\">\n <h2>Connections</h2>\n \
315 <table class=\"loom-rel-table\">\n <thead>\n \
316 <tr><th>From</th><th>Type</th><th>To</th><th>Details</th></tr>\n \
317 </thead>\n <tbody>\n",
318 );
319 for rel in rels {
320 let source_name = nodes
321 .iter()
322 .find(|n| n.id == rel.source_id)
323 .map_or("?", |n| &n.name);
324 let target_name = nodes
325 .iter()
326 .find(|n| n.id == rel.target_id)
327 .map_or("?", |n| &n.name);
328
329 let mut details = Vec::new();
330 if let Some(desc) = &rel.description {
331 details.push(desc.clone());
332 }
333 if let Some(amt) = &rel.amount {
334 if let Some(cur) = &rel.currency {
335 details.push(format!("{amt} {cur}"));
336 } else {
337 details.push(amt.clone());
338 }
339 }
340 if let Some(vf) = &rel.valid_from {
341 details.push(format!("from {vf}"));
342 }
343 if let Some(vu) = &rel.valid_until {
344 details.push(format!("until {vu}"));
345 }
346
347 html.push_str(&format!(
348 " <tr><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
349 escape(source_name),
350 escape(&format_enum(&rel.rel_type)),
351 escape(target_name),
352 escape(&details.join("; ")),
353 ));
354 }
355 html.push_str(" </tbody>\n </table>\n </section>\n");
356}
357
358fn render_entity_detail(html: &mut String, node: &NodeOutput) {
361 html.push_str(" <header class=\"loom-entity-header\">\n");
362
363 if let Some(thumb) = &node.thumbnail {
364 html.push_str(&format!(
365 " <img src=\"{}\" alt=\"{}\" class=\"loom-thumbnail-large\" itemprop=\"image\" loading=\"lazy\" width=\"128\" height=\"128\" />\n",
366 escape_attr(thumb),
367 escape_attr(&node.name)
368 ));
369 }
370
371 html.push_str(&format!(
372 " <h1 itemprop=\"name\">{}</h1>\n",
373 escape(&node.name)
374 ));
375
376 if let Some(q) = &node.qualifier {
377 html.push_str(&format!(
378 " <p class=\"loom-qualifier\">{}</p>\n",
379 escape(q)
380 ));
381 }
382
383 html.push_str(&format!(
384 " <a href=\"/canvas/{}\" class=\"loom-canvas-link\">View on canvas</a>\n",
385 escape_attr(&node.id)
386 ));
387 html.push_str(" </header>\n");
388
389 if let Some(desc) = &node.description {
391 html.push_str(&format!(
392 " <p class=\"loom-description\" itemprop=\"description\">{}</p>\n",
393 escape(desc)
394 ));
395 }
396
397 html.push_str(" <dl class=\"loom-fields\">\n");
399
400 match node.label.as_str() {
401 "person" => {
402 render_dl_item(html, "Role", &node.role.join(", "));
403 render_dl_opt_item(html, "Nationality", node.nationality.as_ref());
404 render_dl_opt_item(html, "Date of Birth", node.date_of_birth.as_ref());
405 render_dl_opt_item(html, "Place of Birth", node.place_of_birth.as_ref());
406 render_dl_opt_item(html, "Status", node.status.as_ref());
407 }
408 "organization" => {
409 render_dl_opt_item(html, "Type", node.org_type.as_ref());
410 if let Some(j) = &node.jurisdiction {
411 render_dl_item(html, "Jurisdiction", &format_jurisdiction(j));
412 }
413 render_dl_opt_item(html, "Headquarters", node.headquarters.as_ref());
414 render_dl_opt_item(html, "Founded", node.founded_date.as_ref());
415 render_dl_opt_item(html, "Registration", node.registration_number.as_ref());
416 render_dl_opt_item(html, "Status", node.status.as_ref());
417 }
418 _ => {}
419 }
420
421 html.push_str(" </dl>\n");
422
423 if !node.aliases.is_empty() {
425 html.push_str(" <div class=\"loom-aliases\">\n <h3>Also known as</h3>\n <ul>\n");
426 for alias in &node.aliases {
427 html.push_str(&format!(" <li>{}</li>\n", escape(alias)));
428 }
429 html.push_str(" </ul>\n </div>\n");
430 }
431}
432
433fn render_cases_list(html: &mut String, cases: &[(String, String)]) {
434 if cases.is_empty() {
435 return;
436 }
437 html.push_str(
438 " <section class=\"loom-cases\">\n <h2>Cases</h2>\n <ul class=\"loom-case-list\">\n",
439 );
440 for (case_slug, case_title) in cases {
441 html.push_str(&format!(
442 " <li><a href=\"/{}\">{}</a></li>\n",
443 escape_attr(case_slug),
444 escape(case_title)
445 ));
446 }
447 html.push_str(" </ul>\n </section>\n");
448}
449
450fn render_case_json_ld(html: &mut String, case: &CaseOutput) {
453 let mut ld = serde_json::json!({
454 "@context": "https://schema.org",
455 "@type": "Article",
456 "headline": truncate(&case.title, 120),
457 "description": truncate(&case.summary, 200),
458 "url": format!("/{}", case.slug.as_deref().unwrap_or(&case.case_id)),
459 });
460
461 if !case.sources.is_empty() {
462 let urls: Vec<&str> = case
463 .sources
464 .iter()
465 .map(|s| match s {
466 SourceEntry::Url(u) => u.as_str(),
467 SourceEntry::Structured { url, .. } => url.as_str(),
468 })
469 .collect();
470 ld["citation"] = serde_json::json!(urls);
471 }
472
473 html.push_str(&format!(
474 " <script type=\"application/ld+json\">{}</script>\n",
475 serde_json::to_string(&ld).unwrap_or_default()
476 ));
477}
478
479fn render_person_json_ld(html: &mut String, node: &NodeOutput) {
480 let mut ld = serde_json::json!({
481 "@context": "https://schema.org",
482 "@type": "Person",
483 "name": &node.name,
484 "url": format!("/{}", node.slug.as_deref().unwrap_or(&node.id)),
485 });
486
487 if let Some(nat) = &node.nationality {
488 ld["nationality"] = serde_json::json!(nat);
489 }
490 if let Some(desc) = &node.description {
491 ld["description"] = serde_json::json!(truncate(desc, 200));
492 }
493 if let Some(thumb) = &node.thumbnail {
494 ld["image"] = serde_json::json!(thumb);
495 }
496
497 html.push_str(&format!(
498 " <script type=\"application/ld+json\">{}</script>\n",
499 serde_json::to_string(&ld).unwrap_or_default()
500 ));
501}
502
503fn render_org_json_ld(html: &mut String, node: &NodeOutput) {
504 let mut ld = serde_json::json!({
505 "@context": "https://schema.org",
506 "@type": "Organization",
507 "name": &node.name,
508 "url": format!("/{}", node.slug.as_deref().unwrap_or(&node.id)),
509 });
510
511 if let Some(desc) = &node.description {
512 ld["description"] = serde_json::json!(truncate(desc, 200));
513 }
514 if let Some(thumb) = &node.thumbnail {
515 ld["logo"] = serde_json::json!(thumb);
516 }
517
518 html.push_str(&format!(
519 " <script type=\"application/ld+json\">{}</script>\n",
520 serde_json::to_string(&ld).unwrap_or_default()
521 ));
522}
523
524pub fn render_sitemap(
531 cases: &[(String, String)],
532 people: &[(String, String)],
533 organizations: &[(String, String)],
534 base_url: &str,
535) -> String {
536 let mut xml = String::with_capacity(4096);
537 xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
538 xml.push_str("<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
539
540 for (slug, _) in cases {
541 xml.push_str(&format!(
542 " <url><loc>{base_url}/{}</loc></url>\n",
543 escape(slug)
544 ));
545 }
546 for (slug, _) in people {
547 xml.push_str(&format!(
548 " <url><loc>{base_url}/{}</loc></url>\n",
549 escape(slug)
550 ));
551 }
552 for (slug, _) in organizations {
553 xml.push_str(&format!(
554 " <url><loc>{base_url}/{}</loc></url>\n",
555 escape(slug)
556 ));
557 }
558
559 xml.push_str("</urlset>\n");
560 xml
561}
562
563fn build_case_og_description(case: &CaseOutput) -> String {
566 if !case.summary.is_empty() {
567 return truncate(&case.summary, 200);
568 }
569 let people_count = case.nodes.iter().filter(|n| n.label == "person").count();
570 let org_count = case
571 .nodes
572 .iter()
573 .filter(|n| n.label == "organization")
574 .count();
575 truncate(
576 &format!(
577 "{} people, {} organizations, {} connections",
578 people_count,
579 org_count,
580 case.relationships.len()
581 ),
582 200,
583 )
584}
585
586fn build_person_og_description(node: &NodeOutput) -> String {
587 let mut parts = Vec::new();
588 if let Some(q) = &node.qualifier {
589 parts.push(q.clone());
590 }
591 if !node.role.is_empty() {
592 parts.push(node.role.join(", "));
593 }
594 if let Some(nat) = &node.nationality {
595 parts.push(nat.clone());
596 }
597 if parts.is_empty() {
598 return truncate(&node.name, 200);
599 }
600 truncate(&format!("{} — {}", node.name, parts.join(" · ")), 200)
601}
602
603fn build_org_og_description(node: &NodeOutput) -> String {
604 let mut parts = Vec::new();
605 if let Some(q) = &node.qualifier {
606 parts.push(q.clone());
607 }
608 if let Some(ot) = &node.org_type {
609 parts.push(format_enum(ot));
610 }
611 if let Some(j) = &node.jurisdiction {
612 parts.push(format_jurisdiction(j));
613 }
614 if parts.is_empty() {
615 return truncate(&node.name, 200);
616 }
617 truncate(&format!("{} — {}", node.name, parts.join(" · ")), 200)
618}
619
620fn check_size(html: &str) -> Result<String, String> {
621 if html.len() > MAX_FRAGMENT_BYTES {
622 Err(format!(
623 "HTML fragment exceeds {MAX_FRAGMENT_BYTES} bytes ({} bytes)",
624 html.len()
625 ))
626 } else {
627 Ok(html.to_string())
628 }
629}
630
631fn truncate(s: &str, max: usize) -> String {
632 if s.len() <= max {
633 s.to_string()
634 } else {
635 let truncated: String = s.chars().take(max.saturating_sub(3)).collect();
636 format!("{truncated}...")
637 }
638}
639
640fn escape(s: &str) -> String {
641 s.replace('&', "&")
642 .replace('<', "<")
643 .replace('>', ">")
644 .replace('"', """)
645}
646
647fn escape_attr(s: &str) -> String {
648 escape(s)
649}
650
651fn og_image_attr(url: Option<&str>) -> String {
653 match url {
654 Some(u) if !u.is_empty() => format!(" data-og-image=\"{}\"", escape_attr(u)),
655 _ => String::new(),
656 }
657}
658
659fn case_hero_image(case: &CaseOutput) -> Option<String> {
661 case.nodes
662 .iter()
663 .filter(|n| n.label == "person")
664 .find_map(|n| n.thumbnail.clone())
665}
666
667fn format_jurisdiction(j: &Jurisdiction) -> String {
668 match &j.subdivision {
669 Some(sub) => format!("{}, {sub}", j.country),
670 None => j.country.clone(),
671 }
672}
673
674fn format_enum(s: &str) -> String {
675 if let Some(custom) = s.strip_prefix("custom:") {
676 return custom.to_string();
677 }
678 s.replace('_', " ")
679}
680
681fn render_dl_field(html: &mut String, label: &str, value: &str) {
682 if !value.is_empty() {
683 html.push_str(&format!(
684 " <span class=\"loom-field\"><strong>{label}:</strong> {}</span>\n",
685 escape(value)
686 ));
687 }
688}
689
690fn render_dl_opt(html: &mut String, label: &str, value: Option<&String>) {
691 if let Some(v) = value {
692 render_dl_field(html, label, v);
693 }
694}
695
696fn render_dl_item(html: &mut String, label: &str, value: &str) {
697 if !value.is_empty() {
698 html.push_str(&format!(
699 " <dt>{label}</dt>\n <dd>{}</dd>\n",
700 escape(value)
701 ));
702 }
703}
704
705fn render_dl_opt_item(html: &mut String, label: &str, value: Option<&String>) {
706 if let Some(v) = value {
707 render_dl_item(html, label, v);
708 }
709}
710
711#[cfg(test)]
712mod tests {
713 use super::*;
714 use crate::output::{CaseOutput, NodeOutput, RelOutput};
715 use crate::parser::SourceEntry;
716
717 fn make_case() -> CaseOutput {
718 CaseOutput {
719 id: "01TESTCASE0000000000000000".into(),
720 case_id: "test-case".into(),
721 title: "Test Corruption Case".into(),
722 summary: "A politician was caught accepting bribes.".into(),
723 tags: vec!["bribery".into(), "government".into()],
724 slug: None,
725 case_type: None,
726 status: None,
727 nodes: vec![
728 NodeOutput {
729 id: "01AAA".into(),
730 label: "person".into(),
731 name: "John Doe".into(),
732 slug: None,
733 qualifier: Some("Governor of Test Province".into()),
734 description: None,
735 thumbnail: Some("https://files.example.com/thumb.webp".into()),
736 aliases: vec![],
737 urls: vec![],
738 role: vec!["politician".into()],
739 nationality: Some("ID".into()),
740 date_of_birth: None,
741 place_of_birth: None,
742 status: Some("convicted".into()),
743 org_type: None,
744 jurisdiction: None,
745 headquarters: None,
746 founded_date: None,
747 registration_number: None,
748 event_type: None,
749 occurred_at: None,
750 severity: None,
751 doc_type: None,
752 issued_at: None,
753 issuing_authority: None,
754 case_number: None,
755 case_type: None,
756 asset_type: None,
757 value: None,
758 tags: vec![],
759 },
760 NodeOutput {
761 id: "01BBB".into(),
762 label: "organization".into(),
763 name: "KPK".into(),
764 slug: None,
765 qualifier: Some("Anti-Corruption Commission".into()),
766 description: None,
767 thumbnail: None,
768 aliases: vec![],
769 urls: vec![],
770 role: vec![],
771 nationality: None,
772 date_of_birth: None,
773 place_of_birth: None,
774 status: None,
775 org_type: Some("government_agency".into()),
776 jurisdiction: Some(Jurisdiction {
777 country: "ID".into(),
778 subdivision: None,
779 }),
780 headquarters: None,
781 founded_date: None,
782 registration_number: None,
783 event_type: None,
784 occurred_at: None,
785 severity: None,
786 doc_type: None,
787 issued_at: None,
788 issuing_authority: None,
789 case_number: None,
790 case_type: None,
791 asset_type: None,
792 value: None,
793 tags: vec![],
794 },
795 NodeOutput {
796 id: "01CCC".into(),
797 label: "event".into(),
798 name: "Arrest".into(),
799 slug: None,
800 qualifier: None,
801 description: Some("John Doe arrested by KPK.".into()),
802 thumbnail: None,
803 aliases: vec![],
804 urls: vec![],
805 role: vec![],
806 nationality: None,
807 date_of_birth: None,
808 place_of_birth: None,
809 status: None,
810 org_type: None,
811 jurisdiction: None,
812 headquarters: None,
813 founded_date: None,
814 registration_number: None,
815 event_type: Some("arrest".into()),
816 occurred_at: Some("2024-03-15".into()),
817 severity: None,
818 doc_type: None,
819 issued_at: None,
820 issuing_authority: None,
821 case_number: None,
822 case_type: None,
823 asset_type: None,
824 value: None,
825 tags: vec![],
826 },
827 ],
828 relationships: vec![RelOutput {
829 id: "01DDD".into(),
830 rel_type: "investigated_by".into(),
831 source_id: "01BBB".into(),
832 target_id: "01CCC".into(),
833 source_urls: vec![],
834 description: None,
835 amount: None,
836 currency: None,
837 valid_from: None,
838 valid_until: None,
839 }],
840 sources: vec![SourceEntry::Url("https://example.com/article".into())],
841 }
842 }
843
844 #[test]
845 fn render_case_produces_valid_html() {
846 let case = make_case();
847 let html = render_case(&case).unwrap();
848
849 assert!(html.starts_with("<article"));
850 assert!(html.ends_with("</article>\n"));
851 assert!(html.contains("data-og-title=\"Test Corruption Case\""));
852 assert!(html.contains("data-og-description="));
853 assert!(html.contains("<h1 itemprop=\"headline\">Test Corruption Case</h1>"));
854 assert!(html.contains("loom-tag"));
855 assert!(html.contains("bribery"));
856 assert!(html.contains("John Doe"));
857 assert!(html.contains("KPK"));
858 assert!(html.contains("Arrest"));
859 assert!(html.contains("2024-03-15"));
860 assert!(html.contains("application/ld+json"));
861 }
862
863 #[test]
864 fn render_case_has_sources() {
865 let case = make_case();
866 let html = render_case(&case).unwrap();
867 assert!(html.contains("Sources"));
868 assert!(html.contains("https://example.com/article"));
869 }
870
871 #[test]
872 fn render_case_has_connections_table() {
873 let case = make_case();
874 let html = render_case(&case).unwrap();
875 assert!(html.contains("Connections"));
876 assert!(html.contains("investigated by"));
877 assert!(html.contains("<table"));
878 }
879
880 #[test]
881 fn render_person_page() {
882 let case = make_case();
883 let person = &case.nodes[0];
884 let cases_list = vec![("test-case".into(), "Test Corruption Case".into())];
885 let html = render_person(person, &cases_list).unwrap();
886
887 assert!(html.contains("itemtype=\"https://schema.org/Person\""));
888 assert!(html.contains("John Doe"));
889 assert!(html.contains("Governor of Test Province"));
890 assert!(html.contains("/canvas/01AAA"));
891 assert!(html.contains("Test Corruption Case"));
892 assert!(html.contains("application/ld+json"));
893 }
894
895 #[test]
896 fn render_organization_page() {
897 let case = make_case();
898 let org = &case.nodes[1];
899 let cases_list = vec![("test-case".into(), "Test Corruption Case".into())];
900 let html = render_organization(org, &cases_list).unwrap();
901
902 assert!(html.contains("itemtype=\"https://schema.org/Organization\""));
903 assert!(html.contains("KPK"));
904 assert!(html.contains("ID")); }
906
907 #[test]
908 fn render_sitemap_includes_all_urls() {
909 let cases = vec![("cases/id/corruption/2024/test-case".into(), "Case 1".into())];
910 let people = vec![("people/id/john-doe".into(), "John".into())];
911 let orgs = vec![("organizations/id/test-corp".into(), "Corp".into())];
912 let xml = render_sitemap(&cases, &people, &orgs, "https://redberrythread.org");
913
914 assert!(xml.contains("<?xml"));
915 assert!(xml.contains("/cases/id/corruption/2024/test-case"));
916 assert!(xml.contains("/people/id/john-doe"));
917 assert!(xml.contains("/organizations/id/test-corp"));
918 }
919
920 #[test]
921 fn escape_html_special_chars() {
922 assert_eq!(escape("<script>"), "<script>");
923 assert_eq!(escape("AT&T"), "AT&T");
924 assert_eq!(escape("\"quoted\""), ""quoted"");
925 }
926
927 #[test]
928 fn truncate_short_string() {
929 assert_eq!(truncate("hello", 10), "hello");
930 }
931
932 #[test]
933 fn truncate_long_string() {
934 let long = "a".repeat(200);
935 let result = truncate(&long, 120);
936 assert!(result.len() <= 120);
937 assert!(result.ends_with("..."));
938 }
939
940 #[test]
941 fn format_enum_underscore() {
942 assert_eq!(format_enum("investigated_by"), "investigated by");
943 assert_eq!(format_enum("custom:Special Type"), "Special Type");
944 }
945}