Tutorial: sectionsV2

sectionsV2

Pour créer des sections avec le nouveau modèle

Une section V2 est un module javascript (il est fortement conseillé d’utiliser typescript pour renforcer la solidité du code) qui contient quatre fonctions asynchrones obligatoires exportées par la section :

  • init()
  • enonce()
  • check()
  • solution()

De plus, la section exporte obligatoirement un objet 'parameters' qui contient les paramètres modifiables de la section dans EditGraphe.

Une section V2 peut contenir d’autres fonctions où constantes exportées dont la liste documentée est disponible dans le fichier des types l’interface Section qui contient les propriétés exportées par une section.

Les paramètres modifiables de la section

Un exemple :

export const parameters: SectionParameters = {
  titre: {
    type: 'string',
    defaultValue: 'exemple de section V2'
  },
  nbQuestions: {
    type: 'number',
    defaultValue: 5,
    help: 'Cet section produit 5 questions par défaut (10 maxi)',
    min: 1,
    max: 10
  },
  nbTries: {
    type: 'number',
    defaultValue: 2,
    help: 'Le nombre de tentatives',
    min: 1,
    max: 2
  }
}

Il n’y a que le titre qui est obligatoire. Le reste est ce que lon peut éventuellement modifier lorsque l’on édite le noeud qui charge cette section dans EditGraphe.
Ici, on peut modifier le nombre de questions (répétitions) et le nombre d’essais.

L’objet 'SectionContext' de la section

Pour plus d’information sur cet objet, voir le fichier SectionContext.ts

Cet objet sert à la communication entre le Player et les différentes fonctions de la section.
Par exemple, comme on y trouve le nombre d’essais ou le numéro de la question courante, la fonction enonce() peut se servir de ces informations pour modifier le contenu de l’énoncé, ou la fonction check() peut s'en servir pour adapter le feedback.

Détail des fonctions obligatoires d’une section V2

Les diverses fonctions de la section sont appelées à tour de rôle par le Player.
Afin de récupérer les informations nécessaires, elles reçoivent toutes en argument l’instance de SectionContext qui contient ce dont elles vont avoir besoin (par exemple : les valeurs choisies pour aléatoiriser l’énoncé passées dans storage, le numéro de l’étape courante, le nombre d’essais déjà utilisés, le nombre d’essais maximum...).
Dans cet objet, on va trouver obligatoirement un Playground (l’objet permettant aux fonctions d’interagir avec la zone de travail) et un Pathway (l’objet qui gère le parcours et les résultats de l’étudiant). La liste complète des propriétés de cet objet est définie et documentée dans le fichier SectionContext.ts (voir la classe SectionContext)

La fonction init()

Appelée une unique fois, son rôle est de mettre en place les données utilisées par la section.
On pourra par exemple y choisir une liste de fonctions utilisées par la suite dans l’énoncé des différentes questions ( pour éviter d’avoir des doublons).

La fonction enonce()

Son rôle, comme son nom l’indique, est d’écrire l’énoncé dans le Playground. (Une documentation du Playground et des fonctions mises à disposition se trouve ici.) Il est possible que cette fonction soit appelée une unique fois (une seule question, une seule étape) ou plusieurs fois (différentes questions, différentes étapes).
Il conviendra de traîter ces différents 'passages' en fonction du 'SectionContext'.

La fonction check()

Une fois l’énoncé placé dans le Playground, le Player va attendre la réponse de l’élève et le clic sur le bouton 'OK'.
À ce moment-là, il va confier la suite des événements (l’analyse de la réponse) à la fonction check().
Elle aussi doit donc s'adapter au SectionContext. La fonction check(), à la différence des autres qui ne retournent rien, retourne une promesse de PartialResult (voir le fichier des types)

La fonction solution()

C'est la fonction qui est appelée une fois le check effectué pour afficher la solution (ou correction) dans la zone dédiée en fonction, toujours, du SectionContext.

La manipulation du DOM à travers le Playground

Pour afficher, récupérer les saisies, corriger, interroger, la section va utiliser l’instance playground qui lui est passée dans le sectionContext passé par le Player.
Des méthodes listées dans le fichier Playground.md permettent de réaliser ces tâches sans avoir à connaître grand chose sur les sélecteurs, les éléments HTML ou autre.
Nous avons essayé de mettre à disposition un système cohérent, simple et relativement uniforme pour faciliter la tâche des futurs codeurs de sections.

