Gunnar

nordisk/gunnar-ui

uitest/src/clans.rs

//! **Clans** (owner, 2026-10-03: "ordinary accounts can group with other in a
//! clan, where group created repos are administered by the group admin";
//! 2026-10-05: "each member contributes storage to the clan") — You → Clans,
//! driven as a person drives it, every claim held against GUNNAR's answer.
//!
//! `admin` is bob's session (the console's administrator, and an ordinary
//! account of his own); `person` is kim's (a viewer, the admin of the
//! enterprise account `kim`, whose employee `dora` the Add-users rows made).
//! bob makes the clan; kim joins it; dora, an employee, is refused in
//! gunnar's words.

use crate::cdp::Cdp;
use crate::http;
use crate::json::{esc, Json};
use crate::matrix::{go, want, Row};
use crate::rig::Rig;

// Not `uitest-…`: that prefix is a reserved namespace name (the name filter), and
// Clans.Create passes the name filter since the clans + enterprise-list merge.
const CLAN: &str = "rig-fellows";
const GIB: f64 = 1_073_741_824.0;
const MAIN_TEXT: &str = "(document.querySelector('main') || document.body).innerText";

/// Set an input (by CSS selector) as a person typing would.
fn type_into(c: &mut Cdp, sess: &str, selector: &str, value: &str) -> Result<(), String> {
    let done = c.eval(
        sess,
        &format!(
            "(() => {{ const i = document.querySelector({}); if (!i) return false;
                const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
                set.call(i, {}); i.dispatchEvent(new Event('input', {{bubbles: true}})); return true; }})()",
            esc(selector),
            esc(value)
        ),
    )?;
    want(done.as_bool() == Some(true), || format!("no {selector} on the page"))
}

fn click(c: &mut Cdp, sess: &str, selector: &str) -> Result<(), String> {
    let done = c.eval(sess, &format!("(() => {{ const b = document.querySelector({}); if (!b) return false; b.click(); return true; }})()", esc(selector)))?;
    want(done.as_bool() == Some(true), || format!("no {selector} to press"))
}

/// The person's clans, as gunnar answered them through the console.
fn clans(c: &mut Cdp, sess: &str) -> Result<Json, String> {
    c.eval(sess, "fetch('/api/account/clans').then(r => r.json())")
}

/// The clan named [`CLAN`] in `clans`, if the person is in it or invited.
fn ours(all: &Json) -> Option<Json> {
    all.path("clans")?.arr().iter().find(|c| c.text("name") == CLAN).cloned()
}

fn num(j: &Json, p: &str) -> Option<f64> {
    j.path(p).and_then(|v| v.as_f64())
}

/// The card for the clan, as drawn: its `data-role`, and its text.
fn card(c: &mut Cdp, sess: &str) -> Result<(String, String), String> {
    let sel = format!("#clan-{CLAN}");
    let role = c.eval_str(sess, &format!("(document.querySelector({}) || {{dataset: {{role: ''}}}}).dataset.role", esc(&sel)))?;
    let text = c.eval_str(sess, &format!("(document.querySelector({}) || {{innerText: ''}}).innerText", esc(&sel)))?;
    Ok((role, text))
}

/// The page's refusal box (gunnar's words), waited for.
fn refusal(c: &mut Cdp, sess: &str) -> Result<String, String> {
    c.wait_for(sess, "!!document.querySelector('#clan-fail .failure')", 20_000)
        .map_err(|_| format!("no refusal drawn: {}", http::cut(&c.eval_str(sess, MAIN_TEXT).unwrap_or_default(), 300)))?;
    c.eval_str(sess, "document.querySelector('#clan-fail .failure').innerText")
}

fn open_clans(c: &mut Cdp, r: &Rig, sess: &str) -> Result<(), String> {
    c.navigate(sess, &format!("{}/account/clans", r.origin()))?;
    // The form is drawn at once; gunnar's list after its answer. Wait for the LIST (a clan,
    // "in no clan", or its refusal): a card read before it was read as "" (r5.3 box run
    // 2026-10-08: "bob's card draws no floor: \"\"", then "no .clan-leave to press").
    c.wait_for(
        sess,
        "!!document.getElementById('clan-create') && (!!document.querySelector('section.panel.clan') || !!document.getElementById('clans-none') || !!document.querySelector('main .failure'))",
        30_000,
    )
    .map_err(|_| format!("You → Clans did not draw: {}", http::cut(&c.eval_str(sess, MAIN_TEXT).unwrap_or_default(), 300)))
}

