forked from yvv/decision
Lot T2 : l'espace perdu en tête d'un bloc Svelte (« 0 contredont 1 simulé ») ; le vote déplié rendu par svelte/server, une garde sur tous les composants (dix autres espaces collés : détails d'une version, menu) ; C34 lit la ligne entière
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
{:else if p.confirmedAt}<span class="tag moss">{t('doc.confirmed')}</span>
|
||||
{:else if p.enteredAt && p.status === 'in_force'}<span class="tag moss">{t('doc.adopted', { date: i18n.date(p.enteredAt) })}</span>{/if}
|
||||
{#if p.blocked}<span class="tag ochre">{t('deep.held')}</span>{/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}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
{#if detail.diff}
|
||||
<button class="btn quiet small" type="button" onclick={() => (showDiff = !showDiff)}>{showDiff ? t('panel.text') : t('panel.changes')}</button>
|
||||
{/if}
|
||||
{#if text.translated}<p class="faint note">{t('doc.lang.translation')}{#if text.stale} · <b>{t('doc.lang.stale')}</b>{/if}</p>{/if}
|
||||
{#if text.translated}<p class="faint note">{t('doc.lang.translation')}{#if text.stale}{' '}· <b>{t('doc.lang.stale')}</b>{/if}</p>{/if}
|
||||
{:else if p.kind === 'remove' && detail.inForce}
|
||||
<del class="removed"><Prose text={detail.inForce.body} /></del>
|
||||
{:else if p.kind === 'order'}
|
||||
@@ -266,7 +266,7 @@
|
||||
{#each orderNote({ ...p, order }) as note (note)}<p class="faint note">{note}</p>{/each}
|
||||
<p class="faint note">{t('module.order.rule')}</p>
|
||||
{:else if p.kind === 'title'}
|
||||
<p class="faint note">{t('module.title.name')} · {inertiaBadge(detail.unit.inertia)}{#if text.translated} · {t('doc.lang.translation')}{#if text.stale} · <b>{t('doc.lang.stale')}</b>{/if}{/if}</p>
|
||||
<p class="faint note">{t('module.title.name')} · {inertiaBadge(detail.unit.inertia)}{#if text.translated}{' '}· {t('doc.lang.translation')}{#if text.stale}{' '}· <b>{t('doc.lang.stale')}</b>{/if}{/if}</p>
|
||||
{/if}
|
||||
|
||||
{#if live}
|
||||
@@ -302,7 +302,7 @@
|
||||
<li class="fix {fix.status}">
|
||||
<DiffText parts={condense(fix.diff)} />
|
||||
<p class="faint small">
|
||||
{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)}
|
||||
</p>
|
||||
<p class="small state">{typoState(fix)}</p>
|
||||
{#if fix.gestures.length}
|
||||
@@ -339,7 +339,7 @@
|
||||
{#each detail.history.slice(1) as h (h.at + h.revision)}
|
||||
<li>
|
||||
<p class="small">
|
||||
<b>{t(`history.${h.kind}`)}</b> · {i18n.date(h.at)}{#if h.author} · {t('history.by', { name: h.author })}{#if h.authorInvited} · {t('author.invited')}{/if}{/if}
|
||||
<b>{t(`history.${h.kind}`)}</b> · {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}
|
||||
</p>
|
||||
{#if h.diff}<DiffText parts={h.diff} />{/if}
|
||||
@@ -361,8 +361,8 @@
|
||||
</p>
|
||||
<p class="reason">{o.reason}</p>
|
||||
<p class="faint small">
|
||||
{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}
|
||||
</p>
|
||||
{#if standing && o.held}<p class="small held">{t('objection.held.here')}</p>{/if}
|
||||
{#if standing}
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
<div class="level">
|
||||
<WaterLevel tally={p.tally} evaluation={p.evaluation} blocked={p.blocked} size="large" />
|
||||
<div class="numbers">
|
||||
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}{#if p.tallySimulated?.for || p.tallySimulated?.against} <span class="faint small">{i18n.plural('vote.tally.simulated', p.tallySimulated.for + p.tallySimulated.against)}</span>{/if}</p>
|
||||
<p class="tally">{t('tally.short', { for: p.tally.for, against: p.tally.against })}{#if p.tallySimulated?.for || p.tallySimulated?.against}{' '}<span class="faint small">{i18n.plural('vote.tally.simulated', p.tallySimulated.for + p.tallySimulated.against)}</span>{/if}</p>
|
||||
{#if p.evaluation}
|
||||
{#if need}<p class="need" title={need.title}>{need.long}</p>{/if}
|
||||
<p class="state">{p.evaluation.reached ? t('tally.reached') : i18n.plural('tally.missing', p.evaluation.missing)}</p>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<header class="topbar">
|
||||
<a class="brand" href="/"><Logo size={1.2} /></a>
|
||||
<button class="menu" type="button" aria-expanded={drawer} aria-controls="drawer" onclick={() => (drawer = true)}>
|
||||
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}{#if session.unread} · {i18n.plural('nav.messages.count', session.unread)}{/if}</span>
|
||||
<Icon name="menu" /><span class="visually-hidden">{t('nav.menu')}{#if session.unread}{' '}· {i18n.plural('nav.messages.count', session.unread)}{/if}</span>
|
||||
{#if session.unread}<span class="badge" aria-hidden="true">{session.unread}</span>{/if}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -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} <span>dont
|
||||
// 1 simulé</span>{/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('<p>{a}{#if s} <span>x</span>{/if}</p>')).toHaveLength(1);
|
||||
expect(dropped('<p>{a}{#if s} · x{:else if t} · y{:else} · z{/if}</p>')).toHaveLength(3);
|
||||
expect(dropped('<p>{#each xs as x}{x} {/each}<b>y</b></p>')).toHaveLength(1);
|
||||
// Kept: `{' '}`, a space outside the block, or a newline that lays the markup out.
|
||||
expect(dropped("<p>{a}{#if s}{' '}<span>x</span>{/if}</p>")).toEqual([]);
|
||||
expect(dropped('<p>{a} {#if s} · x{/if}</p>')).toEqual([]);
|
||||
expect(dropped('<div>{a}{#if s}\n <p>x</p>\n{/if}</div>')).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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 = /<p class="tally[^"]*">([\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(/<!--[\s\S]*?-->/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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user