forked from yvv/decision
- migration 0011 (additive ; 0010 reste au lot V) : documents.quorums (par cran,
NULL = vote_floor à chaque cran, le C d'avant), units.quorum (NULL = celui du
cran), journaux quorum_changes et quorum_amendments ;
- quorum effectif = units.quorum ?? quorums[cran] ; les propositions d'inertie et
les modules en héritent ; p0 = 2 × vote_floor / W, jamais le quorum d'un article ;
- route PUT /units/{id}/quorum { "quorum": n | null } : trustin_access::sets_quorum
(l'auteur de la première version, un administrateur pour le seed), refusée dès
qu'un vote existe (quorum::voted_units : vote du serveur, bulletin de la chaîne
en attente ou confirmé, adoption) ; bornes 2..W, 1..W dans le groupe d'essai ;
- amendement gardé 2026-11-03-quorums-par-cran : écrit les quorums du seed là où
il n'y en a pas, une fois par document, consigné, file de publication là où il
publie ;
- JSON : evaluation.quorum / yesNeeded / noNeeded ; l'unité : quorum,
quorumDefault, canSetQuorum ; doc.quorums ; levels[].quorum ; document.json
publié : quorums ; adoptions.params : quorum (plus de constant) ;
- seed : quorums 12 · 12 · 50 · 50 · 50 (Monnaie libre), 1 partout (essai).
- tests : tests/quorum.rs (5) et les règles pures ; les scénarios du groupe
d'essai qui demandaient deux voix posent le quorum 2 de l'article, comme son
auteur le ferait ; les chiffres de Monnaie libre suivent (9 au cran 2, 39 au
cran 3, 43 au cran 4).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
395 lines
19 KiB
Rust
395 lines
19 KiB
Rust
//! Typo fixes validated (2026-10-31, lot F, L13, ADR 0010), end to end on an
|
|
//! in-memory database. What a typo is does not change (`ld_engine::edit`); who
|
|
//! applies it, and when, does.
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode, header};
|
|
use http_body_util::BodyExt;
|
|
use ld_server::{AppState, CSRF_HEADER, CSRF_VALUE, Config, app, deadlines, open_db, seed};
|
|
use serde_json::{Value, json};
|
|
use tower::ServiceExt;
|
|
|
|
struct Client {
|
|
router: axum::Router,
|
|
cookie: Option<String>,
|
|
}
|
|
|
|
impl Client {
|
|
/// A base seeded as production is before its seed proposal amendment (ADR 0011):
|
|
/// I1, E1 and E2 in force, seeds a minima — as they stay wherever it is refused.
|
|
/// These scenarios need texts in force; the seed of today has none.
|
|
async fn new() -> (Self, AppState) {
|
|
let db = open_db("sqlite::memory:").await.expect("db");
|
|
let mut cfg = Config::for_tests();
|
|
cfg.seed_dir =
|
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/seed-production-2026-11-02");
|
|
let state = AppState::new(cfg, db).await.expect("state");
|
|
seed::ensure(&state).await.expect("seed");
|
|
(Self { router: app(state.clone()), cookie: None }, state)
|
|
}
|
|
|
|
fn other(&self) -> Self {
|
|
Self { router: self.router.clone(), cookie: None }
|
|
}
|
|
|
|
async fn call(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, Value) {
|
|
let mut req = Request::builder().method(method).uri(uri).header(CSRF_HEADER, CSRF_VALUE);
|
|
if let Some(c) = &self.cookie {
|
|
req = req.header(header::COOKIE, c);
|
|
}
|
|
let req = match body {
|
|
Some(b) => req.header(header::CONTENT_TYPE, "application/json").body(Body::from(b.to_string())),
|
|
None => req.body(Body::empty()),
|
|
}
|
|
.expect("request");
|
|
let res = self.router.clone().oneshot(req).await.expect("response");
|
|
let status = res.status();
|
|
if let Some(set) = res.headers().get(header::SET_COOKIE) {
|
|
let raw = set.to_str().expect("cookie").split(';').next().unwrap_or_default().to_owned();
|
|
if raw.starts_with("ld_session=") {
|
|
self.cookie = Some(raw);
|
|
}
|
|
}
|
|
let bytes = res.into_body().collect().await.expect("body").to_bytes();
|
|
(status, serde_json::from_slice(&bytes).unwrap_or(Value::Null))
|
|
}
|
|
|
|
async fn ok(&mut self, method: &str, uri: &str, body: Option<Value>) -> Value {
|
|
let (s, v) = self.call(method, uri, body).await;
|
|
assert_eq!(s, StatusCode::OK, "{method} {uri}: {v}");
|
|
v
|
|
}
|
|
|
|
async fn err(&mut self, method: &str, uri: &str, body: Option<Value>) -> (StatusCode, String) {
|
|
let (s, v) = self.call(method, uri, body).await;
|
|
(s, v["error"].as_str().unwrap_or_default().to_owned())
|
|
}
|
|
|
|
async fn login(&mut self, name: &str, admin: bool) {
|
|
self.ok("POST", "/api/auth/dev", Some(json!({ "name": name, "admin": admin }))).await;
|
|
}
|
|
|
|
async fn doc(&mut self, group: &str) -> Value {
|
|
let g = self.ok("GET", &format!("/api/groups/{group}"), None).await;
|
|
let id = g["docs"][0]["id"].as_str().expect("doc").to_owned();
|
|
self.ok("GET", &format!("/api/docs/{id}"), None).await
|
|
}
|
|
|
|
async fn propose(&mut self, doc: &Value, title: &str, body: &str) -> String {
|
|
let id = doc["doc"]["id"].as_str().expect("id");
|
|
let v = self
|
|
.ok(
|
|
"POST",
|
|
&format!("/api/docs/{id}/proposals"),
|
|
Some(json!({ "title": title, "body": body, "submit": true })),
|
|
)
|
|
.await;
|
|
v["id"].as_str().expect("id").to_owned()
|
|
}
|
|
|
|
async fn fix(&mut self, id: &str, title: &str, body: &str) -> Value {
|
|
self.ok("PATCH", &format!("/api/proposals/{id}"), Some(json!({ "title": title, "body": body }))).await
|
|
}
|
|
|
|
async fn detail(&mut self, id: &str) -> Value {
|
|
self.ok("GET", &format!("/api/proposals/{id}"), None).await
|
|
}
|
|
|
|
async fn messages(&mut self) -> Value {
|
|
self.ok("GET", "/api/messages", None).await
|
|
}
|
|
}
|
|
|
|
fn unit<'a>(doc: &'a Value, code: &str) -> &'a Value {
|
|
doc["units"].as_array().expect("units").iter().find(|u| u["code"] == code).expect("unit")
|
|
}
|
|
|
|
fn gestures(v: &Value) -> Vec<&str> {
|
|
v["gestures"].as_array().expect("gestures").iter().filter_map(Value::as_str).collect()
|
|
}
|
|
|
|
fn last(history: &Value) -> &Value {
|
|
history.as_array().expect("history").last().expect("entry")
|
|
}
|
|
|
|
const TITLE: &str = "Réciprocité";
|
|
const VOTED: &str = "Je m'engage à ne certifier que des persones qui respectent ces engagemnts.";
|
|
const FIX_1: &str = "Je m'engage à ne certifier que des personnes qui respectent ces engagemnts.";
|
|
const FIX_2: &str = "Je m'engage à ne certifier que des persones qui respectent ces engagements.";
|
|
|
|
/// C30, by the API: someone else's typo on a proposal waits for its author, who
|
|
/// validates it from her messages — and keeps the hand while it is not in force.
|
|
#[tokio::test]
|
|
async fn someone_elses_typo_on_a_proposal_waits_for_its_author() {
|
|
let (mut chiara, _) = Client::new().await;
|
|
chiara.login("Chiara", false).await;
|
|
let mut yvv = chiara.other();
|
|
yvv.login("Yvv", false).await;
|
|
let doc = chiara.doc("essai").await;
|
|
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
|
// Its author sets the quorum 2 before the first vote (ADR 0013): in the test group, one
|
|
// voice of two then no longer makes it pass, and it stays hers to answer for.
|
|
let u = chiara.detail(&p).await["unit"]["id"].as_str().expect("unit").to_owned();
|
|
chiara.ok("PUT", &format!("/api/units/{u}/quorum"), Some(json!({ "quorum": 2 }))).await;
|
|
yvv.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
|
|
|
let w = yvv.fix(&p, TITLE, FIX_1).await;
|
|
assert_eq!((w["edit"].as_str(), w["typo"]["answerer"].as_str()), (Some("typo_pending"), Some("author")));
|
|
assert!(w["typo"]["dueAt"].is_string());
|
|
let tid = w["typo"]["id"].as_str().expect("id").to_owned();
|
|
// Nothing written yet: the text stays, and the pending fix shows with its diff.
|
|
let d = yvv.detail(&p).await;
|
|
assert_eq!(d["proposal"]["body"], VOTED);
|
|
let t = &d["typos"][0];
|
|
assert_eq!((t["status"].as_str(), t["writer"].as_str()), (Some("pending"), Some("Yvv")));
|
|
assert!(t["diff"].as_array().is_some_and(|x| !x.is_empty()));
|
|
assert_eq!(gestures(t), Vec::<&str>::new(), "the writer does not answer");
|
|
assert_eq!(gestures(&chiara.detail(&p).await["typos"][0]), ["validate", "refuse"]);
|
|
assert_eq!(yvv.err("POST", &format!("/api/typos/{tid}/validate"), None).await.0, StatusCode::FORBIDDEN);
|
|
|
|
// Its author is told, by a message with the gestures.
|
|
let m = chiara.messages().await;
|
|
let msg = &m["messages"][0];
|
|
assert_eq!((msg["kind"].as_str(), msg["topic"].as_str()), (Some("typo_pending"), Some("typo")));
|
|
assert_eq!(gestures(msg), ["validate", "refuse"]);
|
|
assert_eq!(msg["typo"]["writer"], "Yvv");
|
|
assert_eq!(msg["typo"]["version"]["id"], p.as_str());
|
|
|
|
// Validated: the fix is in the text, the vote stays.
|
|
assert_eq!(chiara.ok("POST", &format!("/api/typos/{tid}/validate"), None).await["status"], "applied");
|
|
let d = chiara.detail(&p).await;
|
|
assert_eq!(d["proposal"]["body"], FIX_1);
|
|
assert_eq!(d["proposal"]["tally"]["for"], 1, "a typo keeps the votes");
|
|
let h = last(&d["history"]);
|
|
assert_eq!(
|
|
(h["kind"].as_str(), h["author"].as_str(), h["typo"]["answeredBy"].as_str()),
|
|
(Some("typo"), Some("Yvv"), Some("Chiara"))
|
|
);
|
|
let m = chiara.messages().await;
|
|
assert_eq!(
|
|
(gestures(&m["messages"][0]).len(), m["messages"][0]["outcome"].as_str(), m["unread"].as_i64()),
|
|
(0, Some("applied"), Some(0))
|
|
);
|
|
|
|
// The author keeps the hand while it is not in force: she takes it back, then validates it again.
|
|
assert_eq!(gestures(&d["typos"][0]), ["refuse"]);
|
|
assert_eq!(chiara.ok("POST", &format!("/api/typos/{tid}/refuse"), None).await["status"], "refused");
|
|
let d = chiara.detail(&p).await;
|
|
assert_eq!(d["proposal"]["body"], VOTED);
|
|
assert_eq!(last(&d["history"])["kind"], "revert");
|
|
assert_eq!(gestures(&d["typos"][0]), ["validate"]);
|
|
chiara.ok("POST", &format!("/api/typos/{tid}/validate"), None).await;
|
|
assert_eq!(chiara.detail(&p).await["proposal"]["body"], FIX_1);
|
|
|
|
// Her own fixes apply at once, as before.
|
|
let own = chiara.fix(&p, TITLE, &FIX_1.replace("engagemnts", "engagements")).await;
|
|
assert_eq!(own["edit"], "typo");
|
|
// A change of meaning stays refused to anyone else.
|
|
let s = yvv
|
|
.err("PATCH", &format!("/api/proposals/{p}"), Some(json!({ "title": TITLE, "body": "Tout autre chose." })))
|
|
.await;
|
|
assert_eq!(s, (StatusCode::CONFLICT, "substantive_not_author".into()));
|
|
}
|
|
|
|
/// C31, by the API: on a text in force, only a validated fix corrects it — by its
|
|
/// author, or an administrator for a starting text; else by itself after three days.
|
|
#[tokio::test]
|
|
async fn on_a_text_in_force_only_a_validated_typo_corrects_it() {
|
|
let (mut chiara, state) = Client::new().await;
|
|
chiara.login("Chiara", false).await;
|
|
let mut yvv = chiara.other();
|
|
yvv.login("Yvv", true).await;
|
|
let doc = chiara.doc("monnaie-libre").await;
|
|
let doc_id = doc["doc"]["id"].as_str().expect("id").to_owned();
|
|
let corrections = || {
|
|
let db = state.db.clone();
|
|
let doc_id = doc_id.clone();
|
|
async move {
|
|
sqlx::query_scalar::<_, i64>(
|
|
"SELECT COUNT(*) FROM outbox WHERE doc_id = ? AND reason = 'correction typographique'",
|
|
)
|
|
.bind(doc_id)
|
|
.fetch_one(&db)
|
|
.await
|
|
.expect("outbox")
|
|
}
|
|
};
|
|
let e1 = unit(&doc, "E1")["inForce"].clone();
|
|
let e1_id = e1["id"].as_str().expect("id").to_owned();
|
|
let fixed = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
|
let w = chiara.fix(&e1_id, e1["title"].as_str().expect("t"), &fixed).await;
|
|
assert_eq!((w["edit"].as_str(), w["typo"]["answerer"].as_str()), (Some("typo_pending"), Some("admin")));
|
|
let tid = w["typo"]["id"].as_str().expect("id").to_owned();
|
|
assert_ne!(chiara.detail(&e1_id).await["proposal"]["body"], fixed.as_str(), "not in force before it is validated");
|
|
assert_eq!(corrections().await, 0, "nothing republished");
|
|
// A starting text has no author: an administrator answers, from his messages.
|
|
assert_eq!(gestures(&yvv.messages().await["messages"][0]), ["validate", "refuse"]);
|
|
assert_eq!(chiara.err("POST", &format!("/api/typos/{tid}/validate"), None).await.0, StatusCode::FORBIDDEN);
|
|
yvv.ok("POST", &format!("/api/typos/{tid}/validate"), None).await;
|
|
assert_eq!(chiara.detail(&e1_id).await["proposal"]["body"], fixed.as_str());
|
|
assert_eq!(corrections().await, 1, "republished once validated");
|
|
// In force, an answer is final.
|
|
assert_eq!(
|
|
yvv.err("POST", &format!("/api/typos/{tid}/refuse"), None).await,
|
|
(StatusCode::CONFLICT, "settled".into())
|
|
);
|
|
|
|
// Nobody answers: three days later it applies by itself.
|
|
let e2 = unit(&doc, "E2")["inForce"].clone();
|
|
let e2_id = e2["id"].as_str().expect("id").to_owned();
|
|
let e2_fixed = e2["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
|
chiara.fix(&e2_id, e2["title"].as_str().expect("t"), &e2_fixed).await;
|
|
state.clock.advance(3 * 86_400 - 60);
|
|
assert_eq!(deadlines::sweep(&state).await.expect("sweep").applied, 0);
|
|
state.clock.advance(120);
|
|
assert_eq!(deadlines::sweep(&state).await.expect("sweep").applied, 1);
|
|
let d = chiara.detail(&e2_id).await;
|
|
assert_eq!(d["proposal"]["body"], e2_fixed.as_str());
|
|
assert_eq!(last(&d["history"])["typo"]["answeredBy"], Value::Null);
|
|
assert_eq!(last(&d["history"])["typo"]["deadline"], true);
|
|
assert_eq!(corrections().await, 2);
|
|
let m = yvv.messages().await;
|
|
assert_eq!((m["messages"][0]["outcome"].as_str(), gestures(&m["messages"][0]).len()), (Some("deadline"), 0));
|
|
|
|
// An administrator's own fix on a starting text applies at once.
|
|
let i1 = unit(&doc, "I1")["inForce"].clone();
|
|
let i1_id = i1["id"].as_str().expect("id");
|
|
let body = i1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
|
assert_eq!(yvv.fix(i1_id, i1["title"].as_str().expect("t"), &body).await["edit"], "typo");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn on_a_text_in_force_by_an_author_she_answers_and_a_refusal_is_final() {
|
|
let (mut chiara, _) = Client::new().await;
|
|
chiara.login("Chiara", false).await;
|
|
let mut yvv = chiara.other();
|
|
yvv.login("Yvv", false).await;
|
|
let doc = chiara.doc("essai").await;
|
|
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
|
// W = 2 in the test group: two voices for make it pass.
|
|
for c in [&mut chiara, &mut yvv] {
|
|
c.ok("POST", &format!("/api/proposals/{p}/vote"), Some(json!({ "choice": "for" }))).await;
|
|
}
|
|
assert_eq!(chiara.detail(&p).await["proposal"]["status"], "in_force");
|
|
let tid = yvv.fix(&p, TITLE, FIX_1).await["typo"]["id"].as_str().expect("id").to_owned();
|
|
assert_eq!(chiara.ok("POST", &format!("/api/typos/{tid}/refuse"), None).await["status"], "refused");
|
|
assert_eq!(chiara.detail(&p).await["proposal"]["body"], VOTED);
|
|
// In force: no going back on the answer.
|
|
let back = chiara.err("POST", &format!("/api/typos/{tid}/validate"), None).await;
|
|
assert_eq!(back, (StatusCode::CONFLICT, "settled".into()));
|
|
assert_eq!(gestures(&chiara.detail(&p).await["typos"][0]), Vec::<&str>::new());
|
|
}
|
|
|
|
/// Two fixes waiting on one text: each is judged against the voted text; once one
|
|
/// applies, the other is written over a text that is gone — stale, « recharge ».
|
|
#[tokio::test]
|
|
async fn two_pending_typos_on_one_text_the_second_goes_stale() {
|
|
let (mut chiara, state) = Client::new().await;
|
|
chiara.login("Chiara", false).await;
|
|
let mut yvv = chiara.other();
|
|
yvv.login("Yvv", false).await;
|
|
let mut luna = chiara.other();
|
|
luna.login("Luna", false).await;
|
|
let doc = chiara.doc("essai").await;
|
|
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
|
let t1 = yvv.fix(&p, TITLE, FIX_1).await["typo"]["id"].as_str().expect("id").to_owned();
|
|
let w2 = luna.fix(&p, TITLE, FIX_2).await;
|
|
assert_eq!(w2["edit"], "typo_pending", "each one slip from the voted text");
|
|
let t2 = w2["typo"]["id"].as_str().expect("id").to_owned();
|
|
chiara.ok("POST", &format!("/api/typos/{t1}/validate"), None).await;
|
|
let d = chiara.detail(&p).await;
|
|
let second = d["typos"].as_array().expect("t").iter().find(|t| t["id"] == t2.as_str()).expect("t2").clone();
|
|
assert_eq!((second["status"].as_str(), gestures(&second).len()), (Some("stale"), 0));
|
|
let late = chiara.err("POST", &format!("/api/typos/{t2}/validate"), None).await;
|
|
assert_eq!(late, (StatusCode::CONFLICT, "changed".into()));
|
|
let m = chiara.messages().await;
|
|
let msg =
|
|
m["messages"].as_array().expect("m").iter().find(|x| x["typo"]["id"] == t2.as_str()).expect("msg").clone();
|
|
assert_eq!((msg["outcome"].as_str(), gestures(&msg).len()), (Some("stale"), 0));
|
|
// The deadline never applies it.
|
|
state.clock.advance(4 * 86_400);
|
|
assert_eq!(deadlines::sweep(&state).await.expect("sweep").applied, 0);
|
|
assert_eq!(chiara.detail(&p).await["proposal"]["body"], FIX_1);
|
|
// Written again over the text of now, it waits as any other.
|
|
assert_eq!(luna.fix(&p, TITLE, &FIX_1.replace("engagemnts", "engagements")).await["edit"], "typo_pending");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_change_of_substance_or_a_withdrawal_leaves_pending_typos_stale() {
|
|
let (mut chiara, _) = Client::new().await;
|
|
chiara.login("Chiara", false).await;
|
|
let mut yvv = chiara.other();
|
|
yvv.login("Yvv", false).await;
|
|
let doc = chiara.doc("essai").await;
|
|
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
|
let t = yvv.fix(&p, TITLE, FIX_1).await["typo"]["id"].as_str().expect("id").to_owned();
|
|
chiara
|
|
.fix(&p, TITLE, "Je m'engage à ne certifier que des personnes rencontrées qui respectent ces engagements.")
|
|
.await;
|
|
assert_eq!(chiara.detail(&p).await["typos"][0]["status"], "stale");
|
|
assert_eq!(
|
|
chiara.err("POST", &format!("/api/typos/{t}/validate"), None).await,
|
|
(StatusCode::CONFLICT, "changed".into())
|
|
);
|
|
let q = chiara.propose(&doc, "Autre", VOTED).await;
|
|
yvv.fix(&q, "Autre", FIX_1).await;
|
|
chiara.ok("POST", &format!("/api/proposals/{q}/withdraw"), None).await;
|
|
let stale: i64 = {
|
|
let d = chiara.detail(&q).await;
|
|
d["typos"].as_array().expect("t").iter().filter(|t| t["status"] == "stale").count() as i64
|
|
};
|
|
assert_eq!(stale, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn translations_stay_editorial() {
|
|
let (mut chiara, _) = Client::new().await;
|
|
chiara.login("Chiara", false).await;
|
|
let mut yvv = chiara.other();
|
|
yvv.login("Yvv", false).await;
|
|
let doc = chiara.doc("essai").await;
|
|
let p = chiara.propose(&doc, TITLE, VOTED).await;
|
|
yvv.ok(
|
|
"PUT",
|
|
&format!("/api/proposals/{p}/translations/en"),
|
|
Some(json!({ "title": "Reciprocity", "body": "I only certify people who keep these commitments." })),
|
|
)
|
|
.await;
|
|
let d = chiara.detail(&p).await;
|
|
assert_eq!(d["proposal"]["translations"]["en"]["title"], "Reciprocity", "without any validation");
|
|
assert_eq!(d["typos"].as_array().expect("t").len(), 0);
|
|
assert_eq!(chiara.messages().await["messages"].as_array().expect("m").len(), 0);
|
|
}
|
|
|
|
/// A text without author: only the administrators who open its group are told,
|
|
/// and a message shows only while its recipient opens the group.
|
|
#[tokio::test]
|
|
async fn only_administrators_who_open_the_group_are_told() {
|
|
let (mut chiara, state) = Client::new().await;
|
|
chiara.login("Chiara", false).await;
|
|
let mut yvv = chiara.other();
|
|
yvv.login("Yvv", true).await;
|
|
let mut luna = chiara.other();
|
|
luna.login("Luna", true).await;
|
|
let leave = |who: &'static str| {
|
|
let db = state.db.clone();
|
|
async move {
|
|
sqlx::query("DELETE FROM memberships WHERE identity_id = ? AND group_slug = 'essai'")
|
|
.bind(who)
|
|
.execute(&db)
|
|
.await
|
|
.expect("leave");
|
|
}
|
|
};
|
|
leave("dev:luna").await;
|
|
let doc = chiara.doc("essai").await;
|
|
let e1 = unit(&doc, "E1")["inForce"].clone();
|
|
let body = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
|
let w = chiara.fix(e1["id"].as_str().expect("id"), e1["title"].as_str().expect("t"), &body).await;
|
|
assert_eq!(w["typo"]["answerer"], "admin");
|
|
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 1);
|
|
assert_eq!(luna.messages().await["messages"].as_array().expect("m").len(), 0, "Luna does not open the test group");
|
|
leave("dev:yvv").await;
|
|
assert_eq!(yvv.messages().await["messages"].as_array().expect("m").len(), 0, "no longer shown");
|
|
}
|