Konubinix' opinionated web of thoughts

Scrutin De Condorcet Randomisé Entre Amis

Braindump

tl;dr: https://sam.konubinix.eu/condorcet/

Pitch et contraintes

Voter à la majorité paraît simple, mais dès qu’il y a plus de deux options la majorité se perd dans les paradoxes. Le scrutin de Condorcet répare ça en regardant chaque duel entre candidats. Quand il n’y a pas de vainqueur net (un cycle), on départage par un tirage au sort dans le Smith set (la short-list des candidats encore en course après élimination des dominés) — reproductible, parce que la graine est le hash des bulletins clos, pas un appel serveur.

Cette app est un outil de vote entre amis. Ce n’est pas un système officiel : on peut bidouiller le bulletin du copain en inspectant IndexedDB. Ça suffit pour un apéro ou un choix de film en famille, et ça tient dans un seul fichier HTML.

Sous le capot : un scrutin = un doc Automerge, dont la donnée vit en local sur chaque navigateur et se synchronise entre amis. Le doc est identifié par une URL automerge:... partageable comme lien d’invitation. Côté UI, petite-vue (un Vue.js minimaliste) gère la réactivité. Pas de roue réinventée — détails en annexe : Bibliothèques externes, Schéma du document Automerge, Store petite-vue.

Les deux modes d’utilisation

L’app supporte deux modes de vote, au choix à la création du scrutin :

  1. Appareil partagé : tout le monde vote sur le même téléphone. Entre deux votes, l’écran montre la liste des votants ; chacun choisit son nom, classe, soumet, et son nom passe à coché « a voté » avant de tendre l’appareil au suivant. L’ordre n’est pas imposé, et la liste — noms et coches uniquement — ne révèle rien des classements déjà soumis : elle sert d’écran-tampon naturel.
  2. Un appareil par personne : chacun ouvre le lien du scrutin sur son téléphone et vote séparément. La propagation passe par un relais WebSocket central, automergesync. La même liste sert d’écran d’identification, à ceci près que l’identité est mémorisée : un reload ne redemande pas qui on est.

Le mode se choisit à la création et conditionne le flux de vote.

Ordre d’affichage neutre

L’ordre dans lequel les candidats apparaissent n’est pas neutre. Le biais de primauté avantage celui qui ouvre la liste ; et l’ordre dans lequel l’organisateur les a saisis suffirait à peser, sans qu’il le veuille, sur le résultat. Pour chaque votant, le bulletin part donc d’un ordre tiré au hasard, indépendant de toute saisie antérieure. En mode appareil partagé, ce tirage rejoué entre votants efface au passage le classement précédent — le secret du bulletin, qui reste important, y trouve un bénéfice non négligeable.

Secret du bulletin

Règle dure : pendant qu’un votant classe ses candidats, il ne doit voir aucun bulletin déjà soumis. L’écran de vote n’affiche jamais la matrice des duels, les totaux partiels, ni la liste des bulletins ; ces vues n’apparaissent qu’après la clôture du scrutin.

Le compteur “bulletins reçus / attendus” et la liste des votants ayant déjà voté sont considérés comme non-secrets : ils n’indiquent pas comment ils ont voté. C’est l’opacité sociale minimale acceptable pour un vote entre amis.

Par ailleurs, rien n’empêche techniquement de cliquer « Modifier » sur le bulletin d’un copain. On fait ici le pari de la confiance : c’est un vote entre amis, pas un scrutin officiel.

Fondations de base (Ouvrir, créer, partager)

Ouvrir l’app

Sans scrutin en cours, il n’y a rien à montrer. Afficher une interface vide ou des contrôles inutiles désorientent. Un seul écran d’entrée — titre, urne, un bouton — et l’utilisateur sait immédiatement quoi faire.

<div class="empty-state" id="emptyState" v-show="!scrutin && !loadingDoc" v-cloak>
  <div class="emoji">&#x1f5f3;</div>
  <h1>Scrutin de Condorcet</h1>
  <p>Aucun scrutin en cours.</p>
  <button class="cta" id="btnOpenCreate" @click="openCreate()">Créer un scrutin</button>
</div>

.empty-state{text-align:center;padding:60px 20px;color:var(--muted)}
.empty-state .emoji{font-size:3rem;margin-bottom:12px}
.empty-state h1{font-size:1.4rem;margin:0 0 8px 0;color:var(--fg)}
.empty-state p{font-size:.95rem;line-height:1.5;margin-bottom:24px}

Liste des scrutins précédents

On revient le lendemain pour rejouer un vote, ou pour relire le résultat d’un scrutin clos — mais l’URL automerge:... est introuvable dans l’historique de navigation, perdue dans la conversation Signal où on l’avait reçue. Le doc, lui, est resté sur l’appareil. On liste donc les scrutins déjà visités depuis l’écran d’accueil, ordonnés du plus récent au plus ancien : un clic re-ouvre le scrutin via son URL ?doc…=, sans recharger autre chose que ce qui est déjà en SW cache.

Un × à droite de chaque ligne retire l’entrée de la liste sans toucher au doc local — l’utilisateur peut toujours revenir au scrutin s’il en retrouve l’URL ailleurs.

« Ailleurs » est une promesse fragile : l’URL automerge:... n’est pas mémorable, et la conversation où on l’avait reçue peut s’être ensevelie. Si l’entrée du past-list est l’unique chemin de retour, sa suppression accidentelle coupe ce chemin. Le × demande donc confirmation, à l’image du retrait d’un votant.

Le test ci-dessous — comme tous ceux du doc — s’appuie sur les helpers Playwright de l’annexe (clear_state, create_scrutin, _role_button, etc.).

@testcase
def test_forget_scrutin_needs_confirmation(page):
    """Le × du past-list demande confirmation ; refuser laisse l'entrée."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"], title="Premier")
    page.wait_for_timeout(300)
    page.goto(BASE_URL)
    page.wait_for_selector("[data-app-ready]")
    page.locator(".past-scrutins").wait_for(state="visible")

    page.once("dialog", lambda d: d.dismiss())
    page.locator(".past-scrutins li").nth(0).get_by_role("button", name="Retirer").click()
    assert page.locator(".past-scrutins li").count() == 1, "entrée retirée malgré dismiss"
    print("  PASS: forget scrutin needs confirmation")

@testcase
def test_past_scrutins_list(page):
    """L'écran d'accueil liste les scrutins déjà visités, du plus récent
    au plus ancien ; un clic re-attache, et le × retire l'entrée."""
    clear_state(page)

    create_scrutin(page, candidates=["A", "B"], voters=["Alice"], title="Premier")
    page.wait_for_timeout(300)  # flush IDB

    page.goto(BASE_URL)
    page.wait_for_selector("[data-app-ready]")

    create_scrutin(page, candidates=["X", "Y"], voters=["Bob"], title="Deuxieme")
    page.wait_for_timeout(300)

    page.goto(BASE_URL)
    page.wait_for_selector("[data-app-ready]")

    page.locator(".past-scrutins").wait_for(state="visible")
    items = page.locator(".past-scrutins li").all_inner_texts()
    assert any("Premier"  in t for t in items), f"Premier missing: {items}"
    assert any("Deuxieme" in t for t in items), f"Deuxieme missing: {items}"
    assert "Deuxieme" in items[0], f"order should be most-recent-first: {items}"

    # Le × retire la ligne courante (Deuxieme) sans casser Premier.
    page.once("dialog", lambda d: d.accept())
    page.locator(".past-scrutins li").nth(0).get_by_role("button", name="Retirer").click()
    page.wait_for_function(
        "() => document.querySelectorAll('.past-scrutins li').length === 1")
    assert "Premier"  in page.locator(".past-scrutins").inner_text()
    assert "Deuxieme" not in page.locator(".past-scrutins").inner_text()

    page.get_by_role("link", name="Premier").click()
    page.locator(".identity-screen").wait_for(state="visible", timeout=5000)
    assert "Alice" in page.locator(".identity-screen").inner_text()

    print("  PASS: past scrutins list")

<div class="past-scrutins" v-show="!scrutin && !loadingDoc && pastScrutins.length" v-cloak>
  <h2>Scrutins précédents</h2>
  <ul>
    <template v-for="s in pastScrutins" :key="s.url">
      <li>
        <a :href="'?doc=' + s.url" v-text="s.title || 'Sans titre'"></a>
        <button type="button" class="forget"
                @click="forgetScrutin(s.url)" aria-label="Retirer">&times;</button>
      </li>
    </template>
  </ul>
</div>

.past-scrutins{padding:0 20px 20px}
.past-scrutins h2{font-size:.95rem;margin:8px 0;color:var(--muted);font-weight:600;text-align:center}
.past-scrutins ul{list-style:none;padding:0;margin:0 auto;max-width:340px}
.past-scrutins li{display:flex;gap:6px;margin-bottom:6px}
.past-scrutins li a{flex:1;color:var(--fg);text-decoration:none;display:block;padding:10px 14px;background:var(--card);border-radius:8px}
.past-scrutins li button.forget{flex-shrink:0;padding:0 14px;background:var(--card);border:0;border-radius:8px;color:var(--muted);font-size:1.2rem;line-height:1;cursor:pointer}

Toute la logique réactive de l’app vit dans un store petite-vue unique, voteStore (cf. Store petite-vue en annexe pour la motivation). recordScrutin s’y greffe et est appelée par attach — la méthode qui rend un doc Automerge actif dans le store — à chaque doc attaché, qu’il vienne d’une création locale, d’un lien partagé ou d’une ré-attache depuis cette même liste.

const PAST_KEY = 'condorcet.past-scrutins';

function loadPastScrutins(){
    try {
        const raw = localStorage.getItem(PAST_KEY);
        const arr = raw ? JSON.parse(raw) : [];
        return arr.sort((a, b) => b.at - a.at);
    } catch(e){ return []; }
}

function savePastScrutins(arr){
    try { localStorage.setItem(PAST_KEY, JSON.stringify(arr)); } catch(e) {}
}

Object.assign(voteStore, {
    pastScrutins: [],

    recordScrutin(url, title){
        const arr = (this.pastScrutins || []).filter(s => s.url !== url);
        arr.push({ url, title: title || '', at: Date.now() });
        arr.sort((a, b) => b.at - a.at);
        this.pastScrutins = arr;
        savePastScrutins(arr);
    },

    forgetScrutin(url){
        if(!confirm("Retirer ce scrutin de la liste ? Le doc reste sur l'appareil.")) return;
        const arr = (this.pastScrutins || []).filter(s => s.url !== url);
        this.pastScrutins = arr;
        savePastScrutins(arr);
    },
});

Créer un scrutin

Pour voter Condorcet il faut au minimum :

  • un titre, pour savoir de quoi on parle ;
  • au moins deux candidats (pas de duel possible avec un seul) ;
  • au moins un votant ;
  • un mode, qui conditionne tout le flux : partagé (un téléphone qui tourne) ou per-device (chacun vote depuis chez soi).

À la validation on crée un doc Automerge — son URL automerge:... portée dans la query string est ce qu’on partage aux amis pour les inviter : le scrutin est immédiatement partageable.

Le test exerce ce flux via le helper create_scrutin (défini en annexe avec les autres helpers Playwright) : il remplit la modale — titre, candidats, votants, mode — et clique « Créer ».

@testcase
def test_create_scrutin(page):
    """Un scrutin valide active l'écran de vote et publie une URL Automerge."""
    clear_state(page)
    create_scrutin(page, candidates=["Le Parrain", "Inception"],
                   voters=["Alice", "Bob"], title="Quel film ce soir ?")
    assert page.locator(".identity-screen").is_visible()
    assert "doc=automerge:" in page.url
    print("  PASS: create scrutin")

On ouvre la modale par une action explicite pour garder l’écran d’accueil épuré tant qu’il n’y a rien à faire.

openCreate(){
    this.restoreFormDraft();
    this.ui.createOpen = true;
    pushOverlay('create');
},

La modale regroupe les quatre champs — titre, candidats, votants, mode — plus une ligne d’actions (Annuler / Créer). Le minimum de deux candidats et d’un votant est imposé par la méthode : Condorcet repose sur des duels, un seul candidat n’a personne à battre, et sans roster on ne sait pas quand tout le monde a voté.

<div class="modal" id="createModal" :class="{open: ui.createOpen}" v-cloak>
  <div class="modal-panel" @input="saveFormDraft()" @change="saveFormDraft()">
    <h2>Nouveau scrutin</h2>

<label>Titre
  <input type="text" id="createTitle" v-model="ui.form.title"
         placeholder="Quel film ce soir&nbsp;?">
</label>

<fieldset>
  <legend>Candidats</legend>
  <template v-for="(c, i) in ui.form.candidates" :key="'c'+i">
    <div class="row candidate-row">
      <span class="candidate-thumb-wrap" v-if="ui.form.candidateImages[i]">
        <img class="candidate-thumb" :src="ui.form.candidateImages[i]" alt="">
        <button type="button" class="candidate-thumb-remove"
                @click="removeCandidateImage(i)" aria-label="Retirer la photo">&times;</button>
      </span>
      <input type="text" :value="c" @input="ui.form.candidates[i] = $event.target.value"
             @paste="handleCandidatePaste(i, $event)"
             @keydown.tab="onCandidateTab(i, $event)"
             :class="{'is-duplicate': c.trim() && duplicateCandidates.includes(c.trim())}"
             :placeholder="'Candidat ' + (i+1)">
      <label class="candidate-pic" :title="ui.form.candidateImages[i] ? 'Changer la photo' : 'Ajouter une photo'">
        &#x1F4F7;
        <input type="file" accept="image/*" capture="environment"
               @change="setCandidateImage(i, $event.target.files[0])">
      </label>
      <button type="button" @click="ui.form.candidates.splice(i, 1); ui.form.candidateImages.splice(i, 1)"
              v-show="ui.form.candidates.length > 2" aria-label="Retirer">&times;</button>
    </div>
  </template>
  <button type="button" class="add-row"
          @click="ui.form.candidates.push(''); ui.form.candidateImages.push('')">+ Candidat</button>
  <p class="error create-error" v-show="candidatesError" v-text="candidatesError"></p>
</fieldset>

<fieldset>
  <legend>Votants</legend>
  <template v-for="(v, i) in ui.form.voters" :key="'v'+i">
    <div class="row">
      <input type="text" :value="v" @input="ui.form.voters[i] = $event.target.value"
             @keydown.tab="onVoterTab(i, $event)"
             :class="{'is-duplicate': v.trim() && duplicateVoters.includes(v.trim())}"
             :placeholder="'Votant ' + (i+1)">
      <button type="button" @click="ui.form.voters.splice(i, 1)"
              v-show="ui.form.voters.length > 1" aria-label="Retirer">&times;</button>
    </div>
  </template>
  <button type="button" class="add-row" @click="ui.form.voters.push('')">+ Votant</button>
  <p class="error create-error" v-show="votersError" v-text="votersError"></p>
</fieldset>

<fieldset>
  <legend>Mode</legend>
  <label><input type="radio" name="mode" value="shared-device" v-model="ui.form.mode">
    Appareil partagé</label>
  <label><input type="radio" name="mode" value="per-device" v-model="ui.form.mode">
    Un appareil par personne</label>
</fieldset>

Le bouton Créer reste désactivé tant que le formulaire est invalide — pas de soumission en aveugle.

    <div class="modal-actions">
      <button class="secondary" @click="cancelCreate()">Annuler</button>
      <button class="primary" id="btnConfirmCreate"
              :disabled="!canCreate"
              @click="createScrutin()">Créer</button>
    </div>
  </div>
</div>

Le mode partagé est proposé par défaut — c’est le cas le plus courant, un téléphone qui tourne à l’apéro. Les autres champs partent vides, avec deux lignes candidat (minimum Condorcet) et une ligne votant.

function initialForm(){
    return {
        title: '',
        candidates: ['', ''],
        candidateImages: ['', ''],
        voters: [''],
        mode: 'shared-device',
    };
}

Object.assign(voteStore.ui, {
    createOpen: false,
    form: initialForm(),
});

À la validation le doc Automerge est créé, son URL reflétée dans la query string, et on s’y attache tout de suite.

async createScrutin(){
    const repo = await ensureRepo(this);
    const f = this.ui.form;
    const candidateImages = {};
    const candidates = [];
    f.candidates.forEach((raw, i) => {
        const name = raw.trim();
        if(!name) return;
        candidates.push(name);
        if(f.candidateImages[i]) candidateImages[name] = dataUrlToBytes(f.candidateImages[i]);
    });
    const voters = f.voters.map(s => s.trim()).filter(Boolean);
    const handle = repo.create({
        title: f.title.trim(),
        candidates,
        candidateImages,
        voters,
        ballots: [],
        drafts: {},
        closed: false,
        mode: f.mode,
        method: 'condorcet-random-smith',
        createdAt: Date.now(),
    });
    history.replaceState(null, '', '?doc=' + handle.url);
    this.clearFormDraft();
    this.ui.createOpen = false;
    this.attach(handle);
},

Object.assign(voteStore, {
    openCreate(){
        this.restoreFormDraft();
        this.ui.createOpen = true;
        pushOverlay('create');
    },
    async createScrutin(){
        const repo = await ensureRepo(this);
        const f = this.ui.form;
        const candidateImages = {};
        const candidates = [];
        f.candidates.forEach((raw, i) => {
            const name = raw.trim();
            if(!name) return;
            candidates.push(name);
            if(f.candidateImages[i]) candidateImages[name] = dataUrlToBytes(f.candidateImages[i]);
        });
        const voters = f.voters.map(s => s.trim()).filter(Boolean);
        const handle = repo.create({
            title: f.title.trim(),
            candidates,
            candidateImages,
            voters,
            ballots: [],
            drafts: {},
            closed: false,
            mode: f.mode,
            method: 'condorcet-random-smith',
            createdAt: Date.now(),
        });
        history.replaceState(null, '', '?doc=' + handle.url);
        this.clearFormDraft();
        this.ui.createOpen = false;
        this.attach(handle);
    },
});

Deux candidats portant le même nom produiraient deux lignes identiques dans la matrice pairwise ; deux votants homonymes permettraient de voter deux fois sous le même nom, ou créeraient une collision sur la clé voter des bulletins. Dans les deux cas le dépouillement serait incohérent. On bloque donc la création dès qu’une liste contient un doublon, avec un message affiché sous le champ concerné pour que l’utilisateur identifie directement la liste à corriger.

@testcase
def test_create_rejects_duplicates(page):
    """Doublons dans candidats ou votants : bouton Créer désactivé +
    message d'erreur visible.  Le scrutin ne peut pas être validé."""
    clear_state(page)
    _role_button(page, "Créer un scrutin").click()
    page.fill("#createTitle", "T")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    v_field = "#createModal fieldset:has(legend:text('Votants'))"
    page.locator(f"{c_field} input[type=text]").nth(0).fill("a")
    page.locator(f"{c_field} input[type=text]").nth(1).fill("b")
    page.click(f"{v_field} button.add-row")
    page.locator(f"{v_field} input").nth(0).fill("Alice")
    page.locator(f"{v_field} input").nth(1).fill("Alice")
    assert _role_button(page, "Créer", exact=True).is_disabled()
    assert "Doublons dans les votants" in page.locator(f"{v_field} .error").inner_text()
    page.locator(f"{v_field} input").nth(1).fill("Bob")
    page.locator(f"{c_field} input[type=text]").nth(1).fill("a")
    assert _role_button(page, "Créer", exact=True).is_disabled()
    assert "Doublons dans les candidats" in page.locator(f"{c_field} .error").inner_text()
    print("  PASS: create rejects duplicates")

Le message d’erreur dit qu’il y a un doublon mais pas lequel. Avec une longue liste l’utilisateur doit alors la relire entièrement pour retrouver les deux entrées identiques. Pour lui éviter ce travail, les champs concernés s’allument en rouge — toutes les occurrences du doublon, pas seulement la dernière saisie, pour qu’on voie les paires d’un coup d’œil.

@testcase
def test_create_marks_duplicate_fields(page):
    """Quand deux candidats portent le même nom, leurs deux champs
    portent la classe =is-duplicate=. Idem pour les votants. Corriger
    la collision la retire des deux."""
    clear_state(page)
    _role_button(page, "Créer un scrutin").click()
    page.fill("#createTitle", "T")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    v_field = "#createModal fieldset:has(legend:text('Votants'))"
    c_inputs = page.locator(f"{c_field} input[type=text]")
    v_inputs = page.locator(f"{v_field} input")

    c_inputs.nth(0).fill("rouge")
    c_inputs.nth(1).fill("rouge")
    assert "is-duplicate" in (c_inputs.nth(0).get_attribute("class") or "")
    assert "is-duplicate" in (c_inputs.nth(1).get_attribute("class") or "")

    page.click(f"{v_field} button.add-row")
    v_inputs.nth(0).fill("Alice")
    v_inputs.nth(1).fill("Alice")
    assert "is-duplicate" in (v_inputs.nth(0).get_attribute("class") or "")
    assert "is-duplicate" in (v_inputs.nth(1).get_attribute("class") or "")

    c_inputs.nth(1).fill("bleu")
    assert "is-duplicate" not in (c_inputs.nth(0).get_attribute("class") or "")
    assert "is-duplicate" not in (c_inputs.nth(1).get_attribute("class") or "")
    print("  PASS: create marks duplicate fields")

La validité du formulaire se rejoue à chaque frappe, et le bouton Créer comme le message d’erreur suivent automatiquement — pas de “Submit” qui ne fait rien. Under the hood, ces deux valeurs sont posées via defineProperty plutôt que Object.assign (qui ne copie pas les accesseurs d’un getter).

function _findDuplicates(items){
    const counts = {};
    items.forEach(s => {
        const t = s.trim();
        if(!t) return;
        counts[t] = (counts[t] || 0) + 1;
    });
    return Object.keys(counts).filter(k => counts[k] > 1);
}

Object.defineProperty(voteStore, 'duplicateCandidates', {
    enumerable: true, configurable: true,
    get(){ return _findDuplicates(this.ui.form.candidates); },
});

Object.defineProperty(voteStore, 'duplicateVoters', {
    enumerable: true, configurable: true,
    get(){ return _findDuplicates(this.ui.form.voters); },
});

Object.defineProperty(voteStore, 'candidatesError', {
    enumerable: true, configurable: true,
    get(){ return this.duplicateCandidates.length > 0 ? 'Doublons dans les candidats.' : ''; },
});

Object.defineProperty(voteStore, 'votersError', {
    enumerable: true, configurable: true,
    get(){ return this.duplicateVoters.length > 0 ? 'Doublons dans les votants.' : ''; },
});

Object.defineProperty(voteStore, 'canCreate', {
    enumerable: true, configurable: true,
    get(){
        const f = this.ui.form;
        const cs = f.candidates.map(s => s.trim()).filter(Boolean);
        const vs = f.voters.map(s => s.trim()).filter(Boolean);
        return f.title.trim() && cs.length >= 2 && vs.length >= 1
            && !this.candidatesError && !this.votersError;
    },
});

Un rechargement accidentel en pleine saisie vide le formulaire ; pour éviter que l’organisateur ressaisisse titre, candidats et votants depuis zéro, le brouillon est persisté dans localStorage sous la clé condorcet.form-draft. Au prochain chargement sa seule présence rouvre la modale automatiquement — repasser par le bouton Créer un scrutin n’a pas de sens quand des entrées attendent déjà l’organisateur, ce bouton n’est utile que sur un état vraiment vierge.

@testcase
def test_form_draft_persists_reload(page):
    """Au reload avec un brouillon non vide, la modale s'ouvre seule
    et les champs sont restaurés tels quels."""
    clear_state(page)
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    v_field = "#createModal fieldset:has(legend:text('Votants'))"
    _role_button(page, "Créer un scrutin").click()
    page.fill("#createTitle", "Mon scrutin")
    page.locator(f"{c_field} input[type=text]").nth(0).fill("Alice")
    page.locator(f"{c_field} input[type=text]").nth(1).fill("Bob")
    page.locator(f"{v_field} input").nth(0).fill("Carol")
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    page.locator("#createModal.open").wait_for(state="visible", timeout=3000)
    assert page.input_value("#createTitle") == "Mon scrutin", "titre perdu après reload"
    assert page.locator(f"{c_field} input[type=text]").nth(0).input_value() == "Alice", "Alice perdu"
    assert page.locator(f"{c_field} input[type=text]").nth(1).input_value() == "Bob", "Bob perdu"
    print("  PASS: form draft persists reload")

Le brouillon est effacé dans deux scénarios symétriques : à la création du scrutin (le travail est terminé) et au clic sur Annuler (le travail est rejeté). Sans la branche Annuler, l’organisateur qui change d’avis serait coincé : son brouillon trainerait, la modale se rouvrirait à chaque reload, et l’écran d’accueil lui resterait inaccessible.

Annuler est une intention déjà explicite, mais le brouillon peut contenir dix candidats et leurs photos. Sur un brouillon non vide on demande donc confirmation ; sur un brouillon vide la question serait du bruit, donc on l’évite.

