erplibre/test/test_check_manifest_gaps.py
Mathieu Benoit 599401b6c4 [ADD] manifestes : détecter un dépôt absent d'un palier de migration
Une migration 12 → 18 traverse six paliers, et le checkout bascule de
manifeste à chaque fois. Un dépôt d'addons absent du manifeste du 17
n'existe pas sur disque pendant l'étape 17 : Odoo déclare ses modules
introuvables, le pilote propose de les effacer, on répond oui, et la
fonctionnalité part sans qu'aucun échec ne soit signalé.

Seul le trou compte — présent avant et après, absent au milieu — et la
branche en amont le confirme : sur 35 trous, 19 sont de vraies
omissions, quinze déclarées en 16 et 18 mais pas en 17 ; les 35 auraient
fait 46 % de bruit. development.git est rétabli en 13 et en 15.

--- EN ---

A 12 → 18 migration crosses six steps, and the checkout switches
manifest each time. An addons repository missing from the 17 manifest
does not exist on disk during step 17: Odoo reports its modules as
missing, the driver offers to delete them, you answer yes, and the
feature is gone without a single failure being reported.

Only the hole counts — present before and after, absent in between — and
the upstream branch confirms it: of 35 holes, 19 are real omissions,
fifteen declared in 16 and 18 but not in 17; all 35 would have been 46%
noise. development.git is restored in 13 and in 15.

Assisted-by: Claude Opus 5
(cherry picked from commit d7613ea930d45153e9fb7c08460690a95e3dca0e)
2026-08-29 02:11:04 -04:00

