tests/ui_tests.rs 8.1 K raw
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_falls_back_to_the_plain_textarea_while_editing() {
81
    let mut app = test_app();
82
    app.tab = EditorTab::Body;
83
    app.mode = Mode::Insert;
84
    let buf = render(&mut app, 100, 40);
85
86
    assert!(screen(&buf).contains("\"name\""));
87
    assert_eq!(fg_of(&buf, "\"name\""), Some(Color::Reset));
88
}
89
90
#[test]
91
fn response_view_is_syntax_highlighted() {
92
    let mut app = test_app();
93
    app.response = Some(HttpResponse {
94
        status: 200,
95
        reason: "OK".into(),
96
        elapsed: Duration::from_millis(12),
97
        headers: vec![("content-type".into(), "application/json".into())],
98
        body: "{\n  \"id\": \"{{notavar}}\",\n  \"count\": 7\n}".into(),
99
        size: 40,
100
    });
101
    let buf = render(&mut app, 100, 40);
102
103
    assert!(screen(&buf).contains("200 OK · 12ms"));
104
    assert_eq!(fg_of(&buf, "\"id\""), Some(Color::Cyan));
105
    assert_eq!(fg_of(&buf, "7"), Some(Color::Yellow));
106
    // Braces in a response are the server's bytes, not template syntax.
107
    assert_eq!(fg_of(&buf, "\"{{notavar}}\""), Some(Color::Green));
108
}
109
110
#[test]
111
fn xml_response_is_syntax_highlighted() {
112
    let mut app = test_app();
113
    app.response = Some(HttpResponse {
114
        status: 500,
115
        reason: "Internal Server Error".into(),
116
        elapsed: Duration::from_millis(3),
117
        headers: Vec::new(),
118
        body: "<error code=\"500\">boom</error>".into(),
119
        size: 30,
120
    });
121
    let buf = render(&mut app, 100, 40);
122
123
    assert_eq!(fg_of(&buf, "<error"), Some(Color::Blue));
124
    assert_eq!(fg_of(&buf, "code"), Some(Color::Cyan));
125
    assert_eq!(fg_of(&buf, "\"500\""), Some(Color::Green));
126
    assert_eq!(fg_of(&buf, "boom"), Some(Color::Reset));
127
}
128
129
#[test]
130
fn docs_tab_shows_types_options_and_defaults() {
131
    let mut app = test_app();
132
    let req = &mut app.collection.requests[0];
133
    req.description = Some("Adds a pet to the store.".into());
134
    req.docs = vec![
135
        FieldDoc {
136
            name: "status".into(),
137
            location: "query".into(),
138
            ty: "string".into(),
139
            required: true,
140
            options: vec!["available".into(), "pending".into(), "sold".into()],
141
            description: Some("Which pets to return.".into()),
142
            default: Some("available".into()),
143
        },
144
        FieldDoc {
145
            name: "pets[].tag".into(),
146
            location: "body".into(),
147
            ty: "array<string>".into(),
148
            ..FieldDoc::default()
149
        },
150
    ];
151
    app.tab = EditorTab::Docs;
152
    let buf = render(&mut app, 100, 40);
153
    let text = screen(&buf);
154
155
    assert!(text.contains("Adds a pet to the store."), "{text}");
156
    assert!(text.contains("Query params"), "{text}");
157
    assert!(text.contains("status*"), "required marker: {text}");
158
    assert!(
159
        text.contains("one of: available | pending | sold"),
160
        "enum options: {text}"
161
    );
162
    assert!(text.contains("= available"), "default: {text}");
163
    assert!(text.contains("Which pets to return."), "{text}");
164
    assert!(text.contains("Body"), "{text}");
165
    assert!(text.contains("pets[].tag"), "{text}");
166
    assert_eq!(fg_of(&buf, "array<string>"), Some(Color::Yellow));
167
}
168
169
#[test]
170
fn docs_tab_explains_itself_when_there_is_nothing_to_show() {
171
    let mut app = test_app();
172
    app.tab = EditorTab::Docs;
173
    let text = screen(&render(&mut app, 100, 40));
174
    assert!(text.contains("No spec docs for this request"), "{text}");
175
}
176
177
#[test]
178
fn renders_without_panicking_in_edge_cases() {
179
    // Tiny terminal, empty body, long body scrolled to the bottom, help popup.
180
    let mut app = test_app();
181
    app.tab = EditorTab::Body;
182
    render(&mut app, 20, 8);
183
184
    app.set_textarea_text("");
185
    render(&mut app, 100, 40);
186
187
    app.set_textarea_text(&(1..=200).map(|i| format!("[{i}]\n")).collect::<String>());
188
    app.textarea.move_cursor(tui_textarea::CursorMove::Bottom);
189
    render(&mut app, 100, 40);
190
191
    app.tab = EditorTab::Docs;
192
    app.docs_scroll = usize::MAX / 2;
193
    render(&mut app, 100, 40);
194
    render(&mut app, 20, 8);
195
196
    app.popup = cielago::app::Popup::Help;
197
    app.help_scroll = usize::MAX / 2;
198
    render(&mut app, 100, 40);
199
    render(&mut app, 30, 10);
200
201
    // A collection with no requests at all: the sidebar hands ratatui a
202
    // selected index on a zero-row list, and there is nothing to draw in the
203
    // URL bar or editor.
204
    let mut empty = App::new(
205
        Collection::new("empty"),
206
        PathBuf::from("/tmp/cielago-ui-empty.json"),
207
        AppConfig::default(),
208
    );
209
    render(&mut empty, 100, 40);
210
    render(&mut empty, 20, 8);
211
}
212
213
#[test]
214
fn url_edit_prompt_shows_in_the_status_bar() {
215
    let mut app = test_app();
216
    app.start_edit(cielago::app::EditTarget::Url);
217
    let buf = render(&mut app, 100, 40);
218
    assert!(screen(&buf).contains("url (verb path)> POST /pets"));
219
}
220
221
#[test]
222
fn help_lists_duplicate_and_new_collection() {
223
    let mut app = test_app();
224
    app.popup = cielago::app::Popup::Help;
225
    let top = screen(&render(&mut app, 100, 60));
226
    assert!(top.contains("duplicate"));
227
    assert!(top.contains("edit URL"));
228
229
    // The commands live past the fold, so scroll to the bottom for those.
230
    app.help_scroll = usize::MAX / 2;
231
    let bottom = screen(&render(&mut app, 100, 60));
232
    assert!(bottom.contains(":new"));
233
    assert!(bottom.contains(":open"));
234
}
235
236
#[test]
237
fn tmp_new_request_render_shows_get() {
238
    let mut app = test_app();
239
    app.start_edit(cielago::app::EditTarget::NewRequest);
240
    app.input.set("make thing");
241
    app.commit_edit();
242
    let buf = render(&mut app, 100, 40);
243
    let s = screen(&buf);
244
    let line = s.lines().find(|l| l.contains("url (verb path)")).unwrap_or("<none>");
245
    println!("PROMPT LINE: {:?}", line);
246
    assert!(s.contains("url (verb path)> GET"), "screen missing GET prefill");
247
}