max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
5 files changed,
+345 insertions,
-18 deletions
| @@ -70,15 +70,38 @@ | |||
| 70 | 70 | use axum::extract::Request; | |
| 71 | 71 | use axum::response::Response as HttpResponse; | |
| 72 | 72 | use quasi_http::Refusal; | |
| 73 | - | use quasi_router::{RouteError, Router}; | |
| 73 | + | use quasi_router::{Params, Response, RouteError, Router}; | |
| 74 | 74 | ||
| 75 | 75 | pub use quasi_http::{DEFAULT_BODY_LIMIT, Render, htmx}; | |
| 76 | 76 | ||
| 77 | + | /// Builds the renderer for one request. | |
| 78 | + | /// | |
| 79 | + | /// It is handed the state, the request's parameters and the answer the router | |
| 80 | + | /// gave, and returns the renderer that answer is rendered with. The | |
| 81 | + | /// [`Response`] is there so the factory can walk the screen and do work only | |
| 82 | + | /// for the regions that are actually on it: a factory that highlighted a blob | |
| 83 | + | /// without looking would run syntect for every request that never shows a file. | |
| 84 | + | /// | |
| 85 | + | /// `None` is the router refusing. There is no screen, the answer is a notice | |
| 86 | + | /// fragment, and a renderer built for it has nothing per-request to say. | |
| 87 | + | pub type RenderFactory<S, R> = dyn Fn(&S, &Params, Option<&Response>) -> R + Send + Sync; | |
| 88 | + | ||
| 89 | + | /// Where the renderer for a request comes from. | |
| 90 | + | /// | |
| 91 | + | /// A host with nothing per-request to say shares one, which is what every | |
| 92 | + | /// client-side host wants and what it cost before this existed. A host serving | |
| 93 | + | /// a public website builds one per request, because that is the only moment it | |
| 94 | + | /// knows what the page contains. See [`Adapter::per_request`]. | |
| 95 | + | enum Renderers<S, R> { | |
| 96 | + | Shared(Arc<R>), | |
| 97 | + | PerRequest(Box<RenderFactory<S, R>>), | |
| 98 | + | } | |
| 99 | + | ||
| 77 | 100 | /// The router, the app's state and a renderer, mounted as an axum service. | |
| 78 | 101 | pub struct Adapter<S, R> { | |
| 79 | 102 | router: Router<S>, | |
| 80 | 103 | state: Arc<S>, | |
| 81 | - | render: Arc<R>, | |
| 104 | + | render: Renderers<S, R>, | |
| 82 | 105 | body_limit: usize, | |
| 83 | 106 | } | |
| 84 | 107 | ||
| @@ -86,7 +109,7 @@ | |||
| 86 | 109 | struct Context<S, R> { | |
| 87 | 110 | router: Router<S>, | |
| 88 | 111 | state: Arc<S>, | |
| 89 | - | render: Arc<R>, | |
| 112 | + | render: Renderers<S, R>, | |
| 90 | 113 | body_limit: usize, | |
| 91 | 114 | } | |
| 92 | 115 | ||
| @@ -96,12 +119,67 @@ | |||
| 96 | 119 | R: Render, | |
| 97 | 120 | { | |
| 98 | 121 | /// Mount a router over this state, rendered by this renderer. | |
| 122 | + | /// | |
| 123 | + | /// One renderer, shared by every request. Right for a host whose renderer | |
| 124 | + | /// is configuration: where the assets live and what the classes are called | |
| 125 | + | /// do not vary by request. | |
| 99 | 126 | #[must_use] | |
| 100 | 127 | pub fn new(router: Router<S>, state: Arc<S>, render: Arc<R>) -> Self { | |
| 101 | 128 | Self { | |
| 102 | 129 | router, | |
| 103 | 130 | state, | |
| 104 | - | render, | |
| 131 | + | render: Renderers::Shared(render), | |
| 132 | + | body_limit: DEFAULT_BODY_LIMIT, | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /// Mount a router over this state, building a renderer per request. | |
| 137 | + | /// | |
| 138 | + | /// For the host that has something to say about this page and not about | |
| 139 | + | /// the next one: the markup filling a bespoke region, a per-screen head, a | |
| 140 | + | /// theme block computed from the signed-in reader. A renderer is | |
| 141 | + | /// configuration and an allocation, so building one per request is cheap; | |
| 142 | + | /// what it buys is that the host gets its say at the one moment it knows | |
| 143 | + | /// what the answer contains. | |
| 144 | + | /// | |
| 145 | + | /// This is the only channel for it, and deliberately. A handler is | |
| 146 | + | /// `fn(&S, Params) -> Result<Response, RouteError>` with no request object | |
| 147 | + | /// and no side channel, so anything it computed could only ride in the | |
| 148 | + | /// [`Response`], and host markup in the router's response type would make | |
| 149 | + | /// the router host-aware. So the factory does the work and the handler | |
| 150 | + | /// stops doing it. | |
| 151 | + | /// | |
| 152 | + | /// ```no_run | |
| 153 | + | /// # use std::sync::Arc; | |
| 154 | + | /// # use quasi_axum::Adapter; | |
| 155 | + | /// # use quasi_router::{Outcome, Router}; | |
| 156 | + | /// # struct App; | |
| 157 | + | /// # #[derive(Default)] struct Html { fills: std::collections::HashMap<String, String> } | |
| 158 | + | /// # impl quasi_axum::Render for Html { | |
| 159 | + | /// # fn screen(&self, _: &quasi_router::Screen) -> String { String::new() } | |
| 160 | + | /// # fn fragment(&self, _: &quasi_router::Node) -> String { String::new() } | |
| 161 | + | /// # } | |
| 162 | + | /// # let quasi = Router::<App>::new(); | |
| 163 | + | /// Adapter::per_request(quasi, Arc::new(App), |_app, _params, answer| { | |
| 164 | + | /// let mut render = Html::default(); | |
| 165 | + | /// if let Some(Outcome::Screen(screen)) = answer.map(|a| &a.outcome) { | |
| 166 | + | /// for slot in &screen.slots { | |
| 167 | + | /// // Only the regions this screen actually has. | |
| 168 | + | /// render.fills.insert(slot.id.clone(), String::new()); | |
| 169 | + | /// } | |
| 170 | + | /// } | |
| 171 | + | /// render | |
| 172 | + | /// }); | |
| 173 | + | /// ``` | |
| 174 | + | #[must_use] | |
| 175 | + | pub fn per_request<F>(router: Router<S>, state: Arc<S>, factory: F) -> Self | |
| 176 | + | where | |
| 177 | + | F: Fn(&S, &Params, Option<&Response>) -> R + Send + Sync + 'static, | |
| 178 | + | { | |
| 179 | + | Self { | |
| 180 | + | router, | |
| 181 | + | state, | |
| 182 | + | render: Renderers::PerRequest(Box::new(factory)), | |
| 105 | 183 | body_limit: DEFAULT_BODY_LIMIT, | |
| 106 | 184 | } | |
| 107 | 185 | } | |
| @@ -161,6 +239,11 @@ | |||
| 161 | 239 | Err(refusal) => return convert(quasi_http::refuse(refusal)), | |
| 162 | 240 | }; | |
| 163 | 241 | ||
| 242 | + | // Kept because the router consumes them and a per-request renderer is | |
| 243 | + | // built after dispatch, when what the factory needs to see is both what | |
| 244 | + | // was asked and what was answered. | |
| 245 | + | let params = incoming.params.clone(); | |
| 246 | + | ||
| 164 | 247 | // Decision 6: the router is sync. Calling it directly would block the | |
| 165 | 248 | // executor for however long the store takes. | |
| 166 | 249 | let dispatch = { | |
| @@ -181,7 +264,16 @@ | |||
| 181 | 264 | Err(RouteError::internal("the request could not be completed")) | |
| 182 | 265 | }); | |
| 183 | 266 | ||
| 184 | - | convert(quasi_http::respond(&*context.render, outcome)) | |
| 267 | + | match &context.render { | |
| 268 | + | Renderers::Shared(render) => convert(quasi_http::respond(&**render, outcome)), | |
| 269 | + | Renderers::PerRequest(factory) => { | |
| 270 | + | // Built from the answer rather than before it, so the factory can | |
| 271 | + | // fill the regions this screen has and skip the work for the ones | |
| 272 | + | // it does not. | |
| 273 | + | let render = factory(&context.state, ¶ms, outcome.as_ref().ok()); | |
| 274 | + | convert(quasi_http::respond(&render, outcome)) | |
| 275 | + | } | |
| 276 | + | } | |
| 185 | 277 | } | |
| 186 | 278 | ||
| 187 | 279 | /// An `http` response with a `Vec` body becomes an axum one. |
| @@ -256,6 +256,79 @@ | |||
| 256 | 256 | ); | |
| 257 | 257 | } | |
| 258 | 258 | ||
| 259 | + | /// A renderer carrying something the factory decided for one request. | |
| 260 | + | struct PerRequest { | |
| 261 | + | greeting: String, | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | impl super::Render for PerRequest { | |
| 265 | + | fn screen(&self, screen: &Screen) -> String { | |
| 266 | + | format!("{}:{}", self.greeting, screen.title) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | fn fragment(&self, _node: &Node) -> String { | |
| 270 | + | self.greeting.clone() | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | #[tokio::test] | |
| 275 | + | async fn a_per_request_renderer_sees_the_params_and_the_answer() { | |
| 276 | + | // The whole point of the factory: the host gets its say at the one moment | |
| 277 | + | // it knows both what was asked and what is being answered. Two requests to | |
| 278 | + | // one route, two different renderers. | |
| 279 | + | let router = Router::<App>::new().get("/", home); | |
| 280 | + | let service = super::Adapter::per_request(router, Arc::new(App), |_app, params, answer| { | |
| 281 | + | let title = match answer.map(|a| &a.outcome) { | |
| 282 | + | Some(quasi_router::Outcome::Screen(screen)) => screen.title.clone(), | |
| 283 | + | _ => "none".to_owned(), | |
| 284 | + | }; | |
| 285 | + | PerRequest { | |
| 286 | + | greeting: format!("{}/{title}", params.get("who").unwrap_or("nobody")), | |
| 287 | + | } | |
| 288 | + | }) | |
| 289 | + | .into_router(); | |
| 290 | + | ||
| 291 | + | let first = service.clone().oneshot(get("/?who=ada")).await.unwrap(); | |
| 292 | + | let first = first.into_body().collect().await.unwrap().to_bytes(); | |
| 293 | + | assert_eq!(String::from_utf8_lossy(&first), "ada/Home:Home"); | |
| 294 | + | ||
| 295 | + | let second = service.oneshot(get("/?who=grace")).await.unwrap(); | |
| 296 | + | let second = second.into_body().collect().await.unwrap().to_bytes(); | |
| 297 | + | assert_eq!(String::from_utf8_lossy(&second), "grace/Home:Home"); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | #[tokio::test] | |
| 301 | + | async fn a_refusal_reaches_the_factory_with_no_answer_to_read() { | |
| 302 | + | // There is no screen to fill when the router refuses, and the factory is | |
| 303 | + | // told so rather than handed something invented. | |
| 304 | + | let router = Router::<App>::new().post("/task/:id/delete", denied); | |
| 305 | + | let service = | |
| 306 | + | super::Adapter::per_request(router, Arc::new(App), |_app, _params, answer| PerRequest { | |
| 307 | + | greeting: match answer { | |
| 308 | + | Some(_) => "answered".to_owned(), | |
| 309 | + | None => "refused".to_owned(), | |
| 310 | + | }, | |
| 311 | + | }) | |
| 312 | + | .into_router(); | |
| 313 | + | ||
| 314 | + | let response = service | |
| 315 | + | .oneshot(post_form("/task/7/delete", "")) | |
| 316 | + | .await | |
| 317 | + | .unwrap(); | |
| 318 | + | assert_eq!(response.status(), StatusCode::FORBIDDEN); | |
| 319 | + | let body = response.into_body().collect().await.unwrap().to_bytes(); | |
| 320 | + | assert_eq!(String::from_utf8_lossy(&body), "refused"); | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | #[tokio::test] | |
| 324 | + | async fn a_shared_renderer_still_serves() { | |
| 325 | + | // The change is additive. A host with nothing per-request to say keeps the | |
| 326 | + | // constructor it had. | |
| 327 | + | let (status, body, _) = send(get("/")).await; | |
| 328 | + | assert_eq!(status, StatusCode::OK); | |
| 329 | + | assert_eq!(body, "screen:Home"); | |
| 330 | + | } | |
| 331 | + | ||
| 259 | 332 | /// The one screen the end-to-end test renders for real. | |
| 260 | 333 | /// | |
| 261 | 334 | /// Deliberately not the `Spy`. Everything above proves the adapter's own |
| @@ -42,17 +42,26 @@ | |||
| 42 | 42 | pub use crate::shell::Shell; | |
| 43 | 43 | pub use makeover_webview::Emit; | |
| 44 | 44 | ||
| 45 | + | use std::collections::HashMap; | |
| 46 | + | ||
| 45 | 47 | use makeover_layout::Arrangement; | |
| 46 | 48 | use quasi_http::Render; | |
| 47 | 49 | use quasi_router::{Node, Screen}; | |
| 48 | 50 | ||
| 49 | 51 | /// A renderer that answers HTML. | |
| 50 | 52 | /// | |
| 51 | - | /// Holds the two things a webview needs and a description never carries: where | |
| 52 | - | /// the host's assets live, and what to prefix class names with. Both are | |
| 53 | - | /// values rather than constants because they are the parts that genuinely | |
| 54 | - | /// differ between an axum route and a Tauri custom-protocol handler, and | |
| 55 | - | /// neither is anything the router can know. | |
| 53 | + | /// Holds the things a webview needs and a description never carries: where the | |
| 54 | + | /// host's assets live, what to prefix class names with, and what goes inside a | |
| 55 | + | /// bespoke region. All values rather than constants because they are the parts | |
| 56 | + | /// that genuinely differ between an axum route and a Tauri custom-protocol | |
| 57 | + | /// handler, and none of them is anything the router can know. | |
| 58 | + | /// | |
| 59 | + | /// # Why a renderer is cheap | |
| 60 | + | /// | |
| 61 | + | /// Three fields, two of them usually shared configuration. A host with | |
| 62 | + | /// something per-request to say builds one per request — that is what | |
| 63 | + | /// [`fills`](Self::fills) is for, and it is an allocation rather than a | |
| 64 | + | /// rebuild. `quasi-axum` takes a factory for exactly this. | |
| 56 | 65 | #[derive(Debug, Clone, Default)] | |
| 57 | 66 | pub struct Webview { | |
| 58 | 67 | /// The document around a screen. | |
| @@ -60,6 +69,22 @@ | |||
| 60 | 69 | /// Class naming, shared with `makeover-webview`'s stylesheet half so the | |
| 61 | 70 | /// emitted markup and the emitted CSS agree on every name. | |
| 62 | 71 | pub emit: Emit, | |
| 72 | + | /// What to put inside a bespoke region, by [`Slot::id`](quasi_router::Slot). | |
| 73 | + | /// | |
| 74 | + | /// Markup, inserted verbatim and unescaped, exactly as | |
| 75 | + | /// [`Shell::head`] is. It is host code's string: the description never sees | |
| 76 | + | /// it, never carries it and cannot be made to produce one. That is what | |
| 77 | + | /// keeps decision 4 intact while giving a server-rendered page something to | |
| 78 | + | /// serve, and it is why `Node::Html` is still refused. | |
| 79 | + | /// | |
| 80 | + | /// Keyed by slot id and not by the bespoke name, because a page of N rows | |
| 81 | + | /// each carrying a fill shares one name and has N ids. The emitted div | |
| 82 | + | /// carries both. | |
| 83 | + | /// | |
| 84 | + | /// A slot with no entry here renders empty, which is what every client host | |
| 85 | + | /// relies on. An entry naming an id the screen does not have is ignored | |
| 86 | + | /// rather than appended anywhere. | |
| 87 | + | pub fills: HashMap<String, String>, | |
| 63 | 88 | } | |
| 64 | 89 | ||
| 65 | 90 | impl Webview { | |
| @@ -74,7 +99,7 @@ | |||
| 74 | 99 | pub fn under(prefix: &str) -> Self { | |
| 75 | 100 | Self { | |
| 76 | 101 | shell: Shell::under(prefix), | |
| 77 | - | emit: Emit::default(), | |
| 102 | + | ..Self::default() | |
| 78 | 103 | } | |
| 79 | 104 | } | |
| 80 | 105 | ||
| @@ -92,6 +117,17 @@ | |||
| 92 | 117 | self | |
| 93 | 118 | } | |
| 94 | 119 | ||
| 120 | + | /// Fill the bespoke region with this slot id, chaining. Not escaped. | |
| 121 | + | /// | |
| 122 | + | /// Repeated calls for one id replace, rather than appending the way | |
| 123 | + | /// [`Shell::with_head`] does: a head accumulates unrelated tags, and a fill | |
| 124 | + | /// is one region's whole contents. | |
| 125 | + | #[must_use] | |
| 126 | + | pub fn with_fill(mut self, slot_id: impl Into<String>, markup: impl Into<String>) -> Self { | |
| 127 | + | self.fills.insert(slot_id.into(), markup.into()); | |
| 128 | + | self | |
| 129 | + | } | |
| 130 | + | ||
| 95 | 131 | /// The class naming the arrangement of a screen's regions. | |
| 96 | 132 | /// | |
| 97 | 133 | /// Two, because our apps have two. A third arrives when an app has one, | |
| @@ -127,13 +163,19 @@ | |||
| 127 | 163 | out.push_str(&node::class("notices", &self.emit)); | |
| 128 | 164 | out.push_str("\">"); | |
| 129 | 165 | for notice in &screen.notices { | |
| 130 | - | node::node_html(notice, self.shell.morphs(), &self.emit, &mut out); | |
| 166 | + | node::node_html( | |
| 167 | + | notice, | |
| 168 | + | self.shell.morphs(), | |
| 169 | + | &self.emit, | |
| 170 | + | &self.fills, | |
| 171 | + | &mut out, | |
| 172 | + | ); | |
| 131 | 173 | } | |
| 132 | 174 | out.push_str("</div>"); | |
| 133 | 175 | } | |
| 134 | 176 | ||
| 135 | 177 | for slot in &screen.slots { | |
| 136 | - | node::slot_html(slot, self.shell.morphs(), &self.emit, &mut out); | |
| 178 | + | node::slot_html(slot, self.shell.morphs(), &self.emit, &self.fills, &mut out); | |
| 137 | 179 | } | |
| 138 | 180 | ||
| 139 | 181 | out.push_str("</main>"); | |
| @@ -146,7 +188,7 @@ | |||
| 146 | 188 | // htmx puts it there. The router already said which element through | |
| 147 | 189 | // `HX-Retarget`, so nothing here needs to know. | |
| 148 | 190 | let mut out = String::with_capacity(256); | |
| 149 | - | node::node_html(node, self.shell.morphs(), &self.emit, &mut out); | |
| 191 | + | node::node_html(node, self.shell.morphs(), &self.emit, &self.fills, &mut out); | |
| 150 | 192 | out | |
| 151 | 193 | } | |
| 152 | 194 | } |
| @@ -19,6 +19,7 @@ | |||
| 19 | 19 | //! is the only party that knows what it just changed. A control that also named | |
| 20 | 20 | //! a target would be a second party deciding one thing. | |
| 21 | 21 | ||
| 22 | + | use std::collections::HashMap; | |
| 22 | 23 | use std::fmt::Write as _; | |
| 23 | 24 | ||
| 24 | 25 | use makeover_layout as layout; | |
| @@ -533,7 +534,13 @@ | |||
| 533 | 534 | } | |
| 534 | 535 | ||
| 535 | 536 | /// One thing on a screen. | |
| 536 | - | pub(crate) fn node_html(node: &Node, morphs: bool, opts: &Emit, out: &mut String) { | |
| 537 | + | pub(crate) fn node_html( | |
| 538 | + | node: &Node, | |
| 539 | + | morphs: bool, | |
| 540 | + | opts: &Emit, | |
| 541 | + | fills: &HashMap<String, String>, | |
| 542 | + | out: &mut String, | |
| 543 | + | ) { | |
| 537 | 544 | match node { | |
| 538 | 545 | Node::Heading { level, text } => { | |
| 539 | 546 | let tag = match level { | |
| @@ -780,7 +787,7 @@ | |||
| 780 | 787 | out.push_str("</div>"); | |
| 781 | 788 | } | |
| 782 | 789 | ||
| 783 | - | Node::Region(slot) => slot_html(slot, morphs, opts, out), | |
| 790 | + | Node::Region(slot) => slot_html(slot, morphs, opts, fills, out), | |
| 784 | 791 | } | |
| 785 | 792 | } | |
| 786 | 793 | ||
| @@ -910,7 +917,13 @@ | |||
| 910 | 917 | } | |
| 911 | 918 | ||
| 912 | 919 | /// A region, and everything under it. | |
| 913 | - | pub(crate) fn slot_html(slot: &Slot, morphs: bool, opts: &Emit, out: &mut String) { | |
| 920 | + | pub(crate) fn slot_html( | |
| 921 | + | slot: &Slot, | |
| 922 | + | morphs: bool, | |
| 923 | + | opts: &Emit, | |
| 924 | + | fills: &HashMap<String, String>, | |
| 925 | + | out: &mut String, | |
| 926 | + | ) { | |
| 914 | 927 | let kind = match &slot.kind { | |
| 915 | 928 | quasi_router::RegionKind::Band => "band", | |
| 916 | 929 | quasi_router::RegionKind::Sidebar => "sidebar", | |
| @@ -949,7 +962,21 @@ | |||
| 949 | 962 | ||
| 950 | 963 | out.push('>'); | |
| 951 | 964 | for node in &slot.body { | |
| 952 | - | node_html(node, morphs, opts, out); | |
| 965 | + | node_html(node, morphs, opts, fills, out); | |
| 953 | 966 | } | |
| 967 | + | ||
| 968 | + | // The host's markup for this region, after whatever the description put | |
| 969 | + | // here, and only for a bespoke one: a fill named against a pane is a host | |
| 970 | + | // reaching into a region the description already owns. | |
| 971 | + | // | |
| 972 | + | // Verbatim. See `Webview::fills` for why that is not the hole it looks | |
| 973 | + | // like: the string is host code's, never a description's, so the escaping | |
| 974 | + | // guarantee the whole vocabulary rests on is untouched. | |
| 975 | + | if matches!(slot.kind, quasi_router::RegionKind::Bespoke { .. }) | |
| 976 | + | && let Some(fill) = fills.get(&slot.id) | |
| 977 | + | { | |
| 978 | + | out.push_str(fill); | |
| 979 | + | } | |
| 980 | + | ||
| 954 | 981 | out.push_str("</div>"); | |
| 955 | 982 | } |
| @@ -424,6 +424,99 @@ | |||
| 424 | 424 | assert!(html.contains("data-bespoke=\"media-player\"></div>")); | |
| 425 | 425 | } | |
| 426 | 426 | ||
| 427 | + | #[test] | |
| 428 | + | fn a_bespoke_region_the_host_filled_carries_its_markup() { | |
| 429 | + | // The server case: there is no client moment, so the fill has to be in the | |
| 430 | + | // bytes the browser gets or it is absent from first paint, absent with JS | |
| 431 | + | // off and absent to a crawler. | |
| 432 | + | let screen = Screen::list_detail("Files", false).with(Slot::bespoke("file", "git-file")); | |
| 433 | + | let html = Webview::new() | |
| 434 | + | .with_fill("file", "<pre class=\"hl\">fn main() {}</pre>") | |
| 435 | + | .screen(&screen); | |
| 436 | + | ||
| 437 | + | assert!( | |
| 438 | + | html.contains("<pre class=\"hl\">fn main() {}</pre></div>"), | |
| 439 | + | "{html}" | |
| 440 | + | ); | |
| 441 | + | } | |
| 442 | + | ||
| 443 | + | #[test] | |
| 444 | + | fn a_bespoke_region_with_no_fill_is_still_empty() { | |
| 445 | + | // What every client host relies on: the div is a place, and a host that | |
| 446 | + | // fills one region has not changed what the others are. | |
| 447 | + | let screen = Screen::list_detail("Files", false) | |
| 448 | + | .with(Slot::bespoke("file", "git-file")) | |
| 449 | + | .with(Slot::bespoke("player", "media-player")); | |
| 450 | + | let html = Webview::new() | |
| 451 | + | .with_fill("file", "<pre></pre>") | |
| 452 | + | .screen(&screen); | |
| 453 | + | ||
| 454 | + | assert!( | |
| 455 | + | html.contains("data-bespoke=\"media-player\"></div>"), | |
| 456 | + | "{html}" | |
| 457 | + | ); | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | #[test] | |
| 461 | + | fn two_regions_sharing_a_name_are_filled_by_id() { | |
| 462 | + | // The re-entrancy case, and the reason fills are keyed by slot id: a page | |
| 463 | + | // of N rows each carrying one shares a single bespoke name. | |
| 464 | + | let screen = Screen::list_detail("Files", false) | |
| 465 | + | .with(Slot::bespoke("row-1", "diff")) | |
| 466 | + | .with(Slot::bespoke("row-2", "diff")); | |
| 467 | + | let html = Webview::new() | |
| 468 | + | .with_fill("row-1", "<i>one</i>") | |
| 469 | + | .with_fill("row-2", "<i>two</i>") | |
| 470 | + | .screen(&screen); | |
| 471 | + | ||
| 472 | + | assert!(html.contains("id=\"row-1\""), "{html}"); | |
| 473 | + | let one = html.find("<i>one</i>").expect("the first row is filled"); | |
| 474 | + | let two = html.find("<i>two</i>").expect("the second row is filled"); | |
| 475 | + | assert!(one < two); | |
| 476 | + | assert!(html.find("id=\"row-2\"").expect("the second row exists") < two); | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | #[test] | |
| 480 | + | fn a_fill_is_markup_and_is_not_escaped() { | |
| 481 | + | // The one string in this renderer that is not escaped, and the reason it | |
| 482 | + | // is safe: it comes from host code, never from a description. A | |
| 483 | + | // description still cannot produce markup, which is what `Node::Html` was | |
| 484 | + | // refused to protect. | |
| 485 | + | let screen = Screen::list_detail("Files", false).with(Slot::bespoke("file", "git-file")); | |
| 486 | + | let html = Webview::new() | |
| 487 | + | .with_fill("file", "<span data-x=\"1\">&</span>") | |
| 488 | + | .screen(&screen); | |
| 489 | + | ||
| 490 | + | assert!(html.contains("<span data-x=\"1\">&</span>"), "{html}"); | |
| 491 | + | assert!(!html.contains("<span"), "{html}"); | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | #[test] | |
| 495 | + | fn a_fill_for_a_region_the_screen_lacks_goes_nowhere() { | |
| 496 | + | // Ignored rather than appended somewhere. A renderer built for one screen | |
| 497 | + | // and handed another emits that other screen unchanged. | |
| 498 | + | let screen = Screen::list_detail("Files", false).with(Slot::bespoke("file", "git-file")); | |
| 499 | + | let plain = render(&screen); | |
| 500 | + | let html = Webview::new() | |
| 501 | + | .with_fill("absent", "<b>stray</b>") | |
| 502 | + | .screen(&screen); | |
| 503 | + | ||
| 504 | + | assert!(!html.contains("stray"), "{html}"); | |
| 505 | + | assert_eq!(html, plain); | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | #[test] | |
| 509 | + | fn a_fill_is_only_for_a_bespoke_region() { | |
| 510 | + | // A pane's contents are the description's. A host reaching into one is | |
| 511 | + | // reaching past the vocabulary rather than into the space it was given. | |
| 512 | + | let screen = Screen::list_detail("Files", false).with(Slot::new("detail", RegionKind::Pane)); | |
| 513 | + | let html = Webview::new() | |
| 514 | + | .with_fill("detail", "<b>stray</b>") | |
| 515 | + | .screen(&screen); | |
| 516 | + | ||
| 517 | + | assert!(!html.contains("stray"), "{html}"); | |
| 518 | + | } | |
| 519 | + | ||
| 427 | 520 | #[test] | |
| 428 | 521 | fn a_slot_id_survives_intact_because_a_fragment_is_aimed_at_it() { | |
| 429 | 522 | let screen = Screen::sidebar_content("Feeds").with(Slot::new("feed-list", RegionKind::Sidebar)); |