[ADD] migration: prédire la copie de site qui rendra 500 après le palier
`check_cow_views` ne voyait que la rupture qui ARRÊTE la migration : la forme que l'arch doit avoir change. Deux autres la laissent finir sans un mot — un ancrage qu'une vue héritière de la cible réclame, un t-call vers un gabarit que la cible ne livre plus — et ne se voient qu'à l'ouverture de la page, c'est-à-dire jamais avant la fin. Rejouée sur la chaîne 12 → 18, la prédiction nomme le défaut de /contact dès le palier 13 → 14, en 0,8 s. Il avait attendu six paliers. Ces copies ne sont PAS neutralisées : chacune porte une page écrite par quelqu'un. Le pilote appelle la réparation après OpenUpgrade, là où la vue module porte enfin l'arch de la cible. --- EN --- `check_cow_views` only saw the break that STOPS the migration: the shape the arch must have changes. Two others let it finish without a word — an anchor a target child view requires, a t-call to a template the target no longer ships — and only show when the page is opened, which is to say never before the end. Replayed on the 12 → 18 chain, the prediction names /contact's defect as early as the 13 → 14 step, in 0.8 s. It had waited six steps. Those copies are NOT neutralized: each holds a page someone wrote. The driver calls the repair after OpenUpgrade, where the module view finally carries the target's arch. Assisted-by: Claude Opus 5
This commit is contained in:
parent
291fd67841
commit
32f62fda04
6 changed files with 618 additions and 2 deletions
|
|
@ -201,6 +201,187 @@ def declared_view_shape(module_dir, template_id):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_sql(database, sql):
|
||||||
|
"""Le texte brut d'une requête. RuntimeError si la base ne répond pas."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["psql", "-X", "-w", "-d", database, "-tA", "-c", sql],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot read '{database}': {result.stderr.strip()}"
|
||||||
|
)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def query_cow_archs(database):
|
||||||
|
"""[(id, clé, mode, website_id, {langue: arch})] — arch ENTIÈRES.
|
||||||
|
|
||||||
|
`query_cow_views` tronque à 400 caractères : assez pour lire la
|
||||||
|
première balise, pas pour évaluer un xpath — un fragment coupé n'est
|
||||||
|
même pas du XML. Et `arch_db` est un jsonb par langue depuis la 17 :
|
||||||
|
une page cassée en fr_CA et saine en en_US, cela existe, on l'a vu.
|
||||||
|
"""
|
||||||
|
jsonb = "jsonb" in run_sql(
|
||||||
|
database,
|
||||||
|
"SELECT data_type FROM information_schema.columns"
|
||||||
|
" WHERE table_name='ir_ui_view' AND column_name='arch_db'",
|
||||||
|
)
|
||||||
|
expr = "arch_db" if jsonb else "json_build_object('', arch_db)"
|
||||||
|
brut = run_sql(
|
||||||
|
database,
|
||||||
|
"SELECT coalesce(json_agg(json_build_object("
|
||||||
|
"'id', id, 'key', coalesce(key, ''), 'mode', mode,"
|
||||||
|
f" 'website_id', website_id, 'arch', {expr})), '[]')"
|
||||||
|
" FROM ir_ui_view WHERE website_id IS NOT NULL",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
lignes = json.loads(brut.strip() or "[]")
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
ligne["id"],
|
||||||
|
ligne["key"],
|
||||||
|
ligne["mode"],
|
||||||
|
ligne["website_id"],
|
||||||
|
ligne["arch"] if isinstance(ligne["arch"], dict) else {},
|
||||||
|
)
|
||||||
|
for ligne in lignes
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def installed_modules(database):
|
||||||
|
"""Les modules installés. Sans eux le balayage des sources est vain.
|
||||||
|
|
||||||
|
Un enfant peut vivre dans N'IMPORTE quel module — `website_crm` hérite
|
||||||
|
de `website.contactus` — donc il faut balayer large. Mais balayer TOUT
|
||||||
|
l'arbre cible coûterait des milliers de fichiers pour rien : seuls les
|
||||||
|
modules installés produiront des vues.
|
||||||
|
"""
|
||||||
|
output = run_sql(
|
||||||
|
database,
|
||||||
|
"SELECT name FROM ir_module_module WHERE state IN"
|
||||||
|
" ('installed', 'to upgrade')",
|
||||||
|
)
|
||||||
|
return [ligne.strip() for ligne in output.splitlines() if ligne.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def full_key(module_name, valeur):
|
||||||
|
"""« contactus » dans le module website devient « website.contactus »."""
|
||||||
|
valeur = (valeur or "").strip()
|
||||||
|
if not valeur:
|
||||||
|
return None
|
||||||
|
return valeur if "." in valeur else f"{module_name}.{valeur}"
|
||||||
|
|
||||||
|
|
||||||
|
def scan_target_views(target_version, lst_module):
|
||||||
|
"""(déclarés, héritages) tels que la version CIBLE les livre.
|
||||||
|
|
||||||
|
`déclarés` : les clés de gabarit que la cible fournit. Sert à repérer
|
||||||
|
un `t-call` vers un gabarit qui n'existe plus.
|
||||||
|
|
||||||
|
`héritages` : {clé parente: [expressions xpath]}. C'est le manque qui
|
||||||
|
a coûté quatre paliers de silence — la cible ajoute un ancrage dans
|
||||||
|
une vue module, sa vue héritière le vise, et la copie de site, qui
|
||||||
|
n'est jamais réécrite, ne l'a pas.
|
||||||
|
"""
|
||||||
|
declares = set()
|
||||||
|
heritages = {}
|
||||||
|
for module_name in lst_module:
|
||||||
|
module_dir = find_module_dir(target_version, module_name)
|
||||||
|
if module_dir is None:
|
||||||
|
continue
|
||||||
|
motif = os.path.join(module_dir, "**", "*.xml")
|
||||||
|
for file_path in sorted(glob.glob(motif, recursive=True)):
|
||||||
|
try:
|
||||||
|
racine = ET.parse(file_path).getroot()
|
||||||
|
except ET.ParseError:
|
||||||
|
continue
|
||||||
|
for element in racine.iter("template"):
|
||||||
|
cle = full_key(module_name, element.get("id"))
|
||||||
|
if cle:
|
||||||
|
declares.add(cle)
|
||||||
|
parent = full_key(module_name, element.get("inherit_id"))
|
||||||
|
if not parent:
|
||||||
|
continue
|
||||||
|
exprs = [
|
||||||
|
noeud.get("expr")
|
||||||
|
for noeud in element.iter("xpath")
|
||||||
|
if noeud.get("expr")
|
||||||
|
]
|
||||||
|
if exprs:
|
||||||
|
heritages.setdefault(parent, []).extend(exprs)
|
||||||
|
return declares, heritages
|
||||||
|
|
||||||
|
|
||||||
|
def will_not_render(database, target_version):
|
||||||
|
"""Les copies qui passeront la migration et rendront 500 ensuite.
|
||||||
|
|
||||||
|
Deux ruptures que `analyse` ne voit pas, parce qu'elles ne touchent
|
||||||
|
pas la FORME de la copie :
|
||||||
|
|
||||||
|
ancrage manquant un enfant de la CIBLE vise `//t[@t-set='x']` que
|
||||||
|
la copie n'a pas.
|
||||||
|
t-call pendant la copie appelle un gabarit que la cible ne
|
||||||
|
livre plus.
|
||||||
|
|
||||||
|
Celles-là ne se neutralisent PAS : la copie porte une page écrite par
|
||||||
|
quelqu'un, et la mettre de côté l'effacerait du site. Elles se
|
||||||
|
réparent — `fix_cow_render.py` remet l'ancrage depuis la vue module
|
||||||
|
et retire l'appel mort, sans toucher au contenu.
|
||||||
|
"""
|
||||||
|
from fix_cow_render import dangling_calls, locates
|
||||||
|
|
||||||
|
copies = query_cow_archs(database)
|
||||||
|
if not copies:
|
||||||
|
return []
|
||||||
|
declares, heritages = scan_target_views(
|
||||||
|
target_version, installed_modules(database)
|
||||||
|
)
|
||||||
|
connus = declares | {cle for _i, cle, _m, _w, _a in copies if cle}
|
||||||
|
risques = []
|
||||||
|
for view_id, key, mode, website_id, langues in copies:
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
# Chaque langue : une page peut être cassée en fr_CA et saine en
|
||||||
|
# en_US, et c'est celle du site qui décide de ce qu'on voit.
|
||||||
|
manques = set()
|
||||||
|
pendants = []
|
||||||
|
for texte in langues.values():
|
||||||
|
for expr in heritages.get(key, []):
|
||||||
|
if not locates(texte, expr):
|
||||||
|
manques.add(expr)
|
||||||
|
for nom in dangling_calls(texte, connus):
|
||||||
|
if nom not in pendants:
|
||||||
|
pendants.append(nom)
|
||||||
|
for expr in sorted(manques):
|
||||||
|
risques.append(
|
||||||
|
(
|
||||||
|
view_id,
|
||||||
|
key,
|
||||||
|
mode,
|
||||||
|
mode,
|
||||||
|
website_id,
|
||||||
|
f"a child of the target needs an anchor this copy lacks:"
|
||||||
|
f" {expr}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for nom in pendants:
|
||||||
|
risques.append(
|
||||||
|
(
|
||||||
|
view_id,
|
||||||
|
key,
|
||||||
|
mode,
|
||||||
|
mode,
|
||||||
|
website_id,
|
||||||
|
f"calls a template the target no longer ships: {nom}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return risques
|
||||||
|
|
||||||
|
|
||||||
def analyse(database, target_version):
|
def analyse(database, target_version):
|
||||||
"""Sort COW views into three buckets by comparing with the target sources.
|
"""Sort COW views into three buckets by comparing with the target sources.
|
||||||
|
|
||||||
|
|
@ -304,9 +485,29 @@ def main():
|
||||||
lst_at_risk, lst_module_absent, lst_no_counterpart = analyse(
|
lst_at_risk, lst_module_absent, lst_no_counterpart = analyse(
|
||||||
config.database, config.target_version
|
config.database, config.target_version
|
||||||
)
|
)
|
||||||
|
# L'autre famille de rupture : la copie garde sa forme et cesse de se
|
||||||
|
# RENDRE. Elle ne se neutralise pas — la copie porte une page écrite
|
||||||
|
# par quelqu'un — elle se répare.
|
||||||
|
lst_no_render = will_not_render(config.database, config.target_version)
|
||||||
database = config.database
|
database = config.database
|
||||||
target_version = config.target_version
|
target_version = config.target_version
|
||||||
|
|
||||||
|
if lst_no_render:
|
||||||
|
print(
|
||||||
|
f"⚠️ {len(lst_no_render)}"
|
||||||
|
f" {t('website COW view(s) will survive the bump and then fail')}"
|
||||||
|
f" {t('to render:')}"
|
||||||
|
)
|
||||||
|
for view_id, key, _mode, _cible, website_id, raison in lst_no_render:
|
||||||
|
print(f" - id={view_id} website={website_id} {key}")
|
||||||
|
print(f" {t(raison)}")
|
||||||
|
print(
|
||||||
|
f" {t('Repair rather than neutralize — the copy holds a page')}"
|
||||||
|
f" {t('someone wrote:')}"
|
||||||
|
f" ./script/odoo/migration/fix_cow_render.py -d {config.database}"
|
||||||
|
)
|
||||||
|
print("")
|
||||||
|
|
||||||
if not lst_at_risk:
|
if not lst_at_risk:
|
||||||
print(
|
print(
|
||||||
f"✅ -> {t('No website COW view changes shape in')}"
|
f"✅ -> {t('No website COW view changes shape in')}"
|
||||||
|
|
@ -378,7 +579,9 @@ def main():
|
||||||
# 0 = rien à signaler, 1 = des copies casseront, 2 = l'outil a échoué.
|
# 0 = rien à signaler, 1 = des copies casseront, 2 = l'outil a échoué.
|
||||||
# Le pilote lisait le texte anglais de cette sortie pour savoir s'il
|
# Le pilote lisait le texte anglais de cette sortie pour savoir s'il
|
||||||
# devait poser sa question : traduire le message le rendait aveugle.
|
# devait poser sa question : traduire le message le rendait aveugle.
|
||||||
return 1 if lst_at_risk else 0
|
# Les deux familles comptent : une copie qui rendra 500 est une
|
||||||
|
# trouvaille, même si la migration, elle, ne s'arrêtera pas.
|
||||||
|
return 1 if (lst_at_risk or lst_no_render) else 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -6710,6 +6710,22 @@ TRANSLATIONS = {
|
||||||
"fr": "Chaque copie de site sait encore se rendre.",
|
"fr": "Chaque copie de site sait encore se rendre.",
|
||||||
"en": "Every website copy still renders.",
|
"en": "Every website copy still renders.",
|
||||||
},
|
},
|
||||||
|
"website COW view(s) will survive the bump and then fail": {
|
||||||
|
"fr": "vue(s) COW de site passeront le palier puis échoueront",
|
||||||
|
"en": "website COW view(s) will survive the bump and then fail",
|
||||||
|
},
|
||||||
|
"to render:": {
|
||||||
|
"fr": "à se rendre :",
|
||||||
|
"en": "to render:",
|
||||||
|
},
|
||||||
|
"Repair rather than neutralize — the copy holds a page": {
|
||||||
|
"fr": "Réparer plutôt que neutraliser — la copie porte une page",
|
||||||
|
"en": "Repair rather than neutralize — the copy holds a page",
|
||||||
|
},
|
||||||
|
"someone wrote:": {
|
||||||
|
"fr": "écrite par quelqu'un :",
|
||||||
|
"en": "someone wrote:",
|
||||||
|
},
|
||||||
"Every website copy renders again.": {
|
"Every website copy renders again.": {
|
||||||
"fr": "Chaque copie de site sait de nouveau se rendre.",
|
"fr": "Chaque copie de site sait de nouveau se rendre.",
|
||||||
"en": "Every website copy renders again.",
|
"en": "Every website copy renders again.",
|
||||||
|
|
|
||||||
|
|
@ -2705,6 +2705,10 @@ class TodoUpgrade:
|
||||||
f"before_{next_version}",
|
f"before_{next_version}",
|
||||||
f"after_{next_version}",
|
f"after_{next_version}",
|
||||||
)
|
)
|
||||||
|
# ICI et pas avant : la réparation prend l'ancrage manquant
|
||||||
|
# dans la vue MODULE, qui ne porte l'arch de la cible
|
||||||
|
# qu'une fois la migration passée.
|
||||||
|
self.repair_cow_render(database_name_upgrade)
|
||||||
|
|
||||||
lst_upgrade_odoo[index] = cmd_upgrade
|
lst_upgrade_odoo[index] = cmd_upgrade
|
||||||
self.dct_progression["state_4_upgrade_odoo_lst"] = (
|
self.dct_progression["state_4_upgrade_odoo_lst"] = (
|
||||||
|
|
@ -3656,6 +3660,30 @@ class TodoUpgrade:
|
||||||
f" -d {database_name} --list"
|
f" -d {database_name} --list"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def repair_cow_render(self, database_name):
|
||||||
|
"""Les copies de site qui passent le palier et rendent 500 après.
|
||||||
|
|
||||||
|
Deux ruptures qu'aucune autre étape ne voit : un ancrage que la
|
||||||
|
vue héritière de la CIBLE réclame et que la copie n'a jamais eu,
|
||||||
|
et un `t-call` vers un gabarit que la cible ne livre plus. Mesuré
|
||||||
|
sur une chaîne 12 → 18 : /contact rendait 500 depuis le palier
|
||||||
|
14 → 15, et rien ne l'a dit avant le test de fumée final.
|
||||||
|
|
||||||
|
On ne neutralise pas : la copie porte une page écrite par
|
||||||
|
quelqu'un. On répare, et le contenu reste.
|
||||||
|
|
||||||
|
`wait_at_error=False` : le code 1 de cet outil veut dire « j'ai
|
||||||
|
trouvé », pas « je suis tombé ». Sans cela le pilote ouvrirait son
|
||||||
|
menu d'erreur sur une réparation qui s'est bien passée.
|
||||||
|
"""
|
||||||
|
outil = os.path.join(PATH_MIGRATION_GLOBAL, "fix_cow_render.py")
|
||||||
|
if not os.path.isfile(outil):
|
||||||
|
return
|
||||||
|
self.todo_upgrade_execute(
|
||||||
|
f"{PYTHON_BIN} ./{outil} -d {database_name} --apply",
|
||||||
|
wait_at_error=False,
|
||||||
|
)
|
||||||
|
|
||||||
def diff_cow_views(self, database_name, label_before, label_after):
|
def diff_cow_views(self, database_name, label_before, label_after):
|
||||||
"""Print what the version bump did to the website COW views."""
|
"""Print what the version bump did to the website COW views."""
|
||||||
directory = os.path.join(
|
directory = os.path.join(
|
||||||
|
|
|
||||||
266
test/test_check_cow_views_render.py
Normal file
266
test/test_check_cow_views_render.py
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||||
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||||
|
|
||||||
|
"""Prédire, AVANT le palier, la copie qui rendra 500 après.
|
||||||
|
|
||||||
|
`check_cow_views` cherchait la rupture qui ARRÊTE la migration : la forme
|
||||||
|
que l'arch doit avoir change. Deux autres la laissent finir et ne se
|
||||||
|
voient qu'à l'ouverture de la page — et personne n'ouvre les pages avant
|
||||||
|
la fin.
|
||||||
|
|
||||||
|
Mesuré sur une chaîne 12 → 18 réelle : /contact rendait 500 depuis le
|
||||||
|
palier 14 → 15. Rejouée avec ce contrôle, la prédiction nomme le défaut
|
||||||
|
dès le palier 13 → 14, cinq paliers avant que quiconque s'en aperçoive.
|
||||||
|
|
||||||
|
Ces copies-là ne se NEUTRALISENT pas : chacune porte une page écrite par
|
||||||
|
quelqu'un, et la mettre de côté l'effacerait du site. Elles se réparent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.append(
|
||||||
|
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
)
|
||||||
|
sys.path.append(
|
||||||
|
os.path.normpath(
|
||||||
|
os.path.join(
|
||||||
|
os.path.dirname(__file__), "..", "script", "odoo", "migration"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
import check_cow_views as cow # noqa: E402
|
||||||
|
|
||||||
|
# La copie du client : sa page, sans l'ancrage que la cible attend, et
|
||||||
|
# avec un appel vers un gabarit que la cible ne livre plus.
|
||||||
|
ARCH_COPIE = (
|
||||||
|
"<t t-name='website.contactus'>"
|
||||||
|
"<t t-call='website.layout'>"
|
||||||
|
"<div id='wrap'><h1>Parlons de votre projet</h1>"
|
||||||
|
"<t t-call='website.company_description'/></div>"
|
||||||
|
"</t></t>"
|
||||||
|
)
|
||||||
|
|
||||||
|
MODULE_ENFANT = """<odoo>
|
||||||
|
<template id="contactus_form" inherit_id="website.contactus">
|
||||||
|
<xpath expr="//t[@t-set='contactus_form_values']" position="after">
|
||||||
|
<t t-set="autre" t-value="2"/>
|
||||||
|
</xpath>
|
||||||
|
</template>
|
||||||
|
</odoo>
|
||||||
|
"""
|
||||||
|
|
||||||
|
MODULE_PARENT = """<odoo>
|
||||||
|
<template id="contactus" name="Contact">
|
||||||
|
<t t-call="website.layout"><div id="wrap"/></t>
|
||||||
|
</template>
|
||||||
|
<template id="layout" name="Layout"><div/></template>
|
||||||
|
</odoo>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TestFullKey(unittest.TestCase):
|
||||||
|
def test_a_bare_id_takes_its_module(self):
|
||||||
|
self.assertEqual(
|
||||||
|
cow.full_key("website", "contactus"), "website.contactus"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_qualified_id_is_left_alone(self):
|
||||||
|
self.assertEqual(
|
||||||
|
cow.full_key("website_crm", "website.contactus"),
|
||||||
|
"website.contactus",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_nothing_stays_nothing(self):
|
||||||
|
self.assertIsNone(cow.full_key("website", ""))
|
||||||
|
self.assertIsNone(cow.full_key("website", None))
|
||||||
|
|
||||||
|
|
||||||
|
class TestScanningTheTargetSources(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.racine = tempfile.mkdtemp(prefix="el_cow_")
|
||||||
|
self.addCleanup(shutil.rmtree, self.racine, True)
|
||||||
|
for module, contenu in (
|
||||||
|
("website", MODULE_PARENT),
|
||||||
|
("website_crm", MODULE_ENFANT),
|
||||||
|
):
|
||||||
|
chemin = os.path.join(self.racine, "odoo", "addons", module)
|
||||||
|
os.makedirs(chemin)
|
||||||
|
with open(
|
||||||
|
os.path.join(chemin, "views.xml"), "w", encoding="utf-8"
|
||||||
|
) as f:
|
||||||
|
f.write(contenu)
|
||||||
|
|
||||||
|
def scan(self, modules=("website", "website_crm")):
|
||||||
|
return cow.scan_target_views(self.racine, list(modules))
|
||||||
|
|
||||||
|
def test_it_lists_what_the_target_ships(self):
|
||||||
|
declares, _ = self.scan()
|
||||||
|
self.assertIn("website.contactus", declares)
|
||||||
|
self.assertIn("website.layout", declares)
|
||||||
|
|
||||||
|
def test_it_maps_a_child_xpath_to_the_parent_it_needs(self):
|
||||||
|
# C'est LE renseignement qui manquait : quelle vue de la cible
|
||||||
|
# exige quel ancrage, et dans quel parent.
|
||||||
|
_, heritages = self.scan()
|
||||||
|
self.assertEqual(
|
||||||
|
heritages["website.contactus"],
|
||||||
|
["//t[@t-set='contactus_form_values']"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_module_absent_from_the_target_is_skipped(self):
|
||||||
|
declares, _ = self.scan(["website", "jamais_livre"])
|
||||||
|
self.assertIn("website.contactus", declares)
|
||||||
|
|
||||||
|
def test_a_template_without_xpath_adds_no_requirement(self):
|
||||||
|
_, heritages = self.scan(["website"])
|
||||||
|
self.assertEqual(heritages, {})
|
||||||
|
|
||||||
|
|
||||||
|
class TestWillNotRender(unittest.TestCase):
|
||||||
|
"""Le verdict, en remplaçant ce qui touche la base et le disque."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.vrai = (
|
||||||
|
cow.query_cow_archs,
|
||||||
|
cow.installed_modules,
|
||||||
|
cow.scan_target_views,
|
||||||
|
)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
(
|
||||||
|
cow.query_cow_archs,
|
||||||
|
cow.installed_modules,
|
||||||
|
cow.scan_target_views,
|
||||||
|
) = self.vrai
|
||||||
|
|
||||||
|
def poser(self, copies, declares, heritages):
|
||||||
|
cow.query_cow_archs = lambda d: copies
|
||||||
|
cow.installed_modules = lambda d: ["website"]
|
||||||
|
cow.scan_target_views = lambda v, m: (declares, heritages)
|
||||||
|
|
||||||
|
def copie(self, arch=ARCH_COPIE, langues=None):
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
1228,
|
||||||
|
"website.contactus",
|
||||||
|
"primary",
|
||||||
|
1,
|
||||||
|
langues or {"en_US": arch},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_it_names_the_anchor_a_target_child_will_need(self):
|
||||||
|
self.poser(
|
||||||
|
self.copie(),
|
||||||
|
{
|
||||||
|
"website.contactus",
|
||||||
|
"website.layout",
|
||||||
|
"website.company_description",
|
||||||
|
},
|
||||||
|
{"website.contactus": ["//t[@t-set='contactus_form_values']"]},
|
||||||
|
)
|
||||||
|
risques = cow.will_not_render("db", "odoo18.0")
|
||||||
|
self.assertEqual(1, len(risques), risques)
|
||||||
|
self.assertIn("contactus_form_values", risques[0][5])
|
||||||
|
|
||||||
|
def test_an_anchor_the_copy_already_has_is_not_reported(self):
|
||||||
|
arch = ARCH_COPIE.replace(
|
||||||
|
"<div id='wrap'>",
|
||||||
|
"<t t-set='contactus_form_values' t-value='1'/><div id='wrap'>",
|
||||||
|
)
|
||||||
|
self.poser(
|
||||||
|
self.copie(arch),
|
||||||
|
{
|
||||||
|
"website.contactus",
|
||||||
|
"website.layout",
|
||||||
|
"website.company_description",
|
||||||
|
},
|
||||||
|
{"website.contactus": ["//t[@t-set='contactus_form_values']"]},
|
||||||
|
)
|
||||||
|
self.assertEqual([], cow.will_not_render("db", "odoo18.0"))
|
||||||
|
|
||||||
|
def test_it_names_a_template_the_target_no_longer_ships(self):
|
||||||
|
self.poser(
|
||||||
|
self.copie(),
|
||||||
|
{"website.contactus", "website.layout"},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
risques = cow.will_not_render("db", "odoo18.0")
|
||||||
|
self.assertEqual(1, len(risques), risques)
|
||||||
|
self.assertIn("company_description", risques[0][5])
|
||||||
|
|
||||||
|
def test_another_copy_counts_as_a_known_template(self):
|
||||||
|
# Un gabarit peut n'exister qu'en base : une copie de site qui en
|
||||||
|
# appelle une autre n'est pas cassée pour autant.
|
||||||
|
copies = self.copie() + [
|
||||||
|
(
|
||||||
|
99,
|
||||||
|
"website.company_description",
|
||||||
|
"primary",
|
||||||
|
1,
|
||||||
|
{"en_US": "<t/>"},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.poser(copies, {"website.contactus", "website.layout"}, {})
|
||||||
|
self.assertEqual([], cow.will_not_render("db", "odoo18.0"))
|
||||||
|
|
||||||
|
def test_a_copy_broken_in_one_language_only_is_still_reported(self):
|
||||||
|
# Le site rend dans SA langue : réparer l'anglais et laisser le
|
||||||
|
# français cassé donne une page en 500 et un rapport vert.
|
||||||
|
sain = ARCH_COPIE.replace(
|
||||||
|
"<div id='wrap'>",
|
||||||
|
"<t t-set='contactus_form_values' t-value='1'/><div id='wrap'>",
|
||||||
|
)
|
||||||
|
self.poser(
|
||||||
|
self.copie(langues={"en_US": sain, "fr_CA": ARCH_COPIE}),
|
||||||
|
{
|
||||||
|
"website.contactus",
|
||||||
|
"website.layout",
|
||||||
|
"website.company_description",
|
||||||
|
},
|
||||||
|
{"website.contactus": ["//t[@t-set='contactus_form_values']"]},
|
||||||
|
)
|
||||||
|
risques = cow.will_not_render("db", "odoo18.0")
|
||||||
|
self.assertEqual(1, len(risques), risques)
|
||||||
|
|
||||||
|
def test_a_copy_without_a_key_is_left_alone(self):
|
||||||
|
self.poser(
|
||||||
|
[(7, "", "primary", 1, {"en_US": ARCH_COPIE})],
|
||||||
|
{"website.company_description"},
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
self.assertEqual([], cow.will_not_render("db", "odoo18.0"))
|
||||||
|
|
||||||
|
def test_no_copy_at_all_is_not_an_error(self):
|
||||||
|
self.poser([], set(), {})
|
||||||
|
self.assertEqual([], cow.will_not_render("db", "odoo18.0"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestItStaysOutOfTheNeutralizer(unittest.TestCase):
|
||||||
|
"""Ces copies se réparent ; les neutraliser effacerait la page."""
|
||||||
|
|
||||||
|
def test_the_neutralizer_only_acts_on_the_shape_bucket(self):
|
||||||
|
with open(
|
||||||
|
os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
"..",
|
||||||
|
"script",
|
||||||
|
"odoo",
|
||||||
|
"migration",
|
||||||
|
"neutralize_cow_views.py",
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
) as handle:
|
||||||
|
source = handle.read()
|
||||||
|
self.assertIn("lst_at_risk, _, _ = analyse(", source)
|
||||||
|
self.assertNotIn("will_not_render", source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -351,5 +351,89 @@ class TestTheReport(unittest.TestCase):
|
||||||
self.assertIn("⚠", texte)
|
self.assertIn("⚠", texte)
|
||||||
|
|
||||||
|
|
||||||
|
class TestItRunsInsideTheMigration(unittest.TestCase):
|
||||||
|
"""La réparation doit tourner APRÈS OpenUpgrade, pas avant.
|
||||||
|
|
||||||
|
Elle prend l'ancrage manquant dans la vue MODULE, qui ne porte l'arch
|
||||||
|
de la version cible qu'une fois la migration passée. Lancée avant,
|
||||||
|
elle recopierait l'ancien ancrage — ou rien.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def pilote(self):
|
||||||
|
chemin = os.path.join(
|
||||||
|
os.path.dirname(__file__),
|
||||||
|
"..",
|
||||||
|
"script",
|
||||||
|
"todo",
|
||||||
|
"todo_upgrade.py",
|
||||||
|
)
|
||||||
|
with open(chemin, encoding="utf-8") as handle:
|
||||||
|
return handle.read()
|
||||||
|
|
||||||
|
def corps_de_la_reparation(self):
|
||||||
|
"""Le CODE de la méthode, docstring retirée.
|
||||||
|
|
||||||
|
La docstring nomme `wait_at_error=False` pour l'expliquer : un
|
||||||
|
test qui lit tout le texte le trouve même quand l'argument a
|
||||||
|
disparu, et croit garder ce qu'il a laissé partir. Mesuré.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
|
||||||
|
for noeud in ast.walk(ast.parse(self.pilote())):
|
||||||
|
if (
|
||||||
|
isinstance(noeud, ast.FunctionDef)
|
||||||
|
and noeud.name == "repair_cow_render"
|
||||||
|
):
|
||||||
|
sans_texte = [
|
||||||
|
n
|
||||||
|
for n in noeud.body
|
||||||
|
if not (
|
||||||
|
isinstance(n, ast.Expr)
|
||||||
|
and isinstance(n.value, ast.Constant)
|
||||||
|
and isinstance(n.value.value, str)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return ast.dump(ast.Module(body=sans_texte, type_ignores=[]))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def test_the_scan_finds_the_method_at_all(self):
|
||||||
|
# Sans cette borne, les tests suivants passeraient sur une chaîne
|
||||||
|
# vide le jour où la méthode est renommée.
|
||||||
|
self.assertTrue(self.corps_de_la_reparation())
|
||||||
|
|
||||||
|
def test_the_driver_calls_the_repair(self):
|
||||||
|
self.assertIn("repair_cow_render", self.pilote())
|
||||||
|
|
||||||
|
def test_it_applies_rather_than_only_reporting(self):
|
||||||
|
corps = self.corps_de_la_reparation()
|
||||||
|
self.assertIn("fix_cow_render.py", corps)
|
||||||
|
self.assertIn("--apply", corps)
|
||||||
|
|
||||||
|
def test_a_finding_does_not_open_the_error_menu(self):
|
||||||
|
# Le code 1 de cet outil veut dire « j'ai trouvé », pas « je suis
|
||||||
|
# tombé ». Sans ce drapeau le pilote s'arrête sur une réparation
|
||||||
|
# réussie.
|
||||||
|
corps = self.corps_de_la_reparation()
|
||||||
|
self.assertIn("wait_at_error", corps)
|
||||||
|
self.assertIn("value=False", corps)
|
||||||
|
|
||||||
|
def test_it_is_called_after_the_data_migration(self):
|
||||||
|
source = self.pilote()
|
||||||
|
migration = source.index("cmd_upgrade,\n new_env=")
|
||||||
|
appel = source.index("self.repair_cow_render(")
|
||||||
|
self.assertLess(
|
||||||
|
migration,
|
||||||
|
appel,
|
||||||
|
"la réparation doit suivre OpenUpgrade : avant, la vue module"
|
||||||
|
" porte encore l'arch de l'ancienne version",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_missing_tool_is_not_a_crash(self):
|
||||||
|
# Un vieux checkout n'a pas le fichier ; la migration ne doit pas
|
||||||
|
# s'arrêter pour autant.
|
||||||
|
corps = self.corps_de_la_reparation()
|
||||||
|
self.assertIn("isfile", corps)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -218,11 +218,30 @@ class TestTheExitCodesTheDriverRelieson(unittest.TestCase):
|
||||||
def test_check_cow_views_reports_findings_with_1(self):
|
def test_check_cow_views_reports_findings_with_1(self):
|
||||||
# Le chemin qui compte demande une base ; on vérifie la décision
|
# Le chemin qui compte demande une base ; on vérifie la décision
|
||||||
# elle-même, à la source.
|
# elle-même, à la source.
|
||||||
|
#
|
||||||
|
# On lit le RETURN, pas une ligne recopiée : l'outil a gagné une
|
||||||
|
# seconde famille de trouvailles (les copies qui rendront 500),
|
||||||
|
# et figer le texte faisait échouer le test sur un ajout juste.
|
||||||
|
# Ce qui doit rester vrai, c'est que CHAQUE famille pèse sur le
|
||||||
|
# code de sortie.
|
||||||
|
import ast
|
||||||
|
|
||||||
path = os.path.join(
|
path = os.path.join(
|
||||||
REPO, "script", "odoo", "migration", "check_cow_views.py"
|
REPO, "script", "odoo", "migration", "check_cow_views.py"
|
||||||
)
|
)
|
||||||
with open(path) as handle:
|
with open(path) as handle:
|
||||||
self.assertIn("return 1 if lst_at_risk else 0", handle.read())
|
arbre = ast.parse(handle.read())
|
||||||
|
retours = [
|
||||||
|
ast.dump(noeud)
|
||||||
|
for fonction in ast.walk(arbre)
|
||||||
|
if isinstance(fonction, ast.FunctionDef)
|
||||||
|
and fonction.name == "main"
|
||||||
|
for noeud in ast.walk(fonction)
|
||||||
|
if isinstance(noeud, ast.Return)
|
||||||
|
]
|
||||||
|
decision = [r for r in retours if "lst_at_risk" in r]
|
||||||
|
self.assertEqual(1, len(decision), retours)
|
||||||
|
self.assertIn("lst_no_render", decision[0])
|
||||||
|
|
||||||
|
|
||||||
class TestAllToolsKeepWorkingStandalone(unittest.TestCase):
|
class TestAllToolsKeepWorkingStandalone(unittest.TestCase):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue