diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 642f4120..39610926 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -19,11 +19,10 @@ on: # un push direct ou une PR mergée depuis ailleurs. djlint EXCLU # volontairement (49 H021 "styles inline" déjà en backlog assumé, voir # CODE_QUALITY.md) — à ajouter ici quand ce lot sera traité. sonarqube, -# lui, reste NON-BLOQUANT (continue-on-error) pendant cette première -# période — voir CODE_QUALITY.md pour la trajectoire vers un mode -# bloquant une fois le rapport trié (code smells, vulnerabilites) plutôt -# que de bloquer tout de suite sur des centaines de signalements pas -# encore triés. +# lui, reste NON-BLOQUANT (continue-on-error) — remis en place le +# 18/09/2026 une fois l'instance prod opérationnelle (voir CODE_QUALITY.md +# pour l'historique du retrait temporaire), le temps que le rapport de +# code smells soit entièrement trié. # Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) : # REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com) @@ -92,7 +91,7 @@ jobs: COPY static/js/play/ static/js/play/ COPY static/js/scenes/ static/js/scenes/ COPY static/js/triggers/ static/js/triggers/ - RUN node --test static/js/play/__tests__/*.test.js static/js/scenes/__tests__/*.test.js + RUN node --test static/js/play/__tests__/*.test.js static/js/play/offline/__tests__/*.test.js static/js/scenes/__tests__/*.test.js DOCKERFILE lint-python: @@ -205,8 +204,19 @@ jobs: cd "${{ secrets.DEPLOY_PATH }}" echo "REGISTRY_IMAGE=${{ secrets.REGISTRY_IMAGE }}" > .env echo "IMAGE_TAG=${{ gitea.sha }}" >> .env - docker login "${{ secrets.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USER }}" -p "${{ secrets.REGISTRY_TOKEN }}" + echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ secrets.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USER }}" --password-stdin docker compose -f docker-compose.prod.yml pull docker compose -f docker-compose.prod.yml up -d docker image prune -f ' + + - name: Nettoyage de la cle SSH + # if: always() - meme si les etapes precedentes ont echoue, la cle + # privee posee ci-dessus (~/.ssh/deploy_key) ne doit jamais rester + # sur le disque du job. Le runner semble deja jetable (voir + # "docker volume rm .../JOB--..." dans les logs des autres + # jobs de ce meme workflow), mais jamais verifie directement pour + # CE job (jamais execute sur dev, reserve a main) - ne pas se fier + # uniquement a une inference par analogie pour une cle privee. + if: always() + run: rm -f ~/.ssh/deploy_key diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..9899d191 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,62 @@ +## Stack et structure +Bref rappel : Python/Flask/Jinja backend, JS/HTML/CSS frontend, architecture en couches (routes → services/screens → db, voir contrat import-linter dans pyproject.toml). + +## Règle absolue : qualité de code non négociable +- Tout code Python doit passer mypy --strict sans erreur. Jamais de # type: ignore sans commentaire justifiant précisément pourquoi (pas de vague, une vraie raison technique). +- Tout code doit passer Ruff, Bandit, Vulture, import-linter, ESLint, Stylelint sans nouvelle erreur avant d'être considéré terminé. +- Avant de committer, lance systématiquement pre-commit run --all-files et corrige tout ce qui échoue — jamais de --no-verify ou de SKIP= sans me le signaler et justifier explicitement pourquoi. +- Aucune règle de lint/typage ne doit être désactivée globalement dans un fichier de config sans ma validation explicite. Une exception ponctuelle (# noqa, # nosec, // NOSONAR, ts-ignore) doit toujours être sur la ligne concernée avec un commentaire expliquant précisément pourquoi, jamais un ignore de fichier entier ou de règle globale. + +## Distinguer bug réel vs faux positif +Avant de corriger un finding d'un outil (Mypy, Bandit, Sonar, etc.), détermine s'il s'agit d'un vrai problème ou d'un faux positif dû au contexte du projet (ex. injection de fixture pytest, dispatch dynamique JS/Jinja, whitelist codée en dur). Ne corrige jamais mécaniquement sans comprendre la cause. Si un correctif change un comportement (pas juste une annotation/un style), signale-le explicitement avant de l'appliquer et explique pourquoi. + +## Tests unitaires — non-régression +- Toute nouvelle fonctionnalité ou tout correctif de bug doit être accompagné d'un test qui aurait échoué avant le fix et passe après. +- Ne jamais me dire "les tests passent" comme preuve suffisante pour un changement de sécurité ou de comportement sensible (ex. échappement de données, gestion de permissions) — écris un test ou un script de vérification dédié qui prouve concrètement le comportement attendu (voir l'exemple du fix XSS json_for_script : test avec une charge malveillante réelle, pas juste "aucune régression"). +- Ne réduis jamais la portée d'une assertion de test pour la faire passer sans discussion préalable avec moi. +- Signale immédiatement toute fuite d'état entre tests (fixtures partagées, données non nettoyées) même si elle n'est pas dans le scope de la tâche en cours. + +## Code mort +Avant de supprimer une fonction jugée "morte" par un outil (Vulture, ESLint no-unused-vars), vérifie par grep exhaustif (imports directs, dispatch dynamique par nom de chaîne, référencé depuis un template Jinja ou un script JS, appel getattr/introspection) avant de conclure qu'elle est vraiment inutilisée. + +## SonarQube local (développement continu) +En plus de l'instance CI (sonar.forgebase.fr, non-bloquante), une instance SonarQube locale doit tourner via Docker pour un usage rapide pendant le développement, séparée de la CI. + +### Mise en place (à faire une fois si l'instance n'existe pas encore) +Si aucune instance locale n'est déjà en place : lance une instance SonarQube Community via Docker (image officielle, avec une base PostgreSQL plutôt que la H2 embarquée par défaut — voir la mise en place déjà documentée pour sonar.forgebase.fr comme référence). Communique-moi clairement l'adresse locale (ex. http://localhost:9000) une fois lancée, et le port utilisé, pour que je puisse ouvrir le dashboard moi-même à tout moment. + +### Utilisation continue pendant le développement +- Avant de considérer une tâche terminée (nouvelle fonctionnalité, gros refactor, fix de bug), lance une analyse sonar-scanner contre cette instance locale. +- Si des erreurs ou vulnérabilités sont détectées sur du code NOUVEAU (écrit dans la session en cours) : corrige-les toi-même directement, comme pour Ruff/Mypy/Bandit — pas besoin de me demander la permission pour un vrai bug évident sur du code que tu viens d'écrire. +- Si l'analyse détecte quelque chose sur du code existant (pas modifié dans la session en cours) : signale-le-moi, n'y touche pas sans validation, même logique que pour le rapport SonarQube de la Phase 3 (analyse d'abord, décision ensemble avant correction). +- Distingue toujours dans ton compte-rendu : ce qui a été corrigé automatiquement (code neuf, évident) vs ce qui est signalé en attente de décision (code existant, ou correction ambiguë). +- Donne-moi régulièrement l'adresse du dashboard local si je veux consulter moi-même le détail visuellement. + +## Convention vs bug de config +Si un preset de lint (ex. airbnb-base, stylelint-config-standard) contredit une convention cohérente déjà établie dans le code (ex. camelCase CSS, var au lieu de let/const), n'impose pas de réécriture mécanique du code : propose d'adapter la config, documente pourquoi, et attends ma validation. + +## Discipline de communication +- Pour tout lot de travail dépassant quelques fichiers, découpe en étapes vérifiables (diff + tests à chaque étape), jamais un correctif massif d'un coup. +- Signale tout changement de comportement réel séparément du reste (pas noyé dans un rapport de style). +- Si un choix a plusieurs options légitimes (garder/typer/supprimer, corriger/documenter une exception), présente les options avec leurs compromis plutôt que de trancher seul. + +## Documentation +Toute règle désactivée, tout # nosec/NOSONAR, toute adaptation de config doit être répercutée dans CODE_QUALITY.md. + +## Organisation des fichiers (applicable à partir de maintenant, pas de refactor rétroactif) + +- 1 fichier = 1 fonction publique. Les fonctions privées d'appui (préfixées _) utilisées uniquement par cette fonction restent dans le même fichier. +- 1 dossier = 1 responsabilité claire. +- Chaque dossier a un fichier barrel (__init__.py) qui importe/exporte toutes les fonctions publiques du dossier (pattern déjà en place dans db/__init__.py et screens/__init__.py — garde __all__ explicite à jour à chaque ajout). +- Chaque dossier a un fichier .md qui documente chaque fonction publique qu'il contient : signature, rôle, paramètres, valeur de retour, exceptions possibles. Mets-le à jour à chaque fonction ajoutée/modifiée/supprimée. + +### Exceptions à cette règle (ne pas séparer) +- Le pattern sanitize_X / resolve_X (validation + résolution avec repli sur valeur par défaut) : ces deux fonctions restent dans le même fichier quand resolve_X appelle directement sanitize_X et qu'elles partagent des constantes de configuration. Ce sont deux étapes d'un même contrat, pas deux responsabilités séparées. +- Un groupe de fonctions fortement couplées par une table de dispatch centrale et des constantes de module partagées (ex. collision_rules.py) peut rester dans un seul fichier si les séparer forcerait soit une duplication de constantes, soit un fichier de constantes partagé importé par tous les autres pour un gain de lisibilité douteux. En cas de doute, demande avant de trancher plutôt que d'appliquer la règle mécaniquement. +- Des handlers Flask co-localisés par convention d'URL (ex. plusieurs routes d'un même sous-domaine fonctionnel) qui n'ont aucun appel ni état partagé entre eux ne sont PAS un cas d'exception — ceux-là doivent être séparés, un fichier par route/fonction. + +### Avant de créer une nouvelle fonction +Vérifie si elle appartient à un fichier existant à forte cohésion (voir exceptions ci-dessus) ou si elle mérite son propre fichier. En cas de doute sur la classification, demande plutôt que de deviner. + +### Constantes partagées entre plusieurs fonctions d'un même dossier +Si plusieurs fonctions séparées (dans des fichiers différents) ont besoin des mêmes constantes, crée un fichier constants.py dans le dossier concerné plutôt que de dupliquer les valeurs — ne duplique jamais une constante de configuration entre fichiers. diff --git a/CODE_QUALITY.md b/CODE_QUALITY.md new file mode 100644 index 00000000..907252c1 --- /dev/null +++ b/CODE_QUALITY.md @@ -0,0 +1,189 @@ +# Qualité de code — Forge Engine + +Référence de fonctionnement du dispositif qualité mis en place (Phases 1 +à 4). Pour le compte-rendu chronologique de ce qui a été fait/trouvé/ +décidé pendant la mise en place, voir `docs/SESSION_RECAP.md` — ce +fichier-ci documente uniquement **l'état actuel** et **comment +l'utiliser au quotidien**. + +Règle de base, non négociable : **aucune règle de lint/typage n'est +désactivée globalement dans un fichier de config sans validation +explicite.** Une exception ponctuelle est toujours une ligne de code +(`# noqa`, `# nosec`, `// NOSONAR`) avec une raison précise, jamais un +ignore de fichier entier ou de règle globale — voir section 4. + +## 1. Vue d'ensemble + +| Outil | Vérifie | Portée | Obligatoire / Avertissement | +|---|---|---|---| +| **Ruff** (lint) | Erreurs Python, imports inutilisés, style | `*.py` | Obligatoire (bloquant CI + pre-commit) | +| **Ruff** (format) | Formatage Python | `*.py` | Obligatoire | +| **Mypy** `--strict` | Typage statique Python | `*.py` | Obligatoire | +| **Bandit** | Sécurité Python (injections, primitives faibles) | `ai`, `auth`, `core`, `db`, `filters`, `publish`, `routes`, `screens`, `scripts`, `app.py`, `build_css.py` | Obligatoire | +| **Vulture** | Code mort Python | mêmes dossiers que Bandit | Obligatoire | +| **import-linter** | Contrat de couches applicatives | tout le code Python | Obligatoire | +| **ESLint** (`airbnb-base`) | Lint JavaScript | `static/js/**/*.js` | Obligatoire | +| **Stylelint** (`stylelint-config-standard`) | Lint CSS | `styles/**/*.css` | Obligatoire | +| **djLint** | Lint des templates Jinja | `templates/**/*.html` | **Avertissement** — H021 (styles inline) en backlog assumé, voir section 6 | +| **SonarQube** (local + CI) | Bugs/vulnérabilités/code smells/hotspots agrégés | tout le dépôt | **Avertissement** — non-bloquant en CI pour l'instant, voir section 3 | + +## 2. Configuration de chaque outil + +### Ruff (`pyproject.toml`, `[tool.ruff]`) +- `line-length = 120`, `target-version = "py313"`. +- Règles activées : `F` (pyflakes), `E`/`W` (pycodestyle), `B` (bugbear), `C4` (comprehensions), `SIM` (simplification), `S` (bandit-équivalent Ruff), `I` (imports). +- `per-file-ignores` : `S101`/`S106` désactivées pour `tests/` uniquement (assert et mots de passe en dur sont la norme dans les tests, jamais en dehors). +- Exclusions : `projects/`, `user_assets/`, `data/`, `Bug/`, `regles/` (données runtime, jamais du code). + +### Mypy (`pyproject.toml`, `[tool.mypy]`) +- `strict = true`, `python_version = "3.13"`. +- `explicit_package_bases = true` + `mypy_path = "."` : nécessaire car `tests/` n'a pas de `__init__.py` — sans ça mypy refuse de démarrer ("Source file found twice under different module names"). **Ce n'est pas un assouplissement de `strict`**, juste la résolution des chemins de module. +- Mêmes exclusions que Ruff. + +### Bandit (`pyproject.toml`, `[tool.bandit]`) +- `exclude_dirs` : `projects`, `user_assets`, `data`, `Bug`, `regles`, `tests` (les tests contiennent des mots de passe/tokens de test en dur, jamais un vrai risque). +- Portée explicite (`-r ai auth core db filters publish routes screens scripts app.py build_css.py`) plutôt que tout le dépôt. + +### Vulture (`pyproject.toml`, `[tool.vulture]`) +- `min_confidence = 80`, `paths` = mêmes dossiers que Bandit + `vulture_whitelist.py`. +- `exclude = ["*/tests/*"]`. +- Faux positifs structurels (routes Flask enregistrées par décorateur, hooks appelés par convention de nom) : whitelist dédiée dans `vulture_whitelist.py`, **jamais** un `min_confidence` abaissé globalement. + +### import-linter (`pyproject.toml`, `[tool.importlinter]`) +- Un seul contrat de type `layers`, du haut vers le bas : `routes` → `ai | publish | core` → `screens` → `auth | filters` → `db`. +- Une couche ne peut importer qu'une couche strictement en dessous d'elle, jamais au-dessus, jamais une couche sœur du même niveau. +- `app.py` (point d'entrée, pas un paquet) reste hors contrat — c'est lui qui importe `routes`/`core`, jamais l'inverse. + +### ESLint (`.eslintrc.json`) +- Base `airbnb-base` + plugins `unused-imports`, `unicorn`. +- `max-len` porté à 120 (aligné sur Ruff) au lieu du 80 par défaut d'Airbnb. +- Plusieurs règles Airbnb désactivées **après vérification individuelle que le code existant les respecte déjà différemment** (voir section 5 pour le détail et le raisonnement de chacune) : `no-param-reassign`, `no-use-before-define` (assoupli pour les fonctions, gardé strict pour variables/classes), `func-names`, `no-underscore-dangle`, `no-console`, `no-plusplus`, `no-continue`, `no-bitwise`, `guard-for-in`, `no-restricted-syntax`, `prefer-destructuring`, `consistent-return`, `no-return-assign`, `no-nested-ternary`, `no-void`, `implicit-arrow-linebreak`, `no-unused-expressions`, `no-useless-concat`. +- `unused-imports/no-unused-vars` avec une liste blanche (`varsIgnorePattern`) d'environ 95 noms : fonctions invoquées uniquement depuis des attributs `onclick`/`onchange` inline dans les templates Jinja, invisibles pour l'analyse statique d'ESLint. +- Bloc `globals` documentant les variables cross-fichiers volontaires (ex. `gameData`, `screensData` — voir section 5). +- **`eslint-plugin-unicorn`** (version `55.0.0` épinglée — la dernière exige ESLint ≥10, incompatible avec notre `^8.57.1`) : activé avec **UNIQUEMENT 9 règles explicitement listées**, jamais sa config `recommended` complète (qui en contient des dizaines d'autres, jamais évaluées pour ce projet — décision délibérée pour ne pas introduire un nouveau volume de règles non passées en revue). Choisi pour nettoyer le lot "modernisation JS" du rapport SonarQube (voir `docs/JS_MODERNIZATION_PLAN.md`) : `eslint-plugin-sonarjs` (l'équivalent officiel SonarSource) ne couvrait qu'une seule des règles visées. Les 9 règles activées, chacune avec un fixer `--fix` vérifié : + - `unicorn/prefer-number-properties` — `parseFloat`/`parseInt`/`isNaN`/`isFinite` → `Number.*` + - `unicorn/prefer-string-replace-all` — `.replace(/x/g, ...)` → `.replaceAll(...)` + - `unicorn/prefer-dom-node-dataset` — `getAttribute('data-x')` → `.dataset.x` + - `unicorn/prefer-includes` — `.indexOf(x) !== -1` → `.includes(x)` + - `unicorn/prefer-string-starts-ends-with` — comparaison manuelle de sous-chaîne → `.startsWith()`/`.endsWith()` + - `unicorn/prefer-modern-math-apis` — expression mathématique manuelle → `Math.hypot()` etc. + - `unicorn/prefer-at` — `arr[arr.length - 1]` → `arr.at(-1)` + - `unicorn/no-useless-fallback-in-spread` — `{...(x || {})}` → `{...x}` + - `unicorn/no-for-loop` — boucle `for` classique sur un itérable → `for...of` + +### Stylelint (`.stylelintrc.json`) +- Base `stylelint-config-standard`. +- `selector-class-pattern`/`selector-id-pattern` désactivées : le projet utilise du camelCase pour ses classes/id CSS depuis le début (`.canvasElement`, `#scormProgress`...) — imposer le kebab-case du preset aurait demandé de renommer des milliers de sélecteurs et leurs usages JS/HTML pour un gain nul. +- `no-descending-specificity` désactivée : le CSS existant est organisé par composant/fonctionnalité, pas par ordre strict de spécificité. +- `ignoreFiles` : `static/vendor/**`, `static/style.css` (bundle généré, contient du Bulma vendored). + +### djLint (`pyproject.toml`, `[tool.djlint]`) +- `profile = "jinja"`, `max_line_length = 160`, `indent = 2`. +- H021 (styles inline) : **backlog assumé, pas une exception corrigée** — voir section 6, ne pas confondre avec les vraies exceptions de la section 5. + +### SonarQube +- **CI (`sonar.forgebase.fr`)** : job `sonarqube` (`.gitea/workflows/deploy.yml`), scan à chaque push, **non-bloquant** (`continue-on-error: true`) le temps que le rapport soit entièrement trié. Retiré le 18/09/2026 (voir historique ci-dessous), remis en place le 18/09/2026 une fois l'instance prod confirmée opérationnelle. +- **Service prod** (`docker-compose.prod.yml`) : reste retiré côté dépôt pour l'instant — l'instance prod opérationnelle n'est pas (encore) pilotée par ce fichier de ce côté-ci ; à réintégrer explicitement si besoin. +- **Historique du retrait temporaire (16-18/09/2026)** : l'accès au dashboard (local WSL2 et prod derrière Caddy) était resté bloqué par des soucis d'infrastructure réseau (redirection de port WSL2/pare-feu Hyper-V côté local, réseau Docker partagé avec Caddy pas encore en place côté prod à ce moment) sans lien avec le code du moteur. Le lot "modernisation JS" (voir `docs/JS_MODERNIZATION_PLAN.md`) s'était arrêté après le lot 3 (`S2486`) pour cette même raison — les lots 4+ dépendent de scores Sonar exacts (complexité cognitive notamment) qu'aucun proxy fiable ne remplace parfaitement (le SonarLint de l'IDE a servi de solution de contournement fonctionnelle en attendant, voir lots 4 en cours directement sur les fichiers ouverts dans l'éditeur). + +## 3. Lancer les checks en local + +```bash +# Un outil a la fois (memes commandes que .pre-commit-config.yaml) +ruff check . +ruff format --check . +mypy . +vulture +bandit -c pyproject.toml -r ai auth core db filters publish routes screens scripts app.py build_css.py +lint-imports +djlint templates +npx eslint "static/js/**/*.js" +npx stylelint "styles/**/*.css" + +# Tout d'un coup (ce que fait un commit) +pre-commit run --all-files + +# Suite de tests +python -m pytest tests/ -q +node --test static/js/play/__tests__/*.test.js static/js/play/offline/__tests__/*.test.js static/js/scenes/__tests__/*.test.js +``` + +SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloquante), instance locale toujours bloquée (voir historique dans cette même sous-section). + +## 4. Procédure de justification d'une exception + +1. **Jamais de correction mécanique sans comprendre la cause.** Avant de corriger un finding, déterminer s'il s'agit d'un vrai problème ou d'un faux positif dû au contexte du projet (fixture pytest, dispatch dynamique JS/Jinja, whitelist codée en dur, architecture volontaire). +2. **Une exception ponctuelle, jamais globale.** Le commentaire de suppression va **sur la ligne exacte** signalée par l'outil — pas la ligne au-dessus, pas la ligne en dessous (piège vécu à plusieurs reprises cette session : un commentaire mal placé ne supprime rien du tout, silencieusement). +3. **La raison est précise, jamais vague.** "Faux positif" seul ne suffit pas — expliquer *pourquoi* (ex. "table_name vient de slugify()+prefixe obj_, jamais d'une entrée brute"). +4. **Format attendu par outil** (vérifié empiriquement cette session, chaque outil a ses propres exigences de position du mot-clé) : + - Bandit : `# nosec ` — le mot `nosec` doit être immédiatement précédé d'un `#` sur la ligne. + - Ruff : `# noqa: ` — le mot `noqa` doit être immédiatement précédé d'un `#` sur la ligne. **Bandit et Ruff peuvent coexister sur une même ligne physique** en utilisant deux `#` distincts (`# nosec B101 # noqa: S101 - raison`) — c'est structurel, pas un choix de style : chaque outil cherche son propre mot-clé juste après un `#`, et aucun format à un seul `#` ne peut satisfaire les deux à la fois (testé empiriquement). + - SonarQube (Python) : `# NOSONAR - raison`, sur la ligne exacte. + - SonarQube (JS) : `// NOSONAR - raison`, sur la ligne exacte. + - SonarQube (templates Jinja/HTML, règle `Web:*`) : **aucune syntaxe trouvée qui fonctionne** malgré plusieurs tentatives (commentaire Jinja `{# #}`, commentaire JS natif dans un `` (voir Phase 3). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `` dans le JSON embarqué (XSS potentiel)** — 25 sites + Python (`routes/scenes/scene_edit_view.py` ×22, + `routes/play/game_play.py` ×1, `publish/build_scorm_package.py` ×2) + utilisaient `json.dumps(...)` brut pour embarquer des données dans un + bloc `` + aurait refermé la balise prématurément et injecté du HTML/JS non + échappé sur la page. Nouveau helper partagé `db.json_for_script()` + (`json.dumps(data).replace(" beaucoup plus que x10 le temps). +// +// A relancer si l'une de ces 3 regex change de forme, pour confirmer que le +// NOSONAR reste justifie plutot que de le recopier aveuglement. +// +// Usage : node docs/redos_probe_s8786.js + +function timeIt(label, fn) { + const start = process.hrtime.bigint(); + const result = fn(); + const end = process.hrtime.bigint(); + const ms = Number(end - start) / 1e6; + console.log(`${label}: ${ms.toFixed(2)}ms -> match=${result !== null}`); + return ms; +} + +console.log('=== Pattern A (filter-repeater-rows.js:10, panel-init.js:254) ==='); +const patternA = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/; +for (const n of [1000, 10000, 50000, 100000]) { + const evil = `{{${'a'.repeat(n)}`; // pas de point, pas de fermeture + timeIt(` n=${n} (pas de point, pas de fermeture)`, () => patternA.exec(evil)); +} +for (const n of [1000, 10000, 50000, 100000]) { + const evil = `{{${'a'.repeat(n)}.${'b'.repeat(n)}`; // point present, pas de fermeture + timeIt(` n=${n} (point present, pas de fermeture)`, () => patternA.exec(evil)); +} + +console.log(''); +console.log('=== Pattern B (filter-repeater-rows.js:11, panel-init.js:261) ==='); +const patternB = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/; +for (const n of [1000, 5000, 10000, 20000]) { + const evil = `{{$a${'.b'.repeat(n)}`; // repetition simple, pas de fermeture + timeIt(` n=${n} (${n} segments ".b", pas de fermeture)`, () => patternB.exec(evil)); +} +for (const n of [1000, 5000, 10000, 20000]) { + const evil = `{{$a${'.b[0]'.repeat(n)}`; // alternance des deux branches + timeIt(` n=${n} (${n} segments ".b[0]" alternes, pas de fermeture)`, () => patternB.exec(evil)); +} + +console.log(''); +console.log('=== Pattern C (xapi-client.js:132) ==='); +const patternC = /\/+$/; +for (const n of [10000, 100000, 1000000]) { + const evil = '/'.repeat(n); + timeIt(` n=${n} (que des slashes)`, () => patternC.exec(evil)); +} + +console.log(''); +console.log('=== Verification d\'equivalence (entrees valides normales, comportement inchange attendu) ==='); +console.log('A sur "{{Objet.champ}}":', patternA.exec('{{Objet.champ}}')); +console.log('B sur "{{$var.champ[0].sous}}":', patternB.exec('{{$var.champ[0].sous}}')); +console.log('C sur "https://host///":', 'https://host///'.replace(patternC, '')); diff --git a/routes/scenes/scene_object_add.py b/routes/scenes/scene_object_add.py index a73e4919..023b7bc0 100644 --- a/routes/scenes/scene_object_add.py +++ b/routes/scenes/scene_object_add.py @@ -2,6 +2,7 @@ from flask import abort, g, jsonify, redirect, request, url_for from werkzeug.wrappers import Response import auth +import db import screens from core.flask_app import app from core.sprite_gate import forbid_admin_only_background, forbid_admin_only_character @@ -94,11 +95,11 @@ def scene_object_add(slug: str, screen_id: int) -> Response: # _clamp_to_camera pour l'IA, ici pour un dépôt manuel. obj_for_clamp = screens.get_scene_object(slug, new_id) screen = screens.get_screen(slug, screen_id) - assert obj_for_clamp is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction - "new_id vient d'etre cree par screens.add_scene_object juste au-dessus" + obj_for_clamp = db.assert_not_none( + obj_for_clamp, "new_id vient d'etre cree par screens.add_scene_object juste au-dessus" ) - assert screen is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction - "screen_id valide : add_scene_object aurait leve IntegrityError sinon (FK scene_id)" + screen = db.assert_not_none( + screen, "screen_id valide : add_scene_object aurait leve IntegrityError sinon (FK scene_id)" ) max_x = max(0, screen["scene_width"] - obj_for_clamp["width"]) max_y = max(0, screen["scene_height"] - obj_for_clamp["height"]) @@ -110,9 +111,7 @@ def scene_object_add(slug: str, screen_id: int) -> Response: ) if request.headers.get("X-Requested-With") == "fetch": obj = screens.get_scene_object(slug, new_id) - assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction - "new_id vient d'etre cree par screens.add_scene_object plus haut" - ) + obj = db.assert_not_none(obj, "new_id vient d'etre cree par screens.add_scene_object plus haut") return jsonify( { "id": new_id, diff --git a/routes/scenes/scene_object_collision.py b/routes/scenes/scene_object_collision.py index f0784c93..f468152d 100644 --- a/routes/scenes/scene_object_collision.py +++ b/routes/scenes/scene_object_collision.py @@ -3,6 +3,7 @@ from typing import Any from flask import jsonify, request from werkzeug.wrappers import Response +import db import screens from core.flask_app import app @@ -33,7 +34,5 @@ def scene_object_collision(slug: str, object_id: int) -> Response | tuple[Respon screens.set_scene_object_collision(slug, object_id, settings) obj = screens.get_scene_object(slug, object_id) - assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction - "object_id valide (deja lu ci-dessus, rien ne l'a supprime entre-temps)" - ) + obj = db.assert_not_none(obj, "object_id valide (deja lu ci-dessus, rien ne l'a supprime entre-temps)") return jsonify({"ok": True, "collision": screens.resolve_collision_settings(obj)}) diff --git a/routes/scenes/scene_object_geometry.py b/routes/scenes/scene_object_geometry.py index b3dc107e..cbc8a7b0 100644 --- a/routes/scenes/scene_object_geometry.py +++ b/routes/scenes/scene_object_geometry.py @@ -1,6 +1,7 @@ from flask import jsonify, request from werkzeug.wrappers import Response +import db import screens from core.flask_app import app @@ -34,8 +35,8 @@ def scene_object_geometry(slug: str, object_id: int) -> Response | tuple[Respons # voir iaChatRefreshAfterTurn() dans static/js/ia/ia-chat.js), rendant # injoignable à la souris tout ce qui dépassait la scène nominale. screen = screens.get_screen(slug, obj["scene_id"]) - assert screen is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction - "obj existe (verifie plus haut) et _scene_objects.scene_id a ON DELETE CASCADE vers _screens" + screen = db.assert_not_none( + screen, "obj existe (verifie plus haut) et _scene_objects.scene_id a ON DELETE CASCADE vers _screens" ) objects = screens.list_scene_objects(slug, obj["scene_id"]) world_width, world_height = screens.resolve_scene_world_size(screen, objects) diff --git a/routes/scenes/scene_object_personnage_data.py b/routes/scenes/scene_object_personnage_data.py index 690e4e1f..6d4e9e2b 100644 --- a/routes/scenes/scene_object_personnage_data.py +++ b/routes/scenes/scene_object_personnage_data.py @@ -1,6 +1,7 @@ from flask import jsonify, request from werkzeug.wrappers import Response +import db import screens from core.flask_app import app from core.sprite_gate import forbid_admin_only_character @@ -22,7 +23,5 @@ def scene_object_personnage_data(slug: str, object_id: int) -> Response | tuple[ forbid_admin_only_character(body["forge_character"]) screens.set_scene_object_personnage_data(slug, object_id, body) obj = screens.get_scene_object(slug, object_id) - assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction - "object_id valide (deja lu ci-dessus, rien ne l'a supprime entre-temps)" - ) + obj = db.assert_not_none(obj, "object_id valide (deja lu ci-dessus, rien ne l'a supprime entre-temps)") return jsonify({"ok": True, "animations": screens.resolve_personnage_animations(obj)}) diff --git a/routes/scenes/scene_object_quiz_config.py b/routes/scenes/scene_object_quiz_config.py index b08c5e74..441b31a4 100644 --- a/routes/scenes/scene_object_quiz_config.py +++ b/routes/scenes/scene_object_quiz_config.py @@ -1,6 +1,7 @@ from flask import jsonify, request from werkzeug.wrappers import Response +import db import screens from core.flask_app import app @@ -20,7 +21,5 @@ def scene_object_quiz_config(slug: str, object_id: int) -> Response | tuple[Resp # coup, plutôt que de deviner quelle balise ajouter/retirer côté # client. obj = screens.get_scene_object(slug, object_id) - assert obj is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction - "object_id valide : set_scene_object_quiz_config a renvoye une config non-None" - ) + obj = db.assert_not_none(obj, "object_id valide : set_scene_object_quiz_config a renvoye une config non-None") return jsonify({"config": config, "rendered_html": screens.render_scene_object(obj)}) diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index c6887190..8da89a66 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -136,8 +136,8 @@ def full_game_payload(slug: str, player_id: str = db.PLAYER_SHARED) -> dict[str, fields_meta = {} for d in definitions: full = db.get_definition(slug, d["id"]) - assert full is not None, ( # nosec B101 # noqa: S101 - narrowing de type, sur par construction - "d['id'] vient de list_definitions(slug), donc get_definition ne peut pas renvoyer None ici" + full = db.assert_not_none( + full, "d['id'] vient de list_definitions(slug), donc get_definition ne peut pas renvoyer None ici" ) rows = db.list_rows(slug, full, player_id) # Un champ "relation" vit dans une colonne SQL "_id", jamais diff --git a/scripts/build_demo_dialogues.py b/scripts/build_demo_dialogues.py index 0ae9732c..b59d0408 100644 --- a/scripts/build_demo_dialogues.py +++ b/scripts/build_demo_dialogues.py @@ -52,6 +52,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from flask.testing import FlaskClient from werkzeug.test import TestResponse +import db import routes # noqa: F401 - enregistre toutes les routes from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja from core.flask_app import app @@ -59,8 +60,9 @@ from core.flask_app import app def _redirect_id(resp: TestResponse, pattern: str) -> int: match = re.search(pattern, resp.headers["Location"]) # NOSONAR S8371 - Location attendue, KeyError = echec clair - assert match is not None, ( # nosec B101 # noqa: S101 - script d'usage unique, jamais execute en production - f"motif {pattern!r} absent de Location: {resp.headers['Location']!r}" # NOSONAR S8371 - idem + match = db.assert_not_none( + match, + f"motif {pattern!r} absent de Location: {resp.headers['Location']!r}", # NOSONAR S8371 - idem ) return int(match.group(1)) diff --git a/static/js/ia/ia-chat.js b/static/js/ia/ia-chat.js index 4ff2714a..96059f26 100644 --- a/static/js/ia/ia-chat.js +++ b/static/js/ia/ia-chat.js @@ -106,8 +106,8 @@ function iaChatRefreshAfterTurn() { // dessus plutôt que de rejouer un script inline. const canvas = document.getElementById('canvas'); if (canvas) { - const newWidth = parseFloat(canvas.style.width); - const newHeight = parseFloat(canvas.style.height); + const newWidth = Number.parseFloat(canvas.style.width); + const newHeight = Number.parseFloat(canvas.style.height); if (!Number.isNaN(newWidth)) window.SCENE_WIDTH = newWidth; if (!Number.isNaN(newHeight)) window.SCENE_HEIGHT = newHeight; } @@ -225,7 +225,7 @@ function iaChatLoadMessages(conversationId) { fetch(`/game/${GAME_SLUG}/ia-conversations/${conversationId}/messages`) .then((r) => r.json()) .then((messages) => { - if (!messages || !messages.length) { + if (!messages?.length) { log.innerHTML = iaChatEmptyStateHtml(); return; } diff --git a/static/js/play/__tests__/actions.test.js b/static/js/play/__tests__/actions.test.js index 80be2ed7..7aa4e7b0 100644 --- a/static/js/play/__tests__/actions.test.js +++ b/static/js/play/__tests__/actions.test.js @@ -11,13 +11,32 @@ global.window = global.window || {}; global.gameData = global.gameData || {}; const { resolveSpriteFrames, runSpriteAnimation, stopAllSpriteAnimations, activeSpriteAnimations, - applyObjectProperty, clampSceneObjectPosition, + applyElementProperty, applyObjectProperty, clampSceneObjectPosition, runActionNode, } = require('../actions.js'); function fakeImg() { return { src: '' }; } +// Mock DOM minimal pour applyElementProperty() — classList/style/dataset +// réels (Set + objet), pas juste des espions, pour pouvoir vérifier l'état +// après coup (ex. "la classe a bien été retirée", pas seulement "la +// méthode a été appelée"). +function fakeElement() { + const classes = new Set(); + return { + textContent: '', + style: { display: '', visibility: '' }, + dataset: {}, + classList: { + add: (c) => classes.add(c), + remove: (c) => classes.delete(c), + toggle: (c) => { if (classes.has(c)) classes.delete(c); else classes.add(c); }, + has: (c) => classes.has(c), + }, + }; +} + test('runSpriteAnimation — un appel répété avec la même animation ne redémarre pas', () => { const el = fakeImg(); const spriteData = { frames: ['/a0.png', '/a1.png', '/a2.png'], fps: 8, loop: true }; @@ -89,6 +108,113 @@ test('resolveSpriteFrames — résout depuis gameData.personnage_animations par // maintenue, voir triggers.js) pouvait sortir du cadre de la scène, rien ne // l'empêchant. applyObjectProperty()/clampSceneObjectPosition() le bornent // désormais à [0, scene_width/height - sa propre taille]. +// Couverture de TOUTES les branches d'applyElementProperty() (39 -> lot 4 +// "modernisation JS", S3776) AVANT tout refactor, pour garantir que +// l'extraction en sous-fonctions ne change aucun comportement — voir +// CODE_QUALITY.md. +test('applyElementProperty — "contenu" : texte littéral, null/undefined -> vidé', () => { + const el = fakeElement(); + applyElementProperty(el, 'contenu', 'Bonjour'); + assert.equal(el.textContent, 'Bonjour'); + applyElementProperty(el, 'contenu', null); + assert.equal(el.textContent, ''); +}); + +test('applyElementProperty — "surbrillance" : toggle/off/valeur par défaut (activer)', () => { + const el = fakeElement(); + applyElementProperty(el, 'surbrillance', 'toggle'); + assert.ok(el.classList.has('forgeHighlight')); + applyElementProperty(el, 'surbrillance', 'toggle'); + assert.ok(!el.classList.has('forgeHighlight')); + applyElementProperty(el, 'surbrillance', 'on'); + assert.ok(el.classList.has('forgeHighlight')); + applyElementProperty(el, 'surbrillance', 'off'); + assert.ok(!el.classList.has('forgeHighlight')); +}); + +test('applyElementProperty — "desactive" : toggle/off/valeur par défaut (désactiver)', () => { + const el = fakeElement(); + applyElementProperty(el, 'desactive', 'toggle'); + assert.ok(el.classList.has('forgeDisabled')); + applyElementProperty(el, 'desactive', 'toggle'); + assert.ok(!el.classList.has('forgeDisabled')); + applyElementProperty(el, 'desactive', 'on'); + assert.ok(el.classList.has('forgeDisabled')); + applyElementProperty(el, 'desactive', 'off'); + assert.ok(!el.classList.has('forgeDisabled')); +}); + +test('applyElementProperty — "visibilite" : toggle (selon état actuel), masque, invisible, valeur par défaut (visible)', () => { + const el = fakeElement(); + applyElementProperty(el, 'visibilite', 'toggle'); + assert.equal(el.style.display, 'none'); + applyElementProperty(el, 'visibilite', 'toggle'); + assert.equal(el.style.display, ''); + applyElementProperty(el, 'visibilite', 'masque'); + assert.equal(el.style.display, 'none'); + applyElementProperty(el, 'visibilite', 'invisible'); + assert.equal(el.style.display, ''); + assert.equal(el.style.visibility, 'hidden'); + applyElementProperty(el, 'visibilite', 'visible'); + assert.equal(el.style.display, ''); + assert.equal(el.style.visibility, ''); +}); + +test('applyElementProperty — "orientation" : toggle/gauche/valeur par défaut (droite)', () => { + const el = fakeElement(); + applyElementProperty(el, 'orientation', 'toggle'); + assert.ok(el.classList.has('forgeFlipped')); + applyElementProperty(el, 'orientation', 'toggle'); + assert.ok(!el.classList.has('forgeFlipped')); + applyElementProperty(el, 'orientation', 'gauche'); + assert.ok(el.classList.has('forgeFlipped')); + applyElementProperty(el, 'orientation', 'droite'); + assert.ok(!el.classList.has('forgeFlipped')); +}); + +test('applyElementProperty — "pos_x_relatif"/"pos_y_relatif" : ajoute au pixel/pourcentage actuel, ignore une valeur non numérique', () => { + const el = fakeElement(); + el.style.left = '10%'; + applyElementProperty(el, 'pos_x_relatif', '5'); + assert.equal(el.style.left, '15%'); + el.style.top = '20%'; + applyElementProperty(el, 'pos_y_relatif', '-5'); + assert.equal(el.style.top, '15%'); + applyElementProperty(el, 'pos_x_relatif', 'pas-un-nombre'); + assert.equal(el.style.left, '15%', 'valeur non numérique : aucun changement'); +}); + +test('applyElementProperty — propriété inconnue ou valeur null/undefined : aucun effet', () => { + const el = fakeElement(); + applyElementProperty(el, 'propriete_qui_n_existe_pas', 'x'); + applyElementProperty(el, 'width', undefined); + applyElementProperty(el, 'width', null); + assert.equal(el.style.width, undefined, 'jamais touché'); +}); + +test('applyElementProperty — valeur "a||b" (va-et-vient) : bascule à chaque appel, persiste via dataset', () => { + // Premier appel (dataset vide, "pas encore à la 2e valeur") -> bascule + // sur la SECONDE valeur (parts[1]) et mémorise l'état "à la 2e" (voir + // le code : atSecond=false -> next=parts[1], dataset mis à '1'). + const el = fakeElement(); + applyElementProperty(el, 'width', '10||90'); + assert.equal(el.style.width, '90%'); + assert.equal(el.dataset.togglewidth, '1'); + applyElementProperty(el, 'width', '10||90'); + assert.equal(el.style.width, '10%'); + assert.equal(el.dataset.togglewidth, '0'); + applyElementProperty(el, 'width', '10||90'); + assert.equal(el.style.width, '90%'); +}); + +test('applyElementProperty — valeur directe (pas de "||") : assignation simple avec suffixe', () => { + const el = fakeElement(); + applyElementProperty(el, 'width', '42'); + assert.equal(el.style.width, '42%'); + applyElementProperty(el, 'bg_color', '#ff0000'); + assert.equal(el.style.backgroundColor, '#ff0000'); +}); + function withScreensData(screenData, fn) { const previousScreens = global.screensData; const previousId = global.window.currentScreenId; @@ -97,6 +223,276 @@ function withScreensData(screenData, fn) { try { fn(); } finally { global.screensData = previousScreens; global.window.currentScreenId = previousId; } } +// Couverture de TOUTES les branches de runActionNode() (58 -> lot 4 +// "modernisation JS", S3776) AVANT tout refactor — un seul appel par +// action_type, avec des dépendances globales (showScreen, fetch, +// FORGE_PLAY_URLS, les fonctions forgeApply*Offline...) remplacées par +// des espions qui enregistrent l'appel plutôt que par leur vraie +// implémentation (déjà testée ailleurs : screens.js, apply-actions.js). +// Objectif : garantir que l'extraction en sous-fonctions n'envoie pas le +// mauvais argument ou n'appelle pas la mauvaise fonction — pas retester +// le comportement de ces dépendances elles-mêmes. +const ACTION_GLOBAL_KEYS = [ + 'showScreen', 'screenIndexById', 'applyOpenRowBindings', 'bindClicks', 'bindHoverTexts', 'bindHoverTriggers', + 'refreshRuntimeData', 'forgeApplyDataActionOffline', 'forgeApplyVariableActionOffline', + 'forgeApplyScoreActionOffline', 'forgeApplyStatusActionOffline', 'forgeApplyAddRowActionOffline', + 'FORGE_PLAY_URLS', 'fetch', 'Audio', +]; + +// `fn` peut être async (branches qui font un vrai fetch) — restaurer les +// globales seulement APRÈS la résolution de son résultat (via .finally() +// sur la promesse renvoyée, pas un await bloquant ici) est obligatoire : +// un simple `try/finally` synchrone les restaurait pendant que la chaîne +// .then() de runActionNode tournait encore (bug rencontré en écrivant ce +// test : "refreshRuntimeData is not a function" alors que le stub était +// bien posé — juste déjà retiré par le moment où le .then() s'exécutait). +// Reste appelable SANS await pour les cas synchrones (pas de changement +// de signature pour la majorité des tests déjà écrits). +function withActionNodeGlobals(fn) { + const calls = []; + const previous = {}; + ACTION_GLOBAL_KEYS.forEach((key) => { previous[key] = global[key]; }); + const previousDispatch = global.window.dispatchGameEvent; + const previousOffline = global.window.FORGE_OFFLINE; + + global.showScreen = (id) => calls.push(['showScreen', id]); + global.screenIndexById = (id) => global.screensData.findIndex((s) => s.id === id); + global.applyOpenRowBindings = () => calls.push(['applyOpenRowBindings']); + global.bindClicks = () => calls.push(['bindClicks']); + global.bindHoverTexts = () => calls.push(['bindHoverTexts']); + global.bindHoverTriggers = () => calls.push(['bindHoverTriggers']); + global.window.dispatchGameEvent = (id) => calls.push(['dispatchGameEvent', id]); + global.refreshRuntimeData = () => { calls.push(['refreshRuntimeData']); return Promise.resolve('refreshed'); }; + global.forgeApplyDataActionOffline = (gd, node) => calls.push(['forgeApplyDataActionOffline', node.id]); + global.forgeApplyVariableActionOffline = (gd, node) => calls.push(['forgeApplyVariableActionOffline', node.id]); + global.forgeApplyScoreActionOffline = (gd, node) => calls.push(['forgeApplyScoreActionOffline', node.id]); + global.forgeApplyStatusActionOffline = (gd, node) => calls.push(['forgeApplyStatusActionOffline', node.id]); + global.forgeApplyAddRowActionOffline = (gd, node) => { calls.push(['forgeApplyAddRowActionOffline', node.id]); return 4242; }; + global.FORGE_PLAY_URLS = { + flowNodeRunData: '/run-data/999999', + flowNodeRunVariable: '/run-variable/999999', + flowNodeRunScore: '/run-score/999999', + flowNodeRunStatus: '/run-status/999999', + flowNodeRunAddRow: '/run-add-row/999999', + }; + global.fetch = (url, opts) => { + calls.push(['fetch', url, (opts && opts.method) || 'GET']); + return Promise.resolve({ json: () => Promise.resolve({ row_id: 4343 }) }); + }; + global.Audio = function FakeAudio(src) { + calls.push(['Audio', src]); + return { play: () => ({ catch: () => {} }) }; + }; + + const restore = () => { + ACTION_GLOBAL_KEYS.forEach((key) => { global[key] = previous[key]; }); + global.window.dispatchGameEvent = previousDispatch; + global.window.FORGE_OFFLINE = previousOffline; + }; + + let result; + try { + result = fn(calls); + } catch (e) { + restore(); + throw e; + } + if (result && typeof result.then === 'function') { + return result.finally(restore); + } + restore(); + return result; +} + +test('runActionNode — ecran_suivant : avance si un écran suivant existe, ne fait rien en fin de liste', () => { + withScreensData({ id: 1 }, () => { + global.screensData = [{ id: 1 }, { id: 2 }]; + withActionNodeGlobals((calls) => { + runActionNode({ action_type: 'ecran_suivant' }); + assert.deepEqual(calls, [['showScreen', 2]]); + }); + global.window.currentScreenId = 2; + withActionNodeGlobals((calls) => { + runActionNode({ action_type: 'ecran_suivant' }); + assert.deepEqual(calls, [], 'déjà le dernier écran : aucun changement'); + }); + }); +}); + +test('runActionNode — ecran_precedent : recule si un écran précédent existe, ne fait rien en tête de liste', () => { + global.screensData = [{ id: 1 }, { id: 2 }]; + global.window.currentScreenId = 2; + withActionNodeGlobals((calls) => { + runActionNode({ action_type: 'ecran_precedent' }); + assert.deepEqual(calls, [['showScreen', 1]]); + }); + global.window.currentScreenId = 1; + withActionNodeGlobals((calls) => { + runActionNode({ action_type: 'ecran_precedent' }); + assert.deepEqual(calls, [], 'déjà le premier écran : aucun changement'); + }); +}); + +test('runActionNode — aller_a : montre l\'écran cible si présent, ignore sans target_screen_id', () => { + withActionNodeGlobals((calls) => { + runActionNode({ action_type: 'aller_a', target_screen_id: 7 }); + assert.deepEqual(calls, [['showScreen', 7]]); + }); + withActionNodeGlobals((calls) => { + runActionNode({ action_type: 'aller_a' }); + assert.deepEqual(calls, []); + }); +}); + +test('runActionNode — ouvrir_ligne : mémorise la ligne cliquée, affiche l\'écran, rebranche les écouteurs', () => { + global.window.lastClickedRowId = 5; + global.window.lastClickedDefinitionId = 9; + withActionNodeGlobals((calls) => { + runActionNode({ action_type: 'ouvrir_ligne', target_screen_id: 3 }); + assert.deepEqual(global.window.currentOpenRow, { definitionId: 9, rowId: 5 }); + assert.deepEqual(calls, [['showScreen', 3], ['applyOpenRowBindings'], ['bindClicks'], ['bindHoverTexts'], ['bindHoverTriggers']]); + }); +}); + +test('runActionNode — modifier_element : applique la propriété sur l\'élément trouvé, ignore si absent', () => { + const el = fakeElement(); + global.document = { querySelector: (sel) => (sel.includes('"5"') ? el : null) }; + runActionNode({ + action_type: 'modifier_element', target_element_id: 5, element_property: 'contenu', element_value: 'Salut', + }); + assert.equal(el.textContent, 'Salut'); + // Aucune erreur levée quand l'élément n'existe pas (id différent -> querySelector renvoie null). + runActionNode({ + action_type: 'modifier_element', target_element_id: 999, element_property: 'contenu', element_value: 'x', + }); +}); + +test('runActionNode — modifier_objet_scene : applique la propriété sur l\'objet trouvé (par target_element_id ou target_object_id)', () => { + const el = fakeElement(); + el.style.width = '10%'; + global.document = { querySelector: () => el }; + runActionNode({ + action_type: 'modifier_objet_scene', target_object_id: 8, element_property: 'contenu', element_value: 'Objet', + }); + assert.equal(el.textContent, 'Objet'); +}); + +test('runActionNode — declencher_evenement : notifie l\'événement personnalisé ciblé', () => { + withActionNodeGlobals((calls) => { + runActionNode({ action_type: 'declencher_evenement', target_custom_event_id: 12 }); + assert.deepEqual(calls, [['dispatchGameEvent', 12]]); + }); +}); + +test('runActionNode — activer_onglet : montre l\'élément visé, masque ses frères directs, ignore les autres', () => { + const elTarget = fakeElement(); + const elSibling = fakeElement(); + elSibling.style.display = ''; + const els = { 1: elTarget, 2: elSibling }; + global.document = { querySelector: (sel) => els[sel.match(/"(\d+)"/)[1]] }; + withScreensData({ + id: 1, + elements: [{ id: 1, parent_id: null }, { id: 2, parent_id: null }, { id: 3, parent_id: 99 }], + }, () => { + runActionNode({ action_type: 'activer_onglet', target_element_id: 1 }); + }); + assert.equal(elTarget.style.display, ''); + assert.equal(elSibling.style.display, 'none'); +}); + +test('runActionNode — attendre : résout après le délai (data_value en secondes), 0 si non numérique', async () => { + const t0 = Date.now(); + await runActionNode({ action_type: 'attendre', data_value: '0.01' }); + assert.ok(Date.now() - t0 >= 5, 'doit avoir réellement attendu'); + await runActionNode({ action_type: 'attendre', data_value: 'pas-un-nombre' }); +}); + +test('runActionNode — modifier_donnee : hors ligne délègue à forgeApplyDataActionOffline, en ligne POST vers flowNodeRunData', async () => { + global.window.FORGE_OFFLINE = true; + withActionNodeGlobals((calls) => { + runActionNode({ action_type: 'modifier_donnee', id: 11 }); + assert.deepEqual(calls, [['forgeApplyDataActionOffline', 11], ['refreshRuntimeData']]); + }); + global.window.FORGE_OFFLINE = false; + await withActionNodeGlobals(async (calls) => { + await runActionNode({ action_type: 'modifier_donnee', id: 999999 }); + assert.equal(calls[0][0], 'fetch'); + assert.equal(calls[0][1], '/run-data/999999'); + assert.equal(calls[0][2], 'POST'); + }); +}); + +test('runActionNode — modifier_variable : hors ligne délègue à forgeApplyVariableActionOffline, en ligne POST vers flowNodeRunVariable', async () => { + global.window.FORGE_OFFLINE = true; + withActionNodeGlobals((calls) => { + runActionNode({ action_type: 'modifier_variable', id: 22 }); + assert.deepEqual(calls, [['forgeApplyVariableActionOffline', 22], ['refreshRuntimeData']]); + }); + global.window.FORGE_OFFLINE = false; + await withActionNodeGlobals(async (calls) => { + await runActionNode({ action_type: 'modifier_variable', id: 999999 }); + assert.equal(calls[0][1], '/run-variable/999999'); + }); +}); + +test('runActionNode — modifier_score : hors ligne délègue à forgeApplyScoreActionOffline, en ligne POST vers flowNodeRunScore', async () => { + global.window.FORGE_OFFLINE = true; + withActionNodeGlobals((calls) => { + runActionNode({ action_type: 'modifier_score', id: 33 }); + assert.deepEqual(calls, [['forgeApplyScoreActionOffline', 33], ['refreshRuntimeData']]); + }); + global.window.FORGE_OFFLINE = false; + await withActionNodeGlobals(async (calls) => { + await runActionNode({ action_type: 'modifier_score', id: 999999 }); + assert.equal(calls[0][1], '/run-score/999999'); + }); +}); + +test('runActionNode — definir_statut_partie : hors ligne délègue à forgeApplyStatusActionOffline, en ligne POST vers flowNodeRunStatus', async () => { + global.window.FORGE_OFFLINE = true; + withActionNodeGlobals((calls) => { + runActionNode({ action_type: 'definir_statut_partie', id: 44 }); + assert.deepEqual(calls, [['forgeApplyStatusActionOffline', 44], ['refreshRuntimeData']]); + }); + global.window.FORGE_OFFLINE = false; + await withActionNodeGlobals(async (calls) => { + await runActionNode({ action_type: 'definir_statut_partie', id: 999999 }); + assert.equal(calls[0][1], '/run-status/999999'); + }); +}); + +test('runActionNode — ajouter_ligne : hors ligne mémorise l\'id renvoyé, en ligne POST puis mémorise json.row_id', async () => { + global.window.FORGE_OFFLINE = true; + withActionNodeGlobals((calls) => { + runActionNode({ action_type: 'ajouter_ligne', id: 55 }); + assert.equal(global.window.lastInsertedRowId, 4242); + assert.deepEqual(calls, [['forgeApplyAddRowActionOffline', 55], ['refreshRuntimeData']]); + }); + global.window.FORGE_OFFLINE = false; + await withActionNodeGlobals(async (calls) => { + await runActionNode({ action_type: 'ajouter_ligne', id: 999999 }); + assert.equal(global.window.lastInsertedRowId, 4343); + assert.equal(calls[0][1], '/run-add-row/999999'); + }); +}); + +test('runActionNode — jouer_son : joue le fichier audio (fire-and-forget), aucun effet sans data_value', () => { + withActionNodeGlobals((calls) => { + runActionNode({ action_type: 'jouer_son', data_value: '/bip.mp3' }); + assert.deepEqual(calls, [['Audio', '/bip.mp3']]); + }); + withActionNodeGlobals((calls) => { + runActionNode({ action_type: 'jouer_son' }); + assert.deepEqual(calls, []); + }); +}); + +test('runActionNode — action_type inconnu (ou "rien") : ne fait rien, renvoie une Promise résolue', async () => { + const result = runActionNode({ action_type: 'rien' }); + assert.ok(result instanceof Promise); + await result; +}); + test('clampSceneObjectPosition — borne au bord droit/bas de la scène', () => { withScreensData({ id: 1, scene_width: 200, scene_height: 100 }, () => { const el = { style: { width: '40px', height: '40px' } }; @@ -147,3 +543,27 @@ test('applyObjectProperty — pos_x_relatif reste dans les limites de la scène assert.equal(el.style.left, '80px'); // 100 - 20, jamais au-delà }); }); + +test('runActionNode — jouer_animation_sprite avec data_value JSON invalide : avertit en console, ne casse pas le rendu (bug S2486 : exception avalée en silence auparavant)', () => { + const el = fakeImg(); + const originalDocument = global.document; + const originalWarn = console.warn; + const warnCalls = []; + global.document = { querySelector: () => el }; + console.warn = (...args) => { warnCalls.push(args); }; + try { + const result = runActionNode({ + action_type: 'jouer_animation_sprite', + target_element_id: 'obj1', + data_value: '{not valid json', + }); + assert.ok(result instanceof Promise, 'doit quand même renvoyer une Promise, comme un data_value valide'); + assert.equal(el.src, '', 'aucune frame ne doit être posée — rendu inchangé, pas de crash'); + assert.equal(warnCalls.length, 1); + assert.match(warnCalls[0][0], /configuration sprite invalide/); + } finally { + global.document = originalDocument; + console.warn = originalWarn; + stopAllSpriteAnimations(); + } +}); diff --git a/static/js/play/__tests__/collision-dialogue-integration.test.js b/static/js/play/__tests__/collision-dialogue-integration.test.js index 5ed4afe8..8901f149 100644 --- a/static/js/play/__tests__/collision-dialogue-integration.test.js +++ b/static/js/play/__tests__/collision-dialogue-integration.test.js @@ -41,6 +41,11 @@ function fakeEl(style) { return undefined; }, getAttribute(attr) { return attr === 'data-object-id' ? this._objectId : null; }, + // Getter (pas une valeur figée à la construction) : recalculé à + // chaque lecture, comme le vrai `.dataset`, puisque `_objectId` est + // posé APRÈS coup par les tests (voir plus bas) — un `dataset: {}` + // gelé au moment de fakeEl() serait toujours vide. + get dataset() { return { objectId: this._objectId }; }, }; } diff --git a/static/js/play/__tests__/collision-rules-controller.test.js b/static/js/play/__tests__/collision-rules-controller.test.js index f4c5fb75..f64639d4 100644 --- a/static/js/play/__tests__/collision-rules-controller.test.js +++ b/static/js/play/__tests__/collision-rules-controller.test.js @@ -35,7 +35,7 @@ function fakeParentEl() { appendChild(child) { children.push(child); }, querySelector(sel) { const m = sel.match(/data-for-object-id="(\d+)"/); - return m ? children.find((c) => c._forObjectId === m[1]) : undefined; + return m ? children.find((c) => c.dataset.forObjectId === m[1]) : undefined; }, }; } @@ -53,12 +53,24 @@ function fakeEl(parentEl) { }; } +// Un vrai `.dataset` (DOMStringMap) convertit toujours la valeur assignée +// en chaîne — reproduit ici via un Proxy, sinon `bubble.dataset.forObjectId +// = objectId` avec un objectId numérique (cas réel : obj.id vient de la +// base) resterait un number, et ne correspondrait plus jamais à la +// capture de regex (toujours une string) dans fakeParentEl.querySelector(). +function makeDataset() { + const store = {}; + return new Proxy(store, { + set(target, key, value) { target[key] = String(value); return true; }, + }); +} + function fakeBubbleEl() { return { className: 'collisionInteractBubble', style: {}, textContent: '', - setAttribute(name, value) { if (name === 'data-for-object-id') this._forObjectId = String(value); }, + dataset: makeDataset(), }; } diff --git a/static/js/play/__tests__/dialogue-box-controller.test.js b/static/js/play/__tests__/dialogue-box-controller.test.js index ee82cf55..fb1800c5 100644 --- a/static/js/play/__tests__/dialogue-box-controller.test.js +++ b/static/js/play/__tests__/dialogue-box-controller.test.js @@ -20,6 +20,24 @@ const { forgeQuizResultQuit, } = require('../dialogue-box-controller.js'); +// Un vrai `.dataset` (DOMStringMap) lit/écrit les MÊMES attributs qu'un +// `getAttribute('data-foo-bar')`/`setAttribute(...)`, juste sous forme +// camelCase (`dataset.fooBar`) — ce Proxy reflète les deux dans le MÊME +// objet `attrs` passé en argument, pour que les mocks déjà construits par +// clé kebab-case (ex. `{ 'data-timer-mode': 'question' }`, déjà utilisé +// par plusieurs tests de ce fichier) continuent de marcher sans y +// toucher, tout en exposant `.dataset` comme le vrai DOM (voir +// dialogue-box-controller.js après le lot 4 "modernisation JS", S7761). +function kebabDataKey(camelProp) { + return `data-${camelProp.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)}`; +} +function makeDatasetFromAttrs(attrs) { + return new Proxy({}, { + get(_, prop) { return attrs[kebabDataKey(prop)]; }, + set(_, prop, value) { attrs[kebabDataKey(prop)] = String(value); return true; }, + }); +} + function fakeWidget(objectId) { const roles = { header: { textContent: '' }, @@ -27,8 +45,10 @@ function fakeWidget(objectId) { footer: { innerHTML: '' }, }; const classes = new Set(); + const attrs = { 'data-object-id': String(objectId) }; return { - getAttribute: (attr) => (attr === 'data-object-id' ? String(objectId) : null), + getAttribute: (attr) => (attr in attrs ? attrs[attr] : null), + dataset: makeDatasetFromAttrs(attrs), classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), @@ -57,7 +77,7 @@ function fakeWidget(objectId) { // couvrir le VRAI viewport qu'en s'échappant de ce conteneur, comme // static/js/play/video-overlay.js le fait déjà pour son propre overlay. test('forgeEscapeCameraForFullscreen déplace le widget en enfant de et retient l\'écran d\'origine', () => { - const screenEl = { getAttribute: (attr) => (attr === 'data-screen-id' ? '7' : null) }; + const screenEl = { dataset: { screenId: '7' } }; const attrs = {}; const classes = new Set(); const bodyChildren = []; @@ -65,7 +85,7 @@ test('forgeEscapeCameraForFullscreen déplace le widget en enfant de et r const widget = { parentNode: null, closest: (sel) => (sel === '.playScreen' ? screenEl : null), - setAttribute: (k, v) => { attrs[k] = v; }, + dataset: makeDatasetFromAttrs(attrs), classList: { add: (c) => classes.add(c) }, }; const previousDoc = global.document; @@ -74,7 +94,7 @@ test('forgeEscapeCameraForFullscreen déplace le widget en enfant de et r forgeEscapeCameraForFullscreen(widget); assert.equal(bodyChildren[0], widget); assert.equal(widget.parentNode, body); - assert.equal(attrs['data-owner-screen-id'], '7'); + assert.equal(widget.dataset.ownerScreenId, '7'); assert.equal(classes.has('quizBoxWidget--detachedForFullscreen'), true); } finally { global.document = previousDoc; } }); @@ -288,6 +308,7 @@ function fakeQuizChoiceBtn() { function fakeQuizWidget(objectId, choiceCount, attrs) { attrs = attrs || {}; + attrs['data-object-id'] = String(objectId); const roles = { header: { textContent: '' }, question: { textContent: '' }, @@ -312,13 +333,10 @@ function fakeQuizWidget(objectId, choiceCount, attrs) { // Simule ".closest('.playScreen')" (voir forgeEscapeCameraForFullscreen) : // un widget "posé sur l'écran courant" est toujours, au départ, // l'enfant d'un ".playScreen" fictif portant le même id d'écran. - closest: (sel) => (sel === '.playScreen' ? { getAttribute: (a) => (a === 'data-screen-id' ? String(window.currentScreenId) : null) } : null), - getAttribute: (attr) => { - if (attr === 'data-object-id') return String(objectId); - if (attr in attrs) return attrs[attr]; - return null; - }, + closest: (sel) => (sel === '.playScreen' ? { dataset: { screenId: String(window.currentScreenId) } } : null), + getAttribute: (attr) => (attr in attrs ? attrs[attr] : null), setAttribute: (k, v) => { attrs[k] = v; }, + dataset: makeDatasetFromAttrs(attrs), classList: { // Variadique (comme le vrai DOMTokenList.remove/add) : le moteur // appelle parfois .remove('is-active', 'is-fullscreen') d'un coup @@ -600,7 +618,6 @@ test('"Continuer →" est TOUJOURS présent dès la question (désactivé), jama test('une ligne "question" est affichée dans la boîte à QUIZ (header = nom de l\'objet déclencheur, corps = question+choix), pas la boîte de dialogue', () => { const dialogueWidget = fakeWidget(42); - dialogueWidget.getAttribute = (attr) => (attr === 'data-object-id' ? '42' : null); const quizWidget = fakeQuizWidget(99, 2); const previousDoc = global.document; global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget); @@ -948,7 +965,7 @@ test('forgeSyncAllDialoguesCompletionToScorm propage reussi/echoue (pas juste "t test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et le score gagné', () => { const dialogueWidget = fakeWidget(42); const quizWidget = { - getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null), + dataset: { objectId: '99' }, classList: { add: () => {}, remove: () => {}, toggle: () => {} }, querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }), querySelectorAll: () => [], @@ -979,7 +996,7 @@ test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n.*, indépendant de xAPI)', () => { const dialogueWidget = fakeWidget(42); const quizWidget = { - getAttribute: (attr) => (attr === 'data-object-id' ? '99' : null), + dataset: { objectId: '99' }, classList: { add: () => {}, remove: () => {}, toggle: () => {} }, querySelector: (sel) => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }), querySelectorAll: () => [], diff --git a/static/js/play/__tests__/quiz-box-templates.test.js b/static/js/play/__tests__/quiz-box-templates.test.js index c4afa9e9..70c8cc24 100644 --- a/static/js/play/__tests__/quiz-box-templates.test.js +++ b/static/js/play/__tests__/quiz-box-templates.test.js @@ -34,6 +34,7 @@ function fakeMangaWidget() { const classes = new Set(); return { getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; }, + dataset: { objectId: '42' }, querySelector(sel) { if (sel === '[data-quiz-role="header"]') return header; if (sel === '[data-manga-role="progress"]') return progress; @@ -139,6 +140,7 @@ function fakeClassiqueWidget() { const progress = { innerHTML: '' }; return { getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; }, + dataset: { objectId: '42' }, querySelector(sel) { if (sel === '[data-quiz-role="header"]') return header; if (sel === '[data-classique-role="progress"]') return progress; @@ -182,6 +184,7 @@ function fakeClassiqueWidgetForResult() { const headerTitle = { textContent: 'Formation Sécurité Incendie' }; return { getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; }, + dataset: { objectId: '42' }, querySelector(sel) { if (sel === '.classiqueContent') return content; if (sel === '[data-classique-role="result"]') return resultZone; diff --git a/static/js/play/actions.js b/static/js/play/actions.js index edd17f22..b10af57e 100644 --- a/static/js/play/actions.js +++ b/static/js/play/actions.js @@ -20,104 +20,130 @@ const ELEMENT_PROPERTY_CSS = { pos_y: { css: 'top', suffix: '%' }, }; +// Voir ELEMENT_ACTION_PROPERTIES ("contenu") : texte littéral, pas de +// binding vers un champ d'objet ici (contrairement à +// applyOpenRowBindings, propre à "Ouvrir la ligne cliquée"). Comme pour +// les autres réglages ci-dessous, cible n'importe quel élément — pointer +// ceci vers un conteneur remplacerait tout son contenu imbriqué, à éviter. +function applyContenuProperty(targetEl, value) { + targetEl.textContent = value == null ? '' : value; +} + +// 3.3 (Confort) — surbrillance générique dynamique : un liseré qui +// clignote (voir la classe .forgeHighlight et son animation dans le +//