@testcase
def test_cancel_create_needs_confirmation(page):
    """Annuler avec brouillon non vide demande confirmation ; refuser
    laisse la modale et le brouillon en place."""
    clear_state(page)
    _role_button(page, "Créer un scrutin").click()
    page.fill("#createTitle", "Brouillon")
    page.get_by_placeholder("Candidat 1").fill("Alice")

    page.once("dialog", lambda d: d.dismiss())
    page.get_by_role("button", name="Annuler").click()
    assert page.locator("#createModal.open").is_visible(), "modale fermée malgré dismiss"
    assert page.input_value("#createTitle") == "Brouillon"
    print("  PASS: cancel create needs confirmation")

@testcase
def test_cancel_create_empty_no_confirmation(page):
    """Annuler avec brouillon vide passe sans confirmation."""
    clear_state(page)
    _role_button(page, "Créer un scrutin").click()
    # Pas de dialog handler : si confirm() était appelé, Playwright
    # dismiss par défaut → la modale resterait ouverte. On vérifie
    # qu'elle se ferme bien.
    page.get_by_role("button", name="Annuler").click()
    assert not page.locator("#createModal.open").is_visible()
    print("  PASS: cancel create empty no confirmation")

@testcase
def test_form_draft_cleared_on_create(page):
    clear_state(page)
    _role_button(page, "Créer un scrutin").click()
    page.fill("#createTitle", "Brouillon")
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    page.goto(BASE_URL)
    page.wait_for_selector("[data-app-ready]")
    _role_button(page, "Créer un scrutin").click()
    assert page.input_value("#createTitle") == "", "brouillon non effacé après création"
    print("  PASS: form draft cleared on create")

@testcase
def test_cancel_clears_draft(page):
    """Annuler la modale supprime le brouillon : l'écran d'accueil
    réapparaît et la modale ne s'ouvre plus toute seule au reload."""
    clear_state(page)
    _role_button(page, "Créer un scrutin").click()
    page.fill("#createTitle", "Brouillon")
    page.get_by_placeholder("Candidat 1").fill("Alice")
    page.once("dialog", lambda d: d.accept())
    page.get_by_role("button", name="Annuler").click()
    assert page.get_by_role("button", name="Créer un scrutin").is_visible()
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    assert page.get_by_role("button", name="Créer un scrutin").is_visible()
    assert not page.locator("#createModal.open").is_visible()
    _role_button(page, "Créer un scrutin").click()
    assert page.input_value("#createTitle") == ""
    assert page.get_by_placeholder("Candidat 1").input_value() == ""
    print("  PASS: cancel clears draft")

const DRAFT_KEY = 'condorcet.form-draft';

function hasNonEmptyDraft(){
    try {
        const raw = localStorage.getItem(DRAFT_KEY);
        if(!raw) return false;
        const d = JSON.parse(raw);
        if(d.title && d.title.trim()) return true;
        if((d.candidates || []).some(c => c && c.trim())) return true;
        if((d.voters || []).some(v => v && v.trim())) return true;
        return false;
    } catch(e){ return false; }
}

Object.assign(voteStore, {
    saveFormDraft(){
        try {
            localStorage.setItem(DRAFT_KEY, JSON.stringify(this.ui.form));
        } catch(e) {}
    },

    restoreFormDraft(){
        try {
            const raw = localStorage.getItem(DRAFT_KEY);
            if(raw) Object.assign(this.ui.form, JSON.parse(raw));
        } catch(e) {}
    },

    clearFormDraft(){
        localStorage.removeItem(DRAFT_KEY);
    },

    cancelCreate(){
        if(hasNonEmptyDraft() && !confirm("Annuler ? Le brouillon (titre, candidats, votants) sera effacé.")) return;
        this._doCancelCreate();
        if(history.state && history.state.overlay === 'create') history.back();
    },

    _doCancelCreate(){
        this.clearFormDraft();
        Object.assign(this.ui.form, initialForm());
        this.ui.importUrl = '';
        this.ui.importError = '';
        this.ui.createOpen = false;
    },
});

Recevoir un lien automerge:... ne devrait pas obliger à retaper tous les candidats et votants pour relancer un scrutin similaire. Un champ Importer depuis URL dans la modale charge le doc visé en arrière-plan et préremplit titre, candidats (avec photos), votants et mode — l’organisateur ajuste ensuite ce qui change avant de cliquer Créer. On accepte aussi un lien complet contenant ?doc=automerge:... pour épargner à l’utilisateur d’extraire le préfixe.

@testcase
def test_import_from_url(page):
    """Coller une URL automerge dans le champ d'import préremplit
    le form de création."""
    clear_state(page)
    create_scrutin(page, candidates=["Pizza", "Sushi"], voters=["Alice"],
                   title="Apéro", mode="per-device")
    doc_url = page.url.split("?doc=")[1]
    page.wait_for_timeout(300)  # flush debounced IndexedDB save
    # Retour à l'écran d'accueil pour partir d'un form vierge ; le doc
    # reste disponible en IndexedDB.
    page.goto(BASE_URL)
    page.wait_for_selector("[data-app-ready]")
    _role_button(page, "Créer un scrutin").click()
    page.get_by_label("Importer depuis URL").fill(doc_url)
    _role_button(page, "Importer", exact=True).click()
    page.wait_for_function(
        "() => document.querySelector('#createTitle')?.value === 'Apéro'",
        timeout=3000)
    assert page.get_by_placeholder("Candidat 1").input_value() == "Pizza"
    assert page.get_by_placeholder("Candidat 2").input_value() == "Sushi"
    assert page.get_by_placeholder("Votant 1").input_value() == "Alice"
    assert page.get_by_label("Un appareil par personne").is_checked()
    print("  PASS: import from url")

Le doc cible peut déjà être local (déjà visité ou copié par ce navigateur), ou arriver via le sync server. repo.find() renvoie un handle qui résout dans les deux cas ; on attend 'ready' ou 'unavailable' pour ne pas bloquer l’UI quand le doc n’existe nulle part. Une fois résolu, on projette son contenu dans ui.form comme le ferait Refaire (cf. Dépouiller) et on déclenche saveFormDraft pour que le brouillon survive un reload.

<fieldset>
  <legend>Importer depuis URL</legend>
  <div class="row">
    <input type="text" v-model="ui.importUrl"
           placeholder="automerge:… ou un lien complet"
           aria-label="Importer depuis URL">
    <button type="button" class="secondary"
            @click="importFromUrl()"
            :disabled="!ui.importUrl.trim()">Importer</button>
  </div>
  <p class="error import-error" v-show="ui.importError" v-text="ui.importError"></p>
</fieldset>

Object.assign(voteStore.ui, {
    importUrl: '',
    importError: '',
});

Object.assign(voteStore, {
    async importFromUrl(){
        this.ui.importError = '';
        const raw = (this.ui.importUrl || '').trim();
        if(!raw) return;
        let url = raw;
        if(/^https?:\/\//.test(raw)){
            try {
                url = new URL(raw).searchParams.get('doc') || '';
            } catch(e){ this.ui.importError = 'URL invalide'; return; }
        }
        const am = await loadAutomerge();
        if(!am.isValidAutomergeUrl(url)){
            this.ui.importError = 'Pas un lien automerge:';
            return;
        }
        try {
            const repo = await ensureRepo(this);
            const handle = await repo.find(url);
            const doc = handle.doc();
            if(!doc){ this.ui.importError = 'Doc introuvable'; return; }
            const cands = [...(doc.candidates || [])];
            Object.assign(this.ui.form, {
                title: doc.title || '',
                candidates: cands.length ? cands : ['', ''],
                candidateImages: cands.map(c => {
                    const bytes = doc.candidateImages && doc.candidateImages[c];
                    return bytes ? bytesToDataUrl(bytes) : '';
                }),
                voters: doc.voters && doc.voters.length ? [...doc.voters] : [''],
                mode: doc.mode || 'shared-device',
            });
            this.saveFormDraft();
            this.ui.importUrl = '';
        } catch(e){
            this.ui.importError = 'Erreur : ' + e.message;
        }
    },
});

Sur desktop, Tab est le geste réflexe pour passer au champ suivant. Mais l’ordre DOM par défaut accroche les boutons 📷 et × avant d’arriver au candidat suivant, et il n’y a pas d’équivalent côté votants : on tabule sur le × puis le candidat d’à côté, plutôt que sur le votant suivant. Tab dans un champ candidat ou votant doit donc sauter directement au suivant, et créer une ligne de plus si on est au bout d’une liste non vide — saisir des noms à la file redevient un seul geste par nom. Shift+Tab reste libre de remonter en arrière sans nous accrocher.

@testcase
def test_tab_advances_form_fields(page):
    """Tab dans un candidat focus le suivant ; sur le dernier rempli,
    une ligne est créée et focusée. Idem côté votants."""
    clear_state(page)
    _role_button(page, "Créer un scrutin").click()
    page.get_by_placeholder("Candidat 1").fill("Alice")
    page.get_by_placeholder("Candidat 1").press("Tab")
    page.wait_for_function(
        "document.activeElement.placeholder === 'Candidat 2'")
    page.locator(":focus").fill("Bob")
    page.locator(":focus").press("Tab")
    page.wait_for_function(
        "document.activeElement.placeholder === 'Candidat 3'")
    page.get_by_placeholder("Votant 1").fill("Carol")
    page.get_by_placeholder("Votant 1").press("Tab")
    page.wait_for_function(
        "document.activeElement.placeholder === 'Votant 2'")
    print("  PASS: tab advances form fields")

function focusFormField(prefix, idx){
    document.querySelector(
        `#createModal input[placeholder="${prefix} ${idx + 1}"]`)?.focus();
}

Object.assign(voteStore, {
    onCandidateTab(i, e){
        if(e.shiftKey) return;
        const arr = this.ui.form.candidates;
        const isLast = i >= arr.length - 1;
        if(isLast && arr[i].trim() === '') return;
        e.preventDefault();
        if(isLast){
            arr.push('');
            this.ui.form.candidateImages.push('');
        }
        requestAnimationFrame(() => focusFormField('Candidat', i + 1));
    },
    onVoterTab(i, e){
        if(e.shiftKey) return;
        const arr = this.ui.form.voters;
        const isLast = i >= arr.length - 1;
        if(isLast && arr[i].trim() === '') return;
        e.preventDefault();
        if(isLast) arr.push('');
        requestAnimationFrame(() => focusFormField('Votant', i + 1));
    },
});

À l’apéro, quelqu’un recharge l’app par accident — on ne peut pas perdre les bulletins déjà soumis, ils représentent de vrais votes. Le doc Automerge est persisté localement ; l’URL automerge:... dans la query string suffit au rechargement pour retrouver exactement le même état.

