Implemente le mini-jeu Scenario (situation, choix, consequences)
Build and deploy / test-python (push) Failing after 59s
Build and deploy / test-js (push) Successful in 50s
Build and deploy / lint-python (push) Failing after 59s
Build and deploy / lint-js (push) Failing after 1m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 59s
Build and deploy / test-python (push) Failing after 59s
Build and deploy / test-js (push) Successful in 50s
Build and deploy / lint-python (push) Failing after 59s
Build and deploy / lint-js (push) Failing after 1m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 59s
Cinquieme mini-jeu du support de formation : le createur ecrit une situation initiale, definit 2 a 4 choix, indique lequel est le bon, et redige la consequence de chaque choix. Plusieurs scenarios peuvent etre crees, joues dans l'ordre d'ecriture (jamais melanges, contrairement a Association/Memory/Mots meles : ce sont des mises en situation sequentielles). En Apercu, l'apprenant lit la situation, choisit une option, decouvre la consequence de SON choix et si c'etait le bon, puis passe au scenario suivant. Comme Association/Memory/Mots meles, le dernier scenario reste affiche une fois repondu : seul le bouton Recommencer apparait, jamais un ecran de resultat separe (reserve au Quiz). Reutilise les classes visuelles du Quiz (docQuizOptions/ docQuizFeedback/docQuizNextBar) plutot que de dupliquer ces regles. Verifie via simulation DOM reelle (jsdom) : progression entre plusieurs scenarios, choix correct/incorrect avec revelation de la bonne reponse, comportement de fin de partie, panneau Proprietes (ajout/suppression de scenario, changement du nombre de choix, selection du bon choix). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d8be80ebd1
commit
b2292de122
@@ -500,36 +500,40 @@ img.docImage {
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
/* ---- Quiz/Association/Memory/Mots mêlés : mini-jeux interactifs en Mode
|
||||
Aperçu (voir docs/plan/maquettes/document-formation-web.html —
|
||||
référence visuelle du Quiz). Masqués en édition (seul
|
||||
.docMinigameBadge y est visible, voir plus haut) ; jamais les deux
|
||||
visibles en même temps. ---- */
|
||||
/* ---- Quiz/Association/Memory/Mots mêlés/Scénario : mini-jeux
|
||||
interactifs en Mode Aperçu (voir docs/plan/maquettes/
|
||||
document-formation-web.html — référence visuelle du Quiz). Masqués en
|
||||
édition (seul .docMinigameBadge y est visible, voir plus haut) ;
|
||||
jamais les deux visibles en même temps. ---- */
|
||||
.docQuizPlayer,
|
||||
.docAssocPlayer,
|
||||
.docMemoryPlayer,
|
||||
.docMotsPlayer {
|
||||
.docMotsPlayer,
|
||||
.docScenarioPlayer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.docEditor3.docEditor3--preview [data-kind="quiz"] .docMinigameBadge,
|
||||
.docEditor3.docEditor3--preview [data-kind="association"] .docMinigameBadge,
|
||||
.docEditor3.docEditor3--preview [data-kind="memory"] .docMinigameBadge,
|
||||
.docEditor3.docEditor3--preview [data-kind="mots"] .docMinigameBadge {
|
||||
.docEditor3.docEditor3--preview [data-kind="mots"] .docMinigameBadge,
|
||||
.docEditor3.docEditor3--preview [data-kind="scenario"] .docMinigameBadge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.docEditor3.docEditor3--preview [data-kind="quiz"] .docQuizPlayer,
|
||||
.docEditor3.docEditor3--preview [data-kind="association"] .docAssocPlayer,
|
||||
.docEditor3.docEditor3--preview [data-kind="memory"] .docMemoryPlayer,
|
||||
.docEditor3.docEditor3--preview [data-kind="mots"] .docMotsPlayer {
|
||||
.docEditor3.docEditor3--preview [data-kind="mots"] .docMotsPlayer,
|
||||
.docEditor3.docEditor3--preview [data-kind="scenario"] .docScenarioPlayer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.docEditor3.docEditor3--preview [data-kind="quiz"],
|
||||
.docEditor3.docEditor3--preview [data-kind="association"],
|
||||
.docEditor3.docEditor3--preview [data-kind="memory"],
|
||||
.docEditor3.docEditor3--preview [data-kind="mots"] {
|
||||
.docEditor3.docEditor3--preview [data-kind="mots"],
|
||||
.docEditor3.docEditor3--preview [data-kind="scenario"] {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
@@ -541,7 +545,9 @@ img.docImage {
|
||||
.docEditor3.docEditor3--preview .docMemoryPlayer,
|
||||
.docEditor3.docEditor3--preview .docMemoryPlayer *,
|
||||
.docEditor3.docEditor3--preview .docMotsPlayer,
|
||||
.docEditor3.docEditor3--preview .docMotsPlayer * {
|
||||
.docEditor3.docEditor3--preview .docMotsPlayer *,
|
||||
.docEditor3.docEditor3--preview .docScenarioPlayer,
|
||||
.docEditor3.docEditor3--preview .docScenarioPlayer * {
|
||||
/* .docEditor3--preview désactive pointer-events sur tout [data-element-id]
|
||||
(voir plus bas) pour empêcher la sélection/le déplacement pendant
|
||||
l'Aperçu — réactivé ici spécifiquement pour que ces mini-jeux restent
|
||||
@@ -1312,6 +1318,17 @@ img.docImage {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ---- Scénario : panneau Propriétés — chaque choix garde sa case "bon
|
||||
choix"/son texte (.docQuizChoiceRow, réutilisé tel quel) ET, en plus,
|
||||
sa conséquence en dessous (voir forgeDocRenderScenarioChoiceHtml,
|
||||
static/document/js/document-editor.js) — un bloc de plus que le Quiz,
|
||||
d'où ce conteneur dédié. ---- */
|
||||
.docScenarioChoiceBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.docQuizAddBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
|
||||
@@ -479,6 +479,7 @@ function forgeDocBindCanvasInteractions() {
|
||||
forgeDocInitAssociationPlayers();
|
||||
forgeDocInitMemoryPlayers();
|
||||
forgeDocInitMotsPlayers();
|
||||
forgeDocInitScenarioPlayers();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
@@ -1135,6 +1136,110 @@ function forgeDocInitMotsPlayers() {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
* Scénario — mise en situation RÉELLEMENT interactive en Mode Aperçu :
|
||||
* l'apprenant lit une situation, choisit une option, découvre la
|
||||
* conséquence de SON choix ainsi que si c'était le bon, puis passe au
|
||||
* scénario suivant. Les scénarios se jouent dans l'ORDRE d'écriture
|
||||
* (jamais mélangés, contrairement à l'Association/Memory/Mots mêlés) :
|
||||
* ce sont des mises en situation séquentielles. Comme ces trois
|
||||
* mini-jeux, le dernier scénario reste affiché une fois répondu : seul
|
||||
* le bouton Recommencer (.docMinigameRestartBar) apparaît, jamais un
|
||||
* écran de résultat séparé (ça reste le comportement du Quiz). État
|
||||
* tenu en mémoire, jamais persisté.
|
||||
* ------------------------------------------------------------------- */
|
||||
|
||||
function forgeDocScenarioPlayerData(playerEl) {
|
||||
try {
|
||||
const config = JSON.parse(playerEl.dataset.scenarioConfig || '{}');
|
||||
return Array.isArray(config.scenarios) ? config.scenarios : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function forgeDocRenderScenarioPlayerScenario(playerEl, state) {
|
||||
state.answered = false;
|
||||
const item = state.scenarios[state.current];
|
||||
const situationEl = playerEl.querySelector('.docScenarioSituation');
|
||||
const choicesEl = playerEl.querySelector('.docScenarioChoices');
|
||||
const consequenceEl = playerEl.querySelector('.docScenarioConsequence');
|
||||
const progEl = playerEl.querySelector('.docScenarioProg');
|
||||
const nextBtn = playerEl.querySelector('.docScenarioNextBtn');
|
||||
|
||||
situationEl.textContent = item.situation;
|
||||
choicesEl.innerHTML = '';
|
||||
consequenceEl.className = 'docScenarioConsequence docQuizFeedback';
|
||||
consequenceEl.textContent = '';
|
||||
nextBtn.classList.remove('is-visible');
|
||||
playerEl.querySelector('.docMinigameRestartBar').classList.remove('is-visible');
|
||||
item.choices.forEach((choice, idx) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'docQuizOption docScenarioChoice';
|
||||
btn.textContent = choice.text;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
forgeDocScenarioPlayerAnswer(playerEl, state, idx, btn);
|
||||
});
|
||||
choicesEl.appendChild(btn);
|
||||
});
|
||||
progEl.textContent = `Scénario ${state.current + 1} sur ${state.scenarios.length}`;
|
||||
}
|
||||
|
||||
function forgeDocScenarioPlayerAnswer(playerEl, state, idx, btn) {
|
||||
if (state.answered) return;
|
||||
state.answered = true;
|
||||
const item = state.scenarios[state.current];
|
||||
const buttons = playerEl.querySelectorAll('.docScenarioChoice');
|
||||
buttons.forEach((b) => { b.disabled = true; });
|
||||
const consequenceEl = playerEl.querySelector('.docScenarioConsequence');
|
||||
const isLast = state.current === state.scenarios.length - 1;
|
||||
if (idx === item.correct_index) {
|
||||
btn.classList.add('is-correct');
|
||||
consequenceEl.classList.add('is-visible', 'is-ok');
|
||||
} else {
|
||||
btn.classList.add('is-wrong');
|
||||
buttons[item.correct_index].classList.add('is-correct');
|
||||
consequenceEl.classList.add('is-visible', 'is-ko');
|
||||
}
|
||||
consequenceEl.textContent = item.choices[idx].consequence || 'Aucune conséquence renseignée pour ce choix.';
|
||||
if (isLast) {
|
||||
playerEl.querySelector('.docMinigameRestartBar').classList.add('is-visible');
|
||||
} else {
|
||||
playerEl.querySelector('.docScenarioNextBtn').classList.add('is-visible');
|
||||
}
|
||||
}
|
||||
|
||||
function forgeDocScenarioPlayerNext(playerEl, state) {
|
||||
state.current += 1;
|
||||
if (state.current < state.scenarios.length) {
|
||||
forgeDocRenderScenarioPlayerScenario(playerEl, state);
|
||||
}
|
||||
}
|
||||
|
||||
function forgeDocScenarioRestart(playerEl, state) {
|
||||
state.current = 0;
|
||||
forgeDocRenderScenarioPlayerScenario(playerEl, state);
|
||||
}
|
||||
|
||||
function forgeDocInitScenarioPlayers() {
|
||||
document.querySelectorAll('.docScenarioPlayer').forEach((playerEl) => {
|
||||
const scenarios = forgeDocScenarioPlayerData(playerEl);
|
||||
if (!scenarios.length) return;
|
||||
const state = { current: 0, answered: false, scenarios };
|
||||
forgeDocRenderScenarioPlayerScenario(playerEl, state);
|
||||
playerEl.querySelector('.docScenarioNextBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
forgeDocScenarioPlayerNext(playerEl, state);
|
||||
});
|
||||
playerEl.querySelector('.docScenarioRestartBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
forgeDocScenarioRestart(playerEl, state);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocSetActiveBottomNav(target) {
|
||||
document.querySelectorAll('#docBottomNav button').forEach((b) => {
|
||||
b.classList.toggle('is-active', b.dataset.target === target);
|
||||
@@ -1737,6 +1842,179 @@ function forgeDocRenderMotsProps(panel, el) {
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocScenarioNewScenario() {
|
||||
// Champs pré-remplis (jamais vides) : sanitize_scenario_config
|
||||
// (routes/document/document_element_update.py) rejette silencieusement
|
||||
// tout scénario dont la situation est vide, ou tout choix dont le
|
||||
// texte est vide — même leçon que forgeDocAssociationNewPair/
|
||||
// forgeDocMemoryNewCard/l'ajout de mot des Mots mêlés.
|
||||
return {
|
||||
situation: 'Nouvelle situation',
|
||||
choices: [
|
||||
{ text: 'Choix 1', consequence: 'Conséquence du choix 1' },
|
||||
{ text: 'Choix 2', consequence: 'Conséquence du choix 2' },
|
||||
],
|
||||
correct_index: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function forgeDocRenderScenarioChoiceHtml(choice, sIndex, cIndex, correctIndex) {
|
||||
return `
|
||||
<div class="docScenarioChoiceBlock">
|
||||
<div class="docQuizChoiceRow">
|
||||
<input type="radio"
|
||||
name="docScenarioCorrect${sIndex}"
|
||||
class="docQuizCorrectRadio docScenarioCorrectRadio"
|
||||
data-scenario-index="${sIndex}"
|
||||
data-choice-index="${cIndex}"
|
||||
${cIndex === correctIndex ? 'checked' : ''}>
|
||||
<input type="text"
|
||||
class="docQuizChoiceInput docScenarioChoiceInput"
|
||||
value="${forgeDocEscapeHtml(choice.text)}"
|
||||
placeholder="Choix ${cIndex + 1}"
|
||||
data-scenario-index="${sIndex}"
|
||||
data-choice-index="${cIndex}">
|
||||
</div>
|
||||
<textarea class="docAssocPairInput docScenarioConsequenceInput"
|
||||
rows="2"
|
||||
placeholder="Conséquence de ce choix"
|
||||
data-scenario-index="${sIndex}"
|
||||
data-choice-index="${cIndex}">${forgeDocEscapeHtml(choice.consequence)}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function forgeDocRenderScenarioHtml(scenario, sIndex) {
|
||||
const choicesHtml = scenario.choices
|
||||
.map((c, cIndex) => forgeDocRenderScenarioChoiceHtml(c, sIndex, cIndex, scenario.correct_index))
|
||||
.join('');
|
||||
return `
|
||||
<div class="docQuizQuestion docScenarioCard" data-scenario-index="${sIndex}">
|
||||
<div class="docQuizQuestionHeader">
|
||||
<span class="docFieldLabel">Scénario ${sIndex + 1}</span>
|
||||
<button type="button" class="docQuizRemoveBtn docScenarioRemoveBtn" data-scenario-index="${sIndex}" title="Supprimer ce scénario">🗑</button>
|
||||
</div>
|
||||
<div class="docField">
|
||||
<textarea class="docScenarioSituationInput"
|
||||
rows="3"
|
||||
placeholder="Situation initiale"
|
||||
data-scenario-index="${sIndex}">${forgeDocEscapeHtml(scenario.situation)}</textarea>
|
||||
</div>
|
||||
<div class="docFieldRow">
|
||||
<label>Nombre de choix</label>
|
||||
<input type="number"
|
||||
class="docScenarioChoiceCountInput"
|
||||
min="2"
|
||||
max="4"
|
||||
value="${scenario.choices.length}"
|
||||
data-scenario-index="${sIndex}"
|
||||
style="width:60px;">
|
||||
</div>
|
||||
<div class="docQuizChoicesList">${choicesHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function forgeDocRenderScenarioProps(panel, el) {
|
||||
const a = el.attributes;
|
||||
const scenarios = a.scenarios || [];
|
||||
|
||||
function patch(partial) {
|
||||
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
|
||||
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
|
||||
});
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
${forgeDocPropsHeader('scénario')}
|
||||
<div class="docField">
|
||||
<span class="docFieldLabel">Couleur du thème</span>
|
||||
<div class="docSwatches" id="docScenarioColor">
|
||||
${['#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">Scénarios</div>
|
||||
<div id="docScenarioList">${scenarios.length ? scenarios.map(forgeDocRenderScenarioHtml).join('') : '<div class="docEmptyState">Aucun scénario — ajoute le premier ci-dessous.</div>'}</div>
|
||||
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docScenarioAdd">+ Ajouter un scénario</button>
|
||||
${forgeDocDeleteButtonHtml()}
|
||||
`;
|
||||
|
||||
panel.querySelectorAll('#docScenarioColor .docSwatch').forEach((sw) => {
|
||||
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
|
||||
});
|
||||
|
||||
document.getElementById('docScenarioAdd').addEventListener('click', () => {
|
||||
patch({ scenarios: [...scenarios, forgeDocScenarioNewScenario()] });
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docScenarioRemoveBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const sIndex = Number(btn.dataset.scenarioIndex);
|
||||
patch({ scenarios: scenarios.filter((_, i) => i !== sIndex) });
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docScenarioSituationInput').forEach((input) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const sIndex = Number(input.dataset.scenarioIndex);
|
||||
patch({ scenarios: scenarios.map((s, i) => (i === sIndex ? { ...s, situation: e.target.value } : s)) });
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docScenarioChoiceCountInput').forEach((input) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const sIndex = Number(input.dataset.scenarioIndex);
|
||||
const count = Math.max(2, Math.min(4, Number(e.target.value) || 2));
|
||||
patch({
|
||||
scenarios: scenarios.map((s, i) => {
|
||||
if (i !== sIndex) return s;
|
||||
const choices = s.choices.slice(0, count);
|
||||
while (choices.length < count) {
|
||||
choices.push({ text: `Choix ${choices.length + 1}`, consequence: '' });
|
||||
}
|
||||
return { ...s, choices, correct_index: s.correct_index < count ? s.correct_index : 0 };
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docScenarioChoiceInput').forEach((input) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const sIndex = Number(input.dataset.scenarioIndex);
|
||||
const cIndex = Number(input.dataset.choiceIndex);
|
||||
patch({
|
||||
scenarios: scenarios.map((s, i) => (
|
||||
i === sIndex
|
||||
? { ...s, choices: s.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) }
|
||||
: s
|
||||
)),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docScenarioConsequenceInput').forEach((input) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const sIndex = Number(input.dataset.scenarioIndex);
|
||||
const cIndex = Number(input.dataset.choiceIndex);
|
||||
patch({
|
||||
scenarios: scenarios.map((s, i) => (
|
||||
i === sIndex
|
||||
? { ...s, choices: s.choices.map((c, ci) => (ci === cIndex ? { ...c, consequence: e.target.value } : c)) }
|
||||
: s
|
||||
)),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docScenarioCorrectRadio').forEach((radio) => {
|
||||
radio.addEventListener('change', () => {
|
||||
const sIndex = Number(radio.dataset.scenarioIndex);
|
||||
const cIndex = Number(radio.dataset.choiceIndex);
|
||||
patch({ scenarios: scenarios.map((s, i) => (i === sIndex ? { ...s, correct_index: cIndex } : s)) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocRenderProps(el) {
|
||||
const panel = document.getElementById('docPropsPanel');
|
||||
if (!el) {
|
||||
@@ -1752,6 +2030,7 @@ function forgeDocRenderProps(el) {
|
||||
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
|
||||
else if (el.kind === 'memory') forgeDocRenderMemoryProps(panel, el);
|
||||
else if (el.kind === 'mots') forgeDocRenderMotsProps(panel, el);
|
||||
else if (el.kind === 'scenario') forgeDocRenderScenarioProps(panel, el);
|
||||
else forgeDocRenderMinigameProps(panel, el);
|
||||
|
||||
const deleteBtn = document.getElementById('docDeletePropBtn');
|
||||
|
||||
Reference in New Issue
Block a user