[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:
parent
c95cf2e9d8
commit
33cf04c7f9
2 changed files with 61 additions and 18 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue