crates/gunnar-ui-web/src/offers.rs
//! **What the "more room" panel draws**, on the host side of the wasm
//! boundary. Same argument as [`crate::room`]: the browser draws, this decides,
//! and `cargo test` can reach it.
//!
//! # The decision this module exists for
//!
//! Until 2026-09-19 the panel was two free-text boxes: a meter name and a
//! number of bytes. That is a debugging instrument, and the console was right
//! to refuse to invent a size — it holds no menu of its own and never will.
//! The missing half was upstream, and it arrived as `Purchase.Bundles`: the
//! server now answers with a list of things somebody can press, each carrying
//! its own words, its own already-rendered amount and its own answer to whether
//! it can be served.
//!
//! So the panel has two shapes and something has to choose between them, on
//! three inputs that are easy to confuse:
//!
//! * the list **arrived and has rows** — draw the rows;
//! * the list **arrived and is empty** — draw the boxes. An empty list is an
//! ANSWER and the ordinary shape of a deployment that declared nothing, and
//! typing a size nobody declared is then the only way to ask for one;
//! * the read **was refused** — NOT the boxes (owner, 2026-10-05 12:20,
//! replacing the 2026-09-18 ruling): a spinner and "Awaiting the cloud
//! provider…" while the console asks again, and after the late point ([`Poll::late_after_ms`], or [`REFUSED_FOR_MS`] for a refused read)
//! of refusals the server's sentence and a **Retry** button that starts the
//! wait again. A refused read is not a deployment that declared nothing, and
//! drawing "New storage limit (GiB) [ ] Choose" for it offered a size field
//! nobody had put on sale. The 2026-09-18 lesson still holds in its other
//! half — the panel is never a blank: there is always the line, and after
//! the ceiling always the button.
//!
//! * the list **arrived, is empty, and the server sent a line** — draw the
//! line and nothing else. Since 2026-09-20 the server keeps the rows this box
//! cannot serve to itself ([`Shop::withheld`] counts them), so an empty list
//! with a line is a shop that HAS rows and can serve none of them to this
//! account. The owner had been reading those rows for five days, priced and
//! buttonless over a sentence about maintenance nobody was carrying out; the
//! ruling was that such a row is not rendered at all, and that the empty
//! window it leaves says so in one line rather than as a blank.
//!
//! * the list **has not been read yet** (`pending`, test2 2026-10-05) — draw
//! a spinner and the server's line ("Awaiting the cloud provider…") and ask
//! again; after a minute, the server's `late` line. NEVER the boxes: an
//! unread window has no rows and nothing withheld, the exact shape of a
//! deployment that declared nothing, and the owner's first visit after a
//! night's idle drew the typed order for it while monetize's cloud reading
//! ran. The rows replace the spinner the moment they arrive.
//!
//! [`room`] is that choice, out here where a test can state each of the five
//! as a claim. **The typed order is drawn for exactly one of them**: an answer
//! that declares nothing.
//!
//! # And the one per row
//!
//! A row that cannot be served renders its sentence and **no button** — not a
//! greyed-out one, which is a control that looks pressable and is a lie about
//! what pressing it would do. [`row`] is that fork, and it holds even when the
//! server sent no sentence to go with the refusal: the absence of a reason is
//! not a reason to offer the press. The server no longer sends such rows; the
//! fork stays as the belt under that.
//!
//! # What is NOT decided here
//!
//! The amount, the words, the period and the id. Every one of them is the
//! server's, carried through this module untouched and rendered verbatim. The
//! single exception is [`added`], which spells the server's `delta` with the
//! same `fmt::bytes` the usage table uses — because a number of bytes is not a
//! sentence, and a console that showed one meaning here and another one two
//! panels up would be two consoles.
use std::collections::BTreeMap;
use serde::Deserialize;
/// One thing this deployment will sell, exactly as the server sent it.
///
/// Every string is the server's. `id` is **opaque** and is echoed back on the
/// order without ever being read: the day an operator names one `bigger`,
/// nothing here may change.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
pub struct Bundle {
pub id: String,
/// What is being sold, in the server's words.
#[serde(default)]
pub label: String,
/// The amount, **already rendered by the server**. This console divides
/// nothing, maps no currency and composes no amount; when this is empty
/// nothing is drawn, because the only alternative would be to invent one.
#[serde(default)]
pub price_words: String,
/// The server's word for the period, verbatim.
#[serde(default)]
pub period: String,
/// A32-29: the price line beside the pay button, the server's (monetize-site-buy).
#[serde(default)]
pub price_line: String,
/// A32-29: the offer button's words (`+10 GiB storage — 100.00 SEK / month`), the
/// server's (monetize-site-buy `offer_words`); drawn as they are.
#[serde(default)]
pub offer_words: String,
/// The cap increase, per meter. The one field re-rendered here — [`added`].
#[serde(default)]
pub delta: BTreeMap<String, u64>,
/// Whether it can be served. **Default `false`**, which is the safe way
/// round: a server too old to send the field, or an answer this console
/// half-understands, draws a sentence rather than a button.
#[serde(default)]
pub can_serve: bool,
/// Why not, in the server's words. Empty when it can be served.
#[serde(default)]
pub reason: String,
/// **What pressing it does to the account's room**, the server's word
/// (`start` | `prolong` | `upgrade` | `bump` | `renew`, 2026-10-05); empty
/// for a deployment that sells plain bundles. Read by nothing here but
/// carried, so the page can tell a row that extends time from one that
/// adds room.
#[serde(default)]
pub action: String,
}
/// **Where the account's room stands — the gauge above the rows**, exactly
/// as the server sent it (`api::increase::Plan`, 2026-10-05). Every string is
/// the server's and is drawn verbatim; the one thing computed here is the
/// gauge's fill, from the two day counts ([`fill_pct`]).
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
pub struct Gauge {
/// `none` | `paid` | `grace` | `read_only` | `retention`.
#[serde(default)]
pub state: String,
#[serde(default)]
pub period: String,
#[serde(default)]
pub label: String,
#[serde(default)]
pub cap_bytes: u64,
#[serde(default)]
pub paid_until_unix_ms: u64,
/// A32-29: the paid-until day, the server's words (`date_words`); empty when there is none.
#[serde(default)]
pub paid_until_words: String,
#[serde(default)]
pub days_left: u32,
#[serde(default)]
pub days_total: u32,
#[serde(default)]
pub ends_words: String,
#[serde(default)]
pub price_words: String,
/// The reminder / grace / read-only line; empty: none.
#[serde(default)]
pub banner: String,
/// The row id the banner's button presses; empty: no button.
#[serde(default)]
pub banner_action: String,
/// `warn` | `bad`.
#[serde(default)]
pub banner_tone: String,
/// The size field's line (a 12-month account); empty: no field.
#[serde(default)]
pub bump_words: String,
/// The smallest size the field may ask for, bytes.
#[serde(default)]
pub bump_min_bytes: u64,
/// The newest order's room while it moves.
#[serde(default)]
pub progress: Option<Step>,
#[serde(default)]
pub progress_reference: String,
}
/// One step of an order's room, the server's word and sentence.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
pub struct Step {
#[serde(default)]
pub state: String,
#[serde(default)]
pub words: String,
}
/// **Is there an account to draw a gauge for?** `none` (and an absent
/// gauge) is the first visit: the rows alone.
pub fn drawn(g: Option<&Gauge>) -> Option<&Gauge> {
g.filter(|g| !g.state.is_empty() && g.state != "none")
}
/// **The gauge's fill, percent**: days left of the gauge's length, clamped.
pub fn fill_pct(g: &Gauge) -> u32 {
if g.days_total == 0 {
return 0;
}
(g.days_left.min(g.days_total) * 100) / g.days_total
}
/// **The size field**: the smallest whole GiB it may ask for, when there is
/// one (a 12-month account), else `None`.
pub fn field_min_gib(g: &Gauge) -> Option<u64> {
(g.bump_min_bytes > 0 && !g.bump_words.is_empty()).then(|| g.bump_min_bytes.div_ceil(1 << 30))
}
/// **The row the banner's button presses**, when the window still offers it.
pub fn banner_row(shop: &Shop) -> Option<&Bundle> {
let g = shop.plan.as_ref()?;
if g.banner.is_empty() || g.banner_action.is_empty() {
return None;
}
shop.offers.iter().find(|b| b.id == g.banner_action && b.can_serve)
}
/// **The shop window as the server sends it: only the rows this box can
/// serve**, plus what it takes to tell an empty window from an undeclared one.
///
/// `api::increase::Window` on the server. Since 2026-09-20 a row monetize
/// cannot serve never reaches the browser — the owner had been reading three
/// cards, two of them priced and buttonless over a sentence about maintenance
/// nobody was carrying out, and ruled that a size the box cannot hold is not
/// offered at all. So `offers` is what can be pressed, every row of it.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Default)]
pub struct Shop {
/// The rows that can be pressed, in the server's order.
#[serde(default)]
pub offers: Vec<Bundle>,
/// How many declared rows the server left out because they cannot be
/// served. What makes `offers == []` two different things — see [`room`].
#[serde(default)]
pub withheld: usize,
/// **The server's one line for a shop that has rows and none that fit.**
/// Rendered as it came; this console writes no sentence of its own here,
/// for the same reason it writes no price.
#[serde(default)]
pub unavailable: Option<String>,
/// **The window is still being read** (test2, 2026-10-05): the server's
/// line to draw beside a spinner while this console asks again. Present =
/// `offers` is NOT an answer — not "nothing declared", not "nothing fits".
/// The words are the server's ("Awaiting the cloud provider…").
#[serde(default)]
pub pending: Option<String>,
/// The server's line for a window that is STILL being read after this
/// console has asked for the late point ([`Poll::late_after_ms`], or [`REFUSED_FOR_MS`] for a refused read). Only beside `pending`.
#[serde(default)]
pub late: Option<String>,
/// **The account's gauge** — present when the deployment sells plans.
#[serde(default)]
pub plan: Option<Gauge>,
/// **The market the server priced this window in** (`SE`/`FI`/`DK`; empty: monetize's default) —
/// the server's one rule from the browser's languages (wiring tracer B 2026-10-08). An order from
/// this window echoes it; this console decides no currency of its own.
#[serde(default)]
pub market: String,
/// A32-29: **when to ask again**, the server's schedule ([`Poll`]); `None` from a server
/// too old to send it.
#[serde(default)]
pub poll: Option<Poll>,
}
/// What the read of the window has said so far.
///
/// Three states and not `Option<Result<…>>`, so that the caller has to name
/// which one it is in and a test can too.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Window<'a> {
/// Still asking.
Waiting,
/// The server refused, or could not be reached, and this console has been
/// asking for less than the late point ([`Poll::late_after_ms`], or [`REFUSED_FOR_MS`] for a refused read): it asks again
/// ([`retry_refused_ms`]). **Not the same as an empty list**, and the
/// whole reason this enum is not `Option<Result<…>>`.
Refused,
/// **Still refused after the late point ([`Poll::late_after_ms`], or [`REFUSED_FOR_MS`] for a refused read) of asking.** Carries the
/// refusal's sentence ([`refusal_words`]), drawn beside Retry.
GaveUp(&'a str),
/// The server answered. Possibly with nothing — or with "still being
/// read" ([`Shop::pending`]).
Listed(&'a Shop),
/// The server answered, and this console has been asking for
/// the late point ([`Poll::late_after_ms`], or [`REFUSED_FOR_MS`] for a refused read) or longer. Only different from `Listed` while the
/// window is still being read.
Late(&'a Shop),
}
/// What the panel draws.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Room {
/// Nothing decided yet.
Waiting,
/// One row per offer, in the server's order — which is the operator's
/// declared order and not this console's to sort.
Offered(Vec<Bundle>),
/// The typed order: a meter and a target. **Never deleted**, because it is
/// the only way to ask for a size nobody declared — and **drawn only for a
/// deployment that declared nothing**, never for a window that could not
/// be read (owner, 2026-10-05 12:20).
Typed,
/// **One line and nothing to press**: the shop has rows and this box can
/// serve none of them to this account. Not the boxes — typing a size into
/// a shop the server has just said is full would only be refused — and not
/// a blank area that reads as a loading failure. The line is the server's.
Nothing(String),
/// **The window is still being read: a spinner and the server's line**
/// ("Awaiting the cloud provider…"), and nothing to press or type into —
/// the console asks again ([`next_poll_ms`]). MEASURED 2026-10-05: the
/// owner's first visit after a night's idle drew the TYPED order here,
/// because an unread window looked like an empty one.
Awaiting(String),
/// **A window refused for the late point ([`Poll::late_after_ms`], or [`REFUSED_FOR_MS`] for a refused read): the server's sentence and a
/// Retry button** that starts the wait again (a spinner, the console asks
/// again). Nothing to type into: a size field under a window that could
/// not be read is a guess at what is on sale (owner, 2026-10-05 12:20).
Retry(String),
}
/// **The choice between the buttons, the boxes and the one line.** See the
/// module docs for why a refusal lands on the spinner and then on Retry —
/// never on the boxes, and never on nothing at all.
///
/// The one line is drawn only when the server SENT one. A window with no
/// offers and no line is a deployment that declared nothing, whatever
/// `withheld` says, because a sentence this console made up to fill that gap
/// would be exactly the kind of fallback the 2026-09-20 fix removed.
pub fn room(window: Window<'_>) -> Room {
match window {
Window::Waiting => Room::Waiting,
// **A refused read is awaited, never typed** (owner, 2026-10-05
// 12:20): a spinner and the line while the console asks again, and
// past the ceiling the server's sentence beside Retry.
Window::Refused => Room::Awaiting(AWAITING.to_owned()),
Window::GaveUp(said) => Room::Retry(said.to_owned()),
// **Still being read: the spinner and the server's line**, before
// anything else in the answer is looked at — its empty `offers` is
// not an answer, and the typed order is the last thing to draw for
// it (test2, 2026-10-05).
Window::Listed(Shop { pending: Some(line), .. }) => Room::Awaiting(line.clone()),
// Still being read after a minute of asking: the server's `late` line
// (or its pending one), without the spinner. Still never the boxes.
Window::Late(Shop { pending: Some(line), late, .. }) => Room::Nothing(late.clone().unwrap_or_else(|| line.clone())),
Window::Listed(shop) | Window::Late(shop) => match (&shop.unavailable, shop.offers.as_slice()) {
(Some(line), []) => Room::Nothing(line.clone()),
(_, []) => Room::Typed,
(_, rows) => Room::Offered(rows.to_vec()),
},
}
}
/// **The server's schedule for a window still being read** (A32-29: monetize-site-buy `POLL`,
/// sent as the window's `poll`): ask again every `again_ms`; past `late_after_ms` draw the
/// `late` line and ask every `late_again_ms`; stop at `give_up_ms`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub struct Poll {
pub again_ms: u32,
pub late_after_ms: u32,
pub late_again_ms: u32,
pub give_up_ms: u32,
}
/// **When a read did not reach the server at all** (a refusal, or an older server that sent no
/// `poll`), the console's own asking: every [`REFUSED_AGAIN_MS`] for [`REFUSED_FOR_MS`], then
/// Retry. The server cannot say this one: it was not reached.
pub const REFUSED_AGAIN_MS: u32 = 2_000;
/// How long a refused read is asked again before the panel draws Retry.
pub const REFUSED_FOR_MS: u32 = 60_000;
/// **The console's own words beside the spinner while a REFUSED read is
/// asked again** — the same words the server sends as `pending`
/// (`api::increase::AWAITING_PROVIDER`). A refusal carries no `pending` line,
/// so this is the one sentence in the panel that is not the server's; the
/// owner chose it (2026-10-05 12:20).
pub const AWAITING: &str = "Awaiting the cloud provider…";
/// **How long to wait before asking again after a REFUSED read**, after
/// `asked_ms` of asking: [`REFUSED_AGAIN_MS`] up to [`REFUSED_FOR_MS`], then `None` —
/// stop, and draw [`Room::Retry`]; the button starts the wait again.
pub fn retry_refused_ms(asked_ms: u32) -> Option<u32> {
(asked_ms < REFUSED_FOR_MS).then_some(REFUSED_AGAIN_MS)
}
/// **The sentence beside Retry**, from the refusal's HTTP `status`, its
/// `message` and the console's one-line `summary` of it (`api::Fail`): the
/// server's own words when it sent some; the summary when it sent none (a
/// maintenance answer carries no message) or was never reached (a status-0
/// message is the browser's, not the server's).
pub fn refusal_words(status: u16, message: &str, summary: &str) -> String {
if status == 0 || message.trim().is_empty() {
summary.to_owned()
} else {
message.to_owned()
}
}
/// **The server says the window is still being read.**
pub fn pending(shop: &Shop) -> bool {
shop.pending.is_some()
}
/// Whether `asked_ms` of asking is late: past the server's `late_after_ms`, or, with no
/// schedule from the server, past [`REFUSED_FOR_MS`].
pub fn is_late(poll: Option<&Poll>, asked_ms: u32) -> bool {
asked_ms >= poll.map_or(REFUSED_FOR_MS, |p| p.late_after_ms)
}
/// **How long to wait before asking again** on the server's schedule, after `asked_ms` of
/// asking; `None`: stop.
pub fn next_poll_ms(poll: &Poll, asked_ms: u32) -> Option<u32> {
if asked_ms >= poll.give_up_ms {
None
} else if is_late(Some(poll), asked_ms) {
Some(poll.late_again_ms)
} else {
Some(poll.again_ms)
}
}
/// What one row offers.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Row<'a> {
/// A button. The caller renders the server's `label`, `price_words` and
/// `period` on it and sends back `id`.
Press(&'a Bundle),
/// **Offered, and not pressable** (owner, test2 2026-10-05: an offer
/// that cannot fit must not look buyable — mark it, do not hide it, so the
/// buyer knows the bigger sizes exist). The caller draws the offer's own
/// words, greyed, as text and not as a control, and monetize's sentence
/// (`why`, possibly empty) under it. The room is monetize's to work out;
/// nothing here does.
Marked { offer: &'a Bundle, why: &'a str },
}
/// **A row that cannot be served is shown as an offer with no button, and
/// monetize's reason.**
pub fn row(bundle: &Bundle) -> Row<'_> {
if bundle.can_serve {
Row::Press(bundle)
} else {
Row::Marked { offer: bundle, why: &bundle.reason }
}
}
/// **The server's `delta`, spelled the way the usage table spells sizes** —
/// `pack_bytes +10.0 GiB`, meters in the map's own sorted order.
///
/// The meter NAME is carried, not translated: it is the same name the table
/// above counts under, and two vocabularies for one meter is how a customer
/// ends up unable to tell which number moved. A meter that is not bytes-shaped
/// is printed as the count it is, because `fmt::bytes` on a repository count
/// would say `5 B`.
pub fn added(delta: &BTreeMap<String, u64>) -> String {
delta
.iter()
.map(|(meter, amount)| {
if meter.ends_with("_bytes") {
format!("{meter} +{}", crate::fmt::bytes(*amount))
} else {
format!("{meter} +{amount}")
}
})
.collect::<Vec<_>>()
.join(", ")
}
/// **Whether this console draws a shop at all** (one shop, owner
/// 2026-10-07): the server's `/auth/status` `shop_here` — `false` on a SOLD
/// (provided) Gunnar, whose room and instances are its provider's to give,
/// and which draws the server's one line instead; absent from an older
/// server, which sells.
pub fn shop_here(said: Option<bool>) -> bool {
said.unwrap_or(true)
}
/// **"Grow this instance"** (one shop 4, owner 2026-10-07): on a SOLD Gunnar
/// — no shop here, the site it was bought at and its name there both said —
/// the link to that shop's sign-up page growing it,
/// `<provider>/signup?plan=grow&instance=<name>`. It is a link, not a call:
/// the instance is grown by its account AT the shop. Nothing on a console that
/// sells, or that is not told both.
pub fn grow_this_instance(sells_here: bool, provider: Option<&str>, instance: Option<&str>) -> Option<String> {
if sells_here {
return None;
}
let provider = provider.map(|p| p.trim().trim_end_matches('/')).filter(|p| p.starts_with("https://") && !p["https://".len()..].is_empty())?;
let instance = instance.map(str::trim).filter(|i| !i.is_empty() && i.len() <= 16 && i.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()))?;
Some(format!("{provider}/signup?plan=grow&instance={instance}"))
}
/// **What this account bought here** (`GET /api/account/purchases`, one
/// shop 2026-10-07): its private Gunnars first, then its room here; for an
/// account without room here, the server's lines for its Storage page. Every
/// word is the server's.
#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq)]
pub struct Purchases {
#[serde(default)]
pub estates: Vec<Instance>,
#[serde(default)]
pub rooms: Vec<RoomLine>,
#[serde(default)]
pub storage_lines: Vec<Line>,
#[serde(default)]
pub buy: Option<Line>,
}
#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq)]
pub struct Instance {
pub company: String,
pub host: String,
#[serde(default)]
pub url: String,
#[serde(default)]
pub state: String,
#[serde(default)]
pub words: String,
#[serde(default)]
pub step: String,
#[serde(default)]
pub resources_words: String,
#[serde(default)]
pub monthly_words: String,
#[serde(default)]
pub paid_until: String,
#[serde(default)]
pub grow_url: String,
#[serde(default)]
pub renew_url: String,
/// ★ ACCT-6: why its front needs a repair, monetize's words; empty while it stands.
#[serde(default)]
pub repair_words: String,
}
#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq)]
pub struct RoomLine {
#[serde(default)]
pub state: String,
#[serde(default)]
pub paid_until: String,
#[serde(default)]
pub cap_bytes: u64,
}
#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq)]
pub struct Line {
pub words: String,
#[serde(default)]
pub url: String,
}
/// **"Manage <company>"** (owner 2026-10-22): the words of an instance row's link to its page
/// that renews, stops renewing and terminates it (the server's `renew_url`).
pub fn manage_words(company: &str) -> String {
format!("Manage {company}")
}
/// Where an instance's sign-in link is asked for again (S10): the console's own door; monetize
/// queues a signed order and the estate's main mints the new link and mails it to the
/// order's address itself.
pub const SIGN_IN_AGAIN: &str = "/auth/private/sign-in-again";
/// **"Send my sign-in link again" on this instance's row** (OM-23): while it runs (ready, or
/// overdue and still running) — the only time it can mint a link. Whether its first admin
/// already has a passkey only the instance knows; it refuses then, in its own words.
pub fn sign_in_again_offered(i: &Instance) -> bool {
matches!(i.state.as_str(), "ready" | "overdue")
}
/// Where a lost front is made again (S8): the console's own door; monetize restores the
/// instance's front from its daily backup, with its keys, and says what it did.
pub const RESTORE_FRONT: &str = "/auth/private/restore-front";
/// **"Restore front", in the row's Repair section** (S8; ACCT-6, owner 2026-10-29): a repair,
/// never an everyday button — offered only when monetize says the front needs one (the row's
/// `repair_words`, which also say why). Whether a front is lost only monetize knows.
pub fn restore_front_offered(i: &Instance) -> bool {
!i.repair_words.trim().is_empty()
}
#[cfg(test)]
mod tests {
use super::*;
fn offer(id: &str, can_serve: bool) -> Bundle {
Bundle {
id: id.to_owned(),
label: "10.0 GiB more room".to_owned(),
price_words: "220.00 SEK".to_owned(),
period: "month".to_owned(),
price_line: "220.00 SEK / month + VAT (your country's rate, added at checkout)".to_owned(),
offer_words: "+10 GiB storage \u{2014} 220.00 SEK / month".to_owned(),
delta: BTreeMap::from([("pack_bytes".to_owned(), 10_737_418_240)]),
can_serve,
reason: String::new(),
action: String::new(),
}
}
fn shop(offers: Vec<Bundle>, withheld: usize, unavailable: Option<&str>) -> Shop {
Shop {
offers,
withheld,
unavailable: unavailable.map(str::to_owned),
..Shop::default()
}
}
const AWAITING: &str = "Awaiting the cloud provider…";
const LATE: &str = "The cloud provider has not answered yet. The sizes on offer appear here as soon as it does.";
/// The window the server answers while monetize's cloud reading runs —
/// byte for byte as `api::increase::Window` serialises it.
fn reading() -> Shop {
serde_json::from_str(&format!(r#"{{"offers":[],"withheld":0,"pending":"{AWAITING}","late":"{LATE}"}}"#)).expect("parses")
}
/// The window a deployment that sells plans answers on a FIRST visit —
/// byte for byte as `api::increase::Window` serialises it.
const FIRST_VISIT: &str = r#"{"offers":[
{"id":"10gb-1m","label":"10 GiB · 1 month","price_words":"13.60 EUR","period":"month","delta":{"pack_bytes":10737418240},"can_serve":true,"reason":"","action":"start","target":{"pack_bytes":10737418240}},
{"id":"10gb-12m","label":"10 GiB · 12 months","price_words":"130.56 EUR","period":"year","delta":{"pack_bytes":10737418240},"can_serve":true,"reason":"","action":"start"},
{"id":"100gb-12m","label":"100 GiB · 12 months","price_words":"449.76 EUR","period":"year","delta":{"pack_bytes":107374182400},"can_serve":true,"reason":"","action":"start"},
{"id":"500gb-12m","label":"500 GiB · 12 months","price_words":"1868.28 EUR","period":"year","delta":{"pack_bytes":536870912000},"can_serve":true,"reason":"","action":"start"}
],"withheld":0,"plan":{"state":"none","cap_bytes":0,"paid_until_unix_ms":0,"days_left":0,"days_total":0,"bump_min_bytes":0}}"#;
/// After a paid month, prolonged to the horizon: the gauge, a prolong row
/// that can no longer be pressed (with the server's reason), the upgrades.
const PAID_AHEAD: &str = r#"{"offers":[
{"id":"prolong-1m","label":"Prolong 1 month (10 GiB)","price_words":"13.60 EUR","period":"month","delta":{},"can_serve":false,"reason":"This account is already paid 3 months ahead, the most a 1-month plan can be prolonged. For longer, choose a 12-month plan. Nothing has been charged.","action":"prolong"},
{"id":"100gb-12m","label":"Upgrade to 100 GiB · 12 months","price_words":"420.00 EUR","period":"year","delta":{"pack_bytes":96636764160},"can_serve":true,"reason":"","action":"upgrade"}
],"withheld":0,"plan":{"state":"paid","period":"month","label":"10 GiB · 1 month","cap_bytes":10737418240,"paid_until_unix_ms":1799000000000,"days_left":6,"days_total":30,
"ends_words":"Paid until 2026-11-05 — 6 days left","price_words":"13.60 EUR per month",
"banner":"Your paid time ends on 2026-11-05 — in 6 days. Prolong 1 month to keep writing after that.","banner_action":"prolong-1m","banner_tone":"warn","bump_min_bytes":0,
"progress":{"state":"queued","words":"Paid · queued for today's batch — your room is reserved and yours to use now; the disk is enlarged in today's batch."},"progress_reference":"gunnar/kim/2026-10-05+pack_bytes=10737418240+plan:month"}}"#;
/// ★ **A first visit to a deployment that sells plans is its rows** — the
/// 1-month plan and the 12-month ones, in the server's order — and NEVER
/// the typed order; there is no gauge to draw yet.
#[test]
fn a_first_visit_draws_the_plans_never_the_typed_order_and_no_gauge() {
let w: Shop = serde_json::from_str(FIRST_VISIT).expect("parses");
match room(Window::Listed(&w)) {
Room::Offered(rows) => {
assert_eq!(rows.iter().map(|b| b.id.as_str()).collect::<Vec<_>>(), ["10gb-1m", "10gb-12m", "100gb-12m", "500gb-12m"]);
assert!(rows.iter().all(|b| matches!(row(b), Row::Press(_))));
assert_eq!(rows[0].label, "10 GiB · 1 month");
}
other => panic!("{other:?}"),
}
assert!(drawn(w.plan.as_ref()).is_none(), "nothing paid: no gauge");
assert!(banner_row(&w).is_none());
}
/// ★ **After a paid period: the gauge (days left, the end, the cap), the
/// banner whose button presses Prolong, the upgrades — and a prolong past
/// the horizon is a SENTENCE, not a button.**
#[test]
fn a_paid_account_draws_the_gauge_the_banner_and_a_refused_prolong_as_its_reason() {
let w: Shop = serde_json::from_str(PAID_AHEAD).expect("parses");
let g = drawn(w.plan.as_ref()).expect("a gauge");
assert_eq!((g.label.as_str(), g.days_left, g.days_total, fill_pct(g)), ("10 GiB · 1 month", 6, 30, 20));
assert!(g.ends_words.contains("2026-11-05"));
assert_eq!(field_min_gib(g), None, "a 1-month account has no size field");
assert_eq!(g.progress.as_ref().map(|s| s.state.as_str()), Some("queued"));
let Room::Offered(rows) = room(Window::Listed(&w)) else { panic!("rows") };
assert_eq!(row(&rows[0]), Row::Marked { offer: &rows[0], why: &rows[0].reason }, "past the horizon: the reason, no button");
assert!(matches!(row(&rows[1]), Row::Press(b) if b.action == "upgrade"));
// The banner's button is the PROLONG row — which cannot be pressed
// now, so the banner draws no button rather than a dead one.
assert!(banner_row(&w).is_none());
let mut pressable = w.clone();
pressable.offers[0].can_serve = true;
assert_eq!(banner_row(&pressable).map(|b| b.id.as_str()), Some("prolong-1m"));
}
/// A 12-month account has the size field: from one GiB above what it holds.
#[test]
fn a_twelve_month_account_has_the_size_field_from_one_gib_above_its_room() {
let g = Gauge {
state: "paid".into(),
period: "year".into(),
cap_bytes: 100 << 30,
bump_words: "Any bigger size: 0.30 EUR per GiB per month, until 2027-10-05".into(),
bump_min_bytes: 101 << 30,
days_left: 200,
days_total: 365,
..Gauge::default()
};
assert_eq!(field_min_gib(&g), Some(101));
assert_eq!(fill_pct(&g), 54);
assert_eq!(fill_pct(&Gauge { days_left: 400, days_total: 365, ..g.clone() }), 100, "clamped");
assert_eq!(fill_pct(&Gauge { days_total: 0, ..g }), 0);
}
/// ★ **THE OWNER'S MORNING (test2, 2026-10-05): a window still being read
/// is a spinner and the server's line — NEVER the typed order.** It has no
/// rows and nothing withheld, which is exactly the shape of a deployment
/// that declared nothing; only `pending` tells the two apart, and before
/// it existed this drew "New storage limit (GiB) [ ] Choose".
#[test]
fn a_window_still_being_read_is_awaited_never_typed() {
let w = reading();
assert!(pending(&w));
assert_eq!(room(Window::Listed(&w)), Room::Awaiting(AWAITING.to_owned()));
assert_ne!(room(Window::Listed(&w)), Room::Typed);
}
/// Pending wins over anything else in the answer: a server that sent a
/// line or a count beside it still has not read the window.
#[test]
fn pending_wins_over_a_line_and_a_count() {
let mut w = shop(vec![], 2, Some("nothing fits"));
w.pending = Some(AWAITING.to_owned());
assert_eq!(room(Window::Listed(&w)), Room::Awaiting(AWAITING.to_owned()));
}
/// ★ **Past the ceiling the server's `late` line, still never the typed
/// order** — and the offers the moment they arrive.
#[test]
fn a_window_still_being_read_after_a_minute_is_the_servers_late_line() {
let w = reading();
assert_eq!(room(Window::Late(&w)), Room::Nothing(LATE.to_owned()));
let mut quiet = reading();
quiet.late = None;
assert_eq!(room(Window::Late(&quiet)), Room::Nothing(AWAITING.to_owned()), "no late line: the pending one stays");
let arrived = shop(vec![offer("10gb", true)], 0, None);
assert!(matches!(room(Window::Late(&arrived)), Room::Offered(rows) if rows.len() == 1));
assert_eq!(room(Window::Late(&shop(vec![], 0, None))), Room::Typed, "an ANSWER that declares nothing is still the typed order");
}
/// ★ A32-29: **the plan's paid-until day is the server's words** (monetize-site-buy
/// `date_words`): the console draws `paid_until_words`, not its own date of the number.
#[test]
fn the_paid_until_day_is_the_servers_words() {
let shop: Shop = serde_json::from_str(r#"{"plan":{"state":"paid","paid_until_unix_ms":1799000000000,"paid_until_words":"the server's day"}}"#).unwrap();
assert_eq!(shop.plan.expect("a plan").paid_until_words, "the server's day");
}
/// ★ A32-29: **the asking is the server's schedule** (monetize-site-buy `POLL`, sent as the
/// window's `poll`): this console keeps no window schedule of its own.
#[test]
fn the_console_asks_on_the_servers_schedule() {
let shop: Shop = serde_json::from_str(r#"{"pending":"…","poll":{"again_ms":7,"late_after_ms":100,"late_again_ms":50,"give_up_ms":1000}}"#).unwrap();
let p = shop.poll.expect("the server's schedule");
assert_eq!((next_poll_ms(&p, 0), next_poll_ms(&p, 99), next_poll_ms(&p, 100), next_poll_ms(&p, 1000)), (Some(7), Some(7), Some(50), None));
assert!(!is_late(Some(&p), 99) && is_late(Some(&p), 100));
for gone in [concat!("pub const LATE_", "POLL_MS"), concat!("pub const GIVE_", "UP_MS")] {
assert!(!include_str!("offers.rs").contains(gone), "{gone}: the schedule is the server's");
}
}
/// A window with no `pending` is not pending — an older server, or every
/// answer that is an answer.
#[test]
fn an_answer_is_not_pending() {
let none: Shop = serde_json::from_str(r#"{"offers":[],"withheld":0}"#).expect("parses");
assert!(!pending(&none));
assert_eq!(room(Window::Listed(&none)), Room::Typed);
}
/// The whole point of the change: when the server has offers, the boxes go
/// away and a row of buttons takes their place.
#[test]
fn offers_replace_the_typed_order() {
let shop = shop(vec![offer("10gb-1m", true), offer("100gb-1m", true)], 0, None);
let Room::Offered(drawn) = room(Window::Listed(&shop)) else {
panic!("a server with offers draws them");
};
assert_eq!(drawn.len(), 2);
assert_eq!(drawn[0].id, "10gb-1m", "the server's order is the operator's");
}
/// ★ **An empty list is an ANSWER, and the typed order survives it.** A
/// deployment that declared nothing is the ordinary open shape, and typing
/// a size nobody declared is the only way left to ask for one.
#[test]
fn nothing_declared_leaves_the_typed_order_standing() {
assert_eq!(room(Window::Listed(&shop(vec![], 0, None))), Room::Typed);
}
/// ★ **A deliverable bundle is offered beside a withheld one.** The server
/// left a row out; the row it kept is drawn exactly as it came, and the
/// count of what was left out changes nothing about the shape. A fix that
/// hid the dead cards and the live one would be worse than the bug.
#[test]
fn a_deliverable_bundle_is_offered_whatever_was_withheld() {
let shop = shop(vec![offer("10gb-1m", true)], 2, None);
let Room::Offered(drawn) = room(Window::Listed(&shop)) else {
panic!("one servable row is a window with a button in it");
};
assert_eq!(drawn.len(), 1);
assert_eq!(row(&drawn[0]), Row::Press(&drawn[0]));
}
/// ★ **Every row withheld is one line, not the boxes and not a blank.** The
/// server said the shop has rows and none fit; the panel says that, in the
/// server's words, and offers nothing to type into — a typed size against a
/// shop the server just called full would only be refused.
#[test]
fn everything_withheld_is_the_servers_one_line() {
let said = "None of the sizes on offer can be added to this account right now.";
let shop = shop(vec![], 2, Some(said));
assert_eq!(room(Window::Listed(&shop)), Room::Nothing(said.to_owned()));
}
/// The line is the server's or it is not drawn. A count with no sentence
/// is treated as a shop that declared nothing: this console does not fill
/// the gap with words of its own — that is the fallback the 2026-09-20 fix
/// took out.
#[test]
fn a_count_without_a_line_is_not_a_line() {
assert_eq!(room(Window::Listed(&shop(vec![], 2, None))), Room::Typed);
}
/// The server's line only stands in for an EMPTY window: a shop with
/// offers draws them, whatever else the answer carries.
#[test]
fn a_line_beside_offers_does_not_hide_them() {
let shop = shop(vec![offer("10gb-1m", true)], 1, Some("nothing fits"));
assert!(matches!(room(Window::Listed(&shop)), Room::Offered(rows) if rows.len() == 1));
}
/// ★ **A REFUSED read is "Awaiting the cloud provider…" with a spinner,
/// NEVER the typed order** (owner, 2026-10-05 12:20 — replaces the
/// 2026-09-18 ruling that drew "New storage limit (GiB) [ ] Choose" for
/// it). The console asks again every [`REFUSED_AGAIN_MS`] while it says so.
#[test]
fn a_refused_window_is_awaited_never_typed() {
assert_eq!(room(Window::Refused), Room::Awaiting(AWAITING.to_owned()));
assert_ne!(room(Window::Refused), Room::Typed);
assert_eq!(AWAITING, "Awaiting the cloud provider…", "the server's own pending words");
assert_eq!(retry_refused_ms(0), Some(REFUSED_AGAIN_MS), "a refusal is asked again at once");
assert_eq!(retry_refused_ms(REFUSED_FOR_MS - 1), Some(REFUSED_AGAIN_MS));
}
/// ★ **Refused past the ceiling: the server's sentence and Retry** — and
/// still never the typed order. The asking stops at the ceiling; the
/// button starts it again.
#[test]
fn a_window_refused_past_the_ceiling_is_the_servers_sentence_and_retry() {
let said = "gunnar cannot be asked who you are right now, so this request cannot be authorised: the product is down";
let words = refusal_words(503, said, "gunnar is not responding right now.");
assert_eq!(words, said, "the server's own sentence, verbatim");
assert_eq!(retry_refused_ms(REFUSED_FOR_MS), None, "past the ceiling the console stops asking");
assert_eq!(room(Window::GaveUp(&words)), Room::Retry(said.to_owned()));
assert_ne!(room(Window::GaveUp(&words)), Room::Typed);
// A maintenance answer carries no message: the console's one line for it.
let maintenance = "Gunnar is temporarily down for maintenance. Please try again shortly.";
assert_eq!(refusal_words(503, "", maintenance), maintenance);
// Never reached: the browser's error is not a server sentence.
let unreached = "The console could not reach the server.";
assert_eq!(refusal_words(0, "TypeError: Failed to fetch", unreached), unreached);
}
/// ★ **The typed order is drawn for ONE shape: an answer that declares
/// nothing.** Every way of not having that answer — still asking, refused,
/// refused past the ceiling, still being read, still being read late — is
/// something else.
#[test]
fn only_a_deployment_that_declares_no_offers_is_typed() {
assert_eq!(room(Window::Listed(&shop(vec![], 0, None))), Room::Typed);
let reading = reading();
for (what, w) in [
("waiting", Window::Waiting),
("refused", Window::Refused),
("refused past the ceiling", Window::GaveUp("monetize is not answering")),
("being read", Window::Listed(&reading)),
("being read, late", Window::Late(&reading)),
] {
assert_ne!(room(w), Room::Typed, "{what} drew the typed order");
}
}
/// While the read is in flight nothing is claimed, in either direction:
/// drawing the boxes and then swapping them for buttons would move a
/// control out from under a pointer.
#[test]
fn a_window_still_being_read_claims_neither_shape() {
assert_eq!(room(Window::Waiting), Room::Waiting);
}
/// ★ **No button on a row that cannot be served** — the offer, marked,
/// and monetize's sentence. Not a disabled control either: that looks
/// pressable, which is a lie about what pressing it would do.
#[test]
fn a_row_that_cannot_be_served_gets_its_reason_and_no_button() {
let mut refused = offer("100gb-12m", false);
// monetize's line on a box that is never resized (round 5.3: no room
// left, no cap; on an estate every size is offered and queued).
refused.reason = "Not offered here right now — ask us about it.".to_owned();
assert_eq!(row(&refused), Row::Marked { offer: &refused, why: "Not offered here right now — ask us about it." });
}
/// ★ **An offer that does not fit is SHOWN — its own words, greyed, not a
/// control — with monetize's sentence under it** (owner, test2
/// 2026-10-05: "an offer that cannot fit must not look buyable"; mark it
/// rather than hide it, so the buyer knows the bigger sizes exist). Before,
/// the row was monetize's sentence alone, and an order's refusal ("We
/// cannot raise this account to pack_bytes 500.0 GiB … Nothing has been
/// charged") read, on load, as if the customer had ordered something.
/// Both panels draw it the same way, and neither works out the room.
#[test]
fn an_offer_that_does_not_fit_is_shown_with_its_words_and_monetizes_sentence_on_both_panels() {
// The customer's offers are the ONE sign-up page's since 2026-10-17.
let (name, page) = ("increase", crate::code::of(include_str!("pages/increase.rs")));
let at = page.find("Row::Marked").unwrap_or_else(|| panic!("{name}: an offer that does not fit is not drawn as an offer"));
let arm = &page[at..(at + 1400).min(page.len())];
assert!(arm.contains("offer-marked") && arm.contains("offer_words") && arm.contains("offer-why"), "{name}: the marked offer is not its words + monetize's sentence:\n{arm}");
let arm_end = arm.find("}.into_any()").unwrap_or(arm.len());
assert!(!arm[..arm_end].contains("on:click"), "{name}: a marked offer is pressable:\n{arm}");
}
/// ★ A32-29: **the offer button's words are the server's** (monetize-site-buy
/// `Bundle.offer_words`), carried as they are; the console composes none.
#[test]
fn the_offer_buttons_words_are_the_servers() {
let b: Bundle = serde_json::from_str(r#"{"id":"b","label":"10.0 GiB more room","price_words":"100.00 SEK","period":"month","delta":{"pack_bytes":10737418240},"can_serve":true,"reason":"","offer_words":"the server's button"}"#).unwrap();
assert_eq!(b.offer_words, "the server's button");
}
/// And the absence of a sentence is not permission to draw the button. A
/// server that refuses without words has still refused.
#[test]
fn a_refusal_with_no_words_is_still_a_refusal() {
let silent = offer("10gb-1m", false);
assert_eq!(row(&silent), Row::Marked { offer: &silent, why: "" });
}
/// A row that CAN be served is the whole bundle, handed back for rendering
/// — so the words on the button are the server's and not a reconstruction.
#[test]
fn a_servable_row_carries_the_servers_own_words_to_the_button() {
let ok = offer("10gb-1m", true);
let Row::Press(b) = row(&ok) else { panic!("a servable row is pressable") };
assert_eq!(b.label, "10.0 GiB more room");
assert_eq!(b.price_words, "220.00 SEK", "carried, never composed");
assert_eq!(b.period, "month");
}
/// The delta is spelled with the usage table's own formatter, and the meter
/// name is the one that table counts under.
#[test]
fn the_delta_is_spelled_the_way_the_table_above_spells_sizes() {
let delta = BTreeMap::from([("pack_bytes".to_owned(), 10_737_418_240)]);
assert_eq!(added(&delta), "pack_bytes +10.0 GiB");
}
/// A meter that is not bytes-shaped is a count, and `fmt::bytes` would say
/// `5 B` about five repositories.
#[test]
fn a_meter_that_is_not_bytes_is_printed_as_the_count_it_is() {
let delta = BTreeMap::from([("repos".to_owned(), 5)]);
assert_eq!(added(&delta), "repos +5");
}
/// ★ **A row this console half-understands draws no button.** `can_serve`
/// defaults to false, so an older or newer server that leaves the field out
/// gets the sentence rather than a press nobody vouched for.
#[test]
fn a_sparse_row_is_not_pressable() {
let parsed: Shop =
serde_json::from_str(r#"{"offers":[{"id":"x","label":"more room"}]}"#).expect("parses");
assert!(!parsed.offers[0].can_serve);
assert_eq!(row(&parsed.offers[0]), Row::Marked { offer: &parsed.offers[0], why: "" });
assert!(matches!(room(Window::Listed(&parsed)), Room::Offered(_)));
assert_eq!(parsed.withheld, 0, "an absent count is nothing withheld");
assert_eq!(parsed.unavailable, None);
}
/// The server's window, byte for byte as `api::increase::Window` serialises
/// it on the three days that matter: rows, an empty shop with a line, and
/// a shop nobody declared.
#[test]
fn the_servers_window_parses_in_all_three_shapes() {
let full: Shop = serde_json::from_str(
r#"{"offers":[{"id":"10gb","label":"10.0 GiB more room","price_words":"100.00 SEK","period":"month","delta":{"pack_bytes":10737418240},"can_serve":true,"reason":""}],"withheld":2}"#,
)
.expect("parses");
assert_eq!(full.offers.len(), 1);
assert_eq!(full.withheld, 2);
assert!(matches!(room(Window::Listed(&full)), Room::Offered(_)));
let line: Shop = serde_json::from_str(
r#"{"offers":[],"withheld":3,"unavailable":"None of the sizes on offer can be added to this account right now."}"#,
)
.expect("parses");
assert!(matches!(room(Window::Listed(&line)), Room::Nothing(_)));
let none: Shop = serde_json::from_str(r#"{"offers":[],"withheld":0}"#).expect("parses");
assert_eq!(room(Window::Listed(&none)), Room::Typed);
}
}
/// **An order echoes the market of the window it was pressed in** — the server's word, `None` for
/// monetize's default. Pure, so it is tested without a browser.
pub fn market_of_window(shop: &Shop) -> Option<String> {
let m = shop.market.trim();
(!m.is_empty()).then(|| m.to_owned())
}
#[cfg(test)]
mod market_tests {
use super::*;
/// ★ **The page decides no currency** (wiring tracer B 2026-10-08): the market is the server's, read
/// off the window it answered, and echoed on the order; none named is monetize's default.
#[test]
fn an_order_echoes_the_market_the_server_priced_the_window_in() {
let dk: Shop = serde_json::from_str(r#"{"offers":[],"withheld":0,"market":"DK"}"#).expect("parses");
assert_eq!(market_of_window(&dk).as_deref(), Some("DK"));
let none: Shop = serde_json::from_str(r#"{"offers":[],"withheld":0}"#).expect("parses");
assert_eq!(market_of_window(&none), None, "monetize's default");
let src = crate::code::of(include_str!("pages/increase.rs"));
assert!(!src.contains(&["navigator", "().language"].concat()), "the window's page reads the browser's language again");
}
}
#[cfg(test)]
mod shop_tests {
use super::shop_here;
/// ★ **A SOLD (provided) Gunnar draws no shop** (one shop, owner
/// 2026-10-07): the server's `shop_here: false` is the console's word; an
/// older server that says nothing sells, as before.
#[test]
fn a_provided_console_draws_no_shop() {
assert!(!shop_here(Some(false)), "a sold Gunnar's shop is its provider's");
assert!(shop_here(Some(true)));
assert!(shop_here(None), "a server that says nothing sells, as before");
}
}
#[cfg(test)]
mod purchases_tests {
use super::*;
/// ★ **S10's "Send my sign-in link again" sits on the instance's own row** (OM-23, plan
/// §3c): a buyer who lost the ready mail's one link asks for a new one where the instance
/// is listed, not one click further on its renew pane — while the instance runs (ready, or
/// overdue and still running), the only time it can mint one. An instance not yet
/// delivered, stopped or gone offers none. Whether its first admin already has a passkey
/// only the instance knows; it refuses then, and the row shows its words.
#[test]
fn the_instance_row_offers_its_sign_in_link_again_while_it_runs() {
let row = |state: &str| Instance { company: "acme".into(), host: "acme.gunnar.rs".into(), state: state.into(), ..Default::default() };
assert_eq!(RESTORE_FRONT, "/auth/private/restore-front", "the console's door (gunnar-ui-server serve.rs)");
// ★ ACCT-6: Restore front only on monetize's word that the front needs repair.
let lost = |state: &str| Instance { repair_words: "acme.gunnar.rs's front has not answered for 4 hours.".into(), ..row(state) };
assert!(restore_front_offered(&lost("ready")) && !restore_front_offered(&row("ready")) && !restore_front_offered(&row("overdue")));
assert!(sign_in_again_offered(&row("ready")));
assert!(sign_in_again_offered(&row("overdue")));
for s in ["waiting_for_payment", "queued", "waiting_for_dns", "laying", "installing", "suspended", "deleted", "cancelled", "failed", ""] {
assert!(!sign_in_again_offered(&row(s)), "{s:?} offers no new sign-in link");
}
assert_eq!(SIGN_IN_AGAIN, "/auth/private/sign-in-again", "the door signup.html's renew pane posts to");
}
/// ★ **"Manage <company>" on the instance's row** (owner 2026-10-22): the account's own page
/// leads its admin to the page that renews, stops renewing and terminates the instance —
/// the server's `renew_url` (`/signup?plan=private&renew=<company>`), named the owner's way.
/// A row is an instance the account owns; no instance, no row, no Manage.
#[test]
fn the_instance_row_offers_manage_on_the_servers_renew_address() {
assert_eq!(manage_words("acme"), "Manage acme");
let page = crate::code::of(include_str!("pages/subscription.rs"));
let row = &page[page.find("pub fn YourPurchases").expect("the instance rows")..];
let row = &row[..row.find("\n}\n").expect("its end")];
assert!(
row.contains("href=e.renew_url.clone()>{crate::offers::manage_words(&e.company)}</a>"),
"Manage leads to the server's renew address: {row}"
);
assert!(!row.contains("\"Renew\""), "one link to that page, called Manage: {row}");
}
/// ★ **"Grow this instance" leads to the shop that sold it** (one shop 4):
/// a sold Gunnar told its shop and its name links to the shop's sign-up
/// page growing it; a console that sells, or is not told both, has none.
#[test]
fn grow_this_instance_leads_to_the_shop_that_sold_it() {
assert_eq!(grow_this_instance(false, Some("https://gunnar.rs"), Some("acme")).as_deref(), Some("https://gunnar.rs/signup?plan=grow&instance=acme"));
assert_eq!(grow_this_instance(false, Some("https://gunnar.rs/"), Some("acme")).as_deref(), Some("https://gunnar.rs/signup?plan=grow&instance=acme"));
assert_eq!(grow_this_instance(true, Some("https://gunnar.rs"), Some("acme")), None, "the shop's own console grows nothing elsewhere");
assert_eq!(grow_this_instance(false, None, Some("acme")), None);
assert_eq!(grow_this_instance(false, Some("https://gunnar.rs"), None), None);
assert_eq!(grow_this_instance(false, Some("http://gunnar.rs"), Some("acme")), None, "https only");
assert_eq!(grow_this_instance(false, Some("https://gunnar.rs"), Some("Acme&x=1")), None, "a name as the shop names estates");
}
}