forked from yvv/decision
Fusion de la tête du chantier (924a40d : lots B2 et F) dans le lot D
Les deux côtés gardés partout. Aux coutures : - une objection (redit / contredit) porte sur deux articles, jamais sur un module ; un ordre ne se corrige pas, ni par son auteur ni par un autre ; une coquille du titre suit le chemin de F (l'auteur du titre, un administrateur pour le titre de départ, ou 3 jours), et documents.title suit à l'application ; - votable() couvre les modules ; leurs totaux passent par shown_tally, à l'écran et dans document.json ; - « Modules à voter » ne compte plus une version retenue par une objection (test vu rouge) ; la liste du groupe la compte toujours parmi les propositions en cours ; - le panier : une fonction pour les articles et les modules, avec les versions retenues de F ; un nouvel article se propose dans une section, à sa fin, et peut répondre à une objection ; - migrations 0007 (modules) puis 0008 (objections) ; - C25 et C26 supportent un groupe d'essai où une voix suffit (dès 16 membres). Boucle : fmt, clippy -D warnings, cargo test (227), svelte-check 0, vitest 55, build, e2e 35 / 35 (C1–C33 et M1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { ALICE, chain, DOC, EVE, person, shot, signIn, VOTE_ACCOUNT, wallet } from './helpers';
|
||||
|
||||
// Lot B2 — the vote on the Ğ1 chain, after its review: a ballot nothing left for
|
||||
// is let go; signed, or shown as a Ğecko code, it is kept and the server confirms
|
||||
// it without the page; the receipt goes to the one who asked, who checks it here.
|
||||
// The chain is simulated (e2e/chain-mock.mjs): never a real transaction. The votes go
|
||||
// to open proposals no other control reads, and only « contre »: nothing enters into
|
||||
// force, nothing becomes well rated, the other controls find the text as they expect.
|
||||
|
||||
type Entry = { id: string; status: string; comment: string };
|
||||
const mine = async (page: Page): Promise<Entry[]> => (await (await page.request.get('/api/ballots')).json()).ballots;
|
||||
|
||||
/** Unfolds the vote of an open proposal, from « Propositions en cours ». */
|
||||
async function unfold(page: Page, title: string) {
|
||||
// By its title only: the excerpt of another proposal may name it.
|
||||
await page.locator('ul.deep button.row').filter({ has: page.locator('span.title').getByText(title, { exact: true }) }).click();
|
||||
return page.getByRole('region', { name: `Le vote de « ${title} »` });
|
||||
}
|
||||
|
||||
const REVOCATION = 'Document de révocation';
|
||||
const NEVER_ALONE = 'Ne jamais certifier seul';
|
||||
|
||||
const answer = (page: Page, yes: boolean) =>
|
||||
page.evaluate((y) => (window as unknown as { __ldAnswer: (yes: boolean) => void }).__ldAnswer(y), yes);
|
||||
|
||||
test('C32 — rien n’est parti : le bulletin est lâché ; signé ou par Ğecko, il est gardé et confirmé sans la page', async ({ page, browser }) => {
|
||||
// The server reads the indexer every 10 seconds.
|
||||
test.setTimeout(120_000);
|
||||
await signIn(page, 'Rosa');
|
||||
await wallet(page, [EVE]);
|
||||
await page.goto(DOC);
|
||||
const { control } = await chain(page);
|
||||
const asked: string[] = [];
|
||||
page.on('request', (r) => {
|
||||
if (r.method() !== 'GET' && /\/api\/(docs\/[^/]+\/ballots|ballots\/)/.test(r.url())) {
|
||||
asked.push(`${r.method()} ${new URL(r.url()).pathname.replace(/[0-9a-f]{32}/g, ':id')}`);
|
||||
}
|
||||
});
|
||||
const deleted = () => asked.filter((a) => a.startsWith('DELETE')).length;
|
||||
const fold = await unfold(page, REVOCATION);
|
||||
const dialog = page.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
|
||||
|
||||
// The node does not answer before anything is signed: nothing left, the ballot is let go.
|
||||
await control('node?down=1');
|
||||
try {
|
||||
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
|
||||
await expect(dialog.getByText('La chaîne Ğ1 ne répond pas. Réessaie dans un moment.')).toBeVisible();
|
||||
await expect.poll(() => asked).toEqual(['POST /api/docs/:id/ballots', 'DELETE /api/ballots/:id']);
|
||||
await dialog.getByRole('button', { name: 'Fermer' }).click();
|
||||
await expect(fold.getByText('En attente de la chaîne Ğ1', { exact: false })).toHaveCount(0);
|
||||
await expect.poll(() => mine(page)).toEqual([]);
|
||||
} finally {
|
||||
// The next controls need the node.
|
||||
await control('node?down=0');
|
||||
}
|
||||
|
||||
// The window closed while the wallet still asks: kept until it answers; refused, let go.
|
||||
await page.evaluate(() => ((window as unknown as { __ldHold: boolean }).__ldHold = true));
|
||||
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
|
||||
await expect(dialog.getByText('Confirme dans ton portefeuille.')).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Fermer' }).click();
|
||||
expect(deleted()).toBe(1);
|
||||
await answer(page, false);
|
||||
await expect.poll(deleted).toBe(2);
|
||||
await expect.poll(() => mine(page)).toEqual([]);
|
||||
|
||||
// Signed once the window is closed: it may land, so it is kept; the server confirms it.
|
||||
await fold.getByRole('button', { name: 'Contre', exact: true }).click();
|
||||
await expect(dialog.getByText('Confirme dans ton portefeuille.')).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Fermer' }).click();
|
||||
await answer(page, true);
|
||||
await expect.poll(async () => (await mine(page)).map((b) => b.status), { timeout: 30_000 }).toEqual(['confirmed']);
|
||||
expect(deleted()).toBe(2);
|
||||
await page.goto(DOC);
|
||||
await expect((await unfold(page, REVOCATION)).locator('.voted')).toHaveText('Ton vote : contre · inscrit sur la chaîne Ğ1');
|
||||
|
||||
// Ğecko: the code shown, the window closed — the ballot is kept; paid from a phone, confirmed by the server.
|
||||
const ugo = await person(browser, 'Ugo');
|
||||
await ugo.goto(DOC);
|
||||
const theirs = await unfold(ugo, REVOCATION);
|
||||
await theirs.getByRole('button', { name: 'Contre', exact: true }).click();
|
||||
const sheet = ugo.getByRole('dialog', { name: 'Voter avec ton portefeuille Ğ1' });
|
||||
await sheet.getByRole('button', { name: 'Passer par Ğecko' }).click();
|
||||
const link = (await sheet.getByRole('link', { name: 'Ouvrir dans Ğecko' }).getAttribute('href')) ?? '';
|
||||
await sheet.getByRole('button', { name: 'Fermer' }).click();
|
||||
await expect(theirs.getByText('En attente de la chaîne Ğ1 : contre.')).toBeVisible();
|
||||
const { node } = await chain(ugo);
|
||||
const fields = { address: ALICE.address, dest: VOTE_ACCOUNT, amount: '10', remark: link.split('comment=')[1] ?? '', signature: '0x01' };
|
||||
await ugo.request.post(node, {
|
||||
data: { jsonrpc: '2.0', id: 1, method: 'author_submitExtrinsic', params: [`0x${Buffer.from(JSON.stringify(fields)).toString('hex')}`] },
|
||||
});
|
||||
await expect.poll(async () => (await mine(ugo)).map((b) => b.status), { timeout: 30_000 }).toEqual(['confirmed']);
|
||||
await ugo.goto(DOC);
|
||||
await expect((await unfold(ugo, REVOCATION)).locator('.voted')).toHaveText('Ton vote : contre · inscrit sur la chaîne Ğ1');
|
||||
});
|
||||
|
||||
test('C33 — le reçu de ton vote : téléchargé, retrouvé dans tes bulletins, vérifié ici ; jamais chez les autres', async ({ page, browser }) => {
|
||||
test.setTimeout(90_000);
|
||||
await signIn(page, 'Ines');
|
||||
await wallet(page, [ALICE]);
|
||||
await page.goto(DOC);
|
||||
const fold = await unfold(page, NEVER_ALONE);
|
||||
await fold.getByRole('button', { name: 'Contre', exact: true }).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 });
|
||||
const [download] = await Promise.all([page.waitForEvent('download'), dialog.getByRole('link', { name: 'Télécharger ton reçu' }).click()]);
|
||||
expect(download.suggestedFilename()).toMatch(/^recu-vote-\d{4}-\d{2}-\d{2}-[0-9a-f]{8}\.json$/);
|
||||
const file = await download.path();
|
||||
const receipt = JSON.parse(readFileSync(file, 'utf8'));
|
||||
expect(receipt).toMatchObject({ format: 'ld1', status: 'confirmed', voter: 'Alice', author: ALICE.address });
|
||||
expect(receipt.comment).toMatch(/^ld1:[0-9a-f]{64}$/);
|
||||
expect(receipt.salt).toMatch(/^[0-9a-f]{32}$/);
|
||||
expect(receipt.lines).toEqual([expect.objectContaining({ choice: 'against', title: NEVER_ALONE })]);
|
||||
|
||||
// The same ballot, in her ballots.
|
||||
await dialog.getByRole('link', { name: 'Tes bulletins' }).click();
|
||||
await expect(page).toHaveURL(/\/bulletins$/);
|
||||
await expect(page.getByRole('heading', { name: 'Tes bulletins de vote' })).toBeVisible();
|
||||
const entry = page.locator('li.ballot');
|
||||
await expect(entry).toHaveCount(1);
|
||||
await expect(entry).toContainText("Compté pour l'identité Ğ1 « Alice »");
|
||||
await expect(entry).toContainText(receipt.comment);
|
||||
|
||||
// Checked here, in the browser, without the server.
|
||||
await page.getByLabel('Fichier du reçu').setInputFiles(file);
|
||||
await expect(page.getByText('Le reçu tient : ses votes et son sel donnent bien ce commentaire.')).toBeVisible();
|
||||
// Only what the seal covers is said verified: the version, its revision and the choice.
|
||||
await expect(page.locator('ul.lines li').first()).toContainText(`Version ${receipt.lines[0].proposalId}, révision ${receipt.lines[0].revision} : contre`);
|
||||
// The article and its title are written in the receipt, outside the seal (relecture of 7ffa4fd).
|
||||
await expect(page.getByText(`${NEVER_ALONE} — noté dans le reçu, hors du sceau`, { exact: false })).toBeVisible();
|
||||
// One choice changed: it no longer holds.
|
||||
const changed = { ...receipt, lines: receipt.lines.map((l: object) => ({ ...l, choice: 'for' })) };
|
||||
await page.getByLabel('Ou colle le reçu').fill(JSON.stringify(changed));
|
||||
await page.getByRole('button', { name: 'Vérifier', exact: true }).click();
|
||||
await expect(page.getByText('Le reçu ne tient pas : ses votes et son sel ne donnent pas son commentaire.')).toBeVisible();
|
||||
await shot(page, '7-bulletins-recu');
|
||||
|
||||
// Nobody else gets it: neither in their ballots, nor by its address.
|
||||
const other = await person(browser, 'Lou');
|
||||
await other.goto('/bulletins');
|
||||
await expect(other.getByText("Tu n'as encore envoyé aucun bulletin par la chaîne Ğ1.")).toBeVisible();
|
||||
expect((await other.request.get(`/api/ballots/${receipt.id}/receipt`)).status()).toBe(404);
|
||||
});
|
||||
+37
-8
@@ -6,11 +6,15 @@
|
||||
// each one is included in a new block, as the transfer and the remark of the
|
||||
// real `utility.batchAll` would be;
|
||||
// - `/v1/graphql`, the « indexer »: answers the server's query
|
||||
// `txComments(condition: {hash})` in the shape of indexer.duniter.org.
|
||||
// `squidStatus { height } txComments(condition: {hash}, filter, first, after)`
|
||||
// in the shape of indexer.duniter.org: the last final block (here, every
|
||||
// block is final), the comments in the order of the chain, filtered (author,
|
||||
// or a transfer to an account of at least an amount) and served page by page.
|
||||
//
|
||||
// And `/control/*` for the tests: `hide` (what is included next stays unseen by
|
||||
// the indexer, as an indexer late on the chain), `show` (stop hiding), `release`
|
||||
// (the indexer catches up), `silent` (the indexer stops answering) and `state`.
|
||||
// the indexer, as an indexer behind the chain), `show` (stop hiding), `release`
|
||||
// (the indexer catches up), `silent` (the indexer stops answering), `node`
|
||||
// (`?down=1`: the node stops answering, before anything is sent) and `state`.
|
||||
//
|
||||
// Listens on an ephemeral port on 127.0.0.1, written to the file given as argument.
|
||||
|
||||
@@ -27,7 +31,7 @@ const IDENTITIES = {
|
||||
g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj: { index: 9005, name: 'Eve', isMember: true },
|
||||
};
|
||||
|
||||
const state = { block: 3_400_000, comments: [], hiding: false, silent: false, submitted: 0 };
|
||||
const state = { block: 3_400_000, comments: [], hiding: false, silent: false, nodeDown: false, submitted: 0 };
|
||||
|
||||
const hex = (bytes) => Buffer.from(bytes).toString('hex');
|
||||
const remarkHash = (remark) => `0x${hex(blake2b(Buffer.from(remark, 'utf8'), { dkLen: 32 }))}`;
|
||||
@@ -49,6 +53,20 @@ async function read(req) {
|
||||
}
|
||||
}
|
||||
|
||||
/** What the real indexer's `filter` keeps, for the two filters the server asks. */
|
||||
function kept(c, filter) {
|
||||
if (!filter) return true;
|
||||
const author = filter.authorId?.equalTo;
|
||||
if (author && c.author.id !== author) return false;
|
||||
const some = filter.transfersByCommentId?.some;
|
||||
if (!some) return true;
|
||||
const min = BigInt(some.amount?.greaterThanOrEqualTo ?? '0');
|
||||
return c.transfersByCommentId.nodes.some((t) => (!some.toId?.equalTo || t.toId === some.toId.equalTo) && BigInt(t.amount) >= min);
|
||||
}
|
||||
|
||||
/** The size of a page: `first: N` in the query, as the server writes it. */
|
||||
const pageSize = (query) => Number(/first:\s*(\d+)/.exec(query)?.[1] ?? 100);
|
||||
|
||||
/** One signed extrinsic of the test mode: a new block, its transfer and its remark. */
|
||||
function include(fields) {
|
||||
state.block += 1;
|
||||
@@ -70,7 +88,9 @@ const server = createServer(async (req, res) => {
|
||||
if (req.method === 'OPTIONS') return send(res, 204, undefined, true);
|
||||
|
||||
if (url.pathname === '/rpc' && req.method === 'POST') {
|
||||
if (state.nodeDown) return send(res, 503, { error: 'unavailable' }, true);
|
||||
const body = await read(req);
|
||||
if (body.method === 'system_health') return send(res, 200, { jsonrpc: '2.0', id: body.id, result: { peers: 8, isSyncing: false, shouldHavePeers: true } }, true);
|
||||
if (body.method !== 'author_submitExtrinsic') return send(res, 200, { jsonrpc: '2.0', id: body.id, error: { code: -32601, message: 'Method not found' } }, true);
|
||||
let fields;
|
||||
try {
|
||||
@@ -90,11 +110,19 @@ const server = createServer(async (req, res) => {
|
||||
const hash = body.variables?.hash;
|
||||
// Only the query of the ballots; the others (size of the web of trust) get nothing, as from a dead indexer.
|
||||
if (state.silent || !hash || !String(body.query ?? '').includes('txComments')) return send(res, 502, { error: 'unavailable' });
|
||||
const nodes = state.comments
|
||||
.filter((c) => !c.hidden && remarkHash(c.remark) === hash)
|
||||
.sort((a, b) => a.blockNumber - b.blockNumber)
|
||||
const all = state.comments
|
||||
.filter((c) => !c.hidden && remarkHash(c.remark) === hash && kept(c, body.variables?.filter))
|
||||
.sort((a, b) => a.blockNumber - b.blockNumber || a.id.localeCompare(b.id))
|
||||
.map(({ hidden: _, ...c }) => c);
|
||||
return send(res, 200, { data: { txComments: { nodes } } });
|
||||
const start = Number(/^at:(\d+)$/.exec(body.variables?.after ?? '')?.[1] ?? 0);
|
||||
const nodes = all.slice(start, start + pageSize(String(body.query)));
|
||||
const end = start + nodes.length;
|
||||
return send(res, 200, {
|
||||
data: {
|
||||
squidStatus: { height: state.block },
|
||||
txComments: { pageInfo: { hasNextPage: end < all.length, endCursor: `at:${end}` }, nodes },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/control/') && req.method === 'POST') {
|
||||
@@ -105,6 +133,7 @@ const server = createServer(async (req, res) => {
|
||||
state.hiding = false;
|
||||
for (const c of state.comments) c.hidden = false;
|
||||
} else if (what === 'silent') state.silent = url.searchParams.get('on') !== '0';
|
||||
else if (what === 'node') state.nodeDown = url.searchParams.get('down') === '1';
|
||||
else if (what !== 'state') return send(res, 404, { error: 'unknown' }, true);
|
||||
return send(res, 200, { block: state.block, submitted: state.submitted, hidden: state.comments.filter((c) => c.hidden).length }, true);
|
||||
}
|
||||
|
||||
+59
-3
@@ -9,6 +9,7 @@ export const VOTE_ACCOUNT = 'g1L5EYvvSBaYuQCPW78bHxYcvHfmWAqHYe3Gsrp9SV4kWZaEM';
|
||||
export const ALICE = { address: 'g1Pn1geRZnHcVdWdKkoYwQrAARDTXK1D7H3zuXj7GNayT1thu', name: 'Alice' };
|
||||
export const BOB = { address: 'g1NCrQaFReZcbUW8pKsazfWFCKNSdrTomnDraUMybJc4DFF5g', name: 'Bob' };
|
||||
export const CHARLIE = { address: 'g1NFWUkPTyZWE77YUrfBKfAhV9W81qmgdD17v1NGhb1EjYFqi', name: 'Charlie' };
|
||||
export const EVE = { address: 'g1QBoGEqZuxL77pF4rMa25f1cH6HAxwgBq66zZu76JPqsaVgj', name: 'Eve' };
|
||||
|
||||
export async function signIn(page: Page, name: string, admin = false) {
|
||||
const r = await page.request.post('/api/auth/dev', {
|
||||
@@ -28,12 +29,19 @@ export async function person(browser: Browser, name: string, admin = false): Pro
|
||||
/**
|
||||
* A fake wallet extension, as duniter-connect offers itself to pages
|
||||
* (`window.injectedWeb3`): it lists `accounts` and signs whatever it is given,
|
||||
* recording it in `window.__ldSigned`; `window.__ldRefuse = true` makes it refuse.
|
||||
* recording it in `window.__ldSigned`; `window.__ldRefuse = true` makes it refuse;
|
||||
* `window.__ldHold = true` makes it wait, as a person who has not answered yet,
|
||||
* until `window.__ldAnswer(true | false)` (sign, or refuse).
|
||||
* It signs nothing real: the page, in test mode, sends to the simulated node.
|
||||
*/
|
||||
export async function wallet(page: Page, accounts: { address: string; name: string }[]) {
|
||||
await page.addInitScript((list) => {
|
||||
const w = window as unknown as Record<string, unknown> & { __ldSigned: unknown[]; __ldRefuse?: boolean };
|
||||
const w = window as unknown as Record<string, unknown> & {
|
||||
__ldSigned: unknown[];
|
||||
__ldRefuse?: boolean;
|
||||
__ldHold?: boolean;
|
||||
__ldAnswer?: (yes: boolean) => void;
|
||||
};
|
||||
w.__ldSigned = [];
|
||||
w.injectedWeb3 = {
|
||||
'duniter-connect': {
|
||||
@@ -42,6 +50,10 @@ export async function wallet(page: Page, accounts: { address: string; name: stri
|
||||
accounts: { get: async () => list, subscribe: () => () => undefined },
|
||||
signer: {
|
||||
signPayload: async (payload: Record<string, unknown>) => {
|
||||
if (w.__ldHold) {
|
||||
const yes = await new Promise<boolean>((resolve) => (w.__ldAnswer = resolve));
|
||||
if (!yes) throw new Error('Cancelled');
|
||||
}
|
||||
if (w.__ldRefuse) throw new Error('Cancelled');
|
||||
w.__ldSigned.push(payload);
|
||||
return { id: w.__ldSigned.length, signature: `0x${'01'.repeat(64)}` };
|
||||
@@ -69,6 +81,49 @@ export async function shot(page: Page, name: string) {
|
||||
if (dir) await page.screenshot({ path: `${dir}/${name}.png` });
|
||||
}
|
||||
|
||||
const CSRF = { 'x-requested-with': 'libredecision' };
|
||||
|
||||
/** The document of the test group, as the server gives it. */
|
||||
export async function testDoc(page: Page): Promise<{
|
||||
id: string;
|
||||
units: { id: string; code: string; inForce: { id: string; title: string } | null; proposals: { id: string; title: string }[] }[];
|
||||
}> {
|
||||
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 { id: d.doc.id, units: d.units };
|
||||
}
|
||||
|
||||
/** A new article proposed by the person of `page`, submitted to the vote; returns its id. */
|
||||
export async function propose(page: Page, title: string, body: string): Promise<string> {
|
||||
const doc = await testDoc(page);
|
||||
const r = await page.request.post(`/api/docs/${doc.id}/proposals`, {
|
||||
data: { kind: 'text', unitId: null, title, body, submit: true },
|
||||
headers: CSRF,
|
||||
});
|
||||
if (!r.ok()) throw new Error(`propose ${r.status()} ${await r.text()}`);
|
||||
return (await r.json()).id;
|
||||
}
|
||||
|
||||
/** Moves the server's clock forward (dev login only) and passes the deadlines at once. */
|
||||
export async function advanceClock(page: Page, seconds: number): Promise<{ lifted: number; applied: number; stale: number }> {
|
||||
const r = await page.request.post('/api/dev/clock', { data: { seconds }, headers: CSRF });
|
||||
if (!r.ok()) throw new Error(`clock ${r.status()}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/** Screenshots on a desktop and on a phone, when `LD_SHOTS` names a directory (never in the loop). */
|
||||
export async function shots(page: Page, name: string) {
|
||||
if (!process.env.LD_SHOTS) return;
|
||||
const size = page.viewportSize();
|
||||
await page.waitForTimeout(400);
|
||||
await shot(page, `${name}-bureau`);
|
||||
await page.setViewportSize({ width: 412, height: 915 });
|
||||
await page.waitForTimeout(200);
|
||||
await shot(page, `${name}-mobile`);
|
||||
if (size) await page.setViewportSize(size);
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
/** The control door of the simulated chain, found where the page finds its node. */
|
||||
export async function chain(page: Page, docPath = DOC) {
|
||||
const [, , group] = docPath.split('/');
|
||||
@@ -79,6 +134,7 @@ export async function chain(page: Page, docPath = DOC) {
|
||||
return {
|
||||
doc: d as { doc: { id: string }; units: { code: string; inForce: { id: string } | null; proposals: { id: string }[] }[] },
|
||||
node,
|
||||
control: async (what: 'hide' | 'show' | 'release' | 'state' | 'silent') => (await page.request.post(`${base}/control/${what}`)).json(),
|
||||
control: async (what: 'hide' | 'show' | 'release' | 'state' | 'silent' | 'node?down=1' | 'node?down=0') =>
|
||||
(await page.request.post(`${base}/control/${what}`)).json(),
|
||||
};
|
||||
}
|
||||
|
||||
+35
-19
@@ -1,4 +1,4 @@
|
||||
import { devices, expect, test, type Page } from '@playwright/test';
|
||||
import { devices, expect, test, type Browser, 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
|
||||
@@ -17,6 +17,31 @@ async function voteFor(page: Page, row: string) {
|
||||
await page.locator('ul.deep section.fold').getByRole('button', { name: 'Pour', exact: true }).click();
|
||||
}
|
||||
|
||||
/** Whether a version of a module of the test group is in force now. */
|
||||
async function inForce(page: Page, proposalId: string): Promise<boolean> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The voices that bring a module version of the test group into force: hers, then a
|
||||
* second one if hers was not enough. Every dev sign-in joins the test group: from 16
|
||||
* members B^W vanishes and, C being 1, one voice may suffice (ADR 0007). Returns the
|
||||
* number of voices for it.
|
||||
*/
|
||||
async function secondVoice(page: Page, browser: Browser, proposalId: string, row: string, news: string): Promise<number> {
|
||||
if (await inForce(page, proposalId)) {
|
||||
await expect(page.getByText(news)).toBeVisible();
|
||||
return 1;
|
||||
}
|
||||
const yvv = await person(browser, 'Yvv');
|
||||
await yvv.goto(TEST_DOC);
|
||||
await voteFor(yvv, row);
|
||||
await expect(yvv.getByText(news)).toBeVisible();
|
||||
return 2;
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -31,17 +56,14 @@ test('C25 — l’ordre des articles : en fin de section, proposé en cliquant,
|
||||
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.
|
||||
// Its vote unfolds under it, like an article's, the list whole (the mark's real number of
|
||||
// votes is seen in Monnaie libre, below: here one voice may confirm a seed).
|
||||
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.
|
||||
@@ -82,25 +104,22 @@ test('C25 — l’ordre des articles : en fin de section, proposé en cliquant,
|
||||
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}`);
|
||||
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, 'c25-3-ordre-vote');
|
||||
// Into force (her voice, or a second one): the section reads in its new order.
|
||||
const voices = await secondVoice(page, browser, pid, 'Ordre des articles · Conclusion', 'Le nouvel ordre de « Conclusion » est entré en vigueur.');
|
||||
|
||||
// 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 expect(order.locator('section.fold')).toContainText(`${voices} pour · 0 contre`);
|
||||
await order.scrollIntoViewIfNeeded();
|
||||
await shot(page, 'c25-3b-ordre-en-vigueur');
|
||||
await expect(order).toContainText(/adopté le/);
|
||||
@@ -131,13 +150,10 @@ test('C26 — le titre du document : proposé et voté en tête, ses traductions
|
||||
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');
|
||||
const pid = ((await fold.getAttribute('id')) ?? '').replace(/^vote-/, '');
|
||||
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();
|
||||
const voices = await secondVoice(page, browser, pid, next, `Le titre « ${next} » est entré en vigueur.`);
|
||||
|
||||
// The document changes title; its translations stay editorial, to review.
|
||||
await page.reload();
|
||||
@@ -148,7 +164,7 @@ test('C26 — le titre du document : proposé et voté en tête, ses traductions
|
||||
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 expect(vote.getByText(`${voices} 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();
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { advanceClock, person, propose, shots, TEST_DOC, testDoc } from './helpers';
|
||||
|
||||
// Lot F (Yvv, 2026-10-30, ADR 0010): an objection « redit » or « contredit »
|
||||
// holds back the later of two versions until it is lifted; the author answers,
|
||||
// the objector lifts or reiterates from the box « Messages », and with no answer
|
||||
// within 3 days it is lifted. A typo fix to someone else's text waits for its
|
||||
// author (an administrator for a seed text) or for 3 days. Each control has its
|
||||
// own people and its own proposals, in the test group.
|
||||
|
||||
const DAY = 86_400;
|
||||
|
||||
/** The menu's « Messages », with its number of unread messages. */
|
||||
async function openBox(page: Page, unread: number) {
|
||||
const nav = page.getByRole('navigation', { name: 'Boîte à outils' });
|
||||
const link = nav.getByRole('link', { name: `Messages, ${unread} ${unread > 1 ? 'non lus' : 'non lu'}` });
|
||||
await expect(link).toBeVisible();
|
||||
await expect(link.locator('.unread')).toHaveText(String(unread));
|
||||
await link.click();
|
||||
await expect(page.getByRole('heading', { level: 1, name: 'Messages' })).toBeVisible();
|
||||
}
|
||||
|
||||
/** The details of a version, in the panel. */
|
||||
async function details(page: Page, id: string) {
|
||||
await page.goto(`${TEST_DOC}?onglet=details&version=${id}`);
|
||||
const panel = page.locator('aside.panel');
|
||||
await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true');
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** From the details of a version: an objection against an article in force. */
|
||||
async function raise(page: Page, id: string, kind: 'Redit' | 'Contredit', target: string, reason: string) {
|
||||
const panel = await details(page, id);
|
||||
await panel.getByText('Faire une objection').click();
|
||||
await panel.locator(`.kinds label:has(input[value="${kind === 'Redit' ? 'redit' : 'contredit'}"])`).click();
|
||||
await expect(panel.getByRole('radio', { name: new RegExp(`^${kind}`) })).toBeChecked();
|
||||
await panel.getByLabel('Quel article ?').selectOption({ label: `En vigueur — ${target}` });
|
||||
await panel.getByLabel(kind === 'Redit' ? 'En quoi ces deux textes disent la même chose' : 'En quoi ces deux textes se contredisent').fill(reason);
|
||||
await panel.getByRole('button', { name: "Envoyer l'objection" }).click();
|
||||
await expect(page.getByText("Objection enregistrée : la dernière arrivée des deux versions ne se vote plus jusqu'à sa levée.")).toBeVisible();
|
||||
return panel;
|
||||
}
|
||||
|
||||
test('C28 — « redit » : la dernière arrivée ne se vote plus, l’auteur modifie l’article visé, la personne qui objecte lève', async ({ browser }) => {
|
||||
const aude = await person(browser, 'Aude');
|
||||
const oscar = await person(browser, 'Oscar');
|
||||
const vera = await person(browser, 'Vera');
|
||||
const title = 'Une seule clé par personne';
|
||||
const id = await propose(aude, title, "Je m'engage à n'avoir qu'un seul compte membre : une seule clé certifiante par être humain.");
|
||||
|
||||
// Oscar: it repeats « Unicité du compte ».
|
||||
const panel = await raise(oscar, id, 'Redit', 'Unicité du compte', 'Unicité du compte dit déjà exactement cela.');
|
||||
await expect(panel.locator('.objection')).toContainText(`« ${title} » redit « Unicité du compte »`);
|
||||
await expect(panel.getByText('cette version ne se vote plus')).toBeVisible();
|
||||
await expect(panel.getByRole('button', { name: "Lever l'objection" })).toBeVisible();
|
||||
await shots(oscar, 'f1-objection-vue-par-qui-objecte');
|
||||
|
||||
// Vera, who votes: held back, said so, nothing to press; the list marks it.
|
||||
await vera.goto(`${TEST_DOC}?vote=${id}`);
|
||||
const fold = vera.getByRole('region', { name: `Le vote de « ${title} »` });
|
||||
await expect(fold.getByText("Une objection retient cette version : elle ne se vote plus jusqu'à sa levée. Les votes déjà faits restent.")).toBeVisible();
|
||||
await expect(fold.getByText('Elle redit « Unicité du compte ».')).toBeVisible();
|
||||
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toHaveCount(0);
|
||||
await expect(vera.locator('ul.deep button.row', { hasText: title })).toContainText('non votable : objection');
|
||||
await fold.scrollIntoViewIfNeeded();
|
||||
await shots(vera, 'f2-objection-vue-par-qui-vote');
|
||||
|
||||
// Aude: a message, and the way to answer — modify the article repeated.
|
||||
await aude.goto(TEST_DOC);
|
||||
await openBox(aude, 1);
|
||||
const raised = aude.locator('li.message', { hasText: 'Oscar fait une objection' });
|
||||
await expect(raised).toContainText(`ta version « ${title} » redit « Unicité du compte »`);
|
||||
await expect(raised).toContainText("Pour répondre, modifie l'article visé.");
|
||||
await shots(aude, 'f3-messages-auteur');
|
||||
await raised.getByRole('button', { name: "Modifier l'article visé" }).click();
|
||||
await expect(aude).toHaveURL(/onglet=proposer/);
|
||||
await expect(aude).toHaveURL(/objection=/);
|
||||
const sheet = aude.locator('aside.panel');
|
||||
await expect(sheet.getByText("Cette proposition répond à une objection : une fois soumise, la personne qui l'a faite la lève ou la réitère.")).toBeVisible();
|
||||
const body = sheet.getByLabel('Texte', { exact: true });
|
||||
await expect(body).toHaveValue(/Je m.engage sur l.honneur/);
|
||||
await body.fill(`${await body.inputValue()}\n\nUne seule clé certifiante par être humain, et un seul compte membre.`);
|
||||
await shots(aude, 'f4-reponse-auteur');
|
||||
await sheet.getByRole('button', { name: 'Soumettre au vote' }).click();
|
||||
await expect(aude.getByText('Proposition soumise au vote.')).toBeVisible();
|
||||
|
||||
// Oscar: the answer, to lift or reiterate, with its deadline.
|
||||
await oscar.goto(TEST_DOC);
|
||||
await openBox(oscar, 1);
|
||||
const answered = oscar.locator('li.message', { hasText: 'a reçu une réponse' });
|
||||
await expect(answered).toContainText(`Ton objection sur « ${title} » a reçu une réponse.`);
|
||||
await expect(answered.getByText(/^échéance : /)).toBeVisible();
|
||||
await expect(answered.getByRole('button', { name: "Réitérer l'objection" })).toBeVisible();
|
||||
await shots(oscar, 'f5-messages-qui-objecte');
|
||||
await answered.getByRole('button', { name: "Lever l'objection" }).click();
|
||||
await expect(oscar.getByText('Objection levée.')).toBeVisible();
|
||||
// Dealt with: no gesture any more, and read.
|
||||
await expect(answered.getByText('objection levée', { exact: true })).toBeVisible();
|
||||
await expect(answered.locator('.btn')).toHaveCount(0);
|
||||
await expect(oscar.getByRole('navigation', { name: 'Boîte à outils' }).getByRole('link', { name: 'Messages', exact: true })).toBeVisible();
|
||||
|
||||
// Vera: it is voted on again.
|
||||
await vera.reload();
|
||||
await expect(fold.getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
|
||||
await expect(fold.getByText('Une objection retient cette version', { exact: false })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('C29 — « contredit » : l’auteur reformule, sans réponse de la personne qui objecte l’objection est levée à l’échéance', async ({ browser }) => {
|
||||
const aude = await person(browser, 'Aude');
|
||||
const oscar = await person(browser, 'Oscar');
|
||||
const vera = await person(browser, 'Vera');
|
||||
const title = 'Certification à sens unique';
|
||||
const id = await propose(aude, title, "Je certifie une personne sans attendre qu'elle me certifie à son tour.");
|
||||
await raise(oscar, id, 'Contredit', 'Réciprocité', 'Réciprocité demande que la certification aille dans les deux sens.');
|
||||
|
||||
// Aude, from the vote under the article: reword it.
|
||||
await aude.goto(`${TEST_DOC}?vote=${id}`);
|
||||
const fold = aude.getByRole('region', { name: `Le vote de « ${title} »` });
|
||||
await expect(fold.getByText('Elle contredit « Réciprocité ».')).toBeVisible();
|
||||
await expect(fold.getByText("Pour qu'elle se vote à nouveau, reformule-la.", { exact: false })).toBeVisible();
|
||||
await fold.scrollIntoViewIfNeeded();
|
||||
await shots(aude, 'f8-objection-vue-par-l-auteur');
|
||||
await fold.getByRole('button', { name: 'Reformuler' }).click();
|
||||
await expect(aude).toHaveURL(/modifier=1/);
|
||||
const panel = aude.locator('aside.panel');
|
||||
const body = panel.getByLabel('Texte', { exact: true });
|
||||
await expect(body).toBeVisible();
|
||||
await body.fill("Je certifie une personne et j'attends qu'elle me certifie à son tour, comme le veut la réciprocité.");
|
||||
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||||
await expect(aude.getByText('Nouvelle version enregistrée : les votants devront reconfirmer.')).toBeVisible();
|
||||
await expect(fold.getByText(/^Une réponse est arrivée : Oscar lève ou réitère l'objection d'ici le /)).toBeVisible();
|
||||
|
||||
// Oscar says nothing; three days pass on the server's clock.
|
||||
const swept = await advanceClock(oscar, 3 * DAY + 60);
|
||||
expect(swept.lifted).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Vera: votable again; the objection says why it was lifted.
|
||||
const panelV = await details(vera, id);
|
||||
await expect(panelV.locator('.objection')).toContainText('levée : 3 jours sans suite');
|
||||
await vera.goto(`${TEST_DOC}?vote=${id}`);
|
||||
await expect(vera.getByRole('region', { name: `Le vote de « ${title} »` }).getByRole('button', { name: 'Pour', exact: true })).toBeVisible();
|
||||
|
||||
// Oscar's message: dealt with, by the deadline.
|
||||
await oscar.goto('/messages');
|
||||
const answered = oscar.locator('li.message', { hasText: `Ton objection sur « ${title} »` });
|
||||
await expect(answered.getByText('objection levée : 3 jours sans suite')).toBeVisible();
|
||||
await expect(answered.locator('.btn')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('C30 — la coquille d’un autre sur une proposition : en attente, puis validée par l’auteur depuis ses messages', async ({ browser }) => {
|
||||
const aude = await person(browser, 'Aude');
|
||||
const vera = await person(browser, 'Vera');
|
||||
const title = 'Clé unique et vivante';
|
||||
const id = await propose(aude, title, "Je m'engage à n'utiliser qu'une seule clé certifiante, tenue par moi seul.");
|
||||
|
||||
// Vera fixes the apostrophes: it waits for Aude, the text stays as it was.
|
||||
const panel = await details(vera, id);
|
||||
await panel.getByText('Corriger une coquille').click();
|
||||
const body = panel.getByLabel('Texte', { exact: true });
|
||||
await body.fill((await body.inputValue()).replaceAll("'", '’'));
|
||||
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||||
await expect(vera.getByText(/^Ta correction attend l'auteur du texte : elle s'applique quand il la valide, ou d'elle-même le /)).toBeVisible();
|
||||
const fix = panel.locator('li.fix');
|
||||
await expect(fix).toContainText('proposée par Vera');
|
||||
await expect(fix).toContainText("en attente : s'applique d'elle-même le");
|
||||
await expect(fix.locator('ins')).toHaveCount(3);
|
||||
await expect(panel.locator('.prose').first()).toContainText("Je m'engage à n'utiliser");
|
||||
await shots(vera, 'f6-coquille-en-attente');
|
||||
|
||||
// Aude validates it from her box.
|
||||
await aude.goto(TEST_DOC);
|
||||
await openBox(aude, 1);
|
||||
const msg = aude.locator('li.message', { hasText: `Vera propose une correction de coquille dans ta version « ${title} ».` });
|
||||
await expect(msg.locator('ins')).toHaveCount(3);
|
||||
await expect(msg.getByText(/^échéance : /)).toBeVisible();
|
||||
await msg.getByRole('button', { name: 'Valider' }).click();
|
||||
await expect(aude.getByText('Correction appliquée.')).toBeVisible();
|
||||
await expect(msg.getByText('correction appliquée', { exact: true })).toBeVisible();
|
||||
await expect(msg.locator('.btn')).toHaveCount(0);
|
||||
|
||||
// The text carries the fix; its history says who wrote it and who let it in.
|
||||
const after = await details(vera, id);
|
||||
await expect(after.locator('.prose').first()).toContainText('Je m’engage à n’utiliser');
|
||||
await expect(after.locator('.history')).toContainText('coquille corrigée');
|
||||
await expect(after.locator('.history')).toContainText('par Vera');
|
||||
await expect(after.locator('.history')).toContainText('validée par Aude');
|
||||
await expect(after.locator('li.fix')).toContainText('appliquée · par Aude');
|
||||
});
|
||||
|
||||
test('C31 — une coquille sur un texte en vigueur attend sa validation, puis s’applique', async ({ browser }) => {
|
||||
// An administrator opens the group first: the message goes to them (a seed text has no author).
|
||||
const ada = await person(browser, 'Ada', true);
|
||||
await ada.goto(TEST_DOC);
|
||||
const vera = await person(browser, 'Vera');
|
||||
await vera.goto(TEST_DOC);
|
||||
const purpose = vera.locator('section.text article.unit', { hasText: "Raison d'être" });
|
||||
await purpose.hover();
|
||||
await purpose.getByRole('button', { name: 'Le vote' }).click();
|
||||
await purpose.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click();
|
||||
const panel = vera.locator('aside.panel');
|
||||
await panel.getByText('Corriger une coquille').click();
|
||||
const body = panel.getByLabel('Texte', { exact: true });
|
||||
await body.fill((await body.inputValue()).replace("C'est le seul", 'C’est le seul'));
|
||||
await panel.getByRole('button', { name: 'Enregistrer la correction' }).click();
|
||||
await expect(vera.getByText(/^Ta correction attend un administrateur, ce texte venant du seed/)).toBeVisible();
|
||||
// In force, only a validated fix changes the text.
|
||||
await expect(purpose).toContainText("C'est le seul et unique propos");
|
||||
await expect(panel.locator('li.fix')).toContainText("en attente : s'applique d'elle-même le");
|
||||
|
||||
await ada.reload();
|
||||
await openBox(ada, 1);
|
||||
const msg = ada.locator('li.message', { hasText: "Vera propose une correction de coquille dans « Raison d'être », un texte du seed" });
|
||||
await shots(ada, 'f7-messages-administrateur');
|
||||
await msg.getByRole('button', { name: 'Valider' }).click();
|
||||
await expect(ada.getByText('Correction appliquée.')).toBeVisible();
|
||||
|
||||
await vera.reload();
|
||||
await expect(purpose).toContainText('C’est le seul et unique propos');
|
||||
const doc = await testDoc(vera);
|
||||
const unit = doc.units.find((u) => u.inForce?.title === "Raison d'être");
|
||||
const after = await details(vera, unit?.inForce?.id ?? '');
|
||||
await expect(after.locator('.history')).toContainText('validée par Ada');
|
||||
});
|
||||
@@ -92,13 +92,15 @@ test('C5 — une proposition remonte en vigueur quand elle atteint son seuil (gr
|
||||
});
|
||||
|
||||
test('C6 — une coquille se corrige sans vote', async ({ page }) => {
|
||||
await signIn(page, 'Chiara');
|
||||
// A seed text has no author: an administrator's fix applies at once (someone
|
||||
// else's waits for an administrator, or for 3 days: C31).
|
||||
await signIn(page, 'Yvv', true);
|
||||
await page.goto(TEST_DOC);
|
||||
const unicity = page.locator('section.text article.unit', { hasText: 'Unicité du compte' });
|
||||
await unicity.hover();
|
||||
await unicity.getByRole('button', { name: 'Le vote' }).click();
|
||||
// What is not the vote lives in the panel, tab « Détails ».
|
||||
await unicity.getByRole('button', { name: 'Coquilles, traductions, signalements, historique' }).click();
|
||||
await unicity.getByRole('button', { name: 'Coquilles, traductions, objections, historique' }).click();
|
||||
const panel = page.locator('aside.panel');
|
||||
await expect(panel.getByRole('tab', { name: 'Détails' })).toHaveAttribute('aria-selected', 'true');
|
||||
await panel.getByText('Corriger une coquille').click();
|
||||
|
||||
+10
-2
@@ -79,8 +79,10 @@ test('C22 — le vote groupé : une section en un geste, et l’article à deux
|
||||
expect(writes.filter((u) => u.endsWith('/votes'))).toHaveLength(1);
|
||||
expect(writes.filter((u) => u.endsWith('/vote'))).toHaveLength(0);
|
||||
await expect(panel.getByText("Rien dans le panier pour l'instant.")).toBeVisible();
|
||||
// Each counted: her version of E3 has its voice, the other none.
|
||||
await expect(e3.getByRole('group', { name: /variante 1\.0\.0/ }).locator('..')).toContainText('1 pour · 0 contre · ton vote : pour');
|
||||
// Each counted: her version of E3 has its voice, the other none. Here every
|
||||
// dev sign-in joins the test group: from 16 members B^W vanishes and, C being 1,
|
||||
// her voice alone may put her version in force (ADR 0007).
|
||||
await expect(e3.getByRole('group', { name: /variante 1\.0\.0|en vigueur/ }).locator('..')).toContainText('1 pour · 0 contre · ton vote : pour');
|
||||
await expect(e3.getByRole('group', { name: /variante 0\.4\.0-draft/ }).locator('..')).toContainText('0 pour · 0 contre');
|
||||
});
|
||||
|
||||
@@ -200,6 +202,12 @@ test('C24 — sans compte de vote, le vote par la chaîne est fermé et le dit ;
|
||||
await expect(fold.getByText('Seuil : 50 voix pour')).toBeVisible();
|
||||
await fold.getByText('Ton vote ne se montre à personne', { exact: false }).scrollIntoViewIfNeeded();
|
||||
await shot(page, '6-vote-ferme');
|
||||
// The basket, opened by its address, says the vote is closed — never « Tes votes comptent dès l'envoi ».
|
||||
await page.goto(`${DOC}?onglet=panier`);
|
||||
const basket = page.locator('aside.panel');
|
||||
await expect(basket.getByText("Le vote par la chaîne Ğ1 n'est pas encore ouvert", { exact: false })).toBeVisible();
|
||||
await expect(basket.getByText("Tes votes comptent dès l'envoi", { exact: false })).toHaveCount(0);
|
||||
await expect(basket.getByRole('button', { name: /^Signer et envoyer|^Voter \(/ })).toBeDisabled();
|
||||
// The test group still votes, directly.
|
||||
await page.goto(TEST_DOC);
|
||||
await expect(page.getByRole('button', { name: /^Voter en lot/ })).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user