返回 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 // Cache survives compact and is the first performance reading to shed
279 // when the row is narrow (#6565).
280 let mut with_cache = segments.clone();
281 with_cache.insert(
282 3,
283 InfoSegment::new(
284 InfoSegmentId::Cache,
285 "cache",
286 "85%",
287 ChromeInk::MetadataValue,
288 ),
289 );
290 let wide_cache = render_row_compact(160, &with_cache);
291 assert!(wide_cache.contains("cache 85%"), "{wide_cache:?}");
292 assert!(
293 !wide_cache.contains("1.2K") && !wide_cache.contains("help"),
294 "{wide_cache:?}"
295 );
296 let narrow_cache = render_row_compact(40, &with_cache);
297 assert!(!narrow_cache.contains("cache"), "{narrow_cache:?}");
298 assert!(
299 narrow_cache.contains("deepseek-v4") && narrow_cache.contains("ctx 61%"),
300 "{narrow_cache:?}"
301 );
302 }
303
304 fn render_row_compact(width: u16, segments: &[InfoSegment]) -> String {
305 let backend = TestBackend::new(width, 1);
306 let mut terminal = Terminal::new(backend).expect("terminal");
307 terminal
308 .draw(|frame| {
309 let hint = help_hint();
310 let info = InfoLine::new(&UI_THEME, &hint, segments).compact(true);
311 use ratatui::widgets::Widget;
312 Widget::render(info, frame.area(), frame.buffer_mut());
313 })
314 .expect("draw");
315 terminal
316 .backend()
317 .buffer()
318 .content()
319 .iter()
320 .map(|cell| cell.symbol().to_string())
321 .collect()
322 }
323
324 /// At the 80% cap the context reading takes the error token — the caller
325 /// picks the ink, and the row paints it on both the label and the value.
326 #[test]
327 fn infoline_context_takes_the_error_token_at_eighty() {
328 let theme = &UI_THEME;
329 let failure = codewhale_palette::grammar::chrome_style(theme, ChromeInk::Failure)
330 .fg
331 .expect("failure ink has a colour");
332 for (pct, expect_failure) in [(79u8, false), (80, true), (99, true)] {
333 let segments = vec![
334 InfoSegment::new(InfoSegmentId::Model, "", "deepseek-v4", ChromeInk::Identity),
335 context(pct),
336 ];
337 let buf = render_buffer(theme, 80, &segments);
338 let row = render_row(theme, 80, &segments);
339 let start = row.find("ctx").expect("context reading painted");
340 let value_fg = buf[(u16::try_from(start + 4).unwrap(), 0)].fg;
341 let label_fg = buf[(u16::try_from(start).unwrap(), 0)].fg;
342 assert_eq!(value_fg == failure, expect_failure, "{pct}%: value ink");
343 assert_eq!(label_fg == failure, expect_failure, "{pct}%: label ink");
344 }
345 }
346
347 /// The hint must name a route that actually opens help in this shell. `F1`
348 /// is eaten by tmux and several emulators, bare `?` is composer text, and how
349 /// a terminal encodes `Ctrl+/` varies enough that printing it was a promise
350 /// the product could not keep. `/help` reaches the same view through the
351 /// composer in every terminal.
352 #[test]
353 fn infoline_help_hint_names_a_route_that_opens_help() {
354 let hint = help_hint();
355 assert_eq!(hint, "/help", "a slash command names itself: {hint}");
356 assert!(!hint.contains("F1"), "terminals eat F1: {hint}");
357 assert!(!hint.starts_with('?'), "bare ? is composer text: {hint}");
358 // The chord stays accepted for the terminals that do deliver it; it is
359 // only no longer what chrome promises.
360 let key = crossterm::event::KeyEvent::new(
361 crossterm::event::KeyCode::Char('/'),
362 crossterm::event::KeyModifiers::CONTROL,
363 );
364 assert!(crate::tui::shell_key_routing::is_help_shortcut(&key));
365 let row = render_row(&UI_THEME, 120, &work_segments());
366 assert!(row.trim_end().ends_with(&hint), "pinned right: {row:?}");
367 }
368
369 /// Every recorded hitbox covers exactly the cells its segment painted, at
370 /// every width — the hitbox pass and the paint pass share one shed pass.
371 #[test]
372 fn infoline_hitboxes_match_painted_cells() {
373 let segments = work_segments();
374 let hint = help_hint();
375 for w in 24..=180u16 {
376 let area = Rect::new(0, 0, w, 1);
377 let info = InfoLine::new(&UI_THEME, &hint, &segments);
378 let hitboxes = infoline_hitboxes(&info, area);
379 let cells = render_cells(&UI_THEME, w, &segments);
380 for hitbox in &hitboxes {
381 let segment = segments.iter().find(|s| s.id == hitbox.id).unwrap();
382 let painted: String = cells
383 [usize::from(hitbox.area.x)..usize::from(hitbox.area.x + hitbox.area.width)]
384 .concat();
385 let expected = if segment.label.is_empty() {
386 segment.value.clone()
387 } else {
388 format!("{} {}", segment.label, segment.value)
389 };
390 assert!(
391 expected.starts_with(painted.trim_end()),
392 "{w}: {:?} hitbox {:?} covers {painted:?}, expected {expected:?}",
393 hitbox.id,
394 hitbox.area
395 );
396 }
397 // No two hitboxes overlap.
398 for (i, a) in hitboxes.iter().enumerate() {
399 for b in &hitboxes[i + 1..] {
400 assert!(
401 a.area.right() <= b.area.x || b.area.right() <= a.area.x,
402 "{w}: hitboxes overlap: {a:?} {b:?}"
403 );
404 }
405 }
406 }
407 }
408
409 /// The context reading's hitbox is exactly the painted `ctx NN%` span.
410 #[test]
411 fn context_meter_hitbox_covers_exactly_the_painted_reading() {
412 let segments = work_segments();
413 let hint = help_hint();
414 for w in 24..=180u16 {
415 let area = Rect::new(0, 0, w, 1);
416 let info = InfoLine::new(&UI_THEME, &hint, &segments);
417 let hitbox = context_meter_hitbox(&info, area).expect("the reading never sheds");
418 let cells = render_cells(&UI_THEME, w, &segments);
419 let painted: String =
420 cells[usize::from(hitbox.x)..usize::from(hitbox.x + hitbox.width)].concat();
421 assert!(
422 "ctx 61%".starts_with(painted.trim_end()),
423 "{w}: context hitbox {hitbox:?} covers {painted:?}"
424 );
425 }
426 }
427
428 /// ASCII-safe mode projects every glyph to a single-width ASCII cell.
429 #[test]
430 fn infoline_ascii_safe_has_no_wide_or_unsupported_glyphs() {
431 let segments = work_segments();
432 let hint = help_hint();
433 for (w, _) in BLOCKER_SIZES {
434 let area = Rect::new(0, 0, w, 1);
435 let mut buf = ratatui::buffer::Buffer::empty(area);
436 let info = InfoLine::new(&UI_THEME, &hint, &segments).ascii_safe(true);
437 ratatui::widgets::Widget::render(info, area, &mut buf);
438 for x in 0..w {
439 let symbol = buf[(x, 0)].symbol();
440 assert!(symbol.is_ascii(), "{w}: cell {x} {symbol:?} is not ASCII");
441 assert_eq!(
442 symbol.width(),
443 1,
444 "{w}: cell {x} {symbol:?} is not one cell"
445 );
446 }
447 }
448 }
449
450 /// Hover and degenerate sizes never panic, and hover only brightens the
451 /// model — the one segment with an action.
452 #[test]
453 fn infoline_hover_and_narrow_do_not_panic() {
454 let segments = work_segments();
455 let hint = help_hint();
456 for (w, h) in [(0u16, 0u16), (1, 1), (5, 1), (24, 1), (300, 1)] {
457 let area = Rect::new(0, 0, w, h);
458 let mut buf = ratatui::buffer::Buffer::empty(area);
459 let info = InfoLine::new(&UI_THEME, &hint, &segments).hovered(Some(InfoSegmentId::Model));
460 ratatui::widgets::Widget::render(info, area, &mut buf);
461 let info = InfoLine::new(&UI_THEME, &hint, &segments);
462 let _ = infoline_hitboxes(&info, area);
463 let _ = context_meter_hitbox(&info, area);
464 }
465 let area = Rect::new(0, 0, 120, 1);
466 let mut plain = ratatui::buffer::Buffer::empty(area);
467 ratatui::widgets::Widget::render(InfoLine::new(&UI_THEME, &hint, &segments), area, &mut plain);
468 let mut hovered = ratatui::buffer::Buffer::empty(area);
469 ratatui::widgets::Widget::render(
470 InfoLine::new(&UI_THEME, &hint, &segments).hovered(Some(InfoSegmentId::Model)),
471 area,
472 &mut hovered,
473 );
474 assert_ne!(plain[(0, 0)].modifier, hovered[(0, 0)].modifier);
475 let ctx_x = u16::try_from(render_row(&UI_THEME, 120, &segments).find("ctx").unwrap()).unwrap();
476 assert_eq!(plain[(ctx_x, 0)], hovered[(ctx_x, 0)]);
477 }
478
479 /// Slice G: the context reading owns the inspector click action, so it
480 /// brightens on hover exactly like the model segment; status-only facts
481 /// (cost) never do.
482 #[test]
483 fn infoline_context_hover_brightens_only_the_context_reading() {
484 let segments = work_segments();
485 let hint = help_hint();
486 let area = Rect::new(0, 0, 120, 1);
487 let mut plain = ratatui::buffer::Buffer::empty(area);
488 ratatui::widgets::Widget::render(InfoLine::new(&UI_THEME, &hint, &segments), area, &mut plain);
489 let mut hovered = ratatui::buffer::Buffer::empty(area);
490 ratatui::widgets::Widget::render(
491 InfoLine::new(&UI_THEME, &hint, &segments).hovered(Some(InfoSegmentId::Context)),
492 area,
493 &mut hovered,
494 );
495 let row = render_row(&UI_THEME, 120, &segments);
496 // Hover feedback lands on the value cells (`61%`); the dim label prefix
497 // (`ctx`) keeps its reading ink, mirroring the model segment's probe.
498 let ctx_x = u16::try_from(row.find("61%").unwrap()).unwrap();
499 assert_ne!(
500 plain[(ctx_x, 0)].modifier,
501 hovered[(ctx_x, 0)].modifier,
502 "hovered context reading must respond visibly"
503 );
504 // Model (actionable but not hovered) and cost (status-only) stay clean.
505 assert_eq!(plain[(0, 0)], hovered[(0, 0)]);
506 let cost_x = u16::try_from(row.find("$0.42").unwrap()).unwrap();
507 assert_eq!(plain[(cost_x, 0)], hovered[(cost_x, 0)]);
508 }
509
510 #[test]
511 fn infoline_preserves_every_live_chrome_ink_and_untouched_host_style() {
512 use ratatui::style::{Color, Modifier, Style};
513 let theme = UiTheme {
514 status_working: Color::Rgb(1, 2, 3),
515 permission_ask: Color::Rgb(2, 3, 4),
516 permission_auto_review: Color::Rgb(3, 4, 5),
517 permission_full_access: Color::Rgb(4, 5, 6),
518 accent_action: Color::Rgb(5, 6, 7),
519 warning: Color::Rgb(6, 7, 8),
520 accent_primary: Color::Rgb(7, 8, 9),
521 text_soft: Color::Rgb(8, 9, 10),
522 text_muted: Color::Rgb(9, 10, 11),
523 text_hint: Color::Rgb(10, 11, 12),
524 text_dim: Color::Rgb(11, 12, 13),
525 error_fg: Color::Rgb(12, 13, 14),
526 ..UI_THEME
527 };
528 for ink in [
529 ChromeInk::Outcome,
530 ChromeInk::PermissionAsk,
531 ChromeInk::PermissionAutoReview,
532 ChromeInk::PermissionFullAccess,
533 ChromeInk::Waiting,
534 ChromeInk::Attention,
535 ChromeInk::Active,
536 ChromeInk::PolicyAct,
537 ChromeInk::PolicyPlan,
538 ChromeInk::PolicyOperate,
539 ChromeInk::Identity,
540 ChromeInk::Info,
541 ChromeInk::MetadataValue,
542 ChromeInk::Metadata,
543 ChromeInk::MetadataHint,
544 ChromeInk::MetadataDim,
545 ChromeInk::Failure,
546 ] {
547 let segments = [InfoSegment::new(
548 InfoSegmentId::Model,
549 "reading",
550 "value",
551 ink,
552 )];
553 let area = Rect::new(3, 2, 32, 2);
554 let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 40, 5));
555 let existing = Style::default()
556 .fg(super::source_theme(false)
557 .color(codewhale_ratatui::Role::Primary)
558 .unwrap())
559 .bg(Color::Rgb(30, 40, 50))
560 .add_modifier(Modifier::ITALIC);
561 buf.set_style(buf.area, existing);
562 let before = buf.clone();
563 let info = InfoLine::new(&theme, "/help", &segments).hovered(Some(InfoSegmentId::Model));
564 ratatui::widgets::Widget::render(info, area, &mut buf);
565 let label_ink = if matches!(ink, ChromeInk::Failure | ChromeInk::Attention) {
566 ink
567 } else {
568 ChromeInk::Metadata
569 };
570 assert_eq!(buf[(3, 2)].fg, label_ink.color(&theme), "{ink:?}: label");
571 for x in 11..16 {
572 let cell = &buf[(x, 2)];
573 assert_eq!(cell.fg, ink.color(&theme), "{ink:?}: value");
574 assert_eq!(cell.bg, Color::Rgb(30, 40, 50), "host owns the ground");
575 assert!(
576 cell.modifier
577 .contains(Modifier::ITALIC | Modifier::BOLD | Modifier::UNDERLINED)
578 );
579 }
580 assert_eq!(
581 buf[(30, 2)].fg,
582 theme.text_hint,
583 "help uses its own live slot"
584 );
585 for position in [(0, 2), (16, 2), (20, 2), (3, 3), (39, 4)] {
586 assert_eq!(
587 buf[position], before[position],
588 "{ink:?}: untouched {position:?}"
589 );
590 }
591 }
592 }
593
594 #[test]
595 fn infoline_cjk_and_combining_text_keep_projected_pointer_geometry_when_clipped() {
596 use ratatui::style::{Color, Style};
597 let segments = [
598 InfoSegment::new(
599 InfoSegmentId::Model,
600 "",
601 "模型-e\u{301}↓",
602 ChromeInk::Identity,
603 ),
604 InfoSegment::new(InfoSegmentId::Context, "上下文", "61%", ChromeInk::Info),
605 ];
606 for ascii in [false, true] {
607 for width in 0..=40 {
608 let area = Rect::new(3, 2, width, 1);
609 let mut buf = ratatui::buffer::Buffer::empty(Rect::new(0, 0, 46, 4));
610 buf.set_style(buf.area, Style::default().bg(Color::Rgb(30, 40, 50)));
611 let before = buf.clone();
612 let info = InfoLine::new(&UI_THEME, "", &segments).ascii_safe(ascii);
613 let hitboxes = infoline_hitboxes(&info, area);
614 let context = context_meter_hitbox(&info, area);
615 ratatui::widgets::Widget::render(info, area, &mut buf);
616 assert_eq!(buf[(2, 2)], before[(2, 2)], "left clipping");
617 assert_eq!(
618 buf[(area.right(), 2)],
619 before[(area.right(), 2)],
620 "right clipping"
621 );
622 assert_eq!(buf[(3, 3)], before[(3, 3)], "the row never wraps");
623 for (index, hitbox) in hitboxes.iter().enumerate() {
624 assert!(hitbox.area.x >= area.x && hitbox.area.right() <= area.right());
625 assert_eq!(hitbox.area.y, area.y);
626 assert_eq!(hitbox.area.height, 1);
627 if index > 0 {
628 assert!(hitboxes[index - 1].area.right() <= hitbox.area.x);
629 }
630 }
631 if width >= 7 {
632 assert_eq!(hitboxes[0].area, Rect::new(3, 2, 7, 1));
633 assert_eq!(buf[(3, 2)].symbol(), "模");
634 assert_eq!(buf[(5, 2)].symbol(), "型");
635 assert_eq!(buf[(8, 2)].symbol(), "e\u{301}");
636 assert_eq!(buf[(9, 2)].symbol(), if ascii { "v" } else { "↓" });
637 }
638 if width >= 20 {
639 assert_eq!(context, Some(Rect::new(13, 2, 10, 1)));
640 assert_eq!(buf[(13, 2)].symbol(), "上");
641 assert_eq!(buf[(15, 2)].symbol(), "下");
642 assert_eq!(buf[(17, 2)].symbol(), "文");
643 assert_eq!(buf[(20, 2)].symbol(), "6");
644 }
645 }
646 }
647 }
648
649 #[test]
650 fn infoline_sanitizes_hidden_controls_before_painting_and_pointer_measurement() {
651 let segments = [
652 InfoSegment::new(
653 InfoSegmentId::Model,
654 "",
655 "model\u{202e}",
656 ChromeInk::Identity,
657 ),
658 InfoSegment::new(
659 InfoSegmentId::Context,
660 "ct\0x",
661 "6\u{1b}1%",
662 ChromeInk::Info,
663 ),
664 InfoSegment::new(
665 InfoSegmentId::OutputTokens,
666 "↓",
667 "1.2K",
668 ChromeInk::MetadataValue,
669 ),
670 ];
671 let area = Rect::new(0, 0, 60, 1);
672 let mut buf = ratatui::buffer::Buffer::empty(area);
673 let info = InfoLine::new(&UI_THEME, "", &segments).ascii_safe(true);
674 let hitboxes = infoline_hitboxes(&info, area);
675 ratatui::widgets::Widget::render(info, area, &mut buf);
676 let row: String = buf.content().iter().map(|cell| cell.symbol()).collect();
677 assert_eq!(row.trim_end(), "model ctx 61% v 1.2K");
678 assert_eq!(
679 hitboxes.iter().map(|hit| hit.area).collect::<Vec<_>>(),
680 [
681 Rect::new(0, 0, 5, 1),
682 Rect::new(8, 0, 7, 1),
683 Rect::new(18, 0, 6, 1),
684 ]
685 );
686 }
687
687 lines RUST