1use std::io::{self, Write};
10
11use chrono::{DateTime, Utc};
12
13use crate::diagnostic::Diagnostic;
14use crate::model::{DataModelDetail, DataModelStructure, ProjectDetail};
15use crate::render::auth::{
16 AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
17};
18use crate::render::dump::{DumpDeleteOutcome, DumpOutcome};
19use crate::render::table::render_prose_footer;
20use crate::render::value::render_value_content;
21use crate::render::{
22 DataModelListView, MetaContext, ProjectListView, Renderer, ResourceListPagination,
23 ResourceListView, ResourceTypeListView,
24};
25use crate::util::text::{html_to_text, strip_control_chars};
26
27fn format_expiry_clause(expires_at: Option<DateTime<Utc>>, expired: bool) -> String {
38 match expires_at {
39 Some(exp) if expired => {
40 format!(" Token expired {}.", exp.format("%Y-%m-%d %H:%M UTC"))
41 }
42 Some(exp) => {
43 format!(" Token expires {}.", exp.format("%Y-%m-%d %H:%M UTC"))
44 }
45 None => String::new(),
46 }
47}
48
49pub struct ProseRenderer {
51 out: Box<dyn Write>,
52}
53
54impl ProseRenderer {
55 pub fn new() -> Self {
57 Self {
58 out: Box::new(io::stdout()),
59 }
60 }
61
62 pub fn with_writer(w: impl Write + 'static) -> Self {
64 Self { out: Box::new(w) }
65 }
66}
67
68impl Default for ProseRenderer {
69 fn default() -> Self {
70 Self::new()
71 }
72}
73
74impl Renderer for ProseRenderer {
75 fn diagnostic(&mut self, diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
76 eprintln!("Error: {diag}"); Ok(())
78 }
79
80 fn auth_login(
81 &mut self,
82 outcome: &AuthLoginOutcome,
83 _meta: &MetaContext,
84 ) -> Result<(), Diagnostic> {
85 match &outcome.expires_at {
86 Some(exp) => writeln!(
87 self.out,
88 "Logged in to {} as {}. Token expires {}.",
89 outcome.server,
90 outcome.user,
91 exp.format("%Y-%m-%d %H:%M UTC"),
92 )?,
93 None => writeln!(
94 self.out,
95 "Logged in to {} as {}.",
96 outcome.server, outcome.user,
97 )?,
98 }
99 Ok(())
100 }
101
102 fn auth_status(
103 &mut self,
104 outcome: &AuthStatusOutcome,
105 _meta: &MetaContext,
106 ) -> Result<(), Diagnostic> {
107 match outcome {
108 AuthStatusOutcome::LoggedIn {
109 server,
110 user,
111 expires_at,
112 expired,
113 } => {
114 let user_str = user
115 .as_deref()
116 .map(|u| format!(" as {u}"))
117 .unwrap_or_default();
118 let expiry_str = format_expiry_clause(*expires_at, *expired);
119 writeln!(self.out, "Logged in to {server}{user_str}.{expiry_str}")?;
120 }
121 AuthStatusOutcome::AuthenticatedViaEnv {
122 server,
123 expires_at,
124 expired,
125 } => {
126 let expiry_str = match expires_at {
127 None => " Token expiry unknown.".to_string(),
128 Some(_) => format_expiry_clause(*expires_at, *expired),
129 };
130 writeln!(
131 self.out,
132 "Authenticated to {server} via DSP_TOKEN.{expiry_str}"
133 )?;
134 }
135 AuthStatusOutcome::NotLoggedIn { server } => {
136 writeln!(self.out, "Not logged in to {server}.")?;
137 }
138 }
139 Ok(())
140 }
141
142 fn auth_logout(
143 &mut self,
144 outcome: &AuthLogoutOutcome,
145 _meta: &MetaContext,
146 ) -> Result<(), Diagnostic> {
147 if outcome.was_cached {
148 writeln!(self.out, "Logged out of {}.", outcome.server)?;
149 } else {
150 writeln!(
151 self.out,
152 "Not logged in to {} (nothing to remove).",
153 outcome.server
154 )?;
155 }
156 Ok(())
157 }
158
159 fn auth_set_token(
160 &mut self,
161 outcome: &AuthSetTokenOutcome,
162 _meta: &MetaContext,
163 ) -> Result<(), Diagnostic> {
164 let user_clause = outcome
165 .user
166 .as_deref()
167 .map(|u| format!(" as {u}"))
168 .unwrap_or_default();
169 let expiry_clause = match outcome.expires_at {
170 Some(exp) => format!(" Token expires {}.", exp.format("%Y-%m-%d %H:%M UTC")),
171 None => String::new(),
172 };
173 writeln!(
174 self.out,
175 "Cached token for {server}{user_clause}.{expiry_clause}",
176 server = outcome.server,
177 )?;
178 Ok(())
179 }
180
181 fn project_dump(
182 &mut self,
183 outcome: &DumpOutcome,
184 _meta: &MetaContext,
185 ) -> Result<(), Diagnostic> {
186 if outcome.reused {
187 match outcome.created_at {
190 Some(ts) => writeln!(
191 self.out,
192 "Downloaded existing dump (created {}).",
193 ts.format("%Y-%m-%d %H:%M UTC"),
194 )?,
195 None => writeln!(self.out, "Downloaded existing dump.")?,
196 }
197 }
198 writeln!(
199 self.out,
200 "Wrote {} ({} bytes).",
201 outcome.path.display(),
202 outcome.bytes,
203 )?;
204 if outcome.cleaned_up {
205 writeln!(self.out, "Cleaned up the server-side dump.")?;
206 }
207 Ok(())
208 }
209
210 fn project_dump_deleted(
211 &mut self,
212 outcome: &DumpDeleteOutcome,
213 _meta: &MetaContext,
214 ) -> Result<(), Diagnostic> {
215 if outcome.deleted {
216 writeln!(self.out, "Removed the project's dump.")?;
217 } else if let Some(ref note) = outcome.note {
218 writeln!(self.out, "{note}")?;
219 }
220 Ok(())
221 }
222
223 fn projects(&mut self, view: &ProjectListView, meta: &MetaContext) -> Result<(), Diagnostic> {
224 let n = view.items.len();
226 match &view.filter {
227 None => writeln!(self.out, "Projects on {} ({n}):", meta.server_label)?,
228 Some(f) => writeln!(
229 self.out,
230 "Projects on {} ({n} of {} matching \"{f}\"):",
231 meta.server_label, view.total
232 )?,
233 }
234
235 writeln!(self.out)?;
236
237 let sc_w = view
239 .items
240 .iter()
241 .map(|p| p.shortcode.len())
242 .max()
243 .unwrap_or(0);
244 let sn_w = view
245 .items
246 .iter()
247 .map(|p| p.shortname.len())
248 .max()
249 .unwrap_or(0);
250 let ln_w = view
251 .items
252 .iter()
253 .map(|p| p.longname.as_deref().unwrap_or("").len())
254 .max()
255 .unwrap_or(0);
256
257 for item in &view.items {
258 let longname = item.longname.as_deref().unwrap_or("");
259 write!(
263 self.out,
264 " {:<sc_w$} {:<sn_w$} {:<ln_w$} {}",
265 item.shortcode,
266 item.shortname,
267 longname,
268 item.status.as_str(),
269 )?;
270 if item.data_models > 0 {
271 let label = if item.data_models == 1 {
272 "data-model"
273 } else {
274 "data-models"
275 };
276 write!(self.out, " \u{b7} {} {label}", item.data_models)?;
277 }
278 writeln!(self.out)?;
279 }
280
281 render_prose_footer(&mut *self.out, meta)?;
282 Ok(())
283 }
284
285 fn project_describe(
286 &mut self,
287 project: &ProjectDetail,
288 meta: &MetaContext,
289 ) -> Result<(), Diagnostic> {
290 writeln!(
293 self.out,
294 "Project: {} ({})",
295 project.shortname, project.shortcode
296 )?;
297
298 if let Some(ln) = &project.longname {
303 writeln!(self.out, " Name: {ln}")?;
304 }
305
306 writeln!(self.out, " IRI: {}", project.iri)?;
307 writeln!(self.out, " Status: {}", project.status.as_str())?;
308
309 if !project.keywords.is_empty() {
311 writeln!(self.out, " Keywords: {}", project.keywords.join(", "))?;
312 }
313
314 let n = project.data_models.len();
316 if n == 0 {
317 writeln!(self.out, " Data-models (0)")?;
318 } else {
319 let names: Vec<&str> = project
320 .data_models
321 .iter()
322 .map(|dm| dm.name.as_str())
323 .collect();
324 writeln!(self.out, " Data-models ({n}): {}", names.join(", "))?;
325 }
326
327 let descriptions: Vec<(Option<&str>, String)> = project
333 .description
334 .iter()
335 .map(|e| (e.language.as_deref(), html_to_text(&e.value)))
336 .filter(|(_, plain)| !plain.is_empty())
337 .collect();
338 if !descriptions.is_empty() {
339 writeln!(self.out, " Description:")?;
340 for (language, plain) in &descriptions {
341 let prefix = match language {
345 Some(lang) => format!(" [{lang}] "),
346 None => " ".to_string(),
347 };
348 let continuation_indent = " ".repeat(prefix.len());
349 let mut first_line = true;
350 for line in plain.lines() {
351 if first_line {
352 writeln!(self.out, "{prefix}{line}")?;
353 first_line = false;
354 } else if line.is_empty() {
355 writeln!(self.out)?;
358 } else {
359 writeln!(self.out, "{continuation_indent}{line}")?;
360 }
361 }
362 }
363 }
364
365 render_prose_footer(&mut *self.out, meta)?;
366 Ok(())
367 }
368
369 fn data_model_describe(
370 &mut self,
371 detail: &DataModelDetail,
372 meta: &MetaContext,
373 ) -> Result<(), Diagnostic> {
374 writeln!(self.out, "Data-model: {}", detail.name)?;
376
377 if let Some(ref lbl) = detail.label {
381 writeln!(self.out, " Label: {lbl}")?;
382 }
383 writeln!(self.out, " IRI: {}", detail.iri)?;
384 if let Some(ref lm) = detail.last_modified {
385 let date = lm.split_once('T').map(|(d, _)| d).unwrap_or(lm.as_str());
386 writeln!(self.out, " Last-modified: {date}")?;
387 }
388
389 let n = detail.resource_types.len();
391 if n == 0 {
392 writeln!(self.out, " Resource-types (0)")?;
393 } else {
394 writeln!(self.out)?;
395 writeln!(self.out, " Resource-types ({n}):")?;
396 let name_w = detail
398 .resource_types
399 .iter()
400 .map(|rt| rt.name.len())
401 .max()
402 .unwrap_or(0);
403 for rt in &detail.resource_types {
404 let label = rt.label.as_deref().unwrap_or("");
405 writeln!(self.out, " {:<name_w$} {label}", rt.name)?;
406 }
407 }
408
409 render_prose_footer(&mut *self.out, meta)?;
410 Ok(())
411 }
412
413 fn data_models(
414 &mut self,
415 view: &DataModelListView,
416 meta: &MetaContext,
417 ) -> Result<(), Diagnostic> {
418 let n = view.items.len();
419 let has_builtins = view.items.iter().any(|d| d.is_builtin);
420 let builtin_suffix = if has_builtins {
421 ", incl. built-ins"
422 } else {
423 ""
424 };
425
426 match &view.filter {
428 None => writeln!(
429 self.out,
430 "data-models on {} ({n}){builtin_suffix}:",
431 meta.server_label,
432 )?,
433 Some(f) => writeln!(
434 self.out,
435 "data-models on {} ({n} of {} matching \"{f}\"){builtin_suffix}:",
436 meta.server_label, view.total,
437 )?,
438 }
439
440 writeln!(self.out)?;
441
442 let name_w = view.items.iter().map(|d| d.name.len()).max().unwrap_or(0);
444 let label_w = view
445 .items
446 .iter()
447 .map(|d| {
448 if let Some(ref lbl) = d.label {
449 lbl.len()
450 } else if d.is_builtin {
451 "(built-in)".len()
452 } else {
453 0
454 }
455 })
456 .max()
457 .unwrap_or(0);
458
459 for item in &view.items {
460 let label_or_marker: &str = if let Some(ref lbl) = item.label {
462 lbl.as_str()
463 } else if item.is_builtin {
464 "(built-in)"
465 } else {
466 ""
467 };
468
469 let date_str: String = match &item.last_modified {
471 Some(s) => s
472 .split_once('T')
473 .map(|(d, _)| d.to_string())
474 .unwrap_or_else(|| s.clone()),
475 None => String::new(),
476 };
477
478 writeln!(
479 self.out,
480 " {:<name_w$} {:<label_w$} {}",
481 item.name, label_or_marker, date_str,
482 )?;
483 }
484
485 render_prose_footer(&mut *self.out, meta)?;
486 Ok(())
487 }
488
489 fn resource_types(
490 &mut self,
491 view: &ResourceTypeListView,
492 meta: &MetaContext,
493 ) -> Result<(), Diagnostic> {
494 let n = view.items.len();
495 let has_builtins = view.items.iter().any(|rt| rt.is_builtin);
496 let has_counts = view.items.iter().any(|rt| rt.count.is_some());
497 let builtin_suffix = if has_builtins {
498 ", incl. built-ins"
499 } else {
500 ""
501 };
502
503 match &view.filter {
505 None => writeln!(
506 self.out,
507 "resource-types in {} on {} ({n}){builtin_suffix}:",
508 view.data_model, meta.server_label,
509 )?,
510 Some(f) => writeln!(
511 self.out,
512 "resource-types in {} on {} ({n} of {} matching \"{f}\"){builtin_suffix}:",
513 view.data_model, meta.server_label, view.total,
514 )?,
515 }
516
517 writeln!(self.out)?;
518
519 let name_w = view.items.iter().map(|rt| rt.name.len()).max().unwrap_or(0);
521 let label_w = view
522 .items
523 .iter()
524 .map(|rt| rt.label.as_deref().map(|l| l.len()).unwrap_or(0))
525 .max()
526 .unwrap_or(0);
527
528 if has_counts {
529 let count_w = view
533 .items
534 .iter()
535 .map(|rt| rt.count.map(|c| c.to_string()).unwrap_or_default().len())
536 .max()
537 .unwrap_or(0);
538 for item in &view.items {
539 let label = item.label.as_deref().unwrap_or("");
540 let count_str = item.count.map(|c| c.to_string()).unwrap_or_default();
541 if item.is_builtin {
542 writeln!(
543 self.out,
544 " {:<name_w$} {:<label_w$} {:>count_w$} (built-in)",
545 item.name, label, count_str,
546 )?;
547 } else {
548 writeln!(
549 self.out,
550 " {:<name_w$} {:<label_w$} {:>count_w$}",
551 item.name, label, count_str,
552 )?;
553 }
554 }
555 } else {
556 for item in &view.items {
557 let label = item.label.as_deref().unwrap_or("");
558 if item.is_builtin {
560 writeln!(
561 self.out,
562 " {:<name_w$} {:<label_w$} (built-in)",
563 item.name, label,
564 )?;
565 } else {
566 writeln!(self.out, " {:<name_w$} {label}", item.name)?;
567 }
568 }
569 }
570
571 render_prose_footer(&mut *self.out, meta)?;
572 Ok(())
573 }
574
575 fn resource_type_describe(
576 &mut self,
577 detail: &crate::model::ResourceTypeDetail,
578 meta: &MetaContext,
579 ) -> Result<(), Diagnostic> {
580 use crate::model::ValueType;
581
582 writeln!(self.out, "Resource-type: {}", detail.name)?;
584
585 if let Some(ref lbl) = detail.label {
589 writeln!(self.out, " Label: {lbl}")?;
590 }
591 if !detail.super_types.is_empty() {
592 writeln!(
593 self.out,
594 " Extends: {}",
595 detail.super_types.join(", ")
596 )?;
597 }
598 if let Some(ref repr) = detail.representation {
599 writeln!(self.out, " Representation: {repr}")?;
600 }
601 writeln!(self.out, " IRI: {}", detail.iri)?;
602 writeln!(self.out, " Data-model: {}", detail.data_model)?;
603 if let Some(count) = detail.count {
604 writeln!(self.out, " Instances: {count}")?;
605 }
606
607 let n = detail.fields.len();
609 if n == 0 {
610 writeln!(self.out, "\n Fields (0)")?;
611 } else {
612 writeln!(self.out, "\n Fields ({n}):")?;
613
614 let vtype_strs: Vec<String> = detail
616 .fields
617 .iter()
618 .map(|f| match &f.value_type {
619 ValueType::Link => {
620 if let Some(ref tgt) = f.link_target {
621 format!("link \u{2192} {tgt}")
622 } else {
623 "link".to_string()
624 }
625 }
626 vt => vt.to_string(),
627 })
628 .collect();
629
630 let name_w = detail
632 .fields
633 .iter()
634 .map(|f| f.name.len())
635 .max()
636 .unwrap_or(0);
637 let vtype_w = vtype_strs.iter().map(|s| s.len()).max().unwrap_or(0);
638 let card_w = detail
639 .fields
640 .iter()
641 .map(|f| f.cardinality.to_string().len())
642 .max()
643 .unwrap_or(0);
644
645 for (field, vtype_str) in detail.fields.iter().zip(vtype_strs.iter()) {
646 let label = field.label.as_deref().unwrap_or("");
647 let card_str = field.cardinality.to_string();
648
649 let source_tag = if let Some(ref dm) = field.data_model {
652 if dm != &detail.data_model {
653 format!(" [from {dm}]")
654 } else {
655 String::new()
656 }
657 } else {
658 String::new()
659 };
660
661 let builtin_marker = if field.is_builtin { " (built-in)" } else { "" };
663
664 writeln!(
665 self.out,
666 " {:<name_w$} {:<vtype_w$} {:<card_w$} {label}{builtin_marker}{source_tag}",
667 field.name, vtype_str, card_str,
668 )?;
669 }
670 }
671
672 render_prose_footer(&mut *self.out, meta)?;
673 Ok(())
674 }
675
676 fn data_model_structure(
677 &mut self,
678 structure: &DataModelStructure,
679 meta: &MetaContext,
680 ) -> Result<(), Diagnostic> {
681 use crate::model::RelationKind;
682
683 let n = structure.relations.len();
685 let plural = if n == 1 { "relation" } else { "relations" };
686 writeln!(
687 self.out,
688 "Structure: {} ({n} {plural})",
689 structure.data_model
690 )?;
691
692 if n > 0 {
693 writeln!(self.out)?;
694
695 let source_w = structure
698 .relations
699 .iter()
700 .map(|r| r.source.len())
701 .max()
702 .unwrap_or(0);
703
704 let target_w = structure
706 .relations
707 .iter()
708 .map(|r| {
709 let tag_len = match &r.target_data_model {
710 Some(dm) if dm != &structure.data_model => {
711 " [to ".len() + dm.len() + "]".len()
712 }
713 _ => 0,
714 };
715 r.target.len() + tag_len
716 })
717 .max()
718 .unwrap_or(0);
719
720 let field_w = structure
722 .relations
723 .iter()
724 .map(|r| r.field.as_deref().unwrap_or("").len())
725 .max()
726 .unwrap_or(0);
727
728 for rel in &structure.relations {
729 let target_col = match &rel.target_data_model {
731 Some(dm) if dm != &structure.data_model => {
732 format!("{} [to {}]", rel.target, dm)
733 }
734 _ => rel.target.clone(),
735 };
736
737 let field_col = rel.field.as_deref().unwrap_or("");
739
740 let kind_marker = match rel.kind {
742 RelationKind::Link => "[link]",
743 RelationKind::Inherits => "[inherits]",
744 };
745
746 writeln!(
747 self.out,
748 " {:<source_w$} \u{2192} {:<target_w$} {:<field_w$} {kind_marker}",
749 rel.source, target_col, field_col,
750 )?;
751 }
752 }
753
754 render_prose_footer(&mut *self.out, meta)?;
755 Ok(())
756 }
757
758 fn resources(&mut self, view: &ResourceListView, meta: &MetaContext) -> Result<(), Diagnostic> {
759 let n = view.items.len();
760
761 match &view.filter {
763 None => writeln!(
764 self.out,
765 "resources of type {} on {} ({n}):",
766 view.resource_type, meta.server_label,
767 )?,
768 Some(f) => writeln!(
769 self.out,
770 "resources of type {} on {} ({n} of {} matching \"{f}\"):",
771 view.resource_type, meta.server_label, view.total,
772 )?,
773 }
774
775 if n > 0 {
776 writeln!(self.out)?;
777
778 let labels: Vec<String> = view
782 .items
783 .iter()
784 .map(|r| strip_control_chars(&r.label))
785 .collect();
786 let label_w = labels.iter().map(|l| l.len()).max().unwrap_or(0);
787
788 for (item, label) in view.items.iter().zip(&labels) {
789 writeln!(self.out, " {:<label_w$} {}", label, item.iri,)?;
790 }
791 }
792
793 if let ResourceListPagination::SinglePage {
795 may_have_more: true,
796 ..
797 } = view.pagination
798 {
799 writeln!(self.out)?;
800 writeln!(
801 self.out,
802 " more results available (use --all to fetch all pages)"
803 )?;
804 }
805
806 render_prose_footer(&mut *self.out, meta)?;
807 Ok(())
808 }
809
810 fn resource_describe(
811 &mut self,
812 detail: &crate::model::ResourceDetail,
813 meta: &MetaContext,
814 ) -> Result<(), Diagnostic> {
815 writeln!(self.out, "Resource: {}", strip_control_chars(&detail.label))?;
817
818 writeln!(self.out, " Type: {}", detail.resource_type)?;
823 writeln!(self.out, " IRI: {}", detail.iri)?;
824 if let Some(ref ark) = detail.ark_url {
825 writeln!(self.out, " ARK: {ark}")?;
826 }
827 if let Some(ref created) = detail.creation_date {
828 writeln!(self.out, " Created: {created}")?;
829 }
830 if let Some(ref modified) = detail.last_modified {
831 writeln!(self.out, " Modified: {modified}")?;
832 }
833 if let Some(ref project) = detail.attached_project {
834 writeln!(self.out, " Project: {project}")?;
835 }
836 if let Some(ref owner) = detail.owner {
837 writeln!(self.out, " Owner: {owner}")?;
838 }
839 if let Some(ref vis) = detail.visibility {
840 writeln!(self.out, " Visibility: {}", vis.as_str())?;
841 }
842 if let Some(ref access) = detail.your_access {
843 writeln!(self.out, " Your access: {}", access.as_str())?;
844 }
845
846 if let Some(ref fields) = detail.values {
848 writeln!(self.out)?;
849 if fields.is_empty() {
850 writeln!(self.out, "Values: (none)")?;
851 } else {
852 writeln!(self.out, "Values:")?;
853 for fv in fields {
854 let header = match &fv.label {
856 Some(lbl) => {
857 format!(
858 "{} ({})",
859 strip_control_chars(lbl),
860 strip_control_chars(&fv.name)
861 )
862 }
863 None => strip_control_chars(&fv.name),
864 };
865 writeln!(self.out, " {header}")?;
866
867 for value in &fv.values {
868 let rendered = render_value_content(&value.content);
869 writeln!(self.out, " {}", strip_control_chars(&rendered))?;
870 if let Some(comment) = &value.comment {
871 writeln!(self.out, " comment: {}", strip_control_chars(comment))?;
872 }
873 }
874 }
875 }
876 }
877
878 render_prose_footer(&mut *self.out, meta)?;
879 Ok(())
880 }
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886 use crate::model::{
887 Cardinality, DataModel, DataModelDetail, DataModelSummary, Field, Project,
888 ProjectDescription, ProjectDetail, ProjectStatus, Representation, ResourceType,
889 ResourceTypeDetail, ResourceTypeSummary, ValueType,
890 };
891 use crate::render::test_support::{SharedBuf, make_meta};
892 use crate::render::{DataModelListView, ResourceTypeListView};
893
894 fn make_fixture() -> Vec<Project> {
895 vec![
896 Project {
897 iri: "http://rdfh.ch/projects/0001".into(),
898 shortcode: "0001".into(),
899 shortname: "anything".into(),
900 longname: Some("Anything Project".into()),
901 status: ProjectStatus::Active,
902 data_models: 2,
903 },
904 Project {
905 iri: "http://rdfh.ch/projects/0002".into(),
906 shortcode: "0002".into(),
907 shortname: "images".into(),
908 longname: None,
909 status: ProjectStatus::Inactive,
910 data_models: 0,
911 },
912 Project {
913 iri: "http://rdfh.ch/projects/0803".into(),
914 shortcode: "0803".into(),
915 shortname: "daschland".into(),
916 longname: Some("=Formula Project".into()),
917 status: ProjectStatus::Active,
918 data_models: 1,
919 },
920 ]
921 }
922
923 #[test]
924 fn projects_prose_no_filter() {
925 let out = SharedBuf::new();
926 let mut renderer = ProseRenderer::with_writer(out.clone());
927 let view = ProjectListView {
928 items: make_fixture(),
929 total: 3,
930 filter: None,
931 };
932 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
933 renderer.projects(&view, &meta).unwrap();
934
935 let s = out.string();
936 assert!(s.contains("Projects on https://api.test.dasch.swiss (3):"));
937 assert!(s.contains("[anonymous on https://api.test.dasch.swiss]"));
939 assert!(!s.contains("rdfh.ch"));
941 assert!(s.contains("2 data-models"));
943 assert!(s.contains("1 data-model"));
945 let data_model_lines = s.lines().filter(|l| l.contains("data-model")).count();
947 assert_eq!(data_model_lines, 2);
948 assert!(!s.contains("None"));
950 }
951
952 #[test]
953 fn projects_prose_with_filter() {
954 let out = SharedBuf::new();
955 let mut renderer = ProseRenderer::with_writer(out.clone());
956 let items = vec![make_fixture().remove(0)]; let view = ProjectListView {
958 items,
959 total: 3,
960 filter: Some("any".to_string()),
961 };
962 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
963 renderer.projects(&view, &meta).unwrap();
964
965 let s = out.string();
966 assert!(s.contains("(1 of 3 matching \"any\")"));
967 }
968
969 #[test]
970 fn projects_prose_footer_on_stdout() {
971 let out = SharedBuf::new();
973 let mut renderer = ProseRenderer::with_writer(out.clone());
974 let view = ProjectListView {
975 items: vec![],
976 total: 0,
977 filter: None,
978 };
979 let meta = make_meta("authenticated as alice", "https://api.dasch.swiss");
980 renderer.projects(&view, &meta).unwrap();
981
982 let s = out.string();
983 assert!(s.contains("[authenticated as alice on https://api.dasch.swiss]"));
984 }
985
986 fn make_beol_detail() -> ProjectDetail {
987 ProjectDetail {
988 iri: "http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF".into(),
989 shortcode: "0801".into(),
990 shortname: "beol".into(),
991 longname: Some("Bernoulli-Euler Online".into()),
992 status: ProjectStatus::Active,
993 description: vec![ProjectDescription {
994 value: "<b>BEOL</b> — early modern mathematics.".into(),
995 language: Some("en".into()),
996 }],
997 keywords: vec!["Bernoulli".into(), "Euler".into(), "Mathematics".into()],
998 data_models: vec![
999 DataModelSummary {
1000 name: "beol".into(),
1001 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1002 },
1003 DataModelSummary {
1004 name: "biblio".into(),
1005 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1006 },
1007 DataModelSummary {
1008 name: "leibniz".into(),
1009 iri: "http://api.dasch.swiss/ontology/0801/leibniz/v2".into(),
1010 },
1011 DataModelSummary {
1012 name: "newton".into(),
1013 iri: "http://api.dasch.swiss/ontology/0801/newton/v2".into(),
1014 },
1015 ],
1016 }
1017 }
1018
1019 #[test]
1020 fn project_describe_prose_full() {
1021 let out = SharedBuf::new();
1022 let mut renderer = ProseRenderer::with_writer(out.clone());
1023 let meta = make_meta("anonymous", "api.dasch.swiss");
1024 renderer
1025 .project_describe(&make_beol_detail(), &meta)
1026 .unwrap();
1027
1028 let s = out.string();
1029 assert!(s.contains("Project: beol (0801)"));
1031 assert!(s.contains("Name: Bernoulli-Euler Online"));
1033 assert!(s.contains("IRI: http://rdfh.ch/projects/yTerZGyxjZVqFMNNKXCDPF"));
1035 assert!(s.contains("Status: active"));
1036 assert!(s.contains("Keywords: Bernoulli, Euler, Mathematics"));
1037 assert!(s.contains("Data-models (4): beol, biblio, leibniz, newton"));
1039 assert!(s.contains("[en] BEOL"));
1041 assert!(
1042 !s.contains("<b>"),
1043 "description must not contain raw <b> tags"
1044 );
1045 assert!(
1046 !s.contains("</b>"),
1047 "description must not contain raw </b> tags"
1048 );
1049 assert!(s.contains("[anonymous on api.dasch.swiss]"));
1051 }
1052
1053 #[test]
1054 fn project_describe_prose_no_longname() {
1055 let out = SharedBuf::new();
1056 let mut renderer = ProseRenderer::with_writer(out.clone());
1057 let mut detail = make_beol_detail();
1058 detail.longname = None;
1059 let meta = make_meta("anonymous", "api.dasch.swiss");
1060 renderer.project_describe(&detail, &meta).unwrap();
1061
1062 let s = out.string();
1063 assert!(s.contains("Project: beol (0801)"));
1065 assert!(
1066 !s.contains("Name:"),
1067 "Name: line must be absent when longname is None"
1068 );
1069 }
1070
1071 #[test]
1072 fn project_describe_prose_empty_keywords_and_description() {
1073 let out = SharedBuf::new();
1074 let mut renderer = ProseRenderer::with_writer(out.clone());
1075 let detail = ProjectDetail {
1076 iri: "http://rdfh.ch/projects/0000".into(),
1077 shortcode: "0000".into(),
1078 shortname: "minimal".into(),
1079 longname: None,
1080 status: ProjectStatus::Inactive,
1081 description: vec![],
1082 keywords: vec![],
1083 data_models: vec![],
1084 };
1085 let meta = make_meta("anonymous", "api.dasch.swiss");
1086 renderer.project_describe(&detail, &meta).unwrap();
1087
1088 let s = out.string();
1089 assert!(!s.contains("Keywords"));
1091 assert!(!s.contains("Description"));
1093 assert!(s.contains("Data-models (0)"));
1095 assert!(!s.contains("Data-models (0):"));
1096 }
1097
1098 #[test]
1099 fn project_describe_prose_description_no_language() {
1100 let out = SharedBuf::new();
1101 let mut renderer = ProseRenderer::with_writer(out.clone());
1102 let detail = ProjectDetail {
1103 iri: "http://rdfh.ch/projects/0001".into(),
1104 shortcode: "0001".into(),
1105 shortname: "test".into(),
1106 longname: None,
1107 status: ProjectStatus::Active,
1108 description: vec![ProjectDescription {
1109 value: "Plain description without language tag.".into(),
1110 language: None,
1111 }],
1112 keywords: vec![],
1113 data_models: vec![],
1114 };
1115 let meta = make_meta("anonymous", "api.dasch.swiss");
1116 renderer.project_describe(&detail, &meta).unwrap();
1117
1118 let s = out.string();
1119 assert!(s.contains(" Plain description without language tag."));
1121 let desc_line = s.lines().find(|l| l.contains("Plain description")).unwrap();
1123 assert!(!desc_line.contains('['));
1124 }
1125
1126 #[test]
1130 fn project_describe_prose_two_descriptions_language_and_none() {
1131 let out = SharedBuf::new();
1132 let mut renderer = ProseRenderer::with_writer(out.clone());
1133 let detail = ProjectDetail {
1134 iri: "http://rdfh.ch/projects/0002".into(),
1135 shortcode: "0002".into(),
1136 shortname: "multilang".into(),
1137 longname: None,
1138 status: ProjectStatus::Active,
1139 description: vec![
1140 ProjectDescription {
1141 value: "English description of this project.".into(),
1142 language: Some("en".into()),
1143 },
1144 ProjectDescription {
1145 value: "Description without a language tag.".into(),
1146 language: None,
1147 },
1148 ],
1149 keywords: vec![],
1150 data_models: vec![],
1151 };
1152 let meta = make_meta("anonymous", "api.dasch.swiss");
1153 renderer.project_describe(&detail, &meta).unwrap();
1154
1155 let s = out.string();
1156
1157 assert!(
1159 s.contains("English description of this project."),
1160 "first description value must appear"
1161 );
1162 assert!(
1163 s.contains("Description without a language tag."),
1164 "second description value must appear"
1165 );
1166
1167 let en_line = s
1169 .lines()
1170 .find(|l| l.contains("English description"))
1171 .expect("expected a line containing 'English description'");
1172 assert!(
1173 en_line.contains("[en] "),
1174 "languaged entry must carry '[en] ' prefix; got: {en_line:?}"
1175 );
1176
1177 let plain_line = s
1179 .lines()
1180 .find(|l| l.contains("Description without a language tag."))
1181 .expect("expected a line containing 'Description without a language tag.'");
1182 assert!(
1183 !plain_line.contains('['),
1184 "no-language entry must not have a '[...]' prefix; got: {plain_line:?}"
1185 );
1186 }
1187
1188 #[test]
1191 fn project_describe_prose_blank_lines_are_not_indented() {
1192 let out = SharedBuf::new();
1193 let mut renderer = ProseRenderer::with_writer(out.clone());
1194 let detail = ProjectDetail {
1195 iri: "http://rdfh.ch/projects/0003".into(),
1196 shortcode: "0003".into(),
1197 shortname: "multipara".into(),
1198 longname: None,
1199 status: ProjectStatus::Active,
1200 description: vec![ProjectDescription {
1201 value: "First paragraph.\n\nSecond paragraph.".into(),
1202 language: Some("en".into()),
1203 }],
1204 keywords: vec![],
1205 data_models: vec![],
1206 };
1207 let meta = make_meta("anonymous", "api.dasch.swiss");
1208 renderer.project_describe(&detail, &meta).unwrap();
1209
1210 let s = out.string();
1211 assert!(s.contains("[en] First paragraph."));
1213 assert!(s.contains("Second paragraph."));
1214 let ws_only = s.lines().find(|l| !l.is_empty() && l.trim().is_empty());
1217 assert!(
1218 ws_only.is_none(),
1219 "blank separator lines must be truly empty, not whitespace-only; got:\n{s}"
1220 );
1221 }
1222
1223 #[test]
1226 fn project_describe_prose_empty_after_html_omits_description_label() {
1227 let out = SharedBuf::new();
1228 let mut renderer = ProseRenderer::with_writer(out.clone());
1229 let detail = ProjectDetail {
1230 iri: "http://rdfh.ch/projects/0004".into(),
1231 shortcode: "0004".into(),
1232 shortname: "tagsonly".into(),
1233 longname: None,
1234 status: ProjectStatus::Active,
1235 description: vec![ProjectDescription {
1236 value: "<br/>".into(), language: Some("en".into()),
1238 }],
1239 keywords: vec![],
1240 data_models: vec![],
1241 };
1242 let meta = make_meta("anonymous", "api.dasch.swiss");
1243 renderer.project_describe(&detail, &meta).unwrap();
1244
1245 let s = out.string();
1246 assert!(
1247 !s.contains("Description:"),
1248 "Description: label must be omitted when no entry has visible text; got:\n{s}"
1249 );
1250 }
1251
1252 fn make_data_model_fixture() -> Vec<DataModel> {
1253 vec![
1254 DataModel {
1255 name: "beol".into(),
1256 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1257 label: Some("The BEOL data-model".into()),
1258 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1259 is_builtin: false,
1260 },
1261 DataModel {
1262 name: "biblio".into(),
1263 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1264 label: None,
1265 last_modified: Some("2024-01-10T08:00:00.000000Z".into()),
1266 is_builtin: false,
1267 },
1268 DataModel {
1269 name: "knora-api".into(),
1270 iri: "http://api.knora.org/ontology/knora-api/v2".into(),
1271 label: None,
1272 last_modified: None,
1273 is_builtin: true,
1274 },
1275 ]
1276 }
1277
1278 #[test]
1279 fn data_models_prose_no_filter() {
1280 let out = SharedBuf::new();
1281 let mut renderer = ProseRenderer::with_writer(out.clone());
1282 let view = DataModelListView {
1283 items: make_data_model_fixture(),
1284 total: 3,
1285 filter: None,
1286 };
1287 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1288 renderer.data_models(&view, &meta).unwrap();
1289
1290 let s = out.string();
1291 assert!(
1293 s.contains("data-models on https://api.test.dasch.swiss (3), incl. built-ins:"),
1294 "header must include count and built-ins suffix; got:\n{s}"
1295 );
1296 assert!(s.contains("[anonymous on https://api.test.dasch.swiss]"));
1298 assert!(
1300 s.contains("beol") && s.contains("The BEOL data-model") && s.contains("2024-05-27")
1301 );
1302 assert!(
1304 !s.contains("T13:43:26"),
1305 "prose must show only the date portion"
1306 );
1307 assert!(s.contains("biblio"));
1309 assert!(s.contains("(built-in)"));
1311 assert!(!s.contains("None"));
1313 }
1314
1315 #[test]
1316 fn data_models_prose_with_filter() {
1317 let out = SharedBuf::new();
1318 let mut renderer = ProseRenderer::with_writer(out.clone());
1319 let items = vec![make_data_model_fixture().remove(0)]; let view = DataModelListView {
1321 items,
1322 total: 3,
1323 filter: Some("beol".to_string()),
1324 };
1325 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1326 renderer.data_models(&view, &meta).unwrap();
1327
1328 let s = out.string();
1329 assert!(
1330 s.contains("(1 of 3 matching \"beol\")"),
1331 "filter header must show m of total; got:\n{s}"
1332 );
1333 assert!(!s.contains("incl. built-ins"));
1335 }
1336
1337 #[test]
1341 fn data_models_prose_no_builtins_header_has_no_builtin_suffix() {
1342 let out = SharedBuf::new();
1343 let mut renderer = ProseRenderer::with_writer(out.clone());
1344 let view = DataModelListView {
1345 items: vec![
1346 DataModel {
1347 name: "beol".into(),
1348 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1349 label: Some("The BEOL data-model".into()),
1350 last_modified: None,
1351 is_builtin: false,
1352 },
1353 DataModel {
1354 name: "biblio".into(),
1355 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2".into(),
1356 label: None,
1357 last_modified: None,
1358 is_builtin: false,
1359 },
1360 ],
1361 total: 2,
1362 filter: None,
1363 };
1364 let meta = make_meta("anonymous", "api.dasch.swiss");
1365 renderer.data_models(&view, &meta).unwrap();
1366
1367 let s = out.string();
1368 let header_line = s
1370 .lines()
1371 .next()
1372 .expect("output must have at least one line");
1373 assert!(
1374 header_line.ends_with("(2):"),
1375 "header must end with '(2):' when there are no builtins; got: {header_line:?}"
1376 );
1377 assert!(
1378 !s.contains("incl. built-ins"),
1379 "header must not contain 'incl. built-ins' when no builtin items are present; \
1380 got:\n{s}"
1381 );
1382 }
1383
1384 #[test]
1385 fn data_models_prose_last_modified_no_t_separator() {
1386 let out = SharedBuf::new();
1389 let mut renderer = ProseRenderer::with_writer(out.clone());
1390 let view = DataModelListView {
1391 items: vec![DataModel {
1392 name: "odd".into(),
1393 iri: "http://example.org/odd".into(),
1394 label: None,
1395 last_modified: Some("2024-05-27 no-separator".into()),
1396 is_builtin: false,
1397 }],
1398 total: 1,
1399 filter: None,
1400 };
1401 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1402 renderer.data_models(&view, &meta).unwrap();
1403
1404 let s = out.string();
1405 assert!(
1407 s.contains("2024-05-27 no-separator"),
1408 "expected full string when no 'T' separator; got:\n{s}"
1409 );
1410 }
1411
1412 fn make_beol_dm_detail() -> DataModelDetail {
1415 DataModelDetail {
1416 name: "beol".into(),
1417 iri: "http://api.dasch.swiss/ontology/0801/beol/v2".into(),
1418 label: Some("The BEOL data-model".into()),
1419 last_modified: Some("2024-05-27T13:43:26.233048Z".into()),
1420 resource_types: vec![
1421 ResourceTypeSummary {
1422 name: "Archive".into(),
1423 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1424 label: Some("Archive".into()),
1425 },
1426 ResourceTypeSummary {
1427 name: "basicLetter".into(),
1428 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#basicLetter".into(),
1429 label: None,
1430 },
1431 ResourceTypeSummary {
1432 name: "letter".into(),
1433 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1434 label: Some("Letter".into()),
1435 },
1436 ],
1437 }
1438 }
1439
1440 #[test]
1441 fn data_model_describe_prose_full() {
1442 let out = SharedBuf::new();
1443 let mut renderer = ProseRenderer::with_writer(out.clone());
1444 let meta = make_meta("anonymous", "api.dasch.swiss");
1445 renderer
1446 .data_model_describe(&make_beol_dm_detail(), &meta)
1447 .unwrap();
1448
1449 let s = out.string();
1450 assert!(s.contains("Data-model: beol"), "header missing; got:\n{s}");
1452 assert!(
1454 s.contains("Label: The BEOL data-model"),
1455 "label line missing; got:\n{s}"
1456 );
1457 assert!(
1459 s.contains("IRI: http://api.dasch.swiss/ontology/0801/beol/v2"),
1460 "IRI line missing; got:\n{s}"
1461 );
1462 assert!(
1464 s.contains("Last-modified: 2024-05-27"),
1465 "last-modified line missing; got:\n{s}"
1466 );
1467 assert!(
1468 !s.contains("T13:43"),
1469 "prose must show only date portion, not full timestamp; got:\n{s}"
1470 );
1471 assert!(
1473 s.contains("Resource-types (3):"),
1474 "resource-types header missing; got:\n{s}"
1475 );
1476 assert!(s.contains("Archive"), "Archive row missing; got:\n{s}");
1478 assert!(
1479 s.contains("basicLetter"),
1480 "basicLetter row missing; got:\n{s}"
1481 );
1482 assert!(s.contains("Letter"), "Letter label missing; got:\n{s}");
1483 assert!(
1485 s.contains("[anonymous on api.dasch.swiss]"),
1486 "footer missing; got:\n{s}"
1487 );
1488 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1490 }
1491
1492 #[test]
1493 fn data_model_describe_prose_no_label_no_last_modified() {
1494 let out = SharedBuf::new();
1495 let mut renderer = ProseRenderer::with_writer(out.clone());
1496 let detail = DataModelDetail {
1497 name: "minimal".into(),
1498 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2".into(),
1499 label: None,
1500 last_modified: None,
1501 resource_types: vec![ResourceTypeSummary {
1502 name: "Thing".into(),
1503 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Thing".into(),
1504 label: None,
1505 }],
1506 };
1507 let meta = make_meta("anonymous", "api.dasch.swiss");
1508 renderer.data_model_describe(&detail, &meta).unwrap();
1509
1510 let s = out.string();
1511 assert!(
1513 !s.contains("Label:"),
1514 "Label: line must be absent when label is None; got:\n{s}"
1515 );
1516 assert!(
1518 !s.contains("Last-modified:"),
1519 "Last-modified: line must be absent when last_modified is None; got:\n{s}"
1520 );
1521 assert!(
1523 s.contains("IRI:"),
1524 "IRI line must always be present; got:\n{s}"
1525 );
1526 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1528 }
1529
1530 #[test]
1531 fn data_model_describe_prose_zero_resource_types() {
1532 let out = SharedBuf::new();
1533 let mut renderer = ProseRenderer::with_writer(out.clone());
1534 let detail = DataModelDetail {
1535 name: "empty".into(),
1536 iri: "http://api.dasch.swiss/ontology/9999/empty/v2".into(),
1537 label: None,
1538 last_modified: None,
1539 resource_types: vec![],
1540 };
1541 let meta = make_meta("anonymous", "api.dasch.swiss");
1542 renderer.data_model_describe(&detail, &meta).unwrap();
1543
1544 let s = out.string();
1545 assert!(
1547 s.contains(" Resource-types (0)"),
1548 "zero resource-types branch missing; got:\n{s}"
1549 );
1550 let rt_line = s
1552 .lines()
1553 .find(|l| l.contains("Resource-types (0)"))
1554 .expect("must have resource-types line");
1555 assert!(
1556 !rt_line.contains("Resource-types (0):"),
1557 "zero branch must NOT have a colon; got: {rt_line:?}"
1558 );
1559 }
1560
1561 #[test]
1562 fn data_model_describe_prose_last_modified_no_t_separator() {
1563 let out = SharedBuf::new();
1564 let mut renderer = ProseRenderer::with_writer(out.clone());
1565 let detail = DataModelDetail {
1566 name: "odd".into(),
1567 iri: "http://api.dasch.swiss/ontology/0000/odd/v2".into(),
1568 label: None,
1569 last_modified: Some("2024-05-27 no-separator".into()),
1570 resource_types: vec![],
1571 };
1572 let meta = make_meta("anonymous", "api.dasch.swiss");
1573 renderer.data_model_describe(&detail, &meta).unwrap();
1574
1575 let s = out.string();
1576 assert!(
1578 s.contains("2024-05-27 no-separator"),
1579 "expected full string when no 'T' separator; got:\n{s}"
1580 );
1581 }
1582
1583 fn make_resource_type_fixture() -> Vec<ResourceType> {
1586 vec![
1587 ResourceType {
1588 name: "Archive".into(),
1589 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#Archive".into(),
1590 label: Some("Archive".into()),
1591 is_builtin: false,
1592 count: None,
1593 },
1594 ResourceType {
1595 name: "letter".into(),
1596 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
1597 label: Some("Letter".into()),
1598 is_builtin: false,
1599 count: None,
1600 },
1601 ]
1602 }
1603
1604 #[test]
1605 fn resource_types_prose_project_only() {
1606 let out = SharedBuf::new();
1607 let mut renderer = ProseRenderer::with_writer(out.clone());
1608 let view = ResourceTypeListView {
1609 items: make_resource_type_fixture(),
1610 total: 2,
1611 filter: None,
1612 data_model: "beol".into(),
1613 };
1614 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1615 renderer.resource_types(&view, &meta).unwrap();
1616
1617 let s = out.string();
1618 assert!(
1620 s.contains("resource-types in beol on https://api.test.dasch.swiss (2):"),
1621 "header missing or incorrect; got:\n{s}"
1622 );
1623 assert!(
1624 !s.contains("incl. built-ins"),
1625 "must not have builtin suffix when no builtins; got:\n{s}"
1626 );
1627 assert!(s.contains("Archive"), "Archive row missing; got:\n{s}");
1629 assert!(s.contains("letter"), "letter row missing; got:\n{s}");
1630 assert!(s.contains("Letter"), "Letter label missing; got:\n{s}");
1631 assert!(
1633 !s.contains("(built-in)"),
1634 "must not have (built-in) marker on project-only items; got:\n{s}"
1635 );
1636 assert!(
1638 s.contains("[anonymous on https://api.test.dasch.swiss]"),
1639 "footer missing; got:\n{s}"
1640 );
1641 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1643 }
1644
1645 #[test]
1646 fn resource_types_prose_with_builtins() {
1647 let out = SharedBuf::new();
1648 let mut renderer = ProseRenderer::with_writer(out.clone());
1649 let mut items = make_resource_type_fixture();
1650 items.push(ResourceType {
1651 name: "Region".into(),
1652 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1653 label: Some("Region".into()),
1654 is_builtin: true,
1655 count: None,
1656 });
1657 let view = ResourceTypeListView {
1658 items,
1659 total: 3,
1660 filter: None,
1661 data_model: "beol".into(),
1662 };
1663 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1664 renderer.resource_types(&view, &meta).unwrap();
1665
1666 let s = out.string();
1667 assert!(
1669 s.contains(
1670 "resource-types in beol on https://api.test.dasch.swiss (3), incl. built-ins:"
1671 ),
1672 "header with builtin suffix missing; got:\n{s}"
1673 );
1674 assert!(
1676 s.contains("(built-in)"),
1677 "(built-in) marker missing on built-in row; got:\n{s}"
1678 );
1679 let archive_line = s.lines().find(|l| l.contains("Archive")).unwrap();
1681 assert!(
1682 !archive_line.contains("(built-in)"),
1683 "project row must not have (built-in) marker; got: {archive_line:?}"
1684 );
1685 }
1686
1687 #[test]
1688 fn resource_types_prose_with_filter() {
1689 let out = SharedBuf::new();
1690 let mut renderer = ProseRenderer::with_writer(out.clone());
1691 let items = vec![make_resource_type_fixture().remove(0)]; let view = ResourceTypeListView {
1693 items,
1694 total: 3,
1695 filter: Some("arch".to_string()),
1696 data_model: "beol".into(),
1697 };
1698 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1699 renderer.resource_types(&view, &meta).unwrap();
1700
1701 let s = out.string();
1702 assert!(
1703 s.contains("(1 of 3 matching \"arch\")"),
1704 "filter header must show m of total; got:\n{s}"
1705 );
1706 }
1707
1708 #[test]
1709 fn resource_types_prose_empty() {
1710 let out = SharedBuf::new();
1711 let mut renderer = ProseRenderer::with_writer(out.clone());
1712 let view = ResourceTypeListView {
1713 items: vec![],
1714 total: 0,
1715 filter: None,
1716 data_model: "beol".into(),
1717 };
1718 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
1719 renderer.resource_types(&view, &meta).unwrap();
1720
1721 let s = out.string();
1722 assert!(
1724 s.contains("resource-types in beol on https://api.test.dasch.swiss (0):"),
1725 "header must show data_model name even when empty; got:\n{s}"
1726 );
1727 assert!(
1729 s.contains("[anonymous on https://api.test.dasch.swiss]"),
1730 "footer missing; got:\n{s}"
1731 );
1732 }
1733
1734 #[test]
1735 fn resource_types_prose_with_counts() {
1736 let out = SharedBuf::new();
1740 let mut renderer = ProseRenderer::with_writer(out.clone());
1741 let mut items = make_resource_type_fixture();
1742 items[0].count = Some(42);
1743 items[1].count = Some(7);
1744 items.push(ResourceType {
1745 name: "Region".into(),
1746 iri: "http://api.knora.org/ontology/knora-api/v2#Region".into(),
1747 label: Some("Region".into()),
1748 is_builtin: true,
1749 count: None,
1750 });
1751 let view = ResourceTypeListView {
1752 items,
1753 total: 3,
1754 filter: None,
1755 data_model: "beol".into(),
1756 };
1757 let meta = MetaContext {
1758 server_label: "https://api.test.dasch.swiss".into(),
1759 auth_state: "anonymous".into(),
1760 filter_warning: None,
1761 count_caveat: Some("counts are not permission-filtered".into()),
1762 };
1763 renderer.resource_types(&view, &meta).unwrap();
1764
1765 let s = out.string();
1766 let archive_line = s.lines().find(|l| l.contains("Archive")).unwrap();
1767 assert!(
1768 archive_line.contains("42"),
1769 "Archive row must show count 42; got: {archive_line:?}"
1770 );
1771 let letter_line = s.lines().find(|l| l.contains("letter")).unwrap();
1772 assert!(
1773 letter_line.contains('7'),
1774 "letter row must show count 7; got: {letter_line:?}"
1775 );
1776 let region_line = s.lines().find(|l| l.contains("Region")).unwrap();
1778 assert!(
1779 region_line.contains("(built-in)"),
1780 "built-in marker must still be present; got: {region_line:?}"
1781 );
1782 assert!(
1784 s.contains("counts are not permission-filtered"),
1785 "footer must carry count_caveat; got:\n{s}"
1786 );
1787 }
1788
1789 fn make_manuscript_detail() -> ResourceTypeDetail {
1795 ResourceTypeDetail {
1796 name: "manuscript".into(),
1797 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#manuscript".into(),
1798 label: Some("Manuscript".into()),
1799 data_model: "beol".into(),
1800 representation: Some(Representation::StillImage),
1801 super_types: vec!["writtenSource".into()],
1802 fields: vec![
1803 Field {
1804 name: "title".into(),
1805 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#title".into(),
1806 label: Some("Title".into()),
1807 value_type: ValueType::Text,
1808 link_target: None,
1809 cardinality: Cardinality::OneOrMore,
1810 is_builtin: false,
1811 data_model: Some("beol".into()),
1812 },
1813 Field {
1814 name: "hasAuthor".into(),
1815 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#hasAuthor".into(),
1816 label: Some("Author".into()),
1817 value_type: ValueType::Link,
1818 link_target: Some("person".into()),
1819 cardinality: Cardinality::ZeroOrMore,
1820 is_builtin: false,
1821 data_model: Some("beol".into()),
1822 },
1823 Field {
1824 name: "isPartOfCollection".into(),
1825 iri: "http://api.dasch.swiss/ontology/0801/biblio/v2#isPartOfCollection".into(),
1826 label: Some("is part of".into()),
1827 value_type: ValueType::Link,
1828 link_target: Some("Collection".into()),
1829 cardinality: Cardinality::ZeroOrMore,
1830 is_builtin: false,
1831 data_model: Some("biblio".into()),
1832 },
1833 ],
1834 count: None,
1835 }
1836 }
1837
1838 #[test]
1839 fn resource_type_describe_prose_full() {
1840 let out = SharedBuf::new();
1841 let mut renderer = ProseRenderer::with_writer(out.clone());
1842 let meta = make_meta("anonymous", "https://api.dasch.swiss");
1843 renderer
1844 .resource_type_describe(&make_manuscript_detail(), &meta)
1845 .unwrap();
1846
1847 let s = out.string();
1848 assert!(
1850 s.contains("Resource-type: manuscript"),
1851 "header missing; got:\n{s}"
1852 );
1853 assert!(
1855 s.contains("Label: Manuscript"),
1856 "label line missing; got:\n{s}"
1857 );
1858 assert!(
1860 s.contains("Extends: writtenSource"),
1861 "Extends line missing; got:\n{s}"
1862 );
1863 assert!(
1865 s.contains("Representation: still-image"),
1866 "Representation line missing; got:\n{s}"
1867 );
1868 assert!(
1870 s.contains("IRI: http://api.dasch.swiss/ontology/0801/beol/v2#manuscript"),
1871 "IRI line missing; got:\n{s}"
1872 );
1873 assert!(
1875 s.contains("Data-model: beol"),
1876 "Data-model line missing; got:\n{s}"
1877 );
1878 assert!(
1880 s.contains("Fields (3):"),
1881 "Fields header missing; got:\n{s}"
1882 );
1883 let title_line = s.lines().find(|l| l.contains("title")).unwrap();
1885 assert!(
1886 title_line.contains("text") && title_line.contains("1-n"),
1887 "title row incomplete; got: {title_line:?}"
1888 );
1889 assert!(
1890 !title_line.contains("[from"),
1891 "title row must not have [from ...] tag; got: {title_line:?}"
1892 );
1893 let author_line = s.lines().find(|l| l.contains("hasAuthor")).unwrap();
1895 assert!(
1896 author_line.contains("\u{2192} person"),
1897 "link field must show '→ person'; got: {author_line:?}"
1898 );
1899 let coll_line = s
1901 .lines()
1902 .find(|l| l.contains("isPartOfCollection"))
1903 .unwrap();
1904 assert!(
1905 coll_line.contains("[from biblio]"),
1906 "cross-DM field must show [from biblio]; got: {coll_line:?}"
1907 );
1908 assert!(
1910 s.contains("[anonymous on https://api.dasch.swiss]"),
1911 "footer missing; got:\n{s}"
1912 );
1913 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1915 }
1916
1917 #[test]
1918 fn resource_type_describe_prose_with_count() {
1919 let out = SharedBuf::new();
1922 let mut renderer = ProseRenderer::with_writer(out.clone());
1923 let mut detail = make_manuscript_detail();
1924 detail.count = Some(123);
1925 let meta = MetaContext {
1926 server_label: "https://api.dasch.swiss".into(),
1927 auth_state: "anonymous".into(),
1928 filter_warning: None,
1929 count_caveat: Some("counts exclude deleted resources".into()),
1930 };
1931 renderer.resource_type_describe(&detail, &meta).unwrap();
1932
1933 let s = out.string();
1934 assert!(
1935 s.contains("Instances: 123"),
1936 "Instances line missing or misaligned; got:\n{s}"
1937 );
1938 let dm_idx = s.find("Data-model:").expect("Data-model line present");
1940 let inst_idx = s.find("Instances:").expect("Instances line present");
1941 assert!(
1942 inst_idx > dm_idx,
1943 "Instances line must come after Data-model line"
1944 );
1945 assert!(
1946 s.contains("counts exclude deleted resources"),
1947 "footer must carry count_caveat; got:\n{s}"
1948 );
1949 }
1950
1951 #[test]
1952 fn resource_type_describe_prose_no_count_no_instances_line() {
1953 let out = SharedBuf::new();
1956 let mut renderer = ProseRenderer::with_writer(out.clone());
1957 let meta = make_meta("anonymous", "https://api.dasch.swiss");
1958 renderer
1959 .resource_type_describe(&make_manuscript_detail(), &meta)
1960 .unwrap();
1961
1962 let s = out.string();
1963 assert!(
1964 !s.contains("Instances:"),
1965 "Instances line must be absent when count is None; got:\n{s}"
1966 );
1967 }
1968
1969 #[test]
1970 fn resource_type_describe_prose_no_label() {
1971 let out = SharedBuf::new();
1972 let mut renderer = ProseRenderer::with_writer(out.clone());
1973 let mut detail = make_manuscript_detail();
1974 detail.label = None;
1975 let meta = make_meta("anonymous", "api.dasch.swiss");
1976 renderer.resource_type_describe(&detail, &meta).unwrap();
1977
1978 let s = out.string();
1979 assert!(
1981 !s.contains("Label:"),
1982 "Label: line must be absent when label is None; got:\n{s}"
1983 );
1984 assert!(
1986 s.contains("Resource-type: manuscript"),
1987 "header missing; got:\n{s}"
1988 );
1989 assert!(!s.contains("None"), "None must not appear; got:\n{s}");
1991 }
1992
1993 #[test]
1994 fn resource_type_describe_prose_zero_fields() {
1995 let out = SharedBuf::new();
1996 let mut renderer = ProseRenderer::with_writer(out.clone());
1997 let detail = ResourceTypeDetail {
1998 name: "Empty".into(),
1999 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#Empty".into(),
2000 label: None,
2001 data_model: "minimal".into(),
2002 representation: None,
2003 super_types: vec![],
2004 fields: vec![],
2005 count: None,
2006 };
2007 let meta = make_meta("anonymous", "api.dasch.swiss");
2008 renderer.resource_type_describe(&detail, &meta).unwrap();
2009
2010 let s = out.string();
2011 assert!(
2013 s.contains(" Fields (0)"),
2014 "zero fields branch missing; got:\n{s}"
2015 );
2016 let fields_line = s
2018 .lines()
2019 .find(|l| l.contains("Fields (0)"))
2020 .expect("must have Fields line");
2021 assert!(
2022 !fields_line.contains("Fields (0):"),
2023 "zero branch must NOT have a colon; got: {fields_line:?}"
2024 );
2025 assert!(
2027 !s.contains("Extends:"),
2028 "Extends line must be absent; got:\n{s}"
2029 );
2030 assert!(
2031 !s.contains("Representation:"),
2032 "Representation line must be absent; got:\n{s}"
2033 );
2034 }
2035
2036 #[test]
2037 fn resource_type_describe_prose_no_representation() {
2038 let out = SharedBuf::new();
2039 let mut renderer = ProseRenderer::with_writer(out.clone());
2040 let mut detail = make_manuscript_detail();
2041 detail.representation = None;
2042 let meta = make_meta("anonymous", "api.dasch.swiss");
2043 renderer.resource_type_describe(&detail, &meta).unwrap();
2044
2045 let s = out.string();
2046 assert!(
2048 !s.contains("Representation:"),
2049 "Representation: line must be absent when representation is None; got:\n{s}"
2050 );
2051 }
2052
2053 #[test]
2054 fn resource_type_describe_prose_include_builtins_marker() {
2055 let out = SharedBuf::new();
2057 let mut renderer = ProseRenderer::with_writer(out.clone());
2058 let detail = ResourceTypeDetail {
2059 name: "thing".into(),
2060 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#thing".into(),
2061 label: None,
2062 data_model: "minimal".into(),
2063 representation: None,
2064 super_types: vec![],
2065 fields: vec![
2066 Field {
2067 name: "hasText".into(),
2068 iri: "http://api.dasch.swiss/ontology/0000/minimal/v2#hasText".into(),
2069 label: Some("Text content".into()),
2070 value_type: ValueType::Text,
2071 link_target: None,
2072 cardinality: Cardinality::One,
2073 is_builtin: false,
2074 data_model: Some("minimal".into()),
2075 },
2076 Field {
2077 name: "arkUrl".into(),
2078 iri: "http://api.knora.org/ontology/knora-api/v2#arkUrl".into(),
2079 label: Some("ARK URL".into()),
2080 value_type: ValueType::Uri,
2081 link_target: None,
2082 cardinality: Cardinality::One,
2083 is_builtin: true,
2084 data_model: None,
2085 },
2086 ],
2087 count: None,
2088 };
2089 let meta = make_meta("anonymous", "api.dasch.swiss");
2090 renderer.resource_type_describe(&detail, &meta).unwrap();
2091
2092 let s = out.string();
2093 let text_line = s.lines().find(|l| l.contains("hasText")).unwrap();
2095 assert!(
2096 !text_line.contains("(built-in)"),
2097 "project field must not have (built-in) marker; got: {text_line:?}"
2098 );
2099 let ark_line = s.lines().find(|l| l.contains("arkUrl")).unwrap();
2101 assert!(
2102 ark_line.contains("(built-in)"),
2103 "built-in field must have (built-in) marker; got: {ark_line:?}"
2104 );
2105 }
2106
2107 #[test]
2108 fn resource_type_describe_prose_extends_multiple_supers() {
2109 let out = SharedBuf::new();
2111 let mut renderer = ProseRenderer::with_writer(out.clone());
2112 let detail = ResourceTypeDetail {
2113 name: "letter".into(),
2114 iri: "http://api.dasch.swiss/ontology/0801/beol/v2#letter".into(),
2115 label: Some("Letter".into()),
2116 data_model: "beol".into(),
2117 representation: None,
2118 super_types: vec!["basicLetter".into(), "writtenSource".into()],
2119 fields: vec![],
2120 count: None,
2121 };
2122 let meta = make_meta("anonymous", "api.dasch.swiss");
2123 renderer.resource_type_describe(&detail, &meta).unwrap();
2124
2125 let s = out.string();
2126 assert!(
2127 s.contains("Extends: basicLetter, writtenSource"),
2128 "multiple supers must be joined with ', '; got:\n{s}"
2129 );
2130 }
2131
2132 use crate::model::{
2135 DatePoint, DateValue, FieldValues, FileValue, ResourceAccess, ResourceDetail,
2136 ResourceVisibility, Value, ValueContent,
2137 };
2138
2139 fn make_resource_detail_no_values() -> ResourceDetail {
2140 ResourceDetail {
2141 label: "Test Resource".into(),
2142 iri: "http://rdfh.ch/0803/abc123".into(),
2143 resource_type: "Page".into(),
2144 ark_url: Some("ark:/72163/1/0803/abc123".into()),
2145 creation_date: Some("2021-01-01T00:00:00Z".into()),
2146 last_modified: None,
2147 attached_project: Some("http://rdfh.ch/projects/0803".into()),
2148 owner: Some("http://rdfh.ch/users/alice".into()),
2149 visibility: Some(ResourceVisibility::Public),
2150 your_access: Some(ResourceAccess::View),
2151 values: None,
2152 }
2153 }
2154
2155 #[test]
2156 fn resource_describe_prose_no_values_unchanged() {
2157 let out = SharedBuf::new();
2160 let mut renderer = ProseRenderer::with_writer(out.clone());
2161 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2162 renderer
2163 .resource_describe(&make_resource_detail_no_values(), &meta)
2164 .unwrap();
2165
2166 let s = out.string();
2167 assert!(s.contains("Resource: Test Resource"), "header missing");
2168 assert!(
2169 !s.contains("Values:"),
2170 "Values: section must be absent when values is None"
2171 );
2172 }
2173
2174 #[test]
2175 fn resource_describe_prose_some_empty_values() {
2176 let out = SharedBuf::new();
2178 let mut renderer = ProseRenderer::with_writer(out.clone());
2179 let mut detail = make_resource_detail_no_values();
2180 detail.values = Some(vec![]);
2181 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2182 renderer.resource_describe(&detail, &meta).unwrap();
2183
2184 let s = out.string();
2185 assert!(
2186 s.contains("Values: (none)"),
2187 "empty values must render as 'Values: (none)'; got:\n{s}"
2188 );
2189 }
2190
2191 #[test]
2192 fn resource_describe_prose_values_section() {
2193 let out = SharedBuf::new();
2195 let mut renderer = ProseRenderer::with_writer(out.clone());
2196 let mut detail = make_resource_detail_no_values();
2197 detail.values = Some(vec![
2198 FieldValues {
2199 name: "hasTitle".into(),
2200 label: Some("Title".into()),
2201 values: vec![ValueContent::Text("Incunabula Page".into()).into()],
2202 },
2203 FieldValues {
2204 name: "seqnum".into(),
2205 label: None, values: vec![ValueContent::Integer(42).into()],
2207 },
2208 ]);
2209 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2210 renderer.resource_describe(&detail, &meta).unwrap();
2211
2212 let s = out.string();
2213 assert!(s.contains("Values:"), "Values: header missing; got:\n{s}");
2214 assert!(
2216 s.contains("Title (hasTitle)"),
2217 "labelled field header format wrong; got:\n{s}"
2218 );
2219 assert!(
2220 s.contains("Incunabula Page"),
2221 "text value missing; got:\n{s}"
2222 );
2223 let seqnum_line = s.lines().find(|l| l.contains("seqnum")).unwrap();
2225 assert!(
2226 !seqnum_line.contains('('),
2227 "unlabelled field must not have parentheses; got: {seqnum_line:?}"
2228 );
2229 assert!(s.contains("42"), "integer value missing; got:\n{s}");
2230 }
2231
2232 #[test]
2233 fn resource_describe_prose_value_with_comment() {
2234 let out = SharedBuf::new();
2237 let mut renderer = ProseRenderer::with_writer(out.clone());
2238 let mut detail = make_resource_detail_no_values();
2239 detail.values = Some(vec![FieldValues {
2240 name: "hasTranscription".into(),
2241 label: Some("Transcription".into()),
2242 values: vec![Value {
2243 content: ValueContent::Text("some transcription".into()),
2244 comment: Some("reading uncertain".into()),
2245 }],
2246 }]);
2247 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2248 renderer.resource_describe(&detail, &meta).unwrap();
2249
2250 let s = out.string();
2251 assert!(
2252 s.contains("some transcription"),
2253 "value line missing; got:\n{s}"
2254 );
2255 assert!(
2256 s.contains(" comment: reading uncertain"),
2257 "comment line missing or mis-indented; got:\n{s}"
2258 );
2259 let lines: Vec<&str> = s.lines().collect();
2261 let value_idx = lines
2262 .iter()
2263 .position(|l| l.contains("some transcription"))
2264 .unwrap();
2265 assert_eq!(
2266 lines[value_idx + 1],
2267 " comment: reading uncertain",
2268 "comment line must directly follow the value line; got:\n{s}"
2269 );
2270 }
2271
2272 #[test]
2273 fn resource_describe_prose_value_without_comment_renders_no_comment_line() {
2274 let out = SharedBuf::new();
2278 let mut renderer = ProseRenderer::with_writer(out.clone());
2279 let mut detail = make_resource_detail_no_values();
2280 detail.values = Some(vec![FieldValues {
2281 name: "hasTranscription".into(),
2282 label: Some("Transcription".into()),
2283 values: vec![ValueContent::Text("some transcription".into()).into()],
2284 }]);
2285 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2286 renderer.resource_describe(&detail, &meta).unwrap();
2287
2288 let s = out.string();
2289 assert!(
2290 s.contains("some transcription"),
2291 "value line missing; got:\n{s}"
2292 );
2293 assert!(
2294 !s.contains("comment:"),
2295 "no comment line must be emitted when value.comment is None; got:\n{s}"
2296 );
2297 }
2298
2299 #[test]
2300 fn resource_describe_prose_link_with_label() {
2301 let out = SharedBuf::new();
2302 let mut renderer = ProseRenderer::with_writer(out.clone());
2303 let mut detail = make_resource_detail_no_values();
2304 detail.values = Some(vec![FieldValues {
2305 name: "isPartOf".into(),
2306 label: Some("Is part of".into()),
2307 values: vec![
2308 ValueContent::Link {
2309 target_iri: "http://rdfh.ch/0803/book1".into(),
2310 target_label: Some("Incunabula Book".into()),
2311 }
2312 .into(),
2313 ValueContent::Link {
2314 target_iri: "http://rdfh.ch/0803/book2".into(),
2315 target_label: None, }
2317 .into(),
2318 ],
2319 }]);
2320 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2321 renderer.resource_describe(&detail, &meta).unwrap();
2322
2323 let s = out.string();
2324 assert!(
2326 s.contains("\u{2192} Incunabula Book [http://rdfh.ch/0803/book1]"),
2327 "link with label rendered incorrectly; got:\n{s}"
2328 );
2329 assert!(
2331 s.contains("\u{2192} http://rdfh.ch/0803/book2"),
2332 "degraded link (no label) rendered incorrectly; got:\n{s}"
2333 );
2334 let degraded_line = s
2336 .lines()
2337 .find(|l| l.contains("http://rdfh.ch/0803/book2"))
2338 .unwrap();
2339 assert!(
2340 !degraded_line.contains('['),
2341 "degraded link must not have brackets; got: {degraded_line:?}"
2342 );
2343 }
2344
2345 #[test]
2346 fn resource_describe_prose_file_still_image_with_dims() {
2347 let out = SharedBuf::new();
2348 let mut renderer = ProseRenderer::with_writer(out.clone());
2349 let mut detail = make_resource_detail_no_values();
2350 detail.values = Some(vec![FieldValues {
2351 name: "hasStillImageFileValue".into(),
2352 label: None,
2353 values: vec![
2354 ValueContent::File(FileValue {
2355 value_type: ValueType::StillImage,
2356 filename: "image.jp2".into(),
2357 url: "https://iiif.example.com/image.jp2/full/max/0/default.jpg".into(),
2358 width: Some(1200),
2359 height: Some(800),
2360 })
2361 .into(),
2362 ],
2363 }]);
2364 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2365 renderer.resource_describe(&detail, &meta).unwrap();
2366
2367 let s = out.string();
2368 assert!(
2370 s.contains("image.jp2 (1200\u{d7}800) https://iiif.example.com"),
2371 "still-image with dims rendered incorrectly; got:\n{s}"
2372 );
2373 }
2374
2375 #[test]
2376 fn resource_describe_prose_date_rendering() {
2377 let out = SharedBuf::new();
2378 let mut renderer = ProseRenderer::with_writer(out.clone());
2379 let mut detail = make_resource_detail_no_values();
2380 let pt = DatePoint {
2382 year: Some(1489),
2383 month: None,
2384 day: None,
2385 era: Some("CE".into()),
2386 };
2387 let single = DateValue {
2388 calendar: "GREGORIAN".into(),
2389 start: pt.clone(),
2390 end: pt.clone(),
2391 };
2392 let range = DateValue {
2394 calendar: "GREGORIAN".into(),
2395 start: DatePoint {
2396 year: Some(1489),
2397 month: None,
2398 day: None,
2399 era: Some("CE".into()),
2400 },
2401 end: DatePoint {
2402 year: Some(1490),
2403 month: None,
2404 day: None,
2405 era: Some("CE".into()),
2406 },
2407 };
2408 detail.values = Some(vec![FieldValues {
2409 name: "hasDate".into(),
2410 label: Some("Date".into()),
2411 values: vec![
2412 ValueContent::Date(single).into(),
2413 ValueContent::Date(range).into(),
2414 ],
2415 }]);
2416 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2417 renderer.resource_describe(&detail, &meta).unwrap();
2418
2419 let s = out.string();
2420 assert!(
2422 s.contains("1489 CE (GREGORIAN)"),
2423 "single-point date rendered incorrectly; got:\n{s}"
2424 );
2425 assert!(
2427 s.contains("1489 CE \u{2013} 1490 CE (GREGORIAN)"),
2428 "range date rendered incorrectly; got:\n{s}"
2429 );
2430 }
2431
2432 #[test]
2435 fn resource_describe_prose_strips_control_chars_in_label() {
2436 let out = SharedBuf::new();
2439 let mut renderer = ProseRenderer::with_writer(out.clone());
2440 let mut detail = make_resource_detail_no_values();
2441 detail.label = "Bad\u{1b}[31mLabel\u{7f}".into();
2442 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2443 renderer.resource_describe(&detail, &meta).unwrap();
2444
2445 let s = out.string();
2446 assert!(
2447 s.contains("Resource: Bad[31mLabel"),
2448 "label control chars not stripped; got:\n{s}"
2449 );
2450 assert!(
2451 !s.contains('\u{1b}') && !s.contains('\u{7f}'),
2452 "control characters leaked into prose output; got:\n{s:?}"
2453 );
2454 }
2455
2456 #[test]
2457 fn resources_prose_strips_control_chars_in_label() {
2458 use crate::model::ResourceSummary;
2459
2460 let out = SharedBuf::new();
2461 let mut renderer = ProseRenderer::with_writer(out.clone());
2462 let view = ResourceListView {
2463 items: vec![ResourceSummary {
2464 label: "Bad\u{1b}[31mLabel\u{7f}".into(),
2465 iri: "http://rdfh.ch/0803/abc123".into(),
2466 ark_url: None,
2467 creation_date: None,
2468 last_modified: None,
2469 resource_type: "Page".into(),
2470 }],
2471 total: 1,
2472 filter: None,
2473 resource_type: "Page".into(),
2474 pagination: ResourceListPagination::SinglePage {
2475 page: 0,
2476 may_have_more: false,
2477 },
2478 };
2479 let meta = make_meta("anonymous", "https://api.test.dasch.swiss");
2480 renderer.resources(&view, &meta).unwrap();
2481
2482 let s = out.string();
2483 assert!(
2484 s.contains("Bad[31mLabel"),
2485 "label control chars not stripped; got:\n{s}"
2486 );
2487 assert!(
2488 !s.contains('\u{1b}') && !s.contains('\u{7f}'),
2489 "control characters leaked into prose output; got:\n{s:?}"
2490 );
2491 }
2492}