391 lines
14 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)
"""Le trou dans un manifeste, et ce qui le distingue d'une absence normale.
Beaucoup de dépôts n'ont pas de branche pour toutes les versions d'Odoo.
Les signaler tous ferait 46 % de bruit — mesuré : 35 trous, 19 vraies
omissions. L'outil ne vaut que par ce tri, et c'est donc lui qu'on teste.
Aucun test ne touche au réseau : `judge` reçoit son lecteur d'amont, et
les tests lui en donnent un qui répond de mémoire. Un test qui appelle
GitHub échoue dans un train, et l'on finit par ne plus le lancer.
"""
import io
import os
import sys
import tempfile
import unittest
from contextlib import redirect_stdout
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, REPO)
from script.analyse import check_manifest_gaps as gaps # noqa: E402
MANIFESTE = """<?xml version="1.0" encoding="UTF-8"?>
<manifest>
<remote name="OCA" fetch="https://github.com/OCA/" />
<remote name="autre" fetch="https://example.org/eq/" />
%s
</manifest>
"""
PROJET = """ <project
name="%s"
path="odoo%s/addons/%s"
remote="%s"
revision="%s"
groups="addons,odoo%s"
/>"""
def projet(nom, version, remote="OCA"):
court = nom.replace(".git", "")
return PROJET % (nom, version, court, remote, version, version)
class Base(unittest.TestCase):
def setUp(self):
self.dossier = tempfile.mkdtemp()
self.addCleanup(__import__("shutil").rmtree, self.dossier)
def ecrire(self, version, noms, remote="OCA"):
corps = "\n".join(projet(n, version, remote) for n in noms)
chemin = os.path.join(
self.dossier, "git_manifest_odoo%s.xml" % version
)
with io.open(chemin, "w", encoding="utf-8") as handle:
handle.write(MANIFESTE % corps)
return chemin
def trous(self):
ordre = [v for v, _ in gaps.versions(self.dossier)]
return gaps.gaps(gaps.declarations(self.dossier), ordre)
class TestReadingTheManifests(Base):
def test_the_versions_come_from_the_files_not_a_list(self):
# Une version ajoutée au dépôt doit entrer dans l'analyse sans
# qu'on pense à toucher le code.
for v in ("12.0", "14.0", "18.0"):
self.ecrire(v, ["a.git"])
self.assertEqual(
["12.0", "14.0", "18.0"],
[v for v, _ in gaps.versions(self.dossier)],
)
def test_ten_comes_after_nine_not_before(self):
# Trier des versions comme du texte met « 10.0 » avant « 9.0 ».
self.assertLess(gaps.rang("9.0"), gaps.rang("10.0"))
def test_the_dev_manifests_are_left_out(self):
# Ils COMPLÈTENT le principal : un dépôt qui n'est que là n'a
# aucune raison d'être partout, et le compter créerait des trous
# imaginaires dans toutes les versions.
self.ecrire("14.0", ["a.git"])
with io.open(
os.path.join(self.dossier, "git_manifest_odoo14.0_dev.xml"),
"w",
encoding="utf-8",
) as handle:
handle.write(MANIFESTE % projet("b.git", "14.0"))
self.assertEqual(["14.0"], [v for v, _ in gaps.versions(self.dossier)])
def test_the_url_is_rebuilt_from_its_remote(self):
self.ecrire("14.0", ["donation.git"])
dct = gaps.declarations(self.dossier)
self.assertEqual(
"https://github.com/OCA/donation.git",
dct["donation.git"]["14.0"],
)
def test_an_unparsable_manifest_does_not_stop_the_others(self):
self.ecrire("14.0", ["a.git"])
with io.open(
os.path.join(self.dossier, "git_manifest_odoo16.0.xml"),
"w",
encoding="utf-8",
) as handle:
handle.write("<manifest><project")
self.assertIn("a.git", gaps.declarations(self.dossier))
class TestWhatCountsAsAHole(Base):
def test_absent_in_the_middle_is_a_hole(self):
self.ecrire("16.0", ["sale-channel.git"])
self.ecrire("17.0", [])
self.ecrire("18.0", ["sale-channel.git"])
trous = self.trous()
self.assertEqual(1, len(trous))
self.assertEqual(["17.0"], trous[0][2])
def test_absent_before_the_first_is_not_a_hole(self):
# Un dépôt né en 16 n'a rien à faire en 12 ; l'exiger crierait
# sur presque tout le fichier.
self.ecrire("12.0", [])
self.ecrire("16.0", ["neuf.git"])
self.ecrire("18.0", ["neuf.git"])
self.assertEqual([], self.trous())
def test_absent_after_the_last_is_not_a_hole(self):
# Un dépôt abandonné après la 14 non plus.
self.ecrire("12.0", ["vieux.git"])
self.ecrire("14.0", ["vieux.git"])
self.ecrire("18.0", [])
self.assertEqual([], self.trous())
def test_a_single_appearance_is_never_a_hole(self):
self.ecrire("12.0", [])
self.ecrire("14.0", ["seul.git"])
self.ecrire("18.0", [])
self.assertEqual([], self.trous())
def test_several_missing_steps_are_all_named(self):
self.ecrire("12.0", ["long.git"])
self.ecrire("13.0", [])
self.ecrire("14.0", [])
self.ecrire("15.0", [])
self.ecrire("16.0", ["long.git"])
self.assertEqual(["13.0", "14.0", "15.0"], self.trous()[0][2])
class TestSettlingAHole(Base):
"""Le tri qui fait tout l'intérêt de l'outil."""
def setUp(self):
super().setUp()
self.ecrire("16.0", ["a.git", "b.git"])
self.ecrire("17.0", [])
self.ecrire("18.0", ["a.git", "b.git"])
self.appels = []
def lecteur(self, reponses):
def lire(url):
self.appels.append(url)
return reponses.get(url.rsplit("/", 1)[-1])
return lire
def test_without_asking_nothing_is_settled(self):
juges = gaps.judge(self.trous(), verifier=False)
self.assertEqual({"inconnu"}, {j[3] for j in juges})
self.assertEqual([], self.appels)
def test_a_branch_upstream_makes_it_an_omission(self):
juges = gaps.judge(
self.trous(),
verifier=True,
lecteur=self.lecteur({"a.git": {"17.0"}, "b.git": {"17.0"}}),
)
self.assertEqual({"omission"}, {j[3] for j in juges})
def test_no_branch_upstream_makes_it_legitimate(self):
juges = gaps.judge(
self.trous(),
verifier=True,
lecteur=self.lecteur({"a.git": {"16.0", "18.0"}, "b.git": set()}),
)
self.assertEqual({"legitime"}, {j[3] for j in juges})
def test_unreachable_is_not_absolution(self):
# None dit « je n'ai pas pu demander », l'ensemble vide dit « il
# n'y en a pas ». Les confondre transformerait une coupure réseau
# en rapport tout vert.
juges = gaps.judge(
self.trous(),
verifier=True,
lecteur=self.lecteur({"a.git": None, "b.git": None}),
)
self.assertEqual({"inconnu"}, {j[3] for j in juges})
def test_each_repository_is_asked_once(self):
gaps.judge(
self.trous(),
verifier=True,
lecteur=self.lecteur({"a.git": set(), "b.git": set()}),
)
self.assertEqual(len(self.appels), len(set(self.appels)))
self.assertEqual(2, len(self.appels))
class TestAskingTheRemote(unittest.TestCase):
"""La seule fonction qui parle au réseau — bouchonnée, jamais appelée."""
def setUp(self):
self.vrai = gaps.subprocess
self.addCleanup(setattr, gaps, "subprocess", self.vrai)
def repondre(self, sortie="", code=0, leve=None):
essai = self
class Faux:
@staticmethod
def run(argv, capture_output=None, text=None, timeout=None):
essai.argv = argv
if leve:
raise leve
return type("R", (), {"returncode": code, "stdout": sortie})
Faux.SubprocessError = self.vrai.SubprocessError
Faux.TimeoutExpired = self.vrai.TimeoutExpired
gaps.subprocess = Faux
def test_no_url_asks_nothing_and_settles_nothing(self):
# Sans URL on ne peut pas demander ; rendre un ensemble vide
# dirait « l'amont n'a pas la branche », ce qu'on ignore.
self.repondre()
self.assertIsNone(gaps.branches(""))
self.assertFalse(hasattr(self, "argv"))
def test_only_version_branches_are_kept(self):
# « master » et « main » ne sont pas des paliers d'Odoo.
self.repondre(
"abc\trefs/heads/16.0\n"
"def\trefs/heads/master\n"
"012\trefs/heads/17.0\n"
"345\trefs/heads/feature/x\n"
)
self.assertEqual({"16.0", "17.0"}, gaps.branches("https://x/y.git"))
def test_a_failed_call_is_not_settled(self):
self.repondre(code=128)
self.assertIsNone(gaps.branches("https://x/y.git"))
def test_a_timeout_is_not_settled(self):
self.repondre(leve=self.vrai.TimeoutExpired("git", 1))
self.assertIsNone(gaps.branches("https://x/y.git"))
def test_a_missing_git_is_not_settled(self):
self.repondre(leve=OSError("git introuvable"))
self.assertIsNone(gaps.branches("https://x/y.git"))
def test_a_repository_with_no_version_branch_is_settled_as_empty(self):
# Là, on a DEMANDÉ et la réponse est « aucune » : c'est un
# verdict, pas une ignorance.
self.repondre("abc\trefs/heads/master\n")
self.assertEqual(set(), gaps.branches("https://x/y.git"))
def test_it_only_reads_never_clones(self):
self.repondre("")
gaps.branches("https://x/y.git")
self.assertEqual(
["git", "ls-remote", "--heads", "https://x/y.git"], self.argv
)
class TestTheReport(Base):
def rapport(self, juges, verifie=True):
return gaps.render(juges, verifie, colour=False)
def test_an_omission_names_the_repository_and_the_step(self):
texte = self.rapport(
[("sale-channel.git", ["17.0"], ["17.0"], "omission")]
)
self.assertIn("sale-channel.git", texte)
self.assertIn("17.0", texte)
def test_an_omission_says_what_to_do(self):
texte = self.rapport([("a.git", ["17.0"], ["17.0"], "omission")])
self.assertIn("git_manifest_odoo", texte)
def test_a_legitimate_hole_is_counted_not_listed(self):
# Seize lignes de « tout va bien » noieraient les dix-neuf qui
# comptent.
texte = self.rapport([("a.git", ["13.0"], [], "legitime")])
self.assertNotIn("a.git", texte)
self.assertIn("1", texte)
def test_offline_says_it_did_not_ask(self):
texte = self.rapport(
[("a.git", ["17.0"], [], "inconnu")], verifie=False
)
self.assertIn(gaps.t("not asked: run with --upstream"), texte)
def test_unreachable_does_not_read_like_offline(self):
texte = self.rapport(
[("a.git", ["17.0"], [], "inconnu")], verifie=True
)
self.assertIn(gaps.t("upstream unreachable"), texte)
def test_nothing_at_all_is_stated(self):
self.assertIn(
gaps.t("Every repository is declared without a hole."),
self.rapport([]),
)
class TestTheCommand(Base):
def lancer(self, argv):
tampon = io.StringIO()
with redirect_stdout(tampon):
code = gaps.main(argv)
return code, tampon.getvalue()
def test_a_missing_directory_is_a_tool_failure(self):
code, _ = self.lancer(
["--manifest-dir", os.path.join(self.dossier, "absent")]
)
self.assertEqual(2, code)
def test_no_hole_exits_zero(self):
self.ecrire("16.0", ["a.git"])
self.ecrire("18.0", ["a.git"])
code, _ = self.lancer(["--manifest-dir", self.dossier, "--no-color"])
self.assertEqual(0, code)
def test_an_unsettled_hole_alone_does_not_exit_one(self):
# Sans --upstream on ne SAIT pas : sortir 1 ferait échouer une
# chaîne d'intégration sur une question qu'on n'a pas posée.
self.ecrire("16.0", ["a.git"])
self.ecrire("17.0", [])
self.ecrire("18.0", ["a.git"])
code, texte = self.lancer(
["--manifest-dir", self.dossier, "--no-color"]
)
self.assertEqual(0, code)
self.assertIn("a.git", texte)
def test_the_json_carries_the_state_of_each_hole(self):
import json
self.ecrire("16.0", ["a.git"])
self.ecrire("17.0", [])
self.ecrire("18.0", ["a.git"])
_code, texte = self.lancer(["--manifest-dir", self.dossier, "--json"])
dct = json.loads(texte)
self.assertFalse(dct["checked_upstream"])
self.assertEqual("a.git", dct["gaps"][0]["repository"])
self.assertEqual("inconnu", dct["gaps"][0]["state"])
class TestAgainstTheRealManifests(unittest.TestCase):
"""Sur le vrai dossier — sans réseau, donc sans trancher."""
def test_the_repository_manifests_parse(self):
lst = gaps.versions()
self.assertGreaterEqual(len(lst), 7)
dct = gaps.declarations()
self.assertGreater(len(dct), 100)
def test_development_is_declared_at_every_step_it_spans(self):
# Les paliers 13 et 15 lui manquaient, et une migration 12 → 18
# les traverse tous les deux.
dct = gaps.declarations()
presentes = set(dct.get("development.git", {}))
for version in (
"12.0",
"13.0",
"14.0",
"15.0",
"16.0",
"17.0",
"18.0",
):
self.assertIn(version, presentes, version)
if __name__ == "__main__":
unittest.main()