1use std::fmt::Write as _;
4
5use crate::api::wiki::{
6 CursorPage, WikiAttachment, WikiComment, WikiGrid, WikiGridRef, WikiHits, WikiPage,
7 WikiPageRef, WikiResource,
8};
9use crate::render::Context;
10use crate::render::style::Palette;
11use crate::render::table::{Column, cursor_tally, open_page_tally, render};
12
13#[must_use]
20pub fn hits(found: &WikiHits, ctx: &Context) -> String {
21 let columns = [
22 Column::whole("SLUG", 44, Palette::key()),
23 Column::new("TYPE", 5, anstyle::Style::new()),
24 Column::new("MODIFIED", 10, Palette::label()),
25 Column::new("TITLE", 50, Palette::untrusted()),
26 ];
27 let rows: Vec<Vec<String>> = found
28 .results
29 .iter()
30 .map(|hit| {
31 vec![
32 hit.slug.clone(),
33 hit.kind.clone(),
34 hit.modified_at
36 .as_deref()
37 .map_or_else(|| "-".to_owned(), |at| at.chars().take(10).collect()),
38 hit.title.clone(),
39 ]
40 })
41 .collect();
42
43 let mut out = render(&columns, &rows, ctx);
44 out.push_str(&open_page_tally(found.results.len(), found.next_page, ctx));
45 out
46}
47
48#[must_use]
55pub fn comments(slug: &str, list: &CursorPage<WikiComment>, ctx: &Context) -> String {
56 let mut out = String::with_capacity(list.results.len() * 200 + 64);
57 let paint = ctx.painter();
58
59 for comment in &list.results {
60 let author = comment.author.as_deref().unwrap_or("-");
61 let mut header = format!(
62 "--- {} by {author} at {}",
63 comment.id,
64 comment.created_at.as_deref().unwrap_or("-")
65 );
66 if comment.resolved {
67 header.push_str(" (resolved)");
68 }
69 if comment.deleted {
70 header.push_str(" (deleted)");
71 }
72 if let Some(posts) = comment.thread_posts.filter(|posts| *posts > 1) {
73 let _ = write!(header, " — {posts} in thread: --thread {}", comment.id);
74 }
75 let _ = writeln!(out, "{}", paint.paint(&header, Palette::label()));
76
77 if comment.deleted || comment.body.is_empty() {
78 continue;
79 }
80 crate::render::text::quoted_block(
81 &mut out,
82 &format!("wiki:{slug}/comment/{} by {author}", comment.id),
83 crate::render::untrusted::Author::Wiki,
84 &comment.body,
85 0,
86 ctx,
87 );
88 }
89
90 out.push_str(&cursor_tally(
91 list.results.len(),
92 list.next_cursor.as_deref(),
93 ctx,
94 ));
95 out
96}
97
98fn megabytes(size: &str) -> String {
101 match size.parse::<f64>() {
102 Ok(mb) if mb > 0.0 => format!("{size} MB"),
103 Ok(_) => "<0.01 MB".to_owned(),
104 Err(_) => size.to_owned(),
105 }
106}
107
108#[must_use]
117pub fn attachments(list: &CursorPage<WikiAttachment>, ctx: &Context) -> String {
118 let columns = [
119 Column::whole("ID", 10, Palette::key()),
120 Column::whole("SIZE", 10, anstyle::Style::new()),
121 Column::new("TYPE", 18, anstyle::Style::new()),
122 Column::new("CREATED", 10, Palette::label()),
123 Column::whole("NAME", 40, Palette::untrusted()),
124 ];
125 let rows: Vec<Vec<String>> = list
126 .results
127 .iter()
128 .map(|file| {
129 vec![
130 file.id.to_string(),
131 megabytes(&file.size),
132 file.mimetype.as_deref().unwrap_or("-").to_owned(),
133 file.created_at
134 .as_deref()
135 .map_or_else(|| "-".to_owned(), |at| at.chars().take(10).collect()),
136 file.name.clone(),
137 ]
138 })
139 .collect();
140
141 let mut out = render(&columns, &rows, ctx);
142 out.push_str(&cursor_tally(
143 list.results.len(),
144 list.next_cursor.as_deref(),
145 ctx,
146 ));
147 out
148}
149
150#[must_use]
160pub fn grid(grid: &WikiGrid, ctx: &Context) -> String {
161 let paint = ctx.painter();
162 let label = |text: &str| paint.paint(text, Palette::label());
163 let mut out = String::with_capacity(256 + grid.rows.len() * 64);
164
165 let _ = writeln!(
166 out,
167 "{} {}",
168 paint.paint(&grid.id, Palette::key()),
169 grid.title
170 );
171 let _ = writeln!(
172 out,
173 "{} {} {} {}",
174 label("page:"),
175 grid.page.as_ref().map_or("-", |page| page.slug.as_str()),
176 label("revision:"),
177 if grid.revision.is_empty() {
178 "-"
179 } else {
180 &grid.revision
181 },
182 );
183 let columns: Vec<String> = grid
184 .columns
185 .iter()
186 .map(|column| format!("{}:{}", column.slug, column.kind))
187 .collect();
188 let _ = writeln!(out, "{} {}", label("columns:"), columns.join(" "));
189
190 let mut table = String::with_capacity(grid.rows.len() * 64);
191 let titles: Vec<String> = grid
192 .columns
193 .iter()
194 .map(|column| tsv_cell(&column.title))
195 .collect();
196 let _ = writeln!(table, "{}", titles.join("\t"));
197 for row in &grid.rows {
198 let cells: Vec<String> = row
199 .cells
200 .iter()
201 .map(|value| tsv_cell(&cell_text(value)))
202 .collect();
203 let _ = writeln!(table, "{}", cells.join("\t"));
204 }
205
206 let (body, withheld) = crate::render::untrusted::head(&table, ctx.description_lines);
207 let body = if ctx.is_human() {
208 format!("```\n{}\n```", body.trim_end())
209 } else {
210 body
211 };
212 crate::render::text::quoted_block(
213 &mut out,
214 &format!("wiki:grid/{}", grid.id),
215 crate::render::untrusted::Author::Wiki,
216 &body,
217 withheld,
218 ctx,
219 );
220
221 let shown = grid.rows.len().saturating_sub(withheld);
222 let _ = writeln!(
223 out,
224 "{}",
225 label(&format!("shown {shown} of {}", grid.rows.len()))
226 );
227 out
228}
229
230fn cell_text(value: &serde_json::Value) -> String {
233 use serde_json::Value;
234
235 match value {
236 Value::Null => String::new(),
237 Value::String(text) => text.clone(),
238 Value::Array(items) => items.iter().map(cell_text).collect::<Vec<_>>().join(", "),
239 Value::Object(fields) => ["username", "display", "key"]
240 .iter()
241 .find_map(|name| fields.get(*name).and_then(Value::as_str))
242 .map_or_else(|| value.to_string(), str::to_owned),
243 other => other.to_string(),
244 }
245}
246
247fn tsv_cell(text: &str) -> String {
249 let mut cell = String::with_capacity(text.len());
250 for character in text.chars() {
251 match character {
252 '\\' => cell.push_str("\\\\"),
253 '\t' => cell.push_str("\\t"),
254 '\n' => cell.push_str("\\n"),
255 '\r' => cell.push_str("\\r"),
256 other => cell.push(other),
257 }
258 }
259 cell
260}
261
262#[must_use]
264pub fn grids(list: &CursorPage<WikiGridRef>, ctx: &Context) -> String {
265 let columns = [
266 Column::whole("ID", 36, Palette::key()),
267 Column::new("CREATED", 10, Palette::label()),
268 Column::new("TITLE", 50, Palette::untrusted()),
269 ];
270 let rows: Vec<Vec<String>> = list
271 .results
272 .iter()
273 .map(|grid| {
274 vec![
275 grid.id.clone(),
276 day(grid.created_at.as_deref()),
277 grid.title.clone(),
278 ]
279 })
280 .collect();
281
282 let mut out = render(&columns, &rows, ctx);
283 out.push_str(&cursor_tally(
284 list.results.len(),
285 list.next_cursor.as_deref(),
286 ctx,
287 ));
288 out
289}
290
291#[must_use]
293pub fn resources(list: &CursorPage<WikiResource>, ctx: &Context) -> String {
294 let columns = [
295 Column::new("TYPE", 10, anstyle::Style::new()),
296 Column::whole("ID", 36, Palette::key()),
297 Column::new("CREATED", 10, Palette::label()),
298 Column::whole("NAME", 40, Palette::untrusted()),
299 ];
300 let rows: Vec<Vec<String>> = list
301 .results
302 .iter()
303 .map(|resource| {
304 vec![
305 resource.kind.clone(),
306 resource.id.clone(),
307 day(resource.created_at.as_deref()),
308 resource.name.clone(),
309 ]
310 })
311 .collect();
312
313 let mut out = render(&columns, &rows, ctx);
314 out.push_str(&cursor_tally(
315 list.results.len(),
316 list.next_cursor.as_deref(),
317 ctx,
318 ));
319 out
320}
321
322fn day(at: Option<&str>) -> String {
324 at.map_or_else(|| "-".to_owned(), |at| at.chars().take(10).collect())
325}
326
327#[must_use]
333pub fn access(access: &crate::api::wiki::WikiAccess, ctx: &Context) -> String {
334 let paint = ctx.painter();
335 let label = |text: &str| paint.paint(text, Palette::label());
336 let mut out = String::with_capacity(128 + access.entries.len() * 80);
337
338 let policy = access.policy.as_deref().unwrap_or("-");
339 let inherited = access
340 .inherited_policy
341 .as_deref()
342 .filter(|_| policy == "inherited")
343 .map_or_else(String::new, |inherited| format!(" ({inherited})"));
344 let _ = writeln!(
345 out,
346 "{} {} {policy}{inherited} {} {}",
347 paint.paint(&access.slug, Palette::key()),
348 label("access:"),
349 label("all staff:"),
350 access.all_staff_role.as_deref().unwrap_or("-"),
351 );
352
353 let columns = [
354 Column::whole("ID", 24, Palette::key()),
355 Column::new("ROLE", 12, anstyle::Style::new()),
356 Column::new("KIND", 5, anstyle::Style::new()),
357 Column::new("VIA", 9, Palette::label()),
358 Column::whole("WHO", 30, Palette::untrusted()),
359 ];
360 let rows: Vec<Vec<String>> = access
361 .entries
362 .iter()
363 .map(|entry| {
364 vec![
365 entry.id.clone(),
366 entry.role.clone(),
367 entry.kind.clone(),
368 entry.via.clone(),
369 entry.who.clone(),
370 ]
371 })
372 .collect();
373 out.push_str(&render(&columns, &rows, ctx));
374 out.push_str(&cursor_tally(access.entries.len(), None, ctx));
375 out
376}
377
378#[must_use]
380pub fn operation(
381 operation: &crate::api::wiki::WikiOperation,
382 status: &crate::api::wiki::OperationStatus,
383) -> String {
384 let mut line = format!(
385 "operation {} {}: {}",
386 operation.kind, operation.id, status.status
387 );
388 if let Some(percentage) = status.percentage.filter(|_| !status.is_done()) {
389 let _ = write!(line, " {percentage:.0}%");
390 }
391 if let Some(slug) = status.page_slug() {
392 let _ = write!(line, " — page {slug}");
393 }
394 if let Some(grid) = status.grid_id() {
395 let _ = write!(line, ", grid {grid}");
396 }
397 if let Some(details) = &status.details {
398 let _ = write!(line, " ({details})");
399 }
400 line.push('\n');
401 line
402}
403
404#[must_use]
410pub fn pages(list: &CursorPage<WikiPageRef>, ctx: &Context) -> String {
411 let columns = [
412 Column::whole("SLUG", 60, Palette::key()),
413 Column::whole("ID", 10, Palette::label()),
414 ];
415 let rows: Vec<Vec<String>> = list
416 .results
417 .iter()
418 .map(|page| vec![page.slug.clone(), page.id.to_string()])
419 .collect();
420
421 let mut out = render(&columns, &rows, ctx);
422 out.push_str(&cursor_tally(
423 list.results.len(),
424 list.next_cursor.as_deref(),
425 ctx,
426 ));
427 out
428}
429
430#[must_use]
436pub fn page(page: &WikiPage, ctx: &Context) -> String {
437 let mut out = String::with_capacity(256 + page.content.as_ref().map_or(0, String::len));
438 let paint = ctx.painter();
439 let label = |text: &str| paint.paint(text, Palette::label());
440
441 let _ = writeln!(
442 out,
443 "{} {}",
444 paint.paint(&page.slug, Palette::key()),
445 page.title
446 );
447 let _ = writeln!(
448 out,
449 "{} {} {} {} {} {}",
450 label("id:"),
451 page.id,
452 label("type:"),
453 page.page_type.as_deref().unwrap_or("-"),
454 label("modified:"),
455 page.modified_at.as_deref().unwrap_or("-"),
456 );
457
458 if let Some(content) = page.content.as_deref().filter(|text| !text.is_empty()) {
459 let folded;
464 let content = if ctx.description_lines.is_some() {
465 folded = fold_data_uris(content);
466 folded.as_str()
467 } else {
468 content
469 };
470 let (body, withheld) = crate::render::untrusted::head(content, ctx.description_lines);
471 crate::render::text::quoted_block(
472 &mut out,
473 &format!("wiki:{}", page.slug),
474 crate::render::untrusted::Author::Wiki,
475 &body,
476 withheld,
477 ctx,
478 );
479 }
480
481 out
482}
483
484const FOLD_OVER: usize = 256;
487
488fn fold_data_uris(text: &str) -> String {
491 const MARKER: &str = ";base64,";
492 let mut out = String::with_capacity(text.len());
493 let mut rest = text;
494 while let Some(at) = rest.find("data:") {
495 let after = &rest[at..];
496 let Some(marker) = after.find(MARKER).filter(|&end| {
497 after[5..end]
498 .chars()
499 .all(|c| c.is_ascii_alphanumeric() || "+-./".contains(c))
500 }) else {
501 out.push_str(&rest[..at + 5]);
502 rest = &rest[at + 5..];
503 continue;
504 };
505 let start = at + marker + MARKER.len();
506 let length = rest[start..]
507 .find(|c: char| !(c.is_ascii_alphanumeric() || "+/=".contains(c)))
508 .unwrap_or(rest.len() - start);
509 out.push_str(&rest[..start]);
510 if length > FOLD_OVER {
511 let tenths = (length * 3 / 4 * 10 + 512) / 1024;
514 let _ = write!(
515 out,
516 "…({}.{} KB, --full shows it)",
517 tenths / 10,
518 tenths % 10
519 );
520 } else {
521 out.push_str(&rest[start..start + length]);
522 }
523 rest = &rest[start + length..];
524 }
525 out.push_str(rest);
526 out
527}
528
529#[cfg(test)]
530mod tests {
531 use super::*;
532 use crate::render::{Audience, Format};
533
534 #[test]
535 fn a_long_inline_picture_is_folded_to_its_size() {
536 let payload = "A".repeat(4096);
537 let text = format!(
538 "before {{% drawio data=\"data:image/svg+xml;base64,{payload}\" width=\"600\" %}} after"
539 );
540 assert_eq!(
541 fold_data_uris(&text),
542 "before {% drawio data=\"data:image/svg+xml;base64,…(3.0 KB, --full shows it)\" width=\"600\" %} after"
543 );
544 }
545
546 #[test]
547 fn a_short_payload_and_plain_text_are_left_alone() {
548 let text = "icon  and the word data: here";
549 assert_eq!(fold_data_uris(text), text);
550 }
551
552 #[test]
553 fn full_shows_an_inline_picture_byte_for_byte() {
554 let content = format!("x data:image/png;base64,{}", "B".repeat(1000));
555 let mut shown = sample();
556 shown.content = Some(content.clone());
557 let mut full = ctx();
558 full.description_lines = None;
559 assert!(page(&shown, &full).contains(&content));
560 assert!(page(&shown, &ctx()).contains("…(0.7 KB, --full shows it)"));
561 }
562
563 fn ctx() -> Context {
564 Context {
565 format: Format::Text,
566 audience: Audience::Machine,
567 description_lines: Some(2),
568 extra_fields: Vec::new(),
569 width: 80,
570 images: false,
571 inline: crate::render::image::Inline::default(),
572 }
573 }
574
575 fn sample() -> WikiPage {
576 WikiPage {
577 id: 4521,
578 slug: "users/ilubenets/runbook".to_owned(),
579 title: "Deploy runbook".to_owned(),
580 page_type: Some("wysiwyg".to_owned()),
581 modified_at: Some("2026-09-01T10:15:00Z".to_owned()),
582 content: Some("# Deploy\n\n1. Tag the release.\n2. Watch the pipeline.".to_owned()),
583 }
584 }
585
586 #[test]
589 fn pages_view_is_stable() {
590 let list = CursorPage {
591 results: vec![
592 WikiPageRef {
593 id: 4521,
594 slug: "users/ilubenets/runbook".to_owned(),
595 },
596 WikiPageRef {
597 id: 4522,
598 slug: "users/ilubenets/runbook/rollback".to_owned(),
599 },
600 ],
601 next_cursor: Some("eyJpZCI6NDUyMn0=".to_owned()),
602 };
603 insta::assert_snapshot!(pages(&list, &ctx()));
604 }
605
606 #[test]
609 fn hits_view_is_stable() {
610 let found = WikiHits {
611 results: vec![
612 crate::api::wiki::WikiHit {
613 slug: "users/ilubenets/runbook".to_owned(),
614 title: "Deploy runbook".to_owned(),
615 kind: "page".to_owned(),
616 modified_at: Some("2026-09-01T10:15:00Z".to_owned()),
617 url: None,
618 snippet: Some("tag the release".to_owned()),
619 },
620 crate::api::wiki::WikiHit {
621 slug: "users/ilubenets/runbook/.files/rollback.pdf".to_owned(),
622 title: "rollback.pdf".to_owned(),
623 kind: "file".to_owned(),
624 modified_at: None,
625 url: None,
626 snippet: None,
627 },
628 ],
629 next_page: Some(2),
630 };
631 insta::assert_snapshot!(hits(&found, &ctx()));
632 }
633
634 #[test]
637 fn comments_view_is_stable() {
638 let comment = |id: u64, body: &str| WikiComment {
639 id,
640 author: Some("ilubenets".to_owned()),
641 created_at: Some("2026-09-02T08:00:00Z".to_owned()),
642 body: body.to_owned(),
643 resolved: false,
644 deleted: false,
645 quote: None,
646 thread_posts: Some(1),
647 };
648 let list = CursorPage {
649 results: vec![
650 WikiComment {
651 thread_posts: Some(3),
652 ..comment(7001, "Step 2 needs the canary first.")
653 },
654 WikiComment {
655 resolved: true,
656 ..comment(7002, "Typo in the title.")
657 },
658 WikiComment {
659 deleted: true,
660 ..comment(7003, "")
661 },
662 ],
663 next_cursor: None,
664 };
665 insta::assert_snapshot!(comments("users/ilubenets/runbook", &list, &ctx()));
666 }
667
668 #[test]
670 fn attachments_view_is_stable() {
671 let list = CursorPage {
672 results: vec![
673 WikiAttachment {
674 id: 901,
675 name: "rollback.pdf".to_owned(),
676 size: "0.25".to_owned(),
677 mimetype: Some("application/pdf".to_owned()),
678 created_at: Some("2026-09-01T11:00:00Z".to_owned()),
679 author: Some("ilubenets".to_owned()),
680 download_url: None,
681 },
682 WikiAttachment {
683 id: 902,
684 name: "notes.txt".to_owned(),
685 size: "-".to_owned(),
686 mimetype: None,
687 created_at: None,
688 author: None,
689 download_url: None,
690 },
691 ],
692 next_cursor: Some("eyJpZCI6OTAyfQ==".to_owned()),
693 };
694 insta::assert_snapshot!(attachments(&list, &ctx()));
695 }
696
697 fn sample_grid() -> WikiGrid {
698 use crate::api::wiki::{GridColumn, GridRow};
699 let column = |slug: &str, title: &str, kind: &str| GridColumn {
700 slug: slug.to_owned(),
701 title: title.to_owned(),
702 kind: kind.to_owned(),
703 };
704 WikiGrid {
705 id: "8f1e2d3c-4b5a-4c6d-8e7f-9a0b1c2d3e4f".to_owned(),
706 title: "Releases".to_owned(),
707 page: Some(WikiPageRef {
708 id: 4521,
709 slug: "users/ilubenets/runbook".to_owned(),
710 }),
711 revision: "12".to_owned(),
712 columns: vec![
713 column("version", "Version", "string"),
714 column("owner", "Owner", "staff"),
715 column("ticket", "Ticket", "ticket"),
716 column("status", "Status", "ticket_field"),
717 column("done", "Done", "checkbox"),
718 ],
719 rows: vec![
720 GridRow {
721 id: "1".to_owned(),
722 cells: vec![
723 serde_json::json!("1.2.0"),
724 serde_json::json!([{"username": "ilubenets", "display_name": "Ilya"}]),
725 serde_json::json!({"key": "PROJ-1", "resolved": false}),
726 serde_json::json!({"key": "inProgress", "display": "В работе"}),
727 serde_json::json!(false),
728 ],
729 },
730 GridRow {
731 id: "2".to_owned(),
732 cells: vec![
733 serde_json::json!("1.3.0\tbeta\nsecond line"),
734 serde_json::json!([]),
735 serde_json::Value::Null,
736 serde_json::Value::Null,
737 serde_json::json!(true),
738 ],
739 },
740 ],
741 }
742 }
743
744 #[test]
747 fn grid_view_is_stable() {
748 let full = Context {
749 description_lines: None,
750 ..ctx()
751 };
752 insta::assert_snapshot!(grid(&sample_grid(), &full));
753 }
754
755 #[test]
757 fn a_long_grid_is_cut_and_says_so() {
758 let text = grid(&sample_grid(), &ctx());
759 assert!(text.contains("(+1 more lines: --full)"), "{text}");
760 assert!(text.trim_end().ends_with("shown 1 of 2"), "{text}");
761 }
762
763 #[test]
764 fn grids_view_is_stable() {
765 let list = CursorPage {
766 results: vec![WikiGridRef {
767 id: "8f1e2d3c-4b5a-4c6d-8e7f-9a0b1c2d3e4f".to_owned(),
768 title: "Releases".to_owned(),
769 created_at: Some("2026-08-01T09:00:00Z".to_owned()),
770 }],
771 next_cursor: None,
772 };
773 insta::assert_snapshot!(grids(&list, &ctx()));
774 }
775
776 #[test]
777 fn resources_view_is_stable() {
778 let list = CursorPage {
779 results: vec![
780 WikiResource {
781 kind: "attachment".to_owned(),
782 id: "901".to_owned(),
783 name: "rollback.pdf".to_owned(),
784 created_at: Some("2026-09-01T11:00:00Z".to_owned()),
785 },
786 WikiResource {
787 kind: "grid".to_owned(),
788 id: "8f1e2d3c-4b5a-4c6d-8e7f-9a0b1c2d3e4f".to_owned(),
789 name: "Releases".to_owned(),
790 created_at: None,
791 },
792 ],
793 next_cursor: Some("eyJpZCI6OTAyfQ==".to_owned()),
794 };
795 insta::assert_snapshot!(resources(&list, &ctx()));
796 }
797
798 #[test]
801 fn access_view_is_stable() {
802 use crate::api::wiki::{AccessEntry, WikiAccess};
803 let entry = |id: &str, role: &str, kind: &str, who: &str, via: &str| AccessEntry {
804 id: id.to_owned(),
805 role: role.to_owned(),
806 kind: kind.to_owned(),
807 who: who.to_owned(),
808 via: via.to_owned(),
809 inheritance: None,
810 };
811 let page_access = WikiAccess {
812 slug: "users/ilubenets/runbook".to_owned(),
813 policy: Some("custom".to_owned()),
814 inherited_policy: None,
815 all_staff_role: None,
816 entries: vec![
817 entry("a1", "author", "user", "ilubenets", "direct"),
818 entry("g7", "reader", "group", "Backend team", "inherited"),
819 ],
820 };
821 insta::assert_snapshot!(access(&page_access, &ctx()));
822 }
823
824 #[test]
827 fn operation_view_is_stable() {
828 use crate::api::wiki::{OperationStatus, WikiOperation};
829 let grid = WikiOperation {
830 id: "op2".to_owned(),
831 kind: "clone_inline_grid".to_owned(),
832 };
833 let running = OperationStatus {
834 status: "in_progress".to_owned(),
835 percentage: Some(40.0),
836 details: None,
837 result: None,
838 };
839 let done = OperationStatus {
840 status: "success".to_owned(),
841 percentage: Some(100.0),
842 details: None,
843 result: Some(serde_json::json!({
844 "grid_id": "4c1d2e3f-0000-4000-8000-000000000002",
845 "page": {"id": 4700, "slug": "users/ilubenets/other"}
846 })),
847 };
848 insta::assert_snapshot!(operation(&grid, &running) + &operation(&grid, &done));
849 }
850
851 #[test]
853 fn page_compact_view_is_stable() {
854 insta::assert_snapshot!(page(&sample(), &ctx()));
855 }
856
857 #[test]
859 fn page_terminal_view_is_stable() {
860 let human = Context {
861 audience: Audience::Human,
862 ..ctx()
863 };
864 insta::assert_snapshot!(page(&sample(), &human));
865 }
866}