tests/ui_tests.rs 8.2 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_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
}