diff --git a/web/e2e/mobile.spec.ts b/web/e2e/mobile.spec.ts index cb7e0de..5024ddd 100644 --- a/web/e2e/mobile.spec.ts +++ b/web/e2e/mobile.spec.ts @@ -1,5 +1,5 @@ import { expect, test, type Locator } from '@playwright/test'; -import { DOC, expectWelcome, shot, signIn } from './helpers'; +import { DOC, expectWelcome, propose, seedsInForce, shot, signIn, TEST_DOC, testDoc } from './helpers'; /** WCAG contrast ratio of two CSS colours `rgb(r, g, b)`. */ function contrast(a: string, b: string): number { @@ -112,3 +112,68 @@ test('M1 — mobile : logo dans la barre, l’accueil sous la voûte, menu en ti await panel.getByRole('group', { name: 'Tout le document' }).getByRole('button', { name: 'Vider' }).click(); await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible(); }); + +/** The height of a target, in CSS pixels. */ +async function height(target: Locator): Promise { + return (await target.boundingBox())?.height ?? 0; +} + +/** Every gesture in sight under `root` — buttons, links, summaries — under 44 px high. */ +async function small(root: Locator): Promise { + return root.evaluate((el) => + [...el.querySelectorAll('button, a[href], summary, [role="radio"]')] + .filter((b) => b.checkVisibility({ visibilityProperty: true }) && b.getClientRects().length > 0) + .map((b) => ({ what: (b.textContent || b.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim(), h: b.getBoundingClientRect().height })) + .filter((x) => x.h < 44) + .map((x) => `${x.what} (${Math.round(x.h)} px)`), + ); +} + +test('M2 — téléphone : les gestes du lecteur font au moins 44 px, les trois lectures se lisent en entier', async ({ page, browser }) => { + await seedsInForce(browser); + await signIn(page, 'Chiara'); + // In the test group: a rival of the Raison d'être (in force), so that its vote names its + // « Autres versions », and an article of hers, whose quorum she sets before any vote. + const doc = await testDoc(page); + const i1 = doc.units.find((u) => u.code === 'I1'); + const r = await page.request.post(`/api/docs/${doc.id}/proposals`, { + data: { kind: 'text', unitId: i1?.id, title: "Raison d'être", body: 'Une clé, une personne vivante : la certification ne garantit rien d’autre.', submit: true }, + headers: { 'x-requested-with': 'libredecision' }, + }); + expect(r.ok()).toBe(true); + const mine = await propose(page, 'Cibles au doigt', 'Je propose cet article pour régler son quorum au doigt.'); + await page.goto(TEST_DOC); + + // The three readings, each whole on the screen: « Si tout passe » is never cut (lot U2). + const views = page.getByRole('radiogroup', { name: 'Lire' }); + const frame = await views.boundingBox(); + expect.soft(await views.evaluate((el) => el.scrollWidth <= el.clientWidth + 1)).toBe(true); + for (const reading of await views.getByRole('radio').all()) { + const b = await reading.boundingBox(); + expect.soft(b && frame && b.x >= frame.x - 1 && b.x + b.width <= frame.x + frame.width + 1, await reading.textContent()).toBe(true); + } + await expect.soft(views.getByRole('radio', { name: 'Si tout passe' })).toBeInViewport({ ratio: 1 }); + await shot(page, 'm2-1-lectures'); + + // « Tout déplier », then the vote of the Raison d'être: every gesture a finger's target. + expect.soft(await height(page.getByRole('button', { name: 'Tout déplier' }))).toBeGreaterThanOrEqual(44); + const first = page.locator('section.text article.unit').first(); + await first.getByRole('button', { name: 'Le vote' }).click(); + const fold = first.locator('section.fold'); + const others = fold.getByRole('button', { name: 'Autres versions (1)' }); + expect.soft(await height(others)).toBeGreaterThanOrEqual(44); + await others.click(); + await fold.getByText('La formule du seuil').click(); + expect.soft(await small(fold)).toEqual([]); + await shot(page, 'm2-2-vote-deplie'); + + // Her own article: « Fixer ce quorum » and the rest of its vote. + await page.goto(`${TEST_DOC}?vote=${mine}`); + const hers = page.getByRole('region', { name: 'Le vote de « Cibles au doigt »' }); + const set = hers.getByRole('button', { name: 'Fixer ce quorum' }); + expect.soft(await height(set)).toBeGreaterThanOrEqual(44); + await hers.getByText("Proposer un changement d'inertie").click(); + expect.soft(await small(hers)).toEqual([]); + await set.scrollIntoViewIfNeeded(); + await shot(page, 'm2-3-quorum'); +}); diff --git a/web/e2e/ordre-quorum.spec.ts b/web/e2e/ordre-quorum.spec.ts index 94bf058..7d1d5be 100644 --- a/web/e2e/ordre-quorum.spec.ts +++ b/web/e2e/ordre-quorum.spec.ts @@ -48,6 +48,10 @@ test('C35 — réordonner une section : les poignées sur ses articles, au gliss await expect(list.locator('.grip')).toHaveCount(3); const submit = page.getByRole('button', { name: 'Proposer cet ordre' }); await expect(submit).toBeDisabled(); + // Off, and it says why (UX-lecteur.md: « le bouton reste éteint et le dit » — lot U2). + const why = "C'est l'ordre en vigueur : déplace au moins un article."; + await expect(page.getByText(why)).toBeVisible(); + await expect(submit).toHaveAccessibleDescription(why); await shot(page, 'c35-2-poignees'); // By dragging its handle: Réciprocité onto the place of Unicité du compte. @@ -55,6 +59,7 @@ test('C35 — réordonner une section : les poignées sur ses articles, au gliss await expect(names).toHaveText([EF, E2, E1]); await expect(page.getByText(`« ${E2} » en place 2 sur 3.`)).toBeAttached(); await expect(submit).toBeEnabled(); + await expect(page.getByText(why)).toHaveCount(0); // With the keyboard: the article keeps the focus, the arrows move it. const items = list.locator('button.item'); await items.nth(1).focus(); @@ -67,6 +72,7 @@ test('C35 — réordonner une section : les poignées sur ses articles, au gliss await list.getByRole('button', { name: `Descendre « ${E2} »` }).click(); await expect(names).toHaveText([EF, E1, E2]); await expect(submit).toBeDisabled(); + await expect(page.getByText(why)).toBeVisible(); await items.nth(2).click(); await expect(items.nth(2)).toHaveAttribute('aria-pressed', 'true'); await items.nth(1).click(); @@ -145,6 +151,9 @@ test('C36 — combien de oui pour passer, combien de non pour bloquer : sous l 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 ».'); + // Said once: the line of the seed proposal, right above its vote, does not say it again (lot U2). + await expect(i1.locator('footer')).not.toContainText(/oui (sur|pour)/); + await expect(i1.locator('footer')).not.toContainText(/\d+ pour · \d+ contre/); // 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'); @@ -237,3 +246,48 @@ test('C37 — le quorum d’un article : son auteur le fixe avant le premier vot await expect(fold.getByRole('region', { name: /^Quorum/ })).toHaveCount(0); await yvv.context().close(); }); + +test('C38 — l’en-tête compte ce que montre la liste ; un changement d’inertie d’un ordre ou du titre se trouve à sa ligne', async ({ page }) => { + await signIn(page, 'Chiara'); + /** The versions « Propositions en cours » shows: one per line, or those inside a line of several. */ + const listed = () => + page.locator('ul.deep li.article').evaluateAll((lis) => lis.reduce((n, li) => n + Math.max(1, li.querySelectorAll('ul.versions > li').length), 0)); + // Monnaie libre, nothing in force: the seed proposals at the head, said apart; the count of the + // proposals in progress is what the list below shows (lot U2: the orders are not counted there). + await page.goto(DOC); + await unfold(page); + const facts = page.locator('.facts'); + await expect.soft(facts).toContainText('0 article en vigueur · 3 Propositions seed · '); + await expect.soft(facts).toContainText(new RegExp(`· ${await listed()} propositions en cours`)); + + // A change of inertia on the order of a section and on the title (test group): each is found at + // the line of its module, where its vote unfolds — never a line of « Propositions en cours ». + 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(); + const modules: { id: string; inertia: number }[] = [d.modules.orders.find((o: { section: string }) => o.section === 'conclusion'), d.modules.title]; + const made: string[] = []; + for (const unit of modules) { + const r = await page.request.post(`/api/units/${unit.id}/inertia`, { data: { target: unit.inertia === 5 ? 4 : unit.inertia + 1 }, headers: CSRF }); + expect(r.ok()).toBe(true); + made.push((await r.json()).id); + } + await page.goto(TEST_DOC); + const title = page.getByRole('group', { name: 'Titre du document' }); + const titleChange = title.getByRole('button', { name: "1 changement d'inertie proposé" }); + await expect(titleChange).toBeVisible(); + await titleChange.click(); + await expect(title.getByRole('region', { name: /^Le vote de « inertie vers \d \/ 5 · / })).toBeVisible(); + await shot(page, 'c38-1-titre-inertie'); + await unfold(page); + await expect.soft(facts).toContainText(new RegExp(`· ${await listed()} propositions en cours`)); + await expect(page.locator('ul.deep').getByText(/inertie vers/)).toHaveCount(0); + const order = page.getByRole('group', { name: 'Ordre des articles · Conclusion' }); + const orderChange = order.getByRole('button', { name: "1 changement d'inertie proposé" }); + await expect(orderChange).toBeVisible(); + await orderChange.click(); + await expect(order.getByRole('region', { name: /^Le vote de « inertie vers \d \/ 5 · / })).toBeVisible(); + await order.scrollIntoViewIfNeeded(); + await shot(page, 'c38-2-ordre-inertie'); + // Withdrawn: the group is left as found. + for (const id of made) expect((await page.request.post(`/api/proposals/${id}/withdraw`, { headers: CSRF })).ok()).toBe(true); +}); diff --git a/web/e2e/reader.spec.ts b/web/e2e/reader.spec.ts index 0c10a7a..d7f3690 100644 --- a/web/e2e/reader.spec.ts +++ b/web/e2e/reader.spec.ts @@ -298,6 +298,8 @@ test('C19 — un article, une ligne : ses versions se déplient', async ({ page // Level 3, the quorum 50 (ADR 0013): 39 « oui » pass it, the voters missing counted as « non » // (« sur 50 votants » before any vote; a « contre » of another control may be there already). await expect(page.locator('section.fold .need')).toHaveText(/^39 oui (sur 50 votants pour passer|pour passer · le quorum la bloque)$/); + // Its other version is right there in the list: the vote does not list it again (lot U2). + await expect(page.locator('section.fold').getByRole('button', { name: /^Autres versions/ })).toHaveCount(0); await expect(page).toHaveURL(/vote=/); await e3.click(); await expect(e3).toHaveAttribute('aria-expanded', 'false'); diff --git a/web/tests/unit/lexicon.test.ts b/web/tests/unit/lexicon.test.ts index 3eea391..29943d1 100644 --- a/web/tests/unit/lexicon.test.ts +++ b/web/tests/unit/lexicon.test.ts @@ -55,3 +55,25 @@ describe('anti-lexicon', () => { } }); }); + +// Lot U2 (reviews of M6c and M7): the same words for the same things. +describe('the reading of the formula', () => { + it('the settings say, like N1.1 and the formula, that the share falls beyond the quorum', () => { + expect(fr['formula.reading']).toContain('Au-delà'); + expect(fr['settings.reading']).toContain('au-delà du quorum'); + expect(en['settings.reading']).toContain('beyond the quorum'); + expect(es['settings.reading']).toContain('más allá del quórum'); + }); +}); + +describe('English: one vocabulary', () => { + it('« Pour » and « Contre » are for and against, never yes and no', () => { + for (const [k, v] of Object.entries(en)) { + expect(v, k).not.toMatch(/\byes\b/i); + expect(v, k).not.toMatch(/“no”|\{no\} no\b/); + } + }); + it('one spelling, the one that dominates: favour', () => { + for (const [k, v] of Object.entries(en)) expect(v, k).not.toMatch(/\bfavor\b/i); + }); +}); diff --git a/web/tests/unit/onceonly.test.ts b/web/tests/unit/onceonly.test.ts new file mode 100644 index 0000000..d9aa753 --- /dev/null +++ b/web/tests/unit/onceonly.test.ts @@ -0,0 +1,131 @@ +// Lot U2 (review of M7): in the vote unfolded, one piece of information in one place. Rendered as +// the server would (svelte/server), and read as a person reads it: the tags left out, and what a +// closed
holds left out too (only its summary shows). +import { render } from 'svelte/server'; +import { describe, expect, it } from 'vitest'; +import UnitBlock from '../../src/lib/components/UnitBlock.svelte'; +import VoteFold from '../../src/lib/components/VoteFold.svelte'; +import type { DocState } from '../../src/lib/doc.svelte'; +import type { Inertia, Proposal, Unit } from '../../src/lib/types'; + +function version(id: string, over: Partial = {}): Proposal { + return { + id, + unitId: 'u1', + kind: 'text', + inertiaTarget: null, + title: 'Connaissance suffisante', + body: 'Je connais suffisamment la personne.', + status: 'open', + origin: 'seed', + label: null, + author: null, + mine: false, + revision: 1, + createdAt: '2026-11-03T00:00:00Z', + updatedAt: '2026-11-03T00:00:00Z', + submittedAt: null, + enteredAt: null, + confirmedAt: null, + seedMinimal: false, + tally: { for: 0, against: 0 }, + tallyPending: { for: 0, against: 0 }, + tallySimulated: { for: 0, against: 0 }, + evaluation: { cast: 0, required: 9, reached: false, missing: 9, requiredRatio: 0.75, wellRated: false, quorum: 12, yesNeeded: 9, noNeeded: 0 }, + votedAtInertia: 2, + blocked: false, + votable: true, + objections: 0, + myVote: null, + myVoteState: null, + myVoteStale: false, + myVoteChain: false, + myBallot: null, + translations: {}, + ...over, + }; +} + +function unitOf(proposals: Proposal[], inertia: Inertia = 2): Unit { + return { id: 'u1', code: 'E3', section: 'technique', position: 1, inertia, inertiaChanges: [], inForce: null, proposals }; +} + +/** The text a person reads: no tags, no server markers, nothing inside a closed
but its summary. */ +function read(html: string): string { + return html + .replace(//g, '') + .replace(/(]*\sopen)[^>]*>[\s\S]*?<\/summary>)[\s\S]*?<\/details>/g, '$1') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' '); +} + +const noop = () => {}; +const doc = { view: null, original: false, articleTitle: () => '', unit: () => undefined } as unknown as DocState; + +function fold(p: Proposal, unit: Unit, besides?: string[]): string { + const { body } = render(VoteFold, { + props: { doc, proposal: p, unit, besides, onopen: noop, onsettings: noop, ondetails: noop, onanswer: noop, onsign: noop }, + }); + return read(body); +} + +const count = (text: string, part: string) => text.split(part).length - 1; + +describe('the vote unfolded says each thing once', () => { + it('the inertia of the article, once (its notches with it)', () => { + const p = version('p1'); + const text = fold(p, unitOf([p])); + expect(count(text, '2 / 5 · basse'), text).toBe(1); + }); + it('the inertia a change of inertia is voted at, once', () => { + const p = version('p2', { kind: 'inertia', inertiaTarget: 4, title: 'inertia:4' }); + const text = fold(p, unitOf([p])); + expect(count(text, '2 / 5 · basse'), text).toBe(1); + }); + it('the other versions already listed beside the vote are not listed again', () => { + const a = version('p1', { label: '1.0.0' }); + const b = version('p2', { label: '0.4.0-draft' }); + const unit = unitOf([a, b]); + // Alone (in the text, say), it names its other version … + expect(fold(a, unit)).toContain('Autres versions (1)'); + // … but in the list of the versions of its article, the other one is right there. + expect(fold(a, unit, ['p1', 'p2'])).not.toContain('Autres versions'); + }); + it('an other version not in sight is still named (the text in force, above)', () => { + const inForce = version('p0', { status: 'in_force', title: 'Connaissance suffisante' }); + const a = version('p1', { label: '1.0.0' }); + const b = version('p2', { label: '0.4.0-draft' }); + const unit = { ...unitOf([a, b]), inForce }; + expect(fold(a, unit, ['p1', 'p2'])).toContain('Autres versions (1)'); + }); +}); + +function block(seed: Proposal, voteOpen: boolean, rivals = 0): string { + const { body } = render(UnitBlock, { + props: { + text: { title: seed.title, body: seed.body, translated: false, stale: false, missing: false }, + seed, + rivals, + voteFor: seed.id, + voteOpen, + onaction: noop, + }, + }); + return read(body); +} + +describe('a seed proposal at the head, its vote unfolded', () => { + const seed = version('p1', { seedProposal: true, title: "Raison d'être" }); + it('folded: its line says how far it is from passing', () => { + const text = block(seed, false, 1); + expect(text).toContain('0 pour · 0 contre · 9 oui sur 12'); + expect(text).toContain('1 autre version'); + }); + it('unfolded: the vote below says it, the line does not say it again', () => { + const text = block(seed, true, 1); + expect(text).not.toContain('0 pour · 0 contre'); + expect(text).not.toContain('9 oui sur 12'); + expect(text).not.toContain('basse'); + expect(text).not.toContain('autre version'); + }); +});