diff --git a/script/odoo/migration/fix_cow_render.py b/script/odoo/migration/fix_cow_render.py new file mode 100755 index 0000000..35f5de9 --- /dev/null +++ b/script/odoo/migration/fix_cow_render.py @@ -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()) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 63a1a1f..911045d 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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", diff --git a/test/test_fix_cow_render.py b/test/test_fix_cow_render.py new file mode 100644 index 0000000..2534bab --- /dev/null +++ b/test/test_fix_cow_render.py @@ -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 = ( + "" + "" + "" + "" + "Contact us" + "" +) +ARCH_COPIE = ( + "" + "" + "Parlons de votre projet" + "" + "" +) +ARCH_ENFANT = ( + "" + "" +) + + +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(""), [] + ) + + def test_an_unparsable_arch_asks_for_nothing_rather_than_crashing(self): + self.assertEqual(cow.anchors_wanted("" + "" + "" + "Ma page" + "" + ) + 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 = "" + 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("", "fr_CA": ""})), + ["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(""), {"": ""}) + + def test_a_non_string_value_is_ignored(self): + self.assertEqual( + cow.langs_of({"en_US": "", "x": 3}), {"en_US": ""} + ) + + 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": "", "fr_CA": ""}, jsonb=True + ) + self.assertEqual(1, sql.count("UPDATE"), sql) + self.assertIn("en_US", sql) + self.assertIn("fr_CA", sql) + self.assertIn("", sql) + self.assertIn("", sql) + self.assertIn("WHERE id = 7", sql) + + def test_the_varchar_case_writes_the_text_itself(self): + sql = cow.write_arch_sql(7, {"": ""}, jsonb=False) + self.assertNotIn("jsonb_build_object", sql) + self.assertIn("", 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 = "" + 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()