erplibre/test/test_check_module_dependency.py
Mathieu Benoit 7923e37e4f [ADD] analyse: qui dépend de qui, à l'écran
Le menu Analyse disait quels modules manquent, jamais qui dépend de qui.
« Puis-je retirer celui-ci » se réglait donc à la main : au palier
17 → 18, il a fallu écrire la requête pour savoir si web_responsive
pouvait partir.

L'écran liste les modules et « d » parcourt quatre relations : ce dont il
dépend, ce qui en dépend, tout ce qu'il entraîne, tout ce qui tombe avec
lui. « f » filtre, « / » cherche — une base en porte trois mille. La
réponse qui compte est écrite en toutes lettres : deux dépendants
déclarés dont zéro installé, c'est un retrait sans danger.

--- EN ---

The Analyse menu told which modules were missing, never which depends on
which. « Can I remove this one » was therefore answered by hand: at the
17 → 18 step we had to write the query to learn whether web_responsive
could go.

The screen lists the modules and « d » walks four relations: what it
needs, what needs it, everything it pulls in, everything that falls with
it. « f » filters, « / » searches — a database holds three thousand of
them. The answer that matters is spelled out: two declared dependents of
which zero installed means removal is safe.

Assisted-by: Claude Opus 5
2026-08-23 03:46:08 -04:00

