forked from yvv/decision
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:
+28
-3
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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