| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
use makeover_layout as layout; |
| 9 |
use makeover_tui::{Fidelity, Theme}; |
| 10 |
use quasi_router::{ |
| 11 |
Act, Action, Cells, Choice, Column, Field, Figure, Meter, Node, RegionKind, Row, Screen, Slot, |
| 12 |
Tag, |
| 13 |
}; |
| 14 |
use ratatui::buffer::Buffer; |
| 15 |
use ratatui::layout::Rect; |
| 16 |
use ratatui::style::Modifier; |
| 17 |
|
| 18 |
use crate::{Tui, View}; |
| 19 |
|
| 20 |
|
| 21 |
|
| 22 |
|
| 23 |
|
| 24 |
|
| 25 |
|
| 26 |
fn tui() -> Tui { |
| 27 |
let dir = makeover::bundled_themes_dir().expect("makeover ships themes"); |
| 28 |
let colours = makeover::load_theme(&[(dir, false)], "goingson").expect("a bundled theme loads"); |
| 29 |
Tui::new( |
| 30 |
Theme::from_theme(&colours).expect("a shipped theme resolves"), |
| 31 |
Fidelity::TrueColor, |
| 32 |
) |
| 33 |
} |
| 34 |
|
| 35 |
|
| 36 |
fn rows(buf: &Buffer) -> Vec<String> { |
| 37 |
(0..buf.area.height) |
| 38 |
.map(|y| { |
| 39 |
(0..buf.area.width) |
| 40 |
.map(|x| buf[(x, y)].symbol()) |
| 41 |
.collect::<String>() |
| 42 |
.trim_end() |
| 43 |
.to_string() |
| 44 |
}) |
| 45 |
.collect() |
| 46 |
} |
| 47 |
|
| 48 |
|
| 49 |
|
| 50 |
|
| 51 |
|
| 52 |
|
| 53 |
|
| 54 |
fn buffer(node: &Node, width: u16, height: u16) -> Buffer { |
| 55 |
let area = Rect::new(0, 0, width, height); |
| 56 |
let mut buf = Buffer::empty(area); |
| 57 |
tui().node(node, &View::new(), area, &mut buf); |
| 58 |
buf |
| 59 |
} |
| 60 |
|
| 61 |
|
| 62 |
fn drawn(node: &Node, width: u16, height: u16) -> Vec<String> { |
| 63 |
rows(&buffer(node, width, height)) |
| 64 |
} |
| 65 |
|
| 66 |
|
| 67 |
|
| 68 |
|
| 69 |
|
| 70 |
|
| 71 |
|
| 72 |
fn marked(buf: &Buffer, y: u16, modifier: Modifier) -> String { |
| 73 |
(0..buf.area.width) |
| 74 |
.filter(|x| buf[(*x, y)].modifier.contains(modifier)) |
| 75 |
.map(|x| buf[(x, y)].symbol()) |
| 76 |
.collect() |
| 77 |
} |
| 78 |
|
| 79 |
|
| 80 |
|
| 81 |
|
| 82 |
|
| 83 |
fn held(runtime: &Runtime, width: u16, height: u16) -> String { |
| 84 |
let area = Rect::new(0, 0, width, height); |
| 85 |
let mut buf = Buffer::empty(area); |
| 86 |
runtime.draw(&tui(), area, &mut buf); |
| 87 |
rows(&buf).join(" ") |
| 88 |
} |
| 89 |
|
| 90 |
|
| 91 |
|
| 92 |
|
| 93 |
|
| 94 |
|
| 95 |
fn column_of(rows: &[String], text: &str) -> Option<usize> { |
| 96 |
rows.iter().find_map(|row| row.find(text)) |
| 97 |
} |
| 98 |
|
| 99 |
fn shown(screen: &Screen, width: u16, height: u16) -> Vec<String> { |
| 100 |
let area = Rect::new(0, 0, width, height); |
| 101 |
let mut buf = Buffer::empty(area); |
| 102 |
tui().screen(screen, &View::new(), area, &mut buf); |
| 103 |
rows(&buf) |
| 104 |
} |
| 105 |
|
| 106 |
#[test] |
| 107 |
fn a_row_draws_its_run_in_the_order_the_description_says_it() { |
| 108 |
|
| 109 |
|
| 110 |
let out = drawn( |
| 111 |
&Node::list([Row::new("Ship it") |
| 112 |
.token(Tag::badge("beta")) |
| 113 |
.meta("2 files")]), |
| 114 |
40, |
| 115 |
3, |
| 116 |
); |
| 117 |
let line = &out[0]; |
| 118 |
assert!(line.contains("Ship it"), "{out:?}"); |
| 119 |
assert!( |
| 120 |
line.find("beta").unwrap() < line.find("2 files").unwrap(), |
| 121 |
"{out:?}" |
| 122 |
); |
| 123 |
} |
| 124 |
|
| 125 |
#[test] |
| 126 |
fn a_selectable_row_draws_its_tick_and_a_current_row_its_marker() { |
| 127 |
let ticked = drawn(&Node::list([Row::new("One").selectable(true)]), 20, 2); |
| 128 |
assert!(ticked[0].starts_with("[x]"), "{ticked:?}"); |
| 129 |
|
| 130 |
let untickable = drawn(&Node::list([Row::new("One")]), 20, 2); |
| 131 |
assert!(!untickable[0].contains("[x]"), "{untickable:?}"); |
| 132 |
} |
| 133 |
|
| 134 |
#[test] |
| 135 |
fn a_meter_is_a_bar_and_a_reading() { |
| 136 |
let out = drawn(&Node::Meter(Meter::new(3, 6).label("subtasks")), 40, 2); |
| 137 |
assert!(out[0].starts_with("#####-----"), "{out:?}"); |
| 138 |
assert!(out[0].contains("3/6 subtasks"), "{out:?}"); |
| 139 |
} |
| 140 |
|
| 141 |
#[test] |
| 142 |
fn a_figure_puts_the_number_over_what_it_counts() { |
| 143 |
let out = drawn(&Node::Figure(Figure::new("17", "Current streak")), 30, 3); |
| 144 |
assert_eq!(out[0], "17"); |
| 145 |
assert_eq!(out[1], "Current streak"); |
| 146 |
} |
| 147 |
|
| 148 |
#[test] |
| 149 |
fn a_strip_of_figures_stacks_rather_than_sitting_in_a_row() { |
| 150 |
|
| 151 |
|
| 152 |
let out = drawn( |
| 153 |
&Node::stats([Figure::new("17", "Streak"), Figure::new("4", "Today")]), |
| 154 |
30, |
| 155 |
5, |
| 156 |
); |
| 157 |
assert_eq!(out[0], "17"); |
| 158 |
assert_eq!(out[2], "4"); |
| 159 |
} |
| 160 |
|
| 161 |
#[test] |
| 162 |
fn markdown_keeps_its_emphasis_and_loses_its_syntax() { |
| 163 |
|
| 164 |
|
| 165 |
|
| 166 |
|
| 167 |
let buf = buffer(&Node::rich("**ship it** now"), 40, 2); |
| 168 |
let out = rows(&buf); |
| 169 |
assert_eq!(out[0], "ship it now"); |
| 170 |
assert_eq!(marked(&buf, 0, Modifier::BOLD), "ship it"); |
| 171 |
} |
| 172 |
|
| 173 |
#[test] |
| 174 |
fn each_inline_mark_reaches_the_cell_that_has_it() { |
| 175 |
let buf = buffer(&Node::rich("*lean* and ~~gone~~"), 40, 2); |
| 176 |
assert_eq!(rows(&buf)[0], "lean and gone"); |
| 177 |
assert_eq!(marked(&buf, 0, Modifier::ITALIC), "lean"); |
| 178 |
assert_eq!(marked(&buf, 0, Modifier::CROSSED_OUT), "gone"); |
| 179 |
} |
| 180 |
|
| 181 |
#[test] |
| 182 |
fn a_code_span_is_set_into_the_page_rather_than_marked() { |
| 183 |
|
| 184 |
|
| 185 |
|
| 186 |
|
| 187 |
let buf = buffer(&Node::rich("run `cargo build` first"), 40, 2); |
| 188 |
assert_eq!(rows(&buf)[0], "run cargo build first"); |
| 189 |
let prose = buf[(0, 0)].bg; |
| 190 |
let code = buf[(4, 0)].bg; |
| 191 |
assert_ne!(code, prose, "a code span should not sit on the page"); |
| 192 |
assert_eq!(buf[(16, 0)].bg, prose, "and the prose after it should"); |
| 193 |
} |
| 194 |
|
| 195 |
#[test] |
| 196 |
fn a_heading_inside_a_rich_node_is_drawn_heavier_than_the_prose_under_it() { |
| 197 |
|
| 198 |
|
| 199 |
|
| 200 |
let buf = buffer(&Node::rich("# Title\n\nBody."), 40, 4); |
| 201 |
let out = rows(&buf); |
| 202 |
assert_eq!(out[0], "Title"); |
| 203 |
assert_eq!(out[2], "Body."); |
| 204 |
assert_eq!(marked(&buf, 0, Modifier::BOLD), "Title"); |
| 205 |
assert_eq!(marked(&buf, 2, Modifier::BOLD), ""); |
| 206 |
} |
| 207 |
|
| 208 |
#[test] |
| 209 |
fn a_deep_heading_reads_as_the_shallowest_a_terminal_can_tell_apart() { |
| 210 |
|
| 211 |
|
| 212 |
|
| 213 |
let third = buffer(&Node::rich("### Third"), 40, 2); |
| 214 |
let sixth = buffer(&Node::rich("###### Sixth"), 40, 2); |
| 215 |
assert_eq!(third[(0, 0)].fg, sixth[(0, 0)].fg); |
| 216 |
|
| 217 |
let prose = buffer(&Node::rich("Third"), 40, 2); |
| 218 |
assert_ne!(third[(0, 0)].fg, prose[(0, 0)].fg); |
| 219 |
} |
| 220 |
|
| 221 |
#[test] |
| 222 |
fn a_list_gets_the_bullet_the_description_refused_to_carry() { |
| 223 |
|
| 224 |
|
| 225 |
let out = drawn(&Node::rich("- one\n- two"), 40, 4); |
| 226 |
assert_eq!(out[0], "- one"); |
| 227 |
assert_eq!(out[1], "- two"); |
| 228 |
} |
| 229 |
|
| 230 |
#[test] |
| 231 |
fn a_quote_is_marked_in_the_margin_and_only_on_its_first_line() { |
| 232 |
|
| 233 |
|
| 234 |
|
| 235 |
let out = drawn(&Node::rich("> quoted\n\nafter"), 40, 4); |
| 236 |
assert_eq!(out[0], "> quoted"); |
| 237 |
assert_eq!(out[2], "after"); |
| 238 |
} |
| 239 |
|
| 240 |
#[test] |
| 241 |
fn emphasis_inside_a_heading_is_added_to_its_weight_rather_than_swapped_for_it() { |
| 242 |
|
| 243 |
|
| 244 |
let buf = buffer(&Node::rich("## Ship ~~later~~"), 40, 2); |
| 245 |
assert_eq!(rows(&buf)[0], "Ship later"); |
| 246 |
assert_eq!(marked(&buf, 0, Modifier::BOLD), "Ship later"); |
| 247 |
assert_eq!(marked(&buf, 0, Modifier::CROSSED_OUT), "later"); |
| 248 |
} |
| 249 |
|
| 250 |
#[test] |
| 251 |
fn a_rich_block_keeps_the_breaks_the_author_wrote() { |
| 252 |
|
| 253 |
|
| 254 |
|
| 255 |
let out = drawn(&Node::rich("one\n\ntwo"), 40, 4); |
| 256 |
assert_eq!(out[0], "one"); |
| 257 |
assert_eq!(out[1], ""); |
| 258 |
assert_eq!(out[2], "two"); |
| 259 |
} |
| 260 |
|
| 261 |
#[test] |
| 262 |
fn a_rich_node_inside_a_row_keeps_its_emphasis_too() { |
| 263 |
|
| 264 |
|
| 265 |
|
| 266 |
let buf = buffer( |
| 267 |
&Node::list([Row::new("Ship it").part(layout::RowPart::Meta, Node::rich("**now**"))]), |
| 268 |
40, |
| 269 |
3, |
| 270 |
); |
| 271 |
|
| 272 |
|
| 273 |
assert_eq!(rows(&buf)[0], "Ship it now"); |
| 274 |
assert_eq!(marked(&buf, 0, Modifier::BOLD), "now"); |
| 275 |
} |
| 276 |
|
| 277 |
#[test] |
| 278 |
fn a_rich_block_wraps_without_losing_which_words_were_marked() { |
| 279 |
|
| 280 |
|
| 281 |
let buf = buffer(&Node::rich("plain **one two three** plain"), 12, 4); |
| 282 |
let out = rows(&buf); |
| 283 |
assert_eq!(out[0], "plain one"); |
| 284 |
assert_eq!(out[1], "two three"); |
| 285 |
assert_eq!(out[2], "plain"); |
| 286 |
assert_eq!(marked(&buf, 0, Modifier::BOLD), "one"); |
| 287 |
assert_eq!(marked(&buf, 1, Modifier::BOLD), "two three"); |
| 288 |
} |
| 289 |
|
| 290 |
#[test] |
| 291 |
fn a_hidden_field_draws_nothing_at_all() { |
| 292 |
let field = Field::new(layout::FieldKind::Hidden, "token", "Token"); |
| 293 |
let out = drawn(&Node::Field(Box::new(field)), 30, 3); |
| 294 |
assert!(out.iter().all(String::is_empty), "{out:?}"); |
| 295 |
} |
| 296 |
|
| 297 |
#[test] |
| 298 |
fn a_secret_field_has_nothing_to_draw_and_that_is_a_finding() { |
| 299 |
|
| 300 |
|
| 301 |
|
| 302 |
|
| 303 |
|
| 304 |
let field = Field::new(layout::FieldKind::Secret, "password", "Password").value("hunter2"); |
| 305 |
assert_eq!(field.value, None); |
| 306 |
|
| 307 |
let out = drawn(&Node::Field(Box::new(field)), 30, 3); |
| 308 |
assert_eq!(out[0], "Password"); |
| 309 |
assert!(!out[1].contains("hunter2"), "{out:?}"); |
| 310 |
} |
| 311 |
|
| 312 |
#[test] |
| 313 |
fn a_choice_field_marks_the_chosen_option() { |
| 314 |
let field = Field::select( |
| 315 |
"priority", |
| 316 |
"Priority", |
| 317 |
vec![Choice::plain("high"), Choice::plain("low")], |
| 318 |
) |
| 319 |
.value("low"); |
| 320 |
let out = drawn(&Node::Field(Box::new(field)), 30, 4); |
| 321 |
assert_eq!(out[1], "( ) high"); |
| 322 |
assert_eq!(out[2], "(*) low"); |
| 323 |
} |
| 324 |
|
| 325 |
#[test] |
| 326 |
fn an_act_draws_its_key_because_the_description_carries_one() { |
| 327 |
|
| 328 |
|
| 329 |
let act = Act::new("Delete", Action::post("/tasks/1/delete")).key("d"); |
| 330 |
let out = drawn(&Node::Act(act), 30, 2); |
| 331 |
assert!(out[0].contains("Delete"), "{out:?}"); |
| 332 |
assert!(out[0].contains("(d)"), "{out:?}"); |
| 333 |
} |
| 334 |
|
| 335 |
#[test] |
| 336 |
fn a_table_narrows_by_dropping_the_columns_that_said_they_could_go() { |
| 337 |
let table = Node::Table { |
| 338 |
columns: vec![ |
| 339 |
Column::new("Name").priority(layout::Priority::Essential), |
| 340 |
Column::new("Added").priority(layout::Priority::Optional), |
| 341 |
], |
| 342 |
rows: vec![Cells::new(["kick.wav", "2026-08-12"])], |
| 343 |
}; |
| 344 |
|
| 345 |
let wide = drawn(&table, 60, 3); |
| 346 |
assert!(wide[0].contains("Added"), "{wide:?}"); |
| 347 |
|
| 348 |
let narrow = drawn(&table, 14, 3); |
| 349 |
assert!(!narrow[0].contains("Added"), "{narrow:?}"); |
| 350 |
} |
| 351 |
|
| 352 |
#[test] |
| 353 |
fn a_notice_belongs_to_the_screen_and_lands_above_every_region() { |
| 354 |
let screen = Screen::sidebar_content("Tasks") |
| 355 |
.saying(Node::Notice { |
| 356 |
kind: layout::Notice::Banner, |
| 357 |
tone: layout::Tone::Success, |
| 358 |
text: "Saved".into(), |
| 359 |
}) |
| 360 |
.with(Slot::new("main", RegionKind::Pane).with(Node::section("Today"))); |
| 361 |
|
| 362 |
let out = shown(&screen, 40, 6); |
| 363 |
assert_eq!(out[0], "Saved"); |
| 364 |
assert!(out.iter().any(|row| row.contains("Today")), "{out:?}"); |
| 365 |
} |
| 366 |
|
| 367 |
#[test] |
| 368 |
fn a_pending_region_says_so_in_words() { |
| 369 |
let slot = Slot::new("detail", RegionKind::Pane) |
| 370 |
.with(Node::text("Ready")) |
| 371 |
.pending(); |
| 372 |
let out = drawn(&Node::Region(slot), 30, 3); |
| 373 |
assert!(out.iter().any(|row| row.contains("Loading")), "{out:?}"); |
| 374 |
assert!(!out.iter().any(|row| row.contains("Ready")), "{out:?}"); |
| 375 |
} |
| 376 |
|
| 377 |
|
| 378 |
|
| 379 |
|
| 380 |
use crate::focus::{Reach, Spot}; |
| 381 |
use crate::{Key, Runtime, Step}; |
| 382 |
use quasi_router::{Address, Chrome, Message, Method, Outcome, Request, Response, Rest}; |
| 383 |
|
| 384 |
|
| 385 |
fn screen_of(nodes: impl IntoIterator<Item = Node>) -> Screen { |
| 386 |
Screen::sidebar_content("Test").with( |
| 387 |
nodes |
| 388 |
.into_iter() |
| 389 |
.fold(Slot::new("main", RegionKind::Pane), Slot::with), |
| 390 |
) |
| 391 |
} |
| 392 |
|
| 393 |
|
| 394 |
fn calling(step: &Step) -> Option<&str> { |
| 395 |
match step { |
| 396 |
Step::Call(request) => Some(request.path.as_str()), |
| 397 |
_ => None, |
| 398 |
} |
| 399 |
} |
| 400 |
|
| 401 |
#[test] |
| 402 |
fn the_focus_walk_and_the_drawing_count_the_same_things() { |
| 403 |
|
| 404 |
|
| 405 |
|
| 406 |
|
| 407 |
|
| 408 |
let screen = screen_of([ |
| 409 |
Node::Act(Act::new("Save", Action::post("/save"))), |
| 410 |
Node::Act(Act::new("Gone", Action::post("/gone")).disabled()), |
| 411 |
Node::Link { |
| 412 |
text: "Docs".into(), |
| 413 |
action: Action::get("/docs"), |
| 414 |
}, |
| 415 |
Node::field(Field::new(layout::FieldKind::Text, "name", "Name")), |
| 416 |
Node::field(Field::new(layout::FieldKind::Hidden, "csrf", "")), |
| 417 |
Node::Form { |
| 418 |
action: Action::post("/new"), |
| 419 |
submit: "Create".into(), |
| 420 |
fields: vec![ |
| 421 |
Field::new(layout::FieldKind::Text, "title", "Title"), |
| 422 |
Field::new(layout::FieldKind::Secret, "password", "Password"), |
| 423 |
], |
| 424 |
}, |
| 425 |
Node::list([ |
| 426 |
Row::new("Open me").activate(Action::get("/one")), |
| 427 |
Row::new("Just words"), |
| 428 |
]) |
| 429 |
.and_more(Rest::more(Action::get("/more"))), |
| 430 |
Node::Select { |
| 431 |
kind: layout::Selector::Tabs, |
| 432 |
options: vec![ |
| 433 |
(Choice::plain("a"), Some(Action::get("/a"))), |
| 434 |
(Choice::plain("b"), None), |
| 435 |
], |
| 436 |
chosen: Some("a".into()), |
| 437 |
action: None, |
| 438 |
}, |
| 439 |
Node::Table { |
| 440 |
columns: vec![Column::new("Name")], |
| 441 |
rows: vec![Cells::new(["one"]).activate(Action::get("/row"))], |
| 442 |
}, |
| 443 |
]); |
| 444 |
|
| 445 |
let expected = crate::focus::spots(&screen).len(); |
| 446 |
|
| 447 |
|
| 448 |
|
| 449 |
|
| 450 |
|
| 451 |
|
| 452 |
|
| 453 |
|
| 454 |
|
| 455 |
|
| 456 |
|
| 457 |
let area = Rect::new(0, 0, 60, 40); |
| 458 |
let draw = |view: &View| { |
| 459 |
let mut buf = Buffer::empty(area); |
| 460 |
tui().screen(&screen, view, area, &mut buf); |
| 461 |
buf |
| 462 |
}; |
| 463 |
|
| 464 |
let mut past = View::new(); |
| 465 |
past.focus_on(expected, expected + 1); |
| 466 |
let unlit = draw(&past); |
| 467 |
|
| 468 |
for at in 0..expected { |
| 469 |
let mut view = View::new(); |
| 470 |
view.focus_on(at, expected); |
| 471 |
assert_ne!( |
| 472 |
draw(&view), |
| 473 |
unlit, |
| 474 |
"focusing {at} of {expected} changed nothing on the screen" |
| 475 |
); |
| 476 |
} |
| 477 |
} |
| 478 |
|
| 479 |
#[test] |
| 480 |
fn a_secret_field_draws_what_was_typed_and_the_description_never_carries_it() { |
| 481 |
|
| 482 |
|
| 483 |
|
| 484 |
let field = Field::new(layout::FieldKind::Secret, "password", "Password").value("hunter2"); |
| 485 |
assert_eq!(field.value, None, "a secret refuses a described value"); |
| 486 |
|
| 487 |
let node = Node::field(field); |
| 488 |
let area = Rect::new(0, 0, 30, 4); |
| 489 |
|
| 490 |
let mut buf = Buffer::empty(area); |
| 491 |
tui().node(&node, &View::new(), area, &mut buf); |
| 492 |
assert!( |
| 493 |
!rows(&buf).iter().any(|row| row.contains('*')), |
| 494 |
"nothing typed yet" |
| 495 |
); |
| 496 |
|
| 497 |
let mut view = View::new(); |
| 498 |
view.set("password", "hunter2"); |
| 499 |
let mut buf = Buffer::empty(area); |
| 500 |
tui().node(&node, &view, area, &mut buf); |
| 501 |
assert!( |
| 502 |
rows(&buf).iter().any(|row| row.contains("*******")), |
| 503 |
"{:?}", |
| 504 |
rows(&buf) |
| 505 |
); |
| 506 |
} |
| 507 |
|
| 508 |
#[test] |
| 509 |
fn tab_walks_the_screen_and_enter_calls_what_it_lands_on() { |
| 510 |
let mut runtime = Runtime::new(screen_of([ |
| 511 |
Node::Act(Act::new("First", Action::post("/first"))), |
| 512 |
Node::Act(Act::new("Second", Action::post("/second"))), |
| 513 |
])); |
| 514 |
|
| 515 |
assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first")); |
| 516 |
assert_eq!(runtime.key(Key::Tab), Step::Idle); |
| 517 |
assert_eq!(calling(&runtime.key(Key::Enter)), Some("/second")); |
| 518 |
|
| 519 |
runtime.key(Key::Tab); |
| 520 |
assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first")); |
| 521 |
} |
| 522 |
|
| 523 |
#[test] |
| 524 |
fn a_disabled_control_is_drawn_and_never_landed_on() { |
| 525 |
let mut runtime = Runtime::new(screen_of([ |
| 526 |
Node::Act(Act::new("Gone", Action::post("/gone")).disabled()), |
| 527 |
Node::Act(Act::new("Live", Action::post("/live"))), |
| 528 |
])); |
| 529 |
assert_eq!(calling(&runtime.key(Key::Enter)), Some("/live")); |
| 530 |
} |
| 531 |
|
| 532 |
#[test] |
| 533 |
fn the_runtime_starts_on_the_first_reach_and_the_description_gets_no_say() { |
| 534 |
|
| 535 |
|
| 536 |
|
| 537 |
let mut runtime = Runtime::new(screen_of([ |
| 538 |
Node::Act(Act::new("First", Action::post("/first"))), |
| 539 |
Node::Act(Act::new("Second", Action::post("/second"))), |
| 540 |
])); |
| 541 |
assert_eq!(calling(&runtime.key(Key::Enter)), Some("/first")); |
| 542 |
} |
| 543 |
|
| 544 |
#[test] |
| 545 |
fn a_key_the_description_named_reaches_its_control_from_anywhere() { |
| 546 |
|
| 547 |
|
| 548 |
let mut runtime = Runtime::new(screen_of([ |
| 549 |
Node::Act(Act::new("First", Action::post("/first"))), |
| 550 |
Node::Act(Act::new("New", Action::get("/new")).key("n")), |
| 551 |
])); |
| 552 |
assert_eq!(calling(&runtime.key(Key::Char('n'))), Some("/new")); |
| 553 |
|
| 554 |
assert_eq!(runtime.key(Key::Char('z')), Step::Idle); |
| 555 |
} |
| 556 |
|
| 557 |
#[test] |
| 558 |
fn a_control_that_asks_first_is_not_called_until_it_is_answered() { |
| 559 |
let mut runtime = Runtime::new(screen_of([Node::Act( |
| 560 |
Act::new("Delete", Action::post("/delete")).confirm("Delete this?"), |
| 561 |
)])); |
| 562 |
|
| 563 |
assert_eq!( |
| 564 |
runtime.key(Key::Enter), |
| 565 |
Step::Ask("Delete this?".to_string()) |
| 566 |
); |
| 567 |
assert!(runtime.asking()); |
| 568 |
assert_eq!(runtime.key(Key::Char('n')), Step::Idle); |
| 569 |
|
| 570 |
assert!(matches!(runtime.key(Key::Enter), Step::Ask(_))); |
| 571 |
assert_eq!(calling(&runtime.key(Key::Char('y'))), Some("/delete")); |
| 572 |
} |
| 573 |
|
| 574 |
#[test] |
| 575 |
fn typing_fills_a_box_and_a_form_submits_what_is_in_it() { |
| 576 |
let mut runtime = Runtime::new(screen_of([Node::Form { |
| 577 |
action: Action::post("/new"), |
| 578 |
submit: "Create".into(), |
| 579 |
fields: vec![ |
| 580 |
Field::new(layout::FieldKind::Text, "title", "Title"), |
| 581 |
Field::new(layout::FieldKind::Checkbox, "urgent", "Urgent"), |
| 582 |
], |
| 583 |
}])); |
| 584 |
|
| 585 |
assert!(runtime.editing()); |
| 586 |
for ch in "Ship".chars() { |
| 587 |
runtime.key(Key::Char(ch)); |
| 588 |
} |
| 589 |
runtime.key(Key::Backspace); |
| 590 |
|
| 591 |
|
| 592 |
|
| 593 |
runtime.key(Key::Tab); |
| 594 |
runtime.key(Key::Char(' ')); |
| 595 |
runtime.key(Key::Tab); |
| 596 |
|
| 597 |
let Step::Call(request) = runtime.key(Key::Enter) else { |
| 598 |
panic!("the submit calls its route"); |
| 599 |
}; |
| 600 |
assert_eq!(request.path, "/new"); |
| 601 |
assert_eq!(request.method, Method::Post); |
| 602 |
assert_eq!(request.payload.get("title"), Some("Shi")); |
| 603 |
assert_eq!(request.payload.get("urgent"), Some(Node::SELECTED)); |
| 604 |
} |
| 605 |
|
| 606 |
#[test] |
| 607 |
fn an_unticked_box_sends_nothing_the_way_a_browser_sends_nothing() { |
| 608 |
let mut runtime = Runtime::new(screen_of([Node::Form { |
| 609 |
action: Action::post("/new"), |
| 610 |
submit: "Create".into(), |
| 611 |
fields: vec![Field::new(layout::FieldKind::Checkbox, "urgent", "Urgent")], |
| 612 |
}])); |
| 613 |
runtime.key(Key::Tab); |
| 614 |
let Step::Call(request) = runtime.key(Key::Enter) else { |
| 615 |
panic!("the submit calls its route"); |
| 616 |
}; |
| 617 |
assert!(!request.payload.contains("urgent"), "{:?}", request.payload); |
| 618 |
} |
| 619 |
|
| 620 |
#[test] |
| 621 |
fn a_field_that_writes_as_it_changes_writes_on_the_keystroke() { |
| 622 |
|
| 623 |
|
| 624 |
|
| 625 |
|
| 626 |
let mut runtime = Runtime::new(screen_of([Node::field( |
| 627 |
Field::new(layout::FieldKind::Text, "query", "Search").changes(Action::post("/search")), |
| 628 |
)])); |
| 629 |
let Step::Call(request) = runtime.key(Key::Char('a')) else { |
| 630 |
panic!("a change writes"); |
| 631 |
}; |
| 632 |
assert_eq!(request.path, "/search"); |
| 633 |
assert_eq!(request.payload.get("query"), Some("a")); |
| 634 |
} |
| 635 |
|
| 636 |
#[test] |
| 637 |
fn a_screen_is_a_place_and_a_write_is_not() { |
| 638 |
let mut runtime = Runtime::new(screen_of([Node::text("first")])); |
| 639 |
|
| 640 |
|
| 641 |
runtime.apply( |
| 642 |
&Request::get("/two"), |
| 643 |
Response { |
| 644 |
outcome: Outcome::Screen(screen_of([Node::text("second")])), |
| 645 |
notice: None, |
| 646 |
address: None, |
| 647 |
invalidates: Vec::new(), |
| 648 |
}, |
| 649 |
); |
| 650 |
runtime.apply( |
| 651 |
&Request::get("/three"), |
| 652 |
Response { |
| 653 |
outcome: Outcome::Screen(screen_of([Node::text("third")])), |
| 654 |
notice: None, |
| 655 |
address: None, |
| 656 |
invalidates: Vec::new(), |
| 657 |
}, |
| 658 |
); |
| 659 |
|
| 660 |
assert_eq!(calling(&runtime.key(Key::Escape)), Some("/two")); |
| 661 |
|
| 662 |
|
| 663 |
let mut runtime = Runtime::new(screen_of([Node::text("first")])); |
| 664 |
runtime.apply( |
| 665 |
&Request::post("/save"), |
| 666 |
Response { |
| 667 |
outcome: Outcome::Screen(screen_of([Node::text("saved")])), |
| 668 |
notice: None, |
| 669 |
address: None, |
| 670 |
invalidates: Vec::new(), |
| 671 |
}, |
| 672 |
); |
| 673 |
assert_eq!(runtime.key(Key::Escape), Step::Idle); |
| 674 |
} |
| 675 |
|
| 676 |
#[test] |
| 677 |
fn a_response_can_say_it_is_not_a_place_when_the_derivation_would_say_it_is() { |
| 678 |
let mut runtime = Runtime::new(screen_of([Node::text("first")])); |
| 679 |
runtime.apply( |
| 680 |
&Request::get("/transient"), |
| 681 |
Response { |
| 682 |
outcome: Outcome::Screen(screen_of([Node::text("transient")])), |
| 683 |
notice: None, |
| 684 |
address: Some(Address::Unchanged), |
| 685 |
invalidates: Vec::new(), |
| 686 |
}, |
| 687 |
); |
| 688 |
assert_eq!(runtime.key(Key::Escape), Step::Idle); |
| 689 |
} |
| 690 |
|
| 691 |
#[test] |
| 692 |
fn a_fragment_replaces_one_region_and_keeps_the_rest_of_the_screen() { |
| 693 |
let screen = Screen::sidebar_content("Test") |
| 694 |
.with(Slot::new("side", RegionKind::Sidebar).with(Node::text("kept"))) |
| 695 |
.with(Slot::new("main", RegionKind::Pane).with(Node::text("old"))); |
| 696 |
let mut runtime = Runtime::new(screen); |
| 697 |
|
| 698 |
let follow = runtime.apply( |
| 699 |
&Request::post("/change"), |
| 700 |
Response { |
| 701 |
outcome: Outcome::Fragment { |
| 702 |
region: "main".into(), |
| 703 |
node: Node::text("new"), |
| 704 |
}, |
| 705 |
notice: None, |
| 706 |
address: None, |
| 707 |
invalidates: Vec::new(), |
| 708 |
}, |
| 709 |
); |
| 710 |
assert!(follow.is_none()); |
| 711 |
|
| 712 |
let out = shown(runtime.screen(), 40, 8); |
| 713 |
assert!(out.iter().any(|row| row.contains("kept")), "{out:?}"); |
| 714 |
assert!(out.iter().any(|row| row.contains("new")), "{out:?}"); |
| 715 |
assert!(!out.iter().any(|row| row.contains("old")), "{out:?}"); |
| 716 |
} |
| 717 |
|
| 718 |
#[test] |
| 719 |
fn a_fragment_naming_a_region_that_is_not_there_says_so() { |
| 720 |
|
| 721 |
|
| 722 |
|
| 723 |
let mut runtime = Runtime::new(screen_of([Node::text("here")])); |
| 724 |
runtime.apply( |
| 725 |
&Request::post("/change"), |
| 726 |
Response { |
| 727 |
outcome: Outcome::Fragment { |
| 728 |
region: "nowhere".into(), |
| 729 |
node: Node::text("new"), |
| 730 |
}, |
| 731 |
notice: None, |
| 732 |
address: None, |
| 733 |
invalidates: Vec::new(), |
| 734 |
}, |
| 735 |
); |
| 736 |
let out = shown(runtime.screen(), 60, 8); |
| 737 |
assert!(out.iter().any(|row| row.contains("nowhere")), "{out:?}"); |
| 738 |
} |
| 739 |
|
| 740 |
#[test] |
| 741 |
fn going_somewhere_else_is_a_second_request_the_host_performs() { |
| 742 |
let mut runtime = Runtime::new(screen_of([Node::text("here")])); |
| 743 |
let follow = runtime.apply( |
| 744 |
&Request::post("/delete"), |
| 745 |
Response { |
| 746 |
outcome: Outcome::Goto(Action::get("/list")), |
| 747 |
notice: None, |
| 748 |
address: None, |
| 749 |
invalidates: Vec::new(), |
| 750 |
}, |
| 751 |
); |
| 752 |
assert_eq!( |
| 753 |
follow.map(|request| request.path), |
| 754 |
Some("/list".to_string()) |
| 755 |
); |
| 756 |
} |
| 757 |
|
| 758 |
#[test] |
| 759 |
fn what_a_response_says_lands_on_the_screen_it_belongs_to() { |
| 760 |
let mut runtime = Runtime::new(screen_of([Node::text("here")])); |
| 761 |
runtime.apply( |
| 762 |
&Request::post("/save"), |
| 763 |
Response { |
| 764 |
outcome: Outcome::Screen(screen_of([Node::text("after")])), |
| 765 |
notice: Some(Message { |
| 766 |
kind: layout::Notice::Banner, |
| 767 |
tone: layout::Tone::Success, |
| 768 |
text: "Saved".into(), |
| 769 |
undo: None, |
| 770 |
}), |
| 771 |
address: None, |
| 772 |
invalidates: Vec::new(), |
| 773 |
}, |
| 774 |
); |
| 775 |
let out = shown(runtime.screen(), 40, 6); |
| 776 |
assert_eq!(out[0], "Saved"); |
| 777 |
} |
| 778 |
|
| 779 |
#[test] |
| 780 |
fn a_new_screen_forgets_what_was_typed_into_the_old_one() { |
| 781 |
|
| 782 |
|
| 783 |
|
| 784 |
let mut runtime = Runtime::new(screen_of([Node::field(Field::new( |
| 785 |
layout::FieldKind::Text, |
| 786 |
"name", |
| 787 |
"Name", |
| 788 |
))])); |
| 789 |
runtime.key(Key::Char('a')); |
| 790 |
assert_eq!(runtime.view().edit("name"), Some("a")); |
| 791 |
|
| 792 |
runtime.apply( |
| 793 |
&Request::get("/other"), |
| 794 |
Response { |
| 795 |
outcome: Outcome::Screen(screen_of([Node::field(Field::new( |
| 796 |
layout::FieldKind::Text, |
| 797 |
"name", |
| 798 |
"Different question, same name", |
| 799 |
))])), |
| 800 |
notice: None, |
| 801 |
address: None, |
| 802 |
invalidates: Vec::new(), |
| 803 |
}, |
| 804 |
); |
| 805 |
assert_eq!(runtime.view().edit("name"), None); |
| 806 |
} |
| 807 |
|
| 808 |
#[test] |
| 809 |
fn a_scrolled_region_shows_the_rows_under_the_ones_it_started_with() { |
| 810 |
let slot = |
| 811 |
Slot::new("main", RegionKind::Pane).extend((0..10).map(|n| Node::text(format!("row {n}")))); |
| 812 |
let node = Node::Region(slot); |
| 813 |
let area = Rect::new(0, 0, 20, 4); |
| 814 |
|
| 815 |
|
| 816 |
|
| 817 |
let mut buf = Buffer::empty(area); |
| 818 |
tui().node(&node, &View::new(), area, &mut buf); |
| 819 |
assert!(rows(&buf)[1].contains("row 0"), "{:?}", rows(&buf)); |
| 820 |
|
| 821 |
let mut view = View::new(); |
| 822 |
view.scrolled_to("main", 3); |
| 823 |
let mut buf = Buffer::empty(area); |
| 824 |
tui().node(&node, &view, area, &mut buf); |
| 825 |
let out = rows(&buf); |
| 826 |
assert!(out[1].contains("row 3"), "{out:?}"); |
| 827 |
assert!(!out.iter().any(|row| row.contains("row 0")), "{out:?}"); |
| 828 |
} |
| 829 |
|
| 830 |
#[test] |
| 831 |
fn scrolling_stops_at_the_bottom_of_what_there_is() { |
| 832 |
|
| 833 |
|
| 834 |
|
| 835 |
let slot = |
| 836 |
Slot::new("main", RegionKind::Pane).extend((0..6).map(|n| Node::text(format!("row {n}")))); |
| 837 |
let node = Node::Region(slot); |
| 838 |
let area = Rect::new(0, 0, 20, 4); |
| 839 |
|
| 840 |
|
| 841 |
|
| 842 |
|
| 843 |
let mut view = View::new(); |
| 844 |
view.scrolled_to("main", 99); |
| 845 |
let mut buf = Buffer::empty(area); |
| 846 |
tui().node(&node, &view, area, &mut buf); |
| 847 |
let out = rows(&buf); |
| 848 |
assert!(out[1].contains("row 4"), "{out:?}"); |
| 849 |
assert!(out[2].contains("row 5"), "{out:?}"); |
| 850 |
} |
| 851 |
|
| 852 |
#[test] |
| 853 |
fn a_page_key_scrolls_the_region_the_caret_is_in() { |
| 854 |
let screen = Screen::sidebar_content("Test") |
| 855 |
.with( |
| 856 |
Slot::new("side", RegionKind::Sidebar) |
| 857 |
.with(Node::Act(Act::new("Side", Action::get("/side")))), |
| 858 |
) |
| 859 |
.with( |
| 860 |
Slot::new("main", RegionKind::Pane) |
| 861 |
.with(Node::Act(Act::new("Main", Action::get("/main")))), |
| 862 |
); |
| 863 |
let mut runtime = Runtime::new(screen); |
| 864 |
|
| 865 |
runtime.key(Key::PageDown); |
| 866 |
assert!(runtime.view().scroll("side") > 0); |
| 867 |
assert_eq!(runtime.view().scroll("main"), 0); |
| 868 |
|
| 869 |
runtime.key(Key::Tab); |
| 870 |
runtime.key(Key::PageDown); |
| 871 |
assert!(runtime.view().scroll("main") > 0); |
| 872 |
} |
| 873 |
|
| 874 |
#[test] |
| 875 |
fn a_modal_keeps_the_keyboard_until_it_is_gone() { |
| 876 |
|
| 877 |
|
| 878 |
let screen = Screen::sidebar_content("Test") |
| 879 |
.with( |
| 880 |
Slot::new("main", RegionKind::Pane) |
| 881 |
.with(Node::Act(Act::new("Behind", Action::post("/behind")))), |
| 882 |
) |
| 883 |
.with(Slot::new("ask", RegionKind::Modal).with(Node::Act(Act::new( |
| 884 |
"In the dialog", |
| 885 |
Action::post("/dialog"), |
| 886 |
)))); |
| 887 |
let mut runtime = Runtime::new(screen); |
| 888 |
|
| 889 |
assert_eq!(runtime.reaches().len(), 1); |
| 890 |
assert_eq!(calling(&runtime.key(Key::Enter)), Some("/dialog")); |
| 891 |
runtime.key(Key::Tab); |
| 892 |
assert_eq!(calling(&runtime.key(Key::Enter)), Some("/dialog")); |
| 893 |
} |
| 894 |
|
| 895 |
#[test] |
| 896 |
fn an_external_address_is_handed_back_to_the_host() { |
| 897 |
let mut runtime = Runtime::new(screen_of([Node::Act(Act::new( |
| 898 |
"Docs", |
| 899 |
Action::external("https://example.invalid/docs"), |
| 900 |
))])); |
| 901 |
assert_eq!( |
| 902 |
runtime.key(Key::Enter), |
| 903 |
Step::Open("https://example.invalid/docs".to_string()) |
| 904 |
); |
| 905 |
} |
| 906 |
|
| 907 |
#[test] |
| 908 |
fn a_tab_that_is_not_showing_holds_nothing_the_caret_can_reach() { |
| 909 |
|
| 910 |
|
| 911 |
let screen = Screen::list_detail("Test", true) |
| 912 |
.with( |
| 913 |
Slot::new("list", RegionKind::Pane) |
| 914 |
.with(Node::Act(Act::new("Showing", Action::get("/showing")))), |
| 915 |
) |
| 916 |
.with( |
| 917 |
Slot::new("detail", RegionKind::Pane) |
| 918 |
.with(Node::Act(Act::new("Hidden", Action::get("/hidden")))), |
| 919 |
); |
| 920 |
let runtime = Runtime::new(screen); |
| 921 |
assert_eq!(runtime.reaches().len(), 1); |
| 922 |
assert!(matches!( |
| 923 |
runtime.focused(), |
| 924 |
Some(Spot::Act { ref action, .. }) if action.destination.as_str() == "/showing" |
| 925 |
)); |
| 926 |
} |
| 927 |
|
| 928 |
#[test] |
| 929 |
fn a_reach_says_which_region_it_is_in() { |
| 930 |
let screen = Screen::sidebar_content("Test") |
| 931 |
.with( |
| 932 |
Slot::new("side", RegionKind::Sidebar) |
| 933 |
.with(Node::Act(Act::new("Side", Action::get("/side")))), |
| 934 |
) |
| 935 |
.with( |
| 936 |
Slot::new("main", RegionKind::Pane) |
| 937 |
.with(Node::Act(Act::new("Main", Action::get("/main")))), |
| 938 |
); |
| 939 |
let reaches: Vec<String> = crate::focus::reaches(&screen) |
| 940 |
.into_iter() |
| 941 |
.map(|Reach { region, .. }| region) |
| 942 |
.collect(); |
| 943 |
assert_eq!(reaches, vec!["side".to_string(), "main".to_string()]); |
| 944 |
} |
| 945 |
|
| 946 |
#[test] |
| 947 |
fn a_pending_region_holds_nothing_the_caret_can_reach() { |
| 948 |
let screen = Screen::sidebar_content("Test").with( |
| 949 |
Slot::new("main", RegionKind::Pane) |
| 950 |
.with(Node::Act(Act::new("Later", Action::get("/later")))) |
| 951 |
.pending(), |
| 952 |
); |
| 953 |
assert!(crate::focus::spots(&screen).is_empty()); |
| 954 |
} |
| 955 |
|
| 956 |
#[test] |
| 957 |
fn a_row_and_the_controls_on_it_are_two_places_to_stand() { |
| 958 |
let mut runtime = Runtime::new(screen_of([Node::list([Row::new("Open me") |
| 959 |
.activate(Action::get("/open")) |
| 960 |
.act(Act::new("Remove", Action::delete("/remove")))])])); |
| 961 |
|
| 962 |
assert_eq!(calling(&runtime.key(Key::Enter)), Some("/open")); |
| 963 |
runtime.key(Key::Tab); |
| 964 |
assert_eq!(calling(&runtime.key(Key::Enter)), Some("/remove")); |
| 965 |
} |
| 966 |
|
| 967 |
#[test] |
| 968 |
fn ticking_a_row_calls_what_the_description_says_ticking_calls() { |
| 969 |
let mut runtime = Runtime::new(screen_of([Node::list([ |
| 970 |
Row::new("Buy milk").toggling(false, Action::post("/done/1")) |
| 971 |
])])); |
| 972 |
assert_eq!(calling(&runtime.key(Key::Char(' '))), Some("/done/1")); |
| 973 |
} |
| 974 |
|
| 975 |
#[test] |
| 976 |
fn a_field_refuses_the_keystroke_past_the_length_the_description_set() { |
| 977 |
let mut field = Field::new(layout::FieldKind::Text, "code", "Code"); |
| 978 |
field.max_length = Some(3); |
| 979 |
let mut runtime = Runtime::new(screen_of([Node::field(field)])); |
| 980 |
for ch in "abcdef".chars() { |
| 981 |
runtime.key(Key::Char(ch)); |
| 982 |
} |
| 983 |
assert_eq!(runtime.view().edit("code"), Some("abc")); |
| 984 |
} |
| 985 |
|
| 986 |
#[test] |
| 987 |
fn an_invalidated_slot_is_on_the_screen_beside_the_one_that_was_replaced() { |
| 988 |
|
| 989 |
|
| 990 |
|
| 991 |
let mut runtime = Runtime::new( |
| 992 |
Screen::sidebar_content("Tasks") |
| 993 |
.with(Slot::new("row-7", RegionKind::Pane).with(Node::text("Open"))) |
| 994 |
.with(Slot::new("task-count", RegionKind::Band).with(Node::text("5 left"))), |
| 995 |
); |
| 996 |
|
| 997 |
runtime.apply( |
| 998 |
&Request::post("/tasks/7/done"), |
| 999 |
Response::fragment("row-7", Node::text("Done")).also("task-count", Node::text("4 left")), |
| 1000 |
); |
| 1001 |
|
| 1002 |
let out = shown(runtime.screen(), 40, 12); |
| 1003 |
assert!(out.iter().any(|row| row.contains("Done")), "{out:?}"); |
| 1004 |
assert!(out.iter().any(|row| row.contains("4 left")), "{out:?}"); |
| 1005 |
assert!(!out.iter().any(|row| row.contains("5 left")), "{out:?}"); |
| 1006 |
} |
| 1007 |
|
| 1008 |
#[test] |
| 1009 |
fn an_invalidation_naming_no_region_is_reported_with_the_others() { |
| 1010 |
|
| 1011 |
|
| 1012 |
let mut runtime = Runtime::new(screen_of([Node::text("Open")])); |
| 1013 |
|
| 1014 |
runtime.apply( |
| 1015 |
&Request::post("/tasks/7/done"), |
| 1016 |
Response::fragment("main", Node::text("Done")) |
| 1017 |
.also("task-count", Node::text("4")) |
| 1018 |
.also("sidebar-badge", Node::text("4")), |
| 1019 |
); |
| 1020 |
|
| 1021 |
let out = shown(runtime.screen(), 60, 12).join(" "); |
| 1022 |
assert!(out.contains("task-count"), "{out}"); |
| 1023 |
assert!(out.contains("sidebar-badge"), "{out}"); |
| 1024 |
assert!(out.contains("are called"), "{out}"); |
| 1025 |
} |
| 1026 |
|
| 1027 |
#[test] |
| 1028 |
fn two_ticks_and_a_commit_control_send_both_values() { |
| 1029 |
|
| 1030 |
|
| 1031 |
|
| 1032 |
|
| 1033 |
let mut runtime = Runtime::new( |
| 1034 |
Screen::sidebar_content("Mail").selecting("chosen").with( |
| 1035 |
Slot::new("main", RegionKind::Pane) |
| 1036 |
.with(Node::list([ |
| 1037 |
Row::new("First").ticking("m-1", false), |
| 1038 |
Row::new("Second").ticking("m-2", false), |
| 1039 |
Row::new("Third").ticking("m-3", false), |
| 1040 |
])) |
| 1041 |
.with(Node::Act( |
| 1042 |
Act::new("Archive", Action::post("/mail/archive")).over("chosen"), |
| 1043 |
)), |
| 1044 |
), |
| 1045 |
); |
| 1046 |
|
| 1047 |
|
| 1048 |
assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle)); |
| 1049 |
runtime.key(Key::Tab); |
| 1050 |
assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle)); |
| 1051 |
|
| 1052 |
|
| 1053 |
runtime.key(Key::Tab); |
| 1054 |
runtime.key(Key::Tab); |
| 1055 |
let Step::Call(request) = runtime.key(Key::Enter) else { |
| 1056 |
panic!("the commit control calls its route"); |
| 1057 |
}; |
| 1058 |
|
| 1059 |
assert_eq!(request.path, "/mail/archive"); |
| 1060 |
assert_eq!( |
| 1061 |
request |
| 1062 |
.payload |
| 1063 |
.get_all(quasi_router::Node::TICKED) |
| 1064 |
.collect::<Vec<_>>(), |
| 1065 |
["m-1", "m-2"] |
| 1066 |
); |
| 1067 |
} |
| 1068 |
|
| 1069 |
#[test] |
| 1070 |
fn a_tick_is_staged_and_never_a_write() { |
| 1071 |
|
| 1072 |
|
| 1073 |
|
| 1074 |
let mut runtime = Runtime::new( |
| 1075 |
Screen::sidebar_content("Mail").selecting("chosen").with( |
| 1076 |
Slot::new("main", RegionKind::Pane) |
| 1077 |
.with(Node::list([Row::new("First").ticking("m-1", false)])), |
| 1078 |
), |
| 1079 |
); |
| 1080 |
|
| 1081 |
assert!( |
| 1082 |
matches!(runtime.key(Key::Char(' ')), Step::Idle), |
| 1083 |
"a tick calls no route" |
| 1084 |
); |
| 1085 |
let out = held(&runtime, 40, 6); |
| 1086 |
assert!(out.contains("[x]"), "{out}"); |
| 1087 |
} |
| 1088 |
|
| 1089 |
#[test] |
| 1090 |
fn a_tickable_row_that_names_nothing_still_binds_no_key() { |
| 1091 |
|
| 1092 |
|
| 1093 |
|
| 1094 |
|
| 1095 |
let mut runtime = Runtime::new(Screen::sidebar_content("Mail").selecting("chosen").with( |
| 1096 |
Slot::new("main", RegionKind::Pane).with(Node::list([Row::new("First").selectable(false)])), |
| 1097 |
)); |
| 1098 |
|
| 1099 |
assert!(matches!(runtime.key(Key::Char(' ')), Step::Idle)); |
| 1100 |
let out = held(&runtime, 40, 6); |
| 1101 |
assert!(!out.contains("[x]"), "{out}"); |
| 1102 |
} |
| 1103 |
|
| 1104 |
#[test] |
| 1105 |
fn a_described_tick_starts_the_set_off_and_the_user_can_take_it_back() { |
| 1106 |
|
| 1107 |
|
| 1108 |
let mut runtime = Runtime::new( |
| 1109 |
Screen::sidebar_content("Mail").selecting("chosen").with( |
| 1110 |
Slot::new("main", RegionKind::Pane) |
| 1111 |
.with(Node::list([Row::new("First").ticking("m-1", true)])) |
| 1112 |
.with(Node::Act( |
| 1113 |
Act::new("Archive", Action::post("/mail/archive")).over("chosen"), |
| 1114 |
)), |
| 1115 |
), |
| 1116 |
); |
| 1117 |
|
| 1118 |
let out = held(&runtime, 40, 6); |
| 1119 |
assert!(out.contains("[x]"), "{out}"); |
| 1120 |
|
| 1121 |
|
| 1122 |
runtime.key(Key::Char(' ')); |
| 1123 |
runtime.key(Key::Tab); |
| 1124 |
let Step::Call(request) = runtime.key(Key::Enter) else { |
| 1125 |
panic!("the commit control calls its route"); |
| 1126 |
}; |
| 1127 |
assert_eq!( |
| 1128 |
request.payload.get_all(quasi_router::Node::TICKED).count(), |
| 1129 |
0 |
| 1130 |
); |
| 1131 |
} |
| 1132 |
|
| 1133 |
#[test] |
| 1134 |
fn a_terminal_and_a_webview_agree_about_a_screens_proportions() { |
| 1135 |
|
| 1136 |
|
| 1137 |
|
| 1138 |
let screen = Screen::sidebar_content("Mail") |
| 1139 |
.with(Slot::new("side", RegionKind::Sidebar).with(Node::text("Folders"))) |
| 1140 |
.with(Slot::new("main", RegionKind::Pane).with(Node::text("Messages"))); |
| 1141 |
|
| 1142 |
|
| 1143 |
|
| 1144 |
assert_eq!(screen.arrangement.share().as_percent(), 25); |
| 1145 |
assert_eq!(screen.arrangement.share().of(100), 25); |
| 1146 |
|
| 1147 |
|
| 1148 |
|
| 1149 |
let out = shown(&screen, 100, 6); |
| 1150 |
assert!(column_of(&out, "Folders") == Some(0), "{out:?}"); |
| 1151 |
assert!( |
| 1152 |
column_of(&out, "Messages").is_some_and(|at| at >= 25), |
| 1153 |
"the content should start after the sidebar's quarter: {out:?}" |
| 1154 |
); |
| 1155 |
} |
| 1156 |
|
| 1157 |
#[test] |
| 1158 |
fn a_narrower_share_moves_the_boundary_on_the_terminal_too() { |
| 1159 |
let screen = Screen::new( |
| 1160 |
"Mail", |
| 1161 |
layout::Arrangement::sidebar_content().with_share(layout::Share::percent(10)), |
| 1162 |
) |
| 1163 |
.with(Slot::new("side", RegionKind::Sidebar).with(Node::text("F"))) |
| 1164 |
.with(Slot::new("main", RegionKind::Pane).with(Node::text("Messages"))); |
| 1165 |
|
| 1166 |
let out = shown(&screen, 100, 6); |
| 1167 |
assert!( |
| 1168 |
column_of(&out, "Messages").is_some_and(|at| (10..25).contains(&at)), |
| 1169 |
"the boundary should follow the described share: {out:?}" |
| 1170 |
); |
| 1171 |
} |
| 1172 |
|
| 1173 |
#[test] |
| 1174 |
fn a_reading_measure_narrows_the_screen_and_centres_it() { |
| 1175 |
|
| 1176 |
|
| 1177 |
|
| 1178 |
let wide = Screen::sidebar_content("Doc") |
| 1179 |
.with(Slot::new("main", RegionKind::Pane).with(Node::text("Words"))); |
| 1180 |
let reading = wide.clone().measured(layout::Measure::Reading); |
| 1181 |
|
| 1182 |
let full = shown(&wide, 120, 4); |
| 1183 |
let narrowed = shown(&reading, 120, 4); |
| 1184 |
|
| 1185 |
let at = |rows: &[String]| column_of(rows, "Words"); |
| 1186 |
assert!( |
| 1187 |
at(&narrowed) > at(&full), |
| 1188 |
"a narrowed screen is centred, so its content starts further in: \ |
| 1189 |
{full:?} then {narrowed:?}" |
| 1190 |
); |
| 1191 |
} |
| 1192 |
|
| 1193 |
#[test] |
| 1194 |
fn a_terminal_narrower_than_the_measure_is_left_alone() { |
| 1195 |
|
| 1196 |
|
| 1197 |
let screen = Screen::sidebar_content("Doc") |
| 1198 |
.with(Slot::new("main", RegionKind::Pane).with(Node::text("Words"))) |
| 1199 |
.measured(layout::Measure::Reading); |
| 1200 |
|
| 1201 |
let out = shown(&screen, 40, 4); |
| 1202 |
|
| 1203 |
|
| 1204 |
assert!( |
| 1205 |
column_of(&out, "Words").is_some_and(|at| at < 15), |
| 1206 |
"{out:?}" |
| 1207 |
); |
| 1208 |
} |
| 1209 |
|
| 1210 |
#[test] |
| 1211 |
fn an_app_binding_fires_from_a_screen_that_knows_nothing_about_it() { |
| 1212 |
|
| 1213 |
|
| 1214 |
let mut runtime = Runtime::new(screen_of([Node::text("anywhere")])) |
| 1215 |
.with_chrome(Chrome::new().bind("k", "Search", Action::get("/palette"))); |
| 1216 |
assert_eq!(calling(&runtime.key(Key::Char('k'))), Some("/palette")); |
| 1217 |
} |
| 1218 |
|
| 1219 |
#[test] |
| 1220 |
fn an_app_binding_beats_a_control_that_wanted_the_same_key() { |
| 1221 |
|
| 1222 |
|
| 1223 |
let mut runtime = Runtime::new(screen_of([Node::Act( |
| 1224 |
Act::new("New", Action::get("/new")).key("k"), |
| 1225 |
)])) |
| 1226 |
.with_chrome(Chrome::new().bind("k", "Search", Action::get("/palette"))); |
| 1227 |
assert_eq!(calling(&runtime.key(Key::Char('k'))), Some("/palette")); |
| 1228 |
} |
| 1229 |
|
| 1230 |
#[test] |
| 1231 |
fn a_field_keeps_the_printable_key_a_binding_wanted() { |
| 1232 |
|
| 1233 |
|
| 1234 |
let mut runtime = Runtime::new(screen_of([Node::Field(Box::new(Field::new( |
| 1235 |
layout::FieldKind::Text, |
| 1236 |
"q", |
| 1237 |
"Query", |
| 1238 |
)))])) |
| 1239 |
.with_chrome(Chrome::new().bind("k", "Search", Action::get("/palette"))); |
| 1240 |
assert!(matches!(runtime.key(Key::Char('k')), Step::Idle)); |
| 1241 |
|
| 1242 |
|
| 1243 |
let drawn = held(&runtime, 40, 6); |
| 1244 |
assert!(drawn.contains('k'), "{drawn:?}"); |
| 1245 |
} |
| 1246 |
|
| 1247 |
#[test] |
| 1248 |
fn an_overlay_is_not_a_place_and_dismissing_it_reveals_what_was_under_it() { |
| 1249 |
let mut runtime = Runtime::new(screen_of([Node::text("underneath")])); |
| 1250 |
|
| 1251 |
|
| 1252 |
|
| 1253 |
for path in ["/two", "/three"] { |
| 1254 |
runtime.apply( |
| 1255 |
&Request::get(path), |
| 1256 |
Response { |
| 1257 |
outcome: Outcome::Screen(screen_of([Node::text("a place")])), |
| 1258 |
notice: None, |
| 1259 |
address: None, |
| 1260 |
invalidates: Vec::new(), |
| 1261 |
}, |
| 1262 |
); |
| 1263 |
} |
| 1264 |
|
| 1265 |
runtime.apply( |
| 1266 |
&Request::get("/palette"), |
| 1267 |
Response { |
| 1268 |
outcome: Outcome::Over(screen_of([Node::text("palette")])), |
| 1269 |
notice: None, |
| 1270 |
address: None, |
| 1271 |
invalidates: Vec::new(), |
| 1272 |
}, |
| 1273 |
); |
| 1274 |
assert!(runtime.overlaid()); |
| 1275 |
|
| 1276 |
|
| 1277 |
assert!(matches!(runtime.key(Key::Escape), Step::Idle)); |
| 1278 |
assert!(!runtime.overlaid()); |
| 1279 |
|
| 1280 |
assert_eq!(calling(&runtime.key(Key::Escape)), Some("/two")); |
| 1281 |
} |
| 1282 |
|
| 1283 |
#[test] |
| 1284 |
fn dismissing_an_overlay_leaves_the_screen_under_it_exactly_as_it_was() { |
| 1285 |
|
| 1286 |
|
| 1287 |
let mut runtime = Runtime::new(screen_of([ |
| 1288 |
Node::Field(Box::new(Field::new( |
| 1289 |
layout::FieldKind::Text, |
| 1290 |
"title", |
| 1291 |
"Title", |
| 1292 |
))), |
| 1293 |
Node::Act(Act::new("Save", Action::post("/save"))), |
| 1294 |
])); |
| 1295 |
runtime.key(Key::Char('h')); |
| 1296 |
runtime.key(Key::Char('i')); |
| 1297 |
|
| 1298 |
runtime.key(Key::Tab); |
| 1299 |
|
| 1300 |
runtime.apply( |
| 1301 |
&Request::get("/palette"), |
| 1302 |
Response { |
| 1303 |
outcome: Outcome::Over(screen_of([Node::Field(Box::new(Field::new( |
| 1304 |
layout::FieldKind::Text, |
| 1305 |
"q", |
| 1306 |
"Query", |
| 1307 |
)))])), |
| 1308 |
notice: None, |
| 1309 |
address: None, |
| 1310 |
invalidates: Vec::new(), |
| 1311 |
}, |
| 1312 |
); |
| 1313 |
|
| 1314 |
runtime.key(Key::Char('z')); |
| 1315 |
runtime.key(Key::Escape); |
| 1316 |
|
| 1317 |
|
| 1318 |
|
| 1319 |
assert_eq!(calling(&runtime.key(Key::Enter)), Some("/save")); |
| 1320 |
let drawn = held(&runtime, 40, 8); |
| 1321 |
assert!( |
| 1322 |
drawn.contains("hi"), |
| 1323 |
"the typing survived the overlay: {drawn:?}" |
| 1324 |
); |
| 1325 |
assert!( |
| 1326 |
!drawn.contains('z'), |
| 1327 |
"the overlay's typing stayed in the overlay: {drawn:?}" |
| 1328 |
); |
| 1329 |
} |
| 1330 |
|
| 1331 |
#[test] |
| 1332 |
fn a_navigation_takes_the_overlay_with_it() { |
| 1333 |
|
| 1334 |
|
| 1335 |
let mut runtime = Runtime::new(screen_of([Node::text("first")])); |
| 1336 |
runtime.apply( |
| 1337 |
&Request::get("/palette"), |
| 1338 |
Response { |
| 1339 |
outcome: Outcome::Over(screen_of([Node::text("palette")])), |
| 1340 |
notice: None, |
| 1341 |
address: None, |
| 1342 |
invalidates: Vec::new(), |
| 1343 |
}, |
| 1344 |
); |
| 1345 |
runtime.apply( |
| 1346 |
&Request::get("/two"), |
| 1347 |
Response { |
| 1348 |
outcome: Outcome::Screen(screen_of([Node::text("second")])), |
| 1349 |
notice: None, |
| 1350 |
address: None, |
| 1351 |
invalidates: Vec::new(), |
| 1352 |
}, |
| 1353 |
); |
| 1354 |
assert!(!runtime.overlaid()); |
| 1355 |
} |
| 1356 |
|
| 1357 |
|
| 1358 |
fn gallery() -> Slot { |
| 1359 |
Slot::widget("shots", "carousel") |
| 1360 |
.extend((0..3).map(|n| { |
| 1361 |
Node::Image(quasi_router::Picture::new( |
| 1362 |
format!("/shot-{n}.png"), |
| 1363 |
format!("shot {n}"), |
| 1364 |
)) |
| 1365 |
})) |
| 1366 |
.showing_one(0) |
| 1367 |
} |
| 1368 |
|
| 1369 |
|
| 1370 |
fn with_view(screen: &Screen, view: &View, width: u16, height: u16) -> Vec<String> { |
| 1371 |
let area = Rect::new(0, 0, width, height); |
| 1372 |
let mut buf = Buffer::empty(area); |
| 1373 |
tui().screen(screen, view, area, &mut buf); |
| 1374 |
rows(&buf) |
| 1375 |
} |
| 1376 |
|
| 1377 |
#[test] |
| 1378 |
fn a_carousel_is_one_frame_and_a_row_rather_than_a_stack() { |
| 1379 |
|
| 1380 |
|
| 1381 |
|
| 1382 |
let screen = Screen::list_detail("Product", false).with(gallery()); |
| 1383 |
let out = shown(&screen, 90, 12).join("\n"); |
| 1384 |
|
| 1385 |
assert!(out.contains("shot 0"), "{out}"); |
| 1386 |
assert!(!out.contains("shot 1"), "{out}"); |
| 1387 |
assert!(out.contains("< Prev >"), "{out}"); |
| 1388 |
assert!(out.contains("1 / 3"), "{out}"); |
| 1389 |
assert!(out.contains("< Next >"), "{out}"); |
| 1390 |
} |
| 1391 |
|
| 1392 |
#[test] |
| 1393 |
fn the_row_is_under_the_frame_and_overlays_nothing() { |
| 1394 |
|
| 1395 |
|
| 1396 |
|
| 1397 |
let screen = Screen::list_detail("Product", false).with(gallery()); |
| 1398 |
let out = shown(&screen, 90, 12); |
| 1399 |
|
| 1400 |
let frame = out.iter().position(|row| row.contains("shot 0")); |
| 1401 |
let row = out.iter().position(|row| row.contains("< Prev >")); |
| 1402 |
assert!(frame < row, "{out:?}"); |
| 1403 |
} |
| 1404 |
|
| 1405 |
#[test] |
| 1406 |
fn a_terminal_draws_the_same_chrome_without_knowing_what_a_carousel_is() { |
| 1407 |
|
| 1408 |
|
| 1409 |
let named = Screen::list_detail("Product", false).with(gallery()); |
| 1410 |
let mut other = gallery(); |
| 1411 |
other.kind = RegionKind::Widget { |
| 1412 |
name: "lookbook".into(), |
| 1413 |
}; |
| 1414 |
let unnamed = Screen::list_detail("Product", false).with(other); |
| 1415 |
|
| 1416 |
assert_eq!(shown(&named, 90, 12), shown(&unnamed, 90, 12)); |
| 1417 |
} |
| 1418 |
|
| 1419 |
#[test] |
| 1420 |
fn the_arrow_keys_move_a_carousel_the_caret_can_never_be_inside() { |
| 1421 |
|
| 1422 |
|
| 1423 |
|
| 1424 |
let screen = Screen::list_detail("Product", false).with(gallery()); |
| 1425 |
let mut view = View::new(); |
| 1426 |
|
| 1427 |
let slot = screen.slots[0] |
| 1428 |
.find("shots") |
| 1429 |
.expect("the carousel is there"); |
| 1430 |
view.show_by(slot, 1); |
| 1431 |
let out = with_view(&screen, &view, 90, 12).join("\n"); |
| 1432 |
|
| 1433 |
assert!(out.contains("shot 1"), "{out}"); |
| 1434 |
assert!(out.contains("2 / 3"), "{out}"); |
| 1435 |
} |
| 1436 |
|
| 1437 |
#[test] |
| 1438 |
fn moving_past_the_last_frame_wraps() { |
| 1439 |
|
| 1440 |
|
| 1441 |
let screen = Screen::list_detail("Product", false).with(gallery()); |
| 1442 |
let slot = screen.slots[0] |
| 1443 |
.find("shots") |
| 1444 |
.expect("the carousel is there"); |
| 1445 |
let mut view = View::new(); |
| 1446 |
|
| 1447 |
view.show_by(slot, -1); |
| 1448 |
assert_eq!(view.shown(slot), Some(2)); |
| 1449 |
view.show_by(slot, 1); |
| 1450 |
assert_eq!(view.shown(slot), Some(0)); |
| 1451 |
} |
| 1452 |
|
| 1453 |
#[test] |
| 1454 |
fn labelled_children_draw_a_strip_above_the_pane_it_opens() { |
| 1455 |
|
| 1456 |
|
| 1457 |
|
| 1458 |
let screen = Screen::list_detail("Project", false).with( |
| 1459 |
Slot::new("detail", RegionKind::TabGroup) |
| 1460 |
.with(Node::Region( |
| 1461 |
Slot::new("overview", RegionKind::Pane) |
| 1462 |
.label("Overview") |
| 1463 |
.with(Node::text("the summary")), |
| 1464 |
)) |
| 1465 |
.with(Node::Region( |
| 1466 |
Slot::new("files", RegionKind::Pane) |
| 1467 |
.label("Files") |
| 1468 |
.with(Node::text("the files")), |
| 1469 |
)) |
| 1470 |
.showing_one(1), |
| 1471 |
); |
| 1472 |
let out = shown(&screen, 60, 16); |
| 1473 |
let joined = out.join("\n"); |
| 1474 |
|
| 1475 |
assert!(joined.contains("Overview"), "{joined}"); |
| 1476 |
assert!(joined.contains("Files"), "{joined}"); |
| 1477 |
|
| 1478 |
assert!(joined.contains("the files"), "{joined}"); |
| 1479 |
assert!(!joined.contains("the summary"), "{joined}"); |
| 1480 |
|
| 1481 |
assert!(!joined.contains("< Prev >"), "{joined}"); |
| 1482 |
|
| 1483 |
let strip = out.iter().position(|row| row.contains("Overview")); |
| 1484 |
let pane = out.iter().position(|row| row.contains("the files")); |
| 1485 |
assert!(strip < pane, "{out:?}"); |
| 1486 |
} |
| 1487 |
|
| 1488 |
#[test] |
| 1489 |
fn a_closed_disclosure_draws_its_name_and_nothing_under_it() { |
| 1490 |
|
| 1491 |
let disclosure = |shown: Option<usize>| { |
| 1492 |
Screen::list_detail("Item", false).with( |
| 1493 |
Slot::widget("more", "disclosure") |
| 1494 |
.with(Node::Region( |
| 1495 |
Slot::new("body", RegionKind::Pane) |
| 1496 |
.label("Technical details") |
| 1497 |
.with(Node::text("the rest")), |
| 1498 |
)) |
| 1499 |
.showing_at_most_one(shown), |
| 1500 |
) |
| 1501 |
}; |
| 1502 |
|
| 1503 |
let closed = shown(&disclosure(None), 60, 12).join("\n"); |
| 1504 |
assert!(closed.contains("Technical details"), "{closed}"); |
| 1505 |
assert!(!closed.contains("the rest"), "{closed}"); |
| 1506 |
|
| 1507 |
let open = shown(&disclosure(Some(0)), 60, 12).join("\n"); |
| 1508 |
assert!(open.contains("the rest"), "{open}"); |
| 1509 |
} |
| 1510 |
|
| 1511 |
#[test] |
| 1512 |
fn a_region_showing_everything_draws_what_it_always_drew() { |
| 1513 |
|
| 1514 |
|
| 1515 |
let screen = Screen::list_detail("Tasks", false).with( |
| 1516 |
Slot::new("main", RegionKind::Pane) |
| 1517 |
.with(Node::text("first")) |
| 1518 |
.with(Node::text("second")), |
| 1519 |
); |
| 1520 |
let out = shown(&screen, 40, 12).join("\n"); |
| 1521 |
|
| 1522 |
assert!(out.contains("first") && out.contains("second"), "{out}"); |
| 1523 |
assert!(!out.contains("< Prev >"), "{out}"); |
| 1524 |
} |
| 1525 |
|