返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tui / infoline / tests.rs
1 //! Golden-buffer contract for the metrics line — the row under the posture
2 //! bar.
3 //!
4 //! Goldens live in `crates/tui/src/tui/goldens/infoline_{screen}_{w}x{h}.txt`
5 //! for the two screens (startup, work) at the blocker sizes. Re-bless by
6 //! deleting the golden and running with `CODEWHALE_BLESS_GOLDENS=1`.
7
8 use ratatui::{Terminal, backend::TestBackend, layout::Rect};
9 use unicode_width::UnicodeWidthStr;
10
11 use super::{InfoLine, InfoSegment, InfoSegmentId, context_meter_hitbox, infoline_hitboxes};
12 use codewhale_palette::{ChromeInk, UI_THEME, UiTheme};
13
14 /// The hint the live shell advertises, from the one binding module that owns
15 /// it — a fixture string here would let chrome and routing drift apart.
16 fn help_hint() -> String {
17 crate::tui::shell_key_routing::info_help_hint(codewhale_localization::Locale::En)
18 }
19
20 const BLOCKER_SIZES: [(u16, u16); 4] = [(80, 24), (100, 30), (120, 32), (160, 40)];
21
22 fn context(pct: u8) -> InfoSegment {
23 InfoSegment::new(
24 InfoSegmentId::Context,
25 "ctx",
26 format!("{pct}%"),
27 if pct >= 80 {
28 ChromeInk::Failure
29 } else {
30 ChromeInk::Info
31 },
32 )
33 }
34
35 /// Approved startup screen: no route yet, no metrics yet.
36 fn startup_segments() -> Vec<InfoSegment> {
37 vec![
38 InfoSegment::new(
39 InfoSegmentId::Model,
40 "",
41 "model not connected",
42 ChromeInk::Waiting,
43 ),
44 context(0),
45 ]
46 }
47
48 /// Approved work screen: model, context, cost, then the session metrics.
49 fn work_segments() -> Vec<InfoSegment> {
50 vec![
51 InfoSegment::new(InfoSegmentId::Model, "", "deepseek-v4", ChromeInk::Identity),
52 context(61),
53 InfoSegment::new(InfoSegmentId::Cost, "", "$0.42", ChromeInk::MetadataValue),
54 InfoSegment::new(
55 InfoSegmentId::Ttft,
56 "ttft",
57 "400ms",
58 ChromeInk::MetadataValue,
59 ),
60 InfoSegment::new(
61 InfoSegmentId::Rate,
62 "",
63 "38 tok/s",
64 ChromeInk::MetadataValue,
65 ),
66 InfoSegment::new(
67 InfoSegmentId::OutputTokens,
68 "↓",
69 "1.2K",
70 ChromeInk::MetadataValue,
71 ),
72 ]
73 }
74
75 fn fixtures() -> Vec<(&'static str, Vec<InfoSegment>)> {
76 vec![("startup", startup_segments()), ("work", work_segments())]
77 }
78
79 fn render_buffer(theme: &UiTheme, width: u16, segments: &[InfoSegment]) -> ratatui::buffer::Buffer {
80 let backend = TestBackend::new(width, 1);
81 let mut terminal = Terminal::new(backend).expect("terminal");
82 let hint = help_hint();
83 terminal
84 .draw(|frame| {
85 let info = InfoLine::new(theme, &hint, segments);
86 use ratatui::widgets::Widget;
87 Widget::render(info, frame.area(), frame.buffer_mut());
88 })
89 .expect("draw");
90 terminal.backend().buffer().clone()
91 }
92
93 fn render_row(theme: &UiTheme, width: u16, segments: &[InfoSegment]) -> String {
94 render_cells(theme, width, segments).concat()
95 }
96
97 /// Per-cell symbols of one rendered row (the golden dump, before joining).
98 fn render_cells(theme: &UiTheme, width: u16, segments: &[InfoSegment]) -> Vec<String> {
99 render_buffer(theme, width, segments)
100 .content()
101 .iter()
102 .map(|cell| cell.symbol().to_string())
103 .collect()
104 }
105
106 fn golden_path(name: &str) -> std::path::PathBuf {
107 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
108 .join("src/tui/goldens")
109 .join(format!("{name}.txt"))
110 }
111
112 fn bless(name: &str, text: &str) {
113 let path = golden_path(name);
114 if let Some(parent) = path.parent() {
115 std::fs::create_dir_all(parent).expect("create goldens dir");
116 }
117 std::fs::write(path, text).expect("write golden");
118 }
119
120 fn golden_text(name: &str) -> Option<String> {
121 // Normalize to LF; a Windows checkout can hand us CRLF while `render_row`
122 // always terminates with LF. Cell symbols never contain CR.
123 std::fs::read_to_string(golden_path(name))
124 .ok()
125 .map(|text| text.replace("\r\n", "\n"))
126 }
127
128 #[test]
129 fn infoline_matches_goldens_at_blocker_sizes() {
130 for (screen, segments) in fixtures() {
131 for (w, h) in BLOCKER_SIZES {
132 let name = format!("infoline_{screen}_{w}x{h}");
133 let rendered = render_row(&UI_THEME, w, &segments);
134 let rendered = format!("{rendered}\n");
135 match golden_text(&name) {
136 Some(expected) => {
137 assert_eq!(
138 rendered, expected,
139 "info-line golden drift at {name}; re-bless only with an approved design change"
140 );
141 }
142 None => {
143 if std::env::var("CODEWHALE_BLESS_GOLDENS").is_ok() {
144 bless(&name, &rendered);
145 } else {
146 panic!(
147 "missing golden {name}; run with CODEWHALE_BLESS_GOLDENS=1 to write it"
148 );
149 }
150 }
151 }
152 }
153 }
154 }
155
156 /// The row states no time of day, carries no wordmark, and no longer names
157 /// the repository or branch: the launch header and the git bottom view own
158 /// those (2026-09-02).
159 #[test]
160 fn infoline_is_model_context_and_metrics_only() {
161 for (_, segments) in fixtures() {
162 for (w, _h) in BLOCKER_SIZES {
163 let row = render_row(&UI_THEME, w, &segments);
164 assert!(
165 !row.contains(':'),
166 "{w}: the metrics line carries no clock: {row:?}"
167 );
168 assert!(
169 !row.contains("CODEWHALE") && !row.contains("codewhale"),
170 "{w}: no wordmark or repository on this row: {row:?}"
171 );
172 assert!(!row.contains('⑂'), "{w}: no branch on this row: {row:?}");
173 }
174 }
175 let work = render_row(&UI_THEME, 160, &work_segments());
176 assert!(
177 work.starts_with("deepseek-v4 ctx 61% $0.42 ttft 400ms 38 tok/s ↓ 1.2K "),
178 "{work:?}"
179 );
180 assert!(work.trim_end().ends_with("/help"), "{work:?}");
181 }
182
183 /// Secondary counts and help yield before performance readings and cost. The model and `ctx NN%` are the floor at every width.
184 #[test]
185 fn infoline_sheds_tokens_then_help_then_rate_then_ttft_then_cost() {
186 let segments = work_segments();
187 // The narrowest row that still shows a thing. A thing that sheds earlier
188 // needs a wider row to survive, so these strictly decrease down the
189 // declared order.
190 let narrowest_showing = |needle: &str| -> u16 {
191 (24..=180u16)
192 .filter(|w| render_row(&UI_THEME, *w, &segments).contains(needle))
193 .min()
194 .unwrap_or_else(|| panic!("{needle} never painted at any width"))
195 };
196 let rate = narrowest_showing("tok/s");
197 let ttft = narrowest_showing("ttft");
198 let tokens = narrowest_showing("↓ 1.2K");
199 let help = narrowest_showing("help");
200 let cost = narrowest_showing("$0.42");
201 assert!(
202 tokens > help && help > rate && rate > ttft && ttft > cost,
203 "shed order broke: rate@{rate} ttft@{ttft} tokens@{tokens} help@{help} cost@{cost}"
204 );
205 for w in 24..=180u16 {
206 let row = render_row(&UI_THEME, w, &segments);
207 assert!(
208 row.contains("deepseek-v4") && row.contains("ctx 61%"),
209 "{w}: the model and the context reading never shed: {row:?}"
210 );
211 }
212 }
213
214 /// `tui.metrics_line = "compact"` (#5950) is the row after its first shed
215 /// rungs, at any width: output counts and help are gone before width is
216 /// consulted; selected TTFT/rate survive when they fit. Hitboxes follow the same
217 /// pass so a click still lands on what painted.
218 #[test]
219 fn infoline_compact_keeps_performance_readings_without_extra_rows() {
220 let segments = work_segments();
221 let hint = help_hint();
222 let compact_row = |width: u16| -> (String, Vec<InfoSegmentId>) {
223 let backend = TestBackend::new(width, 1);
224 let mut terminal = Terminal::new(backend).expect("terminal");
225 let mut ids = Vec::new();
226 terminal
227 .draw(|frame| {
228 let info = InfoLine::new(&UI_THEME, &hint, &segments).compact(true);
229 ids = infoline_hitboxes(&info, frame.area())
230 .into_iter()
231 .map(|hitbox| hitbox.id)
232 .collect();
233 use ratatui::widgets::Widget;
234 Widget::render(info, frame.area(), frame.buffer_mut());
235 })
236 .expect("draw");
237 let row = terminal
238 .backend()
239 .buffer()
240 .content()
241 .iter()
242 .map(|cell| cell.symbol().to_string())
243 .collect::<String>();
244 (row, ids)
245 };
246 let (wide, ids) = compact_row(160);
247 assert_eq!(
248 wide.trim_end(),
249 "deepseek-v4 ctx 61% $0.42 ttft 400ms 38 tok/s",
250 "compact keeps performance, route, context and price: {wide:?}"
251 );
252 assert_eq!(
253 ids,
254 vec![
255 InfoSegmentId::Model,
256 InfoSegmentId::Context,
257 InfoSegmentId::Cost,
258 InfoSegmentId::Ttft,
259 InfoSegmentId::Rate,
260 ]
261 );
262 for w in 24..=180u16 {
263 let (row, _) = compact_row(w);
264 for gone in ["1.2K", "help"] {
265 assert!(
266 !row.contains(gone),
267 "{w}: compact never paints {gone}: {row:?}"
268 );
269 }
270 assert!(
271 row.contains("deepseek-v4") && row.contains("ctx 61%"),
272 "{w}: the floor still never sheds: {row:?}"
273 );
274 }
275 // The full row at the same width is the row the user had before.
276 assert!(render_row(&UI_THEME, 160, &segments).contains("tok/s"));
277 }
278
279 /// At the 80% cap the context reading takes the error token — the caller
280 /// picks the ink, and the row paints it on both the label and the value.
281 #[test]
282 fn infoline_context_takes_the_error_token_at_eighty() {
283 let theme = &UI_THEME;
284 let failure = codewhale_palette::grammar::chrome_style(theme, ChromeInk::Failure)
285 .fg
286 .expect("failure ink has a colour");
287 for (pct, expect_failure) in [(79u8, false), (80, true), (99, true)] {
288 let segments = vec![
289 InfoSegment::new(InfoSegmentId::Model, "", "deepseek-v4", ChromeInk::Identity),
290 context(pct),
291 ];
292 let buf = render_buffer(theme, 80, &segments);
293 let row = render_row(theme, 80, &segments);
294 let start = row.find("ctx").expect("context reading painted");
295 let value_fg = buf[(u16::try_from(start + 4).unwrap(), 0)].fg;
296 let label_fg = buf[(u16::try_from(start).unwrap(), 0)].fg;
297 assert_eq!(value_fg == failure, expect_failure, "{pct}%: value ink");
298 assert_eq!(label_fg == failure, expect_failure, "{pct}%: label ink");
299 }
300 }
301
302 /// The hint must name a route that actually opens help in this shell. `F1`
303 /// is eaten by tmux and several emulators, bare `?` is composer text, and how
304 /// a terminal encodes `Ctrl+/` varies enough that printing it was a promise
305 /// the product could not keep. `/help` reaches the same view through the
306 /// composer in every terminal.
307 #[test]
308 fn infoline_help_hint_names_a_route_that_opens_help() {
309 let hint = help_hint();
310 assert_eq!(hint, "/help", "a slash command names itself: {hint}");
311 assert!(!hint.contains("F1"), "terminals eat F1: {hint}");
312 assert!(!hint.starts_with('?'), "bare ? is composer text: {hint}");
313 // The chord stays accepted for the terminals that do deliver it; it is
314 // only no longer what chrome promises.
315 let key = crossterm::event::KeyEvent::new(
316 crossterm::event::KeyCode::Char('/'),
317 crossterm::event::KeyModifiers::CONTROL,
318 );
319 assert!(crate::tui::shell_key_routing::is_help_shortcut(&key));
320 let row = render_row(&UI_THEME, 120, &work_segments());
321 assert!(row.trim_end().ends_with(&hint), "pinned right: {row:?}");
322 }
323
324 /// Every recorded hitbox covers exactly the cells its segment painted, at
325 /// every width — the hitbox pass and the paint pass share one shed pass.
326 #[test]
327 fn infoline_hitboxes_match_painted_cells() {
328 let segments = work_segments();
329 let hint = help_hint();
330 for w in 24..=180u16 {
331 let area = Rect::new(0, 0, w, 1);
332 let info = InfoLine::new(&UI_THEME, &hint, &segments);
333 let hitboxes = infoline_hitboxes(&info, area);
334 let cells = render_cells(&UI_THEME, w, &segments);
335 for hitbox in &hitboxes {
336 let segment = segments.iter().find(|s| s.id == hitbox.id).unwrap();
337 let painted: String = cells
338 [usize::from(hitbox.area.x)..usize::from(hitbox.area.x + hitbox.area.width)]
339 .concat();
340 let expected = if segment.label.is_empty() {
341 segment.value.clone()
342 } else {
343 format!("{} {}", segment.label, segment.value)
344 };
345 assert!(
346 expected.starts_with(painted.trim_end()),
347 "{w}: {:?} hitbox {:?} covers {painted:?}, expected {expected:?}",
348 hitbox.id,
349 hitbox.area
350 );
351 }
352 // No two hitboxes overlap.
353 for (i, a) in hitboxes.iter().enumerate() {
354 for b in &hitboxes[i + 1..] {
355 assert!(
356 a.area.right() <= b.area.x || b.area.right() <= a.area.x,
357 "{w}: hitboxes overlap: {a:?} {b:?}"
358 );
359 }
360 }
361 }
362 }
363
364 /// The context reading's hitbox is exactly the painted `ctx NN%` span.
365 #[test]
366 fn context_meter_hitbox_covers_exactly_the_painted_reading() {
367 let segments = work_segments();
368 let hint = help_hint();
369 for w in 24..=180u16 {
370 let area = Rect::new(0, 0, w, 1);
371 let info = InfoLine::new(&UI_THEME, &hint, &segments);
372 let hitbox = context_meter_hitbox(&info, area).expect("the reading never sheds");
373 let cells = render_cells(&UI_THEME, w, &segments);
374 let painted: String =
375 cells[usize::from(hitbox.x)..usize::from(hitbox.x + hitbox.width)].concat();
376 assert!(
377 "ctx 61%".starts_with(painted.trim_end()),
378 "{w}: context hitbox {hitbox:?} covers {painted:?}"
379 );
380 }
381 }
382
383 /// ASCII-safe mode projects every glyph to a single-width ASCII cell.
384 #[test]
385 fn infoline_ascii_safe_has_no_wide_or_unsupported_glyphs() {
386 let segments = work_segments();
387 let hint = help_hint();
388 for (w, _) in BLOCKER_SIZES {
389 let area = Rect::new(0, 0, w, 1);
390 let mut buf = ratatui::buffer::Buffer::empty(area);
391 let info = InfoLine::new(&UI_THEME, &hint, &segments).ascii_safe(true);
392 ratatui::widgets::Widget::render(info, area, &mut buf);
393 for x in 0..w {
394 let symbol = buf[(x, 0)].symbol();
395 assert!(symbol.is_ascii(), "{w}: cell {x} {symbol:?} is not ASCII");
396 assert_eq!(
397 symbol.width(),
398 1,
399 "{w}: cell {x} {symbol:?} is not one cell"
400 );
401 }
402 }
403 }
404
405 /// Hover and degenerate sizes never panic, and hover only brightens the
406 /// model — the one segment with an action.
407 #[test]
408 fn infoline_hover_and_narrow_do_not_panic() {
409 let segments = work_segments();
410 let hint = help_hint();
411 for (w, h) in [(0u16, 0u16), (1, 1), (5, 1), (24, 1), (300, 1)] {
412 let area = Rect::new(0, 0, w, h);
413 let mut buf = ratatui::buffer::Buffer::empty(area);
414 let info = InfoLine::new(&UI_THEME, &hint, &segments).hovered(Some(InfoSegmentId::Model));
415 ratatui::widgets::Widget::render(info, area, &mut buf);
416 let info = InfoLine::new(&UI_THEME, &hint, &segments);
417 let _ = infoline_hitboxes(&info, area);
418 let _ = context_meter_hitbox(&info, area);
419 }
420 let area = Rect::new(0, 0, 120, 1);
421 let mut plain = ratatui::buffer::Buffer::empty(area);
422 ratatui::widgets::Widget::render(InfoLine::new(&UI_THEME, &hint, &segments), area, &mut plain);
423 let mut hovered = ratatui::buffer::Buffer::empty(area);
424 ratatui::widgets::Widget::render(
425 InfoLine::new(&UI_THEME, &hint, &segments).hovered(Some(InfoSegmentId::Model)),
426 area,
427 &mut hovered,
428 );
429 assert_ne!(plain[(0, 0)].modifier, hovered[(0, 0)].modifier);
430 let ctx_x = u16::try_from(render_row(&UI_THEME, 120, &segments).find("ctx").unwrap()).unwrap();
431 assert_eq!(plain[(ctx_x, 0)], hovered[(ctx_x, 0)]);
432 }
433
434 /// Slice G: the context reading owns the inspector click action, so it
435 /// brightens on hover exactly like the model segment; status-only facts
436 /// (cost) never do.
437 #[test]
438 fn infoline_context_hover_brightens_only_the_context_reading() {
439 let segments = work_segments();
440 let hint = help_hint();
441 let area = Rect::new(0, 0, 120, 1);
442 let mut plain = ratatui::buffer::Buffer::empty(area);
443 ratatui::widgets::Widget::render(InfoLine::new(&UI_THEME, &hint, &segments), area, &mut plain);
444 let mut hovered = ratatui::buffer::Buffer::empty(area);
445 ratatui::widgets::Widget::render(
446 InfoLine::new(&UI_THEME, &hint, &segments).hovered(Some(InfoSegmentId::Context)),
447 area,
448 &mut hovered,
449 );
450 let row = render_row(&UI_THEME, 120, &segments);
451 // Hover feedback lands on the value cells (`61%`); the dim label prefix
452 // (`ctx`) keeps its reading ink, mirroring the model segment's probe.
453 let ctx_x = u16::try_from(row.find("61%").unwrap()).unwrap();
454 assert_ne!(
455 plain[(ctx_x, 0)].modifier,
456 hovered[(ctx_x, 0)].modifier,
457 "hovered context reading must respond visibly"
458 );
459 // Model (actionable but not hovered) and cost (status-only) stay clean.
460 assert_eq!(plain[(0, 0)], hovered[(0, 0)]);
461 let cost_x = u16::try_from(row.find("$0.42").unwrap()).unwrap();
462 assert_eq!(plain[(cost_x, 0)], hovered[(cost_x, 0)]);
463 }
464
464 lines RUST