diff --git a/web/e2e/helpers.ts b/web/e2e/helpers.ts index d793b5c..908967f 100644 --- a/web/e2e/helpers.ts +++ b/web/e2e/helpers.ts @@ -80,6 +80,17 @@ export function deepRow(page: Page, title: string) { return page.locator('ul.deep button.row').filter({ has: page.locator('span.title').getByText(title, { exact: true }) }); } +/** + * « Propositions en cours » shows its sections folded (lot U): « Tout déplier » opens them + * all, so that a line can be clicked. + */ +export async function unfold(page: Page) { + const all = page.getByRole('button', { name: 'Tout déplier' }); + await expect(all).toBeVisible(); + await all.click(); + await expect(page.getByRole('button', { name: 'Tout replier' })).toBeVisible(); +} + /** A screenshot for the visual check, when `LD_SHOTS` names a directory (never in the loop). */ export async function shot(page: Page, name: string) { const dir = process.env.LD_SHOTS; diff --git a/web/e2e/ordre-quorum.spec.ts b/web/e2e/ordre-quorum.spec.ts new file mode 100644 index 0000000..f220928 --- /dev/null +++ b/web/e2e/ordre-quorum.spec.ts @@ -0,0 +1,237 @@ +import { devices, expect, test, type Page } from '@playwright/test'; +import { deepRow, DOC, person, propose, seedsInForce, shot, signIn, TEST_DOC, testDoc, unfold } from './helpers'; + +// Lot U — the reader (Yvv, 2026-11-02: « la répétition de "ordonnancement" est nulle. Il +// faudrait que ce soit en fin de section, qui ajoute des "poignées" aux modules de la section. +// Et permette de voter. » ; « combien il faut de oui pour passer et combien de non pour +// maintenir l'opposition »). Run after modules.spec (C25 reads the order of the fundamental +// commitments as the seed gives it): here it is reordered, voted, and may enter into force. +test.beforeAll(async ({ browser }) => seedsInForce(browser)); + +const EF = 'Engagements fondamentaux'; +const E1 = 'Unicité du compte'; +const E2 = 'Réciprocité'; +const ORDER = `Ordre des articles · ${EF}`; +const CSRF = { 'x-requested-with': 'libredecision' }; + +/** Whether a version of a module of the test group is in force now. */ +async function moduleInForce(page: Page, proposalId: string): Promise { + const g = await (await page.request.get('/api/groups/essai')).json(); + const d = await (await page.request.get(`/api/docs/${g.docs[0].id}`)).json(); + return [d.modules.title, ...d.modules.orders].some((u) => u?.inForce?.id === proposalId); +} + +test('C35 — réordonner une section : les poignées sur ses articles, au glisser, au clavier, sans glisser, puis le vote', async ({ page, browser }) => { + await signIn(page, 'Chiara'); + await page.goto(TEST_DOC); + // The order closes its section in one line: its name, its weight, its state — never the list again. + const line = page.getByRole('group', { name: ORDER }); + await expect(line).toBeVisible(); + await expect(line).toContainText('Ordre de la section'); + await expect(line).toContainText('inertie 2 / 5 · basse'); + await expect(line.getByText(E1)).toHaveCount(0); + // Nowhere a block that repeats the articles of a section, and no order among the proposals. + await expect(page.locator('section.text').getByText('Ordre des articles ·', { exact: false })).toHaveCount(0); + await expect(page.locator('ul.deep').getByText('Ordre des articles', { exact: false })).toHaveCount(0); + await line.scrollIntoViewIfNeeded(); + await shot(page, 'c35-1-ligne-ordre'); + + // « Réordonner »: the articles of the section fold to their titles, a handle before each, in place. + await line.getByRole('button', { name: 'Réordonner' }).click(); + await expect(page.locator('aside.panel')).toHaveCount(0); + const list = page.locator('section.text').getByRole('list', { name: 'Nouvel ordre des articles' }); + const names = list.locator('button.item .name'); + await expect(names).toHaveText([EF, E1, E2]); + // The articles in force of the section are in the list now, not twice; the one only proposed says so. + await expect(page.locator('section.text article.unit', { hasText: E1 })).toHaveCount(0); + await expect(list.locator('li', { hasText: EF })).toContainText('proposé'); + await expect(list.locator('.grip')).toHaveCount(3); + const submit = page.getByRole('button', { name: 'Proposer cet ordre' }); + await expect(submit).toBeDisabled(); + await shot(page, 'c35-2-poignees'); + + // By dragging its handle: Réciprocité onto the place of Unicité du compte. + await list.locator('li', { hasText: E2 }).locator('.grip').dragTo(list.locator('li', { hasText: E1 })); + await expect(names).toHaveText([EF, E2, E1]); + await expect(page.getByText(`« ${E2} » en place 2 sur 3.`)).toBeAttached(); + await expect(submit).toBeEnabled(); + // With the keyboard: the article keeps the focus, the arrows move it. + const items = list.locator('button.item'); + await items.nth(1).focus(); + await page.keyboard.press('ArrowUp'); + await expect(names).toHaveText([E2, EF, E1]); + await expect(items.nth(0)).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await expect(names).toHaveText([EF, E2, E1]); + // Without dragging: its arrows, or an article then its new place. + await list.getByRole('button', { name: `Descendre « ${E2} »` }).click(); + await expect(names).toHaveText([EF, E1, E2]); + await expect(submit).toBeDisabled(); + await items.nth(2).click(); + await expect(items.nth(2)).toHaveAttribute('aria-pressed', 'true'); + await items.nth(1).click(); + await expect(names).toHaveText([EF, E2, E1]); + // « Annuler » gives the section back as it read. + await page.getByRole('button', { name: 'Annuler' }).click(); + await expect(list).toHaveCount(0); + await expect(page.locator('section.text article.unit', { hasText: E1 })).toHaveCount(1); + await line.getByRole('button', { name: 'Réordonner' }).click(); + await list.getByRole('button', { name: `Monter « ${E2} »` }).click(); + await expect(names).toHaveText([EF, E2, E1]); + + // Then the vote: the order goes to the vote, and its vote unfolds at once, in the same place. + await submit.click(); + await expect(page.getByText('Ordre proposé au vote.')).toBeVisible(); + const fold = line.locator('section.fold'); + await expect(fold).toHaveAttribute('aria-label', `Le vote de « ${ORDER} »`); + await expect(fold.getByRole('list', { name: 'Ordre proposé' }).locator('li')).toHaveText([EF, E2, E1]); + await expect(line).toContainText('1 ordre proposé'); + const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, ''); + await fold.getByRole('button', { name: 'Pour', exact: true }).click(); + await expect(page.getByText('Vote enregistré.')).toBeVisible(); + await shot(page, 'c35-3-ordre-vote'); + // Into force, by her voice or a second one: the section reads in its new order. + if (!(await moduleInForce(page, pid))) { + const yvv = await person(browser, 'Yvv'); + const r = await yvv.request.post(`/api/proposals/${pid}/vote`, { data: { choice: 'for' }, headers: CSRF }); + expect(r.ok()).toBe(true); + await yvv.context().close(); + } + expect(await moduleInForce(page, pid)).toBe(true); + await page.goto(TEST_DOC); + const titles = await page.locator('section.text article.unit h2').allTextContents(); + expect(titles.indexOf(E2)).toBeGreaterThan(-1); + expect(titles.indexOf(E2)).toBeLessThan(titles.indexOf(E1)); + await expect(line).toContainText(/adopté le/); +}); + +test('C35 — sur un téléphone : réordonner une section où rien n’est en vigueur, la liste tient, les cibles se touchent', async ({ browser }) => { + const ctx = await browser.newContext({ ...devices['Pixel 7'], locale: 'fr-FR' }); + const page = await ctx.newPage(); + await signIn(page, 'Chiara'); + await page.goto(DOC); + // Nothing of the technical commitments is in the text: their order closes their section in the list. + await unfold(page); + const line = page.getByRole('group', { name: 'Ordre des articles · Engagements techniques' }); + await line.getByRole('button', { name: 'Réordonner' }).click(); + const list = page.getByRole('list', { name: 'Nouvel ordre des articles' }); + const items = list.locator('button.item'); + await expect(items).toHaveCount(11); + await items.nth(3).click(); + await items.nth(0).click(); + await expect(page.getByRole('button', { name: 'Proposer cet ordre' })).toBeEnabled(); + const holds = await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1); + expect(holds).toBe(true); + for (const target of [items.nth(0), list.locator('.grip').first(), list.getByRole('button', { name: /^Monter/ }).nth(1)]) { + const box = await target.boundingBox(); + expect(box?.height ?? 0).toBeGreaterThanOrEqual(44); + } + await list.scrollIntoViewIfNeeded(); + await shot(page, 'c35-4-poignees-mobile'); + await page.getByRole('button', { name: 'Annuler' }).click(); + await ctx.close(); +}); + +test('C36 — combien de oui pour passer, combien de non pour bloquer : sous l’article déplié, et en bref dans la liste', async ({ page }) => { + await signIn(page, 'Chiara'); + // Before any vote (Monnaie libre): the Raison d'être, a seed proposal at level 2, quorum 12. + await page.goto(DOC); + const i1 = page.locator('section.text article.unit', { hasText: "Raison d'être" }).first(); + await i1.hover(); + await i1.getByRole('button', { name: 'Le vote' }).click(); + const fold = page.getByRole('region', { name: "Le vote de « Raison d'être »" }); + const need = fold.getByText('9 oui sur 12 votants pour passer', { exact: true }); + await expect(need).toBeVisible(); + // The quorum in a tooltip, never as a title. + await expect(need).toHaveAttribute('title', 'Quorum : 12 votants. En dessous, les votants qui manquent comptent comme « non ».'); + // In the list, short: at level 3, quorum 50. + await unfold(page); + await expect(deepRow(page, 'Vérification personnelle de la clé')).toContainText('39 oui sur 50'); + + // With votes (the evaluation as the server gives it, ADR 0013): 9 « pour », 4 « contre » at + // level 2, quorum 12, do not pass — 11 « pour » would; 4 « contre » hold the 9 in check. + const title = 'Vérification personnelle de la clé'; + const doc = await testDoc(page); + const target = doc.units.find((u) => u.proposals.some((p) => p.title === title)); + if (!target) throw new Error(`no « ${title} »`); + let shape: 'held' | 'quorum' = 'held'; + await page.route(/\/api\/docs\/[^/?]+$/, async (route) => { + const res = await route.fetch(); + const body = await res.json(); + for (const u of body.units) { + for (const p of u.proposals) { + if (p.title !== title || p.status !== 'open') continue; + p.tally = shape === 'held' ? { for: 9, against: 4 } : { for: 3, against: 0 }; + p.evaluation = { + ...p.evaluation, + cast: shape === 'held' ? 13 : 3, + quorum: 12, + required: shape === 'held' ? 10 : 9, + reached: false, + missing: shape === 'held' ? 2 : 6, + yesNeeded: shape === 'held' ? 11 : 9, + noNeeded: shape === 'held' ? 4 : 0, + }; + } + } + await route.fulfill({ response: res, json: body }); + }); + await page.goto(TEST_DOC); + await unfold(page); + const row = deepRow(page, title); + await expect(row).toContainText('passe à 11 oui · bloquée à 4 non'); + await row.click(); + const theirs = page.getByRole('region', { name: `Le vote de « ${title} »` }); + await expect(theirs.getByText('11 oui pour passer · 4 non pour bloquer', { exact: true })).toBeVisible(); + await expect(theirs.getByText('Il manque 2 voix pour.')).toBeVisible(); + await shot(page, 'c36-1-oui-non'); + // Below the quorum, the voters missing hold it alone: no « non » is needed. + shape = 'quorum'; + await page.reload(); + await expect(theirs.getByText('9 oui pour passer · le quorum la bloque', { exact: true })).toBeVisible(); + await expect(deepRow(page, title)).toContainText('passe à 9 oui'); + await expect(deepRow(page, title)).not.toContainText('bloquée'); +}); + +test('C37 — le quorum d’un article : son auteur le fixe avant le premier vote, revient au défaut, et lit les refus', async ({ page, browser }) => { + await signIn(page, 'Chiara'); + const title = 'Quorum choisi par son autrice'; + const id = await propose(page, title, 'Je fixe le quorum de cet article avant que quiconque vote.'); + await page.goto(`${TEST_DOC}?vote=${id}`); + const fold = page.getByRole('region', { name: `Le vote de « ${title} »` }); + const quorum = fold.getByRole('region', { name: /^Quorum/ }); + await expect(quorum).toBeVisible(); + // The default of its level, recalled; the test group asks 1. + await expect(quorum.getByText('Par défaut, celui de son cran : 1 votant.')).toBeVisible(); + await expect(quorum.getByRole('button', { name: /^Revenir au défaut/ })).toHaveCount(0); + const field = quorum.getByRole('spinbutton', { name: "Quorum de l'article" }); + await expect(field).toHaveValue('1'); + await field.fill('3'); + await quorum.getByRole('button', { name: 'Fixer ce quorum' }).click(); + await expect(page.getByText('Quorum fixé : 3 votants.')).toBeVisible(); + await expect(fold.getByText(/oui sur 3 votants pour passer$/)).toBeVisible(); + await shot(page, 'c37-1-quorum'); + // Back to the default. + await quorum.getByRole('button', { name: 'Revenir au défaut (1)' }).click(); + await expect(page.getByText('Quorum revenu au défaut : 1 votant.')).toBeVisible(); + await expect(fold.getByText(/oui sur 1 votant pour passer$/)).toBeVisible(); + // Out of bounds: refused (400), said in her words. + await field.fill('100000'); + await quorum.getByRole('button', { name: 'Fixer ce quorum' }).click(); + await expect(quorum.getByRole('alert')).toHaveText(/^Ce quorum sort des bornes/); + // Someone else never sees it. + const yvv = await person(browser, 'Yvv'); + await yvv.goto(`${TEST_DOC}?vote=${id}`); + await expect(yvv.getByRole('region', { name: `Le vote de « ${title} »` })).toBeVisible(); + await expect(yvv.getByRole('region', { name: /^Quorum/ })).toHaveCount(0); + // A vote arrives while her page is open: refused (409), said in her words; then it is gone. + const r = await yvv.request.post(`/api/proposals/${id}/vote`, { data: { choice: 'against' }, headers: CSRF }); + expect(r.ok()).toBe(true); + await field.fill('2'); + await quorum.getByRole('button', { name: 'Fixer ce quorum' }).click(); + await expect(quorum.getByRole('alert')).toHaveText("Quelqu'un a déjà voté sur cet article : son quorum ne change plus."); + await page.reload(); + await expect(fold).toBeVisible(); + await expect(fold.getByRole('region', { name: /^Quorum/ })).toHaveCount(0); + await yvv.context().close(); +}); diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts index 1d7e944..a851068 100644 --- a/web/src/lib/i18n/en.ts +++ b/web/src/lib/i18n/en.ts @@ -133,6 +133,9 @@ export const en: Messages = { 'doc.basket': 'Vote in bulk', 'doc.basket.count': 'Vote in bulk · {n}', 'doc.empty': 'No article is in force. The proposals below move up as soon as they reach their threshold.', + 'doc.empty.seeds': 'No article is in force yet. Start with the seed proposals: they come into force by the vote, like any proposal.', + 'doc.others.one': '{n} other version open', + 'doc.others.other': '{n} other versions open', 'doc.published': 'Published to the text repository', 'doc.publish.pending': 'Publication pending', @@ -158,6 +161,11 @@ export const en: Messages = { 'deep.versions.other': '{n} versions', 'deep.version': 'version {n}', 'deep.lead': 'leading: {label}', + 'deep.section.none': 'No section', + 'deep.section.count.one': '{n} article proposed', + 'deep.section.count.other': '{n} articles proposed', + 'deep.unfold': 'Unfold all', + 'deep.fold': 'Fold all', 'section.introduction': 'Introduction', 'section.fondamental': 'Fundamental commitments', 'section.technique': 'Technical commitments', @@ -181,19 +189,28 @@ export const en: Messages = { 'module.order.rule': 'A new article goes at the end of its section. If it arrives while an order is under vote, it is added at its end, and the votes stay.', 'order.propose': 'Propose another order', 'order.edit.label': 'New order of the articles', - 'order.edit.help': 'Click an article, then its new place, or use its arrows. With the keyboard: Enter to choose it, up and down arrows to move it.', + 'order.edit.help': 'Take an article by its handle and drop it in its place. Without dragging: click an article, then its new place, or use its arrows. With the keyboard: up and down arrows.', 'order.edit.up': 'Move “{title}” up', 'order.edit.down': 'Move “{title}” down', 'order.edit.chosen': '“{title}” chosen: click its new place.', 'order.edit.moved': '“{title}” now in place {n} of {total}.', 'order.edit.submit': 'Propose this order', 'order.edit.unchanged': 'This is the order in force: move at least one article.', + 'order.edit.exists': 'This order is already proposed: vote for it rather than proposing it twice.', + 'order.edit.vote': 'Vote on this order', + 'order.edit.proposed': 'proposed', + 'order.line': 'Order of the section', + 'order.reorder': 'Reorder', + 'order.proposed.one': '{n} order proposed', + 'order.proposed.other': '{n} orders proposed', 'order.edit.note': 'An order is voted at inertia {level}, like an article: unfolded, in the basket, on the Ğ1 chain in Monnaie libre.', 'order.submitted': 'Order submitted to vote.', 'title.propose': 'Propose another title', 'title.propose.label': 'New title', 'title.propose.note': 'The title of the document is voted at inertia {level}. Its translations stay editorial: they will read “to review”.', 'title.submitted': 'Title submitted to vote.', + 'title.proposed.one': '{n} title proposed', + 'title.proposed.other': '{n} titles proposed', 'tally.short': '{for} for · {against} against', 'tally.required': 'Threshold: {n} votes in favor', @@ -230,6 +247,18 @@ export const en: Messages = { 'vote.against': 'Against', 'vote.retract': 'Withdraw my vote', 'vote.saved': 'Vote saved.', + 'vote.saved.simulated': 'Vote simulated.', + 'vote.simulated.mark': 'simulated vote', + 'vote.others': 'Other versions', + 'vote.need': '{yes} yes to pass · {no} no to block', + 'vote.need.quorum': '{yes} yes to pass · the quorum blocks it', + 'vote.need.first.one': '{yes} yes out of {quorum} voter to pass', + 'vote.need.first.other': '{yes} yes out of {quorum} voters to pass', + 'vote.need.short': 'passes at {yes} yes · blocked at {no} no', + 'vote.need.short.quorum': 'passes at {yes} yes', + 'vote.need.short.first': '{yes} yes out of {quorum}', + 'vote.need.title.one': 'Quorum: {n} voter. Below it, the missing voters count as “no”.', + 'vote.need.title.other': 'Quorum: {n} voters. Below it, the missing voters count as “no”.', 'vote.retracted': 'Vote withdrawn.', 'vote.stale': 'The text has changed in substance since you voted: your vote no longer counts until you reconfirm it.', 'vote.reconfirm': 'Reconfirm: {choice}', @@ -256,7 +285,7 @@ export const en: Messages = { 'vote.mine.saved': 'saved', 'vote.mine.pending': 'Waiting for the Ğ1 chain: {choice}.', 'vote.mine.simulated': 'simulated', - 'vote.simulated.note': 'The Ğ1 chain is not open yet: your vote is simulated. It counts as a vote, marked “simulated”, until it is erased when the chain opens.', + 'vote.simulated.note': 'The Ğ1 chain is not open yet: your vote is simulated. It counts as a vote, marked “simulated”, until a human decision erases the simulated votes.', 'vote.tally.simulated.one': 'of which {n} simulated', 'vote.tally.simulated.other': 'of which {n} simulated', 'vote.mine.refused': 'Your last vote was not counted: {reason}', @@ -270,6 +299,21 @@ export const en: Messages = { 'vote.chain.off': 'Voting through the Ğ1 chain is not open yet: the account that receives the votes is still to be created. You can read, propose and raise an objection.', 'vote.members': 'Only members of this group vote here.', 'vote.details': 'Typos, translations, objections, history', + 'quorum.heading.one': 'Quorum · {n} voter', + 'quorum.heading.other': 'Quorum · {n} voters', + 'quorum.default.one': 'By default, that of its level: {n} voter.', + 'quorum.default.other': 'By default, that of its level: {n} voters.', + 'quorum.explain': 'You set the quorum of this article as long as nobody has voted. Below it, the missing voters count as “no”.', + 'quorum.field': 'Quorum of the article', + 'quorum.set': 'Set this quorum', + 'quorum.reset': 'Back to the default ({n})', + 'quorum.done.one': 'Quorum set: {n} voter.', + 'quorum.done.other': 'Quorum set: {n} voters.', + 'quorum.reset.done.one': 'Quorum back to the default: {n} voter.', + 'quorum.reset.done.other': 'Quorum back to the default: {n} voters.', + 'quorum.error.quorum': 'This quorum is out of bounds: at least 2 voters (1 in the test group), at most {w}, the electorate.', + 'quorum.error.voted': 'Someone has already voted on this article: its quorum no longer changes.', + 'quorum.error.forbidden': 'Only the person who wrote the article sets its quorum, before the first vote.', 'ballot.reason.not_member': 'the wallet that signed is not that of a member of the Ğ1 web of trust.', 'ballot.reason.no_identity': 'the wallet that signed is linked to no Ğ1 identity.', 'ballot.reason.transfer': 'the transfer to the vote account is missing, or its amount is too low.', @@ -295,6 +339,8 @@ export const en: Messages = { 'basket.empty': 'Nothing in the basket yet.', 'basket.sent.one': '{n} vote saved.', 'basket.sent.other': '{n} votes saved.', + 'basket.sent.simulated.one': '{n} vote simulated.', + 'basket.sent.simulated.other': '{n} votes simulated.', 'basket.section': 'Vote on this section', 'basket.mine': 'your vote: {choice}', 'basket.held': 'an objection holds it back: against or withdraw, no “for”', @@ -341,7 +387,7 @@ export const en: Messages = { 'ballots.lines.other': '{n} votes', 'ballots.status.pending': 'Waiting for the Ğ1 chain', 'ballots.status.confirmed': 'Counted for the Ğ1 identity “{name}”', - 'ballots.status.simulated': 'Simulated: counted for “{name}” until the Ğ1 chain opens', + 'ballots.status.simulated': 'Simulated: counted for “{name}” until the simulated votes are erased', 'ballots.status.refused': 'Not counted: {reason}', 'ballots.status.expired': 'Not seen on the Ğ1 chain in time: it does not count', 'ballots.block': 'block {n}', diff --git a/web/src/lib/i18n/es.ts b/web/src/lib/i18n/es.ts index 11e1102..444d024 100644 --- a/web/src/lib/i18n/es.ts +++ b/web/src/lib/i18n/es.ts @@ -133,6 +133,9 @@ export const es: Messages = { 'doc.basket': 'Votar en bloque', 'doc.basket.count': 'Votar en bloque · {n}', 'doc.empty': 'Ningún artículo está en vigor. Las propuestas de abajo suben en cuanto alcanzan su umbral.', + 'doc.empty.seeds': 'Ningún artículo está aún en vigor. Empieza por las propuestas semilla: entran en vigor por el voto, como cualquier propuesta.', + 'doc.others.one': '{n} otra versión en curso', + 'doc.others.other': '{n} otras versiones en curso', 'doc.published': 'Publicado en el repositorio de textos', 'doc.publish.pending': 'Publicación pendiente', @@ -158,6 +161,11 @@ export const es: Messages = { 'deep.versions.other': '{n} versiones', 'deep.version': 'versión {n}', 'deep.lead': 'en cabeza: {label}', + 'deep.section.none': 'Sin sección', + 'deep.section.count.one': '{n} artículo propuesto', + 'deep.section.count.other': '{n} artículos propuestos', + 'deep.unfold': 'Desplegar todo', + 'deep.fold': 'Plegar todo', 'section.introduction': 'Introducción', 'section.fondamental': 'Compromisos fundamentales', 'section.technique': 'Compromisos técnicos', @@ -181,19 +189,28 @@ export const es: Messages = { 'module.order.rule': 'Un artículo nuevo se coloca al final de su sección. Si llega durante la votación de un orden, se añade al final, y los votos se mantienen.', 'order.propose': 'Proponer otro orden', 'order.edit.label': 'Nuevo orden de los artículos', - 'order.edit.help': 'Haz clic en un artículo y luego en su nuevo lugar, o usa sus flechas. Con el teclado: Intro para elegirlo, flechas arriba y abajo para moverlo.', + 'order.edit.help': 'Toma un artículo por su asa y suéltalo en su lugar. Sin arrastrar: haz clic en un artículo y luego en su nuevo lugar, o usa sus flechas. Con el teclado: flechas arriba y abajo.', 'order.edit.up': 'Subir «{title}»', 'order.edit.down': 'Bajar «{title}»', 'order.edit.chosen': '«{title}» elegido: haz clic en su nuevo lugar.', 'order.edit.moved': '«{title}» en el lugar {n} de {total}.', 'order.edit.submit': 'Proponer este orden', 'order.edit.unchanged': 'Es el orden en vigor: mueve al menos un artículo.', + 'order.edit.exists': 'Este orden ya está propuesto: vótalo en lugar de proponerlo dos veces.', + 'order.edit.vote': 'Votar este orden', + 'order.edit.proposed': 'propuesto', + 'order.line': 'Orden de la sección', + 'order.reorder': 'Reordenar', + 'order.proposed.one': '{n} orden propuesto', + 'order.proposed.other': '{n} órdenes propuestos', 'order.edit.note': 'Un orden se vota con la inercia {level}, como un artículo: desplegado, en la cesta, por la cadena Ğ1 en Monnaie libre.', 'order.submitted': 'Orden sometido a votación.', 'title.propose': 'Proponer otro título', 'title.propose.label': 'Nuevo título', 'title.propose.note': 'El título del documento se vota con la inercia {level}. Sus traducciones siguen siendo editoriales: pasarán a «por revisar».', 'title.submitted': 'Título sometido a votación.', + 'title.proposed.one': '{n} título propuesto', + 'title.proposed.other': '{n} títulos propuestos', 'tally.short': '{for} a favor · {against} en contra', 'tally.required': 'Umbral: {n} votos a favor', @@ -230,6 +247,18 @@ export const es: Messages = { 'vote.against': 'En contra', 'vote.retract': 'Retirar mi voto', 'vote.saved': 'Voto registrado.', + 'vote.saved.simulated': 'Voto simulado.', + 'vote.simulated.mark': 'voto simulado', + 'vote.others': 'Otras versiones', + 'vote.need': '{yes} sí para aprobar · {no} no para bloquear', + 'vote.need.quorum': '{yes} sí para aprobar · el quórum la bloquea', + 'vote.need.first.one': '{yes} sí de {quorum} votante para aprobar', + 'vote.need.first.other': '{yes} sí de {quorum} votantes para aprobar', + 'vote.need.short': 'aprueba con {yes} sí · bloqueada con {no} no', + 'vote.need.short.quorum': 'aprueba con {yes} sí', + 'vote.need.short.first': '{yes} sí de {quorum}', + 'vote.need.title.one': 'Quórum: {n} votante. Por debajo, los votantes que faltan cuentan como «no».', + 'vote.need.title.other': 'Quórum: {n} votantes. Por debajo, los votantes que faltan cuentan como «no».', 'vote.retracted': 'Voto retirado.', 'vote.stale': 'El texto ha cambiado de fondo desde tu voto: tu voto deja de contar hasta que lo reconfirmes.', 'vote.reconfirm': 'Reconfirmar: {choice}', @@ -256,7 +285,7 @@ export const es: Messages = { 'vote.mine.saved': 'registrado', 'vote.mine.pending': 'Esperando a la cadena Ğ1: {choice}.', 'vote.mine.simulated': 'simulado', - 'vote.simulated.note': 'La cadena Ğ1 aún no está abierta: tu voto es simulado. Cuenta como un voto, marcado «simulado», hasta que se borre al abrirse la cadena.', + 'vote.simulated.note': 'La cadena Ğ1 aún no está abierta: tu voto es simulado. Cuenta como un voto, marcado «simulado», mientras una decisión humana no borre los votos simulados.', 'vote.tally.simulated.one': 'de ellos {n} simulado', 'vote.tally.simulated.other': 'de ellos {n} simulados', 'vote.mine.refused': 'Tu último voto no se contó: {reason}', @@ -270,6 +299,21 @@ export const es: Messages = { 'vote.chain.off': 'La votación por la cadena Ğ1 aún no está abierta: falta crear la cuenta que recibe los votos. Puedes leer, proponer y hacer una objeción.', 'vote.members': 'Aquí solo votan los miembros de este grupo.', 'vote.details': 'Erratas, traducciones, objeciones, historial', + 'quorum.heading.one': 'Quórum · {n} votante', + 'quorum.heading.other': 'Quórum · {n} votantes', + 'quorum.default.one': 'Por defecto, el de su nivel: {n} votante.', + 'quorum.default.other': 'Por defecto, el de su nivel: {n} votantes.', + 'quorum.explain': 'Fijas el quórum de este artículo mientras nadie haya votado. Por debajo, los votantes que faltan cuentan como «no».', + 'quorum.field': 'Quórum del artículo', + 'quorum.set': 'Fijar este quórum', + 'quorum.reset': 'Volver al valor por defecto ({n})', + 'quorum.done.one': 'Quórum fijado: {n} votante.', + 'quorum.done.other': 'Quórum fijado: {n} votantes.', + 'quorum.reset.done.one': 'Quórum de vuelta al valor por defecto: {n} votante.', + 'quorum.reset.done.other': 'Quórum de vuelta al valor por defecto: {n} votantes.', + 'quorum.error.quorum': 'Este quórum se sale de los límites: al menos 2 votantes (1 en el grupo de prueba), como mucho {w}, el electorado.', + 'quorum.error.voted': 'Alguien ya ha votado sobre este artículo: su quórum ya no cambia.', + 'quorum.error.forbidden': 'Solo la persona que escribió el artículo fija su quórum, antes del primer voto.', 'ballot.reason.not_member': 'el monedero que firmó no es el de un miembro de la red de confianza Ğ1.', 'ballot.reason.no_identity': 'el monedero que firmó no está vinculado a ninguna identidad Ğ1.', 'ballot.reason.transfer': 'falta la transferencia a la cuenta de los votos, o su importe es demasiado bajo.', @@ -295,6 +339,8 @@ export const es: Messages = { 'basket.empty': 'Nada en la cesta por ahora.', 'basket.sent.one': '{n} voto registrado.', 'basket.sent.other': '{n} votos registrados.', + 'basket.sent.simulated.one': '{n} voto simulado.', + 'basket.sent.simulated.other': '{n} votos simulados.', 'basket.section': 'Votar esta sección', 'basket.mine': 'tu voto: {choice}', 'basket.held': 'una objeción la retiene: en contra o retirar, sin «a favor»', @@ -341,7 +387,7 @@ export const es: Messages = { 'ballots.lines.other': '{n} votos', 'ballots.status.pending': 'Esperando a la cadena Ğ1', 'ballots.status.confirmed': 'Contado para la identidad Ğ1 «{name}»', - 'ballots.status.simulated': 'Simulado: contado para «{name}» hasta que se abra la cadena Ğ1', + 'ballots.status.simulated': 'Simulado: contado para «{name}» mientras no se borren los votos simulados', 'ballots.status.refused': 'No contado: {reason}', 'ballots.status.expired': 'No visto a tiempo en la cadena Ğ1: no cuenta', 'ballots.block': 'bloque {n}', diff --git a/web/src/lib/i18n/fr.ts b/web/src/lib/i18n/fr.ts index bfa4b0b..ea8100a 100644 --- a/web/src/lib/i18n/fr.ts +++ b/web/src/lib/i18n/fr.ts @@ -137,6 +137,9 @@ export const fr = { 'doc.basket': 'Voter en lot', 'doc.basket.count': 'Voter en lot · {n}', 'doc.empty': "Aucun article n'est en vigueur. Les propositions ci-dessous remontent dès qu'elles atteignent leur seuil.", + 'doc.empty.seeds': "Aucun article n'est encore en vigueur. Commence par les Propositions seed : elles y entrent par le vote, comme toute proposition.", + 'doc.others.one': '{n} autre version en cours', + 'doc.others.other': '{n} autres versions en cours', 'doc.published': 'Publié sur le dépôt des textes', 'doc.publish.pending': 'Publication en attente', @@ -162,6 +165,11 @@ export const fr = { 'deep.versions.other': '{n} versions', 'deep.version': 'version {n}', 'deep.lead': 'en tête : {label}', + 'deep.section.none': 'Sans section', + 'deep.section.count.one': '{n} article proposé', + 'deep.section.count.other': '{n} articles proposés', + 'deep.unfold': 'Tout déplier', + 'deep.fold': 'Tout replier', 'section.introduction': 'Introduction', 'section.fondamental': 'Engagements fondamentaux', 'section.technique': 'Engagements techniques', @@ -185,19 +193,28 @@ export const fr = { 'module.order.rule': "Un nouvel article se place à la fin de sa section. S'il arrive pendant le vote d'un ordre, il s'y ajoute à la fin, et les votes restent.", 'order.propose': 'Proposer un autre ordre', 'order.edit.label': 'Nouvel ordre des articles', - 'order.edit.help': 'Clique un article, puis sa nouvelle place, ou utilise ses flèches. Au clavier : Entrée pour le choisir, flèches haut et bas pour le déplacer.', + 'order.edit.help': 'Prends un article par sa poignée et dépose-le à sa place. Sans glisser : clique un article, puis sa nouvelle place, ou utilise ses flèches. Au clavier : flèches haut et bas.', 'order.edit.up': 'Monter « {title} »', 'order.edit.down': 'Descendre « {title} »', 'order.edit.chosen': '« {title} » choisi : clique sa nouvelle place.', 'order.edit.moved': '« {title} » en place {n} sur {total}.', 'order.edit.submit': 'Proposer cet ordre', 'order.edit.unchanged': "C'est l'ordre en vigueur : déplace au moins un article.", + 'order.edit.exists': 'Cet ordre est déjà proposé : vote-le plutôt que de le proposer deux fois.', + 'order.edit.vote': 'Voter cet ordre', + 'order.edit.proposed': 'proposé', + 'order.line': 'Ordre de la section', + 'order.reorder': 'Réordonner', + 'order.proposed.one': '{n} ordre proposé', + 'order.proposed.other': '{n} ordres proposés', 'order.edit.note': "Un ordre se vote à l'inertie {level}, comme un article : déplié, dans le panier, par la chaîne Ğ1 dans Monnaie libre.", 'order.submitted': 'Ordre proposé au vote.', 'title.propose': 'Proposer un autre titre', 'title.propose.label': 'Nouveau titre', 'title.propose.note': "Le titre du document se vote à l'inertie {level}. Ses traductions restent éditoriales : elles passeront « à revoir ».", 'title.submitted': 'Titre proposé au vote.', + 'title.proposed.one': '{n} titre proposé', + 'title.proposed.other': '{n} titres proposés', 'tally.short': '{for} pour · {against} contre', 'tally.required': 'Seuil : {n} voix pour', @@ -234,6 +251,18 @@ export const fr = { 'vote.against': 'Contre', 'vote.retract': 'Retirer mon vote', 'vote.saved': 'Vote enregistré.', + 'vote.saved.simulated': 'Vote simulé.', + 'vote.simulated.mark': 'vote simulé', + 'vote.others': 'Autres versions', + 'vote.need': '{yes} oui pour passer · {no} non pour bloquer', + 'vote.need.quorum': '{yes} oui pour passer · le quorum la bloque', + 'vote.need.first.one': '{yes} oui sur {quorum} votant pour passer', + 'vote.need.first.other': '{yes} oui sur {quorum} votants pour passer', + 'vote.need.short': 'passe à {yes} oui · bloquée à {no} non', + 'vote.need.short.quorum': 'passe à {yes} oui', + 'vote.need.short.first': '{yes} oui sur {quorum}', + 'vote.need.title.one': 'Quorum : {n} votant. En dessous, les votants qui manquent comptent comme « non ».', + 'vote.need.title.other': 'Quorum : {n} votants. En dessous, les votants qui manquent comptent comme « non ».', 'vote.retracted': 'Vote retiré.', 'vote.stale': "Le texte a changé de fond depuis ton vote : ton vote ne compte plus tant que tu ne l'as pas reconfirmé.", 'vote.reconfirm': 'Reconfirmer : {choice}', @@ -260,7 +289,7 @@ export const fr = { 'vote.mine.saved': 'enregistré', 'vote.mine.pending': 'En attente de la chaîne Ğ1 : {choice}.', 'vote.mine.simulated': 'simulé', - 'vote.simulated.note': "La chaîne Ğ1 n'est pas encore ouverte : ton vote est simulé. Il compte comme un vote, marqué « simulé », jusqu'à son effacement à l'ouverture de la chaîne.", + 'vote.simulated.note': "La chaîne Ğ1 n'est pas encore ouverte : ton vote est simulé. Il compte comme un vote, marqué « simulé », tant qu'une décision humaine n'efface pas les votes simulés.", 'vote.tally.simulated.one': 'dont {n} simulé', 'vote.tally.simulated.other': 'dont {n} simulés', 'vote.mine.refused': "Ton dernier vote n'a pas été compté : {reason}", @@ -274,6 +303,21 @@ export const fr = { 'vote.chain.off': "Le vote par la chaîne Ğ1 n'est pas encore ouvert : le compte qui reçoit les votes reste à créer. Tu peux lire, proposer et faire une objection.", 'vote.members': 'Seuls les membres de ce groupe votent ici.', 'vote.details': 'Coquilles, traductions, objections, historique', + 'quorum.heading.one': 'Quorum · {n} votant', + 'quorum.heading.other': 'Quorum · {n} votants', + 'quorum.default.one': 'Par défaut, celui de son cran : {n} votant.', + 'quorum.default.other': 'Par défaut, celui de son cran : {n} votants.', + 'quorum.explain': "Tu fixes le quorum de cet article tant que personne n'a voté. En dessous, les votants qui manquent comptent comme « non ».", + 'quorum.field': "Quorum de l'article", + 'quorum.set': 'Fixer ce quorum', + 'quorum.reset': 'Revenir au défaut ({n})', + 'quorum.done.one': 'Quorum fixé : {n} votant.', + 'quorum.done.other': 'Quorum fixé : {n} votants.', + 'quorum.reset.done.one': 'Quorum revenu au défaut : {n} votant.', + 'quorum.reset.done.other': 'Quorum revenu au défaut : {n} votants.', + 'quorum.error.quorum': "Ce quorum sort des bornes : au moins 2 votants (1 dans le groupe d'essai), au plus {w}, l'électorat.", + 'quorum.error.voted': "Quelqu'un a déjà voté sur cet article : son quorum ne change plus.", + 'quorum.error.forbidden': "Seule la personne qui a écrit l'article fixe son quorum, avant le premier vote.", 'ballot.reason.not_member': "le portefeuille qui a signé n'est pas celui d'un membre de la toile de confiance Ğ1.", 'ballot.reason.no_identity': "le portefeuille qui a signé n'est rattaché à aucune identité Ğ1.", 'ballot.reason.transfer': 'le virement vers le compte des votes manque, ou son montant est trop faible.', @@ -299,6 +343,8 @@ export const fr = { 'basket.empty': "Rien dans le panier pour l'instant.", 'basket.sent.one': '{n} vote enregistré.', 'basket.sent.other': '{n} votes enregistrés.', + 'basket.sent.simulated.one': '{n} vote simulé.', + 'basket.sent.simulated.other': '{n} votes simulés.', 'basket.section': 'Voter cette section', 'basket.mine': 'ton vote : {choice}', 'basket.held': 'une objection la retient : contre ou retrait, pas de « pour »', @@ -345,7 +391,7 @@ export const fr = { 'ballots.lines.other': '{n} votes', 'ballots.status.pending': 'En attente de la chaîne Ğ1', 'ballots.status.confirmed': "Compté pour l'identité Ğ1 « {name} »", - 'ballots.status.simulated': "Simulé : compté pour « {name} » jusqu'à l'ouverture de la chaîne Ğ1", + 'ballots.status.simulated': 'Simulé : compté pour « {name} » tant que les votes simulés ne sont pas effacés', 'ballots.status.refused': 'Pas compté : {reason}', 'ballots.status.expired': 'Pas vu sur la chaîne Ğ1 à temps : il ne compte pas', 'ballots.block': 'bloc {n}', diff --git a/web/src/lib/labels.ts b/web/src/lib/labels.ts index 5b51091..b01a133 100644 --- a/web/src/lib/labels.ts +++ b/web/src/lib/labels.ts @@ -53,3 +53,25 @@ export function orderNote(p: Proposal): string[] { return notes; } + +/** + * How many « oui » pass a version, how many « non » block it (Yvv, 2026-11-02: « combien il + * faut de oui pour passer et combien de non pour maintenir l'opposition »), from what the + * server says (ADR 0013): `yesNeeded`, the total of *for* that passes with the *against* cast + * now; `noNeeded`, the total of *against* that holds the *for* cast now in check — 0 when + * the quorum holds them alone. Before any vote: « 9 oui sur 12 votants ». The quorum goes + * to the tooltip (`title`), never to the title of a line. + */ +export function needOf(p: Proposal): { long: string; short: string; title: string } | null { + const e = p.evaluation; + if (!e || e.yesNeeded === undefined || e.noNeeded === undefined || e.quorum === undefined) return null; + const yes = e.yesNeeded; + const no = e.noNeeded; + const quorum = e.quorum; + const title = i18n.plural('vote.need.title', quorum); + if (e.cast === 0) { + return { long: i18n.plural('vote.need.first', quorum, { yes, quorum }), short: t('vote.need.short.first', { yes, quorum }), title }; + } + if (no === 0) return { long: t('vote.need.quorum', { yes }), short: t('vote.need.short.quorum', { yes }), title }; + return { long: t('vote.need', { yes, no }), short: t('vote.need.short', { yes, no }), title }; +} diff --git a/web/tests/unit/need.test.ts b/web/tests/unit/need.test.ts new file mode 100644 index 0000000..6b79acc --- /dev/null +++ b/web/tests/unit/need.test.ts @@ -0,0 +1,38 @@ +// « Combien il faut de oui pour passer, combien de non pour bloquer » (Yvv, 2026-11-02), +// told from what the server says (ADR 0013): yesNeeded, noNeeded, the quorum. +import { describe, expect, it } from 'vitest'; +import { needOf } from '../../src/lib/labels'; +import type { Evaluation, Proposal } from '../../src/lib/types'; + +function version(tally: { for: number; against: number }, e: Partial | null): Proposal { + const evaluation: Evaluation | null = e + ? { cast: tally.for + tally.against, required: 0, reached: false, missing: 0, requiredRatio: 0, wellRated: false, ...e } + : null; + return { tally, evaluation } as unknown as Proposal; +} + +describe('the yes to pass, the no to block', () => { + it('before any vote: the yes asked of the quorum', () => { + const need = needOf(version({ for: 0, against: 0 }, { quorum: 12, yesNeeded: 9, noNeeded: 0 })); + expect(need?.long).toBe('9 oui sur 12 votants pour passer'); + expect(need?.short).toBe('9 oui sur 12'); + expect(need?.title).toBe('Quorum : 12 votants. En dessous, les votants qui manquent comptent comme « non ».'); + }); + it('one voter of quorum: singular', () => { + expect(needOf(version({ for: 0, against: 0 }, { quorum: 1, yesNeeded: 1, noNeeded: 0 }))?.long).toBe('1 oui sur 1 votant pour passer'); + }); + it('9 for and 4 against at level 2, quorum 12: 11 yes pass, 4 no block', () => { + const need = needOf(version({ for: 9, against: 4 }, { quorum: 12, yesNeeded: 11, noNeeded: 4 })); + expect(need?.long).toBe('11 oui pour passer · 4 non pour bloquer'); + expect(need?.short).toBe('passe à 11 oui · bloquée à 4 non'); + }); + it('below the quorum, the voters missing hold it alone: no « non » to count', () => { + const need = needOf(version({ for: 3, against: 0 }, { quorum: 12, yesNeeded: 9, noNeeded: 0 })); + expect(need?.long).toBe('9 oui pour passer · le quorum la bloque'); + expect(need?.short).toBe('passe à 9 oui'); + }); + it('nothing said without the numbers of the server', () => { + expect(needOf(version({ for: 0, against: 0 }, null))).toBeNull(); + expect(needOf(version({ for: 0, against: 0 }, { quorum: 12 }))).toBeNull(); + }); +});