[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
|
|
|
#!/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
|
|
|
|
|
|
|
|
|
|
|
[ADD] analyse: i, u et t — installés, en cours, applications
Trois filtres sous la main plutôt qu'un cycle à parcourir. « u » est le
seul qui montrait quelque chose d'invisible jusqu'ici : les états de
passage — to install, to upgrade, to remove — qu'Odoo traverse et ne
devrait pas garder. Mesuré sur test_neutralize_upgrade_13 en pleine
migration : 22 modules figés en « to remove ».
L'en-tête les annonce, le rapport texte les nomme avec leur état, et
« non installés » ne les ramasse plus : un module en « to remove » EST
encore installé, il s'en va. La même touche fait l'aller et le retour.
--- EN ---
Three filters under the hand rather than a cycle to walk. « u » is the
only one showing something invisible until now: the transient states —
to install, to upgrade, to remove — that Odoo passes through and should
not keep. Measured on test_neutralize_upgrade_13 mid-migration:
22 modules frozen in « to remove ».
The header announces them, the text report names them with their state,
and « not installed » no longer collects them: a module in « to remove »
IS still installed, it is on its way out. The same key goes and returns.
Assisted-by: Claude Opus 5
2026-08-23 04:03:18 -04:00
|
|
|
def base(modules, depend=None, apps=()):
|
[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
|
|
|
"""Un rapport minimal. `modules` : {nom: état}."""
|
|
|
|
|
recensement = {
|
[ADD] analyse: i, u et t — installés, en cours, applications
Trois filtres sous la main plutôt qu'un cycle à parcourir. « u » est le
seul qui montrait quelque chose d'invisible jusqu'ici : les états de
passage — to install, to upgrade, to remove — qu'Odoo traverse et ne
devrait pas garder. Mesuré sur test_neutralize_upgrade_13 en pleine
migration : 22 modules figés en « to remove ».
L'en-tête les annonce, le rapport texte les nomme avec leur état, et
« non installés » ne les ramasse plus : un module en « to remove » EST
encore installé, il s'en va. La même touche fait l'aller et le retour.
--- EN ---
Three filters under the hand rather than a cycle to walk. « u » is the
only one showing something invisible until now: the transient states —
to install, to upgrade, to remove — that Odoo passes through and should
not keep. Measured on test_neutralize_upgrade_13 mid-migration:
22 modules frozen in « to remove ».
The header announces them, the text report names them with their state,
and « not installed » no longer collects them: a module in « to remove »
IS still installed, it is on its way out. The same key goes and returns.
Assisted-by: Claude Opus 5
2026-08-23 04:03:18 -04:00
|
|
|
nom: (etat, f"Résumé {nom}", nom in apps, "TechnoLibre")
|
[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
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
[ADD] analyse: i, u et t — installés, en cours, applications
Trois filtres sous la main plutôt qu'un cycle à parcourir. « u » est le
seul qui montrait quelque chose d'invisible jusqu'ici : les états de
passage — to install, to upgrade, to remove — qu'Odoo traverse et ne
devrait pas garder. Mesuré sur test_neutralize_upgrade_13 en pleine
migration : 22 modules figés en « to remove ».
L'en-tête les annonce, le rapport texte les nomme avec leur état, et
« non installés » ne les ramasse plus : un module en « to remove » EST
encore installé, il s'en va. La même touche fait l'aller et le retour.
--- EN ---
Three filters under the hand rather than a cycle to walk. « u » is the
only one showing something invisible until now: the transient states —
to install, to upgrade, to remove — that Odoo passes through and should
not keep. Measured on test_neutralize_upgrade_13 mid-migration:
22 modules frozen in « to remove ».
The header announces them, the text report names them with their state,
and « not installed » no longer collects them: a module in « to remove »
IS still installed, it is on its way out. The same key goes and returns.
Assisted-by: Claude Opus 5
2026-08-23 04:03:18 -04:00
|
|
|
class TestTheTransientStates(unittest.TestCase):
|
|
|
|
|
"""Un module qui reste en chemin est une opération inachevée.
|
|
|
|
|
|
|
|
|
|
Mesuré sur test_neutralize_upgrade_13, en pleine migration :
|
|
|
|
|
22 modules figés en « to remove ». Aucun autre filtre ne les montrait
|
|
|
|
|
pour ce qu'ils sont.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_the_three_transient_states_are_pending(self):
|
|
|
|
|
for etat in ("to install", "to upgrade", "to remove"):
|
|
|
|
|
self.assertTrue(dep.pending(etat), etat)
|
|
|
|
|
|
|
|
|
|
def test_a_settled_state_is_not(self):
|
|
|
|
|
for etat in ("installed", "uninstalled", "uninstallable"):
|
|
|
|
|
self.assertFalse(dep.pending(etat), etat)
|
|
|
|
|
|
|
|
|
|
def rapport(self):
|
|
|
|
|
return base(
|
|
|
|
|
{
|
|
|
|
|
"zebre": "installed",
|
|
|
|
|
"alpha": "to remove",
|
|
|
|
|
"milieu": "to install",
|
|
|
|
|
"bravo": "uninstalled",
|
|
|
|
|
"charlie": "to upgrade",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_the_pending_filter_shows_exactly_those(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
[r["name"] for r in dep.rows(self.rapport(), "pending")],
|
|
|
|
|
["alpha", "charlie", "milieu"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_module_on_its_way_out_is_not_listed_as_absent(self):
|
|
|
|
|
# « to remove » EST encore installé ; le ranger avec ce qu'on
|
|
|
|
|
# pourrait installer envoyait chercher au mauvais endroit.
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
[r["name"] for r in dep.rows(self.rapport(), "absent")], ["bravo"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_to_upgrade_is_both_installed_and_pending(self):
|
|
|
|
|
# Il est chargé ET il attend : les deux sont vrais, et les taire
|
|
|
|
|
# ferait disparaître un module d'une des deux vues.
|
|
|
|
|
noms = [r["name"] for r in dep.rows(self.rapport(), "installed")]
|
|
|
|
|
self.assertIn("charlie", noms)
|
|
|
|
|
self.assertIn(
|
|
|
|
|
"charlie", [r["name"] for r in dep.rows(self.rapport(), "pending")]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_the_header_announces_them(self):
|
|
|
|
|
self.assertIn("3 en cours", dep.head_text(self.rapport()))
|
|
|
|
|
|
|
|
|
|
def test_the_header_stays_quiet_when_there_are_none(self):
|
|
|
|
|
self.assertNotIn("en cours", dep.head_text(base({"a": "installed"})))
|
|
|
|
|
|
|
|
|
|
def test_the_text_report_lists_them_with_their_state(self):
|
|
|
|
|
texte = "\n".join(dep.render_text(self.rapport()))
|
|
|
|
|
self.assertIn("alpha (to remove)", texte)
|
|
|
|
|
self.assertIn("milieu (to install)", texte)
|
|
|
|
|
|
|
|
|
|
def bloc_en_cours(self, lignes):
|
|
|
|
|
"""Les lignes qui suivent le titre « état de passage »."""
|
|
|
|
|
debut = next(i for i, x in enumerate(lignes) if "état de passage" in x)
|
|
|
|
|
suite = []
|
|
|
|
|
for ligne in lignes[debut + 1 :]:
|
|
|
|
|
if not ligne.startswith(" "):
|
|
|
|
|
break
|
|
|
|
|
suite.append(ligne)
|
|
|
|
|
return suite
|
|
|
|
|
|
|
|
|
|
def test_the_text_report_bounds_that_list_too(self):
|
|
|
|
|
# Trois en cours, borne à deux : deux nommés, et le troisième
|
|
|
|
|
# ANNONCÉ. Compter les lignes, pas chercher un chiffre au hasard.
|
|
|
|
|
suite = self.bloc_en_cours(dep.render_text(self.rapport(), limit=2))
|
|
|
|
|
nommes = [x for x in suite if x.strip().endswith(")")]
|
|
|
|
|
self.assertEqual(len(nommes), 2)
|
|
|
|
|
self.assertEqual(suite[-1].strip(), "… 1 de plus")
|
|
|
|
|
|
|
|
|
|
def test_without_a_limit_they_are_all_named(self):
|
|
|
|
|
suite = self.bloc_en_cours(dep.render_text(self.rapport()))
|
|
|
|
|
self.assertEqual(len(suite), 3)
|
|
|
|
|
self.assertFalse(any("de plus" in x for x in suite))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheApplicationFilter(unittest.TestCase):
|
|
|
|
|
def rapport(self):
|
|
|
|
|
return base(
|
|
|
|
|
{
|
|
|
|
|
"zebre": "installed",
|
|
|
|
|
"alpha": "installed",
|
|
|
|
|
"milieu": "uninstalled",
|
|
|
|
|
},
|
|
|
|
|
apps=("zebre", "milieu"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_it_keeps_the_apps_whatever_their_state(self):
|
|
|
|
|
# Un module applicatif non installé reste applicatif : filtrer
|
|
|
|
|
# sur « App » et sur « installé » sont deux questions.
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
[r["name"] for r in dep.rows(self.rapport(), "application")],
|
|
|
|
|
["milieu", "zebre"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_plain_module_is_left_out(self):
|
|
|
|
|
self.assertNotIn(
|
|
|
|
|
"alpha",
|
|
|
|
|
[r["name"] for r in dep.rows(self.rapport(), "application")],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_the_pane_says_whether_it_is_one(self):
|
|
|
|
|
self.assertIn("oui", dep.pane_text(self.rapport(), "zebre"))
|
|
|
|
|
self.assertIn("non", dep.pane_text(self.rapport(), "alpha"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestToggleFilter(unittest.TestCase):
|
|
|
|
|
def test_the_same_key_goes_and_comes_back(self):
|
|
|
|
|
# Sans le retour, il faudrait se souvenir de quelle AUTRE touche
|
|
|
|
|
# ramène à la liste entière.
|
|
|
|
|
self.assertEqual(tui.toggle_filter("all", "pending"), "pending")
|
|
|
|
|
self.assertEqual(tui.toggle_filter("pending", "pending"), "all")
|
|
|
|
|
|
|
|
|
|
def test_another_key_replaces_rather_than_returns(self):
|
|
|
|
|
self.assertEqual(tui.toggle_filter("installed", "pending"), "pending")
|
|
|
|
|
|
|
|
|
|
def test_every_direct_filter_is_a_known_one(self):
|
|
|
|
|
for cible in ("installed", "pending", "application"):
|
|
|
|
|
self.assertIn(cible, dep.FILTRES)
|
|
|
|
|
|
|
|
|
|
|
[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
|
|
|
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"],
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
|
[ADD] analyse: i, u et t — installés, en cours, applications
Trois filtres sous la main plutôt qu'un cycle à parcourir. « u » est le
seul qui montrait quelque chose d'invisible jusqu'ici : les états de
passage — to install, to upgrade, to remove — qu'Odoo traverse et ne
devrait pas garder. Mesuré sur test_neutralize_upgrade_13 en pleine
migration : 22 modules figés en « to remove ».
L'en-tête les annonce, le rapport texte les nomme avec leur état, et
« non installés » ne les ramasse plus : un module en « to remove » EST
encore installé, il s'en va. La même touche fait l'aller et le retour.
--- EN ---
Three filters under the hand rather than a cycle to walk. « u » is the
only one showing something invisible until now: the transient states —
to install, to upgrade, to remove — that Odoo passes through and should
not keep. Measured on test_neutralize_upgrade_13 mid-migration:
22 modules frozen in « to remove ».
The header announces them, the text report names them with their state,
and « not installed » no longer collects them: a module in « to remove »
IS still installed, it is on its way out. The same key goes and returns.
Assisted-by: Claude Opus 5
2026-08-23 04:03:18 -04:00
|
|
|
def rapport_etats(self):
|
|
|
|
|
"""Un jeu qui porte les trois catégories des touches i / u / t."""
|
|
|
|
|
return base(
|
|
|
|
|
{
|
|
|
|
|
"sale": "installed",
|
|
|
|
|
"mail": "installed",
|
|
|
|
|
"muk_theme": "to remove",
|
|
|
|
|
"stock": "to install",
|
|
|
|
|
"vieux": "uninstalled",
|
|
|
|
|
},
|
|
|
|
|
apps=("sale", "stock"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def conduire(self, scenario, rapport=None):
|
[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
|
|
|
import asyncio
|
|
|
|
|
|
[ADD] analyse: i, u et t — installés, en cours, applications
Trois filtres sous la main plutôt qu'un cycle à parcourir. « u » est le
seul qui montrait quelque chose d'invisible jusqu'ici : les états de
passage — to install, to upgrade, to remove — qu'Odoo traverse et ne
devrait pas garder. Mesuré sur test_neutralize_upgrade_13 en pleine
migration : 22 modules figés en « to remove ».
L'en-tête les annonce, le rapport texte les nomme avec leur état, et
« non installés » ne les ramasse plus : un module en « to remove » EST
encore installé, il s'en va. La même touche fait l'aller et le retour.
--- EN ---
Three filters under the hand rather than a cycle to walk. « u » is the
only one showing something invisible until now: the transient states —
to install, to upgrade, to remove — that Odoo passes through and should
not keep. Measured on test_neutralize_upgrade_13 mid-migration:
22 modules frozen in « to remove ».
The header announces them, the text report names them with their state,
and « not installed » no longer collects them: a module in « to remove »
IS still installed, it is on its way out. The same key goes and returns.
Assisted-by: Claude Opus 5
2026-08-23 04:03:18 -04:00
|
|
|
app = tui.build_app(rapport or self.rapport())
|
[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
|
|
|
|
|
|
|
|
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])
|
|
|
|
|
|
[ADD] analyse: i, u et t — installés, en cours, applications
Trois filtres sous la main plutôt qu'un cycle à parcourir. « u » est le
seul qui montrait quelque chose d'invisible jusqu'ici : les états de
passage — to install, to upgrade, to remove — qu'Odoo traverse et ne
devrait pas garder. Mesuré sur test_neutralize_upgrade_13 en pleine
migration : 22 modules figés en « to remove ».
L'en-tête les annonce, le rapport texte les nomme avec leur état, et
« non installés » ne les ramasse plus : un module en « to remove » EST
encore installé, il s'en va. La même touche fait l'aller et le retour.
--- EN ---
Three filters under the hand rather than a cycle to walk. « u » is the
only one showing something invisible until now: the transient states —
to install, to upgrade, to remove — that Odoo passes through and should
not keep. Measured on test_neutralize_upgrade_13 mid-migration:
22 modules frozen in « to remove ».
The header announces them, the text report names them with their state,
and « not installed » no longer collects them: a module in « to remove »
IS still installed, it is on its way out. The same key goes and returns.
Assisted-by: Claude Opus 5
2026-08-23 04:03:18 -04:00
|
|
|
def test_i_u_and_t_each_jump_straight_to_their_filter(self):
|
|
|
|
|
vus = {}
|
|
|
|
|
|
|
|
|
|
async def scenario(app, pilote):
|
|
|
|
|
for touche in ("i", "u", "t"):
|
|
|
|
|
await pilote.press(touche)
|
|
|
|
|
vus[touche] = (
|
|
|
|
|
app.filtre,
|
|
|
|
|
sorted(row["name"] for row in app.lst_row),
|
|
|
|
|
)
|
|
|
|
|
await pilote.press(touche)
|
|
|
|
|
vus[touche + "_retour"] = app.filtre
|
|
|
|
|
|
|
|
|
|
self.conduire(scenario, self.rapport_etats())
|
|
|
|
|
self.assertEqual(vus["i"], ("installed", ["mail", "sale"]))
|
|
|
|
|
self.assertEqual(vus["u"], ("pending", ["muk_theme", "stock"]))
|
|
|
|
|
self.assertEqual(vus["t"], ("application", ["sale", "stock"]))
|
|
|
|
|
for touche in ("i", "u", "t"):
|
|
|
|
|
self.assertEqual(vus[touche + "_retour"], "all")
|
|
|
|
|
|
|
|
|
|
def test_a_second_key_replaces_the_filter_rather_than_stacking(self):
|
|
|
|
|
vus = {}
|
|
|
|
|
|
|
|
|
|
async def scenario(app, pilote):
|
|
|
|
|
await pilote.press("i")
|
|
|
|
|
await pilote.press("u")
|
|
|
|
|
vus["filtre"] = app.filtre
|
|
|
|
|
vus["lignes"] = app.query_one("#left").row_count
|
|
|
|
|
|
|
|
|
|
self.conduire(scenario, self.rapport_etats())
|
|
|
|
|
self.assertEqual(vus["filtre"], "pending")
|
|
|
|
|
self.assertEqual(vus["lignes"], 2)
|
|
|
|
|
|
|
|
|
|
def test_those_letters_are_typed_not_obeyed_while_searching(self):
|
|
|
|
|
# « i », « u » et « t » sont des lettres avant d'être des
|
|
|
|
|
# raccourcis : dans le champ de recherche, elles s'écrivent.
|
|
|
|
|
vus = {}
|
|
|
|
|
|
|
|
|
|
async def scenario(app, pilote):
|
|
|
|
|
await pilote.press("slash")
|
|
|
|
|
for lettre in "it":
|
|
|
|
|
await pilote.press(lettre)
|
|
|
|
|
await pilote.pause()
|
|
|
|
|
vus["motif"] = app.motif
|
|
|
|
|
vus["filtre"] = app.filtre
|
|
|
|
|
|
|
|
|
|
self.conduire(scenario, self.rapport_etats())
|
|
|
|
|
self.assertEqual(vus["motif"], "it")
|
|
|
|
|
self.assertEqual(vus["filtre"], "all")
|
|
|
|
|
|
[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
|
|
|
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()
|