forked from yvv/decision
v3 : modules d'ordre et de titre — l'interface, restes mineurs (L11, C25–C27)
Chaque section du lecteur finit par l'ordre de ses articles (inertie 2) : ses articles numérotés, son vote déplié, « Proposer un autre ordre » — une liste où l'on clique un article puis sa place, ses flèches, ou le clavier. Le titre du document (inertie 4) se vote et se propose sous le titre, « traduction à revoir » quand il a changé. Les deux sont dans la liste des propositions, le panier et la chaîne Ğ1 ; un nouvel article se propose dans une section, à sa fin. La liste du groupe compte les propositions ouvertes des modules. Restes mineurs : la participation ramenée se dit en deux temps (la mesure, puis ce que le groupe demande), les lignes « habituelle » des réglages resserrées, le sélecteur d'inertie se parcourt aux flèches (le cran actuel atteignable, jamais choisi), la marque « seed a minima » porte le vrai nombre de votes. Contrôles : C25 (ordre, dont la chaîne et le téléphone), C26 (titre), C27 (participation ramenée) ; C3, C17 (41 modules), C20 mis à jour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -142,8 +142,10 @@ test('C17 — Décisions : une ligne par doc, les modules à voter, dont sans to
|
||||
const rows = page.locator('ul.lines > li');
|
||||
await expect(rows).toHaveCount(1);
|
||||
await expect(rows.first()).toContainText("Acte d'engagement Certification");
|
||||
await expect(rows.first()).toContainText('33 modules à voter');
|
||||
await expect(rows.first()).toContainText('dont 33 sans ton vote');
|
||||
// 33 articles, the title of the document and the order of 7 sections (ADR 0009; the
|
||||
// section of a single article has no order to decide).
|
||||
await expect(rows.first()).toContainText('41 modules à voter');
|
||||
await expect(rows.first()).toContainText('dont 41 sans ton vote');
|
||||
// A vote on one version of E3 (it has a variant): one module fewer without her vote. « Contre »
|
||||
// counts as much, and leaves the previews of C9 unchanged (the server is shared). In Monnaie
|
||||
// libre the vote goes through the Ğ1 chain: the ballot, then what her wallet (Charlie's
|
||||
@@ -163,8 +165,8 @@ test('C17 — Décisions : une ligne par doc, les modules à voter, dont sans to
|
||||
.poll(async () => (await (await page.request.get(`/api/ballots/${ballot.id}`)).json()).ballot.status, { timeout: 15_000 })
|
||||
.toBe('confirmed');
|
||||
await page.reload();
|
||||
await expect(rows.first()).toContainText('33 modules à voter');
|
||||
await expect(rows.first()).toContainText('dont 32 sans ton vote');
|
||||
await expect(rows.first()).toContainText('41 modules à voter');
|
||||
await expect(rows.first()).toContainText('dont 40 sans ton vote');
|
||||
await rows.first().getByRole('link').click();
|
||||
await expect(page).toHaveURL(path(DOC));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { devices, expect, test, type Page } from '@playwright/test';
|
||||
import { ALICE, DOC, person, shot, signIn, TEST_DOC, wallet } from './helpers';
|
||||
|
||||
// Lot D — the modules (Yvv, 2026-10-30; ADR 0009): at the end of each section, the
|
||||
// order of its articles (inertia 2), proposed with a list one clicks; at the head of
|
||||
// the document, its title (inertia 4). Both voted like an article: unfolded, in the
|
||||
// basket, on the Ğ1 chain in Monnaie libre (simulated here: e2e/chain-mock.mjs).
|
||||
// The server is shared with the specs that follow: the test group keeps its articles
|
||||
// and their votes; its conclusion is reordered and its title changes.
|
||||
|
||||
const K1 = 'Règles abrégées de la toile de confiance';
|
||||
const K2 = 'Monnaie G1';
|
||||
|
||||
/** A vote unfolded in the list of proposals: « Pour », then the news. */
|
||||
async function voteFor(page: Page, row: string) {
|
||||
await page.locator('ul.deep button.row', { hasText: row }).click();
|
||||
await page.locator('ul.deep section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
|
||||
}
|
||||
|
||||
test('C25 — l’ordre des articles : en fin de section, proposé en cliquant, au clavier, voté, en vigueur', async ({ page, browser }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(TEST_DOC);
|
||||
// Each section ends with its order; a section of one article has nothing to decide.
|
||||
const modules = page.locator('section.text [role="group"]');
|
||||
await expect(modules).toHaveCount(7);
|
||||
await expect(page.getByRole('group', { name: 'Ordre des articles · Ordonnancement' })).toHaveCount(0);
|
||||
const order = page.getByRole('group', { name: 'Ordre des articles · Conclusion' });
|
||||
await expect(order.locator(':scope > ol li')).toHaveText([`1 ${K1}`, `2 ${K2}`]);
|
||||
await expect(order).toContainText('2 articles · inertie 2 / 5 · basse');
|
||||
await expect(order.getByText('seed a minima · 0 vote')).toBeVisible();
|
||||
await order.scrollIntoViewIfNeeded();
|
||||
await shot(page, 'c25-1-ordre-lecture');
|
||||
|
||||
// Its vote unfolds under it, like an article's; the mark tells the real number of votes.
|
||||
const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
|
||||
await fond.hover();
|
||||
await fond.getByRole('button', { name: 'Le vote' }).click();
|
||||
const fondVote = page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' });
|
||||
await expect(fondVote.getByText('inertie 2 / 5 · basse', { exact: true })).toBeVisible();
|
||||
await expect(fondVote.getByRole('list', { name: 'Ordre en vigueur' }).locator('li')).toHaveText(['Engagements fondamentaux', 'Unicité du compte', 'Réciprocité']);
|
||||
await fondVote.getByRole('button', { name: 'Pour', exact: true }).click();
|
||||
await expect(fond.getByText('seed a minima · 1 vote')).toBeVisible();
|
||||
await fondVote.getByRole('button', { name: 'Retirer mon vote' }).click();
|
||||
await expect(fond.getByText('seed a minima · 0 vote')).toBeVisible();
|
||||
await fond.getByRole('button', { name: 'Le vote' }).click();
|
||||
|
||||
// Another order: the article, then its new place.
|
||||
await order.hover();
|
||||
await order.getByRole('button', { name: 'Proposer un autre ordre' }).click();
|
||||
await expect(page).toHaveURL(/onglet=proposer/);
|
||||
const panel = page.locator('aside.panel');
|
||||
const list = panel.getByRole('list', { name: 'Nouvel ordre des articles' });
|
||||
const items = list.locator('button.item');
|
||||
const names = list.locator('button.item .name');
|
||||
await expect(names).toHaveText([K1, K2]);
|
||||
const submit = panel.getByRole('button', { name: 'Proposer cet ordre' });
|
||||
await expect(submit).toBeDisabled();
|
||||
await items.nth(1).click();
|
||||
await expect(items.nth(1)).toHaveAttribute('aria-pressed', 'true');
|
||||
await items.nth(0).click();
|
||||
await expect(names).toHaveText([K2, K1]);
|
||||
await expect(panel.getByText(`« ${K2} » en place 1 sur 2.`)).toBeAttached();
|
||||
await expect(submit).toBeEnabled();
|
||||
// With the keyboard: the moved article keeps the focus, the arrows move it.
|
||||
await expect(items.nth(0)).toBeFocused();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(names).toHaveText([K1, K2]);
|
||||
await expect(submit).toBeDisabled();
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await expect(names).toHaveText([K2, K1]);
|
||||
// Or its arrows.
|
||||
await panel.getByRole('button', { name: `Descendre « ${K2} »` }).click();
|
||||
await panel.getByRole('button', { name: `Monter « ${K2} »` }).click();
|
||||
await expect(names).toHaveText([K2, K1]);
|
||||
await shot(page, 'c25-2-ordre-propose');
|
||||
await submit.click();
|
||||
await expect(page.getByText('Ordre proposé au vote.')).toBeVisible();
|
||||
|
||||
// Its vote unfolds in the list of proposals, the list it proposes in sight.
|
||||
const fold = page.locator('ul.deep section.fold');
|
||||
await expect(fold).toHaveAttribute('aria-label', 'Le vote de « Ordre des articles · Conclusion »');
|
||||
await expect(fold.locator('ol.order li')).toHaveText([K2, K1]);
|
||||
await expect(page.locator('ul.deep li.section', { hasText: 'Conclusion' })).toBeVisible();
|
||||
await expect(page.locator('ul.deep button.row', { hasText: 'Ordre des articles · Conclusion' })).toContainText(`${K2} · ${K1}`);
|
||||
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
||||
await expect(page.getByText('Vote enregistré.')).toBeVisible();
|
||||
await shot(page, 'c25-3-ordre-vote');
|
||||
|
||||
// In the basket too: at the end of its section.
|
||||
await page.getByRole('button', { name: /^Voter en lot/ }).click();
|
||||
const basket = panel.getByRole('region', { name: 'Conclusion' });
|
||||
await expect(basket.locator('li.slot').last()).toContainText('Ordre des articles · Conclusion');
|
||||
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
|
||||
|
||||
// A second voice brings it into force: the section reads in its new order.
|
||||
const yvv = await person(browser, 'Yvv');
|
||||
await yvv.goto(TEST_DOC);
|
||||
await voteFor(yvv, 'Ordre des articles · Conclusion');
|
||||
await expect(yvv.getByText('Le nouvel ordre de « Conclusion » est entré en vigueur.')).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(order.locator(':scope > ol li')).toHaveText([`1 ${K2}`, `2 ${K1}`]);
|
||||
// In force now: its vote stays unfolded, under the module, the list whole.
|
||||
await expect(order.locator('section.fold')).toContainText('2 pour · 0 contre');
|
||||
await order.scrollIntoViewIfNeeded();
|
||||
await shot(page, 'c25-3b-ordre-en-vigueur');
|
||||
await expect(order).toContainText(/adopté le/);
|
||||
const rows = await page.locator('ul.deep button.row .title').allTextContents();
|
||||
expect(rows.indexOf(K2)).toBeLessThan(rows.indexOf(K1));
|
||||
});
|
||||
|
||||
test('C26 — le titre du document : proposé et voté en tête, ses traductions à revoir', async ({ page, browser }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
await page.goto(TEST_DOC);
|
||||
const head = page.locator('header.head');
|
||||
const bar = head.locator('.title-unit');
|
||||
await expect(bar).toContainText('Titre du document · inertie 4 / 5 · haute · seed a minima · 0 vote');
|
||||
await head.hover();
|
||||
await bar.getByRole('button', { name: 'Proposer un autre titre' }).click();
|
||||
await expect(page).toHaveURL(/onglet=proposer/);
|
||||
const panel = page.locator('aside.panel');
|
||||
const input = panel.getByRole('textbox', { name: 'Nouveau titre' });
|
||||
await expect(input).toHaveValue("Acte d'engagement Certification");
|
||||
const submit = panel.getByRole('button', { name: 'Proposer un autre titre' });
|
||||
await expect(submit).toBeDisabled();
|
||||
await expect(panel.getByText("Le titre du document se vote à l'inertie 4 / 5 · haute.", { exact: false })).toBeVisible();
|
||||
const next = "Acte d'engagement de la certification";
|
||||
await input.fill(next);
|
||||
await shot(page, 'c26-1-titre-propose');
|
||||
await submit.click();
|
||||
await expect(page.getByText('Titre proposé au vote.')).toBeVisible();
|
||||
const fold = page.locator('ul.deep section.fold');
|
||||
await expect(fold).toHaveAttribute('aria-label', `Le vote de « ${next} »`);
|
||||
await expect(page.locator('ul.deep button.row', { hasText: next })).toContainText('Titre du document');
|
||||
await fold.getByRole('button', { name: 'Pour', exact: true }).click();
|
||||
await expect(page.getByText('Vote enregistré.')).toBeVisible();
|
||||
|
||||
const yvv = await person(browser, 'Yvv');
|
||||
await yvv.goto(TEST_DOC);
|
||||
await voteFor(yvv, next);
|
||||
await expect(yvv.getByText(`Le titre « ${next} » est entré en vigueur.`)).toBeVisible();
|
||||
|
||||
// The document changes title; its translations stay editorial, to review.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText(next);
|
||||
await expect(bar).toContainText(/adopté le/);
|
||||
await expect(bar.locator('.tag')).toHaveCount(0);
|
||||
// In force now: its vote stays unfolded, under the title.
|
||||
const vote = bar.locator('section.fold');
|
||||
await expect(bar.getByRole('button', { name: 'Le vote' })).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(vote.getByText('inertie 4 / 5 · haute', { exact: true })).toBeVisible();
|
||||
await expect(vote.getByText('2 pour · 0 contre')).toBeVisible();
|
||||
await shot(page, 'c26-2-titre-vote');
|
||||
await bar.getByRole('button', { name: 'Le vote' }).click();
|
||||
await page.getByRole('radiogroup', { name: 'Langue du texte' }).getByRole('radio', { name: 'EN' }).click();
|
||||
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Certification Commitment Act');
|
||||
await expect(bar.locator('.tag')).toHaveText('translation needs review');
|
||||
await shot(page, 'c26-3-titre-traduction-a-revoir');
|
||||
});
|
||||
|
||||
test('C27 — la participation ramenée dans ses bornes : la mesure, puis ce que le groupe demande', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
// A group of four whose median is 2,5 voters, brought back to half of it by the scale.
|
||||
await page.route(/\/api\/docs\/[^/?]+$/, async (route) => {
|
||||
const res = await route.fetch();
|
||||
const body = await res.json();
|
||||
body.doc.electorate = 4;
|
||||
body.doc.participation = { ...body.doc.participation, source: 'median', median: 2.5, sample: 12, bounded: true, voters: 2, ratio: 0.5, bounds: [0.05, 0.5] };
|
||||
await route.fulfill({ response: res, json: body });
|
||||
});
|
||||
await page.goto(TEST_DOC);
|
||||
await page.getByRole('button', { name: 'Réglages de la formule' }).click();
|
||||
const panel = page.locator('aside.panel');
|
||||
// The measure as it is: 2,5 of 4 is 62,5 %, never 3 of 4 rounded first.
|
||||
await expect(panel.getByText(/^3 votants sur 4 \(62,5\s%\) : la médiane des 12 propositions votées depuis un an\.$/)).toBeVisible();
|
||||
// Then what the group asks, the group as subject.
|
||||
await expect(panel.getByText(/^Ramenée entre 5\s% et 50\s% de l'électorat : le groupe demande ses voix pour comme si 2 personnes votaient \(50\s%\)\.$/)).toBeVisible();
|
||||
await expect(panel.getByText("Part exigée : les « pour » demandés au-delà de C, à 2 votants comme d'habitude.")).toBeVisible();
|
||||
await shot(page, 'c27-participation-bornee');
|
||||
});
|
||||
|
||||
test('C25 — sur la chaîne Ğ1 et sur un téléphone : l’ordre se vote dans le panier, la liste tient', async ({ browser }) => {
|
||||
// The chain is waited for: the server reads the indexer every 10 seconds.
|
||||
test.setTimeout(90_000);
|
||||
const ctx = await browser.newContext({ ...devices['Pixel 7'], locale: 'fr-FR' });
|
||||
const page = await ctx.newPage();
|
||||
await signIn(page, 'Chiara');
|
||||
await wallet(page, [ALICE]);
|
||||
await page.goto(DOC);
|
||||
// The basket of Monnaie libre: the order of the fundamental commitments, alone, in one transaction.
|
||||
await page.getByRole('button', { name: /^Voter en lot/ }).click();
|
||||
const panel = page.locator('aside.panel');
|
||||
await panel.getByRole('group', { name: 'Ton choix pour « Ordre des articles · Engagements fondamentaux »' }).getByRole('button', { name: 'Pour' }).click();
|
||||
await panel.getByRole('button', { name: 'Signer et envoyer (1)' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
|
||||
await expect(dialog.getByText('Ton vote compte : il est inscrit sur la chaîne Ğ1.')).toBeVisible({ timeout: 20_000 });
|
||||
await dialog.getByRole('button', { name: 'Fermer' }).click();
|
||||
await panel.getByRole('button', { name: 'Fermer le panneau' }).click();
|
||||
const fond = page.getByRole('group', { name: 'Ordre des articles · Engagements fondamentaux' });
|
||||
await expect(fond.getByText('seed a minima · 1 vote')).toBeVisible();
|
||||
await fond.getByRole('button', { name: 'Le vote' }).click();
|
||||
await expect(page.getByRole('region', { name: 'Le vote de « Ordre des articles · Engagements fondamentaux »' }).locator('.voted')).toHaveText(
|
||||
'Ton vote : pour · inscrit sur la chaîne Ğ1',
|
||||
);
|
||||
await fond.scrollIntoViewIfNeeded();
|
||||
await shot(page, 'c25-4-ordre-mobile');
|
||||
// The title on a phone: its gestures in sight, without a pointer to hover with.
|
||||
await page.goto(TEST_DOC);
|
||||
await expect(page.locator('.title-unit .links')).toHaveCSS('opacity', '1');
|
||||
await shot(page, 'c26-4-titre-mobile');
|
||||
// The list one clicks, on a phone: nothing overflows, the buttons are big enough to touch.
|
||||
const tech = page.getByRole('group', { name: 'Ordre des articles · Engagements techniques' });
|
||||
await tech.getByRole('button', { name: 'Proposer un autre ordre' }).click();
|
||||
const items = panel.getByRole('list', { name: 'Nouvel ordre des articles' }).locator('button.item');
|
||||
await expect(items).toHaveCount(11);
|
||||
await items.nth(3).click();
|
||||
await items.nth(0).click();
|
||||
await expect(panel.getByRole('button', { name: 'Proposer cet ordre' })).toBeEnabled();
|
||||
const holds = await panel.locator('.body').evaluate((el) => el.scrollWidth <= el.clientWidth + 1);
|
||||
expect(holds).toBe(true);
|
||||
const box = await items.nth(0).boundingBox();
|
||||
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
await shot(page, 'c25-5-ordre-propose-mobile');
|
||||
await page.getByRole('heading', { level: 1 }).scrollIntoViewIfNeeded();
|
||||
await ctx.close();
|
||||
});
|
||||
+24
-4
@@ -43,7 +43,9 @@ test('C3 — le texte en vigueur : raison d’être et deux engagements, seed a
|
||||
await expect(units.nth(0).locator('h2')).toHaveText("Raison d'être");
|
||||
await expect(units.nth(1).locator('h2')).toHaveText('Unicité du compte');
|
||||
await expect(units.nth(2).locator('h2')).toHaveText('Réciprocité');
|
||||
await expect(page.getByText('seed a minima · 0 vote')).toHaveCount(3);
|
||||
// The seed mark, with the real number of votes beside it (none yet on these three).
|
||||
await expect(units.getByText('seed a minima · 0 vote')).toHaveCount(3);
|
||||
await expect(units.getByTitle("Entré en vigueur sans vote, pour ne pas partir d'une page blanche. Il reste sous vote, comme tout texte.")).toHaveCount(3);
|
||||
await expect(page.getByRole('heading', { name: 'Propositions en cours' })).toBeVisible();
|
||||
// One line per article: E3 and E7 hold their two variants inside.
|
||||
await expect(page.locator('ul.deep button.row')).toHaveCount(30);
|
||||
@@ -287,7 +289,8 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
|
||||
// At 100 voters: C plus each level's share of the 50 votes beyond C.
|
||||
const required = panel.locator('table tbody td.required');
|
||||
await expect(required).toHaveText(['80', '84', '88', '92', '95']);
|
||||
await expect(panel.getByText('celles exigées à la participation habituelle, 100 votants')).toBeVisible();
|
||||
await expect(panel.getByText("Voix pour : à 100 votants, comme d'habitude.")).toBeVisible();
|
||||
await expect(panel.getByText("Part exigée : les « pour » demandés au-delà de C, à 100 votants comme d'habitude.")).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// From the vote of an article: Unicité du compte is at 4 / 5, its row is marked.
|
||||
@@ -300,7 +303,7 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
|
||||
await expect(panel.locator('tr.current th')).toContainText('4');
|
||||
await expect(panel.locator('tr.current th')).toContainText('cet article');
|
||||
await expect(required).toHaveText(['50', '50', '50', '50', '50']);
|
||||
await expect(panel.getByText('celles exigées à la participation de cette version, 0 votant.')).toBeVisible();
|
||||
await expect(panel.getByText('Voix pour : à 0 votant, la participation de cette version.')).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// The five levels to choose from, in the test group; the change is voted at the article's level.
|
||||
@@ -311,9 +314,26 @@ test('C20 — réglages de la formule et cinq crans', async ({ page }) => {
|
||||
await e2.getByText("Proposer un changement d'inertie").click();
|
||||
const levels = e2.getByRole('radiogroup', { name: 'Nouvelle inertie' }).getByRole('radio');
|
||||
await expect(levels).toHaveCount(5);
|
||||
await expect(levels.nth(3)).toBeDisabled();
|
||||
// The current level is reachable — one stop in the tab order — and never chosen.
|
||||
await expect(levels.nth(3)).toHaveAttribute('aria-disabled', 'true');
|
||||
await expect(levels.nth(3)).toContainText('actuelle');
|
||||
await expect(e2.locator('[role="radio"][tabindex="0"]')).toHaveCount(1);
|
||||
await expect(levels.nth(3)).toHaveAttribute('tabindex', '0');
|
||||
await expect(e2.getByText("Un changement d'inertie se vote lui-même à l'inertie de l'article : 4 / 5 · haute.")).toBeVisible();
|
||||
await levels.nth(3).click();
|
||||
await expect(e2.locator('[role="radio"][aria-checked="true"]')).toHaveCount(0);
|
||||
// With the keyboard: the arrows go from level to level and choose it, wrapping round.
|
||||
await levels.nth(3).focus();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(levels.nth(4)).toBeFocused();
|
||||
await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true');
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await expect(levels.nth(0)).toBeFocused();
|
||||
await expect(levels.nth(0)).toHaveAttribute('aria-checked', 'true');
|
||||
await expect(e2.locator('[role="radio"][tabindex="0"]')).toHaveCount(1);
|
||||
await page.keyboard.press('ArrowLeft');
|
||||
await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true');
|
||||
// Or with a click.
|
||||
await levels.nth(4).click();
|
||||
await expect(levels.nth(4)).toHaveAttribute('aria-checked', 'true');
|
||||
await e2.getByRole('button', { name: 'Soumettre au vote' }).click();
|
||||
|
||||
Reference in New Issue
Block a user