diff --git a/script/odoo/migration/check_stale_scss.py b/script/odoo/migration/check_stale_scss.py index e9c51e3..9ff3415 100755 --- a/script/odoo/migration/check_stale_scss.py +++ b/script/odoo/migration/check_stale_scss.py @@ -218,8 +218,29 @@ def defined_in_sources(version_dir, lst_module): return defined +def module_file(base_url, version_dir): + """Le fichier du module que la personnalisation masque, ou None. + + Le premier segment de l'URL est le nom du module ; le reste est son + chemin. C'est ce fichier que reset_asset rendrait, donc c'est contre lui + que se lit ce que la copie a réellement changé. + """ + parts = base_url.strip("/").split("/") + if len(parts) < 2: + return None + pattern = os.path.join(version_dir, "**", parts[0], *parts[1:]) + for candidate in glob.iglob(pattern, recursive=True): + if os.path.isfile(candidate): + return candidate + return None + + def analyse(database, version_dir, filestore=None): - """[(id, url, [variables absentes])] pour les personnalisations à risque.""" + """Un dictionnaire par personnalisation à risque. + + Le contenu et le fichier du module y sont joints : l'appelant qui veut + montrer l'écart ne doit pas relire la base ni retrouver le chemin. + """ filestore = filestore_dir(database, filestore) lst_module = installed_modules(database) defined = defined_in_sources(version_dir, lst_module) @@ -233,11 +254,133 @@ def analyse(database, version_dir, filestore=None): if name not in own and name not in defined } ) - if missing: - lst_finding.append((att_id, url, missing)) + if not missing: + continue + base_url, bundle = split_custom_url(url) + path = module_file(base_url, version_dir) + module_content = "" + if path: + with open(path, "r", encoding="utf-8", errors="replace") as handle: + module_content = handle.read() + lst_finding.append( + { + "id": att_id, + "url": url, + "missing": missing, + "custom": content, + "base_url": base_url, + "bundle": bundle, + "module_path": path, + "module_content": module_content, + "version_dir": version_dir, + "database": database, + } + ) return lst_finding +def render_diff(finding): + """Ce que la copie a changé, comparée au fichier du module de la cible. + + C'est la seule chose qu'abandonner la copie ferait perdre. Sans elle, on + répond « oui, réinitialise » sans savoir si l'on jette trois lignes ou + une page entière. + """ + import difflib + + lines = [ + f"── id={finding['id']} {finding['url']} ──", + "", + f" {t('Missing:')} " + + ", ".join("$" + name for name in finding["missing"]), + "", + ] + if not finding["module_path"]: + lines += [ + f" {t('No module file of that name in')}" + f" {finding['version_dir']} :", + f" {t('the target no longer ships it, so there is nothing to')}", + f" {t('fall back on. Read the copy before dropping it.')}", + ] + return "\n".join(lines) + diff = list( + difflib.unified_diff( + finding["module_content"].splitlines(), + finding["custom"].splitlines(), + fromfile=finding["module_path"], + tofile=f"custom id={finding['id']}", + lineterm="", + n=2, + ) + ) + if len(diff) <= 2: + lines.append(f" {t('The copy is identical to the module file.')}") + return "\n".join(lines) + lines += [f" {line}" for line in diff] + plus = sum(1 for x in diff if x[:1] == "+" and not x.startswith("+++")) + minus = sum(1 for x in diff if x[:1] == "-" and not x.startswith("---")) + lines += [ + "", + f" +{plus}/-{minus} {t('line(s): that is what resetting gives up.')}", + ] + return "\n".join(lines) + + +def reset_command(lst_finding, database, config_path="./config.conf"): + """La commande qui rend les fichiers de module. Rien n'est lancé ici.""" + lines = [f"./odoo_bin.sh shell -c {config_path} -d {database} <<'PY'"] + for finding in lst_finding: + lines.append( + "env['web_editor.assets'].reset_asset" + f"('{finding['base_url']}', '{finding['bundle']}')" + ) + lines += ["env.cr.commit()", "PY"] + return "\n".join(lines) + + +def apply_reset(lst_finding, database, config_path="./config.conf"): + """Rendre les fichiers de module. ÉCRIT en base — le seul endroit ici. + + Réversible seulement au sens où la personnalisation peut être réécrite : + reset_asset supprime la pièce jointe. D'où la sauvegarde préalable, qui + n'est pas une politesse mais la condition pour dire « oui » sans risque. + """ + script = "\n".join( + "env['web_editor.assets'].reset_asset" + f"('{f['base_url']}', '{f['bundle']}')" + for f in lst_finding + ) + done = subprocess.run( + ["./odoo_bin.sh", "shell", "-c", config_path, "-d", database], + input=script + "\nenv.cr.commit()\n", + capture_output=True, + text=True, + ) + return done.returncode, done.stdout + done.stderr + + +def backup_custom(lst_finding, database): + """Écrire les copies sur disque AVANT de les abandonner. + + reset_asset supprime la pièce jointe : sans ceci, les lignes réellement + personnalisées ne seraient plus nulle part. + """ + directory = os.path.join( + "private", "odoo", "migration", database, "scss_backup" + ) + os.makedirs(directory, exist_ok=True) + lst_path = [] + for finding in lst_finding: + name = f"{finding['id']}_" + finding["url"].strip("/").replace( + "/", "_" + ) + path = os.path.join(directory, name) + with open(path, "w", encoding="utf-8") as handle: + handle.write(finding["custom"]) + lst_path.append(path) + return lst_path + + def render(lst_finding, database, version_dir): if not lst_finding: return ( @@ -248,29 +391,75 @@ def render(lst_finding, database, version_dir): f" {t('customized SCSS use(s) a variable that')} {version_dir}" f" {t('no longer defines: the bundle will not compile.')}" ] - for att_id, url, missing in lst_finding: - lines.append(f" - id={att_id} {url}") - lines.append(f" {', '.join('$' + m for m in missing)}") + for finding in lst_finding: + lines.append(f" - id={finding['id']} {finding['url']}") + lines.append( + " " + ", ".join("$" + name for name in finding["missing"]) + ) lines += [ f" {t('Each one is a copy frozen on an older version. Dropping it')}" f" {t('restores the module file:')}", - f" ./odoo_bin.sh shell -c ./config.conf -d {database} <<'PY'", ] - for _att_id, url, _missing in lst_finding: - base, bundle = split_custom_url(url) - lines.append( - f" env['web_editor.assets'].reset_asset(" - f"'{base}', '{bundle}')" - ) lines += [ - " env.cr.commit()", - " PY", - f" {t('Read it first: what it holds beyond the stale variable is')}" - f" {t('a real customization, to re-apply as a small file.')}", + " " + line + for line in reset_command(lst_finding, database).splitlines() ] + lines.append( + f" {t('Read it first: what it holds beyond the stale variable is')}" + f" {t('a real customization, to re-apply as a small file.')}" + ) return "\n".join(lines) + "\n" +def prompt(lst_finding, database, config_path="./config.conf", ask=input): + """Montrer, puis proposer de corriger. Rend True si l'on a écrit. + + Répondre « oui, réinitialise » sans avoir vu l'écart, c'est accepter de + perdre on ne sait quoi. L'invite revient donc après chaque lecture : + regarder ne répond pas à la question. + """ + while True: + answer = ( + ask( + f"💬 {t('What do you want to do with these customizations?')}" + f" ({t('Enter = nothing')}," + f" v = {t('what the copy changed')}," + f" w = {t('full screen')}," + f" a = {t('reset them onto the module file')}) : " + ) + .strip() + .lower() + ) + if answer == "v": + for finding in lst_finding: + print(render_diff(finding)) + print() + continue + if answer == "w": + try: + from check_stale_scss_tui import run_tui + except ImportError: + print(f"ℹ️ {t('Full screen view unavailable.')}") + continue + if not run_tui(lst_finding): + for finding in lst_finding: + print(render_diff(finding)) + continue + if answer == "a": + lst_path = backup_custom(lst_finding, database) + print(f"📦 {t('Saved before resetting')} :") + for path in lst_path: + print(f" {path}") + status, output = apply_reset(lst_finding, database, config_path) + print(output.strip()[-2000:]) + if status: + print(f"❌ {t('Reset failed, nothing was changed.')}") + return False + print(f"✅ -> {t('Reset done.')}") + return True + return False + + def split_custom_url(url): """« /a/b.custom.web.assets_frontend.scss » -> (« /a/b.scss », bundle). @@ -287,8 +476,8 @@ def split_custom_url(url): def main(argv=None): parser = argparse.ArgumentParser( description=( - "Predict which customized SCSS will break on the target version" - " (read-only)." + "Predict which customized SCSS will break on the target version." + " Read-only unless --apply." ) ) parser.add_argument("-d", "--database", required=True) @@ -303,6 +492,27 @@ def main(argv=None): default=None, help="filestore directory (default: ~/.local/share/Odoo/filestore/)", ) + parser.add_argument( + "-c", + "--config", + default="./config.conf", + help="Odoo config used by the shell for --apply", + ) + parser.add_argument( + "--diff", + action="store_true", + help="print what each copy changed, without asking", + ) + parser.add_argument( + "--tui", + action="store_true", + help="browse the differences full screen", + ) + parser.add_argument( + "--apply", + action="store_true", + help="reset them onto the module file (WRITES; saves a copy first)", + ) config = parser.parse_args(argv) if not os.path.isdir(config.target_version): @@ -318,8 +528,44 @@ def main(argv=None): except RuntimeError as exc: print(f"❌ {exc}") return 2 + print(render(lst_finding, config.database, config.target_version)) - return 1 if lst_finding else 0 + if not lst_finding: + return 0 + + if config.diff: + for finding in lst_finding: + print(render_diff(finding)) + print() + if config.tui: + from check_stale_scss_tui import run_tui + + if not run_tui(lst_finding): + for finding in lst_finding: + print(render_diff(finding)) + if config.apply: + lst_path = backup_custom(lst_finding, config.database) + print(f"📦 {t('Saved before resetting')} :") + for path in lst_path: + print(f" {path}") + status, output = apply_reset( + lst_finding, config.database, config.config + ) + print(output.strip()[-2000:]) + if status: + print(f"❌ {t('Reset failed, nothing was changed.')}") + return 2 + print(f"✅ -> {t('Reset done.')}") + return 0 + + # Aucun drapeau : on demande, plutôt que d'imprimer un rapport et de + # laisser retrouver soi-même les deux arguments de reset_asset. Mais + # seulement devant un terminal — dans un tube, une invite bloquerait + # l'appelant sans que personne ne voie la question. + if not (config.diff or config.tui) and sys.stdin.isatty(): + if prompt(lst_finding, config.database, config.config): + return 0 + return 1 if __name__ == "__main__": diff --git a/script/odoo/migration/check_stale_scss_tui.py b/script/odoo/migration/check_stale_scss_tui.py new file mode 100755 index 0000000..cf234ee --- /dev/null +++ b/script/odoo/migration/check_stale_scss_tui.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Full-screen browsing of the customized SCSS a version bump would break. + +Two things have to be read before answering « reset it »: what the copy +changed — the only thing resetting gives up — and which variables the target +no longer defines, which is why it breaks at all. Space switches between them. + +The text report says the same, but a 1000-line diff in a terminal scrollback +is not something anyone reads before deciding. +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", "..")) +) + +from check_stale_scss import render_diff, reset_command # noqa: E402 + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +CSS = """ +Screen { layout: vertical; } +#head { height: 3; padding: 0 1; background: $panel; color: $text; } +#body { height: 1fr; } +#files { width: 42; border-right: solid $accent; } +#pane { width: 1fr; padding: 0 1; } +""" + + +def render_missing(finding): + """Pourquoi ça casse : ce que la cible ne définit plus.""" + lines = [ + f"── id={finding['id']} {finding['url']} ──", + "", + f" {t('This copy uses variables that')} {finding['version_dir']}" + f" {t('no longer defines')} :", + "", + ] + for name in finding["missing"]: + lines.append(f" ${name}") + lines += [ + "", + f" {t('The copy was written against an older version and frozen')}", + f" {t('there. The module has since renamed what it relies on.')}", + "", + f" {t('Resetting restores')} :", + f" {finding['module_path'] or t('(nothing: the target no longer ships this file)')}", + ] + return "\n".join(lines) + + +def build_app(lst_finding): + """Build the application. Textual is imported here, not at module level. + + The module stays importable — and therefore testable — on a machine + without Textual, which is also what lets the caller fall back to the text + report rather than fail. + """ + from textual.app import App, ComposeResult + from textual.containers import Horizontal, VerticalScroll + from textual.widgets import DataTable, Footer, Header, Static + + class StaleScssApp(App): + CSS = globals()["CSS"] + BINDINGS = [ + ("q,escape", "quit", "Quit"), + ("space,tab", "toggle", "Diff / why"), + ("c", "copy", "Copy reset command"), + ] + + def __init__(self, lst_finding): + super().__init__() + self.lst_finding = lst_finding + self.why = False + self.index = 0 + + def compose(self) -> ComposeResult: + yield Header() + yield Static("", id="head") + with Horizontal(id="body"): + yield DataTable(id="files", cursor_type="row") + with VerticalScroll(id="pane"): + yield Static("", id="content") + yield Footer() + + def on_mount(self): + self.title = "Customized SCSS at risk" + table = self.query_one("#files", DataTable) + table.add_columns("file", "+/-", "missing") + for finding in self.lst_finding: + table.add_row( + os.path.basename(finding["url"])[:30], + self._weight(finding), + str(len(finding["missing"])), + key=str(finding["id"]), + ) + self._show() + + def _weight(self, finding): + """Combien la copie s'écarte, pour trier d'un coup d'œil.""" + if not finding["module_path"]: + return "—" + import difflib + + diff = list( + difflib.unified_diff( + finding["module_content"].splitlines(), + finding["custom"].splitlines(), + lineterm="", + n=0, + ) + ) + plus = sum( + 1 for x in diff if x[:1] == "+" and not x.startswith("+++") + ) + minus = sum( + 1 for x in diff if x[:1] == "-" and not x.startswith("---") + ) + return f"+{plus}/-{minus}" + + def _show(self): + if not self.lst_finding: + return + finding = self.lst_finding[self.index] + which = "why it breaks" if self.why else "what the copy changed" + self.query_one("#head", Static).update( + f"{finding['url']} · id={finding['id']}\n" + f"[{which}] — space to switch, c to copy the reset command" + ) + render = render_missing if self.why else render_diff + self.query_one("#content", Static).update(render(finding)) + + def on_data_table_row_highlighted(self, event): + if event.data_table.id == "files" and self.lst_finding: + self.index = event.cursor_row + self._show() + + def action_toggle(self): + self.why = not self.why + self._show() + + def action_copy(self): + """Mettre la commande de réparation dans le presse-papier. + + Sans cela il faut la recopier à la main depuis un plein écran, + avec deux arguments dont une faute ne se voit pas. + """ + finding = self.lst_finding[self.index] + command = reset_command([finding], finding["database"]) + try: + self.copy_to_clipboard(command) + self.notify(t("Reset command copied.")) + except Exception: + self.notify(command, timeout=20) + + return StaleScssApp(lst_finding) + + +def run_tui(lst_finding, run_app=True): + """Ouvrir l'écran. False si on n'a pas pu — et alors on DIT pourquoi. + + Trois refus, trois raisons, aucune n'est une panne : rien à montrer, pas + de terminal, ou Textual absent. Se taire ferait réafficher le rapport + texte à la place de l'écran demandé, sans rien qui distingue les deux. + """ + if not lst_finding: + return False + if not sys.stdout.isatty(): + print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}") + return False + try: + from script.todo import textual_setup + except Exception: + textual_setup = None + if textual_setup and not textual_setup.available(): + if not textual_setup.in_venv(): + print( + f"ℹ️ {t('Textual is missing from this interpreter:')}" + f" {sys.executable}" + ) + print(f" {t('Run it with')} .venv.erplibre/bin/python3") + if textual_setup and not textual_setup.ensure(): + return False + try: + app = build_app(lst_finding) + except ImportError: + print( + f"ℹ️ {t('Textual is missing from this interpreter:')}" + f" {sys.executable}" + ) + return False + if not run_app: + return app + app.run() + return True diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index aa611d1..16a77d2 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4822,6 +4822,90 @@ TRANSLATIONS = { "fr": "Lire les personnalisations ci-dessus avant de continuer.", "en": "Read the customizations above before continuing.", }, + "Missing:": { + "fr": "Manquantes :", + "en": "Missing:", + }, + "No module file of that name in": { + "fr": "Aucun fichier de module de ce nom dans", + "en": "No module file of that name in", + }, + "the target no longer ships it, so there is nothing to": { + "fr": "la cible ne le livre plus, il n'y a donc rien sur quoi", + "en": "the target no longer ships it, so there is nothing to", + }, + "fall back on. Read the copy before dropping it.": { + "fr": "retomber. Lire la copie avant de l'abandonner.", + "en": "fall back on. Read the copy before dropping it.", + }, + "The copy is identical to the module file.": { + "fr": "La copie est identique au fichier du module.", + "en": "The copy is identical to the module file.", + }, + "line(s): that is what resetting gives up.": { + "fr": "ligne(s) : c'est ce que la réinitialisation abandonne.", + "en": "line(s): that is what resetting gives up.", + }, + "What do you want to do with these customizations?": { + "fr": "Que voulez-vous faire de ces personnalisations ?", + "en": "What do you want to do with these customizations?", + }, + "Enter = nothing": { + "fr": "Entrée = rien", + "en": "Enter = nothing", + }, + "what the copy changed": { + "fr": "ce que la copie a changé", + "en": "what the copy changed", + }, + "reset them onto the module file": { + "fr": "les réinitialiser sur le fichier du module", + "en": "reset them onto the module file", + }, + "Full screen view unavailable.": { + "fr": "Vue plein écran indisponible.", + "en": "Full screen view unavailable.", + }, + "Saved before resetting": { + "fr": "Sauvegardé avant réinitialisation", + "en": "Saved before resetting", + }, + "Reset failed, nothing was changed.": { + "fr": "La réinitialisation a échoué, rien n'a été modifié.", + "en": "Reset failed, nothing was changed.", + }, + "Reset done.": { + "fr": "Réinitialisation faite.", + "en": "Reset done.", + }, + "This copy uses variables that": { + "fr": "Cette copie utilise des variables que", + "en": "This copy uses variables that", + }, + "no longer defines": { + "fr": "ne définit plus", + "en": "no longer defines", + }, + "The copy was written against an older version and frozen": { + "fr": "La copie a été écrite contre une version antérieure et figée", + "en": "The copy was written against an older version and frozen", + }, + "there. The module has since renamed what it relies on.": { + "fr": "là. Le module a depuis renommé ce dont elle dépend.", + "en": "there. The module has since renamed what it relies on.", + }, + "Resetting restores": { + "fr": "La réinitialisation rend", + "en": "Resetting restores", + }, + "(nothing: the target no longer ships this file)": { + "fr": "(rien : la cible ne livre plus ce fichier)", + "en": "(nothing: the target no longer ships this file)", + }, + "Reset command copied.": { + "fr": "Commande de réinitialisation copiée.", + "en": "Reset command copied.", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 8ea6765..15f0bf5 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1645,17 +1645,13 @@ class TodoUpgrade: # une copie figée, et un palier renomme aussi des variables. Celle-ci # ne se voit qu'à l'écran d'une page — « Style error », sans dire quel # fichier ni depuis quand — et seulement une fois le palier passé. - status_scss, _cmd, _out = self.todo_upgrade_execute( + # Sur le VRAI terminal : l'outil pose lui-même ses questions — voir + # l'écart, l'ouvrir en plein écran, réinitialiser — et un tube les + # rendrait toutes injoignables, comme il fermait déjà la TUI. + self.run_on_terminal( f"{PYTHON_BIN} ./script/odoo/migration/check_stale_scss.py" - f" -d {database_name} -t odoo{start_version + 1}.0", - get_output=True, - wait_at_error=False, + f" -d {database_name} -t odoo{start_version + 1}.0" ) - if status_scss == 1: - self.ask_gate( - f"💬 {t('Read the customizations above before continuing.')}" - f" {t('(b = go back to a previous step)')} : " - ) msg = "3 - Clean up database before data migration" self.print_step(msg) diff --git a/test/test_check_stale_scss.py b/test/test_check_stale_scss.py index 6a6f0f0..5df606a 100755 --- a/test/test_check_stale_scss.py +++ b/test/test_check_stale_scss.py @@ -101,45 +101,175 @@ class TestTheReport(unittest.TestCase): ) todo_i18n._current_lang = "en" + def finding(self, **override): + data = { + "id": 1110, + "url": "/a/b.custom.web.assets_frontend.scss", + "missing": ["o-theme-font-number"], + "custom": "a { b: $o-theme-font-number; }", + "base_url": "/a/b.scss", + "bundle": "web.assets_frontend", + "module_path": "odoo13.0/a/b.scss", + "module_content": "a { b: 1; }", + "version_dir": "odoo13.0", + "database": "db", + } + data.update(override) + return data + def test_nothing_at_risk_says_so(self): text = scss.render([], "db", "odoo13.0") self.assertIn("✅", text) def test_a_finding_names_the_attachment_and_the_variable(self): - text = scss.render( - [ - ( - 1110, - "/a/b.custom.web.assets_frontend.scss", - ["o-theme-font-number"], - ) - ], - "db", - "odoo13.0", - ) + text = scss.render([self.finding()], "db", "odoo13.0") self.assertIn("1110", text) self.assertIn("$o-theme-font-number", text) def test_it_prints_a_command_that_can_be_pasted(self): # Un diagnostic sans le geste qui répare oblige à rechercher les deux # arguments de reset_asset au pire moment. - text = scss.render( - [(1110, "/a/b.custom.web.assets_frontend.scss", ["x"])], - "mydb", - "odoo13.0", - ) + text = scss.render([self.finding()], "mydb", "odoo13.0") self.assertIn("reset_asset('/a/b.scss', 'web.assets_frontend')", text) self.assertIn("-d mydb", text) def test_it_warns_before_dropping(self): - text = scss.render( - [(1110, "/a/b.custom.web.assets_frontend.scss", ["x"])], - "db", - "odoo13.0", - ) + text = scss.render([self.finding()], "db", "odoo13.0") self.assertIn("real customization", text) +class TestTheDiff(unittest.TestCase): + """Ce que la copie a changé : la seule chose que réinitialiser perd.""" + + def setUp(self): + from script.todo import todo_i18n + + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def finding(self, **override): + data = { + "id": 1110, + "url": "/a/b.custom.web.assets_frontend.scss", + "missing": ["x"], + "custom": "a { b: 2; }", + "base_url": "/a/b.scss", + "bundle": "web.assets_frontend", + "module_path": "odoo13.0/a/b.scss", + "module_content": "a { b: 1; }", + "version_dir": "odoo13.0", + "database": "db", + } + data.update(override) + return data + + def test_it_counts_what_would_be_lost(self): + text = scss.render_diff(self.finding()) + self.assertIn("+1/-1", text) + + def test_an_identical_copy_says_it_loses_nothing(self): + text = scss.render_diff(self.finding(custom="a { b: 1; }")) + self.assertIn("identical", text) + + def test_a_missing_module_file_is_said_not_guessed(self): + # Si la cible ne livre plus le fichier, il n'y a rien sur quoi + # retomber : le taire ferait accepter une réinitialisation vide. + text = scss.render_diff(self.finding(module_path=None)) + self.assertIn("no longer ships", text) + + +class TestThePrompt(unittest.TestCase): + """Regarder ne doit pas répondre à la question.""" + + def setUp(self): + from script.todo import todo_i18n + + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def finding(self): + return { + "id": 1, + "url": "/a/b.custom.web.assets_frontend.scss", + "missing": ["x"], + "custom": "a { b: 2; }", + "base_url": "/a/b.scss", + "bundle": "web.assets_frontend", + "module_path": "odoo13.0/a/b.scss", + "module_content": "a { b: 1; }", + "version_dir": "odoo13.0", + "database": "db", + } + + def run_prompt(self, answers): + import contextlib + import io + + seq = iter(answers) + out = io.StringIO() + with contextlib.redirect_stdout(out): + wrote = scss.prompt( + [self.finding()], "db", ask=lambda prompt: next(seq) + ) + return wrote, out.getvalue() + + def test_enter_writes_nothing(self): + wrote, text = self.run_prompt([""]) + self.assertFalse(wrote) + self.assertEqual(text, "") + + def test_v_shows_the_diff_and_asks_again(self): + # Sans le « asks again », montrer vaudrait réponse : on aurait vu le + # diff et perdu la main sur la décision. + wrote, text = self.run_prompt(["v", ""]) + self.assertFalse(wrote) + self.assertIn("+1/-1", text) + + def test_apply_saves_a_copy_before_writing(self): + # reset_asset SUPPRIME la pièce jointe : sans sauvegarde préalable, + # les lignes personnalisées ne seraient plus nulle part. + import os + import tempfile + + previous = os.getcwd() + self.addCleanup(os.chdir, previous) + os.chdir(tempfile.mkdtemp()) + seen = {} + original = scss.apply_reset + scss.apply_reset = lambda lst, db, cfg="./config.conf": ( + seen.update(called=True), + (0, "ok"), + )[1] + self.addCleanup(setattr, scss, "apply_reset", original) + wrote, text = self.run_prompt(["a"]) + self.assertTrue(wrote) + self.assertTrue(seen.get("called")) + import glob + + saved = glob.glob("private/odoo/migration/db/scss_backup/*") + self.assertEqual(len(saved), 1) + with open(saved[0]) as handle: + self.assertEqual(handle.read(), "a { b: 2; }") + + def test_a_failed_reset_says_nothing_changed(self): + import os + import tempfile + + previous = os.getcwd() + self.addCleanup(os.chdir, previous) + os.chdir(tempfile.mkdtemp()) + original = scss.apply_reset + scss.apply_reset = lambda lst, db, cfg="./config.conf": (1, "boom") + self.addCleanup(setattr, scss, "apply_reset", original) + wrote, text = self.run_prompt(["a"]) + self.assertFalse(wrote) + self.assertIn("nothing was changed", text) + + class TestTheMigrationRunsIt(unittest.TestCase): def test_it_is_checked_at_the_same_place_as_the_cow_views(self): # Les deux prédictions valent avant le palier, pas après : c'est le @@ -155,6 +285,32 @@ class TestTheMigrationRunsIt(unittest.TestCase): source.index("4 - Upgrade version with OpenUpgrade"), ) + def test_it_gets_a_real_terminal(self): + # L'outil pose lui-même ses questions et peut ouvrir un plein écran : + # un tube les rendrait toutes injoignables, sans rien signaler. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + # On ne compare pas à une mise en forme — black la change — mais à + # l'appel le plus proche EN AMONT : c'est lui qui exécute. + avant = source[: source.index("check_stale_scss.py")] + self.assertGreater( + avant.rfind("run_on_terminal("), + avant.rfind("todo_upgrade_execute("), + "l'outil repasse par l'exécuteur à tube", + ) + + +class TestThePromptStaysOutOfAPipe(unittest.TestCase): + def test_it_only_asks_in_front_of_a_terminal(self): + # Une invite dans un tube bloquerait l'appelant sur une question que + # personne ne voit. + with open(scss.__file__) as handle: + source = handle.read() + self.assertIn("sys.stdin.isatty()", source) + if __name__ == "__main__": unittest.main()