From 11f846d3940525746a7b36f4bd6f8f8e2ef174e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Fri, 16 Oct 2026 22:55:54 +0200 Subject: [PATCH] =?UTF-8?q?fix(labs):=20les=20messages=20d'=C3=A9chec=20en?= =?UTF-8?q?=20EN=20+=20FR,=20et=20leurs=20accents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chaque message qu'un test fait lire à l'apprenant porte désormais ses deux langues, par le helper `message(en=…, fr=…)` du `conftest.py` du catalogue. Le helper reste ici et jamais dans dsoxlab : un lab doit rester jouable par un `pytest` nu, sans le moteur, et c'est le test de non-couplage. Deux leçons de la campagne valent d'être écrites. La première : `pytest.fail(...)` dit exactement la même chose à l'apprenant qu'un `assert`, et ne compter que le second laissait 256 messages hors de la mesure, dont 220 pour un seul catalogue. Trois catalogues étaient déclarés « entièrement bilingues » à tort. La seconde : la forme ne prouve rien. Un catalogue affichait 93 messages bilingues sur 93 dont les 93 rendaient un `NameError`, le nom du helper ayant atterri dans un commentaire `noqa` au lieu de l'import. Quatre autres portaient une variable locale `message` qui masquait le helper pour toute la fonction, dont un cas que `ruff F823` ne voit pas : l'appel placé APRÈS l'affectation. `dsoxlab instructor audit` mesure désormais si l'appel peut s'exécuter. Les accents suivent la même règle : la réécriture est refusée si sa version sans accent diffère de l'original, si bien qu'elle ne peut rien changer d'autre que des signes diacritiques. Un mot reformulé, un identifiant touché, une interpolation perdue sont rejetés avant écriture, et le garde-fou a servi deux fois. Co-Authored-By: Claude Opus 5 --- conftest.py | 21 ++++ .../challenge/tests/test_functional.py | 92 +++++++++----- .../challenge/tests/test_functional.py | 113 ++++++++++++------ .../challenge/tests/test_functional.py | 103 ++++++++++------ .../challenge/tests/test_functional.py | 50 +++++--- .../challenge/tests/test_functional.py | 79 ++++++++---- .../challenge/tests/test_functional.py | 38 ++++-- .../challenge/tests/test_functional.py | 41 +++++-- 8 files changed, 371 insertions(+), 166 deletions(-) diff --git a/conftest.py b/conftest.py index e396537..4d7dbe8 100644 --- a/conftest.py +++ b/conftest.py @@ -47,6 +47,27 @@ OUTIL = "security_check.py" +def message(en: str, fr: str) -> str: + """Le message d'une assertion, dans la langue de l'apprenant. + + Quand un test échoue, `dsoxlab check` affiche le **message écrit par + l'auteur** : c'est la seule consigne que l'apprenant reçoit à cet instant. + Tout le reste du catalogue existe en deux versions (`README.md` et + `README.fr.md`) ; ce fichier-ci ne le peut pas, le contrat imposant le nom + `challenge/tests/test_functional.py`. Il suit donc le modèle de + `hints.yaml`, qui porte lui aussi ses deux langues dans un seul fichier. + + Le helper vit ici, et non dans dsoxlab : un test de lab doit rester + jouable par un simple `pytest`, sans l'outil installé. dsoxlab ne + reconnaît que la **forme** de l'appel, et `dsoxlab instructor audit` + compte les messages qui la portent. + + La langue vient de `DSOXLAB_LANG`, comme pour le reste de l'outil, et + l'anglais est le défaut. + """ + return fr if os.environ.get("DSOXLAB_LANG", "en").lower().startswith("fr") else en + + def workdir_lab(fichier_test: str | Path) -> Path: """Le répertoire de travail du lab dont `fichier_test` est le test.""" return Path(fichier_test).resolve().parent.parent / "work" diff --git a/labs/automatisation-code-de-sortie/challenge/tests/test_functional.py b/labs/automatisation-code-de-sortie/challenge/tests/test_functional.py index b0f2873..626603a 100644 --- a/labs/automatisation-code-de-sortie/challenge/tests/test_functional.py +++ b/labs/automatisation-code-de-sortie/challenge/tests/test_functional.py @@ -28,6 +28,7 @@ findings_au_hasard, lancer_outil, lire_resume, + message, tirage, workdir_lab, ) @@ -57,17 +58,23 @@ def _code_attendu(severites: list[str], seuil: str) -> int: def test_un_critique_arrete_le_pipeline(projet: Path, tmp_path: Path) -> None: rapport = _rapport(tmp_path, ["CRITICAL", "HIGH", "LOW"]) execution = lancer_outil(projet, str(rapport), "--fail-on", "CRITICAL") - assert execution.code == 1, ( - "Une vulnérabilité CRITICAL avec `--fail-on CRITICAL` : le code doit " - f"être 1, celui qui arrête le job.\n {execution.resume()}" + assert execution.code == 1, message( + en="A CRITICAL vulnerability with `--fail-on CRITICAL`: the code must be " + f"1, the one that stops the job.\n {execution.resume()}", + fr="Une vulnérabilité CRITICAL avec `--fail-on CRITICAL` : le code doit " + f"être 1, celui qui arrête le job.\n {execution.resume()}", ) - assert lire_resume(execution.sortie) == compter(["CRITICAL", "HIGH", "LOW"]), ( - "Le résumé reste affiché quand le seuil est atteint : c'est ce que " - f"l'équipe lit dans le journal du job pour savoir pourquoi.\n {execution.resume()}" + assert lire_resume(execution.sortie) == compter(["CRITICAL", "HIGH", "LOW"]), message( + en="The summary stays on screen when the threshold is reached: it is what " + f"the team reads in the job's log to know why.\n {execution.resume()}", + fr="Le résumé reste affiché quand le seuil est atteint : c'est ce que " + f"l'équipe lit dans le journal du job pour savoir pourquoi.\n {execution.resume()}", ) - assert "1" in execution.erreurs and "Traceback" not in execution.erreurs, ( - "La sortie d'erreur dit combien de vulnérabilités atteignent le seuil, " - f"en une ligne, sans trace Python.\n {execution.resume()}" + assert "1" in execution.erreurs and "Traceback" not in execution.erreurs, message( + en="The error output says how many vulnerabilities reach the threshold, " + f"in one line, with no Python traceback.\n {execution.resume()}", + fr="La sortie d'erreur dit combien de vulnérabilités atteignent le seuil, " + f"en une ligne, sans trace Python.\n {execution.resume()}", ) @@ -80,26 +87,36 @@ def test_le_seuil_compte_tout_ce_qui_est_au_dessus(projet: Path, tmp_path: Path) for seuil in SEUILS: attendu = _code_attendu(severites, seuil) execution = lancer_outil(projet, str(rapport), "--fail-on", seuil) - assert execution.code == attendu, ( - f"Graine {graine}, `--fail-on {seuil}` : code {execution.code}, attendu {attendu}.\n" + assert execution.code == attendu, message( + en=f"Seed {graine}, `--fail-on {seuil}`: code {execution.code}, expected {attendu}.\n" + f" severities in the report: {compter(severites)}\n\n" + "The threshold is inclusive and counts everything AT LEAST as " + "serious: `--fail-on HIGH` fails on a CRITICAL. UNKNOWN reaches " + "no threshold.\n" + f" {execution.resume()}", + fr=f"Graine {graine}, `--fail-on {seuil}` : code {execution.code}, attendu {attendu}.\n" f" sévérités du rapport : {compter(severites)}\n\n" "Le seuil est inclusif et compte tout ce qui est AU MOINS aussi grave : " "`--fail-on HIGH` échoue sur un CRITICAL. UNKNOWN n'atteint aucun seuil.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) def test_sous_le_seuil_ou_sans_seuil_le_pipeline_continue(projet: Path, tmp_path: Path) -> None: rapport = _rapport(tmp_path, ["MEDIUM", "LOW", "UNKNOWN", "medium"]) avec = lancer_outil(projet, str(rapport), "--fail-on", "high") - assert avec.code == 0, ( - "Rien n'atteint HIGH (le seuil s'écrit aussi en minuscules) : code 0.\n" - f" {avec.resume()}" + assert avec.code == 0, message( + en="Nothing reaches HIGH (the threshold is also accepted in lower case): " + f"code 0.\n {avec.resume()}", + fr="Rien n'atteint HIGH (le seuil s'écrit aussi en minuscules) : code 0.\n" + f" {avec.resume()}", ) sans = lancer_outil(projet, str(_rapport(tmp_path, ["CRITICAL"], "critique.json"))) - assert sans.code == 0, ( - "Sans `--fail-on`, l'outil ne juge pas : il résume et sort en 0, même " - f"sur un CRITICAL.\n {sans.resume()}" + assert sans.code == 0, message( + en="Without `--fail-on`, the tool does not judge: it summarises and exits " + f"with 0, even on a CRITICAL.\n {sans.resume()}", + fr="Sans `--fail-on`, l'outil ne juge pas : il résume et sort en 0, même " + f"sur un CRITICAL.\n {sans.resume()}", ) @@ -107,28 +124,45 @@ def test_une_entree_invalide_reste_un_code_2(projet: Path, tmp_path: Path) -> No """Un seuil inconnu et un rapport absent : 2, jamais 1.""" rapport = _rapport(tmp_path, ["CRITICAL"]) inconnu = lancer_outil(projet, str(rapport), "--fail-on", "SEVERE") - assert inconnu.code == 2 and "critical" in inconnu.erreurs.lower(), ( - "`--fail-on SEVERE` : code 2, et le message liste les seuils acceptés. " + assert inconnu.code == 2 and "critical" in inconnu.erreurs.lower(), message( + en="`--fail-on SEVERE`: code 2, and the message lists the accepted " + "thresholds. Click does that on its own when the option declares its " + "choices.\n" + f" {inconnu.resume()}", + fr="`--fail-on SEVERE` : code 2, et le message liste les seuils acceptés. " "Click le fait seul quand l'option déclare ses choix.\n" - f" {inconnu.resume()}" + f" {inconnu.resume()}", ) absent = lancer_outil(projet, str(tmp_path / "absent.json"), "--fail-on", "CRITICAL") - assert absent.code == 2 and "Traceback" not in absent.erreurs, ( - "Un rapport absent avec `--fail-on` : code 2, l'entrée est inexploitable. " + assert absent.code == 2 and "Traceback" not in absent.erreurs, message( + en="A missing report with `--fail-on`: code 2, the input cannot be used. " + "A code 1 would tell the pipeline « a critical flaw », which is " + f"false.\n {absent.resume()}", + fr="Un rapport absent avec `--fail-on` : code 2, l'entrée est inexploitable. " "Un code 1 dirait au pipeline « une faille critique », ce qui est faux.\n" - f" {absent.resume()}" + f" {absent.resume()}", ) def test_l_aide_est_celle_de_click_et_documente_les_options(projet: Path, tmp_path: Path) -> None: execution = lancer_outil(projet, "--help") - assert execution.code == 0, execution.resume() + assert execution.code == 0, message( + en=execution.resume(), + fr=execution.resume(), + ) aide = execution.sortie - assert aide.startswith("Usage:"), ( - "L'aide doit être celle de Click, qui commence par `Usage:` (argparse " + assert aide.startswith("Usage:"), message( + en="The help must be Click's, which starts with `Usage:` (argparse writes " + "`usage:`). If the tool answers that the click module is missing: " + "`uv add click`.\n" + f" {execution.resume()}", + fr="L'aide doit être celle de Click, qui commence par `Usage:` (argparse " "écrit `usage:`). Si l'outil répond que le module click est " "introuvable : `uv add click`.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) for option in ("--fail-on", "--osv"): - assert option in aide, f"L'aide ne mentionne pas `{option}`.\n {execution.resume()}" + assert option in aide, message( + en=f"The help does not mention `{option}`.\n {execution.resume()}", + fr=f"L'aide ne mentionne pas `{option}`.\n {execution.resume()}", + ) diff --git a/labs/automatisation-dans-la-ci/challenge/tests/test_functional.py b/labs/automatisation-dans-la-ci/challenge/tests/test_functional.py index 50c124f..e0fbc01 100644 --- a/labs/automatisation-dans-la-ci/challenge/tests/test_functional.py +++ b/labs/automatisation-dans-la-ci/challenge/tests/test_functional.py @@ -29,6 +29,7 @@ exiger_workdir, jouer_act, lire_workflows, + message, references_uses, workdir_lab, ) @@ -44,9 +45,14 @@ def projet() -> Path: exiger_workdir(WORKDIR, LAB_ID) if not (WORKDIR / ".github" / "workflows").is_dir(): pytest.fail( - "Aucun workflow dans `.github/workflows/` : GitHub ne cherche les " + message( + en="No workflow in `.github/workflows/`: GitHub only looks for " + "workflows there, with the dot in front of `.github`. Nothing can " + "run as long as it does not exist.", + fr="Aucun workflow dans `.github/workflows/` : GitHub ne cherche les " "workflows que là, avec le point devant `.github`. Rien ne peut " "tourner tant qu'il n'existe pas.", + ), pytrace=False, ) return WORKDIR @@ -70,14 +76,20 @@ def _jouer(projet: Path, severites: list[str] | None = None, outil: Path | None def _job_unique(resultat) -> str: - assert resultat.jobs, ( - "act n'a joué aucun job : soit aucun workflow ne déclare cet événement " - "dans son `on:`, soit le fichier est invalide.\n " + resultat.resume() + assert resultat.jobs, message( + en="act ran no job: either no workflow declares this event in its `on:`, " + "or the file is invalid.\n " + resultat.resume(), + fr="act n'a joué aucun job : soit aucun workflow ne déclare cet événement " + "dans son `on:`, soit le fichier est invalide.\n " + resultat.resume(), ) - assert len(resultat.jobs) == 1, ( - "Un seul job est attendu, qui teste l'outil puis applique le seuil : " + assert len(resultat.jobs) == 1, message( + en="A single job is expected, which tests the tool and then applies the " + "threshold: the order of the steps is what guarantees a broken tool " + "decides nothing.\n " + + resultat.resume(), + fr="Un seul job est attendu, qui teste l'outil puis applique le seuil : " "l'ordre des étapes est la garantie qu'un outil cassé ne décide de rien.\n " - + resultat.resume() + + resultat.resume(), ) return next(iter(resultat.jobs)) @@ -85,16 +97,21 @@ def _job_unique(resultat) -> str: def test_un_rapport_propre_laisse_passer(projet: Path) -> None: res = _jouer(projet, severites=[]) job = _job_unique(res) - assert res.job(job) == "success", ( - "Sur un rapport sans vulnérabilité, la porte doit laisser passer.\n " + res.resume() + assert res.job(job) == "success", message( + en="On a report with no vulnerability, the gate must let it through.\n " + res.resume(), + fr="Sur un rapport sans vulnérabilité, la porte doit laisser passer.\n " + res.resume(), ) - assert any("TOTAL: 0" in ligne for ligne in res.lignes), ( - "Le job est vert, mais l'outil n'a pas résumé le rapport : il doit " - "lire `rapports/trivy.json`, celui que le scanner dépose.\n " + res.resume() + assert any("TOTAL: 0" in ligne for ligne in res.lignes), message( + en="The job is green, but the tool did not summarise the report: it must " + "read `rapports/trivy.json`, the one the scanner writes.\n " + res.resume(), + fr="Le job est vert, mais l'outil n'a pas résumé le rapport : il doit " + "lire `rapports/trivy.json`, celui que le scanner dépose.\n " + res.resume(), ) - assert any(" passed" in ligne for ligne in res.lignes), ( - "Les tests de l'outil (`tests/`) ne tournent pas dans le pipeline. " - "Ils passent avant le seuil : un outil cassé ne doit rien décider.\n " + res.resume() + assert any(" passed" in ligne for ligne in res.lignes), message( + en="The tool's own tests (`tests/`) do not run in the pipeline. They " + "come before the threshold: a broken tool must decide nothing.\n " + res.resume(), + fr="Les tests de l'outil (`tests/`) ne tournent pas dans le pipeline. " + "Ils passent avant le seuil : un outil cassé ne doit rien décider.\n " + res.resume(), ) @@ -102,23 +119,31 @@ def test_un_critique_arrete_le_pipeline(projet: Path) -> None: """Le rapport livré : 2 CRITICAL, sortie réelle de Trivy.""" res = _jouer(projet) job = _job_unique(res) - assert res.job(job) == "failure", ( - "Le rapport livré porte deux vulnérabilités CRITICAL : le job doit " - "échouer. Le seuil est-il passé à l'outil (`--fail-on CRITICAL`) ?\n " + res.resume() + assert res.job(job) == "failure", message( + en="The provided report carries two CRITICAL vulnerabilities: the job " + "must fail. Is the threshold passed to the tool " + "(`--fail-on CRITICAL`)?\n " + res.resume(), + fr="Le rapport livré porte deux vulnérabilités CRITICAL : le job doit " + "échouer. Le seuil est-il passé à l'outil (`--fail-on CRITICAL`) ?\n " + res.resume(), ) - assert any(ligne.startswith("FAILED:") for ligne in res.lignes), ( - "Le job échoue, mais pas sur le seuil : l'outil n'a pas écrit sa ligne " + assert any(ligne.startswith("FAILED:") for ligne in res.lignes), message( + en="The job fails, but not on the threshold: the tool did not write its " + "`FAILED: ...` line. A job red for another reason is not a gate.\n " + + res.resume(), + fr="Le job échoue, mais pas sur le seuil : l'outil n'a pas écrit sa ligne " "`FAILED: ...`. Un job rouge pour une autre raison n'est pas une porte.\n " - + res.resume() + + res.resume(), ) def test_le_seuil_est_critical_et_pas_plus_bas(projet: Path) -> None: res = _jouer(projet, severites=["HIGH", "HIGH", "MEDIUM"]) job = _job_unique(res) - assert res.job(job) == "success", ( - "Un rapport sans CRITICAL (deux HIGH et un MEDIUM) doit passer : la " - "porte demandée bloque au seuil CRITICAL, pas avant.\n " + res.resume() + assert res.job(job) == "success", message( + en="A report with no CRITICAL (two HIGH and one MEDIUM) must pass: the " + "gate asked for blocks at the CRITICAL threshold, not before.\n " + res.resume(), + fr="Un rapport sans CRITICAL (deux HIGH et un MEDIUM) doit passer : la " + "porte demandée bloque au seuil CRITICAL, pas avant.\n " + res.resume(), ) @@ -126,36 +151,48 @@ def test_un_outil_casse_ne_decide_de_rien(projet: Path) -> None: """L'outil remplacé par une version au seuil exclusif : ses tests doivent arrêter le job.""" res = _jouer(projet, severites=[], outil=OUTIL_CASSE) job = _job_unique(res) - assert res.job(job) == "failure", ( - "Avec un outil cassé (seuil exclusif), le pipeline doit s'arrêter sur " + assert res.job(job) == "failure", message( + en="With a broken tool (an exclusive threshold), the pipeline must stop " + "on its tests. The job is green: the tests do not run, or do not run " + "before the threshold.\n " + res.resume(), + fr="Avec un outil cassé (seuil exclusif), le pipeline doit s'arrêter sur " "ses tests. Le job est vert : les tests ne tournent pas, ou pas avant " - "le seuil.\n " + res.resume() + "le seuil.\n " + res.resume(), ) - assert not any(ligne.startswith("TOTAL:") for ligne in res.lignes), ( - "L'outil cassé a quand même résumé le rapport : le seuil s'applique " + assert not any(ligne.startswith("TOTAL:") for ligne in res.lignes), message( + en="The broken tool still summarised the report: the threshold applies " + "before, or in spite of, the tests failing. The tests come first, " + "and a failed step stops the ones after it.\n " + res.resume(), + fr="L'outil cassé a quand même résumé le rapport : le seuil s'applique " "avant ou malgré l'échec des tests. Les tests passent d'abord, et un " - "step en échec arrête les suivants.\n " + res.resume() + "step en échec arrête les suivants.\n " + res.resume(), ) def test_les_pull_requests_passent_la_porte_avec_des_droits_minimaux(projet: Path) -> None: res = _jouer(projet, severites=[], evenement="pull_request") job = _job_unique(res) - assert res.job(job) == "success", ( - "La porte doit aussi tourner sur chaque pull request, avant la fusion.\n " + res.resume() + assert res.job(job) == "success", message( + en="The gate must also run on every pull request, before the merge.\n " + res.resume(), + fr="La porte doit aussi tourner sur chaque pull request, avant la fusion.\n " + res.resume(), ) for chemin, wf in lire_workflows(projet).items(): if not {"push", "pull_request"} & declencheurs(wf): continue droits = wf.get("permissions") - assert droits == {"contents": "read"}, ( - f"{chemin} : `permissions:` vaut {droits!r}. Le job lit le code et " + assert droits == {"contents": "read"}, message( + en=f"{chemin}: `permissions:` is {droits!r}. The job reads the code and " + "nothing else: `contents: read`, at workflow level. A missing " + "block leaves the repository's default rights.", + fr=f"{chemin} : `permissions:` vaut {droits!r}. Le job lit le code et " "rien d'autre : `contents: read`, au niveau du workflow. Un bloc " - "absent laisse les droits par défaut du dépôt." + "absent laisse les droits par défaut du dépôt.", ) non_epinglees = [f"{r.fichier}:{r.ligne} {r.action}@{r.ref}" for r in references_uses(projet) if not r.locale and not r.epinglee_par_sha] - assert not non_epinglees, ( - "Chaque action s'épingle sur le SHA complet de son commit, version en " - "commentaire : " + ", ".join(non_epinglees) + assert not non_epinglees, message( + en="Every action is pinned to the full SHA of its commit, with the " + "version in a comment: " + ", ".join(non_epinglees), + fr="Chaque action s'épingle sur le SHA complet de son commit, version en " + "commentaire : " + ", ".join(non_epinglees), ) diff --git a/labs/automatisation-interroger-osv/challenge/tests/test_functional.py b/labs/automatisation-interroger-osv/challenge/tests/test_functional.py index 36097e3..4c47ae4 100644 --- a/labs/automatisation-interroger-osv/challenge/tests/test_functional.py +++ b/labs/automatisation-interroger-osv/challenge/tests/test_functional.py @@ -27,6 +27,7 @@ exiger_workdir, lancer_outil, lire_resume, + message, workdir_lab, ) @@ -59,11 +60,15 @@ def _osv(projet: Path, rapport: Path, serveur, **env: str): def _exiger_succes(execution) -> None: - assert execution.code == 0, ( - "`--osv` devait réussir (code 0). Si la sortie parle de `requests` " + assert execution.code == 0, message( + en="`--osv` had to succeed (code 0). If the output mentions `requests` " + "not found, the library is not a dependency of the project: " + "`uv add requests` declares it.\n" + f" {execution.resume()}", + fr="`--osv` devait réussir (code 0). Si la sortie parle de `requests` " "introuvable, la bibliothèque n'est pas une dépendance du projet : " "`uv add requests` l'y déclare.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) @@ -73,21 +78,29 @@ def test_chaque_paquet_est_interroge_une_seule_fois(projet: Path, rapport: Path, recus = [] for r in serveur_osv.requetes: corps = r["corps"] - assert r["chemin"] == "/v1/query", ( - f"Requête reçue sur {r['chemin']} : OSV répond sur `/v1/query`, " - "au bout de l'adresse donnée par OSV_API_URL." + assert r["chemin"] == "/v1/query", message( + en=f"Request received on {r['chemin']}: OSV answers on `/v1/query`, at " + "the end of the address given by OSV_API_URL.", + fr=f"Requête reçue sur {r['chemin']} : OSV répond sur `/v1/query`, " + "au bout de l'adresse donnée par OSV_API_URL.", ) paquet = corps.get("package") or {} - assert paquet.get("ecosystem") == "PyPI", ( - f"Corps reçu : {corps}. OSV doit savoir dans quel écosystème " - "chercher : `\"ecosystem\": \"PyPI\"` dans `package`." + assert paquet.get("ecosystem") == "PyPI", message( + en=f"Body received: {corps}. OSV must know which ecosystem to search " + "in: `\"ecosystem\": \"PyPI\"` inside `package`.", + fr=f"Corps reçu : {corps}. OSV doit savoir dans quel écosystème " + "chercher : `\"ecosystem\": \"PyPI\"` dans `package`.", ) recus.append((paquet.get("name"), corps.get("version"))) - assert sorted(recus) == sorted(ATTENDUS), ( - f"Requêtes reçues : {sorted(recus)}\n attendues : {sorted(ATTENDUS)}\n\n" + assert sorted(recus) == sorted(ATTENDUS), message( + en=f"Requests received: {sorted(recus)}\n expected: {sorted(ATTENDUS)}\n\n" + "One request per distinct (package, version) pair of the report: " + "`requests 2.25.0` appears twice there, it is queried once, and " + "`requests 2.31.0` is another pair.", + fr=f"Requêtes reçues : {sorted(recus)}\n attendues : {sorted(ATTENDUS)}\n\n" "Une requête par couple (paquet, version) distinct du rapport : " "`requests 2.25.0` y figure deux fois, il ne s'interroge qu'une fois, " - "et `requests 2.31.0` est un autre couple." + "et `requests 2.31.0` est un autre couple.", ) @@ -105,12 +118,17 @@ def test_les_vulnerabilites_connues_s_affichent_par_paquet(projet: Path, rapport "OSV requests==2.31.0: GHSA-j8r2-6x86-q33q, PYSEC-2023-74", "OSV urllib3==1.26.4: GHSA-v845-jxx5-vc9f", ] - assert lignes == attendu, ( - "Les lignes OSV ne sont pas celles attendues.\n" + assert lignes == attendu, message( + en="The OSV lines are not the expected ones.\n" + f" expected: {attendu}\n obtained: {lignes}\n\n" + "One line per pair, sorted by package then version, in the form " + "`OSV package==version: ID, ID` (sorted identifiers) or `: none`.\n" + f" {execution.resume()}", + fr="Les lignes OSV ne sont pas celles attendues.\n" f" attendu : {attendu}\n obtenu : {lignes}\n\n" "Une ligne par couple, triés par paquet puis version, au format " "`OSV paquet==version: ID, ID` (identifiants triés) ou `: none`.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) @@ -119,32 +137,46 @@ def test_le_resume_reste_et_sans_osv_aucun_appel(projet: Path, rapport: Path, se attendu = compter([f["severity"] for f in FINDINGS]) avec = _osv(projet, rapport, serveur_osv) _exiger_succes(avec) - assert lire_resume(avec.sortie) == attendu, ( - f"Avec `--osv`, le résumé par sévérité doit rester le même.\n attendu : {attendu}\n" - f" {avec.resume()}" + assert lire_resume(avec.sortie) == attendu, message( + en=f"With `--osv`, the summary per severity must stay the same.\n expected: {attendu}\n" + f" {avec.resume()}", + fr=f"Avec `--osv`, le résumé par sévérité doit rester le même.\n attendu : {attendu}\n" + f" {avec.resume()}", ) avant = len(serveur_osv.requetes) sans = lancer_outil(projet, str(rapport), env={"OSV_API_URL": serveur_osv.url}) - assert sans.code == 0 and lire_resume(sans.sortie) == attendu, sans.resume() - assert len(serveur_osv.requetes) == avant, ( - "Sans `--osv`, l'outil ne doit faire aucun appel réseau : dans un " - "pipeline hors ligne, le simple résumé doit continuer de marcher." + assert sans.code == 0 and lire_resume(sans.sortie) == attendu, message( + en=sans.resume(), + fr=sans.resume(), + ) + assert len(serveur_osv.requetes) == avant, message( + en="Without `--osv`, the tool must make no network call at all: in an " + "offline pipeline, the plain summary must keep working.", + fr="Sans `--osv`, l'outil ne doit faire aucun appel réseau : dans un " + "pipeline hors ligne, le simple résumé doit continuer de marcher.", ) def _exiger_refus_propre(execution, indice: str, cas: str) -> None: - assert execution.code == 2, ( - f"{cas} : l'outil doit sortir en 2, comme pour toute entrée inexploitable.\n" - f" {execution.resume()}" + assert execution.code == 2, message( + en=f"{cas}: the tool must exit with 2, as for any unusable input.\n" + f" {execution.resume()}", + fr=f"{cas} : l'outil doit sortir en 2, comme pour toute entrée inexploitable.\n" + f" {execution.resume()}", ) - assert "Traceback" not in execution.erreurs, ( - f"{cas} : une trace Python remonte. Les exceptions de `requests` " + assert "Traceback" not in execution.erreurs, message( + en=f"{cas}: a Python traceback comes through. `requests` exceptions can " + "be caught, and the problem stated in one line.\n" + f" {execution.resume()}", + fr=f"{cas} : une trace Python remonte. Les exceptions de `requests` " "s'attrapent, et le problème se dit en une ligne.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) - assert indice in execution.erreurs, ( - f"{cas} : le message doit dire ce qui s'est passé (il doit contenir « {indice} »).\n" - f" {execution.resume()}" + assert indice in execution.erreurs, message( + en=f"{cas}: the message must say what happened (it must contain « {indice} »).\n" + f" {execution.resume()}", + fr=f"{cas} : le message doit dire ce qui s'est passé (il doit contenir « {indice} »).\n" + f" {execution.resume()}", ) @@ -161,8 +193,11 @@ def test_une_api_muette_ne_bloque_pas_le_pipeline(projet: Path, rapport: Path, s duree = time.monotonic() - debut _exiger_refus_propre(execution, "OSV", "API qui ne répond pas") # uv et Python démarrent en moins de 2 s ; une seule requête attendue. - assert duree < 5, ( - f"L'outil a attendu {duree:.1f} s : le délai maximal OSV_TIMEOUT=1 " + assert duree < 5, message( + en=f"The tool waited {duree:.1f} s: the OSV_TIMEOUT=1 maximum delay is " + "not applied. Without `timeout=`, `requests` waits forever.\n" + f" {execution.resume()}", + fr=f"L'outil a attendu {duree:.1f} s : le délai maximal OSV_TIMEOUT=1 " "n'est pas appliqué. Sans `timeout=`, `requests` attend indéfiniment.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) diff --git a/labs/automatisation-lire-un-rapport/challenge/tests/test_functional.py b/labs/automatisation-lire-un-rapport/challenge/tests/test_functional.py index 5489351..1b1aca8 100644 --- a/labs/automatisation-lire-un-rapport/challenge/tests/test_functional.py +++ b/labs/automatisation-lire-un-rapport/challenge/tests/test_functional.py @@ -35,6 +35,7 @@ findings_au_hasard, lancer_outil, lire_resume, + message, tirage, workdir_lab, ) @@ -50,16 +51,22 @@ def projet() -> Path: def _exiger_resume(execution, attendu: dict[str, int], contexte: str) -> None: - assert execution.code == 0, ( - f"{contexte} : l'outil devait réussir (code 0).\n {execution.resume()}" + assert execution.code == 0, message( + en=f"{contexte}: the tool had to succeed (code 0).\n {execution.resume()}", + fr=f"{contexte} : l'outil devait réussir (code 0).\n {execution.resume()}", ) obtenu = lire_resume(execution.sortie) - assert obtenu == attendu, ( - f"{contexte} : le résumé ne correspond pas au rapport.\n" + assert obtenu == attendu, message( + en=f"{contexte}: the summary does not match the report.\n" + f" expected: {attendu}\n obtained: {obtenu}\n\n" + "One line per severity, in the order CRITICAL, HIGH, MEDIUM, LOW, " + "UNKNOWN, in the form `CRITICAL: 2`, then `TOTAL: n`.\n" + f" {execution.resume()}", + fr=f"{contexte} : le résumé ne correspond pas au rapport.\n" f" attendu : {attendu}\n obtenu : {obtenu}\n\n" "Une ligne par sévérité, dans l'ordre CRITICAL, HIGH, MEDIUM, LOW, " "UNKNOWN, au format `CRITICAL: 2`, puis `TOTAL: n`.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) @@ -102,22 +109,31 @@ def test_un_rapport_vide_rend_des_zeros(projet: Path, tmp_path: Path) -> None: def _exiger_refus_propre(execution, nom: str, cas: str) -> None: - assert execution.code == 2, ( - f"{cas} : l'outil doit sortir en 2, le code des entrées invalides.\n" - f" {execution.resume()}" + assert execution.code == 2, message( + en=f"{cas}: the tool must exit with 2, the code for invalid input.\n" + f" {execution.resume()}", + fr=f"{cas} : l'outil doit sortir en 2, le code des entrées invalides.\n" + f" {execution.resume()}", ) - assert "Traceback" not in execution.erreurs, ( - f"{cas} : l'outil laisse remonter une trace Python. Attrapez l'exception " + assert "Traceback" not in execution.erreurs, message( + en=f"{cas}: the tool lets a Python traceback through. Catch the exception " + "and state the problem in one line, on the error output.\n" + f" {execution.resume()}", + fr=f"{cas} : l'outil laisse remonter une trace Python. Attrapez l'exception " "et dites le problème en une ligne, sur la sortie d'erreur.\n" - f" {execution.resume()}" + f" {execution.resume()}", ) - assert nom in execution.erreurs, ( - f"{cas} : le message d'erreur doit nommer le fichier en cause ({nom}).\n" - f" {execution.resume()}" + assert nom in execution.erreurs, message( + en=f"{cas}: the error message must name the offending file ({nom}).\n" + f" {execution.resume()}", + fr=f"{cas} : le message d'erreur doit nommer le fichier en cause ({nom}).\n" + f" {execution.resume()}", ) - assert not lire_resume(execution.sortie), ( - f"{cas} : aucun résumé ne doit s'afficher pour un rapport illisible.\n" - f" {execution.resume()}" + assert not lire_resume(execution.sortie), message( + en=f"{cas}: no summary must be printed for an unreadable report.\n" + f" {execution.resume()}", + fr=f"{cas} : aucun résumé ne doit s'afficher pour un rapport illisible.\n" + f" {execution.resume()}", ) diff --git a/labs/automatisation-lire-un-sbom/challenge/tests/test_functional.py b/labs/automatisation-lire-un-sbom/challenge/tests/test_functional.py index 3463bfa..8c1de7c 100644 --- a/labs/automatisation-lire-un-sbom/challenge/tests/test_functional.py +++ b/labs/automatisation-lire-un-sbom/challenge/tests/test_functional.py @@ -27,6 +27,7 @@ exiger_workdir, lancer_outil, lire_resume, + message, workdir_lab, ) @@ -68,74 +69,102 @@ def _sbom(projet: Path, serveur, *options: str): def test_chaque_composant_pypi_est_interroge_une_fois(projet: Path, serveur_osv) -> None: execution = _sbom(projet, serveur_osv) - assert execution.code == 0, ( - "Un SBOM CycloneDX avec `--osv` doit être lu. Il se reconnaît à sa clé " - f"`bomFormat`, et ses composants vivent sous `components`.\n {execution.resume()}" + assert execution.code == 0, message( + en="A CycloneDX SBOM with `--osv` must be read. It is recognised by its " + f"`bomFormat` key, and its components live under `components`.\n {execution.resume()}", + fr="Un SBOM CycloneDX avec `--osv` doit être lu. Il se reconnaît à sa clé " + f"`bomFormat`, et ses composants vivent sous `components`.\n {execution.resume()}", ) recus = sorted(((r["corps"].get("package") or {}).get("name"), r["corps"].get("version"), (r["corps"].get("package") or {}).get("ecosystem")) for r in serveur_osv.requetes) attendus = sorted((n, v, "PyPI") for n, v in PYPI) - assert recus == attendus, ( - f"Requêtes reçues : {recus}\n attendues : {attendus}\n\n" + assert recus == attendus, message( + en=f"Requests received: {recus}\n expected: {attendus}\n\n" + "One request per distinct PyPI component: `requests 2.25.0` appears " + "twice in the SBOM. The `purl` states the ecosystem " + "(`pkg:pypi/...`): npm components are not queried like PyPI packages.", + fr=f"Requêtes reçues : {recus}\n attendues : {attendus}\n\n" "Une requête par composant PyPI distinct : `requests 2.25.0` figure deux " "fois dans le SBOM. Le `purl` dit l'écosystème (`pkg:pypi/...`) : les " - "composants npm ne s'interrogent pas comme des paquets PyPI." + "composants npm ne s'interrogent pas comme des paquets PyPI.", ) def test_le_resume_compte_les_vulnerabilites_connues_d_osv(projet: Path, serveur_osv) -> None: serveur_osv.reponses = REPONSES execution = _sbom(projet, serveur_osv) - assert execution.code == 0, execution.resume() + assert execution.code == 0, message( + en=execution.resume(), + fr=execution.resume(), + ) attendu = _attendu(REPONSES) obtenu = lire_resume(execution.sortie) - assert obtenu == attendu, ( - "Le résumé d'un SBOM compte les vulnérabilités qu'OSV connaît, une par " + assert obtenu == attendu, message( + en="An SBOM's summary counts the vulnerabilities OSV knows about, one per " + "advisory, with the severity of the GitHub advisory.\n" + f" expected: {attendu}\n obtained: {obtenu}\n\n" + "GitHub writes MODERATE where the summary says MEDIUM, and an " + f"advisory with no severity (PYSEC) counts as UNKNOWN.\n {execution.resume()}", + fr="Le résumé d'un SBOM compte les vulnérabilités qu'OSV connaît, une par " "avis, avec la sévérité de l'avis GitHub.\n" f" attendu : {attendu}\n obtenu : {obtenu}\n\n" "GitHub écrit MODERATE ce que le résumé appelle MEDIUM, et un avis sans " - f"sévérité (PYSEC) compte en UNKNOWN.\n {execution.resume()}" + f"sévérité (PYSEC) compte en UNKNOWN.\n {execution.resume()}", ) def test_un_sbom_sans_osv_est_une_entree_inexploitable(projet: Path) -> None: execution = lancer_outil(projet, str(SBOM)) - assert execution.code == 2 and "--osv" in execution.erreurs and "Traceback" not in execution.erreurs, ( - "Un SBOM liste des composants, pas des vulnérabilités : sans `--osv`, " + assert execution.code == 2 and "--osv" in execution.erreurs and "Traceback" not in execution.erreurs, message( + en="An SBOM lists components, not vulnerabilities: without `--osv`, the " + "tool has nothing to count. It says so in one line that names " + "`--osv`, with code 2, rather than printing a zeroed summary that " + f"would suggest a healthy project.\n {execution.resume()}", + fr="Un SBOM liste des composants, pas des vulnérabilités : sans `--osv`, " "l'outil n'a rien à compter. Il le dit en une ligne qui nomme `--osv`, " "avec le code 2, au lieu d'afficher un résumé à zéro qui ferait croire " - f"le projet sain.\n {execution.resume()}" + f"le projet sain.\n {execution.resume()}", ) def test_le_seuil_s_applique_a_un_sbom(projet: Path, serveur_osv) -> None: serveur_osv.reponses = REPONSES haut = _sbom(projet, serveur_osv, "--fail-on", "HIGH") - assert haut.code == 1, ( - "Le SBOM porte un HIGH (urllib3) et un CRITICAL (pyyaml) d'après OSV : " - f"`--fail-on HIGH` doit rendre 1.\n {haut.resume()}" + assert haut.code == 1, message( + en="The SBOM carries a HIGH (urllib3) and a CRITICAL (pyyaml) according " + f"to OSV: `--fail-on HIGH` must return 1.\n {haut.resume()}", + fr="Le SBOM porte un HIGH (urllib3) et un CRITICAL (pyyaml) d'après OSV : " + f"`--fail-on HIGH` doit rendre 1.\n {haut.resume()}", ) serveur_osv.reponses = {"requests": {"GHSA-j8r2-6x86-q33q": "MODERATE"}} modere = _sbom(projet, serveur_osv, "--fail-on", "HIGH") - assert modere.code == 0, ( - "Avec un seul avis MODERATE, rien n'atteint HIGH : code 0. MODERATE " - f"est un MEDIUM, pas un HIGH.\n {modere.resume()}" + assert modere.code == 0, message( + en="With a single MODERATE advisory, nothing reaches HIGH: code 0. " + f"MODERATE is a MEDIUM, not a HIGH.\n {modere.resume()}", + fr="Avec un seul avis MODERATE, rien n'atteint HIGH : code 0. MODERATE " + f"est un MEDIUM, pas un HIGH.\n {modere.resume()}", ) def test_les_rapports_restent_lisibles_et_les_composants_npm_ignores(projet: Path, serveur_osv, tmp_path: Path) -> None: """Le rapport Trivy des labs précédents, et un SBOM sans aucun composant PyPI.""" trivy = lancer_outil(projet, str(DONNEES / "trivy-multi.json")) - assert trivy.code == 0 and lire_resume(trivy.sortie).get("TOTAL") == 22, ( - f"Le rapport Trivy doit toujours donner ses 22 failles distinctes.\n {trivy.resume()}" + assert trivy.code == 0 and lire_resume(trivy.sortie).get("TOTAL") == 22, message( + en=f"The Trivy report must still give its 22 distinct flaws.\n {trivy.resume()}", + fr=f"Le rapport Trivy doit toujours donner ses 22 failles distinctes.\n {trivy.resume()}", ) sbom = json.loads(SBOM.read_text(encoding="utf-8")) sbom["components"] = [c for c in sbom["components"] if not str(c.get("purl", "")).startswith("pkg:pypi/")] npm = tmp_path / "npm.cdx.json" npm.write_text(json.dumps(sbom), encoding="utf-8") execution = lancer_outil(projet, str(npm), "--osv", env={"OSV_API_URL": serveur_osv.url}) - assert execution.code == 0 and lire_resume(execution.sortie).get("TOTAL") == 0, execution.resume() - assert not serveur_osv.requetes, ( - "Un SBOM sans composant PyPI ne doit déclencher aucune requête PyPI : " - f"{[r['corps'] for r in serveur_osv.requetes]}" + assert execution.code == 0 and lire_resume(execution.sortie).get("TOTAL") == 0, message( + en=execution.resume(), + fr=execution.resume(), + ) + assert not serveur_osv.requetes, message( + en="An SBOM with no PyPI component must trigger no PyPI request: " + f"{[r['corps'] for r in serveur_osv.requetes]}", + fr="Un SBOM sans composant PyPI ne doit déclencher aucune requête PyPI : " + f"{[r['corps'] for r in serveur_osv.requetes]}", ) diff --git a/labs/automatisation-rapport-trivy/challenge/tests/test_functional.py b/labs/automatisation-rapport-trivy/challenge/tests/test_functional.py index 075fd8d..e14fbaa 100644 --- a/labs/automatisation-rapport-trivy/challenge/tests/test_functional.py +++ b/labs/automatisation-rapport-trivy/challenge/tests/test_functional.py @@ -30,6 +30,7 @@ findings_au_hasard, lancer_outil, lire_resume, + message, tirage, workdir_lab, ) @@ -75,15 +76,20 @@ def trivy_au_hasard(graine: int) -> dict: def _exiger_resume(execution, attendu: dict[str, int], contexte: str) -> None: - assert execution.code == 0, ( - f"{contexte} : l'outil devait réussir (code 0). Un rapport Trivy est un " + assert execution.code == 0, message( + en=f"{contexte}: the tool had to succeed (code 0). A Trivy report is a " + "JSON object, not a list: version 2 refuses it, version 3 must " + f"recognise it.\n {execution.resume()}", + fr=f"{contexte} : l'outil devait réussir (code 0). Un rapport Trivy est un " "objet JSON, pas une liste : la version 2 le refuse, la version 3 doit " - f"le reconnaître.\n {execution.resume()}" + f"le reconnaître.\n {execution.resume()}", ) obtenu = lire_resume(execution.sortie) - assert obtenu == attendu, ( - f"{contexte} : le résumé ne correspond pas au rapport.\n" - f" attendu : {attendu}\n obtenu : {obtenu}\n {execution.resume()}" + assert obtenu == attendu, message( + en=f"{contexte}: the summary does not match the report.\n" + f" expected: {attendu}\n obtained: {obtenu}\n {execution.resume()}", + fr=f"{contexte} : le résumé ne correspond pas au rapport.\n" + f" attendu : {attendu}\n obtenu : {obtenu}\n {execution.resume()}", ) @@ -93,9 +99,14 @@ def test_le_vrai_rapport_trivy_compte_22_failles_distinctes(projet: Path) -> Non execution = lancer_outil(projet, str(chemin)) if execution.code == 0 and lire_resume(execution.sortie).get("TOTAL") == 26: pytest.fail( - "TOTAL vaut 26 : ce sont les occurrences, pas les failles. `requests " + message( + en="TOTAL is 26: those are occurrences, not vulnerabilities. `requests " + "2.25.0` is declared in two files, and its four vulnerabilities " + "appear in two targets; each one counts only once.", + fr="TOTAL vaut 26 : ce sont les occurrences, pas les failles. `requests " "2.25.0` est déclaré dans deux fichiers, et ses quatre vulnérabilités " "apparaissent dans deux cibles ; chacune ne compte qu'une fois.", + ), pytrace=False, ) _exiger_resume(execution, attendu, "rapport réel de Trivy 0.74.0") @@ -140,10 +151,15 @@ def test_osv_interroge_les_paquets_du_rapport_trivy(projet: Path, serveur_osv) - for r in rapport["Results"] for v in r.get("Vulnerabilities") or []}) execution = lancer_outil(projet, str(DONNEES / "trivy-multi.json"), "--osv", env={"OSV_API_URL": serveur_osv.url}) - assert execution.code == 0, execution.resume() + assert execution.code == 0, message( + en=execution.resume(), + fr=execution.resume(), + ) recus = sorted(((r["corps"].get("package") or {}).get("name"), r["corps"].get("version")) for r in serveur_osv.requetes) - assert recus == attendus, ( - f"Avec un rapport Trivy, `--osv` interroge chaque couple (PkgName, " - f"InstalledVersion) une fois.\n attendus : {attendus}\n reçus : {recus}" + assert recus == attendus, message( + en=f"With a Trivy report, `--osv` queries each (PkgName, InstalledVersion) " + f"pair once.\n expected: {attendus}\n received: {recus}", + fr=f"Avec un rapport Trivy, `--osv` interroge chaque couple (PkgName, " + f"InstalledVersion) une fois.\n attendus : {attendus}\n reçus : {recus}", ) diff --git a/labs/automatisation-tester-son-outil/challenge/tests/test_functional.py b/labs/automatisation-tester-son-outil/challenge/tests/test_functional.py index c0b00d0..92b7abe 100644 --- a/labs/automatisation-tester-son-outil/challenge/tests/test_functional.py +++ b/labs/automatisation-tester-son-outil/challenge/tests/test_functional.py @@ -35,7 +35,7 @@ import pytest -from conftest import exiger_workdir, workdir_lab +from conftest import exiger_workdir, message, workdir_lab WORKDIR = workdir_lab(__file__) LAB_ID = "automatisation-tester-son-outil" @@ -63,7 +63,10 @@ def jouer_la_suite(outil: str) -> Passage: exiger_workdir(WORKDIR, LAB_ID) uv = shutil.which("uv") if uv is None: - pytest.fail("uv est introuvable sur le PATH. Lancez `mise install`.", pytrace=False) + pytest.fail(message( + en="uv cannot be found on the PATH. Run `mise install`.", + fr="uv est introuvable sur le PATH. Lancez `mise install`.", + ), pytrace=False) copie = Path(tempfile.mkdtemp(prefix="security-check-")) / "projet" shutil.copytree(WORKDIR, copie, ignore=shutil.ignore_patterns(*EXCLUS)) shutil.copy2(outil, copie / "security_check.py") @@ -85,34 +88,48 @@ def _exiger_reference_verte() -> Passage: ref = jouer_la_suite(str(REFERENCE)) if "Failed to spawn" in ref.sortie and "pytest" in ref.sortie: pytest.fail( - "pytest n'est pas installé dans le projet : `uv add --dev pytest` le " + message( + en="pytest is not installed in the project: `uv add --dev pytest` " + "declares it as a development dependency.\n" + ref.resume(), + fr="pytest n'est pas installé dans le projet : `uv add --dev pytest` le " "déclare comme dépendance de développement.\n" + ref.resume(), + ), pytrace=False, ) - assert ref.code == 0, ( - "Votre suite échoue sur l'outil de RÉFÉRENCE, qui est juste : un test " + assert ref.code == 0, message( + en="Your suite fails against the REFERENCE tool, which is correct: one " + "of your tests expects something other than the behaviour the " + "previous labs asked for.\n" + + ref.resume(), + fr="Votre suite échoue sur l'outil de RÉFÉRENCE, qui est juste : un test " "attend autre chose que le comportement demandé par les labs précédents.\n" - + ref.resume() + + ref.resume(), ) return ref def test_la_suite_passe_sur_l_outil_juste_et_compte_assez_de_tests() -> None: ref = _exiger_reference_verte() - assert ref.reussis >= MINIMUM_DE_TESTS, ( - f"{ref.reussis} test(s) seulement : il en faut au moins {MINIMUM_DE_TESTS}. " + assert ref.reussis >= MINIMUM_DE_TESTS, message( + en=f"Only {ref.reussis} test(s): at least {MINIMUM_DE_TESTS} are needed. " + "The provided example only checks a constant; a useful suite " + "exercises reading, counting, deduplication and the exit codes.\n" + + ref.resume(), + fr=f"{ref.reussis} test(s) seulement : il en faut au moins {MINIMUM_DE_TESTS}. " "L'exemple livré ne vérifie qu'une constante ; une suite utile exerce " "la lecture, le compte, le dédoublonnage et les codes de sortie.\n" - + ref.resume() + + ref.resume(), ) def _exiger_mutant_tue(nom: str, defaut: str, piste: str) -> None: _exiger_reference_verte() passage = jouer_la_suite(str(MUTANTS / f"{nom}.py")) - assert passage.code != 0 and passage.echoues > 0, ( - f"Le mutant « {nom} » survit : votre suite passe sur un outil où {defaut}.\n" - f"Ajoutez un test qui l'attrape : {piste}\n" + passage.resume() + assert passage.code != 0 and passage.echoues > 0, message( + en=f"The « {nom} » mutant survives: your suite passes on a tool where " + f"{defaut}.\nAdd a test that catches it: {piste}\n" + passage.resume(), + fr=f"Le mutant « {nom} » survit : votre suite passe sur un outil où {defaut}.\n" + f"Ajoutez un test qui l'attrape : {piste}\n" + passage.resume(), )