[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,
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",

View file

@ -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.