#!/usr/bin/env python3 # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Poids du schéma : ce qui se teste sans base. Le classement d'une table (modèle / m2m / système / orpheline) vit dans `collect()`, qui interroge PostgreSQL. Il s'éprouve sur la base synthétique décrite dans le docstring de `test_analyse_lib.py`, augmentée des quatre cas qui comptent : une table m2m sans ligne `ir_model` mais listée dans `ir_model_relation`, un modèle à `_table` surchargé, un modèle abstrait, et une vraie orpheline. Attendu : une seule orpheline, la vraie. Ici, les fonctions pures — mise en forme et rendu. `render()` est une fonction de la donnée vers le texte, donc tout le rapport se teste sur un dictionnaire écrit à la main. """ import unittest from script.analyse import analyse_schema_size as A from script.todo import todo_i18n def fixture(**override): """Un résultat de collect() minimal, que chaque test tord à sa guise.""" data = { "tool": "analyse_schema_size", "version": 1, "database": "prod_18", "odoo_version": "18.0.1.3", "db_bytes": 12884901888, "exact": False, "has_relation_table": True, "n_tables": 3, "n_models": 4, "tables": [ { "table_name": "mail_message", "total_bytes": 3221225472, "table_bytes": 2254857830, "index_bytes": 943718400, "est_rows": 4183221, "exact_rows": None, "model": "mail.message", "origin": "model", }, { "table_name": "res_groups_users_rel", "total_bytes": 16384, "table_bytes": 8192, "index_bytes": 8192, "est_rows": -1, "exact_rows": None, "model": None, "origin": "m2m", }, { "table_name": "old_module_thing", "total_bytes": 431916544, "table_bytes": 400000000, "index_bytes": 31916544, "est_rows": 90211, "exact_rows": None, "model": None, "origin": "orphan", }, ], "orphan_tables": [], "models_without_table": [], "counts": { "orphan_tables": 0, "models_without_table": 0, "m2m_tables": 1, }, } data["orphan_tables"] = [ r for r in data["tables"] if r["origin"] == "orphan" ] data["counts"]["orphan_tables"] = len(data["orphan_tables"]) data.update(override) return data class TestFmtBytes(unittest.TestCase): def test_zero_and_bytes_have_no_decimal(self): self.assertEqual(A.fmt_bytes(0), "0 B") self.assertEqual(A.fmt_bytes(512), "512 B") def test_scales_to_binary_units(self): self.assertEqual(A.fmt_bytes(1024), "1.0 KiB") self.assertEqual(A.fmt_bytes(1024**2), "1.0 MiB") self.assertEqual(A.fmt_bytes(1024**3), "1.0 GiB") self.assertEqual(A.fmt_bytes(1024**4), "1.0 TiB") def test_beyond_tebibyte_stays_in_tebibytes(self): self.assertEqual(A.fmt_bytes(5 * 1024**4), "5.0 TiB") def test_none_is_a_question_mark(self): self.assertEqual(A.fmt_bytes(None), "?") class TestFmtRows(unittest.TestCase): def test_never_analyzed_is_not_zero(self): # PostgreSQL >= 14 met reltuples à -1 quand aucun ANALYZE n'a tourné. # Afficher « 0 » ferait passer une table pleine pour une table vide. self.assertEqual(A.fmt_rows(-1), "?") self.assertEqual(A.fmt_rows(None), "?") def test_zero_stays_zero(self): # Une table réellement vide et analysée doit dire 0, pas « ? ». self.assertEqual(A.fmt_rows(0), "0") def test_groups_thousands_with_spaces(self): self.assertEqual(A.fmt_rows(4183221), "4 183 221") class TestWrapNote(unittest.TestCase): def test_short_text_is_one_line(self): self.assertEqual(A.wrap_note(" ", "court"), [" court"]) def test_continuation_lines_align_under_the_first(self): lines = A.wrap_note(" 💡 ", "mot " * 40) self.assertGreater(len(lines), 1) self.assertTrue(lines[0].startswith(" 💡 ")) for line in lines[1:]: self.assertTrue(line.startswith(" " * len(" 💡 ")), repr(line)) def test_respects_the_width(self): for line in A.wrap_note(" ", "mot " * 60, width=50): self.assertLessEqual(len(line), 50, repr(line)) def test_empty_text_does_not_crash(self): self.assertEqual(A.wrap_note(" ", ""), [" "]) class TestRender(unittest.TestCase): """Le rapport, sur une donnée écrite à la main. `set_lang` est fixé : `_current_lang` est un état de module alimenté par une préférence utilisateur, donc sans cela le test dépendrait de la langue de la machine. """ def setUp(self): # PAS `set_lang()` : il PERSISTE la langue dans ./env_var.sh, un # fichier suivi par git. Un test qui l'appelle modifie l'arbre de # travail et laisse la langue changée pour tout ce qui suit — # `_current_lang = None` ne défait que la mémoïsation, pas le # fichier, et la résolution suivante relit celui-ci. On écrit donc # la mémoïsation directement, et on rend la valeur trouvée. self.addCleanup( setattr, todo_i18n, "_current_lang", todo_i18n._current_lang ) todo_i18n._current_lang = "en" def test_reports_the_orphan_and_not_the_m2m(self): # Le faux positif à éviter : une table m2m n'a aucune ligne ir_model, # et sur une base ordinaire il y en a des centaines. out = A.render(fixture()) self.assertIn("Orphan tables (1)", out) orphan_block = out.split("Orphan tables")[1] self.assertIn("old_module_thing", orphan_block) self.assertNotIn("res_groups_users_rel", orphan_block) def test_no_orphan_says_so_plainly(self): data = fixture(orphan_tables=[]) data["counts"]["orphan_tables"] = 0 out = A.render(data) self.assertIn("Every table belongs to an installed model.", out) self.assertNotIn("Orphan tables", out) def test_models_without_table_is_a_fact_not_a_finding(self): # Les modèles abstraits sont dans ir_model et n'ont pas de table : des # centaines sur une base ordinaire. La ligne doit l'expliquer. data = fixture(models_without_table=[{"model": "mail.thread"}]) data["counts"]["models_without_table"] = 1 out = A.render(data) self.assertIn("Models without table", out) self.assertIn("abstract models have none", out) def test_models_without_table_line_is_absent_when_zero(self): self.assertNotIn("Models without table", A.render(fixture())) def test_warns_when_m2m_cannot_be_told_apart(self): # Sans ir_model_relation, chaque table m2m passerait pour orpheline : # le rapport doit dire qu'il n'est pas fiable, pas se taire. out = A.render(fixture(has_relation_table=False)) self.assertIn("ir_model_relation is absent", out) def test_estimate_warning_only_without_exact(self): self.assertIn("estimates from the last ANALYZE", A.render(fixture())) self.assertNotIn( "estimates from the last ANALYZE", A.render(fixture(exact=True)), ) def test_top_limits_the_table_list(self): out = A.render(fixture(), top=1) self.assertIn("Heaviest tables (1/3)", out) self.assertIn("use -v to list them all", out) def test_verbose_lists_everything(self): out = A.render(fixture(), verbose=True) self.assertIn("All tables, heaviest first", out) self.assertNotIn("use -v to list them all", out) def test_never_analyzed_shows_a_question_mark(self): out = A.render(fixture(), verbose=True) row = [l for l in out.splitlines() if "res_groups_users_rel" in l][0] self.assertTrue(row.rstrip().endswith("?"), repr(row)) def test_unknown_odoo_version_does_not_crash(self): self.assertIn("Odoo ?", A.render(fixture(odoo_version=None))) def test_french_differs_from_english(self): english = A.render(fixture()) todo_i18n._current_lang = "fr" # cf. plus haut : pas de persistance french = A.render(fixture()) self.assertIn("Tables orphelines", french) self.assertNotEqual(english, french) if __name__ == "__main__": unittest.main()