Skip to main content

max / quasi

27.4 KB · 692 lines History Blame Raw
1 //! Host-agnostic routing: a request in, a renderer-agnostic description out.
2 //!
3 //! <!-- wiki: quasi-overview -->
4 //!
5 //! The keystone of the stack. A route answers with what a screen *is*, composed
6 //! from `makeover-layout`'s vocabulary, and the host decides how that becomes
7 //! pixels. Returning markup instead would pin every consumer to a webview and
8 //! hand the terminal and egui renderers an adapter, which is the failure the
9 //! description layer exists to prevent.
10 //!
11 //! Nothing here may import a host crate. `tauri`, `axum` and `wry` are all thin
12 //! adapters written on top of this, never dependencies of it. That rule is what
13 //! keeps a host repriceable per app instead of welded into the view layer, and
14 //! it is what lets the same route serve a desktop protocol handler and an HTTP
15 //! endpoint with no second implementation.
16 //!
17 //! # The shape
18 //!
19 //! ```text
20 //! request (path + params)
21 //! -> router this crate, imports no host crate
22 //! -> description makeover-layout, composed by this crate's Screen
23 //! -> renderer webview | tui | egui
24 //! -> host adapter axum route | Tauri protocol | wry | direct call
25 //! ```
26 //!
27 //! The renderers share no trait, and looking for one is the wrong move: what
28 //! they have in common is the description above them. `quasi_http::Serves` is
29 //! the nearest thing and is not it — that is an HTTP host's contract, which a
30 //! terminal cannot implement and does not need.
31 //!
32 //! [`Renderer`] is not that trait either, and is not a step towards one. It is
33 //! a two-variant classification answering the single question a description
34 //! cannot ask: whether this renderer has to be told what happens without a
35 //! request. See [`Destination::Local`].
36 //!
37 //! ```
38 //! use quasi_router::{Action, Node, Outcome, Request, Response, RouteError, Router, Screen, Slot};
39 //! use quasi_router::layout::{Arrangement, Region};
40 //!
41 //! struct App {
42 //! tasks: Vec<(u32, String, bool)>,
43 //! }
44 //!
45 //! fn show_task(app: &App, request: Request) -> Result<Response, RouteError> {
46 //! let id: u32 = request
47 //! .captures
48 //! .require("id")?
49 //! .parse()
50 //! .map_err(|_| RouteError::not_found("no such task"))?;
51 //! let (_, title, done) = app
52 //! .tasks
53 //! .iter()
54 //! .find(|(t, _, _)| *t == id)
55 //! .ok_or_else(|| RouteError::not_found("no such task"))?;
56 //!
57 //! let mut detail = Slot::new("detail", quasi_router::RegionKind::Pane)
58 //! .with(Node::page(title.clone()));
59 //! if !done {
60 //! detail = detail.with(Node::act(
61 //! "Complete",
62 //! Action::post(format!("/task/{id}/complete")),
63 //! ));
64 //! }
65 //!
66 //! Ok(Screen::list_detail(title.clone(), false).with(detail).into())
67 //! }
68 //!
69 //! fn complete_task(_app: &App, request: Request) -> Result<Response, RouteError> {
70 //! let id = request.captures.require("id")?;
71 //! Ok(Response::fragment(
72 //! "detail",
73 //! Node::text(format!("task {id} is done")),
74 //! ))
75 //! }
76 //!
77 //! let router = Router::<App>::new()
78 //! .get("/task/{id}", show_task)
79 //! .post("/task/{id}/complete", complete_task);
80 //!
81 //! let app = App { tasks: vec![(7, "Write the router".into(), false)] };
82 //! let answer = router.handle(&app, Request::get("/task/7")).unwrap();
83 //! assert!(matches!(answer.outcome, Outcome::Screen(_)));
84 //! ```
85 //!
86 //! # What is settled, and where it is written down
87 //!
88 //! The design lives on the wiki note `quasi-overview`, and the decisions this
89 //! crate implements are numbered there. In short:
90 //!
91 //! - **An action is a route** (2). One address space for reads and writes, and
92 //! the verb separates them. See [`Method`].
93 //! - **The screen tree lives here, not in `makeover-layout`** (3). It will churn
94 //! while the router is proven against a second host, and a route is an address,
95 //! which is the one thing the description layer never names. See [`Screen`].
96 //! - **An opaque region is filled per host** (4). See
97 //! [`RegionKind::Handover`] and [`RegionKind::Ceded`].
98 //! - **The router is sync** (6). See [`Handler`].
99 //! - **A response carries a target, and may carry a notice or a redirect** (7).
100 //! See [`Response`] and [`Outcome`].
101 //! - **`Router<S>`, generic over app state** (8). See [`Router`].
102 //! - **Failure is classified** (9). See [`RouteError`].
103 //!
104 //! # What a handler owes the first paint
105 //!
106 //! `makeover-layout`'s header states the rule ("First paint is final paint"):
107 //! nothing resizes after it is drawn, and nothing stands in for content that has
108 //! not arrived. This crate is the side that can break it, because a handler
109 //! decides what the description knows before a renderer ever sees it.
110 //!
111 //! Two obligations follow, and both are the handler's rather than the renderer's.
112 //!
113 //! **Return the screen filled.** A handler is sync and returns a whole
114 //! [`Screen`], so the data is in hand before any markup exists and there is
115 //! nothing to wait for. `Readiness::Pending` describes a region that changes
116 //! later, and a handler reaching for it on the way out is describing a moment
117 //! that did not happen.
118 //!
119 //! That is a rule about a *first paint*, and [`Outcome::Started`] is not one.
120 //! A write that has been handed off is a moment that did happen: the reader
121 //! pressed something, work is running, and the region it will fill has nothing
122 //! in it yet. What makes the two different is that the screen is already up.
123 //! Answering an arrival with a pending region is describing a wait nobody
124 //! experienced; answering a write with one is the only honest thing to say.
125 //!
126 //! **Pay for the count, or say you never will.** Anywhere the description
127 //! carries an optional measurement, the `Option` is a fact about the query and
128 //! not about the clock. A handler that wants the reader to see a total runs the
129 //! count before it returns; one that will not pay for the count leaves it empty
130 //! permanently and gets a shape that reads honestly without it. Filling it in on
131 //! a later pass is the one thing forbidden, because the number arrives wider
132 //! than the space left for it.
133
134 pub mod chrome;
135 pub mod containment;
136 pub mod error;
137 pub mod frame;
138 mod path;
139 pub mod renderer;
140 pub mod request;
141 pub mod response;
142 pub mod router;
143 pub mod screen;
144 pub mod stage;
145
146 /// The description layer, re-exported.
147 ///
148 /// Every intent a [`Screen`] composes is `makeover-layout`'s, and a consumer
149 /// needs them to build one. Re-exported so that an app and a renderer are
150 /// provably reading the same version of the vocabulary rather than two
151 /// semver-compatible ones that happen to resolve together.
152 pub use makeover_layout as layout;
153
154 pub use crate::chrome::{Band, Binding, Brand, Chrome, Disclose, Panel, Place, Role};
155 pub use crate::containment::{Containment, Element, Level, Of};
156 pub use crate::error::{Class, RouteError};
157 pub use crate::frame::Frame;
158 pub use crate::renderer::Renderer;
159 pub use crate::request::{Method, Params, Request};
160 pub use crate::response::{
161 Address, Anchor, Invalidated, Locating, Message, Outcome, Picked, Response, Sought,
162 safe_file_name,
163 };
164 pub use crate::router::{Handler, Router};
165 pub use crate::screen::{
166 Accepted, Act, Action, Adds, Answer, Bar, CUTOFFS, Candidate, Canvas, Cell, CellKey, Chart,
167 Choice, Choosing, Clock, Column, Consult, Curve, Destination, Discovery, Document, Feed,
168 FeedKind, Field, Figure, Held, Image, Instance, Jump, Meter, Node, Outline, Placed, Prefill,
169 Progress, Prose, Question, Ranked, RegionKind, Repeat, Repeating, Replaces, Rest, Reveal,
170 Richness, Row, Run, Screen, Slot, SocialKind, Table, Tag, ThemeChoice, Trust, folded,
171 folded_by, writable_root_attr,
172 };
173 // `Frame` is deliberately not re-exported here: `crate::frame::Frame` is what a
174 // mount puts around a screen and `chrome::Panel` is a place in it, both older
175 // words for other things. A selective region's member is
176 // `quasi_router::screen::Frame`, spelled through its module.
177 pub use crate::screen::{Body, Picks};
178
179 #[cfg(test)]
180 mod tests {
181 use super::*;
182 use crate::layout::{Arrangement, Tone};
183
184 /// The smallest app state a route can be written against.
185 struct State {
186 greeting: &'static str,
187 }
188
189 fn home(state: &State, _request: Request) -> Result<Response, RouteError> {
190 Ok(Screen::sidebar_content("Home")
191 .with(Slot::new("content", RegionKind::Pane).with(Node::text(state.greeting)))
192 .into())
193 }
194
195 fn new_task(_state: &State, _request: Request) -> Result<Response, RouteError> {
196 Ok(Response::fragment("detail", Node::text("a new task")))
197 }
198
199 // Taken by value because [`Handler`] says so, and a handler that only reads
200 // its parameters is the common case rather than an oversight.
201 #[allow(clippy::needless_pass_by_value)]
202 fn show_task(_state: &State, request: Request) -> Result<Response, RouteError> {
203 let id = request.captures.require("id")?.to_owned();
204 Ok(Response::fragment("detail", Node::text(id)))
205 }
206
207 fn forbidden(_state: &State, _request: Request) -> Result<Response, RouteError> {
208 Err(RouteError::denied("not yours"))
209 }
210
211 fn router() -> Router<State> {
212 // Deliberately registered least-specific-first, so the ordering being
213 // tested is the table's own and not the order of these lines.
214 Router::new()
215 .get("/task/{id}", show_task)
216 .get("/task/new", new_task)
217 .get("/", home)
218 .post("/task/{id}/delete", forbidden)
219 }
220
221 fn state() -> State {
222 State { greeting: "hello" }
223 }
224
225 fn text_of(response: &Response) -> Option<&str> {
226 match &response.outcome {
227 Outcome::Fragment {
228 node: Node::Text { text, .. },
229 ..
230 } => Some(text),
231 _ => None,
232 }
233 }
234
235 #[test]
236 fn a_static_route_beats_a_capture_whatever_the_order() {
237 let answer = router()
238 .handle(&state(), Request::get("/task/new"))
239 .unwrap();
240 assert_eq!(text_of(&answer), Some("a new task"));
241 }
242
243 #[test]
244 fn a_capture_reaches_the_handler() {
245 let answer = router().handle(&state(), Request::get("/task/7")).unwrap();
246 assert_eq!(text_of(&answer), Some("7"));
247 }
248
249 #[test]
250 fn the_path_capture_wins_over_a_supplied_value() {
251 // The path is the address; the body is only what was sent to it.
252 let sent = Params::new().with("id", "9");
253 let answer = router()
254 .handle(&state(), Request::get("/task/7").carrying(sent))
255 .unwrap();
256 assert_eq!(text_of(&answer), Some("7"));
257 }
258
259 #[test]
260 fn a_read_and_a_write_are_different_routes_at_one_address() {
261 let router = router();
262 let missing = router
263 .handle(&state(), Request::post("/task/7"))
264 .unwrap_err();
265 // `1e35bc8a`: not a `NotFound`, which it was until 2026-08-29. The
266 // address is there and the verb is not, and saying the address is gone
267 // is how a crawler drops a page a host is serving.
268 assert_eq!(missing.class, Class::Unsupported);
269 assert_eq!(missing.class.http_status(), 405);
270 assert!(missing.message.contains("another method"));
271 }
272
273 #[test]
274 fn a_verb_that_misses_names_the_verbs_that_would_not_have() {
275 let refused = Router::<State>::new()
276 .get("/pricing", home)
277 .post("/pricing", home)
278 .handle(
279 &state(),
280 Request {
281 method: Method::Delete,
282 ..Request::get("/pricing")
283 },
284 )
285 .unwrap_err();
286
287 assert_eq!(refused.class, Class::Unsupported);
288 assert_eq!(refused.allow, vec![Method::Get, Method::Post]);
289 // The spelling an HTTP host puts in the header, built here so that two
290 // adapters cannot disagree about the separator.
291 assert_eq!(refused.allow_header().as_deref(), Some("GET, POST"));
292 }
293
294 #[test]
295 fn the_allow_list_does_not_depend_on_the_order_routes_were_registered() {
296 let late = Router::<State>::new()
297 .post("/pricing", home)
298 .get("/pricing", home)
299 .handle(
300 &state(),
301 Request {
302 method: Method::Delete,
303 ..Request::get("/pricing")
304 },
305 )
306 .unwrap_err();
307 assert_eq!(late.allow_header().as_deref(), Some("GET, POST"));
308 }
309
310 #[test]
311 fn an_address_that_is_simply_absent_claims_no_verbs() {
312 let missing = router()
313 .handle(&state(), Request::get("/nowhere"))
314 .unwrap_err();
315 // Empty is "no claim", and an adapter reads it as "send no `Allow`".
316 // A 404 that named verbs would be describing a page that is not there.
317 assert!(missing.allow.is_empty());
318 assert!(missing.allow_header().is_none());
319 }
320
321 #[test]
322 fn the_verbs_at_an_address_can_be_asked_for_directly() {
323 assert_eq!(router().verbs_at("/task/new"), vec![Method::Get]);
324 assert_eq!(router().verbs_at("/task/7/delete"), vec![Method::Post]);
325 assert!(router().verbs_at("/nowhere").is_empty());
326 }
327
328 #[test]
329 fn an_unknown_path_says_so_without_mentioning_a_method() {
330 let missing = router()
331 .handle(&state(), Request::get("/nowhere"))
332 .unwrap_err();
333 assert_eq!(missing.class, Class::NotFound);
334 assert!(!missing.message.contains("another method"));
335 }
336
337 #[test]
338 fn a_denial_carries_a_banner_and_a_status() {
339 let denied = router()
340 .handle(&state(), Request::post("/task/7/delete"))
341 .unwrap_err();
342 assert_eq!(denied.class, Class::Denied);
343 assert_eq!(denied.class.http_status(), 403);
344 assert_eq!(denied.notice, layout::Notice::Banner);
345 assert_eq!(denied.tone(), Tone::Warning);
346 assert!(!denied.class.is_ours());
347 }
348
349 #[test]
350 fn a_missing_parameter_is_our_bug_not_the_users() {
351 // Reached only by calling the handler outside the router, which is what
352 // a renderer emitting an unfilled route amounts to.
353 let missing = show_task(&state(), Request::get("/task/7")).unwrap_err();
354 assert_eq!(missing.class, Class::Internal);
355 assert!(missing.class.is_ours());
356 }
357
358 #[test]
359 fn a_screen_answers_with_no_target_and_a_fragment_with_one() {
360 let router = router();
361 let screen = router.handle(&state(), Request::get("/")).unwrap();
362 assert_eq!(screen.target(), None);
363
364 let fragment = router.handle(&state(), Request::get("/task/7")).unwrap();
365 assert_eq!(fragment.target(), Some("detail"));
366 }
367
368 #[test]
369 fn the_route_table_reads_back_most_specific_first() {
370 let router = router();
371 let table: Vec<_> = router.routes().collect();
372 let new_at = table.iter().position(|(_, p)| *p == "/task/new").unwrap();
373 let id_at = table.iter().position(|(_, p)| *p == "/task/{id}").unwrap();
374 assert!(new_at < id_at);
375 assert_eq!(router.len(), 4);
376 }
377
378 #[test]
379 #[should_panic(expected = "registered twice")]
380 fn registering_one_route_twice_is_a_bug() {
381 let _ = Router::<State>::new()
382 .get("/task/{id}", show_task)
383 .get("/task/{id}", show_task);
384 }
385
386 #[test]
387 fn a_slot_is_found_at_any_depth() {
388 let screen = Screen::new("Tabs", Arrangement::list_detail(true)).with(
389 Slot::new("tabs", RegionKind::TabGroup)
390 .with(Node::Region(Slot::new("pane-a", RegionKind::Pane))),
391 );
392 assert!(screen.slot("tabs").is_some());
393 assert!(screen.slot("pane-a").is_some());
394 assert!(screen.slot("pane-b").is_none());
395 }
396
397 #[test]
398 fn a_fragment_replaces_a_top_level_regions_contents() {
399 let mut screen = Screen::sidebar_content("Home").with(
400 Slot::new("content", RegionKind::Pane)
401 .with(Node::text("first"))
402 .with(Node::text("second")),
403 );
404
405 assert!(screen.replace("content", Node::text("after")));
406
407 let slot = screen.slot("content").expect("the region is still there");
408 // Replaced, not appended: a fragment is one region's new contents,
409 // which is the whole reason it can be smaller than a screen.
410 assert_eq!(slot.body, Body::All(vec![Ranked::new(Node::text("after"))]));
411 }
412
413 #[test]
414 fn a_fragment_reaches_a_region_nested_inside_another() {
415 let mut screen = Screen::new("Tabs", Arrangement::list_detail(true)).with(
416 Slot::new("tabs", RegionKind::TabGroup)
417 .with(Node::Region(Slot::new("pane-a", RegionKind::Pane))),
418 );
419
420 assert!(screen.replace("pane-a", Node::text("loaded")));
421
422 let pane = screen.slot("pane-a").expect("the nested region");
423 assert_eq!(
424 pane.body,
425 Body::All(vec![Ranked::new(Node::text("loaded"))])
426 );
427 // The region it is inside keeps its own body, which still holds the
428 // nested region rather than having been replaced by it.
429 let tabs = screen.slot("tabs").expect("the outer region");
430 assert_eq!(tabs.body.len(), 1);
431 }
432
433 #[test]
434 fn a_pending_region_stops_being_pending_when_its_content_arrives() {
435 let mut screen =
436 Screen::sidebar_content("Home").with(Slot::new("content", RegionKind::Pane).pending());
437
438 assert!(screen.replace("content", Node::text("here")));
439
440 let slot = screen.slot("content").expect("the region");
441 assert_eq!(slot.readiness, layout::Readiness::Ready);
442 }
443
444 #[test]
445 fn a_fragment_for_a_region_that_is_not_there_answers_false() {
446 let mut screen =
447 Screen::sidebar_content("Home").with(Slot::new("content", RegionKind::Pane));
448
449 // Not a panic and not a silent no-op: a miss means a route naming a
450 // slot that no longer exists, so the caller is the one that can act on
451 // it -- redraw, or fail a test.
452 assert!(!screen.replace("detail", Node::text("nowhere")));
453 assert!(screen.slot("content").expect("untouched").body.is_empty());
454 }
455
456 #[test]
457 fn an_owned_field_borrows_back_as_the_description_layers_own() {
458 let field = Field::select(
459 "priority",
460 "Priority",
461 vec![Choice::plain("high"), Choice::new("low", "Low")],
462 )
463 .required()
464 .error("pick one");
465
466 field.with_layout(|borrowed| {
467 assert_eq!(borrowed.name, "priority");
468 assert_eq!(borrowed.options.len(), 2);
469 assert_eq!(borrowed.options[1].label, "Low");
470 assert!(borrowed.required);
471 // The description layer's own reading of the same state.
472 assert!(borrowed.invalid());
473 assert!(borrowed.kind.offers_options());
474 });
475 }
476
477 #[test]
478 fn the_opaque_regions_are_the_undescribed_ones() {
479 assert!(RegionKind::Pane.described());
480 assert!(
481 !RegionKind::Handover {
482 name: "day-plan".into()
483 }
484 .described()
485 );
486 }
487
488 #[test]
489 fn a_group_contains_a_section_and_claims_nothing_else() {
490 use crate::containment::{Containment, Element};
491
492 let group = Slot::group("appearance")
493 .with(Node::section("Appearance"))
494 .with(Node::text("Theme"));
495
496 assert_eq!(group.kind, RegionKind::Group);
497 assert!(group.kind.described());
498
499 // The point of the member. A pane is looked into and scrolls; a group
500 // does neither, so a settings screen's four groups are not four wells.
501 assert_eq!(group.kind.depth(), layout::Depth::Flat);
502 assert_eq!(RegionKind::Pane.depth(), layout::Depth::Well);
503
504 // Blocks, by the ladder's third rule, with no special case needed: the
505 // heading and the fields under it are ordinary body nodes.
506 assert_eq!(group.containment(), Containment::Blocks);
507 assert_eq!(group.body.len(), 2);
508 }
509
510 #[test]
511 fn a_screen_nobody_described_is_indexable() {
512 // `bool::default()` is false, so a derived Default here would deindex
513 // every screen that never mentioned the subject. The impl is written
514 // out for exactly this, and this is the assertion that keeps it.
515 assert!(Discovery::default().indexable);
516 assert!(Screen::sidebar_content("Home").discovery.indexable);
517 assert!(
518 !Screen::sidebar_content("Home")
519 .indexed(false)
520 .discovery
521 .indexable
522 );
523 }
524
525 #[test]
526 fn every_social_kind_has_a_distinct_spelling() {
527 // A kind added upstream without a spelling fails here rather than
528 // reaching a crawler as an og:type nothing recognises.
529 let kinds = [
530 SocialKind::Website,
531 SocialKind::Article,
532 SocialKind::Profile,
533 SocialKind::Product,
534 SocialKind::Video,
535 SocialKind::Song,
536 ];
537 let mut seen = Vec::new();
538 for kind in kinds {
539 let spelling = kind.as_str();
540 assert!(!spelling.is_empty(), "{kind:?} spells nothing");
541 assert!(!seen.contains(&spelling), "{spelling} is spelled twice");
542 seen.push(spelling);
543 }
544 assert_eq!(SocialKind::default(), SocialKind::Website);
545 }
546
547 #[test]
548 fn every_feed_kind_has_a_distinct_media_type() {
549 // A kind added without a spelling fails here rather than reaching a
550 // reader as an `application/rss` nothing subscribes to.
551 let kinds = [FeedKind::Rss, FeedKind::Atom, FeedKind::JsonFeed];
552 let mut seen = Vec::new();
553 for kind in kinds {
554 let spelling = kind.media_type();
555 assert!(!spelling.is_empty(), "{kind:?} spells nothing");
556 assert!(!seen.contains(&spelling), "{spelling} is spelled twice");
557 seen.push(spelling);
558 }
559 assert_eq!(FeedKind::default(), FeedKind::Rss);
560 }
561
562 #[test]
563 fn a_screen_says_its_feed_once_and_nobody_says_it_is_offering_one() {
564 // The default has to be no feed, or every screen claims one.
565 assert!(Discovery::default().feed.is_none());
566 let screen = Screen::single("Blog").syndicating(Feed::new(
567 FeedKind::Rss,
568 "Project updates",
569 "/p/thing/feed.xml",
570 ));
571 let feed = screen.discovery.feed.as_ref().expect("said");
572 assert_eq!(feed.kind.media_type(), "application/rss+xml");
573 assert_eq!(feed.title, "Project updates");
574 assert_eq!(feed.href, "/p/thing/feed.xml");
575 }
576
577 #[test]
578 fn a_screen_says_where_the_caret_starts_and_most_screens_say_nothing() {
579 // `None` is nearly every screen, which is every screen the reader
580 // arrives at to read.
581 assert!(Screen::single("Home").opens_at.is_none());
582 let login = Screen::single("Log in").opening_at("email");
583 assert_eq!(login.opens_at.as_deref(), Some("email"));
584 }
585
586 #[test]
587 fn an_overlay_targets_no_region_and_sends_the_user_nowhere() {
588 // The two questions a host asks before it looks for a body. An overlay
589 // answers no to both: it is not aimed at a region, and dismissing it
590 // reveals the screen the user never left.
591 let answer = Response::over(Screen::sidebar_content("Palette"));
592 assert!(answer.target().is_none());
593 assert!(answer.destination().is_none());
594 assert!(matches!(answer.outcome, Outcome::Over(_)));
595 }
596
597 #[test]
598 fn chrome_is_held_beside_the_router_rather_than_arriving_with_an_answer() {
599 // The claim `Chrome` makes, as a type: it is built once and no
600 // `Response` carries one, so an affordance available everywhere cannot
601 // be a fact about one answer.
602 let chrome = Chrome::new().bind("ctrl+k", "Search", Action::get("/palette"));
603 assert_eq!(
604 chrome.bound("ctrl+k").map(|b| b.label.as_str()),
605 Some("Search")
606 );
607 assert_eq!(
608 chrome.bound("ctrl+k").map(|b| &b.action),
609 Some(&Action::get("/palette"))
610 );
611 }
612
613 #[test]
614 fn a_field_asks_for_room_the_way_a_column_does() {
615 use crate::layout::Width;
616
617 // `6d6a9160`, settled 2026-08-16: fill is determined at the description
618 // stage. `Column::width` has carried this fact about a table cell since
619 // the beginning and `Share` carries it about a region, so a leaf control
620 // having no way to say it was an inconsistency rather than a principle.
621 // The default is `Fill`, matching `Column::new`, so the member is
622 // additive: a description written before it existed draws exactly as it
623 // did. `Content` would have been the tidier reading and would have
624 // narrowed every field in every app on the day it landed.
625 let quiet = Field::new(layout::FieldKind::Text, "query", "Search");
626 assert_eq!(quiet.width, Width::Fill);
627 assert_eq!(quiet.width, Column::new("Name").width);
628
629 let sized = quiet.width(Width::Content);
630 assert_eq!(sized.width, Width::Content);
631 }
632
633 /// The reader for the names a repeating question submits under: what a
634 /// handler calls to get back the `Vec` its domain type holds.
635 #[test]
636 fn a_repeating_question_reads_back_as_a_list() {
637 let params = Params::new()
638 .with("title", "Standup")
639 .with("reminder[1]", "900")
640 .with("reminder[0]", "300")
641 .with("reminder[2]", "3600");
642
643 // In slot order, whatever order the host sent them in.
644 assert_eq!(params.repeated("reminder"), ["300", "900", "3600"]);
645 // The rest of the form is untouched by it.
646 assert_eq!(params.get("title"), Some("Standup"));
647 assert!(params.repeated("title").is_empty());
648
649 // A hole is what a browser sends when the reader removed the middle
650 // slot and nothing renumbered, and the answers are still the answers.
651 let holed = Params::new()
652 .with("reminder[0]", "300")
653 .with("reminder[2]", "3600");
654 assert_eq!(holed.repeated("reminder"), ["300", "3600"]);
655
656 // A question nobody answered, which is every slot removed.
657 assert!(Params::new().repeated("reminder").is_empty());
658
659 // And a name that only looks like one belongs to nothing.
660 let near = Params::new().with("reminders", "300");
661 assert!(near.repeated("reminder").is_empty());
662 }
663
664 /// The same reader one level finer, for a slot that is several questions.
665 #[test]
666 fn a_grouped_slot_reads_back_one_part_at_a_time() {
667 let params = Params::new()
668 .with("note", "two files")
669 .with("file[1].name", "b.wav")
670 .with("file[1].size", "8")
671 .with("file[0].name", "a.wav")
672 .with("file[0].size", "4");
673
674 // In slot order, and one part at a time: pairing them by position is
675 // the caller's, because only the caller knows it asked for both.
676 assert_eq!(params.repeated_part("file", "name"), ["a.wav", "b.wav"]);
677 assert_eq!(params.repeated_part("file", "size"), ["4", "8"]);
678
679 // The two readers do not answer each other's names, which is what lets
680 // a grouped question and an ordinary one share a form.
681 assert!(params.repeated("file").is_empty());
682 assert!(params.repeated_part("file", "missing").is_empty());
683 assert_eq!(params.get("note"), Some("two files"));
684
685 // A hole is a slot the reader removed, for `repeated`'s reason.
686 let holed = Params::new()
687 .with("file[0].name", "a.wav")
688 .with("file[2].name", "c.wav");
689 assert_eq!(holed.repeated_part("file", "name"), ["a.wav", "c.wav"]);
690 }
691 }
692