[REM] résidus : retirer le contrôle res_lang.active à NULL

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)
This commit is contained in:
Mathieu Benoit 2026-08-27 02:15:30 -04:00
parent c95cf2e9d8
commit 33cf04c7f9
2 changed files with 61 additions and 18 deletions

View file

@ -27,7 +27,6 @@ finit par être ignoré en entier.
Ne restent ici que les constats qui se jugent SANS point de comparaison, 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 : 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, ir_model_relation sans table 0 → 68 la table m2m est nommée,
elle n'existe pas elle n'existe pas
index doublés convention 17 0 → 414 Odoo 17 renomme, sans 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 liste de prix par défaut absente 0 → 1 `product` installé, son
xmlid pas là 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. 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 Un quatrième a été RETIRÉ après vérification
-------------------------------------------- --------------------------------------------
« ir_model_relation nomme une table absente » : 0 avant, 68 après, le « ir_model_relation nomme une table absente » : 0 avant, 68 après, le
@ -107,15 +127,6 @@ CONTROLES = (
"gravity": "broken", "gravity": "broken",
"repair": "script/todo/todo_upgrade.py (uninstall_one_by_one)", "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", "key": "duplicate_index",
"title": "Indexes duplicated by the Odoo 17 renaming", "title": "Indexes duplicated by the Odoo 17 renaming",
@ -189,7 +200,7 @@ CONTROLES = (
"sql": "SELECT count(*) FROM ir_model_data d WHERE NOT EXISTS" "sql": "SELECT count(*) FROM ir_model_data d WHERE NOT EXISTS"
" (SELECT 1 FROM ir_model m WHERE m.model = d.model)", " (SELECT 1 FROM ir_model m WHERE m.model = d.model)",
"gravity": "broken", "gravity": "broken",
"repair": "script/analyse/database_cleanup.py", "repair": "script/odoo/migration/database_cleanup.py",
}, },
{ {
"key": "attachment_field_gone", "key": "attachment_field_gone",

View file

@ -24,9 +24,8 @@ import os
import sys import sys
import unittest import unittest
sys.path.insert( REPO = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
0, 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 check_migration_residue as residue # noqa: E402
from script.analyse import monitoring # 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): def test_broken_is_read_before_watch(self):
resultats = {c["key"]: 0 for c in residue.CONTROLES} resultats = {c["key"]: 0 for c in residue.CONTROLES}
resultats["duplicate_index"] = 400 # watch resultats["duplicate_index"] = 400 # watch
resultats["lang_active_null"] = 1 # broken resultats["stuck_modules"] = 1 # broken
trouve, _ = residue.judge(resultats) 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( def test_a_check_that_could_not_run_is_not_a_check_that_found_nothing(
self, self,
@ -247,7 +246,7 @@ class TestWhatAMigrationLeftBehind(unittest.TestCase):
def test_the_exit_code_separates_nothing_from_something(self): def test_the_exit_code_separates_nothing_from_something(self):
vide = {c["key"]: 0 for c in residue.CONTROLES} vide = {c["key"]: 0 for c in residue.CONTROLES}
self.assertEqual(residue.judge(vide)[0], []) 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]) self.assertTrue(residue.judge(plein)[0])
@ -451,6 +450,39 @@ def verdict(**champs):
return brut 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): class TestTheVerdictsSection(unittest.TestCase):
"""Ce que les contrôles SQL ne peuvent structurellement pas voir. """Ce que les contrôles SQL ne peuvent structurellement pas voir.