crates/gunnar-ui-web/src/activity.rs
//! **The Activity panel's words and its arithmetic** — the half of the usage
//! page that decides things, kept where `cargo test` can reach it.
//!
//! Same argument as [`crate::room`], and the same layout: `pages::usage` is
//! `#[cfg(target_arch = "wasm32")]`, so nothing written beside the view is
//! compiled — let alone run — by `cargo test` on the host. A rule that only
//! ever executes inside a browser nobody launches is a rule with no test. So
//! every sentence this panel puts on screen, and every number that decides how
//! tall a bar is, is decided here: no leptos, no `web-sys`, the clock arrives
//! as a parameter.
//!
//! # The three answers this panel can give, and why they must not look alike
//!
//! `Usage.Activity` answers a *series*, and there are three completely
//! different things it can come back with. Drawing any two of them the same way
//! is the defect this module exists to prevent, because all three are "an empty
//! chart" to a renderer that is not paying attention:
//!
//! * **Bars.** Rows came back. Draw them.
//! * **Nothing moved.** A real answer with an empty `days` and a non-zero
//! `retained_days`: gunnar keeps four hundred days, holds a ledger for this
//! namespace, and there is no row in the last thirty because nothing was
//! pushed, fetched or refused. The tenant is quiet. ([`NoBars::Quiet`])
//! * **This box does not count activity.** Either gunnar answered
//! `retained_days: 0` — it keeps no ledger at all — or it refused the call
//! with `Unimplemented`, which is what a gunnar older than the FLOW ledger
//! does. **That is not a quiet tenant**, and saying "no activity in the last
//! 30 days" over it would be this console asserting a measurement that was
//! never taken, about a namespace that may well be the busiest on the box.
//! ([`NoBars::NotCounted`])
//! * **It could not be read.** Any other refusal — `PermissionDenied` above
//! all. Silence is not evidence of silence. ([`NoBars::Unreadable`])
//!
//! # A day with no row is a gap, and the chart draws it as one
//!
//! gunnar sends **no row at all** for a day nothing happened on, and the server
//! carries that absence through rather than zero-filling it (`api::usage`). The
//! chart still wants a fixed x axis — thirty slots, oldest first, one per UTC
//! day — so [`bars`] lays the rows into their slots and leaves the rest EMPTY.
//! That is a layout decision and not an invented measurement: an empty slot
//! carries the sentence *nothing moved*, and it is reachable in the DOM as that
//! sentence rather than as a bar of height zero that a reader would take for a
//! rounding artefact.
use std::collections::BTreeMap;
use serde::Deserialize;
use crate::fmt;
/// **How many UTC days the panel draws**, and it must be the number
/// `api::usage::ACTIVITY_DAYS` asks gunnar for.
///
/// The two cannot share a constant — this crate deliberately depends on nothing
/// of the server's — so it is pinned in words on both sides and asserted in
/// [`tests::the_window_is_thirty_days_on_both_sides_of_the_wire`]. A page that
/// said "the last 30 days" over a fortnight of bars would be a true sentence
/// above a false chart, which is worse than either alone.
pub const WINDOW_DAYS: i64 = 30;
const DAY_MS: i64 = 86_400_000;
/// One UTC day's flow, exactly as `GET /api/namespaces/{name}/activity` sends
/// it.
///
/// `#[serde(default)]` on every field that is not the day key, for one reason:
/// a console served from a build older than the server it is talking to must
/// draw the days it can rather than fail to parse the answer. The day key has
/// no default because a row with no day is not a row.
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
pub struct Day {
/// Midnight UTC of the day, in Unix milliseconds.
pub day_unix_ms: i64,
#[serde(default)]
pub bytes_in: u64,
#[serde(default)]
pub bytes_out: u64,
#[serde(default)]
pub pushes: u64,
#[serde(default)]
pub fetches: u64,
/// LFS object bytes as the LFS session counted them — **also inside
/// `bytes_in`/`bytes_out`** as wire bytes. Two numbers about one transfer,
/// so the panel never subtracts one from the other.
#[serde(default)]
pub lfs_bytes_in: u64,
#[serde(default)]
pub lfs_bytes_out: u64,
#[serde(default)]
pub refused: u64,
/// Only the reasons that actually happened. A reason that is not here did
/// not occur, and it is **absent rather than 0** all the way from gunnar.
#[serde(default)]
pub refused_by_reason: BTreeMap<String, u64>,
#[serde(default)]
pub auth_failures: u64,
#[serde(default)]
pub distinct_principals: u64,
#[serde(default)]
pub distinct_principals_capped: bool,
/// The last push of that day, `None` when the day had none.
#[serde(default)]
pub last_push_unix_ms: Option<i64>,
#[serde(default)]
pub last_fetch_unix_ms: Option<i64>,
}
/// The series for one namespace.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Activity {
pub namespace: String,
/// How many days this gunnar keeps. **0 means it keeps none**, which is a
/// box that does not count activity and not a quiet tenant — see
/// [`NoBars`].
#[serde(default)]
pub retained_days: i64,
/// Oldest first, days with no traffic absent.
#[serde(default)]
pub days: Vec<Day>,
}
// ── the bars ───────────────────────────────────────────────────────────────
/// **The tallest number any bar on one chart is drawn against.**
///
/// Both series on a chart share it — `bytes_in` with `bytes_out`, `pushes` with
/// `fetches` — because the whole reason to put them side by side is to compare
/// them, and two bars scaled independently would show a day that served a
/// gigabyte and accepted a kilobyte as two bars of the same height.
///
/// The two CHARTS do not share one: bytes and counts are different units, and
/// scaling twenty-seven fetches against four million bytes draws no chart at
/// all.
pub fn peak_bytes(days: &[Day]) -> u64 {
days.iter()
.map(|d| d.bytes_in.max(d.bytes_out))
.max()
.unwrap_or(0)
}
/// The same, for the transfer counts.
pub fn peak_count(days: &[Day]) -> u64 {
days.iter()
.map(|d| d.pushes.max(d.fetches))
.max()
.unwrap_or(0)
}
/// **A bar's height, 0–100, against the chart's peak.**
///
/// `0` when the peak is 0, which is the only division-by-zero this chart can
/// reach: a window in which every row is a row of zeroes. It is not
/// hypothetical — a day with only refusals moves no bytes at all — and a
/// panicking chart on a namespace that was turned away thirty days running
/// would be the worst possible moment for one.
///
/// The arithmetic goes through `u128` because `bytes_in` is a wire-byte count
/// and `value * 100` overflows `u64` above about 184 PB. A wrapped percentage
/// draws a nearly-empty bar for the busiest day in the window, which reads as
/// a data bug rather than the rendering bug it is. [`crate::room::fill_pct`]
/// takes the same precaution for the same reason.
pub fn bar_pct(value: u64, peak: u64) -> u32 {
if peak == 0 {
return 0;
}
((value.min(peak) as u128 * 100) / peak as u128) as u32
}
/// One slot on the x axis: a UTC day, and what moved on it.
#[derive(Debug, Clone, PartialEq)]
pub struct Bar {
/// Midnight UTC of the day this slot is.
pub day_unix_ms: i64,
/// `MM-DD`, the axis label. The year is in [`Bar::hint`]: thirty slots on a
/// phone have no room for it, and every one of them is in the same year
/// except across a new year, where the hint carries it.
pub label: String,
/// **False when gunnar sent no row for this day** — nothing moved. Not the
/// same as a row of zeroes, and the panel says so rather than drawing a
/// bar of height 0 that reads as a rounding artefact.
pub moved: bool,
pub bytes_in_pct: u32,
pub bytes_out_pct: u32,
pub pushes_pct: u32,
pub fetches_pct: u32,
/// Transfers turned away that day. `0` = none; the slot is marked only
/// above 0.
pub refused: u64,
/// The whole day in one sentence, for the slot's `title` and its
/// `aria-label`. A chart nobody can read with a screen reader is a chart
/// half the operators of this console cannot read at all.
pub hint: String,
}
/// **The thirty slots, oldest first, ending with the UTC day `now` is in.**
///
/// Rows are laid into the slot whose day they name and every other slot is
/// left empty. A row outside the window — which gunnar has no reason to send,
/// but which a clock skew of a few seconds across midnight could produce — is
/// dropped rather than drawn in the wrong place: a bar under the wrong date is
/// worse than a bar that is not there.
pub fn bars(activity: &Activity, now_unix_ms: i64) -> Vec<Bar> {
let peak_b = peak_bytes(&activity.days);
let peak_c = peak_count(&activity.days);
let today = now_unix_ms.div_euclid(DAY_MS) * DAY_MS;
let first = today - (WINDOW_DAYS - 1) * DAY_MS;
(0..WINDOW_DAYS)
.map(|i| {
let day_unix_ms = first + i * DAY_MS;
match activity.days.iter().find(|d| d.day_unix_ms == day_unix_ms) {
Some(day) => Bar {
day_unix_ms,
label: axis_label(day_unix_ms),
moved: true,
bytes_in_pct: bar_pct(day.bytes_in, peak_b),
bytes_out_pct: bar_pct(day.bytes_out, peak_b),
pushes_pct: bar_pct(day.pushes, peak_c),
fetches_pct: bar_pct(day.fetches, peak_c),
refused: day.refused,
hint: day_hint(day),
},
None => Bar {
day_unix_ms,
label: axis_label(day_unix_ms),
moved: false,
bytes_in_pct: 0,
bytes_out_pct: 0,
pushes_pct: 0,
fetches_pct: 0,
refused: 0,
hint: format!("{} \u{b7} nothing moved", fmt::date(day_unix_ms)),
},
}
})
.collect()
}
/// `MM-DD` of a UTC midnight — the axis tick.
pub fn axis_label(day_unix_ms: i64) -> String {
// `fmt::date` is `YYYY-MM-DD` and always ten characters wide, so this is
// the tail of it rather than a second date arithmetic to keep in step.
fmt::date(day_unix_ms)[5..].to_owned()
}
/// **One day in one sentence.**
///
/// Bytes in the units an operator reads, transfers as counts, and the refusals
/// last with their reasons — because a day with refusals on it is the only kind
/// of day somebody reads this panel to find.
pub fn day_hint(day: &Day) -> String {
let mut line = format!(
"{} \u{b7} {} in, {} out \u{b7} {} {}, {} {}",
fmt::date(day.day_unix_ms),
fmt::bytes(day.bytes_in),
fmt::bytes(day.bytes_out),
fmt::count(day.pushes),
plural(day.pushes, "push", "pushes"),
fmt::count(day.fetches),
plural(day.fetches, "fetch", "fetches"),
);
let refusals = refusal_phrase(day);
if !refusals.is_empty() {
line.push_str(" \u{b7} ");
line.push_str(&refusals);
}
line
}
/// The refusals of one day, with their reasons; empty when nothing was refused.
///
/// **A reason gunnar did not send is not named**, and the count is gunnar's own
/// total rather than the sum of the breakdown. The two can differ — a refusal
/// gunnar has not learned to attribute yet is in `refused` and in no reason —
/// and when they do, the honest sentence is *"5 refused: admission 1, cap 4"*
/// with the arithmetic left visible, not a total quietly rewritten to 5.
pub fn refusal_phrase(day: &Day) -> String {
if day.refused == 0 {
return String::new();
}
let mut line = format!("{} refused", fmt::count(day.refused));
if !day.refused_by_reason.is_empty() {
// `BTreeMap`, so the reasons are in the same order on every render:
// `admission`, `cap`, `policy`.
let reasons: Vec<String> = day
.refused_by_reason
.iter()
.map(|(why, n)| format!("{why} {}", fmt::count(*n)))
.collect();
line.push_str(": ");
line.push_str(&reasons.join(", "));
}
line
}
fn plural(n: u64, one: &'static str, many: &'static str) -> &'static str {
if n == 1 {
one
} else {
many
}
}
// ── last seen ──────────────────────────────────────────────────────────────
/// **When this namespace was last pushed to and last fetched from.**
///
/// `None` is **"never"**, and that word is the whole reason this function is
/// tested. gunnar sends `0` for a namespace nothing has touched inside the kept
/// window, the server turns that 0 into `None`
/// (`api::usage::Usage::last_push_unix_ms`), and if either half of that chain
/// broke the line would read *"last push 689 months ago"* — 1 Jan 1970 rendered
/// as an age — beside a namespace created this morning. "Never" and "a very
/// long time ago" are opposite answers to the question an operator is asking.
///
/// Both halves are on one line because the pair is the point: a namespace being
/// fetched from daily and pushed to never is a mirror, and one pushed to and
/// never fetched is a backup target. Two separate lines invite reading one.
pub fn last_seen_line(
last_push_unix_ms: Option<i64>,
last_fetch_unix_ms: Option<i64>,
now_unix_ms: i64,
) -> String {
format!(
"Last push {} \u{b7} last fetch {}",
seen(last_push_unix_ms, now_unix_ms),
seen(last_fetch_unix_ms, now_unix_ms)
)
}
fn seen(at: Option<i64>, now: i64) -> String {
match at {
Some(at) => fmt::since(at, now),
None => "never".to_owned(),
}
}
// ── an empty chart, and the three things it can mean ───────────────────────
/// **Why there are no bars.** See the module doc: three different facts, and a
/// renderer that drew them alike would be the defect.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoBars {
/// gunnar keeps a ledger for this namespace and has no row in the window.
/// Nothing was pushed, fetched or refused. The tenant is quiet.
Quiet,
/// **This box does not count activity at all** — it keeps no days, or it is
/// older than the FLOW ledger and refuses the call. Not a quiet tenant, and
/// this namespace may be the busiest on the box.
NotCounted,
/// The answer could not be read: refused, unreachable, or a fault. Silence
/// is not evidence of silence.
Unreadable,
}
/// What an ANSWER with no rows in it means.
///
/// `retained_days` is the discriminator and it is gunnar's own field: it says
/// how far back the box remembers. Zero is a box that remembers nothing, which
/// is the one case where an empty `days` says nothing about the tenant.
pub fn no_bars(retained_days: i64) -> NoBars {
if retained_days > 0 {
NoBars::Quiet
} else {
NoBars::NotCounted
}
}
/// What a REFUSAL means.
///
/// `Unimplemented` — HTTP 501 on this console, or gunnar's own code — is a
/// gunnar older than the FLOW ledger, and it is the one refusal that is a fact
/// about the BOX rather than about the caller or the namespace. Everything
/// else, `PermissionDenied` first among them, is [`NoBars::Unreadable`]:
/// treating "you may not look" as "nothing happened" is how a console tells a
/// tenant their service is idle when what it means is that it could not ask.
pub fn no_bars_after_refusal(status: u16, code: &str) -> NoBars {
if status == 501 || code.eq_ignore_ascii_case("unimplemented") {
NoBars::NotCounted
} else {
NoBars::Unreadable
}
}
/// The heading for an empty chart.
pub fn no_bars_title(state: NoBars) -> &'static str {
match state {
NoBars::Quiet => "No activity in the last 30 days",
NoBars::NotCounted => "This box does not count activity",
NoBars::Unreadable => "This namespace's activity could not be read",
}
}
/// The sentence under it, which is where the three states stop being
/// interchangeable.
pub fn no_bars_detail(state: NoBars, retained_days: i64) -> String {
match state {
NoBars::Quiet => format!(
"Gunnar keeps {retained_days} days of this namespace's traffic and holds no row \
for any of the last 30: nothing was pushed, fetched or refused. A day with no \
traffic has no row, so this is silence and not a gap in the reading."
),
NoBars::NotCounted => "This Gunnar does not keep a per-day ledger, so there is nothing \
to draw. That is a fact about the server and not about this namespace \u{2014} it \
may be the busiest on the box. The meters above are counted either way."
.to_owned(),
NoBars::Unreadable => "Gunnar refused or could not answer, so nothing is known about \
this namespace's traffic. An empty chart here would say it was idle, which is \
not what happened."
.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
const DAY: i64 = 86_400_000;
/// Midnight UTC of an ordinary day (2026-09-03), and the anchor for every
/// fixture here. A whole multiple of [`DAY`] on purpose: a "day" that is
/// not a midnight would make the slot assertions pass for the wrong reason.
const D0: i64 = 20_699 * DAY;
/// Three days out of six, with the quiet days MISSING — the shape gunnar
/// sends and the server carries through. Every number distinct, so a field
/// read out of its neighbour is visible.
fn ledger() -> Vec<Day> {
vec![
Day {
day_unix_ms: D0,
bytes_in: 1_048_576,
bytes_out: 5_242_880,
pushes: 4,
fetches: 11,
..Day::default()
},
// `D0 + 1` has no row: nothing moved that day.
Day {
day_unix_ms: D0 + 2 * DAY,
bytes_in: 3_145_728,
bytes_out: 786_432,
pushes: 2,
fetches: 7,
refused: 5,
// Two reasons of the three; `policy` never happened.
refused_by_reason: BTreeMap::from([
("admission".to_owned(), 1),
("cap".to_owned(), 4),
]),
..Day::default()
},
Day {
day_unix_ms: D0 + 5 * DAY,
bytes_in: 0,
bytes_out: 9_437_184,
pushes: 0,
fetches: 19,
last_push_unix_ms: None,
..Day::default()
},
]
}
fn series() -> Activity {
Activity {
namespace: "alpha".to_owned(),
retained_days: 400,
days: ledger(),
}
}
/// "Today" for the fixtures: the last row's day, so the whole ledger sits
/// inside a thirty-day window ending on it.
const NOW: i64 = D0 + 5 * DAY + 50_000_000;
/// ★ **A day gunnar sent no row for is a GAP, not a bar of height zero.**
///
/// gunnar omits a day nothing happened on and the server carries the
/// absence through rather than zero-filling it. The chart still needs a
/// fixed x axis, so the slots are laid out here — and the empty ones say
/// *nothing moved* instead of drawing a zero-height bar that a reader takes
/// for a rounding artefact or for a measurement that was made.
///
/// Seen red against a `bars` that dropped the empty slots and rendered the
/// three rows side by side: *"the chart has 3 slots; thirty days of x axis
/// became three adjacent bars and 2026-09-07 is drawn where 2026-09-04
/// belongs"*.
#[test]
fn every_day_in_the_window_gets_a_slot_and_a_day_with_no_row_is_marked_as_one() {
let bars = bars(&series(), NOW);
assert_eq!(
bars.len(),
WINDOW_DAYS as usize,
"the chart has {} slots; the panel says thirty days in words",
bars.len()
);
// Oldest first, one UTC day apart, ending with the day NOW is in.
assert_eq!(bars[0].day_unix_ms, D0 + 5 * DAY - 29 * DAY);
assert_eq!(bars[WINDOW_DAYS as usize - 1].day_unix_ms, D0 + 5 * DAY);
for pair in bars.windows(2) {
assert_eq!(
pair[1].day_unix_ms - pair[0].day_unix_ms,
DAY,
"the x axis must be one whole UTC day per slot"
);
}
let at = |ms: i64| {
bars.iter()
.find(|b| b.day_unix_ms == ms)
.unwrap_or_else(|| panic!("a slot for {ms}"))
};
assert!(at(D0).moved, "the day with a row must be drawn as one");
assert!(
!at(D0 + DAY).moved,
"a day gunnar sent no row for was drawn as traffic"
);
assert_eq!(
at(D0 + DAY).hint,
"2026-09-04 \u{b7} nothing moved",
"an empty slot must say what it is, not sit there mute"
);
// …and it carries no numbers at all, so nothing about it can be read
// as a measurement.
assert_eq!(at(D0 + DAY).bytes_in_pct, 0);
assert_eq!(at(D0 + DAY).refused, 0);
}
/// ★ **In and out share one scale; pushes and fetches share another.**
///
/// The bytes chart's peak is 9 437 184 (the third day's `bytes_out`), so
/// the first day's 1 MiB in is 11 % and its 5 MiB out is 55 %. The counts
/// chart's peak is 19 fetches, so 4 pushes is 21 %.
///
/// Seen red with one scale for both charts: nineteen fetches against nine
/// million bytes is *"fetches_pct: left 21 right 0"* — every count bar
/// invisible.
#[test]
fn the_two_series_on_a_chart_are_drawn_against_one_peak() {
assert_eq!(peak_bytes(&ledger()), 9_437_184);
assert_eq!(peak_count(&ledger()), 19);
let bars = bars(&series(), NOW);
let first = bars.iter().find(|b| b.day_unix_ms == D0).expect("a slot");
assert_eq!(first.bytes_in_pct, 11, "1 MiB of a 9 MiB peak");
assert_eq!(first.bytes_out_pct, 55, "5 MiB of a 9 MiB peak");
assert_eq!(first.pushes_pct, 21, "4 of a peak of 19");
assert_eq!(first.fetches_pct, 57, "11 of a peak of 19");
let tallest = bars
.iter()
.find(|b| b.day_unix_ms == D0 + 5 * DAY)
.expect("a slot");
assert_eq!(tallest.bytes_out_pct, 100, "the peak day is a full bar");
assert_eq!(tallest.fetches_pct, 100);
}
/// The scaling arithmetic, away from the chart.
///
/// The zero peak is not hypothetical: a window in which every row is
/// refusals moves no bytes at all, and a panicking chart on a namespace
/// that was turned away thirty days running would be the worst possible
/// moment for one.
#[test]
fn a_bar_against_a_peak_of_nothing_is_empty_rather_than_a_panic() {
assert_eq!(bar_pct(0, 0), 0);
assert_eq!(bar_pct(7, 0), 0);
assert_eq!(bar_pct(0, 100), 0);
assert_eq!(bar_pct(50, 100), 50);
assert_eq!(bar_pct(100, 100), 100);
// A value above the peak cannot happen — the peak is the maximum — but
// the bar saturates rather than running off the end if it ever does.
assert_eq!(bar_pct(140, 100), 100);
// Wire bytes overflow `u64` when multiplied by 100 above about 184 PB,
// and a wrapped percentage draws the busiest day as nearly empty.
assert_eq!(bar_pct(u64::MAX / 2, u64::MAX), 49);
assert_eq!(
peak_bytes(&[]),
0,
"an empty series has no peak and no panic"
);
assert_eq!(peak_count(&[]), 0);
}
/// ★ **"never", not 1970.**
///
/// gunnar sends 0 for a namespace nothing has touched inside the kept
/// window and the server turns it into `None`. Rendering the 0 would put
/// *"last push 689 months ago"* — an age computed from 1 Jan 1970 — beside
/// a namespace created this morning, and "never" and "a very long time ago"
/// are opposite answers to the question being asked.
///
/// Seen red with `fmt::since(0, now)` in place of the `None` arm:
/// *"Last push 689 mo ago \u{b7} last fetch 689 mo ago"*.
#[test]
fn a_namespace_nothing_ever_touched_reads_never_and_not_an_age() {
let now = D0 + 5 * DAY;
assert_eq!(
last_seen_line(None, None, now),
"Last push never \u{b7} last fetch never"
);
// A real stamp still reads as an age, or the rule above would be
// "always never".
assert_eq!(
last_seen_line(Some(now - 2 * DAY), Some(now - 300_000), now),
"Last push 2 d ago \u{b7} last fetch 5 min ago"
);
// The mixed case is the one the pair exists for: fetched from daily,
// never pushed to, which is a mirror and not an idle namespace.
assert_eq!(
last_seen_line(None, Some(now - 3_600_000 * 3), now),
"Last push never \u{b7} last fetch 3 h ago"
);
}
/// ★ **Nothing moved, this box does not count, and could not be read are
/// three different sentences.**
///
/// All three are "an empty chart" to a renderer that is not paying
/// attention, and each is a different thing to tell an operator. The one
/// that matters most is the middle: a gunnar older than the FLOW ledger
/// refuses `Usage.Activity` with `Unimplemented`, and answering that with
/// "no activity in the last 30 days" would be this console asserting a
/// measurement nobody took about a namespace that may be the busiest on the
/// box.
///
/// Seen red against a page that had one empty state: *"a box that does not
/// count activity was reported as a quiet tenant"*.
#[test]
fn an_empty_chart_says_which_of_the_three_things_it_means() {
// A real answer, a real ledger, no rows: the tenant is quiet.
assert_eq!(no_bars(400), NoBars::Quiet);
assert_eq!(
no_bars_title(NoBars::Quiet),
"No activity in the last 30 days"
);
assert!(
no_bars_detail(NoBars::Quiet, 400).contains("400 days"),
"the quiet sentence must say how far back the box actually looked: {}",
no_bars_detail(NoBars::Quiet, 400)
);
// A box that keeps no ledger at all.
assert_eq!(no_bars(0), NoBars::NotCounted);
assert_eq!(
no_bars_title(NoBars::NotCounted),
"This box does not count activity"
);
// …and the same box seen through the refusal an older gunnar gives.
assert_eq!(
no_bars_after_refusal(501, "unimplemented"),
NoBars::NotCounted
);
assert_eq!(no_bars_after_refusal(501, ""), NoBars::NotCounted);
assert_eq!(
no_bars_after_refusal(502, "Unimplemented"),
NoBars::NotCounted
);
// Every other refusal is unreadable, and `PermissionDenied` above all:
// "you may not look" must never be drawn as "nothing happened".
assert_eq!(
no_bars_after_refusal(403, "permissiondenied"),
NoBars::Unreadable
);
assert_eq!(no_bars_after_refusal(404, "notfound"), NoBars::Unreadable);
assert_eq!(no_bars_after_refusal(0, ""), NoBars::Unreadable);
assert_eq!(
no_bars_after_refusal(503, "unavailable"),
NoBars::Unreadable
);
// The three sentences are three sentences.
let quiet = no_bars_title(NoBars::Quiet);
let uncounted = no_bars_title(NoBars::NotCounted);
let unread = no_bars_title(NoBars::Unreadable);
assert_ne!(quiet, uncounted);
assert_ne!(quiet, unread);
assert_ne!(uncounted, unread);
assert_ne!(
no_bars_detail(NoBars::Quiet, 400),
no_bars_detail(NoBars::Unreadable, 400)
);
}
/// ★ **A refusal reason gunnar did not send is not named, and the total is
/// gunnar's own.**
///
/// The walled day was refused by `admission` and by `cap` and never by
/// `policy`. Naming `policy 0` would tell a tenant their pushes are not
/// being turned away by the push gate — a fact this console did not measure
/// and gunnar did not claim — and it is the tenant deciding whether their
/// problem is money or a protected branch who needs the difference.
#[test]
fn a_day_names_only_the_refusal_reasons_that_happened() {
let walled = &ledger()[1];
assert_eq!(refusal_phrase(walled), "5 refused: admission 1, cap 4");
assert!(
!refusal_phrase(walled).contains("policy"),
"a reason gunnar did not send was named: {}",
refusal_phrase(walled)
);
// A day with no refusals says nothing at all rather than "0 refused".
assert_eq!(refusal_phrase(&ledger()[0]), "");
// Refusals with NO breakdown: gunnar counted them and attributed none,
// so the sentence stops after the total rather than inventing a reason
// or dropping the refusals it was told about.
assert_eq!(
refusal_phrase(&Day {
refused: 3,
refused_by_reason: BTreeMap::new(),
..Day::default()
}),
"3 refused"
);
// The total is gunnar's, not the sum: when the two disagree the
// arithmetic is left visible rather than the total rewritten.
assert_eq!(
refusal_phrase(&Day {
refused: 9,
refused_by_reason: BTreeMap::from([("cap".to_owned(), 4)]),
..Day::default()
}),
"9 refused: cap 4"
);
}
/// The day sentence, whole — the words a screen reader reads out, in the
/// units an operator reads, with the singular that stops "1 pushes".
#[test]
fn one_day_reads_as_one_sentence() {
assert_eq!(
day_hint(&ledger()[0]),
"2026-09-03 \u{b7} 1.0 MiB in, 5.0 MiB out \u{b7} 4 pushes, 11 fetches"
);
assert_eq!(
day_hint(&ledger()[1]),
"2026-09-05 \u{b7} 3.0 MiB in, 768.0 KiB out \u{b7} 2 pushes, 7 fetches \u{b7} \
5 refused: admission 1, cap 4"
);
assert_eq!(
day_hint(&Day {
day_unix_ms: D0,
pushes: 1,
fetches: 1,
..Day::default()
}),
"2026-09-03 \u{b7} 0 B in, 0 B out \u{b7} 1 push, 1 fetch",
"\"1 pushes\" is the kind of thing a reader stops trusting a page over"
);
assert_eq!(axis_label(D0), "09-03");
}
/// **Thirty days here and thirty days on the wire.**
///
/// This crate depends on nothing of the server's, so the window is pinned
/// in two places — `api::usage::ACTIVITY_DAYS` and [`WINDOW_DAYS`] — and
/// the words on screen say thirty. A page that said "the last 30 days" over
/// a fortnight of bars is a true sentence above a false chart.
#[test]
fn the_window_is_thirty_days_on_both_sides_of_the_wire() {
assert_eq!(WINDOW_DAYS, 30);
assert!(
no_bars_title(NoBars::Quiet).contains("30 days"),
"the empty state must name the same window the chart draws"
);
assert_eq!(bars(&series(), NOW).len(), WINDOW_DAYS as usize);
}
}