返回 CodeWhale
tideline_tests.rs
根目录 / crates / tui / src / tui / notifications / tideline_tests.rs
1 //! Golden-buffer contract for the Tideline notifications inbox (spec §5a/
2 //! §5c). Goldens: `notifications_{w}x{h}` at the four blocker sizes.
3 //! Re-bless with `CODEWHALE_BLESS_GOLDENS=1`.
4
5 use ratatui::buffer::Buffer;
6 use ratatui::layout::Rect;
7 use unicode_width::UnicodeWidthChar;
8
9 use super::{
10 NotificationKind, TidelineInbox, TidelineInboxRecord, render_tideline_inbox,
11 tideline_inbox_hitboxes,
12 };
13 use crate::tui::golden_harness::{BLOCKER_SIZES, assert_matches_golden, render_golden_text};
14 use codewhale_palette::UI_THEME;
15
16 fn record(kind: NotificationKind, title: &str, at: &str, read: bool) -> TidelineInboxRecord {
17 TidelineInboxRecord {
18 kind,
19 title: title.to_string(),
20 body: None,
21 at: at.to_string(),
22 read,
23 }
24 }
25
26 /// The approved inbox fixture: one interactive ask, one completion, one read
27 /// terminal whale, second row selected (so its body row shows).
28 fn records() -> Vec<TidelineInboxRecord> {
29 vec![
30 TidelineInboxRecord {
31 kind: NotificationKind::ApprovalNeeded,
32 title: "rm -rf target/ in worktree".to_string(),
33 body: Some("whale-2 wants to clean the build directory".to_string()),
34 at: "14:41".to_string(),
35 read: false,
36 },
37 record(
38 NotificationKind::TurnComplete,
39 "turn surfaced ✓",
40 "14:38",
41 false,
42 ),
43 record(
44 NotificationKind::SubagentTerminal,
45 "whale-3 done",
46 "14:20",
47 true,
48 ),
49 ]
50 }
51
52 fn draw(width: u16, height: u16, inbox: &TidelineInbox<'_>) -> String {
53 render_golden_text(width, height, |buf| {
54 render_tideline_inbox(Rect::new(0, 0, width, height), buf, inbox);
55 })
56 }
57
58 #[test]
59 fn notifications_matches_goldens_at_blocker_sizes() {
60 for (w, h) in BLOCKER_SIZES {
61 let recs = records();
62 let inbox = TidelineInbox::new(&UI_THEME, &recs).selected(0);
63 assert_matches_golden(&format!("notifications_{w}x{h}"), &draw(w, h, &inbox));
64 }
65 }
66
67 #[test]
68 fn notifications_header_counts_unread() {
69 let recs = records();
70 let inbox = TidelineInbox::new(&UI_THEME, &recs);
71 let text = draw(80, 24, &inbox);
72 assert!(text.contains("NOTIFICATIONS · 2 unread"), "{text}");
73 let all_read: Vec<TidelineInboxRecord> = recs
74 .iter()
75 .map(|r| TidelineInboxRecord {
76 read: true,
77 ..r.clone()
78 })
79 .collect();
80 let text = draw(80, 24, &TidelineInbox::new(&UI_THEME, &all_read));
81 let header = text.lines().next().unwrap_or_default().trim();
82 assert_eq!(header, "NOTIFICATIONS", "read inbox drops the count");
83 }
84
85 #[test]
86 fn notifications_unread_rows_carry_the_gold_mark() {
87 let recs = records();
88 let inbox = TidelineInbox::new(&UI_THEME, &recs);
89 let text = draw(80, 24, &inbox);
90 assert!(
91 text.contains("◆ approval"),
92 "unread ask is gold-marked: {text}"
93 );
94 assert!(text.contains("○ whale done"), "read row is hollow: {text}");
95 }
96
97 #[test]
98 fn notifications_selected_body_replaces_not_doubles() {
99 let recs = records();
100 let inbox = TidelineInbox::new(&UI_THEME, &recs).selected(0);
101 let text = draw(80, 24, &inbox);
102 assert!(
103 text.contains("whale-2 wants to clean"),
104 "selected body row: {text}"
105 );
106 // Row 2 (the completion) must still be present exactly once.
107 assert_eq!(text.matches("turn surfaced").count(), 1, "{text}");
108 }
109
110 #[test]
111 fn notifications_empty_state_is_quiet_not_blank() {
112 let inbox = TidelineInbox::new(&UI_THEME, &[]);
113 let text = draw(80, 24, &inbox);
114 assert!(text.contains("quiet water"), "{text}");
115 }
116
117 #[test]
118 fn notifications_ascii_safe_projects_marks() {
119 let recs = records();
120 let inbox = TidelineInbox::new(&UI_THEME, &recs).ascii_safe(true);
121 let text = draw(80, 24, &inbox);
122 assert!(text.contains("* approval"), "gold ◆ projects to *: {text}");
123 assert!(
124 text.contains(". whale done"),
125 "read ○ projects to .: {text}"
126 );
127 for ch in text.chars() {
128 if ch != '\n' {
129 assert_eq!(ch.width(), Some(1), "ascii-safe single-width: {ch:?}");
130 }
131 }
132 }
133
134 #[test]
135 fn notifications_hitboxes_match_painted_rows() {
136 let recs = records();
137 let inbox = TidelineInbox::new(&UI_THEME, &recs).selected(0);
138 let (w, h) = (100, 30);
139 let area = Rect::new(0, 0, w, h);
140 let mut buf = Buffer::empty(area);
141 render_tideline_inbox(area, &mut buf, &inbox);
142 let hitboxes = tideline_inbox_hitboxes(area, &inbox);
143 assert_eq!(hitboxes.len(), 3, "one rect per record");
144 // First rect covers the selected record's body row.
145 assert_eq!(hitboxes[0].height, 2);
146 // No overlaps, all inside, all cover painted text.
147 for pair in hitboxes.windows(2) {
148 assert!(pair[0].y + pair[0].height <= pair[1].y, "no overlap");
149 }
150 for rect in &hitboxes {
151 let cells: String = (rect.x..rect.x + rect.width)
152 .map(|x| buf[(x, rect.y)].symbol().to_string())
153 .collect();
154 assert!(!cells.trim().is_empty(), "rect {rect:?} covers empty cells");
155 }
156 }
157
158 #[test]
159 fn notifications_kind_words_never_say_error() {
160 for kind in [
161 NotificationKind::TurnComplete,
162 NotificationKind::SubagentTerminal,
163 NotificationKind::ApprovalNeeded,
164 NotificationKind::InputNeeded,
165 NotificationKind::ElevationNeeded,
166 NotificationKind::ModelNotify,
167 ] {
168 let rec = record(kind, "t", "00:00", false);
169 assert_ne!(rec.kind_word(), "error");
170 assert_ne!(rec.kind_word(), "Error");
171 }
172 }
173
174 #[test]
175 fn notifications_degenerate_sizes_do_not_panic() {
176 for (w, h) in [(0u16, 0), (4, 1), (8, 2), (200, 50)] {
177 let recs = records();
178 let inbox = TidelineInbox::new(&UI_THEME, &recs);
179 let _ = draw(w, h, &inbox);
180 }
181 }
182
182 lines RUST