From ff8b6bcaf90d3a968b9653db739d22cae36b8ef2 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 21 Aug 2026 05:55:54 -0400 Subject: [PATCH] [ADD] migration quality: lay OpenUpgrade's declared changes over the real ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report counted + and -. A field that stops being stored is neither: it loses its column and keeps its existence. Read as a loss it cries wolf every step; ignored it would mask a real one. OpenUpgrade already knows, in the upgrade_analysis.txt files the checkout carries -- the same source the OCA coverage pages are generated from, read locally, at the checkout's exact version. Three categories keep the signal from drowning: a field whose model went away is not a second finding (544 of them on one real step), a field of a module OpenUpgrade does not analyse was never its business, and the rest is grouped by field name -- __last_update counted itself 391 times. --- FR --- Le rapport comptait des + et des -. Un champ qui cesse d'être stocké n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu comme une perte, il alarme à chaque palier ; ignoré, il en masquerait une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que porte le checkout -- la source des pages de couverture de l'OCA, lue localement, à la version exacte. Trois catégories évitent que le signal se noie : un champ dont le modèle a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le reste est groupé par nom -- __last_update se comptait 391 fois. Assisted-by: Claude Opus 5 --- script/analyse/check_migration_quality.py | 222 +++++++++++++++++- script/analyse/openupgrade_analysis.py | 267 ++++++++++++++++++++++ script/todo/todo_i18n.py | 64 ++++++ test/test_check_migration_quality.py | 155 +++++++++++++ test/test_openupgrade_analysis.py | 186 +++++++++++++++ 5 files changed, 892 insertions(+), 2 deletions(-) create mode 100644 script/analyse/openupgrade_analysis.py create mode 100644 test/test_openupgrade_analysis.py diff --git a/script/analyse/check_migration_quality.py b/script/analyse/check_migration_quality.py index f69f3c7..4b5123d 100755 --- a/script/analyse/check_migration_quality.py +++ b/script/analyse/check_migration_quality.py @@ -218,6 +218,21 @@ def inspect(database): "SELECT model || '.' || name FROM ir_model_fields ORDER BY 1", ) etat["field"] = sorted(ligne[0] for ligne in champs or []) + # À quel module appartient chaque champ. Sans cela, un champ d'un + # module OCA compterait comme « non déclaré par OpenUpgrade » — + # littéralement vrai, et trompeur : OpenUpgrade n'analyse que le cœur + # d'Odoo, il ne pouvait rien en dire. + origines = run_psql( + database, + "SELECT f.model || '.' || f.name, d.module FROM ir_model_fields f" + " JOIN ir_model_data d ON d.model = 'ir.model.fields'" + " AND d.res_id = f.id", + ) + dct_origine = {} + for ligne in origines or []: + if len(ligne) >= 2: + dct_origine.setdefault(ligne[0], set()).add(ligne[1]) + etat["field_module"] = {cle: sorted(v) for cle, v in dct_origine.items()} # Les copies COW par leur CLÉ : c'est elle qu'on réinitialise, et # c'est par elle qu'on les retrouve d'une version à l'autre. copies = run_psql( @@ -577,6 +592,91 @@ def as_version(etat): return None +_CACHE_DECLARE = {} + + +def declared_index(version): + """L'index OpenUpgrade du palier, mis en cache par version. + + Lire 402 fichiers coûte 0,04 s — négligeable une fois, pas six fois + par rapport quand on compare tous les paliers d'affilée. + """ + if version not in _CACHE_DECLARE: + from script.analyse import openupgrade_analysis + + _CACHE_DECLARE[version] = openupgrade_analysis.load(version) + return _CACHE_DECLARE[version] + + +def overlay_declared( + version, modeles_perdus, champs_perdus, origine_champ=None +): + """Le THÉORIQUE posé sur le PRATIQUE : qui avait été annoncé ? + + Une perte déclarée par OpenUpgrade n'est pas un incident, c'est le + palier qui fait son travail. Les séparer transforme « 300 champs + disparus » — un chiffre devant lequel on ne peut rien faire — en une + poignée de champs que personne n'a annoncés, et qu'il faut regarder. + + Le cas qui justifie tout : « devenu calculé ». Le champ EXISTE + toujours ; il n'a plus de colonne. Ni gain ni perte : transformation. + """ + from script.analyse import openupgrade_analysis + + if not version: + return {"available": False, "reason": "version"} + index = declared_index(version) + if not index["modules"]: + return {"available": False, "reason": "missing"} + + modeles = {"obsolete": [], "renamed": [], "undeclared": []} + for nom in modeles_perdus: + change = openupgrade_analysis.model_change(nom, index) + if not change: + modeles["undeclared"].append(nom) + elif change[0] == "renamed": + modeles["renamed"].append((nom, change[1])) + else: + modeles["obsolete"].append(nom) + + champs = { + "del": [], + "unstored": [], + "company_dependent": [], + "moved": [], + "model_gone": [], + "not_analysed": [], + "undeclared": [], + } + partis = tuple(f"{nom}." for nom in modeles_perdus) + analyses = openupgrade_analysis.analysed_modules(version) + for cle in champs_perdus: + change = openupgrade_analysis.field_change(cle, index) + if change: + if change[0] == "moved": + champs["moved"].append((cle, change[1])) + else: + champs[change[0]].append(cle) + continue + # Un champ dont le MODÈLE a disparu n'est pas une trouvaille de + # plus : c'est la même, comptée une fois par champ. Sur un palier + # réel cela triplait la liste et noyait le vrai signal. + if partis and cle.startswith(partis): + champs["model_gone"].append(cle) + continue + origines = set((origine_champ or {}).get(cle) or []) + if origines and not (origines & analyses): + champs["not_analysed"].append(cle) + continue + champs["undeclared"].append(cle) + return { + "available": True, + "modules": index["modules"], + "models": modeles, + "fields": champs, + } + + def explain_loss(table, version): """Ce qu'Odoo a fait de cette table à cette version, ou None. @@ -635,14 +735,22 @@ def compare(avant, apres): # Les plus grosses pertes EN TÊTE : on attaque une liste de # cinquante-sept par le haut, pas par ordre alphabétique. lignes_perdues.sort(key=lambda item: -(item[1] - item[2])) + champs_perdus = sorted(champ_avant - champ_apres) + modeles_perdus = sorted(mod_avant - mod_apres) return { - "fields_lost": sorted(champ_avant - champ_apres), + "declared": overlay_declared( + version, + modeles_perdus, + champs_perdus, + avant.get("field_module"), + ), + "fields_lost": champs_perdus, "fields_gained": sorted(champ_apres - champ_avant), "cow_lost": sorted(cow_avant - cow_apres), "cow_gained": sorted(cow_apres - cow_avant), "modules_lost": sorted(inst_avant - inst_apres), "modules_gained": sorted(inst_apres - inst_avant), - "models_lost": sorted(mod_avant - mod_apres), + "models_lost": modeles_perdus, "models_gained": sorted(mod_apres - mod_avant), "rows_lost": lignes_perdues, "rows_gained": lignes_gagnees, @@ -735,6 +843,115 @@ def overall(lst_snapshot): return compare(presents[0], presents[-1]) +DECLARE_MODELES = ( + ("obsolete", "declared obsolete", "dim"), + ("renamed", "renamed by Odoo", "dim"), + ("undeclared", "NOT declared by OpenUpgrade", "warn"), +) + +# L'ordre range du plus rassurant au plus inquiétant : ce qu'on doit +# regarder finit la liste, donc reste sous les yeux. +DECLARE_CHAMPS = ( + ("del", "declared removed", "dim"), + ("moved", "moved to another module", "dim"), + ("company_dependent", "became a per-company jsonb column", "dim"), + ( + "unstored", + "computed now — the field remains, the column does not", + "ok", + ), + ("model_gone", "their model went away too", "dim"), + ("not_analysed", "in a module OpenUpgrade does not analyse", "dim"), + ("undeclared", "NOT declared by OpenUpgrade", "warn"), +) + + +def group_by_field_name(cles): + """[(nom, combien, exemple)] par nom de champ, les plus nombreux d'abord. + + Un champ retiré d'un MIXIN disparaît de tous les modèles qui en + héritent : `__last_update` s'est ainsi compté 580 fois au palier 17. + C'est UN changement. Listé modèle par modèle, il remplissait l'écran + et cachait les vingt autres ; groupé, il tient sur une ligne. + """ + par_nom = {} + for cle in cles: + nom = cle.rsplit(".", 1)[-1] if "." in cle else cle + par_nom.setdefault(nom, []).append(cle) + return sorted( + ((nom, len(lst), sorted(lst)[0]) for nom, lst in par_nom.items()), + key=lambda item: (-item[1], item[0]), + ) + + +def render_declared(declare, colour, limit=8): + """Le théorique posé sur le pratique. Rien si on ne peut pas le lire. + + Se taire quand l'analyse manque plutôt que d'afficher des zéros : + « 0 déclaré » et « analyse introuvable » se ressemblent à l'œil et ne + veulent pas du tout dire la même chose. + """ + from script.todo.migration_status import paint + + if not declare.get("available"): + if declare.get("reason") == "missing": + return [ + f" {paint('⇄', 'dim', colour)}" + f" {t('No OpenUpgrade analysis for this step.')}" + ] + return [] + lignes = [ + f" {paint('⇄', 'dim', colour)} {t('Against OpenUpgrade')}" + f" ({declare['modules']} {t('core module(s) analysed')})" + ] + for titre, groupes, table in ( + (t("model(s) lost"), declare["models"], DECLARE_MODELES), + (t("field(s) lost"), declare["fields"], DECLARE_CHAMPS), + ): + total = sum(len(groupes.get(cle) or []) for cle, _l, _c in table) + if not total: + continue + lignes.append(f" {total} {titre} :") + for cle, libelle, teinte in table: + trouves = groupes.get(cle) or [] + if not trouves: + continue + lignes.append( + f" {paint(str(len(trouves)).rjust(5), teinte, colour)}" + f" {t(libelle)}" + ) + if cle not in ("undeclared", "unstored"): + # Ces deux-là seuls méritent des noms : l'un parce qu'il + # faut aller voir, l'autre parce qu'on croirait à une + # perte. Nommer les autres noierait le rapport. + continue + plats = [ + element[0] if isinstance(element, tuple) else element + for element in trouves + ] + if table is DECLARE_CHAMPS: + groupes_nom = group_by_field_name(plats) + for nom, combien, exemple in groupes_nom[:limit]: + suffixe = ( + f" × {combien} {t('model(s)')}" if combien > 1 else "" + ) + montre = exemple if combien == 1 else nom + lignes.append(f" {montre}{suffixe}") + if len(groupes_nom) > limit: + lignes.append( + f" … {len(groupes_nom) - limit}" + f" {t('other field name(s)')}" + ) + else: + for nom in plats[:limit]: + lignes.append(f" {nom}") + if len(plats) > limit: + lignes.append( + f" … {len(plats) - limit} {t('more')}" + ) + return lignes + + def render_text(lst_snapshot, colour=None, limit=8): """Le rapport complet. C'est aussi le repli du plein écran.""" from script.todo.migration_status import paint, supports_colour @@ -868,6 +1085,7 @@ def render_compare(diff, colour, limit=8): lignes.append(f" {connu['why']}") if len(connues) > limit: lignes.append(f" … {len(connues) - limit} {t('more')}") + lignes.extend(render_declared(diff.get("declared") or {}, colour, limit)) delta = diff["delta"] lignes.append( " " diff --git a/script/analyse/openupgrade_analysis.py b/script/analyse/openupgrade_analysis.py new file mode 100644 index 0000000..ff27f61 --- /dev/null +++ b/script/analyse/openupgrade_analysis.py @@ -0,0 +1,267 @@ +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce qu'OpenUpgrade DÉCLARE changer, pour l'opposer à ce qui a changé. + +Le rapport de qualité comptait des « + » et des « − ». C'est vrai mais +insuffisant : un champ qui cesse d'être stocké disparaît de la base sans +rien perdre — il est calculé maintenant. Compté comme une perte, il +alarme ; ignoré, il masquerait une vraie perte. Il faut un troisième +état, et OpenUpgrade le connaît déjà. + +Chaque module cœur d'Odoo porte, dans OpenUpgrade, un fichier +`upgrade_analysis.txt` qui liste ce que le palier change : + + ---Models in module 'account'--- + obsolete model account.unreconcile [transient] + new model product.combo (renamed from pos.combo in module point_of_sale) + ---Fields in module 'account'--- + account / account.account / code (char) : not stored anymore + account / account.cash.rounding / loss_account_id (many2one) : needs + conversion to v18-style company dependent + +C'est la même information que la page « coverage analysis » publiée par +l'OCA — elle est engendrée depuis ces fichiers — mais lue dans le dépôt, +donc sans réseau, et à la version exacte du checkout. + +Ce qu'on n'y trouvera pas +------------------------- +Ces fichiers ne couvrent que les modules CŒUR d'Odoo. Un champ d'un +module OCA ou maison n'y sera jamais, et son absence ne veut donc pas +dire « perte non déclarée » au même titre. Le rapport le dit plutôt que +de laisser conclure. +""" + +import os +import re + +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) + +# `new model x.y [transient]` ou `new model x.y (renamed from a.b in module m)` +MODELE_NEUF = re.compile( + r"^new model (\S+)(?:\s+\(renamed from (\S+) in module (\S+)\))?" +) +MODELE_OBSOLETE = re.compile(r"^obsolete model (\S+)") +# `module / model / champ (type) : description` — le type est absent sur +# les pseudo-champs comme `_order`, et le « : » n'est pas toujours espacé. +CHAMP = re.compile( + r"^(\S+)\s+/\s+(\S+)\s+/\s+(\S+)\s*(?:\(([^)]*)\))?\s*:\s*(.*)$" +) +# `module is now 'sale' ('point_of_sale')` +DEPLACE = re.compile(r"module is now '([^']+)'") + +SECTIONS = { + "Models": "model", + "Fields": "field", + "XML records": "xml", +} + + +def scripts_dir(version, root=None): + """Où vivent les analyses pour ce palier d'ARRIVÉE.""" + return os.path.join( + root or REPO_ROOT, + f"odoo{version}.0", + "OCA_OpenUpgrade", + "openupgrade_scripts", + "scripts", + ) + + +def analysis_files(version, root=None): + """Les `upgrade_analysis.txt` de ce palier, triés. + + On filtre sur le dossier de version : à côté des `18.0.1.0` vivent un + `tests` et, en 14, un `0.0` — les lire mélangerait les paliers. + """ + base = scripts_dir(version, root) + trouves = [] + if not os.path.isdir(base): + return trouves + prefixe = f"{version}." + for module in sorted(os.listdir(base)): + dossier = os.path.join(base, module) + if not os.path.isdir(dossier): + continue + for release in sorted(os.listdir(dossier)): + if not release.startswith(prefixe): + continue + chemin = os.path.join(dossier, release, "upgrade_analysis.txt") + if os.path.isfile(chemin): + trouves.append(chemin) + return trouves + + +_CACHE_MODULES = {} + + +def analysed_modules(version, root=None): + """Les modules cœur qu'OpenUpgrade analyse à ce palier. + + Les noms de dossiers SONT la liste. Elle sert à distinguer « aucune + déclaration » de « hors de son champ » : OpenUpgrade ne dit rien d'un + module OCA, et le compter comme non déclaré accuserait à tort. + """ + if version in _CACHE_MODULES: + return _CACHE_MODULES[version] + base = scripts_dir(version, root) + trouves = set() + if os.path.isdir(base): + trouves = { + nom + for nom in os.listdir(base) + if os.path.isdir(os.path.join(base, nom)) + } + if root is None: + _CACHE_MODULES[version] = trouves + return trouves + + +def blank_index(): + return { + "models_new": set(), + "models_obsolete": set(), + "models_renamed": {}, + "fields_new": set(), + "fields_del": set(), + "fields_unstored": set(), + "fields_stored": set(), + "fields_moved": {}, + "fields_company_dependent": set(), + "fields_other": {}, + "xml_new": 0, + "xml_del": 0, + "modules": 0, + } + + +def classify(description): + """La catégorie d'un changement de champ, d'après sa description. + + L'ordre compte : « NEW relation: … » commence par NEW, et + « not stored anymore » contient « stored ». On teste donc du plus + précis au plus général plutôt que par appartenance de sous-chaîne. + """ + texte = description.strip() + if texte.startswith("NEW"): + return "new" + if texte.startswith("DEL"): + return "del" + if texte.startswith("not stored anymore"): + return "unstored" + if texte.startswith("is now stored"): + return "stored" + if "needs conversion to" in texte and "company dependent" in texte: + return "company_dependent" + if DEPLACE.search(texte): + return "moved" + return "other" + + +def parse(texte, index=None): + """Lire UN fichier d'analyse dans l'index (créé au besoin).""" + index = blank_index() if index is None else index + section = None + for ligne in texte.splitlines(): + entete = re.match(r"^---(.+?) in module '([^']+)'---", ligne) + if entete: + section = SECTIONS.get(entete.group(1)) + continue + if not ligne.strip(): + continue + if section == "model": + _read_model(ligne, index) + elif section == "field": + _read_field(ligne, index) + elif section == "xml": + if ligne.startswith("NEW"): + index["xml_new"] += 1 + elif ligne.startswith("DEL"): + index["xml_del"] += 1 + return index + + +def _read_model(ligne, index): + trouve = MODELE_NEUF.match(ligne) + if trouve: + index["models_new"].add(trouve.group(1)) + if trouve.group(2): + # Le renommage se lit du côté du NOUVEAU modèle : c'est là + # qu'OpenUpgrade note d'où il vient. L'ancien n'apparaît nulle + # part comme « obsolete », donc sans cette ligne sa + # disparition passerait pour une perte sèche. + index["models_renamed"][trouve.group(2)] = trouve.group(1) + return + trouve = MODELE_OBSOLETE.match(ligne) + if trouve: + index["models_obsolete"].add(trouve.group(1)) + + +def _read_field(ligne, index): + trouve = CHAMP.match(ligne) + if not trouve: + return + _module, modele, champ, _type, description = trouve.groups() + if champ.startswith("_"): + # `_order`, `_sql_constraints` : ce ne sont pas des champs, et les + # compter fausserait le rapprochement avec `ir_model_fields`. + return + cle = f"{modele}.{champ}" + genre = classify(description) + if genre == "new": + index["fields_new"].add(cle) + elif genre == "del": + index["fields_del"].add(cle) + elif genre == "unstored": + index["fields_unstored"].add(cle) + elif genre == "stored": + index["fields_stored"].add(cle) + elif genre == "company_dependent": + index["fields_company_dependent"].add(cle) + elif genre == "moved": + index["fields_moved"][cle] = DEPLACE.search(description).group(1) + else: + index["fields_other"].setdefault(cle, []).append(description.strip()) + + +def load(version, root=None): + """L'index complet d'un palier. `modules` à 0 = rien de lisible.""" + index = blank_index() + for chemin in analysis_files(version, root): + try: + with open(chemin, "r", encoding="utf-8") as handle: + parse(handle.read(), index) + except OSError: + continue + index["modules"] += 1 + return index + + +def model_change(nom, index): + """Ce qu'OpenUpgrade déclare pour ce modèle disparu, ou None.""" + if nom in index["models_renamed"]: + return ("renamed", index["models_renamed"][nom]) + if nom in index["models_obsolete"]: + return ("obsolete", None) + return None + + +def field_change(cle, index): + """Ce qu'OpenUpgrade déclare pour ce champ disparu, ou None. + + « unstored » est le cas qui justifie tout l'outil : le champ EXISTE + toujours, il n'a plus de colonne parce qu'il est calculé. Ce n'est ni + un gain ni une perte — c'est une transformation, et la confondre avec + une perte fait crier au loup à chaque palier. + """ + if cle in index["fields_del"]: + return ("del", None) + if cle in index["fields_unstored"]: + return ("unstored", None) + if cle in index["fields_company_dependent"]: + return ("company_dependent", None) + if cle in index["fields_moved"]: + return ("moved", index["fields_moved"][cle]) + return None diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 4998b01..a7ff7cf 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5891,6 +5891,70 @@ TRANSLATIONS = { "fr": "ligne(s)", "en": "row(s)", }, + "Against OpenUpgrade": { + "fr": "Face à OpenUpgrade", + "en": "Against OpenUpgrade", + }, + "NOT declared by OpenUpgrade": { + "fr": "NON déclarés par OpenUpgrade", + "en": "NOT declared by OpenUpgrade", + }, + "No OpenUpgrade analysis for this step.": { + "fr": "Pas d'analyse OpenUpgrade pour ce palier.", + "en": "No OpenUpgrade analysis for this step.", + }, + "became a per-company jsonb column": { + "fr": "passés en colonne jsonb par société", + "en": "became a per-company jsonb column", + }, + "computed now — the field remains, the column does not": { + "fr": "devenus calculés — le champ demeure, la colonne non", + "en": "computed now — the field remains, the column does not", + }, + "core module(s) analysed": { + "fr": "module(s) cœur analysé(s)", + "en": "core module(s) analysed", + }, + "declared obsolete": { + "fr": "déclarés obsolètes", + "en": "declared obsolete", + }, + "declared removed": { + "fr": "déclarés supprimés", + "en": "declared removed", + }, + "field(s) lost": { + "fr": "champ(s) perdu(s)", + "en": "field(s) lost", + }, + "model(s) lost": { + "fr": "modèle(s) perdu(s)", + "en": "model(s) lost", + }, + "moved to another module": { + "fr": "déplacés vers un autre module", + "en": "moved to another module", + }, + "renamed by Odoo": { + "fr": "renommés par Odoo", + "en": "renamed by Odoo", + }, + "their model went away too": { + "fr": "leur modèle a disparu aussi", + "en": "their model went away too", + }, + "in a module OpenUpgrade does not analyse": { + "fr": "dans un module qu'OpenUpgrade n'analyse pas", + "en": "in a module OpenUpgrade does not analyse", + }, + "model(s)": { + "fr": "modèle(s)", + "en": "model(s)", + }, + "other field name(s)": { + "fr": "autre(s) nom(s) de champ", + "en": "other field name(s)", + }, "Census": { "fr": "Recensement", "en": "Census", diff --git a/test/test_check_migration_quality.py b/test/test_check_migration_quality.py index 0cf4dba..169d825 100644 --- a/test/test_check_migration_quality.py +++ b/test/test_check_migration_quality.py @@ -1175,5 +1175,160 @@ class TestItRefusesToNestItself(Base): self.assertIn("its own", source) +class TestTheOpenUpgradeOverlay(unittest.TestCase): + """Le théorique posé sur le pratique. + + Le rapport comptait des « + » et des « − ». Un champ devenu calculé + n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. + Sans troisième état, on alarmait à chaque palier. + """ + + INDEX = { + "models_new": {"product.combo"}, + "models_obsolete": {"account.unreconcile"}, + "models_renamed": {"pos.combo": "product.combo"}, + "fields_new": set(), + "fields_del": {"account.journal.secure_sequence_id"}, + "fields_unstored": {"account.account.code"}, + "fields_stored": set(), + "fields_moved": {"pos.combo.base_price": "product"}, + "fields_company_dependent": {"account.cash.rounding.loss_account_id"}, + "fields_other": {}, + "xml_new": 0, + "xml_del": 0, + "modules": 402, + } + + def setUp(self): + quality._CACHE_DECLARE[18] = self.INDEX + from script.analyse import openupgrade_analysis as oa + + self.vrai = oa.analysed_modules + oa.analysed_modules = lambda version, root=None: {"account", "sale"} + self.oa = oa + + def tearDown(self): + quality._CACHE_DECLARE.pop(18, None) + self.oa.analysed_modules = self.vrai + + def pose(self, modeles=(), champs=(), origines=None): + return quality.overlay_declared( + 18, list(modeles), list(champs), origines + ) + + def test_a_declared_obsolete_model_is_not_a_finding(self): + d = self.pose(modeles=["account.unreconcile"]) + self.assertEqual(d["models"]["obsolete"], ["account.unreconcile"]) + self.assertEqual(d["models"]["undeclared"], []) + + def test_a_renamed_model_names_its_destination(self): + d = self.pose(modeles=["pos.combo"]) + self.assertEqual( + d["models"]["renamed"], [("pos.combo", "product.combo")] + ) + + def test_an_unknown_model_stays_undeclared(self): + d = self.pose(modeles=["ma.bebelle"]) + self.assertEqual(d["models"]["undeclared"], ["ma.bebelle"]) + + def test_a_field_that_became_computed_is_its_own_category(self): + # NI perte NI gain : le champ demeure, la colonne non. C'est tout + # l'objet de l'ajout. + d = self.pose(champs=["account.account.code"]) + self.assertEqual(d["fields"]["unstored"], ["account.account.code"]) + self.assertEqual(d["fields"]["undeclared"], []) + + def test_declared_removals_and_moves_land_apart(self): + d = self.pose( + champs=[ + "account.journal.secure_sequence_id", + "pos.combo.base_price", + "account.cash.rounding.loss_account_id", + ] + ) + self.assertEqual( + d["fields"]["del"], ["account.journal.secure_sequence_id"] + ) + self.assertEqual( + d["fields"]["moved"], [("pos.combo.base_price", "product")] + ) + self.assertEqual( + d["fields"]["company_dependent"], + ["account.cash.rounding.loss_account_id"], + ) + + def test_a_field_whose_model_vanished_is_not_counted_again(self): + # 544 champs pour un seul modèle disparu, mesuré sur un vrai + # palier : listés un par un, ils cachaient les vingt vraies + # trouvailles. + d = self.pose( + modeles=["account.unreconcile"], + champs=["account.unreconcile.name"], + ) + self.assertEqual( + d["fields"]["model_gone"], ["account.unreconcile.name"] + ) + self.assertEqual(d["fields"]["undeclared"], []) + + def test_a_field_of_an_unanalysed_module_is_not_accused(self): + # OpenUpgrade n'analyse que le cœur : dire « non déclaré » d'un + # champ OCA est vrai à la lettre et faux en esprit. + d = self.pose( + champs=["x.mon_champ"], + origines={"x.mon_champ": ["mon_module_oca"]}, + ) + self.assertEqual(d["fields"]["not_analysed"], ["x.mon_champ"]) + self.assertEqual(d["fields"]["undeclared"], []) + + def test_a_core_field_with_no_declaration_SURVIVES_as_undeclared(self): + # Le signal ne doit pas se faire absorber par les catégories + # rassurantes : c'est la seule ligne qui demande un examen. + d = self.pose( + champs=["account.account.mystere"], + origines={"account.account.mystere": ["account"]}, + ) + self.assertEqual( + d["fields"]["undeclared"], ["account.account.mystere"] + ) + + def test_no_analysis_is_said_not_shown_as_zero(self): + # « 0 déclaré » et « analyse absente » se ressemblent à l'œil. + quality._CACHE_DECLARE[18] = dict(self.INDEX, modules=0) + d = self.pose(modeles=["x.y"]) + self.assertFalse(d["available"]) + self.assertEqual(d["reason"], "missing") + texte = "\n".join(quality.render_declared(d, False)) + self.assertIn( + todo_i18n.t("No OpenUpgrade analysis for this step."), texte + ) + + def test_an_unknown_version_declares_nothing_quietly(self): + d = quality.overlay_declared(None, ["x.y"], []) + self.assertFalse(d["available"]) + self.assertEqual(quality.render_declared(d, False), []) + + +class TestGroupingByFieldName(unittest.TestCase): + def test_one_mixin_field_is_one_finding(self): + # `__last_update` s'est compté 391 fois sur un vrai palier : c'est + # UN changement. + groupes = quality.group_by_field_name( + [f"modele{i}.zz_last_update" for i in range(391)] + + ["account.account.aa_autre"] + ) + self.assertEqual(groupes[0][0], "zz_last_update") + self.assertEqual(groupes[0][1], 391) + + def test_the_most_widespread_comes_first(self): + groupes = quality.group_by_field_name( + ["a.zzz", "b.zzz", "c.zzz", "d.aaa"] + ) + self.assertEqual([nom for nom, _n, _e in groupes], ["zzz", "aaa"]) + + def test_a_lone_field_keeps_its_full_key_as_example(self): + groupes = quality.group_by_field_name(["account.account.seul"]) + self.assertEqual(groupes[0], ("seul", 1, "account.account.seul")) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_openupgrade_analysis.py b/test/test_openupgrade_analysis.py new file mode 100644 index 0000000..839ad54 --- /dev/null +++ b/test/test_openupgrade_analysis.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Lire ce qu'OpenUpgrade déclare, sans le déformer. + +La catégorie qui porte tout l'outil est « unstored » : le champ EXISTE +encore, il n'a plus de colonne parce qu'il est calculé. Le ranger avec +les suppressions ferait crier au loup à chaque palier ; l'oublier +masquerait une vraie perte. +""" + +import os +import shutil +import sys +import tempfile +import unittest + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.analyse import openupgrade_analysis as oa # noqa: E402 + +EXEMPLE = """---Models in module 'account'--- +obsolete model account.unreconcile [transient] +new model account.code.mapping [sql_view] +new model product.combo (renamed from pos.combo in module point_of_sale) +---Fields in module 'account'--- +account / account.account / _order : _order is now 'code' ('id') +account / account.account / code (char) : not stored anymore +account / account.account / code_store (char) : NEW +account / account.journal / secure_sequence_id (many2one) : DEL relation: ir.sequence +account / account.account / company_id (many2one) : is now stored +account / account.cash.rounding / loss_account_id (many2one) : needs conversion to v18-style company dependent +account / pos.combo / base_price (float) : module is now 'product' ('point_of_sale') +account / account.journal / type (selection) : selection_keys added: [credit] +---XML records in module 'account'--- +NEW ir.ui.view: account.view_x +DEL ir.ui.view: account.view_y +DEL ir.ui.view: account.view_z +""" + + +class TestParsing(unittest.TestCase): + def setUp(self): + self.index = oa.parse(EXEMPLE) + + def test_obsolete_and_new_models(self): + self.assertIn("account.unreconcile", self.index["models_obsolete"]) + self.assertIn("account.code.mapping", self.index["models_new"]) + + def test_a_rename_is_read_from_the_NEW_side(self): + # OpenUpgrade ne note le renommage que sur le nouveau modèle ; + # l'ancien n'apparaît jamais comme « obsolete ». Sans cette + # lecture, sa disparition passerait pour une perte sèche. + self.assertEqual( + self.index["models_renamed"].get("pos.combo"), "product.combo" + ) + + def test_each_field_lands_in_its_category(self): + for cle, categorie in ( + ("account.account.code", "fields_unstored"), + ("account.account.code_store", "fields_new"), + ("account.journal.secure_sequence_id", "fields_del"), + ("account.account.company_id", "fields_stored"), + ( + "account.cash.rounding.loss_account_id", + "fields_company_dependent", + ), + ): + self.assertIn(cle, self.index[categorie], f"{cle} → {categorie}") + + def test_a_moved_field_keeps_its_new_module(self): + self.assertEqual( + self.index["fields_moved"].get("pos.combo.base_price"), "product" + ) + + def test_pseudo_fields_are_not_fields(self): + # `_order` n'est pas dans `ir_model_fields` : le compter fausserait + # le rapprochement avec la base. + for cle in list(self.index["fields_other"]) + list( + self.index["fields_new"] + ): + self.assertNotIn("._order", cle) + + def test_an_unclassified_change_is_kept_not_dropped(self): + # « selection_keys added » n'a pas de catégorie : le perdre + # silencieusement ferait mentir les totaux. + self.assertIn("account.journal.type", self.index["fields_other"]) + + def test_xml_records_are_counted(self): + self.assertEqual(self.index["xml_new"], 1) + self.assertEqual(self.index["xml_del"], 2) + + +class TestClassify(unittest.TestCase): + def test_new_with_details_is_still_new(self): + self.assertEqual( + oa.classify("NEW relation: res.company, required"), "new" + ) + + def test_not_stored_is_not_confused_with_stored(self): + # « not stored anymore » CONTIENT « stored » : tester par + # appartenance de sous-chaîne inverserait le sens. + self.assertEqual(oa.classify("not stored anymore"), "unstored") + self.assertEqual(oa.classify("is now stored"), "stored") + + def test_an_unknown_wording_falls_back_to_other(self): + self.assertEqual(oa.classify("something brand new"), "other") + + +class TestFindingTheFiles(unittest.TestCase): + def setUp(self): + self.racine = tempfile.mkdtemp() + base = os.path.join( + self.racine, + "odoo18.0", + "OCA_OpenUpgrade", + "openupgrade_scripts", + "scripts", + ) + for module, release in ( + ("account", "18.0.1.0"), + ("account", "17.0.1.0"), + ("sale", "18.0.2.0"), + ("tests", "18.0.1.0"), + ): + dossier = os.path.join(base, module, release) + os.makedirs(dossier) + with open( + os.path.join(dossier, "upgrade_analysis.txt"), + "w", + encoding="utf-8", + ) as handle: + handle.write(EXEMPLE) + + def tearDown(self): + shutil.rmtree(self.racine) + + def test_only_the_target_version_is_read(self): + # À côté des `18.0.x` vivent des dossiers d'autres paliers : les + # mélanger attribuerait à la 18 des changements de la 17. + trouves = oa.analysis_files(18, self.racine) + self.assertTrue(all("18.0" in c for c in trouves), trouves) + self.assertEqual(len(trouves), 3) + + def test_a_missing_checkout_is_empty_not_a_crash(self): + self.assertEqual(oa.analysis_files(13, self.racine), []) + self.assertEqual(oa.load(13, self.racine)["modules"], 0) + + def test_the_module_list_comes_from_the_directories(self): + modules = oa.analysed_modules(18, self.racine) + self.assertIn("account", modules) + self.assertIn("sale", modules) + + def test_loading_counts_the_modules_it_read(self): + self.assertEqual(oa.load(18, self.racine)["modules"], 3) + + +class TestTheRealCheckout(unittest.TestCase): + """Le dépôt lui-même, quand il est là. Sinon on ne prétend rien.""" + + def setUp(self): + if not oa.analysis_files(18): + self.skipTest("OCA_OpenUpgrade absent du checkout 18.0") + + def test_it_reads_hundreds_of_core_modules(self): + index = oa.load(18) + self.assertGreater(index["modules"], 300) + + def test_it_finds_the_company_dependent_conversion(self): + # C'est la refonte qui a fait disparaître `ir_property` en 18 : + # si l'outil ne la voit pas, il ne sert à rien sur ce palier-là. + index = oa.load(18) + self.assertTrue( + any( + cle.startswith("account.cash.rounding.") + for cle in index["fields_company_dependent"] + ), + sorted(index["fields_company_dependent"])[:5], + ) + + +if __name__ == "__main__": + unittest.main()