[ADD] migration: browse the drifted COW copies full screen
The text report puts a thousand lines between the two things that decide:
what the copy holds that the module view does not — all a reset gives up
— and which child no longer finds its anchor, which is why anything
breaks. Space switches between them, « c » copies the reset command.
Offered as [4] on the error prompt, next to check and reset, and run on
the real terminal: a full screen started through the capturing executor
falls back to the text report with nothing to tell the two apart.
The diff is rendered in one place, shared with the command line. Two
renderings would drift without anything saying so.
--- FR ---
[ADD] migration : parcourir les copies COW dérivées en plein écran
Le rapport texte met mille lignes entre les deux choses qui décident :
ce que la copie porte et que la vue module n'a pas — tout ce qu'une
réinitialisation abandonne — et quel enfant ne trouve plus son ancrage,
la raison pour laquelle quoi que ce soit casse. L'espace bascule, « c »
copie la commande de réparation.
Proposé en [4] à l'invite d'erreur, à côté de vérifier et réinitialiser,
et lancé sur le vrai terminal : un plein écran passé par l'exécuteur qui
capture retombe sur le rapport texte, sans rien qui les distingue.
Le diff est rendu à un seul endroit, partagé avec la ligne de commande.
Deux rendus dériveraient sans que rien ne le signale.
Assisted-by: Claude Opus 5
2026-08-17 06:29:25 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
|
|
|
|
"""Lire une copie COW avant d'accepter de la perdre, en plein écran.
|
|
|
|
|
|
|
|
|
|
Le rapport texte met à mille lignes d'écart les deux choses qui décident :
|
|
|
|
|
ce que la copie porte en propre — la seule chose qu'une réinitialisation
|
|
|
|
|
abandonne — et quel enfant ne trouve plus son ancrage, qui est la raison
|
|
|
|
|
pour laquelle quoi que ce soit casse. L'espace bascule entre les deux.
|
|
|
|
|
|
|
|
|
|
La commande de réparation prend une CLÉ. La recopier à la main depuis un
|
|
|
|
|
diff défilé est l'endroit où un caractère se perd, et une clé sans
|
|
|
|
|
correspondance n'est pas une erreur pour l'outil : il tourne et ne fait
|
|
|
|
|
rien. D'où « c ».
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import unittest
|
|
|
|
|
|
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration"))
|
|
|
|
|
|
|
|
|
|
import reset_stale_cow_tui as tui # noqa: E402
|
|
|
|
|
import reset_stale_cow_views as reset # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def finding(**override):
|
|
|
|
|
cow = {
|
|
|
|
|
"id": 2841,
|
|
|
|
|
"key": "website_blog.blog_post_complete",
|
|
|
|
|
"website_id": 1,
|
|
|
|
|
"arch": "<t>\n <div/>\n <span>vieux</span>\n</t>",
|
|
|
|
|
}
|
|
|
|
|
module = {
|
|
|
|
|
"id": 1826,
|
|
|
|
|
"key": "website_blog.blog_post_complete",
|
|
|
|
|
"website_id": None,
|
|
|
|
|
"arch": "<t>\n <div/>\n <section id='o_wblog_post_footer'/>\n</t>",
|
|
|
|
|
}
|
|
|
|
|
broken = [(3288, "//section[@id='o_wblog_post_footer']")]
|
|
|
|
|
if "cow" in override:
|
|
|
|
|
cow.update(override["cow"])
|
|
|
|
|
return cow, override.get("module", module), override.get("broken", broken)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestBothScreens(unittest.TestCase):
|
|
|
|
|
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 test_the_diff_shows_what_the_copy_holds(self):
|
|
|
|
|
cow, module, _broken = finding()
|
|
|
|
|
text = reset.render_diff(module, cow, indent=" ")
|
|
|
|
|
self.assertIn("vieux", text)
|
|
|
|
|
self.assertIn("o_wblog_post_footer", text)
|
|
|
|
|
|
|
|
|
|
def test_the_why_screen_names_the_failing_child(self):
|
|
|
|
|
# Sans lui, on lit un diff sans savoir pourquoi il compte.
|
|
|
|
|
text = tui.render_broken(*finding())
|
|
|
|
|
self.assertIn("3288", text)
|
|
|
|
|
self.assertIn("//section[@id='o_wblog_post_footer']", text)
|
|
|
|
|
|
|
|
|
|
def test_the_why_screen_says_what_a_reset_costs(self):
|
|
|
|
|
text = tui.render_broken(*finding())
|
|
|
|
|
self.assertIn("INHERITING", text)
|
|
|
|
|
|
|
|
|
|
def test_a_copy_without_broken_child_is_said_so(self):
|
|
|
|
|
# Elle a dérivé sans rien casser ENCORE : le taire ferait croire à
|
|
|
|
|
# une erreur de détection.
|
|
|
|
|
cow, module, _ = finding()
|
|
|
|
|
text = tui.render_broken(cow, module, [])
|
|
|
|
|
self.assertIn("without", text)
|
|
|
|
|
|
|
|
|
|
def test_the_two_screens_use_the_same_diff_as_the_cli(self):
|
|
|
|
|
# Deux rendus séparés dériveraient sans que rien ne le signale.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
source = inspect.getsource(tui)
|
|
|
|
|
self.assertIn("from reset_stale_cow_views import", source)
|
|
|
|
|
self.assertIn("render_diff", source)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheTriageColumn(unittest.TestCase):
|
|
|
|
|
def test_the_weight_counts_both_directions(self):
|
|
|
|
|
cow, module, _ = finding()
|
|
|
|
|
self.assertEqual(tui.weight(cow, module), "+1/-1")
|
|
|
|
|
|
|
|
|
|
def test_no_module_twin_has_no_weight(self):
|
|
|
|
|
cow, _module, _ = finding()
|
|
|
|
|
self.assertEqual(tui.weight(cow, None), "—")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheRefusalIsNeverSilent(unittest.TestCase):
|
|
|
|
|
"""Un refus muet ferait réafficher le rapport texte sans le dire."""
|
|
|
|
|
|
|
|
|
|
def test_a_pipe_is_explained(self):
|
|
|
|
|
import io
|
|
|
|
|
from contextlib import redirect_stdout
|
|
|
|
|
|
|
|
|
|
out = io.StringIO()
|
|
|
|
|
with redirect_stdout(out):
|
|
|
|
|
result = tui.run_tui([finding()], "db")
|
|
|
|
|
self.assertFalse(result)
|
|
|
|
|
self.assertTrue(out.getvalue().strip(), "refus muet")
|
|
|
|
|
|
|
|
|
|
def test_nothing_to_show_stays_silent(self):
|
|
|
|
|
import io
|
|
|
|
|
from contextlib import redirect_stdout
|
|
|
|
|
|
|
|
|
|
out = io.StringIO()
|
|
|
|
|
with redirect_stdout(out):
|
|
|
|
|
self.assertFalse(tui.run_tui([], "db"))
|
|
|
|
|
self.assertEqual(out.getvalue(), "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheToolAndTheMigrationOfferIt(unittest.TestCase):
|
|
|
|
|
def test_the_tool_has_a_tui_flag(self):
|
|
|
|
|
import subprocess
|
|
|
|
|
|
|
|
|
|
done = subprocess.run(
|
|
|
|
|
[
|
|
|
|
|
sys.executable,
|
|
|
|
|
"./script/odoo/migration/reset_stale_cow_views.py",
|
|
|
|
|
"--help",
|
|
|
|
|
],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
cwd=REPO,
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(done.returncode, 0)
|
|
|
|
|
self.assertIn("--tui", done.stdout)
|
|
|
|
|
|
|
|
|
|
def test_the_error_prompt_offers_it_as_option_four(self):
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
[ADD] migration: repair views whose stored type contradicts their parent
Odoo refused to load a database: it validated a <search> arch with tree
rules. The view is not a COW copy -- the COW tools were right to say
they had nothing to reset -- its stored `type` simply lies.
Nothing would ever have fixed it. In ir_ui_view.py, `type` is filled in
`create`, and only when absent; `write` never recomputes it. A wrong
value stays wrong, and `-u module` fails on the validation it causes
before it could rewrite anything.
Plain SQL, no ORM: the registry is what will not load, and a repair that
needed Odoo to fix what stops Odoo would be useless.
The rule is Odoo's own -- an inherited view takes its parent's type --
and it holds: zero disagreement on a pristine 18 install and on four
migrated databases, exactly one on the database that refused to load.
--- FR ---
Odoo refusait de charger une base : il validait un arch <search> avec
les règles d'un tree. La vue n'est pas une copie COW — les outils COW
avaient raison de dire qu'ils n'avaient rien à réinitialiser — c'est son
`type` stocké qui ment.
Rien ne l'aurait jamais réparé. Dans ir_ui_view.py, `type` est rempli
dans `create`, et seulement s'il est absent ; `write` ne le recalcule
jamais. Une valeur fausse le reste, et `-u module` échoue sur la
validation qu'elle provoque avant de pouvoir réécrire quoi que ce soit.
En SQL, sans ORM : c'est le registre qui ne charge plus, et une
réparation qui aurait besoin d'Odoo ne servirait à rien.
La règle est celle d'Odoo — une vue héritée prend le type de son parent
— et elle tient : zéro écart sur une 18 neuve et sur quatre bases
migrées, exactement un sur celle qui refusait de charger.
Assisted-by: Claude Opus 5
2026-08-22 00:46:42 -04:00
|
|
|
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
|
|
|
|
|
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
|
|
|
|
|
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
|
|
|
|
|
# éprouve, pas une méthode en particulier.
|
|
|
|
|
source = inspect.getsource(
|
|
|
|
|
TodoUpgrade.todo_upgrade_execute
|
|
|
|
|
) + inspect.getsource(TodoUpgrade._prompt_on_error)
|
[ADD] migration: browse the drifted COW copies full screen
The text report puts a thousand lines between the two things that decide:
what the copy holds that the module view does not — all a reset gives up
— and which child no longer finds its anchor, which is why anything
breaks. Space switches between them, « c » copies the reset command.
Offered as [4] on the error prompt, next to check and reset, and run on
the real terminal: a full screen started through the capturing executor
falls back to the text report with nothing to tell the two apart.
The diff is rendered in one place, shared with the command line. Two
renderings would drift without anything saying so.
--- FR ---
[ADD] migration : parcourir les copies COW dérivées en plein écran
Le rapport texte met mille lignes entre les deux choses qui décident :
ce que la copie porte et que la vue module n'a pas — tout ce qu'une
réinitialisation abandonne — et quel enfant ne trouve plus son ancrage,
la raison pour laquelle quoi que ce soit casse. L'espace bascule, « c »
copie la commande de réparation.
Proposé en [4] à l'invite d'erreur, à côté de vérifier et réinitialiser,
et lancé sur le vrai terminal : un plein écran passé par l'exécuteur qui
capture retombe sur le rapport texte, sans rien qui les distingue.
Le diff est rendu à un seul endroit, partagé avec la ligne de commande.
Deux rendus dériveraient sans que rien ne le signale.
Assisted-by: Claude Opus 5
2026-08-17 06:29:25 -04:00
|
|
|
self.assertIn("Browse the differences full screen", source)
|
|
|
|
|
self.assertIn('wait_status == "4"', source)
|
|
|
|
|
|
|
|
|
|
def test_it_gets_a_real_terminal(self):
|
|
|
|
|
# Un plein écran lancé par l'exécuteur qui capture retomberait sur
|
|
|
|
|
# le rapport texte, sans que rien ne distingue les deux.
|
|
|
|
|
import inspect
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import TodoUpgrade
|
|
|
|
|
|
[ADD] migration: repair views whose stored type contradicts their parent
Odoo refused to load a database: it validated a <search> arch with tree
rules. The view is not a COW copy -- the COW tools were right to say
they had nothing to reset -- its stored `type` simply lies.
Nothing would ever have fixed it. In ir_ui_view.py, `type` is filled in
`create`, and only when absent; `write` never recomputes it. A wrong
value stays wrong, and `-u module` fails on the validation it causes
before it could rewrite anything.
Plain SQL, no ORM: the registry is what will not load, and a repair that
needed Odoo to fix what stops Odoo would be useless.
The rule is Odoo's own -- an inherited view takes its parent's type --
and it holds: zero disagreement on a pristine 18 install and on four
migrated databases, exactly one on the database that refused to load.
--- FR ---
Odoo refusait de charger une base : il validait un arch <search> avec
les règles d'un tree. La vue n'est pas une copie COW — les outils COW
avaient raison de dire qu'ils n'avaient rien à réinitialiser — c'est son
`type` stocké qui ment.
Rien ne l'aurait jamais réparé. Dans ir_ui_view.py, `type` est rempli
dans `create`, et seulement s'il est absent ; `write` ne le recalcule
jamais. Une valeur fausse le reste, et `-u module` échoue sur la
validation qu'elle provoque avant de pouvoir réécrire quoi que ce soit.
En SQL, sans ORM : c'est le registre qui ne charge plus, et une
réparation qui aurait besoin d'Odoo ne servirait à rien.
La règle est celle d'Odoo — une vue héritée prend le type de son parent
— et elle tient : zéro écart sur une 18 neuve et sur quatre bases
migrées, exactement un sur celle qui refusait de charger.
Assisted-by: Claude Opus 5
2026-08-22 00:46:42 -04:00
|
|
|
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
|
|
|
|
|
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
|
|
|
|
|
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
|
|
|
|
|
# éprouve, pas une méthode en particulier.
|
|
|
|
|
source = inspect.getsource(
|
|
|
|
|
TodoUpgrade.todo_upgrade_execute
|
|
|
|
|
) + inspect.getsource(TodoUpgrade._prompt_on_error)
|
[ADD] migration: browse the drifted COW copies full screen
The text report puts a thousand lines between the two things that decide:
what the copy holds that the module view does not — all a reset gives up
— and which child no longer finds its anchor, which is why anything
breaks. Space switches between them, « c » copies the reset command.
Offered as [4] on the error prompt, next to check and reset, and run on
the real terminal: a full screen started through the capturing executor
falls back to the text report with nothing to tell the two apart.
The diff is rendered in one place, shared with the command line. Two
renderings would drift without anything saying so.
--- FR ---
[ADD] migration : parcourir les copies COW dérivées en plein écran
Le rapport texte met mille lignes entre les deux choses qui décident :
ce que la copie porte et que la vue module n'a pas — tout ce qu'une
réinitialisation abandonne — et quel enfant ne trouve plus son ancrage,
la raison pour laquelle quoi que ce soit casse. L'espace bascule, « c »
copie la commande de réparation.
Proposé en [4] à l'invite d'erreur, à côté de vérifier et réinitialiser,
et lancé sur le vrai terminal : un plein écran passé par l'exécuteur qui
capture retombe sur le rapport texte, sans rien qui les distingue.
Le diff est rendu à un seul endroit, partagé avec la ligne de commande.
Deux rendus dériveraient sans que rien ne le signale.
Assisted-by: Claude Opus 5
2026-08-17 06:29:25 -04:00
|
|
|
start = source.index('wait_status == "4"')
|
|
|
|
|
window = source[start : start + 620]
|
|
|
|
|
self.assertIn("run_on_terminal", window)
|
|
|
|
|
self.assertIn("--tui", window)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|