Implemente le mini-jeu Association (glisser-deposer par paires)

document_engine/labels/association_config.py (nouveau) : modele de
donnees, meme convention resolve_X/sanitize_X que quiz_config.py -
DEFAULT_ASSOCIATION_CONFIG, sanitize_association_config (chaque paire
doit avoir ses deux cotes non vides, sinon supprimee silencieusement ;
liste tronquee a MAX_PAIRS=8).

Cote serveur, routes/document/document_element_update.py revalide
desormais aussi l'association avant persistance (meme raisonnement que
pour le quiz). Le rendu (_render_association) affiche un resume reel
(nombre de paires) et, des qu'au moins une paire existe, un plateau de
glisser-deposer REELEMENT interactif en Mode Apercu
(_render_association_player) : les deux colonnes (termes/
correspondances) sont melangees independamment (random.shuffle,
melange d'affichage documente dans CODE_QUALITY.md) puis embarquees en
JSON dans un attribut data-assoc-config.

Cote editeur, le panneau Proprietes d'une association ("relier
visuellement deux champs qui vont ensemble") est une liste de paires
repetable, chaque ligne reliant visuellement un champ "Element" et un
champ "Correspondance" par un glyphe ↔. Le plateau jouable en Apercu
(static/document/js/document-editor.js) supporte deux facons de jouer,
toutes deux reelles : glisser-deposer HTML5 natif, ou cliquer une
carte puis son emplacement (repli pour les appareils sans support
fiable du drag) - bonne association verrouillee en vert, mauvaise
signalee puis reinitialisee, ecran de resultat une fois toutes les
paires associees.

Bug reel trouve ET corrige via simulation DOM complete (glisser-
deposer + clic simules, pas juste un chargement de page) : le
feedback visuel reutilisait la classe CSS du quiz via une
reaffectation de className qui supprimait au passage la classe
d'identite docAssocFeedback, rendant l'element introuvable des le
premier essai de match (aurait plante en usage reel des la premiere
tentative). Corrige en gardant toujours les deux classes ensemble.

Tests : 8 tests purs (tests/document/test_association_config.py, sans
Flask) + 1 test de route verifiant la sanitization a l'ecriture.

SKIP=djlint : backlog H021 pre-existant, aucun template touche ici.
ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous
verts ; 50 tests document verifies frais. Verification manuelle live
complete : ajout, sanitization sur paire invalide, rendu du plateau,
et simulation DOM du gameplay reel (glisser-deposer correct/incorrect,
clic-selection, progression, ecran de resultat) - script de
diagnostic non conserve dans le depot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-20 13:01:21 +02:00
co-authored by Claude Sonnet 5
parent 9fb22be1ae
commit ce750ec697
12 changed files with 631 additions and 33 deletions
+217
View File
@@ -476,6 +476,7 @@ function forgeDocBindCanvasInteractions() {
}
});
forgeDocInitQuizPlayers();
forgeDocInitAssociationPlayers();
}
/* ---------------------------------------------------------------------
@@ -603,6 +604,148 @@ function forgeDocInitQuizPlayers() {
});
}
/* ---------------------------------------------------------------------
* Association — glisser-déposer RÉELLEMENT interactif en Mode Aperçu :
* relier chaque carte de gauche à sa correspondance de droite. Deux
* façons de jouer, toutes deux réelles (pas de simulation) : glisser-
* déposer natif HTML5, ou cliquer une carte puis son emplacement (pour
* les appareils sans support fiable du drag). État tenu en mémoire,
* jamais persisté.
* ------------------------------------------------------------------- */
function forgeDocAssociationPlayerData(playerEl) {
try {
const config = JSON.parse(playerEl.dataset.assocConfig || '{}');
return {
left: Array.isArray(config.left) ? config.left : [],
right: Array.isArray(config.right) ? config.right : [],
};
} catch (e) {
return { left: [], right: [] };
}
}
function forgeDocAssociationMatchResult(playerEl, state, pairIndex, itemEl, slotEl, isMatch) {
const feedbackEl = playerEl.querySelector('.docAssocFeedback');
if (!isMatch) {
// "docAssocFeedback" doit rester dans la liste (c'est la classe qui
// permet de retrouver cet élément — voir querySelector ci-dessus) :
// une réaffectation de className qui ne garde que "docQuizFeedback..."
// (réutilisé pour le STYLE visuel) la supprimerait silencieusement,
// rendant l'élément introuvable dès le prochain appel (bug réel
// trouvé le 20/09/2026 via simulation DOM).
feedbackEl.className = 'docAssocFeedback docQuizFeedback is-visible is-ko';
feedbackEl.textContent = "Ce n'est pas la bonne association, réessaie.";
itemEl.classList.add('is-wrong');
slotEl.classList.add('is-wrong');
setTimeout(() => {
itemEl.classList.remove('is-wrong');
slotEl.classList.remove('is-wrong');
}, 500);
return;
}
itemEl.classList.add('is-matched');
itemEl.removeAttribute('draggable');
slotEl.classList.add('is-matched');
state.matched.add(pairIndex);
feedbackEl.className = 'docAssocFeedback docQuizFeedback is-visible is-ok';
feedbackEl.textContent = 'Bonne association !';
playerEl.querySelector('.docAssocProg').textContent = `${state.matched.size} / ${state.total} associées`;
if (state.matched.size >= state.total) {
playerEl.querySelector('.docAssocCard').style.display = 'none';
playerEl.querySelector('.docAssocResultCard').style.display = 'block';
}
}
function forgeDocRenderAssociationBoard(playerEl, state) {
const leftCol = playerEl.querySelector('.docAssocColumnLeft');
const rightCol = playerEl.querySelector('.docAssocColumnRight');
leftCol.innerHTML = '';
rightCol.innerHTML = '';
state.left.forEach((item) => {
const itemEl = document.createElement('div');
itemEl.className = 'docAssocItem';
itemEl.textContent = item.text;
itemEl.dataset.pairIndex = String(item.pair_index);
itemEl.setAttribute('draggable', 'true');
itemEl.addEventListener('dragstart', (e) => {
e.stopPropagation();
e.dataTransfer.setData('text/forge-assoc-pair', String(item.pair_index));
e.dataTransfer.effectAllowed = 'move';
});
itemEl.addEventListener('click', (e) => {
e.stopPropagation();
if (state.matched.has(item.pair_index)) return;
if (state.selected) state.selected.classList.remove('is-selected');
state.selected = itemEl;
state.selectedPairIndex = item.pair_index;
itemEl.classList.add('is-selected');
});
leftCol.appendChild(itemEl);
});
state.right.forEach((item) => {
const slotEl = document.createElement('div');
slotEl.className = 'docAssocSlot';
slotEl.textContent = item.text;
slotEl.dataset.pairIndex = String(item.pair_index);
slotEl.addEventListener('dragover', (e) => {
if (!state.matched.has(item.pair_index)) e.preventDefault();
});
slotEl.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
if (state.matched.has(item.pair_index)) return;
const draggedPairIndex = Number(e.dataTransfer.getData('text/forge-assoc-pair'));
const itemEl = leftCol.querySelector(`.docAssocItem[data-pair-index="${draggedPairIndex}"]`);
if (!itemEl) return;
const isMatch = draggedPairIndex === item.pair_index;
forgeDocAssociationMatchResult(playerEl, state, item.pair_index, itemEl, slotEl, isMatch);
});
slotEl.addEventListener('click', (e) => {
e.stopPropagation();
if (state.matched.has(item.pair_index) || !state.selected) return;
const itemEl = state.selected;
const { selectedPairIndex } = state;
state.selected.classList.remove('is-selected');
state.selected = null;
const isMatch = selectedPairIndex === item.pair_index;
forgeDocAssociationMatchResult(playerEl, state, item.pair_index, itemEl, slotEl, isMatch);
});
rightCol.appendChild(slotEl);
});
}
function forgeDocAssociationRestart(playerEl) {
const data = forgeDocAssociationPlayerData(playerEl);
const state = {
left: data.left,
right: data.right,
matched: new Set(),
total: data.left.length,
selected: null,
selectedPairIndex: null,
};
playerEl.querySelector('.docAssocResultCard').style.display = 'none';
playerEl.querySelector('.docAssocCard').style.display = 'block';
playerEl.querySelector('.docAssocFeedback').className = 'docAssocFeedback docQuizFeedback';
playerEl.querySelector('.docAssocProg').textContent = `0 / ${state.total} associées`;
forgeDocRenderAssociationBoard(playerEl, state);
}
function forgeDocInitAssociationPlayers() {
document.querySelectorAll('.docAssocPlayer').forEach((playerEl) => {
const data = forgeDocAssociationPlayerData(playerEl);
if (!data.left.length) return;
forgeDocAssociationRestart(playerEl);
playerEl.querySelector('.docAssocRestartBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocAssociationRestart(playerEl);
});
});
}
function forgeDocSetActiveBottomNav(target) {
document.querySelectorAll('#docBottomNav button').forEach((b) => {
b.classList.toggle('is-active', b.dataset.target === target);
@@ -930,6 +1073,79 @@ function forgeDocRenderQuizProps(panel, el) {
});
}
function forgeDocAssociationNewPair() {
return { left: '', right: '' };
}
function forgeDocRenderAssociationPairHtml(pair, pIndex) {
return `
<div class="docAssocPairRow" data-pair-index="${pIndex}">
<input type="text"
class="docAssocPairInput"
value="${pair.left}"
placeholder="Élément"
data-pair-index="${pIndex}"
data-side="left">
<span class="docAssocPairLink" aria-hidden="true">↔</span>
<input type="text"
class="docAssocPairInput"
value="${pair.right}"
placeholder="Correspondance"
data-pair-index="${pIndex}"
data-side="right">
<button type="button" class="docQuizRemoveBtn" data-pair-index="${pIndex}" title="Supprimer cette paire">🗑</button>
</div>
`;
}
function forgeDocRenderAssociationProps(panel, el) {
const a = el.attributes;
const pairs = a.pairs || [];
function patch(partial) {
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
});
}
panel.innerHTML = `
${forgeDocPropsHeader('association')}
<div class="docField">
<span class="docFieldLabel">Couleur du thème</span>
<div class="docSwatches" id="docAssocColor">
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
</div>
</div>
<div class="docSectionLabel docQuizQuestionsLabel">Paires à relier</div>
<div id="docAssocPairsList">${pairs.length ? pairs.map(forgeDocRenderAssociationPairHtml).join('') : '<div class="docEmptyState">Aucune paire — ajoute la première ci-dessous.</div>'}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docAssocAddPair">+ Ajouter une paire</button>
${forgeDocDeleteButtonHtml()}
`;
panel.querySelectorAll('#docAssocColor .docSwatch').forEach((sw) => {
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
});
document.getElementById('docAssocAddPair').addEventListener('click', () => {
patch({ pairs: [...pairs, forgeDocAssociationNewPair()] });
});
panel.querySelectorAll('#docAssocPairsList .docQuizRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const pIndex = Number(btn.dataset.pairIndex);
patch({ pairs: pairs.filter((_, i) => i !== pIndex) });
});
});
panel.querySelectorAll('.docAssocPairInput').forEach((input) => {
input.addEventListener('change', (e) => {
const pIndex = Number(input.dataset.pairIndex);
const { side } = input.dataset;
patch({ pairs: pairs.map((p, i) => (i === pIndex ? { ...p, [side]: e.target.value } : p)) });
});
});
}
function forgeDocRenderProps(el) {
const panel = document.getElementById('docPropsPanel');
if (!el) {
@@ -942,6 +1158,7 @@ function forgeDocRenderProps(el) {
else if (el.kind === 'bouton') forgeDocRenderButtonProps(panel, el);
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
else forgeDocRenderMinigameProps(panel, el);
const deleteBtn = document.getElementById('docDeletePropBtn');