« 254 attachment files missing » does not say which. « m » now lists them, grouped by model AND FIELD — the field is the most useful thing in the row, because it says which field lost its image. On a real migration it splits into 234 res.country/image flags, 13 payment.provider logos, and a handful of scattered attachments including one 255 kB event photo: the first two are module data a reinstall restores, the last is not, and the raw list put them on the same footing. The metadata is read ONLY when asked. One more query per database would lengthen a survey that runs in four seconds, for something one looks at by asking for it. And every figure of a step now carries its change against the previous one. « 2283 views » is a number; « +61 » is information. The first step gets none: inventing a zero there would claim a comparison that does not exist. --- FR --- [ADD] qualité de migration : nommer les fichiers absents, et montrer les écarts « 254 fichiers de pièces jointes absents » ne dit pas lesquels. « m » en donne la liste, groupée par modèle ET PAR CHAMP — le champ est le renseignement le plus utile de la ligne, car il dit lequel a perdu son image. Sur une vraie migration : 234 drapeaux res.country/image, 13 logos de fournisseurs de paiement, et une poignée de pièces jointes éparses dont une photo d'événement de 255 ko. Les deux premiers groupes sont des données de module qu'une réinstallation restaure, le dernier non. Les métadonnées ne sont lues QU'À LA DEMANDE : une requête de plus par base allongerait un parcours qui tient en quatre secondes. Et chaque chiffre d'un palier porte son écart avec le précédent. « 2283 vues » est un nombre, « +61 » est une information. Le premier palier n'en a pas : y inventer un zéro annoncerait une comparaison qui n'existe pas. Assisted-by: Claude Opus 5
678 lines
24 KiB
Python
678 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
"""Ce qu'une migration gagne et ce qu'elle perd, palier par palier.
|
|
|
|
La question qu'on se pose après six paliers n'est pas « a-t-elle fini » —
|
|
le journal le dit — mais « qu'est-ce qui a changé en chemin ». Une
|
|
migration laisse une base PAR PALIER, et elles existent toutes encore : on
|
|
les compare côte à côte plutôt que de rejouer quoi que ce soit.
|
|
|
|
Le point le plus délicat est le rapprochement des tables renommées. Deux
|
|
garde-fous ont été essayés et rejetés SUR UNE VRAIE MIGRATION avant celui
|
|
qui tient, et un faux rapprochement ne serait pas une coquetterie : il
|
|
ferait DISPARAÎTRE une perte réelle du rapport. D'où la règle qui compte
|
|
plus que tout ici — une perte est toujours listée, jamais retirée.
|
|
"""
|
|
|
|
import io
|
|
import os
|
|
import sys
|
|
import unittest
|
|
from contextlib import redirect_stdout
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
sys.path.insert(0, os.path.join(REPO, "script", "analyse"))
|
|
|
|
from script.analyse import check_migration_quality as quality # noqa: E402
|
|
from script.analyse import check_migration_quality_tui as qtui # noqa: E402
|
|
from script.todo import todo_i18n # noqa: E402
|
|
|
|
|
|
def snapshot(**override):
|
|
etat = {
|
|
"database": "db",
|
|
"exists": True,
|
|
"odoo": "13.0",
|
|
"version": 13,
|
|
"installed": ["account", "sale"],
|
|
"module": {"account": "installed", "sale": "installed"},
|
|
"model": ["account.move", "sale.order"],
|
|
"table": {"account_move": 100, "sale_order": 20},
|
|
"model_without_table": [],
|
|
"view": 2000,
|
|
"view_cow": 40,
|
|
"menu": 400,
|
|
"action": 300,
|
|
"attachment": 5000,
|
|
"attachment_stored": 4000,
|
|
"attachment_missing": 0,
|
|
"language": 2,
|
|
}
|
|
etat.update(override)
|
|
return etat
|
|
|
|
|
|
class Base(unittest.TestCase):
|
|
def setUp(self):
|
|
self.addCleanup(
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
|
)
|
|
todo_i18n._current_lang = "en"
|
|
|
|
|
|
class TestFindingTheChain(Base):
|
|
"""Les bases se déduisent de ce que la migration a ÉCRIT.
|
|
|
|
Le pilote nomme ses bases de palier « <base>_upgrade_<version> » et
|
|
retient sa cible. Redemander ces noms serait redemander ce qu'on sait.
|
|
"""
|
|
|
|
PROGRESSION = {
|
|
"config_database_name": "test_neutralize",
|
|
"target_odoo_version": "18.0",
|
|
"state_4_upgrade_odoo_lst": [[], [], [], [], [], []],
|
|
}
|
|
|
|
def test_it_walks_from_the_start_to_the_target(self):
|
|
lst = quality.chain(self.PROGRESSION)
|
|
self.assertEqual(
|
|
[version for version, _db in lst], [12, 13, 14, 15, 16, 17, 18]
|
|
)
|
|
|
|
def test_the_first_one_is_the_origin_database(self):
|
|
# Le palier 12 n'a pas de base « _upgrade_12 » : c'est la base
|
|
# d'origine, celle qu'on a restaurée.
|
|
self.assertEqual(
|
|
quality.chain(self.PROGRESSION)[0][1], "test_neutralize"
|
|
)
|
|
|
|
def test_the_others_follow_the_naming_of_the_driver(self):
|
|
noms = [db for _v, db in quality.chain(self.PROGRESSION)]
|
|
self.assertIn("test_neutralize_upgrade_13", noms)
|
|
self.assertIn("test_neutralize_upgrade_18", noms)
|
|
|
|
def test_a_migration_not_started_yields_the_origin_alone(self):
|
|
lst = quality.chain(
|
|
{"config_database_name": "db", "target_odoo_version": "18.0"}
|
|
)
|
|
self.assertEqual(lst, [(None, "db")])
|
|
|
|
def test_no_progression_yields_nothing(self):
|
|
self.assertEqual(quality.chain({}), [])
|
|
|
|
|
|
class TestWhatIsGainedAndLost(Base):
|
|
def test_a_module_uninstalled_is_a_loss(self):
|
|
diff = quality.compare(
|
|
snapshot(installed=["account", "sale"]),
|
|
snapshot(installed=["account"]),
|
|
)
|
|
self.assertEqual(diff["modules_lost"], ["sale"])
|
|
self.assertEqual(diff["modules_gained"], [])
|
|
|
|
def test_a_model_that_appears_is_a_gain(self):
|
|
diff = quality.compare(
|
|
snapshot(model=["account.move"]),
|
|
snapshot(model=["account.move", "account.edi"]),
|
|
)
|
|
self.assertEqual(diff["models_gained"], ["account.edi"])
|
|
|
|
def test_a_table_that_empties_is_reported(self):
|
|
# LE signal : un module en moins se voit, une table qui passe de
|
|
# cent lignes à zéro ne se voit nulle part.
|
|
diff = quality.compare(
|
|
snapshot(table={"account_invoice": 651}),
|
|
snapshot(table={"account_invoice": 0}),
|
|
)
|
|
self.assertEqual(diff["rows_lost"], [("account_invoice", 651, 0)])
|
|
|
|
def test_a_table_that_disappears_counts_as_emptied(self):
|
|
diff = quality.compare(
|
|
snapshot(table={"account_invoice": 651}), snapshot(table={})
|
|
)
|
|
self.assertEqual(diff["rows_lost"], [("account_invoice", 651, 0)])
|
|
|
|
def test_an_empty_table_that_disappears_is_not_a_loss(self):
|
|
# Rien à perdre : le signaler noierait les vraies pertes.
|
|
diff = quality.compare(snapshot(table={"vide": 0}), snapshot(table={}))
|
|
self.assertEqual(diff["rows_lost"], [])
|
|
|
|
def test_counts_move_with_their_sign(self):
|
|
diff = quality.compare(snapshot(view=2000), snapshot(view=3733))
|
|
self.assertEqual(diff["delta"]["view"], 1733)
|
|
|
|
def test_a_missing_database_is_not_comparable(self):
|
|
diff = quality.compare(snapshot(), {"exists": False})
|
|
self.assertTrue(diff["unavailable"])
|
|
|
|
|
|
class TestNotCryingWolfOnRenames(Base):
|
|
"""Deux garde-fous rejetés sur une VRAIE migration avant celui-ci."""
|
|
|
|
def test_the_row_count_alone_is_not_enough(self):
|
|
# Il accouplait `account_account_tag_account_tax_template_rel` à
|
|
# `dms_directory` : les deux comptaient sept lignes.
|
|
self.assertFalse(
|
|
quality.looks_renamed(
|
|
"account_account_tag_account_tax_template_rel", "dms_directory"
|
|
)
|
|
)
|
|
|
|
def test_a_shared_word_is_not_enough_either(self):
|
|
# « cleanup » ne dit rien, « users » non plus.
|
|
self.assertFalse(
|
|
quality.looks_renamed(
|
|
"cleanup_purge_wizard_menu", "cleanup_create_indexes_line"
|
|
)
|
|
)
|
|
self.assertFalse(
|
|
quality.looks_renamed(
|
|
"digest_tip_res_users_rel", "project_allowed_portal_users_rel"
|
|
)
|
|
)
|
|
|
|
def test_real_renames_are_recognised(self):
|
|
for un, deux in (
|
|
("muk_dms_directory", "dms_directory"),
|
|
("website_redirect", "website_rewrite"),
|
|
(
|
|
"account_invoice_purchase_order_rel",
|
|
"account_move_purchase_order_rel",
|
|
),
|
|
):
|
|
self.assertTrue(quality.looks_renamed(un, deux), (un, deux))
|
|
|
|
def test_a_rename_needs_the_same_row_count(self):
|
|
# Un nom qui se ressemble mais un compte qui change n'est pas un
|
|
# renommage : c'est un renommage ET une perte, qu'on ne fusionne pas.
|
|
diff = quality.compare(
|
|
snapshot(table={"muk_dms_directory": 10}),
|
|
snapshot(table={"dms_directory": 4}),
|
|
)
|
|
self.assertEqual(diff["renamed"], [])
|
|
|
|
def test_a_renamed_table_is_STILL_listed_as_lost(self):
|
|
"""La règle qui compte plus que tout ici.
|
|
|
|
Retirer une perte parce qu'on croit à un renommage, c'est faire
|
|
disparaître du rapport ce qu'on est venu y chercher — et le
|
|
rapprochement s'est déjà trompé.
|
|
"""
|
|
diff = quality.compare(
|
|
snapshot(table={"muk_dms_directory": 7}),
|
|
snapshot(table={"dms_directory": 7}),
|
|
)
|
|
self.assertEqual(
|
|
diff["renamed"], [("muk_dms_directory", "dms_directory", 7)]
|
|
)
|
|
self.assertIn(("muk_dms_directory", 7, 0), diff["rows_lost"])
|
|
|
|
def test_the_report_says_it_is_only_probable(self):
|
|
diff = quality.compare(
|
|
snapshot(table={"muk_dms_directory": 7}),
|
|
snapshot(table={"dms_directory": 7}),
|
|
)
|
|
texte = "\n".join(quality.render_compare(diff, colour=False))
|
|
self.assertIn("muk_dms_directory", texte)
|
|
self.assertIn("probably renamed", texte)
|
|
|
|
|
|
class TestTheOverallReport(Base):
|
|
def test_it_compares_the_ENDS_not_the_sum_of_steps(self):
|
|
"""Un module retiré en 15 puis remis en 17 n'a rien perdu.
|
|
|
|
L'addition des comparaisons deux à deux le compterait deux fois,
|
|
une en perte et une en gain, et le bilan mentirait dans les deux
|
|
sens à la fois.
|
|
"""
|
|
lst = [
|
|
snapshot(odoo="12.0", installed=["a", "b"]),
|
|
snapshot(odoo="15.0", installed=["a"]),
|
|
snapshot(odoo="18.0", installed=["a", "b"]),
|
|
]
|
|
bilan = quality.overall(lst)
|
|
self.assertEqual(bilan["modules_lost"], [])
|
|
self.assertEqual(bilan["modules_gained"], [])
|
|
|
|
def test_it_needs_two_databases(self):
|
|
self.assertTrue(quality.overall([snapshot()])["unavailable"])
|
|
|
|
def test_missing_databases_are_skipped_not_fatal(self):
|
|
lst = [
|
|
snapshot(odoo="12.0", installed=["a"]),
|
|
{"database": "absente", "exists": False},
|
|
snapshot(odoo="18.0", installed=["a", "b"]),
|
|
]
|
|
self.assertEqual(quality.overall(lst)["modules_gained"], ["b"])
|
|
|
|
|
|
class TestTheReportItself(Base):
|
|
def test_every_step_is_listed(self):
|
|
texte = quality.render_text(
|
|
[
|
|
snapshot(odoo="12.0", database="a"),
|
|
snapshot(odoo="13.0", database="b"),
|
|
],
|
|
colour=False,
|
|
)
|
|
self.assertIn("12.0", texte)
|
|
self.assertIn("13.0", texte)
|
|
|
|
def test_a_missing_database_is_named_not_hidden(self):
|
|
texte = quality.render_text(
|
|
[snapshot(), {"database": "absente", "exists": False}],
|
|
colour=False,
|
|
)
|
|
self.assertIn("absente", texte)
|
|
self.assertIn("not found", texte)
|
|
|
|
def test_missing_attachment_files_are_surfaced(self):
|
|
# La trouvaille faite à la main sur une vraie migration : 254
|
|
# fichiers absents du filestore, que rien ne signalait.
|
|
texte = quality.render_text(
|
|
[snapshot(attachment_missing=254)], colour=False
|
|
)
|
|
self.assertIn("254", texte)
|
|
|
|
def test_it_ends_with_the_start_to_finish_comparison(self):
|
|
texte = quality.render_text(
|
|
[snapshot(odoo="12.0"), snapshot(odoo="18.0")], colour=False
|
|
)
|
|
self.assertIn("From start to finish", texte)
|
|
self.assertLess(texte.index("12.0"), texte.index("From start"))
|
|
|
|
|
|
class TestTheStatisticsCarryTheirDelta(Base):
|
|
"""Un chiffre seul ne dit rien.
|
|
|
|
« 2283 vues » est un nombre ; « +61 » est une information. C'est
|
|
l'écart qu'on lit, pas la valeur.
|
|
"""
|
|
|
|
def test_each_figure_gets_its_change(self):
|
|
lignes = qtui.statistics(
|
|
snapshot(view=2283, installed=["a"]),
|
|
snapshot(view=2222, installed=["a", "b"]),
|
|
)
|
|
par_nom = {libelle: ecart for libelle, _v, ecart in lignes}
|
|
self.assertEqual(par_nom["views"], 61)
|
|
self.assertEqual(par_nom["modules"], -1)
|
|
|
|
def test_the_first_step_has_NO_delta(self):
|
|
# Inventer un écart de zéro laisserait croire à une comparaison
|
|
# qui n'existe pas : il n'y a rien avant le premier palier.
|
|
lignes = qtui.statistics(snapshot(), None)
|
|
self.assertTrue(all(ecart is None for _l, _v, ecart in lignes))
|
|
|
|
def test_an_unchanged_figure_shows_zero_not_nothing(self):
|
|
# Zéro est une réponse : « rien n'a bougé » se distingue de « on
|
|
# ne sait pas ».
|
|
lignes = qtui.statistics(snapshot(view=100), snapshot(view=100))
|
|
par_nom = {libelle: ecart for libelle, _v, ecart in lignes}
|
|
self.assertEqual(par_nom["views"], 0)
|
|
|
|
def test_every_figure_of_the_pane_is_covered(self):
|
|
libelles = [
|
|
libelle
|
|
for libelle, _v, _e in qtui.statistics(snapshot(), snapshot())
|
|
]
|
|
for attendu in ("modules", "models", "views", "menus", "attachments"):
|
|
self.assertIn(attendu, libelles)
|
|
|
|
def test_the_pane_prints_the_sign(self):
|
|
row = {
|
|
"kind": "step",
|
|
"data": snapshot(view=2283),
|
|
"previous": snapshot(view=2222),
|
|
"diff": None,
|
|
}
|
|
texte = qtui.pane_text([], row)
|
|
self.assertIn("+61", texte)
|
|
|
|
|
|
class TestListingTheMissingFiles(Base):
|
|
"""« 254 fichiers absents » ne dit pas lesquels.
|
|
|
|
Le groupement tranche : deux cent trente-quatre drapeaux de pays sont
|
|
des images livrées par un module, qu'une mise à jour restaure. Une
|
|
pièce jointe d'événement de 255 ko, non. La liste brute mettait les
|
|
deux sur le même plan.
|
|
"""
|
|
|
|
DETAIL = [
|
|
{
|
|
"store_fname": "aa/1",
|
|
"model": "res.country",
|
|
"field": "image",
|
|
"res_id": "1",
|
|
"mimetype": "image/png",
|
|
"size": 100,
|
|
"name": "fr",
|
|
},
|
|
{
|
|
"store_fname": "aa/2",
|
|
"model": "res.country",
|
|
"field": "image",
|
|
"res_id": "2",
|
|
"mimetype": "image/png",
|
|
"size": 100,
|
|
"name": "ca",
|
|
},
|
|
{
|
|
"store_fname": "aa/4",
|
|
"model": "res.country",
|
|
"field": "image_128",
|
|
"res_id": "1",
|
|
"mimetype": "image/png",
|
|
"size": 50,
|
|
"name": "fr128",
|
|
},
|
|
{
|
|
"store_fname": "bb/3",
|
|
"model": "calendar.event",
|
|
"field": "-",
|
|
"res_id": "1",
|
|
"mimetype": "image/png",
|
|
"size": 255603,
|
|
"name": "photo.png",
|
|
},
|
|
]
|
|
|
|
def render(self, etat=None, **kw):
|
|
original = quality.missing_detail
|
|
quality.missing_detail = lambda db, lst, limit=400: self.DETAIL
|
|
self.addCleanup(setattr, quality, "missing_detail", original)
|
|
return quality.render_missing(
|
|
etat
|
|
or snapshot(
|
|
attachment_missing=3,
|
|
attachment_missing_list=["aa/1", "aa/2", "bb/3"],
|
|
),
|
|
**kw,
|
|
)
|
|
|
|
def test_nothing_missing_says_so_plainly(self):
|
|
texte = quality.render_missing(snapshot(attachment_missing=0))
|
|
self.assertIn("every attachment file is present", texte)
|
|
|
|
def test_the_grouping_comes_first(self):
|
|
texte = self.render()
|
|
self.assertLess(
|
|
texte.index("by model and field"), texte.index("one by one")
|
|
)
|
|
|
|
def test_the_grouping_counts_by_model_AND_field(self):
|
|
"""Le champ est le renseignement le plus utile du lot.
|
|
|
|
Il dit QUEL champ a perdu son image : l'`image_1920` d'un pays n'a
|
|
pas le même poids qu'une pièce jointe de facture. Grouper sur le
|
|
seul modèle fondrait `image` et `image_128` en une ligne, et l'on
|
|
perdrait exactement ce qu'on venait chercher.
|
|
"""
|
|
entete = self.render().split("one by one")[0]
|
|
lignes = [
|
|
ligne for ligne in entete.splitlines() if "res.country" in ligne
|
|
]
|
|
self.assertEqual(len(lignes), 2, entete)
|
|
self.assertTrue(any("image_128" in ligne for ligne in lignes))
|
|
|
|
def test_the_loudest_group_comes_first(self):
|
|
texte = self.render().split("one by one")[0]
|
|
self.assertLess(texte.index("res.country"), texte.index("calendar"))
|
|
|
|
def test_each_file_names_its_record_and_its_path(self):
|
|
texte = self.render()
|
|
self.assertIn("calendar.event#1", texte)
|
|
self.assertIn("bb/3", texte)
|
|
self.assertIn("photo.png", texte)
|
|
|
|
def test_a_long_list_is_cut_and_SAYS_so(self):
|
|
texte = self.render(limit=1)
|
|
self.assertIn("more", texte)
|
|
|
|
def test_unreadable_metadata_is_admitted(self):
|
|
original = quality.missing_detail
|
|
quality.missing_detail = lambda db, lst, limit=400: []
|
|
self.addCleanup(setattr, quality, "missing_detail", original)
|
|
texte = quality.render_missing(
|
|
snapshot(attachment_missing=3, attachment_missing_list=["a"])
|
|
)
|
|
self.assertIn("Could not read", texte)
|
|
|
|
def test_the_metadata_is_read_ONLY_on_demand(self):
|
|
# Une requête de plus par base allongerait un parcours qui tient
|
|
# en quatre secondes, pour ce qu'on ne regarde qu'en le demandant.
|
|
import inspect
|
|
|
|
self.assertNotIn("missing_detail", inspect.getsource(quality.inspect))
|
|
self.assertIn(
|
|
"missing_detail", inspect.getsource(quality.render_missing)
|
|
)
|
|
|
|
def test_the_names_are_kept_but_bounded(self):
|
|
# Une base peut en aligner des dizaines de milliers ; l'écran n'en
|
|
# montrera jamais tant, et les garder toutes coûterait pour rien.
|
|
import inspect
|
|
|
|
source = inspect.getsource(quality.inspect)
|
|
self.assertIn("attachment_missing_list", source)
|
|
self.assertIn("MAX_MISSING", source)
|
|
|
|
def test_a_quote_in_a_filename_cannot_break_the_query(self):
|
|
vu = {}
|
|
original = quality.run_psql
|
|
quality.run_psql = lambda db, sql: vu.setdefault("sql", sql) and []
|
|
self.addCleanup(setattr, quality, "run_psql", original)
|
|
quality.missing_detail("db", ["aa/o'brien"])
|
|
self.assertIn("o''brien", vu["sql"])
|
|
|
|
|
|
class TestTheMissingFilesButton(Base):
|
|
def test_m_is_bound(self):
|
|
app = qtui.build_app([snapshot()])
|
|
touches = {
|
|
touche
|
|
for entree in app.BINDINGS
|
|
for touche in entree[0].split(",")
|
|
}
|
|
self.assertIn("m", touches)
|
|
|
|
def test_the_action_exists(self):
|
|
app = qtui.build_app([snapshot()])
|
|
self.assertTrue(hasattr(app, "action_toggle_missing"))
|
|
|
|
def test_the_pane_switches_to_the_list(self):
|
|
original = quality.missing_detail
|
|
quality.missing_detail = lambda db, lst, limit=400: []
|
|
self.addCleanup(setattr, quality, "missing_detail", original)
|
|
row = {
|
|
"kind": "step",
|
|
"data": snapshot(
|
|
attachment_missing=3, attachment_missing_list=["a"]
|
|
),
|
|
"previous": None,
|
|
"diff": None,
|
|
}
|
|
chiffres = qtui.pane_text([], row, show_missing=False)
|
|
liste = qtui.pane_text([], row, show_missing=True)
|
|
self.assertIn("modules", chiffres)
|
|
self.assertNotIn("modules", liste)
|
|
|
|
def test_the_pane_tells_you_the_key_exists(self):
|
|
# Une touche que rien n'annonce est une touche que personne ne
|
|
# presse.
|
|
row = {
|
|
"kind": "step",
|
|
"data": snapshot(attachment_missing=254),
|
|
"previous": None,
|
|
"diff": None,
|
|
}
|
|
self.assertIn("press m", qtui.pane_text([], row))
|
|
|
|
|
|
class TestItNeverWrites(Base):
|
|
"""Les bases de palier sont parfois la seule copie d'un état."""
|
|
|
|
def test_the_connection_is_read_only_at_the_server(self):
|
|
import inspect
|
|
|
|
source = inspect.getsource(quality.run_psql)
|
|
self.assertIn("default_transaction_read_only=on", source)
|
|
|
|
def test_no_odoo_is_started(self):
|
|
# Six démarrages coûteraient une heure ET écriraient dans les
|
|
# bases. L'inspection en SQL prend moins d'une demi-seconde.
|
|
import inspect
|
|
|
|
source = inspect.getsource(quality)
|
|
for interdit in ("odoo_bin", "run.sh", "--update", "-u all"):
|
|
self.assertNotIn(interdit, source, interdit)
|
|
|
|
|
|
class TestTheFullScreen(Base):
|
|
def test_one_row_per_step_plus_the_overall(self):
|
|
lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")]
|
|
lignes = qtui.rows(lst)
|
|
self.assertEqual(
|
|
[x["kind"] for x in lignes], ["step", "step", "overall"]
|
|
)
|
|
|
|
def test_the_overall_comes_LAST(self):
|
|
# On descend la liste comme on a vécu la migration ; « qu'en
|
|
# reste-t-il » se pose une fois le chemin vu.
|
|
lst = [snapshot(odoo="12.0"), snapshot(odoo="18.0")]
|
|
self.assertEqual(qtui.rows(lst)[-1]["kind"], "overall")
|
|
|
|
def test_a_missing_database_gets_its_own_row(self):
|
|
lignes = qtui.rows(
|
|
[snapshot(), {"database": "absente", "exists": False}]
|
|
)
|
|
self.assertIn("missing", [x["kind"] for x in lignes])
|
|
|
|
def test_the_column_counts_the_lost_tables(self):
|
|
lst = [
|
|
snapshot(odoo="12.0", table={"t": 100}),
|
|
snapshot(odoo="13.0", table={"t": 0}),
|
|
]
|
|
self.assertEqual(qtui.rows(lst)[1]["detail"], "1")
|
|
|
|
def test_a_step_that_lost_nothing_leaves_it_empty(self):
|
|
lst = [snapshot(odoo="12.0"), snapshot(odoo="13.0")]
|
|
self.assertEqual(qtui.rows(lst)[1]["detail"], "")
|
|
|
|
def test_the_pane_uses_the_same_comparison_as_the_text(self):
|
|
import inspect
|
|
|
|
self.assertIn(
|
|
"quality.render_compare", inspect.getsource(qtui.pane_text)
|
|
)
|
|
|
|
def test_a_pipe_is_explained_rather_than_silent(self):
|
|
out = io.StringIO()
|
|
with redirect_stdout(out):
|
|
self.assertFalse(qtui.run_tui([snapshot()]))
|
|
self.assertTrue(out.getvalue().strip())
|
|
|
|
def test_nothing_to_show_stays_silent(self):
|
|
out = io.StringIO()
|
|
with redirect_stdout(out):
|
|
self.assertFalse(qtui.run_tui([]))
|
|
self.assertEqual(out.getvalue(), "")
|
|
|
|
|
|
class TestWhereItIsOffered(Base):
|
|
def test_the_analyse_menu_offers_it(self):
|
|
chemin = os.path.join(REPO, "script", "todo", "todo.py")
|
|
with open(chemin) as handle:
|
|
texte = handle.read()
|
|
self.assertIn("Quality of a migration, step by step", texte)
|
|
self.assertIn("execute_analyse_migration_quality", texte)
|
|
|
|
def test_the_existing_menu_numbers_did_not_move(self):
|
|
# Quelqu'un connaît « 1 », « 2 », « 3 » : les décaler pour insérer
|
|
# une entrée au milieu se paierait à chaque usage.
|
|
import inspect
|
|
|
|
from script.todo.todo import TODO
|
|
|
|
source = inspect.getsource(TODO.prompt_execute_analyse)
|
|
self.assertLess(
|
|
source.index("Tables and database size"),
|
|
source.index("Quality of a migration"),
|
|
)
|
|
|
|
def test_the_state_screen_offers_it_too(self):
|
|
import inspect
|
|
|
|
from script.todo import migration_status_tui as stui
|
|
|
|
source = inspect.getsource(stui.build_app)
|
|
self.assertIn('"k", "quality"', source)
|
|
self.assertIn("check_migration_quality", source)
|
|
|
|
def test_it_hands_the_terminal_over(self):
|
|
# Deux applications Textual ne peuvent pas peindre le même écran.
|
|
import inspect
|
|
|
|
from script.todo import migration_status_tui as stui
|
|
|
|
source = inspect.getsource(stui.build_app)
|
|
debut = source.index("def action_quality")
|
|
self.assertIn("self.suspend()", source[debut : debut + 1400])
|
|
|
|
def test_it_opens_it_in_its_OWN_process(self):
|
|
"""Le défaut que « suspend est appelé » ne suffisait pas à attraper.
|
|
|
|
`suspend()` rend le terminal mais n'arrête PAS la boucle asyncio.
|
|
`app.run()` appelle `asyncio.run()`, qui refuse de tourner dans une
|
|
boucle déjà en cours : « asyncio.run() cannot be called from a
|
|
running event loop ». Un sous-processus a sa propre boucle.
|
|
"""
|
|
import inspect
|
|
|
|
from script.todo import migration_status_tui as stui
|
|
|
|
source = inspect.getsource(stui.build_app)
|
|
debut = source.index("def action_quality")
|
|
fenetre = source[debut : debut + 1400]
|
|
self.assertIn("subprocess.call", fenetre)
|
|
self.assertNotIn("run_quality(", fenetre)
|
|
|
|
|
|
class TestItRefusesToNestItself(Base):
|
|
"""Le filet de sécurité, pour qui rappellerait `run_tui` de l'intérieur.
|
|
|
|
Une trace de quarante lignes n'apprend rien ; une phrase qui dit
|
|
d'ouvrir un processus à part, si.
|
|
"""
|
|
|
|
def test_no_loop_running_is_the_normal_case(self):
|
|
self.assertFalse(qtui.in_event_loop())
|
|
|
|
def test_a_running_loop_is_detected(self):
|
|
import asyncio
|
|
|
|
async def dedans():
|
|
return qtui.in_event_loop()
|
|
|
|
self.assertTrue(asyncio.run(dedans()))
|
|
|
|
def test_run_tui_consults_it_before_starting(self):
|
|
import inspect
|
|
|
|
source = inspect.getsource(qtui.run_tui)
|
|
self.assertLess(
|
|
source.index("in_event_loop()"), source.index("app.run()")
|
|
)
|
|
|
|
def test_the_refusal_says_what_to_do_instead(self):
|
|
import inspect
|
|
|
|
source = inspect.getsource(qtui.run_tui)
|
|
self.assertIn("its own", source)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|