//! The public fee calculator at `/pricing`, described. //! //! `1e35bc8a`. The first screen on `base.html` to own its whole document, and //! the first described screen this server serves to a reader with no session. //! Both firsts are consequences of the page rather than goals: `/pricing` is a //! marketing page with no writes on it, so there is no session to resolve and //! nothing for the Askama half to keep. //! //! It replaces `templates/pages/pricing.html`, `templates/partials/fee_calculator.html`, //! `landing::pricing_page`, `landing::pricing_compare` and the `` //! custom element with its island script. The arithmetic is untouched: //! [`crate::fee_calculator`] still computes every number from //! `docs/business/assumptions.toml`, and nothing about the fee model runs in //! the browser now any more than it did before. //! //! # The recompute is the region's, not a dial's //! //! `Slot::consults` (quasicoherent `cb62a9dc`). The calculator region names the //! route, the wait and the region the answer lands in; the values it sends are //! the questions it contains, gathered by containment. That is the whole of the //! shipped `hx-trigger` block -- five `input changed delay:300ms from:#id` //! clauses and an `hx-include` listing the same five ids again -- said once, //! with nothing naming a dial. //! //! It also decides what `` was. That element swapped the two //! `data-price-*` values on four radios in the DOM and re-fired the recompute, //! so the browser held a price the server also knew. Under a region consult it //! is one more dial inside the region: a two-option question, sent with the //! rest, and [`compare`] computes on the founder price or the list price. No //! custom element, no DOM rewriting, and one place that knows what a tier //! costs. //! //! # The tier radio sends a tier, not a price //! //! The shipped radio's `value` was the price in dollars, which was the only //! thing that could work while a script rewrote it. With the mode as its own //! dial the price is a function of both, so the value is the tier's own name //! and [`Dials`] resolves the pair. An old shared link carrying `?tier=16` //! still lands where it did: a `tier` that parses as a number is read as //! dollars, which is what it meant. //! //! # What the results panel says now, and what it stopped saying //! //! The partial hand-drew a two-segment bar with a marker, a crossover tick, an //! axis and a legend, and `fee_calculator::Scale` existed to compute the four //! percentages it positioned them with. All of it said one thing: where the //! reader's volume sits against the crossover. That is a proportion of a set, //! which is [`quasi_router::Meter`] -- with the reader's sales as `done` and //! the crossover as `total`, so passing it overflows the bar, which //! [`layout::Meter::done`] names as the case worth drawing. `Scale` goes with //! the partial, and [`crate::fee_calculator::Crossover`] carries the two counts //! the meter is drawn from, already whole. //! //! # Two deliberate parity differences //! //! - **The footer's two script-driven links are not here.** `base.html`'s //! footer ends with "What's new" and "Shortcuts", both `` bound by `actions-pages.js`. A described act names a //! route or a host behaviour the description can state, and "run the function //! registered under this string" is neither. Both are reachable from every //! other page on the site; `9f2ac7d1` is the vocabulary question. //! - **The dials carry `USD` rather than a leading `$`.** `Field::unit` is //! drawn after the value in every renderer, and `10 $` is the reading to //! avoid. makeover-layout 0.33.0 ruled a unit is a fact about the value and //! not part of the label, so the symbol does not move into the question's //! name; a leading unit is still unsaid, which is filed rather than worked //! around here. use std::time::Duration; use makeover_layout as layout; use quasi_declare::declare; use quasi_router::{Action, Choice, Consult, Document, Node, Request, Response, RouteError}; use quasi_webview::Webview; use crate::Billing; use crate::fee_calculator::{self, Inputs, Outcome, Verdict}; use crate::tier_prices::TierPrices; /// The address the screen answers, and the one `landing::pricing_page` gives up. pub const PATH: &str = "/pricing"; /// The route the calculator asks when a dial moves, as the reader's browser /// spells it. The router inside the nest sees it with [`PATH`] taken off. const COMPARE: &str = "/pricing/compare"; /// The region that holds every dial and the results panel. /// /// Its id is what the shipped markup called it, and nothing outside this module /// reaches for it: the include list it used to need is gone. const CALCULATOR: &str = "pricing-calculator"; /// The region holding the whole page, and what the skip link jumps to. const PAGE: &str = "pricing-page"; /// How wide the page runs. `pricing.html` said it as `centered-page`; it is a /// described property now, and the renderer turns it back into that class. const MEASURE: layout::Measure = layout::Measure::Contained; /// The region a recompute replaces. /// /// `pub` so the tests name the constant rather than transcribing it. pub const RESULTS: &str = "results-panel"; /// How long a dial must stand still before the calculator is re-asked. /// /// The shipped `delay:300ms`, unchanged. It is the description's for /// [`Consult::after`]'s reason: how expensive a question is to ask is the /// route's own fact, and no renderer can know it. const SETTLES: Duration = Duration::from_millis(300); // The names every dial submits under. `item_price`, `sales`, `other_pct` and // `other_per_sale` are what the shipped inputs were named and what a shared // calculator URL carries, so they are kept exactly. const ITEM_PRICE: &str = "item_price"; const SALES: &str = "sales"; const TIER: &str = "tier"; const PRICE_MODE: &str = "price_mode"; const OTHER_PCT: &str = "other_pct"; const OTHER_PER_SALE: &str = "other_per_sale"; /// Calculate on the half-price founder rate. const FOUNDER: &str = "founder"; /// Calculate on the standing list rate. const LIST: &str = "list"; /// The state one `/pricing` request is answered against. /// /// Not [`super::Viewer`]: that factory resolves a session and refuses without /// one, which is right for every screen behind a login and wrong for a /// marketing page. Nothing here is per-reader, so the adapter holds one of /// these for the life of the process rather than building one per request. pub struct Pricing { /// Stripe's published rates, the tier table and the calculator's opening /// positions, all derived from `assumptions.toml` at startup. pub billing: Billing, /// Whether the half-price founder window is open. Decides whether the mode /// question is asked at all, and which rate the page opens on. pub founder_window_open: bool, /// Whether `/changelog` resolves, which is the one conditional link in the /// site footer. pub changelog_published: bool, } /// Where every dial sits for one request. /// /// The query string merged over the configured defaults, then clamped. This is /// `landing::PricingCompareQuery::resolve` moved intact, less the two display /// strings the template needed: a described field carries its own value. struct Dials { inputs: Inputs, /// Which tier is picked, by name. The price it resolves to is on `inputs`. tier: &'static str, /// Which rate the tier prices are read at. mode: &'static str, } impl Dials { /// Read the dials out of what the control was offered under. fn read(state: &Pricing, carried: &quasi_router::Params) -> Self { let prices = &state.billing.tier_prices; let number = |name: &str| { carried .get(name) .map(str::trim) .and_then(|v| v.parse::().ok()) }; // With the window shut there is one rate and no question about it, so a // mode arriving in the query is ignored rather than honoured: a shared // link should not be able to price a window that has closed. let mode = match (state.founder_window_open, carried.get(PRICE_MODE)) { (false, _) => LIST, (true, Some(LIST)) => LIST, (true, _) => FOUNDER, }; // A `tier` that parses as a number is a link written before the tier // radio sent a name, when the value was the price itself. Honoured as // dollars so those links land where they did. let (tier, tier_cost) = match carried.get(TIER) { Some(raw) => match raw.trim().parse::() { Ok(dollars) if dollars >= 0.0 => (Tier::BASIC.key, dollars), _ => { let tier = Tier::named(raw).unwrap_or(Tier::BASIC); (tier.key, f64::from(tier.price(prices, mode))) } }, None => (Tier::BASIC.key, f64::from(Tier::BASIC.price(prices, mode))), }; let mut inputs = state .billing .fee_calculator .default_inputs(f64::from(prices.basic_std)); if let Some(v) = number(ITEM_PRICE) { inputs.item_price = v; } if let Some(v) = number(SALES) { inputs.sales_per_month = v; } // Typed as a whole percent and held as a fraction, which is the one // conversion this page does. The dial multiplies back out when it // renders, so the reader sees what they typed. if let Some(v) = number(OTHER_PCT) { inputs.other_pct = v / 100.0; } if let Some(v) = number(OTHER_PER_SALE) { inputs.other_per_sale = v; } inputs.tier_cost = tier_cost; Self { inputs: state.billing.fee_calculator.sanitize(inputs), tier, mode, } } /// What the calculator makes of these positions. fn outcome(&self, state: &Pricing) -> Outcome { state.billing.fee_calculator.compute(self.inputs) } } /// One tier, as the radio needs it. /// /// The four are a table here rather than eight branches in a template: the /// shipped markup spelled every price twice, once as the radio's value and once /// as the card's display, and a `{% if founder_window_open %}` around each. struct Tier { /// What the radio sends, and what a shared link carries. key: &'static str, /// What the card reads. label: &'static str, /// What the tier is for, in the reader's terms. The envelope comes off /// [`TierPrices`] and is spliced in. fits: &'static str, } impl Tier { const BASIC: Self = Self { key: "basic", label: "Basic", fits: "Fits text, blogs, newsletters.", }; const SMALL_FILES: Self = Self { key: "small_files", label: "Small Files", fits: "Fits audio, plugins, binaries.", }; const BIG_FILES: Self = Self { key: "big_files", label: "Big Files", fits: "Fits video, games, large software.", }; const EVERYTHING: Self = Self { key: "everything", label: "Everything", fits: "Big Files envelope plus first access to high-cost features as they ship.", }; /// The four, in the order the cards are read. const ALL: [Self; 4] = [ Self::BASIC, Self::SMALL_FILES, Self::BIG_FILES, Self::EVERYTHING, ]; /// The tier this key names, if it names one. fn named(key: &str) -> Option { Self::ALL.into_iter().find(|tier| tier.key == key) } /// What it costs a month at this rate. fn price(&self, prices: &TierPrices, mode: &str) -> i32 { let founder = mode == FOUNDER; match self.key { "small_files" => { if founder { prices.small_files_founder } else { prices.small_files_std } } "big_files" => { if founder { prices.big_files_founder } else { prices.big_files_std } } "everything" => { if founder { prices.everything_founder } else { prices.everything_std } } // Basic, and the arm a tier added upstream lands in: the cheapest // envelope is a wrong price rather than a panic on a public page. _ => { if founder { prices.basic_founder } else { prices.basic_std } } } } /// The second line under the tier's name: what it costs, what it holds and /// what that suits. fn detail(&self, prices: &TierPrices, mode: &str) -> String { let price = self.price(prices, mode); match self.key { "small_files" => format!( "${price}/mo. {}/file, {} total. {}", prices.small_files_per_file, prices.small_files_total, self.fits ), "big_files" => format!( "${price}/mo. {}/file, {} total. {}", prices.big_files_per_file, prices.big_files_total, self.fits ), // Everything's envelope is Big Files', which its own sentence says, // so it names no caps of its own. "everything" => format!("${price}/mo. {}", self.fits), _ => format!( "${price}/mo. {}/file, {} total. {}", prices.basic_per_file, prices.basic_total, self.fits ), } } } /// The whole page. pub fn screen(state: &Pricing, request: Request) -> Result { // The bag is moved out of the request rather than borrowed from it: the // handler signature is quasi's, so the request arrives owned and nothing // else here reads it. let carried = request.carried; let dials = Dials::read(state, &carried); Ok(page(state, &dials).into()) } /// A recompute: the results panel and nothing else. /// /// Pure arithmetic over the dials, no session and no state change, which is why /// it is a GET and why the screen it belongs to needs no token. pub fn compare(state: &Pricing, request: Request) -> Result { let carried = request.carried; let dials = Dials::read(state, &carried); Ok(Response::fragment( RESULTS, Node::Region(results(&dials.outcome(state))), )) } declare! { /// The described document, top to bottom. /// /// `measured` is what `pricing.html` said as `class="centered-page"`, read /// off the screen rather than off a route table, and `documented` is that /// class landing on the body. On the screen and not on the shell /// (quasicoherent `ee1882e0`): the shell is built once and `Arc`'d at /// adapter construction, so a class set there is a constant for every /// screen that adapter ever serves. /// /// `summarised` is the whole of what `base.html` put in /// ``, including the fee sentence, because this /// one string is now all three tags: the social pair and the plain one. shape page(state: &Pricing, dials: &Dials) -> Screen; screen list_detail "Pricing Calculator - Makenotwork" false { measured MEASURE; documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); summarised "Work out what you keep on every sale here, against whatever the \ platform you sell on now deducts. 0% platform fee, only the \ payment processor's ~3%."; region PAGE as Pane { page "Pricing Calculator"; text "See what you keep on every sale."; include calculator(state, dials); act "Join the Alpha" to get "/join"; list { row "Home" { activate to get "/"; } row "Browse as guest" { activate to get "/discover"; } } include footer(state); } } } declare! { /// Every dial, and the panel they recompute. /// /// The region names the route, the wait and the region the answer lands in; /// the values it sends are the questions it contains, gathered by /// containment. That is the whole of the shipped `hx-trigger` block -- five /// `input changed delay:300ms from:#id` clauses and an `hx-include` listing /// the same five ids again -- said once, with nothing naming a dial. /// /// The dials stand in the region rather than in a form, because there is no /// form: nothing on this page submits, and a consulting region is what asks. shape calculator(state: &Pricing, dials: &Dials) -> Slot; region CALCULATOR as Group { consulting Consult::new(Action::get(COMPARE).replacing(RESULTS)).after(SETTLES); section "What you sell"; include dial( ITEM_PRICE, "Price per item", dials.inputs.item_price, fee_calculator::MAX_ITEM_PRICE, "1", "USD" ); include dial( SALES, "Sales per month", dials.inputs.sales_per_month, fee_calculator::MAX_SALES, "1", "/mo" ); // Same wording as the landing tagline, because it is the same offer. banner layout::Tone::Success "Founder pricing open. Half off creator tiers, locked for life. The \ calculator is using founder prices." when state.founder_window_open; section "Your content tier"; text "Every tier is the complete platform: profile, project pages, forum, \ discovery, memberships, analytics, full data export. The tier picks \ the file-size envelope, not the feature set."; // Only while there are two rates to choose between. With the window shut // the page is what it was before either the toggle or this question // existed. field Radio PRICE_MODE "Calculate with" when state.founder_window_open { option Choice::new(FOUNDER, "Founder price"); option Choice::new(LIST, "List price"); value dials.mode; } field Radio TIER "Content tier" { for tier in Tier::ALL.iter() { option Choice::new(tier.key, tier.label) .detailing(tier.detail(&state.billing.tier_prices, dials.mode)); } value dials.tier; } section "Wherever else you sell"; text "Fill in what the other platform takes. Use its total deduction, its \ own cut plus any payment processing it adds, which is the figure you \ can read off a payout. We hold no rates for anyone but ourselves, so \ nothing here can go stale or be picked to flatter us."; include dial( OTHER_PCT, "Their cut", other_pct_shown(dials), other_pct_max(), "0.1", "%" ); include dial( OTHER_PER_SALE, "Their fee per sale", dials.inputs.other_per_sale, fee_calculator::MAX_OTHER_PER_SALE, "0.05", "USD" ); include results(&dials.outcome(state)); } } /// Their cut as the reader typed it. /// /// Held as a fraction and typed as a whole percent, which is the one conversion /// this page does. A supplier because multiplication is an expression and the /// form admits none; it hands back an `f64`, which keeps it out of the /// population. fn other_pct_shown(dials: &Dials) -> f64 { dials.inputs.other_pct * 100.0 } /// The same conversion for the bound. See [`other_pct_shown`]. fn other_pct_max() -> f64 { fee_calculator::MAX_OTHER_PCT * 100.0 } declare! { /// A dial: a bounded number holding what it holds, in the unit it is /// measured in. /// /// The bounds are `fee_calculator`'s own constants, so the box refuses what /// `FeeCalculator::sanitize` would clamp instead of silently disagreeing /// with it. `within` and not `Field::range`: the latter is the slider's /// constructor and these are typed boxes, so the bounds are a rule the /// answer is checked against rather than the control itself. shape dial( name: &'static str, label: &'static str, value: f64, max: f64, step: &'static str, unit: &'static str, ) -> Field; field Number name label { value fmt_dial(value); step step; unit unit; within "0" fmt_dial(max); } } declare! { /// The panel the recompute replaces. /// /// A `Slot` rather than a `Node` so the screen can nest it and [`compare`] /// can answer with it, which is the one thing both paths have to agree /// about. /// /// The table is three columns and three fixed rows, so the cells stay /// positional: a row reads against its heading without leaving the /// declaration, and every row carries the same three cells whatever the /// arithmetic said. shape results(outcome: &Outcome) -> Slot; region RESULTS as Pane { banner verdict_tone(outcome.verdict) &outcome.headline; table { column "Monthly" { width Fill; priority Essential; } column "Here" { width Content; priority Essential; } column "The other platform" { width Content; priority Essential; } cells { cell "You sell"; cell outcome.gross.clone(); cell outcome.gross.clone(); } cells { cell "You keep"; cell outcome.mnw_keep.clone(); cell outcome.other_keep.clone(); } cells { cell "Total fees"; cell outcome.mnw_rate.clone(); cell outcome.other_rate.clone(); } } // Where the reader's volume sits against the crossover, which is the // whole of what the hand-drawn two-segment bar said. Absent when there // is no crossover, because there is then no set to be a proportion of, // and the note below says why in words. An `Option` is an iterator of // at most one. for crossover in outcome.crossover.iter() { section "Where each one wins"; proportion crossover.sales crossover.at { label "sales a month to the crossover"; tone verdict_tone(outcome.verdict); } } for note in outcome.crossover_note.iter() { text note; } text "Our side of this uses the payment processor's published US card rates. \ Yours is whatever you type in, so check it against a real payout rather \ than a pricing page: some platforms quote their cut before processing \ and some after."; text "Selling small-ticket items? Payment processors charge a fixed fee per \ sale (~$0.30) that hits harder on $1-5 items. That is an industry-wide \ constraint and it applies wherever you sell. Bundling items into \ collections lets fans buy in groups at a single transaction cost \ instead of paying per-item processing."; } } /// What a verdict means, as every renderer already draws it. /// /// Named `verdict_tone` rather than `tone` because `tone` is a setting in a /// declaration body and a supplier wearing a setting's name reads as one. /// /// The template branched on `Verdict::css_class`, three class names this /// server's own stylesheet defined. A tone says the same thing in a word every /// host has. const fn verdict_tone(verdict: Verdict) -> layout::Tone { match verdict { Verdict::MnwAhead => layout::Tone::Success, Verdict::Even => layout::Tone::Neutral, Verdict::OtherAheadForNow | Verdict::OtherAhead => layout::Tone::Warning, } } declare! { /// The site footer, described. /// /// Here rather than in `base.html` because this screen owns its document. /// It stays in this module while it is the only such screen; the second one /// moves it out, and moving it is the cheaper half of that conversion. shape footer(state: &Pricing) -> Slot; region "site-footer" as Pane { list { row "Pricing" { activate to get "/pricing"; } row "Creators" { activate to get "/creators"; } row "Docs" { activate to get "/docs"; } row "Legal" { activate to get "/policy"; } row "Credits" { activate to get "/docs/credits"; } // Linked only while a published changelog project exists; the route // 404s otherwise. See `crate::changelog`. row "Changelog" when state.changelog_published { activate to get "/changelog"; } row "Contact" { activate to external "mailto:info@makenot.work"; } row "Status" { activate to get "/health"; } } text "(c) 2026 Make Creative, LLC"; } } /// Render a dial's value: no trailing zeros on a whole number, at most two /// decimals otherwise. `12.6`, `0.30` and `25` all read as typed. /// /// `landing::fmt_dial`, moved with the two dials that needed it. fn fmt_dial(v: f64) -> String { let s = format!("{v:.2}"); s.trim_end_matches('0').trim_end_matches('.').to_string() } /// The document this screen is drawn in. /// /// Everything from `` to ``, which is the part `base.html` /// owned for every other page on this site. Three things it has to carry that /// a region-sized screen never did: /// /// 1. **The body class.** `pricing.html` wrote /// `class="{{ shell::measure(Measure::Contained) }}"`, and the measure is a /// described property now ([`Screen::measured`]), so the class is read off /// the screen rather than off a route table. That is what `2790e5c4` asks /// for, answered from the description instead of from a mapping this server /// would have to keep in step with 74 templates. /// 2. **The head.** [`crate::shell`] already owns it for the Askama pages, and /// the same [`quasi_webview::Shell`] builds it here, so the two documents /// cannot drift. /// 3. **The tail.** `base.html` ends with a toast container and seven classic /// script shims that the `data-action` dispatcher resolves through. They are /// markup no description will name -- a script tag, and a container another /// script writes into -- which is what `Shell::with_body_last` is for. #[must_use] pub fn renderer() -> Webview { let shell = crate::shell::described() .with_body_first(crate::shell::skip_link(PAGE)) .with_body_last(crate::shell::body_last()) // What this site offers from everywhere, which today is one key // (`e0c0d991`). On the shell rather than on a screen because that is // the whole claim chrome makes: an affordance reachable from every // screen is not a fact about any one of them, and every screen // converted after this inherits it with no further work. // // It is also what gives `Outcome::Over` somewhere to land -- a renderer // emits the overlay container for an app that declares chrome, and an // app that declares none gets a swap that does nothing. .with_chrome(crate::quasi::shortcuts::chrome()); // The `Shell::head` append that stood here is gone as of quasi 0.80 // (quasicoherent `a0e16839`). It wrote the plain `` // by hand because `Screen::summarised` reached `og:description` and // `twitter:description` and stopped; it reaches all three now, off the one // string the screen already declares. An escape hatch spending itself on // something a described property carries is the escape hatch going unused. // Webview::new().with_shell(shell) } #[cfg(test)] mod tests { use super::*; use quasi_router::Slot; /// A calculator built from the canonical assumptions, at either rate. /// /// The real table and the real Stripe fees, because the thing under test is /// which of them a dial reaches rather than what they are: a fixture with /// invented prices would pass while the tier lookup read the wrong column. fn state(founder_window_open: bool) -> Pricing { crate::tier_prices::TierPrices::install_test_default(); Pricing { billing: Billing { payments: None, payment_caps: crate::payments::PaymentCapabilities::default(), tier_prices: crate::tier_prices::TierPrices::global().clone(), runway_config: crate::tier_prices::RunwayConfig { quarters: 0, last_updated_iso: String::new(), }, fee_calculator: crate::fee_calculator::FeeCalculator::load( "docs/business/assumptions.toml", ), }, founder_window_open, changelog_published: false, } } fn carrying(pairs: &[(&str, &str)]) -> quasi_router::Params { pairs.iter().copied().collect() } /// A dial position, spelled with a tolerance so `float_cmp` stays happy. /// The same helper `fee_calculator::tests` uses, for its reason. fn approx(got: f64, want: f64, what: &str) { assert!((got - want).abs() < 1e-9, "{what}: got {got}, want {want}"); } /// The whole of the shipped `hx-trigger`/`hx-include` block, said once. /// /// Five `input changed delay:300ms from:#id` clauses and five ids listed /// again to be sent. Here the region names the route, the wait and the /// landing place, and names no dial at all. #[test] fn the_region_asks_and_nothing_names_a_dial() { let state = state(true); let screen = page(&state, &Dials::read(&state, &carrying(&[]))); let asking = screen.consulting(); assert_eq!(asking.len(), 1, "one panel recomputes, not several"); let consult = &asking[0].consults[0]; assert_eq!(consult.action.route(), Some(COMPARE)); assert_eq!(consult.after, SETTLES); assert!( consult.sends.is_empty(), "a dial inside the region rides along by containment, so nothing \ should be named: {:?}", consult.sends ); } /// Every dial the recompute needs is inside the region that asks, which is /// what containment means here. Six with the window open, because the mode /// is a dial like any other. #[test] fn the_region_contains_every_dial_the_route_reads() { let state = state(true); let screen = page(&state, &Dials::read(&state, &carrying(&[]))); let names: Vec<&str> = screen.consulting()[0] .questions() .iter() .map(|field| field.name.as_str()) .collect(); assert_eq!( names, [ ITEM_PRICE, SALES, PRICE_MODE, TIER, OTHER_PCT, OTHER_PER_SALE ] ); } /// With the window shut there is one rate, so there is no question to ask /// about it and no control that does nothing. #[test] fn the_mode_is_not_asked_once_the_window_shuts() { let state = state(false); let screen = page(&state, &Dials::read(&state, &carrying(&[]))); let names: Vec<&str> = screen.consulting()[0] .questions() .iter() .map(|field| field.name.as_str()) .collect(); assert!(!names.contains(&PRICE_MODE), "{names:?}"); } /// The mode reaches the arithmetic. This is what `` did in /// the DOM, and the whole reason it could be deleted. #[test] fn the_mode_picks_which_rate_the_tier_costs() { let state = state(true); let prices = &state.billing.tier_prices; assert_ne!( prices.basic_founder, prices.basic_std, "the assumptions make this vacuous if the two rates are equal" ); let founder = Dials::read(&state, &carrying(&[(TIER, "basic")])); let list = Dials::read(&state, &carrying(&[(TIER, "basic"), (PRICE_MODE, LIST)])); approx( founder.inputs.tier_cost, f64::from(prices.basic_founder), "founder rate", ); approx( list.inputs.tier_cost, f64::from(prices.basic_std), "list rate", ); } /// A shut window prices at list whatever a shared link says, so a link /// cannot resurrect an offer that has ended. #[test] fn a_link_cannot_price_a_window_that_has_closed() { let state = state(false); let dials = Dials::read(&state, &carrying(&[(TIER, "basic"), (PRICE_MODE, FOUNDER)])); assert_eq!(dials.mode, LIST); approx( dials.inputs.tier_cost, f64::from(state.billing.tier_prices.basic_std), "a shut window prices at list", ); } /// The radio's value used to be the price itself, so a link written then /// carries dollars where a tier name goes now. #[test] fn an_older_link_carrying_a_price_is_read_as_dollars() { let state = state(true); let dials = Dials::read(&state, &carrying(&[(TIER, "24"), (SALES, "100")])); approx(dials.inputs.tier_cost, 24.0, "the price the link carried"); approx(dials.inputs.sales_per_month, 100.0, "sales"); } /// The cut is typed as a whole percent and held as a fraction, which is the /// one conversion this page does. #[test] fn the_cut_is_typed_whole_and_held_as_a_fraction() { let state = state(false); let dials = Dials::read(&state, &carrying(&[(OTHER_PCT, "12.6")])); approx(dials.inputs.other_pct, 0.126, "12.6% as a fraction"); } /// The class `pricing.html` carried, still on `` and now the /// screen's rather than the adapter's. /// /// quasicoherent `ee1882e0`. Asserted on the screen rather than on the /// emitted markup because that is where the change is: the renderer folding /// a document into a `` tag is quasi-webview's own test, and this /// server does not link `quasi-http` to call `Serves::screen` here. #[test] fn the_document_carries_the_class_the_template_carried() { let screen = page(&state(false), &Dials::read(&state(false), &carrying(&[]))); assert_eq!( screen.document.body_class.as_deref(), Some(crate::shell::body_class(MEASURE, &[]).as_str()) ); assert_eq!(screen.document.body_class.as_deref(), Some("centered-page")); use quasi_axum::Serves as _; let rendered = Webview::new().screen(&page( &state(false), &Dials::read(&state(false), &carrying(&[])), )); assert!(rendered.contains("class=\"centered-page\""), "{rendered}"); } /// `736f45a5`. The calculator answers in the page rather than over the /// wire, so there is no wait to draw and no spelling to carry. #[test] fn the_page_spells_no_spinner() { use quasi_axum::Serves as _; let rendered = Webview::new().screen(&page( &state(false), &Dials::read(&state(false), &carrying(&[])), )); for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] { assert!( !rendered.contains(spelling), "{spelling} survives in {rendered}" ); } } /// Where the reader sits against the crossover, drawn only when there is a /// crossover to sit against. #[test] fn the_panel_meters_the_crossover_only_when_there_is_one() { let state = state(false); let calculator = &state.billing.fee_calculator; let ahead = calculator.compute(Inputs { item_price: 25.0, sales_per_month: 40.0, tier_cost: 16.0, other_pct: 0.126, other_per_sale: 0.30, }); assert!(has_meter(&results(&ahead)), "a crossover with no meter"); // Their cut is below our processing, so no volume closes the gap and // there is no set for a proportion to be of. let never = calculator.compute(Inputs { item_price: 5.0, sales_per_month: 500.0, tier_cost: 16.0, other_pct: 0.01, other_per_sale: 0.0, }); assert!(!has_meter(&results(&never)), "metered against nothing"); } fn has_meter(slot: &Slot) -> bool { slot.body .iter() .any(|placed| matches!(placed.node, Node::Meter(_))) } }