erplibre/test/test_check_migration_quality.py
Mathieu Benoit 951b88abab [FIX] todo analyse: give the migration entry its icon
An entry with no icon, in a menu where every other one has one, reads as
unfinished. The section gets 🚚 and the entry 📐 — the same symbol the
quality report already wears in its own title, because it is the same
thing.

The spacing follows a convention this menu keeps without stating it: an
icon of « neutral » width — 🗄, 🖼 — occupies one column in a terminal
and takes TWO spaces to line up, while a wide one — 📏, 📐 — takes one.
Getting it wrong shifts the line, and nothing says so until it is under
your eyes. A test now states it.

--- FR ---

[FIX] todo analyse : donner son icône à l'entrée de migration

Une entrée sans icône, dans un menu où toutes les autres en ont une, se
lit comme une entrée inachevée. La section reçoit 🚚 et l'entrée 📐 — le
symbole que le rapport de qualité porte déjà dans son propre titre,
puisque c'est la même chose.

L'espacement suit une convention que ce menu tient sans la dire : une
icône de largeur « neutre » — 🗄, 🖼 — s'affiche sur une colonne dans un
terminal et prend DEUX espaces pour s'aligner, une icône large — 📏, 📐 —
un seul. Se tromper décale la ligne, et rien ne le signale avant de
l'avoir sous les yeux. Un test l'énonce désormais.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00

747 lines
27 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 TestTheMenuEntryLooksLikeItsNeighbours(Base):
"""Une entrée sans icône au milieu d'un menu qui en a partout se lit
comme une entrée inachevée."""
ENTREES = (
"Structure",
"Tables and database size",
"Customisation",
"Customised views, website copies included",
"Studio and hand-made x_ fields",
"Migration",
"Quality of a migration, step by step",
)
def libelle(self, cle, langue):
from script.todo import todo_i18n
return todo_i18n.TRANSLATIONS[cle][langue]
def test_every_entry_carries_an_icon(self):
import unicodedata
for cle in self.ENTREES:
for langue in ("fr", "en"):
premier = self.libelle(cle, langue)[0]
self.assertNotEqual(
unicodedata.category(premier)[0],
"L",
f"{cle} ({langue}) : pas d'icône",
)
def test_the_spacing_follows_the_width_of_the_icon(self):
"""Convention du menu, découverte en la lisant plutôt qu'écrite.
Une icône de largeur « neutre » — 🗄, 🖼 — s'affiche sur une
colonne dans un terminal et prend DEUX espaces pour s'aligner ;
une icône large — 📏, 📐 — en prend un seul. Se tromper décale la
ligne, et rien ne le dit avant de l'avoir sous les yeux.
"""
import unicodedata
for cle in self.ENTREES:
for langue in ("fr", "en"):
texte = self.libelle(cle, langue)
attendu = (
2 if unicodedata.east_asian_width(texte[0]) == "N" else 1
)
espaces = len(texte[1:]) - len(texte[1:].lstrip(" "))
self.assertEqual(
espaces, attendu, f"{cle} ({langue}) : {texte!r}"
)
def test_the_quality_entry_wears_the_report_s_own_symbol(self):
# Le même symbole pour la même chose. Un libellé de MENU porte
# son icône dans la traduction — elle se traduit avec lui.
self.assertTrue(
self.libelle(
"Quality of a migration, step by step", "fr"
).startswith("📐")
)
# Le titre du rapport, lui, porte son symbole dans le CODE de
# rendu, à côté de ses voisins 📍, 🧪 et 🔷 : deux conventions
# distinctes, chacune tenable dans son contexte, et l'on vérifie
# chacune là où elle vit.
import inspect
self.assertIn("📐", inspect.getsource(quality.render_text))
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()