From 33cf04c7f9fdcedf40d79a7d1f705dda347cde84 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 02:15:30 -0400 Subject: [PATCH] =?UTF-8?q?[REM]=20r=C3=A9sidus=20:=20retirer=20le=20contr?= =?UTF-8?q?=C3=B4le=20res=5Flang.active=20=C3=A0=20NULL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le contrôle jugeait cassée une langue dont `active` est NULL : listée nulle part, plus réactivable. Faux dans la source 18 — un domaine ('active','=',False) compile en (IS NULL OR = FALSE) (models.py:3217), l'action du menu Langues porte active_test: False, le tri passe par COALESCE(active, FALSE) et la lecture rend bool(value). Odoo écrit ce NULL : active = fields.Boolean() sans défaut (res_lang.py:64) et un res.lang.csv sans la colonne, soit un NULL par langue ajoutée au catalogue. « Zéro avant, non nul après » ne suffit donc pas. Un test refuse désormais une clé que plus rien ne définit. --- EN --- The check called a language broken when `active` is NULL: listed nowhere, no longer re-enablable. False in the 18 source — an ('active','=',False) domain compiles to (IS NULL OR = FALSE) (models.py:3217), the Languages menu action carries active_test: False, sorting goes through COALESCE(active, FALSE), reading returns bool(value). Odoo writes that NULL itself: active = fields.Boolean() with no default (res_lang.py:64) and a res.lang.csv without the column, one NULL per language added to the catalogue. So "zero before, nonzero after" is not enough. A test now refuses a key that nothing defines any more. Assisted-by: Claude Opus 5 (cherry picked from commit ee932334cfde63c1af831316549a432c3d382721) --- script/analyse/check_migration_residue.py | 35 +++++++++++------- test/test_monitoring.py | 44 +++++++++++++++++++---- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/script/analyse/check_migration_residue.py b/script/analyse/check_migration_residue.py index d75f75c..651b84b 100755 --- a/script/analyse/check_migration_residue.py +++ b/script/analyse/check_migration_residue.py @@ -27,7 +27,6 @@ finit par être ignoré en entier. Ne restent ici que les constats qui se jugent SANS point de comparaison, parce qu'ils sont faux en eux-mêmes. Les mêmes bases, mêmes mesures : - res_lang.active à NULL 0 → 9 un booléen NULL est un bug ir_model_relation sans table 0 → 68 la table m2m est nommée, elle n'existe pas index doublés convention 17 0 → 414 Odoo 17 renomme, sans @@ -35,9 +34,30 @@ parce qu'ils sont faux en eux-mêmes. Les mêmes bases, mêmes mesures : liste de prix par défaut absente 0 → 1 `product` installé, son xmlid pas là -Zéro avant, non nul après : aucun de ces trois ne peut s'expliquer +Zéro avant, non nul après : aucun de ceux-là ne peut s'expliquer autrement que par la migration. +« Zéro avant, non nul après » ne suffit pourtant pas +---------------------------------------------------- +Un quatrième contrôle a vécu ici et n'y est plus : `res_lang.active` à +NULL, 0 avant et 9 après. Le chiffre était juste, la conclusion fausse. + +Mesuré palier par palier : 0, 1, 2, 3, 5, 8, 9 — les NULL arrivent avec +les langues que CHAQUE version ajoute au catalogue. Et c'est Odoo +lui-même qui les écrit : `active = fields.Boolean()` sans défaut +(res_lang.py:64) et un `res.lang.csv` sans colonne `active` — l'INSERT ne +porte pas la colonne, PostgreSQL y met NULL. + +Aucune conséquence, vérifiée dans la source de la 18 : un domaine +`('active','=',False)` compile en `(IS NULL OR = FALSE)` +(models.py:3217-3222), l'action du menu Langues porte `active_test: False` +(res_lang_views.xml:136), le tri passe par `COALESCE(active, FALSE)` +(models.py:5692) et la lecture rend `bool(value)` (fields.py:1515). NULL +et FALSE sont indiscernables partout. + +La leçon : la croissance mesurée doit AUSSI être inexplicable autrement. +Ici elle s'expliquait très bien. + Un quatrième a été RETIRÉ après vérification -------------------------------------------- « ir_model_relation nomme une table absente » : 0 avant, 68 après, le @@ -107,15 +127,6 @@ CONTROLES = ( "gravity": "broken", "repair": "script/todo/todo_upgrade.py (uninstall_one_by_one)", }, - { - "key": "lang_active_null", - "title": "Languages whose active flag is NULL", - "why": "A boolean that is neither true nor false: the language is" - " listed nowhere and cannot be re-enabled from the interface.", - "sql": "SELECT count(*) FROM res_lang WHERE active IS NULL", - "gravity": "broken", - "repair": None, - }, { "key": "duplicate_index", "title": "Indexes duplicated by the Odoo 17 renaming", @@ -189,7 +200,7 @@ CONTROLES = ( "sql": "SELECT count(*) FROM ir_model_data d WHERE NOT EXISTS" " (SELECT 1 FROM ir_model m WHERE m.model = d.model)", "gravity": "broken", - "repair": "script/analyse/database_cleanup.py", + "repair": "script/odoo/migration/database_cleanup.py", }, { "key": "attachment_field_gone", diff --git a/test/test_monitoring.py b/test/test_monitoring.py index a4ceb31..c7375b7 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -24,9 +24,8 @@ import os import sys import unittest -sys.path.insert( - 0, os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) -) +REPO = os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, REPO) from script.analyse import check_migration_residue as residue # noqa: E402 from script.analyse import monitoring # noqa: E402 @@ -190,9 +189,9 @@ class TestWhatAMigrationLeftBehind(unittest.TestCase): def test_broken_is_read_before_watch(self): resultats = {c["key"]: 0 for c in residue.CONTROLES} resultats["duplicate_index"] = 400 # watch - resultats["lang_active_null"] = 1 # broken + resultats["stuck_modules"] = 1 # broken trouve, _ = residue.judge(resultats) - self.assertEqual(trouve[0][0]["key"], "lang_active_null") + self.assertEqual(trouve[0][0]["key"], "stuck_modules") def test_a_check_that_could_not_run_is_not_a_check_that_found_nothing( self, @@ -247,7 +246,7 @@ class TestWhatAMigrationLeftBehind(unittest.TestCase): def test_the_exit_code_separates_nothing_from_something(self): vide = {c["key"]: 0 for c in residue.CONTROLES} self.assertEqual(residue.judge(vide)[0], []) - plein = dict(vide, lang_active_null=3) + plein = dict(vide, stuck_modules=3) self.assertTrue(residue.judge(plein)[0]) @@ -451,6 +450,39 @@ def verdict(**champs): return brut +class TestTheControlsThemselves(unittest.TestCase): + """Un contrôle retiré ne doit pas survivre dans les tests. + + « res_lang.active à NULL » a vécu ici avec la gravité « broken », et + c'était un faux constat : Odoo écrit lui-même ce NULL, faute de défaut + sur `active = fields.Boolean()`. Deux tests le nommaient encore après + son retrait — ils passaient, en construisant un dictionnaire avec une + clé inconnue de personne. + """ + + def test_every_key_a_test_names_still_exists(self): + import re + + connues = {c["key"] for c in residue.CONTROLES} + source = io.open(__file__, encoding="utf-8").read() + nommees = set(re.findall(r'resultats\["([a-z_]+)"\]', source)) + nommees |= set(re.findall(r"dict\(vide, ([a-z_]+)=", source)) + self.assertTrue(nommees) + self.assertEqual(set(), nommees - connues) + + def test_no_control_promises_a_repair_tool_that_is_missing(self): + import os + + for controle in residue.CONTROLES: + morceaux = (controle["repair"] or "").split() + chemin = morceaux[0] if morceaux else "" + if chemin.startswith("script/"): + self.assertTrue( + os.path.isfile(os.path.join(REPO, chemin)), + f"{controle['key']} → {chemin}", + ) + + class TestTheVerdictsSection(unittest.TestCase): """Ce que les contrôles SQL ne peuvent structurellement pas voir.