@testcase
def test_persistence_reload(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    take_phone(page)
    submit_ballot(page)
    url_before = page.url
    # La sauvegarde IndexedDB est debounced (100 ms) ; on attend qu'elle soit flushée.
    page.wait_for_timeout(300)
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    assert page.url == url_before, f"url changed: {url_before}{page.url}"
    # Après reload on retombe sur l'identity-screen, prêt pour le votant suivant.
    page.locator(".identity-screen").wait_for(state="visible")
    identity_text = page.locator(".identity-screen").inner_text()
    assert "Alice" in identity_text and "a voté" in identity_text
    print("  PASS: persistence reload")

La persistance doit aussi préserver la réactivité : après un reload, un nouveau bulletin doit mettre à jour le compteur “bulletins reçus / attendus” et déclencher l’apparition du bouton de clôture dès que tout le monde a voté — sans décalage ni rechargement manuel. Cet invariant impose un choix de design côté store, détaillé en Store petite-vue.

@testcase
def test_reload_reactivity(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    take_phone(page)
    submit_ballot(page)
    page.wait_for_timeout(300)  # flush debounced IndexedDB save
    counter_before = page.locator(".identity-screen .counter").inner_text()
    assert "1" in counter_before and "2" in counter_before, f"pre-reload: {counter_before}"
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    page.locator(".identity-screen").wait_for(state="visible")
    take_phone(page)
    submit_ballot(page)
    _role_button(page, "Dépouiller").wait_for(state="visible", timeout=5000)
    counter_after = page.locator(".identity-screen .counter").inner_text()
    assert "2" in counter_after, f"counter didn't update after reload: {counter_after}"
    print("  PASS: reload reactivity")

Titre toujours en bandeau

Le titre du scrutin se cale en haut, sous la rangée d’en-tête (← Menu, build-tag, indicateur de sync), et y reste sur tous les écrans : on a toujours sous les yeux ce sur quoi on vote.

<div class="scrutin-title" v-show="scrutin" v-cloak>
  <h1 v-text="scrutin.title"></h1>
</div>

.scrutin-title{padding:0 20px 4px 20px;text-align:center}
.scrutin-title h1{margin:0;font-size:1.05rem;color:var(--muted);font-weight:600}

Partager le scrutin (lien et QR)

L’URL ?doc=automerge:... est le sésame pour rejoindre le scrutin. Mais sur mobile, personne ne copie une URL depuis la barre d’adresse. Tous les écrans hors-bulletin — pass, identification, attente, tally — exposent donc deux raccourcis : partage natif ou clipboard pour envoyer par message, QR pour partage en face-à-face.

Les deux boutons vivent dans une .share-bar épinglée, plus un toast pour le feedback quand on passe par le clipboard.

<div class="share-bar" v-show="scrutin && stage !== 'ballot'" v-cloak>
  <button class="secondary" id="btnShare" @click="shareScrutin()" title="Partager">
    🔗 Partager
  </button>
  <button class="secondary" id="btnQR" @click="openQR()" title="QR code">
    ▦ QR
  </button>
  <span class="share-toast" v-show="ui.shareToast" v-text="ui.shareToast"></span>
</div>

.share-bar{display:flex;gap:8px;justify-content:center;align-items:center;padding:12px 20px;flex-wrap:wrap}
.share-bar button{padding:8px 14px;font-size:.9rem}
.share-toast{color:var(--ok);font-size:.85rem;padding:4px 8px}
.qr-modal .modal-panel{text-align:center}
#qrHolder{margin:16px auto;display:inline-block;background:#fff;padding:10px;border-radius:8px}
#qrHolder svg{display:block;max-width:260px;height:auto}
.qr-url{font-family:monospace;font-size:.75rem;color:var(--muted);word-break:break-all;margin:12px 0}

Sur mobile, navigator.share déclenche la sheet native — l’utilisateur choisit lui-même où envoyer (SMS, Signal, email…). C’est de loin l’expérience la plus fluide, et c’est le chemin par défaut quand l’API est disponible.

@testcase
def test_share_native(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    page.evaluate("""() => {
      window.__shareCalls = [];
      navigator.share = async (data) => { window.__shareCalls.push(data); };
    }""")
    _role_button(page, "Partager").click()
    page.wait_for_timeout(100)
    calls = page.evaluate("window.__shareCalls")
    assert len(calls) == 1, f"expected 1 share call, got {calls}"
    assert "doc=automerge:" in calls[0].get("url", ""), f"bad share url: {calls}"
    print("  PASS: share native")

Quand navigator.share est absent (desktop, vieux navigateur), copier dans le clipboard est le minimum acceptable. Sans retour visuel, l’utilisateur ne saurait pas si l’action a fonctionné et risquerait de réessayer inutilement — d’où le toast.

@testcase
def test_share_clipboard(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    page.evaluate("""() => {
      delete navigator.share;
      window.__clip = [];
      navigator.clipboard.writeText = async (t) => { window.__clip.push(t); };
    }""")
    _role_button(page, "Partager").click()
    page.wait_for_timeout(200)
    clip = page.evaluate("window.__clip")
    assert len(clip) == 1, f"expected 1 clipboard call, got {clip}"
    assert "doc=automerge:" in clip[0], f"bad clipboard url: {clip}"
    # Toast visible
    assert page.locator(".share-toast").is_visible()
    print("  PASS: share clipboard fallback")

shareScrutin essaie d’abord l’API native, puis retombe sur le clipboard en affichant un toast (“Lien copié” ou “Copie impossible” selon le résultat), qui s’efface après 2 s.

Object.assign(voteStore.ui, {
    shareToast: '',
});

Object.assign(voteStore, {
    currentShareUrl(){
        return location.origin + location.pathname + location.search;
    },

    async shareScrutin(){
        const url = this.currentShareUrl();
        const title = (this.scrutin && this.scrutin.title) || 'Scrutin';
        if(navigator.share){
            try { await navigator.share({ title, url }); return; }
            catch(e){ /* annulé ou non supporté → fallback clipboard */ }
        }
        try {
            await navigator.clipboard.writeText(url);
            this.ui.shareToast = 'Lien copié';
            setTimeout(() => { this.ui.shareToast = ''; }, 2000);
        } catch(e){
            this.ui.shareToast = 'Copie impossible';
            setTimeout(() => { this.ui.shareToast = ''; }, 2000);
        }
    },
});

Pour un partage en face-à-face autour d’une table, taper l’URL est exclu. Un QR scannable avec l’appareil photo est la friction minimale ; il est généré côté client, sans appel serveur.

<div class="modal qr-modal" :class="{open: ui.qrOpen}" v-cloak @click.self="closeQR()">
  <div class="modal-panel">
    <h2>Rejoindre ce scrutin</h2>
    <div id="qrHolder" v-html="ui.qrSvg"></div>
    <p class="qr-url" v-text="ui.shareUrl"></p>
    <button class="secondary" @click="closeQR()">Fermer</button>
  </div>
</div>

@testcase
def test_qr(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    _role_button(page, "QR").click()
    page.locator(".qr-modal.open").wait_for()
    page.locator(".qr-modal #qrHolder svg").wait_for()
    url_shown = page.locator(".qr-url").inner_text()
    assert "doc=automerge:" in url_shown, f"URL not shown: {url_shown}"
    assert url_shown.startswith("http"), f"Bad URL: {url_shown}"
    print("  PASS: qr code")

openQR génère le SVG avec qrcode-generator (cf. Bibliothèques externes) et l’affiche dans la modale, avec l’URL en texte monospace sous le code pour les cas où le scan échoue.

Object.assign(voteStore.ui, {
    qrOpen: false,
    qrSvg: '',
    shareUrl: '',
});

Object.assign(voteStore, {
    openQR(){
        const url = this.currentShareUrl();
        const qr = window.qrcode(0, 'M');
        qr.addData(url);
        qr.make();
        this.ui.qrSvg = qr.createSvgTag({ cellSize: 6, margin: 2 });
        this.ui.shareUrl = url;
        this.ui.qrOpen = true;
        pushOverlay('qr');
    },

    closeQR(){
        this._doCloseQR();
        if(history.state && history.state.overlay === 'qr') history.back();
    },

    _doCloseQR(){
        this.ui.qrOpen = false;
    },
});

Photos de candidats

Voter avec des enfants qui ne lisent pas encore, ou avec un contexte multilingue, rend le texte pur insuffisant : “Kebab” et “Sushi” sont des étiquettes opaques pour eux. Permettre à chaque candidat une photo change la donne — l’enfant reconnaît la frite ou la pizza à l’image, pointe son préféré, glisse les tuiles du bulletin. Le nom reste comme clé technique (matrice, anti-collision, fallback si l’image ne charge pas) ; l’image devient l’affordance principale.

Sur mobile, la photo qu’on veut pour un candidat n’existe pas encore : on désigne le plat sur la table, on appuie 📷, et la capture vit dans la modale (l’attribut capture“environment”= ouvre directement la caméra arrière, sans sélecteur de fichier intermédiaire). Sur desktop, c’est l’inverse : la photo a presque toujours déjà été créée — un screenshot fraîchement pris, une image copiée d’un site, une vignette tirée d’un message — et elle vit dans le presse-papiers. Forcer un détour par Enregistrer sous… puis Sélectionner un fichier… brise le Ctrl+V que tout utilisateur desktop a dans les doigts. Les deux entrées coexistent donc sur la même ligne, et la miniature qui apparaît à gauche du nom est la même quel qu’ait été le geste. Rien n’est envoyé à un serveur : l’image est compressée côté client et stockée en dataURL dans le doc Automerge, qui se propage via la sync comme le reste du scrutin.

Une photo brute d’iPhone fait 3-5 MB, inenvoyable à propager dans le doc à chaque tick. On re-encode donc systématiquement en JPEG qualité 0.75 avec une dimension max de 512 px via <canvas>, ce qui ramène une photo typique à 40-80 KB — négligeable dans le doc. L’utilisateur qui prend une très grosse photo ne s’en rend pas compte : la compression est transparente, et le résultat reste lisible sur le bulletin de vote.

Pour que l’enfant puisse zoomer sur une vignette 64×64, on autorise le pinch-zoom en surchargeant le <meta viewport> — voir Bases visuelles.

@testcase
def test_candidate_image(page):
    """Une image uploadée dans le formulaire apparaît sur le bulletin
    de vote et sur la carte vainqueur. Le nom reste la clé, l'image
    décore."""
    import base64, pathlib, tempfile
    clear_state(page)
    # 1×1 pixel rouge en PNG : plus petite image valide possible.
    png = base64.b64decode(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
        "nGP4z8DwHwAFAAH/8QWjhwAAAABJRU5ErkJggg==")
    tmp = pathlib.Path(tempfile.mkdtemp()) / "candidate.png"
    tmp.write_bytes(png)

    _role_button(page, "Créer un scrutin").click()
    page.fill("#createTitle", "T")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    v_field = "#createModal fieldset:has(legend:text('Votants'))"
    page.locator(f"{c_field} input[type=text]").nth(0).fill("Rouge")
    page.locator(f"{c_field} input[type=text]").nth(1).fill("Bleu")
    # Upload sur le premier candidat (via input[type=file] caché).
    page.locator(f"{c_field} input[type=file]").nth(0).set_input_files(str(tmp))
    # La miniature doit apparaître dans la modale après compression.
    page.wait_for_function(
        "() => document.querySelector('#createModal .candidate-thumb')"
        " && document.querySelector('#createModal .candidate-thumb').offsetParent !== null",
        timeout=3000)
    page.locator(f"{v_field} input").nth(0).fill("Alice")
    _role_button(page, "Créer", exact=True).click()
    page.wait_for_selector(".identity-screen")

    # Au bulletin, l'image du candidat Rouge apparaît (Object URL bâti
    # depuis les bytes du doc).
    take_phone(page)
    page.wait_for_function(
        "() => document.querySelector('.reorder-list .reorder-image[src^=\"blob:\"]')",
        timeout=3000)
    rank_as(page, ['Rouge', 'Bleu'])
    submit_ballot(page)
    tally(page)
    page.locator(".winner-name").wait_for(state="visible")
    assert page.locator(".winner-name").inner_text() == "Rouge"
    # Carte vainqueur : image visible.
    img = page.locator(".winner-image")
    assert img.is_visible(), "image du vainqueur absente"
    src = img.get_attribute("src")
    assert src and src.startswith("blob:"), \
        f"attendu Object URL (blob:), got {src[:40] if src else None}"
    print("  PASS: candidate image")

En pratique, tester le Ctrl+V passe par un détour : Playwright ne donne pas d’accès au presse-papiers système, alors on fabrique un ClipboardEvent avec une File synthétique. Le geste utilisateur reste fidèle, et le succès se mesure au même endroit qu’avec un upload — la miniature qui apparaît à gauche du nom.

@testcase
def test_candidate_image_paste(page):
    """Coller (Ctrl+V) une image dans le champ texte du candidat
    déclenche la même compression que l'upload : une miniature
    apparaît à gauche du nom."""
    clear_state(page)
    _role_button(page, "Créer un scrutin").click()
    page.fill("#createTitle", "T")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    page.locator(f"{c_field} input[type=text]").nth(0).fill("Rouge")
    # 1×1 PNG rouge synthétisé en File, déposé dans un DataTransfer,
    # puis dispatché comme ClipboardEvent('paste') sur l'input texte.
    png_b64 = ("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
               "nGP4z8DwHwAFAAH/8QWjhwAAAABJRU5ErkJggg==")
    page.evaluate("""(b64) => {
        const bin = atob(b64);
        const arr = new Uint8Array(bin.length);
        for(let i=0; i<bin.length; i++) arr[i] = bin.charCodeAt(i);
        const file = new File([arr], 'pasted.png', {type: 'image/png'});
        const dt = new DataTransfer();
        dt.items.add(file);
        const input = document.querySelectorAll(
            '#createModal .candidate-row input[type=text]')[0];
        input.dispatchEvent(new ClipboardEvent('paste', {
            clipboardData: dt, bubbles: true, cancelable: true}));
    }""", png_b64)
    page.wait_for_selector(
        "#createModal .candidate-thumb[src^='data:']", timeout=3000)
    print("  PASS: candidate image paste")

Une image prise puis perdue au reload gâche la soirée : elle doit survivre au F5 comme le reste du scrutin, via la persistance locale du doc Automerge.

@testcase
def test_candidate_image_persists_reload(page):
    """L'image est persistée dans le doc Automerge, donc un F5 la
    restitue : on compare l'empreinte des bytes avant et après."""
    import base64, pathlib, tempfile
    clear_state(page)
    png = base64.b64decode(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
        "nGP4z8DwHwAFAAH/8QWjhwAAAABJRU5ErkJggg==")
    tmp = pathlib.Path(tempfile.mkdtemp()) / "candidate.png"
    tmp.write_bytes(png)
    _role_button(page, "Créer un scrutin").click()
    page.fill("#createTitle", "T")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    v_field = "#createModal fieldset:has(legend:text('Votants'))"
    page.locator(f"{c_field} input[type=text]").nth(0).fill("Rouge")
    page.locator(f"{c_field} input[type=text]").nth(1).fill("Bleu")
    page.locator(f"{c_field} input[type=file]").nth(0).set_input_files(str(tmp))
    page.locator(f"{v_field} input").nth(0).fill("Alice")
    page.wait_for_selector("#createModal .candidate-thumb[src^='data:']", timeout=3000)
    _role_button(page, "Créer", exact=True).click()
    page.wait_for_selector(".identity-screen")
    take_phone(page)
    page.wait_for_selector(".reorder-image[src^='blob:']", timeout=3000)
    hash_before = _img_sha256(page, ".reorder-image")
    # Reload : Alice n'a pas voté, on retombe sur l'identity-screen.
    page.wait_for_timeout(300)  # flush IndexedDB debouncé
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    page.wait_for_selector(".identity-screen")
    take_phone(page)
    page.wait_for_selector(".reorder-image[src^='blob:']", timeout=3000)
    hash_after = _img_sha256(page, ".reorder-image")
    assert hash_before == hash_after, \
        "bytes image modifiés par le reload : before={!r} after={!r}".format(
            hash_before[:16], hash_after[:16])
    print("  PASS: candidate image persists reload")

Tous les candidats n’auront pas forcément une photo. Quand certains en ont et d’autres non, le <img> du candidat sans photo reste caché (v-show=false) — pas de carré vide dans le bulletin.

@testcase
def test_candidate_image_mixed(page):
    """Deux candidats, un seul avec image : le bulletin affiche une
    seule =.reorder-image= rendue (l'autre est v-show=false)."""
    import base64, pathlib, tempfile
    clear_state(page)
    png = base64.b64decode(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
        "nGP4z8DwHwAFAAH/8QWjhwAAAABJRU5ErkJggg==")
    tmp = pathlib.Path(tempfile.mkdtemp()) / "candidate.png"
    tmp.write_bytes(png)
    _role_button(page, "Créer un scrutin").click()
    page.fill("#createTitle", "T")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    v_field = "#createModal fieldset:has(legend:text('Votants'))"
    page.locator(f"{c_field} input[type=text]").nth(0).fill("AvecImage")
    page.locator(f"{c_field} input[type=text]").nth(1).fill("SansImage")
    page.locator(f"{c_field} input[type=file]").nth(0).set_input_files(str(tmp))
    page.locator(f"{v_field} input").nth(0).fill("Alice")
    page.wait_for_selector("#createModal .candidate-thumb[src^='data:']", timeout=3000)
    _role_button(page, "Créer", exact=True).click()
    page.wait_for_selector(".identity-screen")
    take_phone(page)
    page.wait_for_selector(".reorder-list")
    visible_count = page.evaluate(
        "() => Array.from(document.querySelectorAll('.reorder-image'))"
        ".filter(el => el.offsetParent !== null).length")
    assert visible_count == 1, \
        f"attendu 1 image visible (AvecImage), got {visible_count}"
    # Et l'image visible est bien sur le bon candidat.
    src = page.locator(
        ".reorder-item[data-candidate='AvecImage'] .reorder-image"
    ).get_attribute("src")
    assert src and src.startswith("blob:"), \
        f"AvecImage sans Object URL : {src[:40] if src else None}"
    print("  PASS: candidate image mixed")

Les noms et les images vivent dans deux tableaux parallèles indexés pareillement. Retirer une ligne candidat doit donc aussi retirer son image au même index, sinon chaque image suivante se décalerait sur un autre candidat.

@testcase
def test_candidate_image_row_removal(page):
    """Retirer la ligne C1 (via ×) laisse l'image sur C2 --- le splice
    sur candidates et sur candidateImages reste en phase."""
    import base64, pathlib, tempfile
    clear_state(page)
    png = base64.b64decode(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
        "nGP4z8DwHwAFAAH/8QWjhwAAAABJRU5ErkJggg==")
    tmp = pathlib.Path(tempfile.mkdtemp()) / "candidate.png"
    tmp.write_bytes(png)
    _role_button(page, "Créer un scrutin").click()
    page.fill("#createTitle", "T")
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    v_field = "#createModal fieldset:has(legend:text('Votants'))"
    # Active le bouton × (visible uniquement quand > 2 candidats).
    page.click(f"{c_field} button.add-row")
    page.locator(f"{c_field} input[type=text]").nth(0).fill("C1")
    page.locator(f"{c_field} input[type=text]").nth(1).fill("C2")
    page.locator(f"{c_field} input[type=text]").nth(2).fill("C3")
    # Image sur C2 (index 1), puis retire C1 (index 0).
    page.locator(f"{c_field} input[type=file]").nth(1).set_input_files(str(tmp))
    page.wait_for_selector("#createModal .candidate-thumb[src^='data:']", timeout=3000)
    page.locator(f"{c_field} .candidate-row").nth(0) \
        .locator("button[aria-label='Retirer']").click()
    # Vérifie : 2 candidats, C2 en tête avec sa miniature.
    rows = page.locator(f"{c_field} .candidate-row")
    assert rows.count() == 2
    names = rows.locator("input[type=text]").evaluate_all("els => els.map(e => e.value)")
    assert names == ["C2", "C3"], f"ordre après × : {names}"
    first_has_thumb = rows.nth(0).locator(".candidate-thumb").count() == 1
    second_has_thumb = rows.nth(1).locator(".candidate-thumb").count() == 1
    assert first_has_thumb and not second_has_thumb, \
        f"miniatures après × : first={first_has_thumb}, second={second_has_thumb}"
    # Pousse jusqu'au bulletin pour s'assurer que le doc reflète aussi.
    page.locator(f"{v_field} input").nth(0).fill("Alice")
    _role_button(page, "Créer", exact=True).click()
    page.wait_for_selector(".identity-screen")
    take_phone(page)
    page.wait_for_selector(".reorder-list")
    c2_image = page.locator(
        ".reorder-item[data-candidate='C2'] .reorder-image"
    ).count() == 1
    c3_image = page.locator(
        ".reorder-item[data-candidate='C3'] .reorder-image"
    ).count() == 1
    assert c2_image and not c3_image, \
        f"bulletin après ×+create : C2={c2_image}, C3={c3_image}"
    print("  PASS: candidate image row removal")

Une mauvaise photo se pose vite : un screenshot raté, le mauvais plat, une compression partie de travers. Il faut pouvoir la retirer sans pour autant supprimer le candidat lui-même — son nom est peut-être déjà bon. Un × en surimpression sur la miniature efface la dataURL et laisse le nom intact, prêt à recevoir une autre photo ou à partir tel quel.

@testcase
def test_candidate_image_can_be_removed(page):
    """× sur la miniature efface la photo et laisse le nom intact."""
    import base64, pathlib, tempfile
    clear_state(page)
    png = base64.b64decode(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
        "nGP4z8DwHwAFAAH/8QWjhwAAAABJRU5ErkJggg==")
    tmp = pathlib.Path(tempfile.mkdtemp()) / "p.png"
    tmp.write_bytes(png)
    _role_button(page, "Créer un scrutin").click()
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    page.locator(f"{c_field} input[type=text]").nth(0).fill("Alice")
    page.locator(f"{c_field} input[type=file]").nth(0).set_input_files(str(tmp))
    page.wait_for_selector("#createModal .candidate-thumb[src^='data:']",
                           timeout=3000)
    page.once("dialog", lambda d: d.accept())
    page.get_by_role("button", name="Retirer la photo").first.click()
    page.wait_for_selector(f"{c_field} .candidate-row .candidate-thumb",
                           state="hidden", timeout=3000)
    assert page.locator(f"{c_field} input[type=text]").nth(0).input_value() == "Alice"
    print("  PASS: candidate image can be removed")

La photo est de la donnée réelle : elle a coûté un cadrage, un appui sur l’obturateur, parfois une compression côté browser. La détruire par accident veut dire recommencer. Le × demande donc confirmation, et refuser laisse la miniature en place.

@testcase
def test_candidate_image_remove_needs_confirmation(page):
    """× sur la miniature demande confirmation ; refuser laisse la photo."""
    import base64, pathlib, tempfile
    clear_state(page)
    png = base64.b64decode(
        "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4"
        "nGP4z8DwHwAFAAH/8QWjhwAAAABJRU5ErkJggg==")
    tmp = pathlib.Path(tempfile.mkdtemp()) / "p.png"
    tmp.write_bytes(png)
    _role_button(page, "Créer un scrutin").click()
    c_field = "#createModal fieldset:has(legend:text('Candidats'))"
    page.locator(f"{c_field} input[type=text]").nth(0).fill("Alice")
    page.locator(f"{c_field} input[type=file]").nth(0).set_input_files(str(tmp))
    page.wait_for_selector("#createModal .candidate-thumb[src^='data:']", timeout=3000)

    page.once("dialog", lambda d: d.dismiss())
    page.get_by_role("button", name="Retirer la photo").first.click()
    assert page.locator("#createModal .candidate-thumb[src^='data:']").count() == 1, \
        "photo effacée malgré dismiss"
    print("  PASS: candidate image remove needs confirmation")

La dataURL compressée se pose directement dans ui.form.candidateImages[i], au même niveau que les autres champs du formulaire — pas de store séparé pour les images. La compression vit dans setCandidateImage que 📷 et Ctrl+V partagent : si demain la dimension max ou la qualité JPEG change, il n’y a qu’un endroit où le faire. La suppression écrit dans le même slot, pour rester en phase avec le brouillon de formulaire.

const IMAGE_MAX_DIM = 512;
const IMAGE_QUALITY = 0.75;

function compressImageToDataUrl(file){
    return new Promise((resolve, reject) => {
        const reader = new FileReader();
        reader.onerror = () => reject(reader.error);
        reader.onload = () => {
            const img = new Image();
            img.onerror = () => reject(new Error('image load failed'));
            img.onload = () => {
                const scale = Math.min(1, IMAGE_MAX_DIM / Math.max(img.width, img.height));
                const w = Math.round(img.width * scale);
                const h = Math.round(img.height * scale);
                const canvas = document.createElement('canvas');
                canvas.width = w; canvas.height = h;
                const ctx = canvas.getContext('2d');
                ctx.drawImage(img, 0, 0, w, h);
                resolve(canvas.toDataURL('image/jpeg', IMAGE_QUALITY));
            };
            img.src = reader.result;
        };
        reader.readAsDataURL(file);
    });
}

Object.assign(voteStore, {
    async setCandidateImage(i, file){
        if(!file) return;
        try {
            const dataUrl = await compressImageToDataUrl(file);
            this.ui.form.candidateImages[i] = dataUrl;
            this.saveFormDraft();
        } catch(e){
            console.error('compression image candidat', e);
        }
    },

    handleCandidatePaste(i, ev){
        const items = ev.clipboardData && ev.clipboardData.items;
        if(!items) return;
        for(const item of items){
            if(item.kind === 'file' && item.type.startsWith('image/')){
                ev.preventDefault();
                const file = item.getAsFile();
                if(file) this.setCandidateImage(i, file);
                return;
            }
        }
    },

    removeCandidateImage(i){
        if(!confirm('Retirer cette photo ?')) return;
        this.ui.form.candidateImages[i] = '';
        this.saveFormDraft();
    },
});

Trois tailles d’image pour trois contextes : miniature (36 px) dans la modale de création pour valider le choix photo, médium (64 px) dans le bulletin pour reconnaître un candidat d’un coup d’œil, grande (140 px) sur la carte vainqueur pour célébrer le résultat.

Les boîtes sont carrées mais les photos ne le sont pas toujours : on utilise object-fit: contain et non cover, pour qu’une photo en portrait ou en paysage soit affichée entière (avec un peu de fond autour) plutôt que tronquée sur un visage ou un détail saillant.

.candidate-row{align-items:center}
.candidate-thumb-wrap{position:relative;display:inline-flex;flex-shrink:0}
.candidate-thumb{width:36px;height:36px;object-fit:contain;border-radius:6px;border:1px solid #333;flex-shrink:0}
.candidate-thumb-remove{position:absolute;top:-6px;right:-6px;width:18px;height:18px;border-radius:9px;background:var(--danger);color:#fff;font-size:.75rem;line-height:1;padding:0;display:flex;align-items:center;justify-content:center}
.candidate-pic{display:inline-flex;align-items:center;justify-content:center;background:var(--card);color:var(--fg);padding:8px 10px;border-radius:6px;cursor:pointer;font-size:1.1rem;line-height:1}
.candidate-pic input[type=file]{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none}
.reorder-image{width:64px;height:64px;object-fit:contain;border-radius:8px;margin:0 8px;flex-shrink:0}
.winner-image{display:block;width:140px;height:140px;object-fit:contain;border-radius:12px;margin:10px auto 6px auto;border:2px solid var(--ok)}

Ajouter un votant en cours de route

Prévoir la liste exacte des votants au moment de créer le scrutin suppose de tout savoir à l’avance — à l’apéro, c’est rarement le cas. Un retardataire arrive, un invité surprise se joint. Si l’ajout n’est possible que depuis l’écran de création, on est bloqués. La barre d’ajout est donc accessible sur tous les écrans hors-bulletin, y compris après clôture.

Les noms des votants servent de clé dans les bulletins : un doublon permettrait de voter deux fois sous le même nom et bloquerait le dépouillement, donc l’ajout d’un homonyme est rejeté avec un message visible. Ajouter quelqu’un après la clôture doit aussi avoir un sens cohérent : un tally calculé sans ce votant n’est plus valide, donc le scrutin se ré-ouvre automatiquement et l’écran de dépouillement disparaît le temps que le nouveau venu vote — le dépouillement se relancera à la prochaine clôture manuelle.

Un cas particulier mérite attention en mode per-device : si on est sur l’écran d’identification (pas encore identifié sur cet appareil), l’ajout déclenche aussi l’auto-identification — la personne qui s’ajoute bascule directement sur son propre bulletin. Sur les autres écrans on ajoute juste le nom — le nouveau venu cliquera son nom dans l’écran d’identification depuis son propre appareil (la liste est mise à jour via Automerge/sync).

Sur le même bandeau, un lien discret bascule le scrutin entre mode partagé et per-device. L’organisateur peut le réaliser en cours de route — un ami sans smartphone, un wifi qui flanche — et changer sans relancer ; les bulletins déjà soumis restent valides.

<div class="add-voter-bar"
     v-show="scrutin && stage !== 'ballot'"
     v-cloak>
  <input type="text" v-model="ui.newVoter"
         placeholder="Ajouter un votant…"
         @keyup.enter="addVoter()">
  <button class="secondary" id="btnAddVoter"
          @click="addVoter()" :disabled="!ui.newVoter.trim()">
    + Ajouter
  </button>
  <p class="error" v-show="ui.addVoterError" v-text="ui.addVoterError"></p>
</div>
<div class="mode-toggle-bar" v-show="scrutin && !scrutin.closed && stage !== 'ballot'" v-cloak>
  <button class="mode-toggle" @click="switchMode()"
          v-text="scrutin.mode === 'per-device' ? 'Passer à un téléphone commun' : 'Passer à un téléphone par personne'">
  </button>
</div>

.add-voter-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:12px 20px;border-top:1px solid #333;margin-top:12px}
.add-voter-bar input{flex:1;min-width:140px}
.add-voter-bar button{padding:10px 14px;font-size:.9rem}
.add-voter-bar .error{color:var(--danger);font-size:.85rem;margin:6px 0 0 0;flex-basis:100%;text-align:center}

Un retardataire qui arrive après les premiers bulletins doit pouvoir s’inscrire sans interrompre le scrutin ni invalider les votes déjà enregistrés.

@testcase
def test_add_voter_mid_vote(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    pick_identity(page, "Alice")
    submit_ballot(page)
    # Alice a voté. On ajoute Charlie au roster.
    add_voter(page, "Charlie")
    assert "Charlie" in page.locator(".identity-list").inner_text(), \
        "Charlie absent du roster après ajout"
    # Charlie peut être picked directement (l'ordre n'est pas imposé).
    pick_identity(page, "Charlie")
    submit_ballot(page)
    pick_identity(page, "Bob")
    submit_ballot(page)
    _role_button(page, "Dépouiller").wait_for(state="visible")
    tally(page)
    page.locator(".winner-name").wait_for(state="visible")
    print("  PASS: add voter mid vote")

Un résultat affiché sans que tout le monde ait voté n’est pas valide. Quand quelqu’un s’ajoute après la clôture, le scrutin se ré-ouvre — le tally affiché ne compte plus, il sera recalculé une fois que le nouveau venu aura voté.

@testcase
def test_add_voter_after_tally(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    for _ in range(2):
        take_phone(page)
        submit_ballot(page)
    tally(page)
    page.locator(".winner-name").wait_for(state="visible")
    # On ajoute Charlie depuis la tally-screen : le tally disparaît,
    # on retombe sur l'identity-screen et Charlie peut voter.
    add_voter(page, "Charlie")
    page.locator(".winner-name").wait_for(state="hidden")
    page.locator(".identity-screen").wait_for(state="visible")
    pick_identity(page, "Charlie")
    submit_ballot(page)
    _role_button(page, "Dépouiller").wait_for(state="visible")
    tally(page)
    page.locator(".winner-name").wait_for(state="visible")
    print("  PASS: add voter after tally")

Un doublon dans le roster permettrait de voter deux fois sous le même nom — ou créerait une collision sur la clé voter dans les bulletins. On rejette avec un message visible pour que l’utilisateur comprenne pourquoi.

@testcase
def test_add_voter_duplicate(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    add_voter(page, "Alice")
    assert page.locator(".add-voter-bar .error").is_visible()
    assert page.locator(".identity-list li").count() == 2, \
        "doublon Alice accepté dans le roster"
    print("  PASS: add voter duplicate rejected")

En mode per-device, quelqu’un qui s’ajoute depuis l’écran d’identification a déjà choisi son nom : lui imposer un deuxième clic sur cet écran serait une friction gratuite. On le bascule directement sur son bulletin.

@testcase
def test_add_voter_per_device_self(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"], mode="per-device")
    # Bob n'est pas dans la liste, il s'ajoute depuis l'identity-screen.
    add_voter(page, "Bob")
    # Il est directement basculé sur le bulletin.
    page.locator(".ballot-screen").wait_for(state="visible")
    assert "Bob" in page.locator(".ballot-screen h2").inner_text()
    rank_as(page, ['A', 'B'])
    submit_ballot(page)
    page.locator(".waiting-screen").wait_for(state="visible")
    assert "Bob" in page.locator(".waiting-screen h2").inner_text()
    assert page.locator(".scrutin-title").inner_text() == "T"
    voters_text = page.locator(".voters-list").inner_text()
    assert "Alice" in voters_text and "Bob" in voters_text, \
        f"voters-list incomplet : {voters_text!r}"
    # Bob change d'avis depuis le waiting-screen.
    _role_button(page, "Modifier mon vote").click()
    page.locator(".ballot-screen").wait_for(state="visible")
    assert ballot_items(page) == ["A", "B"], \
        f"pré-remplissage attendu ['A','B'], obtenu {ballot_items(page)}"
    submit_ballot(page)
    page.locator(".waiting-screen").wait_for(state="visible")
    # Depuis le waiting-screen, l'organisateur bascule en mode partagé.
    _role_button(page, "Passer à un téléphone commun").click()
    page.locator(".identity-screen").wait_for(state="visible")
    assert not page.locator(".waiting-screen").is_visible(), \
        "waiting-screen devrait disparaître après switch vers partagé"
    print("  PASS: add voter per-device self")

Object.assign(voteStore.ui, {
    newVoter: '',
    addVoterError: '',
});

Object.assign(voteStore, {
    addVoter(){
        this.ui.addVoterError = '';
        const name = (this.ui.newVoter || '').trim();
        if(!name){ this.ui.addVoterError = 'Nom requis'; return; }
        if(this.scrutin.voters.includes(name)){
            this.ui.addVoterError = 'Ce nom est déjà dans la liste';
            return;
        }
        const wasIdentifying = this.scrutin.mode === 'per-device' && this.stage === 'identify';
        this.change(d => {
            d.voters.push(name);
            if(d.closed) d.closed = false;
        });
        this.tally = null;
        this.ui.newVoter = '';
        if(wasIdentifying) this.chooseIdentity(name);
    },
});

Retirer un votant en cours de route

Miroir de l’ajout : un invité s’en va avant la clôture, ou un nom a été typé en doublon orthographique (Antoine et Anthoine). Sans retrait, le fantôme bloque allVoted — le scrutin ne peut clore tant que ce nom n’a pas voté. Un × discret à droite de chaque nom dans l’écran d’identification retire le votant ; son bulletin et son brouillon éventuels partent avec lui, parce qu’un bulletin attaché à un votant absent reste compté dans les duels et fausse le tally.

@testcase
def test_remove_voter_takes_ballot(page):
    """Retirer un votant qui a déjà voté retire aussi son bulletin ;
    les bulletins des autres restent intacts."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    pick_identity(page, "Alice")
    submit_ballot(page)
    pick_identity(page, "Bob")
    submit_ballot(page)

    page.locator(".identity-screen").wait_for(state="visible")
    assert ballot_count(page) == (2, 2)

    bob_li = page.locator(".identity-list li").filter(has_text="Bob")
    page.once("dialog", lambda d: d.accept())
    bob_li.get_by_role("button", name="Retirer ce votant").click()
    bob_li.wait_for(state="hidden")

    identity_text = page.locator(".identity-list").inner_text()
    assert "Alice" in identity_text and "Bob" not in identity_text
    # Compteur passé à 1/1 : seul le bulletin d'Alice subsiste,
    # celui de Bob est parti avec lui.
    assert ballot_count(page) == (1, 1)
    print("  PASS: remove voter takes ballot")

Le × vit dans l’identity-screen, qui se masque dès que le scrutin clôt — pour ne pas faire flotter “a voté” par-dessus le vainqueur. Retirer après clôture passe donc par la réouverture du scrutin exposée sur l’écran de résultat (cf. Dépouiller) : une fois ré-ouvert, l’identity-screen revient et le × y reprend son rôle ordinaire. Le tally local s’invalide à la suppression pour que la matrice ne compte plus les duels du votant retiré.

@testcase
def test_remove_voter_after_tally(page):
    """Retirer un votant après dépouillement passe par la réouverture :
    sur l'écran de résultat l'identity-screen est masqué (pas de
    "a voté" qui flotte par-dessus le vainqueur), un clic sur
    "Modifier les bulletins" le ramène, et le × y reprend son rôle."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    for _ in range(2):
        take_phone(page)
        submit_ballot(page)
    tally(page)
    page.locator(".winner-name").wait_for(state="visible")
    assert page.locator(".identity-screen").is_hidden(), \
        "identity-screen ne doit pas flotter sur l'écran de résultat"

    _role_button(page, "Modifier les bulletins").click()
    page.locator(".identity-screen").wait_for(state="visible")

    bob_li = page.locator(".identity-list li").filter(has_text="Bob")
    page.once("dialog", lambda d: d.accept())
    bob_li.get_by_role("button", name="Retirer ce votant").click()

    page.locator(".winner-name").wait_for(state="hidden")
    identity_text = page.locator(".identity-list").inner_text()
    assert "Alice" in identity_text and "Bob" not in identity_text
    print("  PASS: remove voter after tally")

Un scrutin sans votant n’a personne à départager. On bloque le retrait du dernier nom en faisant disparaître son × tant que la roster n’a qu’un seul votant ; l’utilisateur peut toujours en ajouter un autre puis retirer celui-ci.

@testcase
def test_remove_voter_floor(page):
    """Les × sont rendus tant qu'il reste plusieurs votants ; ils
    disparaissent dès qu'on atteint un seul nom dans la roster."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    removers = page.get_by_role("button", name="Retirer ce votant")
    assert removers.count() == 2

    alice_li = page.locator(".identity-list li").filter(has_text="Alice")
    page.once("dialog", lambda d: d.accept())
    alice_li.get_by_role("button", name="Retirer ce votant").click()
    alice_li.wait_for(state="hidden")

    identity_text = page.locator(".identity-list").inner_text()
    assert "Alice" not in identity_text and "Bob" in identity_text
    assert removers.count() == 0
    print("  PASS: remove voter floor enforced")

Le × est petit, et le pouce qui parcourt la liste peut l’accrocher par accident. Comme la suppression est irréversible — bulletin et brouillon partent avec le nom —, on demande confirmation avant d’agir : refuser laisse le votant intact, sans toucher à l’état du scrutin.

@testcase
def test_remove_voter_needs_confirmation(page):
    """Le × demande confirmation ; refuser laisse le votant en place
    et son bulletin intact."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice", "Bob"])
    pick_identity(page, "Bob")
    submit_ballot(page)

    page.once("dialog", lambda d: d.dismiss())
    bob_li = page.locator(".identity-list li").filter(has_text="Bob")
    bob_li.get_by_role("button", name="Retirer ce votant").click()

    identity_text = page.locator(".identity-list").inner_text()
    assert "Bob" in identity_text, f"Bob removed despite dismiss: {identity_text}"
    assert ballot_count(page) == (1, 2), f"ballot_count={ballot_count(page)}"
    print("  PASS: remove voter needs confirmation")

Comme addVoter, l’écriture tient en une seule transaction Automerge ; le tally local est invalidé pour qu’il se recalcule à la prochaine clôture.

Object.assign(voteStore, {
    removeVoter(name){
        if(!confirm(`Retirer ${name} ? Son bulletin et son brouillon seront effacés.`)) return;
        const vIdx = this.scrutin.voters.indexOf(name);
        const bIdx = this.scrutin.ballots.findIndex(b => b.voter === name);
        this.change(d => {
            d.voters.splice(vIdx, 1);
            if(bIdx >= 0) d.ballots.splice(bIdx, 1);
            if(d.drafts && d.drafts[name]) delete d.drafts[name];
            if(d.closed) d.closed = false;
        });
        this.tally = null;
    },
});

Départager : les duels de Condorcet

Pour plus de deux options, le vote à la majorité simple crée des paradoxes. Le scrutin de Condorcet compare chaque duel entre candidats. En cas de cycle (pas de vainqueur net), on départage par un tirage au sort reproductible dans le Smith set.

Dépouiller

Un résultat sorti de nulle part ne convainc pas. Pour que le groupe fasse confiance au vainqueur, chacun doit pouvoir vérifier : “est-ce que mon candidat a vraiment perdu ?” La matrice des duels rend ça lisible : pour chaque paire (A, B), on voit combien de votants ont préféré A à B et combien l’inverse. C’est aussi l’occasion d’expliquer Condorcet en le pratiquant — la méthode devient intuitive sur un vrai vote.

Chaque bulletin traduit un classement en préférences pairwise : pour tout couple (A, B), si A est classé avant B dans le bulletin, on incrémente pairwise[A][B]. Agrégés sur tous les bulletins, ces compteurs forment la matrice. A bat B en duel quand pairwise[A][B] > pairwise[B][A].

Si un candidat bat chacun des autres en tête-à-tête, la majorité le préfère à n’importe quel challenger — c’est le vainqueur de Condorcet, la solution indiscutable. L’app doit l’afficher tel quel et signaler qu’il est strict (pas de tirage nécessaire), pour que les votants comprennent pourquoi ce candidat a gagné et pas seulement qui.

@testcase
def test_condorcet_winner(page):
    # V1: A>B>C, V2: A>C>B, V3: B>A>C
    # A bat B (2-1), A bat C (3-0), B bat C (2-1) → A est vainqueur de Condorcet
    clear_state(page)
    create_scrutin(page, candidates=["A", "B", "C"], voters=["V1", "V2", "V3"])
    for ranking in [["A", "B", "C"], ["A", "C", "B"], ["B", "A", "C"]]:
        take_phone(page)
        rank_as(page, ranking)
        submit_ballot(page)
    tally(page)
    assert page.locator(".winner-name").inner_text() == "A"
    assert "strict" in page.locator(".tally-kind").inner_text()
    assert page.locator(".scrutin-title").inner_text() == "T"
    print("  PASS: Condorcet winner")

Deux temps, deux fonctions : d’abord bâtir la matrice des duels, puis la scanner pour y repérer un éventuel gagnant strict.

function pairwiseMatrix(candidates, ballots){
    const m = {};
    for(const a of candidates){
        m[a] = {};
        for(const b of candidates) if(b !== a) m[a][b] = 0;
    }
    for(const ballot of ballots){
        const pos = {};
        ballot.ranking.forEach((c, i) => { pos[c] = i; });
        for(let i = 0; i < candidates.length; i++){
            for(let j = i+1; j < candidates.length; j++){
                const a = candidates[i], b = candidates[j];
                if(a in pos && b in pos){
                    if(pos[a] < pos[b]) m[a][b]++;
                    else m[b][a]++;
                }
            }
        }
    }
    return m;
}

function condorcetWinner(candidates, m){
    for(const c of candidates){
        const beatsAll = candidates.every(o => o === c || m[c][o] > m[o][c]);
        if(beatsAll) return c;
    }
    return null;
}

Mais ce vainqueur peut ne pas exister. Le paradoxe de Condorcet, classique à trois votants :

votant classement
V1 A > B > C
V2 B > C > A
V3 C > A > B

Duels : A bat B (V1, V3 préfèrent A, V2 préfère B → 2-1), B bat C (2-1), C bat A (2-1). Chaque candidat en bat un et est battu par un autre : la préférence majoritaire est intransitive. Il n’y a pas de “meilleur” au sens de Condorcet — il faut donc une règle de secours. Face à ce cycle, l’app doit basculer dans le chemin de tirage aléatoire dans le Smith set, et signaler “Smith” dans l’explication pour que les votants comprennent qu’on n’est plus dans le cas strict.

@testcase
def test_random_smith(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B", "C"], voters=["V1", "V2", "V3"])
    for ranking in [["A", "B", "C"], ["B", "C", "A"], ["C", "A", "B"]]:
        take_phone(page)
        rank_as(page, ranking)
        submit_ballot(page)
    tally(page)
    assert page.locator(".winner-name").inner_text() in ("A", "B", "C")
    assert "Smith" in page.locator(".tally-kind").inner_text()
    print("  PASS: random-smith tiebreak")

La règle de secours consulte le Smith set : le plus petit ensemble non-vide S tel que chaque membre de S bat (en duel) chaque candidat extérieur à S. Autrement dit, la “short-list” des candidats qui restent sérieusement en course après qu’on a exclu ceux qu’une majorité préfère à un autre finaliste. Propriétés utiles :

  • Quand un vainqueur de Condorcet existe, le Smith set vaut {vainqueur}. Le cas général englobe donc le cas strict.
  • Dans le paradoxe ci-dessus, le Smith set vaut {A, B, C} : chacun des trois en bat un autre, aucun n’est dominé par un extérieur (il n’y en a pas).
  • Si on ajoute un quatrième candidat D battu par les trois du cycle, le Smith set reste {A, B, C} et D en est exclu — D n’a pas voix au tirage.

Pour construire le Smith set on part de l’ensemble complet et on éjecte itérativement tout membre qu’un candidat extérieur bat en duel, jusqu’à stabilisation. Ce qui reste est par définition le plus petit ensemble dominant — et D, battu par tout le monde, en est naturellement exclu.

function smithSet(candidates, m){
    let smith = new Set(candidates);
    let changed = true;
    while(changed){
        changed = false;
        for(const c of Array.from(smith)){
            const outsiders = candidates.filter(o => !smith.has(o));
            const beatenByOutside = outsiders.some(o => m[o][c] > m[c][o]);
            if(beatenByOutside){ smith.delete(c); changed = true; }
        }
    }
    return Array.from(smith).sort();
}

Dans le Smith set, il faut encore départager. Math.random() suffirait techniquement, mais alors le dépouillement ne serait pas vérifiable : personne ne pourrait, après coup, confirmer que l’app n’a pas triché. On veut un tirage reproductible — à bulletins identiques, même vainqueur, recalculable par n’importe qui en JS ou en Python — tout en gardant le résultat aléatoire vis-à-vis des votants. Les quatre premiers octets de la graine sont affichés au dépouillement pour qu’on puisse citer “le tirage abcd1234”.

Under the hood, cette reproductibilité prend la forme d’un hash SHA-256 calculé sur les bulletins triés par nom de votant (ordre stable indépendant de l’ordre de soumission) ; les quatre premiers octets fournissent le germe entier d’un LCG simple (mulberry32), suffisant pour un tirage unique et vérifiable.

async function seedFromBallots(ballots){
    const normalised = ballots
          .map(b => ({voter: b.voter, ranking: b.ranking}))
          .sort((a, b) => a.voter.localeCompare(b.voter));
    const payload = JSON.stringify(normalised);
    const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(payload));
    return new Uint8Array(buf);
}

function mulberry32(seed){
    let a = seed >>> 0;
    return function(){
        a |= 0; a = a + 0x6D2B79F5 | 0;
        let t = Math.imul(a ^ a >>> 15, 1 | a);
        t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
        return ((t ^ t >>> 14) >>> 0) / 4294967296;
    };
}

computeTally orchestre les deux chemins : d’abord tester le vainqueur strict (s’il existe, on ignore Smith et tirage) ; sinon calculer le Smith set, dériver la graine, et tirer un index avec mulberry32. Le résultat retourné est directement consommable côté template, sans logique supplémentaire.

async function computeTally(scrutin){
    const m = pairwiseMatrix(scrutin.candidates, scrutin.ballots);
    const w = condorcetWinner(scrutin.candidates, m);
    if(w) return { kind: 'condorcet', winner: w, pairwise: m };
    const smith = smithSet(scrutin.candidates, m);
    const seedBytes = await seedFromBallots(scrutin.ballots);
    const seedInt = new DataView(seedBytes.buffer).getUint32(0);
    const rng = mulberry32(seedInt);
    const pick = smith[Math.floor(rng() * smith.length)];
    const seedShort = Array.from(seedBytes.slice(0, 4))
          .map(b => b.toString(16).padStart(2, '0')).join('');
    return { kind: 'random-smith', winner: pick, smith, pairwise: m, seedShort };
}

Le titre du scrutin reste en bandeau au-dessus du résultat.

<div id="tallyScreen" v-show="scrutin && scrutin.closed" v-cloak>
  <h2>Résultat</h2>
  <template v-if="tally">
    <div>
      <p class="tally-kind" v-text="tallyExplanation"></p>
      <div class="winner-card">
        <div class="winner-label">Vainqueur</div>
        <img class="winner-image" v-if="candidateImageUrls && candidateImageUrls[tally.winner]"
             :src="candidateImageUrls[tally.winner]" alt="">
        <div class="winner-name" v-text="tally.winner"></div>
      </div>

      <h3>Matrice des duels</h3>
      <table class="pairwise">
        <thead>
          <tr>
            <th></th>
            <template v-for="c in scrutin.candidates" :key="'h'+c">
              <th v-text="c"></th>
            </template>
          </tr>
        </thead>
        <tbody>
          <template v-for="a in scrutin.candidates" :key="'r'+a">
            <tr>
              <th v-text="a"></th>
              <template v-for="b in scrutin.candidates" :key="'c'+a+b">
                <td :class="{self: a === b, wins: a !== b && tally.pairwise[a][b] > tally.pairwise[b][a], 'pair-current': pairCurrent(a, b)}"
                    v-text="a === b ? '—' : tally.pairwise[a][b]"></td>
              </template>
            </tr>
          </template>
        </tbody>
      </table>

      <div class="tally-animate">
        <button class="secondary" id="btnAnimate"
                v-show="!ui.anim.active" @click="animateStart()">
          &#x25B6; Animer
        </button>
        <div class="anim-transport" v-show="ui.anim.active">
          <button class="icon" id="btnAnimPrev"
                  :disabled="ui.anim.step === 0"
                  title="Précédent" @click="animatePrev()">&#x23EE;</button>
          <button class="icon" id="btnAnimToggle"
                  :title="ui.anim.timer ? 'Pause' : 'Lecture'"
                  @click="animateToggle()">
            <span v-show="!ui.anim.timer">&#x25B6;</span>
            <span v-show="ui.anim.timer">&#x23F8;</span>
          </button>
          <button class="icon" id="btnAnimNext"
                  :disabled="ui.anim.step >= ui.anim.total - 1"
                  title="Suivant" @click="animateNext()">&#x23ED;</button>
          <span class="anim-counter" v-text="animCounterText"></span>
          <button class="icon anim-speed" id="btnAnimSpeed"
                  title="Vitesse" @click="animateCycleSpeed()"
                  v-text="ui.anim.speed + 'x'"></button>
          <button class="icon" id="btnAnimStop"
                  title="Fermer" @click="animateStop()">&#x2715;</button>
        </div>
        <p class="anim-legend" v-show="ui.anim.legend" v-text="ui.anim.legend"></p>
        <ul class="anim-breakdown" v-show="ui.anim.active && ui.anim.breakdown.length" v-cloak>
          <template v-for="row in ui.anim.breakdown" :key="row.voter">
            <li :class="{processed: row.processed, current: row.current}">
              <span class="voter" v-text="row.voter"></span>
              <span class="arrow">&#x2192;</span>
              <span class="pref" v-text="row.prefers || '—'"></span>
            </li>
          </template>
        </ul>
      </div>

      <div class="plurality-panel" v-if="plurality" v-cloak>
        <h3>Et si c'était un scrutin majoritaire ?</h3>
        <p>Chaque votant ne pèse que sur son premier choix, vainqueur = qui en a le plus.</p>
        <ul class="plurality-counts">
          <template v-for="c in scrutin.candidates" :key="'pl'+c">
            <li>
              <span class="plurality-name" v-text="c"></span>
              <span class="plurality-leader" aria-hidden="true"></span>
              <span class="plurality-count" v-text="plurality.counts[c]"></span>
              <span class="plurality-unit">voix</span>
            </li>
          </template>
        </ul>
        <p class="plurality-result">
          <span v-if="plurality.winners.length === 1">
            Vainqueur à la pluralité : <strong class="plurality-winners" v-text="plurality.winners[0]"></strong>.
          </span>
          <span v-if="plurality.winners.length > 1">
            Ex æquo à la pluralité : <strong class="plurality-winners" v-text="plurality.winners.join(', ')"></strong>.
          </span>
          <span v-if="!plurality.winners.includes(tally.winner)" class="plurality-divergence">
            Différent du vainqueur du scrutin (<strong v-text="tally.winner"></strong>).
          </span>
        </p>
      </div>

      <button class="secondary edit-ballot" id="btnEditBallot" @click="editBallotAfterTally()">Modifier les bulletins</button>
      <button class="secondary redo-vote" id="btnRedoVote" @click="redoScrutin()">Refaire</button>
      <button class="primary new-vote" id="btnNewVote" @click="newVote()">Nouveau vote</button>

      <div v-if="tally.kind === 'random-smith'" class="smith-note">
        <h3>Aucun vainqueur strict --- Smith set</h3>
        <p>Pas de candidat ne bat tous les autres en duel : il y a un
          cycle. Le Smith set est le plus petit ensemble dominant. On y
          tire au sort avec une graine reproductible calculée sur les
          bulletins clos.</p>
        <ul>
          <template v-for="c in tally.smith" :key="'s'+c">
            <li v-text="c"></li>
          </template>
        </ul>
        <p class="seed">Graine : <code v-text="tally.seedShort"></code></p>
      </div>
    </div>
  </template>
</div>

#tallyScreen{padding:20px}
.tally-kind{color:var(--muted);font-size:.9rem}
.winner-card{background:var(--card);padding:24px;border-radius:12px;text-align:center;margin:16px 0;border:2px solid var(--ok)}
.winner-label{font-size:.85rem;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
.winner-name{font-size:2rem;font-weight:800;margin-top:6px}
.pairwise{width:100%;border-collapse:collapse;margin:12px 0}
.pairwise th,.pairwise td{padding:6px 10px;text-align:center;border:1px solid #333}
.pairwise td.self{background:#1a1a2a;color:var(--muted)}
.pairwise td.wins{color:var(--ok);font-weight:700}
.smith-note{background:var(--card);padding:12px 16px;border-radius:8px;margin-top:16px}
.smith-note .seed{color:var(--muted);font-size:.8rem;margin-top:8px}
.edit-ballot{display:block;width:100%;margin-top:24px}
.redo-vote{display:block;width:100%;margin-top:8px}
.new-vote{display:block;width:100%;margin-top:8px}

Le clic sur “Dépouiller” ne doit pas laisser de moment de flottement : dès la clôture, l’UI affiche le vainqueur dans une carte et explique en une phrase pourquoi il a gagné. Les votants doivent comprendre, pas seulement voir — la confiance dans le résultat tient à ce que chacun puisse se dire “oui, mon candidat a vraiment été battu en duel”, d’où la matrice des duels affichée juste en dessous.

Un résultat n’a de valeur que s’il reste affiché après un reload ou l’ouverture d’une copie partagée du doc : le groupe cite le vainqueur, un retardataire ouvre le lien, quelqu’un revient plus tard sur la page — la carte du vainqueur doit apparaître dès que le scrutin clos est rehydraté, sans action manuelle.

@testcase
def test_tally_persists_after_reload(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    take_phone(page)
    rank_as(page, ['A', 'B'])
    submit_ballot(page)
    tally(page)
    page.locator(".winner-name").wait_for(state="visible")
    assert page.locator(".winner-name").inner_text() == "A"
    page.wait_for_timeout(300)  # flush debounced IndexedDB save
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    page.locator(".winner-name").wait_for(state="visible", timeout=5000)
    assert page.locator(".winner-name").inner_text() == "A"
    print("  PASS: tally persists after reload")

Une fois le vainqueur connu, trois suites sont possibles : repartir d’une page blanche pour un autre scrutin, refaire celui-ci avec une variante (un votant en plus, un candidat oublié), ou réviser un bulletin si quelqu’un a changé d’avis avant qu’on n’agisse sur le résultat. Trois boutons sur l’écran de résultat couvrent ces cas, le doc clos restant dans tous les cas persisté localement et accessible par son URL.

Nouveau vote renvoie cet onglet à l’écran d’accueil pour repartir d’une page blanche : strip de ?doc…= et rechargement.

@testcase
def test_new_vote_button(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    take_phone(page)
    submit_ballot(page)
    tally(page)
    page.locator(".winner-name").wait_for(state="visible")
    _role_button(page, "Nouveau vote").click()
    page.wait_for_selector("[data-app-ready]")
    assert page.get_by_role("button", name="Créer un scrutin").is_visible()
    assert "doc=" not in page.url
    print("  PASS: new vote button")

Refaire sert le cas opposé : un votant arrivé en retard, un candidat qu’on a oublié, ou une revanche sur le même groupe. Plutôt que de tout retaper, le bouton recopie le scrutin clos dans le brouillon de formulaire et la modale de création s’ouvre seule au rechargement avec titre, candidats (et leurs photos), votants et mode déjà remplis. L’organisateur n’a qu’à ajuster ce qui change.

@testcase
def test_redo_prefills_form(page):
    """« Refaire » recopie le scrutin clos dans la modale de création :
    titre, candidats, votants, mode pré-remplis."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"],
                   title="Apéro", mode="per-device")
    pick_identity(page, "Alice")
    submit_ballot(page)
    tally(page)
    page.locator(".winner-name").wait_for(state="visible")
    page.get_by_role("button", name="Refaire", exact=True).click()
    page.wait_for_selector("[data-app-ready]")
    assert page.get_by_label("Titre").input_value() == "Apéro"
    assert page.get_by_placeholder("Candidat 1").input_value() == "A"
    assert page.get_by_placeholder("Candidat 2").input_value() == "B"
    assert page.get_by_placeholder("Votant 1").input_value() == "Alice"
    assert page.get_by_label("Un appareil par personne").is_checked()
    assert "doc=" not in page.url
    print("  PASS: redo prefills form")

Le brouillon réutilise le slot localStorage.condorcet.form-draft déjà géré par la persistance du formulaire : aucune mécanique nouvelle, juste à reprojeter la map candidateImages={nom: data} du doc en l’array parallèle aux candidats que le formulaire attend. Refaire n’est qu’un raccourci pour « recharger avec un brouillon prérempli » : l’auto-ouverture qui suit est exactement la même que pour n’importe quel autre brouillon.

Modifier les bulletins sert le troisième cas : un votant change d’avis avant qu’on n’ait agi sur le résultat. Le clic ré-ouvre le scrutin (closed=false, tally invalidé puisqu’il ne reflète plus les bulletins) et ramène à l’écran d’identification, d’où le votant relance sa procédure de vote avec son ancien classement comme point de départ. Au re-submit puis re-Dépouille, le nouveau résultat reflète le bulletin révisé. La même mutation Automerge ré-ouvre et invalide, comme l’ajout d’un votant après clôture.

@testcase
def test_edit_ballot_after_tally(page):
    """Sur l'écran de résultat, "Modifier les bulletins" ré-ouvre le
    scrutin (winner-card masquée) et ramène à l'identity-screen
    pour qu'un votant recharge son ancien classement et le révise."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    take_phone(page)
    rank_as(page, ["A", "B"])
    submit_ballot(page)
    tally(page)
    page.locator(".winner-name").wait_for(state="visible")
    assert page.locator(".winner-name").inner_text() == "A"

    _role_button(page, "Modifier les bulletins").click()
    page.locator(".winner-name").wait_for(state="hidden")
    page.locator(".identity-screen").wait_for(state="visible")
    _role_button(page, "Modifier").click()
    assert ballot_items(page) == ["A", "B"], \
        f"pré-remplissage attendu ['A','B'], obtenu {ballot_items(page)}"
    rank_as(page, ["B", "A"])
    submit_ballot(page)

    tally(page)
    page.locator(".winner-name").wait_for(state="visible")
    assert page.locator(".winner-name").inner_text() == "B"
    print("  PASS: edit ballot after tally")

Object.defineProperty(voteStore, 'tallyExplanation', {
    enumerable: true, configurable: true,
    get(){
        if(!this.tally) return '';
        if(this.tally.kind === 'condorcet')
            return 'Vainqueur de Condorcet strict : bat tous les autres en duel.';
        return 'Aucun vainqueur de Condorcet : cycle détecté. Tirage au sort reproductible dans le Smith set.';
    },
});

Object.assign(voteStore, {
    async close(){
        this.change(d => { d.closed = true; });
        this.tally = await computeTally(this.scrutin);
    },

    newVote(){
        location.href = location.pathname;
    },

    redoScrutin(){
        const s = this.scrutin;
        const liveImages = (_handle && _handle.doc() && _handle.doc().candidateImages) || {};
        const draft = {
            title: s.title,
            candidates: [...s.candidates],
            candidateImages: s.candidates.map(c => {
                const bytes = liveImages[c];
                return bytes ? bytesToDataUrl(bytes) : '';
            }),
            voters: [...s.voters],
            mode: s.mode,
        };
        try { localStorage.setItem(DRAFT_KEY, JSON.stringify(draft)); } catch(e) {}
        location.href = location.pathname;
    },

    editBallotAfterTally(){
        this.change(d => { d.closed = false; });
        this.tally = null;
    },
});

Rejouer le dépouillement

La matrice affiche les scores finaux mais cache le chemin : un novice qui voit “A : 2 / 1 / 0” sur la ligne A ne reconstitue pas seul d’où viennent ces chiffres. Pour que chacun comprenne comment on arrive au tableau — et pas seulement qui a gagné — un bouton Animer rejoue le dépouillement bulletin par bulletin, à la manière d’un lecteur audio.

L’unité de pas est le bulletin à l’intérieur d’un duel. Pour chaque paire (a, b) prise dans l’ordre du scan naturel (i < j), on déroule les bulletins un par un : chaque bulletin incrémente d’une unité le côté qu’il préfère (+1 Kebab si le votant a classé Kebab avant Sushi), la case correspondante de la matrice tique, et un panneau sous la légende affiche tous les bulletins pour cette paire, avec le bulletin courant en surbrillance, les précédents en opacité pleine, les suivants grisés. Le “d’où viennent ces 2 et ces 1” devient explicite : on voit qui vote pour quoi et le compteur grimper.

Au clic sur Animer, la matrice se remplace par sa version partielle (pointillés sur les paires non jouées, compteur partiel sur la paire en cours), on se pose sur le premier bulletin de la première paire en pause — pas d’auto-play qui déroule avant que le votant ait eu le temps de comprendre ce qu’il regarde. Une barre de transport remplace le bouton : ⏮ précédent, ▶/⏸ lecture/pause, ⏭ suivant, un compteur sémantique Duel n/P · Bulletin b/B (P paires, B bulletins), un bouton de vitesse qui cycle entre 1x, 2x et 0.5x, et ✕ pour fermer. Le compteur sémantique évite le calcul mental “4/9 → quelle paire, quel bulletin ?”. À l’utilisateur de lancer la lecture avec ▶ quand il a absorbé le contexte, ou d’avancer à son rythme avec ⏭. Au dernier pas la lecture s’arrête d’elle-même ; un ▶ depuis la fin relance depuis le début, comme rejouer un morceau.

La légende seule ne suffit pas à relier visuellement le duel en cours aux cases concernées de la matrice. Deux flashes orange se succèdent sur les cases miroirs de la paire courante : un gros flash quand on entre dans une nouvelle paire, un plus discret quand seul le compteur grimpe à l’intérieur d’une même paire. L’œil suit le point chaud du regard. Under the hood, le premier flash est un keyframe CSS posé par la classe pair-current ; le second doit être déclenché impérativement via la Web Animations API, parce qu’un keyframe CSS ne rejoue pas sans changement de classe.

@testcase
def test_animate_tally(page):
    """Après dépouillement, Animer pose l'utilisateur sur le premier
    pas en pause (pas d'auto-play), puis les contrôles type lecteur
    audio permettent lecture, pause, next/prev, et fermeture qui
    restaure la matrice."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B", "C"], voters=["Alice", "Bob"])
    for _ in range(2):
        take_phone(page)
        submit_ballot(page)
    tally(page)
    page.locator(".winner-name").wait_for(state="visible")
    cells = lambda: page.locator(".pairwise td").all_inner_texts()
    before = cells()

    # Click Animer : transport visible, premier pas affiché, pas d'auto-play.
    page.click("#btnAnimate")
    page.locator(".anim-transport").wait_for(state="visible")
    assert "…" in cells(), f"attendu placeholders après start : {cells()}"
    assert "Duel" in page.locator(".anim-legend").inner_text()
    # Deux cases miroirs de la paire courante portent la classe pair-current.
    assert page.locator(".pairwise td.pair-current").count() == 2, \
        "attendu 2 cases miroirs mises en évidence"
    # Le panneau de breakdown liste les bulletins (2 votants = 2 lignes)
    # dont exactement une marquée "current".
    assert page.locator(".anim-breakdown li").count() == 2
    assert page.locator(".anim-breakdown li.current").count() == 1
    # Compteur sémantique : "Duel x/3 · Bulletin y/2" (3 paires, 2 votants).
    counter = page.locator(".anim-counter").inner_text()
    assert "Duel" in counter and "Bulletin" in counter \
        and "/3" in counter and "/2" in counter, \
        f"compteur sémantique attendu, got {counter!r}"
    # Pas d'auto-play : le compteur doit rester figé sans action.
    page.wait_for_timeout(1700)
    assert page.locator(".anim-counter").inner_text() == counter, \
        f"auto-play inattendu : compteur a avancé de {counter!r}"

    # Next avance d'un pas, Prev revient.
    page.click("#btnAnimNext")
    assert page.locator(".anim-counter").inner_text() != counter, \
        "Next n'a pas avancé"
    page.click("#btnAnimPrev")
    assert page.locator(".anim-counter").inner_text() == counter, \
        "Prev n'a pas reculé à l'état précédent"

    # Play : toggle démarre le timer, compteur avance après un tick.
    page.click("#btnAnimToggle")
    page.wait_for_timeout(1700)
    playing = page.locator(".anim-counter").inner_text()
    assert playing != counter, "Play n'a pas démarré le timer"
    # Pause : toggle à nouveau, compteur figé.
    page.click("#btnAnimToggle")
    paused = page.locator(".anim-counter").inner_text()
    page.wait_for_timeout(1700)
    assert page.locator(".anim-counter").inner_text() == paused, \
        "Pause n'a pas arrêté le timer"

    # Speed cycle : 1x → 2x → 0.5x → 1x.
    speed = page.locator("#btnAnimSpeed")
    assert speed.inner_text().strip() == "1x", f"défaut : {speed.inner_text()}"
    speed.click()
    assert speed.inner_text().strip() == "2x"
    speed.click()
    assert speed.inner_text().strip() == "0.5x"
    speed.click()
    assert speed.inner_text().strip() == "1x"

    # Stop : matrice restaurée, transport disparaît, pair-current disparaît.
    page.click("#btnAnimStop")
    page.locator(".anim-transport").wait_for(state="hidden")
    assert cells() == before, f"matrice non restaurée après stop : {cells()}"
    assert page.locator(".pairwise td.pair-current").count() == 0, \
        "pair-current persiste après stop"
    print("  PASS: animate tally")

La barre de transport est une rangée d’icônes centrée sous la matrice, avec Play et Pause rendus en un seul bouton à deux états — une zone cliquable unique pour la bascule, comme sur tous les lecteurs.

<div class="tally-animate">
  <button class="secondary" id="btnAnimate"
          v-show="!ui.anim.active" @click="animateStart()">
    &#x25B6; Animer
  </button>
  <div class="anim-transport" v-show="ui.anim.active">
    <button class="icon" id="btnAnimPrev"
            :disabled="ui.anim.step === 0"
            title="Précédent" @click="animatePrev()">&#x23EE;</button>
    <button class="icon" id="btnAnimToggle"
            :title="ui.anim.timer ? 'Pause' : 'Lecture'"
            @click="animateToggle()">
      <span v-show="!ui.anim.timer">&#x25B6;</span>
      <span v-show="ui.anim.timer">&#x23F8;</span>
    </button>
    <button class="icon" id="btnAnimNext"
            :disabled="ui.anim.step >= ui.anim.total - 1"
            title="Suivant" @click="animateNext()">&#x23ED;</button>
    <span class="anim-counter" v-text="animCounterText"></span>
    <button class="icon anim-speed" id="btnAnimSpeed"
            title="Vitesse" @click="animateCycleSpeed()"
            v-text="ui.anim.speed + 'x'"></button>
    <button class="icon" id="btnAnimStop"
            title="Fermer" @click="animateStop()">&#x2715;</button>
  </div>
  <p class="anim-legend" v-show="ui.anim.legend" v-text="ui.anim.legend"></p>
  <ul class="anim-breakdown" v-show="ui.anim.active && ui.anim.breakdown.length" v-cloak>
    <template v-for="row in ui.anim.breakdown" :key="row.voter">
      <li :class="{processed: row.processed, current: row.current}">
        <span class="voter" v-text="row.voter"></span>
        <span class="arrow">&#x2192;</span>
        <span class="pref" v-text="row.prefers || '—'"></span>
      </li>
    </template>
  </ul>
</div>

.tally-animate{text-align:center;margin:12px 0}
.tally-animate > button{padding:8px 14px;font-size:.9rem}
.anim-transport{display:inline-flex;gap:6px;align-items:center;justify-content:center;flex-wrap:wrap;background:var(--card);padding:6px 10px;border-radius:999px}
.anim-transport button.icon{background:transparent;color:var(--fg);padding:6px 10px;font-size:1rem;border-radius:999px}
.anim-transport button.icon:disabled{opacity:.3}
.anim-transport button.anim-speed{font-size:.8rem;font-weight:600;min-width:2.4em;padding:6px 8px}
.anim-counter{color:var(--muted);font-size:.85rem;padding:0 6px}
.anim-legend{color:var(--muted);font-size:.9rem;margin:10px 0 0 0;min-height:1.3em}
@keyframes pair-flash{
    0%{background:rgba(249,168,38,0)}
    40%{background:rgba(249,168,38,.65)}
    100%{background:rgba(249,168,38,.22)}
}
.pairwise td.pair-current{background:rgba(249,168,38,.22);animation:pair-flash .5s ease}
.anim-breakdown{list-style:none;padding:0;margin:8px auto 0 auto;max-width:320px;text-align:left}
.anim-breakdown li{display:flex;gap:8px;align-items:center;padding:4px 10px;border-radius:6px;color:var(--muted);opacity:.4;transition:opacity .3s,background .3s}
.anim-breakdown li.processed{opacity:1;color:var(--fg)}
.anim-breakdown li.current{background:rgba(249,168,38,.18)}
.anim-breakdown .voter{flex:1;font-weight:500}
.anim-breakdown .arrow{color:var(--muted)}
.anim-breakdown .pref{font-weight:700}

Le compteur sémantique “Duel n/P · Bulletin b/B” demande de savoir à tout moment où on en est dans quelle paire. Un seul pas courant couvrant la grille (paire × bulletin) suffit : le compteur y lit tout ce qu’il affiche sans logique côté template.

Object.assign(voteStore.ui, {
    anim: { active: false, step: 0, total: 0, pairs: [], ballots: [],
            full: null, timer: null, legend: '', breakdown: [],
            pairIdx: 0, ballotIdx: 0, speed: 1, _lastPair: -1 },
});

Object.defineProperty(voteStore, 'animCounterText', {
    enumerable: true, configurable: true,
    get(){
        const a = this.ui.anim;
        if(!a.active || !a.pairs.length) return '';
        return `Duel ${a.pairIdx+1}/${a.pairs.length}` +
               ` · Bulletin ${a.ballotIdx+1}/${a.ballots.length}`;
    },
});

Le compteur partiel et le breakdown affichent tous deux “qui préfère qui” sur chaque bulletin : ils doivent s’appuyer sur une seule fonction (_ballotPref) pour éviter que l’animation ne se contredise elle-même en cours de tick.

Object.assign(voteStore, {
    pairCurrent(a, b){
        if(!this.ui.anim.active || a === b) return false;
        const cur = this.ui.anim.pairs[this.ui.anim.pairIdx];
        if(!cur) return false;
        return (a === cur[0] && b === cur[1]) || (a === cur[1] && b === cur[0]);
    },

    _ballotPref(ballot, a, b){
        const pa = ballot.ranking.indexOf(a);
        const pb = ballot.ranking.indexOf(b);
        if(pa === -1 || pb === -1) return null;
        return pa < pb ? a : b;
    },
});

À chaque pas, la matrice affichée est reconstruite plutôt que mutée — une nouvelle référence est la seule chose que petite-vue sait détecter à coup sûr sans tracer les mutations profondes. Au passage, on affiche gratuitement le rendu partiel : paires terminées avec leurs valeurs finales, paire courante avec son compteur actuel, paires futures en pointillés.

Object.assign(voteStore, {
    _animPartial(pairIdx, ballotIdx){
        const cs = this.scrutin.candidates;
        const m = {};
        for(const a of cs){
            m[a] = {};
            for(const b of cs) if(b !== a) m[a][b] = '…';
        }
        for(let k = 0; k < pairIdx; k++){
            const [a, b] = this.ui.anim.pairs[k];
            m[a][b] = this.ui.anim.full[a][b];
            m[b][a] = this.ui.anim.full[b][a];
        }
        const [a, b] = this.ui.anim.pairs[pairIdx];
        let xa = 0, xb = 0;
        for(let i = 0; i <= ballotIdx; i++){
            const pref = this._ballotPref(this.ui.anim.ballots[i], a, b);
            if(pref === a) xa++;
            else if(pref === b) xb++;
        }
        m[a][b] = xa;
        m[b][a] = xb;
        return m;
    },

    _animRender(){
        const B = this.ui.anim.ballots.length;
        const s = this.ui.anim.step;
        const p = Math.floor(s / B);
        const bi = s % B;
        const samePair = this.ui.anim._lastPair === p;
        this.ui.anim._lastPair = p;
        this.ui.anim.pairIdx = p;
        this.ui.anim.ballotIdx = bi;
        this.tally.pairwise = this._animPartial(p, bi);
        const [a, b] = this.ui.anim.pairs[p];
        const ballot = this.ui.anim.ballots[bi];
        this.ui.anim.legend = `Duel ${a} vs ${b} — bulletin de ${ballot.voter}`;
        const breakdown = [];
        for(let i = 0; i < B; i++){
            const bal = this.ui.anim.ballots[i];
            breakdown.push({
                voter: bal.voter,
                prefers: this._ballotPref(bal, a, b),
                processed: i <= bi,
                current: i === bi,
            });
        }
        this.ui.anim.breakdown = breakdown;
        if(samePair) this._animValueFlash();
    },

    _animValueFlash(){
        requestAnimationFrame(() => {
            document.querySelectorAll('.pairwise td.pair-current').forEach(el => {
                el.animate([
                    { backgroundColor: 'rgba(249,168,38,.6)' },
                    { backgroundColor: 'rgba(249,168,38,.22)' },
                ], { duration: 400, easing: 'ease-out' });
            });
        });
    },
});

Le transport se comporte comme un lecteur audio. Un clic manuel (précédent/suivant) pause implicitement : l’utilisateur prend le contrôle, le timer ne doit pas l’écraser au tick suivant. Arrivé au bout, ▶ rejoue depuis le début — rejouer le morceau plutôt que pousser dans le vide. Le cycle de vitesse (1x, 2x, 0.5x) s’applique immédiatement en cours de lecture — pas besoin de relancer pour que le nouveau tempo prenne effet.

Object.assign(voteStore, {
    animateStart(){
        if(!this.tally || this.ui.anim.active) return;
        const cs = this.scrutin.candidates;
        const pairs = [];
        for(let i = 0; i < cs.length; i++)
            for(let j = i+1; j < cs.length; j++)
                pairs.push([cs[i], cs[j]]);
        const ballots = this.scrutin.ballots.slice();
        if(pairs.length === 0 || ballots.length === 0) return;
        this.ui.anim.full = this.tally.pairwise;
        this.ui.anim.pairs = pairs;
        this.ui.anim.ballots = ballots;
        this.ui.anim.total = pairs.length * ballots.length;
        this.ui.anim.step = 0;
        this.ui.anim.active = true;
        this.ui.anim._lastPair = -1;
        this._animRender();
    },

    animatePlay(){
        if(this.ui.anim.timer) return;
        const interval = 1500 / this.ui.anim.speed;
        this.ui.anim.timer = setInterval(() => {
            if(this.ui.anim.step >= this.ui.anim.total - 1){
                this.animatePause();
                return;
            }
            this.ui.anim.step++;
            this._animRender();
        }, interval);
    },

    animatePause(){
        if(this.ui.anim.timer){
            clearInterval(this.ui.anim.timer);
            this.ui.anim.timer = null;
        }
    },

    animateToggle(){
        if(this.ui.anim.timer){ this.animatePause(); return; }
        if(this.ui.anim.step >= this.ui.anim.total - 1){
            this.ui.anim.step = 0;
            this._animRender();
        }
        this.animatePlay();
    },

    animateNext(){
        if(this.ui.anim.step >= this.ui.anim.total - 1) return;
        this.animatePause();
        this.ui.anim.step++;
        this._animRender();
    },

    animatePrev(){
        if(this.ui.anim.step <= 0) return;
        this.animatePause();
        this.ui.anim.step--;
        this._animRender();
    },

    animateCycleSpeed(){
        const speeds = [1, 2, 0.5];
        const i = speeds.indexOf(this.ui.anim.speed);
        this.ui.anim.speed = speeds[(i + 1) % speeds.length];
        if(this.ui.anim.timer){
            this.animatePause();
            this.animatePlay();
        }
    },

    animateStop(){
        this.animatePause();
        if(this.ui.anim.full) this.tally.pairwise = this.ui.anim.full;
        this.ui.anim.active = false;
        this.ui.anim.legend = '';
        this.ui.anim.pairs = [];
        this.ui.anim.ballots = [];
        this.ui.anim.breakdown = [];
        this.ui.anim.step = 0;
        this.ui.anim.total = 0;
        this.ui.anim.full = null;
        this.ui.anim._lastPair = -1;
    },
});

Et si c’était un scrutin majoritaire ?

Affirmer que le scrutin majoritaire crée des paradoxes ne suffit pas ; pour le voir, il faut le montrer. Sous le résultat Condorcet, un panneau rejoue les mêmes bulletins en pluralité — chacun ne pèse que sur son premier choix, vainqueur = qui en a le plus. Quand les deux méthodes coïncident, tant mieux. Quand elles divergent, la divergence parle d’elle-même : un candidat consensuel (deuxième pour beaucoup) peut gagner tous les duels mais arriver derrière en pluralité.

Cinq votants suffisent à exhiber le cas. Deux mettent A en tête, deux C, et un seul B — la pluralité départage A et C ex æquo (2 voix chacun), pendant que B, classé deuxième par presque tout le monde, gagne tous ses duels et coiffe le scrutin Condorcet. Le panneau doit afficher ces compteurs, nommer A et C comme vainqueurs ex æquo de la pluralité, et signaler que B — le vainqueur du scrutin — n’apparaît pas dans cette finale.

@testcase
def test_plurality_comparison(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B", "C"],
                   voters=["V1", "V2", "V3", "V4", "V5"])
    for ranking in [["A", "B", "C"], ["A", "B", "C"],
                    ["B", "C", "A"],
                    ["C", "B", "A"], ["C", "B", "A"]]:
        take_phone(page)
        rank_as(page, ranking)
        submit_ballot(page)
    tally(page)
    assert page.locator(".winner-name").inner_text() == "B"
    panel = page.locator(".plurality-panel")
    panel.wait_for(state="visible")
    counts = {li.locator(".plurality-name").inner_text():
              int(li.locator(".plurality-count").inner_text())
              for li in panel.locator(".plurality-counts li").all()}
    assert counts == {"A": 2, "B": 1, "C": 2}, counts
    winners_text = panel.locator(".plurality-winners").inner_text()
    assert "A" in winners_text and "C" in winners_text, winners_text
    assert "B" not in winners_text, winners_text
    assert panel.locator(".plurality-divergence").is_visible()
    print("  PASS: plurality comparison")

Le calcul est direct : compter les premiers choix de chaque bulletin, prendre le maximum, retenir tous les ex æquo. Le getter plurality du store dérive le résultat à la volée à partir des bulletins clos, sans état supplémentaire à maintenir.

function pluralityResult(candidates, ballots){
    const counts = {};
    for(const c of candidates) counts[c] = 0;
    for(const b of ballots){
        const first = b.ranking[0];
        if(first in counts) counts[first]++;
    }
    const top = Math.max(0, ...Object.values(counts));
    const winners = candidates.filter(c => counts[c] === top);
    return { counts, winners };
}

Object.defineProperty(voteStore, 'plurality', {
    enumerable: true, configurable: true,
    get(){
        if(!this.scrutin || !this.tally) return null;
        return pluralityResult(this.scrutin.candidates, this.scrutin.ballots);
    },
});

Le panneau s’insère sous la matrice et l’animation, juste avant les boutons d’action. Une ligne par candidat — nom à gauche, voix à droite, reliés par des pointillés pour que l’œil suive sans hésiter —, un paragraphe pour nommer le ou les vainqueurs de pluralité, et une mention en évidence quand le vainqueur du scrutin n’y figure pas. La juxtaposition entre la matrice de duels juste au-dessus et le décompte de pluralité juste en dessous fait tout le travail pédagogique.

<div class="plurality-panel" v-if="plurality" v-cloak>
  <h3>Et si c'était un scrutin majoritaire ?</h3>
  <p>Chaque votant ne pèse que sur son premier choix, vainqueur = qui en a le plus.</p>
  <ul class="plurality-counts">
    <template v-for="c in scrutin.candidates" :key="'pl'+c">
      <li>
        <span class="plurality-name" v-text="c"></span>
        <span class="plurality-leader" aria-hidden="true"></span>
        <span class="plurality-count" v-text="plurality.counts[c]"></span>
        <span class="plurality-unit">voix</span>
      </li>
    </template>
  </ul>
  <p class="plurality-result">
    <span v-if="plurality.winners.length === 1">
      Vainqueur à la pluralité : <strong class="plurality-winners" v-text="plurality.winners[0]"></strong>.
    </span>
    <span v-if="plurality.winners.length > 1">
      Ex æquo à la pluralité : <strong class="plurality-winners" v-text="plurality.winners.join(', ')"></strong>.
    </span>
    <span v-if="!plurality.winners.includes(tally.winner)" class="plurality-divergence">
      Différent du vainqueur du scrutin (<strong v-text="tally.winner"></strong>).
    </span>
  </p>
</div>

.plurality-panel{background:var(--card);padding:12px 16px;border-radius:8px;margin-top:16px}
.plurality-counts{list-style:none;padding:0;margin:8px 0;display:flex;flex-direction:column;gap:4px}
.plurality-counts li{display:flex;gap:8px;align-items:baseline}
.plurality-name{font-weight:500}
.plurality-leader{flex:1;border-bottom:1px dotted var(--muted);align-self:flex-end;margin:0 2px 6px}
.plurality-count{font-weight:700;color:var(--ok)}
.plurality-unit{color:var(--muted);font-size:.85rem}
.plurality-result{margin:8px 0 0 0}
.plurality-divergence{display:block;margin-top:6px;color:#f9a826;font-weight:600}

Voter à l’aveugle

Pendant qu’un votant classe ses candidats, il ne doit voir aucun bulletin déjà soumis (secret du bulletin) ; et le bulletin lui est présenté dans un ordre tiré au hasard, indépendant de toute saisie antérieure (ordre d’affichage neutre).

Voter en mode appareil partagé

En mode partagé, tout le monde vote sur le même téléphone. Deux contraintes : le votant qui prend l’appareil ne doit pas voir le classement du précédent (sinon il est influencé), et l’ordre des votants n’est pas imposé — chacun se sert quand il est prêt.

La liste des votants assume les deux rôles à la fois. Entre deux votes, elle est seule visible : chaque nom est un bouton, le coché « a voté » grise les noms déjà passés, et un × à droite retire un nom typé par erreur ou parti avant la fin (cf. Retirer un votant en cours de route). Aucun classement, aucun bulletin n’apparaît — la liste fait office d’écran-tampon naturel. On tend l’appareil au suivant, qui clique son nom, classe ses candidats par drag-and-drop, soumet, et son nom passe à coché à son tour.

Les noms déjà cochés sont désactivés. Ce n’est pas une exigence de secret — qui a voté n’est pas un secret entre amis — mais une protection contre l’écrasement accidentel d’un bulletin déjà soumis.

<div class="identity-screen"
     v-show="scrutin && stage === 'identify' && !scrutin.closed"
     v-cloak>
  <h2 v-show="nextVoter">Qui es-tu&nbsp;?</h2>
  <p class="hint" v-show="nextVoter">Choisis ton nom pour commencer à voter.</p>
  <ul class="identity-list">
    <template v-for="v in (scrutin ? scrutin.voters : [])" :key="'i'+v">
      <li>
        <button class="identity-pick" @click="chooseIdentity(v)"
                :disabled="hasBallotFor(v)">
          <span class="name" v-text="v"></span>
          <span class="status" v-show="hasBallotFor(v)">a voté</span>
        </button>
        <button class="identity-revote" type="button"
                @click="chooseIdentity(v)"
                v-show="hasBallotFor(v) && scrutin.mode !== 'per-device'">Modifier</button>
        <button class="identity-remove" type="button"
                @click="removeVoter(v)"
                v-show="scrutin.voters.length > 1"
                aria-label="Retirer ce votant">&times;</button>
      </li>
    </template>
  </ul>
  <p class="counter">
    <span v-text="ballotsCount"></span> / <span v-text="rosterCount"></span> bulletins
  </p>
</div>

Une fois un nom choisi, l’écran de classement remplace la liste : les candidats dans un ordre aléatoire regénéré à chaque votant (cf. règle de secret). Le votant réordonne par drag-and-drop puis envoie ; retour à la liste des votants, son nom coché.

Chaque item du classement porte deux grips identiques — un à gauche, un à droite — pour que le pouce trouve sa poignée sans déplacer le téléphone, quelle que soit la main qui tient l’appareil (et le réflexe marche aussi pour un gaucher).

Le drag-and-drop est piloté par un moteur partagé (cf. shared_blocks.org) qui émet un événement à chaque déplacement ; le store s’y abonne pour refléter le nouvel ordre dans son état local. Les grips multiples par item sont gérés par le moteur sans code JS spécifique côté scrutin.

<div id="voteFlow" v-show="scrutin && !scrutin.closed" v-cloak>

  <!-- Écran de classement -->
  <div class="ballot-screen" v-show="stage === 'ballot'">
    <h2>Ton classement, <span v-text="currentVoter"></span></h2>
    <p class="hint">Glisse pour ordonner du plus préféré (en haut) au moins préféré.</p>
    <div class="ballot-toolbar">
      <button type="button" class="layout-toggle" @click="toggleBallotLayout()"
              :aria-label="ui.ballotLayout === 'grid' ? 'Passer en colonne' : 'Passer en grille'"
              :title="ui.ballotLayout === 'grid' ? 'Passer en colonne' : 'Passer en grille'">
        <svg v-if="ui.ballotLayout === 'grid'" width="18" height="14" viewBox="0 0 18 14" fill="currentColor">
          <rect width="18" height="2" rx="1"/>
          <rect y="6" width="18" height="2" rx="1"/>
          <rect y="12" width="18" height="2" rx="1"/>
        </svg>
        <svg v-else width="18" height="18" viewBox="0 0 18 18" fill="currentColor">
          <rect width="5" height="5" rx="1"/>
          <rect x="6.5" width="5" height="5" rx="1"/>
          <rect x="13" width="5" height="5" rx="1"/>
          <rect y="6.5" width="5" height="5" rx="1"/>
          <rect x="6.5" y="6.5" width="5" height="5" rx="1"/>
          <rect x="13" y="6.5" width="5" height="5" rx="1"/>
          <rect y="13" width="5" height="5" rx="1"/>
          <rect x="6.5" y="13" width="5" height="5" rx="1"/>
          <rect x="13" y="13" width="5" height="5" rx="1"/>
        </svg>
      </button>
    </div>
    <ol class="ranking reorder-list" id="ranking" data-reorder="ranking"
        :class="{'layout-column': ui.ballotLayout === 'column'}"
        @vue:mounted="bindRankReorder($el)">
      <template v-for="(c, i) in ui.ranking" :key="c">
        <li class="reorder-item" :data-idx="i" :data-candidate="c">
          <span class="reorder-grip" aria-label="Glisser pour réordonner">&#x283F;</span>
          <span class="reorder-rank" v-text="i + 1"></span>
          <img class="reorder-image" v-if="candidateImageUrls && candidateImageUrls[c]"
               :src="candidateImageUrls[c]" alt="">
          <span class="reorder-name" v-text="c"></span>
          <span class="reorder-grip" aria-label="Glisser pour réordonner">&#x283F;</span>
        </li>
      </template>
    </ol>
    <div class="ballot-actions">
      <button class="secondary" @click="cancelBallot()">Annuler</button>
      <button class="primary" id="btnSubmit" @click="submitBallot()">Envoyer mon bulletin</button>
    </div>
  </div>

</div>

.ballot-screen{padding:20px}
.ballot-screen h2{margin:0 0 4px 0}
.ballot-screen .hint{color:var(--muted);font-size:.85rem;margin-bottom:16px}
.ranking{list-style:none;padding:0}
.ballot-toolbar{display:flex;justify-content:flex-end;margin-bottom:8px}
.layout-toggle{background:transparent;color:var(--muted);padding:6px 10px;border:1px solid #333;border-radius:6px;display:inline-flex;align-items:center}
.layout-toggle:hover{color:var(--fg);background:rgba(255,255,255,.05)}
.layout-toggle svg{display:block}
.ballot-screen .reorder-list{flex-direction:row;flex-wrap:wrap}
.ballot-screen .reorder-item{flex:1 1 280px;min-width:0}
.ballot-screen .reorder-list.layout-column{flex-direction:column;flex-wrap:nowrap}
.ballot-screen .reorder-list.layout-column .reorder-item{flex:0 0 auto}
.ballot-actions{display:flex;gap:12px;margin-top:40px;padding-top:20px;border-top:1px solid rgba(255,255,255,.12)}
.ballot-actions button{flex:1}

Le flux complet du mode partagé : chacun choisit son nom dans la liste, classe, soumet, et son nom passe à coché « a voté ». L’ordre n’est pas imposé — le test prend volontairement Bob avant Alice pour s’en assurer. À deux bulletins le bouton Dépouiller s’active. Le titre du scrutin reste en bandeau au-dessus, sur l’identification comme sur le classement.

@testcase
def test_shared_device_flow(page):
    clear_state(page)
    create_scrutin(page, candidates=["A", "B", "C"], voters=["Alice", "Bob"])
    assert page.locator(".scrutin-title").inner_text() == "T"
    pick_identity(page, "Bob")
    assert page.locator(".scrutin-title").inner_text() == "T"
    rank_as(page, ['B', 'C', 'A'])
    page.once("dialog", lambda d: d.dismiss())
    _role_button(page, "Envoyer mon bulletin").click()
    assert _role_button(page, "Envoyer mon bulletin").is_visible(), "dismiss devrait garder l'écran de vote"
    submit_ballot(page)
    identity_text = page.locator(".identity-screen").inner_text()
    assert "Bob" in identity_text and "a voté" in identity_text
    # Bob change d'avis : Modifier pré-remplit son classement soumis.
    _role_button(page, "Modifier").click()
    assert ballot_items(page) == ["B", "C", "A"], \
        f"pré-remplissage attendu ['B','C','A'], obtenu {ballot_items(page)}"
    submit_ballot(page)
    pick_identity(page, "Alice")
    submit_ballot(page)
    _role_button(page, "Dépouiller").wait_for(state="visible")
    print("  PASS: shared-device flow")

La contrainte de secret est absolue : pendant que Bob attend son tour, il ne doit pas voir les candidats ni pouvoir inférer le classement d’Alice. Rien ne doit fuiter dans l’écran-tampon.

@testcase
def test_ballot_secrecy(page):
    clear_state(page)
    create_scrutin(page, candidates=["Kebab", "Sushi"], voters=["Alice", "Bob"])
    take_phone(page)
    submit_ballot(page)
    visible = page.locator(".identity-screen").inner_text()
    assert "Kebab" not in visible
    assert "Sushi" not in visible
    print("  PASS: ballot secrecy")

Après qu’Alice ait réordonné et soumis son bulletin, Bob doit voir une liste propre : exactement les bons candidats, une fois chacun, rangs 1..N contigus — pas de fantôme ni de doublon hérités des manipulations d’Alice.

@testcase
def test_ballot_clean_between_voters(page):
    """Scénario utilisateur : Alice classe ses candidats selon sa
    préférence, soumet, puis Bob prend le téléphone. L'écran de Bob
    doit montrer exactement les candidats (une fois chacun, rangs
    1..N) --- pas de résidu des manipulations d'Alice."""
    clear_state(page)
    cands = ["rouge", "vert", "bleu"]
    create_scrutin(page, candidates=cands, voters=["Alice", "Bob"])
    pick_identity(page, "Alice")
    assert_ballot_clean(page, cands)
    preference = ["bleu", "rouge", "vert"]
    # Précondition : avec la graine pinnée, si le shuffle initial coïncide
    # avec la préférence, =rank_as= ne fait aucun drag --- le test passerait
    # trivialement. Rejeter ce cas force à choisir une autre graine.
    assert ballot_items(page) != preference, \
        "shuffle initial == préférence : le test n'exerce pas de drag"
    rank_as(page, preference)
    submit_ballot(page)
    pick_identity(page, "Bob")
    assert_ballot_clean(page, cands)
    print("  PASS: ballot clean between voters")

Un votant qui n’a pas fini son classement doit pouvoir passer le téléphone et reprendre plus tard exactement où il en était — sinon il faut tout reclasser quand on se laisse devancer, et le partage à l’apéro devient pénible. Chaque réorganisation sauve donc le classement courant dans le doc Automerge, par votant, et il suit donc la sync : Annuler n’a rien de spécial à faire, c’est déjà persisté ; un F5 accidentel, ou la fermeture / ré-ouverture de la page, restitue l’ordre laissé en plan ; et en per-device, on retrouve le brouillon en ouvrant le scrutin sur un autre appareil avec la même identité (téléphone qui décharge → on bascule sur l’ordi). Quand le votant clique son nom à nouveau, on restaure le brouillon au lieu de mélanger. À la soumission, le brouillon est effacé.

Le brouillon vit donc dans le doc partagé : techniquement, n’importe quel participant peut l’inspecter via IndexedDB. La contrainte de secret du bulletin (cf. Secret du bulletin) tient côté UI — l’app n’affiche jamais les drafts des autres ; pour le reste, on est entre amis, comme pour le bulletin final.

@testcase
def test_resume_later(page):
    """Mode partagé : Alice annule en cours, Bob vote, Alice
    reprend ; l'ordre où elle s'était arrêtée est restauré."""
    clear_state(page)
    cands = ["A", "B", "C"]
    create_scrutin(page, candidates=cands, voters=["Alice", "Bob"])
    pick_identity(page, "Alice")
    alice_partial = ["C", "A", "B"]
    # Si le shuffle initial coïncide déjà avec l'ordre voulu,
    # =rank_as= ne fait aucun drag --- le test passerait sans
    # avoir réordonné. On rejette ce cas pour forcer une vraie
    # manipulation.
    assert ballot_items(page) != alice_partial, \
        "shuffle initial == ordre cible : le test n'exerce pas de drag"
    rank_as(page, alice_partial)
    cancel_ballot(page)
    pick_identity(page, "Bob")
    rank_as(page, ["B", "A", "C"])
    submit_ballot(page)
    pick_identity(page, "Alice")
    assert ballot_items(page) == alice_partial, \
        f"ordre d'Alice non restauré : {ballot_items(page)}"
    submit_ballot(page)
    _role_button(page, "Dépouiller").wait_for(state="visible")
    print("  PASS: resume later")

@testcase
def test_partial_survives_reload(page):
    """Mode partagé : un classement en cours est persisté en
    continu --- un =F5= avant soumission ne perd pas l'ordre."""
    clear_state(page)
    cands = ["A", "B", "C"]
    create_scrutin(page, candidates=cands, voters=["Alice"])
    pick_identity(page, "Alice")
    target = ["C", "A", "B"]
    assert ballot_items(page) != target, \
        "shuffle initial == ordre cible : le test n'exerce pas de drag"
    rank_as(page, target)
    page.reload()
    page.wait_for_selector("[data-app-ready]")
    pick_identity(page, "Alice")
    assert ballot_items(page) == target, \
        f"ordre perdu après reload : {ballot_items(page)}"
    print("  PASS: partial survives reload")

@testcase
def test_partial_syncs_across_devices(page):
    """Per-device : Alice classe sur un appareil, ouvre le scrutin
    sur un autre avec la même identité, retrouve son ordre."""
    sync_url = require_sync_server()
    with two_contexts(page.context.browser) as (ctxA, ctxB):
        pageA = open_app(ctxA, f"{BASE_URL}?sync_url={sync_url}")
        create_scrutin(pageA, candidates=["A", "B", "C"],
                       voters=["Alice", "Bob"], mode="per-device")
        doc_url = pageA.url
        pageA.locator(".identity-screen button:has-text('Alice')").click()
        target = ["C", "A", "B"]
        assert ballot_items(pageA) != target, \
            "shuffle initial == ordre cible : le test n'exerce pas de drag"
        rank_as(pageA, target)
        pageA.wait_for_timeout(500)  # propagation sync

        pageB = open_app(ctxB, f"{doc_url}&sync_url={sync_url}")
        pageB.locator(".identity-screen").wait_for(state="visible")
        pageB.locator(".identity-screen button:has-text('Alice')").click()
        assert wait_for(pageB, lambda: ballot_items(pageB) == target), \
            f"brouillon non synchronisé : {ballot_items(pageB)}"
    print("  PASS: partial syncs across devices")

Côté store, le clic sur un nom de la liste appelle chooseIdentity, qui enchaîne directement sur startBallot : on prend le brouillon partiel de ce votant s’il existe, sinon on mélange les candidats, et on bascule à stage = 'ballot'. À la soumission, on revient sur la liste (stage = 'identify'), où le nom du votant apparaît coché grâce à hasBallotFor, et le brouillon partiel est effacé. La branche per-device de chooseIdentity s’occupe de la persistance de l’identité (cf. Identité persistée).

Annuler en cours de bulletin renvoie sur la liste. En per-device, on en profite pour effacer l’identité du localStorage, car la personne qui annule peut avoir besoin de laisser quelqu’un d’autre voter sur le même appareil.

function shuffled(arr){
    const a = arr.slice();
    for(let i = a.length - 1; i > 0; i--){
        const j = Math.floor(Math.random() * (i + 1));
        [a[i], a[j]] = [a[j], a[i]];
    }
    return a;
}

Object.assign(voteStore.ui, {
    ranking: [],
    ballotLayout: localStorage.getItem('condorcet.ballot-layout') || 'grid',
});

Object.assign(voteStore, {
    toggleBallotLayout(){
        this.ui.ballotLayout = this.ui.ballotLayout === 'grid' ? 'column' : 'grid';
        localStorage.setItem('condorcet.ballot-layout', this.ui.ballotLayout);
    },
});

Object.assign(voteStore, {
    currentVoter: null,

    // Parametré → une méthode, pas un getter.
    hasBallotFor(name){
        return !!this.scrutin && this.scrutin.ballots.some(b => b.voter === name);
    },

    loadSubmitted(voter){
        return this.scrutin?.ballots.find(b => b.voter === voter)?.ranking;
    },

    switchMode(){
        const newMode = this.scrutin.mode === 'per-device' ? 'shared-device' : 'per-device';
        this.change(d => { d.mode = newMode; });
        if(newMode === 'shared-device' && this.stage === 'waiting'){
            this.identity = null;
            localStorage.removeItem(this.identityKey());
            this.stage = 'identify';
        }
    },

    loadPartial(voter){
        const drafts = (this.scrutin && this.scrutin.drafts) || {};
        const draft = drafts[voter];
        if(!draft) return null;
        const order = draft.ranking;
        const candidates = this.scrutin.candidates;
        if(!Array.isArray(order) || order.length !== candidates.length) return null;
        const cs = new Set(candidates);
        if(!order.every(c => cs.has(c)) || new Set(order).size !== order.length) return null;
        return order;
    },

    savePartial(voter, ranking){
        this.change(d => {
            if(!d.drafts) d.drafts = {};
            d.drafts[voter] = { ranking, at: Date.now() };
        });
    },

    clearPartial(voter){
        this.change(d => {
            if(d.drafts && d.drafts[voter]) delete d.drafts[voter];
        });
    },

    chooseIdentity(name){
        if(this.scrutin.mode === 'per-device'){
            this.identity = name;
            localStorage.setItem(this.identityKey(), name);
            if(this.hasBallotFor(name)){ this.stage = 'waiting'; return; }
        }
        this.startBallot(name);
    },

    startBallot(voter){
        this.currentVoter = voter || this.nextVoter;
        this.ui.ranking = this.loadPartial(this.currentVoter)
            || this.loadSubmitted(this.currentVoter)
            || shuffled(this.scrutin.candidates);
        this.stage = 'ballot';
        pushOverlay('ballot');
    },

    cancelBallot(){
        this._doCancelBallot();
        if(history.state && history.state.overlay === 'ballot') history.back();
    },

    _doCancelBallot(){
        this.currentVoter = null;
        this.ui.ranking = [];
        if(this.scrutin && this.scrutin.mode === 'per-device'){
            this.identity = null;
            localStorage.removeItem(this.identityKey());
        }
        this.stage = 'identify';
    },

    submitBallot(){
        if(!confirm("Tu valides ce classement ?")) return;
        const ranking = this.ui.ranking.slice();
        const voter = this.currentVoter;
        this.change(d => {
            const existing = d.ballots.findIndex(b => b.voter === voter);
            const ballot = { voter, ranking, at: Date.now() };
            if(existing >= 0) d.ballots[existing] = ballot;
            else d.ballots.push(ballot);
            if(d.drafts && d.drafts[voter]) delete d.drafts[voter];
        });
        this.currentVoter = null;
        this.ui.ranking = [];
        this.stage = this.scrutin.mode === 'per-device' ? 'waiting' : 'identify';
        if(history.state && history.state.overlay === 'ballot') history.back();
    },

    bindRankReorder(ol){
        const store = this;
        ol.addEventListener('reorder:move', e => {
            const order = store.ui.ranking.slice();
            const [moved] = order.splice(e.detail.from, 1);
            order.splice(e.detail.to, 0, moved);
            store.ui.ranking = order;
            if(store.currentVoter) store.savePartial(store.currentVoter, order);
        });
    },
});

Chacun sur son téléphone : le mode per-device

Chacun ouvre le lien du scrutin sur son propre téléphone et vote séparément. La synchronisation est assurée via WebSocket Automerge vers le serveur automergesync, avec identification mémorisée en localStorage.

Voter en mode un appareil par personne

En per-device, chacun est sur son propre appareil. La même liste des votants sert ici d’écran d’identification : chacun choisit son nom une fois, et c’est tout. Mémoriser ce choix dans localStorage évite de redemander l’identité au reload — ce serait irritant en plein vote. La clé est scopée à l’URL du scrutin pour qu’il n’y ait pas de collision entre scrutins distincts sur le même appareil.

Une fois le bulletin envoyé, on ne renvoie pas sur la liste d’identification : l’identité est déjà connue, et la propre liste des votants est inutile au votant qui a fini. À la place, un écran d’attente confirme le bulletin et montre qui a voté — information sociale minimale, acceptable entre amis. Aucun classement n’est révélé. Le bandeau du titre reste en place.

<div class="waiting-screen"
     v-show="scrutin && !scrutin.closed && scrutin.mode === 'per-device' && stage === 'waiting'"
     v-cloak>
  <h2>Merci, <span v-text="identity"></span>.</h2>
  <p v-show="nextVoter">Ton bulletin est enregistré. En attente des autres…</p>
  <p class="counter">
    <span v-text="ballotsCount"></span> / <span v-text="rosterCount"></span> bulletins
  </p>
  <ul class="voters-list">
    <template v-for="v in (scrutin ? scrutin.voters : [])" :key="'w'+v">
      <li :class="{voted: hasBallotFor(v)}">
        <span v-text="v"></span>
        <span class="check" v-show="hasBallotFor(v)"></span>
      </li>
    </template>
  </ul>
  <button class="secondary" style="margin-top:20px" @click="startBallot(identity)">Modifier mon vote</button>
</div>

.identity-screen,.waiting-screen{text-align:center;padding:40px 20px}
.identity-screen h2,.waiting-screen h2{font-size:1.3rem;margin:0 0 8px 0}
.identity-screen .hint{color:var(--muted);font-size:.9rem;margin-bottom:20px}
.identity-list{list-style:none;padding:0;margin:0 0 20px 0}
.identity-list li{display:flex;gap:6px;margin-bottom:8px}
.identity-pick{flex:1;text-align:left;display:flex;justify-content:space-between;align-items:center;padding:14px 16px;background:var(--card);color:var(--fg)}
.identity-pick .status{color:var(--muted);font-size:.85rem;font-weight:400}
.identity-pick:disabled .name{color:var(--muted)}
.identity-revote{flex-shrink:0;padding:0 12px;background:transparent;border:1px solid #555;border-radius:6px;color:var(--muted);font-size:.8rem}
.mode-toggle-bar{text-align:center;padding:8px 20px 16px}
.mode-toggle{background:none;border:none;color:var(--muted);font-size:.75rem;opacity:.5;cursor:pointer;text-decoration:underline}
.mode-toggle:hover{opacity:1}
.identity-remove{flex-shrink:0;padding:0 14px;background:var(--card);border-radius:6px;color:var(--muted);font-size:1.2rem;line-height:1}
.waiting-screen .counter{margin:16px 0 8px 0;color:var(--muted);font-size:.85rem}
.voters-list{list-style:none;padding:0;margin:8px auto 0 auto;text-align:left;max-width:260px}
.voters-list li{display:flex;justify-content:space-between;padding:6px 12px;border-radius:6px;color:var(--muted)}
.voters-list li.voted{color:var(--fg)}
.voters-list .check{color:var(--ok);font-weight:700}

Le mode per-device n’a de valeur que par la sync : A vote sur son téléphone, B voit le compteur se mettre à jour sur le sien sans rechargement. Le bulletin de B doit se propager jusqu’à A via WebSocket et faire apparaître le bouton Dépouiller chez A.

@testcase
def test_per_device_sync(page):
    sync_url = require_sync_server()
    with two_contexts(page.context.browser) as (ctxA, ctxB):
        pageA = open_app(ctxA, f"{BASE_URL}?sync_url={sync_url}")
        create_scrutin(pageA, candidates=["A", "B"], voters=["Alice", "Bob"],
                       mode="per-device")
        doc_url = pageA.url
        pageA.locator(".identity-screen button:has-text('Alice')").click()
        submit_ballot(pageA)
        pageA.wait_for_timeout(500)
        pageB = open_app(ctxB, f"{doc_url}&sync_url={sync_url}")
        pageB.locator(".identity-screen").wait_for(state="visible")
        pageB.locator(".identity-screen button:has-text('Bob')").click()
        submit_ballot(pageB)
        assert wait_for(pageA, lambda: _role_button(pageA, "Dépouiller").is_visible()), \
            "A n'a pas vu l'état all-voted propagé"
    print("  PASS: per-device sync")

Le mode per-device ne justifie pas un sous-système séparé : la divergence avec le mode partagé tient à quelques lignes de branchement sur scrutin.mode == ‘per-device’= dans chooseIdentity, cancelBallot et submitBallot. Le bloc per-device gère seulement ce qui lui est propre — l’identité attachée à un navigateur, sa clé localStorage, et la rehydratation au boot qui saute l’écran d’identification quand l’identité est déjà connue.

Object.assign(voteStore, {
    identity: null,  // le votant attaché à CE navigateur

    identityKey(){ return 'condorcet.identity.' + _handle.url; },

    initStagePerDevice(doc){
        const stored = localStorage.getItem(this.identityKey());
        if(stored && doc.voters.includes(stored)){
            this.identity = stored;
            if(doc.ballots.some(b => b.voter === stored)){
                this.stage = 'waiting';
            } else {
                this.startBallot(stored);
            }
        } else {
            this.stage = 'identify';
        }
    },
});

Bouton Dépouiller partagé

Les écrans hors-bulletin diffèrent selon le mode (écran d’identification seul en partagé ; identification + attente en per-device), mais le déclencheur “Dépouiller” est le même partout : dès que tout le monde a voté, n’importe qui peut clore le scrutin. On factorise donc ce bouton dans une close-bar unique, montrée sous l’écran courant dès que plus personne n’a à voter.

<div class="close-bar"
     v-show="scrutin && !scrutin.closed && allVoted && stage !== 'ballot'"
     v-cloak>
  <p>Tout le monde a voté.</p>
  <button class="primary" id="btnClose" @click="close()">Dépouiller</button>
</div>

.close-bar{text-align:center;padding:20px;border-top:1px solid #333;margin-top:20px}
.close-bar p{margin:0 0 12px 0;color:var(--muted)}

Classer beaucoup de candidats

À une dizaine de candidats, le ballot dépasse l’écran. Pour faire passer un nom du haut vers le bas, il faut relâcher le drag à mi-chemin pour scroller, re-saisir, déplacer encore — la boucle scroll-drag-scroll-drag est fatigante et casse le geste. Le moteur reorder partagé (cf. Reorder engine) auto-scrolle dès que le pointeur s’approche du bord haut ou bas du viewport. On garde le doigt appuyé, la page suit, le slot grisé reste collé à l’élément traîné, et on lâche au bon endroit.

@testcase
def test_ballot_auto_scroll(page):
    """Tenir le drag près du bord bas fait défiler la page toute seule,
    l'item traîné atterrit dans le dernier quintile (inatteignable sans
    scroll automatique), et le slot grisé reste sur lui pendant tout le
    trajet."""
    clear_state(page)
    candidates = [f"C{i:02d}" for i in range(20)]
    create_scrutin(page, candidates=candidates, voters=["Alice"])
    pick_identity(page, "Alice")
    page.locator(".ballot-screen").wait_for(state="visible")

    page.evaluate("window.scrollTo(0, 0)")
    assert page.evaluate("window.scrollY") == 0

    items = page.locator(".reorder-list .reorder-item")
    first_name = items.first.locator(".reorder-name").inner_text()
    box = items.first.locator(".reorder-grip").first.bounding_box()
    page.mouse.move(box["x"] + box["width"]/2, box["y"] + box["height"]/2)
    page.mouse.down()
    vp = page.viewport_size
    page.mouse.move(box["x"] + box["width"]/2, vp["height"] - 20)

    # On attend un scroll important : tenir au bord pendant ~half-second
    # déplace la page de plusieurs centaines de px, ce qui pousse le
    # cursor (resté à clientY = vp.bottom-20) bien après l'item le plus
    # bas du viewport initial.
    page.wait_for_function("() => window.scrollY > 300", timeout=5000)

    placeholder = page.locator(".reorder-item.drag-placeholder")
    assert placeholder.count() == 1, \
        f"attendu 1 slot grisé, vu {placeholder.count()}"
    ph_cand = placeholder.get_attribute("data-candidate")
    assert ph_cand == first_name, \
        f"slot grisé sur {ph_cand!r}, attendu l'élément traîné {first_name!r}"

    page.mouse.up()

    final_order = ballot_items(page)
    final_pos = final_order.index(first_name)
    assert final_pos >= len(final_order) - 6, \
        f"{first_name} ended at {final_pos}/{len(final_order)}, expected last 6"
    print("  PASS: ballot auto-scroll near edge")

Classer sur écran large

Sur tablette ou desktop, la colonne unique laisse beaucoup d’espace horizontal vide à côté du bulletin. La liste enroule donc en grille flexible : chaque item garde une largeur minimale lisible, et autant d’items que la viewport en permet se rangent côte à côte. Sur téléphone, un seul tient et la grille redevient naturellement une colonne, sans media query. Le drag-and-drop fonctionne d’une case à l’autre dans toutes les directions, y compris intra-rangée — on saisit un candidat et on le pose là où on veut.

La grille n’est pas toujours souhaitable, même quand elle tient à l’écran : avec peu de candidats elle peut paraître éparpillée, et certains préfèrent simplement lire de haut en bas. Un bouton sur l’écran de classement bascule entre grille et colonne, et le choix est mémorisé dans localStorage pour qu’il survive au prochain scrutin — une préférence qu’on pose une fois, pas un toggle qu’on refait à chaque vote.

@testcase
def test_ballot_grid_on_wide(page):
    """Sur viewport large, au moins deux candidats partagent une ligne,
    et un drag intra-rangée déplace bien le candidat traîné --- cas
    qu'un moteur strictement vertical ne sait pas gérer."""
    page.set_viewport_size({"width": 700, "height": 700})
    try:
        clear_state(page)
        candidates = [f"C{i}" for i in range(4)]
        create_scrutin(page, candidates=candidates, voters=["Alice"])
        pick_identity(page, "Alice")
        page.locator(".ballot-screen").wait_for(state="visible")
        items = page.locator(".reorder-list .reorder-item")
        ys = sorted({round(items.nth(i).bounding_box()["y"] / 5) * 5
                     for i in range(4)})
        assert len(ys) < 4, f"liste pas en grille : Y distincts {ys}"

        initial = ballot_items(page)
        sb = items.nth(0).locator(".reorder-grip").first.bounding_box()
        tb = items.nth(1).bounding_box()
        sx = sb["x"] + sb["width"]/2
        sy = sb["y"] + sb["height"]/2
        tx = tb["x"] + tb["width"] - 10
        ty = tb["y"] + tb["height"]/2
        page.mouse.move(sx, sy)
        page.mouse.down()
        for s in range(1, 13):
            page.mouse.move(sx + (tx - sx) * s / 12,
                            sy + (ty - sy) * s / 12)
        page.mouse.up()
        page.wait_for_timeout(180)

        final = ballot_items(page)
        assert final[1] == initial[0], \
            f"après drag intra-rangée, {initial[0]} attendu en pos 1 : {final}"
        print("  PASS: ballot grid intra-row drag")
    finally:
        page.set_viewport_size({"width": 400, "height": 800})

@testcase
def test_ballot_layout_toggle(page):
    """Sur viewport large, le bouton bascule la liste entre grille et
    colonne, et le choix tient au reload."""
    page.set_viewport_size({"width": 700, "height": 700})
    try:
        clear_state(page)
        candidates = [f"C{i}" for i in range(4)]
        create_scrutin(page, candidates=candidates, voters=["Alice"])
        pick_identity(page, "Alice")
        page.locator(".ballot-screen").wait_for(state="visible")
        items = page.locator(".reorder-list .reorder-item")

        def distinct_ys():
            return sorted({round(items.nth(i).bounding_box()["y"] / 5) * 5
                           for i in range(4)})

        assert len(distinct_ys()) < 4, \
            f"défaut attendu en grille : {distinct_ys()}"

        page.locator(".ballot-toolbar button").click()
        page.wait_for_timeout(100)
        assert len(distinct_ys()) == 4, \
            f"après toggle, attendu colonne : {distinct_ys()}"

        page.reload()
        page.wait_for_selector("[data-app-ready]")
        pick_identity(page, "Alice")
        page.locator(".ballot-screen").wait_for(state="visible")
        items_after = page.locator(".reorder-list .reorder-item")
        ys3 = sorted({round(items_after.nth(i).bounding_box()["y"] / 5) * 5
                      for i in range(4)})
        assert len(ys3) == 4, f"colonne attendue après reload : {ys3}"
        print("  PASS: ballot layout toggle")
    finally:
        page.set_viewport_size({"width": 400, "height": 800})

Long-press sans menu parasite

Au moment où un votant pose son doigt sur une rangée pour l’attraper, Android et iOS lisent ce hold comme un long-press et ouvrent leur menu système — Enregistrer l'image, Copier, Ouvrir dans un nouvel onglet sur les photos de candidats. Le menu masque la liste et casse le geste. On bloque donc le menu contextuel sur les rangées du classement, ailleurs il reste actif. La suppression est portée par le moteur reorder partagé (cf. Reorder engine).

@testcase
def test_no_contextmenu_on_ranking(page):
    """Le contextmenu déclenché sur un item du classement (long-press
    tactile en prod, clic droit dans le test) doit ressortir avec
    =defaultPrevented= : c'est le seul signal observable que le menu
    système ne s'ouvrira pas."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B", "C"], voters=["Alice"])
    pick_identity(page, "Alice")
    page.locator(".ballot-screen").wait_for(state="visible")

    page.evaluate("""() => {
        window.__ctx = null;
        window.addEventListener('contextmenu', e => {
            window.__ctx = e.defaultPrevented;
        });
    }""")

    box = page.locator(".reorder-list .reorder-item").first.bounding_box()
    page.mouse.click(box["x"] + box["width"] / 2,
                     box["y"] + box["height"] / 2,
                     button="right")
    page.wait_for_function("() => window.__ctx !== null", timeout=2000)
    assert page.evaluate("() => window.__ctx") is True, \
        "contextmenu sur reorder-item attendu supprimé"
    print("  PASS: no contextmenu on ranking item")

Quitter un scrutin

Une fois attaché à un scrutin, l’URL ?doc…= maintient l’utilisateur captif : aucun chemin pour rejoindre un autre scrutin de la liste, ou simplement repartir d’une page propre. Éditer la query string à la main n’est pas une option mobile, et fermer l’onglet perd la session. On expose donc un bouton ← Menu présent dès qu’un scrutin est attaché ; un clic ramène à l’écran d’accueil.

@testcase
def test_menu_button_returns_to_main(page):
    """Cliquer ← Menu depuis un scrutin attaché ramène à l'écran d'accueil."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    assert page.locator(".identity-screen").is_visible()

    _role_button(page, "← Menu").click()

    page.locator(".empty-state").wait_for(state="visible")
    assert "doc=automerge:" not in page.url
    print("  PASS: menu button returns to main")

Le scrutin quitté n’est pas perdu : recordScrutin ayant été appelé à l’attache (cf. Liste des scrutins précédents), il apparaît dans la liste sur l’écran d’accueil, prêt à être ré-ouvert.

@testcase
def test_menu_button_keeps_scrutin_in_past(page):
    """Quitter via ← Menu laisse le scrutin dans Scrutins précédents,
    prêt à être ré-ouvert."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"], title="ScrutinX")

    _role_button(page, "← Menu").click()

    page.get_by_role("link", name="ScrutinX").wait_for(state="visible")
    print("  PASS: menu button keeps scrutin in past")

Pendant l’écran de classement le bouton s’efface : Annuler remplit déjà ce rôle pour le bulletin en cours (cf. Voter en mode appareil partagé), et un second escape ferait double emploi sous le pouce du votant.

@testcase
def test_menu_button_hidden_during_ballot(page):
    """Sur le ballot-screen, ← Menu est caché --- =Annuler= prend le relais."""
    clear_state(page)
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    pick_identity(page, "Alice")
    page.locator(".ballot-screen").wait_for(state="visible")
    assert not _role_button(page, "← Menu").is_visible()
    print("  PASS: menu button hidden during ballot")

Pas de détachement explicite à écrire : retirer ?doc…= de la query suffit, le flux d’init existant remonte alors l’écran d’accueil avec la liste des passages.

Object.assign(voteStore, {
    goToMenu(){
        location.search = '';
    },
});

La barre se cale dans la rangée d’en-tête, en retrait du build-tag fixe à gauche et de l’indicateur de sync à droite pour les laisser occuper les coins sans chevauchement. La couleur muette marque son rôle de raccourci de navigation, hors du flux des actions principales — Partager, QR, Dépouiller.

<div class="menu-bar" v-show="scrutin && stage !== 'ballot'" v-cloak>
  <button class="menu-link" @click="goToMenu()">&larr; Menu</button>
</div>

.menu-bar{padding:8px 12px 8px 80px}
.menu-bar .menu-link{padding:6px 12px;font-size:.85rem;background:transparent;color:var(--muted);font-weight:500}

Bouton retour

Sur Android (PWA installée ou navigateur), le bouton retour matériel est le geste de navigation principal. Sans intervention, presser retour quitte l’app — y compris depuis une modale ouverte ou en plein classement, ce qui sacrifie tout le contexte que l’utilisateur a en tête. On hooke donc le bouton retour pour qu’il ferme l’overlay courant : modale de création (= Annuler), modale QR (= Fermer), écran de classement (= Annuler du bulletin, qui sauve le brouillon partiel pour reprise — cf. Voter en mode appareil partagé). Une fois sur l’écran principal sans overlay, retour reprend son comportement par défaut (sortir de l’app ou revenir à la page précédente du navigateur).

@testcase
def test_back_closes_overlays(page):
    """Le bouton retour ferme l'overlay courant au lieu de quitter
    l'app : modale Créer, écran de classement, modale QR."""
    clear_state(page)

    # Modale de création
    _role_button(page, "Créer un scrutin").click()
    assert page.locator("#createModal.open").is_visible()
    page.go_back()
    page.wait_for_function(
        "() => !document.querySelector('#createModal.open')")
    assert _role_button(page, "Créer un scrutin").is_visible()

    # Écran de classement
    create_scrutin(page, candidates=["A", "B"], voters=["Alice"])
    pick_identity(page, "Alice")
    assert page.locator(".ballot-screen").is_visible()
    page.go_back()
    page.locator(".identity-screen").wait_for(state="visible")
    assert not page.locator(".ballot-screen").is_visible()

    # Modale QR
    _role_button(page, "QR").click()
    page.locator(".qr-modal.open").wait_for(state="visible")
    page.go_back()
    page.wait_for_function(
        "() => !document.querySelector('.qr-modal.open')")

    print("  PASS: back closes overlays")

function pushOverlay(name){
    history.pushState({ overlay: name }, '');
}

function syncOverlaysFromHistory(store){
    const overlay = history.state && history.state.overlay;
    if(store.ui.qrOpen && overlay !== 'qr') store._doCloseQR();
    if(store.ui.createOpen && overlay !== 'create') store._doCancelCreate();
    if(store.stage === 'ballot' && overlay !== 'ballot') store._doCancelBallot();
}

Plumbing

Bibliothèques externes

On ne réinvente aucune roue. Les briques tierces, déclarées dans package.json et bundlées par rspack (cf. Bundler avec rspack) :

  • petite-vue — réactivité de l’UI, directives HTML v-show, v-for, v-model, @click. Pas de build step côté framework, ~6 KB.

  • Automerge 2 + automerge-repo 1 + automerge-repo-storage-indexeddb

    • automerge-repo-network-websocket — CRDT local-first,

    persistance IndexedDB, sync WebSocket vers le serveur automergesync. Un scrutin = un doc Automerge, identifié par son URL automerge:<hash>, partageable comme lien d’invitation.

  • qrcode-generator — génération du QR pour partage en face-à-face.

  • API Web Crypto native — hash SHA-256 pour la graine de tie-break.

Le drag-and-drop est custom (pointer events purs), pas de lib — voir shared_blocks.org pour le moteur partagé.

Le service worker et le manifest PWA sont définis inline dans ce fichier plutôt que via des blocs partagés, parce que leur contenu (nom, couleurs, icône, liste d’assets à cacher) est spécifique à l’app.

Schéma du document Automerge

Un doc par scrutin. Pas de doc racine qui liste tous les scrutins — chaque scrutin vit sous sa propre URL automerge:....

{
  title: string,
  candidates: string[],                // l'ordre de création, immuable
  candidateImages: {[name]: Uint8Array},  // bytes JPEG compressés ; optionnel par candidat
  voters: string[],                    // le roster, mutable (cf. [[#c0d07c0c-0017-4000-8000-000000000000][ajout]] / [[#c0d07c0c-004a-4000-8000-000000000000][retrait]] en cours de route)
  ballots: { voter: string, ranking: string[], at: number }[],
  drafts: {[voter]: { ranking: string[], at: number }},  // brouillons en cours, partagés via la sync (cf. [[#c0d07c0c-0013-4000-8000-000000000000][Voter en mode appareil partagé]])
  closed: boolean,
  mode: 'shared-device' | 'per-device',
  method: 'condorcet-random-smith',
  createdAt: number,
}

Un bulletin par votant : un nouveau vote écrase l’ancien en cherchant par voter.

Repo Automerge, persistance IndexedDB, sync optionnel

Chaque scrutin est un doc Automerge indépendant — pas de doc racine qui listerait tous les scrutins. Son URL automerge:<hash> est portée dans la query string, ce qui rend le partage trivial : donner l’URL, c’est inviter. IndexedDB garde une copie locale pour l’offline et la persistance entre sessions.

La sync WebSocket est optionnelle : sans ?sync_url, l’app tourne entièrement en local, ce qui convient au mode partagé et aux tests. Le sync_url est mémorisé dans localStorage après le premier visit pour que les liens partagés sans ce paramètre synchent quand même.

Un utilisateur ne doit jamais voir une app bloquée en chargement à cause d’un doc lent à arriver. Le chargement initial attend au plus 3 s (IndexedDB répond en quelques ms, WebSocket peut traîner) ; passé ce délai, l’app devient interactive sans attendre, et le store se remplira quand le doc arrivera via l’événement change. Le serveur de sync est décrit dans Serveur de sync automergesync.

Quand la sync est armée, l’état de la liaison avec le serveur n’est pas un détail interne : l’utilisateur a besoin de savoir si son bulletin partira vraiment ou restera en local (cf. Indicateur de connexion). makeRepo prend donc un callback que l’adapter alimente via les événements peer-candidate et peer-disconnected du protocole NetworkAdapter — câblés dès la création de l’adapter pour que la première transition soit captée même si l’UI s’abonne tard.

Automerge + ses adapters pèsent ~1.8 MiB de WASM et ~50 KiB de JS au-delà de l’app elle-même. Les charger statiquement bloque le premier paint sur tout le téléchargement. On les charge donc en dynamic import : l’écran d’accueil se rend avec juste petite-vue + qrcode, et les modules Automerge n’arrivent qu’au moment où on en a besoin (URL avec ?doc…=, clic sur Créer un scrutin, import depuis URL). loadAutomerge memoize le promise pour que les appels concurrents partagent une seule fetch ; ensureRepo fait la même chose au-dessus pour la Repo construite.

// Modules Automerge chargés dynamiquement à la première demande.
// Avec rspack + =experiments.asyncWebAssembly=, l'entrée
// =fullfat_bundler= se câble toute seule : =import * from "*.wasm"=
// produit un module JS dont les exports sont les fonctions
// wasm-bindgen, le WASM est instancié à l'import dynamique.
let _automergePromise = null;
function loadAutomerge(){
    if(!_automergePromise){
        _automergePromise = (async () => {
            const [repoMod, idbMod, wsMod] = await Promise.all([
                import('@automerge/automerge-repo'),
                import('@automerge/automerge-repo-storage-indexeddb'),
                import('@automerge/automerge-repo-network-websocket'),
            ]);
            return {
                Repo: repoMod.Repo,
                isValidAutomergeUrl: repoMod.isValidAutomergeUrl,
                IndexedDBStorageAdapter: idbMod.IndexedDBStorageAdapter,
                WebSocketClientAdapter: wsMod.WebSocketClientAdapter,
            };
        })();
    }
    return _automergePromise;
}

async function makeRepo(onSync){
    const am = await loadAutomerge();
    const params = new URLSearchParams(location.search);
    const fromParam = params.get('sync_url');
    if(fromParam){
        localStorage.setItem('condorcet.sync_url', fromParam);
        const clean = new URL(location);
        clean.searchParams.delete('sync_url');
        history.replaceState(null, '', clean);
    }
    const syncUrl = localStorage.getItem('condorcet.sync_url');
    let adapter = null;
    if(syncUrl){
        adapter = new am.WebSocketClientAdapter(syncUrl);
        adapter.on('peer-candidate', () => onSync?.('connected'));
        adapter.on('peer-disconnected', () => onSync?.('disconnected'));
        onSync?.('connecting');
    } else {
        onSync?.('off');
    }
    return new am.Repo({
        storage: new am.IndexedDBStorageAdapter('condorcet'),
        network: adapter ? [adapter] : [],
    });
}

let _repoPromise = null;
function ensureRepo(reactiveStore){
    if(!_repoPromise){
        _repoPromise = makeRepo(s => { reactiveStore.syncStatus = s; })
            .then(repo => { _repo = repo; return repo; });
    }
    return _repoPromise;
}

async function loadInitialHandle(repo){
    const am = await loadAutomerge();
    const urlParam = new URLSearchParams(location.search).get('doc');
    if(!urlParam || !am.isValidAutomergeUrl(urlParam)) return null;
    return await repo.find(urlParam);
}

Bundler avec rspack

L’app doit démarrer hors-ligne : à l’apéro, le wifi est capricieux, et un téléphone qui ne peut pas voter parce qu’un CDN ne répond pas, c’est une panne produit. Tout est donc bundlé localement, pas de fetch esm.sh au runtime. Bénéfice dérivé : les tests sont déterministes, la suite ne dépend pas du réseau.

Le choix du bundler est contraint par Automerge : son entrée fullfat_bundler importe le WASM via import * as wasm from "./automerge_wasm_bg.wasm", un pattern spécifique à experiments.asyncWebAssembly de webpack. bun et esbuild ne l’instancient pas correctement. On choisit rspack — compatible webpack, plus rapide, plus simple à configurer.

rspack émet par défaut deux copies du WASM (~3.6 MiB) parce qu’il est référencé par deux chemins : fullfat_bundler au runtime (import * as wasm), et low_level.js via new URL(...) (présent pour les utilisateurs de l’entrée /slim, qu’on n’utilise pas). On dédoublonne via un alias resolve.alias qui dirige web/automerge_wasm.js vers un stub re-exportant l’API du bundler ; un seul binaire reste émis.

En pratique, le stub doit aussi exposer un default (même nul) : low_level.js fait import { default as initWasm }, sans quoi rspack refuse le link avec ESModulesLinkingError. initWasm n’est jamais invoquée — fullfat_bundler appelle UseApi(api) directement.

Le nom du WASM est figé à automerge.wasm via output.webassemblyModuleFilename : le hash par défaut change à chaque build, ce qui rendrait impossible un <link rel“preload”>= en HTML. Avec un nom stable, on peut amorcer le téléchargement en parallèle de bundle.js (cf. Service worker et préchargement WASM).

Service worker et préchargement WASM

Sans intervention, chaque ouverture de l’app re-télécharge ~2 MiB (bundle.js + WASM Automerge). Sur 4G ce sont plusieurs secondes visibles avant l’écran initial. Deux leviers, indépendants :

  1. À la première visite, sans amorce le WASM ne commence à se télécharger qu’après le parse de bundle.js et l’évaluation de ses imports : deux RTT en série. Un <link rel“preload”>= dans le <head> avec le nom stable fixé par le bundler (cf. Bundler avec rspack) les rend concurrents.

  2. Aux visites suivantes, on ne veut rien re-télécharger. On branche le service worker partagé du recueil (cf. Service worker (cache-first + write-through)) : sa stratégie cache-first / write-through colle exactement à nos assets (HTML qu’on veut frais, bundle.js et WASM qu’on veut figés tant que le build ne change pas), et son sw-register coupe l’enregistrement sur localhost ce qui protège les tests d’un cache piégé.

Reste à fixer ce qui est spécifique à cette app : le preload pointe vers automerge.wasm, et le SW déclare CACHE + ASSETS avant d’inclure les handlers partagés. La coquille précachée se limite à ./ et index.html : bundle.js et automerge.wasm se cachent au premier fetch via la branche write-through, ce qui évite un double round-trip à l’install.

Pour invalider proprement, on sépare deux pools : un pour la coquille app, un pour les assets vendor (WASM Automerge + chunks lazy d’automerge-repo). Les deux ont des cycles de vie indépendants : la coquille change à chaque modif de notre code (root, app-js, sw, manifest via build-hash en tête de doc, cf. Hash de build) ; le vendor change uniquement quand on bumpe une dépendance, ce que package-json reflète. On rappelle build-hash avec la liste de blocs adéquate pour chaque pool. Conséquence : éditer un libellé d’UI ne force pas l’utilisateur à re-télécharger 1.8 MiB de WASM, et bumper Automerge n’évincte pas la coquille HTML précachée pour l’offline. Le routage entre pools est piloté par regex côté shared SW (cf. Service worker (cache-first + write-through)) : tout .wasm ou *.bundle.js part dans le pool vendor, le reste dans le pool app.

<link rel="preload" as="fetch" type="application/wasm" crossorigin
      href="automerge.wasm">
nil

const CACHES = [
    { pattern: /\.wasm$|\.bundle\.js$/,
      name:    'condorcet-vendor-nil' },
    { name:    'condorcet-app-nil' },
];
const ASSETS = ['./', './index.html'];

nil

Serveur de sync automergesync

Le mode per-device n’a de valeur que si les bulletins se propagent vraiment entre téléphones : A vote, B voit le compteur s’incrémenter sur son écran, sans avoir à recharger. Automerge gère le CRDT, mais deux navigateurs ne peuvent pas s’envoyer des patches directement — il faut un relais WebSocket. Un serveur minimal, qui ne fait que repasser les messages entre clients, suffit : pas d’auth métier, pas de logique applicative, juste un tuyau CRDT.

On réutilise pour ça le package npm officiel @automerge/automerge-repo-sync-server, empaqueté dans l’image Docker konubinix/automergesync. Côté client, ouvrir une fois l’app avec ?sync_url=wss://<ton-serveur> suffit — la valeur est mémorisée en localStorage et réutilisée aux visites suivantes (cf. test test_sync_url_memorized plus bas).

La sync doit se tester de bout en bout contre le même binaire qu’en prod — sinon un passage vert en test peut cacher un bug de version en prod. La fixture require_sync_server (cf. Fixture Docker) lance l’image officielle au premier test qui en a besoin.

La sync WebSocket n’est utile que si les mutations d’un navigateur arrivent vraiment dans l’autre : A soumet un bulletin, B (dans un contexte isolé) le voit dans son store sans rechargement.

@testcase
def test_sync_propagation(page):
    """Un bulletin soumis dans un navigateur se propage à l'autre via le sync server."""
    sync_url = require_sync_server()
    with two_contexts(page.context.browser) as (ctxA, ctxB):
        # A : arme la sync puis crée le scrutin
        pageA = open_app(ctxA, f"{BASE_URL}?sync_url={sync_url}")
        create_scrutin(pageA, candidates=["A", "B", "C"], voters=["Alice", "Bob"])
        doc_url = pageA.url  # inclut ?doc=automerge:...
        # Alice vote
        take_phone(pageA)
        submit_ballot(pageA)
        pageA.wait_for_timeout(500)  # flush debounced save + sync
        # B : arme la sync pour son propre localStorage + ouvre le doc
        pageB = open_app(ctxB, f"{doc_url}&sync_url={sync_url}")
        wait_for(pageB, lambda: "a voté" in pageB.locator(".identity-list").inner_text())
        identity_text = pageB.locator(".identity-list").inner_text()
        assert "Alice" in identity_text and "a voté" in identity_text, \
            f"B n'a pas reçu le bulletin : {identity_text!r}"
    print("  PASS: sync propagation")

L’invitation se fait via un magic link cliqué une seule fois. Après ça, les liens de scrutin partagés n’ont pas de sync_url — pourtant la sync doit fonctionner. Mémoriser sync_url dans localStorage au premier visit suffit pour que les visites suivantes synchent automatiquement.

@testcase
def test_sync_url_memorized(page):
    """Après un premier visit avec ?sync_url, les visites ultérieures sans param sync aussi."""
    sync_url = require_sync_server()
    with two_contexts(page.context.browser) as (ctxA, ctxB):
        pageA = open_app(ctxA, f"{BASE_URL}?sync_url={sync_url}")
        create_scrutin(pageA, candidates=["A", "B"], voters=["Alice", "Bob"])
        doc_qs = "?" + pageA.url.split("?", 1)[1]  # "?doc=automerge:..."
        take_phone(pageA)
        submit_ballot(pageA)
        pageA.wait_for_timeout(500)

        # Étape 1 : visite "magic link" avec ?sync_url, pas de doc.
        pageB = open_app(ctxB, f"{BASE_URL}?sync_url={sync_url}")
        stored = pageB.evaluate("localStorage.getItem('condorcet.sync_url')")
        assert stored == sync_url, f"sync_url non mémorisé : {stored!r}"
        assert "sync_url=" not in pageB.url, f"sync_url non strippé : {pageB.url}"
        # Étape 2 : même page, on navigue vers ?doc=... SANS sync_url
        pageB.goto(f"{BASE_URL}{doc_qs}", timeout=15000)
        pageB.wait_for_selector("[data-app-ready]", timeout=15000)
        # La sync doit se brancher via le sync_url stocké
        wait_for(pageB, lambda: "a voté" in pageB.locator(".identity-list").inner_text())
        identity_text = pageB.locator(".identity-list").inner_text()
        assert "Alice" in identity_text and "a voté" in identity_text, \
            f"B sans sync_url n'a pas reçu le bulletin : {identity_text!r}"
    print("  PASS: sync_url memorized")

Indicateur de connexion

Une fois la sync armée, l’utilisateur doit pouvoir voir en un coup d’œil si son téléphone est en lien avec le serveur. Sans ça, un bulletin qui tarde à apparaître chez l’autre laisse le doute : retard réseau, socket coupée, serveur absent ? Une pastille discrète en haut à droite, lue à la volée, tranche ce doute sans solliciter l’attention quand tout va bien.

Trois états visuels : orange pulsant pendant la connexion, vert une fois le serveur atteint, rouge si la socket se coupe. En l’absence de sync_url armé (off) la pastille reste cachée — afficher un voyant là où rien ne se synchronise inquiéterait pour rien.

La pastille traduit fidèlement le champ syncStatus du store, lui-même alimenté par le callback de makeRepo via les transitions peer-candidate / peer-disconnected de l’adapter (cf. Repo Automerge). Une fois le serveur atteint au moins une fois, la pastille passe au vert ; le test ci-dessous fige cette garantie de bout en bout.

@testcase
def test_sync_indicator(page):
    """L'indicateur passe à 'connected' (pastille verte) après handshake serveur."""
    sync_url = require_sync_server()
    page.goto(f"{BASE_URL}?sync_url={sync_url}", timeout=15000)
    page.wait_for_selector("[data-app-ready]", timeout=15000)
    wait_for(page, lambda: page.locator(".sync-indicator.sync-connected").is_visible())
    assert page.locator(".sync-indicator.sync-connected").is_visible(), \
        "pastille verte attendue après handshake"
    print("  PASS: sync indicator")

Le markup expose la classe sync-<status> pour que le CSS pilote couleur et animation, et syncStatusLabel en title pour le tooltip de bureau. pointer-events:none empêche la pastille d’intercepter un tap dans la zone du haut.

<div class="sync-indicator"
     v-show="syncStatus !== 'off'"
     :class="'sync-'+syncStatus"
     :title="syncStatusLabel"
     v-cloak>
  <span class="dot"></span>
  <span class="label" v-text="syncStatusLabel"></span>
</div>

.sync-indicator{position:fixed;top:calc(env(safe-area-inset-top) + 8px);right:8px;display:flex;align-items:center;gap:6px;padding:4px 8px;background:rgba(0,0,0,.35);border-radius:12px;font-size:.72rem;color:var(--muted);pointer-events:none;z-index:50}
.sync-indicator .dot{width:8px;height:8px;border-radius:50%;background:var(--muted)}
.sync-indicator.sync-connecting .dot{background:var(--accent);animation:sync-pulse 1s ease-in-out infinite}
.sync-indicator.sync-connected .dot{background:var(--ok)}
.sync-indicator.sync-disconnected .dot{background:var(--danger)}
@keyframes sync-pulse{0%,100%{opacity:1}50%{opacity:.3}}

Fixture Docker pour tests de sync

La fixture démarre un container à partir de l’image prod au premier test qui en a besoin (cache partagé entre tests) et l’arrête via atexit.

import atexit, contextlib, shutil, socket, subprocess, tempfile, time, uuid
SYNC_IMAGE = "konubinix/automergesync:0.1.0"
_sync_state = {"fixture": None}

nil

def start_sync_server():
    """Return (container, data_dir, sync_url). Cached across calls."""
    if _sync_state["fixture"] is not None:
        return _sync_state["fixture"]
    if not shutil.which("docker"):
        raise RuntimeError("docker introuvable — requis pour les tests de sync")
    data_dir = tempfile.mkdtemp(prefix="amr-sync-data-")
    container = f"condorcet-sync-{uuid.uuid4().hex[:8]}"
    port = _free_port()
    try:
        subprocess.check_call(
            ["docker", "run", "-d", "--rm",
             "--name", container,
             "-p", f"127.0.0.1:{port}:3030",
             "-e", "PORT=3030",
             "-e", "DATA_DIR=/data",
             "-v", f"{data_dir}:/data",
             SYNC_IMAGE,
             "automerge-repo-sync-server"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
        )
    except subprocess.CalledProcessError as e:
        shutil.rmtree(data_dir, ignore_errors=True)
        raise RuntimeError(f"échec docker run {SYNC_IMAGE}: {e}") from e
    import urllib.request, urllib.error
    for _ in range(200):
        try:
            urllib.request.urlopen(f"http://localhost:{port}/", timeout=0.5).close()
            _sync_state["fixture"] = (container, data_dir, f"ws://localhost:{port}")
            atexit.register(_stop_sync_server_atexit)
            return _sync_state["fixture"]
        except (urllib.error.URLError, ConnectionError, TimeoutError, OSError):
            time.sleep(0.1)
    subprocess.run(["docker", "stop", container],
                   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    shutil.rmtree(data_dir, ignore_errors=True)
    raise RuntimeError(f"sync server n'a pas répondu sur le port {port}")

def require_sync_server():
    return start_sync_server()[2]

@contextlib.contextmanager
def two_contexts(browser, viewport={"width": 400, "height": 800}):
    """Paire de contextes navigateur, fermés en sortie du =with=."""
    ctxA = browser.new_context(viewport=viewport)
    ctxB = browser.new_context(viewport=viewport)
    seed_context(ctxA)
    seed_context(ctxB)
    try:
        yield ctxA, ctxB
    finally:
        ctxA.close()
        ctxB.close()

def _stop_sync_server_atexit():
    fx = _sync_state.get("fixture")
    if not fx:
        return
    container, data_dir, _ = fx
    subprocess.run(["docker", "stop", container],
                   stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    shutil.rmtree(data_dir, ignore_errors=True)
    _sync_state["fixture"] = None

Store petite-vue — unique source de vérité réactive

Automerge et petite-vue proposent chacun leur propre modèle d’objet réactif — et ils ne se composent pas. Au lieu d’essayer de les marier, on pose une règle unique : le store ne contient jamais d’objets Automerge, seulement un snapshot cloné en JSON plat après chaque mutation ou réception sync. Une seule source de vérité, un seul point de rafraîchissement. Trois conséquences concrètes en découlent.

Pourquoi JSON plat. Sans clonage, le compteur “bulletins reçus / attendus” ne s’incrémente pas à l’arrivée d’un bulletin, et la bascule vers l’écran résultat ne se déclenche pas à la clôture : les templates Vue ne voient tout simplement pas le changement. Automerge enveloppe son doc dans un Proxy copy-on-write que la réactivité de Vue ne sait pas retraverser ; cloner en JSON à chaque écriture lui donne un objet ordinaire à observer.

Pourquoi Repo et DocHandle — les deux types principaux d’automerge-repo — vivent hors du store. Rangés dans le store, ces objets crashent au premier accès à une de leurs méthodes : le Proxy Vue intercepte leurs accès et casse les champs privés de classe ("Receiver must be an instance of…"). Ils restent donc en variables de module, et le store ne voit qu’un snapshot déjà plat.

Pourquoi des flags plats plutôt que des getters pour ce qui dépend de scrutin.ballots. Petite-vue ne retrace pas les getters qui lisent une propriété en profondeur sur un doc rehydraté depuis IndexedDB : ils restent figés à la valeur initiale. allVoted est donc recalculé en dur à chaque mutation, pour que le bouton Dépouiller suive l’état des bulletins sans dépendre d’un getter fragile.

Les getters qui ne dépendent pas d’une profondeur fragile (nextVoter, rosterCount, ballotsCount) restent en place — la ré-assignation entière de scrutin à chaque mutation suffit à les recalculer. Les getters retournent des arrays ou des objets simples, jamais des Set ou Map : petite-vue tente de les proxifier et échoue avec “non-object as target”.

À l’attache, le doc peut ne pas encore être disponible (IndexedDB ou WebSocket en transit). Le store doit rester utilisable dans cet état ; le listener de changements prend le relais dès que le doc arrive.

Corollaire côté templates : v-show ne court-circuite pas l’évaluation réactive des sous-effects. Un v-for“v in scrutin.voters”= dans un parent v-show“scrutin && …"= jette quand même au boot tant que scrutin est null. Les v-for qui descendent dans scrutin se protègent donc avec un fallback explicite (v-for“v in (scrutin ? scrutin.voters : [])") plutôt que de compter sur le =v-show du parent.

Le statut de la liaison sync vit ici aussi, comme champ scalaire plat syncStatus (‘off’, ‘connecting’, ‘connected’, ‘disconnected’) alimenté par le callback de makeRepo (cf. Indicateur de connexion). Pas besoin de getter en profondeur — c’est un scalaire, la réactivité de petite-vue le suit naturellement.

Pourquoi les images candidates en bytes opaques. Une dataURL JPEG stockée en string dans le doc explose l’historique CRDT : Automerge encode chaque caractère d’une string comme une op individuelle, donc une image de 25 KB occupe 25 000 ops. Vingt candidats avec photo suffisent à pousser le snapshot à plusieurs centaines de KB et la désérialisation au boot à plusieurs secondes. Stockée comme Uint8Array, l’image entière compte pour une seule op opaque. Le formulaire de création garde la dataURL (compatible avec le brouillon localStorage et un <img :src> direct) ; la conversion vit aux frontières form↔doc.

Côté rendu, le store maintient à part un dict candidateImageUrls qui mappe chaque candidat à un Object URL bâti depuis ses bytes. Les templates de bulletin et de résultat lisent ce dict plutôt que scrutin.candidateImages directement. Les Object URLs sont révoqués à chaque rafraîchissement pour ne pas accumuler les Blobs en mémoire.

function dataUrlToBytes(dataUrl){
    const b64 = dataUrl.split(',', 2)[1];
    const bin = atob(b64);
    const out = new Uint8Array(bin.length);
    for(let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
    return out;
}
function bytesToObjectUrl(bytes){
    return URL.createObjectURL(new Blob([bytes], {type: 'image/jpeg'}));
}
function bytesToDataUrl(bytes){
    let bin = '';
    for(let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
    return 'data:image/jpeg;base64,' + btoa(bin);
}

function cloneDoc(doc){
    if(!doc) return doc;
    const { candidateImages, ...rest } = doc;
    return JSON.parse(JSON.stringify(rest));
}

let _repo = null;
let _handle = null;

const voteStore = {
    scrutin: null,
    candidateImageUrls: {},
    tally: null,
    stage: 'identify',   // 'identify' | 'ballot' | 'waiting'
    allVoted: false,
    syncStatus: 'off',   // 'off' | 'connecting' | 'connected' | 'disconnected'
    loadingDoc: false,   // true tant qu'on attend la résolution d'un =?doc=...= (cf. init lazy)
    ui: {},

    get rosterCount(){ return this.scrutin ? this.scrutin.voters.length : 0; },
    get ballotsCount(){ return this.scrutin ? this.scrutin.ballots.length : 0; },
    get nextVoter(){
        if(!this.scrutin) return null;
        const voted = {};
        for(const b of this.scrutin.ballots) voted[b.voter] = true;
        return this.scrutin.voters.find(v => !voted[v]) || null;
    },
    get syncStatusLabel(){
        return ({
            connecting: 'Connexion au serveur…',
            connected: 'Synchronisé',
            disconnected: 'Hors ligne',
        })[this.syncStatus] || '';
    },

    _refreshImageUrls(doc){
        for(const u of Object.values(this.candidateImageUrls)) URL.revokeObjectURL(u);
        const map = {};
        if(doc && doc.candidateImages){
            for(const [name, bytes] of Object.entries(doc.candidateImages)){
                map[name] = bytesToObjectUrl(bytes);
            }
        }
        this.candidateImageUrls = map;
    },

    change(fn){
        _handle.change(fn);
        const doc = _handle.doc();
        this.scrutin = cloneDoc(doc);
        this._refreshImageUrls(doc);
        this.syncFlags();
    },

    syncFlags(){
        const s = this.scrutin;
        this.allVoted = !!s && s.ballots.length >= s.voters.length;
    },

    attach(handle){
        _handle = handle;
        const doc = handle.doc();
        if(doc){
            this.scrutin = cloneDoc(doc);
            this._refreshImageUrls(doc);
            this.syncFlags();
            this.recordScrutin(handle.url, doc.title);
            if(doc.closed) computeTally(doc).then(t => { this.tally = t; });
            this.initStage(doc);
        } else {
            this.stage = 'identify';
        }
        handle.on('change', ev => {
            this.scrutin = cloneDoc(ev.doc);
            this._refreshImageUrls(ev.doc);
            this.syncFlags();
            if(ev.doc.closed && !this.tally){
                computeTally(ev.doc).then(t => { this.tally = t; });
            }
        });
    },

    initStage(doc){
        if(doc.mode === 'per-device') this.initStagePerDevice(doc);
        else this.stage = 'identify';
    },
};

Initialisation au chargement

Le store doit être rendu réactif avant qu’on n’attache le doc Automerge. Sinon, les callbacks async posés à l’attache (résolution du tally, listener de changement du doc) capturent le store dans son état non-réactif : leurs écritures ultérieures n’éveillent plus les templates. Le symptôme se voit surtout au reload, quand le tally arrive après le mount et que la carte du vainqueur refuse d’apparaître.

L’auto-ouverture pilotée par le brouillon (cf. persistance du formulaire) doit attendre que le mount soit fait : avant le mount, openCreate n’a pas d’app pour déclencher un rendu visible. Et elle ne s’enclenche que sans doc attaché — un brouillon orphelin éclipserait un scrutin en cours.

On monte petite-vue avant d’attendre Automerge : sans ?doc…= dans l’URL, l’écran d’accueil se rend immédiatement et le module Automerge se charge en arrière-plan pendant que l’utilisateur tape ses candidats. Avec ?doc…=, on doit attendre la résolution du handle pour pouvoir attacher — mais on signale loadingDoc pour que l’écran d’accueil ne flashe pas pendant ce temps. Dans tous les cas data-app-ready n’est posé qu’à la fin de l’init : pour les tests, ce signal continue de vouloir dire “prêt à interagir, doc attaché si demandé”.

const reactiveStore = reactive(voteStore);
reactiveStore.pastScrutins = loadPastScrutins();

window.addEventListener('popstate', () => syncOverlaysFromHistory(reactiveStore));

(async function init(){
    const urlParam = new URLSearchParams(location.search).get('doc');
    reactiveStore.loadingDoc = !!urlParam;

    createApp(reactiveStore).mount('body');

    if(urlParam){
        const repo = await ensureRepo(reactiveStore);
        const handle = await loadInitialHandle(repo);
        if(handle) reactiveStore.attach(handle);
        reactiveStore.loadingDoc = false;
    } else {
        // Préchauffe Automerge en tâche de fond pour que le clic
        // sur =Créer= ne paie pas le téléchargement.
        ensureRepo(reactiveStore).catch(() => {});
        if(hasNonEmptyDraft()) reactiveStore.openCreate();
    }

    document.body.setAttribute('data-app-ready', '1');
})();

Bases visuelles

Thème sombre minimal, palette “urne”. Rien d’original — l’UX est dans le flux, pas dans la décoration.

Le bloc partagé pwa-meta() fige le viewport avec user-scalable=no (comportement PWA “app-like” par défaut), ce qui interdit le pinch-zoom ; pour autoriser le zoom sur les photos de candidats (cf. Photos de candidats), le root pose une seconde balise viewport avec maximum-scale=5.0 — la dernière rendue l’emporte.

:root{
    --bg:#1b1d2e; --card:#262a40; --fg:#e8e8f0; --muted:#8a8ea5;
    --accent:#f9a826; --danger:#e94560; --ok:#4ecca3;
}
*{box-sizing:border-box}
[v-cloak]{display:none !important}
body{margin:0;background:var(--bg);color:var(--fg);font-family:system-ui,sans-serif;line-height:1.4;min-height:100vh;padding:env(safe-area-inset-top) 0 env(safe-area-inset-bottom) 0}
button{font:inherit;cursor:pointer;border:0;border-radius:6px;padding:12px 20px;background:var(--accent);color:#111;font-weight:600}
button.secondary{background:var(--card);color:var(--fg)}
button.cta,button.primary{background:var(--accent);color:#111}
button:disabled{opacity:.4;cursor:not-allowed}
input,textarea{font:inherit;background:var(--card);color:var(--fg);border:1px solid #333;border-radius:6px;padding:10px;width:100%}
label{display:block;margin-bottom:12px;font-size:.9rem;color:var(--muted)}
label input{margin-top:4px}
fieldset{border:1px solid #333;border-radius:6px;padding:10px 12px;margin:12px 0}
legend{color:var(--muted);font-size:.85rem;padding:0 6px}
.row{display:flex;gap:6px;margin-bottom:6px}
.row input{flex:1}
.add-row{background:transparent;color:var(--accent);padding:6px;font-weight:500}
h1,h2,h3{font-family:system-ui,sans-serif}
#loading{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg);z-index:9999}
body[data-app-ready] #loading{display:none}
.loading-ring{width:44px;height:44px;border:3px solid #333;border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}

.modal{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;align-items:center;justify-content:center;padding:16px;z-index:100}
.modal.open{display:flex}
.modal-panel{background:var(--bg);border-radius:12px;padding:20px;max-width:420px;width:100%;max-height:90vh;overflow:auto}
.modal-panel h2{margin-top:0}
.modal-actions{display:flex;gap:10px;margin-top:16px}
.modal-actions button{flex:1}
.create-error{color:var(--danger);font-size:.85rem;margin:8px 0 0 0;text-align:center}
input.is-duplicate{border-color:var(--danger);color:var(--danger)}

Hash de build

PWA oblige, le service worker peut servir une version périmée pendant qu’une nouvelle a été tanglée. Pour repérer la build qui tourne réellement, un petit hash du bloc root est affiché en haut à gauche — sept caractères à comparer au #+RESULTS: du bloc build-hash en tête de document. Le marqueur est en muted avec pointer-events:none : il informe sans gêner les taps.

<span class="build-tag" title="Build">nil</span>

.build-tag{position:fixed;top:calc(env(safe-area-inset-top) + 8px);left:8px;font-size:.65rem;color:var(--muted);font-family:monospace;pointer-events:none;z-index:50}

Playwright tests

Les tests ne touchent que ce que l’utilisateur voit et ce qu’il fait : en lecture, texte affiché, rôle ARIA, label, placeholder ; en action, clic sur du texte visible, drag à la souris. Sont interdits : toute lecture d’état interne du store, tout hook de test injecté côté production, tout sélecteur d’implémentation (#btnSubmit, .identity-screen h2). Sinon les tests deviennent une taxe sur le refactor — chaque restructuration interne casse des tests qui ne valident rien du comportement visible.

Les tests sont éparpillés dans le doc à côté de chaque feature ; le runner doit pourtant pouvoir les lancer tous. Tenir une liste centrale serait fragile — un test ajouté qu’on oublie d’inscrire ne saute aux yeux qu’à la prochaine régression.

nil
nil

Chaque test démarre d’un état propre. Sans réinitialisation, l’IndexedDB du test précédent laisse traîner des scrutins fantômes, et un localStorage avec brouillon de form fait ressurgir la modale au reload — assez pour que les tests interfèrent entre eux.

def clear_state(page):
    page.goto(BASE_URL)
    page.wait_for_selector("[data-app-ready]")
    page.evaluate("""async () => {
      const dbs = await indexedDB.databases();
      await Promise.all(dbs.map(d => new Promise(res => {
        const r = indexedDB.deleteDatabase(d.name);
        r.onsuccess = r.onerror = r.onblocked = () => res();
      })));
      localStorage.clear();
    }""")
    page.goto(BASE_URL)
    page.wait_for_selector("[data-app-ready]")

« Page chargée » et « app prête à interagir » ne coïncident pas : le mount petite-vue arrive après plusieurs await, donc l’app reste inerte un moment. Pour distinguer, le markup pose data-app-ready au tout dernier moment de l’init (cf. Initialisation au chargement) ; tout helper qui ouvre l’app attend ce marqueur.

D’autres attentes dépendent d’événements asynchrones — une sync à recevoir, un debounce à laisser passer, une mutation propagée par Automerge — au délai imprévisible. Un sleep fixe est soit trop long (suite lente), soit trop court (faux négatifs intermittents).

def open_app(ctx, url):
    """new_page + goto + attendre =data-app-ready=. Renvoie la page prête."""
    p = ctx.new_page()
    p.goto(url, timeout=15000)
    p.wait_for_selector("[data-app-ready]", timeout=15000)
    return p


def wait_for(page, pred, timeout=15.0, step=0.2):
    """Poll pred() jusqu'à ce qu'il soit truthy ; retourne la dernière valeur.
    Utilise =page.wait_for_timeout= pour que Playwright pilote la cadence."""
    deadline = timeout
    while not pred() and deadline > 0:
        page.wait_for_timeout(int(step * 1000))
        deadline -= step
    return pred()

Quasiment tous les tests commencent par créer un scrutin. C’est le geste le plus répété, donc le premier à mettre en helper.

La règle « clic par libellé visible, jamais par classe ni id » a besoin d’un seul point de passage pour qu’aucun test ne puisse la contourner par mégarde. C’est _role_button.

def _role_button(page, name, **kw):
    return page.get_by_role("button", name=name, **kw)


def create_scrutin(page, *, title="T", candidates, voters, mode="shared-device"):
    # La modale peut s'être ouverte seule au reload (brouillon non vide) ;
    # dans ce cas le bouton "Créer un scrutin" est masqué derrière l'overlay.
    if not page.locator("#createModal.open").is_visible():
        _role_button(page, "Créer un scrutin").click()
    page.get_by_label("Titre").fill(title)
    for _ in range(len(candidates) - 2):
        _role_button(page, "+ Candidat").click()
    # exact: en placeholder substring-match, "Candidat 1" matche aussi
    # "Candidat 10", "Candidat 11"… → strict-mode violation dès qu'on dépasse 9.
    for i, c in enumerate(candidates):
        page.get_by_placeholder(f"Candidat {i+1}", exact=True).fill(c)
    for _ in range(len(voters) - 1):
        _role_button(page, "+ Votant").click()
    for i, v in enumerate(voters):
        page.get_by_placeholder(f"Votant {i+1}", exact=True).fill(v)
    if mode == "per-device":
        page.get_by_label("Un appareil par personne").check()
    _role_button(page, "Créer", exact=True).click()

Au-delà de la création, la même séquence revient sans cesse : choisir un nom, classer, soumettre ou annuler, dépouiller, parfois ajouter quelqu’un en route. Regroupés ici, ces gestes restent à une ligne dans les tests — l’ordre des clics, les dialogs à accepter, les placeholders à remplir vivent une fois pour toutes.

def take_phone(page):
    """Pick the first non-voted voter from the identity-screen.
    Suffit pour les tests qui n'ont besoin que d'avancer un votant
    sans choix particulier (un seul votant, ou ordre indifférent)."""
    page.locator(".identity-pick:not([disabled])").first.click()


def pick_identity(page, name):
    _role_button(page, name, exact=True).click()


def submit_ballot(page):
    page.once("dialog", lambda d: d.accept())
    _role_button(page, "Envoyer mon bulletin").click()


def cancel_ballot(page):
    _role_button(page, "Annuler").click()


def tally(page):
    _role_button(page, "Dépouiller").click()


def add_voter(page, name):
    page.get_by_placeholder("Ajouter un votant…").fill(name)
    _role_button(page, "+ Ajouter").click()

Vérifier que l’UI dit ce qu’elle doit dire passe par lire le DOM côté utilisateur. Ces lectures se concentrent ici pour qu’aucun test ne ré-écrive ses propres sélecteurs CSS au passage.

Cas spécial : _img_sha256. Les images sont rendues via URL.createObjectURL, dont l’identifiant blob: change à chaque appel ; comparer deux rendus se fait donc sur l’empreinte des octets, pas sur l’URL.

import re


def ballot_count(page):
    text = page.get_by_text(re.compile(r"\d+\s*/\s*\d+\s*bulletins")).first.inner_text()
    m = re.search(r"(\d+)\s*/\s*(\d+)\s*bulletins", text)
    return int(m.group(1)), int(m.group(2))


def ballot_items(page):
    """Noms des candidats visibles dans la liste de classement, dans l'ordre affiché.
    Lit directement le span =.reorder-name= pour chaque item."""
    return page.locator(".reorder-list .reorder-name").all_inner_texts()


def assert_ballot_clean(page, candidates):
    """Invariant : sur l'écran de vote, une rangée par candidat, rangs
    1..N contigus, aucun doublon ni fantôme."""
    names = ballot_items(page)
    assert len(names) == len(candidates), \
        f"{len(names)} rangées affichées, attendu {len(candidates)} (candidates={candidates})"
    assert sorted(names) == sorted(candidates), \
        f"rangées visibles {names} ≠ candidats {candidates}"
    ranks = [int(t) for t in page.locator(".reorder-list .reorder-rank").all_inner_texts()]
    expected = list(range(1, len(candidates) + 1))
    assert ranks == expected, f"rangs affichés {ranks}, attendu {expected}"


def _img_sha256(page, selector):
    """Empreinte SHA-256 des bytes d'une image rendue (Object URL ou
    dataURL), pour comparer deux instances sans dépendre de l'identifiant
    =blob:= qui change à chaque =URL.createObjectURL=."""
    return page.evaluate("""async (sel) => {
        const img = document.querySelector(sel);
        const r = await fetch(img.src);
        const buf = await r.arrayBuffer();
        const hash = await crypto.subtle.digest('SHA-256', buf);
        return Array.from(new Uint8Array(hash))
            .map(b => b.toString(16).padStart(2,'0')).join('');
    }""", selector)

Notre moteur de réorganisation custom (cf. shared_blocks.org) écoute des vrais pointer events, donc l’API drag synthétique de Playwright ne suffit pas : le test doit émettre les events lui-même. Deux subtilités à connaître pour que ça marche.

D’abord, le moteur ne reconnaît pas un déplacement à partir d’un seul pointermove : il a besoin d’une suite de positions intermédiaires pour s’amorcer. Le test en émet une douzaine entre le pointerdown et le pointerup.

Ensuite, un drag réordonne la liste à l’instant où il se résout : les indices des autres items changent. Calculer en une passe la séquence d’indices à dragger pour atteindre l’ordre voulu serait donc fragile. rank_as itère cible-par-cible et relit l’état du DOM après chaque drag.

def _drag_item(page, src_idx, tgt_idx):
    """Drag via l'engine pointer-based partagé : pointerdown doit partir
    d'un =.reorder-grip=. Plusieurs pointermoves intermédiaires pour
    déclencher la boucle de move de l'engine. Chaque item a plusieurs
    =.reorder-grip= (gauche + droite pour l'ergonomie bi-manuelle) ;
    on scope au premier grip de l'item visé."""
    items = page.locator(".reorder-list .reorder-item")
    sb = items.nth(src_idx).locator(".reorder-grip").first.bounding_box()
    tb = items.nth(tgt_idx).bounding_box()
    sx = sb["x"] + sb["width"] / 2
    sy = sb["y"] + sb["height"] / 2
    tx = tb["x"] + tb["width"] / 2
    ty = tb["y"] + (2 if tgt_idx < src_idx else tb["height"] - 2)
    page.mouse.move(sx, sy)
    page.mouse.down()
    for s in range(1, 13):
        page.mouse.move(sx + (tx - sx) * s / 12, sy + (ty - sy) * s / 12)
    page.mouse.up()
    page.wait_for_timeout(180)


def rank_as(page, order):
    """Réordonne la liste de classement pour qu'elle se termine dans l'ordre
    voulu, via vrais drags. =order= est une liste de noms de candidats."""
    for target_pos, wanted in enumerate(order):
        current = ballot_items(page)
        if current[target_pos] == wanted:
            continue
        src_idx = current.index(wanted)
        _drag_item(page, src_idx, target_pos)
    final = ballot_items(page)
    assert final == order, f"rank_as : attendu {order}, obtenu {final}"

Notes pointant ici