erplibre/test/test_analyse_schema_size.py

226 lines
8.5 KiB
Python
Raw Normal View History

#!/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()