From f78f5785522d947663ced3527cfb0a92f09c2c82 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 10 Mar 2026 02:09:17 -0400 Subject: [PATCH] [ADD] test: add unit tests for ConfigFile Ensure deep_merge_with_lists, get_config, and get_config_value behave correctly with multi-layer config merging (base, override, private) to prevent regressions in config resolution logic. Generated by Claude Code 2.1.72 model claude-opus-4-6 Co-Authored-By: Mathieu Benoit --- test/test_config_file.py | 262 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 test/test_config_file.py diff --git a/test/test_config_file.py b/test/test_config_file.py new file mode 100644 index 0000000..9e6b216 --- /dev/null +++ b/test/test_config_file.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +# © 2025 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import json +import os +import tempfile +import unittest +from unittest.mock import patch + +from script.config.config_file import ConfigFile + + +class TestDeepMergeWithLists(unittest.TestCase): + def setUp(self): + self.cfg = ConfigFile() + + def test_empty_dicts(self): + result = self.cfg.deep_merge_with_lists({}, {}) + self.assertEqual(result, {}) + + def test_dest_only(self): + result = self.cfg.deep_merge_with_lists( + {"a": 1, "b": 2}, {} + ) + self.assertEqual(result, {"a": 1, "b": 2}) + + def test_src_only(self): + result = self.cfg.deep_merge_with_lists( + {}, {"a": 1, "b": 2} + ) + self.assertEqual(result, {"a": 1, "b": 2}) + + def test_simple_merge(self): + result = self.cfg.deep_merge_with_lists( + {"a": 1}, {"b": 2} + ) + self.assertEqual(result, {"a": 1, "b": 2}) + + def test_src_overrides_dest_string(self): + result = self.cfg.deep_merge_with_lists( + {"a": "old"}, {"a": "new"} + ) + self.assertEqual(result, {"a": "new"}) + + def test_empty_src_string_keeps_dest(self): + result = self.cfg.deep_merge_with_lists( + {"a": "old"}, {"a": ""} + ) + self.assertEqual(result, {"a": "old"}) + + def test_nested_dict_merge(self): + dest = {"a": {"x": 1, "y": 2}} + src = {"a": {"y": 3, "z": 4}} + result = self.cfg.deep_merge_with_lists(dest, src) + self.assertEqual(result, {"a": {"x": 1, "y": 3, "z": 4}}) + + def test_deeply_nested_dict_merge(self): + dest = {"a": {"b": {"c": 1}}} + src = {"a": {"b": {"d": 2}}} + result = self.cfg.deep_merge_with_lists(dest, src) + self.assertEqual(result, {"a": {"b": {"c": 1, "d": 2}}}) + + def test_list_replace_strategy(self): + result = self.cfg.deep_merge_with_lists( + {"a": [1, 2]}, {"a": [3, 4]}, list_strategy="replace" + ) + self.assertEqual(result, {"a": [3, 4]}) + + def test_list_extend_strategy(self): + result = self.cfg.deep_merge_with_lists( + {"a": [1, 2]}, {"a": [3, 4]}, list_strategy="extend" + ) + self.assertEqual(result, {"a": [1, 2, 3, 4]}) + + def test_dest_dict_not_mutated(self): + dest = {"a": {"x": 1}} + src = {"a": {"y": 2}} + self.cfg.deep_merge_with_lists(dest, src) + self.assertEqual(dest, {"a": {"x": 1}}) + + def test_src_overrides_non_string_non_dict_non_list(self): + result = self.cfg.deep_merge_with_lists( + {"a": 1}, {"a": 2} + ) + self.assertEqual(result, {"a": 2}) + + +class TestGetConfig(unittest.TestCase): + def setUp(self): + self.cfg = ConfigFile() + self.tmpdir = tempfile.mkdtemp() + + def tearDown(self): + for f in os.listdir(self.tmpdir): + os.remove(os.path.join(self.tmpdir, f)) + os.rmdir(self.tmpdir) + + def _write_json(self, filename, data): + path = os.path.join(self.tmpdir, filename) + with open(path, "w") as f: + json.dump(data, f) + return path + + def test_get_config_base_only(self): + base_path = self._write_json( + "base.json", {"instance": [{"name": "test"}]} + ) + with patch( + "script.config.config_file.CONFIG_FILE", base_path + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + os.path.join(self.tmpdir, "nonexistent1.json"), + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + os.path.join(self.tmpdir, "nonexistent2.json"), + ): + result = self.cfg.get_config("instance") + self.assertEqual(result, [{"name": "test"}]) + + def test_get_config_returns_none_for_missing_key(self): + base_path = self._write_json("base.json", {"a": 1}) + with patch( + "script.config.config_file.CONFIG_FILE", base_path + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + os.path.join(self.tmpdir, "nonexistent1.json"), + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + os.path.join(self.tmpdir, "nonexistent2.json"), + ): + result = self.cfg.get_config("missing") + self.assertIsNone(result) + + def test_get_config_override_merges(self): + base_path = self._write_json( + "base.json", + {"instance": [{"name": "base"}]}, + ) + override_path = self._write_json( + "override.json", + {"instance": [{"name": "override"}]}, + ) + with patch( + "script.config.config_file.CONFIG_FILE", base_path + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + override_path, + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + os.path.join(self.tmpdir, "nonexistent.json"), + ): + result = self.cfg.get_config("instance") + # Lists with extend: base + override + self.assertEqual( + result, + [{"name": "base"}, {"name": "override"}], + ) + + def test_get_config_private_merges(self): + base_path = self._write_json( + "base.json", + {"data": {"key": "base_val"}}, + ) + private_path = self._write_json( + "private.json", + {"data": {"key": "private_val"}}, + ) + with patch( + "script.config.config_file.CONFIG_FILE", base_path + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + os.path.join(self.tmpdir, "nonexistent.json"), + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + private_path, + ): + result = self.cfg.get_config("data") + self.assertEqual(result, {"key": "private_val"}) + + def test_get_config_all_three_files(self): + base_path = self._write_json( + "base.json", + {"items": [1], "meta": {"a": "base"}}, + ) + override_path = self._write_json( + "override.json", + {"items": [2], "meta": {"b": "override"}}, + ) + private_path = self._write_json( + "private.json", + {"items": [3], "meta": {"a": "private"}}, + ) + with patch( + "script.config.config_file.CONFIG_FILE", base_path + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + override_path, + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + private_path, + ): + result_items = self.cfg.get_config("items") + result_meta = self.cfg.get_config("meta") + # Merge order: base + private, then + override + # Lists extend: [1] + [3] = [1,3], then [1,3] + [2] = [1,3,2] + self.assertEqual(result_items, [1, 3, 2]) + # Dict merge: {a: base} + {a: private} = {a: private} + # then {a: private} + {b: override} = {a: private, b: override} + self.assertEqual( + result_meta, {"a": "private", "b": "override"} + ) + + def test_no_config_files_exist(self): + with patch( + "script.config.config_file.CONFIG_FILE", + os.path.join(self.tmpdir, "nope1.json"), + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_FILE", + os.path.join(self.tmpdir, "nope2.json"), + ), patch( + "script.config.config_file.CONFIG_OVERRIDE_PRIVATE_FILE", + os.path.join(self.tmpdir, "nope3.json"), + ): + result = self.cfg.get_config("anything") + self.assertIsNone(result) + + +class TestGetConfigValue(unittest.TestCase): + def setUp(self): + self.cfg = ConfigFile() + + def test_nested_value(self): + with patch.object( + self.cfg, + "get_config", + return_value={"level1": {"level2": "found"}}, + ): + result = self.cfg.get_config_value( + ["root", "level1", "level2"] + ) + self.assertEqual(result, "found") + + def test_single_key(self): + with patch.object( + self.cfg, + "get_config", + return_value={"key": "value"}, + ): + result = self.cfg.get_config_value(["root"]) + self.assertEqual(result, {"key": "value"}) + + +class TestGetLogoAsciiFilePath(unittest.TestCase): + def test_returns_logo_path(self): + cfg = ConfigFile() + result = cfg.get_logo_ascii_file_path() + self.assertEqual(result, "./script/todo/logo_ascii.txt") + + +if __name__ == "__main__": + unittest.main()