2026-03-10 02:49:03 -04:00
|
|
|
#!/usr/bin/env python3
|
2026-03-11 23:15:07 -04:00
|
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
2026-03-10 02:49:03 -04:00
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
[ADD] transform data : anonymiser un fichier externe ou une base Odoo
Partager un fichier ou une base demandait d'anonymiser à la main,
colonne par colonne, ou de ne pas partager. L'entrée ouvre un fichier
externe — Excel, Access, CSV, XML, JSON — ou une base Odoo, dit ce
qu'elle porte, puis en tire une copie. L'original n'est jamais touché.
Deux règles gouvernent ce qui sort. Une colonne n'est laissée intacte
que sur son CONTENU mesuré, hors onze étiquettes structurelles : décider
sur l'étiquette recopiait les colonnes les plus identifiantes en les
annonçant protégées. Et la copie est relue octet par octet, un classeur
portant de la donnée en une douzaine d'endroits hors des cellules.
Vérifié : 5 748 tests — 13 rouges, déjà rouges sur master.
--- EN ---
Sharing a file or a database meant anonymising by hand, column by
column, or not sharing. The entry opens an external file — Excel,
Access, CSV, XML, JSON — or an Odoo database, says what it holds, then
draws a copy of it. The original is never touched.
Two rules govern what comes out. A column is left alone on its measured
CONTENT, bar eleven structural labels: deciding on the label copied the
most identifying columns verbatim while announcing them as protected.
And the copy is reread byte by byte, a workbook holding data in a dozen
places that are not cells.
Checked: 5,748 tests — 13 red, already red on master.
Assisted-by: Claude Opus 5
2026-09-10 23:58:01 -04:00
|
|
|
import ast
|
|
|
|
|
import builtins
|
|
|
|
|
import io
|
2026-03-10 02:49:03 -04:00
|
|
|
import json
|
|
|
|
|
import os
|
2026-08-10 03:10:50 -04:00
|
|
|
import subprocess
|
[ADD] transform data : anonymiser un fichier externe ou une base Odoo
Partager un fichier ou une base demandait d'anonymiser à la main,
colonne par colonne, ou de ne pas partager. L'entrée ouvre un fichier
externe — Excel, Access, CSV, XML, JSON — ou une base Odoo, dit ce
qu'elle porte, puis en tire une copie. L'original n'est jamais touché.
Deux règles gouvernent ce qui sort. Une colonne n'est laissée intacte
que sur son CONTENU mesuré, hors onze étiquettes structurelles : décider
sur l'étiquette recopiait les colonnes les plus identifiantes en les
annonçant protégées. Et la copie est relue octet par octet, un classeur
portant de la donnée en une douzaine d'endroits hors des cellules.
Vérifié : 5 748 tests — 13 rouges, déjà rouges sur master.
--- EN ---
Sharing a file or a database meant anonymising by hand, column by
column, or not sharing. The entry opens an external file — Excel,
Access, CSV, XML, JSON — or an Odoo database, says what it holds, then
draws a copy of it. The original is never touched.
Two rules govern what comes out. A column is left alone on its measured
CONTENT, bar eleven structural labels: deciding on the label copied the
most identifying columns verbatim while announcing them as protected.
And the copy is reread byte by byte, a workbook holding data in a dozen
places that are not cells.
Checked: 5,748 tests — 13 red, already red on master.
Assisted-by: Claude Opus 5
2026-09-10 23:58:01 -04:00
|
|
|
import sys
|
2026-03-10 02:49:03 -04:00
|
|
|
import tempfile
|
|
|
|
|
import unittest
|
2026-08-10 03:10:50 -04:00
|
|
|
from pathlib import Path
|
2026-08-10 03:10:50 -04:00
|
|
|
from unittest.mock import MagicMock, mock_open, patch
|
2026-03-10 02:49:03 -04:00
|
|
|
|
[ADD] transform data : anonymiser un fichier externe ou une base Odoo
Partager un fichier ou une base demandait d'anonymiser à la main,
colonne par colonne, ou de ne pas partager. L'entrée ouvre un fichier
externe — Excel, Access, CSV, XML, JSON — ou une base Odoo, dit ce
qu'elle porte, puis en tire une copie. L'original n'est jamais touché.
Deux règles gouvernent ce qui sort. Une colonne n'est laissée intacte
que sur son CONTENU mesuré, hors onze étiquettes structurelles : décider
sur l'étiquette recopiait les colonnes les plus identifiantes en les
annonçant protégées. Et la copie est relue octet par octet, un classeur
portant de la donnée en une douzaine d'endroits hors des cellules.
Vérifié : 5 748 tests — 13 rouges, déjà rouges sur master.
--- EN ---
Sharing a file or a database meant anonymising by hand, column by
column, or not sharing. The entry opens an external file — Excel,
Access, CSV, XML, JSON — or an Odoo database, says what it holds, then
draws a copy of it. The original is never touched.
Two rules govern what comes out. A column is left alone on its measured
CONTENT, bar eleven structural labels: deciding on the label copied the
most identifying columns verbatim while announcing them as protected.
And the copy is reread byte by byte, a workbook holding data in a dozen
places that are not cells.
Checked: 5,748 tests — 13 red, already red on master.
Assisted-by: Claude Opus 5
2026-09-10 23:58:01 -04:00
|
|
|
from script.todo import todo_i18n
|
2026-03-10 02:49:03 -04:00
|
|
|
from script.todo.todo import (
|
|
|
|
|
ANDROID_DIR,
|
|
|
|
|
CONFIG_FILE,
|
|
|
|
|
CONFIG_OVERRIDE_FILE,
|
|
|
|
|
ENABLE_CRASH,
|
2026-03-10 03:06:07 -04:00
|
|
|
ERROR_LOG_PATH,
|
2026-03-10 02:49:03 -04:00
|
|
|
GRADLE_FILE,
|
|
|
|
|
LOGO_ASCII_FILE,
|
|
|
|
|
MOBILE_HOME_PATH,
|
|
|
|
|
STRINGS_FILE,
|
|
|
|
|
TODO,
|
2026-03-10 03:06:07 -04:00
|
|
|
VENV_ERPLIBRE,
|
2026-03-10 03:59:35 -04:00
|
|
|
)
|
|
|
|
|
from script.todo.version_manager import (
|
|
|
|
|
INSTALLED_ODOO_VERSION_FILE,
|
|
|
|
|
ODOO_VERSION_FILE,
|
2026-03-10 02:49:03 -04:00
|
|
|
VERSION_DATA_FILE,
|
2026-03-10 03:59:35 -04:00
|
|
|
get_odoo_version,
|
2026-03-10 02:49:03 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTODOInit(unittest.TestCase):
|
|
|
|
|
def test_initial_attributes(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
self.assertIsNone(todo.dir_path)
|
2026-03-10 03:45:11 -04:00
|
|
|
self.assertIsNone(todo.selected_file_path)
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertIsNotNone(todo.config_file)
|
|
|
|
|
self.assertIsNotNone(todo.execute)
|
2026-03-10 03:59:35 -04:00
|
|
|
self.assertIsNotNone(todo.kdbx_manager)
|
2026-03-10 02:49:03 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestFillHelpInfo(unittest.TestCase):
|
|
|
|
|
def setUp(self):
|
|
|
|
|
self.todo = TODO()
|
|
|
|
|
|
|
|
|
|
@patch("script.todo.todo.t")
|
|
|
|
|
def test_basic_help_info(self, mock_t):
|
|
|
|
|
mock_t.side_effect = lambda k: {
|
|
|
|
|
"command": "Command:",
|
|
|
|
|
"back": "Back",
|
|
|
|
|
}.get(k, k)
|
2026-03-10 04:23:27 -04:00
|
|
|
choices = [
|
2026-03-10 02:49:03 -04:00
|
|
|
{"prompt_description": "Option A"},
|
|
|
|
|
{"prompt_description": "Option B"},
|
|
|
|
|
]
|
2026-03-10 04:23:27 -04:00
|
|
|
result = self.todo.fill_help_info(choices)
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertIn("[1] Option A", result)
|
|
|
|
|
self.assertIn("[2] Option B", result)
|
|
|
|
|
self.assertIn("[0] Back", result)
|
|
|
|
|
|
|
|
|
|
@patch("script.todo.todo.t")
|
|
|
|
|
def test_with_prompt_description_key(self, mock_t):
|
|
|
|
|
mock_t.side_effect = lambda k: {
|
|
|
|
|
"command": "Command:",
|
|
|
|
|
"back": "Back",
|
|
|
|
|
"my_key": "Translated Description",
|
|
|
|
|
}.get(k, k)
|
2026-03-10 04:23:27 -04:00
|
|
|
choices = [
|
2026-03-10 02:49:03 -04:00
|
|
|
{
|
|
|
|
|
"prompt_description": "fallback",
|
|
|
|
|
"prompt_description_key": "my_key",
|
|
|
|
|
},
|
|
|
|
|
]
|
2026-03-10 04:23:27 -04:00
|
|
|
result = self.todo.fill_help_info(choices)
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertIn("[1] Translated Description", result)
|
|
|
|
|
|
|
|
|
|
@patch("script.todo.todo.t")
|
|
|
|
|
def test_empty_list(self, mock_t):
|
|
|
|
|
mock_t.side_effect = lambda k: {
|
|
|
|
|
"command": "Command:",
|
|
|
|
|
"back": "Back",
|
|
|
|
|
}.get(k, k)
|
|
|
|
|
result = self.todo.fill_help_info([])
|
|
|
|
|
self.assertIn("Command:", result)
|
|
|
|
|
self.assertIn("[0] Back", result)
|
|
|
|
|
self.assertNotIn("[1]", result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestGetOdooVersion(unittest.TestCase):
|
|
|
|
|
def test_reads_version_data(self):
|
|
|
|
|
version_data = {
|
|
|
|
|
"odoo18.0_python3.12.10": {
|
|
|
|
|
"odoo_version": "18.0",
|
|
|
|
|
"python_version": "3.12.10",
|
|
|
|
|
"default": True,
|
|
|
|
|
"is_deprecated": False,
|
|
|
|
|
},
|
|
|
|
|
"odoo16.0_python3.10.18": {
|
|
|
|
|
"odoo_version": "16.0",
|
|
|
|
|
"python_version": "3.10.18",
|
|
|
|
|
"default": False,
|
|
|
|
|
"is_deprecated": False,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
|
|
version_file = os.path.join(tmpdir, "version.json")
|
|
|
|
|
with open(version_file, "w") as f:
|
|
|
|
|
json.dump(version_data, f)
|
|
|
|
|
|
|
|
|
|
odoo_version_file = os.path.join(tmpdir, ".odoo-version")
|
|
|
|
|
with open(odoo_version_file, "w") as f:
|
|
|
|
|
f.write("18.0")
|
|
|
|
|
|
|
|
|
|
with patch(
|
2026-03-10 03:59:35 -04:00
|
|
|
"script.todo.version_manager.VERSION_DATA_FILE", version_file
|
2026-03-10 02:49:03 -04:00
|
|
|
), patch(
|
2026-03-10 03:59:35 -04:00
|
|
|
"script.todo.version_manager.INSTALLED_ODOO_VERSION_FILE",
|
2026-03-10 02:49:03 -04:00
|
|
|
os.path.join(tmpdir, "nonexistent.txt"),
|
|
|
|
|
), patch(
|
2026-03-10 03:59:35 -04:00
|
|
|
"script.todo.version_manager.ODOO_VERSION_FILE",
|
2026-03-10 02:49:03 -04:00
|
|
|
odoo_version_file,
|
|
|
|
|
):
|
2026-03-10 04:23:27 -04:00
|
|
|
versions, installed, odoo_current = get_odoo_version()
|
2026-03-10 02:49:03 -04:00
|
|
|
|
2026-03-10 04:23:27 -04:00
|
|
|
self.assertEqual(len(versions), 2)
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertEqual(odoo_current, "odoo18.0")
|
|
|
|
|
# Check erplibre_version was added
|
2026-03-10 04:23:27 -04:00
|
|
|
names = [v["erplibre_version"] for v in versions]
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertIn("odoo18.0_python3.12.10", names)
|
|
|
|
|
self.assertIn("odoo16.0_python3.10.18", names)
|
|
|
|
|
|
|
|
|
|
def test_installed_versions_read(self):
|
|
|
|
|
version_data = {
|
|
|
|
|
"odoo18.0_python3.12.10": {
|
|
|
|
|
"odoo_version": "18.0",
|
|
|
|
|
"python_version": "3.12.10",
|
|
|
|
|
"default": True,
|
|
|
|
|
"is_deprecated": False,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
|
|
version_file = os.path.join(tmpdir, "version.json")
|
|
|
|
|
with open(version_file, "w") as f:
|
|
|
|
|
json.dump(version_data, f)
|
|
|
|
|
|
|
|
|
|
installed_file = os.path.join(tmpdir, "installed.txt")
|
|
|
|
|
with open(installed_file, "w") as f:
|
|
|
|
|
f.write("odoo18.0\nodoo16.0\n")
|
|
|
|
|
|
|
|
|
|
with patch(
|
2026-03-10 03:59:35 -04:00
|
|
|
"script.todo.version_manager.VERSION_DATA_FILE", version_file
|
2026-03-10 02:49:03 -04:00
|
|
|
), patch(
|
2026-03-10 03:59:35 -04:00
|
|
|
"script.todo.version_manager.INSTALLED_ODOO_VERSION_FILE",
|
2026-03-10 02:49:03 -04:00
|
|
|
installed_file,
|
|
|
|
|
), patch(
|
2026-03-10 03:59:35 -04:00
|
|
|
"script.todo.version_manager.ODOO_VERSION_FILE",
|
2026-03-10 02:49:03 -04:00
|
|
|
os.path.join(tmpdir, "nonexistent"),
|
|
|
|
|
):
|
2026-03-10 04:23:27 -04:00
|
|
|
versions, installed, odoo_current = get_odoo_version()
|
2026-03-10 02:49:03 -04:00
|
|
|
|
2026-03-10 04:23:27 -04:00
|
|
|
self.assertEqual(installed, ["odoo16.0", "odoo18.0"])
|
2026-03-10 02:49:03 -04:00
|
|
|
self.assertIsNone(odoo_current)
|
|
|
|
|
|
|
|
|
|
def test_no_version_data_raises(self):
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
|
|
|
version_file = os.path.join(tmpdir, "empty.json")
|
|
|
|
|
with open(version_file, "w") as f:
|
|
|
|
|
json.dump({}, f)
|
|
|
|
|
|
2026-03-10 04:05:04 -04:00
|
|
|
with patch(
|
|
|
|
|
"script.todo.version_manager.VERSION_DATA_FILE", version_file
|
|
|
|
|
):
|
2026-03-10 02:49:03 -04:00
|
|
|
with self.assertRaises(Exception):
|
2026-03-10 03:59:35 -04:00
|
|
|
get_odoo_version()
|
2026-03-10 02:49:03 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestOnDirSelected(unittest.TestCase):
|
|
|
|
|
@patch("script.todo.todo.todo_file_browser", create=True)
|
|
|
|
|
def test_sets_dir_path(self, mock_browser):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.on_dir_selected("/some/path")
|
|
|
|
|
self.assertEqual(todo.dir_path, "/some/path")
|
2026-08-10 03:10:50 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestExecuteFromConfiguration(unittest.TestCase):
|
2026-03-10 02:49:03 -04:00
|
|
|
def test_with_command(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
dct = {"command": "./run.sh"}
|
|
|
|
|
todo.execute_from_configuration(dct)
|
|
|
|
|
todo.execute.exec_command_live.assert_called()
|
|
|
|
|
|
2026-08-10 03:10:50 -04:00
|
|
|
def test_every_command_entry_of_the_real_config_is_reachable(self):
|
|
|
|
|
"""Le dict synthétique du test précédent ne suffisait pas.
|
|
|
|
|
|
|
|
|
|
`4fc15c3` a renommé la clé cherchée par le code en « Command: »,
|
|
|
|
|
le libellé affiché. Plus aucune entrée de todo.json ne
|
|
|
|
|
correspondait, et « Open ERPLibre with TODO 🤖 » ne faisait plus
|
|
|
|
|
rien — sans erreur, le `if` étant simplement faux. Seule la VRAIE
|
|
|
|
|
configuration relie les deux côtés.
|
|
|
|
|
"""
|
|
|
|
|
with open(CONFIG_FILE) as fh:
|
|
|
|
|
config = json.load(fh)
|
|
|
|
|
|
|
|
|
|
entrees = []
|
|
|
|
|
|
|
|
|
|
def parcourir(noeud):
|
|
|
|
|
if isinstance(noeud, dict):
|
|
|
|
|
if "command" in noeud:
|
|
|
|
|
entrees.append(noeud)
|
|
|
|
|
for valeur in noeud.values():
|
|
|
|
|
parcourir(valeur)
|
|
|
|
|
elif isinstance(noeud, list):
|
|
|
|
|
for element in noeud:
|
|
|
|
|
parcourir(element)
|
|
|
|
|
|
|
|
|
|
parcourir(config)
|
|
|
|
|
self.assertTrue(entrees, "todo.json n'a plus d'entrée `command`")
|
|
|
|
|
|
|
|
|
|
for entree in entrees:
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo.execute_from_configuration(entree)
|
|
|
|
|
self.assertTrue(
|
|
|
|
|
todo.execute.exec_command_live.called,
|
|
|
|
|
f"entrée ignorée en silence : {entree.get('command')}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_with_makefile_cmd(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo.execute.exec_command_live.return_value = 0
|
|
|
|
|
dct = {"makefile_cmd": "run_test"}
|
|
|
|
|
todo.execute_from_configuration(dct)
|
|
|
|
|
call_args = todo.execute.exec_command_live.call_args
|
|
|
|
|
self.assertIn("make run_test", call_args[0][0])
|
|
|
|
|
|
2026-03-10 02:49:03 -04:00
|
|
|
def test_makefile_cmd_ignored_when_flag(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
dct = {"makefile_cmd": "run_test"}
|
|
|
|
|
todo.execute_from_configuration(dct, ignore_makefile=True)
|
|
|
|
|
todo.execute.exec_command_live.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_with_callback(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
callback = MagicMock()
|
|
|
|
|
dct = {"callback": callback}
|
|
|
|
|
todo.execute_from_configuration(dct)
|
|
|
|
|
callback.assert_called_once_with(dct)
|
|
|
|
|
|
|
|
|
|
def test_makefile_error_stops_execution(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo.execute.exec_command_live.return_value = 1
|
|
|
|
|
callback = MagicMock()
|
|
|
|
|
dct = {"makefile_cmd": "broken", "callback": callback}
|
|
|
|
|
todo.execute_from_configuration(dct)
|
|
|
|
|
callback.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestConstants(unittest.TestCase):
|
|
|
|
|
def test_config_file_path(self):
|
|
|
|
|
self.assertEqual(CONFIG_FILE, "./script/todo/todo.json")
|
|
|
|
|
|
|
|
|
|
def test_config_override_path(self):
|
|
|
|
|
self.assertEqual(CONFIG_OVERRIDE_FILE, "./private/todo/todo.json")
|
|
|
|
|
|
|
|
|
|
def test_logo_path(self):
|
|
|
|
|
self.assertEqual(LOGO_ASCII_FILE, "./script/todo/logo_ascii.txt")
|
|
|
|
|
|
|
|
|
|
def test_venv_erplibre(self):
|
2026-03-10 03:06:07 -04:00
|
|
|
self.assertEqual(VENV_ERPLIBRE, ".venv.erplibre")
|
2026-03-10 02:49:03 -04:00
|
|
|
|
|
|
|
|
def test_file_error_path(self):
|
2026-03-10 03:06:07 -04:00
|
|
|
self.assertEqual(ERROR_LOG_PATH, ".erplibre.error.txt")
|
2026-03-10 02:49:03 -04:00
|
|
|
|
|
|
|
|
def test_version_data_file(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
VERSION_DATA_FILE,
|
|
|
|
|
os.path.join("conf", "supported_version_erplibre.json"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_mobile_paths(self):
|
|
|
|
|
self.assertEqual(ANDROID_DIR, "android")
|
|
|
|
|
self.assertIn("mobile", MOBILE_HOME_PATH)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestDeployGitServer(unittest.TestCase):
|
|
|
|
|
def test_local_mode(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo._deploy_git_server(production_ready=False, action="init")
|
|
|
|
|
cmd = todo.execute.exec_command_live.call_args[0][0]
|
|
|
|
|
self.assertIn("--action init", cmd)
|
|
|
|
|
self.assertNotIn("--production-ready", cmd)
|
|
|
|
|
|
|
|
|
|
def test_production_mode(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo._deploy_git_server(production_ready=True, action="all")
|
|
|
|
|
cmd = todo.execute.exec_command_live.call_args[0][0]
|
|
|
|
|
self.assertIn("--production-ready", cmd)
|
|
|
|
|
self.assertIn("--action all", cmd)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestProcessKillGitDaemon(unittest.TestCase):
|
|
|
|
|
def test_calls_pkill(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo.process_kill_git_daemon()
|
|
|
|
|
cmd = todo.execute.exec_command_live.call_args[0][0]
|
|
|
|
|
self.assertIn("pkill", cmd)
|
|
|
|
|
self.assertIn("git daemon", cmd)
|
2026-08-10 03:10:50 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestExecuteUnitTests(unittest.TestCase):
|
2026-03-10 02:49:03 -04:00
|
|
|
def test_success_path(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo.execute.exec_command_live.return_value = (0, ["OK"])
|
|
|
|
|
with patch("builtins.print") as mock_print:
|
|
|
|
|
todo.execute_unit_tests()
|
|
|
|
|
cmd = todo.execute.exec_command_live.call_args[0][0]
|
|
|
|
|
self.assertIn("unittest discover", cmd)
|
|
|
|
|
|
|
|
|
|
def test_failure_path(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo.execute.exec_command_live.return_value = (1, ["FAIL"])
|
|
|
|
|
with patch("builtins.print") as mock_print:
|
|
|
|
|
todo.execute_unit_tests()
|
2026-08-10 03:10:50 -04:00
|
|
|
# Verify it was called - error handling path
|
|
|
|
|
|
|
|
|
|
def test_stdout_is_unbuffered_so_the_verdict_lands_last(self):
|
|
|
|
|
"""Signalé à l'usage : « pas clair si les tests ont passé ».
|
|
|
|
|
|
|
|
|
|
unittest écrit son verdict sur stderr et les tests impriment sur
|
|
|
|
|
stdout ; capturés ensemble, le stdout tamponné se déversait après
|
|
|
|
|
le « OK ». Le lecteur voyait donc du bruit en dernier, pas le
|
|
|
|
|
résultat.
|
|
|
|
|
"""
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo.execute.exec_command_live.return_value = (0, ["OK"])
|
|
|
|
|
with patch("builtins.print"):
|
|
|
|
|
todo.execute_unit_tests()
|
|
|
|
|
cmd = todo.execute.exec_command_live.call_args[0][0]
|
|
|
|
|
self.assertIn("python -u -m unittest", cmd)
|
|
|
|
|
|
|
|
|
|
def test_the_pattern_reaches_the_command(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo.execute.exec_command_live.return_value = (0, ["OK"])
|
|
|
|
|
with patch("builtins.print"):
|
|
|
|
|
todo.execute_unit_tests("test_mail*.py")
|
|
|
|
|
cmd = todo.execute.exec_command_live.call_args[0][0]
|
|
|
|
|
self.assertIn("-p 'test_mail*.py'", cmd)
|
|
|
|
|
|
|
|
|
|
def test_the_default_pattern_is_still_the_whole_suite(self):
|
|
|
|
|
"""La signature a gagné un paramètre : l'entrée [3] ne doit pas
|
|
|
|
|
s'être mise à ne lancer qu'un sous-ensemble en silence."""
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
todo.execute.exec_command_live.return_value = (0, ["OK"])
|
|
|
|
|
with patch("builtins.print"):
|
|
|
|
|
todo.execute_unit_tests()
|
|
|
|
|
cmd = todo.execute.exec_command_live.call_args[0][0]
|
|
|
|
|
self.assertIn("-p 'test_*.py'", cmd)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestTestMenuDispatch(unittest.TestCase):
|
|
|
|
|
"""Le câblage des entrées, pas leur contenu.
|
|
|
|
|
|
|
|
|
|
Un `elif` qui pointe le mauvais motif lancerait une suite verte sans
|
|
|
|
|
rien tester de ce que l'utilisateur a demandé — panne silencieuse que
|
|
|
|
|
seul ce test attrape.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _choose(self, entry):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
with patch.object(
|
|
|
|
|
todo, "execute_unit_tests"
|
|
|
|
|
) as mock_run, patch.object(todo, "execute_test_module"), patch(
|
|
|
|
|
"click.prompt", side_effect=[entry, "0"]
|
|
|
|
|
), patch(
|
|
|
|
|
"builtins.print"
|
|
|
|
|
):
|
|
|
|
|
todo.prompt_execute_test()
|
|
|
|
|
return mock_run
|
|
|
|
|
|
|
|
|
|
def test_entry_4_runs_the_mail_tests(self):
|
|
|
|
|
self.assertEqual(self._choose("4").call_args[0], ("test_mail*.py",))
|
|
|
|
|
|
|
|
|
|
def test_entry_5_runs_the_analyse_tests(self):
|
|
|
|
|
self.assertEqual(self._choose("5").call_args[0], ("test_analyse*.py",))
|
|
|
|
|
|
|
|
|
|
def test_entry_3_still_runs_everything(self):
|
|
|
|
|
self.assertEqual(self._choose("3").call_args[0], ())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestKdbxGetExtraCommandUser(unittest.TestCase):
|
[FIX] security: the KeePass password leaves the command line too
Same exposure as the master password, same fix. kdbx_manager put the Odoo
password straight into the web_login command; /proc/<pid>/cmdline is
readable by every user on the machine, and no downstream filter reaches
that.
The command now carries the NAME of an environment variable, never the
value. One name per entry, because several credentials go out in a single
"parallel" call and a single variable could not tell them apart.
get_extra_command_user therefore returns (fragments, variables), and the
two call sites hand the variables to exec_command_live, which already
merged an environment.
Two things found on the way. web_login re-sent config.default_password_auth
when it retried after dismissing a modal, ignoring whatever the caller had
passed -- the retry silently fell back to "admin". And install_forgejo
printed the admin password back to the terminal, hence into the install log
and any CI capture; its own header already documents the default.
A test pins the guarantee: the fragment must not contain the password.
--- FR ---
Même exposition que pour le mot de passe maître, même correctif.
kdbx_manager mettait le mot de passe Odoo directement dans la commande
web_login ; /proc/<pid>/cmdline est lisible par tout utilisateur de la
machine, et aucun filtre en aval ne l'atteint.
La commande porte désormais le NOM d'une variable d'environnement, jamais
la valeur. Un nom par entrée, car plusieurs identifiants partent dans un
seul appel « parallel » et une variable unique ne saurait les distinguer.
get_extra_command_user rend donc (fragments, variables), et les deux
appelants confient les variables à exec_command_live, qui fusionnait déjà
un environnement.
Deux trouvailles en chemin. web_login renvoyait config.default_password_auth
à la reprise après une modale, ignorant ce que l'appelant avait fourni — la
reprise retombait en silence sur « admin ». Et install_forgejo réaffichait
le mot de passe administrateur, donc dans le journal d'installation et
toute capture de CI ; son propre en-tête documente déjà le défaut.
Un test verrouille la garantie : le fragment ne doit pas porter le secret.
Assisted-by: Claude Opus 5
2026-08-23 00:25:03 -04:00
|
|
|
"""La fonction rend (fragments, variables d'environnement).
|
|
|
|
|
|
|
|
|
|
Le mot de passe ne doit JAMAIS revenir dans les fragments : ils
|
|
|
|
|
deviennent une ligne de commande, que tout utilisateur de la machine
|
|
|
|
|
peut lire dans /proc/<pid>/cmdline. Seul le NOM d'une variable y a sa
|
|
|
|
|
place, et c'est ce que le dernier test verrouille.
|
|
|
|
|
"""
|
|
|
|
|
|
2026-03-10 02:49:03 -04:00
|
|
|
def test_empty_kdbx_key(self):
|
|
|
|
|
todo = TODO()
|
2026-03-10 03:59:35 -04:00
|
|
|
result = todo.kdbx_manager.get_extra_command_user("")
|
[FIX] security: the KeePass password leaves the command line too
Same exposure as the master password, same fix. kdbx_manager put the Odoo
password straight into the web_login command; /proc/<pid>/cmdline is
readable by every user on the machine, and no downstream filter reaches
that.
The command now carries the NAME of an environment variable, never the
value. One name per entry, because several credentials go out in a single
"parallel" call and a single variable could not tell them apart.
get_extra_command_user therefore returns (fragments, variables), and the
two call sites hand the variables to exec_command_live, which already
merged an environment.
Two things found on the way. web_login re-sent config.default_password_auth
when it retried after dismissing a modal, ignoring whatever the caller had
passed -- the retry silently fell back to "admin". And install_forgejo
printed the admin password back to the terminal, hence into the install log
and any CI capture; its own header already documents the default.
A test pins the guarantee: the fragment must not contain the password.
--- FR ---
Même exposition que pour le mot de passe maître, même correctif.
kdbx_manager mettait le mot de passe Odoo directement dans la commande
web_login ; /proc/<pid>/cmdline est lisible par tout utilisateur de la
machine, et aucun filtre en aval ne l'atteint.
La commande porte désormais le NOM d'une variable d'environnement, jamais
la valeur. Un nom par entrée, car plusieurs identifiants partent dans un
seul appel « parallel » et une variable unique ne saurait les distinguer.
get_extra_command_user rend donc (fragments, variables), et les deux
appelants confient les variables à exec_command_live, qui fusionnait déjà
un environnement.
Deux trouvailles en chemin. web_login renvoyait config.default_password_auth
à la reprise après une modale, ignorant ce que l'appelant avait fourni — la
reprise retombait en silence sur « admin ». Et install_forgejo réaffichait
le mot de passe administrateur, donc dans le journal d'installation et
toute capture de CI ; son propre en-tête documente déjà le défaut.
Un test verrouille la garantie : le fragment ne doit pas porter le secret.
Assisted-by: Claude Opus 5
2026-08-23 00:25:03 -04:00
|
|
|
self.assertEqual(result, ("", {}))
|
2026-03-10 02:49:03 -04:00
|
|
|
|
|
|
|
|
def test_none_kdbx_key(self):
|
|
|
|
|
todo = TODO()
|
2026-03-10 03:59:35 -04:00
|
|
|
result = todo.kdbx_manager.get_extra_command_user(None)
|
[FIX] security: the KeePass password leaves the command line too
Same exposure as the master password, same fix. kdbx_manager put the Odoo
password straight into the web_login command; /proc/<pid>/cmdline is
readable by every user on the machine, and no downstream filter reaches
that.
The command now carries the NAME of an environment variable, never the
value. One name per entry, because several credentials go out in a single
"parallel" call and a single variable could not tell them apart.
get_extra_command_user therefore returns (fragments, variables), and the
two call sites hand the variables to exec_command_live, which already
merged an environment.
Two things found on the way. web_login re-sent config.default_password_auth
when it retried after dismissing a modal, ignoring whatever the caller had
passed -- the retry silently fell back to "admin". And install_forgejo
printed the admin password back to the terminal, hence into the install log
and any CI capture; its own header already documents the default.
A test pins the guarantee: the fragment must not contain the password.
--- FR ---
Même exposition que pour le mot de passe maître, même correctif.
kdbx_manager mettait le mot de passe Odoo directement dans la commande
web_login ; /proc/<pid>/cmdline est lisible par tout utilisateur de la
machine, et aucun filtre en aval ne l'atteint.
La commande porte désormais le NOM d'une variable d'environnement, jamais
la valeur. Un nom par entrée, car plusieurs identifiants partent dans un
seul appel « parallel » et une variable unique ne saurait les distinguer.
get_extra_command_user rend donc (fragments, variables), et les deux
appelants confient les variables à exec_command_live, qui fusionnait déjà
un environnement.
Deux trouvailles en chemin. web_login renvoyait config.default_password_auth
à la reprise après une modale, ignorant ce que l'appelant avait fourni — la
reprise retombait en silence sur « admin ». Et install_forgejo réaffichait
le mot de passe administrateur, donc dans le journal d'installation et
toute capture de CI ; son propre en-tête documente déjà le défaut.
Un test verrouille la garantie : le fragment ne doit pas porter le secret.
Assisted-by: Claude Opus 5
2026-08-23 00:25:03 -04:00
|
|
|
self.assertEqual(result, ("", {}))
|
2026-03-10 02:49:03 -04:00
|
|
|
|
|
|
|
|
def test_kdbx_not_available(self):
|
|
|
|
|
todo = TODO()
|
2026-03-10 03:59:35 -04:00
|
|
|
todo.kdbx_manager.get_kdbx = MagicMock(return_value=None)
|
|
|
|
|
result = todo.kdbx_manager.get_extra_command_user("some_key")
|
[FIX] security: the KeePass password leaves the command line too
Same exposure as the master password, same fix. kdbx_manager put the Odoo
password straight into the web_login command; /proc/<pid>/cmdline is
readable by every user on the machine, and no downstream filter reaches
that.
The command now carries the NAME of an environment variable, never the
value. One name per entry, because several credentials go out in a single
"parallel" call and a single variable could not tell them apart.
get_extra_command_user therefore returns (fragments, variables), and the
two call sites hand the variables to exec_command_live, which already
merged an environment.
Two things found on the way. web_login re-sent config.default_password_auth
when it retried after dismissing a modal, ignoring whatever the caller had
passed -- the retry silently fell back to "admin". And install_forgejo
printed the admin password back to the terminal, hence into the install log
and any CI capture; its own header already documents the default.
A test pins the guarantee: the fragment must not contain the password.
--- FR ---
Même exposition que pour le mot de passe maître, même correctif.
kdbx_manager mettait le mot de passe Odoo directement dans la commande
web_login ; /proc/<pid>/cmdline est lisible par tout utilisateur de la
machine, et aucun filtre en aval ne l'atteint.
La commande porte désormais le NOM d'une variable d'environnement, jamais
la valeur. Un nom par entrée, car plusieurs identifiants partent dans un
seul appel « parallel » et une variable unique ne saurait les distinguer.
get_extra_command_user rend donc (fragments, variables), et les deux
appelants confient les variables à exec_command_live, qui fusionnait déjà
un environnement.
Deux trouvailles en chemin. web_login renvoyait config.default_password_auth
à la reprise après une modale, ignorant ce que l'appelant avait fourni — la
reprise retombait en silence sur « admin ». Et install_forgejo réaffichait
le mot de passe administrateur, donc dans le journal d'installation et
toute capture de CI ; son propre en-tête documente déjà le défaut.
Un test verrouille la garantie : le fragment ne doit pas porter le secret.
Assisted-by: Claude Opus 5
2026-08-23 00:25:03 -04:00
|
|
|
self.assertEqual(result, ("", {}))
|
|
|
|
|
|
|
|
|
|
def test_password_never_reaches_the_command_line(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
entry = MagicMock(username="odoo", password="s3cr3t")
|
|
|
|
|
kp = MagicMock()
|
|
|
|
|
kp.find_entries_by_title = MagicMock(return_value=entry)
|
|
|
|
|
todo.kdbx_manager.get_kdbx = MagicMock(return_value=kp)
|
|
|
|
|
fragment, env = todo.kdbx_manager.get_extra_command_user("une_cle")
|
|
|
|
|
self.assertNotIn("s3cr3t", fragment)
|
|
|
|
|
self.assertIn(
|
|
|
|
|
"--default_password_auth_env EL_WEB_LOGIN_PWD_0", fragment
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(env, {"EL_WEB_LOGIN_PWD_0": "s3cr3t"})
|
2026-03-10 02:49:03 -04:00
|
|
|
|
|
|
|
|
|
2026-08-10 03:10:50 -04:00
|
|
|
class TestSetupClaudeCommit(unittest.TestCase):
|
|
|
|
|
"""Le déploiement d'une commande `/…` dans ~/.claude/commands.
|
|
|
|
|
|
|
|
|
|
La méthode a été généralisée depuis : elle prend le nom de la commande
|
|
|
|
|
et son gabarit, et quand la cible existe elle DEMANDE confirmation au
|
|
|
|
|
lieu de passer son tour. Le test ne détournait pas `input` — il aurait
|
|
|
|
|
bloqué si l'appel n'avait pas échoué avant.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_existing_file_and_refusal_writes_nothing(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
with patch("os.path.exists", return_value=True), patch(
|
|
|
|
|
"builtins.input", return_value="n"
|
|
|
|
|
), patch("builtins.open") as mock_open, patch(
|
|
|
|
|
"os.makedirs"
|
|
|
|
|
) as mock_makedirs, patch(
|
|
|
|
|
"builtins.print"
|
|
|
|
|
):
|
|
|
|
|
todo._setup_claude_command(
|
|
|
|
|
"commit", "template_claude_commands_commit.md"
|
|
|
|
|
)
|
|
|
|
|
# Un refus doit sortir AVANT toute écriture : ni lecture du gabarit,
|
|
|
|
|
# ni création du dossier. Sans ces deux assertions, le test passait
|
|
|
|
|
# aussi bien si la méthode écrasait le fichier.
|
|
|
|
|
mock_open.assert_not_called()
|
|
|
|
|
mock_makedirs.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_existing_file_and_acceptance_writes(self):
|
|
|
|
|
"""Le pendant : sans lui, la méthode pourrait ne JAMAIS écrire et
|
|
|
|
|
le test ci-dessus resterait vert."""
|
|
|
|
|
todo = TODO()
|
|
|
|
|
with patch("os.path.exists", return_value=True), patch(
|
|
|
|
|
"builtins.input", return_value="y"
|
|
|
|
|
), patch("builtins.open", mock_open(read_data="gabarit")), patch(
|
|
|
|
|
"os.makedirs"
|
|
|
|
|
) as mock_makedirs, patch(
|
|
|
|
|
"builtins.print"
|
|
|
|
|
):
|
|
|
|
|
todo._setup_claude_command(
|
|
|
|
|
"commit", "template_claude_commands_commit.md"
|
|
|
|
|
)
|
|
|
|
|
mock_makedirs.assert_called_once()
|
|
|
|
|
|
|
|
|
|
|
[ADD] script todo : /todo_generate_code, effort high et règles du dépôt
Les règles à appliquer avant de coder sont éparpillées entre les configs,
les hooks et les modules maison, et la documentation en contredit
plusieurs : autopep8 est recommandé alors que le script sort en 1, aucun
script oca-* n'est installé, la racine n'a ni .pre-commit-config.yaml ni
.pylintrc, et flake8 comme pylint-odoo ne vivent que dans le venv Odoo,
que rien ne lance. Le gabarit énonce ce que l'outillage impose, à effort
high, l'épinglage ultracode restant celui de l'utilisateur.
Vérifié : 146 règles relevées, 142 confirmées contre leur citation, 4
retirées. Deux gardes lient chaque entrée du menu à un gabarit présent
qui déclare le bon nom.
--- EN ---
The rules to apply before coding are scattered across the configs, the
hooks and the in-house modules, and the documentation contradicts several
of them: autopep8 is recommended although the script exits 1, no oca-*
script is installed, the root carries neither .pre-commit-config.yaml nor
.pylintrc, and flake8 as well as pylint-odoo live only in the Odoo venv,
which nothing invokes. The template states what the tooling enforces, at
high effort, the ultracode pin remaining the user's own.
Checked: 146 rules surveyed, 142 confirmed against their citation, 4
dropped. Two guards tie each menu entry to a template that exists and
declares the right name.
Assisted-by: Claude Opus 5
2026-09-02 07:16:23 -04:00
|
|
|
class TestClaudeCommandTemplates(unittest.TestCase):
|
|
|
|
|
"""Chaque commande proposée par le menu doit avoir son gabarit.
|
|
|
|
|
|
|
|
|
|
Un nom de gabarit fautif ne se voit qu'à l'exécution, au moment où le
|
|
|
|
|
déploiement échoue devant l'utilisateur : rien ne relie le littéral passé
|
|
|
|
|
à `_setup_claude_command` au fichier de `conf/`.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _deployed_templates():
|
|
|
|
|
"""Les gabarits nommés dans les appels à `_setup_claude_command`."""
|
|
|
|
|
import ast
|
|
|
|
|
|
|
|
|
|
source = Path("script/todo/todo.py").read_text(encoding="utf-8")
|
|
|
|
|
found = []
|
|
|
|
|
for node in ast.walk(ast.parse(source)):
|
|
|
|
|
if not isinstance(node, ast.Call):
|
|
|
|
|
continue
|
|
|
|
|
attr = getattr(node.func, "attr", None)
|
|
|
|
|
if attr != "_setup_claude_command":
|
|
|
|
|
continue
|
|
|
|
|
# (nom_de_commande, nom_de_gabarit) : les deux sont des littéraux,
|
|
|
|
|
# sans quoi le test ne peut rien affirmer.
|
|
|
|
|
args = [
|
|
|
|
|
a.value
|
|
|
|
|
for a in node.args
|
|
|
|
|
if isinstance(a, ast.Constant) and isinstance(a.value, str)
|
|
|
|
|
]
|
|
|
|
|
if len(args) >= 2:
|
|
|
|
|
found.append(args[1])
|
|
|
|
|
return found
|
|
|
|
|
|
|
|
|
|
def test_every_menu_template_exists(self):
|
|
|
|
|
templates = self._deployed_templates()
|
|
|
|
|
self.assertGreaterEqual(len(templates), 4, templates)
|
|
|
|
|
for name in templates:
|
|
|
|
|
with self.subTest(template=name):
|
|
|
|
|
self.assertTrue(
|
|
|
|
|
os.path.isfile(os.path.join("conf", name)),
|
|
|
|
|
f"conf/{name} est nommé par le menu et n'existe pas",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_every_template_declares_its_own_name(self):
|
|
|
|
|
"""Le `name:` du frontmatter donne le nom de la commande `/…` ; un
|
|
|
|
|
gabarit qui en déclare un autre déploie un fichier dont le contenu
|
|
|
|
|
parle d'une commande différente."""
|
|
|
|
|
source = Path("script/todo/todo.py").read_text(encoding="utf-8")
|
|
|
|
|
import ast
|
|
|
|
|
|
|
|
|
|
pairs = []
|
|
|
|
|
for node in ast.walk(ast.parse(source)):
|
|
|
|
|
if not isinstance(node, ast.Call):
|
|
|
|
|
continue
|
|
|
|
|
if getattr(node.func, "attr", None) != "_setup_claude_command":
|
|
|
|
|
continue
|
|
|
|
|
args = [
|
|
|
|
|
a.value
|
|
|
|
|
for a in node.args
|
|
|
|
|
if isinstance(a, ast.Constant) and isinstance(a.value, str)
|
|
|
|
|
]
|
|
|
|
|
if len(args) >= 2:
|
|
|
|
|
pairs.append((args[0], args[1]))
|
|
|
|
|
self.assertTrue(pairs)
|
|
|
|
|
for command, template in pairs:
|
|
|
|
|
with self.subTest(command=command):
|
|
|
|
|
text = Path("conf", template).read_text(encoding="utf-8")
|
|
|
|
|
self.assertIn(f"name: {command}\n", text)
|
|
|
|
|
|
|
|
|
|
|
[ADD] script todo : gérer les plugins Claude Code et leur liste ERPLibre
Rien ne posait un plugin depuis TODO ; la CLI seule le faisait, hors du
menu. L'installation passe par « -y » : la sortie de TODO est un tuyau,
pas un terminal, et la CLI refuse sans lui toute installation qui
exécute une commande déclarée par un marketplace. La liste préférée
s'affiche donc AVANT la confirmation, seule occasion de la lire ; ses
quatre plugins travaillent sur le poste, sans service tiers ni compte.
La recherche lit les manifestes sur le disque, donc hors ligne.
Vérifié : 8 tests neufs, dont la frontière de mot qui sépare deux noms
dont l'un contient l'autre — une recherche naïve en rate trois.
--- EN ---
Nothing installed a plugin from TODO; the CLI alone did, outside the
menu. Installing goes through « -y »: TODO's output is a pipe, not a
terminal, and without it the CLI refuses any install that runs a
command declared by a marketplace. The preferred list is therefore
shown BEFORE the confirmation, the only chance to read it; its four
plugins run on the workstation, with no third party and no account.
Search reads the manifests from disk, hence offline.
Checked: 8 new tests, among them the word boundary separating two names
where one contains the other — a naive search misses three of them.
Assisted-by: Claude Opus 5
2026-09-02 06:35:05 -04:00
|
|
|
class TestClaudePlugins(unittest.TestCase):
|
|
|
|
|
"""Le menu des plugins Claude Code.
|
|
|
|
|
|
|
|
|
|
Ce qui est vérifié ici ne se voit pas à la lecture : la frontière de mot
|
|
|
|
|
qui distingue deux noms dont l'un contient l'autre, le refus qui n'installe
|
|
|
|
|
rien, et l'absence de l'exécutable, qui doit se dire au lieu de passer pour
|
|
|
|
|
un échec de la commande.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def test_absent_binary_reports_without_executing(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
todo.execute = MagicMock()
|
|
|
|
|
with patch("script.todo.todo.shutil.which", return_value=None), patch(
|
|
|
|
|
"builtins.print"
|
|
|
|
|
):
|
|
|
|
|
self.assertEqual(todo._claude_plugin_exec("list"), 1)
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
todo._claude_plugin_exec("list", capture=True), (1, [])
|
|
|
|
|
)
|
|
|
|
|
# La forme du retour suit l'appelant : un appelant qui déballe un
|
|
|
|
|
# couple ne doit pas recevoir un entier nu.
|
|
|
|
|
todo.execute.exec_command_live.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_installed_name_matches_on_word_boundary(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
# Chaque cas négatif CONTIENT le nom cherché comme sous-chaîne : une
|
|
|
|
|
# recherche naïve les déclarerait tous posés.
|
|
|
|
|
cases = [
|
|
|
|
|
(["code-review-toolkit@market v1.0"], "code-review", False),
|
|
|
|
|
(["my-superpowers@market v1.0"], "superpowers", False),
|
|
|
|
|
(["superpowers2@market v1.0"], "superpowers", False),
|
|
|
|
|
(["code-review@market v1.0"], "code-review", True),
|
|
|
|
|
(["No plugins installed."], "superpowers", False),
|
|
|
|
|
(["superpowers@market (enabled)"], "superpowers", True),
|
|
|
|
|
]
|
|
|
|
|
for lines, name, expected in cases:
|
|
|
|
|
with self.subTest(name=name, lines=lines):
|
|
|
|
|
with patch.object(
|
|
|
|
|
todo, "_claude_plugin_exec", return_value=(0, lines)
|
|
|
|
|
):
|
|
|
|
|
self.assertIs(
|
|
|
|
|
todo._claude_plugin_is_installed(name), expected
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_unreadable_list_reports_not_installed(self):
|
|
|
|
|
"""Un code de sortie non nul ne vaut pas « absent » par hasard : la
|
|
|
|
|
réinstallation qui suit est idempotente, l'inverse effacerait."""
|
|
|
|
|
todo = TODO()
|
|
|
|
|
with patch.object(
|
|
|
|
|
todo, "_claude_plugin_exec", return_value=(2, ["boom"])
|
|
|
|
|
):
|
|
|
|
|
self.assertFalse(todo._claude_plugin_is_installed("superpowers"))
|
|
|
|
|
|
|
|
|
|
def test_refusing_the_preferred_list_installs_nothing(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
with patch("builtins.input", return_value="n"), patch.object(
|
|
|
|
|
todo, "_claude_plugin_exec"
|
|
|
|
|
) as mock_exec, patch("builtins.print"):
|
|
|
|
|
todo._claude_install_preferred_plugins()
|
|
|
|
|
mock_exec.assert_not_called()
|
|
|
|
|
|
|
|
|
|
def test_accepting_installs_only_what_is_missing(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
with patch("builtins.input", return_value="y"), patch.object(
|
|
|
|
|
todo, "_claude_plugin_exec"
|
|
|
|
|
) as mock_exec, patch.object(
|
|
|
|
|
todo,
|
|
|
|
|
"_claude_plugin_is_installed",
|
|
|
|
|
side_effect=lambda name: name == "pyright-lsp",
|
|
|
|
|
), patch(
|
|
|
|
|
"builtins.print"
|
|
|
|
|
):
|
|
|
|
|
todo._claude_install_preferred_plugins()
|
|
|
|
|
called = [call.args[0] for call in mock_exec.call_args_list]
|
|
|
|
|
# « -y » est obligatoire : la sortie de TODO est un tuyau, et la CLI
|
|
|
|
|
# refuse sans lui toute installation qui exécute une commande.
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
called,
|
|
|
|
|
[
|
|
|
|
|
"install superpowers -y",
|
|
|
|
|
"install claude-security -y",
|
|
|
|
|
"install skill-creator -y",
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_catalog_skips_an_unreadable_manifest(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
|
|
|
for name, body in (
|
|
|
|
|
("good", '{"plugins":[{"name":"a",' '"description":"d"}]}'),
|
|
|
|
|
("broken", "{not json"),
|
|
|
|
|
):
|
|
|
|
|
folder = os.path.join(tmp, name, ".claude-plugin")
|
|
|
|
|
os.makedirs(folder)
|
|
|
|
|
with open(
|
|
|
|
|
os.path.join(folder, "marketplace.json"), "w"
|
|
|
|
|
) as handle:
|
|
|
|
|
handle.write(body)
|
|
|
|
|
with patch.object(TODO, "_CLAUDE_MARKETPLACES_DIR", tmp):
|
|
|
|
|
catalog = todo._claude_marketplace_catalog()
|
|
|
|
|
self.assertEqual(catalog, [("a", "good", "d")])
|
|
|
|
|
|
|
|
|
|
def test_catalog_is_empty_without_any_marketplace(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
with patch.object(
|
|
|
|
|
TODO, "_CLAUDE_MARKETPLACES_DIR", "/nonexistent-marketplaces"
|
|
|
|
|
):
|
|
|
|
|
self.assertEqual(todo._claude_marketplace_catalog(), [])
|
|
|
|
|
|
|
|
|
|
def test_search_matches_name_and_description(self):
|
|
|
|
|
todo = TODO()
|
|
|
|
|
catalog = [
|
|
|
|
|
("pyright-lsp", "official", "Python language server"),
|
|
|
|
|
("mongodb", "official", "Document database"),
|
|
|
|
|
]
|
|
|
|
|
with patch.object(
|
|
|
|
|
todo, "_claude_marketplace_catalog", return_value=catalog
|
|
|
|
|
), patch("builtins.input", return_value="python"), patch(
|
|
|
|
|
"builtins.print"
|
|
|
|
|
) as mock_print:
|
|
|
|
|
todo._claude_plugin_search()
|
|
|
|
|
printed = " ".join(
|
|
|
|
|
str(call.args[0]) for call in mock_print.call_args_list
|
|
|
|
|
)
|
|
|
|
|
self.assertIn("pyright-lsp", printed)
|
|
|
|
|
self.assertNotIn("mongodb", printed)
|
|
|
|
|
|
|
|
|
|
|
2026-03-10 03:59:35 -04:00
|
|
|
class TestSelectDatabase(unittest.TestCase):
|
2026-03-10 04:05:04 -04:00
|
|
|
@patch("script.todo.database_manager.click")
|
2026-03-10 03:59:35 -04:00
|
|
|
def test_select_database_returns_name(self, mock_click):
|
|
|
|
|
todo = TODO()
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager._execute = MagicMock()
|
|
|
|
|
todo.db_manager._execute.exec_command_live.return_value = (
|
2026-03-10 03:59:35 -04:00
|
|
|
0,
|
|
|
|
|
["db_test", "db_prod"],
|
|
|
|
|
)
|
|
|
|
|
mock_click.prompt.return_value = "1"
|
2026-03-10 04:05:04 -04:00
|
|
|
result = todo.db_manager.select_database()
|
2026-03-10 03:59:35 -04:00
|
|
|
self.assertEqual(result, "db_test")
|
|
|
|
|
|
2026-03-10 04:05:04 -04:00
|
|
|
@patch("script.todo.database_manager.click")
|
2026-03-10 03:59:35 -04:00
|
|
|
def test_select_database_returns_false_on_zero(self, mock_click):
|
|
|
|
|
todo = TODO()
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager._execute = MagicMock()
|
|
|
|
|
todo.db_manager._execute.exec_command_live.return_value = (
|
|
|
|
|
0,
|
|
|
|
|
["db_test"],
|
|
|
|
|
)
|
2026-03-10 03:59:35 -04:00
|
|
|
mock_click.prompt.return_value = "0"
|
2026-03-10 04:05:04 -04:00
|
|
|
result = todo.db_manager.select_database()
|
2026-03-10 03:59:35 -04:00
|
|
|
self.assertFalse(result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestRestoreFromDatabase(unittest.TestCase):
|
|
|
|
|
@patch("builtins.input")
|
|
|
|
|
def test_restore_by_filename(self, mock_input):
|
|
|
|
|
todo = TODO()
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager._execute = MagicMock()
|
|
|
|
|
todo.db_manager._execute.exec_command_live.return_value = (
|
|
|
|
|
0,
|
|
|
|
|
[],
|
|
|
|
|
)
|
2026-03-10 03:59:35 -04:00
|
|
|
# status="1" (by filename), db name default, no neutralize
|
|
|
|
|
mock_input.side_effect = ["1", "", "n", "n"]
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager.restore_from_database()
|
|
|
|
|
cmd = todo.db_manager._execute.exec_command_live.call_args_list[0][0][
|
|
|
|
|
0
|
|
|
|
|
]
|
2026-03-10 03:59:35 -04:00
|
|
|
self.assertIn("db_restore.py", cmd)
|
|
|
|
|
|
|
|
|
|
@patch("builtins.input")
|
|
|
|
|
def test_restore_with_neutralize(self, mock_input):
|
|
|
|
|
todo = TODO()
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager._execute = MagicMock()
|
|
|
|
|
todo.db_manager._execute.exec_command_live.return_value = (
|
|
|
|
|
0,
|
|
|
|
|
[],
|
|
|
|
|
)
|
2026-03-10 03:59:35 -04:00
|
|
|
mock_input.side_effect = ["1", "mydb", "y", "n"]
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager.restore_from_database()
|
|
|
|
|
cmd = todo.db_manager._execute.exec_command_live.call_args_list[0][0][
|
|
|
|
|
0
|
|
|
|
|
]
|
2026-03-10 03:59:35 -04:00
|
|
|
self.assertIn("--neutralize", cmd)
|
|
|
|
|
self.assertIn("mydb_neutralize", cmd)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestCreateBackupFromDatabase(unittest.TestCase):
|
2026-03-10 04:05:04 -04:00
|
|
|
@patch("script.todo.database_manager.click")
|
2026-03-10 03:59:35 -04:00
|
|
|
@patch("builtins.input")
|
|
|
|
|
def test_creates_backup_command(self, mock_input, mock_click):
|
|
|
|
|
todo = TODO()
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager._execute = MagicMock()
|
|
|
|
|
todo.db_manager._execute.exec_command_live.return_value = (
|
2026-03-10 03:59:35 -04:00
|
|
|
0,
|
|
|
|
|
["test_db"],
|
|
|
|
|
)
|
|
|
|
|
mock_click.prompt.return_value = "1"
|
|
|
|
|
# backup name input
|
|
|
|
|
mock_input.return_value = "backup.zip"
|
2026-03-10 04:05:04 -04:00
|
|
|
todo.db_manager.create_backup_from_database()
|
|
|
|
|
cmd = todo.db_manager._execute.exec_command_live.call_args_list[-1][0][
|
|
|
|
|
0
|
|
|
|
|
]
|
2026-03-10 03:59:35 -04:00
|
|
|
self.assertIn("--backup", cmd)
|
|
|
|
|
self.assertIn("test_db", cmd)
|
|
|
|
|
|
|
|
|
|
|
2026-08-10 03:10:50 -04:00
|
|
|
class TestModuleLevelAbortExit(unittest.TestCase):
|
|
|
|
|
"""`click.exceptions.Abort` (raised by `click.prompt` on both Ctrl+C and
|
|
|
|
|
Ctrl+D/EOF - see click's own `termui.prompt_func`) is NOT a
|
|
|
|
|
`KeyboardInterrupt` subclass. Only the top-level menu's `click.prompt`
|
|
|
|
|
call is wrapped locally, inside `run()` (todo.py around line 149) -
|
|
|
|
|
every submenu (`prompt_assistant`, etc.) lets `Abort` propagate
|
|
|
|
|
uncaught. These tests drive the real script end to end (not a mock of
|
|
|
|
|
the dispatch chain) to prove the module-level guard around
|
|
|
|
|
`todo.run()` (todo.py around line 7159) now catches it too.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _run_todo(self, stdin_text):
|
|
|
|
|
repo_root = Path(__file__).resolve().parent.parent
|
|
|
|
|
python_bin = repo_root / ".venv.erplibre" / "bin" / "python3"
|
|
|
|
|
env = os.environ.copy()
|
|
|
|
|
with tempfile.TemporaryDirectory() as home_dir:
|
|
|
|
|
env["HOME"] = home_dir
|
|
|
|
|
return subprocess.run(
|
|
|
|
|
[str(python_bin), "script/todo/todo.py"],
|
|
|
|
|
cwd=repo_root,
|
|
|
|
|
input=stdin_text,
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
env=env,
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_ctrl_d_in_a_submenu_exits_cleanly(self):
|
|
|
|
|
# "3" enters the Assistant submenu; the immediate EOF that follows
|
|
|
|
|
# raises Abort from a click.prompt() call that run() does not wrap.
|
|
|
|
|
result = self._run_todo("3\n")
|
|
|
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
|
self.assertNotIn("Traceback", result.stderr)
|
|
|
|
|
self.assertNotIn("click.exceptions.Abort", result.stderr)
|
|
|
|
|
|
|
|
|
|
def test_ctrl_d_on_the_top_menu_still_exits_cleanly(self):
|
|
|
|
|
# Regression guard: the pre-existing local handler in run() must
|
|
|
|
|
# keep working once the module-level guard is added alongside it.
|
|
|
|
|
result = self._run_todo("")
|
|
|
|
|
self.assertEqual(result.returncode, 0, result.stderr)
|
|
|
|
|
self.assertNotIn("Traceback", result.stderr)
|
|
|
|
|
|
|
|
|
|
|
[ADD] transform data : anonymiser un fichier externe ou une base Odoo
Partager un fichier ou une base demandait d'anonymiser à la main,
colonne par colonne, ou de ne pas partager. L'entrée ouvre un fichier
externe — Excel, Access, CSV, XML, JSON — ou une base Odoo, dit ce
qu'elle porte, puis en tire une copie. L'original n'est jamais touché.
Deux règles gouvernent ce qui sort. Une colonne n'est laissée intacte
que sur son CONTENU mesuré, hors onze étiquettes structurelles : décider
sur l'étiquette recopiait les colonnes les plus identifiantes en les
annonçant protégées. Et la copie est relue octet par octet, un classeur
portant de la donnée en une douzaine d'endroits hors des cellules.
Vérifié : 5 748 tests — 13 rouges, déjà rouges sur master.
--- EN ---
Sharing a file or a database meant anonymising by hand, column by
column, or not sharing. The entry opens an external file — Excel,
Access, CSV, XML, JSON — or an Odoo database, says what it holds, then
draws a copy of it. The original is never touched.
Two rules govern what comes out. A column is left alone on its measured
CONTENT, bar eleven structural labels: deciding on the label copied the
most identifying columns verbatim while announcing them as protected.
And the copy is reread byte by byte, a workbook holding data in a dozen
places that are not cells.
Checked: 5,748 tests — 13 red, already red on master.
Assisted-by: Claude Opus 5
2026-09-10 23:58:01 -04:00
|
|
|
class TestOptionsDeLAnonymiseur(unittest.TestCase):
|
|
|
|
|
"""Le mode, les listes, et les deux options qui suivent.
|
|
|
|
|
|
|
|
|
|
La garde de la liste blanche appartient à la question des MODÈLES :
|
|
|
|
|
c'est le `elif` de son `if`. Une question insérée entre les deux la
|
|
|
|
|
rattache à la dernière posée, si bien qu'elle refuse selon une
|
|
|
|
|
réponse qui n'est pas la sienne et laisse passer une liste blanche
|
|
|
|
|
vide — laquelle n'anonymise rien, en l'annonçant comme un succès.
|
|
|
|
|
|
|
|
|
|
Les cinq chemins du mode sont couverts ici, plus le fichier de mots
|
|
|
|
|
introuvable.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
from script.todo.todo import TODO
|
|
|
|
|
|
|
|
|
|
self.todo = TODO.__new__(TODO)
|
|
|
|
|
self.vrai_input = builtins.input
|
|
|
|
|
self.addCleanup(setattr, builtins, "input", self.vrai_input)
|
|
|
|
|
self.sortie = io.StringIO()
|
|
|
|
|
vrai = sys.stdout
|
|
|
|
|
sys.stdout = self.sortie
|
|
|
|
|
self.addCleanup(setattr, sys, "stdout", vrai)
|
|
|
|
|
|
|
|
|
|
def _repondre(self, mode, *reponses):
|
|
|
|
|
import script.todo.todo as module
|
|
|
|
|
|
|
|
|
|
suite = iter(reponses)
|
|
|
|
|
builtins.input = lambda invite="": next(suite)
|
|
|
|
|
vrai = module.click.prompt
|
|
|
|
|
module.click.prompt = lambda *a, **k: mode
|
|
|
|
|
self.addCleanup(setattr, module.click, "prompt", vrai)
|
|
|
|
|
return self.todo._monitoring_anonymize_options()
|
|
|
|
|
|
|
|
|
|
def test_une_liste_blanche_sans_modele_est_REFUSEE(self):
|
|
|
|
|
"""Elle ne ferait rien : le dire vaut mieux que la lancer."""
|
|
|
|
|
self.assertIsNone(self._repondre("2", "", "n", "n", ""))
|
|
|
|
|
|
|
|
|
|
def test_une_liste_blanche_avec_un_modele_passe(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self._repondre("2", "res.partner", "n", "n", ""),
|
|
|
|
|
["--mode", "whitelist", "--models", "res.partner"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_l_hybride_sans_modele_passe(self):
|
|
|
|
|
"""Ses défauts SONT sa liste : rien à nommer."""
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self._repondre("1", "", "n", "n", ""), ["--mode", "hybrid"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_les_deux_options_suivent_le_mode(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self._repondre("1", "", "o", "o", ""),
|
|
|
|
|
["--mode", "hybrid", "--include-logins", "--keep-digits"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_la_liste_noire_exclut(self):
|
|
|
|
|
self.assertEqual(
|
|
|
|
|
self._repondre("3", "res.users", "n", "n", ""),
|
|
|
|
|
["--mode", "blacklist", "--exclude", "res.users"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_un_mode_inconnu_renonce(self):
|
|
|
|
|
self.assertIsNone(self._repondre("9"))
|
|
|
|
|
|
|
|
|
|
def test_un_fichier_de_mots_introuvable_est_REFUSE(self):
|
|
|
|
|
"""Le lancer produirait une trace au lieu d'un message."""
|
|
|
|
|
self.assertIsNone(
|
|
|
|
|
self._repondre("1", "", "n", "n", "/nexistepas/mots.py")
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestAttributsDeTODO(unittest.TestCase):
|
|
|
|
|
"""Tout `self.X` que `TODO` LIT est-il posé par TODO ou un mixin ?
|
|
|
|
|
|
|
|
|
|
Un attribut mal orthographié ne lève qu'à l'exécution de l'entrée qui
|
|
|
|
|
le touche : `self._execute` là où `TODO` pose `self.execute` traverse
|
|
|
|
|
tout contrôle statique et attend l'opérateur.
|
|
|
|
|
|
|
|
|
|
Le nom se cherche dans les classes dont TODO HÉRITE, et nulle part
|
|
|
|
|
ailleurs : `DatabaseManager` pose bien `_execute`, si bien que le
|
|
|
|
|
chercher dans tout `script/todo/` le trouve et ne voit rien. Un
|
|
|
|
|
bouchon de test qui fournit l'attribut masque la faute de la même
|
|
|
|
|
façon.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
RACINE = Path(__file__).resolve().parents[1] / "script" / "todo"
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
def _noms_de_classe(classe):
|
|
|
|
|
"""Méthodes et attributs que cette classe POSE."""
|
|
|
|
|
noms = set()
|
|
|
|
|
for noeud in ast.walk(classe):
|
|
|
|
|
if isinstance(noeud, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
|
|
|
noms.add(noeud.name)
|
|
|
|
|
if (
|
|
|
|
|
isinstance(noeud, ast.Attribute)
|
|
|
|
|
and isinstance(noeud.value, ast.Name)
|
|
|
|
|
and noeud.value.id == "self"
|
|
|
|
|
and isinstance(noeud.ctx, ast.Store)
|
|
|
|
|
):
|
|
|
|
|
noms.add(noeud.attr)
|
|
|
|
|
for corps in classe.body:
|
|
|
|
|
if isinstance(corps, ast.Assign):
|
|
|
|
|
for cible in corps.targets:
|
|
|
|
|
if isinstance(cible, ast.Name):
|
|
|
|
|
noms.add(cible.id)
|
|
|
|
|
return noms
|
|
|
|
|
|
|
|
|
|
def _classe_todo(self):
|
|
|
|
|
source = (self.RACINE / "todo.py").read_text(encoding="utf-8")
|
|
|
|
|
arbre = ast.parse(source)
|
|
|
|
|
return next(
|
|
|
|
|
n
|
|
|
|
|
for n in ast.walk(arbre)
|
|
|
|
|
if isinstance(n, ast.ClassDef) and n.name == "TODO"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def test_aucun_attribut_lu_sans_etre_pose(self):
|
|
|
|
|
todo = self._classe_todo()
|
|
|
|
|
bases = {b.id for b in todo.bases if isinstance(b, ast.Name)}
|
|
|
|
|
self.assertGreater(len(bases), 5, "TODO est composé de mixins")
|
|
|
|
|
poses = self._noms_de_classe(todo)
|
|
|
|
|
trouvees = set()
|
|
|
|
|
for chemin in sorted(self.RACINE.rglob("*.py")):
|
|
|
|
|
try:
|
|
|
|
|
arbre = ast.parse(chemin.read_text(encoding="utf-8"))
|
|
|
|
|
except SyntaxError: # pragma: no cover - fichier en travaux
|
|
|
|
|
continue
|
|
|
|
|
for noeud in ast.walk(arbre):
|
|
|
|
|
if isinstance(noeud, ast.ClassDef) and noeud.name in bases:
|
|
|
|
|
poses |= self._noms_de_classe(noeud)
|
|
|
|
|
trouvees.add(noeud.name)
|
|
|
|
|
# Une base introuvable rendrait le contrôle muet : ses noms
|
|
|
|
|
# manqueraient, et tout ce qu'elle pose passerait pour orphelin.
|
|
|
|
|
self.assertEqual(bases - trouvees, set(), "base introuvable")
|
|
|
|
|
lus = {
|
|
|
|
|
noeud.attr
|
|
|
|
|
for noeud in ast.walk(todo)
|
|
|
|
|
if isinstance(noeud, ast.Attribute)
|
|
|
|
|
and isinstance(noeud.value, ast.Name)
|
|
|
|
|
and noeud.value.id == "self"
|
|
|
|
|
and isinstance(noeud.ctx, ast.Load)
|
|
|
|
|
}
|
|
|
|
|
self.assertEqual(sorted(lus - poses), [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestLeFluxQuiEcrit(unittest.TestCase):
|
|
|
|
|
"""`_monitoring_write_flow` : les codes de sortie sont un CONTRAT.
|
|
|
|
|
|
|
|
|
|
La marche à blanc rend 2 pour un refus, 1 quand il y a du travail, et
|
|
|
|
|
0 quand il n'y a RIEN à anonymiser. Ne distinguer que le 2 fait
|
|
|
|
|
confirmer puis « appliquer » un plan vide : l'appelant tire alors une
|
|
|
|
|
sauvegarde de la base INTACTE et l'annonce anonymisée.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
from script.analyse import monitoring
|
|
|
|
|
|
|
|
|
|
self.todo = TODO.__new__(TODO)
|
|
|
|
|
self.todo._monitoring_anonymize_options = lambda: ["--mode", "hybrid"]
|
|
|
|
|
self.monitoring = monitoring
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
setattr, monitoring, "run_analysis", monitoring.run_analysis
|
|
|
|
|
)
|
|
|
|
|
self.addCleanup(setattr, builtins, "input", builtins.input)
|
|
|
|
|
self.sortie = io.StringIO()
|
|
|
|
|
vrai = sys.stdout
|
|
|
|
|
sys.stdout = self.sortie
|
|
|
|
|
self.addCleanup(setattr, sys, "stdout", vrai)
|
|
|
|
|
self.appels = []
|
|
|
|
|
|
|
|
|
|
def _codes(self, *codes):
|
|
|
|
|
"""Les codes que la marche à blanc puis l'écriture rendront."""
|
|
|
|
|
suite = iter(codes)
|
|
|
|
|
|
|
|
|
|
def faux(analyse, database, **kw):
|
|
|
|
|
self.appels.append(list(kw.get("extra") or []))
|
|
|
|
|
return next(suite)
|
|
|
|
|
|
|
|
|
|
self.monitoring.run_analysis = faux
|
|
|
|
|
|
|
|
|
|
def _taper(self, *reponses):
|
|
|
|
|
suite = iter(reponses)
|
|
|
|
|
builtins.input = lambda invite="": next(suite)
|
|
|
|
|
|
|
|
|
|
def _dit(self, cle):
|
|
|
|
|
return todo_i18n.t(cle) in self.sortie.getvalue()
|
|
|
|
|
|
|
|
|
|
def test_un_plan_vide_ne_se_fait_pas_confirmer(self):
|
|
|
|
|
"""Retaper le nom d'une base qu'on ne touchera pas obtient un
|
|
|
|
|
consentement sans objet."""
|
|
|
|
|
self._codes(0)
|
|
|
|
|
self._taper() # aucune invite ne doit être posée
|
|
|
|
|
self.assertIs(False, self.todo._monitoring_write_flow({}, "base"))
|
|
|
|
|
self.assertEqual(1, len(self.appels))
|
|
|
|
|
|
|
|
|
|
def test_un_plan_vide_le_DIT(self):
|
|
|
|
|
self._codes(0)
|
|
|
|
|
self.todo._monitoring_write_flow({}, "base")
|
|
|
|
|
self.assertTrue(self._dit("Nothing to anonymise: nothing to confirm."))
|
|
|
|
|
|
|
|
|
|
def test_un_refus_de_la_marche_a_blanc_arrete(self):
|
|
|
|
|
self._codes(2)
|
|
|
|
|
self.assertIs(False, self.todo._monitoring_write_flow({}, "base"))
|
|
|
|
|
self.assertEqual(1, len(self.appels))
|
|
|
|
|
|
|
|
|
|
def test_du_travail_annonce_demande_le_nom_puis_ecrit(self):
|
|
|
|
|
self._codes(3, 0)
|
|
|
|
|
self._taper("base")
|
|
|
|
|
self.assertIs(True, self.todo._monitoring_write_flow({}, "base"))
|
|
|
|
|
self.assertEqual(2, len(self.appels))
|
|
|
|
|
self.assertIn("--apply", self.appels[1])
|
|
|
|
|
self.assertIn("--confirm", self.appels[1])
|
|
|
|
|
|
|
|
|
|
def test_un_nom_mal_retape_n_ecrit_rien(self):
|
|
|
|
|
self._codes(3)
|
|
|
|
|
self._taper("bas")
|
|
|
|
|
self.assertIs(False, self.todo._monitoring_write_flow({}, "base"))
|
|
|
|
|
self.assertEqual(1, len(self.appels))
|
|
|
|
|
|
|
|
|
|
def test_une_ecriture_en_erreur_rend_FAUX(self):
|
|
|
|
|
"""L'appelant ne doit pas tirer de sauvegarde derrière."""
|
|
|
|
|
self._codes(3, 2)
|
|
|
|
|
self._taper("base")
|
|
|
|
|
self.assertIs(False, self.todo._monitoring_write_flow({}, "base"))
|
|
|
|
|
|
|
|
|
|
def test_renoncer_aux_options_n_appelle_rien(self):
|
|
|
|
|
self.todo._monitoring_anonymize_options = lambda: None
|
|
|
|
|
self._codes()
|
|
|
|
|
self.assertIs(False, self.todo._monitoring_write_flow({}, "base"))
|
|
|
|
|
self.assertEqual([], self.appels)
|
|
|
|
|
|
|
|
|
|
def test_seul_le_code_du_TRAVAIL_ouvre_la_confirmation(self):
|
|
|
|
|
"""Une trace Python sort en 1. Lire « ni 0 ni 2 » comme du travail
|
|
|
|
|
faisait demander la confirmation destructrice après un plantage,
|
|
|
|
|
puis « appliquer » un plan jamais calculé."""
|
|
|
|
|
for code in (0, 1, 2, 4, 5, 127):
|
|
|
|
|
with self.subTest(code=code):
|
|
|
|
|
self._codes(code)
|
|
|
|
|
self.appels = []
|
|
|
|
|
self._taper() # aucune invite ne doit être posée
|
|
|
|
|
self.assertIs(
|
|
|
|
|
False, self.todo._monitoring_write_flow({}, "base")
|
|
|
|
|
)
|
|
|
|
|
self.assertEqual(1, len(self.appels))
|
|
|
|
|
|
|
|
|
|
def test_un_code_inattendu_le_DIT(self):
|
|
|
|
|
"""Le refus, lui, a déjà parlé : ne pas le redoubler."""
|
|
|
|
|
self._codes(1)
|
|
|
|
|
self.todo._monitoring_write_flow({}, "base")
|
|
|
|
|
self.assertTrue(self._dit("The dry run ended on an unexpected code:"))
|
|
|
|
|
self.sortie.truncate(0)
|
|
|
|
|
self.sortie.seek(0)
|
|
|
|
|
self._codes(2)
|
|
|
|
|
self.appels = []
|
|
|
|
|
self.todo._monitoring_write_flow({}, "base")
|
|
|
|
|
self.assertFalse(self._dit("The dry run ended on an unexpected code:"))
|
|
|
|
|
|
|
|
|
|
def test_une_ecriture_SANS_EFFET_ne_vaut_pas_une_ecriture(self):
|
|
|
|
|
"""Un plan devenu vide entre les deux passes rendait 0, psql
|
|
|
|
|
acceptant un script vide : l'appelant tirait alors une sauvegarde
|
|
|
|
|
de la base intacte en l'annonçant anonymisée."""
|
|
|
|
|
from script.analyse import anonymize
|
|
|
|
|
|
|
|
|
|
self._codes(3, anonymize.SORTIE_SANS_EFFET)
|
|
|
|
|
self._taper("base")
|
|
|
|
|
self.assertIs(False, self.todo._monitoring_write_flow({}, "base"))
|
|
|
|
|
|
|
|
|
|
def test_les_codes_sont_ceux_que_le_moteur_declare(self):
|
|
|
|
|
"""Le contrat vit dans `anonymize`, pas en double ici."""
|
|
|
|
|
from script.analyse import anonymize
|
|
|
|
|
|
|
|
|
|
self.assertEqual(0, anonymize.SORTIE_RIEN)
|
|
|
|
|
self.assertEqual(2, anonymize.SORTIE_REFUS)
|
|
|
|
|
self.assertEqual(3, anonymize.SORTIE_A_FAIRE)
|
|
|
|
|
self.assertEqual(4, anonymize.SORTIE_SANS_EFFET)
|
|
|
|
|
self.assertNotIn(
|
|
|
|
|
1,
|
|
|
|
|
(
|
|
|
|
|
anonymize.SORTIE_RIEN,
|
|
|
|
|
anonymize.SORTIE_REFUS,
|
|
|
|
|
anonymize.SORTIE_A_FAIRE,
|
|
|
|
|
anonymize.SORTIE_SANS_EFFET,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-03-10 02:49:03 -04:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|