Skip to main content

table_editor/
view.rs

1//! The pages a repository computes and the browser renders.
2//!
3//! A view is the other half of what a repository serves: a table is where the
4//! typing happens, and a view is where the reading does. It answers a question
5//! the tables can only be read to answer—which books are out on loan and how
6//! late they are—by computing a page that is in no file.
7//!
8//! What a page is made of is [`crate::page`]: a table of rows described by the
9//! same columns a table sends, a grid of cards, or one thing in detail. There
10//! is no sorting, no filtering, and no state: a parameter changes, the page is
11//! fetched again, and what comes back is what is shown.
12//!
13//! A page in detail may offer an action, which is the one thing here that
14//! writes. The button is on the page, the form is described beside it, and what
15//! it writes is [`ViewLogic::act`]'s to do—through the same [`Context`] a table
16//! writes through, so the file's bytes and ordering rules hold. An action is
17//! only reachable where the page being looked at offers it: the router renders
18//! the view and refuses anything no button on that page offers, both the name
19//! and the arguments the button's own form carries.
20
21use std::collections::BTreeMap;
22
23use serde::{Deserialize, Serialize};
24
25use crate::context::Context;
26use crate::error::ApiError;
27use crate::page::{CardGroup, Detail, Section};
28use crate::schema::SelectOption;
29
30/// One control at the top of a view.
31#[derive(Debug, Clone, Serialize)]
32pub struct Param {
33    key: String,
34    label: String,
35    #[serde(rename = "type")]
36    kind: ParamKind,
37    #[serde(skip_serializing_if = "Vec::is_empty")]
38    options: Vec<SelectOption>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    default: Option<String>,
41    #[serde(skip_serializing_if = "is_false")]
42    hidden: bool,
43}
44
45fn is_false(value: &bool) -> bool {
46    !*value
47}
48
49/// How a parameter is asked for. It is the `type` the payload carries and
50/// nothing a consumer names: a parameter is built by [`Param::select`] or
51/// [`Param::string`], which is what decides it.
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "lowercase")]
54pub(crate) enum ParamKind {
55    Select,
56    String,
57}
58
59impl Param {
60    /// A choice among options, which are rebuilt on every request like a
61    /// schema is, so a select can be filled from a table.
62    pub fn select(
63        key: impl Into<String>,
64        label: impl Into<String>,
65        options: impl IntoIterator<Item = impl Into<SelectOption>>,
66    ) -> Self {
67        Self {
68            key: key.into(),
69            label: label.into(),
70            kind: ParamKind::Select,
71            options: options.into_iter().map(Into::into).collect(),
72            default: None,
73            hidden: false,
74        }
75    }
76
77    pub fn string(key: impl Into<String>, label: impl Into<String>) -> Self {
78        Self {
79            key: key.into(),
80            label: label.into(),
81            kind: ParamKind::String,
82            options: Vec::new(),
83            default: None,
84            hidden: false,
85        }
86    }
87
88    /// What the view is asked for when the address names nothing.
89    pub fn default(mut self, value: impl Into<String>) -> Self {
90        self.default = Some(value.into());
91        self
92    }
93
94    /// Draw no control for this parameter. It is for a parameter that arrives
95    /// through a link rather than through the page—which story a detail page is
96    /// about—where a control would be a second way to ask a question the reader
97    /// has already asked. It is still declared, so it takes a default and is
98    /// still handed to the view.
99    pub fn hidden(mut self) -> Self {
100        self.hidden = true;
101        self
102    }
103
104    pub fn key(&self) -> &str {
105        &self.key
106    }
107
108    pub(crate) fn fallback(&self) -> Option<&str> {
109        self.default.as_deref()
110    }
111
112    /// Whether this parameter would offer `value`. A select with no options
113    /// offers whatever it is given, since it has named nothing to choose from.
114    pub(crate) fn offers(&self, value: &str) -> bool {
115        self.kind != ParamKind::Select
116            || self.options.is_empty()
117            || self.options.iter().any(|option| option.value == value)
118    }
119}
120
121/// What a view was asked for.
122///
123/// It holds the address's parameters, with a declared parameter the address
124/// left out filled in from its default. Everything the address carried is
125/// kept, including keys no parameter names, so a view may read more than it
126/// declares; [`ViewArgs::iter`] walks all of it.
127///
128/// A parameter the address gave a value its options no longer offer falls back
129/// to the default. That is what happens when one parameter's options depend on
130/// another's value and the other has just changed: a subgenre that belonged to
131/// the genre before this one is not an answer to the question being asked now.
132/// An empty value is a value: a parameter cleared on purpose stays cleared
133/// rather than filling itself in again.
134///
135/// An action is asked with the same arguments the page it was on was asked
136/// with, and with the arguments its own form carried, so [`ViewLogic::act`]
137/// reads which thing it is writing about the same way [`ViewLogic::render`]
138/// reads which thing it is drawing.
139#[derive(Debug, Clone, Default, Serialize)]
140pub struct ViewArgs(BTreeMap<String, String>);
141
142impl ViewArgs {
143    pub(crate) fn from_query(query: &BTreeMap<String, String>) -> Self {
144        Self(query.clone())
145    }
146
147    /// Settle what the view is rendering from, now that its parameters are
148    /// known. See the type's own description for the rules.
149    pub(crate) fn resolve(query: &BTreeMap<String, String>, params: &[Param]) -> Self {
150        let mut args = query.clone();
151        for param in params {
152            let asked = args.get(param.key());
153            let keep = match asked {
154                Some(value) => param.offers(value),
155                None => false,
156            };
157            if keep {
158                continue;
159            }
160            // With nothing to fall back to, an answer nobody offers is left
161            // where it is rather than replaced with a guess.
162            if let Some(fallback) = param.fallback() {
163                args.insert(param.key().to_string(), fallback.to_string());
164            }
165        }
166        Self(args)
167    }
168
169    pub fn get(&self, key: &str) -> Option<&str> {
170        self.0.get(key).map(String::as_str)
171    }
172
173    /// The value of `key`, or `fallback` where the address and the parameter's
174    /// own default both said nothing.
175    pub fn get_or<'a>(&'a self, key: &str, fallback: &'a str) -> &'a str {
176        self.get(key).unwrap_or(fallback)
177    }
178
179    /// Every key and value, in order, including those no parameter declares.
180    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
181        self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
182    }
183
184    pub fn is_empty(&self) -> bool {
185        self.0.is_empty()
186    }
187
188    pub fn len(&self) -> usize {
189        self.0.len()
190    }
191}
192
193/// What a form was filled in with.
194///
195/// Every answer arrives as text, because that is what a control on a page
196/// produces: a number box hands back the digits that were typed, and an empty
197/// box hands back nothing at all. So a field is read through the reader that
198/// suits it, and a field holding something that field cannot be is a 400
199/// naming it rather than a panic or a silent zero.
200#[derive(Debug, Clone, Default)]
201pub struct Fields(BTreeMap<String, String>);
202
203impl Fields {
204    /// The answer as it was typed, or nothing where the form did not carry the
205    /// field at all.
206    pub fn get(&self, key: &str) -> Option<&str> {
207        self.0.get(key).map(String::as_str)
208    }
209
210    /// The answer as text, trimmed. A field nobody filled in is empty rather
211    /// than absent, since a form that was saved answered every field it had.
212    pub fn text(&self, key: &str) -> &str {
213        self.get(key).unwrap_or("").trim()
214    }
215
216    /// The answer as a whole number.
217    pub fn integer(&self, key: &str) -> Result<i64, ApiError> {
218        let text = self.text(key);
219        text.parse()
220            .map_err(|_| Self::refuse(key, text, "a whole number"))
221    }
222
223    /// The answer as a number, whole or not.
224    ///
225    /// `inf` and `NaN` parse as floats and are refused with the rest: a row
226    /// holding one serialises to `null`, which would put a wrong value in a
227    /// file rather than say the answer was no good.
228    pub fn number(&self, key: &str) -> Result<f64, ApiError> {
229        let text = self.text(key);
230        match text.parse::<f64>() {
231            Ok(number) if number.is_finite() => Ok(number),
232            _ => Err(Self::refuse(key, text, "a number")),
233        }
234    }
235
236    /// The answer as a `YYYY-MM-DD` date.
237    ///
238    /// The shape is checked and the ranges with it, so nothing beyond a real
239    /// month and a plausible day gets through; which days a month actually has
240    /// is a calendar question, and the repository writing the date is what
241    /// holds a calendar.
242    pub fn date(&self, key: &str) -> Result<&str, ApiError> {
243        let text = self.text(key);
244        if is_date(text) {
245            Ok(text)
246        } else {
247            Err(Self::refuse(key, text, "a date, as YYYY-MM-DD"))
248        }
249    }
250
251    /// Every key and answer, in order.
252    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
253        self.0.iter().map(|(k, v)| (k.as_str(), v.as_str()))
254    }
255
256    fn refuse(key: &str, text: &str, wanted: &str) -> ApiError {
257        if text.is_empty() {
258            ApiError::bad_request(format!("{key} was left empty, and it wants {wanted}"))
259        } else {
260            ApiError::bad_request(format!("{key} is \"{text}\", which is not {wanted}"))
261        }
262    }
263}
264
265/// Whether `text` is a `YYYY-MM-DD` date with a real month and a day that some
266/// month has.
267fn is_date(text: &str) -> bool {
268    let mut parts = text.split('-');
269    let (Some(year), Some(month), Some(day), None) =
270        (parts.next(), parts.next(), parts.next(), parts.next())
271    else {
272        return false;
273    };
274    let digits =
275        |text: &str, width: usize| text.len() == width && text.bytes().all(|b| b.is_ascii_digit());
276    if !digits(year, 4) || !digits(month, 2) || !digits(day, 2) {
277        return false;
278    }
279    let number = |text: &str| text.parse::<u32>().unwrap_or(0);
280    (1..=12).contains(&number(month)) && (1..=31).contains(&number(day))
281}
282
283/// What one render of a view produced: a note about the whole of it, and the
284/// body it is to be read as.
285///
286/// The body is one of three: sections of rows, groups of cards, or one thing in
287/// detail. A view answers with one of them, and a view that built two is a
288/// failure naming both rather than a page that shows whichever the browser
289/// happened to look for first.
290#[derive(Debug, Clone, Default, Serialize)]
291pub struct ViewData {
292    #[serde(skip_serializing_if = "Option::is_none")]
293    note: Option<String>,
294    sections: Vec<Section>,
295    #[serde(skip_serializing_if = "Vec::is_empty")]
296    groups: Vec<CardGroup>,
297    #[serde(skip_serializing_if = "Option::is_none")]
298    detail: Option<Detail>,
299}
300
301impl ViewData {
302    pub fn new() -> Self {
303        Self::default()
304    }
305
306    pub fn note(mut self, note: impl Into<String>) -> Self {
307        self.note = Some(note.into());
308        self
309    }
310
311    pub fn section(mut self, section: Section) -> Self {
312        self.sections.push(section);
313        self
314    }
315
316    /// One group of cards. A group with no cards in it is dropped rather than
317    /// drawn empty, so a view can name every group it knows about and let the
318    /// data decide which of them the page has.
319    pub fn group(mut self, group: CardGroup) -> Self {
320        if !group.is_empty() {
321            self.groups.push(group);
322        }
323        self
324    }
325
326    pub fn detail(mut self, detail: Detail) -> Self {
327        self.detail = Some(detail);
328        self
329    }
330
331    /// What this page is, named the way a failure would want to read.
332    fn bodies(&self) -> Vec<&'static str> {
333        let mut bodies = Vec::new();
334        if !self.sections.is_empty() {
335            bodies.push("sections of rows");
336        }
337        if !self.groups.is_empty() {
338            bodies.push("groups of cards");
339        }
340        if self.detail.is_some() {
341            bodies.push("one thing in detail");
342        }
343        bodies
344    }
345
346    /// Refuse a page that is two pages. The browser draws one body, so a view
347    /// that built both would have half of what it computed silently dropped.
348    fn one_body(&self, view: &str) -> Result<(), ApiError> {
349        let bodies = self.bodies();
350        if bodies.len() > 1 {
351            return Err(ApiError::server(format!(
352                "the view \"{view}\" answered with {}; a view answers with one of them",
353                bodies.join(" and ")
354            )));
355        }
356        Ok(())
357    }
358
359    /// Whether a button on this page offers `name` to be written about
360    /// `args`.
361    ///
362    /// The arguments a button's own form carries have to be among the settled
363    /// ones, with the same values. A form built per row therefore offers a
364    /// write of that row and of nothing else: posting its action with another
365    /// row's arguments matches no button, however many rows the page has. A
366    /// form that carries no arguments is offered by its name alone, which is
367    /// all it claims to be about.
368    fn offers(&self, name: &str, args: &ViewArgs) -> bool {
369        let Some(detail) = &self.detail else {
370            return false;
371        };
372        detail.actions().any(|(action, carried)| {
373            action == name
374                && carried
375                    .iter()
376                    .all(|(key, value)| args.get(key) == Some(value.as_str()))
377        })
378    }
379
380    /// Refuse a form that answers one of the view's own questions.
381    ///
382    /// A form's arguments are added to the page's on the way to the action, so
383    /// one keyed after a declared parameter would send the action to a page
384    /// other than the one the button is on—and that other page is what the
385    /// offer would then be checked against. Nothing good comes of it, and a
386    /// consumer walks into it without noticing, so it is refused where it is
387    /// built rather than documented as a trap.
388    fn no_form_answers_a_parameter(&self, view: &str, params: &[Param]) -> Result<(), ApiError> {
389        let Some(detail) = &self.detail else {
390            return Ok(());
391        };
392        for (action, carried) in detail.actions() {
393            for key in carried.keys() {
394                if params.iter().any(|param| param.key() == key) {
395                    return Err(ApiError::server(format!(
396                        "the view \"{view}\" offers the action \"{action}\" with an argument \
397                         keyed \"{key}\", which is one of the view's own parameters; a form's \
398                         arguments are added to the page's, so that form would write about \
399                         another page than the one it is on"
400                    )));
401                }
402            }
403        }
404        Ok(())
405    }
406}
407
408/// One repository's view.
409///
410/// `params` and `render` both run on every request, against one [`Context`],
411/// so a view reading three tables reads each of them once however many of its
412/// parts consult them.
413pub trait ViewLogic: Send + Sync + 'static {
414    /// The route segment and `?view=` value. It must be made of unreserved URL
415    /// characters, and may not take a reserved name or one a table has;
416    /// building a [`crate::Server`] over such a view panics.
417    fn name(&self) -> &'static str;
418
419    /// The heading the page and the shell's switcher show.
420    fn title(&self) -> &'static str;
421
422    /// Whether the shell's switcher lists this view.
423    ///
424    /// A page about one thing, reached from a card that says which—one story,
425    /// one branch—says no here. A switcher entry for it would open whichever
426    /// one its parameters happen to default to, which is nobody's question.
427    /// It is served, linked to, opened by name from the command line, and
428    /// titled by the shell exactly as any other view is; the top bar simply
429    /// does not offer it.
430    fn in_switcher(&self) -> bool {
431        true
432    }
433
434    /// The controls at the top of the page, rebuilt per request like a schema,
435    /// so a select can be filled from a table.
436    ///
437    /// `asked` is what the address carried, before defaults are filled in, so
438    /// one parameter's options may depend on another's value. A parameter
439    /// whose options are built that way should give a default drawn from the
440    /// same values: an answer the new options do not offer is replaced by that
441    /// default rather than kept.
442    fn params(&self, _ctx: &Context, _asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
443        Ok(Vec::new())
444    }
445
446    fn render(&self, args: &ViewArgs, ctx: &Context) -> Result<ViewData, ApiError>;
447
448    /// Write what an action asks for, and say in a sentence what was written.
449    ///
450    /// `name` is the action a button on the page named, `fields` is what its
451    /// form was filled in with, and `args` is what the page itself was asked
452    /// plus what that form carried. The write goes through `ctx`, which is the
453    /// same path a table's save takes: read the rows, change them, and write
454    /// the file back, so the ordering and the bytes are what the table's own
455    /// rules make them.
456    ///
457    /// The sentence is shown to the reader and the page is then fetched again,
458    /// so an action says what it did and never what the page should now show.
459    ///
460    /// A view with no buttons that write implements none of this. The router
461    /// refuses an action no button on the page offers, so the default is
462    /// reached only where that check itself has gone wrong.
463    fn act(
464        &self,
465        name: &str,
466        _fields: &Fields,
467        _args: &ViewArgs,
468        _ctx: &Context,
469    ) -> Result<String, ApiError> {
470        Err(ApiError::server(format!(
471            "this view has no action called \"{name}\" to write"
472        )))
473    }
474}
475
476/// The object-safe façade the router dispatches through, as [`crate::Table`]
477/// is for tables. A blanket implementation covers every [`ViewLogic`], so
478/// nothing outside this module implements it.
479pub trait View: Send + Sync {
480    /// The route segment, from [`ViewLogic::name`].
481    fn route(&self) -> &'static str;
482
483    /// The shell's heading, from [`ViewLogic::title`].
484    fn heading(&self) -> &'static str;
485
486    /// Whether the switcher lists this view, from [`ViewLogic::in_switcher`].
487    fn listed(&self) -> bool;
488
489    /// The keys of the parameters this view declares, which the server checks
490    /// against the ones the address itself uses.
491    fn param_keys(&self, ctx: &Context) -> Result<Vec<String>, ApiError>;
492
493    /// `GET /api/views/<view>`: the parameters, what they resolved to, and the
494    /// page rendered from them.
495    fn handle_get(
496        &self,
497        query: &BTreeMap<String, String>,
498        ctx: &Context,
499    ) -> Result<String, ApiError>;
500
501    /// `POST /api/views/<view>/actions/<name>`: write what the form asks for,
502    /// and answer with the sentence saying so.
503    fn handle_action(
504        &self,
505        name: &str,
506        query: &BTreeMap<String, String>,
507        body: &str,
508        ctx: &Context,
509    ) -> Result<String, ApiError>;
510}
511
512impl<V: ViewLogic> View for V {
513    fn route(&self) -> &'static str {
514        self.name()
515    }
516
517    fn heading(&self) -> &'static str {
518        self.title()
519    }
520
521    fn listed(&self) -> bool {
522        self.in_switcher()
523    }
524
525    fn param_keys(&self, ctx: &Context) -> Result<Vec<String>, ApiError> {
526        Ok(self
527            .params(ctx, &ViewArgs::default())?
528            .iter()
529            .map(|param| param.key().to_string())
530            .collect())
531    }
532
533    fn handle_get(
534        &self,
535        query: &BTreeMap<String, String>,
536        ctx: &Context,
537    ) -> Result<String, ApiError> {
538        // The parameters are built from what was asked, so that one may depend
539        // on another; what was asked is then settled against them.
540        let params = self.params(ctx, &ViewArgs::from_query(query))?;
541        let args = ViewArgs::resolve(query, &params);
542        let data = self.render(&args, ctx)?;
543        data.one_body(self.name())?;
544        data.no_form_answers_a_parameter(self.name(), &params)?;
545
546        serde_json::to_string(&ViewPayload {
547            view: self.name(),
548            title: self.title(),
549            params,
550            args,
551            note: data.note,
552            sections: data.sections,
553            groups: data.groups,
554            detail: data.detail,
555        })
556        .map_err(|e| ApiError::server(e.to_string()))
557    }
558
559    fn handle_action(
560        &self,
561        name: &str,
562        query: &BTreeMap<String, String>,
563        body: &str,
564        ctx: &Context,
565    ) -> Result<String, ApiError> {
566        let request: ActionRequest = serde_json::from_str(body)
567            .map_err(|e| ApiError::bad_request(format!("invalid request body: {e}")))?;
568
569        let params = self.params(ctx, &ViewArgs::from_query(query))?;
570        let args = ViewArgs::resolve(query, &params);
571
572        // What the page offers is what may be written. The page is rendered
573        // from the arguments the action was asked with, through the context the
574        // write will go through, so what is checked is the page the reader was
575        // looking at: a button that is disabled, or that belongs to some other
576        // row, offers nothing.
577        let page = self.render(&args, ctx)?;
578        page.one_body(self.name())?;
579        page.no_form_answers_a_parameter(self.name(), &params)?;
580        if !page.offers(name, &args) {
581            return Err(ApiError::new(
582                404,
583                format!(
584                    "the view \"{}\" offers no action called \"{name}\" about what was asked",
585                    self.name()
586                ),
587            ));
588        }
589
590        let confirmation = self.act(name, &Fields(request.fields), &args, ctx)?;
591        serde_json::to_string(&ActionReply {
592            confirmation: &confirmation,
593        })
594        .map_err(|e| ApiError::server(e.to_string()))
595    }
596}
597
598/// `GET /api/views/<view>`. The parameters travel with every answer rather
599/// than from an endpoint of their own: they are rebuilt from the tables each
600/// time and may have changed, and one round trip is enough.
601#[derive(Serialize)]
602struct ViewPayload<'a> {
603    view: &'a str,
604    title: &'a str,
605    params: Vec<Param>,
606    args: ViewArgs,
607    #[serde(skip_serializing_if = "Option::is_none")]
608    note: Option<String>,
609    sections: Vec<Section>,
610    #[serde(skip_serializing_if = "Vec::is_empty")]
611    groups: Vec<CardGroup>,
612    #[serde(skip_serializing_if = "Option::is_none")]
613    detail: Option<Detail>,
614}
615
616/// The body of `POST /api/views/<view>/actions/<name>`: what the form was
617/// filled in with. The arguments travel in the address, as they do for a
618/// render, so one rule settles them for both.
619#[derive(Deserialize)]
620struct ActionRequest {
621    #[serde(default)]
622    fields: BTreeMap<String, String>,
623}
624
625/// What an action answers with: the sentence the reader is shown. What the page
626/// now says is the page's to answer, and the browser asks for it again.
627#[derive(Serialize)]
628struct ActionReply<'a> {
629    confirmation: &'a str,
630}
631
632#[cfg(test)]
633mod tests {
634    use serde_json::{Value, json};
635
636    use super::*;
637    use crate::fixture;
638    use crate::page::{
639        Button, Card, CardGroup, DetailRow, DetailSection, Field, Form, Status, Tone,
640    };
641    use crate::schema::Column;
642
643    fn query(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
644        pairs
645            .iter()
646            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
647            .collect()
648    }
649
650    fn fields(pairs: &[(&str, &str)]) -> Fields {
651        Fields(
652            pairs
653                .iter()
654                .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
655                .collect(),
656        )
657    }
658
659    #[test]
660    fn an_address_that_names_a_parameter_is_taken_at_its_word() {
661        let params = vec![Param::select("branch", "Branch", ["cen", "est"]).default("cen")];
662        let args = ViewArgs::resolve(&query(&[("branch", "est")]), &params);
663        assert_eq!(args.get("branch"), Some("est"));
664    }
665
666    #[test]
667    fn a_parameter_the_address_leaves_out_falls_back_to_its_default() {
668        let params = vec![Param::select("branch", "Branch", ["cen"]).default("cen")];
669        let args = ViewArgs::resolve(&query(&[]), &params);
670        assert_eq!(args.get("branch"), Some("cen"));
671    }
672
673    #[test]
674    fn a_parameter_with_no_default_is_simply_absent() {
675        let params = vec![Param::string("who", "Borrower")];
676        let args = ViewArgs::resolve(&query(&[]), &params);
677        assert_eq!(args.get("who"), None);
678        assert_eq!(args.get_or("who", "anyone"), "anyone");
679        assert!(args.is_empty());
680    }
681
682    #[test]
683    fn a_value_the_options_no_longer_offer_falls_back_to_the_default() {
684        // Which is what a dependent parameter needs: the subgenre chosen under
685        // the last genre is no answer to the genre being asked about now.
686        let params = vec![Param::select("subgenre", "Subgenre", ["Memoir"]).default("Memoir")];
687        let args = ViewArgs::resolve(&query(&[("subgenre", "Natural History")]), &params);
688        assert_eq!(args.get("subgenre"), Some("Memoir"));
689    }
690
691    #[test]
692    fn a_value_nothing_offers_and_nothing_replaces_is_left_alone() {
693        let params = vec![Param::select("subgenre", "Subgenre", ["Memoir"])];
694        let args = ViewArgs::resolve(&query(&[("subgenre", "Natural History")]), &params);
695        assert_eq!(args.get("subgenre"), Some("Natural History"));
696    }
697
698    #[test]
699    fn a_select_that_offers_nothing_takes_whatever_it_is_given() {
700        let params = vec![Param::select("branch", "Branch", Vec::<String>::new()).default("cen")];
701        let args = ViewArgs::resolve(&query(&[("branch", "anything")]), &params);
702        assert_eq!(args.get("branch"), Some("anything"));
703    }
704
705    #[test]
706    fn a_parameter_cleared_on_purpose_stays_cleared() {
707        // An empty value is a value: refilling the default would make a text
708        // parameter impossible to clear.
709        let params = vec![Param::string("who", "Borrower").default("Ada")];
710        let args = ViewArgs::resolve(&query(&[("who", "")]), &params);
711        assert_eq!(args.get("who"), Some(""));
712    }
713
714    #[test]
715    fn a_key_no_parameter_names_is_kept_for_a_view_that_wants_it() {
716        let params = vec![Param::select("branch", "Branch", ["cen"]).default("cen")];
717        let args = ViewArgs::resolve(&query(&[("sort", "due")]), &params);
718        assert_eq!(args.get("sort"), Some("due"));
719        assert_eq!(args.get("branch"), Some("cen"));
720        assert_eq!(
721            args.iter().collect::<Vec<_>>(),
722            vec![("branch", "cen"), ("sort", "due")]
723        );
724        assert_eq!(args.len(), 2);
725    }
726
727    #[test]
728    fn a_parameter_serializes_to_the_documented_shape() {
729        let param = Param::select(
730            "branch",
731            "Branch",
732            [SelectOption::labelled("cen", "Central")],
733        )
734        .default("cen");
735
736        assert_eq!(
737            serde_json::to_value(&param).unwrap(),
738            json!({ "key": "branch", "label": "Branch", "type": "select",
739                    "options": [{ "value": "cen", "label": "Central" }],
740                    "default": "cen" })
741        );
742
743        assert_eq!(
744            serde_json::to_value(Param::string("who", "Borrower")).unwrap(),
745            json!({ "key": "who", "label": "Borrower", "type": "string" })
746        );
747    }
748
749    #[test]
750    fn a_hidden_parameter_says_so_and_is_settled_like_any_other() {
751        let param = Param::string("codename", "Codename")
752            .default("teare")
753            .hidden();
754        assert_eq!(
755            serde_json::to_value(&param).unwrap(),
756            json!({ "key": "codename", "label": "Codename", "type": "string",
757                    "default": "teare", "hidden": true })
758        );
759
760        let args = ViewArgs::resolve(&query(&[]), &[param]);
761        assert_eq!(args.get("codename"), Some("teare"));
762    }
763
764    #[test]
765    fn a_field_is_read_as_the_kind_of_answer_it_asked_for() {
766        let filled = fields(&[
767            ("borrower", "  Ada Ferreira  "),
768            ("days", "21"),
769            ("rating", "4.5"),
770            ("from", "2026-09-21"),
771        ]);
772        assert_eq!(filled.text("borrower"), "Ada Ferreira");
773        assert_eq!(filled.integer("days").unwrap(), 21);
774        assert_eq!(filled.number("rating").unwrap(), 4.5);
775        assert_eq!(filled.date("from").unwrap(), "2026-09-21");
776        assert_eq!(filled.get("days"), Some("21"));
777        assert_eq!(
778            filled.iter().map(|(k, _)| k).collect::<Vec<_>>(),
779            vec!["borrower", "days", "from", "rating"]
780        );
781    }
782
783    #[test]
784    fn a_field_the_form_did_not_carry_reads_as_empty_text() {
785        let empty = fields(&[]);
786        assert_eq!(empty.text("borrower"), "");
787        assert_eq!(empty.get("borrower"), None);
788    }
789
790    #[test]
791    fn a_field_that_does_not_parse_is_a_bad_request_naming_it() {
792        let filled = fields(&[
793            ("days", "a fortnight"),
794            ("from", "2026-13-01"),
795            ("empty", ""),
796        ]);
797
798        let days = filled.integer("days").unwrap_err();
799        assert_eq!(days.status, 400);
800        assert!(days.message.contains("days"), "{}", days.message);
801        assert!(days.message.contains("a fortnight"), "{}", days.message);
802
803        // A whole number is not a number in general: 4.5 days is not 4 days.
804        assert_eq!(
805            fields(&[("days", "4.5")])
806                .integer("days")
807                .unwrap_err()
808                .status,
809            400
810        );
811        assert_eq!(fields(&[("days", "4.5")]).number("days").unwrap(), 4.5);
812
813        // A float that is not a finite one parses and is refused all the same:
814        // a row holding it serialises to null, which would put a wrong value
815        // in a file rather than say the answer was no good.
816        for text in ["inf", "-inf", "infinity", "NaN", "nan"] {
817            let refused = fields(&[("rating", text)]).number("rating").unwrap_err();
818            assert_eq!(refused.status, 400, "{text}");
819            assert!(refused.message.contains("rating"), "{text}");
820        }
821
822        // The shape and the ranges both have to hold.
823        assert_eq!(filled.date("from").unwrap_err().status, 400);
824        for bad in [
825            "",
826            "2026-9-1",
827            "26-09-01",
828            "2026-09-32",
829            "2026-00-01",
830            "not a date",
831            "2026-09-01-02",
832        ] {
833            assert!(!is_date(bad), "{bad} read as a date");
834        }
835        for good in ["2026-09-01", "1999-12-31", "2026-02-30"] {
836            assert!(is_date(good), "{good} did not read as a date");
837        }
838
839        let empty = filled.integer("empty").unwrap_err();
840        assert!(empty.message.contains("left empty"), "{}", empty.message);
841    }
842
843    // ── Views of cards and of one thing ─────────────────────────────────────
844
845    /// A view of each body, decided by the `body` argument, and one action.
846    struct Shelf;
847
848    impl ViewLogic for Shelf {
849        fn name(&self) -> &'static str {
850            "shelf"
851        }
852
853        fn title(&self) -> &'static str {
854            "Shelf"
855        }
856
857        fn params(&self, _ctx: &Context, _asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
858            Ok(vec![
859                Param::string("body", "Body").default("cards").hidden(),
860            ])
861        }
862
863        fn render(&self, args: &ViewArgs, _ctx: &Context) -> Result<ViewData, ApiError> {
864            let card = |title: &str| {
865                Card::new(title.to_string()).status(Status::new("On the shelf", Tone::Good))
866            };
867            match args.get_or("body", "cards") {
868                "cards" => Ok(ViewData::new()
869                    .note("Two of them.")
870                    .group(CardGroup::new("Here").cards([card("Nine Doors"), card("Moss")]))
871                    .group(CardGroup::new("Elsewhere"))),
872                "detail" => Ok(ViewData::new().detail(
873                    Detail::new("Nine Doors").section(
874                        DetailSection::main("Lend it").row(
875                            DetailRow::new("Central").button(Button::form(
876                                "Lend it out",
877                                Form::new("lend")
878                                    .arg("title", "Nine Doors")
879                                    .field(Field::number("days", "Days").default(21)),
880                            )),
881                        ),
882                    ),
883                )),
884                "both" => Ok(ViewData::new()
885                    .group(CardGroup::new("Here").card(card("Moss")))
886                    .detail(Detail::new("Moss"))),
887                _ => Ok(ViewData::new().section(Section::new([Column::string("title", "Title")]))),
888            }
889        }
890
891        fn act(
892            &self,
893            name: &str,
894            fields: &Fields,
895            args: &ViewArgs,
896            _ctx: &Context,
897        ) -> Result<String, ApiError> {
898            let days = fields.integer("days")?;
899            Ok(format!(
900                "{name}: {} is out for {days} days.",
901                args.get_or("title", "nothing")
902            ))
903        }
904    }
905
906    fn page(asked: &[(&str, &str)]) -> Value {
907        let dir = fixture::temp_dir();
908        let json = Shelf.handle_get(&query(asked), &dir.context()).unwrap();
909        serde_json::from_str(&json).unwrap()
910    }
911
912    #[test]
913    fn a_page_of_cards_carries_its_groups_and_no_sections() {
914        let shown = page(&[("body", "cards")]);
915        assert_eq!(shown["note"], "Two of them.");
916        assert_eq!(shown["sections"], json!([]));
917        assert!(shown.get("detail").is_none());
918        assert_eq!(shown["groups"][0]["heading"], "Here");
919        assert_eq!(shown["groups"][0]["cards"][0]["title"], "Nine Doors");
920        // The empty group was dropped rather than drawn.
921        assert_eq!(shown["groups"].as_array().unwrap().len(), 1);
922    }
923
924    #[test]
925    fn a_page_of_one_thing_carries_a_detail_and_no_groups() {
926        let shown = page(&[("body", "detail")]);
927        assert_eq!(shown["detail"]["title"], "Nine Doors");
928        assert_eq!(shown["sections"], json!([]));
929        assert!(shown.get("groups").is_none());
930    }
931
932    #[test]
933    fn a_page_of_rows_still_carries_the_shape_it_always_did() {
934        let shown = page(&[("body", "rows")]);
935        assert_eq!(shown["sections"][0]["columns"][0]["field"], "title");
936        assert!(shown.get("groups").is_none());
937        assert!(shown.get("detail").is_none());
938    }
939
940    #[test]
941    fn a_view_that_answers_two_ways_at_once_is_refused() {
942        let dir = fixture::temp_dir();
943        let failure = Shelf
944            .handle_get(&query(&[("body", "both")]), &dir.context())
945            .unwrap_err();
946        assert_eq!(failure.status, 500);
947        assert!(failure.message.contains("shelf"), "{}", failure.message);
948        assert!(
949            failure.message.contains("groups of cards")
950                && failure.message.contains("one thing in detail"),
951            "{}",
952            failure.message
953        );
954    }
955
956    #[test]
957    fn an_action_the_page_offers_writes_and_says_what_it_did() {
958        let dir = fixture::temp_dir();
959        let json = Shelf
960            .handle_action(
961                "lend",
962                &query(&[("body", "detail"), ("title", "Nine Doors")]),
963                r#"{"fields":{"days":"14"}}"#,
964                &dir.context(),
965            )
966            .unwrap();
967        assert_eq!(
968            serde_json::from_str::<Value>(&json).unwrap(),
969            json!({ "confirmation": "lend: Nine Doors is out for 14 days." })
970        );
971    }
972
973    #[test]
974    fn an_action_the_page_does_not_offer_is_refused() {
975        let dir = fixture::temp_dir();
976        let unknown = Shelf
977            .handle_action(
978                "burn-it",
979                &query(&[("body", "detail")]),
980                r#"{"fields":{}}"#,
981                &dir.context(),
982            )
983            .unwrap_err();
984        assert_eq!(unknown.status, 404);
985        assert!(unknown.message.contains("burn-it"), "{}", unknown.message);
986
987        // The same action on a page that has no buttons at all: a page of cards
988        // offers nothing to write, whatever another page of the same view does.
989        let elsewhere = Shelf
990            .handle_action(
991                "lend",
992                &query(&[("body", "cards")]),
993                r#"{"fields":{"days":"14"}}"#,
994                &dir.context(),
995            )
996            .unwrap_err();
997        assert_eq!(elsewhere.status, 404);
998    }
999
1000    #[test]
1001    fn an_action_about_a_row_the_page_does_not_offer_is_refused() {
1002        // The page offers "lend" for Nine Doors and for nothing else, because
1003        // that is the row its one button was built for. The action's name is
1004        // right and its fields are right; the row is not.
1005        let dir = fixture::temp_dir();
1006        let wrong_row = Shelf
1007            .handle_action(
1008                "lend",
1009                &query(&[("body", "detail"), ("title", "Moss")]),
1010                r#"{"fields":{"days":"14"}}"#,
1011                &dir.context(),
1012            )
1013            .unwrap_err();
1014        assert_eq!(wrong_row.status, 404);
1015        assert!(wrong_row.message.contains("lend"), "{}", wrong_row.message);
1016
1017        // Leaving the row out entirely is no better: the button's arguments
1018        // have to be among what was asked, not merely not contradicted.
1019        let no_row = Shelf
1020            .handle_action(
1021                "lend",
1022                &query(&[("body", "detail")]),
1023                r#"{"fields":{"days":"14"}}"#,
1024                &dir.context(),
1025            )
1026            .unwrap_err();
1027        assert_eq!(no_row.status, 404);
1028    }
1029
1030    #[test]
1031    fn a_form_that_answers_one_of_the_views_own_questions_is_refused() {
1032        // `body` is what this view's parameter is keyed, so a form carrying it
1033        // would send the action to a page other than the one it is on—and that
1034        // other page is what the offer would be checked against.
1035        struct Crossed;
1036        impl ViewLogic for Crossed {
1037            fn name(&self) -> &'static str {
1038                "crossed"
1039            }
1040            fn title(&self) -> &'static str {
1041                "Crossed"
1042            }
1043            fn params(&self, _ctx: &Context, _asked: &ViewArgs) -> Result<Vec<Param>, ApiError> {
1044                Ok(vec![Param::string("body", "Body").default("detail")])
1045            }
1046            fn render(&self, _args: &ViewArgs, _ctx: &Context) -> Result<ViewData, ApiError> {
1047                Ok(ViewData::new().detail(Detail::new("Nine Doors").section(
1048                    DetailSection::main("Lend it").row(DetailRow::new("Central").button(
1049                        Button::form("Lend it out", Form::new("lend").arg("body", "cards")),
1050                    )),
1051                )))
1052            }
1053        }
1054
1055        let dir = fixture::temp_dir();
1056        let rendered = Crossed.handle_get(&query(&[]), &dir.context()).unwrap_err();
1057        assert_eq!(rendered.status, 500);
1058        assert!(rendered.message.contains("crossed"), "{}", rendered.message);
1059        assert!(rendered.message.contains("body"), "{}", rendered.message);
1060
1061        // The same page is refused on the way to a write, so a consumer cannot
1062        // meet it for the first time through an action.
1063        let written = Crossed
1064            .handle_action("lend", &query(&[]), r#"{"fields":{}}"#, &dir.context())
1065            .unwrap_err();
1066        assert_eq!(written.status, 500);
1067    }
1068
1069    #[test]
1070    fn an_action_whose_field_does_not_parse_is_a_bad_request() {
1071        let dir = fixture::temp_dir();
1072        let failure = Shelf
1073            .handle_action(
1074                "lend",
1075                &query(&[("body", "detail"), ("title", "Nine Doors")]),
1076                r#"{"fields":{"days":"a fortnight"}}"#,
1077                &dir.context(),
1078            )
1079            .unwrap_err();
1080        assert_eq!(failure.status, 400);
1081        assert!(failure.message.contains("days"), "{}", failure.message);
1082    }
1083
1084    #[test]
1085    fn an_action_with_an_unreadable_body_is_a_bad_request() {
1086        let dir = fixture::temp_dir();
1087        for body in ["", "not json", r#"{"fields":{"days":14}}"#] {
1088            let failure = Shelf
1089                .handle_action(
1090                    "lend",
1091                    &query(&[("body", "detail"), ("title", "Nine Doors")]),
1092                    body,
1093                    &dir.context(),
1094                )
1095                .unwrap_err();
1096            assert_eq!(failure.status, 400, "{body}");
1097        }
1098    }
1099
1100    #[test]
1101    fn a_view_with_no_action_of_its_own_refuses_to_write() {
1102        struct Plain;
1103        impl ViewLogic for Plain {
1104            fn name(&self) -> &'static str {
1105                "plain"
1106            }
1107            fn title(&self) -> &'static str {
1108                "Plain"
1109            }
1110            fn render(&self, _args: &ViewArgs, _ctx: &Context) -> Result<ViewData, ApiError> {
1111                Ok(ViewData::new())
1112            }
1113        }
1114
1115        let dir = fixture::temp_dir();
1116        // The router's own check comes first, so the default `act` is reached
1117        // only by calling it.
1118        let refused = Plain
1119            .act("lend", &fields(&[]), &ViewArgs::default(), &dir.context())
1120            .unwrap_err();
1121        assert_eq!(refused.status, 500);
1122        assert!(refused.message.contains("lend"), "{}", refused.message);
1123    }
1124}