crates/gunnar-ui-web/src/twin.rs
//! **What the Twin panel and the restore action say**, on the host side of the
//! wasm boundary.
//!
//! Same layout as [`crate::dataset`]: `pages::storage` is wasm-only, so every
//! rule about what the panel shows, when it goes red and when the restore
//! action is drawn lives here, where `cargo test` runs it.
//!
//! The state word is gunnar's (`ok` / `behind` / `silent`). Anything but `ok`
//! is an alarm — including a word from a newer gunnar this build does not
//! know, because a panel that stays calm about a state it cannot read is the
//! failure this panel exists to prevent.
use serde::Deserialize;
use crate::fmt;
/// One twin, as `GET /api/twin` sends it.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct TwinRow {
pub principal: String,
pub address: String,
pub last_seen_unix_ms: Option<i64>,
pub seen_ago_ms: u64,
pub applied_seq: u64,
pub head_seq: u64,
pub lag_entries: u64,
pub disk_total_bytes: u64,
pub disk_used_bytes: u64,
pub fill_permille: Option<u32>,
pub state: String,
pub why: String,
pub silent_after_ms: u64,
}
/// A twin's view of its primary (only on a twin).
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct PrimaryRow {
pub address: String,
pub last_answer_unix_ms: Option<i64>,
pub answer_ago_ms: u64,
pub primary_head_seq: u64,
pub applied_seq: u64,
pub state: String,
pub why: String,
}
/// `GET /api/twin`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct TwinPanel {
pub twins: Vec<TwinRow>,
pub primary: Option<PrimaryRow>,
pub unanswered: String,
/// The twin this console is configured to reach (`GUNNAR_UI_TWIN`), empty
/// when none is. CONFIGURATION, not a measurement: it says where the
/// restore would read from, never that anything answered there.
pub configured_twin: String,
}
/// What a recharge would lose (gunnar-repair's `Loss`).
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Loss {
pub entries: u64,
pub unreplicated: u64,
pub what: String,
}
/// **[Review twin]** — gunnar-repair's `review_twin`, as `GET /api/restore`
/// carries it. The words are the server's; this console only lays them out.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Review {
pub twin_addr: String,
/// `healthy`, `behind`, `primary_silent`, `primary_diverged`, `stopped`,
/// `empty`, `unreachable` — `None` before it was asked.
pub health: Option<String>,
pub head_seq: u64,
pub first_seq: u64,
pub primary_head_seq: u64,
pub applied_seq: u64,
pub last_heartbeat_unix_ms: i64,
pub heartbeat_ago_ms: u64,
pub would_lose: Loss,
pub recharge_possible: bool,
pub verdict: String,
pub refusal: String,
}
/// `GET /api/restore`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Eligibility {
pub eligible: bool,
pub twin_address: String,
pub twin_reachable: bool,
pub twin_first_seq: u64,
pub twin_head_seq: u64,
pub window_open: bool,
pub principals: u64,
pub keys: u64,
pub confirm: String,
pub why: String,
/// gunnar-repair's refusal code; empty when eligible.
pub code: String,
/// The main answers and is not fresh: only a fence-first override.
pub main_live: bool,
pub override_confirm: String,
pub review: Option<Review>,
}
/// `POST /api/restore`'s answer.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Restored {
pub head_seq: u64,
pub applied: u64,
pub lost: u64,
pub restart_required: bool,
pub detail: String,
}
/// Red: anything but gunnar's `ok`.
pub fn alarmed(state: &str) -> bool {
state != "ok"
}
/// True when any row on the panel is red.
pub fn panel_alarm(panel: &TwinPanel) -> bool {
panel.twins.iter().any(|t| alarmed(&t.state)) || panel.primary.as_ref().is_some_and(|p| alarmed(&p.state))
}
/// The badge word for a state.
pub fn state_label(state: &str) -> &'static str {
match state {
"ok" => "In step",
"behind" => "Behind",
"silent" => "Silent",
_ => "Unknown",
}
}
/// `4 s`, `3 min`, `2 h`, `5 d`.
pub fn ago(ms: u64) -> String {
let s = ms / 1000;
match s {
0..=59 => format!("{s} s ago"),
60..=3599 => format!("{} min ago", s / 60),
3600..=86_399 => format!("{} h ago", s / 3600),
_ => format!("{} d ago", s / 86_400),
}
}
/// When the primary last heard from this twin.
pub fn seen_line(row: &TwinRow) -> String {
match row.last_seen_unix_ms {
Some(_) => ago(row.seen_ago_ms),
None => "never heard from".to_owned(),
}
}
/// How far behind the twin runs.
pub fn lag_line(row: &TwinRow) -> String {
if row.lag_entries == 0 {
format!("none (at seq {})", row.applied_seq)
} else {
format!("{} entries (seq {} of {})", row.lag_entries, row.applied_seq, row.head_seq)
}
}
/// How full the twin's data volume is.
pub fn fill_line(row: &TwinRow) -> String {
match row.fill_permille {
Some(p) => format!("{}.{} % of {}", p / 10, p % 10, fmt::bytes(row.disk_total_bytes)),
None => "not measured".to_owned(),
}
}
/// **What the panel says when gunnar reports no twin row at all.**
///
/// A configured twin gunnar does not report is NOT the same as no twin, and
/// the two must not read alike: the first is a twin this primary has never
/// heard from — which is exactly what a twin that will not boot looks like —
/// and the second is a box that has no second copy. Neither sentence claims
/// anything was measured; the address is configuration.
pub fn no_twin_detail(panel: &TwinPanel) -> String {
if panel.configured_twin.is_empty() {
"Gunnar reports no twin for this box, and no twin is configured for the restore.".to_owned()
} else {
format!(
"Gunnar reports no twin row. A twin is configured at {}: until it polls this primary, \
or is enrolled in group twin, Gunnar has nothing to report about it.",
panel.configured_twin
)
}
}
/// Refusals the person waives by typing the phrase after reading the review
/// (the POST then says `stale_ok` / `accept_lost` / `primary_fenced`).
pub fn waived(e: &Eligibility) -> Option<&'static str> {
match e.code.as_str() {
"stale_source" => Some("stale_ok"),
"would_lose" => Some("accept_lost"),
"not_fenced" => Some("primary_fenced"),
_ => None,
}
}
/// **What the page says first on a `not_fenced` refusal** (R-13): `(line, more)`,
/// the line on screen and the rest behind its "?".
pub fn fence_hint(e: &Eligibility) -> Option<(&'static str, &'static str)> {
(e.code == "not_fenced").then_some((
"The twin still hears its old primary: fence or stop it first.",
"Two writable primaries split the estate. Fence the old primary (standby) or stop its machine, \
then type the phrase: that says it is fenced. A fenced primary still answers its twin, so the \
twin cannot tell for you.",
))
}
/// Draw the restore action? Only for an administrator, and only when the
/// server said this box is fresh, the twin answered, and named a phrase
/// (eligible, or refused only for a reason the review lets the person waive).
pub fn show_restore(is_admin: bool, e: Option<&Eligibility>) -> bool {
is_admin
&& e.is_some_and(|e| (e.eligible || waived(e).is_some()) && e.twin_reachable && e.twin_head_seq > 0 && !e.confirm.is_empty())
}
/// Draw the fence-first override? Only for an administrator, on a main that
/// answers as LIVE, with the server's override phrase.
pub fn show_override(is_admin: bool, e: Option<&Eligibility>) -> bool {
is_admin && e.is_some_and(|e| e.main_live && !e.override_confirm.is_empty())
}
/// The POST body for what the person typed.
pub fn restore_body(typed: &str, e: &Eligibility, override_live_main: bool) -> serde_json::Value {
serde_json::json!({
"confirm": typed.trim(),
"override_live_main": override_live_main,
"stale_ok": waived(e) == Some("stale_ok"),
"accept_lost": waived(e) == Some("accept_lost"),
"primary_fenced": waived(e) == Some("primary_fenced"),
})
}
/// **The review, line by line**: health, head vs primary, last heartbeat,
/// what a recharge would lose, and the server's verdict.
///
/// `said` is the sentence the panel shows on its own beside the review (the
/// answer's `why`, which IS the review's verdict): it is not repeated here
/// (owner, gunnar.rs 2026-10-14: the `main_live: …` paragraph stood twice).
pub fn review_lines(r: &Review, said: &str) -> Vec<String> {
let mut lines = vec![format!("Health: {}", r.health.as_deref().unwrap_or("not asked").replace('_', " "))];
let verdict = (!r.verdict.is_empty() && r.verdict != said).then(|| r.verdict.clone());
if r.health.as_deref() == Some("unreachable") {
lines.extend(verdict);
return lines;
}
lines.push(format!(
"Twin holds seq {}..={}; it has applied {} of the {} its primary last reported.",
r.first_seq.max(1),
r.head_seq,
r.applied_seq,
r.primary_head_seq
));
lines.push(if r.last_heartbeat_unix_ms > 0 {
format!("Last heartbeat from its primary: {}.", ago(r.heartbeat_ago_ms))
} else {
"Last heartbeat from its primary: never, since the twin started.".to_owned()
});
lines.push(if r.would_lose.entries == 0 && r.would_lose.unreplicated == 0 {
"A recharge would lose nothing the twin was sent.".to_owned()
} else {
let mut l = format!("A recharge would lose {} entr{} below the twin's log", r.would_lose.entries, if r.would_lose.entries == 1 { "y" } else { "ies" });
if r.would_lose.unreplicated > 0 {
l.push_str(&format!(", and {} the primary had that never reached the twin", r.would_lose.unreplicated));
}
if !r.would_lose.what.is_empty() {
l.push_str(&format!(" ({})", r.would_lose.what));
}
l.push('.');
l
});
lines.extend(verdict);
lines
}
/// What a healthy main reads instead of gunnar-repair's `main_live` paragraph.
pub const MAIN_IS_RUNNING: &str = "Main is running normally, so there is nothing to recharge. Recharge is for a main that is lost or wiped.";
/// **The reason, in a person's words**, with the technical sentence kept as
/// the detail a reader asks for: `Some((line, detail))` for a reason this page
/// has plain words for (a main that is running), `None` for every other, which
/// is shown as the server said it.
pub fn plain_why(e: &Eligibility) -> Option<(&'static str, String)> {
(e.code == "main_live").then(|| (MAIN_IS_RUNNING, e.why.clone()))
}
/// **Why the restore is not on the page**, for an administrator who is looking
/// for it — and `None` for everyone else, because a missing action is not an
/// operator's problem and an action that never existed needs no excuse.
///
/// The sentence is the server's `why`, verbatim: it names the configured twin
/// and what did not answer, and this console does not decide any of that.
/// Drawing nothing here is what made a front with `GUNNAR_UI_TWIN` unset look,
/// to the one person who could fix it, exactly like a front with no feature.
pub fn restore_absence(is_admin: bool, e: Option<&Eligibility>) -> Option<String> {
if !is_admin {
return None;
}
let e = e?;
if show_restore(is_admin, Some(e)) {
return None;
}
Some(if e.why.is_empty() {
"this Gunnar did not say why.".to_owned()
} else {
e.why.clone()
})
}
/// May the confirmed press be sent? Only with the server's phrase typed exactly.
pub fn may_send(typed: &str, e: &Eligibility) -> bool {
!e.confirm.is_empty() && typed.trim() == e.confirm
}
/// May the fence-first override be sent? Only with its own phrase typed exactly.
pub fn may_override(typed: &str, e: &Eligibility) -> bool {
!e.override_confirm.is_empty() && typed.trim() == e.override_confirm
}
/// The one sentence above the button.
pub fn offer_line(e: &Eligibility) -> String {
format!(
"This box is fresh (no identities, no keys) and the twin at {} holds transactions {}..={}. \
Restoring replays all of them into this box.",
e.twin_address,
e.twin_first_seq.max(1),
e.twin_head_seq
)
}
/// The warning in the confirmation.
pub fn warning() -> &'static str {
"Every identity, key, grant and repository the twin holds is written into this box. \
Do not close this page until it answers; a broken stream leaves the box restoring, \
and running the restore again resumes it."
}
/// What the restore did.
pub fn restored_line(r: &Restored) -> String {
let mut line = format!("Restored through seq {}: {} applied, {} lost.", r.head_seq, r.applied, r.lost);
if r.restart_required {
line.push_str(" Restart Gunnar to serve the restored data.");
}
if !r.detail.is_empty() {
line.push(' ');
line.push_str(&r.detail);
}
line
}
/// **[Rebuild main]**, as `GET /api/rebuild` sends it (main rebuild, owner 2026-10-12): the
/// ONE box's guardian on THIS estate's main — monetize's words, passed through.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct Rebuild {
pub finding: String,
pub words: String,
pub remedy: String,
pub case_id: String,
pub case_state: String,
pub steps: Vec<String>,
pub outcome: String,
pub auto_fix: bool,
/// The phrase a press carries; empty: nothing may be pressed now.
pub confirm: String,
}
/// The lines the panel shows: the guardian's words, then the case, step by step.
pub fn rebuild_lines(r: &Rebuild) -> Vec<String> {
let mut out = vec![r.words.clone()];
if !r.case_id.is_empty() {
out.push(format!("Case {} is {}.", r.case_id, r.case_state));
out.extend(r.steps.iter().cloned());
if !r.outcome.is_empty() {
out.push(r.outcome.clone());
}
}
if r.auto_fix {
out.push("Auto-fix is on at its provider: it may start this by itself.".to_string());
}
out
}
/// The button's words for the guardian's remedy.
pub fn rebuild_button(r: &Rebuild) -> &'static str {
match r.remedy.as_str() {
"recharge_from_twin" => "Recharge the main from the twin…",
_ => "Rebuild main from the twin…",
}
}
/// May the press be sent: something to press, and the phrase typed exactly.
pub fn may_rebuild(typed: &str, r: &Rebuild) -> bool {
!r.confirm.is_empty() && typed.trim() == r.confirm
}
#[cfg(test)]
mod tests {
/// ★ [Rebuild main]: monetize's words first, then the case; the press only on the exact phrase.
#[test]
fn the_rebuild_says_monetizes_words_and_presses_only_on_the_phrase() {
let r: Rebuild = serde_json::from_str(r#"{"finding":"main_lost","words":"the main is silent (never promoted)","remedy":"new_server","confirm":"rebuild main"}"#).unwrap();
assert_eq!(rebuild_lines(&r), vec!["the main is silent (never promoted)".to_string()]);
assert_eq!(rebuild_button(&r), "Rebuild main from the twin…");
assert!(may_rebuild(" rebuild main", &r) && !may_rebuild("rebuild", &r));
let running = Rebuild { case_id: "c-1".into(), case_state: "running".into(), steps: vec!["running — fence main".into()], confirm: String::new(), auto_fix: true, ..r };
assert_eq!(rebuild_lines(&running)[1..], ["Case c-1 is running.".to_string(), "running — fence main".to_string(), "Auto-fix is on at its provider: it may start this by itself.".to_string()]);
assert!(!may_rebuild("rebuild main", &running), "nothing to press while a case runs");
}
use super::*;
fn row(state: &str, lag: u64, seen: Option<i64>, fill: Option<u32>) -> TwinRow {
TwinRow {
principal: "twin".into(),
address: "10.13.8.122:40112".into(),
last_seen_unix_ms: seen,
seen_ago_ms: 125_000,
applied_seq: 100 - lag,
head_seq: 100,
lag_entries: lag,
disk_total_bytes: 2 << 30,
fill_permille: fill,
state: state.into(),
..TwinRow::default()
}
}
fn fresh() -> Eligibility {
Eligibility {
eligible: true,
twin_address: "10.13.8.122:50051".into(),
twin_reachable: true,
twin_first_seq: 1,
twin_head_seq: 42,
window_open: true,
confirm: "restore 42 from twin".into(),
..Eligibility::default()
}
}
#[test]
fn the_panel_is_red_when_a_twin_is_behind_or_silent_and_only_then() {
let calm = TwinPanel { twins: vec![row("ok", 0, Some(1), Some(500))], ..TwinPanel::default() };
assert!(!panel_alarm(&calm));
for state in ["behind", "silent", "something-newer"] {
let red = TwinPanel { twins: vec![row("ok", 0, Some(1), Some(500)), row(state, 3, Some(1), None)], ..TwinPanel::default() };
assert!(panel_alarm(&red), "{state} did not turn the panel red");
}
let twin_side = TwinPanel { primary: Some(PrimaryRow { state: "silent".into(), ..PrimaryRow::default() }), ..TwinPanel::default() };
assert!(panel_alarm(&twin_side));
assert_eq!((state_label("ok"), state_label("behind"), state_label("silent"), state_label("x")), ("In step", "Behind", "Silent", "Unknown"));
}
#[test]
fn a_row_says_last_seen_lag_and_fill_and_never_invents_a_measurement() {
let r = row("behind", 7, Some(1_700_000_000_000), Some(503));
assert_eq!(seen_line(&r), "2 min ago");
assert_eq!(lag_line(&r), "7 entries (seq 93 of 100)");
assert_eq!(fill_line(&r), "50.3 % of 2.0 GB".replace("2.0 GB", &fmt::bytes(2 << 30)));
let never = row("silent", 0, None, None);
assert_eq!((seen_line(&never).as_str(), fill_line(&never).as_str()), ("never heard from", "not measured"));
assert_eq!(lag_line(&never), "none (at seq 100)");
assert_eq!(ago(4_000), "4 s ago");
assert_eq!(ago(7_200_000), "2 h ago");
}
#[test]
fn the_restore_action_is_drawn_only_for_an_admin_on_a_fresh_box_with_a_reachable_twin() {
assert!(show_restore(true, Some(&fresh())));
assert!(!show_restore(false, Some(&fresh())), "an operator saw the restore");
assert!(!show_restore(true, None), "drawn before the server answered");
assert!(!show_restore(true, Some(&Eligibility { eligible: false, confirm: String::new(), ..fresh() })));
assert!(!show_restore(true, Some(&Eligibility { twin_reachable: false, ..fresh() })));
assert!(!show_restore(true, Some(&Eligibility { confirm: String::new(), ..fresh() })));
// What a server answers for an enrolled box.
let enrolled: Eligibility = serde_json::from_str(r#"{"eligible":false,"twin_reachable":true,"twin_head_seq":42,"principals":3,"keys":4,"confirm":"","why":"not fresh"}"#).unwrap();
assert!(!show_restore(true, Some(&enrolled)));
}
/// An administrator looking for the restore is TOLD why it is not there.
#[test]
fn an_absent_restore_says_which_of_its_conditions_failed() {
assert_eq!(restore_absence(true, Some(&fresh())), None, "the action is drawn; it needs no excuse");
assert_eq!(restore_absence(false, None), None, "an operator was told about an admin's action");
assert_eq!(restore_absence(false, Some(&Eligibility { eligible: false, ..fresh() })), None);
assert_eq!(restore_absence(true, None), None, "a sentence before the server answered");
let unset = Eligibility { why: "no twin is configured (GUNNAR_UI_TWIN is unset)".into(), ..Eligibility::default() };
assert_eq!(restore_absence(true, Some(&unset)).as_deref(), Some("no twin is configured (GUNNAR_UI_TWIN is unset)"));
let down = Eligibility {
eligible: false,
twin_reachable: false,
why: "the twin at 10.13.8.122:50051 did not answer Mirror.LogHead: no answer within 10 s".into(),
..fresh()
};
assert!(restore_absence(true, Some(&down)).is_some_and(|w| w.contains("did not answer")));
// A server that refused without a reason still gets a sentence, never silence.
let mute = Eligibility { eligible: false, confirm: String::new(), why: String::new(), ..fresh() };
assert_eq!(restore_absence(true, Some(&mute)).as_deref(), Some("this Gunnar did not say why."));
}
/// No twin reported is not the same thing as no twin configured.
#[test]
fn an_unreported_twin_and_an_unconfigured_one_do_not_read_alike() {
let none = TwinPanel::default();
assert_eq!(no_twin_detail(&none), "Gunnar reports no twin for this box, and no twin is configured for the restore.");
let configured = TwinPanel { configured_twin: "10.13.8.122:50051".into(), ..TwinPanel::default() };
let said = no_twin_detail(&configured);
assert!(said.contains("10.13.8.122:50051"), "{said}");
assert_ne!(said, no_twin_detail(&none));
// Configuration only: nothing here claims the twin was reached.
for claim in ["reachable", "answered", "silent", "behind", "ok"] {
assert!(!said.contains(claim), "`{claim}` in {said:?} — that is a measurement");
}
}
/// **[Review twin]** says health, head vs primary, last heartbeat and what
/// a recharge would lose — the server's numbers, nothing invented.
#[test]
fn the_review_reads_health_heads_heartbeat_and_loss() {
let json = r#"{"twin_addr":"t:1","health":"primary_silent","head_seq":40,"first_seq":30,"primary_head_seq":42,"applied_seq":40,
"last_heartbeat_unix_ms":5,"heartbeat_ago_ms":125000,"would_lose":{"entries":29,"unreplicated":2,"what":"pushes since"},
"recharge_possible":false,"verdict":"would_lose: …","refusal":"would_lose","future_field":1}"#;
let r: Review = serde_json::from_str(json).unwrap();
let lines = review_lines(&r, "");
assert_eq!(lines[0], "Health: primary silent");
assert!(lines[1].contains("30..=40") && lines[1].contains("40 of the 42"), "{}", lines[1]);
assert_eq!(lines[2], "Last heartbeat from its primary: 2 min ago.");
assert!(lines[3].contains("29 entries") && lines[3].contains("2 the primary had") && lines[3].contains("pushes since"), "{}", lines[3]);
assert_eq!(lines[4], "would_lose: …");
let down = Review { health: Some("unreachable".into()), verdict: "did not answer".into(), ..Review::default() };
assert_eq!(review_lines(&down, ""), vec!["Health: unreachable".to_owned(), "did not answer".to_owned()]);
}
/// ★ Owner, gunnar.rs 2026-10-14: the `main_live: …` paragraph stood TWICE
/// under Recharge from twin — the review's verdict and the answer's `why`
/// are the same sentence, and the page drew both. It is drawn once, and a
/// healthy main reads in a person's words with the technical sentence kept
/// as the detail.
#[test]
fn a_running_mains_reason_is_said_once_and_in_plain_words() {
let technical = "main_live: the main is NOT fresh, so Bootstrap.Restore would be refused: its identity table holds 3 principals (last seen 1791927777610)";
let review = Review { health: Some("healthy".into()), head_seq: 42, verdict: technical.into(), refusal: "main_live".into(), ..Review::default() };
let e = Eligibility { eligible: false, code: "main_live".into(), main_live: true, why: technical.into(), review: Some(review.clone()), ..fresh() };
let lines = review_lines(&review, &e.why);
assert!(!lines.iter().any(|l| l == technical), "the verdict is not repeated beside `why`: {lines:?}");
let (line, detail) = plain_why(&e).expect("plain words for a running main");
assert_eq!(line, "Main is running normally, so there is nothing to recharge. Recharge is for a main that is lost or wiped.");
for raw in ["Bootstrap.Restore", "identity table", "1791927777610", "main_live"] {
assert!(!line.contains(raw), "{raw} in the main text");
}
assert_eq!(detail, technical, "the technical sentence is kept as the detail");
// Any other reason is the server's own sentence.
assert_eq!(plain_why(&Eligibility { code: "no_twin".into(), why: "no twin is configured".into(), ..fresh() }), None);
// And the page draws through these.
let page = include_str!("pages/admin_storage.rs");
assert!(page.contains("twin::review_lines(&r, &said)") && page.contains("twin::plain_why"), "the panel draws the reason once");
}
/// A refusal the review explains (stale, a retention hole) is waived by
/// typing the phrase, and the POST says which; a live main only overrides.
#[test]
fn a_waivable_refusal_draws_the_action_and_a_live_main_only_the_override() {
let stale = Eligibility { eligible: false, code: "stale_source".into(), ..fresh() };
assert!(show_restore(true, Some(&stale)));
let body = restore_body(" restore 42 from twin ", &stale, false);
assert_eq!((body["stale_ok"].as_bool(), body["accept_lost"].as_bool(), body["confirm"].as_str()), (Some(true), Some(false), Some("restore 42 from twin")));
let live = Eligibility { eligible: false, code: "main_live".into(), main_live: true, confirm: String::new(), override_confirm: "fence main and restore 42 from twin".into(), ..fresh() };
assert!(!show_restore(true, Some(&live)));
assert!(show_override(true, Some(&live)) && !show_override(false, Some(&live)));
assert!(may_override("fence main and restore 42 from twin", &live) && !may_override("restore 42 from twin", &live));
assert_eq!(restore_body("x", &live, true)["override_live_main"], true);
}
/// **R-13 on the page**: a twin that still hears its primary draws the
/// action with ONE line on what to do first; the POST says `primary_fenced`.
#[test]
fn a_not_fenced_refusal_draws_the_action_with_its_fence_line() {
let unfenced = Eligibility { eligible: false, code: "not_fenced".into(), ..fresh() };
assert_eq!(waived(&unfenced), Some("primary_fenced"));
assert!(show_restore(true, Some(&unfenced)) && !show_restore(false, Some(&unfenced)));
let body = restore_body("restore 42 from twin", &unfenced, false);
assert_eq!((body["primary_fenced"].as_bool(), body["stale_ok"].as_bool()), (Some(true), Some(false)));
let (line, more) = fence_hint(&unfenced).expect("the page says what to do first");
assert!(line.contains("fence") && line.chars().count() <= crate::hint::ONE_LINE, "{line}");
assert!(more.contains("stop") && more.contains("twin"), "{more}");
assert_eq!(fence_hint(&fresh()), None, "nothing to fence on an eligible box");
assert_eq!(restore_body("x", &fresh(), false)["primary_fenced"], false, "said only for a not_fenced refusal");
}
#[test]
fn the_confirmed_press_needs_the_servers_phrase_exactly() {
let e = fresh();
assert!(may_send("restore 42 from twin", &e));
assert!(may_send(" restore 42 from twin ", &e));
for typed in ["", "restore", "restore 41 from twin", "RESTORE 42 FROM TWIN"] {
assert!(!may_send(typed, &e), "{typed:?} was accepted");
}
assert!(!may_send("", &Eligibility::default()), "an empty phrase confirms nothing");
}
#[test]
fn the_answer_reads_as_gunnars_numbers() {
let r = Restored { head_seq: 42, applied: 42, lost: 0, restart_required: true, detail: "restart".into() };
let line = restored_line(&r);
assert!(line.contains("seq 42") && line.contains("42 applied") && line.contains("0 lost") && line.contains("Restart"), "{line}");
assert!(offer_line(&fresh()).contains("1..=42"));
}
/// Everything the panel and the action can say, on the open console: no money.
#[test]
fn nothing_the_twin_panel_can_say_is_about_money() {
let (fence_line, fence_more) = fence_hint(&Eligibility { code: "not_fenced".into(), ..fresh() }).expect("a fence hint");
let mut said = vec![fence_line.to_owned(), fence_more.to_owned(), warning().to_owned(), offer_line(&fresh()), restored_line(&Restored { restart_required: true, detail: "x".into(), ..Restored::default() })];
for s in ["ok", "behind", "silent", "x"] {
said.push(state_label(s).to_owned());
}
let r = row("behind", 2, None, Some(1));
said.extend([seen_line(&r), lag_line(&r), fill_line(&r)]);
said.push(no_twin_detail(&TwinPanel::default()));
said.push(no_twin_detail(&TwinPanel { configured_twin: "10.13.8.122:50051".into(), ..TwinPanel::default() }));
said.push(restore_absence(true, Some(&Eligibility { eligible: false, confirm: String::new(), ..fresh() })).expect("a reason"));
for text in &said {
for word in text.split(|c: char| !c.is_ascii_alphanumeric()).map(str::to_ascii_lowercase) {
assert!(
!["price", "pay", "paid", "buy", "bought", "sell", "sold", "billed", "billing", "quota", "plan", "invoice", "money", "purchase"].contains(&word.as_str()),
"`{word}` in {text:?}"
);
}
}
}
}