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