| 1 | //! Whole mounted transcript acceptance against private frozen production code. |
| 2 | use super::*; |
| 3 | use crate::config::Config; |
| 4 | use crate::tui::selection::TranscriptSelectionPoint; |
| 5 | use std::path::PathBuf; |
| 6 | |
| 7 | fn app(locale: Locale, case: usize) -> App { |
| 8 | let mut app = App::new( |
| 9 | crate::test_support::test_tui_options(PathBuf::from(".")), |
| 10 | &Config::default(), |
| 11 | ); |
| 12 | app.launch.visible = false; |
| 13 | app.ui_locale = locale; |
| 14 | app.theme_id = palette::ThemeId::Terminal; |
| 15 | app.ui_theme = palette::TERMINAL_UI_THEME; |
| 16 | app.low_motion = true; |
| 17 | app.fancy_animations = false; |
| 18 | if case == 0 { |
| 19 | return app; |
| 20 | } |
| 21 | app.push_history_cell(HistoryCell::User { |
| 22 | content: "Review this source and its exact source copy".into(), |
| 23 | }); |
| 24 | app.push_history_cell(HistoryCell::Assistant { |
| 25 | content: "A linked [guide](https://example.test/private-target) with 鲸鱼 cafe\u{0301} 1\u{20e3}① and 👩\u{200d}💻.\n\n```rust\nlet source = exact_source;\n```".into(), streaming: case == 2, |
| 26 | }); |
| 27 | if case >= 3 { |
| 28 | app.use_mouse_capture = true; |
| 29 | for n in 0..8 { |
| 30 | app.push_history_cell(HistoryCell::User { |
| 31 | content: format!("User turn {n}"), |
| 32 | }); |
| 33 | app.push_history_cell(HistoryCell::Assistant { |
| 34 | content: format!("Receipt {n} with enough text to wrap in a narrow transcript."), |
| 35 | streaming: false, |
| 36 | }); |
| 37 | } |
| 38 | app.viewport.transcript_scroll = |
| 39 | crate::tui::scrolling::TranscriptScroll::at_line(if case == 4 { 3 } else { 0 }); |
| 40 | } |
| 41 | if case == 4 { |
| 42 | app.viewport.transcript_selection.anchor = Some(TranscriptSelectionPoint { |
| 43 | line_index: 3, |
| 44 | column: 1, |
| 45 | }); |
| 46 | app.viewport.transcript_selection.head = Some(TranscriptSelectionPoint { |
| 47 | line_index: 5, |
| 48 | column: 9, |
| 49 | }); |
| 50 | } |
| 51 | if case == 5 { |
| 52 | // A user theme keeps independent source colors across native chrome. |
| 53 | app.ui_theme.surface_bg = Color::Rgb(13, 29, 47); |
| 54 | app.ui_theme.border = Color::Rgb(61, 79, 101); |
| 55 | app.ui_theme.status_working = Color::Rgb(127, 149, 173); |
| 56 | app.ui_theme.selection_bg = Color::Rgb(181, 197, 211); |
| 57 | app.push_history_cell(HistoryCell::Tool(ToolCell::Generic(GenericToolCell { |
| 58 | name: "read_file".into(), |
| 59 | status: ToolStatus::Success, |
| 60 | input_summary: Some("src/main.rs".into()), |
| 61 | output: Some("kept source and warning details".into()), |
| 62 | prompts: None, |
| 63 | spillover_path: None, |
| 64 | output_summary: None, |
| 65 | is_diff: false, |
| 66 | }))); |
| 67 | } |
| 68 | app |
| 69 | } |
| 70 | fn guarded(area: Rect) -> Buffer { |
| 71 | let mut buf = Buffer::empty(Rect::new(2, 3, area.width + 12, area.height + 8)); |
| 72 | for cell in &mut buf.content { |
| 73 | cell.set_symbol("~") |
| 74 | .set_style(Style::default().bg(Color::Rgb(11, 23, 37))); |
| 75 | } |
| 76 | buf |
| 77 | } |
| 78 | fn geometry(app: &App) -> (Option<Rect>, usize, usize, usize, Option<Rect>) { |
| 79 | ( |
| 80 | app.viewport.last_transcript_area, |
| 81 | app.viewport.last_transcript_top, |
| 82 | app.viewport.last_transcript_visible, |
| 83 | app.viewport.last_transcript_total, |
| 84 | app.viewport.jump_to_latest_button_area, |
| 85 | ) |
| 86 | } |
| 87 | #[test] |
| 88 | fn mounted_transcript_complete_buffer_and_viewport_match_frozen_renderer() { |
| 89 | for &locale in Locale::shipped() { |
| 90 | for case in 0..6 { |
| 91 | let (mut current, mut old) = (app(locale, case), app(locale, case)); |
| 92 | for width in [20, 40, 80, 120] { |
| 93 | for height in [3, 5, 12, 20] { |
| 94 | let area = Rect::new(7, 5, width, height); |
| 95 | let mut actual = guarded(area); |
| 96 | let mut expected = actual.clone(); |
| 97 | legacy_transcript::snapshot(&mut old, area, 0).render(area, &mut expected); |
| 98 | ChatWidget::new_with_ocean_elapsed(&mut current, area, 0) |
| 99 | .render(area, &mut actual); |
| 100 | assert_eq!( |
| 101 | actual, expected, |
| 102 | "locale={locale:?} case={case} area={area:?}" |
| 103 | ); |
| 104 | assert_eq!(geometry(¤t), geometry(&old)); |
| 105 | assert_eq!(current.history.len(), old.history.len()); |
| 106 | assert_eq!( |
| 107 | current.viewport.transcript_cache.line_meta(), |
| 108 | old.viewport.transcript_cache.line_meta() |
| 109 | ); |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | } |
| 115 | #[test] |
| 116 | fn mounted_transcript_stream_resize_and_scroll_lock_preserve_cache_source() { |
| 117 | let (mut current, mut old) = (app(Locale::En, 2), app(Locale::En, 2)); |
| 118 | for (chunk, width, height, top) in [ |
| 119 | ("First source", 80, 12, None), |
| 120 | ("First source plus 鲸鱼\n```rust\nlet x", 20, 5, None), |
| 121 | ( |
| 122 | "First source plus 鲸鱼\n```rust\nlet x = 1;\n```\n[guide](https://example.test/source)", |
| 123 | 40, |
| 124 | 3, |
| 125 | Some(0), |
| 126 | ), |
| 127 | ("Final source and receipt", 80, 20, Some(0)), |
| 128 | ] { |
| 129 | for app in [&mut current, &mut old] { |
| 130 | app.history[1] = HistoryCell::Assistant { |
| 131 | content: chunk.into(), |
| 132 | streaming: chunk != "Final source and receipt", |
| 133 | }; |
| 134 | app.bump_history_cell(1); |
| 135 | if let Some(top) = top { |
| 136 | app.viewport.transcript_scroll = |
| 137 | crate::tui::scrolling::TranscriptScroll::at_line(top); |
| 138 | app.user_scrolled_during_stream = true; |
| 139 | } |
| 140 | } |
| 141 | let area = Rect::new(7, 5, width, height); |
| 142 | let mut actual = guarded(area); |
| 143 | let mut expected = actual.clone(); |
| 144 | legacy_transcript::snapshot(&mut old, area, 0).render(area, &mut expected); |
| 145 | ChatWidget::new_with_ocean_elapsed(&mut current, area, 0).render(area, &mut actual); |
| 146 | assert_eq!(actual, expected); |
| 147 | assert_eq!(geometry(¤t), geometry(&old)); |
| 148 | assert_eq!( |
| 149 | current.user_scrolled_during_stream, |
| 150 | old.user_scrolled_during_stream |
| 151 | ); |
| 152 | assert_eq!( |
| 153 | current.viewport.transcript_cache.lines(), |
| 154 | old.viewport.transcript_cache.lines() |
| 155 | ); |
| 156 | match ¤t.history[1] { |
| 157 | HistoryCell::Assistant { content, .. } => assert_eq!(content, chunk), |
| 158 | _ => panic!("source authority changed"), |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | #[test] |
| 163 | fn mounted_transcript_selection_preserves_native_cjk_keycap_and_style_grammar() { |
| 164 | for text in [ |
| 165 | "中 cafe\u{0301} words", |
| 166 | "1\u{20e3}① 1\u{fe0f}\u{20e3} words", |
| 167 | "👩\u{200d}💻 joined emoji", |
| 168 | ] { |
| 169 | let line = Line::from(vec![ |
| 170 | Span::styled( |
| 171 | text, |
| 172 | Style::default() |
| 173 | .fg(Color::Rgb(31, 43, 59)) |
| 174 | .add_modifier(Modifier::ITALIC), |
| 175 | ), |
| 176 | Span::styled(" tail", Style::default().bg(Color::Rgb(71, 83, 97))), |
| 177 | ]); |
| 178 | for start in 0..8 { |
| 179 | for end in start..12 { |
| 180 | let selection = Style::default() |
| 181 | .bg(Color::Rgb(101, 113, 127)) |
| 182 | .add_modifier(Modifier::REVERSED); |
| 183 | assert_eq!( |
| 184 | codewhale_ratatui::transcript_selected_spans_measured( |
| 185 | &line, |
| 186 | start, |
| 187 | end, |
| 188 | selection, |
| 189 | grapheme_display_width |
| 190 | ), |
| 191 | legacy_transcript::selected_spans(&line, start, end, selection) |
| 192 | ); |
| 193 | } |
| 194 | } |
| 195 | } |
| 196 | } |
| 197 | #[test] |
| 198 | fn mounted_transcript_links_never_hyperlink_jump_chrome_or_paint_targets() { |
| 199 | let mut app = app(Locale::En, 3); |
| 200 | let area = Rect::new(7, 5, 40, 8); |
| 201 | let mut widget = ChatWidget::new_with_ocean_elapsed(&mut app, area, 0); |
| 202 | widget.lines = vec![Line::from("Read this visible guide"); 8]; |
| 203 | widget.transcript_area = area; |
| 204 | widget.scrollbar = Some(codewhale_ratatui::TranscriptScrollFacts { |
| 205 | top: 0, |
| 206 | visible: 8, |
| 207 | total: 20, |
| 208 | }); |
| 209 | widget.jump_to_latest_button = codewhale_ratatui::transcript_jump_rect(area, true); |
| 210 | widget.line_links = vec![ |
| 211 | vec![crate::tui::osc8::LineLink { |
| 212 | col_start: 0, |
| 213 | col_end: 100, |
| 214 | target: "https://example.test/opaque-target".into() |
| 215 | }]; |
| 216 | 8 |
| 217 | ]; |
| 218 | let mut buf = guarded(area); |
| 219 | widget.render(area, &mut buf); |
| 220 | let plan = widget.viewport().plan(area); |
| 221 | let button = plan.jump.unwrap(); |
| 222 | let regions = crate::tui::osc8::FRAME_LINKS.with(|regions| regions.borrow().clone()); |
| 223 | assert!(!regions.is_empty()); |
| 224 | for region in regions { |
| 225 | let rect = Rect::new( |
| 226 | region.col_start, |
| 227 | region.row, |
| 228 | region.col_end - region.col_start + 1, |
| 229 | 1, |
| 230 | ); |
| 231 | assert_eq!(rect.intersection(plan.link_area), rect); |
| 232 | assert!(rect.intersection(button).is_empty()); |
| 233 | } |
| 234 | let shown: String = buf.content.iter().map(|cell| cell.symbol()).collect(); |
| 235 | assert!(!shown.contains("opaque-target")); |
| 236 | assert_eq!(buf[(button.x + 1, button.y + 1)].symbol(), "↓"); |
| 237 | } |
| 238 |