Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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");
    }
}