Préambule : le concept d’items dans le Playground

Notre playground, dans lequel s'affiche notre section V2, va conserver une liste nominative des différents éléments que la section va mettre en place (on les appellera les items du playground).
Cela va du simple span contenant du texte, à la figure MG32 en passant par des tables interactives complètes.
On y accèdera via, par exemple, la méthode playground.getItemValue(name).
Le stockage de ces items est automatique : il est réalisé à chaque fois que l’on utilise la méthode playground.displayWork().

Un exemple pour mieux comprendre comment ça se passe :

// Dans la fonction enonce() 
import playground from 'src/lib/player/Playground'

await playground.displayWork('Quelle est la forme irréductible de $\\dfrac{18}{36}$ ? : %{unDemi}',
  {
    unDemi: {
      type: 'input',
      label: '',
      inputProps: {
        type: 'number'
      }
    }
  })
// On vient d’ajouter dans playground.items un item nommé 'unDemi'. À l’affichage, la phrase est ajoutée et un input attend la saisie de l’élève.

// Dans la fonction check()
const saisie = playground.getItemValue('unDemi')
// On vient de récupérer la valeur saisie dans l’input par l’étudiant
// On peut faire la vérification et ajouter un petit feedback 
if (saisie === 0.5) {
  playground.setFeedbackItem('unDemi', true, '🙂')
  return {
    feedBackMessage: 'Bien !', ok: true, partialScore: 1, evaluation: ''
  }
} else {
  playground.setFeedbackItem('unDemi', false, '😣')
  return {
    feedBackMessage: 'C\'est faux !', ok: false, partialScore: 0, evaluation: ''
  }
}

Cette façon de faire est pratiquement la même quelle que soit l’interface de saisie (figure MG32, cases à cocher, boutons radios, éditeur Mathlive, liste déroulante...).
Seules les propriétés associées à la variable changent selon le type.
En effet, si on veut introduire une figure MG32, il va bien falloir la passer... Idem pour une liste déroulante ou une série de cases à cocher.
L’implantation des différents éléments passe par les méthodes "display" du playground (playground.displayWork(), playground.displaySolution()).

syntaxe des fonctions "display"

Le premier argument d’une fonction de display (playground.displayWork(), playground.displaySolution()) est le contenu.
Dans ce contenu, on peut introduire du latex en ligne (il sera encadré par un $ de chaque côté) ou en bloc (avec un double $ de part et d’autre).
On introduira les 'variables' à l’aide de la syntaxe : %{name} (on remplacera name par le nom de la variable).
Par variable, j'entends ici le nom de l’item du playground associé.

Dans le deuxième argument, nous devons préciser la nature de l’item 'name'. C'est ce que nous verrons dans la partie suivante.

Le troisième argument, facultatif, précise certaines options du display, comme par exemple s'il est persistent.
Un display persistent restera affiché lors de létape suivante (pour les questions multi-étapes).

Les différents items/variables

  1. input standard Il s'agit d’un input html classique, à cela prêt qu'on lui adjoint un clavier virtuel escamotable ( cf VirtualKeyboard).
    Exemple de mise en oeuvre :
await playground.displayWork('Donnez la valeur approchée de $\\pi$ par défaut au centième pres : %{piReponse}',
  {
    piReponse: {
      type: 'input',
      label: '$\\pi\\approx $',
      inputProps: {
        type: 'text'
      }
    }
  },
  { persistent: true }
)

