[ADD] migration: réparer les copies de site qui ne savent plus se rendre

`check_cow_views` cherche 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 manquant : une vue héritière fait un xpath sur `//t[@t-set=…]`
que le module a gagné en chemin ; la copie, page d'utilisateur, n'est
jamais réécrite. Un t-call pendant : le gabarit appelé n'existe plus dans
la cible. Mesuré sur une 12 → 18 réelle : /contact rendait 500 depuis le
palier 14 → 15, quatre paliers de silence, et le test de fumée le voyait
sans savoir le nommer.

Le premier se répare depuis la vue module, en gardant la page telle que
son auteur l'a écrite. Le second se retire, et l'humain décide.

--- EN ---

`check_cow_views` looks for the break that STOPS the migration: the shape
the arch must have changes. Two others let it finish without a word.

A missing anchor: a child view xpaths on `//t[@t-set=…]` that the module
gained along the way; the copy, a user page, is never rewritten. A
dangling t-call: the template called no longer exists in the target.
Measured on a real 12 → 18: /contact returned 500 since the 14 → 15 step,
four steps of silence, and the smoke test saw it without naming it.

The first is repaired from the module view, keeping the page as its
author wrote it. The second is removed, and the human decides.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-24 02:53:24 -04:00
parent a476e34a57
commit 291fd67841
3 changed files with 972 additions and 1 deletions

View file