631 lines
22 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)
"""Qui dépend de qui, et ce qu'on peut retirer sans casser.
La question posée au palier 17 → 18 — « puis-je retirer web_responsive »
— a demandé une requête écrite à la main. L'outil y répond, et la réponse
qui compte n'est pas « deux modules en dépendent » mais « aucun de ceux
qui en dépendent n'est installé ».
"""
import os
import sys
import unittest
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.analyse import check_module_dependency as dep # noqa: E402
from script.analyse import check_module_dependency_tui as tui # noqa: E402
def base(modules, depend=None):
"""Un rapport minimal. `modules` : {nom: état}."""
recensement = {
nom: (etat, f"Résumé {nom}", False, "TechnoLibre")
for nom, etat in modules.items()
}
depend = (
{nom: sorted(depend.get(nom, [])) for nom in recensement}
if depend
else {nom: [] for nom in recensement}
)
return {
"database": "essai",
"version": "18.0",
"modules": recensement,
"depends": depend,
"dependents": dep.reverse(depend),
"broken": dep.broken(recensement, depend),
}
class TestReverse(unittest.TestCase):
def test_it_inverts_the_arrows(self):
self.assertEqual(
dep.reverse({"zebre": ["alpha"], "alpha": []}),
{"alpha": ["zebre"], "zebre": []},
)
def test_a_module_cited_but_absent_still_gets_an_entry(self):
# C'est le cas qu'on cherche : quelqu'un dépend d'un module que la
# base ne connaît pas. L'omettre le rendrait invisible.
self.assertEqual(dep.reverse({"a": ["fantome"]})["fantome"], ["a"])
def test_a_duplicate_dependency_is_counted_once(self):
self.assertEqual(dep.reverse({"a": ["b", "b"]})["b"], ["a"])
class TestClosure(unittest.TestCase):
def test_it_walks_through(self):
graphe = {"zebre": ["milieu"], "milieu": ["alpha"], "alpha": []}
self.assertEqual(dep.closure("zebre", graphe), ["alpha", "milieu"])
def test_it_excludes_itself(self):
self.assertNotIn("a", dep.closure("a", {"a": ["b"], "b": ["a"]}))
def test_a_cycle_terminates(self):
# Un cycle ne devrait pas exister en base ; s'il y en a un, on veut
# un résultat, pas une boucle sans fin.
self.assertEqual(
dep.closure("a", {"a": ["b"], "b": ["c"], "c": ["a"]}),
["b", "c"],
)
def test_a_chain_longer_than_the_bound_is_cut_not_hung(self):
chaine = {
f"n{i}": [f"n{i + 1}"] for i in range(dep.PROFONDEUR_MAX + 8)
}
atteints = dep.closure("n0", chaine)
self.assertEqual(len(atteints), dep.PROFONDEUR_MAX)
def test_an_unknown_module_reaches_nothing(self):
self.assertEqual(dep.closure("absent", {"a": ["b"]}), [])
class TestBroken(unittest.TestCase):
def test_installed_on_an_uninstalled_dependency(self):
rapport = base(
{"chef": "installed", "socle": "uninstalled"},
{"chef": ["socle"]},
)
self.assertEqual(rapport["broken"], [("chef", "socle", "uninstalled")])
def test_installed_on_a_module_the_base_never_heard_of(self):
rapport = base({"chef": "installed"}, {"chef": ["fantome"]})
self.assertEqual(rapport["broken"], [("chef", "fantome", "unknown")])
def test_an_uninstalled_module_may_depend_on_anything(self):
# Il ne tourne pas : ses dépendances ne cassent rien.
rapport = base(
{"chef": "uninstalled", "socle": "uninstalled"},
{"chef": ["socle"]},
)
self.assertEqual(rapport["broken"], [])
def test_to_upgrade_counts_as_in_place(self):
# Le module est chargé, il sera seulement rejoué.
rapport = base(
{"chef": "installed", "socle": "to upgrade"},
{"chef": ["socle"]},
)
self.assertEqual(rapport["broken"], [])
def test_to_install_is_not_in_place_yet(self):
rapport = base(
{"chef": "installed", "socle": "to install"},
{"chef": ["socle"]},
)
self.assertEqual(rapport["broken"], [("chef", "socle", "to install")])
class TestCounts(unittest.TestCase):
def rapport(self):
return base(
{
"web_responsive": "installed",
"erplibre_base": "uninstalled",
"web_responsive_company": "uninstalled",
"mail": "installed",
},
{
"web_responsive": ["mail"],
"erplibre_base": ["web_responsive"],
"web_responsive_company": ["web_responsive"],
},
)
def test_the_third_number_counts_only_what_is_in_place(self):
# Le cas réel : deux dépendants déclarés, aucun installé. Ne
# montrer que « 2 » ferait renoncer à un retrait sans danger.
self.assertEqual(
dep.counts(self.rapport(), "web_responsive"), (1, 2, 0)
)
def test_an_installed_dependent_is_counted(self):
rapport = self.rapport()
rapport["modules"]["erplibre_base"] = ("installed", "", False, "")
self.assertEqual(dep.counts(rapport, "web_responsive")[2], 1)
def test_a_module_nobody_needs(self):
self.assertEqual(
dep.counts(self.rapport(), "erplibre_base"), (1, 0, 0)
)
class TestRows(unittest.TestCase):
def rapport(self):
return base(
{
"zebre": "installed",
"alpha": "uninstalled",
"milieu": "installed",
},
{"zebre": ["alpha"]},
)
def test_they_come_out_sorted_by_name(self):
# Les noms sont posés dans un ordre qui CONTREDIT l'alphabet :
# sans tri, la liste sortirait zebre, alpha, milieu.
self.assertEqual(
[row["name"] for row in dep.rows(self.rapport())],
["alpha", "milieu", "zebre"],
)
def test_the_installed_filter_keeps_only_those(self):
self.assertEqual(
[r["name"] for r in dep.rows(self.rapport(), "installed")],
["milieu", "zebre"],
)
def test_the_absent_filter_is_the_complement(self):
self.assertEqual(
[r["name"] for r in dep.rows(self.rapport(), "absent")], ["alpha"]
)
def test_the_broken_filter_keeps_the_guilty_only(self):
# zebre est installé et dépend d'alpha qui ne l'est pas.
self.assertEqual(
[r["name"] for r in dep.rows(self.rapport(), "broken")], ["zebre"]
)
def test_the_detail_column_shows_both_counts(self):
row = [r for r in dep.rows(self.rapport()) if r["name"] == "alpha"][0]
self.assertEqual(row["detail"], "0↓ 1/1↑")
def test_every_filter_is_understood(self):
# Un filtre inconnu ne doit pas vider la liste en silence.
for filtre in dep.FILTRES:
dep.rows(self.rapport(), filtre)
class TestListing(unittest.TestCase):
def rapport(self):
return base(
{"haut": "installed", "milieu": "installed", "bas": "installed"},
{"haut": ["milieu"], "milieu": ["bas"]},
)
def test_depends_is_direct_only(self):
self.assertEqual(
dep.listing(self.rapport(), "haut", "depends"), ["milieu"]
)
def test_pulls_goes_all_the_way_down(self):
self.assertEqual(
dep.listing(self.rapport(), "haut", "pulls"), ["bas", "milieu"]
)
def test_dependents_is_direct_only(self):
self.assertEqual(
dep.listing(self.rapport(), "bas", "dependents"), ["milieu"]
)
def test_falls_goes_all_the_way_up(self):
self.assertEqual(
dep.listing(self.rapport(), "bas", "falls"), ["haut", "milieu"]
)
def test_every_mode_has_a_title(self):
for mode in dep.DETAILS:
self.assertIn(mode, dep.TITRE_DETAIL)
class TestPaneText(unittest.TestCase):
def rapport(self):
return base(
{"web_responsive": "installed", "compagnon": "uninstalled"},
{"compagnon": ["web_responsive"]},
)
def test_it_answers_the_removal_question_in_words(self):
# Deux chiffres à comparer soi-même, ce n'est pas une réponse.
texte = dep.pane_text(self.rapport(), "web_responsive")
self.assertIn("rien d'installé n'en dépend", texte)
def test_it_stays_silent_when_something_does_depend_on_it(self):
rapport = self.rapport()
rapport["modules"]["compagnon"] = ("installed", "", False, "")
self.assertNotIn(
"rien d'installé n'en dépend",
dep.pane_text(rapport, "web_responsive"),
)
def test_an_absent_dependent_is_marked_as_such(self):
texte = dep.pane_text(
self.rapport(), "web_responsive", mode="dependents"
)
self.assertIn("compagnon", texte)
self.assertIn("absent", texte)
def test_an_unknown_module_says_so(self):
self.assertIn("inconnu", dep.pane_text(self.rapport(), "jamais_vu"))
def test_nothing_selected_is_not_a_crash(self):
self.assertTrue(dep.pane_text(self.rapport(), None))
def test_an_empty_listing_says_nothing_rather_than_showing_blank(self):
texte = dep.pane_text(self.rapport(), "web_responsive", mode="depends")
self.assertIn("rien", texte.lower())
def test_the_limit_announces_what_it_cut(self):
rapport = base(
dict(
{"chef": "installed"},
**{f"d{i}": "installed" for i in range(9)},
),
{"chef": [f"d{i}" for i in range(9)]},
)
texte = dep.pane_text(rapport, "chef", mode="depends", limit=4)
self.assertIn("5", texte)
class TestRenderText(unittest.TestCase):
def rapport(self):
return base({f"m{i:02d}": "installed" for i in range(30)})
def test_without_a_cap_everything_is_there(self):
lignes = dep.render_text(self.rapport())
self.assertEqual(sum(1 for x in lignes if x.startswith("✅")), 30)
def test_the_cap_bounds_the_module_list(self):
# Sur une vraie base c'est 3035 modules et 6046 lignes : sans
# borne, le repli du menu est une seconde panne.
lignes = dep.render_text(self.rapport(), cap=5)
self.assertEqual(sum(1 for x in lignes if x.startswith("✅")), 5)
def test_the_cap_says_how_many_it_hid(self):
self.assertTrue(
any("25" in x for x in dep.render_text(self.rapport(), cap=5))
)
def test_an_unreadable_database_says_so(self):
lignes = dep.render_text({"database": "x", "unavailable": True})
self.assertEqual(len(lignes), 1)
self.assertIn("x", lignes[0])
class TestSurvey(unittest.TestCase):
def setUp(self):
from script.analyse import check_module_package as package
self.package = package
self.vrai = (package.census, package.dependencies, package.db_version)
def tearDown(self):
(
self.package.census,
self.package.dependencies,
self.package.db_version,
) = self.vrai
def poser(self, recensement, depend):
self.package.census = lambda d: recensement
self.package.dependencies = lambda d: depend
self.package.db_version = lambda d: "18.0"
def test_a_module_without_dependency_still_exists_in_the_graph(self):
# Sans cela, `closure` et l'écran le traitent comme inconnu.
self.poser({"seul": ("installed", "", False, "")}, {})
self.assertEqual(dep.survey("x")["depends"], {"seul": []})
def test_an_unreadable_base_is_not_an_empty_one(self):
self.poser(None, {})
self.assertTrue(dep.survey("x").get("unavailable"))
def test_an_empty_base_is_readable(self):
self.poser({}, {})
self.assertFalse(dep.survey("x").get("unavailable"))
def test_it_reads_the_base_once_for_both_views(self):
appels = []
self.poser({"a": ("installed", "", False, "")}, {})
vrai = self.package.census
self.package.census = lambda d: (appels.append(d), vrai(d))[1]
dep.survey("x")
self.assertEqual(appels, ["x"])
class TestTuiPureParts(unittest.TestCase):
def test_the_mode_cycles_through_all_of_them_and_returns(self):
vus, mode = [], None
for _ in range(len(dep.DETAILS) + 1):
mode = tui.next_mode(mode)
vus.append(mode)
self.assertEqual(vus, list(dep.DETAILS) + [None])
def test_an_unknown_mode_restarts_the_cycle(self):
self.assertEqual(tui.next_mode("n'importe quoi"), dep.DETAILS[0])
def test_the_filter_cycles(self):
self.assertEqual(tui.next_filter(dep.FILTRES[-1]), dep.FILTRES[0])
def test_the_cursor_out_of_range_is_not_a_crash(self):
# La table garde son curseur quand la liste raccourcit.
self.assertIsNone(tui.current_name([{"name": "a"}], 7))
self.assertIsNone(tui.current_name([], 0))
self.assertIsNone(tui.current_name([{"name": "a"}], None))
def test_the_cursor_follows_the_module_across_a_filter(self):
lst = [{"name": "alpha"}, {"name": "zebre"}]
self.assertEqual(tui.cursor_for(lst, "zebre"), 1)
def test_a_module_filtered_away_loses_the_cursor(self):
self.assertIsNone(tui.cursor_for([{"name": "alpha"}], "zebre"))
def test_the_search_ignores_the_case(self):
lst = [{"name": "Web_Responsive"}, {"name": "mail"}]
self.assertEqual(len(tui.matching(lst, "web")), 1)
def test_an_empty_search_keeps_everything(self):
lst = [{"name": "a"}, {"name": "b"}]
self.assertIs(tui.matching(lst, ""), lst)
def test_the_subtitle_names_the_three_states(self):
texte = tui.subtitle("depends", "installed", "web")
self.assertIn("directement", texte)
self.assertIn("installés", texte)
self.assertIn("web", texte)
class FausseTable:
"""Le contrat de DataTable, réduit à ce que `populate` en utilise."""
def __init__(self):
self.lignes = []
self.curseur = None
def clear(self):
self.lignes = []
def add_row(self, *cellules):
self.lignes.append(cellules)
def move_cursor(self, row=None):
self.curseur = row
class TestPopulate(unittest.TestCase):
def test_it_fills_and_keeps_the_cursor_on_the_module(self):
table = FausseTable()
lst = [
{"name": "alpha", "label": "✅ alpha", "detail": "0↓ 0/0↑"},
{"name": "zebre", "label": "✅ zebre", "detail": "1↓ 0/0↑"},
]
self.assertEqual(tui.populate(table, lst, "zebre"), 1)
self.assertEqual(len(table.lignes), 2)
self.assertEqual(table.curseur, 1)
def test_it_clears_before_refilling(self):
table = FausseTable()
table.add_row("vieux")
tui.populate(table, [{"name": "a", "label": "a", "detail": ""}])
self.assertEqual(len(table.lignes), 1)
def test_a_vanished_module_leaves_the_cursor_alone(self):
table = FausseTable()
tui.populate(
table, [{"name": "a", "label": "a", "detail": ""}], "parti"
)
self.assertIsNone(table.curseur)
class FauxChamp:
"""Le contrat d'Input, réduit à ce que `hide_find` en utilise."""
def __init__(self):
self.value = "web"
self.classes = {"visible"}
def remove_class(self, nom):
self.classes.discard(nom)
def has_class(self, nom):
return nom in self.classes
class FausseTableFocus(FausseTable):
def __init__(self):
super().__init__()
self.focalisee = False
def focus(self):
self.focalisee = True
class TestHideFind(unittest.TestCase):
def test_it_hands_the_keyboard_back_to_the_list(self):
# Le geste qui compte : sans lui, Textual laisse le clavier au
# champ de recherche et plus aucune touche n'agit.
champ, table = FauxChamp(), FausseTableFocus()
tui.hide_find(champ, table)
self.assertTrue(table.focalisee)
def test_it_hides_the_field(self):
champ, table = FauxChamp(), FausseTableFocus()
tui.hide_find(champ, table)
self.assertFalse(champ.has_class("visible"))
def test_it_leaves_the_text_alone(self):
# Effacer ici ferait deux chemins pour le même état : l'appelant
# vide le champ, et c'est CE geste qui redéclenche le filtrage.
champ, table = FauxChamp(), FausseTableFocus()
tui.hide_find(champ, table)
self.assertEqual(champ.value, "web")
class TestTheScreenRefusesWhenItCannot(unittest.TestCase):
def test_an_unreadable_report_opens_nothing(self):
self.assertFalse(tui.run_tui({"unavailable": True}))
def test_an_empty_base_opens_nothing(self):
self.assertFalse(tui.run_tui({"modules": {}}))
def test_nothing_at_all_opens_nothing(self):
self.assertFalse(tui.run_tui(None))
def textual_present():
try:
import textual # noqa: F401
except Exception:
return False
return True
@unittest.skipUnless(textual_present(), "textual absent")
class TestTheScreenActuallyDrives(unittest.TestCase):
"""Conduire l'écran, touche par touche.
Les fonctions pures étaient toutes vertes et l'écran, lui, ne
répondait pas : le champ de recherche caché prenait le focus au
démarrage — « display: none » ne retire pas un widget du parcours du
clavier — donc « d » tapait dans une boîte invisible. Seul un test
qui PRESSE les touches pouvait le voir.
"""
def rapport(self):
return base(
{
"web_responsive": "installed",
"mail": "installed",
"compagnon": "uninstalled",
"casse": "installed",
"zebre": "installed",
},
{
"web_responsive": ["mail"],
"compagnon": ["web_responsive"],
"casse": ["fantome"],
"zebre": ["web_responsive"],
},
)
def conduire(self, scenario):
import asyncio
app = tui.build_app(self.rapport())
async def piloter():
async with app.run_test() as pilote:
await scenario(app, pilote)
asyncio.run(piloter())
return app
def test_the_keyboard_starts_on_the_list_not_in_the_search(self):
vus = []
async def scenario(app, pilote):
vus.append(type(app.focused).__name__)
self.conduire(scenario)
self.assertEqual(vus, ["DataTable"])
def test_pressing_d_walks_every_relation_and_comes_back(self):
vus = []
async def scenario(app, pilote):
for _ in range(len(dep.DETAILS) + 1):
await pilote.press("d")
vus.append(app.mode)
self.conduire(scenario)
self.assertEqual(vus, list(dep.DETAILS) + [None])
def test_pressing_f_narrows_the_list(self):
vus = {}
async def scenario(app, pilote):
table = app.query_one("#left")
for _ in range(len(dep.FILTRES)):
await pilote.press("f")
vus[app.filtre] = table.row_count
self.conduire(scenario)
self.assertEqual(vus["all"], 5)
self.assertEqual(vus["installed"], 4)
self.assertEqual(vus["absent"], 1)
self.assertEqual(vus["broken"], 1)
def test_the_search_filters_then_gives_the_keyboard_back(self):
vus = {}
async def scenario(app, pilote):
await pilote.press("slash")
vus["focus_ouvert"] = type(app.focused).__name__
for lettre in "web":
await pilote.press(lettre)
await pilote.pause()
vus["lignes"] = app.query_one("#left").row_count
await pilote.press("enter")
vus["focus_rendu"] = type(app.focused).__name__
await pilote.press("d")
vus["mode"] = app.mode
self.conduire(scenario)
self.assertEqual(vus["focus_ouvert"], "Input")
self.assertEqual(vus["lignes"], 1)
self.assertEqual(vus["focus_rendu"], "DataTable")
# « d » doit remarcher une fois la recherche finie : sans cela on
# ne peut plus rien faire après avoir cherché.
self.assertEqual(vus["mode"], dep.DETAILS[0])
def test_escape_closes_the_search_before_it_closes_the_screen(self):
vus = {}
async def scenario(app, pilote):
await pilote.press("slash")
for lettre in "web":
await pilote.press(lettre)
await pilote.pause()
await pilote.press("escape")
await pilote.pause()
vus["motif"] = app.motif
vus["lignes"] = app.query_one("#left").row_count
vus["vivant"] = app.is_running
# La tabulation ne doit pas non plus retomber dans le champ
# refermé : « display: none » ne l'en retire pas, seul
# can_focus le fait.
await pilote.press("tab")
vus["apres_tab"] = type(app.focused).__name__
await pilote.press("escape")
await pilote.pause()
vus["apres_second_echap"] = app.is_running
self.conduire(scenario)
self.assertNotEqual(vus["apres_tab"], "Input")
# …et le second échap, lui, ferme bien l'écran.
self.assertFalse(vus["apres_second_echap"])
# Renoncer à une recherche ne doit pas fermer l'écran : on a
# parfois filtré trois mille modules pour arriver là.
self.assertTrue(vus["vivant"])
self.assertEqual(vus["motif"], "")
self.assertEqual(vus["lignes"], 5)
if __name__ == "__main__":
unittest.main()