v3 : les versions d'un article tiennent une seule ligne (L8)

- « Propositions en cours » : une ligne par article. Un article à plusieurs versions montre
  leur nombre et la meilleure en tête (niveau d'eau, pour / contre, « en tête » quand elle
  devance vraiment) ; la ligne se déplie (bouton à aria-expanded, clavier) sur ses versions,
  chacune avec son étiquette (variante, auteur), son niveau, ses marques, et un extrait qui
  part de là où les versions divergent ; un clic ouvre son vote comme avant.
- Règle pure dans versions.ts (vitest) : ordre du document, meilleure d'abord (niveau d'eau,
  puis pour, puis contre ; une version bloquée jamais en tête), un article reste dans un
  filtre dès qu'une de ses versions passe.
- e2e : C3 compte 30 lignes, C14 déplie « Connaissance suffisante » au clavier ; C11 passe
  par un article à une seule version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-10-30 10:42:27 +01:00
co-authored by Claude Opus 5.5
parent a5b92faa96
commit edc88111e0
10 changed files with 397 additions and 40 deletions
@@ -35,7 +35,11 @@ les applications clientes (Cesium, Ğecko…) peuvent suivre.
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.
@@ -11,7 +11,7 @@ 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 (Raison d'être, Unicité du compte, Réciprocité), chacun « seed a minima · 0 vote » ; en dessous, « Propositions en cours » (32 lignes) |
| 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 : 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. » |
@@ -22,6 +22,7 @@ Où tester : les contrôles qui écrivent (C5, C6, C7) se font dans le **groupe
| 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
+28 -3
View File
@@ -33,7 +33,8 @@ test('C3 — le texte en vigueur : raison d’être et deux engagements, seed a
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 }) => {
@@ -147,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();
});
@@ -205,3 +206,27 @@ test('C13 — la Raison d’être ouvre le texte en vigueur, en trois langues',
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);
});
+168 -28
View File
@@ -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>
+4
View File
@@ -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',
+4
View File
@@ -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',
+4
View File
@@ -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',
+79
View File
@@ -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>
+98
View File
@@ -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.']);
});
});