uitest/src/fixture.rs
//! **THE fixture: one estate, four products, every number distinct and non-zero.**
//!
//! This file is the single source of every figure the console is tested
//! against — on the wire by `crates/monetize-ui-server/tests/screens_*.rs`
//! (which `#[path]`-include it) and in the DOM by the browser matrix. One
//! writer, so a screen row and an endpoint test cannot disagree about what
//! `alice` holds.
//!
//! # Why every number is different
//!
//! On every real gunnar `lfs_bytes` is 0, most caches are 0, and a fixture
//! that copied reality would make `Cache 0 B` indistinguishable from a meter
//! the screen never wired up. So each meter of each tenant carries its own
//! value, no two the same, none zero — and the rendered string is written
//! beside the raw number (`Shown`) and PROVED against the one byte formatter
//! (`monetize_product::bytes`) by a self-test in `main.rs`. A row that finds
//! `10.2 GiB` on the page has found the fixture's `10_952_200_000`, not a
//! coincidence.
//!
//! # What each product is for
//!
//! | product | dot | fleet | rate | growth |
//! |---------|------|--------------------------------|--------------------------|-----------------|
//! | gunnar | up | measured, `fits`, capacity | approximate → `Full in ~`| **sealed** |
//! | holger | down | unmeasured, no tenants | not enough history | (never measured)|
//! | idun | up | **unmeasured**, capacity null | **not enough history** | (never measured)|
//! | skade | up | measured, **oversold** by 4 GiB| **bursty** → no date | operator_only |
//!
//! gunnar's tenants are the sort test: seven of them, inserted in a fixed
//! ALPHABETICAL order (which is what the BFF's join emits), and the expected
//! attention order is stated in [`GUNNAR_CLOSEST_FIRST`] — different from the
//! alphabetical one, so a console that forgot to sort is red.
// Shared by `#[path]` with the server tests: not every item is used by every includer.
#![allow(dead_code)]
use std::collections::BTreeMap;
use monetize_proto as pb;
pub const GIB: u64 = 1 << 30;
pub const MIB: u64 = 1 << 20;
/// A raw number and the string the one formatter renders it as.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Shown {
pub raw: u64,
pub text: &'static str,
}
/// An instant on a Sep 4 (the spec's day). `month_day` is year-less.
pub const T0: u64 = 1_757_000_000_000;
/// A `paid_until` far enough out that no ladder logic in a test can trip on it.
pub const PAID_UNTIL: u64 = 1_800_000_000_000;
/// The issue time every fact this fixture mints carries. Fixed, like
/// `PAID_UNTIL`, so the console's pages are byte-stable between runs; the
/// monotonicity it feeds is a product's concern and not this mock's.
pub const ISSUED_AT: u64 = 1_790_000_000_000;
/// **The estates the ONE monetize's fleet knows** (OVW-4): `(name, product, domain)`, each
/// installed, its main reporting in. The Overview's rows and each row's summary read them.
pub const ESTATES: &[(&str, &str, &str)] = &[("gunnar-rs", "gunnar", "gunnar.rs")];
pub const VENDORS: &[&str] = &["invoice", "mockpay"];
pub const CLOUDS: &[&str] = &["upcloud"];
/// **The spend line** (`UI.md` screen 1's last line): what the ledger's fold
/// says has been committed at the cloud, against its LIFETIME ceiling. The
/// monthly ceiling is deliberately unset — absent, not zero.
pub const SPEND_SPENT_MINOR: u64 = 226;
pub const SPEND_SPENT_TEXT: &str = "2.26";
pub const SPEND_CEILING_MINOR: u64 = 1_000;
pub const SPEND_CEILING_TEXT: &str = "10.00";
pub fn spend() -> Vec<pb::CloudSpend> {
vec![pb::CloudSpend {
provider: "upcloud".into(),
currency: "EUR".into(),
spent_minor: SPEND_SPENT_MINOR,
spent_this_month_minor: Some(SPEND_SPENT_MINOR),
ceiling_total_minor: Some(SPEND_CEILING_MINOR),
ceiling_monthly_minor: None,
}]
}
/// CLOUD: what we own at the provider, reconciled against the ledger — this
/// product's bought disks, nothing orphaned, nothing missing.
pub fn cloud(product: &str) -> pb::CloudInventory {
let owned: Vec<pb::OwnedResource> = holdings_of(product)
.into_iter()
.map(|(tenant, h)| {
let gib = match h.resource.as_ref().and_then(|r| r.kind.as_ref()) {
Some(pb::resource::Kind::Disk(d)) => d.gib,
_ => 0,
};
pb::OwnedResource {
provider: h.provider.clone(),
id: h.id.clone(),
kind: "disk".into(),
gib,
zone: "se-sto1".into(),
attached_to: ALICE_DISK_SERVER.into(),
reference: h.reference.clone(),
tenant,
priced: true,
monthly_minor: gib * 22,
provenance: "ledger".into(),
..Default::default()
}
})
.collect();
let total_bytes = owned.iter().map(|o| o.gib * GIB).sum();
let monthly_minor = owned.iter().map(|o| o.monthly_minor).sum();
pb::CloudInventory { provider: "upcloud".into(), available: true, error: String::new(), owned, orphans: vec![], missing: vec![], foreign_count: 0, total_bytes, monthly_minor, unpriced: 0, currency: "EUR".into(), ..Default::default() }
}
fn holdings_of(product: &str) -> Vec<(String, pb::Holding)> {
let tenants: &[&str] = match product {
"gunnar" => &["alice", "bob"],
"skade" => &["mira"],
_ => &[],
};
tenants.iter().flat_map(|t| holdings(product, t).into_iter().map(|h| ((*t).to_owned(), h))).collect()
}
// ── gunnar's tenants, meter by meter ─────────────────────────────────────
/// `alice` — AT CAP (over it, in fact): `UI.md` screen 3's tenant.
pub const ALICE_PACK: Shown = Shown { raw: 10_952_200_000, text: "10.2 GiB" };
pub const ALICE_CACHE: Shown = Shown { raw: 11_703_818_240, text: "10.9 GiB" };
pub const ALICE_LFS: Shown = Shown { raw: 1_395_864_372, text: "1.3 GiB" };
pub const ALICE_TOMBSTONED: Shown = Shown { raw: 87_031_808, text: "83.0 MiB" };
pub const ALICE_REPOS: u64 = 26;
pub const ALICE_RAM: Shown = Shown { raw: 432_537_600, text: "412.5 MiB" };
/// `bob` — 31 %.
pub const BOB_PACK: Shown = Shown { raw: 3_328_599_655, text: "3.1 GiB" };
pub const BOB_CACHE: Shown = Shown { raw: 2_362_232_013, text: "2.2 GiB" };
pub const BOB_LFS: Shown = Shown { raw: 751_619_277, text: "716.8 MiB" };
pub const BOB_TOMBSTONED: Shown = Shown { raw: 5_242_880, text: "5.0 MiB" };
pub const BOB_REPOS: u64 = 4;
pub const BOB_RAM: Shown = Shown { raw: 12_897_485, text: "12.3 MiB" };
/// `anna` — 89 %.
pub const ANNA_PACK: Shown = Shown { raw: 9_556_302_234, text: "8.9 GiB" };
/// `team/sub` — 97 %, in GRACE: attention by state as well as by closeness.
pub const TEAM_SUB_PACK: Shown = Shown { raw: 10_415_295_693, text: "9.7 GiB" };
/// `zz-visibility` — 50 % of a 1 GiB plan.
pub const ZZ_PACK: Shown = Shown { raw: 536_870_912, text: "512.0 MiB" };
/// `bjorn` — NO fact, so no cap: the uncapped case.
pub const BJORN_PACK: Shown = Shown { raw: 2_469_606_196, text: "2.3 GiB" };
/// `edge` — EXACTLY at cap: `AT CAP` must appear when value == cap, not only when it exceeds it.
pub const EDGE_PACK: Shown = Shown { raw: 10 * GIB, text: "10.0 GiB" };
pub const CAP_10: Shown = Shown { raw: 10 * GIB, text: "10.0 GiB" };
pub const CAP_1: Shown = Shown { raw: GIB, text: "1.0 GiB" };
pub const CAP_CACHE_20: Shown = Shown { raw: 20 * GIB, text: "20.0 GiB" };
/// The seven, in the order the BFF emits them (alphabetical by tenant).
///
/// It stopped being alphabetical on 2026-09-05, when the name cleanse
/// (`0cb233c`) substituted neutral placeholders in place and nobody re-sorted
/// the list. Two tests that compare a server's emitted order against it —
/// `monetize-ui-server` screens_endpoints screen 2, and `the_closest_first_…`
/// here — have been RED ever since, for a fixture's reason and not a server's.
/// [`gunnar_tenants_are_actually_alphabetical`] is the guard, so the next
/// rename cannot do it again quietly.
pub const GUNNAR_TENANTS_ALPHABETICAL: &[&str] = &["alice", "anna", "bjorn", "bob", "edge", "team/sub", "zz-visibility"];
/// **The order `UI.md` rule 5 asks for**: the tenants at their cap first (`edge`
/// and `alice` tie there; either order between them is right), then by
/// percentage, then the uncapped one last. NOT alphabetical.
pub const GUNNAR_CLOSEST_FIRST: &[&str] = &["edge", "alice", "team/sub", "anna", "zz-visibility", "bob", "bjorn"];
/// gunnar's fleet on `pack_bytes`: the sum of the seven readings above.
pub fn gunnar_pack_used() -> u64 {
ALICE_PACK.raw + BOB_PACK.raw + ANNA_PACK.raw + TEAM_SUB_PACK.raw + ZZ_PACK.raw + BJORN_PACK.raw + EDGE_PACK.raw
}
/// …and the string the big number renders it as. Proved by the self-test.
pub const GUNNAR_PACK_USED_TEXT: &str = "44.7 GiB";
pub const GUNNAR_CAPACITY: Shown = Shown { raw: 60 * GIB, text: "60.0 GiB" };
/// The six caps the facts name: five × 10 GiB and one × 1 GiB. `bjorn` names none.
pub const GUNNAR_ALLOWANCE: u64 = 5 * 10 * GIB + GIB;
pub const GUNNAR_HEADROOM: u64 = 60 * GIB - GUNNAR_ALLOWANCE;
/// gunnar's fleet rate: 1.5 GiB/wk, APPROXIMATE — projected, with its `~`.
pub const GUNNAR_FLEET_PER_WEEK: f64 = 1.5 * GIB as f64;
pub const GUNNAR_FLEET_PER_WEEK_TEXT: &str = "1.5 GiB";
/// alice's own rate: 1.4 GiB/wk, FIRM.
pub const ALICE_PER_WEEK: f64 = 1.4 * GIB as f64;
pub const ALICE_PER_WEEK_TEXT: &str = "1.4 GiB";
/// skade's fleet rate: 1.2 GiB/wk, BURSTY — the rate is true, the date is forbidden.
pub const SKADE_FLEET_PER_WEEK: f64 = 1.2 * GIB as f64;
pub const SKADE_FLEET_PER_WEEK_TEXT: &str = "1.2 GiB";
pub const DAY_MS: u64 = 24 * 3_600_000;
pub const WINDOW_30D_MS: u64 = 30 * DAY_MS;
pub const NEEDED_SPAN_MS: u64 = 302_400_000;
// ── skade: oversold ──────────────────────────────────────────────────────
pub const MIRA_STORE: Shown = Shown { raw: 8_267_919_852, text: "7.7 GiB" };
pub const NILS_STORE: Shown = Shown { raw: 1_181_116_007, text: "1.1 GiB" };
pub const SKADE_CAP_8: Shown = Shown { raw: 8 * GIB, text: "8.0 GiB" };
pub const SKADE_CAPACITY: Shown = Shown { raw: 12 * GIB, text: "12.0 GiB" };
/// Two tenants promised 8 GiB each on a 12 GiB box: oversold by 4 GiB.
pub const SKADE_OVERSOLD: Shown = Shown { raw: 4 * GIB, text: "4.0 GiB" };
pub fn skade_store_used() -> u64 {
MIRA_STORE.raw + NILS_STORE.raw
}
// ── idun: unmeasured, young ─────────────────────────────────────────────
pub const OLA_ARCHIVE: Shown = Shown { raw: 2_576_980_378, text: "2.4 GiB" };
pub const IDUN_CAP_5: Shown = Shown { raw: 5 * GIB, text: "5.0 GiB" };
pub const IDUN_UNMEASURED_REASON: &str = "idun has not answered its capacity question yet";
// holger never HAD a capacity RPC — "it predates it" was gunnar's sentence
// borrowed for a stub. What the plugin says today (2026-09-18) is that nothing
// on holger's control plane has ever measured the filesystem its store sits on.
pub const HOLGER_UNMEASURED_REASON: &str = "holger has no capacity RPC — nothing on its control plane has ever measured a filesystem";
// The shape a down holger really produces since the plugin was wired
// (2026-09-18): `status_to_error` names the box. It was "holger: not wired"
// while every method was a stub, and a fixture asserting a sentence the
// product can no longer say is a fixture that teaches the wrong thing.
pub const HOLGER_DOWN_DETAIL: &str = "product unavailable: holger at 127.0.0.1:50051: tcp connect error";
// ── the sentences that must survive whole ────────────────────────────────
/// gunnar's growth remediation, verbatim as `monetize_product_gunnar::remediation()`
/// would say it. The console holds it behind `⚠ cannot grow`. The LAST clause is
/// the one a truncation takes first, and the one that stops the wrong purchase.
pub const SEALED_DETAIL: &str = "this set cannot grow while gunnar runs: gunnar has no runtime data-set growth in any build and is PID 1 with no shell, so a disk bought and attached to this box would be BILLED AND INVISIBLE. Serving more needs a fresh install onto a larger set, not another disk";
/// An order for alice to 100 GiB refused: `can_absorb`'s paragraph, verbatim.
pub const SEALED_REFUSAL: &str = "the product cannot deliver this to alice: gunnar can serve the pack_bytes cap of 100.0 GiB, but the order also budgets 20.0 GiB of cache_bytes on the SAME filesystem and only 10.0 GiB is left once the raise is served — short 10.0 GiB. AND THIS SET CANNOT GROW WHILE GUNNAR RUNS: gunnar has no runtime data-set growth in any build and is PID 1 with no shell, so a disk bought and attached to this box would be BILLED AND INVISIBLE. Serving more needs a fresh install onto a larger set, not another disk";
/// **The price list** (`[prices.gunnar.SE]`): 22.00 SEK per GiB-month of disk,
/// nothing for CPU or RAM — and skade's, in EUR. idun and holger have none,
/// which is FREE: the open-source deployment's shape, on the same estate.
pub const GUNNAR_DISK_GIB_MONTH_MINOR: u64 = 2_200;
pub const SKADE_DISK_GIB_MONTH_MINOR: u64 = 1_238;
/// 90 GiB more for alice at 22.00: `1 980.00 SEK`.
pub const ALICE_QUOTE_90_GIB_MINOR: u64 = 90 * GUNNAR_DISK_GIB_MONTH_MINOR;
pub const ALICE_QUOTE_90_GIB_TEXT: &str = "1980.00 SEK";
/// What the provider charges for those 90 GiB: 23 cents a GiB-month, measured.
pub const ALICE_QUOTE_90_GIB_COST_MINOR: u64 = 90 * 23;
pub fn prices() -> Vec<pb::PriceList> {
vec![
pb::PriceList { product: "gunnar".into(), market: pb::Market::Se as i32, currency: pb::Currency::Sek as i32, units: [("disk_gib_month".to_string(), GUNNAR_DISK_GIB_MONTH_MINOR), ("cpu_core_month".to_string(), 0), ("ram_gib_month".to_string(), 0)].into() },
pb::PriceList { product: "skade".into(), market: pb::Market::Fi as i32, currency: pb::Currency::Eur as i32, units: [("disk_gib_month".to_string(), SKADE_DISK_GIB_MONTH_MINOR)].into() },
]
}
/// The one policy: monthly, 14 days of grace, 90 of retention.
pub fn policy() -> pb::Policy {
pb::Policy { period: "month".into(), grace_days: 14, retention_days: 90 }
}
pub fn hosts() -> BTreeMap<String, String> {
[("gunnar".to_string(), ALICE_DISK_SERVER.to_string()), ("skade".to_string(), "skade-fi-hel1".to_string())].into()
}
pub const SKADE_GROWTH_DETAIL: &str = "an operator can attach a larger volume and grow the store at the next restart; nothing grows on its own";
pub const STUCK_REASON: &str = "paid, never delivered: gunnar refused the signed fact";
pub const ZZ_REFUND_REASON: &str = "the cloud refused the disk";
pub const ANNA_DECLINE_REASON: &str = "the vendor declined the card";
// ── the estate ───────────────────────────────────────────────────────────
fn meter(name: &str, meaning: &str, enforcement: pb::Enforcement, scope: pb::Scope) -> pb::MeterInfo {
// The price-list unit: the storage meters are disk, the cores are CPU,
// and a count has none. `tombstoned_bytes` is deliberately disk-backed
// here — deleted-not-purged bytes the list still charges for — so the
// console is tested saying `still costs` from the PRICE rather than
// from a word it invented.
let unit = match name {
"pack_bytes" | "tombstoned_bytes" | "archive_bytes" | "store_bytes" => "disk_gib_month",
"cpu_millicores" => "cpu_core_month",
_ => "",
};
pb::MeterInfo { name: name.into(), meaning: meaning.into(), enforcement: enforcement as i32, scope: scope as i32, unit: unit.into() }
}
fn reach(state: &str, checked: u64, last_up: u64, detail: &str) -> pb::Reachability {
pb::Reachability { state: state.into(), checked_at_unix_ms: checked, last_up_unix_ms: last_up, detail: detail.into() }
}
/// The paid door, as the poller last saw it. `unproven` for every product but
/// gunnar, which is the honest fixture: a box nobody could ask is the state
/// three of these four are in, and it is deliberately NOT the same pixel as a
/// box that answered UNIMPLEMENTED.
fn door(state: &str, detail: &str) -> pb::MeteredDoor {
pb::MeteredDoor { state: state.into(), checked_at_unix_ms: T0, detail: detail.into() }
}
pub const PRODUCT_IDS: &[&str] = &["gunnar", "holger", "idun", "skade"];
/// `Products.List`, in this order.
pub fn products() -> Vec<pb::ProductInfo> {
use pb::Enforcement::{Enforced, Measured, Provisioned};
use pb::Scope::{Product, Tenant};
vec![
pb::ProductInfo {
id: "gunnar".into(),
meters: vec![
meter("pack_bytes", "object bytes on disk that are not re-derivable; what is sold", Enforced, Tenant),
meter("cache_bytes", "the exploded read cache; re-derivable", Enforced, Tenant),
meter("lfs_bytes", "LFS objects under the namespace's git dirs", Measured, Tenant),
meter("tombstoned_bytes", "soft-deleted repositories not yet purged; still charged", Measured, Tenant),
meter("repos", "live repositories in the namespace", Measured, Tenant),
meter("open_store_ram_bytes", "RAM an open store costs", Measured, Tenant),
meter("cpu_millicores", "CPU bought for the box", Provisioned, Product),
],
reachable: Some(reach("up", T0, T0, "")),
// The one product in the fixture with an appliance that mints actor
// tickets, so a screen that offers self-service renewal has a
// product to offer it for — and three that it must not.
tenant_renewal: true,
metered_door: Some(door("enforcing", "Entitlement.List answered")),
},
pb::ProductInfo {
id: "holger".into(),
meters: vec![meter("archive_bytes", "archived artifact bytes", Enforced, Tenant), meter("artifacts", "artifacts held", Measured, Tenant)],
reachable: Some(reach("down", T0, 0, HOLGER_DOWN_DETAIL)),
tenant_renewal: false,
metered_door: Some(door("unproven", "holger did not answer at all this tick")),
},
pb::ProductInfo {
id: "idun".into(),
meters: vec![meter("archive_bytes", "archived bytes", Enforced, Tenant), meter("requests", "requests served", Measured, Tenant)],
reachable: Some(reach("up", T0 + 60_000, T0 + 60_000, "")),
tenant_renewal: false,
metered_door: Some(door("unproven", "the idun plugin has no metered-door probe")),
},
pb::ProductInfo {
id: "skade".into(),
meters: vec![meter("store_bytes", "bytes in the store", Enforced, Tenant), meter("requests", "requests served", Measured, Tenant)],
reachable: Some(reach("up", T0 + 120_000, T0 + 120_000, "")),
tenant_renewal: false,
metered_door: Some(door("unproven", "the skade plugin has no metered-door probe")),
},
]
}
fn usage(tenant: &str, at: u64, meters: &[(&str, u64)]) -> pb::UsageReport {
pb::UsageReport { tenant: tenant.into(), meters: meters.iter().map(|(n, v)| pb::Meter { name: (*n).into(), value: *v }).collect(), measured_at_unix_ms: at }
}
/// `Usage.ReadAll` for one product, **emitted in tenant order** — the order the
/// BFF's `BTreeMap` join produces — so nothing upstream of the console has done
/// the console's sorting for it.
///
/// SORTED HERE rather than by the order of the literals below, which is what
/// broke when the name cleanse substituted placeholders in place: a mock that
/// emits a different order from the server it doubles is a test rig that lies,
/// and the tenants' `measured_at` stamps stay attached to the tenants instead
/// of to their line numbers.
pub fn usages(product: &str) -> Vec<pb::UsageReport> {
let mut out = usages_unsorted(product);
out.sort_by(|a, b| a.tenant.cmp(&b.tenant));
out
}
fn usages_unsorted(product: &str) -> Vec<pb::UsageReport> {
match product {
"gunnar" => vec![
usage("anna", T0 + 1_000, &[("pack_bytes", ANNA_PACK.raw), ("cache_bytes", 4_724_464_128), ("lfs_bytes", 33 * MIB), ("tombstoned_bytes", MIB), ("repos", 11), ("open_store_ram_bytes", 77 * MIB)]),
usage("bjorn", T0 + 2_000, &[("pack_bytes", BJORN_PACK.raw), ("cache_bytes", 1_288_490_189), ("lfs_bytes", 17 * MIB), ("tombstoned_bytes", 3 * MIB), ("repos", 7), ("open_store_ram_bytes", 41 * MIB)]),
usage("edge", T0 + 3_000, &[("pack_bytes", EDGE_PACK.raw), ("cache_bytes", 6_442_450_944), ("lfs_bytes", 9 * MIB), ("tombstoned_bytes", 2 * MIB), ("repos", 13), ("open_store_ram_bytes", 99 * MIB)]),
usage("bob", T0 + 4_000, &[("pack_bytes", BOB_PACK.raw), ("cache_bytes", BOB_CACHE.raw), ("lfs_bytes", BOB_LFS.raw), ("tombstoned_bytes", BOB_TOMBSTONED.raw), ("repos", BOB_REPOS), ("open_store_ram_bytes", BOB_RAM.raw)]),
usage("alice", T0 + 5_000, &[("pack_bytes", ALICE_PACK.raw), ("cache_bytes", ALICE_CACHE.raw), ("lfs_bytes", ALICE_LFS.raw), ("tombstoned_bytes", ALICE_TOMBSTONED.raw), ("repos", ALICE_REPOS), ("open_store_ram_bytes", ALICE_RAM.raw)]),
usage("team/sub", T0 + 6_000, &[("pack_bytes", TEAM_SUB_PACK.raw), ("cache_bytes", 3_006_477_107), ("lfs_bytes", 21 * MIB), ("tombstoned_bytes", 6 * MIB), ("repos", 9), ("open_store_ram_bytes", 58 * MIB)]),
usage("zz-visibility", T0 + 7_000, &[("pack_bytes", ZZ_PACK.raw), ("cache_bytes", 268_435_456), ("lfs_bytes", 2 * MIB), ("tombstoned_bytes", 512 * 1024), ("repos", 1), ("open_store_ram_bytes", 3 * MIB)]),
],
"idun" => vec![usage("ola", T0 + 8_000, &[("archive_bytes", OLA_ARCHIVE.raw), ("requests", 4_811)])],
"skade" => vec![
usage("mira", T0 + 9_000, &[("store_bytes", MIRA_STORE.raw), ("requests", 12_040)]),
usage("nils", T0 + 10_000, &[("store_bytes", NILS_STORE.raw), ("requests", 391)]),
],
_ => vec![],
}
}
fn caps(pairs: &[(&str, u64)]) -> BTreeMap<String, u64> {
pairs.iter().map(|(k, v)| ((*k).to_owned(), *v)).collect()
}
/// The fact's `plan` field carries the ORDER it came from: the reference out
/// of a `payment:<vendor>:<reference>` source, nothing for an operator's or
/// the ladder's fact.
fn fact(tenant: &str, state: pb::State, source: &str, c: &[(&str, u64)]) -> pb::EntitlementFact {
let order = source.strip_prefix("payment:").and_then(|s| s.split_once(':')).map(|(_, r)| r.to_owned()).unwrap_or_default();
pb::EntitlementFact { tenant: tenant.into(), plan: order, state: state as i32, paid_until_unix_ms: Some(PAID_UNTIL), caps: caps(c), source: source.into(), signature: vec![], issued_unix_ms: None, issued_signature: vec![] }
}
/// `Entitlement.List` for one product, UNSIGNED — the mock signs on the way out.
/// `bjorn` and `stuck` have none: one is the uncapped tenant, the other the
/// attempt that never delivered.
pub fn facts(product: &str) -> Vec<pb::EntitlementFact> {
use pb::State::{Grace, Paid};
match product {
"gunnar" => vec![
fact("anna", Paid, "payment:invoice:gunnar/anna/2026-08-01", &[("pack_bytes", CAP_10.raw)]),
fact("edge", Paid, "payment:mockpay:gunnar/edge/2026-08-20", &[("pack_bytes", CAP_10.raw)]),
fact("bob", Paid, "payment:invoice:gunnar/bob/2026-09-04", &[("pack_bytes", CAP_10.raw)]),
fact("alice", Paid, "payment:invoice:gunnar/alice/2026-09-04", &[("pack_bytes", CAP_10.raw), ("cache_bytes", CAP_CACHE_20.raw)]),
fact("team/sub", Grace, "ladder", &[("pack_bytes", CAP_10.raw)]),
fact("zz-visibility", Paid, "payment:mockpay:gunnar/zz-visibility/2026-09-04", &[("pack_bytes", CAP_1.raw)]),
],
"idun" => vec![fact("ola", Paid, "operator", &[("archive_bytes", IDUN_CAP_5.raw)])],
"skade" => vec![
fact("mira", Paid, "payment:invoice:skade/mira/2026-09-01", &[("store_bytes", SKADE_CAP_8.raw)]),
fact("nils", Paid, "payment:invoice:skade/nils/2026-09-02", &[("store_bytes", SKADE_CAP_8.raw)]),
],
_ => vec![],
}
}
/// A per-tenant meter that promises no capacity: summed, never compared.
fn not_storage(meter: &str, tenants: u64, used: u64, without_cap: u64) -> pb::FleetMeter {
pb::FleetMeter { meter: meter.into(), tenants, used, tenants_without_reading: 0, allowance: 0, tenants_without_cap: without_cap, oversell: "not_storage".into(), oversell_bytes: 0, oversell_reason: "".into(), capacity: None, is_a_floor: without_cap > 0 }
}
fn sum(product: &str, meter: &str) -> u64 {
usages(product).iter().flat_map(|u| u.meters.iter()).filter(|m| m.name == meter).map(|m| m.value).sum()
}
/// `Usage.Fleet`, canned per product — the fold itself is `monetize::fleet`'s
/// and is tested where it lives; what THIS fixture guarantees is that the
/// numbers here are the sums of the readings above (`BASE-MODEL.md`: nothing on
/// screen the ledger cannot rebuild). The self-test holds it to that.
pub fn fleet(product: &str) -> pb::FleetResponse {
match product {
"gunnar" => pb::FleetResponse {
meters: vec![
// `bjorn` names no cap, so the allowance is a floor — and the
// console must say so rather than draw the picture as complete.
pb::FleetMeter { meter: "pack_bytes".into(), tenants: 7, used: gunnar_pack_used(), tenants_without_reading: 0, allowance: GUNNAR_ALLOWANCE, tenants_without_cap: 1, oversell: "fits".into(), oversell_bytes: GUNNAR_HEADROOM, oversell_reason: "".into(), capacity: Some(GUNNAR_CAPACITY.raw), is_a_floor: true },
not_storage("cache_bytes", 7, sum("gunnar", "cache_bytes"), 6),
not_storage("lfs_bytes", 7, sum("gunnar", "lfs_bytes"), 7),
not_storage("tombstoned_bytes", 7, sum("gunnar", "tombstoned_bytes"), 7),
not_storage("repos", 7, sum("gunnar", "repos"), 7),
not_storage("open_store_ram_bytes", 7, sum("gunnar", "open_store_ram_bytes"), 7),
],
servable_measured: true,
servable_bytes: GUNNAR_CAPACITY.raw - gunnar_pack_used(),
total_bytes: 64 * GIB,
servable_reason: String::new(),
servable_at_unix_ms: T0 + 30_000,
growth: "sealed".into(),
growth_detail: SEALED_DETAIL.into(),
cloud: Some(cloud("gunnar")),
},
"holger" => pb::FleetResponse {
meters: vec![
pb::FleetMeter { meter: "archive_bytes".into(), tenants: 0, used: 0, tenants_without_reading: 0, allowance: 0, tenants_without_cap: 0, oversell: "unmeasured".into(), oversell_bytes: 0, oversell_reason: HOLGER_UNMEASURED_REASON.into(), capacity: None, is_a_floor: true },
not_storage("artifacts", 0, 0, 0),
],
servable_measured: false,
servable_bytes: 0,
total_bytes: 0,
servable_reason: HOLGER_UNMEASURED_REASON.into(),
servable_at_unix_ms: 0,
growth: String::new(),
growth_detail: String::new(),
cloud: Some(cloud("holger")),
},
"idun" => pb::FleetResponse {
meters: vec![
pb::FleetMeter { meter: "archive_bytes".into(), tenants: 1, used: OLA_ARCHIVE.raw, tenants_without_reading: 0, allowance: IDUN_CAP_5.raw, tenants_without_cap: 0, oversell: "unmeasured".into(), oversell_bytes: 0, oversell_reason: IDUN_UNMEASURED_REASON.into(), capacity: None, is_a_floor: true },
not_storage("requests", 1, 4_811, 1),
],
servable_measured: false,
servable_bytes: 0,
total_bytes: 0,
servable_reason: IDUN_UNMEASURED_REASON.into(),
servable_at_unix_ms: 0,
growth: String::new(),
growth_detail: String::new(),
cloud: Some(cloud("idun")),
},
"skade" => pb::FleetResponse {
meters: vec![
pb::FleetMeter { meter: "store_bytes".into(), tenants: 2, used: skade_store_used(), tenants_without_reading: 0, allowance: 2 * SKADE_CAP_8.raw, tenants_without_cap: 0, oversell: "oversold".into(), oversell_bytes: SKADE_OVERSOLD.raw, oversell_reason: "".into(), capacity: Some(SKADE_CAPACITY.raw), is_a_floor: false },
not_storage("requests", 2, 12_040 + 391, 2),
],
servable_measured: true,
servable_bytes: SKADE_CAPACITY.raw - skade_store_used(),
total_bytes: 16 * GIB,
servable_reason: String::new(),
servable_at_unix_ms: T0 + 45_000,
growth: "operator_only".into(),
growth_detail: SKADE_GROWTH_DETAIL.into(),
cloud: Some(cloud("skade")),
},
_ => pb::FleetResponse::default(),
}
}
fn known(per_week: f64, confidence: &str, share: f64, readings: u64, tenants: (u64, u64)) -> pb::TrendResponse {
pb::TrendResponse {
enough_history: true,
per_week,
confidence: confidence.into(),
largest_step_share: share,
readings,
span_ms: WINDOW_30D_MS,
needed_span_ms: NEEDED_SPAN_MS,
window_ms: WINDOW_30D_MS,
first_at_unix_ms: T0 - WINDOW_30D_MS,
last_at_unix_ms: T0,
measured_tenants: tenants.0,
unmeasured_tenants: tenants.1,
}
}
fn not_enough() -> pb::TrendResponse {
pb::TrendResponse { enough_history: false, readings: 12, span_ms: 2 * DAY_MS, needed_span_ms: NEEDED_SPAN_MS, window_ms: WINDOW_30D_MS, first_at_unix_ms: T0 - 2 * DAY_MS, last_at_unix_ms: T0, ..Default::default() }
}
/// `Usage.Trend` for `(product, tenant)`; `tenant == ""` is the fleet.
pub fn trend(product: &str, tenant: &str) -> pb::TrendResponse {
match (product, tenant) {
("gunnar", "") => known(GUNNAR_FLEET_PER_WEEK, "approximate", 0.42, 2016, (7, 0)),
("gunnar", "alice") => known(ALICE_PER_WEEK, "firm", 0.11, 288, (0, 0)),
("skade", "") => known(SKADE_FLEET_PER_WEEK, "bursty", 0.97, 288, (2, 0)),
("skade", _) => known(SKADE_FLEET_PER_WEEK / 2.0, "bursty", 0.97, 144, (0, 0)),
_ => not_enough(),
}
}
/// How a fixture sale ended: its steps, whether it drew, its outcome and
/// reason, and whether it is a task for the operator.
struct Ending<'a> {
steps: u32,
drawn: bool,
outcome: &'a str,
reason: &'a str,
needs_you: bool,
}
fn sale(product: &str, tenant: &str, minor: u64, (cap_meter, cap): (&str, u64), at: u64, ending: Ending<'_>) -> pb::Sale {
let Ending { steps, drawn, outcome, reason, needs_you } = ending;
// The whole cap was the delta: every fixture sale is a first order. The
// provider's cost is 23 cents a GiB-month of the delta, in EUR — a
// different currency from the price on purpose, so a row that mixed the
// two would be visibly wrong.
let delta_gib = cap / GIB;
pb::Sale {
reference: format!("{product}/{tenant}/2026-09-04"),
product: product.into(),
tenant: tenant.into(),
price: Some(pb::Money { minor, currency: pb::Currency::Sek as i32 }),
cost: Some(pb::Money { minor: delta_gib * 23, currency: pb::Currency::Eur as i32 }),
// No vendor reported a cut for the fixture's sales. Absent, not zero —
// the console must render that as it always did, and a screen that
// invented "vendor kept 0.00" would be visibly wrong.
fee: None,
// The VAT per order at 25 % on the net, as monetize folds it; a
// declined attempt charged nothing and carries none.
tax: (outcome != "declined").then_some(pb::Money { minor: minor / 4, currency: pb::Currency::Sek as i32 }),
tax_from_vendor: false,
vat_basis_points: if outcome == "declined" { 0 } else { 2500 },
caps: caps(&[(cap_meter, cap)]),
delta: caps(&[(cap_meter, cap)]),
started_unix_ms: at - 60_000,
at_unix_ms: at,
steps,
drawn,
outcome: outcome.into(),
reason: reason.into(),
needs_you,
livemode: None,
customer_country: String::new(),
}
}
/// `Ledger.Sales`, newest first. `""` is every product.
///
/// gunnar's four are the four endings: an OPEN attempt (paid, never delivered —
/// the only row that is a task), a sale, a refund that DID draw, and a decline
/// that never locked a krona.
pub fn sales(product: &str) -> Vec<pb::Sale> {
let gunnar = vec![
sale("gunnar", "stuck", 22_000, ("pack_bytes", CAP_10.raw), T0 + 400_000, Ending { steps: 2, drawn: true, outcome: "open", reason: STUCK_REASON, needs_you: true }),
sale("gunnar", "bob", 22_000, ("pack_bytes", CAP_10.raw), T0 + 300_000, Ending { steps: 4, drawn: true, outcome: "granted", reason: "", needs_you: false }),
sale("gunnar", "zz-visibility", 100, ("pack_bytes", CAP_1.raw), T0 + 200_000, Ending { steps: 3, drawn: true, outcome: "refunded", reason: ZZ_REFUND_REASON, needs_you: false }),
sale("gunnar", "anna", 22_000, ("pack_bytes", CAP_10.raw), T0 + 100_000, Ending { steps: 2, drawn: false, outcome: "declined", reason: ANNA_DECLINE_REASON, needs_you: false }),
];
let skade = vec![sale("skade", "mira", 9_900, ("store_bytes", SKADE_CAP_8.raw), T0 + 250_000, Ending { steps: 4, drawn: true, outcome: "granted", reason: "", needs_you: false })];
match product {
"gunnar" => gunnar,
"skade" => skade,
"" => {
let mut all = gunnar;
all.extend(skade);
all.sort_by_key(|s| std::cmp::Reverse(s.at_unix_ms));
all
}
_ => vec![],
}
}
fn row(reference: &str, seq: u32, at: u64, kind: &str) -> pb::LedgerRow {
pb::LedgerRow { reference: reference.into(), seq, at_unix_ms: at, kind: kind.into(), json: format!("{{\"step\":\"{kind}\"}}") }
}
pub const BOB_REF: &str = "gunnar/bob/2026-09-04";
pub const STUCK_REF: &str = "gunnar/stuck/2026-09-04";
pub const ZZ_REF: &str = "gunnar/zz-visibility/2026-09-04";
pub const ANNA_REF: &str = "gunnar/anna/2026-09-04";
pub const ALICE_REF: &str = "gunnar/alice/2026-09-04";
/// Every ledger row, oldest first within a reference. The steps behind the
/// outcome lines above.
pub fn rows() -> Vec<pb::LedgerRow> {
vec![
row(ALICE_REF, 0, T0 - 4_000, "Reserved"),
row(ALICE_REF, 1, T0 - 3_000, "Ensured"),
row(ALICE_REF, 2, T0 - 2_000, "Captured"),
row(ALICE_REF, 3, T0 - 1_000, "EntitlementPushed"),
row(ANNA_REF, 0, T0 + 99_000, "Reserved"),
row(ANNA_REF, 1, T0 + 100_000, "Declined"),
row(ZZ_REF, 0, T0 + 198_000, "Reserved"),
row(ZZ_REF, 1, T0 + 199_000, "Ensured"),
row(ZZ_REF, 2, T0 + 200_000, "Released"),
row(BOB_REF, 0, T0 + 297_000, "Reserved"),
row(BOB_REF, 1, T0 + 298_000, "Ensured"),
row(BOB_REF, 2, T0 + 299_000, "Captured"),
row(BOB_REF, 3, T0 + 300_000, "EntitlementPushed"),
row(STUCK_REF, 0, T0 + 399_000, "Reserved"),
row(STUCK_REF, 1, T0 + 400_000, "Captured"),
]
}
/// The references still open: money captured for `stuck`, never pushed.
pub fn unfinished() -> Vec<String> {
vec![STUCK_REF.to_owned()]
}
fn disk(server: &str, gib: u64) -> Option<pb::Resource> {
Some(pb::Resource { kind: Some(pb::resource::Kind::Disk(pb::resource::Disk { server: server.into(), gib })) })
}
pub const ALICE_DISK_SERVER: &str = "gunnar-se-sto1";
pub const ALICE_DISK_ID: &str = "res-77";
/// `Entitlement.Held`: what a tenant is standing on.
pub fn holdings(product: &str, tenant: &str) -> Vec<pb::Holding> {
match (product, tenant) {
("gunnar", "alice") => vec![pb::Holding { reference: ALICE_REF.into(), resource: disk(ALICE_DISK_SERVER, 10), provider: "upcloud".into(), id: ALICE_DISK_ID.into() }],
("gunnar", "bob") => vec![pb::Holding { reference: BOB_REF.into(), resource: disk(ALICE_DISK_SERVER, 10), provider: "upcloud".into(), id: "res-78".into() }],
("skade", "mira") => vec![pb::Holding { reference: "skade/mira/2026-09-04".into(), resource: disk("skade-fi-hel1", 8), provider: "upcloud".into(), id: "res-91".into() }],
_ => vec![],
}
}
/// **`Purchase.Quote` for `caps` on `tenant`**, canned: the delta over the
/// tenant's fact, priced by [`prices`] on the disk-backed meters, costing
/// 23 cents a GiB-month at the provider. alice's order for 100 GiB is the
/// sealed refusal (`can_serve = false`, the paragraph); a downgrade is
/// refused outright by the mock like the server would.
pub fn quote(product: &str, tenant: &str, caps_asked: &BTreeMap<String, u64>) -> Result<pb::QuoteResponse, String> {
let current: BTreeMap<String, u64> = facts(product).into_iter().find(|f| f.tenant == tenant).map(|f| f.caps).unwrap_or_default();
let mut delta = BTreeMap::new();
let mut target = current.clone();
for (k, want) in caps_asked {
let have = current.get(k).copied().unwrap_or(0);
if *want < have {
return Err(format!("the order would SHRINK {k} for {tenant} from {have} to {want}. Shrinking is a different verb"));
}
if *want > have {
delta.insert(k.clone(), want - have);
}
target.insert(k.clone(), *want);
}
if delta.is_empty() {
return Err(format!("nothing to sell {tenant}: the order raises no cap above what they already have"));
}
let list = prices().into_iter().find(|l| l.product == product);
let disk_units: Vec<String> = products().into_iter().find(|p| p.id == product).map(|p| p.meters.into_iter().filter(|m| m.unit == "disk_gib_month").map(|m| m.name).collect()).unwrap_or_default();
let disk_delta: u64 = delta.iter().filter(|(k, _)| disk_units.contains(k)).map(|(_, v)| *v).sum();
let gib = disk_delta.div_ceil(GIB);
let unit = list.as_ref().and_then(|l| l.units.get("disk_gib_month").copied()).unwrap_or(0);
let currency = list.as_ref().map(|l| l.currency).unwrap_or(pb::Currency::Sek as i32);
let sealed = product == "gunnar" && tenant == "alice" && target.get("pack_bytes").copied().unwrap_or(0) >= 100 * GIB;
Ok(pb::QuoteResponse {
reference: format!("{product}/{tenant}/2026-09-05+{}", target.iter().map(|(k, v)| format!("{k}={v}")).collect::<Vec<_>>().join(",")),
price: Some(pb::Money { minor: gib * unit, currency }),
cost: Some(pb::Money { minor: gib * 23, currency: pb::Currency::Eur as i32 }),
current,
caps: target,
delta,
resources: if gib == 0 { vec![] } else { vec![disk(hosts().get(product).map(String::as_str).unwrap_or(product), gib).unwrap()] },
can_serve: !sealed,
reason: if sealed { SEALED_REFUSAL.into() } else { String::new() },
period: "month".into(),
words: String::new(),
// This fixture drives the OPERATOR console, which formats its own
// amount from `price`. The customer-facing string is empty here
// because nothing in these screens renders one.
price_words: String::new(),
})
}
/// Every meter name that must NEVER be on a surface (`UI.md` "what must be
/// DELETED"), and the money words `BASE-MODEL.md` forbids. The sweep row and the
/// endpoint tests both read this list.
pub const RAW_METER_NAMES: &[&str] = &["pack_bytes", "tombstoned_bytes", "open_store_ram_bytes", "cache_bytes", "lfs_bytes"];
pub const FORBIDDEN_WORDS: &[&str] = &["balance", "credit", "top up", "topup", "top-up"];
// ── The TAX page ──────────────────────────────────────────────────────
/// **The jurisdiction the fixture files in.** A real one, so the console's Tax
/// page is exercised against numbered boxes and real labels rather than against
/// the open default's dashes — the dashes are already tested where they are
/// decided (`monetize::tax::LoudTax`).
pub const TAX_JURISDICTION: &str = "SE";
/// The turnover that chose the fixture's filing period, in öre: 2 500 000,00
/// SEK, which is over 1 MSEK and under 40 MSEK — so, quarterly.
pub const TAX_TURNOVER_MINOR: u64 = 2_500_000 * 100;
/// The year the fixture's boxes are for, and the year its turnover is from.
pub const TAX_YEAR: u32 = 2026;
pub const TAX_TURNOVER_YEAR: u32 = 2025;
/// The four quarters of [`TAX_YEAR`], the period a fixture with that turnover
/// files in.
pub fn tax_periods() -> Vec<pb::TaxPeriod> {
[
("2026-Q1", 1_767_225_600_000u64, 1_775_001_600_000u64),
("2026-Q2", 1_775_001_600_000, 1_782_864_000_000),
("2026-Q3", 1_782_864_000_000, 1_790_812_800_000),
("2026-Q4", 1_790_812_800_000, 1_798_761_600_000),
]
.iter()
.map(|(label, from, to)| pb::TaxPeriod { label: (*label).to_owned(), kind: "quarterly".into(), from_unix_ms: *from, to_unix_ms: *to })
.collect()
}
/// **The fixture's filled form**, with DISTINCT non-zero numbers and one box
/// that is genuinely absent — a zero could not tell a wired field from a dead
/// one, and an all-filled report could not tell `—` from `0.00`.
pub fn tax_lines() -> Vec<pb::TaxLine> {
let sek = |minor: i64| pb::TaxLine { box_id: String::new(), label: String::new(), amount_minor: Some(minor), currency: "SEK".into() };
let named = |box_id: &str, label: &str, line: pb::TaxLine| pb::TaxLine { box_id: box_id.to_owned(), label: label.to_owned(), ..line };
vec![
named("05", "Momspliktig försäljning som inte ingår i fält 06, 07 eller 08", sek(150_000)),
named("10", "Utgående moms 25 %", sek(37_500)),
// The one that must NOT render as 0.00: goods, which this ledger cannot
// speak about at all.
pb::TaxLine { box_id: "20".into(), label: "Inköp av varor från annat EU-land".into(), amount_minor: None, currency: String::new() },
named("21", "Inköp av tjänster från annat EU-land, enligt huvudregeln", sek(44_350)),
named("30", "Utgående moms 25 %", sek(11_088)),
named("48", "Ingående moms att dra av", sek(11_088)),
named("49", "Moms att betala eller få tillbaka", sek(37_500)),
]
}
/// What the fixture's report says it could not do.
pub fn tax_notes() -> Vec<String> {
vec!["Box 48 here is the reverse charge only.".to_owned()]
}
/// The CSV the fixture hands back, in the shape `monetize::tax::Report::to_csv`
/// renders: a header, one row per box, `—` for the box nobody computed, and a
/// comma-bearing label quoted.
pub fn tax_csv() -> String {
let mut out = String::from("box,label,amount,currency\n");
for l in tax_lines() {
let amount = match l.amount_minor {
None => "—".to_owned(),
Some(m) => format!("{}.{:02}", m / 100, (m % 100).abs()),
};
let label = if l.label.contains(',') { format!("\"{}\"", l.label) } else { l.label.clone() };
out.push_str(&format!("{},{label},{amount},{}\n", l.box_id, l.currency));
}
out
}
/// **The board monetize-server draws for the gunnar.rs shape** — front, main,
/// twin — verbatim from `cargo run -p monetize-server --example estate_svg`,
/// so the console's map is exercised against the real renderer's document.
pub const BOARD_SVG: &str = include_str!("board.svg");
#[cfg(test)]
mod order_tests {
use super::*;
/// **A constant called ALPHABETICAL is sorted, or it is a trap.**
///
/// Two other tests assert a server's emitted order EQUALS this list. When a
/// rename left it unsorted both of them went red and read as server bugs.
/// The list is the thing under test here, so a rename that disturbs it is
/// caught by name, in one place, and not by two assertions about somebody
/// else's code.
#[test]
fn gunnar_tenants_are_actually_alphabetical() {
let mut sorted = GUNNAR_TENANTS_ALPHABETICAL.to_vec();
sorted.sort_unstable();
assert_eq!(sorted, GUNNAR_TENANTS_ALPHABETICAL, "the list a server's order is compared against must BE sorted");
// And it names the same seven tenants as the attention order, which is
// the other half of what the two lists are for.
let mut a = GUNNAR_TENANTS_ALPHABETICAL.to_vec();
let mut b = GUNNAR_CLOSEST_FIRST.to_vec();
a.sort_unstable();
b.sort_unstable();
assert_eq!(a, b, "the two orders must name the same tenants");
assert_ne!(GUNNAR_TENANTS_ALPHABETICAL, GUNNAR_CLOSEST_FIRST, "or a console that forgot to sort would be green");
}
}