Ici, nous affichons une consigne se terminant par un input classique.
L’item associé à cet input se nomme 'piReponse' et on définit ses propriétés dans un objet de type DomEltInputValues DomEltInput.
Cet input est précédé d’un label (Le latex qu'il contient aurait pu être intégré au contenu, raison pour laquelle le label est facultatif).
Cet input est de type 'text' (c'est une propriété standard des inputs html).

  • Notez qu'il aurait pu être de type number, avoir un min, un max, un step comme les inputs de ce type (cf la documentation sur les inputs ici).
  • On aurait pu ajouter une propriété labelProps, facultative, pour préciser les propriétés de l’élément

Enfin, ce display sera persistant pour la prochaine étape...

  1. input type 'select'
    Comme nous avons détaillé précédemment la façon dont les items sont introduits dans le display, nous n’allons détailler ci-après que les propriétés intrinsèques aux différents items.
    Pour l’item de type 'select', voici les propriétés afférentes :
maListe: {
  choices: Choice[], // voir le commentaire ci-dessous
    inputProps?: Partial<HTMLSelectElement> & {
      heading?: string, // un éventuel premier élément non sélectionnable ('Fais ton choix' par exemple)
      onChange?: (choice: Choice) => void, // Une callback exécutée lors du choix.
      select?: number, // L’index du choix sélectionné par défaut.
    },
    persistent?: boolean // pour rendre persistante la liste.
}

les différentes valeurs de la liste choices peuvent être des strings ou des objets de type IChoice comme des images, du latex... On prendra alors soin de leur associer une 'value' (voir la classe ListeDeroulante).

  1. input type 'mathlive'
    C'est un input amélioré de type MathfieldElement.
    Il est muni d’un clavier virtuel configurable permettant d’écrire des formules mathématiques.
    Pour l’item de type 'select', voici les propriétés afférentes :
inputML: {
  value: string, 
  restriction: RegExp,
  commands: string[],
  persistent: boolean
}

restiction concerne le clavier... On pourra filtrer les touches affichées grâce à cette RegExp.
commands contient la liste des commandes spéciales du clavier. value est l’élément central de cet input particulier :

  • si value est vide, alors, l’input sera un input MathfieldElement classique, rectangulaire, vide.
  • si value contient une chaine de caractère latex (sans $) mais comportant une ou plusieurs variables (par exemple : ' \frac{%{num}}{%{den}}') alors l’input sera de type 'fillInTheBlank'.
    Un input de type 'fillInTheBlank' permet de figer une grande formule qui contient des emplacements (les placeholders) qui permettent la saisie.
    Dans l’exemple de la parenthèse, on a un trait de fraction avec un champ de saisie au numérateur (num) et un autre au dénominateur (den).
    Dans ce cas, la valeur retournée par playground.getItemValue('inputML') est un objet {num: valnum, den: valden}. Dans le cas classique, la valeur retournée est la saisie en latex (string).
  1. input type 'checkboxes' Il y a peu de différences notables entre cet input et l’input 'select' dans ses propriétés.
    On notera la présence d’une propriété supplémentaire verticalBlock, un booléen permettant de mettre les options verticales plutôt qu'horizontales.
    La différence notable se situe dans la valeur retournée par l’item correspondant qui est de type string[] et pas string.
    En effet, un tel input permet de sélectionner plusieurs réponses, contrairement à 'select'.

  2. input type 'radio' On retrouve les mêmes propriétés que dans l’input de type 'checkboxes'. La seule différence notable est que l’on ne peut sélectionner qu'une réponse.

  3. input type 'MG32'

inputMtg: {
 value: string
 svgOptions: object
 mtgOptions: object
 inputProps: Record<string, { initialValue: number }>
 persistent: boolean
}

Cet input est particulier.
value va contenir la figure mtg en code base64.
svgOptions et mtgOptions sont les objets à passer à getMtgAppLecteurApi() (voir la documentation inhérente)
inputProps va permettre de préciser les valeurs initiales des calculs retournés par la figure. En effet, il convient de vérifier si l’élève a manipulé la figure pour activer le isAnswered de l’item. Donc nous comparerons cette valeur initiale à la valeur du calcul au moment du getValue().

  1. input type 'table'
    Encore un item particulier. Dailleurs, on ne le trouve pas dans lib/entities/dom/* mais dans lib/outils/tableaux.
monTableau: {
    tableau: ItableDblEntry | ItableProp,
    inputProps: {
        classes: string, sousType: 'prop' | 'dblEntry'
    },
    persistent?: boolean
}

Il y a deux types différents de tableaux proposés : le tableau à double entrée (qui possède des entêtes de lignes et des entêtes de colonne et un contenu principal au croisement des lignes et des colonnes) et le tableau de deux lignes ( tableaux de proportionnalité ou de valeurs).
sousType sert donc à préciser ce que l’on veut. classes sert tout simplement à préciser la classe css affectée à la table créée. Si on ne la précise pas, c'est la classe 'tableauMathlive' qui est utilisée. La propriété tableau est la plus importante (obligatoire). Elle définit le contenu du tableau.
On ditinguera :

  • les tableaux de type ItableProp : { nbColonnes: number, ligne1: Icell[], ligne2: Icell[] }
  • les tableaux de type ItableDblEntry :{ raws: Array<Icell[]>, headingCols: Icell[], headingLines: Icell[] }

L’élément de base de ces tableaux étant la cellule (de type Icell) :{ texte: string, latex: boolean, gras: boolean, color: string }. Comme on peut le constater, les cellules peuvent contenir du texte classique ou du latex, le texte peut être mis en gras et en couleur.
Si texte est un string vide, alors, la cellule sera remplie par un input de type MathfieldElement. Le playground.getItemValue sur un tel item retourne un objet listant toutes les cellules concernées et la valeur saisie.
Par exemple : {L1C1: '3', L1C2: '6'} signifie que l’étudiant a saisi '3' en Ligne 1 colonne 1 et '6' en ligne 1 colonne 2.

Un exemple de tableau de type proportionnalité :

await playground.displayWork(`Compléter le tableau de proportionnalité suivant : 
%{${ double }}`, {
  double: {
    type: 'table',
    tableau: {
      nbColonnes: 3,
      ligne1: [{ texte: 'masse en kg', latex: false, gras: true, color: 'black' }, { texte: '1' }, { texte: '2' }],
      ligne2: [{ texte: 'prix en €', latex: false, gras: true, color: 'black' }, { texte: '5' }, { texte: '' }]
    },
    inputProps: {
      name, sousType: 'prop', classes: ''
    },
    persistent: false
  }
}, {})

Le await playground.getValue('double') devrait retourner {L1C2: '10'} si l’élève ne s'est pas trompé.

Un autre exemple de type à double entrée :

await playground.displayWork('Compléter la table de multiplication suivante : \n%{tabDistri1}', {
  tabDistri1: {
    type: 'table',
    tableau: {
      raws: [{ texte: '' }, { texte: '' }, { texte: '' }, { texte: '' }],
      headingCols: [{ texte: '\\times', latex: true }, { texte: '2x', latex: true }, { texte: '1', latex: true }],
      headingLines: [{ texte: '2x', latex: true }, { texte: '1', latex: true }],
    },
    inputProps: {
      name: 'tabDistri1', sousType: 'dblEntry', classes: ''
    },
    persistent: false
  }
}, {})
return

Le await playground.getValue('tabDistri1') devrait retourner {L1C1: '4x^2',L1C2: '2x',L2C1:'2x', L2C2:'1'} si l’élève ne s'est pas trompé.

  1. texteElement Pour finir avec des choses plus digestes, ceci n’est pas un input.
    Ceci sert à ajouter un item de type texte. On pourrait penser que ça ne sert à rien vu que dans un displayWork() ou un displaySolution() on peut déjà ajouter un texte et même du latex, sans pour autant créer d’item... Mais on a pensé aux artistes : ce texteElement dispose d’une propriété className propice aux enjolivures...
    Il dispose aussi de son propre booléen persistant (comme les autres items)...

Mise en oeuvre et test d’une nouvelle section V2

Pour illustrer cette partie, nous allons créer une section qui demande à l’élève d’effectuer des compléments à 100.
Les paramètres modifiables de la section seront :

  • le nombre de répétitions (dans la limite de la taille du complément)
  • le nombre d’essais par tentative
  • la taille du complément
  1. Nous avons donc, pour commencer à définir l’objet parameters :
export const parameters: SectionParameters = {
  titre: {
    type: 'string',
    defaultValue: 'compléments à 100'
  },
  nbQuestions: {
    type: 'number',
    defaultValue: 5,
    help: 'Cette section produit 5 questions par défaut (10 maxi)',
    min: 1,
    max: 10
  },
  maxTries: {
    type: 'number',
    defaultValue: 2,
    help: 'Le nombre de tentatives',
    min: 1,
    max: 2
  },
  maxComplement: {
    type: 'number',
    defaultValue: 20,
    help: 'Le complément maximum',
    min: 1,
    max: 50
  }
}
  1. La fonction init()
    Ell doit stocker une liste de valeurs pour les questions sans doublons. En effet, si on choisit au hasard des valeurs lors de chaque répétition, rien ne garantit qu'elles seront différentes.
export async function init ({ storage, params, nbQuestions }: SectionContext): Promise<void> {
  const complements: number[] = []
  let max: number = Number(params.maxComplement)
  if (max < nbQuestions) max = nbQuestions + 5 // La valeur max du complément doit permettre d’avoir assez de valeurs différentes sinon, cela provoquerait une boucle infinie ci-dessous.
  do {
    const int = j3pGetRandomInt(1, max)
    if (!complements.includes(int)) complements.push(int) // On vérifie que cet entier ne fait pas partie de la liste
  } while (complements.length < nbQuestions) // On boucle jusqu'à avoir le nombre de valeurs souhaitées
  Object.assign(storage, { complements }) // On stocke les valeurs des compléments qui seront demandés dans storage.
}
  1. La fonction enonce()
    Elle sera la plus simple possible : on écrit la consigne et on met un input en place.
export async function enonce ({ storage, playground, question, nbTries }: SectionContext): Promise<void> {
  const complements = storage.complements as number[]
  const complement = complements[question] ?? 10
  if (nbTries < 2) { // Il ne faut mettre en place qu'une seule fois l’item 'reponse' car sinon, cela provoque une erreur. Il servira pour les différents essais (sinon, il faudrait lui donner un nom différent à chaque fois).
    await playground.displayWork(`Quel nombre faut-il ajouter à $${ 100 - complement }$ pour faire $100$ ? %{reponse}`,
      {
        reponse: {
          type: 'mathlive',
          value: '',
          persistent: true
        }
      }
    )
  }
}
  1. La fonction check()
import textes from 'src/lib/core/textes' // à mettre au début du module ce sont les textes standardisés

const { smile, anger, confused, cFaux, cBien, essaieEncore } = textes // Les constantes utilisées pour les feedback 

export async function check ({ storage, playground, question, nbTries }: SectionContext): Promise<PartialResult> {
  const complements = storage.complements as number[]
  const complement = complements[question]
  const saisie = await playground.getItemValue('reponse')
  if (Number(saisie) === complement) {
    await playground.setFeedbackItem('reponse', true, smile)
    return {
      feedBackMessage: cBien, ok: true, partialScore: 1, evaluation: ''
    }
  } else {
    if (nbTries < 2) {
      await playground.setFeedbackItem('reponse', false, confused)
      return {
        feedBackMessage: essaieEncore, ok: false, partialScore: 0, evaluation: ''
      }
    } else {
      await playground.setFeedbackItem('reponse', false, anger)
      return {
        feedBackMessage: cFaux, ok: false, partialScore: 0, evaluation: ''
      }
    }
  }
}
  1. La fonction solution()
export async function solution ({ storage, playground, question }: SectionContext): Promise<void> {
  const complements = storage.complements as number[]
  const complement = complements[question] ?? 10
  await playground.displaySolution(`$${ 100 - complement }+\\mathbf{${ complement }}=100$ car $100-${ 100 - complement }=\\mathbf{${ complement }}$`, {}, {})
}
  1. Mise en place de la section et tests. Vous pouvez retrouver l’ensemble du code de cette section ici

    Pour pouvoir tester cette nouvelle section, nous allons la référencer dans loadSection.ts :
    Dans ce fichier, vous trouverez un objet sections. Rendez-vous à la fin de cet objet, et ajouter un enregistrement correspondant à votre section (on pourra calquer la ligne précédente en changeant ce qui doit l'être).
    ,SectionV2Exemple: (): Promise<Section> => import('src/sections/sectionV2Exemple') // aj 2024-05-29 Ne pas oublier la virgule qui sépare les enregistrements de l’objet sections !
    Ainsi, on va pouvoir créer un graphe qui utilisera la section 'SectionV2Exemple'.
    Faites un pnpm start dans un terminal à la source du projet, puis dans le navigateur, cliquez sur l’onglet ' initialiser un parcours'.
    Ensuite vous copiez ce Graph V2 dans la zone d’édition 'Graphe' (sans le "const graph =", juste la partie entre les accolades) :

const graph = {
  nodes: {
    1: {
      section: 'SectionV2Exemple',
      params: {
        nbQuestions: 3, maxTries: 3, maxComplement: 50, limite: 15, suivant: false
      },
      label: 'Un exemple de section V2',
      maxRuns: 1,
      titre: 'V2Exemple',
      connectors:
        [
          {
            target: '2',
            label: 'Rang 1',
            feedback: 'Terminé !',
            typeCondition: 'none'
          }
        ]
    },
    2:
      {
        label: 'Fin',
        section: ''
      }
  },
  startingId: '1'
}

Enfin, vous cliquez sur 'play V2' et c'est parti !