pub fn rows(r: &Rig, rows: &mut Vec<Row>, c: &mut Cdp, admin: &str, person: &str) {
    // ── bob makes a clan on You → Clans ────────────────────────────────────
    go(rows, "you-clans-makes-a-clan-whose-maker-is-its-admin-and-its-namespace-is-private", || {
        open_clans(c, r, admin)?;
        // The top bar's menus are drawn from the reader, after the page: under
        // load (dwarf, load 20, round 3) the entry came later than the page's
        // body, and the row read it at 132 ms. Waited for, as the body is.
        let menu = c.wait_for(admin, "!!document.querySelector('a[href=\"/account/clans\"]')", 20_000).is_ok();
        want(menu, || "You ▾ carries no Clans entry".to_owned())?;
        type_into(c, admin, "#clan-name", CLAN)?;
        click(c, admin, "#clan-create")?;
        c.wait_for(admin, &format!("!!document.getElementById({})", esc(&format!("clan-{CLAN}"))), 30_000)
            .map_err(|_| format!("no clan card after Create: {}", http::cut(&c.eval_str(admin, MAIN_TEXT).unwrap_or_default(), 300)))?;
        // Round 3 (owner 2026-10-14): a clan is created with nothing given;
        // what one gives is set under More.
        type_into(c, admin, &format!("#clan-{CLAN} .clan-give"), "1")?;
        click(c, admin, &format!("#clan-{CLAN} .clan-give-set"))?;
        c.wait_for(admin, &format!("!!document.querySelector('#clan-{CLAN} .clan-floor')"), 20_000)
            .map_err(|_| format!("bob's 1 GiB was not given: {}", http::cut(&c.eval_str(admin, MAIN_TEXT).unwrap_or_default(), 300)))?;
        let (role, text) = card(c, admin)?;
        let clan = ours(&clans(c, admin)?).ok_or("gunnar lists no clan for bob")?;
        want(role == "admin" && clan.text("role") == "admin", || format!("bob's standing: drawn {role:?}, gunnar {:?}", clan.text("role")))?;
        want(num(&clan, "room_bytes") == Some(GIB), || format!("the clan's room is {:?}, bob gave 1 GiB", num(&clan, "room_bytes")))?;
        want(text.contains("Room 1.0 GiB"), || format!("the card says {text:?}"))?;
        // Defaults as for accounts: the clan's namespace is not public.
        let settings = c.eval(admin, &format!("fetch('/api/account/namespaces/{CLAN}/settings').then(r => r.json())"))?;
        want(settings.path("public").and_then(|v| v.as_bool()) == Some(false), || format!("the clan's namespace: {}", http::cut(&settings.dump(), 200)))?;
        Ok(format!("{CLAN}: bob is its admin, room 1 GiB, namespace private"))
    });

    // ── the admin invites; an employee is refused in gunnar's words ────────
    go(rows, "the-clan-admin-invites-an-account-and-an-employee-is-refused-in-gunnars-words", || {
        open_clans(c, r, admin)?;
        type_into(c, admin, &format!("#clan-{CLAN} .clan-invite-who"), "kim")?;
        click(c, admin, &format!("#clan-{CLAN} .clan-invite-send"))?;
        c.wait_for(admin, &format!("!!document.querySelector('#clan-{CLAN} .clan-invite[data-id=\"kim\"]')"), 20_000)
            .map_err(|_| format!("kim's invitation is not drawn: {}", http::cut(&card(c, admin).unwrap_or_default().1, 300)))?;
        // dora is kim's EMPLOYEE (the Add-users rows made her).
        let employees = c.eval(person, "fetch('/api/account/employees?account=kim').then(r => r.json())")?;
        let dora = employees.path("employees").map(|e| e.arr().iter().any(|p| p.text("id") == "dora")).unwrap_or(false);
        want(dora, || format!("dora is not kim's employee (did the Add-users rows run?): {}", http::cut(&employees.dump(), 200)))?;
        type_into(c, admin, &format!("#clan-{CLAN} .clan-invite-who"), "dora")?;
        click(c, admin, &format!("#clan-{CLAN} .clan-invite-send"))?;
        let said = refusal(c, admin)?;
        want(said.contains("employee of kim"), || format!("the refusal is not gunnar's employee rule: {said:?}"))?;
        let clan = ours(&clans(c, admin)?).ok_or("bob's clan is gone")?;
        let invited: Vec<String> = clan.path("invites").map(|i| i.arr().iter().map(|x| x.text("id")).collect()).unwrap_or_default();
        want(invited == ["kim"], || format!("gunnar holds invitations for {invited:?}"))?;
        Ok(format!("kim invited; dora refused: {}", http::cut(&said, 160)))
    });

    // ── kim joins, giving room of her own; the clan's room is the sum ──────
    let mut kim_gave = 0.0;
    go(rows, "the-invited-account-joins-on-its-own-page-and-the-clans-room-is-the-sum", || {
        open_clans(c, r, person)?;
        c.wait_for(person, &format!("!!document.querySelector('#clan-{CLAN} .clan-accept')"), 20_000)
            .map_err(|_| format!("kim is not offered to join: {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 300)))?;
        let before = ours(&clans(c, person)?).ok_or("gunnar lists no invitation for kim")?;
        want(before.text("role") == "invited", || format!("kim's standing before: {:?}", before.text("role")))?;
        // kim gives 1 GiB when her own room has it, as gunnar counts it;
        // otherwise nothing — and asking past her room is refused.
        let left = num(&before, "your_room_left_bytes");
        let mut refused = String::new();
        if let Some(left) = left {
            type_into(c, person, &format!("#clan-{CLAN} .clan-accept-give"), &format!("{}", (left / GIB).ceil() + 1.0))?;
            click(c, person, &format!("#clan-{CLAN} .clan-accept"))?;
            refused = refusal(c, person)?;
            want(refused.contains("short"), || format!("joining past her own room was not refused as short: {refused:?}"))?;
            want(ours(&clans(c, person)?).is_some_and(|c| c.text("role") == "invited"), || "a refused join joined".to_owned())?;
        }
        kim_gave = match left {
            Some(left) if left < GIB => 0.0,
            _ => 1.0,
        };
        type_into(c, person, &format!("#clan-{CLAN} .clan-accept-give"), &format!("{kim_gave}"))?;
        click(c, person, &format!("#clan-{CLAN} .clan-accept"))?;
        c.wait_for(person, &format!("(document.querySelector('#clan-{CLAN}') || {{dataset: {{}}}}).dataset.role === 'member'"), 20_000)
            .map_err(|_| format!("kim did not become a member: {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 300)))?;
        let clan = ours(&clans(c, person)?).ok_or("kim's clan is gone")?;
        let members = clan.path("members").map(|m| m.arr().to_vec()).unwrap_or_default();
        let sum: f64 = members.iter().filter_map(|m| num(m, "contribution_bytes")).sum();
        want(num(&clan, "room_bytes") == Some(sum), || format!("the clan's room {:?} is not the sum of its members' {sum}", num(&clan, "room_bytes")))?;
        want(sum == GIB + kim_gave * GIB, || format!("bob 1 GiB + kim {kim_gave} GiB, gunnar says {sum}"))?;
        let (_, text) = card(c, person)?;
        want(text.contains(&format!("Room {:.1} GiB", 1.0 + kim_gave)), || format!("kim's card says {text:?}"))?;
        Ok(format!(
            "kim joined giving {kim_gave} GiB (room left {}); room = {} GiB = the sum{}",
            left.map_or("uncapped".to_owned(), |l| format!("{:.1} GiB", l / GIB)),
            sum / GIB,
            if refused.is_empty() { String::new() } else { format!("; past her room: {}", http::cut(&refused, 100)) }
        ))
    });

    // ── what is made in the clan is administered by its admin ──────────────
    go(rows, "only-the-clan-admin-administers-a-clan-repository-and-a-member-does-not", || {
        let made = c.eval(
            admin,
            &format!(
                "fetch('/api/repos', {{method: 'POST', headers: {{'Content-Type': 'application/json'}},
                    body: JSON.stringify({{namespace: {}, name: 'shared', hash_kind: 'sha256', visibility: 'private'}})}}).then(r => r.json())",
                esc(CLAN)
            ),
        )?;
        want(made.text("path") == format!("{CLAN}/shared"), || format!("the clan admin could not make a repository: {}", http::cut(&made.dump(), 200)))?;
        // Something to store: a commit, pushed by stock git.
        let wt = r.work.join("clan-wt");
        std::fs::create_dir_all(&wt).map_err(|e| e.to_string())?;
        r.git(&wt, &["init", "-q", "--object-format=sha256", "-b", "main", "."])?;
        r.git(&wt, &["config", "user.name", "uitest"])?;
        r.git(&wt, &["config", "user.email", "info@example.invalid"])?;
        std::fs::write(wt.join("README"), "a clan's repository\n".repeat(64)).map_err(|e| e.to_string())?;
        r.git(&wt, &["add", "README"])?;
        r.git(&wt, &["commit", "-qm", "first"])?;
        r.git(&wt, &["push", "-q", &r.remote(&format!("{CLAN}/shared")), "main"])?;
        let ask = |c: &mut Cdp, sess: &str| c.eval(sess, &format!("fetch('/api/repos/{CLAN}/shared/visibility').then(r => r.json())"));
        let bob = ask(c, admin)?;
        let kim = ask(c, person)?;
        want(bob.path("account_admin").and_then(|v| v.as_bool()) == Some(true), || format!("gunnar does not say bob administers it: {}", http::cut(&bob.dump(), 200)))?;
        want(kim.path("account_admin").and_then(|v| v.as_bool()) == Some(false), || format!("gunnar says a member administers it: {}", http::cut(&kim.dump(), 200)))?;
        Ok(format!("bob may publish {CLAN}/shared; kim may not: {}", http::cut(&kim.text("why"), 120)))
    });

    // ── a contribution goes down only to what the clan stores ──────────────
    go(rows, "a-contribution-goes-down-only-to-what-the-clan-stores-in-gunnars-words", || {
        let clan = ours(&clans(c, admin)?).ok_or("bob's clan is gone")?;
        let used = num(&clan, "used_bytes").ok_or_else(|| format!("gunnar did not measure what the clan stores: {}", http::cut(&clan.dump(), 200)))?;
        want(used > 0.0, || "the clan stores nothing after the push".to_owned())?;
        // bob gives 0 if kim's room covers what the clan stores; then kim may
        // not. One of the two is refused with gunnar's floor, and the floor
        // gunnar drew beforehand says which.
        let bob_floor = num(&clan, "your_floor_bytes").unwrap_or(-1.0);
        let (who, sess) = if bob_floor > 0.0 { ("bob", admin) } else { ("kim", person) };
        if who == "kim" {
            open_clans(c, r, admin)?;
            type_into(c, admin, &format!("#clan-{CLAN} .clan-give"), "0")?;
            click(c, admin, &format!("#clan-{CLAN} .clan-give-set"))?;
            c.wait_for(admin, &format!("!!document.querySelector('#clan-{CLAN}') && !document.querySelector('#clan-{CLAN} .clan-floor')"), 20_000)
                .map_err(|_| format!("bob could not give 0 though kim covers the clan: {}", http::cut(&c.eval_str(admin, MAIN_TEXT).unwrap_or_default(), 300)))?;
        }
        open_clans(c, r, sess)?;
        let (_, text) = card(c, sess)?;
        want(text.contains("You can lower it to"), || format!("{who}'s card draws no floor: {text:?}"))?;
        type_into(c, sess, &format!("#clan-{CLAN} .clan-give"), "0")?;
        click(c, sess, &format!("#clan-{CLAN} .clan-give-set"))?;
        let said = refusal(c, sess)?;
        want(said.contains("may go down to"), || format!("{who}'s lowering was not refused with the floor: {said:?}"))?;
        let after = ours(&clans(c, sess)?).ok_or("the clan is gone")?;
        want(num(&after, "your_contribution_bytes").unwrap_or(0.0) > 0.0, || "a refused lowering lowered".to_owned())?;
        Ok(format!("the clan stores {used} B; {who} may not give 0: {}", http::cut(&said, 140)))
    });

    // ── the last admin cannot leave ─────────────────────────────────────────
    go(rows, "the-last-admin-cannot-leave-and-a-member-leaves-only-when-its-room-is-not-in-use", || {
        open_clans(c, r, admin)?;
        click(c, admin, &format!("#clan-{CLAN} .clan-leave"))?;
        let last = refusal(c, admin)?;
        want(last.contains("last admin"), || format!("bob, its last admin, was not refused as such: {last:?}"))?;
        let clan = ours(&clans(c, person)?).ok_or("kim's clan is gone")?;
        let floor = num(&clan, "your_floor_bytes").unwrap_or(0.0);
        let mine = num(&clan, "your_contribution_bytes").unwrap_or(0.0);
        open_clans(c, r, person)?;
        click(c, person, &format!("#clan-{CLAN} .clan-leave"))?;
        if floor > 0.0 {
            let said = refusal(c, person)?;
            want(said.contains("in use"), || format!("kim's room is in use and her leaving was not refused: {said:?}"))?;
            Ok(format!("bob: {}; kim (gives {mine} B, floor {floor} B): {}", http::cut(&last, 80), http::cut(&said, 80)))
        } else {
            c.wait_for(person, &format!("!document.querySelector('#clan-{CLAN}')"), 20_000)
                .map_err(|_| format!("kim did not leave: {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 300)))?;
            want(ours(&clans(c, person)?).is_none(), || "gunnar still lists kim's clan after she left".to_owned())?;
            Ok(format!("bob: {}; kim (her {mine} B not in use) left", http::cut(&last, 80)))
        }
    });

    // ── round 2 (owner, 2026-10-05): a repository moves out, then the clan dissolves ──
    go(rows, "the-clan-admin-moves-a-repository-out-to-an-account-whose-admin-takes-it-and-the-empty-clan-dissolves", || {
        // bob, the clan's admin, offers its repository to kim's account.
        open_clans(c, r, admin)?;
        type_into(c, admin, &format!("#clan-{CLAN} .clan-move-repo"), "shared")?;
        type_into(c, admin, &format!("#clan-{CLAN} .clan-move-to"), "kim")?;
        click(c, admin, &format!("#clan-{CLAN} .clan-move-offer"))?;
        c.wait_for(admin, &format!("!!document.querySelector('#clan-{CLAN} .clan-offer[data-repo=\"shared\"]')"), 20_000)
            .map_err(|_| format!("the offer is not drawn: {}", http::cut(&card(c, admin).unwrap_or_default().1, 300)))?;
        let clan = ours(&clans(c, admin)?).ok_or("bob's clan is gone")?;
        let offered: Vec<String> = clan.path("offers").map(|o| o.arr().iter().map(|x| x.text("to")).collect()).unwrap_or_default();
        want(offered == ["kim"], || format!("gunnar holds offers to {offered:?}"))?;
        // kim's page carries it; she takes it.
        open_clans(c, r, person)?;
        c.wait_for(person, &format!("!!document.querySelector('#clan-offers-to-me [data-repo=\"{CLAN}/shared\"] .clan-offer-take')"), 20_000)
            .map_err(|_| format!("kim is not offered the repository: {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 300)))?;
        click(c, person, &format!("#clan-offers-to-me [data-repo=\"{CLAN}/shared\"] .clan-offer-take"))?;
        c.wait_for(person, "!document.querySelector('#clan-offers-to-me')", 30_000)
            .map_err(|_| format!("the offer did not close: {}", http::cut(&c.eval_str(person, MAIN_TEXT).unwrap_or_default(), 300)))?;
        // gunnar: the repository is kim's now, and the clan holds none.
        let moved = c.eval(person, "fetch('/api/repos/kim/shared').then(r => r.status)")?;
        want(moved.as_f64() == Some(200.0), || format!("kim/shared answers {}", moved.dump()))?;
        let gone = c.eval(admin, &format!("fetch('/api/repos/{CLAN}/shared').then(r => r.status)"))?;
        want(gone.as_f64() != Some(200.0), || format!("{CLAN}/shared still answers {}", gone.dump()))?;
        // Empty now: bob dissolves the clan on its page.
        open_clans(c, r, admin)?;
        // Round 3: Dissolve is under More and asks for the clan's name.
        type_into(c, admin, &format!("#clan-{CLAN} .clan-dissolve-confirm"), CLAN)?;
        click(c, admin, &format!("#clan-{CLAN} .clan-dissolve"))?;
        c.wait_for(admin, &format!("!document.querySelector('#clan-{CLAN}')"), 20_000)
            .map_err(|_| format!("the clan did not dissolve: {}", http::cut(&c.eval_str(admin, MAIN_TEXT).unwrap_or_default(), 300)))?;
        want(ours(&clans(c, admin)?).is_none(), || "gunnar still lists the dissolved clan".to_owned())?;
        Ok(format!("{CLAN}/shared offered to kim, taken: kim/shared answers 200, {CLAN}/shared {}; the empty clan dissolved", gone.dump()))
    });
}