//! 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 [`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. //! //! # 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_router::{ Action, Cell, Cells, Choice, Column, Consult, Document, Field, Meter, Node, RegionKind, Request, Response, RouteError, Row, Screen, Slot, }; 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))), )) } /// The described document, top to bottom. fn page(state: &Pricing, dials: &Dials) -> Screen { Screen::list_detail("Pricing Calculator - Makenotwork", false) .measured(MEASURE) // What `pricing.html` said as `class="centered-page"`, read off the // measure declared on the line above rather than off a route table. // // 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 -- // which is right for one page and silently wrong for the second, and // the second is what the conversion is producing. The empty slice is // `pricing.html` carrying nothing beside the measure, which is why this // screen needed no mapping at all. .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[]))) // 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. It carried only the first // sentence while the plain tag was appended separately. .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%.", ) .with( Slot::new(PAGE, RegionKind::Pane) .with(Node::page("Pricing Calculator")) .with(Node::text("See what you keep on every sale.")) .with(Node::Region(calculator(state, dials))) .with(Node::act("Join the Alpha", Action::get("/join"))) .with(Node::List { rows: vec![ Row::new("Home").activate(Action::get("/")), Row::new("Browse as guest").activate(Action::get("/discover")), ], more: None, }) .with(Node::Region(footer(state))), ) } /// Every dial, and the panel they recompute. fn calculator(state: &Pricing, dials: &Dials) -> Slot { let prices = &state.billing.tier_prices; let inputs = dials.inputs; let mut slot = Slot::group(CALCULATOR) .consulting(Consult::new(Action::get(COMPARE).replacing(RESULTS)).after(SETTLES)) .with(Node::section("What you sell")) .with(Node::field(dial( ITEM_PRICE, "Price per item", inputs.item_price, fee_calculator::MAX_ITEM_PRICE, "1", "USD", ))) .with(Node::field(dial( SALES, "Sales per month", inputs.sales_per_month, fee_calculator::MAX_SALES, "1", "/mo", ))); // Same wording as the landing tagline, because it is the same offer. if state.founder_window_open { slot = slot.with(Node::banner( layout::Tone::Success, "Founder pricing open. Half off creator tiers, locked for life. The \ calculator is using founder prices.", )); } slot = slot .with(Node::section("Your content tier")) .with(Node::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. if state.founder_window_open { slot = slot.with(Node::field( Field::radio( PRICE_MODE, "Calculate with", vec![ Choice::new(FOUNDER, "Founder price"), Choice::new(LIST, "List price"), ], ) .value(dials.mode), )); } slot = slot .with(Node::field( Field::radio( TIER, "Content tier", Tier::ALL .iter() .map(|tier| { Choice::new(tier.key, tier.label).detailing(tier.detail(prices, dials.mode)) }) .collect(), ) .value(dials.tier), )) .with(Node::section("Wherever else you sell")) .with(Node::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.", )) .with(Node::field(dial( OTHER_PCT, "Their cut", inputs.other_pct * 100.0, fee_calculator::MAX_OTHER_PCT * 100.0, "0.1", "%", ))) .with(Node::field(dial( OTHER_PER_SALE, "Their fee per sale", inputs.other_per_sale, fee_calculator::MAX_OTHER_PER_SALE, "0.05", "USD", ))) .with(Node::Region(results(&dials.outcome(state)))); slot } /// A dial: a bounded number holding what it holds, in the unit it is measured /// in. /// /// `min` and `max` are set on the struct rather than through a builder because /// `Field::range` is the slider's constructor and these are typed boxes: the /// bounds are a rule the answer is checked against, not the control itself. /// They are `fee_calculator`'s own constants, so the box refuses what /// `FeeCalculator::sanitize` would clamp instead of silently disagreeing with /// it. fn dial( name: &'static str, label: &'static str, value: f64, max: f64, step: &'static str, unit: &'static str, ) -> Field { let mut field = Field::new(layout::FieldKind::Number, name, label) .value(fmt_dial(value)) .step(step) .unit(unit); field.min = Some("0".to_string()); field.max = Some(fmt_dial(max)); field } /// The panel the recompute replaces. /// /// Returns the [`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. fn results(outcome: &Outcome) -> Slot { let mut slot = Slot::new(RESULTS, RegionKind::Pane) .with(Node::banner(tone(outcome.verdict), &outcome.headline)) .with(Node::Table { columns: vec![ Column::new("Monthly") .width(layout::Width::Fill) .priority(layout::Priority::Essential), Column::new("Here") .width(layout::Width::Content) .priority(layout::Priority::Essential), Column::new("The other platform") .width(layout::Width::Content) .priority(layout::Priority::Essential), ], rows: vec![ Cells::new([ Cell::new("You sell"), Cell::new(outcome.gross.clone()), Cell::new(outcome.gross.clone()), ]), Cells::new([ Cell::new("You keep"), Cell::new(outcome.mnw_keep.clone()), Cell::new(outcome.other_keep.clone()), ]), Cells::new([ Cell::new("Total fees"), Cell::new(outcome.mnw_rate.clone()), Cell::new(outcome.other_rate.clone()), ]), ], more: None, }); // 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. if let Some(crossover) = outcome.crossover_sales { slot = slot .with(Node::section("Where each one wins")) .with(Node::Meter( Meter::new( outcome.sales_per_month.round().max(0.0) as u32, crossover.ceil().max(1.0) as u32, ) .tone(tone(outcome.verdict)) .label("sales a month to the crossover"), )); } if let Some(note) = &outcome.crossover_note { slot = slot.with(Node::text(note)); } slot.with(Node::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.", )) .with(Node::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. /// /// 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 tone(verdict: Verdict) -> layout::Tone { match verdict { Verdict::MnwAhead => layout::Tone::Success, Verdict::Even => layout::Tone::Neutral, Verdict::OtherAheadForNow | Verdict::OtherAhead => layout::Tone::Warning, } } /// 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. fn footer(state: &Pricing) -> Slot { let mut rows = vec![ Row::new("Pricing").activate(Action::get("/pricing")), Row::new("Creators").activate(Action::get("/creators")), Row::new("Docs").activate(Action::get("/docs")), Row::new("Legal").activate(Action::get("/policy")), Row::new("Credits").activate(Action::get("/docs/credits")), ]; // Linked only while a published changelog project exists; the route 404s // otherwise. See `crate::changelog`. if state.changelog_published { rows.push(Row::new("Changelog").activate(Action::get("/changelog"))); } rows.push(Row::new("Contact").activate(Action::external("mailto:info@makenot.work"))); rows.push(Row::new("Status").activate(Action::get("/health"))); Slot::new("site-footer", RegionKind::Pane) .with(Node::List { rows, more: None }) .with(Node::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::*; /// 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(_))) } }