[ADD] analyse: ausculter une base qui n'est pas ici
Les analyses existaient ; le chemin d'AVANT manquait. La base d'un client
est dans un zip, derrière une URL, ou vivante sur un serveur.
« Restant de migration » a dû être écrit : check_migration_quality compare
les bases de PALIER et exige le journal de progression — devant une
sauvegarde isolée, ni l'un ni l'autre n'existe.
Ses compteurs évidents ont été écartés après mesure. Comparés à la base
d'ORIGINE : champs sans colonne 25 → 72, modèles sans table 90 → 158.
Vingt-cinq et quatre-vingt-dix AVANT toute migration : du bruit. Ne
restent que les constats faux en eux-mêmes, 0 avant, non nuls après —
9 langues au drapeau NULL, 68 tables m2m absentes, 414 index doublés.
Le passe-plat RPC n'accepte que la lecture. psql l'obtient du serveur ;
une session RPC n'a rien d'équivalent, et la liste blanche est donc
appliquée dans le passe-plat, pas chez l'appelant.
--- EN ---
The analyses existed; the path BEFORE them did not. A customer database
sits in a zip, behind a URL, or live on a server.
« Migration leftovers » had to be written: check_migration_quality
compares STEP databases and needs the progression log — facing a lone
backup, neither exists.
Its obvious counters were dropped after measuring. Against the ORIGINAL
database: fields with no column 25 → 72, models with no table 90 → 158.
Twenty-five and ninety BEFORE any migration: noise. Only what is wrong in
itself remains, 0 before and non-zero after — 9 languages with a NULL
flag, 68 missing m2m tables, 414 duplicated indexes.
The RPC proxy only reads. psql gets that from the server; an RPC session
has no equivalent, so the allowlist lives in the proxy, not the caller.
Assisted-by: Claude Opus 5
2026-08-24 07:21:37 -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)
|
|
|
|
|
|
|
|
|
|
"""Le monitoring : ce qu'il refuse, et ce qu'il avoue ne pas savoir.
|
|
|
|
|
|
|
|
|
|
Deux propriétés valent d'être épinglées, et ce ne sont pas les plus
|
|
|
|
|
visibles.
|
|
|
|
|
|
|
|
|
|
La première est un refus. Toutes les analyses du dépôt lisent par psql en
|
|
|
|
|
`default_transaction_read_only=on` : c'est le SERVEUR qui interdit
|
|
|
|
|
d'écrire. Une session RPC n'a pas d'équivalent — rien n'empêche un
|
|
|
|
|
`write` sur la production d'un client. La liste blanche est donc appliquée
|
|
|
|
|
dans le passe-plat lui-même, et non chez l'appelant, pour qu'aucune
|
|
|
|
|
analyse à venir ne puisse s'en dispenser par distraction.
|
|
|
|
|
|
|
|
|
|
La seconde est un aveu. Une provenance qui ne permet pas une analyse doit
|
|
|
|
|
le DIRE. Une analyse muette qu'on prend pour rassurante est pire que pas
|
|
|
|
|
d'analyse : c'est la faute que ce dépôt a déjà corrigée trois fois.
|
|
|
|
|
"""
|
|
|
|
|
|
[ADD] qualité de migration : verdicts, sources, revue
Le rapport comparait les paliers sans dire si la migration avait réussi,
alors que les verdicts dorment déjà dans lst_event du journal de
progression : des contrôles en échec y restent sans remonter nulle part.
Trois sections s'ajoutent sous les paliers : les verdicts, rattachés au
palier ODOO et non au compteur du pilote, décalé d'un rang ; où vivent les
traces, car config.conf laisse logfile= vide et la sortie d'Odoo meurt avec
le terminal ; et la revue, six étapes lançables par « r ». Le contrôle de
résidus porte la même section sans toucher son code de sortie : un verdict
vient du fichier, pas de la base.
--- EN ---
The report compared the tiers without saying whether the migration had
succeeded, while the verdicts already sit in lst_event of the progression
file: failed checks stay there and surface nowhere.
Three sections are added below the tiers: the verdicts, tied to the ODOO
tier and not to the driver counter, which is off by one; where the traces
live, since config.conf leaves logfile= empty and Odoo's output dies with
the terminal; and the review, six steps runnable with "r". The residue
check carries the same section without touching its exit code: a verdict
comes from the file, not from the database.
Assisted-by: Claude Opus 5
(cherry picked from commit b05e0333c4b94d58eb794f09a2459e41d826c655)
2026-08-26 07:46:32 -04:00
|
|
|
import io
|
[ADD] analyse: ausculter une base qui n'est pas ici
Les analyses existaient ; le chemin d'AVANT manquait. La base d'un client
est dans un zip, derrière une URL, ou vivante sur un serveur.
« Restant de migration » a dû être écrit : check_migration_quality compare
les bases de PALIER et exige le journal de progression — devant une
sauvegarde isolée, ni l'un ni l'autre n'existe.
Ses compteurs évidents ont été écartés après mesure. Comparés à la base
d'ORIGINE : champs sans colonne 25 → 72, modèles sans table 90 → 158.
Vingt-cinq et quatre-vingt-dix AVANT toute migration : du bruit. Ne
restent que les constats faux en eux-mêmes, 0 avant, non nuls après —
9 langues au drapeau NULL, 68 tables m2m absentes, 414 index doublés.
Le passe-plat RPC n'accepte que la lecture. psql l'obtient du serveur ;
une session RPC n'a rien d'équivalent, et la liste blanche est donc
appliquée dans le passe-plat, pas chez l'appelant.
--- EN ---
The analyses existed; the path BEFORE them did not. A customer database
sits in a zip, behind a URL, or live on a server.
« Migration leftovers » had to be written: check_migration_quality
compares STEP databases and needs the progression log — facing a lone
backup, neither exists.
Its obvious counters were dropped after measuring. Against the ORIGINAL
database: fields with no column 25 → 72, models with no table 90 → 158.
Twenty-five and ninety BEFORE any migration: noise. Only what is wrong in
itself remains, 0 before and non-zero after — 9 languages with a NULL
flag, 68 missing m2m tables, 414 duplicated indexes.
The RPC proxy only reads. psql gets that from the server; an RPC session
has no equivalent, so the allowlist lives in the proxy, not the caller.
Assisted-by: Claude Opus 5
2026-08-24 07:21:37 -04:00
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
import unittest
|
|
|
|
|
|
[REM] résidus : retirer le contrôle res_lang.active à NULL
Le contrôle jugeait cassée une langue dont `active` est NULL : listée
nulle part, plus réactivable. Faux dans la source 18 — un domaine
('active','=',False) compile en (IS NULL OR = FALSE) (models.py:3217),
l'action du menu Langues porte active_test: False, le tri passe par
COALESCE(active, FALSE) et la lecture rend bool(value).
Odoo écrit ce NULL : active = fields.Boolean() sans défaut
(res_lang.py:64) et un res.lang.csv sans la colonne, soit un NULL par
langue ajoutée au catalogue. « Zéro avant, non nul après » ne suffit
donc pas. Un test refuse désormais une clé que plus rien ne définit.
--- EN ---
The check called a language broken when `active` is NULL: listed
nowhere, no longer re-enablable. False in the 18 source — an
('active','=',False) domain compiles to (IS NULL OR = FALSE)
(models.py:3217), the Languages menu action carries active_test: False,
sorting goes through COALESCE(active, FALSE), reading returns bool(value).
Odoo writes that NULL itself: active = fields.Boolean() with no default
(res_lang.py:64) and a res.lang.csv without the column, one NULL per
language added to the catalogue. So "zero before, nonzero after" is not
enough. A test now refuses a key that nothing defines any more.
Assisted-by: Claude Opus 5
(cherry picked from commit ee932334cfde63c1af831316549a432c3d382721)
2026-08-27 02:15:30 -04:00
|
|
|
REPO = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
|
sys.path.insert(0, REPO)
|
[ADD] analyse: ausculter une base qui n'est pas ici
Les analyses existaient ; le chemin d'AVANT manquait. La base d'un client
est dans un zip, derrière une URL, ou vivante sur un serveur.
« Restant de migration » a dû être écrit : check_migration_quality compare
les bases de PALIER et exige le journal de progression — devant une
sauvegarde isolée, ni l'un ni l'autre n'existe.
Ses compteurs évidents ont été écartés après mesure. Comparés à la base
d'ORIGINE : champs sans colonne 25 → 72, modèles sans table 90 → 158.
Vingt-cinq et quatre-vingt-dix AVANT toute migration : du bruit. Ne
restent que les constats faux en eux-mêmes, 0 avant, non nuls après —
9 langues au drapeau NULL, 68 tables m2m absentes, 414 index doublés.
Le passe-plat RPC n'accepte que la lecture. psql l'obtient du serveur ;
une session RPC n'a rien d'équivalent, et la liste blanche est donc
appliquée dans le passe-plat, pas chez l'appelant.
--- EN ---
The analyses existed; the path BEFORE them did not. A customer database
sits in a zip, behind a URL, or live on a server.
« Migration leftovers » had to be written: check_migration_quality
compares STEP databases and needs the progression log — facing a lone
backup, neither exists.
Its obvious counters were dropped after measuring. Against the ORIGINAL
database: fields with no column 25 → 72, models with no table 90 → 158.
Twenty-five and ninety BEFORE any migration: noise. Only what is wrong in
itself remains, 0 before and non-zero after — 9 languages with a NULL
flag, 68 missing m2m tables, 414 duplicated indexes.
The RPC proxy only reads. psql gets that from the server; an RPC session
has no equivalent, so the allowlist lives in the proxy, not the caller.
Assisted-by: Claude Opus 5
2026-08-24 07:21:37 -04:00
|
|
|
|
|
|
|
|
from script.analyse import check_migration_residue as residue # noqa: E402
|
[REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.
--- EN ---
This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.
Assisted-by: Claude Opus 5
2026-09-24 13:30:31 -04:00
|
|
|
from script.analyse import (
|
|
|
|
|
monitoring, # noqa: E402
|
|
|
|
|
monitoring_tui, # noqa: E402
|
|
|
|
|
)
|
[ADD] analyse: ausculter une base qui n'est pas ici
Les analyses existaient ; le chemin d'AVANT manquait. La base d'un client
est dans un zip, derrière une URL, ou vivante sur un serveur.
« Restant de migration » a dû être écrit : check_migration_quality compare
les bases de PALIER et exige le journal de progression — devant une
sauvegarde isolée, ni l'un ni l'autre n'existe.
Ses compteurs évidents ont été écartés après mesure. Comparés à la base
d'ORIGINE : champs sans colonne 25 → 72, modèles sans table 90 → 158.
Vingt-cinq et quatre-vingt-dix AVANT toute migration : du bruit. Ne
restent que les constats faux en eux-mêmes, 0 avant, non nuls après —
9 langues au drapeau NULL, 68 tables m2m absentes, 414 index doublés.
Le passe-plat RPC n'accepte que la lecture. psql l'obtient du serveur ;
une session RPC n'a rien d'équivalent, et la liste blanche est donc
appliquée dans le passe-plat, pas chez l'appelant.
--- EN ---
The analyses existed; the path BEFORE them did not. A customer database
sits in a zip, behind a URL, or live on a server.
« Migration leftovers » had to be written: check_migration_quality
compares STEP databases and needs the progression log — facing a lone
backup, neither exists.
Its obvious counters were dropped after measuring. Against the ORIGINAL
database: fields with no column 25 → 72, models with no table 90 → 158.
Twenty-five and ninety BEFORE any migration: noise. Only what is wrong in
itself remains, 0 before and non-zero after — 9 languages with a NULL
flag, 68 missing m2m tables, 414 duplicated indexes.
The RPC proxy only reads. psql gets that from the server; an RPC session
has no equivalent, so the allowlist lives in the proxy, not the caller.
Assisted-by: Claude Opus 5
2026-08-24 07:21:37 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestWhatTheProxyRefuses(unittest.TestCase):
|
|
|
|
|
"""Le passe-plat RPC n'accepte que la lecture."""
|
|
|
|
|
|
|
|
|
|
def test_a_write_is_refused_before_any_network_call(self):
|
|
|
|
|
# Aucune URL joignable ici : si l'appel partait, le test échouerait
|
|
|
|
|
# par timeout au lieu de PermissionError. C'est le contrôle qu'on
|
|
|
|
|
# veut — le refus vient AVANT le réseau.
|
|
|
|
|
for methode in ("write", "create", "unlink", "execute", "load"):
|
|
|
|
|
with self.assertRaises(PermissionError):
|
|
|
|
|
monitoring.live_call(
|
|
|
|
|
"http://127.0.0.1:1", "db", 1, "x", "res.partner", methode
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_the_allowlist_holds_nothing_that_writes(self):
|
|
|
|
|
interdits = ("write", "create", "unlink", "copy", "load", "execute")
|
|
|
|
|
for methode in monitoring.RPC_READ_ONLY:
|
|
|
|
|
self.assertNotIn(methode, interdits)
|
|
|
|
|
|
|
|
|
|
def test_the_refusal_names_the_method(self):
|
|
|
|
|
with self.assertRaises(PermissionError) as capture:
|
|
|
|
|
monitoring.live_call(
|
|
|
|
|
"http://127.0.0.1:1", "db", 1, "x", "res.partner", "unlink"
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("unlink", str(capture.exception))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestWhatASourceAdmits(unittest.TestCase):
|
|
|
|
|
"""Ce qu'une provenance ne permet pas doit être dit, pas caché."""
|
|
|
|
|
|
|
|
|
|
def test_every_analysis_says_why_it_cannot_do_live(self):
|
|
|
|
|
for analyse in monitoring.ANALYSES:
|
[ADD] analyse: anonymiser une copie, sans IA et sans rien casser
Des mots pris dans une liste, des nombres tirés entre 0 et 1000, écrits
en SQL. Aucun modèle, aucun réseau — un test le vérifie sur les imports.
Le difficile n'est pas de remplacer, c'est de savoir ce qu'on n'a PAS le
droit de toucher. Mesuré sur une base 18 réelle : 505 champs `selection`
sont stockés en varchar, 2693 many2one sont des entiers, 194 textes sont
des jsonb par langue, 301 contraintes d'unicité attendent une collision.
« Tous les champs string » n'existe pas ; on croise ir_model_fields,
pg_attribute et pg_constraint, et aucune des trois ne suffit seule.
Trois pièges ont été trouvés en LANÇANT l'outil, pas en le relisant :
PostgreSQL refuse d'indexer un ARRAY[...] sans parenthèses,
res_partner.credit_limit est un jsonb qu'Odoo appelle float, et
crm_lead.probability porte un CHECK qui interdit 1000. Chaque fois
l'écriture a échoué et la base est restée intacte : une seule
transaction, tout ou rien.
Preuve sur copie jetable : empreinte du schéma identique, 848 tables,
6495 contraintes, arch_db et xmlid intacts, lang et many2one inchangés —
seules les colonnes visées ont changé.
--- EN ---
Words from a list, numbers drawn between 0 and 1000, written in SQL. No
model, no network — a test checks that on the imports.
The hard part is not replacing, it is knowing what must NOT be touched.
Measured on a real 18 database: 505 `selection` fields are stored as
varchar, 2693 many2one are integers, 194 texts are per-language jsonb,
301 unique constraints await a collision. "All string fields" does not
exist; we cross ir_model_fields, pg_attribute and pg_constraint, and none
of the three is enough alone.
Three traps were found by RUNNING it, not by rereading it: PostgreSQL
refuses to subscript a bare ARRAY[...], res_partner.credit_limit is a
jsonb Odoo calls float, and crm_lead.probability has a CHECK forbidding
1000. Each time the write failed and the database stayed intact: one
transaction, all or nothing.
Proof on a throwaway copy: identical schema fingerprint, 848 tables, 6495
constraints, arch_db and xmlids intact, lang and many2one unchanged —
only the targeted columns changed.
Assisted-by: Claude Opus 5
2026-08-25 00:43:50 -04:00
|
|
|
self.assertTrue(analyse["why_not"].strip(), analyse["key"])
|
[ADD] analyse: ausculter une base qui n'est pas ici
Les analyses existaient ; le chemin d'AVANT manquait. La base d'un client
est dans un zip, derrière une URL, ou vivante sur un serveur.
« Restant de migration » a dû être écrit : check_migration_quality compare
les bases de PALIER et exige le journal de progression — devant une
sauvegarde isolée, ni l'un ni l'autre n'existe.
Ses compteurs évidents ont été écartés après mesure. Comparés à la base
d'ORIGINE : champs sans colonne 25 → 72, modèles sans table 90 → 158.
Vingt-cinq et quatre-vingt-dix AVANT toute migration : du bruit. Ne
restent que les constats faux en eux-mêmes, 0 avant, non nuls après —
9 langues au drapeau NULL, 68 tables m2m absentes, 414 index doublés.
Le passe-plat RPC n'accepte que la lecture. psql l'obtient du serveur ;
une session RPC n'a rien d'équivalent, et la liste blanche est donc
appliquée dans le passe-plat, pas chez l'appelant.
--- EN ---
The analyses existed; the path BEFORE them did not. A customer database
sits in a zip, behind a URL, or live on a server.
« Migration leftovers » had to be written: check_migration_quality
compares STEP databases and needs the progression log — facing a lone
backup, neither exists.
Its obvious counters were dropped after measuring. Against the ORIGINAL
database: fields with no column 25 → 72, models with no table 90 → 158.
Twenty-five and ninety BEFORE any migration: noise. Only what is wrong in
itself remains, 0 before and non-zero after — 9 languages with a NULL
flag, 68 missing m2m tables, 414 duplicated indexes.
The RPC proxy only reads. psql gets that from the server; an RPC session
has no equivalent, so the allowlist lives in the proxy, not the caller.
Assisted-by: Claude Opus 5
2026-08-24 07:21:37 -04:00
|
|
|
|
|
|
|
|
def test_available_and_unavailable_cover_every_analysis(self):
|
|
|
|
|
for genre in (monitoring.KIND_DATABASE, monitoring.KIND_LIVE):
|
|
|
|
|
total = len(monitoring.available(genre)) + len(
|
|
|
|
|
monitoring.unavailable(genre)
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(total, len(monitoring.ANALYSES))
|
|
|
|
|
|
|
|
|
|
def test_a_restored_backup_and_a_database_are_the_same_thing(self):
|
|
|
|
|
"""Passé la restauration, la provenance ne se distingue plus."""
|
|
|
|
|
self.assertTrue(monitoring.available(monitoring.KIND_DATABASE))
|
|
|
|
|
|
|
|
|
|
def test_an_unknown_key_gives_none_rather_than_raising(self):
|
|
|
|
|
self.assertIsNone(monitoring.analysis_by_key("pas_une_analyse"))
|
|
|
|
|
|
|
|
|
|
def test_the_command_names_the_database(self):
|
|
|
|
|
analyse = monitoring.ANALYSES[0]
|
|
|
|
|
cmd = monitoring.command_for(analyse, "ma_base")
|
|
|
|
|
self.assertIn("-d", cmd)
|
|
|
|
|
self.assertEqual(cmd[cmd.index("-d") + 1], "ma_base")
|
|
|
|
|
self.assertIn(analyse["script"], cmd)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestWhetherTheNeutralisationTook(unittest.TestCase):
|
|
|
|
|
"""Poser la question et ne pas vérifier, c'est l'illusion mesurée."""
|
|
|
|
|
|
|
|
|
|
def test_a_missing_flag_is_reported_as_not_neutralised(self):
|
|
|
|
|
rapport = monitoring.neutralize_report(
|
|
|
|
|
{"flag": 0, "cron_active": 35, "mail_server": 0, "payment_live": 1}
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("❌", rapport)
|
|
|
|
|
self.assertIn("35", rapport)
|
|
|
|
|
|
|
|
|
|
def test_no_mail_server_is_a_warning_and_never_a_reassurance(self):
|
|
|
|
|
"""Zéro serveur NE prouve PAS la sûreté : Odoo retombe sur la conf.
|
|
|
|
|
|
|
|
|
|
C'est pour cela que le `neutralize.sql` d'Odoo INSÈRE un serveur
|
|
|
|
|
bouchon au lieu de tout supprimer. Compter zéro et conclure « sûr »
|
|
|
|
|
se tromperait dans le mauvais sens.
|
|
|
|
|
"""
|
|
|
|
|
rapport = monitoring.neutralize_report(
|
|
|
|
|
{"flag": 1, "cron_active": 0, "mail_server": 0, "payment_live": 0}
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("⚠", rapport)
|
|
|
|
|
self.assertIn("smtp_server", rapport)
|
|
|
|
|
|
|
|
|
|
def test_a_table_that_does_not_exist_is_not_counted_as_zero(self):
|
|
|
|
|
"""Absent n'est pas nul, et surtout pas une bonne nouvelle."""
|
|
|
|
|
appels = []
|
|
|
|
|
|
|
|
|
|
class FauxLib:
|
|
|
|
|
@staticmethod
|
|
|
|
|
def run_psql(database, sql, config_path=None):
|
|
|
|
|
appels.append(sql)
|
|
|
|
|
raise RuntimeError("relation does not exist")
|
|
|
|
|
|
|
|
|
|
# `from script.analyse import lib_analyse` lit l'ATTRIBUT du paquet :
|
|
|
|
|
# remplacer l'entrée de sys.modules ne change rien, et le vrai psql
|
|
|
|
|
# tourne. Le compteur d'appels est là pour le prouver — sans lui, ce
|
|
|
|
|
# test passait au vert avec ZÉRO appel bouchonné.
|
|
|
|
|
import script.analyse as paquet
|
|
|
|
|
|
|
|
|
|
sauvegarde = paquet.lib_analyse
|
|
|
|
|
paquet.lib_analyse = FauxLib
|
|
|
|
|
try:
|
|
|
|
|
etat = monitoring.neutralize_state("peu_importe")
|
|
|
|
|
finally:
|
|
|
|
|
paquet.lib_analyse = sauvegarde
|
|
|
|
|
for valeur in etat.values():
|
|
|
|
|
self.assertIsNone(valeur)
|
|
|
|
|
self.assertEqual(len(appels), len(monitoring.NEUTRALIZE_SQL))
|
|
|
|
|
|
|
|
|
|
def test_a_live_payment_provider_is_an_error_not_a_warning(self):
|
|
|
|
|
rapport = monitoring.neutralize_report(
|
|
|
|
|
{"flag": 1, "cron_active": 0, "mail_server": 1, "payment_live": 2}
|
|
|
|
|
)
|
|
|
|
|
ligne = [
|
|
|
|
|
texte
|
|
|
|
|
for texte in rapport.splitlines()
|
|
|
|
|
if "payment" in texte or "paiement" in texte
|
|
|
|
|
]
|
|
|
|
|
self.assertTrue(ligne)
|
|
|
|
|
self.assertTrue(ligne[0].startswith("❌"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheChooserScreen(unittest.TestCase):
|
|
|
|
|
"""L'écran ne fait que choisir — mais il doit choisir juste."""
|
|
|
|
|
|
|
|
|
|
def test_every_analysis_is_shown_even_when_it_cannot_run(self):
|
|
|
|
|
lignes = monitoring_tui.rows(monitoring.KIND_LIVE)
|
|
|
|
|
self.assertEqual(len(lignes), len(monitoring.ANALYSES))
|
|
|
|
|
|
|
|
|
|
def test_an_unusable_line_carries_its_reason(self):
|
|
|
|
|
for _key, _label, utilisable, raison in monitoring_tui.rows(
|
|
|
|
|
monitoring.KIND_LIVE
|
|
|
|
|
):
|
|
|
|
|
if not utilisable:
|
|
|
|
|
self.assertTrue(raison.strip())
|
|
|
|
|
|
|
|
|
|
def test_a_usable_line_carries_no_excuse(self):
|
|
|
|
|
for _key, _label, utilisable, raison in monitoring_tui.rows(
|
|
|
|
|
monitoring.KIND_DATABASE
|
|
|
|
|
):
|
|
|
|
|
if utilisable:
|
|
|
|
|
self.assertEqual(raison, "")
|
|
|
|
|
|
|
|
|
|
def test_the_detail_pane_says_when_a_source_cannot_serve(self):
|
|
|
|
|
texte = monitoring_tui.detail(
|
|
|
|
|
monitoring.ANALYSES[0]["key"], monitoring.KIND_LIVE
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("✖", texte)
|
|
|
|
|
|
|
|
|
|
def test_the_detail_pane_of_an_unknown_key_is_empty(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
monitoring_tui.detail("pas_une_analyse", monitoring.KIND_DATABASE),
|
|
|
|
|
"",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestWhatAMigrationLeftBehind(unittest.TestCase):
|
|
|
|
|
"""Le classement, et surtout ce qu'il refuse de taire."""
|
|
|
|
|
|
|
|
|
|
def test_broken_is_read_before_watch(self):
|
|
|
|
|
resultats = {c["key"]: 0 for c in residue.CONTROLES}
|
|
|
|
|
resultats["duplicate_index"] = 400 # watch
|
[REM] résidus : retirer le contrôle res_lang.active à NULL
Le contrôle jugeait cassée une langue dont `active` est NULL : listée
nulle part, plus réactivable. Faux dans la source 18 — un domaine
('active','=',False) compile en (IS NULL OR = FALSE) (models.py:3217),
l'action du menu Langues porte active_test: False, le tri passe par
COALESCE(active, FALSE) et la lecture rend bool(value).
Odoo écrit ce NULL : active = fields.Boolean() sans défaut
(res_lang.py:64) et un res.lang.csv sans la colonne, soit un NULL par
langue ajoutée au catalogue. « Zéro avant, non nul après » ne suffit
donc pas. Un test refuse désormais une clé que plus rien ne définit.
--- EN ---
The check called a language broken when `active` is NULL: listed
nowhere, no longer re-enablable. False in the 18 source — an
('active','=',False) domain compiles to (IS NULL OR = FALSE)
(models.py:3217), the Languages menu action carries active_test: False,
sorting goes through COALESCE(active, FALSE), reading returns bool(value).
Odoo writes that NULL itself: active = fields.Boolean() with no default
(res_lang.py:64) and a res.lang.csv without the column, one NULL per
language added to the catalogue. So "zero before, nonzero after" is not
enough. A test now refuses a key that nothing defines any more.
Assisted-by: Claude Opus 5
(cherry picked from commit ee932334cfde63c1af831316549a432c3d382721)
2026-08-27 02:15:30 -04:00
|
|
|
resultats["stuck_modules"] = 1 # broken
|
[ADD] analyse: ausculter une base qui n'est pas ici
Les analyses existaient ; le chemin d'AVANT manquait. La base d'un client
est dans un zip, derrière une URL, ou vivante sur un serveur.
« Restant de migration » a dû être écrit : check_migration_quality compare
les bases de PALIER et exige le journal de progression — devant une
sauvegarde isolée, ni l'un ni l'autre n'existe.
Ses compteurs évidents ont été écartés après mesure. Comparés à la base
d'ORIGINE : champs sans colonne 25 → 72, modèles sans table 90 → 158.
Vingt-cinq et quatre-vingt-dix AVANT toute migration : du bruit. Ne
restent que les constats faux en eux-mêmes, 0 avant, non nuls après —
9 langues au drapeau NULL, 68 tables m2m absentes, 414 index doublés.
Le passe-plat RPC n'accepte que la lecture. psql l'obtient du serveur ;
une session RPC n'a rien d'équivalent, et la liste blanche est donc
appliquée dans le passe-plat, pas chez l'appelant.
--- EN ---
The analyses existed; the path BEFORE them did not. A customer database
sits in a zip, behind a URL, or live on a server.
« Migration leftovers » had to be written: check_migration_quality
compares STEP databases and needs the progression log — facing a lone
backup, neither exists.
Its obvious counters were dropped after measuring. Against the ORIGINAL
database: fields with no column 25 → 72, models with no table 90 → 158.
Twenty-five and ninety BEFORE any migration: noise. Only what is wrong in
itself remains, 0 before and non-zero after — 9 languages with a NULL
flag, 68 missing m2m tables, 414 duplicated indexes.
The RPC proxy only reads. psql gets that from the server; an RPC session
has no equivalent, so the allowlist lives in the proxy, not the caller.
Assisted-by: Claude Opus 5
2026-08-24 07:21:37 -04:00
|
|
|
trouve, _ = residue.judge(resultats)
|
[REM] résidus : retirer le contrôle res_lang.active à NULL
Le contrôle jugeait cassée une langue dont `active` est NULL : listée
nulle part, plus réactivable. Faux dans la source 18 — un domaine
('active','=',False) compile en (IS NULL OR = FALSE) (models.py:3217),
l'action du menu Langues porte active_test: False, le tri passe par
COALESCE(active, FALSE) et la lecture rend bool(value).
Odoo écrit ce NULL : active = fields.Boolean() sans défaut
(res_lang.py:64) et un res.lang.csv sans la colonne, soit un NULL par
langue ajoutée au catalogue. « Zéro avant, non nul après » ne suffit
donc pas. Un test refuse désormais une clé que plus rien ne définit.
--- EN ---
The check called a language broken when `active` is NULL: listed
nowhere, no longer re-enablable. False in the 18 source — an
('active','=',False) domain compiles to (IS NULL OR = FALSE)
(models.py:3217), the Languages menu action carries active_test: False,
sorting goes through COALESCE(active, FALSE), reading returns bool(value).
Odoo writes that NULL itself: active = fields.Boolean() with no default
(res_lang.py:64) and a res.lang.csv without the column, one NULL per
language added to the catalogue. So "zero before, nonzero after" is not
enough. A test now refuses a key that nothing defines any more.
Assisted-by: Claude Opus 5
(cherry picked from commit ee932334cfde63c1af831316549a432c3d382721)
2026-08-27 02:15:30 -04:00
|
|
|
self.assertEqual(trouve[0][0]["key"], "stuck_modules")
|
[ADD] analyse: ausculter une base qui n'est pas ici
Les analyses existaient ; le chemin d'AVANT manquait. La base d'un client
est dans un zip, derrière une URL, ou vivante sur un serveur.
« Restant de migration » a dû être écrit : check_migration_quality compare
les bases de PALIER et exige le journal de progression — devant une
sauvegarde isolée, ni l'un ni l'autre n'existe.
Ses compteurs évidents ont été écartés après mesure. Comparés à la base
d'ORIGINE : champs sans colonne 25 → 72, modèles sans table 90 → 158.
Vingt-cinq et quatre-vingt-dix AVANT toute migration : du bruit. Ne
restent que les constats faux en eux-mêmes, 0 avant, non nuls après —
9 langues au drapeau NULL, 68 tables m2m absentes, 414 index doublés.
Le passe-plat RPC n'accepte que la lecture. psql l'obtient du serveur ;
une session RPC n'a rien d'équivalent, et la liste blanche est donc
appliquée dans le passe-plat, pas chez l'appelant.
--- EN ---
The analyses existed; the path BEFORE them did not. A customer database
sits in a zip, behind a URL, or live on a server.
« Migration leftovers » had to be written: check_migration_quality
compares STEP databases and needs the progression log — facing a lone
backup, neither exists.
Its obvious counters were dropped after measuring. Against the ORIGINAL
database: fields with no column 25 → 72, models with no table 90 → 158.
Twenty-five and ninety BEFORE any migration: noise. Only what is wrong in
itself remains, 0 before and non-zero after — 9 languages with a NULL
flag, 68 missing m2m tables, 414 duplicated indexes.
The RPC proxy only reads. psql gets that from the server; an RPC session
has no equivalent, so the allowlist lives in the proxy, not the caller.
Assisted-by: Claude Opus 5
2026-08-24 07:21:37 -04:00
|
|
|
|
|
|
|
|
def test_a_check_that_could_not_run_is_not_a_check_that_found_nothing(
|
|
|
|
|
self,
|
|
|
|
|
):
|
|
|
|
|
resultats = {c["key"]: 0 for c in residue.CONTROLES}
|
|
|
|
|
resultats["missing_pricelist"] = {"error": "relation absente"}
|
|
|
|
|
trouve, illisibles = residue.judge(resultats)
|
|
|
|
|
self.assertEqual(trouve, [])
|
|
|
|
|
self.assertEqual(len(illisibles), 1)
|
|
|
|
|
|
|
|
|
|
def test_the_report_shows_the_unreadable_ones(self):
|
|
|
|
|
resultats = {c["key"]: 0 for c in residue.CONTROLES}
|
|
|
|
|
resultats["missing_pricelist"] = {"error": "relation absente"}
|
|
|
|
|
texte = residue.render("base", resultats, colour=False)
|
|
|
|
|
self.assertIn("❔", texte)
|
|
|
|
|
self.assertIn("relation absente", texte)
|
|
|
|
|
|
|
|
|
|
def test_a_clean_database_states_what_it_could_not_see(self):
|
|
|
|
|
"""Un vert qui ne dit pas sa portée se lit comme une garantie."""
|
|
|
|
|
resultats = {c["key"]: 0 for c in residue.CONTROLES}
|
|
|
|
|
texte = residue.render("base", resultats, colour=False)
|
|
|
|
|
self.assertIn("✅", texte)
|
|
|
|
|
self.assertIn(
|
|
|
|
|
residue.t("This reads one database on its own — it cannot see"),
|
|
|
|
|
texte,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_every_finding_names_a_repair_or_says_there_is_none(self):
|
|
|
|
|
resultats = {c["key"]: 1 for c in residue.CONTROLES}
|
|
|
|
|
texte = residue.render("base", resultats, colour=False)
|
|
|
|
|
for controle in residue.CONTROLES:
|
|
|
|
|
attendu = controle["repair"] or residue.t("no repair tool yet")
|
|
|
|
|
self.assertIn(attendu, texte)
|
|
|
|
|
|
|
|
|
|
def test_each_check_is_declared_completely(self):
|
|
|
|
|
cles = set()
|
|
|
|
|
for controle in residue.CONTROLES:
|
|
|
|
|
for champ in ("key", "title", "why", "sql", "gravity"):
|
|
|
|
|
self.assertTrue(controle.get(champ), controle["key"])
|
|
|
|
|
self.assertIn(controle["gravity"], ("broken", "watch"))
|
|
|
|
|
self.assertNotIn(controle["key"], cles)
|
|
|
|
|
cles.add(controle["key"])
|
|
|
|
|
|
|
|
|
|
def test_each_query_reads_and_returns_one_number(self):
|
|
|
|
|
"""Une seule colonne, un seul SELECT : `inspect` en fait un int."""
|
|
|
|
|
for controle in residue.CONTROLES:
|
|
|
|
|
sql = " ".join(controle["sql"].split())
|
|
|
|
|
self.assertTrue(sql.upper().startswith("SELECT"), controle["key"])
|
|
|
|
|
for interdit in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER"):
|
|
|
|
|
self.assertNotIn(f" {interdit} ", f" {sql.upper()} ")
|
|
|
|
|
|
|
|
|
|
def test_the_exit_code_separates_nothing_from_something(self):
|
|
|
|
|
vide = {c["key"]: 0 for c in residue.CONTROLES}
|
|
|
|
|
self.assertEqual(residue.judge(vide)[0], [])
|
[REM] résidus : retirer le contrôle res_lang.active à NULL
Le contrôle jugeait cassée une langue dont `active` est NULL : listée
nulle part, plus réactivable. Faux dans la source 18 — un domaine
('active','=',False) compile en (IS NULL OR = FALSE) (models.py:3217),
l'action du menu Langues porte active_test: False, le tri passe par
COALESCE(active, FALSE) et la lecture rend bool(value).
Odoo écrit ce NULL : active = fields.Boolean() sans défaut
(res_lang.py:64) et un res.lang.csv sans la colonne, soit un NULL par
langue ajoutée au catalogue. « Zéro avant, non nul après » ne suffit
donc pas. Un test refuse désormais une clé que plus rien ne définit.
--- EN ---
The check called a language broken when `active` is NULL: listed
nowhere, no longer re-enablable. False in the 18 source — an
('active','=',False) domain compiles to (IS NULL OR = FALSE)
(models.py:3217), the Languages menu action carries active_test: False,
sorting goes through COALESCE(active, FALSE), reading returns bool(value).
Odoo writes that NULL itself: active = fields.Boolean() with no default
(res_lang.py:64) and a res.lang.csv without the column, one NULL per
language added to the catalogue. So "zero before, nonzero after" is not
enough. A test now refuses a key that nothing defines any more.
Assisted-by: Claude Opus 5
(cherry picked from commit ee932334cfde63c1af831316549a432c3d382721)
2026-08-27 02:15:30 -04:00
|
|
|
plein = dict(vide, stuck_modules=3)
|
[ADD] analyse: ausculter une base qui n'est pas ici
Les analyses existaient ; le chemin d'AVANT manquait. La base d'un client
est dans un zip, derrière une URL, ou vivante sur un serveur.
« Restant de migration » a dû être écrit : check_migration_quality compare
les bases de PALIER et exige le journal de progression — devant une
sauvegarde isolée, ni l'un ni l'autre n'existe.
Ses compteurs évidents ont été écartés après mesure. Comparés à la base
d'ORIGINE : champs sans colonne 25 → 72, modèles sans table 90 → 158.
Vingt-cinq et quatre-vingt-dix AVANT toute migration : du bruit. Ne
restent que les constats faux en eux-mêmes, 0 avant, non nuls après —
9 langues au drapeau NULL, 68 tables m2m absentes, 414 index doublés.
Le passe-plat RPC n'accepte que la lecture. psql l'obtient du serveur ;
une session RPC n'a rien d'équivalent, et la liste blanche est donc
appliquée dans le passe-plat, pas chez l'appelant.
--- EN ---
The analyses existed; the path BEFORE them did not. A customer database
sits in a zip, behind a URL, or live on a server.
« Migration leftovers » had to be written: check_migration_quality
compares STEP databases and needs the progression log — facing a lone
backup, neither exists.
Its obvious counters were dropped after measuring. Against the ORIGINAL
database: fields with no column 25 → 72, models with no table 90 → 158.
Twenty-five and ninety BEFORE any migration: noise. Only what is wrong in
itself remains, 0 before and non-zero after — 9 languages with a NULL
flag, 68 missing m2m tables, 414 duplicated indexes.
The RPC proxy only reads. psql gets that from the server; an RPC session
has no equivalent, so the allowlist lives in the proxy, not the caller.
Assisted-by: Claude Opus 5
2026-08-24 07:21:37 -04:00
|
|
|
self.assertTrue(residue.judge(plein)[0])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheScreenAnswersKeys(unittest.TestCase):
|
|
|
|
|
"""Presser les touches, et pas seulement lire les fonctions pures.
|
|
|
|
|
|
|
|
|
|
`ListView` consomme Entrée pour émettre son propre `Selected` : la
|
|
|
|
|
liaison de l'application ne la voit jamais. L'écran ne répondait donc
|
|
|
|
|
pas à Entrée, et `rows()` comme `detail()` restaient parfaitement
|
|
|
|
|
justes — aucun test de fonction pure ne pouvait le montrer.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
try:
|
|
|
|
|
import textual # noqa: F401
|
|
|
|
|
except ImportError:
|
|
|
|
|
self.skipTest("Textual absent de cet interpréteur")
|
|
|
|
|
|
|
|
|
|
def _presser(self, kind, touches):
|
|
|
|
|
import asyncio
|
|
|
|
|
|
|
|
|
|
async def jouer():
|
|
|
|
|
app = monitoring_tui.build_app(kind, "ma_base")
|
|
|
|
|
async with app.run_test() as pilot:
|
|
|
|
|
for touche in touches:
|
|
|
|
|
await pilot.press(touche)
|
|
|
|
|
await pilot.pause()
|
|
|
|
|
return app.return_value
|
|
|
|
|
|
|
|
|
|
return asyncio.run(jouer())
|
|
|
|
|
|
|
|
|
|
def test_enter_chooses_the_highlighted_analysis(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self._presser(monitoring.KIND_DATABASE, ["enter"]),
|
|
|
|
|
monitoring.ANALYSES[0]["key"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_moving_down_then_enter_chooses_the_second(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self._presser(monitoring.KIND_DATABASE, ["down", "enter"]),
|
|
|
|
|
monitoring.ANALYSES[1]["key"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_enter_on_an_unusable_line_chooses_nothing(self):
|
|
|
|
|
"""Et ne lève pas : un écran qui plante vaut moins qu'un refus."""
|
|
|
|
|
self.assertIsNone(self._presser(monitoring.KIND_LIVE, ["enter"]))
|
|
|
|
|
|
|
|
|
|
def test_q_gives_up(self):
|
|
|
|
|
self.assertIsNone(self._presser(monitoring.KIND_DATABASE, ["q"]))
|
[ADD] analyse: l'état d'une instance, lu pour l'usage qu'on en fait
Le même chiffre veut dire deux choses opposées. Zéro cron actif est le
succès attendu d'une copie et une panne totale sur une production. Un
rapport qui ignore cela crie au loup sur ce qu'on vient de demander, et
l'on cesse de le lire. L'attente est donc déclarée, copy ou live, et
chaque contrôle dit ce qu'il juge sous l'une et sous l'autre.
Deux contrôles ont été ÉCARTÉS sous copy après mesure : sur la base 12
d'origine, jamais démarrée, 11 crons étaient déjà en retard et db_backup
déjà vide — notre propre update_prod_to_dev les efface. Les afficher en
rouge aurait été du bruit ; en vert, un mensonge. Ils sont montrés non
jugés, avec la raison.
Le code Python en base a été mesuré et abandonné : 132 actions serveur,
zéro citant un modèle inexistant, et les 3 « modèles sans table » sont
ir.autovacuum et deux autres modèles abstraits d'Odoo.
--- EN ---
The same number means two opposite things. Zero active cron is the
expected success of a copy and a total outage on production. A report
that ignores this cries wolf over what was just requested, and stops
being read. The expectation is therefore declared, copy or live, and each
check states what it judges under either.
Two checks were DROPPED under copy after measuring: on the untouched 12
source database, 11 crons were already late and db_backup already empty —
our own update_prod_to_dev deletes them. Red would have been noise; green
a lie. They are shown unjudged, with the reason.
In-database Python was measured and dropped: 132 server actions, none
naming a missing model, and the 3 "models without a table" are
ir.autovacuum and two other Odoo abstract models.
Assisted-by: Claude Opus 5
2026-08-24 14:39:09 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheSourceMenuIsWrittenTwice(unittest.TestCase):
|
|
|
|
|
"""Les numéros affichés mènent-ils où ils le disent ?
|
|
|
|
|
|
|
|
|
|
`_monitoring_select_source` imprime « [2] Une sauvegarde .zip » d'un
|
|
|
|
|
côté et teste `answer == "2"` de l'autre. Rien ne relie les deux :
|
|
|
|
|
insérer « une base locale » en tête décale tout le reste à la main.
|
|
|
|
|
C'est le piège exact que `MenuCoherence` garde pour les autres menus,
|
|
|
|
|
et celui-ci n'entre pas dans son moule — il s'imprime, il ne se
|
|
|
|
|
déclare pas.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
import re
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
todo = (
|
|
|
|
|
Path(__file__).resolve().parent.parent
|
|
|
|
|
/ "script"
|
|
|
|
|
/ "todo"
|
|
|
|
|
/ "todo.py"
|
|
|
|
|
)
|
|
|
|
|
source = todo.read_text(encoding="utf-8")
|
|
|
|
|
debut = source.index("def _monitoring_select_source(self):")
|
|
|
|
|
fin = source.index("def _monitoring_live(self):", debut)
|
|
|
|
|
self.corps = source[debut:fin]
|
|
|
|
|
self.affiches = re.findall(r'print\(f"\[(\d+)\] \{t\(', self.corps)
|
|
|
|
|
self.branches = re.findall(r'if answer == "(\d+)":', self.corps)
|
|
|
|
|
|
|
|
|
|
def test_the_menu_was_actually_parsed(self):
|
|
|
|
|
"""Sur des listes vides, tout passe : mieux vaut tomber ici."""
|
|
|
|
|
self.assertGreaterEqual(len(self.affiches), 4)
|
|
|
|
|
|
|
|
|
|
def test_every_shown_entry_has_a_branch(self):
|
|
|
|
|
montres = [n for n in self.affiches if n != "0"]
|
|
|
|
|
self.assertEqual(sorted(montres), sorted(self.branches))
|
|
|
|
|
|
|
|
|
|
def test_the_numbering_is_contiguous_from_one(self):
|
|
|
|
|
montres = sorted(int(n) for n in self.affiches if n != "0")
|
|
|
|
|
self.assertEqual(montres, list(range(1, len(montres) + 1)))
|
|
|
|
|
|
|
|
|
|
def test_a_local_database_is_offered_first(self):
|
|
|
|
|
"""La provenance la plus directe, et l'ordre du menu d'à côté."""
|
|
|
|
|
self.assertEqual(self.affiches[0], "1")
|
|
|
|
|
self.assertIn("A local database", self.corps.split("if answer")[0])
|
[FIX] liste de prix : décider sur la fonctionnalité, pas sur l'exécutant
`env.user.has_group()` répond « oui » dès que l'exécutant est membre du
groupe, et la migration l'y ajoute en cours de route. Or la case des
réglages lit tout autre chose : ce que `base.group_user` IMPLIQUE
(res_config.py, « which groups are implied by the group Employee »).
Décider sur l'exécutant créait une liste de prix dans une base dont la
fonctionnalité est éteinte, et Odoo prévenait à chaque ouverture des
réglages qu'il allait l'archiver. Le contrôle « restant de migration »
posait la même mauvaise question ; les deux lisent désormais l'implication
du groupe. Vérifié sur copie jetable, dans les deux sens : fonctionnalité
éteinte, rien n'est signalé ; activée, constat et réparation reviennent.
--- EN ---
`env.user.has_group()` says yes as soon as the caller belongs to the
group, and the migration adds it along the way. But the settings checkbox
reads something else: what `base.group_user` IMPLIES (res_config.py,
"which groups are implied by the group Employee"). Deciding on the caller
created a pricelist in a database whose feature is off, and Odoo warned on
every opening of the settings that it would archive it. The
migration-residue check asked the same wrong question; both now read the
group implication. Verified on a throwaway copy, both ways: feature off,
nothing is reported; feature on, finding and repair come back.
Assisted-by: Claude Opus 5
(cherry picked from commit 38ba25e01894f45fb956bcbb08cc3d96a125e648)
2026-08-26 03:58:45 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAMissingPricelistOnlyCountsWhenTheFeatureIsOn(unittest.TestCase):
|
|
|
|
|
"""Une liste de prix absente n'est un défaut que si on l'a demandée.
|
|
|
|
|
|
|
|
|
|
Mesuré : six utilisateurs étaient membres DIRECTS de
|
|
|
|
|
`product.group_product_pricelist` — hérité d'un palier de migration —
|
|
|
|
|
alors que `base.group_user` ne l'impliquait pas. La case des réglages
|
|
|
|
|
était donc décochée, et la réparation créait quand même une liste.
|
|
|
|
|
Odoo prévenait ensuite à chaque ouverture des réglages qu'il allait
|
|
|
|
|
l'archiver.
|
|
|
|
|
|
|
|
|
|
`res.config.settings` lit ce que `base.group_user` IMPLIQUE ; le
|
|
|
|
|
contrôle pose désormais la même question.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _controle(self):
|
|
|
|
|
for controle in residue.CONTROLES:
|
|
|
|
|
if controle["key"] == "missing_pricelist":
|
|
|
|
|
return controle
|
|
|
|
|
raise AssertionError("contrôle introuvable")
|
|
|
|
|
|
|
|
|
|
def test_it_asks_whether_the_feature_is_implied(self):
|
|
|
|
|
sql = " ".join(self._controle()["sql"].split())
|
|
|
|
|
self.assertIn("res_groups_implied_rel", sql)
|
|
|
|
|
self.assertIn("group_product_pricelist", sql)
|
|
|
|
|
self.assertIn("group_user", sql)
|
|
|
|
|
|
|
|
|
|
def test_it_does_not_settle_for_direct_membership(self):
|
|
|
|
|
"""`res_groups_users_rel` dirait « quelqu'un est dans le groupe »,
|
|
|
|
|
ce qui était vrai et menait au faux positif."""
|
|
|
|
|
self.assertNotIn("res_groups_users_rel", self._controle()["sql"])
|
|
|
|
|
|
|
|
|
|
def test_it_still_requires_the_module_and_the_table(self):
|
|
|
|
|
sql = " ".join(self._controle()["sql"].split())
|
|
|
|
|
self.assertIn("ir_module_module", sql)
|
|
|
|
|
self.assertIn("to_regclass", sql)
|
2026-08-26 04:37:21 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTheRepairAsksItsOwnDetector(unittest.TestCase):
|
|
|
|
|
"""Une réparation qui n'écoute pas son détecteur fabrique des doublons.
|
|
|
|
|
|
|
|
|
|
Mesuré sur une migration de bout en bout : la liste de prix avait
|
|
|
|
|
traversé les six paliers, PARTAGÉE entre sociétés (company_id vide).
|
|
|
|
|
`_activate_or_create_pricelists` ne compte pas une liste partagée
|
|
|
|
|
comme appartenant à la société — elle en a donc créé une seconde,
|
|
|
|
|
vide, à côté de celle du client.
|
|
|
|
|
|
|
|
|
|
Le détecteur `pricelist_missing`, lui, disait déjà « rien ne manque ».
|
|
|
|
|
Il fallait que la réparation le lui demande.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _source(self):
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
chemin = (
|
|
|
|
|
Path(__file__).resolve().parent.parent
|
|
|
|
|
/ "script"
|
|
|
|
|
/ "odoo"
|
|
|
|
|
/ "migration"
|
|
|
|
|
/ "restore_config_defaults.py"
|
|
|
|
|
)
|
|
|
|
|
return chemin.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
def _garde(self):
|
|
|
|
|
"""Le texte qui précède l'APPEL, pas sa mention dans la docstring.
|
|
|
|
|
|
|
|
|
|
`index` trouvait la PREMIÈRE occurrence — celle de l'en-tête du
|
|
|
|
|
module, qui explique justement ce que fait cette méthode. Le test
|
|
|
|
|
s'évaluait alors sur un extrait de prose et échouait. `rindex`
|
|
|
|
|
prend la dernière, qui est l'appel.
|
|
|
|
|
"""
|
|
|
|
|
source = self._source()
|
|
|
|
|
debut = source.rindex("_activate_or_create_pricelists()")
|
|
|
|
|
return source[max(0, debut - 400) : debut]
|
|
|
|
|
|
|
|
|
|
def test_the_repair_is_gated_on_an_empty_count(self):
|
|
|
|
|
self.assertIn("pricelist_before", self._garde())
|
|
|
|
|
|
|
|
|
|
def test_the_feature_condition_is_still_there(self):
|
|
|
|
|
self.assertIn("pricelist_group", self._garde())
|
|
|
|
|
|
|
|
|
|
def test_the_feature_is_read_from_the_implication(self):
|
|
|
|
|
"""Et non de l'appartenance de celui qui exécute."""
|
|
|
|
|
source = self._source()
|
|
|
|
|
self.assertIn("implied_ids", source)
|
|
|
|
|
self.assertNotIn(
|
|
|
|
|
'env.user.has_group(\n "product.group_product_pricelist"',
|
|
|
|
|
source,
|
|
|
|
|
)
|
[FIX] tests : balayer test/test_*.py et refuser un fichier muet
Le lanceur ne prenait que sept préfixes de noms, « le reste demandant une
base de données » : les 3703 tests du répertoire passent avec PostgreSQL
injoignable, et la suite n'en exécutait que 1131. Il balaie test/test_*.py.
Deux formes rendent un fichier muet sans erreur : unittest.main() posé en
plein milieu, qui sort avant que la seconde moitié soit définie (quatre
fichiers, 87 tests), et l'absence de bloc __main__, qui compte zéro (huit
fichiers, 174 tests). Une garde refuse ces deux formes et le retour d'une
liste de préfixes.
--- EN ---
The runner took seven filename prefixes only, "the rest needing a
database": all 3703 tests in the directory pass with PostgreSQL
unreachable, and the suite ran 1131 of them. It now globs test/test_*.py.
Two shapes make a file silent without error: unittest.main() placed
mid-file, which exits before the second half is defined (four files, 87
tests), and no __main__ block at all, which counts zero (eight files, 174
tests). A guard now refuses both shapes and the return of a prefix list.
Assisted-by: Claude Opus 5
(cherry picked from commit db71b333cc08ae0fce4ddaaf037357648b10236b)
2026-08-26 07:46:14 -04:00
|
|
|
|
|
|
|
|
|
[ADD] qualité de migration : verdicts, sources, revue
Le rapport comparait les paliers sans dire si la migration avait réussi,
alors que les verdicts dorment déjà dans lst_event du journal de
progression : des contrôles en échec y restent sans remonter nulle part.
Trois sections s'ajoutent sous les paliers : les verdicts, rattachés au
palier ODOO et non au compteur du pilote, décalé d'un rang ; où vivent les
traces, car config.conf laisse logfile= vide et la sortie d'Odoo meurt avec
le terminal ; et la revue, six étapes lançables par « r ». Le contrôle de
résidus porte la même section sans toucher son code de sortie : un verdict
vient du fichier, pas de la base.
--- EN ---
The report compared the tiers without saying whether the migration had
succeeded, while the verdicts already sit in lst_event of the progression
file: failed checks stay there and surface nowhere.
Three sections are added below the tiers: the verdicts, tied to the ODOO
tier and not to the driver counter, which is off by one; where the traces
live, since config.conf leaves logfile= empty and Odoo's output dies with
the terminal; and the review, six steps runnable with "r". The residue
check carries the same section without touching its exit code: a verdict
comes from the file, not from the database.
Assisted-by: Claude Opus 5
(cherry picked from commit b05e0333c4b94d58eb794f09a2459e41d826c655)
2026-08-26 07:46:32 -04:00
|
|
|
def verdict(**champs):
|
|
|
|
|
"""Un événement du journal de progression, forme réelle."""
|
|
|
|
|
brut = {
|
|
|
|
|
"at": "2026-08-26 03:19:59.846453",
|
|
|
|
|
"step": "4.1.I - Migrate database",
|
|
|
|
|
"kind": "test",
|
|
|
|
|
"name": "smoke_public_url",
|
|
|
|
|
"status": 1,
|
|
|
|
|
"detail": ".venv.erplibre/bin/python3"
|
|
|
|
|
" ./script/odoo/migration/smoke_public_url.py"
|
|
|
|
|
" -d test_neutralize_upgrade_14 --internal-required",
|
|
|
|
|
}
|
|
|
|
|
brut.update(champs)
|
|
|
|
|
return brut
|
|
|
|
|
|
|
|
|
|
|
[ADD] verdicts : tous les paliers, leur journal, et la bascule
L'écran de qualité ne listait que les échecs, quand la question devant
une base migrée est « qu'a-t-on vérifié » : les quatorze verdicts
s'affichent, de la 12 à la 18, seule façon de voir qu'un échec a été
rattrapé à un palier plus haut. Le panneau ne portait que la commande ;
il montre le passage du journal d'étape qui l'entoure, garde par tee ce
qu'il lance lui-même et le relit sans relancer. La sortie de l'outil,
elle, part sur le terminal : un tube ferait renoncer les pleins écrans.
Relancer un test d'un autre palier ouvrait la base avec la mauvaise
version, qui y écrit avant d'échouer ; l'écran demande avant de basculer.
--- EN ---
The quality screen listed failures only, when the question in front of a
migrated database is "what did we check": all fourteen verdicts now show,
12 through 18, the only way to see that a failure at one tier was
recovered higher up. The panel carried only the command; it shows the
step-log passage around it, keeps by tee what it runs itself and re-reads
that without rerunning. The tool output goes to the terminal: a pipe
would make full-screen tools give up. Replaying a test from another tier
opened the database with the wrong version, which writes before it
fails; the screen asks before switching the checkout.
Assisted-by: Claude Opus 5
(cherry picked from commit 2d460b7c777d39a887dabfe7cf5405864c6c3f8c)
2026-08-27 04:36:03 -04:00
|
|
|
class TestTheStepLogInTheReport(unittest.TestCase):
|
|
|
|
|
"""La commande seule ne dit pas POURQUOI."""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
import json
|
|
|
|
|
import shutil
|
|
|
|
|
import tempfile
|
|
|
|
|
|
|
|
|
|
self.dossier = tempfile.mkdtemp()
|
|
|
|
|
self.addCleanup(shutil.rmtree, self.dossier)
|
|
|
|
|
self.json = json
|
|
|
|
|
self.progression = os.path.join(self.dossier, "progression.json")
|
|
|
|
|
# `step_log_path` cherche sous private/odoo/migration/<base>/step_log
|
|
|
|
|
self.ancien = residue.status.PATH_MIGRATION_PRIVATE
|
|
|
|
|
residue.status.PATH_MIGRATION_PRIVATE = os.path.join(
|
|
|
|
|
self.dossier, "private"
|
|
|
|
|
)
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
setattr,
|
|
|
|
|
residue.status,
|
|
|
|
|
"PATH_MIGRATION_PRIVATE",
|
|
|
|
|
self.ancien,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def ecrire_progression(self, evenements):
|
|
|
|
|
with io.open(self.progression, "w", encoding="utf-8") as handle:
|
|
|
|
|
self.json.dump(
|
|
|
|
|
{
|
|
|
|
|
"lst_event": evenements,
|
|
|
|
|
"config_database_name": "test_neutralize",
|
|
|
|
|
"target_odoo_version": "18.0",
|
|
|
|
|
"state_4_upgrade_odoo_lst": [1, 2, 3, 4, 5, 6],
|
|
|
|
|
},
|
|
|
|
|
handle,
|
|
|
|
|
)
|
|
|
|
|
return self.progression
|
|
|
|
|
|
|
|
|
|
def ecrire_journal(self, step, lignes):
|
|
|
|
|
dossier = os.path.join(
|
|
|
|
|
residue.status.PATH_MIGRATION_PRIVATE,
|
|
|
|
|
"test_neutralize",
|
|
|
|
|
"step_log",
|
|
|
|
|
)
|
|
|
|
|
os.makedirs(dossier, exist_ok=True)
|
|
|
|
|
chemin = os.path.join(dossier, residue.status.step_slug(step) + ".log")
|
|
|
|
|
with io.open(chemin, "w", encoding="utf-8") as handle:
|
|
|
|
|
handle.write("\n".join(lignes) + "\n")
|
|
|
|
|
return chemin
|
|
|
|
|
|
|
|
|
|
def bloc(self, **kw):
|
|
|
|
|
return "\n".join(
|
|
|
|
|
residue.verdicts_block(
|
|
|
|
|
"test_neutralize_upgrade_14", False, self.progression, **kw
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_the_passage_around_the_failure_is_shown(self):
|
|
|
|
|
self.ecrire_progression([verdict()])
|
|
|
|
|
self.ecrire_journal(
|
|
|
|
|
"4.1.I - Migrate database",
|
|
|
|
|
[
|
|
|
|
|
"odoo: ce qui precedait",
|
|
|
|
|
"[2026-08-26 03:19:44.166204] $ .venv.erplibre/bin/python3"
|
|
|
|
|
" ./script/odoo/migration/smoke_public_url.py"
|
|
|
|
|
" -d test_neutralize_upgrade_14 --internal-required",
|
|
|
|
|
"[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1",
|
|
|
|
|
"odoo: ce qui suivait",
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
texte = self.bloc()
|
|
|
|
|
self.assertIn("odoo: ce qui precedait", texte)
|
|
|
|
|
self.assertIn("[test] smoke_public_url -> 1", texte)
|
|
|
|
|
self.assertNotIn("odoo: ce qui suivait", texte)
|
|
|
|
|
|
|
|
|
|
def test_the_log_path_is_named(self):
|
|
|
|
|
self.ecrire_progression([verdict()])
|
|
|
|
|
chemin = self.ecrire_journal(
|
|
|
|
|
"4.1.I - Migrate database",
|
|
|
|
|
[
|
|
|
|
|
"[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/"
|
|
|
|
|
"smoke_public_url.py -d test_neutralize_upgrade_14",
|
|
|
|
|
"[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1",
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
self.assertIn(chemin, self.bloc())
|
|
|
|
|
|
|
|
|
|
def test_no_step_log_stays_silent(self):
|
|
|
|
|
# Une ligne « pas de journal » par verdict noierait ceux qui
|
|
|
|
|
# comptent.
|
|
|
|
|
self.ecrire_progression([verdict()])
|
|
|
|
|
texte = self.bloc()
|
|
|
|
|
self.assertIn("smoke_public_url", texte)
|
|
|
|
|
self.assertNotIn("step_log", texte)
|
|
|
|
|
|
|
|
|
|
def test_asking_for_none_shows_none(self):
|
|
|
|
|
self.ecrire_progression([verdict()])
|
|
|
|
|
self.ecrire_journal(
|
|
|
|
|
"4.1.I - Migrate database",
|
|
|
|
|
[
|
|
|
|
|
"[2026-08-26 03:19:44.166204] $ ./script/odoo/migration/"
|
|
|
|
|
"smoke_public_url.py -d test_neutralize_upgrade_14",
|
|
|
|
|
"[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1",
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
self.assertNotIn("[test]", self.bloc(lignes_avant=0))
|
|
|
|
|
|
[ADD] migration : garder la sortie des tests, par pseudo-terminal
Le journal d'étape notait la commande et son code de retour, jamais ce
qu'elle avait écrit : l'écran d'analyse ne pouvait rien montrer d'un
échec. Un tube aurait capturé et changé le programme — smoke_public_url
appelle can_ask(), qui exige stdin ET stdout sur un terminal, et derrière
un tube il cesse en silence d'offrir la réparation des vues COW. Un
pseudo-terminal lève le dilemme : l'enfant voit un vrai terminal, la
réponse tapée lui parvient, le code de retour survit. Neuf exécutions y
passent, dont check_hidden_models, qui tournait sans verdict retenu ;
deux restent dehors, pty.spawn naît en 0×0 et un plein écran s'y perdrait.
--- EN ---
The step log recorded the command and its exit code, never what it
wrote: the analysis screen could show nothing of a failure. A pipe would
have captured and changed the program — smoke_public_url calls can_ask(),
which requires stdin AND stdout to be terminals, and behind a pipe it
silently stops offering the COW view repair. A pty settles it: the child
sees a real terminal, a typed answer reaches it, the exit code survives.
Nine runs go through it, including check_hidden_models, which ran with no
verdict recorded; two stay out, pty.spawn starts at 0×0 and a full-screen
app would lay out on nothing.
Assisted-by: Claude Opus 5
(cherry picked from commit 81e9227502a0d16e6409338cf958495a5f8e3827)
2026-08-27 05:54:24 -04:00
|
|
|
def test_the_report_stops_saying_it_once_the_output_is_captured(self):
|
|
|
|
|
# Depuis que le pilote capture par pseudo-terminal, la sortie est
|
|
|
|
|
# dans le journal : le dire encore serait faux.
|
|
|
|
|
self.ecrire_progression([verdict()])
|
|
|
|
|
self.ecrire_journal(
|
|
|
|
|
"4.1.I - Migrate database",
|
|
|
|
|
[
|
|
|
|
|
"[2026-08-26 03:19:44.166204] $ .venv.erplibre/bin/python3"
|
|
|
|
|
" ./script/odoo/migration/smoke_public_url.py"
|
|
|
|
|
" -d test_neutralize_upgrade_14 --internal-required",
|
|
|
|
|
"❌ 3 URL sur 37 rendent 500",
|
|
|
|
|
"[2026-08-26 03:19:59.847489] [test] smoke_public_url -> 1",
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
texte = self.bloc()
|
|
|
|
|
self.assertIn("3 URL sur 37", texte)
|
|
|
|
|
self.assertNotIn(
|
|
|
|
|
residue.t("the tool output is not in the step log: it goes"),
|
|
|
|
|
texte,
|
|
|
|
|
)
|
|
|
|
|
|
[ADD] verdicts : tous les paliers, leur journal, et la bascule
L'écran de qualité ne listait que les échecs, quand la question devant
une base migrée est « qu'a-t-on vérifié » : les quatorze verdicts
s'affichent, de la 12 à la 18, seule façon de voir qu'un échec a été
rattrapé à un palier plus haut. Le panneau ne portait que la commande ;
il montre le passage du journal d'étape qui l'entoure, garde par tee ce
qu'il lance lui-même et le relit sans relancer. La sortie de l'outil,
elle, part sur le terminal : un tube ferait renoncer les pleins écrans.
Relancer un test d'un autre palier ouvrait la base avec la mauvaise
version, qui y écrit avant d'échouer ; l'écran demande avant de basculer.
--- EN ---
The quality screen listed failures only, when the question in front of a
migrated database is "what did we check": all fourteen verdicts now show,
12 through 18, the only way to see that a failure at one tier was
recovered higher up. The panel carried only the command; it shows the
step-log passage around it, keeps by tee what it runs itself and re-reads
that without rerunning. The tool output goes to the terminal: a pipe
would make full-screen tools give up. Replaying a test from another tier
opened the database with the wrong version, which writes before it
fails; the screen asks before switching the checkout.
Assisted-by: Claude Opus 5
(cherry picked from commit 2d460b7c777d39a887dabfe7cf5405864c6c3f8c)
2026-08-27 04:36:03 -04:00
|
|
|
def test_it_says_the_tool_output_is_elsewhere(self):
|
|
|
|
|
# Sans cela on cherche dans le journal une sortie qui n'y a
|
|
|
|
|
# jamais été écrite.
|
|
|
|
|
self.ecrire_progression([verdict()])
|
|
|
|
|
self.assertIn(
|
|
|
|
|
residue.t("the tool output is not in the step log: it goes"),
|
|
|
|
|
self.bloc(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_an_all_green_run_does_not_carry_that_warning(self):
|
|
|
|
|
self.ecrire_progression([verdict(status=0)])
|
|
|
|
|
self.assertNotIn(
|
|
|
|
|
residue.t("the tool output is not in the step log: it goes"),
|
|
|
|
|
self.bloc(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_the_step_shown_is_the_odoo_version(self):
|
|
|
|
|
# « 4.1.I » est le compteur du pilote ; la migration en est au 14.
|
|
|
|
|
self.ecrire_progression([verdict()])
|
|
|
|
|
texte = self.bloc()
|
|
|
|
|
self.assertIn("14 smoke_public_url", texte)
|
|
|
|
|
|
|
|
|
|
|
[REM] résidus : retirer le contrôle res_lang.active à NULL
Le contrôle jugeait cassée une langue dont `active` est NULL : listée
nulle part, plus réactivable. Faux dans la source 18 — un domaine
('active','=',False) compile en (IS NULL OR = FALSE) (models.py:3217),
l'action du menu Langues porte active_test: False, le tri passe par
COALESCE(active, FALSE) et la lecture rend bool(value).
Odoo écrit ce NULL : active = fields.Boolean() sans défaut
(res_lang.py:64) et un res.lang.csv sans la colonne, soit un NULL par
langue ajoutée au catalogue. « Zéro avant, non nul après » ne suffit
donc pas. Un test refuse désormais une clé que plus rien ne définit.
--- EN ---
The check called a language broken when `active` is NULL: listed
nowhere, no longer re-enablable. False in the 18 source — an
('active','=',False) domain compiles to (IS NULL OR = FALSE)
(models.py:3217), the Languages menu action carries active_test: False,
sorting goes through COALESCE(active, FALSE), reading returns bool(value).
Odoo writes that NULL itself: active = fields.Boolean() with no default
(res_lang.py:64) and a res.lang.csv without the column, one NULL per
language added to the catalogue. So "zero before, nonzero after" is not
enough. A test now refuses a key that nothing defines any more.
Assisted-by: Claude Opus 5
(cherry picked from commit ee932334cfde63c1af831316549a432c3d382721)
2026-08-27 02:15:30 -04:00
|
|
|
class TestTheControlsThemselves(unittest.TestCase):
|
|
|
|
|
"""Un contrôle retiré ne doit pas survivre dans les tests.
|
|
|
|
|
|
|
|
|
|
« res_lang.active à NULL » a vécu ici avec la gravité « broken », et
|
|
|
|
|
c'était un faux constat : Odoo écrit lui-même ce NULL, faute de défaut
|
|
|
|
|
sur `active = fields.Boolean()`. Deux tests le nommaient encore après
|
|
|
|
|
son retrait — ils passaient, en construisant un dictionnaire avec une
|
|
|
|
|
clé inconnue de personne.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_every_key_a_test_names_still_exists(self):
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
connues = {c["key"] for c in residue.CONTROLES}
|
[ADD] verdicts : tous les paliers, leur journal, et la bascule
L'écran de qualité ne listait que les échecs, quand la question devant
une base migrée est « qu'a-t-on vérifié » : les quatorze verdicts
s'affichent, de la 12 à la 18, seule façon de voir qu'un échec a été
rattrapé à un palier plus haut. Le panneau ne portait que la commande ;
il montre le passage du journal d'étape qui l'entoure, garde par tee ce
qu'il lance lui-même et le relit sans relancer. La sortie de l'outil,
elle, part sur le terminal : un tube ferait renoncer les pleins écrans.
Relancer un test d'un autre palier ouvrait la base avec la mauvaise
version, qui y écrit avant d'échouer ; l'écran demande avant de basculer.
--- EN ---
The quality screen listed failures only, when the question in front of a
migrated database is "what did we check": all fourteen verdicts now show,
12 through 18, the only way to see that a failure at one tier was
recovered higher up. The panel carried only the command; it shows the
step-log passage around it, keeps by tee what it runs itself and re-reads
that without rerunning. The tool output goes to the terminal: a pipe
would make full-screen tools give up. Replaying a test from another tier
opened the database with the wrong version, which writes before it
fails; the screen asks before switching the checkout.
Assisted-by: Claude Opus 5
(cherry picked from commit 2d460b7c777d39a887dabfe7cf5405864c6c3f8c)
2026-08-27 04:36:03 -04:00
|
|
|
with io.open(__file__, encoding="utf-8") as handle:
|
|
|
|
|
source = handle.read()
|
[REM] résidus : retirer le contrôle res_lang.active à NULL
Le contrôle jugeait cassée une langue dont `active` est NULL : listée
nulle part, plus réactivable. Faux dans la source 18 — un domaine
('active','=',False) compile en (IS NULL OR = FALSE) (models.py:3217),
l'action du menu Langues porte active_test: False, le tri passe par
COALESCE(active, FALSE) et la lecture rend bool(value).
Odoo écrit ce NULL : active = fields.Boolean() sans défaut
(res_lang.py:64) et un res.lang.csv sans la colonne, soit un NULL par
langue ajoutée au catalogue. « Zéro avant, non nul après » ne suffit
donc pas. Un test refuse désormais une clé que plus rien ne définit.
--- EN ---
The check called a language broken when `active` is NULL: listed
nowhere, no longer re-enablable. False in the 18 source — an
('active','=',False) domain compiles to (IS NULL OR = FALSE)
(models.py:3217), the Languages menu action carries active_test: False,
sorting goes through COALESCE(active, FALSE), reading returns bool(value).
Odoo writes that NULL itself: active = fields.Boolean() with no default
(res_lang.py:64) and a res.lang.csv without the column, one NULL per
language added to the catalogue. So "zero before, nonzero after" is not
enough. A test now refuses a key that nothing defines any more.
Assisted-by: Claude Opus 5
(cherry picked from commit ee932334cfde63c1af831316549a432c3d382721)
2026-08-27 02:15:30 -04:00
|
|
|
nommees = set(re.findall(r'resultats\["([a-z_]+)"\]', source))
|
|
|
|
|
nommees |= set(re.findall(r"dict\(vide, ([a-z_]+)=", source))
|
|
|
|
|
self.assertTrue(nommees)
|
|
|
|
|
self.assertEqual(set(), nommees - connues)
|
|
|
|
|
|
|
|
|
|
def test_no_control_promises_a_repair_tool_that_is_missing(self):
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
for controle in residue.CONTROLES:
|
|
|
|
|
morceaux = (controle["repair"] or "").split()
|
|
|
|
|
chemin = morceaux[0] if morceaux else ""
|
|
|
|
|
if chemin.startswith("script/"):
|
|
|
|
|
self.assertTrue(
|
|
|
|
|
os.path.isfile(os.path.join(REPO, chemin)),
|
|
|
|
|
f"{controle['key']} → {chemin}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
[ADD] qualité de migration : verdicts, sources, revue
Le rapport comparait les paliers sans dire si la migration avait réussi,
alors que les verdicts dorment déjà dans lst_event du journal de
progression : des contrôles en échec y restent sans remonter nulle part.
Trois sections s'ajoutent sous les paliers : les verdicts, rattachés au
palier ODOO et non au compteur du pilote, décalé d'un rang ; où vivent les
traces, car config.conf laisse logfile= vide et la sortie d'Odoo meurt avec
le terminal ; et la revue, six étapes lançables par « r ». Le contrôle de
résidus porte la même section sans toucher son code de sortie : un verdict
vient du fichier, pas de la base.
--- EN ---
The report compared the tiers without saying whether the migration had
succeeded, while the verdicts already sit in lst_event of the progression
file: failed checks stay there and surface nowhere.
Three sections are added below the tiers: the verdicts, tied to the ODOO
tier and not to the driver counter, which is off by one; where the traces
live, since config.conf leaves logfile= empty and Odoo's output dies with
the terminal; and the review, six steps runnable with "r". The residue
check carries the same section without touching its exit code: a verdict
comes from the file, not from the database.
Assisted-by: Claude Opus 5
(cherry picked from commit b05e0333c4b94d58eb794f09a2459e41d826c655)
2026-08-26 07:46:32 -04:00
|
|
|
class TestTheVerdictsSection(unittest.TestCase):
|
|
|
|
|
"""Ce que les contrôles SQL ne peuvent structurellement pas voir.
|
|
|
|
|
|
|
|
|
|
Un test de fumée qui échoue n'écrit rien en base : rien n'est cassé,
|
|
|
|
|
la requête suivante répond. Aucun contrôle lisant la base ne le
|
|
|
|
|
retrouvera jamais — et c'était la moitié de ce qu'une migration peut
|
|
|
|
|
rater.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
import json
|
|
|
|
|
import shutil
|
|
|
|
|
import tempfile
|
|
|
|
|
|
|
|
|
|
self.dossier = tempfile.mkdtemp()
|
|
|
|
|
self.addCleanup(shutil.rmtree, self.dossier)
|
|
|
|
|
self.chemin = os.path.join(self.dossier, "progression.json")
|
|
|
|
|
self.json = json
|
|
|
|
|
|
|
|
|
|
def ecrire(self, evenements):
|
|
|
|
|
with io.open(self.chemin, "w", encoding="utf-8") as handle:
|
|
|
|
|
self.json.dump({"lst_event": evenements}, handle)
|
|
|
|
|
return self.chemin
|
|
|
|
|
|
|
|
|
|
def test_two_steps_of_one_migration_are_the_same_lineage(self):
|
|
|
|
|
# Interroger la base 18 doit montrer l'échec du palier 14 : c'est
|
|
|
|
|
# le seul endroit où il subsiste.
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
residue.famille("test_neutralize_upgrade_14"),
|
|
|
|
|
residue.famille("test_neutralize_upgrade_18"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_plain_name_is_its_own_lineage(self):
|
[FIX] conventions : retirer un nom d'organisation des exemples
Un nom d'organisation servait d'exemple dans une docstring et dans quatre
tests — nom de base, chemin de compte, préfixe de copie. La règle du dépôt
l'interdit partout hors de `private/`, et ces fichiers suivent le dépôt en
amont : sur un fork rendu public, l'exemple le devient aussi.
Un test le fige pour toujours, et trois des cinq sites servaient justement à
démontrer qu'un chemin de compte ou un nom est refusé. Une valeur inventée
démontre aussi bien, et celle-ci ne paraît nulle part ailleurs. La docstring
perd son exemple : il n'illustrait que « long », et la phrase le dit
maintenant. Le détecteur du dépôt ne voit pas cette classe — il n'annonçait
rien ici.
--- EN ---
An organisation's name served as an example in one docstring and in four
tests — a database name, an account path, a copy prefix. The repository's rule
forbids it anywhere outside `private/`, and these files follow the repository
upstream: on a fork made public, the example becomes public too.
A test freezes it forever, and three of the five sites existed precisely to
demonstrate that an account path or a name is refused. An invented value
demonstrates just as well, and this one appears nowhere else. The docstring
loses its example: it only illustrated "long", which the sentence now says.
The repository's detector does not see this class — it reported nothing here.
Assisted-by: Claude Opus 5
2026-09-09 04:34:46 -04:00
|
|
|
self.assertEqual("copy_garance3", residue.famille("copy_garance3"))
|
[ADD] qualité de migration : verdicts, sources, revue
Le rapport comparait les paliers sans dire si la migration avait réussi,
alors que les verdicts dorment déjà dans lst_event du journal de
progression : des contrôles en échec y restent sans remonter nulle part.
Trois sections s'ajoutent sous les paliers : les verdicts, rattachés au
palier ODOO et non au compteur du pilote, décalé d'un rang ; où vivent les
traces, car config.conf laisse logfile= vide et la sortie d'Odoo meurt avec
le terminal ; et la revue, six étapes lançables par « r ». Le contrôle de
résidus porte la même section sans toucher son code de sortie : un verdict
vient du fichier, pas de la base.
--- EN ---
The report compared the tiers without saying whether the migration had
succeeded, while the verdicts already sit in lst_event of the progression
file: failed checks stay there and surface nowhere.
Three sections are added below the tiers: the verdicts, tied to the ODOO
tier and not to the driver counter, which is off by one; where the traces
live, since config.conf leaves logfile= empty and Odoo's output dies with
the terminal; and the review, six steps runnable with "r". The residue
check carries the same section without touching its exit code: a verdict
comes from the file, not from the database.
Assisted-by: Claude Opus 5
(cherry picked from commit b05e0333c4b94d58eb794f09a2459e41d826c655)
2026-08-26 07:46:32 -04:00
|
|
|
|
|
|
|
|
def test_another_migration_verdicts_are_not_shown(self):
|
|
|
|
|
# Deux migrations partagent le fichier. Attribuer l'échec de
|
|
|
|
|
# l'une à l'autre enverrait chercher une panne qui n'existe pas.
|
|
|
|
|
chemin = self.ecrire([verdict()])
|
|
|
|
|
_tous, ratés = residue.verdicts("autre_client_upgrade_18", chemin)
|
|
|
|
|
self.assertEqual([], ratés)
|
|
|
|
|
|
|
|
|
|
def test_the_failure_of_an_earlier_step_is_shown(self):
|
|
|
|
|
chemin = self.ecrire([verdict()])
|
|
|
|
|
_tous, ratés = residue.verdicts("test_neutralize_upgrade_18", chemin)
|
|
|
|
|
self.assertEqual(["smoke_public_url"], [e["name"] for e in ratés])
|
|
|
|
|
|
|
|
|
|
def test_a_step_is_named_by_its_odoo_version(self):
|
|
|
|
|
chemin = self.ecrire([verdict()])
|
|
|
|
|
texte = "\n".join(
|
|
|
|
|
residue.verdicts_block("test_neutralize_upgrade_18", False, chemin)
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("14", texte)
|
|
|
|
|
self.assertIn("smoke_public_url", texte)
|
|
|
|
|
|
|
|
|
|
def test_no_file_says_nothing_at_all(self):
|
|
|
|
|
# Devant la sauvegarde d'un client, il n'y a jamais eu de
|
|
|
|
|
# migration locale : annoncer l'absence d'un fichier qu'on
|
|
|
|
|
# n'attendait pas ne renseigne personne.
|
|
|
|
|
absent = os.path.join(self.dossier, "jamais_ecrit.json")
|
|
|
|
|
self.assertEqual([], residue.verdicts_block("base", False, absent))
|
|
|
|
|
|
|
|
|
|
def test_all_green_is_stated_not_left_silent(self):
|
|
|
|
|
chemin = self.ecrire([verdict(status=0), verdict(status=0)])
|
|
|
|
|
texte = "\n".join(
|
|
|
|
|
residue.verdicts_block("test_neutralize_upgrade_18", False, chemin)
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("2", texte)
|
|
|
|
|
self.assertIn(residue.t("checks, all passed"), texte)
|
|
|
|
|
|
|
|
|
|
def test_the_report_says_these_are_not_from_the_database(self):
|
|
|
|
|
# Sans cette phrase, un verdict d'il y a quatre paliers se lirait
|
|
|
|
|
# comme un défaut présent de la base qu'on a sous les yeux.
|
|
|
|
|
chemin = self.ecrire([verdict()])
|
|
|
|
|
texte = "\n".join(
|
|
|
|
|
residue.verdicts_block("test_neutralize_upgrade_18", False, chemin)
|
|
|
|
|
)
|
|
|
|
|
self.assertIn(
|
|
|
|
|
residue.t("These come from the file, not the database:"), texte
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_a_past_verdict_does_not_become_a_finding(self):
|
|
|
|
|
# Le code de sortie dit « la BASE porte un défaut ». Y compter un
|
|
|
|
|
# verdict passé rendrait 1 pour toujours, et le pilote traiterait
|
|
|
|
|
# une base saine comme cassée à chaque appel.
|
|
|
|
|
vide = {c["key"]: 0 for c in residue.CONTROLES}
|
|
|
|
|
trouve, _illisibles = residue.judge(vide)
|
|
|
|
|
self.assertEqual([], trouve)
|
|
|
|
|
|
|
|
|
|
def test_the_clean_report_still_carries_the_verdicts(self):
|
|
|
|
|
# Le cas qui compte : aucun résidu en base, et pourtant un test
|
|
|
|
|
# de fumée a échoué en chemin. Le rapport « rien trouvé » ne doit
|
|
|
|
|
# pas être le dernier mot.
|
|
|
|
|
chemin = self.ecrire([verdict()])
|
|
|
|
|
ancien = residue.quality.DEFAULT_PROGRESSION
|
|
|
|
|
residue.quality.DEFAULT_PROGRESSION = chemin
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
setattr, residue.quality, "DEFAULT_PROGRESSION", ancien
|
|
|
|
|
)
|
|
|
|
|
vide = {c["key"]: 0 for c in residue.CONTROLES}
|
|
|
|
|
texte = residue.render(
|
|
|
|
|
"test_neutralize_upgrade_18", vide, colour=False
|
|
|
|
|
)
|
|
|
|
|
self.assertIn(residue.t("None of the checks found anything."), texte)
|
|
|
|
|
self.assertIn("smoke_public_url", texte)
|
|
|
|
|
|
|
|
|
|
def test_the_colour_it_asks_for_exists(self):
|
|
|
|
|
# `paint` retombe sur une chaîne vide suivie d'un RESET quand le
|
|
|
|
|
# genre est inconnu : cela n'annule rien et ne teinte rien.
|
|
|
|
|
chemin = self.ecrire([verdict()])
|
|
|
|
|
for ligne in residue.verdicts_block(
|
|
|
|
|
"test_neutralize_upgrade_18", True, chemin
|
|
|
|
|
):
|
|
|
|
|
if ligne and ligne.endswith(residue.RESET):
|
|
|
|
|
self.assertNotEqual(residue.RESET, ligne.strip())
|
|
|
|
|
|
|
|
|
|
|
[FIX] tests : balayer test/test_*.py et refuser un fichier muet
Le lanceur ne prenait que sept préfixes de noms, « le reste demandant une
base de données » : les 3703 tests du répertoire passent avec PostgreSQL
injoignable, et la suite n'en exécutait que 1131. Il balaie test/test_*.py.
Deux formes rendent un fichier muet sans erreur : unittest.main() posé en
plein milieu, qui sort avant que la seconde moitié soit définie (quatre
fichiers, 87 tests), et l'absence de bloc __main__, qui compte zéro (huit
fichiers, 174 tests). Une garde refuse ces deux formes et le retour d'une
liste de préfixes.
--- EN ---
The runner took seven filename prefixes only, "the rest needing a
database": all 3703 tests in the directory pass with PostgreSQL
unreachable, and the suite ran 1131 of them. It now globs test/test_*.py.
Two shapes make a file silent without error: unittest.main() placed
mid-file, which exits before the second half is defined (four files, 87
tests), and no __main__ block at all, which counts zero (eight files, 174
tests). A guard now refuses both shapes and the return of a prefix list.
Assisted-by: Claude Opus 5
(cherry picked from commit db71b333cc08ae0fce4ddaaf037357648b10236b)
2026-08-26 07:46:14 -04:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|