| 1 | //! Render tests: draw the whole UI into an in-memory terminal and inspect the |
| 2 | //! cells. These cover the syntax highlighting, which the keymap tests can't see. |
| 3 | |
| 4 | use std::path::PathBuf; |
| 5 | use std::time::Duration; |
| 6 | |
| 7 | use cielago::app::{App, EditorTab, Mode}; |
| 8 | use cielago::http::HttpResponse; |
| 9 | use cielago::model::{Collection, FieldDoc, Method, SavedRequest}; |
| 10 | use cielago::store::AppConfig; |
| 11 | use cielago::ui; |
| 12 | use ratatui::Terminal; |
| 13 | use ratatui::backend::TestBackend; |
| 14 | use ratatui::buffer::Buffer; |
| 15 | use ratatui::style::Color; |
| 16 | |
| 17 | fn test_app() -> App { |
| 18 | let mut c = Collection::new("test"); |
| 19 | c.servers = vec!["https://one.example.com".into()]; |
| 20 | let mut create = SavedRequest::blank("createPet"); |
| 21 | create.method = Method::Post; |
| 22 | create.path = "/pets".into(); |
| 23 | create.body = |
| 24 | Some("{\n \"name\": \"{{petName}}\",\n \"legs\": 4,\n \"good\": true\n}".into()); |
| 25 | c.requests = vec![create]; |
| 26 | App::new( |
| 27 | c, |
| 28 | PathBuf::from("/tmp/cielago-ui-test.json"), |
| 29 | AppConfig::default(), |
| 30 | ) |
| 31 | } |
| 32 | |
| 33 | fn render(app: &mut App, w: u16, h: u16) -> Buffer { |
| 34 | let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap(); |
| 35 | terminal.draw(|f| ui::draw(f, app)).unwrap(); |
| 36 | terminal.backend().buffer().clone() |
| 37 | } |
| 38 | |
| 39 | fn row_text(buf: &Buffer, y: u16) -> String { |
| 40 | (0..buf.area.width) |
| 41 | .map(|x| buf.cell((x, y)).map(|c| c.symbol()).unwrap_or(" ")) |
| 42 | .collect() |
| 43 | } |
| 44 | |
| 45 | /// Foreground colour of the first cell of `needle` on screen. Rows contain |
| 46 | /// multi-byte box-drawing characters, so the byte offset is converted to a |
| 47 | /// column (one character per cell). |
| 48 | fn fg_of(buf: &Buffer, needle: &str) -> Option<Color> { |
| 49 | for y in 0..buf.area.height { |
| 50 | let row = row_text(buf, y); |
| 51 | if let Some(byte) = row.find(needle) { |
| 52 | let x = row[..byte].chars().count() as u16; |
| 53 | return buf.cell((x, y)).map(|c| c.fg); |
| 54 | } |
| 55 | } |
| 56 | panic!("{needle:?} not on screen"); |
| 57 | } |
| 58 | |
| 59 | fn screen(buf: &Buffer) -> String { |
| 60 | (0..buf.area.height) |
| 61 | .map(|y| row_text(buf, y)) |
| 62 | .collect::<Vec<_>>() |
| 63 | .join("\n") |
| 64 | } |
| 65 | |
| 66 | #[test] |
| 67 | fn body_view_is_syntax_highlighted() { |
| 68 | let mut app = test_app(); |
| 69 | app.tab = EditorTab::Body; |
| 70 | let buf = render(&mut app, 100, 40); |
| 71 | |
| 72 | assert_eq!(fg_of(&buf, "\"name\""), Some(Color::Cyan), "object key"); |
| 73 | assert_eq!(fg_of(&buf, "4"), Some(Color::Yellow), "number"); |
| 74 | assert_eq!(fg_of(&buf, "true"), Some(Color::Magenta), "literal"); |
| 75 | // Variables stand out from the string they sit in. |
| 76 | assert_eq!(fg_of(&buf, "{{petName}}"), Some(Color::Magenta)); |
| 77 | } |
| 78 | |
| 79 | #[test] |
| 80 | fn body_stays_highlighted_and_read_only() { |
| 81 | // There is no in-app body editor โ the body is edited via `$EDITOR`, so it |
| 82 | // renders syntax-highlighted regardless of mode. |
| 83 | let mut app = test_app(); |
| 84 | app.tab = EditorTab::Body; |
| 85 | app.mode = Mode::Insert; |
| 86 | let buf = render(&mut app, 100, 40); |
| 87 | |
| 88 | assert!(screen(&buf).contains("\"name\"")); |
| 89 | assert_eq!(fg_of(&buf, "\"name\""), Some(Color::Cyan)); |
| 90 | } |
| 91 | |
| 92 | #[test] |
| 93 | fn response_view_is_syntax_highlighted() { |
| 94 | let mut app = test_app(); |
| 95 | app.response = Some(HttpResponse { |
| 96 | status: 200, |
| 97 | reason: "OK".into(), |
| 98 | elapsed: Duration::from_millis(12), |
| 99 | headers: vec![("content-type".into(), "application/json".into())], |
| 100 | body: "{\n \"id\": \"{{notavar}}\",\n \"count\": 7\n}".into(), |
| 101 | size: 40, |
| 102 | }); |
| 103 | let buf = render(&mut app, 100, 40); |
| 104 | |
| 105 | assert!(screen(&buf).contains("200 OK ยท 12ms")); |
| 106 | assert_eq!(fg_of(&buf, "\"id\""), Some(Color::Cyan)); |
| 107 | assert_eq!(fg_of(&buf, "7"), Some(Color::Yellow)); |
| 108 | // Braces in a response are the server's bytes, not template syntax. |
| 109 | assert_eq!(fg_of(&buf, "\"{{notavar}}\""), Some(Color::Green)); |
| 110 | } |
| 111 | |
| 112 | #[test] |
| 113 | fn xml_response_is_syntax_highlighted() { |
| 114 | let mut app = test_app(); |
| 115 | app.response = Some(HttpResponse { |
| 116 | status: 500, |
| 117 | reason: "Internal Server Error".into(), |
| 118 | elapsed: Duration::from_millis(3), |
| 119 | headers: Vec::new(), |
| 120 | body: "<error code=\"500\">boom</error>".into(), |
| 121 | size: 30, |
| 122 | }); |
| 123 | let buf = render(&mut app, 100, 40); |
| 124 | |
| 125 | assert_eq!(fg_of(&buf, "<error"), Some(Color::Blue)); |
| 126 | assert_eq!(fg_of(&buf, "code"), Some(Color::Cyan)); |
| 127 | assert_eq!(fg_of(&buf, "\"500\""), Some(Color::Green)); |
| 128 | assert_eq!(fg_of(&buf, "boom"), Some(Color::Reset)); |
| 129 | } |
| 130 | |
| 131 | #[test] |
| 132 | fn docs_tab_shows_types_options_and_defaults() { |
| 133 | let mut app = test_app(); |
| 134 | let req = &mut app.collection.requests[0]; |
| 135 | req.description = Some("Adds a pet to the store.".into()); |
| 136 | req.docs = vec![ |
| 137 | FieldDoc { |
| 138 | name: "status".into(), |
| 139 | location: "query".into(), |
| 140 | ty: "string".into(), |
| 141 | required: true, |
| 142 | options: vec!["available".into(), "pending".into(), "sold".into()], |
| 143 | description: Some("Which pets to return.".into()), |
| 144 | default: Some("available".into()), |
| 145 | }, |
| 146 | FieldDoc { |
| 147 | name: "pets[].tag".into(), |
| 148 | location: "body".into(), |
| 149 | ty: "array<string>".into(), |
| 150 | ..FieldDoc::default() |
| 151 | }, |
| 152 | ]; |
| 153 | app.tab = EditorTab::Docs; |
| 154 | let buf = render(&mut app, 100, 40); |
| 155 | let text = screen(&buf); |
| 156 | |
| 157 | assert!(text.contains("Adds a pet to the store."), "{text}"); |
| 158 | assert!(text.contains("Query params"), "{text}"); |
| 159 | assert!(text.contains("status*"), "required marker: {text}"); |
| 160 | assert!( |
| 161 | text.contains("one of: available | pending | sold"), |
| 162 | "enum options: {text}" |
| 163 | ); |
| 164 | assert!(text.contains("= available"), "default: {text}"); |
| 165 | assert!(text.contains("Which pets to return."), "{text}"); |
| 166 | assert!(text.contains("Body"), "{text}"); |
| 167 | assert!(text.contains("pets[].tag"), "{text}"); |
| 168 | assert_eq!(fg_of(&buf, "array<string>"), Some(Color::Yellow)); |
| 169 | } |
| 170 | |
| 171 | #[test] |
| 172 | fn docs_tab_explains_itself_when_there_is_nothing_to_show() { |
| 173 | let mut app = test_app(); |
| 174 | app.tab = EditorTab::Docs; |
| 175 | let text = screen(&render(&mut app, 100, 40)); |
| 176 | assert!(text.contains("No spec docs for this request"), "{text}"); |
| 177 | } |
| 178 | |
| 179 | #[test] |
| 180 | fn renders_without_panicking_in_edge_cases() { |
| 181 | // Tiny terminal, empty body, long body scrolled to the bottom, help popup. |
| 182 | let mut app = test_app(); |
| 183 | app.tab = EditorTab::Body; |
| 184 | render(&mut app, 20, 8); |
| 185 | |
| 186 | app.set_body_text(""); |
| 187 | render(&mut app, 100, 40); |
| 188 | |
| 189 | app.set_body_text(&(1..=200).map(|i| format!("[{i}]\n")).collect::<String>()); |
| 190 | app.body_scroll = usize::MAX / 2; |
| 191 | render(&mut app, 100, 40); |
| 192 | |
| 193 | app.tab = EditorTab::Docs; |
| 194 | app.docs_scroll = usize::MAX / 2; |
| 195 | render(&mut app, 100, 40); |
| 196 | render(&mut app, 20, 8); |
| 197 | |
| 198 | app.popup = cielago::app::Popup::Help; |
| 199 | app.help_scroll = usize::MAX / 2; |
| 200 | render(&mut app, 100, 40); |
| 201 | render(&mut app, 30, 10); |
| 202 | |
| 203 | // A collection with no requests at all: the sidebar hands ratatui a |
| 204 | // selected index on a zero-row list, and there is nothing to draw in the |
| 205 | // URL bar or editor. |
| 206 | let mut empty = App::new( |
| 207 | Collection::new("empty"), |
| 208 | PathBuf::from("/tmp/cielago-ui-empty.json"), |
| 209 | AppConfig::default(), |
| 210 | ); |
| 211 | render(&mut empty, 100, 40); |
| 212 | render(&mut empty, 20, 8); |
| 213 | } |
| 214 | |
| 215 | #[test] |
| 216 | fn url_edit_prompt_shows_in_the_status_bar() { |
| 217 | let mut app = test_app(); |
| 218 | app.start_edit(cielago::app::EditTarget::Url); |
| 219 | let buf = render(&mut app, 100, 40); |
| 220 | assert!(screen(&buf).contains("url (verb path)> POST /pets")); |
| 221 | } |
| 222 | |
| 223 | #[test] |
| 224 | fn help_lists_duplicate_and_new_collection() { |
| 225 | let mut app = test_app(); |
| 226 | app.popup = cielago::app::Popup::Help; |
| 227 | let top = screen(&render(&mut app, 100, 60)); |
| 228 | assert!(top.contains("duplicate")); |
| 229 | assert!(top.contains("edit URL")); |
| 230 | |
| 231 | // The commands live past the fold, so scroll to the bottom for those. |
| 232 | app.help_scroll = usize::MAX / 2; |
| 233 | let bottom = screen(&render(&mut app, 100, 60)); |
| 234 | assert!(bottom.contains(":new")); |
| 235 | assert!(bottom.contains(":open")); |
| 236 | } |
| 237 | |
| 238 | #[test] |
| 239 | fn tmp_new_request_render_shows_get() { |
| 240 | let mut app = test_app(); |
| 241 | app.start_edit(cielago::app::EditTarget::NewRequest); |
| 242 | app.input.set("make thing"); |
| 243 | app.commit_edit(); |
| 244 | let buf = render(&mut app, 100, 40); |
| 245 | let s = screen(&buf); |
| 246 | let line = s.lines().find(|l| l.contains("url (verb path)")).unwrap_or("<none>"); |
| 247 | println!("PROMPT LINE: {:?}", line); |
| 248 | assert!(s.contains("url (verb path)> GET"), "screen missing GET prefill"); |
| 249 | } |