@ -0,0 +1,577 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Les copies de site web qui ne savent plus se rendre après une montée.
`check_cow_views` prédit UN mode de rupture : la FORME que l'arch doit
avoir change entre deux versions — template autonome d'un côté, spec
d'héritage de l'autre. C'est celui qui arrête la migration elle-même, et
c'est pour cela qu'on le cherche AVANT de partir.
Deux autres modes la laissent finir sans un mot et n'apparaissent qu'à
l'ouverture de la page. Mesurés sur une migration 12 → 18 réelle :
ancrage manquant une vue héritière cherche `//t[@t-set='x']` dans la
copie. Le module a gagné cet ancrage en chemin — ici
au palier 14 → 15 — et sa propre vue héritière a
suivi ; la copie, elle, n'est jamais réécrite,
c'est une page d'utilisateur. Quatre paliers plus
tard /contact rendait 500, et rien ne l'avait dit.
t-call pendant la copie appelle un gabarit que la version cible ne
livre plus. `website.company_description` a disparu
en 18 : les deux copies qui l'appelaient rendaient
500, l'une d'elles pour CETTE raison seulement une
fois son ancrage remis.
Le premier se répare : l'ancrage existe dans la vue module de même clé,
on le remet là où le module le place. Le second ne se répare pas — un
gabarit disparu ne se réinvente pas — on retire l'appel, et la personne
décide si le bloc doit revenir autrement.
Lecture seule par défaut. `--apply` écrit, puis RELIT pour vérifier :
annoncer « corrigé » sans regarder ferait rouvrir la même page cassée.
"""
import argparse
import json
import os
import subprocess
import sys
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
def run_psql(database, sql, read_only=True):
"""Interroger la base. None si elle ne répond pas.
Lecture seule garantie par le SERVEUR et non par la politesse du
code : une réparation qui se trompe de base doit être refusée par
PostgreSQL, pas par une relecture attentive.
"""
# PGOPTIONS et non un « SET » en tête : celui-ci ÉCRIT une ligne
# « SET » dans la sortie, que le json.loads d'à côté prend en pleine
# figure. Même forme que fix_view_type.py, pour la même raison.
env = os.environ.copy()
if read_only:
env["PGOPTIONS"] = "-c default_transaction_read_only=on"
env["PSQLRC"] = ""
result = subprocess.run(
["psql", "-X", "-w", "-d", database, "-tA", "-c", sql],
capture_output=True,
text=True,
env=env,
)
if result.returncode:
return None
return result.stdout
def arch_is_jsonb(database):
"""La 16 stocke l'arch en varchar, la 17 et la 18 en jsonb."""
sortie = run_psql(
database,
"SELECT data_type FROM information_schema.columns"
" WHERE table_name='ir_ui_view' AND column_name='arch_db'",
)
return bool(sortie) and "jsonb" in sortie
# `arch_db` est un jsonb d'UNE ENTRÉE PAR LANGUE depuis la 17. N'en lire
# qu'une — et surtout n'en écrire qu'une — laisse la page cassée dans
# toutes les autres : mesuré sur /contact, réparé en en_US et toujours en
# 500 parce que le site rend en fr_CA.
def arch_expr(jsonb):
return "arch_db" if jsonb else "json_build_object('', arch_db)"
def langs_of(arch):
"""{langue: texte}. La clé vide quand la version ne traduit pas l'arch."""
if isinstance(arch, dict):
return {k: v for k, v in arch.items() if isinstance(v, str)}
return {"": arch} if isinstance(arch, str) else {}
def fetch(database, sql):
"""Rendre une liste de dictionnaires, ou None si la base se tait.
Un seul aller-retour et du JSON plutôt qu'un découpage par lignes :
un arch contient des sauts de ligne, et toute lecture ligne à ligne
finirait par couper une vue en deux sans le dire.
"""
sortie = run_psql(
database, f"SELECT coalesce(json_agg(x), '[]') FROM ({sql}) x"
)
if sortie is None:
return None
try:
return json.loads(sortie.strip() or "[]")
except ValueError:
return None
def cow_copies(database, jsonb):
"""Les copies faites par le site : website_id posé, aucun fichier.
`arch_fs` vide est le discriminant : une copie qui garde un fichier
est réécrite par la mise à jour du module, celle qui n'en a pas est
du contenu d'utilisateur et ne l'est jamais.
"""
return fetch(
database,
f"SELECT id, key, {arch_expr(jsonb)} AS arch FROM ir_ui_view"
" WHERE website_id IS NOT NULL AND coalesce(arch_fs, '') = ''"
" AND active",
)
def children_of(database, jsonb, lst_id):
"""{parent: [enfants]} — les vues qui héritent d'une de ces copies."""
if not lst_id:
return {}
ids = ",".join(str(i) for i in lst_id)
lignes = fetch(
database,
f"SELECT id, key, inherit_id, {arch_expr(jsonb)} AS arch"
f" FROM ir_ui_view WHERE inherit_id IN ({ids}) AND active",
)
dct = {}
for ligne in lignes or []:
dct.setdefault(ligne["inherit_id"], []).append(ligne)
return dct
def module_twin(database, jsonb, cle):
"""L'arch de la vue MODULE de même clé, celle dont la copie est née."""
lignes = fetch(
database,
f"SELECT {arch_expr(jsonb)} AS arch FROM ir_ui_view"
f" WHERE key = '{cle}' AND website_id IS NULL"
" AND coalesce(arch_fs, '') <> '' LIMIT 1",
)
return (lignes or [{}])[0].get("arch")
def known_keys(database):
sortie = run_psql(
database, "SELECT key FROM ir_ui_view WHERE key IS NOT NULL"
)
if sortie is None:
return None
return {ligne.strip() for ligne in sortie.splitlines() if ligne.strip()}
def parse(arch):
"""L'arbre, ou None si l'arch ne s'analyse pas."""
from lxml import etree
if not arch:
return None
try:
return etree.fromstring(arch.encode("utf-8"))
except Exception:
return None
def anchors_wanted(arch_enfant):
"""Les expressions xpath qu'une vue héritière cherche dans son parent."""
arbre = parse(arch_enfant)
if arbre is None:
return []
return [
noeud.get("expr") for noeud in arbre.iter("xpath") if noeud.get("expr")
]
def locates(arch, expr):
"""L'expression trouve-t-elle quelque chose dans cet arch ?
Une expression illisible compte comme TROUVÉE : elle ne relève pas de
cet outil, et la signaler comme un ancrage manquant enverrait
réparer une vue qui n'a rien.
"""
arbre = parse(arch)
if arbre is None:
return True
try:
return bool(arbre.xpath(expr))
except Exception:
return True
def dangling_calls(arch, connus):
"""Les `t-call` de cette arch vers un gabarit que la base n'a pas.
Seuls les appels LITTÉRAUX : un `t-call` calculé se résout à
l'exécution et l'on ne peut rien en dire ici.
"""
arbre = parse(arch)
if arbre is None:
return []
manquants = []
for noeud in arbre.iter():
nom = noeud.get("t-call")
if not nom or "{" in nom or nom.startswith("#"):
continue
if nom not in connus and nom not in manquants:
manquants.append(nom)
return manquants
def signature(noeud):
"""De quoi reconnaître un élément d'un arch dans l'autre."""
for attribut in ("t-call", "id", "t-name"):
if noeud.get(attribut):
return (noeud.tag, attribut, noeud.get(attribut))
return (noeud.tag, None, None)
def find_by_signature(arbre, sig):
tag, attribut, valeur = sig
for noeud in arbre.iter(tag):
if attribut is None:
return noeud
if noeud.get(attribut) == valeur:
return noeud
return None
def copie_de(noeud):
from lxml import etree
return etree.fromstring(etree.tostring(noeud))
def place_before(parent, ancre, accueil):
"""À quel rang poser l'ancrage dans `accueil`.
On cherche le premier frère SUIVANT de l'ancrage que la copie possède
aussi, et l'on se place juste devant. Sans aucun repère, en tête :
un `t-set` en tête est toujours valide, jamais en retard.
"""
suivants = list(parent)[list(parent).index(ancre) + 1 :]
for frere in suivants:
jumeau = find_by_signature(accueil, signature(frere))
if jumeau is not None and jumeau.getparent() is accueil:
return list(accueil).index(jumeau)
return 0
def repair_anchor(arch_copie, arch_module, expr):
"""Remettre l'ancrage là où le module le place. None si impossible.
On ne devine pas l'endroit : on lit dans le module quel élément
CONTIENT l'ancrage, on retrouve le même dans la copie par sa
signature, et l'on y insère en tête. Sans correspondance, on rend
None — mieux vaut une réparation refusée qu'un bloc posé au hasard
au milieu de la page de quelqu'un.
"""
from lxml import etree
module = parse(arch_module)
copie = parse(arch_copie)
if module is None or copie is None:
return None
try:
cibles = module.xpath(expr)
except Exception:
return None
if not cibles:
return None
ancre = cibles[0]
parent = ancre.getparent()
if parent is None:
return None
accueil = find_by_signature(copie, signature(parent))
if accueil is None:
return None
# AVANT le même frère suivant, et non au même rang : la copie n'a
# pas forcément les frères qui précèdent l'ancrage dans le module, et
# « rang 1 » y désignerait alors une tout autre place. Ce qui doit
# être respecté, c'est l'ORDRE — QWeb évalue dans l'ordre du
# document, donc un `t-set` posé après le contenu qui s'en sert n'est
# pas vu, et l'enfant qui s'insère « after » l'ancrage le suit.
accueil.insert(place_before(parent, ancre, accueil), copie_de(ancre))
return etree.tostring(copie, encoding="unicode")
def repair_call(arch_copie, nom):
"""Retirer l'appel au gabarit disparu. None si rien à retirer."""
from lxml import etree
copie = parse(arch_copie)
if copie is None:
return None
retires = 0
for noeud in list(copie.iter()):
if noeud.get("t-call") != nom:
continue
parent = noeud.getparent()
if parent is None:
continue
parent.remove(noeud)
retires += 1
if not retires:
return None
return etree.tostring(copie, encoding="unicode")
def any_lang(arch):
"""N'importe laquelle : les xpath d'une vue ne sont pas traduits."""
langues = langs_of(arch)
return langues.get("en_US") or (list(langues.values()) or [""])[0]
def audit(database):
"""Ce que chaque copie a de cassé. None si la base ne répond pas."""
jsonb = arch_is_jsonb(database)
copies = cow_copies(database, jsonb)
connus = known_keys(database)
if copies is None or connus is None:
return None
enfants = children_of(database, jsonb, [c["id"] for c in copies])
rapport = []
for copie in copies:
manque = []
pendants = []
for langue, texte in sorted(langs_of(copie["arch"]).items()):
for enfant in enfants.get(copie["id"], []):
# L'enfant a lui aussi ses langues ; une seule suffit à
# connaître ses xpath, ils ne sont pas traduits.
for expr in anchors_wanted(any_lang(enfant["arch"])):
if not locates(texte, expr):
manque.append(
{
"enfant": enfant["id"],
"expr": expr,
"langue": langue,
}
)
for nom in dangling_calls(texte, connus):
if nom not in pendants:
pendants.append(nom)
if manque or pendants:
rapport.append(
{
"id": copie["id"],
"key": copie["key"],
"anchors": manque,
"calls": pendants,
}
)
return {"database": database, "jsonb": jsonb, "views": rapport}
def write_arch_sql(vue_id, langues, jsonb):
"""La mise à jour, TOUTES les langues d'un coup.
Réécrire une seule entrée du jsonb laisse la page cassée dans les
autres : mesuré sur /contact, réparé en en_US et toujours en 500
parce que le site rend en fr_CA.
Dollar-quoting, parce qu'un arch porte une apostrophe à presque
chaque attribut. Le marqueur est assez long pour qu'aucun gabarit ne
le contienne par accident.
"""
if not jsonb:
texte = langues.get("") or next(iter(langues.values()), "")
return (
f"UPDATE ir_ui_view SET arch_db = $elcow${texte}$elcow$"
f" WHERE id = {vue_id};"
)
paires = ", ".join(
f"$elcow${langue}$elcow$, $elcow${texte}$elcow$"
for langue, texte in sorted(langues.items())
)
return (
f"UPDATE ir_ui_view SET arch_db = jsonb_build_object({paires})"
f" WHERE id = {vue_id};"
)
def plan(database, rapport):
"""[(id, clé, geste, quoi, sql)] — ce que `--apply` ferait, rien de plus.
Les réparations s'enchaînent sur le MÊME texte, langue par langue, et
une seule écriture les porte toutes : deux UPDATE sur la même vue et
le second effacerait le premier.
"""
jsonb = rapport["jsonb"]
gestes = []
for vue in rapport["views"]:
ligne = cow_copies_one(database, jsonb, vue["id"])
if not ligne:
continue
langues = langs_of(ligne["arch"])
module = module_twin(database, jsonb, vue["key"])
arch_module = any_lang(module) if module else None
touche = False
for langue in sorted(langues):
texte = langues[langue]
for manque in vue["anchors"]:
if locates(texte, manque["expr"]):
continue
neuf_texte = (
repair_anchor(texte, arch_module, manque["expr"])
if arch_module
else None
)
if neuf_texte is None:
gestes.append(
(
vue["id"],
vue["key"],
"anchor-impossible",
f"{manque['expr']} [{langue or '—'}]",
None,
)
)
continue
texte = neuf_texte
touche = True
gestes.append(
(
vue["id"],
vue["key"],
"anchor",
f"{manque['expr']} [{langue or '—'}]",
None,
)
)
for nom in vue["calls"]:
neuf_texte = repair_call(texte, nom)
if neuf_texte is None:
continue
texte = neuf_texte
touche = True
gestes.append(
(
vue["id"],
vue["key"],
"t-call",
f"{nom} [{langue or '—'}]",
None,
)
)
langues[langue] = texte
if touche:
gestes.append(
(
vue["id"],
vue["key"],
"write",
f"{len(langues)} langue(s)",
write_arch_sql(vue["id"], langues, jsonb),
)
)
return gestes
def cow_copies_one(database, jsonb, vue_id):
lignes = fetch(
database,
f"SELECT id, key, {arch_expr(jsonb)} AS arch FROM ir_ui_view"
f" WHERE id = {vue_id}",
)
return (lignes or [None])[0]
def render(rapport, gestes):
if not rapport["views"]:
return [f"✅ {t('Every website copy still renders.')}"]
lignes = [
f"🖼 {t('Website copies that can no longer render')}"
f" — {rapport['database']}",
"",
]
for vue in rapport["views"]:
lignes.append(f" [{vue['id']}] {vue['key']}")
for manque in vue["anchors"]:
lignes.append(
f" ⚓ {t('anchor missing for view')} {manque['enfant']} :"
f" {manque['expr']}"
)
for nom in vue["calls"]:
lignes.append(
f" ☎ {t('calls a template that is gone:')} {nom}"
)
impossibles = [g for g in gestes if g[2] == "anchor-impossible"]
faisables = [g for g in gestes if g[2] in ("anchor", "t-call")]
lignes.append("")
lignes.append(
f" {len(faisables)} {t('repair(s) available with --apply')}"
)
if impossibles:
lignes.append(
f" ⚠ {len(impossibles)} {t('cannot be repaired automatically')}"
)
if any(g[2] == "t-call" for g in faisables):
lignes.append(
f" {t('Removing a call removes its block from the page.')}"
)
return lignes
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Report website COW copies that can no longer render after a"
" version bump, and optionally repair them."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument(
"--apply",
action="store_true",
help="actually repair (default: report only)",
)
parser.add_argument("--json", action="store_true", help="machine output")
config = parser.parse_args(argv)
rapport = audit(config.database)
if rapport is None:
print(f"❌ {t('Cannot read the database: ')}{config.database}")
return 2
gestes = plan(config.database, rapport)
if config.json:
print(
json.dumps(rapport, indent=2, sort_keys=True, ensure_ascii=False)
)
return 1 if rapport["views"] else 0
if not rapport["views"]:
print("\n".join(render(rapport, gestes)))
return 0
if not config.apply:
print("\n".join(render(rapport, gestes)))
return 1
for _id, _cle, genre, _quoi, sql in gestes:
if sql and run_psql(config.database, sql, read_only=False) is None:
print(f"❌ {t('The correction failed.')}")
return 2
# RELIRE : annoncer « corrigé » sans regarder ferait rouvrir la même
# page cassée en croyant le problème derrière soi.
apres = audit(config.database)
if apres is None:
print(f"❌ {t('Cannot read the database: ')}{config.database}")
return 2
if apres["views"]:
print("\n".join(render(apres, plan(config.database, apres))))
print(f"⚠️ {t('Some copies still cannot render.')}")
return 1
print(f"✅ {t('Every website copy renders again.')}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -3531,7 +3531,10 @@ TRANSLATIONS = {
"fr": "« qm create » en exige un. Deux voies :",
"en": "qm create needs one. Two ways:",
},
"create an internal": {"fr": "créer un pont interne", "en": "create an internal"},
"create an internal": {
"fr": "créer un pont interne",
"en": "create an internal",
},
"touches no physical NIC": {
"fr": "ne touche à aucune interface physique",
"en": "touches no physical NIC",
@ -6699,6 +6702,42 @@ TRANSLATIONS = {
"fr": "🔗 Dépendances entre les modules",
"en": "🔗 Dependencies between modules",
},
"Website copies that can no longer render": {
"fr": "Copies de site qui ne savent plus se rendre",
"en": "Website copies that can no longer render",
},
"Every website copy still renders.": {
"fr": "Chaque copie de site sait encore se rendre.",
"en": "Every website copy still renders.",
},
"Every website copy renders again.": {
"fr": "Chaque copie de site sait de nouveau se rendre.",
"en": "Every website copy renders again.",
},
"Some copies still cannot render.": {
"fr": "Des copies ne savent toujours pas se rendre.",
"en": "Some copies still cannot render.",
},
"anchor missing for view": {
"fr": "ancrage absent pour la vue",
"en": "anchor missing for view",
},
"calls a template that is gone:": {
"fr": "appelle un gabarit disparu :",
"en": "calls a template that is gone:",
},
"repair(s) available with --apply": {
"fr": "réparation(s) possible(s) avec --apply",
"en": "repair(s) available with --apply",
},
"cannot be repaired automatically": {
"fr": "ne se réparent pas toutes seules",
"en": "cannot be repaired automatically",
},
"Removing a call removes its block from the page.": {
"fr": "Retirer un appel retire son bloc de la page.",
"en": "Removing a call removes its block from the page.",
},
"modules installed": {
"fr": "modules installés",
"en": "modules installed",

355
test/test_fix_cow_render.py Normal file
View file

@ -0,0 +1,355 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Une copie de site web qui ne sait plus se rendre après la montée.
`check_cow_views` cherche la rupture qui ARRÊTE la migration : la forme
que l'arch doit avoir a changé. Deux autres la laissent finir sans un mot
et ne se voient qu'à l'ouverture de la page.
Mesuré sur une migration 12 → 18 réelle : /contact rendait 500 depuis le
palier 14 → 15 — quatre paliers de silence. La copie COW de
`website.contactus` n'a jamais eu le `t-set='contactus_form_values'` que
le module a gagné en chemin, et sur lequel sa vue héritière fait un
xpath. Les deux copies appelaient en plus `website.company_description`,
gabarit disparu en 18.
"""
import os
import sys
import unittest
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.odoo.migration import fix_cow_render as cow # noqa: E402
# La forme réelle, réduite : le module porte l'ancrage, la copie ne l'a
# jamais eu, et toutes deux appellent le gabarit disparu.
ARCH_MODULE = (
"<t t-name='website.contactus'>"
"<t t-call='website.layout'>"
"<t t-set='logged_partner' t-value='1'/>"
"<t t-set='contactus_form_values' t-value=\"{'a': 1}\"/>"
"<div id='wrap'><h1>Contact us</h1></div>"
"</t></t>"
)
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>"
)
ARCH_ENFANT = (
"<data><xpath expr=\"//t[@t-set='contactus_form_values']\" position='after'>"
"<t t-set='autre' t-value='2'/></xpath></data>"
)
class TestWhatAChildAsksFor(unittest.TestCase):
def test_it_reads_the_xpath_of_a_child(self):
self.assertEqual(
cow.anchors_wanted(ARCH_ENFANT),
["//t[@t-set='contactus_form_values']"],
)
def test_a_child_without_xpath_asks_for_nothing(self):
self.assertEqual(
cow.anchors_wanted("<data><field name='x'/></data>"), []
)
def test_an_unparsable_arch_asks_for_nothing_rather_than_crashing(self):
self.assertEqual(cow.anchors_wanted("<data><oops"), [])
class TestLocating(unittest.TestCase):
def test_the_module_has_the_anchor(self):
self.assertTrue(
cow.locates(ARCH_MODULE, "//t[@t-set='contactus_form_values']")
)
def test_the_copy_does_not(self):
self.assertFalse(
cow.locates(ARCH_COPIE, "//t[@t-set='contactus_form_values']")
)
def test_an_illegible_expression_counts_as_found(self):
# Elle ne relève pas de cet outil ; la signaler enverrait réparer
# une vue qui n'a rien.
self.assertTrue(cow.locates(ARCH_COPIE, "//[[["))
def test_an_unparsable_arch_counts_as_found(self):
self.assertTrue(cow.locates("<t><oops", "//t"))
class TestDanglingCalls(unittest.TestCase):
CONNUS = {"website.layout", "website.contactus"}
def test_it_names_the_template_that_is_gone(self):
self.assertEqual(
cow.dangling_calls(ARCH_COPIE, self.CONNUS),
["website.company_description"],
)
def test_a_known_template_is_not_reported(self):
self.assertNotIn(
"website.layout", cow.dangling_calls(ARCH_COPIE, self.CONNUS)
)
def test_a_computed_call_is_left_alone(self):
# Il se résout à l'exécution : on ne peut rien en dire ici.
arch = "<t><t t-call='{{ nom }}'/><t t-call='#x'/></t>"
self.assertEqual(cow.dangling_calls(arch, self.CONNUS), [])
def test_the_same_missing_template_is_named_once(self):
arch = "<t><t t-call='parti'/><div><t t-call='parti'/></div></t>"
self.assertEqual(cow.dangling_calls(arch, self.CONNUS), ["parti"])
class TestRepairingTheAnchor(unittest.TestCase):
def test_it_puts_the_anchor_back_where_the_module_holds_it(self):
neuf = cow.repair_anchor(
ARCH_COPIE, ARCH_MODULE, "//t[@t-set='contactus_form_values']"
)
self.assertIsNotNone(neuf)
self.assertTrue(
cow.locates(neuf, "//t[@t-set='contactus_form_values']")
)
def test_the_anchor_lands_inside_its_host_not_at_the_root(self):
# `//t[@t-set='x']` le trouverait n'importe où : ce n'est donc pas
# la preuve. Ce qui compte est la PLACE — le `t-set` porte une
# portée, et l'enfant s'insère « after » l'ancrage. Posé à la
# racine, le formulaire atterrirait hors de la mise en page.
from lxml import etree
neuf = cow.repair_anchor(
ARCH_COPIE, ARCH_MODULE, "//t[@t-set='contactus_form_values']"
)
arbre = etree.fromstring(neuf.encode("utf-8"))
ancre = arbre.xpath("//t[@t-set='contactus_form_values']")[0]
self.assertEqual(
"website.layout",
ancre.getparent().get("t-call"),
etree.tostring(arbre, encoding="unicode"),
)
def test_the_anchor_keeps_the_rank_the_module_gives_it(self):
# QWeb évalue dans l'ordre du document : un `t-set` posé après le
# contenu qui s'en sert n'est pas vu, et l'enfant qui s'insère
# « after » l'ancrage le suit où qu'il aille.
from lxml import etree
neuf = cow.repair_anchor(
ARCH_COPIE, ARCH_MODULE, "//t[@t-set='contactus_form_values']"
)
arbre = etree.fromstring(neuf.encode("utf-8"))
hote = arbre.xpath("//t[@t-call='website.layout']")[0]
rangs = {
enfant.get("t-set") or enfant.get("id"): rang
for rang, enfant in enumerate(hote)
}
self.assertLess(rangs["contactus_form_values"], rangs["wrap"], neuf)
def test_it_goes_before_the_sibling_it_precedes_in_the_module(self):
# Le jeu précédent ne prouve rien : la bonne place y VAUT zéro,
# donc un repli en tête passerait pour une réussite. Ici la copie
# porte déjà un élément avant le frère repère, et la bonne place
# est 1 — la seule que le repli ne trouve pas.
from lxml import etree
copie = (
"<t t-name='website.contactus'>"
"<t t-call='website.layout'>"
"<t t-set='deja_la' t-value='0'/>"
"<div id='wrap'><h1>Ma page</h1></div>"
"</t></t>"
)
neuf = cow.repair_anchor(
copie, ARCH_MODULE, "//t[@t-set='contactus_form_values']"
)
hote = etree.fromstring(neuf.encode("utf-8")).xpath(
"//t[@t-call='website.layout']"
)[0]
noms = [e.get("t-set") or e.get("id") for e in hote]
self.assertEqual(
["deja_la", "contactus_form_values", "wrap"], noms, neuf
)
def test_it_keeps_the_page_written_by_the_customer(self):
# C'est tout l'enjeu : réinitialiser la vue rendrait la page
# fonctionnelle et effacerait son contenu.
neuf = cow.repair_anchor(
ARCH_COPIE, ARCH_MODULE, "//t[@t-set='contactus_form_values']"
)
self.assertIn("Parlons de votre projet", neuf)
def test_it_refuses_when_the_host_element_has_no_twin(self):
# Mieux vaut une réparation refusée qu'un bloc posé au hasard au
# milieu de la page de quelqu'un.
etrangere = "<t t-name='x'><section id='rien'/></t>"
self.assertIsNone(
cow.repair_anchor(
etrangere, ARCH_MODULE, "//t[@t-set='contactus_form_values']"
)
)
def test_it_refuses_when_the_module_has_no_anchor_either(self):
self.assertIsNone(
cow.repair_anchor(ARCH_COPIE, ARCH_COPIE, "//t[@t-set='absent']")
)
def test_it_refuses_an_unparsable_arch(self):
self.assertIsNone(cow.repair_anchor("<t><oops", ARCH_MODULE, "//t"))
class TestRemovingTheCall(unittest.TestCase):
def test_the_call_goes_and_the_rest_stays(self):
neuf = cow.repair_call(ARCH_COPIE, "website.company_description")
self.assertIsNotNone(neuf)
self.assertNotIn("company_description", neuf)
self.assertIn("Parlons de votre projet", neuf)
self.assertIn("website.layout", neuf)
def test_nothing_to_remove_means_no_change_proposed(self):
self.assertIsNone(cow.repair_call(ARCH_COPIE, "jamais.vu"))
def test_every_occurrence_goes(self):
arch = "<t><t t-call='parti'/><div><t t-call='parti'/></div></t>"
self.assertNotIn("parti", cow.repair_call(arch, "parti"))
class TestLanguages(unittest.TestCase):
"""`arch_db` porte UNE ENTRÉE PAR LANGUE depuis la 17.
N'en lire qu'une déclare la vue saine alors qu'une autre est cassée ;
n'en écrire qu'une laisse la page en 500. Les deux me sont arrivés :
/contact réparé en en_US rendait toujours 500, parce que le site rend
en fr_CA.
"""
def test_it_sees_every_language(self):
self.assertEqual(
sorted(cow.langs_of({"en_US": "<t/>", "fr_CA": "<u/>"})),
["en_US", "fr_CA"],
)
def test_the_query_never_pins_one_language(self):
# C'est ICI que le défaut est né : `arch_db->>'en_US'` ramenait
# une seule langue, l'audit déclarait la vue saine et /contact
# restait en 500 parce que le site rend en fr_CA. La requête doit
# ramener l'OBJET entier.
requete = cow.arch_expr(jsonb=True)
self.assertNotIn("en_US", requete)
self.assertNotIn("->>", requete)
self.assertIn("arch_db", requete)
def test_a_varchar_column_is_wrapped_as_one_nameless_language(self):
# Une seule forme en aval : le reste du code n'a pas à savoir si
# la version traduit l'arch ou non.
requete = cow.arch_expr(jsonb=False)
self.assertIn("json_build_object", requete)
def test_a_plain_string_is_the_language_less_case(self):
self.assertEqual(cow.langs_of("<t/>"), {"": "<t/>"})
def test_a_non_string_value_is_ignored(self):
self.assertEqual(
cow.langs_of({"en_US": "<t/>", "x": 3}), {"en_US": "<t/>"}
)
def test_the_xpath_of_a_child_is_read_from_any_language(self):
# Les xpath ne sont pas traduits : n'importe quelle langue suffit.
self.assertEqual(
cow.anchors_wanted(cow.any_lang({"fr_CA": ARCH_ENFANT})),
["//t[@t-set='contactus_form_values']"],
)
class TestTheSqlItWrites(unittest.TestCase):
def test_it_writes_all_the_languages_in_one_update(self):
# Deux UPDATE sur la même vue et le second effacerait le premier ;
# un seul portant une langue laisserait l'autre cassée.
sql = cow.write_arch_sql(
7, {"en_US": "<t a='1'/>", "fr_CA": "<t b='2'/>"}, jsonb=True
)
self.assertEqual(1, sql.count("UPDATE"), sql)
self.assertIn("en_US", sql)
self.assertIn("fr_CA", sql)
self.assertIn("<t a='1'/>", sql)
self.assertIn("<t b='2'/>", sql)
self.assertIn("WHERE id = 7", sql)
def test_the_varchar_case_writes_the_text_itself(self):
sql = cow.write_arch_sql(7, {"": "<t/>"}, jsonb=False)
self.assertNotIn("jsonb_build_object", sql)
self.assertIn("<t/>", sql)
def test_both_branches_survive_an_apostrophe(self):
# La 16 stocke en varchar, la 17 et la 18 en jsonb : les DEUX
# chemins écrivent, et n'éprouver que l'un laisse l'autre casser
# au premier attribut cité.
arch = "<t t-value=\"{'a': 1}\"/>"
for jsonb, langues in ((True, {"en_US": arch}), (False, {"": arch})):
with self.subTest(jsonb=jsonb):
sql = cow.write_arch_sql(7, langues, jsonb=jsonb)
self.assertIn("$elcow$" + arch + "$elcow$", sql)
class TestTheReport(unittest.TestCase):
def rapport(self):
return {
"database": "essai",
"jsonb": True,
"views": [
{
"id": 1228,
"key": "website.contactus",
"anchors": [{"enfant": 2782, "expr": "//t[@t-set='x']"}],
"calls": ["website.company_description"],
}
],
}
def test_a_clean_database_says_so(self):
texte = "\n".join(
cow.render({"database": "x", "jsonb": True, "views": []}, [])
)
self.assertIn("✅", texte)
def test_it_names_the_view_the_child_and_the_template(self):
texte = "\n".join(cow.render(self.rapport(), []))
self.assertIn("1228", texte)
self.assertIn("2782", texte)
self.assertIn("website.company_description", texte)
def test_it_warns_that_removing_a_call_removes_a_block(self):
# Le message passe par `t()` : le comparer à un mot anglais en
# dur le fait tomber dès qu'on le traduit. On demande la MÊME
# traduction que le code, ce qui éprouve l'avertissement sans
# épouser une langue.
gestes = [(1228, "k", "t-call", "website.company_description", "SQL")]
texte = "\n".join(cow.render(self.rapport(), gestes))
self.assertIn(
cow.t("Removing a call removes its block from the page."), texte
)
def test_it_stays_silent_about_blocks_when_nothing_is_removed(self):
gestes = [(1228, "k", "anchor", "//t", "SQL")]
texte = "\n".join(cow.render(self.rapport(), gestes))
self.assertNotIn(
cow.t("Removing a call removes its block from the page."), texte
)
def test_it_says_when_a_repair_is_out_of_reach(self):
gestes = [(1228, "k", "anchor-impossible", "//t", None)]
texte = "\n".join(cow.render(self.rapport(), gestes))
self.assertIn("1", texte)
self.assertIn("⚠", texte)
if __name__ == "__main__":
unittest.main()