diff --git a/web/e2e/vote.spec.ts b/web/e2e/vote.spec.ts index 2126685..132e719 100644 --- a/web/e2e/vote.spec.ts +++ b/web/e2e/vote.spec.ts @@ -260,6 +260,10 @@ test('C34 — la chaîne fermée, Monnaie libre vote quand même : le vote simul await expect(fold.getByText("La chaîne Ğ1 n'est pas encore ouverte : ton vote est simulé.", { exact: false })).toHaveCount(1); const n = before.p.tallySimulated.against + 1; await expect(fold.getByText(n === 1 ? 'dont 1 simulé' : `dont ${n} simulés`, { exact: true })).toBeVisible(); + // The line reads with its space: never « 0 contredont 1 simulé » (review of M6b). + await expect(fold.locator('p.tally')).toHaveText( + `0 pour · ${before.p.tally.against + 1} contre ${n === 1 ? 'dont 1 simulé' : `dont ${n} simulés`}`, + ); // Counted at once, marked as simulated. const after = await version(page, title); expect(after.p.myVoteState).toBe('simulated'); diff --git a/web/src/lib/components/VersionDetails.svelte b/web/src/lib/components/VersionDetails.svelte index 3457cd6..a511600 100644 --- a/web/src/lib/components/VersionDetails.svelte +++ b/web/src/lib/components/VersionDetails.svelte @@ -242,7 +242,7 @@ {:else if p.confirmedAt}{t('doc.confirmed')} {:else if p.enteredAt && p.status === 'in_force'}{t('doc.adopted', { date: i18n.date(p.enteredAt) })}{/if} {#if p.blocked}{t('deep.held')}{/if} - {#if p.author}{t('panel.by', { name: p.author })}{#if p.authorInvited} · {t('author.invited')}{/if}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if} + {#if p.author}{t('panel.by', { name: p.author })}{#if p.authorInvited}{' '}· {t('author.invited')}{/if}{:else if p.origin === 'seed'}{t('panel.seed.origin')}{/if}

@@ -255,7 +255,7 @@ {#if detail.diff} {/if} - {#if text.translated}

{t('doc.lang.translation')}{#if text.stale} · {t('doc.lang.stale')}{/if}

{/if} + {#if text.translated}

{t('doc.lang.translation')}{#if text.stale}{' '}· {t('doc.lang.stale')}{/if}

{/if} {:else if p.kind === 'remove' && detail.inForce} {:else if p.kind === 'order'} @@ -266,7 +266,7 @@ {#each orderNote({ ...p, order }) as note (note)}

{note}

{/each}

{t('module.order.rule')}

{:else if p.kind === 'title'} -

{t('module.title.name')} · {inertiaBadge(detail.unit.inertia)}{#if text.translated} · {t('doc.lang.translation')}{#if text.stale} · {t('doc.lang.stale')}{/if}{/if}

+

{t('module.title.name')} · {inertiaBadge(detail.unit.inertia)}{#if text.translated}{' '}· {t('doc.lang.translation')}{#if text.stale}{' '}· {t('doc.lang.stale')}{/if}{/if}

{/if} {#if live} @@ -302,7 +302,7 @@
  • - {t('typo.by', { name: fix.writer ?? '' })}{#if fix.writerInvited} · {t('author.invited')}{/if} · {i18n.date(fix.at)} + {t('typo.by', { name: fix.writer ?? '' })}{#if fix.writerInvited}{' '}· {t('author.invited')}{/if} · {i18n.date(fix.at)}

    {typoState(fix)}

    {#if fix.gestures.length} @@ -339,7 +339,7 @@ {#each detail.history.slice(1) as h (h.at + h.revision)}
  • - {t(`history.${h.kind}`)} · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if} + {t(`history.${h.kind}`)} · {i18n.date(h.at)}{#if h.author}{' '}· {t('history.by', { name: h.author })}{#if h.authorInvited}{' '}· {t('author.invited')}{/if}{/if} {#if h.typo?.deadline} · {t('history.typo.deadline')}{:else if h.typo?.answeredBy} · {t('history.typo.validated', { name: h.typo.answeredBy })}{/if}

    {#if h.diff}{/if} @@ -361,8 +361,8 @@

    {o.reason}

    - {t('objection.by', { name: o.objector ?? '' })}{#if o.objectorInvited} · {t('author.invited')}{/if} · {i18n.date(o.at)} - · {stateOf(o)}{#if standing && o.answer} · {t(`objection.answer.${o.answer}`)}{/if} + {t('objection.by', { name: o.objector ?? '' })}{#if o.objectorInvited}{' '}· {t('author.invited')}{/if} · {i18n.date(o.at)} + · {stateOf(o)}{#if standing && o.answer}{' '}· {t(`objection.answer.${o.answer}`)}{/if}

    {#if standing && o.held}

    {t('objection.held.here')}

    {/if} {#if standing} diff --git a/web/src/lib/components/VoteFold.svelte b/web/src/lib/components/VoteFold.svelte index 201424f..1deecb0 100644 --- a/web/src/lib/components/VoteFold.svelte +++ b/web/src/lib/components/VoteFold.svelte @@ -160,7 +160,7 @@
    -

    {t('tally.short', { for: p.tally.for, against: p.tally.against })}{#if p.tallySimulated?.for || p.tallySimulated?.against} {i18n.plural('vote.tally.simulated', p.tallySimulated.for + p.tallySimulated.against)}{/if}

    +

    {t('tally.short', { for: p.tally.for, against: p.tally.against })}{#if p.tallySimulated?.for || p.tallySimulated?.against}{' '}{i18n.plural('vote.tally.simulated', p.tallySimulated.for + p.tallySimulated.against)}{/if}

    {#if p.evaluation} {#if need}

    {need.long}

    {/if}

    {p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}

    diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 787e4e5..73a60fd 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -44,7 +44,7 @@
    diff --git a/web/tests/unit/spaces.test.ts b/web/tests/unit/spaces.test.ts new file mode 100644 index 0000000..e17fcb2 --- /dev/null +++ b/web/tests/unit/spaces.test.ts @@ -0,0 +1,98 @@ +// A space at the start or the end of a block ({#if}, {:else}, {#each}…) is dropped by Svelte, +// in the browser as on the server: glued to what comes before, « {n} contre{#if s} dont +// 1 simulé{/if} » reads « 0 contredont 1 simulé » (review of M6b). Every component is +// read here, and a space a block would drop is pointed at: `{' '}` keeps it. +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { parse } from 'svelte/compiler'; +import { describe, expect, it } from 'vitest'; + +const SRC = join(__dirname, '../../src'); + +function components(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((e) => { + const path = join(dir, e.name); + return e.isDirectory() ? components(path) : e.name.endsWith('.svelte') ? [path] : []; + }); +} + +interface Node { + type: string; + start: number; + data?: string; + elseif?: boolean; + [key: string]: unknown; +} +interface Fragment { + nodes: Node[]; +} + +/** The fragments a block shows, its `{:else if}` chain included. */ +function branches(b: Node): Fragment[] { + const out: Fragment[] = []; + for (const key of ['consequent', 'alternate', 'body', 'fallback', 'pending', 'then', 'catch', 'fragment']) { + const f = b[key] as Fragment | null | undefined; + if (!f?.nodes) continue; + const only = f.nodes.length === 1 ? f.nodes[0] : undefined; + if (key === 'alternate' && only?.type === 'IfBlock' && only.elseif) out.push(...branches(only)); + else out.push(f); + } + return out; +} + +const BLOCKS = new Set(['IfBlock', 'EachBlock', 'AwaitBlock', 'KeyBlock']); +/** A space or a tab, not the newline that only lays the markup out. */ +const LEADS = /^[ \t]/; +const TRAILS = /[ \t]$/; + +/** Each space a block drops while something is glued to it, as `line: text`. */ +function dropped(source: string): string[] { + const ast = parse(source, { modern: true }) as unknown as { fragment: Fragment }; + const out: string[] = []; + const line = (at: number) => source.slice(0, at).split('\n').length; + const walk = (f: Fragment) => { + f.nodes.forEach((n, i) => { + if (BLOCKS.has(n.type)) { + const before = f.nodes[i - 1]; + const after = f.nodes[i + 1]; + const gluedBefore = before !== undefined && !(before.type === 'Text' && /\s$/.test(before.data ?? '')); + const gluedAfter = after !== undefined && !(after.type === 'Text' && /^\s/.test(after.data ?? '')); + for (const b of branches(n)) { + const first = b.nodes[0]; + const last = b.nodes.at(-1); + if (gluedBefore && first?.type === 'Text' && LEADS.test(first.data ?? '')) { + out.push(`${line(first.start)}: « ${(first.data ?? '').slice(0, 30)} »`); + } + if (gluedAfter && last?.type === 'Text' && TRAILS.test(last.data ?? '')) { + out.push(`${line(last.start)}: « ${(last.data ?? '').slice(-30)} »`); + } + } + } + for (const value of Object.values(n)) { + const child = value as Fragment | null; + if (child && typeof child === 'object' && Array.isArray(child.nodes)) walk(child); + } + }); + }; + walk(ast.fragment); + return out; +} + +describe('a space a block would drop', () => { + it('is seen where it is glued', () => { + expect(dropped('

    {a}{#if s} x{/if}

    ')).toHaveLength(1); + expect(dropped('

    {a}{#if s} · x{:else if t} · y{:else} · z{/if}

    ')).toHaveLength(3); + expect(dropped('

    {#each xs as x}{x} {/each}y

    ')).toHaveLength(1); + // Kept: `{' '}`, a space outside the block, or a newline that lays the markup out. + expect(dropped("

    {a}{#if s}{' '}x{/if}

    ")).toEqual([]); + expect(dropped('

    {a} {#if s} · x{/if}

    ')).toEqual([]); + expect(dropped('
    {a}{#if s}\n

    x

    \n{/if}
    ')).toEqual([]); + }); + + it('is never dropped in a component of the interface', () => { + const found = components(SRC).flatMap((path) => + dropped(readFileSync(path, 'utf8')).map((d) => `${relative(SRC, path)}:${d}`), + ); + expect(found).toEqual([]); + }); +}); diff --git a/web/tests/unit/votefold.test.ts b/web/tests/unit/votefold.test.ts new file mode 100644 index 0000000..8a1f90b --- /dev/null +++ b/web/tests/unit/votefold.test.ts @@ -0,0 +1,68 @@ +// The vote unfolded under the article, rendered as the server would (svelte/server): what +// it reads, letter for letter. A space at the start of a block is lost (Svelte trims it): +// « 0 contredont 1 simulé » (review of M6b). +import { render } from 'svelte/server'; +import { describe, expect, it } from 'vitest'; +import VoteFold from '../../src/lib/components/VoteFold.svelte'; +import type { DocState } from '../../src/lib/doc.svelte'; +import type { Proposal, Tally, Unit } from '../../src/lib/types'; + +function proposal(tally: Tally, tallySimulated: Tally): Proposal { + return { + id: 'p1', + unitId: 'u1', + kind: 'text', + inertiaTarget: null, + title: 'Logiciel auditable', + body: '', + 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, + tallyPending: { for: 0, against: 0 }, + tallySimulated, + evaluation: null, + votedAtInertia: 3, + blocked: false, + votable: true, + objections: 0, + myVote: null, + myVoteState: null, + myVoteStale: false, + myVoteChain: false, + myBallot: null, + translations: {}, + }; +} + +function fold(p: Proposal): string { + const unit: Unit = { id: 'u1', code: 'E9', section: null, position: 1, inertia: 3, inertiaChanges: [], inForce: null, proposals: [p] }; + const doc = { view: null, original: false, articleTitle: () => '' } as unknown as DocState; + const noop = () => {}; + const { body } = render(VoteFold, { + props: { doc, proposal: p, unit, onopen: noop, onsettings: noop, ondetails: noop, onanswer: noop, onsign: noop }, + }); + const line = /

    ([\s\S]*?)<\/p>/.exec(body)?.[1]; + if (line === undefined) throw new Error(`no line of totals in ${body}`); + // The text read: tags and the markers of the server rendering left out. + return line.replace(//g, '').replace(/<[^>]+>/g, ''); +} + +describe('the totals of the vote unfolded', () => { + it('say the simulated votes after a space', () => { + expect(fold(proposal({ for: 0, against: 1 }, { for: 0, against: 1 }))).toBe('0 pour · 1 contre dont 1 simulé'); + expect(fold(proposal({ for: 2, against: 1 }, { for: 2, against: 1 }))).toBe('2 pour · 1 contre dont 3 simulés'); + }); + it('say nothing more without a simulated vote', () => { + expect(fold(proposal({ for: 4, against: 2 }, { for: 0, against: 0 }))).toBe('4 pour · 2 contre'); + }); +});