[ADD] migration : détecter le type de vue tree resté dans les sources
Odoo 18 a supprimé le type de vue tree, sans conversion : un module
resté dessus ne s'installe pas, ou casse au clic (« View types not
defined tree found in act_window »). Les autres outils de la revue
lisent la BASE, où un module jamais installé ne laisse rien.
Le mot reste un identifiant valide : sur 465 occurrences, 80 cassent — le
noyau 18 garde account.view_invoice_tree en <list>. D'où lxml et ast,
jamais de regex : seule la position du littéral décide. Trois portes
filtrent avant tout motif : version, module installable, fichier chargé.
Balayage : 80 constats sur 2654 modules, 26 touchés.
--- EN ---
Odoo 18 removed the tree view type with no shim: a module still on it
fails to install, or breaks on click ("View types not defined tree found
in act_window"). The other review tools read the DATABASE, where a
module that never installed leaves nothing.
The word is still a valid identifier: of 465 occurrences, 80 break — the
18 core keeps account.view_invoice_tree in <list>. Hence lxml and ast,
never regex: only the literal's position decides. Three gates filter
before any pattern: version, installable module, loaded file. Sweep: 80
findings over 2654 modules, 26 affected.
Assisted-by: Claude Opus 5
(cherry picked from commit 38a91039d111b3fa6de32edf315c5caae8553c11)
This commit is contained in:
parent
599401b6c4
commit
8fe0bafe10
5 changed files with 1067 additions and 9 deletions
|
|
@ -253,6 +253,11 @@ REVUE = (
|
|||
"script/analyse/check_manifest_gaps.py --upstream",
|
||||
"manifest",
|
||||
),
|
||||
(
|
||||
"Is any module still on a pre-18 view type?",
|
||||
"script/analyse/check_view_type_tree.py",
|
||||
"viewtype",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
525
script/analyse/check_view_type_tree.py
Executable file
525
script/analyse/check_view_type_tree.py
Executable file
|
|
@ -0,0 +1,525 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Le type de vue « tree » qui survit dans un module, et casse à l'usage.
|
||||
|
||||
Odoo 18 a supprimé le type `tree`. La sélection de `ir.ui.view.type` est
|
||||
`list, form, graph, pivot, calendar, kanban, search, qweb`
|
||||
(ir_ui_view.py:153), et le noyau ne porte AUCUNE conversion de
|
||||
compatibilité — vérifié par balayage : la seule occurrence hors `etree`
|
||||
est `odoo/tests/form.py:261`, sans rapport.
|
||||
|
||||
Pourquoi cet outil-ci ne lit pas la base
|
||||
----------------------------------------
|
||||
C'est l'angle mort de tous les autres. Un module resté sur `<tree>` ne
|
||||
s'installe pas : il n'y a donc RIEN à lire en base, et un rapport tiré de
|
||||
la base dit « tout va bien » avec assurance. Le défaut est sur le DISQUE,
|
||||
et il revient à chaque resynchronisation de Google Repo.
|
||||
|
||||
Le piège, et pourquoi ni grep ni regex
|
||||
--------------------------------------
|
||||
« tree » est d'abord un morceau d'IDENTIFIANT. Sur 465 occurrences
|
||||
mesurées dans `odoo18.0/addons`, une soixantaine cassent ; le reste est
|
||||
un id de `<record>`, un `<field name="name">`, un `env.ref()`, un nom de
|
||||
variable. Et ce n'est pas de la négligence : le noyau 18 a gardé ses
|
||||
propres identifiants historiques en ne renommant que les balises —
|
||||
`account.view_invoice_tree` existe toujours, son arch en `<list>`.
|
||||
|
||||
La démonstration tient en deux lignes du même dépôt :
|
||||
|
||||
openeducat_admission/models/admission.py:420
|
||||
(tree_view and tree_view.id or False, 'list') ← sain
|
||||
openeducat_fees/models/student.py:142
|
||||
(tree_view and tree_view.id or False, 'tree') ← cassé
|
||||
|
||||
Même nom de variable, même forme. Seul le littéral en POSITION de type
|
||||
décide. D'où la règle : lxml et `ast`, jamais la ligne brute. Le bénéfice
|
||||
est double — les commentaires disparaissent d'eux-mêmes, puisque ni l'un
|
||||
ni l'autre ne construit de nœud pour eux.
|
||||
|
||||
Trois portes avant tout motif
|
||||
-----------------------------
|
||||
Ce sont elles qui font le tri, pas les motifs.
|
||||
|
||||
1. VERSION — rien à faire sous la 18 : `tree` y est valide.
|
||||
2. MODULE — un fichier ne compte que sous un `__manifest__.py`, lu par
|
||||
`literal_eval`. `installable: False` disqualifie le module entier.
|
||||
3. CHARGEMENT — un XML doit figurer dans `data` ou `demo` ; un Python
|
||||
doit être ATTEIGNABLE depuis `__init__.py`. Un fichier que personne ne
|
||||
charge ne casse rien, et `literal_eval` fait disparaître d'elle-même
|
||||
toute entrée commentée du manifeste.
|
||||
|
||||
Ce que la balise en sous-vue fait vraiment
|
||||
------------------------------------------
|
||||
Un `<tree>` sous un x2many n'est pas ignoré poliment : `ir_ui_view.py`
|
||||
ne le reconnaît pas, donc ne le RETIRE pas, et ses `<field>` repartent
|
||||
dans la validation avec le modèle du formulaire PARENT. Le message parle
|
||||
alors d'un champ inexistant et ne prononce jamais le mot « tree ».
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
sys.path.append(
|
||||
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
)
|
||||
|
||||
try:
|
||||
from script.todo.todo_i18n import t
|
||||
except Exception: # pragma: no cover - repli si i18n indisponible
|
||||
|
||||
def t(key: str) -> str:
|
||||
return key
|
||||
|
||||
|
||||
REPO_ROOT = os.path.normpath(
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
|
||||
)
|
||||
FICHIER_VERSION = ".odoo-version"
|
||||
PREMIERE_VERSION_SANS_TREE = (18, 0)
|
||||
MANIFESTE = "__manifest__.py"
|
||||
|
||||
COULEURS = {
|
||||
"broken": "\033[31m",
|
||||
"watch": "\033[33m",
|
||||
"ok": "\033[32m",
|
||||
"step": "\033[36m",
|
||||
"dim": "\033[90m",
|
||||
}
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
def paint(texte, genre, colour):
|
||||
"""Teinter, ou rendre le texte tel quel quand la couleur est coupée."""
|
||||
if not colour:
|
||||
return texte
|
||||
return f"{COULEURS.get(genre, '')}{texte}{RESET}"
|
||||
|
||||
|
||||
def version_active(racine=REPO_ROOT):
|
||||
"""(majeur, mineur) du checkout, ou None si illisible."""
|
||||
try:
|
||||
with io.open(
|
||||
os.path.join(racine, FICHIER_VERSION), encoding="utf-8"
|
||||
) as handle:
|
||||
brut = handle.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
morceaux = brut.split(".")
|
||||
try:
|
||||
return (int(morceaux[0]), int(morceaux[1]))
|
||||
except (IndexError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def concerne(version):
|
||||
"""La 18 et au-delà. Sous elle, `tree` est valide — au pire déprécié."""
|
||||
return bool(version) and version >= PREMIERE_VERSION_SANS_TREE
|
||||
|
||||
|
||||
def lire_manifeste(chemin):
|
||||
"""Le dict du manifeste, par `literal_eval` — jamais par import.
|
||||
|
||||
Un `__manifest__.py` est une expression, pas un programme : l'évaluer
|
||||
exécuterait du code d'un dépôt tiers pour lire quatre clés. Et
|
||||
`literal_eval` fait disparaître d'elle-même toute entrée commentée.
|
||||
"""
|
||||
try:
|
||||
with io.open(chemin, encoding="utf-8") as handle:
|
||||
valeur = ast.literal_eval(handle.read())
|
||||
except (OSError, ValueError, SyntaxError, MemoryError, RecursionError):
|
||||
return None
|
||||
return valeur if isinstance(valeur, dict) else None
|
||||
|
||||
|
||||
def modules(racine):
|
||||
"""[(chemin du module, manifeste)] — ceux qu'Odoo installerait.
|
||||
|
||||
`installable: False` disqualifie : Odoo ne le liste même pas, il ne
|
||||
peut casser ni à l'installation ni à l'exécution.
|
||||
"""
|
||||
lst = []
|
||||
for dossier, sous, fichiers in os.walk(racine):
|
||||
if MANIFESTE not in fichiers:
|
||||
continue
|
||||
sous[:] = [] # un module ne contient pas un autre module
|
||||
manifeste = lire_manifeste(os.path.join(dossier, MANIFESTE))
|
||||
if manifeste is None:
|
||||
continue
|
||||
if manifeste.get("installable", True) is False:
|
||||
continue
|
||||
lst.append((dossier, manifeste))
|
||||
return sorted(lst)
|
||||
|
||||
|
||||
def xml_charges(module, manifeste):
|
||||
"""Les XML que le manifeste demande de charger, et qui existent."""
|
||||
lst, absents = [], []
|
||||
for clef in ("data", "demo"):
|
||||
for relatif in manifeste.get(clef) or []:
|
||||
if not isinstance(relatif, str) or not relatif.endswith(".xml"):
|
||||
continue
|
||||
chemin = os.path.join(module, relatif)
|
||||
(lst if os.path.isfile(chemin) else absents).append(chemin)
|
||||
return lst, absents
|
||||
|
||||
|
||||
def py_atteignables(module):
|
||||
"""Les .py qu'un `import` du module finit par charger.
|
||||
|
||||
On SUIT les `from . import x`, plutôt que de ramasser tout le dossier :
|
||||
un fichier que personne n'importe ne s'exécute jamais, et le signaler
|
||||
ferait crier sur des brouillons laissés là.
|
||||
"""
|
||||
vus, atteints = set(), []
|
||||
pile = [module]
|
||||
while pile:
|
||||
paquet = pile.pop()
|
||||
init = os.path.join(paquet, "__init__.py")
|
||||
if init in vus or not os.path.isfile(init):
|
||||
continue
|
||||
vus.add(init)
|
||||
atteints.append(init)
|
||||
try:
|
||||
with io.open(init, encoding="utf-8") as handle:
|
||||
arbre = ast.parse(handle.read())
|
||||
except (OSError, SyntaxError, ValueError):
|
||||
continue
|
||||
for noeud in ast.walk(arbre):
|
||||
noms = []
|
||||
if isinstance(noeud, ast.ImportFrom) and noeud.level:
|
||||
if noeud.module:
|
||||
noms.append(noeud.module)
|
||||
noms += [a.name for a in noeud.names]
|
||||
elif isinstance(noeud, ast.Import):
|
||||
noms = [a.name.split(".")[0] for a in noeud.names]
|
||||
for nom in noms:
|
||||
relatif = nom.replace(".", os.sep)
|
||||
fichier = os.path.join(paquet, relatif + ".py")
|
||||
dossier = os.path.join(paquet, relatif)
|
||||
if os.path.isfile(fichier) and fichier not in vus:
|
||||
vus.add(fichier)
|
||||
atteints.append(fichier)
|
||||
elif os.path.isdir(dossier):
|
||||
pile.append(dossier)
|
||||
return [c for c in atteints if os.sep + "tests" + os.sep not in c]
|
||||
|
||||
|
||||
def _modes(texte):
|
||||
"""« form, tree » → ['form', 'tree'] — le découpage est obligatoire.
|
||||
|
||||
Un simple `in` attraperait aussi un futur « treemap », et raterait
|
||||
l'espace après la virgule que les vues réelles écrivent.
|
||||
"""
|
||||
return [x.strip() for x in (texte or "").split(",")]
|
||||
|
||||
|
||||
def ligne_de_la_balise(chemin, sourceline, balise="tree"):
|
||||
"""lxml rend la ligne où FINIT la balise ouvrante ; on remonte.
|
||||
|
||||
Mesuré : sur une balise étalée sur six lignes, `sourceline` disait 66
|
||||
quand `<tree` commençait à 61. Citer la fin envoie chercher au mauvais
|
||||
endroit.
|
||||
"""
|
||||
try:
|
||||
with io.open(chemin, encoding="utf-8", errors="replace") as handle:
|
||||
lignes = handle.readlines()
|
||||
except OSError:
|
||||
return sourceline
|
||||
for rang in range(min(sourceline, len(lignes)) - 1, -1, -1):
|
||||
if "<" + balise in lignes[rang]:
|
||||
return rang + 1
|
||||
return sourceline
|
||||
|
||||
|
||||
def constats_xml(chemin):
|
||||
"""[(ligne, motif, extrait, correction)] pour UN fichier de vues."""
|
||||
from lxml import etree
|
||||
|
||||
try:
|
||||
arbre = etree.parse(chemin)
|
||||
except (etree.XMLSyntaxError, OSError):
|
||||
return None # illisible : c'est un constat à part, pas un « tree »
|
||||
lst = []
|
||||
for el in arbre.iter():
|
||||
# Ce test, et lui seul, écarte commentaires et instructions de
|
||||
# traitement : lxml ne construit pas d'élément pour un <!-- -->.
|
||||
if not isinstance(el.tag, str):
|
||||
continue
|
||||
if el.tag == "tree":
|
||||
lst.append(
|
||||
(
|
||||
ligne_de_la_balise(chemin, el.sourceline),
|
||||
"xml_balise",
|
||||
"<tree>",
|
||||
"<list>",
|
||||
)
|
||||
)
|
||||
continue
|
||||
expr = el.get("expr")
|
||||
if expr:
|
||||
segments = expr.replace("//", "/").split("/")
|
||||
for segment in segments:
|
||||
if segment.split("[")[0] == "tree":
|
||||
lst.append((el.sourceline, "xml_xpath", expr[:70], "list"))
|
||||
break
|
||||
if el.tag != "field":
|
||||
continue
|
||||
nom = el.get("name")
|
||||
texte = (el.text or "").strip()
|
||||
if nom == "view_mode" and "tree" in _modes(texte):
|
||||
lst.append(
|
||||
(
|
||||
el.sourceline,
|
||||
"xml_view_mode",
|
||||
texte[:60],
|
||||
texte.replace("tree", "list"),
|
||||
)
|
||||
)
|
||||
elif nom == "type" and texte == "tree":
|
||||
lst.append((el.sourceline, "xml_type", texte, "list"))
|
||||
return lst
|
||||
|
||||
|
||||
def _est_tuple_de_vue(noeud):
|
||||
"""(id, 'tree') — un couple dont le SECOND membre est le type.
|
||||
|
||||
Le premier membre est un id, False, un Name ou une expression : jamais
|
||||
une chaîne. Sans cette condition, la règle crierait sur les couples de
|
||||
compatibilité ('list', 'tree'), qui sont du code défensif correct.
|
||||
"""
|
||||
if not isinstance(noeud, (ast.Tuple, ast.List)) or len(noeud.elts) != 2:
|
||||
return False
|
||||
premier, second = noeud.elts
|
||||
if not isinstance(second, ast.Constant) or second.value != "tree":
|
||||
return False
|
||||
return not (
|
||||
isinstance(premier, ast.Constant) and isinstance(premier.value, str)
|
||||
)
|
||||
|
||||
|
||||
def constats_py(chemin):
|
||||
"""[(ligne, motif, extrait, correction)] pour UN fichier python."""
|
||||
try:
|
||||
with io.open(chemin, encoding="utf-8") as handle:
|
||||
source = handle.read()
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", SyntaxWarning)
|
||||
arbre = ast.parse(source)
|
||||
except (OSError, SyntaxError, ValueError):
|
||||
return None
|
||||
lst = []
|
||||
for noeud in ast.walk(arbre):
|
||||
if _est_tuple_de_vue(noeud):
|
||||
lst.append(
|
||||
(noeud.lineno, "py_views", "(…, 'tree')", "(…, 'list')")
|
||||
)
|
||||
continue
|
||||
if not isinstance(noeud, ast.Dict):
|
||||
continue
|
||||
paires = {
|
||||
clef.value: valeur
|
||||
for clef, valeur in zip(noeud.keys, noeud.values)
|
||||
if isinstance(clef, ast.Constant) and isinstance(clef.value, str)
|
||||
}
|
||||
est_action = (
|
||||
isinstance(paires.get("type"), ast.Constant)
|
||||
and paires["type"].value == "ir.actions.act_window"
|
||||
)
|
||||
for clef in ("view_mode", "view_type"):
|
||||
valeur = paires.get(clef)
|
||||
if not isinstance(valeur, ast.Constant):
|
||||
continue
|
||||
if not isinstance(valeur.value, str):
|
||||
continue
|
||||
if "tree" not in _modes(valeur.value):
|
||||
continue
|
||||
# `view_type` sur un dict d'act_window est un vestige d'Odoo 12
|
||||
# que la 18 ignore sans bruit. Ailleurs — un `code.generator.
|
||||
# view`, dont la sélection ne propose que `list` — il lève.
|
||||
if clef == "view_type" and est_action:
|
||||
continue
|
||||
lst.append(
|
||||
(
|
||||
valeur.lineno,
|
||||
"py_" + clef,
|
||||
valeur.value[:60],
|
||||
valeur.value.replace("tree", "list"),
|
||||
)
|
||||
)
|
||||
return lst
|
||||
|
||||
|
||||
def inspect(racine, sous_dossier="addons"):
|
||||
"""{'findings': […], 'unreadable': […], 'missing': […]} pour un checkout."""
|
||||
version = version_active(racine)
|
||||
rapport = {
|
||||
"version": version,
|
||||
"scanned": 0,
|
||||
"findings": [],
|
||||
"unreadable": [],
|
||||
"missing": [],
|
||||
}
|
||||
if not concerne(version):
|
||||
return rapport
|
||||
base = os.path.join(racine, "odoo%d.%d" % version, sous_dossier)
|
||||
if not os.path.isdir(base):
|
||||
return rapport
|
||||
for module, manifeste in modules(base):
|
||||
rapport["scanned"] += 1
|
||||
court = os.path.basename(module)
|
||||
charges, absents = xml_charges(module, manifeste)
|
||||
for chemin in absents:
|
||||
rapport["missing"].append((court, os.path.relpath(chemin, racine)))
|
||||
for chemin in charges:
|
||||
lst = constats_xml(chemin)
|
||||
if lst is None:
|
||||
rapport["unreadable"].append(
|
||||
(court, os.path.relpath(chemin, racine))
|
||||
)
|
||||
continue
|
||||
for ligne, motif, extrait, correction in lst:
|
||||
rapport["findings"].append(
|
||||
(
|
||||
court,
|
||||
os.path.relpath(chemin, racine),
|
||||
ligne,
|
||||
motif,
|
||||
extrait,
|
||||
correction,
|
||||
)
|
||||
)
|
||||
for chemin in py_atteignables(module):
|
||||
lst = constats_py(chemin)
|
||||
if lst is None:
|
||||
rapport["unreadable"].append(
|
||||
(court, os.path.relpath(chemin, racine))
|
||||
)
|
||||
continue
|
||||
for ligne, motif, extrait, correction in lst:
|
||||
rapport["findings"].append(
|
||||
(
|
||||
court,
|
||||
os.path.relpath(chemin, racine),
|
||||
ligne,
|
||||
motif,
|
||||
extrait,
|
||||
correction,
|
||||
)
|
||||
)
|
||||
return rapport
|
||||
|
||||
|
||||
def render(rapport, colour=True):
|
||||
"""Le rapport lisible, groupé par module."""
|
||||
lignes = [f"🌲 {t('View type tree, removed in Odoo 18')}", ""]
|
||||
if not concerne(rapport["version"]):
|
||||
lignes.append(
|
||||
paint(
|
||||
f" {t('nothing to do below 18.0 — tree is valid there')}",
|
||||
"dim",
|
||||
colour,
|
||||
)
|
||||
)
|
||||
return "\n".join(lignes)
|
||||
|
||||
trouves = rapport["findings"]
|
||||
if not trouves:
|
||||
lignes.append(
|
||||
paint(
|
||||
f"✅ {t('No module carries it.')}"
|
||||
f" ({rapport['scanned']} {t('modules read')})",
|
||||
"ok",
|
||||
colour,
|
||||
)
|
||||
)
|
||||
par_module = {}
|
||||
for module, chemin, ligne, motif, extrait, correction in trouves:
|
||||
par_module.setdefault(module, []).append(
|
||||
(chemin, ligne, motif, extrait, correction)
|
||||
)
|
||||
for module in sorted(par_module):
|
||||
lst = par_module[module]
|
||||
lignes.append(
|
||||
paint(f"❌ {str(len(lst)).rjust(4)} {module}", "broken", colour)
|
||||
)
|
||||
for chemin, ligne, motif, extrait, correction in sorted(lst):
|
||||
lignes.append(paint(f" {chemin}:{ligne}", "dim", colour))
|
||||
lignes.append(f" {extrait} → {correction}")
|
||||
lignes.append("")
|
||||
|
||||
# Deux constats voisins, jamais mélangés aux « tree » : ils ont une
|
||||
# autre cause et une autre réparation.
|
||||
for clef, titre, genre in (
|
||||
("missing", "listed in the manifest, absent from disk", "broken"),
|
||||
("unreadable", "listed in the manifest, unparsable", "watch"),
|
||||
):
|
||||
if rapport[clef]:
|
||||
lignes.append(paint(f"{t(titre)} :", genre, colour))
|
||||
for module, chemin in sorted(rapport[clef]):
|
||||
lignes.append(paint(f" {chemin}", "dim", colour))
|
||||
lignes.append("")
|
||||
return "\n".join(lignes).rstrip()
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
import argparse
|
||||
import json
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description=t("Modules still carrying the removed tree view type."),
|
||||
)
|
||||
parser.add_argument("--root", default=REPO_ROOT)
|
||||
parser.add_argument("--json", action="store_true")
|
||||
parser.add_argument("--no-color", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if not os.path.isdir(args.root):
|
||||
print(f"❌ {args.root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
rapport = inspect(args.root)
|
||||
if args.json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"odoo": (
|
||||
"%d.%d" % rapport["version"]
|
||||
if rapport["version"]
|
||||
else None
|
||||
),
|
||||
"modules_read": rapport["scanned"],
|
||||
"findings": [
|
||||
{
|
||||
"module": m,
|
||||
"file": f,
|
||||
"line": ligne,
|
||||
"pattern": motif,
|
||||
"found": extrait,
|
||||
"replace_with": corr,
|
||||
}
|
||||
for m, f, ligne, motif, extrait, corr in rapport[
|
||||
"findings"
|
||||
]
|
||||
],
|
||||
"missing_files": [f for _m, f in rapport["missing"]],
|
||||
"unparsable_files": [f for _m, f in rapport["unreadable"]],
|
||||
},
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
else:
|
||||
colour = sys.stdout.isatty() and not args.no_color
|
||||
print(render(rapport, colour))
|
||||
return 1 if rapport["findings"] or rapport["missing"] else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -6979,6 +6979,39 @@ TRANSLATIONS = {
|
|||
},
|
||||
# --- Écran de qualité : Verdicts, Validation, Revue ---
|
||||
# --- Trous dans les manifestes Google Repo ---
|
||||
# --- Type de vue « tree » resté dans un module ---
|
||||
"Is any module still on a pre-18 view type?": {
|
||||
"fr": "Un module est-il resté sur un type de vue d'avant 18 ?",
|
||||
"en": "Is any module still on a pre-18 view type?",
|
||||
},
|
||||
"View type tree, removed in Odoo 18": {
|
||||
"fr": "Type de vue tree, supprimé dans Odoo 18",
|
||||
"en": "View type tree, removed in Odoo 18",
|
||||
},
|
||||
"nothing to do below 18.0 — tree is valid there": {
|
||||
"fr": "rien à faire sous la 18.0 — tree y est valide",
|
||||
"en": "nothing to do below 18.0 — tree is valid there",
|
||||
},
|
||||
"No module carries it.": {
|
||||
"fr": "Aucun module ne le porte.",
|
||||
"en": "No module carries it.",
|
||||
},
|
||||
"modules read": {
|
||||
"fr": "modules lus",
|
||||
"en": "modules read",
|
||||
},
|
||||
"listed in the manifest, absent from disk": {
|
||||
"fr": "listés au manifeste, absents du disque",
|
||||
"en": "listed in the manifest, absent from disk",
|
||||
},
|
||||
"listed in the manifest, unparsable": {
|
||||
"fr": "listés au manifeste, illisibles",
|
||||
"en": "listed in the manifest, unparsable",
|
||||
},
|
||||
"Modules still carrying the removed tree view type.": {
|
||||
"fr": "Modules portant encore le type de vue tree, supprimé.",
|
||||
"en": "Modules still carrying the removed tree view type.",
|
||||
},
|
||||
"Will every step find its addons?": {
|
||||
"fr": "Chaque palier trouvera-t-il ses addons ?",
|
||||
"en": "Will every step find its addons?",
|
||||
|
|
|
|||
|
|
@ -1793,7 +1793,7 @@ class TestTheReviewChecklist(Base):
|
|||
continue
|
||||
sans_base += 1
|
||||
self.assertIn("script/analyse/", commande, commande)
|
||||
self.assertEqual(1, sans_base)
|
||||
self.assertTrue(sans_base)
|
||||
|
||||
def test_the_checkout_step_needs_no_database(self):
|
||||
# C'est l'angle mort des six autres : elles lisent toutes la BASE
|
||||
|
|
@ -1801,8 +1801,9 @@ class TestTheReviewChecklist(Base):
|
|||
sans_base = [
|
||||
(q, c) for q, c, k in quality.REVUE if k and "{db}" not in c
|
||||
]
|
||||
self.assertEqual(1, len(sans_base))
|
||||
self.assertIn("check_manifest_gaps", sans_base[0][1])
|
||||
self.assertTrue(sans_base)
|
||||
for _question, commande in sans_base:
|
||||
self.assertTrue(commande.startswith("script/analyse/"), commande)
|
||||
|
||||
def test_the_first_step_has_nothing_to_run(self):
|
||||
# « La migration est-elle allée jusqu'au bout » se lit dans le
|
||||
|
|
@ -1880,14 +1881,17 @@ class TestTheThreeExtraSections(Base):
|
|||
self.assertNotIn("{db}", commande)
|
||||
|
||||
def test_without_a_database_only_the_db_free_step_can_be_run(self):
|
||||
# La revue du checkout — les manifestes — se lance justement AVANT
|
||||
# qu'une migration existe. Exiger une base la rendait inerte au
|
||||
# seul moment où elle sert.
|
||||
# La revue du checkout — manifestes, types de vue — se lance
|
||||
# justement AVANT qu'une migration existe. Exiger une base la
|
||||
# rendait inerte au seul moment où elle sert.
|
||||
lst = qtui.extra_rows([], {})
|
||||
lancables = [r for r in lst if r["kind"] == "review" and r["command"]]
|
||||
self.assertEqual(1, len(lancables))
|
||||
self.assertNotIn("{db}", lancables[0]["command"])
|
||||
self.assertNotIn(" -d ", lancables[0]["command"])
|
||||
attendu = [c for _q, c, k in quality.REVUE if k and "{db}" not in c]
|
||||
self.assertEqual(len(attendu), len(lancables))
|
||||
self.assertTrue(lancables)
|
||||
for ligne in lancables:
|
||||
self.assertNotIn("{db}", ligne["command"])
|
||||
self.assertNotIn(" -d ", ligne["command"])
|
||||
|
||||
def test_a_step_that_names_a_database_stays_silent_without_one(self):
|
||||
lst = qtui.extra_rows([], {})
|
||||
|
|
|
|||
491
test/test_check_view_type_tree.py
Normal file
491
test/test_check_view_type_tree.py
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""« tree » est d'abord un IDENTIFIANT, et rarement un type de vue.
|
||||
|
||||
Sur 465 occurrences du mot dans odoo18.0/addons, quatre-vingts cassent.
|
||||
Le reste est un id de <record>, un <field name="name">, un env.ref(), un
|
||||
nom de variable — et le noyau 18 lui-même a gardé ses anciens ids en ne
|
||||
renommant que les balises : account.view_invoice_tree existe toujours.
|
||||
|
||||
Un détecteur qui ancre sur le mot rend quatre cents lignes fausses et ne
|
||||
sera pas relancé une seconde fois. C'est donc le TRI qu'on teste ici, pas
|
||||
la détection : chaque exclusion a son test, et il y en a plus que de
|
||||
motifs.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, REPO)
|
||||
|
||||
from script.analyse import check_view_type_tree as arbre # noqa: E402
|
||||
|
||||
|
||||
class Base(unittest.TestCase):
|
||||
"""Un checkout jetable : une version, un module, ce qu'on y met."""
|
||||
|
||||
def setUp(self):
|
||||
self.racine = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, self.racine)
|
||||
self.version("18.0")
|
||||
self.addons = os.path.join(self.racine, "odoo18.0", "addons")
|
||||
os.makedirs(self.addons)
|
||||
|
||||
def version(self, texte):
|
||||
with io.open(
|
||||
os.path.join(self.racine, arbre.FICHIER_VERSION),
|
||||
"w",
|
||||
encoding="utf-8",
|
||||
) as handle:
|
||||
handle.write(texte + "\n")
|
||||
|
||||
def module(self, nom="essai", manifeste=None, init="from . import models"):
|
||||
chemin = os.path.join(self.addons, "depot", nom)
|
||||
os.makedirs(chemin, exist_ok=True)
|
||||
dct = {"name": nom, "version": "18.0.1.0.0", "data": []}
|
||||
dct.update(manifeste or {})
|
||||
self.ecrire(os.path.join(chemin, arbre.MANIFESTE), repr(dct))
|
||||
if init is not None:
|
||||
self.ecrire(os.path.join(chemin, "__init__.py"), init)
|
||||
return chemin
|
||||
|
||||
def ecrire(self, chemin, contenu):
|
||||
os.makedirs(os.path.dirname(chemin), exist_ok=True)
|
||||
with io.open(chemin, "w", encoding="utf-8") as handle:
|
||||
handle.write(contenu)
|
||||
return chemin
|
||||
|
||||
def constats(self):
|
||||
return arbre.inspect(self.racine)["findings"]
|
||||
|
||||
def motifs(self):
|
||||
return sorted(c[3] for c in self.constats())
|
||||
|
||||
|
||||
class TestTheVersionGate(Base):
|
||||
def test_below_18_nothing_is_a_defect(self):
|
||||
# `tree` y est valide, au pire déprécié en 17. Crier dessus
|
||||
# ferait rejeter le rapport en entier.
|
||||
self.assertFalse(arbre.concerne((17, 0)))
|
||||
self.assertFalse(arbre.concerne((12, 0)))
|
||||
|
||||
def test_18_and_beyond_are_judged(self):
|
||||
self.assertTrue(arbre.concerne((18, 0)))
|
||||
self.assertTrue(arbre.concerne((19, 0)))
|
||||
|
||||
def test_an_unreadable_version_judges_nothing(self):
|
||||
os.remove(os.path.join(self.racine, arbre.FICHIER_VERSION))
|
||||
self.assertIsNone(arbre.version_active(self.racine))
|
||||
self.assertEqual([], self.constats())
|
||||
|
||||
def test_a_nonsense_version_judges_nothing(self):
|
||||
self.version("develop")
|
||||
self.assertIsNone(arbre.version_active(self.racine))
|
||||
|
||||
|
||||
class TestTheModuleGate(Base):
|
||||
def test_a_manifest_is_read_never_executed(self):
|
||||
# Un __manifest__.py vient d'un dépôt tiers : l'exécuter pour lire
|
||||
# quatre clés donnerait la main à n'importe qui.
|
||||
chemin = self.ecrire(
|
||||
os.path.join(self.racine, "m", arbre.MANIFESTE),
|
||||
"__import__('os').system('touch /tmp/jamais')\n",
|
||||
)
|
||||
self.assertIsNone(arbre.lire_manifeste(chemin))
|
||||
|
||||
def test_a_module_that_odoo_would_not_list_is_skipped(self):
|
||||
# `installable: False` : il ne peut casser ni à l'installation ni
|
||||
# à l'exécution.
|
||||
module = self.module(
|
||||
manifeste={"installable": False, "data": ["v.xml"]}
|
||||
)
|
||||
self.ecrire(
|
||||
os.path.join(module, "v.xml"),
|
||||
"<odoo><record model='ir.ui.view'><field name='arch'>"
|
||||
"<tree/></field></record></odoo>",
|
||||
)
|
||||
self.assertEqual([], self.constats())
|
||||
|
||||
def test_a_module_that_is_installable_is_read(self):
|
||||
module = self.module(manifeste={"data": ["v.xml"]})
|
||||
self.ecrire(
|
||||
os.path.join(module, "v.xml"),
|
||||
"<odoo><record model='ir.ui.view'><field name='arch'>"
|
||||
"<tree/></field></record></odoo>",
|
||||
)
|
||||
self.assertEqual(["xml_balise"], self.motifs())
|
||||
|
||||
def test_a_directory_without_a_manifest_is_not_a_module(self):
|
||||
os.makedirs(os.path.join(self.addons, "depot", "pas_un_module"))
|
||||
self.assertEqual([], arbre.modules(self.addons))
|
||||
|
||||
|
||||
class TestTheLoadingGate(Base):
|
||||
def test_an_xml_the_manifest_does_not_load_is_ignored(self):
|
||||
# Personne ne le charge : il ne casse rien, et le signaler ferait
|
||||
# crier sur des brouillons laissés là.
|
||||
module = self.module(manifeste={"data": []})
|
||||
self.ecrire(
|
||||
os.path.join(module, "orphelin.xml"), "<odoo><tree/></odoo>"
|
||||
)
|
||||
self.assertEqual([], self.constats())
|
||||
|
||||
def test_a_demo_file_counts_like_a_data_file(self):
|
||||
module = self.module(manifeste={"demo": ["d.xml"]})
|
||||
self.ecrire(os.path.join(module, "d.xml"), "<odoo><tree/></odoo>")
|
||||
self.assertEqual(["xml_balise"], self.motifs())
|
||||
|
||||
def test_a_python_nobody_imports_is_ignored(self):
|
||||
module = self.module(init="")
|
||||
self.ecrire(
|
||||
os.path.join(module, "brouillon.py"),
|
||||
"A = [(1, 'tree')]\n",
|
||||
)
|
||||
self.assertEqual([], self.constats())
|
||||
|
||||
def test_a_python_reached_through_the_init_is_read(self):
|
||||
module = self.module(init="from . import models")
|
||||
self.ecrire(os.path.join(module, "models.py"), "A = [(1, 'tree')]\n")
|
||||
self.assertEqual(["py_views"], self.motifs())
|
||||
|
||||
def test_a_module_imported_for_its_function_is_still_followed(self):
|
||||
# « from .hooks import post_init_hook » importe une FONCTION ;
|
||||
# chercher post_init_hook.py manquait tous les hooks.py, et avec
|
||||
# eux vingt-sept constats réels.
|
||||
module = self.module(init="from .hooks import post_init_hook")
|
||||
self.ecrire(
|
||||
os.path.join(module, "hooks.py"),
|
||||
"def post_init_hook(env):\n"
|
||||
" env['code.generator.view'].create({'view_type': 'tree'})\n",
|
||||
)
|
||||
self.assertEqual(["py_view_type"], self.motifs())
|
||||
|
||||
def test_a_subpackage_is_walked(self):
|
||||
module = self.module(init="from . import models")
|
||||
self.ecrire(
|
||||
os.path.join(module, "models", "__init__.py"), "from . import x"
|
||||
)
|
||||
self.ecrire(
|
||||
os.path.join(module, "models", "x.py"), "A = [(1, 'tree')]\n"
|
||||
)
|
||||
self.assertEqual(["py_views"], self.motifs())
|
||||
|
||||
def test_a_test_file_is_never_judged(self):
|
||||
module = self.module(init="from . import tests")
|
||||
self.ecrire(
|
||||
os.path.join(module, "tests", "__init__.py"), "from . import t"
|
||||
)
|
||||
self.ecrire(
|
||||
os.path.join(module, "tests", "t.py"), "A = [(1, 'tree')]\n"
|
||||
)
|
||||
self.assertEqual([], self.constats())
|
||||
|
||||
|
||||
class TestTheXmlPatterns(Base):
|
||||
def vue(self, corps, manifeste=None):
|
||||
module = self.module(manifeste=manifeste or {"data": ["v.xml"]})
|
||||
self.ecrire(os.path.join(module, "v.xml"), corps)
|
||||
return self.constats()
|
||||
|
||||
def test_a_tree_tag_is_caught(self):
|
||||
self.assertEqual(
|
||||
["xml_balise"],
|
||||
[
|
||||
c[3]
|
||||
for c in self.vue(
|
||||
"<odoo><tree><field name='a'/></tree></odoo>"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_the_closing_tag_is_not_a_second_defect(self):
|
||||
# Elle est la moitié du même : les compter séparément double le
|
||||
# total et fait croire à deux corrections.
|
||||
self.assertEqual(1, len(self.vue("<odoo><tree/></odoo>")))
|
||||
|
||||
def test_a_commented_tree_is_not_a_defect(self):
|
||||
# Aucun geste explicite : lxml ne construit pas d'élément pour le
|
||||
# contenu d'un commentaire. C'est l'argument décisif contre grep.
|
||||
self.assertEqual([], self.vue("<odoo><!-- <tree/> --></odoo>"))
|
||||
|
||||
def test_a_record_id_that_contains_tree_is_not_a_defect(self):
|
||||
# Le noyau 18 a gardé account.view_invoice_tree, arch en <list>.
|
||||
self.assertEqual(
|
||||
[],
|
||||
self.vue(
|
||||
"<odoo><record id='view_invoice_tree' model='ir.ui.view'>"
|
||||
"<field name='name'>account.move.tree</field>"
|
||||
"<field name='arch' type='xml'><list/></field>"
|
||||
"</record></odoo>"
|
||||
),
|
||||
)
|
||||
|
||||
def test_a_view_mode_naming_tree_is_caught(self):
|
||||
constats = self.vue(
|
||||
"<odoo><field name='view_mode'>tree,form</field></odoo>"
|
||||
)
|
||||
self.assertEqual(["xml_view_mode"], [c[3] for c in constats])
|
||||
self.assertEqual("list,form", constats[0][5])
|
||||
|
||||
def test_a_space_after_the_comma_does_not_hide_it(self):
|
||||
self.assertEqual(
|
||||
["xml_view_mode"],
|
||||
[
|
||||
c[3]
|
||||
for c in self.vue(
|
||||
"<odoo><field name='view_mode'>form, tree</field></odoo>"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_a_mode_that_merely_starts_with_tree_is_not_caught(self):
|
||||
# Un substring attraperait « treemap ».
|
||||
self.assertEqual(
|
||||
[],
|
||||
self.vue(
|
||||
"<odoo><field name='view_mode'>treemap,form</field></odoo>"
|
||||
),
|
||||
)
|
||||
|
||||
def test_an_explicit_type_field_is_caught(self):
|
||||
self.assertEqual(
|
||||
["xml_type"],
|
||||
[
|
||||
c[3]
|
||||
for c in self.vue(
|
||||
"<odoo><field name='type'>tree</field></odoo>"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_an_xpath_aiming_at_a_tree_is_caught(self):
|
||||
self.assertEqual(
|
||||
["xml_xpath"],
|
||||
[
|
||||
c[3]
|
||||
for c in self.vue(
|
||||
"<odoo><xpath expr=\"//tree/field[@name='a']\"/></odoo>"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_an_xpath_naming_tree_inside_a_predicate_is_not_caught(self):
|
||||
self.assertEqual(
|
||||
[],
|
||||
self.vue(
|
||||
"<odoo><xpath expr=\"//list[@name='tree_view']\"/></odoo>"
|
||||
),
|
||||
)
|
||||
|
||||
def test_an_unparsable_file_is_reported_apart(self):
|
||||
# Autre cause, autre réparation : ne pas le mêler aux « tree ».
|
||||
module = self.module(manifeste={"data": ["v.xml"]})
|
||||
self.ecrire(os.path.join(module, "v.xml"), "<odoo><tree")
|
||||
rapport = arbre.inspect(self.racine)
|
||||
self.assertEqual([], rapport["findings"])
|
||||
self.assertEqual(1, len(rapport["unreadable"]))
|
||||
|
||||
def test_a_file_listed_but_absent_is_reported_apart(self):
|
||||
# Il rend le module ininstallable, et cela n'a rien à voir avec
|
||||
# un type de vue.
|
||||
self.module(manifeste={"data": ["jamais_ecrit.xml"]})
|
||||
rapport = arbre.inspect(self.racine)
|
||||
self.assertEqual(1, len(rapport["missing"]))
|
||||
|
||||
def test_the_line_reported_is_where_the_tag_opens(self):
|
||||
# lxml rend la ligne où la balise ouvrante FINIT ; mesuré, il
|
||||
# disait 66 quand <tree commençait à 61.
|
||||
constats = self.vue(
|
||||
"<odoo>\n<tree\n string='x'\n create='0'\n>\n"
|
||||
"<field name='a'/></tree>\n</odoo>"
|
||||
)
|
||||
self.assertEqual(2, constats[0][2])
|
||||
|
||||
|
||||
class TestThePythonPatterns(Base):
|
||||
def code(self, source):
|
||||
module = self.module(init="from . import models")
|
||||
self.ecrire(os.path.join(module, "models.py"), source)
|
||||
return self.constats()
|
||||
|
||||
def test_a_view_tuple_typed_tree_is_caught(self):
|
||||
self.assertEqual(
|
||||
["py_views"],
|
||||
[
|
||||
c[3]
|
||||
for c in self.code(
|
||||
"def f(res):\n return [(res and res.id or False, 'tree')]\n"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_a_pair_of_two_strings_is_defensive_code_not_a_defect(self):
|
||||
# ('list', 'tree') couvre les deux noms de balise ; en 18 seule la
|
||||
# branche list se produit, l'entrée tree reste morte.
|
||||
self.assertEqual([], self.code("A = ('list', 'tree')\n"))
|
||||
|
||||
def test_the_variable_name_decides_nothing(self):
|
||||
# Le piège le plus tentant : les deux formes sont à quelques
|
||||
# lignes d'écart dans le même dépôt et ne diffèrent que par le
|
||||
# littéral. Odoo ne lit pas les noms de variables.
|
||||
self.assertEqual([], self.code("tree_view = [(1, 'list')]\n"))
|
||||
|
||||
def test_a_dict_view_mode_is_caught(self):
|
||||
self.assertEqual(
|
||||
["py_view_mode"],
|
||||
[c[3] for c in self.code("A = {'view_mode': 'tree,form'}\n")],
|
||||
)
|
||||
|
||||
def test_a_view_mode_without_the_action_type_is_still_caught(self):
|
||||
# Plusieurs actions réelles omettent la clé `type` et sont
|
||||
# pourtant renvoyées telles quelles au client.
|
||||
self.assertEqual(
|
||||
["py_view_mode"],
|
||||
[
|
||||
c[3]
|
||||
for c in self.code(
|
||||
"A = {'name': 'x', 'view_mode': 'form,tree,kanban'}\n"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_view_type_on_an_act_window_is_a_harmless_leftover(self):
|
||||
# Vestige d'Odoo 12 : la 18 ne lit que view_mode / views / view_ids
|
||||
# et ignore la clé surnuméraire sans bruit.
|
||||
self.assertEqual(
|
||||
[],
|
||||
self.code(
|
||||
"A = {'type': 'ir.actions.act_window', 'view_type': 'tree'}\n"
|
||||
),
|
||||
)
|
||||
|
||||
def test_view_type_anywhere_else_is_a_live_selection(self):
|
||||
# code.generator.view.view_type ne propose plus que « list » :
|
||||
# la valeur tree lève au post_init_hook.
|
||||
self.assertEqual(
|
||||
["py_view_type"],
|
||||
[
|
||||
c[3]
|
||||
for c in self.code(
|
||||
"A = {'view_type': 'tree', 'view_name': 'x'}\n"
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_a_comparison_against_tree_is_not_a_defect(self):
|
||||
# Une comparaison ne fabrique jamais un type de vue : au pire du
|
||||
# code mort. La compter gonflerait le rapport de lignes que
|
||||
# personne ne peut réparer isolément.
|
||||
self.assertEqual(
|
||||
[],
|
||||
self.code(
|
||||
"def f(v):\n return v != 'tree' and v in ('list', 'tree')\n"
|
||||
),
|
||||
)
|
||||
|
||||
def test_a_tree_used_as_a_data_structure_is_not_a_defect(self):
|
||||
self.assertEqual(
|
||||
[],
|
||||
self.code(
|
||||
"import lxml.etree as etree\n"
|
||||
"def f(d):\n return d['tree'], etree.tostring\n"
|
||||
),
|
||||
)
|
||||
|
||||
def test_the_unix_command_named_tree_is_not_a_defect(self):
|
||||
self.assertEqual([], self.code("cmd = 'tree'\nPKGS = ['tree']\n"))
|
||||
|
||||
def test_a_file_that_does_not_parse_is_reported_apart(self):
|
||||
module = self.module(init="from . import models")
|
||||
self.ecrire(os.path.join(module, "models.py"), "def f(:\n")
|
||||
rapport = arbre.inspect(self.racine)
|
||||
self.assertEqual([], rapport["findings"])
|
||||
self.assertEqual(1, len(rapport["unreadable"]))
|
||||
|
||||
|
||||
class TestTheCommand(Base):
|
||||
def lancer(self, argv):
|
||||
tampon = io.StringIO()
|
||||
with redirect_stdout(tampon):
|
||||
code = arbre.main(argv)
|
||||
return code, tampon.getvalue()
|
||||
|
||||
def test_a_missing_root_is_a_tool_failure(self):
|
||||
code, _ = self.lancer(["--root", os.path.join(self.racine, "absent")])
|
||||
self.assertEqual(2, code)
|
||||
|
||||
def test_a_clean_checkout_exits_zero(self):
|
||||
self.module()
|
||||
code, texte = self.lancer(["--root", self.racine, "--no-color"])
|
||||
self.assertEqual(0, code)
|
||||
self.assertIn(arbre.t("No module carries it."), texte)
|
||||
|
||||
def test_a_finding_exits_one(self):
|
||||
module = self.module(manifeste={"data": ["v.xml"]})
|
||||
self.ecrire(os.path.join(module, "v.xml"), "<odoo><tree/></odoo>")
|
||||
code, _ = self.lancer(["--root", self.racine, "--no-color"])
|
||||
self.assertEqual(1, code)
|
||||
|
||||
def test_below_18_it_says_so_instead_of_staying_silent(self):
|
||||
self.version("17.0")
|
||||
_code, texte = self.lancer(["--root", self.racine, "--no-color"])
|
||||
self.assertIn(
|
||||
arbre.t("nothing to do below 18.0 — tree is valid there"), texte
|
||||
)
|
||||
|
||||
def test_the_json_says_what_to_replace_it_with(self):
|
||||
import json
|
||||
|
||||
module = self.module(manifeste={"data": ["v.xml"]})
|
||||
self.ecrire(
|
||||
os.path.join(module, "v.xml"),
|
||||
"<odoo><field name='view_mode'>tree,form</field></odoo>",
|
||||
)
|
||||
_code, texte = self.lancer(["--root", self.racine, "--json"])
|
||||
dct = json.loads(texte)
|
||||
self.assertEqual("18.0", dct["odoo"])
|
||||
self.assertEqual("list,form", dct["findings"][0]["replace_with"])
|
||||
|
||||
|
||||
class TestAgainstTheRealCheckout(unittest.TestCase):
|
||||
"""Sur le vrai arbre — c'est là que le tri se prouve."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.rapport = arbre.inspect(REPO)
|
||||
|
||||
def test_it_reads_the_whole_addon_tree(self):
|
||||
if not arbre.concerne(arbre.version_active(REPO)):
|
||||
self.skipTest("checkout sous la 18.0")
|
||||
self.assertGreater(self.rapport["scanned"], 1000)
|
||||
|
||||
def test_it_finds_the_line_that_broke_a_kanban(self):
|
||||
# « View types not defined tree found in act_window action 444 »,
|
||||
# au clic sur action_view_ticket depuis le kanban de projet.
|
||||
if not arbre.concerne(arbre.version_active(REPO)):
|
||||
self.skipTest("checkout sous la 18.0")
|
||||
vus = [
|
||||
(f, ligne)
|
||||
for _m, f, ligne, _p, _e, _c in self.rapport["findings"]
|
||||
if "helpdesk_mgmt_project" in f
|
||||
]
|
||||
self.assertTrue(vus, "le défaut rencontré en production n'est pas vu")
|
||||
|
||||
def test_the_core_odoo_identifiers_are_not_reported(self):
|
||||
# account.view_invoice_tree et ses semblables vivent dans le noyau
|
||||
# 18 avec une arch en <list> : les signaler serait ~400 lignes
|
||||
# fausses, et le rapport ne serait pas relancé.
|
||||
for _m, fichier, _l, _p, _e, _c in self.rapport["findings"]:
|
||||
self.assertNotIn("odoo18.0/odoo/", fichier)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue