213 lines
8 KiB
Python
213 lines
8 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
|
|
|
||
|
|
"""Champs hors module : l'attribution et le rendu, sans base.
|
||
|
|
|
||
|
|
Ce qui décide d'un blocage vit dans `collect()`, qui a besoin de PostgreSQL.
|
||
|
|
Il s'éprouve sur une base synthétique portant les cinq cas qui comptent :
|
||
|
|
|
||
|
|
* un champ Studio dont la colonne existe — rien à signaler ;
|
||
|
|
* un champ fait main SANS colonne — bloquant, le registre ne chargerait pas ;
|
||
|
|
* un one2many non stocké — PAS un bloquant, il n'a jamais de colonne ;
|
||
|
|
* un champ sur `ir.actions.act_window`, dont la table est `ir_act_window` —
|
||
|
|
`replace('.', '_')` crierait à la colonne manquante ;
|
||
|
|
* un many2one vers un modèle absent — bloquant.
|
||
|
|
|
||
|
|
Attendu : 6 champs, 2 bloquants, 1 modèle manuel. Rejoué ensuite sur la forme
|
||
|
|
12.0 — `company_dependent` retirée, `field_description` en text, pas de
|
||
|
|
`ir_model_fields_selection` — pour la même sortie.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import unittest
|
||
|
|
|
||
|
|
from script.analyse import analyse_custom_field as A
|
||
|
|
from script.todo import todo_i18n
|
||
|
|
|
||
|
|
|
||
|
|
def field(**override):
|
||
|
|
row = {
|
||
|
|
"id": 1,
|
||
|
|
"model": "res.partner",
|
||
|
|
"name": "x_champ",
|
||
|
|
"ttype": "char",
|
||
|
|
"relation": None,
|
||
|
|
"store": True,
|
||
|
|
"xmlid_modules": None,
|
||
|
|
"origin": "handmade",
|
||
|
|
"blocker": None,
|
||
|
|
}
|
||
|
|
row.update(override)
|
||
|
|
return row
|
||
|
|
|
||
|
|
|
||
|
|
class TestFieldOrigin(unittest.TestCase):
|
||
|
|
def test_no_xmlid_at_all_means_hand_made(self):
|
||
|
|
# Un champ créé en mode développeur n'a AUCUNE ligne ir_model_data.
|
||
|
|
# C'est ce qui le distingue, pas son nom : il s'appelle « x_… » lui
|
||
|
|
# aussi.
|
||
|
|
self.assertEqual(A.field_origin(field(xmlid_modules=None)), "handmade")
|
||
|
|
self.assertEqual(A.field_origin(field(xmlid_modules=[])), "handmade")
|
||
|
|
|
||
|
|
def test_studio_module_means_studio(self):
|
||
|
|
self.assertEqual(
|
||
|
|
A.field_origin(field(xmlid_modules=["studio_customization"])),
|
||
|
|
"studio",
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_studio_seen_among_several_xmlids(self):
|
||
|
|
# Un champ peut porter plusieurs identifiants externes. Une jointure
|
||
|
|
# plate n'en rendrait qu'un, choisi au hasard : Studio passerait
|
||
|
|
# inaperçu une fois sur deux. D'où l'agrégat côté SQL.
|
||
|
|
self.assertEqual(
|
||
|
|
A.field_origin(
|
||
|
|
field(xmlid_modules=["aaa", "studio_customization"])
|
||
|
|
),
|
||
|
|
"studio",
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_a_module_xmlid_means_a_module(self):
|
||
|
|
self.assertEqual(
|
||
|
|
A.field_origin(field(xmlid_modules=["mon_module"])), "module"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_the_x_studio_prefix_is_never_the_only_signal(self):
|
||
|
|
# Le préfixe ressemble à Studio mais ne prouve rien : sans identifiant
|
||
|
|
# externe, le champ a été fait à la main.
|
||
|
|
self.assertEqual(
|
||
|
|
A.field_origin(field(name="x_studio_faux", xmlid_modules=None)),
|
||
|
|
"handmade",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestLabels(unittest.TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
|
||
|
|
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
|
||
|
|
# travail et laisse la langue changée pour tout ce qui suit —
|
||
|
|
# `_current_lang = None` ne défait que la mémoïsation, pas le
|
||
|
|
# fichier, et la résolution suivante relit celui-ci. On écrit donc
|
||
|
|
# la mémoïsation directement, et on rend la valeur trouvée.
|
||
|
|
self.addCleanup(
|
||
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||
|
|
)
|
||
|
|
todo_i18n._current_lang = "en"
|
||
|
|
|
||
|
|
def test_every_origin_has_a_label(self):
|
||
|
|
for name in ("studio", "handmade", "module"):
|
||
|
|
self.assertNotEqual(A.origin_label(name), name)
|
||
|
|
|
||
|
|
def test_every_blocker_has_a_label(self):
|
||
|
|
for name in (
|
||
|
|
"missing_column",
|
||
|
|
"dangling_relation",
|
||
|
|
"model_gone",
|
||
|
|
"table_unknown",
|
||
|
|
):
|
||
|
|
self.assertNotEqual(A.blocker_label(name), name)
|
||
|
|
|
||
|
|
def test_unknown_key_falls_back_to_itself(self):
|
||
|
|
self.assertEqual(A.origin_label("inconnu"), "inconnu")
|
||
|
|
|
||
|
|
|
||
|
|
class TestNoColumnTypes(unittest.TestCase):
|
||
|
|
def test_to_many_fields_have_no_column_by_design(self):
|
||
|
|
# Le faux positif à ne pas produire : un one2many n'a jamais de
|
||
|
|
# colonne, il vit dans une table de relation. Le compter manquant
|
||
|
|
# ferait un bloquant sur chaque relation d'une base ordinaire.
|
||
|
|
self.assertIn("one2many", A.NO_COLUMN_TYPES)
|
||
|
|
self.assertIn("many2many", A.NO_COLUMN_TYPES)
|
||
|
|
self.assertNotIn("char", A.NO_COLUMN_TYPES)
|
||
|
|
self.assertNotIn("many2one", A.NO_COLUMN_TYPES)
|
||
|
|
|
||
|
|
|
||
|
|
class TestRender(unittest.TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
# PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un
|
||
|
|
# fichier suivi par git. Un test qui l'appelle modifie l'arbre de
|
||
|
|
# travail et laisse la langue changée pour tout ce qui suit —
|
||
|
|
# `_current_lang = None` ne défait que la mémoïsation, pas le
|
||
|
|
# fichier, et la résolution suivante relit celui-ci. On écrit donc
|
||
|
|
# la mémoïsation directement, et on rend la valeur trouvée.
|
||
|
|
self.addCleanup(
|
||
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
||
|
|
)
|
||
|
|
todo_i18n._current_lang = "en"
|
||
|
|
|
||
|
|
def data(self, **override):
|
||
|
|
fields = [
|
||
|
|
field(id=1, name="x_studio_code", origin="studio"),
|
||
|
|
field(
|
||
|
|
id=2,
|
||
|
|
name="x_fait_main",
|
||
|
|
origin="handmade",
|
||
|
|
blocker="missing_column",
|
||
|
|
),
|
||
|
|
]
|
||
|
|
data = {
|
||
|
|
"tool": "analyse_custom_field",
|
||
|
|
"version": 1,
|
||
|
|
"database": "prod_18",
|
||
|
|
"odoo_version": "18.0.1.3",
|
||
|
|
"n_fields": 2,
|
||
|
|
"n_models": 1,
|
||
|
|
"counts": {
|
||
|
|
"studio": 1,
|
||
|
|
"handmade": 1,
|
||
|
|
"module": 0,
|
||
|
|
"blockers": 1,
|
||
|
|
"models": 1,
|
||
|
|
},
|
||
|
|
"fields": fields,
|
||
|
|
"models": [{"model": "x_contrat", "description": "Contrat"}],
|
||
|
|
"blockers": [fields[1]],
|
||
|
|
}
|
||
|
|
data.update(override)
|
||
|
|
return data
|
||
|
|
|
||
|
|
def test_blockers_come_first_and_say_why(self):
|
||
|
|
out = A.render(self.data())
|
||
|
|
self.assertIn("Blocking (1)", out)
|
||
|
|
self.assertIn("stored, but its column is missing", out)
|
||
|
|
self.assertLess(out.index("Blocking"), out.index("To carry over"))
|
||
|
|
|
||
|
|
def test_says_the_registry_will_not_load(self):
|
||
|
|
self.assertIn("stops the registry from loading", A.render(self.data()))
|
||
|
|
|
||
|
|
def test_no_blocker_block_when_there_is_none(self):
|
||
|
|
data = self.data(blockers=[])
|
||
|
|
data["counts"]["blockers"] = 0
|
||
|
|
self.assertNotIn("Blocking", A.render(data))
|
||
|
|
|
||
|
|
def test_counts_say_how_many_of_the_list_are_blocking(self):
|
||
|
|
# Les bloquants sont AUSSI dans la liste à reporter : le dire, sinon
|
||
|
|
# deux populations dont les comptes ne s'additionnent pas.
|
||
|
|
self.assertIn("(2, 1 blocking)", A.render(self.data()))
|
||
|
|
|
||
|
|
def test_clean_database_says_so(self):
|
||
|
|
data = self.data(
|
||
|
|
n_fields=0, n_models=0, fields=[], models=[], blockers=[]
|
||
|
|
)
|
||
|
|
out = A.render(data)
|
||
|
|
self.assertIn("No field or model was added outside a module.", out)
|
||
|
|
|
||
|
|
def test_says_nothing_will_recreate_them(self):
|
||
|
|
# La raison d'être de l'outil : ces champs ne sont dans aucun fichier.
|
||
|
|
self.assertIn("no module will recreate", A.render(self.data()))
|
||
|
|
|
||
|
|
def test_hint_is_absent_in_verbose_and_in_the_menu(self):
|
||
|
|
self.assertIn("Use -v", A.render(self.data()))
|
||
|
|
self.assertNotIn("Use -v", A.render(self.data(), verbose=True))
|
||
|
|
self.assertNotIn("Use -v", A.render(self.data(), hints=False))
|
||
|
|
|
||
|
|
def test_french_differs(self):
|
||
|
|
english = A.render(self.data())
|
||
|
|
todo_i18n._current_lang = "fr" # cf. setUp : pas de persistance
|
||
|
|
french = A.render(self.data())
|
||
|
|
self.assertIn("Fait à la main", french)
|
||
|
|
self.assertNotEqual(english, french)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|