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:
Yvv
2026-11-03 09:56:15 +01:00
co-authored by Claude Opus 5.5
parent 3956a3ee83
commit f6c110a60c
6 changed files with 179 additions and 9 deletions
+98
View File
@@ -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([]);
});
});
+68
View File
@@ -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');
});
});