forked from yvv/decision
Fusion du lot R : Raison d'être (amendement de seed gardé), versions groupées
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
-- Seed amendments (2026-10-30): a starting text replaced by a newer one of the
|
||||
-- seed, decided once per document and only on an article nobody has touched
|
||||
-- (src/seed_amend.rs). Each decision is kept here: applied, refused (and why),
|
||||
-- or already present. Additive only.
|
||||
|
||||
CREATE TABLE seed_amendments (
|
||||
amendment_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL REFERENCES documents(id),
|
||||
unit_code TEXT NOT NULL,
|
||||
proposal_id TEXT, -- the version in force that was examined
|
||||
outcome TEXT NOT NULL, -- applied | refused | present
|
||||
reason TEXT, -- why it was refused
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (amendment_id, doc_id)
|
||||
);
|
||||
@@ -289,9 +289,11 @@ pub async fn load_state(state: &AppState, doc_id: &str) -> AppResult<DocState> {
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
// Same second (the variants of a seed): the order they were written in, not their
|
||||
// random identifiers. The engine ranks rivals on its own; this order is what people see.
|
||||
let proposals = sqlx::query_as::<_, ProposalRow>(&format!(
|
||||
"{PROPOSAL_SELECT} WHERE p.unit_id IN (SELECT id FROM units WHERE doc_id = ?)
|
||||
AND p.status IN ('open', 'in_force', 'draft') ORDER BY p.submitted_at, p.created_at, p.id"
|
||||
AND p.status IN ('open', 'in_force', 'draft') ORDER BY p.submitted_at, p.created_at, p.rowid"
|
||||
))
|
||||
.bind(doc_id)
|
||||
.fetch_all(&state.db)
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod error;
|
||||
pub mod proposals;
|
||||
pub mod publish;
|
||||
pub mod seed;
|
||||
pub mod seed_amend;
|
||||
pub mod team;
|
||||
pub mod util;
|
||||
pub mod web;
|
||||
|
||||
@@ -71,10 +71,11 @@ fn check_text(title: &str, body: &str, needs_body: bool) -> AppResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The text the votes were cast on: its last initial or substantive revision.
|
||||
/// The text the votes were cast on: its last initial or substantive revision, or the
|
||||
/// starting text a seed amendment put in place (`seed_amend`).
|
||||
async fn voted_text(state: &AppState, p: &ProposalRow) -> AppResult<(String, String)> {
|
||||
let row: Option<(String, String)> = sqlx::query_as(
|
||||
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive')
|
||||
"SELECT title, body FROM revisions WHERE proposal_id = ? AND kind IN ('initial', 'substantive', 'amendment')
|
||||
ORDER BY id DESC LIMIT 1",
|
||||
)
|
||||
.bind(&p.id)
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
//!
|
||||
//! On start, missing groups are created and every `seeds/*.json` document is
|
||||
//! instantiated in each group it names, **only if that group does not hold it
|
||||
//! yet**. An existing document is never touched: from its first vote on, it
|
||||
//! is runtime data.
|
||||
//! yet**. An existing document is never instantiated again: from its first
|
||||
//! vote on, it is runtime data. The one way a newer seed text reaches an
|
||||
//! existing document is a seed amendment (`seed_amend`): listed, decided once,
|
||||
//! and applied only to an article nobody has touched.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::seed_amend;
|
||||
use crate::util::{self, LangMap};
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
@@ -64,14 +67,23 @@ pub async fn ensure_groups(state: &AppState) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// On start: the groups, the documents missing from them, then the seed
|
||||
/// amendments (each decided once per document).
|
||||
pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
|
||||
let seeds = documents(state).await?;
|
||||
seed_amend::apply(state, &seeds, seed_amend::AMENDMENTS).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The groups and every seed document missing from its groups; returns the seeds read.
|
||||
pub async fn documents(state: &AppState) -> anyhow::Result<Vec<SeedDoc>> {
|
||||
ensure_groups(state).await?;
|
||||
let dir = state.cfg.seed_dir.clone();
|
||||
let mut entries = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!(dir = %dir.display(), error = %e, "no seed directory");
|
||||
return Ok(());
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
};
|
||||
let mut files = Vec::new();
|
||||
@@ -82,13 +94,14 @@ pub async fn ensure(state: &AppState) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
let mut seeds = Vec::new();
|
||||
for path in files {
|
||||
load_file(state, &path).await?;
|
||||
seeds.push(load_file(state, &path).await?);
|
||||
}
|
||||
Ok(())
|
||||
Ok(seeds)
|
||||
}
|
||||
|
||||
async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<()> {
|
||||
async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<SeedDoc> {
|
||||
let raw = tokio::fs::read_to_string(path).await?;
|
||||
let seed: SeedDoc = serde_json::from_str(&raw).map_err(|e| anyhow::anyhow!("{}: {e}", path.display()))?;
|
||||
for inst in &seed.instances {
|
||||
@@ -111,7 +124,7 @@ async fn load_file(state: &AppState, path: &Path) -> anyhow::Result<()> {
|
||||
instantiate(state, &seed, inst).await?;
|
||||
tracing::info!(group = %inst.group, slug = %seed.slug, "seed instantiated");
|
||||
}
|
||||
Ok(())
|
||||
Ok(seed)
|
||||
}
|
||||
|
||||
async fn instantiate(state: &AppState, seed: &SeedDoc, inst: &SeedInstance) -> anyhow::Result<()> {
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
//! Seed amendments — a starting text replaced by a newer text of the seed.
|
||||
//!
|
||||
//! A seeded document is never instantiated again: once in the database, it is
|
||||
//! runtime data. When the seed's text of an article changes (Yvv, 2026-10-30:
|
||||
//! the Préambule becomes « Raison d'être »), new bases get it from the seed;
|
||||
//! an existing base gets it through an amendment listed here, and **only if
|
||||
//! nobody has touched the article**:
|
||||
//!
|
||||
//! - its version in force is the seed's own, a *seed a minima* (never voted
|
||||
//! into force, never confirmed), at revision 1;
|
||||
//! - nobody ever voted on it, corrected it, edited one of its translations or
|
||||
//! named it in a contradiction signal;
|
||||
//! - its text is exactly the one the amendment replaces (fingerprint).
|
||||
//!
|
||||
//! Each document is decided once, at the first start that knows the
|
||||
//! amendment, and the decision is kept in `seed_amendments`: applied, refused
|
||||
//! (and why — also in the logs), or present (the base was born with the new
|
||||
//! text). Applied, the text and its seed translations are replaced in one
|
||||
//! transaction, the article's history gets a line of its own (kind
|
||||
//! `amendment`, with its diff) and, where the document publishes, the
|
||||
//! publication queue a row.
|
||||
|
||||
use crate::AppState;
|
||||
use crate::seed::{SeedDoc, SeedVersion};
|
||||
use crate::util;
|
||||
use sqlx::SqliteConnection;
|
||||
|
||||
/// One starting text to replace, in every document instantiated from a seed.
|
||||
pub struct Amendment {
|
||||
/// Stable identifier: each document's decision is kept under it.
|
||||
pub id: &'static str,
|
||||
/// Slug of the seed document.
|
||||
pub doc: &'static str,
|
||||
/// Code of the article.
|
||||
pub unit: &'static str,
|
||||
/// Fingerprint of the starting text expected in force (source language).
|
||||
pub from: &'static str,
|
||||
/// Fingerprint of the new text: the version in force of the seed today, which
|
||||
/// must hash to it (a seed edited later never reaches a base by this way).
|
||||
pub to: &'static str,
|
||||
/// Reason given to the publication queue, and so to the commit of the texts.
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
pub const AMENDMENTS: &[Amendment] = &[Amendment {
|
||||
id: "2026-10-30-i1-raison-d-etre",
|
||||
doc: "acte-engagement-certification",
|
||||
unit: "I1",
|
||||
from: "ac17693c61b678ae48f5386f3d528a78385a62f3e2bb12a802a21f45c762b375",
|
||||
to: "a7095d70602a0fc60d0904e9ffbce6d8520914ce3cb424d8f75e48bf6cfdf41a",
|
||||
reason: "texte de départ remplacé : I1",
|
||||
}];
|
||||
|
||||
/// The fingerprint of a text: the hash translations are pinned to.
|
||||
pub fn fingerprint(title: &str, body: &str) -> String {
|
||||
util::source_hash(title, body)
|
||||
}
|
||||
|
||||
/// What the database says of the version in force of the article.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Facts {
|
||||
pub origin: String,
|
||||
/// Voted into force (`entered_at`).
|
||||
pub entered: bool,
|
||||
/// A seed a minima that later reached its threshold (`confirmed_at`).
|
||||
pub confirmed: bool,
|
||||
pub revision: i64,
|
||||
/// Votes cast on it, whatever their revision.
|
||||
pub votes: i64,
|
||||
/// Kinds of its revisions, in order.
|
||||
pub revisions: Vec<String>,
|
||||
/// Translations written or corrected by a member.
|
||||
pub member_translations: i64,
|
||||
/// Contradiction signals naming it, standing or lifted.
|
||||
pub signals: i64,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Verdict {
|
||||
Apply,
|
||||
/// The new text is already in force: nothing to do.
|
||||
Present,
|
||||
Refuse(&'static str),
|
||||
}
|
||||
|
||||
/// The rule, apart from the database: any trace of a person keeps the text.
|
||||
pub fn verdict(facts: Option<&Facts>, a: &Amendment) -> Verdict {
|
||||
let Some(f) = facts else { return Verdict::Refuse("no_text_in_force") };
|
||||
if f.fingerprint == a.to {
|
||||
return Verdict::Present;
|
||||
}
|
||||
let refusal = if f.origin != "seed" {
|
||||
"not_a_seed"
|
||||
} else if f.entered {
|
||||
"voted_into_force"
|
||||
} else if f.confirmed {
|
||||
"confirmed"
|
||||
} else if f.revision != 1 {
|
||||
"revised"
|
||||
} else if f.votes > 0 {
|
||||
"votes"
|
||||
} else if f.revisions != ["initial"] {
|
||||
"corrected"
|
||||
} else if f.member_translations > 0 {
|
||||
"translation_edited"
|
||||
} else if f.signals > 0 {
|
||||
"signalled"
|
||||
} else if f.fingerprint != a.from {
|
||||
"text_differs"
|
||||
} else {
|
||||
return Verdict::Apply;
|
||||
};
|
||||
Verdict::Refuse(refusal)
|
||||
}
|
||||
|
||||
/// A decision taken at this start.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Outcome {
|
||||
pub amendment: &'static str,
|
||||
pub group: String,
|
||||
pub verdict: Verdict,
|
||||
}
|
||||
|
||||
/// Decides every amendment not yet decided, document by document.
|
||||
pub async fn apply(state: &AppState, seeds: &[SeedDoc], list: &[Amendment]) -> anyhow::Result<Vec<Outcome>> {
|
||||
let mut out = Vec::new();
|
||||
for a in list {
|
||||
let Some((seed, target)) = target_of(seeds, a) else { continue };
|
||||
let docs: Vec<(String, String, Option<String>)> =
|
||||
sqlx::query_as("SELECT id, group_slug, publish_path FROM documents WHERE slug = ? ORDER BY group_slug")
|
||||
.bind(a.doc)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
for (doc_id, group, publish_path) in docs {
|
||||
let Some(v) = decide(state, a, seed, target, &doc_id, publish_path.is_some()).await? else { continue };
|
||||
match v {
|
||||
Verdict::Apply => tracing::info!(amendment = a.id, %group, unit = a.unit, "seed amendment applied"),
|
||||
Verdict::Present => {
|
||||
tracing::info!(amendment = a.id, %group, unit = a.unit, "seed amendment: text already there")
|
||||
}
|
||||
Verdict::Refuse(why) => {
|
||||
tracing::warn!(amendment = a.id, %group, unit = a.unit, reason = why, "seed amendment refused: the article was touched, it keeps its text")
|
||||
}
|
||||
}
|
||||
if v == Verdict::Apply && publish_path.is_some() {
|
||||
state.publish_wake.notify_one();
|
||||
}
|
||||
out.push(Outcome { amendment: a.id, group, verdict: v });
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The seed document and its version in force for the article, checked against `to`.
|
||||
fn target_of<'a>(seeds: &'a [SeedDoc], a: &Amendment) -> Option<(&'a SeedDoc, &'a SeedVersion)> {
|
||||
let Some(seed) = seeds.iter().find(|s| s.slug == a.doc) else {
|
||||
tracing::error!(amendment = a.id, doc = a.doc, "seed amendment: no such seed document, nothing decided");
|
||||
return None;
|
||||
};
|
||||
let version =
|
||||
seed.units.iter().find(|u| u.code == a.unit).and_then(|u| u.versions.iter().find(|v| v.status == "in_force"));
|
||||
let src = seed.source_lang.as_str();
|
||||
let text = version.and_then(|v| Some((v.title.get(src)?, v.body.get(src)?)));
|
||||
match (version, text) {
|
||||
(Some(v), Some((title, body))) if fingerprint(title, body) == a.to => Some((seed, v)),
|
||||
_ => {
|
||||
tracing::error!(
|
||||
amendment = a.id,
|
||||
doc = a.doc,
|
||||
unit = a.unit,
|
||||
"seed amendment: the seed text is not the one it names, nothing decided"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn facts(conn: &mut SqliteConnection, proposal_id: &str) -> anyhow::Result<Option<Facts>> {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct Row {
|
||||
origin: String,
|
||||
entered_at: Option<String>,
|
||||
confirmed_at: Option<String>,
|
||||
revision: i64,
|
||||
title: String,
|
||||
body: String,
|
||||
}
|
||||
let Some(p) = sqlx::query_as::<_, Row>(
|
||||
"SELECT origin, entered_at, confirmed_at, revision, title, body FROM proposals WHERE id = ? AND status = 'in_force'",
|
||||
)
|
||||
.bind(proposal_id)
|
||||
.fetch_optional(&mut *conn)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let votes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM votes WHERE proposal_id = ?")
|
||||
.bind(proposal_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
let revisions: Vec<String> = sqlx::query_scalar("SELECT kind FROM revisions WHERE proposal_id = ? ORDER BY id")
|
||||
.bind(proposal_id)
|
||||
.fetch_all(&mut *conn)
|
||||
.await?;
|
||||
let member_translations: i64 = sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM translations WHERE proposal_id = ? AND (origin <> 'seed' OR updated_by IS NOT NULL)",
|
||||
)
|
||||
.bind(proposal_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
let signals: i64 =
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM signals WHERE proposal_id = ?1 OR target_proposal_id = ?1")
|
||||
.bind(proposal_id)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
Ok(Some(Facts {
|
||||
origin: p.origin,
|
||||
entered: p.entered_at.is_some(),
|
||||
confirmed: p.confirmed_at.is_some(),
|
||||
revision: p.revision,
|
||||
votes,
|
||||
revisions,
|
||||
member_translations,
|
||||
signals,
|
||||
fingerprint: fingerprint(&p.title, &p.body),
|
||||
}))
|
||||
}
|
||||
|
||||
/// One document, in one transaction: `None` when it was decided at an earlier start.
|
||||
async fn decide(
|
||||
state: &AppState,
|
||||
a: &Amendment,
|
||||
seed: &SeedDoc,
|
||||
target: &SeedVersion,
|
||||
doc_id: &str,
|
||||
publishes: bool,
|
||||
) -> anyhow::Result<Option<Verdict>> {
|
||||
let mut tx = state.db.begin().await?;
|
||||
let decided: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM seed_amendments WHERE amendment_id = ? AND doc_id = ?")
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
if decided > 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let in_force: Option<String> =
|
||||
sqlx::query_scalar::<_, Option<String>>("SELECT in_force_id FROM units WHERE doc_id = ? AND code = ?")
|
||||
.bind(doc_id)
|
||||
.bind(a.unit)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten();
|
||||
let found = match &in_force {
|
||||
Some(id) => facts(&mut tx, id).await?,
|
||||
None => None,
|
||||
};
|
||||
let mut v = verdict(found.as_ref(), a);
|
||||
let now = util::now();
|
||||
if let (Verdict::Apply, Some(pid)) = (v, &in_force) {
|
||||
let src = seed.source_lang.as_str();
|
||||
let pick = |m: &util::LangMap| m.get(src).cloned().unwrap_or_default();
|
||||
let (title, body) = (pick(&target.title), pick(&target.body));
|
||||
// Written only over the text that was judged, still untouched.
|
||||
let done = sqlx::query(
|
||||
"UPDATE proposals SET title = ?, body = ?, revision = revision + 1, updated_at = ?
|
||||
WHERE id = ? AND status = 'in_force' AND origin = 'seed' AND entered_at IS NULL
|
||||
AND confirmed_at IS NULL AND revision = 1",
|
||||
)
|
||||
.bind(&title)
|
||||
.bind(&body)
|
||||
.bind(&now)
|
||||
.bind(pid)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if done.rows_affected() == 1 {
|
||||
sqlx::query(
|
||||
"INSERT INTO revisions (proposal_id, revision, kind, title, body, author_id, created_at)
|
||||
VALUES (?, 2, 'amendment', ?, ?, NULL, ?)",
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(&title)
|
||||
.bind(&body)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let hash = fingerprint(&title, &body);
|
||||
for lang in util::LANGS.iter().filter(|l| **l != src) {
|
||||
let (Some(t), Some(b)) = (target.title.get(*lang), target.body.get(*lang)) else { continue };
|
||||
sqlx::query(
|
||||
"INSERT INTO translations (proposal_id, lang, title, body, source_hash, origin, updated_by, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'seed', NULL, ?)
|
||||
ON CONFLICT(proposal_id, lang) DO UPDATE SET title = excluded.title, body = excluded.body,
|
||||
source_hash = excluded.source_hash, origin = 'seed', updated_by = NULL, updated_at = excluded.updated_at",
|
||||
)
|
||||
.bind(pid)
|
||||
.bind(lang)
|
||||
.bind(t)
|
||||
.bind(b)
|
||||
.bind(&hash)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
if publishes {
|
||||
sqlx::query("INSERT INTO outbox (doc_id, reason, created_at, next_at) VALUES (?, ?, ?, ?)")
|
||||
.bind(doc_id)
|
||||
.bind(a.reason)
|
||||
.bind(&now)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
v = Verdict::Refuse("changed");
|
||||
}
|
||||
}
|
||||
let (outcome, reason) = match v {
|
||||
Verdict::Apply => ("applied", None),
|
||||
Verdict::Present => ("present", None),
|
||||
Verdict::Refuse(why) => ("refused", Some(why)),
|
||||
};
|
||||
sqlx::query(
|
||||
"INSERT INTO seed_amendments (amendment_id, doc_id, unit_code, proposal_id, outcome, reason, at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(a.id)
|
||||
.bind(doc_id)
|
||||
.bind(a.unit)
|
||||
.bind(&in_force)
|
||||
.bind(outcome)
|
||||
.bind(reason)
|
||||
.bind(&now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(Some(v))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const A: Amendment = Amendment {
|
||||
id: "test",
|
||||
doc: "doc",
|
||||
unit: "I1",
|
||||
from: "old",
|
||||
to: "new",
|
||||
reason: "texte de départ remplacé : I1",
|
||||
};
|
||||
|
||||
fn intact() -> Facts {
|
||||
Facts {
|
||||
origin: "seed".into(),
|
||||
revision: 1,
|
||||
revisions: vec!["initial".into()],
|
||||
fingerprint: "old".into(),
|
||||
..Facts::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untouched_starting_text_is_replaced() {
|
||||
assert_eq!(verdict(Some(&intact()), &A), Verdict::Apply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_text_already_there_is_left_as_it_is() {
|
||||
let f = Facts { fingerprint: "new".into(), revision: 3, votes: 12, ..intact() };
|
||||
assert_eq!(verdict(Some(&f), &A), Verdict::Present);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_trace_of_a_person_keeps_the_text() {
|
||||
let cases = [
|
||||
(None, "no_text_in_force"),
|
||||
(Some(Facts { origin: "member".into(), ..intact() }), "not_a_seed"),
|
||||
(Some(Facts { entered: true, ..intact() }), "voted_into_force"),
|
||||
(Some(Facts { confirmed: true, ..intact() }), "confirmed"),
|
||||
(Some(Facts { revision: 2, ..intact() }), "revised"),
|
||||
(Some(Facts { votes: 1, ..intact() }), "votes"),
|
||||
(Some(Facts { revisions: vec!["initial".into(), "typo".into()], ..intact() }), "corrected"),
|
||||
(Some(Facts { revisions: vec![], ..intact() }), "corrected"),
|
||||
(Some(Facts { member_translations: 1, ..intact() }), "translation_edited"),
|
||||
(Some(Facts { signals: 1, ..intact() }), "signalled"),
|
||||
(Some(Facts { fingerprint: "other".into(), ..intact() }), "text_differs"),
|
||||
];
|
||||
for (facts, reason) in cases {
|
||||
assert_eq!(verdict(facts.as_ref(), &A), Verdict::Refuse(reason), "{facts:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,7 +115,9 @@ async fn the_seed_a_minima_and_the_open_proposals() {
|
||||
// Every other article is an open proposal at zero votes, standard inertia.
|
||||
let e3 = unit(&doc, "E3");
|
||||
assert!(e3["inForce"].is_null());
|
||||
assert_eq!(e3["proposals"].as_array().expect("p").len(), 2, "E3 and its 0.4.0 variant");
|
||||
// In the order of the seed, whatever their random identifiers.
|
||||
let labels: Vec<&str> = e3["proposals"].as_array().expect("p").iter().filter_map(|p| p["label"].as_str()).collect();
|
||||
assert_eq!(labels, ["1.0.0", "0.4.0-draft"], "E3 and its 0.4.0 variant");
|
||||
assert_eq!(e3["inertia"], "standard");
|
||||
// Translations seeded.
|
||||
assert!(e1["translations"]["en"]["body"].as_str().expect("en").contains("commit"));
|
||||
@@ -203,12 +205,13 @@ async fn editing_is_not_voting() {
|
||||
b.login("Chiara", false).await;
|
||||
let id = doc_id(&mut a, "monnaie-libre").await;
|
||||
let (_, doc) = a.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let i1 = unit(&doc, "I1")["inForce"].clone();
|
||||
let pid = i1["id"].as_str().expect("id").to_owned();
|
||||
// Anyone may fix the glued word of the preamble in force, without a vote.
|
||||
let fixed = i1["body"].as_str().expect("body").replace("latransmission", "la transmission");
|
||||
let e1 = unit(&doc, "E1")["inForce"].clone();
|
||||
let pid = e1["id"].as_str().expect("id").to_owned();
|
||||
// Anyone may give the text in force its typographic apostrophes, without a vote.
|
||||
let fixed = e1["body"].as_str().expect("body").replace('\'', "\u{2019}");
|
||||
assert!(fixed.contains("m\u{2019}engage"));
|
||||
let (s, v) =
|
||||
b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": fixed }))).await;
|
||||
b.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": e1["title"], "body": fixed }))).await;
|
||||
assert_eq!(s, StatusCode::OK, "{v}");
|
||||
assert_eq!(v["edit"], "typo");
|
||||
// A change of meaning in force is refused: it goes through a proposal.
|
||||
@@ -216,7 +219,7 @@ async fn editing_is_not_voting() {
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{pid}"),
|
||||
Some(json!({ "title": i1["title"], "body": "Tout autre chose." })),
|
||||
Some(json!({ "title": e1["title"], "body": "Tout autre chose." })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::CONFLICT);
|
||||
@@ -759,7 +762,7 @@ async fn publication_writes_the_texts_in_force_to_the_repository() {
|
||||
};
|
||||
let fr = show("monnaie-libre/acte-engagement-certification/fr.md");
|
||||
assert!(fr.contains("# Acte d'engagement Certification"), "{fr}");
|
||||
assert!(fr.contains("## Préambule") && fr.contains("## Unicité du compte") && fr.contains("## Réciprocité"));
|
||||
assert!(fr.contains("## Raison d'être") && fr.contains("## Unicité du compte") && fr.contains("## Réciprocité"));
|
||||
assert!(!fr.contains("Connaissance suffisante"), "only the texts in force are published");
|
||||
assert!(show("monnaie-libre/acte-engagement-certification/en.md").contains("traduction: editoriale"));
|
||||
let json: Value =
|
||||
@@ -862,3 +865,307 @@ async fn a_refused_first_push_goes_out_again_and_a_new_empty_repository_receives
|
||||
assert_eq!(pending(&db).await, 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I1 « Raison d'être » (Yvv's review, 2026-10-30): the seed and its amendment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The Préambule every base seeded before 2026-10-30 holds (the seed file had not
|
||||
/// changed since 67ddee2, the first commit of the v3).
|
||||
const PREAMBULE: [(&str, &str, &str); 3] = [
|
||||
(
|
||||
"fr",
|
||||
"Préambule",
|
||||
"Le présent acte d'engagement définit les obligations réciproques des membres de la toile de confiance de la monnaie libre G1. Cet acte est de fait l'unique relation contractuelle de notre toile fiduciaire. Toute Certification doit s'accompagner de latransmission de ce document, dont le certificateur doit s'assurer qu'il a été étudié, compris et accepté par le certifié.",
|
||||
),
|
||||
(
|
||||
"en",
|
||||
"Preamble",
|
||||
"This commitment act defines the reciprocal obligations of the members of the web of trust of the G1 libre currency. This act is, de facto, the sole contractual relationship of our fiduciary web. Every Certification must be accompanied by the transmission of this document, and the certifier must make sure that it has been studied, understood and accepted by the certified person.",
|
||||
),
|
||||
(
|
||||
"es",
|
||||
"Preámbulo",
|
||||
"La presente acta de compromiso define las obligaciones recíprocas de los miembros de la red de confianza de la moneda libre G1. Esta acta es, de hecho, la única relación contractual de nuestra red fiduciaria. Toda Certificación debe ir acompañada de la transmisión de este documento, y el certificador debe asegurarse de que la persona certificada lo ha estudiado, comprendido y aceptado.",
|
||||
),
|
||||
];
|
||||
|
||||
const RAISON_D_ETRE: &str = "*«\u{a0}Derrière une clé il n'y a qu'un seul être humain vivant\u{a0}; derrière un être humain, il n'y a qu'une seule clé certifiante.\u{a0}»*\n\nC'est le seul et unique propos de la certification";
|
||||
|
||||
fn today_seed() -> Value {
|
||||
let path = Config::for_tests().seed_dir.join("acte-engagement-certification.json");
|
||||
serde_json::from_str(&std::fs::read_to_string(path).expect("seed")).expect("json")
|
||||
}
|
||||
|
||||
/// The seed as it was before 2026-10-30: the Préambule in force.
|
||||
fn first_seed_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("ld-first-seed-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("dir");
|
||||
let mut seed = today_seed();
|
||||
let i1 = seed["units"].as_array_mut().expect("units").iter_mut().find(|u| u["code"] == "I1").expect("I1");
|
||||
for (lang, title, body) in PREAMBULE {
|
||||
i1["versions"][0]["title"][lang] = json!(title);
|
||||
i1["versions"][0]["body"][lang] = json!(body);
|
||||
}
|
||||
std::fs::write(dir.join("acte-engagement-certification.json"), seed.to_string()).expect("write");
|
||||
dir
|
||||
}
|
||||
|
||||
/// A base seeded with the Préambule, as production was: groups and documents, by the
|
||||
/// code of the time (no amendment).
|
||||
async fn base_with_the_preambule(tag: &str, mut cfg: Config) -> (Client, AppState) {
|
||||
cfg.seed_dir = first_seed_dir(tag);
|
||||
let db = open_db("sqlite::memory:").await.expect("db");
|
||||
let state = AppState::new(cfg, db).await.expect("state");
|
||||
seed::documents(&state).await.expect("first seed");
|
||||
(Client { router: app(state.clone()), cookie: None, set_cookie: None }, state)
|
||||
}
|
||||
|
||||
/// The same base, started again with today's seed (and its amendments).
|
||||
async fn restart(before: &AppState, cfg: Config) -> (Client, AppState) {
|
||||
let state = AppState::new(cfg, before.db.clone()).await.expect("state");
|
||||
seed::ensure(&state).await.expect("seed");
|
||||
(Client { router: app(state.clone()), cookie: None, set_cookie: None }, state)
|
||||
}
|
||||
|
||||
/// Each document's decision on the amendment: (group, outcome, reason, at).
|
||||
async fn decisions(state: &AppState) -> Vec<(String, String, Option<String>, String)> {
|
||||
sqlx::query_as(
|
||||
"SELECT d.group_slug, s.outcome, s.reason, s.at FROM seed_amendments s JOIN documents d ON d.id = s.doc_id
|
||||
ORDER BY d.group_slug",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("decisions")
|
||||
}
|
||||
|
||||
fn outcomes(rows: &[(String, String, Option<String>, String)]) -> Vec<(&str, &str, Option<&str>)> {
|
||||
rows.iter().map(|(g, o, r, _)| (g.as_str(), o.as_str(), r.as_deref())).collect()
|
||||
}
|
||||
|
||||
async fn i1_of(c: &mut Client, group: &str) -> Value {
|
||||
let id = doc_id(c, group).await;
|
||||
let (_, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
unit(&doc, "I1")["inForce"].clone()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_new_base_starts_with_the_raison_d_etre() {
|
||||
let (mut c, state) = Client::new().await;
|
||||
c.login("Chiara", false).await;
|
||||
for group in ["monnaie-libre", "essai"] {
|
||||
let i1 = i1_of(&mut c, group).await;
|
||||
assert_eq!(i1["title"], "Raison d'être");
|
||||
assert!(i1["body"].as_str().expect("body").starts_with(RAISON_D_ETRE), "{i1}");
|
||||
assert_eq!((i1["revision"].as_i64(), i1["seedMinimal"].as_bool()), (Some(1), Some(true)));
|
||||
for (lang, title) in [("en", "Purpose"), ("es", "Razón de ser")] {
|
||||
let tr = &i1["translations"][lang];
|
||||
assert_eq!((tr["title"].as_str(), tr["stale"].as_bool()), (Some(title), Some(false)), "{lang}");
|
||||
}
|
||||
}
|
||||
// Born with the text: nothing to replace, and it is said once.
|
||||
assert_eq!(outcomes(&decisions(&state).await), [("essai", "present", None), ("monnaie-libre", "present", None)]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_shipped_amendment_goes_from_the_preambule_to_the_seed_text() {
|
||||
let a = ld_server::seed_amend::AMENDMENTS.iter().find(|a| a.unit == "I1").expect("I1 amendment");
|
||||
assert_eq!(a.doc, "acte-engagement-certification");
|
||||
let (_, title, body) = PREAMBULE[0];
|
||||
assert_eq!(ld_server::seed_amend::fingerprint(title, body), a.from, "the Préambule bases hold");
|
||||
let seed = today_seed();
|
||||
let i1 = seed["units"].as_array().expect("units").iter().find(|u| u["code"] == "I1").expect("I1");
|
||||
let v = &i1["versions"][0];
|
||||
assert_eq!(v["status"], "in_force");
|
||||
let (title, body) = (v["title"]["fr"].as_str().expect("title"), v["body"]["fr"].as_str().expect("body"));
|
||||
assert_eq!(ld_server::seed_amend::fingerprint(title, body), a.to, "the text of the seed today");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_seed_amendment_replaces_an_untouched_preambule_once_and_republishes_it() {
|
||||
use std::process::Command;
|
||||
let dir = std::env::temp_dir().join(format!("ld-amend-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("dir");
|
||||
let remote = dir.join("textes.git");
|
||||
assert!(Command::new("git").args(["init", "-q", "--bare"]).arg(&remote).status().expect("git").success());
|
||||
let mut cfg = Config::for_tests();
|
||||
cfg.data_dir = dir.join("data");
|
||||
cfg.publish = Some(ld_server::config::Publish {
|
||||
remote: remote.display().to_string(),
|
||||
branch: "main".into(),
|
||||
key: dir.join("data/keys/publish_ed25519"),
|
||||
author_name: "libreDecision".into(),
|
||||
author_email: "decision@example.org".into(),
|
||||
});
|
||||
let git = |args: &[&str]| {
|
||||
let out = Command::new("git").arg("-C").arg(&remote).args(args).output().expect("git");
|
||||
String::from_utf8(out.stdout).expect("utf8")
|
||||
};
|
||||
let show = |file: &str| git(&["show", &format!("main:monnaie-libre/acte-engagement-certification/{file}")]);
|
||||
|
||||
// Production as it is: the Préambule, published.
|
||||
let (_, before) = base_with_the_preambule("applied", cfg.clone()).await;
|
||||
ld_server::publish::pass(&before).await.expect("pass");
|
||||
assert!(show("fr.md").contains("## Préambule"));
|
||||
|
||||
// The new version starts: the untouched Préambule becomes the Raison d'être, in both groups.
|
||||
let (mut c, state) = restart(&before, cfg.clone()).await;
|
||||
let first = decisions(&state).await;
|
||||
assert_eq!(outcomes(&first), [("essai", "applied", None), ("monnaie-libre", "applied", None)]);
|
||||
c.login("Chiara", false).await;
|
||||
for group in ["monnaie-libre", "essai"] {
|
||||
let i1 = i1_of(&mut c, group).await;
|
||||
assert_eq!(i1["title"], "Raison d'être", "{group}");
|
||||
assert!(i1["body"].as_str().expect("body").starts_with(RAISON_D_ETRE));
|
||||
assert_eq!((i1["revision"].as_i64(), i1["seedMinimal"].as_bool()), (Some(2), Some(true)));
|
||||
for (lang, title) in [("en", "Purpose"), ("es", "Razón de ser")] {
|
||||
let tr = &i1["translations"][lang];
|
||||
assert_eq!(
|
||||
(tr["title"].as_str(), tr["stale"].as_bool(), tr["origin"].as_str()),
|
||||
(Some(title), Some(false), Some("seed")),
|
||||
"{group} {lang}"
|
||||
);
|
||||
}
|
||||
// The article's history says so, with what changed.
|
||||
let (_, d) = c.call("GET", &format!("/api/proposals/{}", i1["id"].as_str().expect("id")), None).await;
|
||||
let history = d["history"].as_array().expect("history");
|
||||
assert_eq!(history.len(), 2, "{d}");
|
||||
assert_eq!((history[1]["kind"].as_str(), history[1]["author"].as_str()), (Some("amendment"), None));
|
||||
let parts = history[1]["diff"].as_array().expect("diff");
|
||||
let said =
|
||||
|op: &str| parts.iter().filter(|p| p["op"] == op).filter_map(|p| p["text"].as_str()).collect::<String>();
|
||||
assert!(said("removed").contains("Préambule") && said("added").contains("Raison"), "{parts:?}");
|
||||
}
|
||||
// Republished where the document publishes — never from the test group.
|
||||
let queued: Vec<(String, String)> = sqlx::query_as(
|
||||
"SELECT d.group_slug, o.reason FROM outbox o JOIN documents d ON d.id = o.doc_id WHERE o.done_at IS NULL",
|
||||
)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.expect("outbox");
|
||||
assert_eq!(queued, [("monnaie-libre".to_owned(), "texte de départ remplacé : I1".to_owned())]);
|
||||
ld_server::publish::pass(&state).await.expect("pass");
|
||||
let fr = show("fr.md");
|
||||
assert!(fr.contains("## Raison d'être") && !fr.contains("Préambule"), "{fr}");
|
||||
assert!(show("en.md").contains("## Purpose") && show("es.md").contains("## Razón de ser"));
|
||||
assert!(git(&["log", "-1", "--format=%B", "main"]).contains("texte de départ remplacé : I1"));
|
||||
|
||||
// Once only: the next start changes nothing.
|
||||
let (mut c, again) = restart(&state, cfg.clone()).await;
|
||||
assert_eq!(decisions(&again).await, first);
|
||||
c.login("Chiara", false).await;
|
||||
let i1 = i1_of(&mut c, "monnaie-libre").await;
|
||||
let pid = i1["id"].as_str().expect("id").to_owned();
|
||||
let revisions: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM revisions WHERE proposal_id = ?")
|
||||
.bind(&pid)
|
||||
.fetch_one(&again.db)
|
||||
.await
|
||||
.expect("count");
|
||||
assert_eq!((revisions, i1["revision"].as_i64()), (2, Some(2)));
|
||||
|
||||
// From now on a typo is measured against the new text, and the Préambule is a change of meaning.
|
||||
let body = i1["body"].as_str().expect("body");
|
||||
let (s, v) = c
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{pid}"),
|
||||
Some(json!({ "title": i1["title"], "body": body.replacen("fiduciaire", "fiduciare", 1) })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["edit"].as_str()), (StatusCode::OK, Some("typo")), "{v}");
|
||||
let (s, v) = c
|
||||
.call(
|
||||
"PATCH",
|
||||
&format!("/api/proposals/{pid}"),
|
||||
Some(json!({ "title": PREAMBULE[0].1, "body": PREAMBULE[0].2 })),
|
||||
)
|
||||
.await;
|
||||
assert_eq!((s, v["error"].as_str()), (StatusCode::CONFLICT, Some("substantive_in_force")));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_seed_amendment_leaves_a_touched_preambule_alone() {
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Touch {
|
||||
Vote,
|
||||
Typo,
|
||||
Translation,
|
||||
Signal,
|
||||
Text,
|
||||
}
|
||||
for (touch, reason) in [
|
||||
(Touch::Vote, "votes"),
|
||||
(Touch::Typo, "corrected"),
|
||||
(Touch::Translation, "translation_edited"),
|
||||
(Touch::Signal, "signalled"),
|
||||
(Touch::Text, "text_differs"),
|
||||
] {
|
||||
let tag = format!("{touch:?}").to_lowercase();
|
||||
let (mut c, before) = base_with_the_preambule(&tag, Config::for_tests()).await;
|
||||
c.login("Yvv", false).await;
|
||||
// Touched in the test group only: Monnaie libre stays intact.
|
||||
let id = doc_id(&mut c, "essai").await;
|
||||
let (_, doc) = c.call("GET", &format!("/api/docs/{id}"), None).await;
|
||||
let i1 = unit(&doc, "I1")["inForce"].clone();
|
||||
let pid = i1["id"].as_str().expect("id").to_owned();
|
||||
let (s, v) = match touch {
|
||||
Touch::Vote => {
|
||||
c.call("POST", &format!("/api/proposals/{pid}/vote"), Some(json!({ "choice": "for" }))).await
|
||||
}
|
||||
Touch::Typo => {
|
||||
let body = i1["body"].as_str().expect("body").replace("latransmission", "la transmission");
|
||||
c.call("PATCH", &format!("/api/proposals/{pid}"), Some(json!({ "title": i1["title"], "body": body })))
|
||||
.await
|
||||
}
|
||||
Touch::Translation => {
|
||||
let en = &i1["translations"]["en"];
|
||||
let body =
|
||||
en["body"].as_str().expect("en").replace("This commitment act", "The present commitment act");
|
||||
c.call(
|
||||
"PUT",
|
||||
&format!("/api/proposals/{pid}/translations/en"),
|
||||
Some(json!({ "title": en["title"], "body": body })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Touch::Signal => {
|
||||
let k1 = unit(&doc, "K1")["proposals"][0]["id"].as_str().expect("k1").to_owned();
|
||||
c.call(
|
||||
"POST",
|
||||
&format!("/api/proposals/{k1}/signals"),
|
||||
Some(json!({ "targetId": pid, "reason": "Ces règles contredisent le préambule." })),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Touch::Text => {
|
||||
sqlx::query("UPDATE proposals SET body = body || ' Fin.' WHERE id = ?")
|
||||
.bind(&pid)
|
||||
.execute(&before.db)
|
||||
.await
|
||||
.expect("another text");
|
||||
(StatusCode::OK, Value::Null)
|
||||
}
|
||||
};
|
||||
assert_eq!(s, StatusCode::OK, "{touch:?}: {v}");
|
||||
|
||||
let (mut c, state) = restart(&before, Config::for_tests()).await;
|
||||
let first = decisions(&state).await;
|
||||
assert_eq!(
|
||||
outcomes(&first),
|
||||
[("essai", "refused", Some(reason)), ("monnaie-libre", "applied", None)],
|
||||
"{touch:?}"
|
||||
);
|
||||
c.login("Yvv", false).await;
|
||||
let i1 = i1_of(&mut c, "essai").await;
|
||||
assert_eq!(i1["title"], "Préambule", "{touch:?}");
|
||||
assert_eq!(i1["revision"], 1, "{touch:?}");
|
||||
let (_, d) = c.call("GET", &format!("/api/proposals/{pid}"), None).await;
|
||||
assert!(d["history"].as_array().expect("h").iter().all(|h| h["kind"] != "amendment"), "{touch:?}");
|
||||
// Decided once: a later start does not look again.
|
||||
let (_, again) = restart(&state, Config::for_tests()).await;
|
||||
assert_eq!(decisions(&again).await, first, "{touch:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,14 +26,20 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre.
|
||||
Vallée Drôme Crestois — pas l'Atelier du canal. Un groupe et son contenu ne s'ouvrent qu'à
|
||||
ses membres ; Monnaie libre s'ouvre à tout membre de la toile de confiance, les autres
|
||||
groupes sur accord dans trustinWallet.
|
||||
4. **Le texte en vigueur**, seul, en grand, typographie de lecture (Literata) : au départ le
|
||||
Préambule et les deux engagements fondamentaux (Unicité du compte, Réciprocité), marqués
|
||||
4. **Le texte en vigueur**, seul, en grand, typographie de lecture (Literata) : au départ la
|
||||
**Raison d'être** (texte d'Yvv du 2026-10-30, qui remplace le Préambule : « Derrière une clé
|
||||
il n'y a qu'un seul être humain vivant ; derrière un être humain, il n'y a qu'une seule clé
|
||||
certifiante. ») et les deux engagements fondamentaux (Unicité du compte, Réciprocité), marqués
|
||||
*seed a minima · 0 vote*. Au survol (ou toujours visibles sur mobile), trois gestes discrets :
|
||||
**Le vote** (en cours, inertie, formule, annexes) · **Les propositions** · **Proposer**. Ils
|
||||
ouvrent un panneau latéral (une feuille sur mobile) dont l'état vit dans l'URL.
|
||||
5. **En dessous**, les propositions en cours — les 30 autres articles du texte source, à zéro
|
||||
vote, inertie standard — rangées par section (introduction, fondamental, technique,
|
||||
conclusion, annexes, formule, inertie, ordonnancement), les variantes signalées.
|
||||
conclusion, annexes, formule, inertie, ordonnancement), **une ligne par article** : un
|
||||
article qui a plusieurs versions (variantes du seed, propositions rivales) montre leur
|
||||
nombre et la meilleure en tête (niveau d'eau, pour / contre) ; la ligne se déplie sur ses
|
||||
versions, chacune avec son étiquette, son niveau et ses marques, et chacune ouvre son vote.
|
||||
Les filtres gardent un article dès qu'une de ses versions passe.
|
||||
6. **Deux aperçus** : « si les bien cotées passent » et « si tout passe ».
|
||||
7. **Trois langues** : fr · en · es, pour l'interface comme pour le texte. Le français fait foi ;
|
||||
une traduction devenue ancienne (le français a changé depuis) est signalée.
|
||||
@@ -156,6 +162,17 @@ traductions marquées éditoriales) et `document.json`, commit et pousse sur
|
||||
sa partie publique s'affiche dans trustinWallet, à ajouter comme clé de déploiement (écriture)
|
||||
du dépôt. Le groupe d'essai ne publie jamais.
|
||||
|
||||
### Un texte de départ remplacé
|
||||
|
||||
Un document seedé n'est jamais réinstancié : quand le seed change un texte de départ (le
|
||||
Préambule devenu Raison d'être), un **amendement de seed** le remplace dans une base existante,
|
||||
une seule fois, et seulement sur un article que personne n'a touché (*seed a minima* d'origine,
|
||||
révision 1, aucun vote, aucune correction, aucune traduction modifiée, aucun signalement, texte
|
||||
identique à l'empreinte attendue). Chaque décision est consignée (`seed_amendments`, et dans les
|
||||
journaux quand l'article garde son texte) ; appliqué, l'amendement se lit dans l'historique de
|
||||
l'article (« texte de départ remplacé », avec le diff) et le texte est republié
|
||||
(`crates/ld-server/src/seed_amend.rs`).
|
||||
|
||||
### Accès — trustinWallet
|
||||
|
||||
Tout le monde connecté peut tout faire dans les groupes qu'il ouvre (lire, proposer, voter,
|
||||
|
||||
@@ -11,16 +11,18 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe
|
||||
|---|---|---|---|
|
||||
| C1 | L'accueil | ouvrir `/` sans être connectée | « Rendre possible la décision collective », la phrase d'outils, les quatre sections ; le menu à gauche avec les quatre outils, trois grisés ; « Se connecter avec sa clé Ğ1 » |
|
||||
| C2 | Les groupes | se connecter, ouvrir « Groupes » | Monnaie libre, Axiom, Librodrome, Groupe local Vallée Drôme Crestois ; **pas** l'Atelier du canal |
|
||||
| C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Préambule, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » ; en dessous, « Propositions en cours » (32 lignes) |
|
||||
| C4 | Gestes contextuels | survoler le Préambule → « Le vote » | un panneau : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie ; l'URL garde le panneau ; Échap le ferme |
|
||||
| C3 | Le texte en vigueur | Monnaie libre → Docs de référence → Acte d'engagement Certification | trois articles seulement (Raison d'être, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » ; en dessous, « Propositions en cours » (30 lignes, une par article) |
|
||||
| C4 | Gestes contextuels | survoler la Raison d'être → « Le vote » | un panneau : seuil 50 voix pour, il en manque 50 ; « La formule du seuil » se déplie ; l'URL garde le panneau ; Échap le ferme |
|
||||
| C5 | Entrée en vigueur | groupe d'essai : à deux, voter « Pour » sur « Les deux garanties réciproques » | au second vote, « … est entré en vigueur » ; le texte en vigueur compte quatre articles |
|
||||
| C6 | Coquille sans vote | groupe d'essai : Préambule → « Le vote » → « Corriger une coquille », remplacer « latransmission » par « la transmission » | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » |
|
||||
| C6 | Coquille sans vote | groupe d'essai : Unicité du compte → « Le vote » → « Corriger une coquille », remplacer les apostrophes droites par des apostrophes typographiques (« Je m’engage sur l’honneur ») | « Coquille corrigée, sans toucher aux votes. » ; un changement de sens est refusé : « Ce changement touche au sens : propose-le comme modification. » |
|
||||
| C7 | Une décision par proposition | groupe d'essai : « Proposer un nouvel article », deux paragraphes « Je m'engage à… » | l'assistant : « Ce texte semble porter 2 décisions » ; « Découper en 2 brouillons » ; « Tes brouillons » apparaît |
|
||||
| C8 | Trois langues | choisir EN puis ES pour le texte | « Certification Commitment Act », « Acta de compromiso de Certificación » ; l'interface suit |
|
||||
| C9 | Les aperçus | « Si tout passe », puis « Si les bien cotées passent » | 33 articles avec la mention d'aperçu, puis 3 |
|
||||
| C10 | trustinWallet | ouvrir `/acces` sans être administratrice | « La gestion des accès est réservée aux administrateurs de trustinWallet. » (côté admin : créer un lien d'invitation) |
|
||||
| C11 | Une personne, une voix | avec un lien d'invitation, ouvrir le vote d'un article de Monnaie libre | « Ici, seuls les membres de la toile de confiance Ğ1 votent… » à la place des boutons ; dans le groupe d'essai, les boutons sont là |
|
||||
| C12 | Lien d'équipe | administrateur : `/acces` → « Liens d'équipe », cocher le groupe d'essai, créer ; l'ouvrir dans deux navigateurs, sous deux noms | chacun arrive sur ses groupes sous son nom ; dans `/acces`, « 2 / 30 personnes » et deux identités « venue par » le lien ; un nom déjà pris est refusé, même autrement écrit (« luna » pour « Luna ») ; sur un navigateur où quelqu'un est connecté, « Ce n'est pas moi » le déconnecte ; « Révoquer » ferme le lien |
|
||||
| C13 | La Raison d'être | Monnaie libre → Acte d'engagement Certification, puis EN, puis ES | en tête du texte en vigueur, « Raison d'être » : la première phrase en italique, puis deux paragraphes ; « Purpose », « Razón de ser » |
|
||||
| C14 | Les versions d'un article | Monnaie libre → « Propositions en cours » → « Connaissance suffisante » (au clavier : Tab jusqu'à la ligne, Entrée) | une seule ligne, « 2 versions » ; dépliée : « variante 1.0.0 » et « variante 0.4.0-draft », chacune avec son niveau et ce qui la distingue ; un clic sur une version ouvre son vote ; un nouveau clic sur la ligne la replie |
|
||||
| M1 | Mobile | téléphone (ou fenêtre étroite) | menu en tiroir, les trois gestes visibles sans survol, le panneau s'ouvre en feuille |
|
||||
|
||||
## Au-delà de la liste
|
||||
@@ -47,3 +49,4 @@ page, la langue et la taille de l'écran. Les retours se traitent dans trustinWa
|
||||
| 2026-10-21 | automate | local, liens d'équipe et huitième relecture | C1–C12, M1 | 13 / 13 verts |
|
||||
| 2026-10-21 | automate | local, neuvième relecture (noms, places, quantités) | C1–C12, M1 | 13 / 13 verts |
|
||||
| 2026-10-21 | automate | local, mention « sur invitation » (dixième relecture) | C1–C12, M1 | 13 / 13 verts |
|
||||
| 2026-10-30 | automate | local, lot R (Raison d'être, versions d'un article) | C1–C14, M1 | 15 / 15 verts |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote, inertie standard (Yvv, 2026-10-21).",
|
||||
"_source": "maquette/frontend/app/data/seeds/duniter-g1.bundle.json (doc engagement-certification, 33 clauses + 2 variantes). En vigueur au départ : I1, E1, E2 (seed a minima, aucun vote). Tout le reste : propositions ouvertes à 0 vote, inertie standard (Yvv, 2026-10-21). I1 « Raison d'être » remplace le Préambule (texte d'Yvv, revue du 2026-10-30).",
|
||||
"slug": "acte-engagement-certification",
|
||||
"title": {
|
||||
"fr": "Acte d'engagement Certification",
|
||||
@@ -35,14 +35,14 @@
|
||||
"label": "1.0.0",
|
||||
"status": "in_force",
|
||||
"title": {
|
||||
"fr": "Préambule",
|
||||
"en": "Preamble",
|
||||
"es": "Preámbulo"
|
||||
"fr": "Raison d'être",
|
||||
"en": "Purpose",
|
||||
"es": "Razón de ser"
|
||||
},
|
||||
"body": {
|
||||
"fr": "Le présent acte d'engagement définit les obligations réciproques des membres de la toile de confiance de la monnaie libre G1. Cet acte est de fait l'unique relation contractuelle de notre toile fiduciaire. Toute Certification doit s'accompagner de latransmission de ce document, dont le certificateur doit s'assurer qu'il a été étudié, compris et accepté par le certifié.",
|
||||
"en": "This commitment act defines the reciprocal obligations of the members of the web of trust of the G1 libre currency. This act is, de facto, the sole contractual relationship of our fiduciary web. Every Certification must be accompanied by the transmission of this document, and the certifier must make sure that it has been studied, understood and accepted by the certified person.",
|
||||
"es": "La presente acta de compromiso define las obligaciones recíprocas de los miembros de la red de confianza de la moneda libre G1. Esta acta es, de hecho, la única relación contractual de nuestra red fiduciaria. Toda Certificación debe ir acompañada de la transmisión de este documento, y el certificador debe asegurarse de que la persona certificada lo ha estudiado, comprendido y aceptado."
|
||||
"fr": "*« Derrière une clé il n'y a qu'un seul être humain vivant ; derrière un être humain, il n'y a qu'une seule clé certifiante. »*\n\nC'est le seul et unique propos de la certification, qui donne à notre toile fiduciaire sa valeur. Nous pouvons nous fier à l'unicité et l'intégrité de tout geste signé par cette clé. C'est l'enjeu de la certification.\n\nCet acte d'engagement est par ailleurs le seul acte de nature contractuelle qui fonde l'usage de la première monnaie libre.",
|
||||
"en": "*“Behind a key there is only one living human being; behind a human being, there is only one certifying key.”*\n\nThis is the one and only purpose of certification, which gives our fiduciary web its value. We can rely on the uniqueness and integrity of every action signed with this key. That is what is at stake in certification.\n\nThis commitment act is, moreover, the only act of a contractual nature on which the use of the first libre currency is founded.",
|
||||
"es": "*«Detrás de una clave no hay más que un solo ser humano vivo; detrás de un ser humano, no hay más que una sola clave certificadora.»*\n\nEste es el propósito único y exclusivo de la certificación, que da su valor a nuestra red fiduciaria. Podemos fiarnos de la unicidad y la integridad de toda acción firmada con esta clave. Eso es lo que está en juego en la certificación.\n\nEsta acta de compromiso es, además, el único acto de naturaleza contractual en el que se funda el uso de la primera moneda libre."
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+54
-11
@@ -22,18 +22,19 @@ test('C2 — les groupes de la même clé ; pas l’Atelier du canal', async ({
|
||||
await expect(page.getByText('Atelier du canal')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('C3 — le texte en vigueur : préambule et deux engagements, seed a minima', async ({ page }) => {
|
||||
test('C3 — le texte en vigueur : raison d’être et deux engagements, seed a minima', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(DOC);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText("Acte d'engagement Certification");
|
||||
const units = page.locator('section.text article.unit');
|
||||
await expect(units).toHaveCount(3);
|
||||
await expect(units.nth(0).locator('h2')).toHaveText('Préambule');
|
||||
await expect(units.nth(0).locator('h2')).toHaveText("Raison d'être");
|
||||
await expect(units.nth(1).locator('h2')).toHaveText('Unicité du compte');
|
||||
await expect(units.nth(2).locator('h2')).toHaveText('Réciprocité');
|
||||
await expect(page.getByText('seed a minima · 0 vote')).toHaveCount(3);
|
||||
await expect(page.getByRole('heading', { name: 'Propositions en cours' })).toBeVisible();
|
||||
await expect(page.locator('ul.deep button.row')).toHaveCount(32);
|
||||
// One line per article: E3 and E7 hold their two variants inside.
|
||||
await expect(page.locator('ul.deep button.row')).toHaveCount(30);
|
||||
});
|
||||
|
||||
test('C4 — les actions sont contextuelles, le vote s’ouvre en panneau', async ({ page }) => {
|
||||
@@ -70,19 +71,20 @@ test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (gr
|
||||
test('C6 — une coquille se corrige sans vote', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(TEST_DOC);
|
||||
const preamble = page.locator('section.text article.unit').first();
|
||||
await preamble.hover();
|
||||
await preamble.getByRole('button', { name: 'Le vote' }).click();
|
||||
const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
|
||||
await unicity.hover();
|
||||
await unicity.getByRole('button', { name: 'Le vote' }).click();
|
||||
const panel = page.locator('aside.panel');
|
||||
await panel.getByText('Corriger une coquille').click();
|
||||
const body = panel.getByLabel('Texte', { exact: true });
|
||||
await body.fill((await body.inputValue()).replace('latransmission', 'la transmission'));
|
||||
// Typographic apostrophes: the letters and the signs stay, only their style changes.
|
||||
await body.fill((await body.inputValue()).replaceAll("'", '’'));
|
||||
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||||
await expect(page.getByText('Coquille corrigée, sans toucher aux votes.')).toBeVisible();
|
||||
await expect(preamble).toContainText('la transmission de ce document');
|
||||
await expect(unicity).toContainText('Je m’engage sur l’honneur');
|
||||
// A change of meaning is turned away.
|
||||
await panel.getByText('Corriger une coquille').click();
|
||||
await body.fill('Tout autre chose, qui change le sens du préambule.');
|
||||
await body.fill('Tout autre chose, qui change le sens de cet engagement.');
|
||||
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||||
await expect(panel.getByText('Ce changement touche au sens : propose-le comme modification.')).toBeVisible();
|
||||
});
|
||||
@@ -146,13 +148,13 @@ test('C11 — une personne, une voix : dans Monnaie libre, seuls les membres de
|
||||
await invited.goto(`/invitation#${link.split('#')[1]}`);
|
||||
await expect(invited).toHaveURL(/\/groupes$/);
|
||||
await invited.goto(DOC);
|
||||
await invited.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' }).first().click();
|
||||
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
|
||||
const panel = invited.locator('aside.panel');
|
||||
await expect(panel.getByText('Ici, seuls les membres de la toile de confiance Ğ1 votent', { exact: false })).toBeVisible();
|
||||
await expect(panel.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
|
||||
// In the test group, the same person votes.
|
||||
await invited.goto(TEST_DOC);
|
||||
await invited.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' }).first().click();
|
||||
await invited.locator('ul.deep button.row', { hasText: 'Vérification personnelle de la clé' }).click();
|
||||
await expect(invited.locator('aside.panel').getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -187,3 +189,44 @@ test('C12 — lien d’équipe : chaque personne qui l’ouvre a sa propre ident
|
||||
await expect(team.getByText('2 / 30 personnes', { exact: false })).toBeVisible();
|
||||
await expect(yvv.getByText('venue par « Équipe de test »')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('C13 — la Raison d’être ouvre le texte en vigueur, en trois langues', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(DOC);
|
||||
const first = page.locator('section.text article.unit').first();
|
||||
await expect(first.locator('h2')).toHaveText("Raison d'être");
|
||||
// The first sentence in italics, then two paragraphs.
|
||||
const paragraphs = first.locator('.prose p');
|
||||
await expect(paragraphs).toHaveCount(3);
|
||||
await expect(paragraphs.nth(0).locator('em')).toContainText("Derrière une clé il n'y a qu'un seul être humain vivant");
|
||||
await expect(paragraphs.nth(1)).toContainText("C'est le seul et unique propos de la certification");
|
||||
await expect(paragraphs.nth(2)).toContainText('la première monnaie libre');
|
||||
await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click();
|
||||
await expect(first.locator('h2')).toHaveText('Purpose');
|
||||
await page.getByRole('radiogroup', { name: 'Text language' }).getByRole('radio', { name: 'ES' }).click();
|
||||
await expect(first.locator('h2')).toHaveText('Razón de ser');
|
||||
});
|
||||
|
||||
test('C14 — un article, une ligne : ses versions se déplient', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(DOC);
|
||||
const e3 = page.locator('ul.deep button.row', { hasText: 'Connaissance suffisante' });
|
||||
await expect(e3).toHaveCount(1);
|
||||
await expect(e3).toContainText('2 versions');
|
||||
await expect(e3).toHaveAttribute('aria-expanded', 'false');
|
||||
// Unfolded with the keyboard: its two versions, each with its label.
|
||||
await e3.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(e3).toHaveAttribute('aria-expanded', 'true');
|
||||
const versions = page.locator(`#${await e3.getAttribute('aria-controls')}`).getByRole('button');
|
||||
await expect(versions).toHaveCount(2);
|
||||
await expect(versions.nth(0)).toContainText('variante 1.0.0');
|
||||
await expect(versions.nth(1)).toContainText('variante 0.4.0-draft');
|
||||
// A version opens its vote, as any proposal.
|
||||
await versions.nth(1).click();
|
||||
await expect(page.locator('aside.panel').getByText('Seuil : 50 voix pour')).toBeVisible();
|
||||
await expect(page).toHaveURL(/version=/);
|
||||
await e3.click();
|
||||
await expect(e3).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(versions).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -1,45 +1,126 @@
|
||||
<script lang="ts">
|
||||
// The open proposals, one line per article (Yvv, 2026-10-30). An article with a
|
||||
// single version opens its vote; one with several shows the best of them and
|
||||
// unfolds the others, each with its label, its water level and its marks — and
|
||||
// each opens its own vote.
|
||||
import { plain } from '$lib/markdown';
|
||||
import { relationOf, titleOf } from '$lib/labels';
|
||||
import { t, type Key } from '$lib/i18n/index.svelte';
|
||||
import { i18n, t, type Key } from '$lib/i18n/index.svelte';
|
||||
import type { Proposal, Unit } from '$lib/types';
|
||||
import { excerpts, type Article } from '$lib/versions';
|
||||
import Icon from './Icon.svelte';
|
||||
import WaterLevel from './WaterLevel.svelte';
|
||||
|
||||
let {
|
||||
items,
|
||||
articles,
|
||||
selected,
|
||||
onopen,
|
||||
}: { items: { unit: Unit; proposal: Proposal }[]; selected: string | null; onopen: (unit: Unit, p: Proposal) => void } = $props();
|
||||
}: { articles: Article[]; selected: string | null; onopen: (unit: Unit, p: Proposal) => void } = $props();
|
||||
|
||||
const SECTIONS = ['introduction', 'fondamental', 'technique', 'conclusion', 'annexe', 'formule', 'inertie', 'ordonnancement'];
|
||||
const sectionLabel = (s: string | null) => (s && SECTIONS.includes(s) ? t(`section.${s}` as Key) : null);
|
||||
const variants = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
|
||||
|
||||
// Unfolded by hand, or because the version open in the panel is inside.
|
||||
let unfolded = $state<Record<string, boolean>>({});
|
||||
const isOpen = (a: Article) => unfolded[a.unit.id] ?? a.versions.some((p) => p.id === selected);
|
||||
const toggle = (a: Article) => (unfolded[a.unit.id] = !isOpen(a));
|
||||
|
||||
/** The title of the article: its best text version, or what the best proposal is. */
|
||||
function headline(a: Article): string {
|
||||
const text = a.versions.find((p) => p.kind === 'text');
|
||||
const best = a.versions[0];
|
||||
return text ? text.title : best ? titleOf(best, a.unit) : a.unit.code;
|
||||
}
|
||||
|
||||
/** What tells a version from its siblings: its variant, its author, or its rank of arrival. */
|
||||
function labelOf(p: Proposal, a: Article): string {
|
||||
if (p.kind !== 'text') return titleOf(p, a.unit);
|
||||
if (p.label) return t('deep.variant', { label: p.label });
|
||||
if (p.author?.name) return t('panel.by', { name: p.author.name });
|
||||
const arrival = a.unit.proposals.filter((x) => x.status === 'open').findIndex((x) => x.id === p.id);
|
||||
return t('deep.version', { n: arrival + 1 });
|
||||
}
|
||||
|
||||
/** The excerpt of each text version, from where it parts ways with its siblings. */
|
||||
function toldApart(a: Article): Map<string, string> {
|
||||
const textual = a.versions.filter((p) => p.kind === 'text');
|
||||
const told = excerpts(textual.map((p) => plain(p.body)));
|
||||
return new Map(textual.map((p, k) => [p.id, told[k] ?? plain(p.body)]));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet marks(p: Proposal)}
|
||||
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
|
||||
{#if p.blocked}<span class="tag ochre">{t('deep.blocked')}</span>{/if}
|
||||
{#if p.myVoteStale}<span class="tag ochre">{t('deep.stale')}</span>{/if}
|
||||
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
|
||||
{/snippet}
|
||||
|
||||
<ul class="deep">
|
||||
{#each items as { unit, proposal: p }, i (p.id)}
|
||||
{#if sectionLabel(unit.section) && unit.section !== items[i - 1]?.unit.section}
|
||||
{#each articles as a, i (a.unit.id)}
|
||||
{@const unit = a.unit}
|
||||
{@const best = a.versions[0]}
|
||||
{#if sectionLabel(unit.section) && unit.section !== articles[i - 1]?.unit.section}
|
||||
<li class="section eyebrow">{sectionLabel(unit.section)}</li>
|
||||
{/if}
|
||||
<li>
|
||||
<button type="button" class="row" class:current={selected === p.id} onclick={() => onopen(unit, p)}>
|
||||
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
|
||||
<span class="main">
|
||||
<span class="title">{titleOf(p, unit)}</span>
|
||||
{#if p.kind === 'text'}<span class="excerpt">{plain(p.body)}</span>{/if}
|
||||
<span class="meta">
|
||||
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
|
||||
{#if relationOf(p, unit)}<span class="faint">{relationOf(p, unit)}</span>{/if}
|
||||
{#if variants(unit) > 1 && p.label}<span class="faint">{t('deep.variant', { label: p.label })}</span>{/if}
|
||||
{#if unit.inertia !== 'standard'}<span class="faint">{t(`inertia.${unit.inertia}`)}</span>{/if}
|
||||
{#if p.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
|
||||
{#if p.blocked}<span class="tag ochre">{t('deep.blocked')}</span>{/if}
|
||||
{#if p.myVoteStale}<span class="tag ochre">{t('deep.stale')}</span>{/if}
|
||||
{#if p.mine}<span class="tag">{t('deep.mine')}</span>{/if}
|
||||
{#if best && a.versions.length === 1}
|
||||
<li class="article">
|
||||
<button type="button" class="row" class:current={selected === best.id} onclick={() => onopen(unit, best)}>
|
||||
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
|
||||
<span class="main">
|
||||
<span class="title">{titleOf(best, unit)}</span>
|
||||
{#if best.kind === 'text'}<span class="excerpt">{plain(best.body)}</span>{/if}
|
||||
<span class="meta">
|
||||
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
|
||||
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
|
||||
{#if unit.inertia !== 'standard'}<span class="faint">{t(`inertia.${unit.inertia}`)}</span>{/if}
|
||||
{@render marks(best)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</button>
|
||||
</li>
|
||||
{:else if best}
|
||||
{@const open = isOpen(a)}
|
||||
{@const title = headline(a)}
|
||||
{@const told = toldApart(a)}
|
||||
<li class="article" class:open>
|
||||
<button type="button" class="row" aria-expanded={open} aria-controls="versions-{unit.id}" onclick={() => toggle(a)}>
|
||||
<WaterLevel tally={best.tally} evaluation={best.evaluation} blocked={best.blocked} />
|
||||
<span class="main">
|
||||
<span class="title">{title}</span>
|
||||
{#if best.kind === 'text'}<span class="excerpt">{plain(best.body)}</span>{/if}
|
||||
<span class="meta">
|
||||
<span class="count">{i18n.plural('deep.versions', a.versions.length)}</span>
|
||||
{#if a.leads}<span>{t('deep.lead', { label: labelOf(best, a) })}</span>{/if}
|
||||
<span>{t('tally.short', { for: best.tally.for, against: best.tally.against })}</span>
|
||||
{#if relationOf(best, unit)}<span class="faint">{relationOf(best, unit)}</span>{/if}
|
||||
{#if unit.inertia !== 'standard'}<span class="faint">{t(`inertia.${unit.inertia}`)}</span>{/if}
|
||||
{#if best.evaluation?.wellRated}<span class="tag water">{t('deep.wellrated')}</span>{/if}
|
||||
{#if a.versions.some((p) => p.myVoteStale)}<span class="tag ochre">{t('deep.stale')}</span>{/if}
|
||||
{#if a.versions.some((p) => p.mine)}<span class="tag">{t('deep.mine')}</span>{/if}
|
||||
</span>
|
||||
</span>
|
||||
<span class="chevron"><Icon name="chevron" size={18} /></span>
|
||||
</button>
|
||||
<ul class="versions" id="versions-{unit.id}" hidden={!open}>
|
||||
{#each a.versions as p (p.id)}
|
||||
<li>
|
||||
<button type="button" class="version" class:current={selected === p.id} onclick={() => onopen(unit, p)}>
|
||||
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} />
|
||||
<span class="main">
|
||||
<span class="label">{labelOf(p, a)}{#if p.kind === 'text' && p.title !== title}<span class="other"> · {p.title}</span>{/if}</span>
|
||||
{#if p.kind === 'text'}<span class="excerpt">{told.get(p.id)}</span>{/if}
|
||||
<span class="meta">
|
||||
<span>{t('tally.short', { for: p.tally.for, against: p.tally.against })}</span>
|
||||
{@render marks(p)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/if}
|
||||
{:else}
|
||||
<li class="faint none">{t('deep.none')}</li>
|
||||
{/each}
|
||||
@@ -53,21 +134,28 @@
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.article {
|
||||
margin: 0 -1rem;
|
||||
border-radius: var(--r-card);
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.article.open {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.85rem 1rem;
|
||||
margin: 0 -1rem;
|
||||
width: calc(100% + 2rem);
|
||||
border: 0;
|
||||
border-radius: var(--r-card);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease, transform 0.12s ease;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.row:hover,
|
||||
.article:not(.open) .row:hover,
|
||||
.row.current {
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow);
|
||||
@@ -98,6 +186,53 @@
|
||||
color: var(--ink-soft);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.count {
|
||||
color: var(--water);
|
||||
}
|
||||
.chevron {
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
color: var(--ink-faint);
|
||||
transition: transform 0.2s var(--ease);
|
||||
}
|
||||
.open .chevron {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.versions {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0.6rem 0.7rem 1.6rem;
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
.versions[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.version {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 0;
|
||||
border-radius: var(--r-input);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.version:hover,
|
||||
.version.current {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.label {
|
||||
font-weight: 750;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.other {
|
||||
font-weight: 600;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
.section {
|
||||
margin: 1.6rem 0 0.3rem;
|
||||
color: var(--ink-soft);
|
||||
@@ -108,4 +243,9 @@
|
||||
.none {
|
||||
padding: 0.8rem 0;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.versions {
|
||||
padding-left: 0.6rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
unit: { id: string; code: string; inertia: Inertia };
|
||||
inForce: { id: string; title: string; body: string } | null;
|
||||
diff: DiffPart[] | null;
|
||||
history: { revision: number; kind: 'initial' | 'typo' | 'substantive'; author: string | null; authorInvited: boolean; at: string; diff: DiffPart[] | null }[];
|
||||
history: { revision: number; kind: 'initial' | 'typo' | 'substantive' | 'amendment'; author: string | null; authorInvited: boolean; at: string; diff: DiffPart[] | null }[];
|
||||
signals: {
|
||||
id: string; reason: string; author: string; authorInvited: boolean; mine: boolean; at: string; liftedAt: string | null; liftedBy: string | null;
|
||||
other: { id: string; title: string; status: string; code: string | null } | null;
|
||||
|
||||
@@ -130,6 +130,10 @@ export const en: Messages = {
|
||||
'deep.filter.mine': 'Mine',
|
||||
'deep.none': 'No proposals in this filter.',
|
||||
'deep.variant': 'variant {label}',
|
||||
'deep.versions.one': '{n} version',
|
||||
'deep.versions.other': '{n} versions',
|
||||
'deep.version': 'version {n}',
|
||||
'deep.lead': 'leading: {label}',
|
||||
'section.introduction': 'Introduction',
|
||||
'section.fondamental': 'Fundamental commitments',
|
||||
'section.technique': 'Technical commitments',
|
||||
@@ -208,6 +212,7 @@ export const en: Messages = {
|
||||
'history.initial': 'initial text',
|
||||
'history.typo': 'typo fixed',
|
||||
'history.substantive': 'substantive change (votes to reconfirm)',
|
||||
'history.amendment': 'starting text replaced',
|
||||
'history.by': 'by {name}',
|
||||
|
||||
'typo.title': 'Fix a typo',
|
||||
|
||||
@@ -130,6 +130,10 @@ export const es: Messages = {
|
||||
'deep.filter.mine': 'Las mías',
|
||||
'deep.none': 'Ninguna propuesta en este filtro.',
|
||||
'deep.variant': 'variante {label}',
|
||||
'deep.versions.one': '{n} versión',
|
||||
'deep.versions.other': '{n} versiones',
|
||||
'deep.version': 'versión {n}',
|
||||
'deep.lead': 'en cabeza: {label}',
|
||||
'section.introduction': 'Introducción',
|
||||
'section.fondamental': 'Compromisos fundamentales',
|
||||
'section.technique': 'Compromisos técnicos',
|
||||
@@ -208,6 +212,7 @@ export const es: Messages = {
|
||||
'history.initial': 'texto inicial',
|
||||
'history.typo': 'errata corregida',
|
||||
'history.substantive': 'cambio de fondo (votos por reconfirmar)',
|
||||
'history.amendment': 'texto de partida sustituido',
|
||||
'history.by': 'por {name}',
|
||||
|
||||
'typo.title': 'Corregir una errata',
|
||||
|
||||
@@ -133,6 +133,10 @@ export const fr = {
|
||||
'deep.filter.mine': 'Les miennes',
|
||||
'deep.none': 'Aucune proposition dans ce filtre.',
|
||||
'deep.variant': 'variante {label}',
|
||||
'deep.versions.one': '{n} version',
|
||||
'deep.versions.other': '{n} versions',
|
||||
'deep.version': 'version {n}',
|
||||
'deep.lead': 'en tête : {label}',
|
||||
'section.introduction': 'Introduction',
|
||||
'section.fondamental': 'Engagements fondamentaux',
|
||||
'section.technique': 'Engagements techniques',
|
||||
@@ -211,6 +215,7 @@ export const fr = {
|
||||
'history.initial': 'texte initial',
|
||||
'history.typo': 'coquille corrigée',
|
||||
'history.substantive': 'changement de fond (votes à reconfirmer)',
|
||||
'history.amendment': 'texte de départ remplacé',
|
||||
'history.by': 'par {name}',
|
||||
|
||||
'typo.title': 'Corriger une coquille',
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// The versions of an article, for the list of open proposals (Yvv, 2026-10-30:
|
||||
// « Quand un article a plusieurs versions, faut pas ajouter un article dans la
|
||||
// liste »): one line per article, its best version first, the others inside.
|
||||
|
||||
import type { Proposal, Unit } from './types';
|
||||
|
||||
export type Filter = 'all' | 'wellrated' | 'mine';
|
||||
|
||||
export interface Article {
|
||||
unit: Unit;
|
||||
/** The open proposals of the article, best first. */
|
||||
versions: Proposal[];
|
||||
/** The best version is strictly ahead of the next one (not a tie). */
|
||||
leads: boolean;
|
||||
}
|
||||
|
||||
/** How far a version has risen toward its threshold, from 0 to 1: its water level. */
|
||||
export function level(p: Proposal): number {
|
||||
const required = p.evaluation?.required ?? 0;
|
||||
return required > 0 ? Math.min(1, p.tally.for / required) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Negative when `a` comes before `b`: the highest water, then more for, then fewer
|
||||
* against. A blocked version comes after the others: it cannot enter into force.
|
||||
*/
|
||||
export function compare(a: Proposal, b: Proposal): number {
|
||||
return (
|
||||
Number(a.blocked) - Number(b.blocked) ||
|
||||
level(b) - level(a) ||
|
||||
b.tally.for - a.tally.for ||
|
||||
a.tally.against - b.tally.against
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain excerpts of sibling versions, each starting a few words before the versions
|
||||
* part ways — two variants often share a long opening, and a one-line excerpt would
|
||||
* show only that.
|
||||
*/
|
||||
export function excerpts(texts: string[]): string[] {
|
||||
const LEAD = 3;
|
||||
const words = texts.map((t) => t.split(/\s+/).filter(Boolean));
|
||||
const [first] = words;
|
||||
if (!first || words.length < 2) return texts;
|
||||
let common = 0;
|
||||
while (common < first.length && words.every((w) => w[common] === first[common])) common++;
|
||||
const start = common - LEAD;
|
||||
// A short common start reads well as it is.
|
||||
if (start < 4) return texts;
|
||||
return words.map((w) => `… ${w.slice(start).join(' ')}`);
|
||||
}
|
||||
|
||||
export function passes(p: Proposal, filter: Filter): boolean {
|
||||
if (filter === 'wellrated') return !!p.evaluation?.wellRated;
|
||||
if (filter === 'mine') return p.mine;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* One article per unit, in the order of the document; an article is kept when at
|
||||
* least one of its versions passes the filter, and then shows all of them.
|
||||
*/
|
||||
export function articles(items: { unit: Unit; proposal: Proposal }[], filter: Filter = 'all'): Article[] {
|
||||
const byUnit = new Map<string, { unit: Unit; versions: Proposal[] }>();
|
||||
for (const { unit, proposal } of items) {
|
||||
const entry = byUnit.get(unit.id) ?? { unit, versions: [] };
|
||||
entry.versions.push(proposal);
|
||||
byUnit.set(unit.id, entry);
|
||||
}
|
||||
return [...byUnit.values()]
|
||||
.filter(({ versions }) => versions.some((p) => passes(p, filter)))
|
||||
.map(({ unit, versions }) => {
|
||||
// Array.prototype.sort is stable: equal versions keep the order of the document.
|
||||
const ranked = [...versions].sort(compare);
|
||||
const [best, next] = ranked;
|
||||
return { unit, versions: ranked, leads: !!best && !!next && compare(best, next) < 0 };
|
||||
});
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
import { DocState, shown, type PanelState, type Tab, type View } from '$lib/doc.svelte';
|
||||
import { i18n, LANGS, t } from '$lib/i18n/index.svelte';
|
||||
import type { Lang, Proposal, Unit } from '$lib/types';
|
||||
import { articles, type Filter } from '$lib/versions';
|
||||
|
||||
const doc = new DocState();
|
||||
const group = $derived(page.params.group ?? '');
|
||||
@@ -42,17 +43,14 @@
|
||||
void goto(`${page.url.pathname}${search ? `?${search}` : ''}`, { replaceState: !!panel && !!next, noScroll: true, keepFocus: true });
|
||||
}
|
||||
|
||||
let filter = $state<'all' | 'wellrated' | 'mine'>('all');
|
||||
let filter = $state<Filter>('all');
|
||||
const view = $derived(doc.view);
|
||||
const source = $derived<Lang>(view?.doc.sourceLang ?? 'fr');
|
||||
const lang = $derived<Lang>(doc.original ? source : i18n.lang);
|
||||
const title = $derived(view ? (view.doc.title[lang] ?? view.doc.title.fr ?? '') : '');
|
||||
const summary = $derived(view ? (view.doc.summary[lang] ?? view.doc.summary.fr ?? '') : '');
|
||||
const deep = $derived(
|
||||
doc.open.filter(({ proposal: p }) =>
|
||||
filter === 'wellrated' ? p.evaluation?.wellRated : filter === 'mine' ? p.mine : true,
|
||||
),
|
||||
);
|
||||
// One line per article; an article stays when one of its versions passes the filter.
|
||||
const deep = $derived(articles(doc.open, filter));
|
||||
const rivalsOf = (u: Unit) => u.proposals.filter((p) => p.status === 'open' && p.kind === 'text').length;
|
||||
const VIEWS: View[] = ['inforce', 'wellrated', 'all'];
|
||||
|
||||
@@ -153,7 +151,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<DeepList items={deep} selected={panel?.proposalId ?? null} onopen={onProposal} />
|
||||
<DeepList articles={deep} selected={panel?.proposalId ?? null} onopen={onProposal} />
|
||||
<button class="btn new" type="button" onclick={() => openPanel({ unitId: null, proposalId: null, tab: 'propose' })}><Icon name="plus" size={16} />{t('deep.new')}</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Proposal, Unit } from '../../src/lib/types';
|
||||
import { articles, excerpts, level } from '../../src/lib/versions';
|
||||
|
||||
function prop(id: string, unitId: string, over: Partial<Proposal> = {}): Proposal {
|
||||
return {
|
||||
id, unitId, kind: 'text', inertiaTarget: null, title: unitId, body: `texte ${id}`, status: 'open', origin: 'seed',
|
||||
label: null, author: null, mine: false, revision: 1, createdAt: '', updatedAt: '', submittedAt: null, enteredAt: null,
|
||||
confirmedAt: null, seedMinimal: false, tally: { for: 0, against: 0 },
|
||||
evaluation: { cast: 0, required: 50, reached: false, missing: 50, requiredRatio: 1, wellRated: false },
|
||||
votedAtInertia: 'standard', blocked: false, signals: 0, myVote: null, myVoteStale: false, translations: {},
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function unit(id: string, proposals: Proposal[]): Unit {
|
||||
return { id, code: id, section: null, position: 0, inertia: 'standard', inForce: null, proposals };
|
||||
}
|
||||
|
||||
/** The reader's list: every open proposal, unit by unit, in document order. */
|
||||
function items(...units: Unit[]) {
|
||||
return units.flatMap((u) => u.proposals.map((proposal) => ({ unit: u, proposal })));
|
||||
}
|
||||
|
||||
const tally = (f: number, against = 0) => ({ tally: { for: f, against } });
|
||||
|
||||
function at<T>(xs: readonly T[], i: number): T {
|
||||
const x = xs[i];
|
||||
if (x === undefined) throw new Error(`nothing at ${i}`);
|
||||
return x;
|
||||
}
|
||||
|
||||
describe('the versions of an article', () => {
|
||||
it('one line per article, in document order, its versions inside', () => {
|
||||
const e2 = unit('E2', [prop('a', 'E2')]);
|
||||
const e3 = unit('E3', [prop('b1', 'E3', { label: '1.0.0' }), prop('b2', 'E3', { label: '0.4.0-draft' })]);
|
||||
const e4 = unit('E4', [prop('c', 'E4')]);
|
||||
const list = articles(items(e2, e3, e4));
|
||||
expect(list.map((a) => a.unit.id)).toEqual(['E2', 'E3', 'E4']);
|
||||
expect(at(list, 1).versions.map((p) => p.id)).toEqual(['b1', 'b2']);
|
||||
});
|
||||
|
||||
it('the best version first: the highest water, then more for, then fewer against', () => {
|
||||
const u = unit('E3', [
|
||||
prop('low', 'E3', tally(3)),
|
||||
prop('high', 'E3', tally(20, 4)),
|
||||
prop('same', 'E3', tally(20, 1)),
|
||||
]);
|
||||
const a = at(articles(items(u)), 0);
|
||||
expect(a.versions.map((p) => p.id)).toEqual(['same', 'high', 'low']);
|
||||
expect(a.leads).toBe(true);
|
||||
});
|
||||
|
||||
it('equal versions keep their order, and none is said to lead', () => {
|
||||
const u = unit('E3', [prop('first', 'E3'), prop('second', 'E3')]);
|
||||
const a = at(articles(items(u)), 0);
|
||||
expect(a.versions.map((p) => p.id)).toEqual(['first', 'second']);
|
||||
expect(a.leads).toBe(false);
|
||||
});
|
||||
|
||||
it('a blocked version never comes first: it cannot enter into force', () => {
|
||||
const u = unit('E3', [prop('blocked', 'E3', { ...tally(40), blocked: true }), prop('free', 'E3', tally(2))]);
|
||||
expect(at(at(articles(items(u)), 0).versions, 0).id).toBe('free');
|
||||
});
|
||||
|
||||
it('a filter keeps an article when one of its versions passes, with all its versions', () => {
|
||||
const wellRated = { cast: 9, required: 50, reached: false, missing: 41, requiredRatio: 1, wellRated: true };
|
||||
const e3 = unit('E3', [prop('theirs', 'E3'), prop('mine', 'E3', { mine: true, evaluation: wellRated })]);
|
||||
const e4 = unit('E4', [prop('other', 'E4')]);
|
||||
for (const filter of ['mine', 'wellrated'] as const) {
|
||||
const list = articles(items(e3, e4), filter);
|
||||
expect(list.map((a) => a.unit.id), filter).toEqual(['E3']);
|
||||
expect(at(list, 0).versions).toHaveLength(2);
|
||||
}
|
||||
expect(articles(items(e3, e4), 'all')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('the water level is the share of the threshold reached', () => {
|
||||
expect(level(prop('x', 'E1', tally(25)))).toBe(0.5);
|
||||
expect(level(prop('x', 'E1', tally(80)))).toBe(1);
|
||||
expect(level(prop('x', 'E1', { evaluation: null }))).toBe(0);
|
||||
});
|
||||
|
||||
it('sibling versions are told apart where they part ways', () => {
|
||||
const a = 'Je me suis assuré de connaître suffisamment la personne qui gère cette clé publique. Connaître suffisamment ne signifie pas avoir vu.';
|
||||
const b = 'Je me suis assuré de connaître suffisamment la personne qui gère cette clé publique : je sais la recontacter.';
|
||||
expect(excerpts([a, b])).toEqual([
|
||||
'… gère cette clé publique. Connaître suffisamment ne signifie pas avoir vu.',
|
||||
'… gère cette clé publique : je sais la recontacter.',
|
||||
]);
|
||||
// A short common start reads well as it is.
|
||||
expect(excerpts(['Je certifie une personne.', 'Je certifie deux personnes.'])).toEqual([
|
||||
'Je certifie une personne.',
|
||||
'Je certifie deux personnes.',
|
||||
]);
|
||||
expect(excerpts(['Seule.'])).toEqual(['Seule.']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user