diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..b59bda2 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.10", "3.12"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install openai tqdm + + - name: Syntax gate (compileall) + run: python -m compileall . -q -x '(archive|__pycache__|\.git)' + + - name: Run tests + run: python -m unittest discover -s tests -v diff --git a/README.md b/README.md index 470cf21..4198905 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # W40K Rogue Trader Translator (EN → PT-BR) +![tests](https://github.com/ltsuemitsu/w40k-tradutor/actions/workflows/tests.yml/badge.svg) + A fan-made toolkit to translate *Warhammer 40,000: Rogue Trader* (Owlcat Games) localization files from English to Brazilian Portuguese, using LLM APIs (DeepSeek by default; also Zhipu GLM or any OpenAI-compatible endpoint). @@ -126,6 +128,19 @@ Tracked in `GUI_ROADMAP.md`. Highlights: direct function integration in the GUI (replacing subprocess calls), interactive string reviewer, secure key storage via `keyring`, PyInstaller packaging, cost dashboard, tests. +## Development + +Run the test suite (stdlib `unittest` only — no pytest needed): + +```bash +pip install openai tqdm +python -m unittest discover -s tests -v +``` + +Tests use tiny synthetic fixtures — no network, no API keys, no LLM calls. +CI runs a compile-all syntax gate plus the suite on Ubuntu and Windows × +Python 3.10/3.12 (see `.github/workflows/tests.yml`). + ## Contributing Issues and PRs are welcome. Please **never** commit game localization files, diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_diff_tool.py b/tests/test_diff_tool.py new file mode 100644 index 0000000..7fac8cc --- /dev/null +++ b/tests/test_diff_tool.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +"""Tests for diff_tool.py — audit, update detection, smart diff. + +Pure functions over synthetic Owlcat localization dicts: +{"strings": {"": {"Offset": int, "Text": str}}}. No network, no files needed. +""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from diff_tool import audit_translation, detect_update, smart_diff + + +def _loc(entries): + """entries: dict uuid -> text. Builds a localization dict.""" + return {"strings": {k: {"Offset": i, "Text": t} for i, (k, t) in enumerate(entries.items())}} + + +EMPTY_GLOSSARY = {"metadata": {}, "terms": []} + + +class TestDetectUpdate(unittest.TestCase): + def test_only_added_and_changed_in_delta(self): + en_old = _loc({ + "uuid-1": "Hello world.", + "uuid-2": "The Emperor protects.", + "uuid-3": "This string was removed.", + }) + en_new = _loc({ + "uuid-1": "Hello world.", # unchanged + "uuid-2": "The Emperor protects us all.", # modified + "uuid-4": "Brand new string.", # added + }) + pt_current = _loc({"uuid-1": "Olá mundo.", "uuid-2": "O Imperador protege."}) + + result = detect_update(en_new, en_old, pt_current) + + self.assertEqual({k for k, _ in result["new_keys"]}, {"uuid-4"}) + self.assertEqual({k for k, _, _ in result["modified_keys"]}, {"uuid-2"}) + self.assertEqual(set(result["removed_keys"]), {"uuid-3"}) + self.assertEqual(set(result["unchanged_keys"]), {"uuid-1"}) + + def test_modified_entry_carries_old_text(self): + en_old = _loc({"uuid-2": "Old wording."}) + en_new = _loc({"uuid-2": "New wording."}) + result = detect_update(en_new, en_old, _loc({})) + _, item, old_text = result["modified_keys"][0] + self.assertEqual(item["Text"], "New wording.") + self.assertEqual(old_text, "Old wording.") + + +class TestAuditTranslation(unittest.TestCase): + def test_flags_untranslated_and_accepts_translated(self): + orig = _loc({ + "uuid-1": "The sword causes damage to the enemy.", + "uuid-2": "The door is locked tight.", + }) + trans = _loc({ + "uuid-1": "A espada causa dano ao inimigo.", # properly translated + "uuid-2": "The door is locked tight.", # EN == PT: untranslated + }) + cats = audit_translation(orig, trans, EMPTY_GLOSSARY) + + identical_keys = {k for k, _, _ in cats["identical"]} + ok_keys = {k for k, _, _ in cats["ok"]} + self.assertIn("uuid-2", identical_keys) + self.assertIn("uuid-1", ok_keys) + self.assertNotIn("uuid-1", identical_keys) + self.assertNotIn("uuid-2", ok_keys) + + def test_glossary_term_is_not_flagged_as_untranslated(self): + glossary = {"metadata": {}, "terms": [{ + "term_english": "Plasma Gun", "term_translated": "Plasma Gun", + "category": "weapon", "preserve": True, + }]} + orig = _loc({"uuid-1": "Plasma Gun"}) + trans = _loc({"uuid-1": "Plasma Gun"}) # same text, but it is a glossary term + cats = audit_translation(orig, trans, glossary) + preserved_keys = {k for k, _, _ in cats["glossary_preserved"]} + identical_keys = {k for k, _, _ in cats["identical"]} + self.assertIn("uuid-1", preserved_keys) + self.assertNotIn("uuid-1", identical_keys) + + +class TestSmartDiff(unittest.TestCase): + GLOSSARY = {"metadata": {}, "terms": [{ + "term_english": "Plasma Gun", "term_translated": "Plasma Gun", + "category": "weapon", "preserve": True, "source": "test", + "context": "", "confidence": "high", "usage_count": 1, + "created_at": "2026-01-01T00:00:00", + }]} + + def test_flags_needs_work_and_detects_preserved_in_context(self): + orig = _loc({ + "uuid-1": "Equip the Plasma Gun before the fight.", + "uuid-2": "The door is locked tight.", + "uuid-3": "The window hangs open today.", + }) + trans = _loc({ + # Translated, keeps the glossary term in EN (correct behavior) + "uuid-1": "Equipe a Plasma Gun antes da luta.", + # Fully translated, no glossary term involved + "uuid-2": "A porta está bem trancada.", + # Untranslated -> needs work + "uuid-3": "The window hangs open today.", + }) + result, needs_work = smart_diff(orig, trans, self.GLOSSARY) + + preserved_keys = {k for k, _, _, _ in result["preserved"]} + ok_keys = {k for k, _, _ in result["ok"]} + needs_keys = {k for k, _, _, _ in needs_work} + + self.assertIn("uuid-1", preserved_keys) + # The glossary term found in context is reported + for k, _, _, found_terms in result["preserved"]: + if k == "uuid-1": + self.assertIn("Plasma Gun", found_terms) + self.assertIn("uuid-2", ok_keys) + self.assertIn("uuid-3", needs_keys) + self.assertEqual(result["needs_work"], needs_work) + + def test_pure_mechanic_name_is_ok_even_untranslated(self): + orig = _loc({"uuid-1": "Plasma Gun"}) + trans = _loc({"uuid-1": "Plasma Gun"}) + result, needs_work = smart_diff(orig, trans, self.GLOSSARY) + ok_keys = {k for k, _, _ in result["ok"]} + self.assertIn("uuid-1", ok_keys) + self.assertEqual(needs_work, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_glossary_wiki.py b/tests/test_glossary_wiki.py new file mode 100644 index 0000000..915161d --- /dev/null +++ b/tests/test_glossary_wiki.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- +"""Tests for glossary_manager.py (entry model + load/save) and +wiki_sync.get_wiki_data() (static wiki term dataset). + +No network: get_wiki_data() returns module-level constants only. +""" +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from glossary_manager import GlossaryEntry, load_glossary, save_glossary +from wiki_sync import get_wiki_data + + +class TestGlossaryEntry(unittest.TestCase): + def test_to_dict_from_dict_round_trip(self): + entry = GlossaryEntry( + term_english="Plasma Gun", + term_translated="Arma de Plasma", + category="weapon", + context="Ranged weapon", + confidence="high", + first_seen_batch=3, + usage_count=7, + created_at="2026-01-01T12:00:00", + ) + clone = GlossaryEntry.from_dict(entry.to_dict()) + self.assertEqual(clone, entry) + self.assertEqual(clone.to_dict(), entry.to_dict()) + + def test_created_at_auto_filled(self): + entry = GlossaryEntry(term_english="X", term_translated="Y", category="outro") + self.assertTrue(entry.created_at) + + def test_defaults(self): + entry = GlossaryEntry(term_english="X", term_translated="Y", category="outro") + self.assertEqual(entry.context, "") + self.assertEqual(entry.confidence, "high") + self.assertEqual(entry.usage_count, 1) + self.assertEqual(entry.first_seen_batch, 0) + + +class TestLoadSaveGlossary(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.path = os.path.join(self._tmp.name, "glossary.json") + + def test_missing_file_returns_initialized_structure(self): + data = load_glossary(self.path) + self.assertEqual(data["terms"], []) + self.assertEqual(data["metadata"]["total_terms"], 0) + self.assertIn("version", data["metadata"]) + self.assertIn("updated_at", data["metadata"]) + + def test_save_updates_metadata_counts(self): + data = load_glossary(self.path) + data["terms"].append({"term_english": "A", "term_translated": "B", "category": "outro"}) + data["terms"].append({"term_english": "C", "term_translated": "D", "category": "outro"}) + save_glossary(self.path, data) + + with open(self.path, "r", encoding="utf-8") as f: + saved = json.load(f) + self.assertEqual(saved["metadata"]["total_terms"], 2) + self.assertTrue(saved["metadata"]["updated_at"]) + self.assertEqual(len(saved["terms"]), 2) + + def test_save_then_load_round_trip(self): + data = load_glossary(self.path) + data["terms"].append({"term_english": "Plasma Gun", "term_translated": "Arma de Plasma", + "category": "weapon", "preserve": True}) + save_glossary(self.path, data) + reloaded = load_glossary(self.path) + self.assertEqual(reloaded["terms"], data["terms"]) + self.assertEqual(reloaded["metadata"]["total_terms"], 1) + + +class TestWikiData(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.wiki = get_wiki_data() + + def test_sixteen_categories(self): + self.assertEqual(len(self.wiki), 16) + + def test_total_term_count(self): + total = sum(len(terms) for terms in self.wiki.values()) + self.assertGreater(total, 2500) + + def test_no_category_is_empty(self): + for category, terms in self.wiki.items(): + self.assertGreater(len(terms), 0, f"category {category!r} is empty") + + def test_every_term_is_a_non_empty_string(self): + for category, terms in self.wiki.items(): + for term in terms: + self.assertIsInstance(term, str, f"{category}: {term!r}") + self.assertTrue(term.strip(), f"{category}: blank term {term!r}") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_merge.py b/tests/test_merge.py new file mode 100644 index 0000000..36af832 --- /dev/null +++ b/tests/test_merge.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +"""Tests for merge.py — merging corrections into the main translation file. + +merge.py is CLI-shaped, so tests drive main() with a patched sys.argv and +synthetic JSON fixtures in a temp dir. No network, no API keys. +""" +import contextlib +import glob +import io +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import merge + + +BASE_DATA = {"strings": { + "uuid-1": {"Offset": 1, "Text": "Texto antigo"}, + "uuid-2": {"Offset": 2, "Text": "Texto intacto"}, +}} + + +def _run_merge(argv): + """Run merge.main() with patched argv and swallowed stdout.""" + with mock.patch.object(sys, "argv", ["merge.py"] + argv): + with contextlib.redirect_stdout(io.StringIO()): + return merge.main() + + +class TestMerge(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.base = os.path.join(self._tmp.name, "ptBR.json") + self.corr = os.path.join(self._tmp.name, "fix.json") + self.out = os.path.join(self._tmp.name, "out.json") + self._write(self.base, BASE_DATA) + + def _write(self, path, data): + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False) + + def _read(self, path): + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + def test_merge_applies_changes_and_creates_backup(self): + corrections = {"strings": { + # Re-translated validator item (Text differs from _current_translation) + "uuid-1": {"Offset": 1, "Text": "Texto corrigido", + "_current_translation": "Old English text", "_issue": "nao traduzido"}, + # Brand-new key not present in the base + "uuid-3": {"Offset": 3, "Text": "Texto adicionado"}, + }} + self._write(self.corr, corrections) + + rc = _run_merge(["-b", self.base, "-c", self.corr, "-o", self.out, "--backup"]) + + self.assertEqual(rc, 0) + merged = self._read(self.out) + self.assertEqual(merged["strings"]["uuid-1"]["Text"], "Texto corrigido") + self.assertEqual(merged["strings"]["uuid-2"]["Text"], "Texto intacto") + self.assertEqual(merged["strings"]["uuid-3"]["Text"], "Texto adicionado") + # Debug metadata from the validator must not leak into the merged entry + self.assertNotIn("_current_translation", merged["strings"]["uuid-1"]) + self.assertNotIn("_issue", merged["strings"]["uuid-1"]) + + backups = glob.glob(f"{self.base}.*.backup") + self.assertEqual(len(backups), 1) + self.assertEqual(self._read(backups[0]), BASE_DATA) + + def test_refuses_untranslated_validator_output(self): + # Every item still has Text == _current_translation (never re-translated) + corrections = {"strings": { + "uuid-1": {"Offset": 1, "Text": "Still English", + "_current_translation": "Still English", "_issue": "nao traduzido"}, + }} + self._write(self.corr, corrections) + + rc = _run_merge(["-b", self.base, "-c", self.corr, "-o", self.out, "--backup"]) + + self.assertEqual(rc, 1) + self.assertFalse(os.path.exists(self.out)) + # Base untouched, no backup created + self.assertEqual(self._read(self.base), BASE_DATA) + self.assertEqual(glob.glob(f"{self.base}.*.backup"), []) + + def test_dry_run_changes_nothing(self): + corrections = {"strings": { + "uuid-1": {"Offset": 1, "Text": "Texto corrigido"}, + }} + self._write(self.corr, corrections) + + rc = _run_merge(["-b", self.base, "-c", self.corr, "-o", self.out, "--dry-run"]) + + self.assertEqual(rc, 0) + self.assertFalse(os.path.exists(self.out)) + self.assertEqual(self._read(self.base), BASE_DATA) + + def test_empty_corrections_file_fails(self): + self._write(self.corr, {"strings": {}}) + rc = _run_merge(["-b", self.base, "-c", self.corr, "-o", self.out]) + self.assertEqual(rc, 1) + self.assertFalse(os.path.exists(self.out)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tradutor.py b/tests/test_tradutor.py new file mode 100644 index 0000000..f62782f --- /dev/null +++ b/tests/test_tradutor.py @@ -0,0 +1,237 @@ +# -*- coding: utf-8 -*- +"""Tests for tradutor.py — core engine pieces that never touch the LLM. + +Covered: TagProtector, SmartGlossary, split_batch, load_blacklist, atomic_save. +All fixtures are tiny synthetic JSONs built in temp dirs. No network, no API keys. +""" +import json +import os +import re +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from tradutor import ( + SmartGlossary, + TagProtector, + atomic_save, + estimate_tokens, + load_blacklist, + split_batch, +) + + +def _term(en, pt, category, preserve): + return { + "term_english": en, + "term_translated": pt, + "category": category, + "preserve": preserve, + "source": "test", + "context": "", + "confidence": "high", + "usage_count": 1, + "created_at": "2026-01-01T00:00:00", + } + + +GLOSSARY_DATA = { + "metadata": {"version": "2.1", "total_terms": 4}, + "terms": [ + _term("Plasma Gun", "Plasma Gun", "item", True), # preserve flag + _term("Power Sword", "Power Sword", "weapon", False), # preserve category + _term("Med Kit", "Med Kit", "item", True), # space in term + _term("Imperial Navy", "Marinha Imperial", "faction", False), # not preserved + ], +} + + +class TestTagProtector(unittest.TestCase): + TAGGED = ( + 'You gain {g|talent}Barrage{/g} and {n}move{/n} faster. ' + 'Red warning with icon ' + 'and link text.' + ) + + def test_round_trip_is_faithful(self): + protected, ph = TagProtector.protect(self.TAGGED) + restored = TagProtector.restore(protected, ph) + self.assertEqual(restored, self.TAGGED) + + def test_protect_shields_all_tag_kinds(self): + protected, ph = TagProtector.protect(self.TAGGED) + for fragment in ("{g|", "{/g}", "{n}", "{/n}", "", + ""): + self.assertNotIn(fragment, protected) + # Placeholders stand in for the tags + self.assertTrue(re.findall(r"§TAG\d+§", protected)) + # Inner text stays visible for the translator + for inner in ("Barrage", "move", "Red warning", "link text"): + self.assertIn(inner, protected) + + def test_restore_leaves_no_placeholders(self): + protected, ph = TagProtector.protect(self.TAGGED) + restored = TagProtector.restore(protected, ph) + self.assertIsNone(re.search(r"§TAG\d+§", restored)) + + def test_plain_text_is_untouched(self): + text = "Nothing to protect here." + protected, ph = TagProtector.protect(text) + self.assertEqual(protected, text) + self.assertEqual(ph, {}) + self.assertEqual(TagProtector.restore(protected, ph), text) + + def test_empty_text(self): + protected, ph = TagProtector.protect("") + self.assertEqual(protected, "") + self.assertEqual(ph, {}) + + +class TestSmartGlossary(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + self.glossary_path = os.path.join(self._tmp.name, "glossary.json") + with open(self.glossary_path, "w", encoding="utf-8") as f: + json.dump(GLOSSARY_DATA, f, ensure_ascii=False) + + def _make(self, mode="preserve"): + return SmartGlossary(self.glossary_path, preserve_mode=mode) + + def test_preserve_flagged_term_exact_match(self): + g = self._make() + self.assertTrue(g.should_preserve("Plasma Gun")) + + def test_exact_match_is_case_insensitive(self): + g = self._make() + self.assertTrue(g.should_preserve("PLASMA GUN")) + self.assertTrue(g.should_preserve("plasma gun")) + + def test_preserve_category_term_exact_match(self): + g = self._make() + # "Power Sword" has preserve=False but category "weapon" is in preserve_cats + self.assertTrue(g.should_preserve("Power Sword")) + + def test_hyphen_variant_of_spaced_term(self): + g = self._make() + # Glossary term is "Med Kit" (space); text uses the hyphenated form + self.assertTrue(g.should_preserve("Med-Kit")) + + def test_non_listed_text_is_not_preserved(self): + g = self._make() + self.assertFalse(g.should_preserve("The rain falls on the battlefield today.")) + + def test_non_preserve_category_is_not_preserved(self): + g = self._make() + # "Imperial Navy" is category "faction" with preserve=False + self.assertFalse(g.should_preserve("Imperial Navy")) + + def test_contains_match_finds_embedded_term(self): + g = self._make() + found, terms = g.should_preserve_with_terms("Equip the Plasma Gun before the fight.") + self.assertTrue(found) + self.assertIn("Plasma Gun", terms) + + def test_should_preserve_with_terms_returns_matched_terms(self): + g = self._make() + found, terms = g.should_preserve_with_terms("Power Sword") + self.assertTrue(found) + self.assertEqual(terms, ["Power Sword"]) + + def test_no_match_returns_empty_terms(self): + g = self._make() + found, terms = g.should_preserve_with_terms("Completely unrelated sentence here.") + self.assertFalse(found) + self.assertEqual(terms, []) + + def test_complete_mode_preserves_nothing(self): + g = self._make(mode="complete") + self.assertFalse(g.should_preserve("Plasma Gun")) + self.assertFalse(g.should_preserve("Power Sword")) + found, terms = g.should_preserve_with_terms("Equip the Plasma Gun before the fight.") + self.assertFalse(found) + self.assertEqual(terms, []) + + def test_missing_glossary_file_loads_empty(self): + g = SmartGlossary(os.path.join(self._tmp.name, "nope.json"), preserve_mode="preserve") + self.assertEqual(g.entries, {}) + self.assertFalse(g.should_preserve("Plasma Gun")) + + +class TestSplitBatch(unittest.TestCase): + def _items(self, n, text_len=20): + # 20 chars -> estimate_tokens = 5 + return [(f"uuid-{i}", {"Offset": i, "Text": "x" * text_len}) for i in range(n)] + + def test_respects_token_cap_and_keeps_order(self): + items = self._items(5) # 5 tokens each, cap 10 -> batches of 2,2,1 + batches = split_batch(items, max_tok=10) + self.assertEqual([len(b) for b in batches], [2, 2, 1]) + flattened = [kv for b in batches for kv in b] + self.assertEqual(flattened, items) + for b in batches: + total = sum(estimate_tokens(v["Text"]) for _, v in b) + self.assertLessEqual(total, 10) + + def test_oversized_single_item_gets_own_batch(self): + items = [("big", {"Offset": 0, "Text": "y" * 100})] + self._items(5) + batches = split_batch(items, max_tok=10) + self.assertEqual([len(b) for b in batches], [1, 2, 2, 1]) + self.assertEqual(batches[0][0][0], "big") + flattened = [kv for b in batches for kv in b] + self.assertEqual(flattened, items) + + def test_empty_input(self): + self.assertEqual(split_batch([]), []) + + +class TestLoadBlacklist(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.addCleanup(self._tmp.cleanup) + + def _write(self, name, data): + path = os.path.join(self._tmp.name, name) + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f) + return path + + def test_list_form(self): + path = self._write("bl.json", ["aaa-111", "bbb-222"]) + self.assertEqual(load_blacklist(path), {"aaa-111", "bbb-222"}) + + def test_dict_form(self): + path = self._write("bl.json", {"ccc-333": {"reason": "EULA"}, "ddd-444": {}}) + self.assertEqual(load_blacklist(path), {"ccc-333", "ddd-444"}) + + def test_missing_file_returns_empty(self): + self.assertEqual(load_blacklist(os.path.join(self._tmp.name, "nope.json")), set()) + + def test_none_path_returns_empty(self): + self.assertEqual(load_blacklist(None), set()) + + +class TestAtomicSave(unittest.TestCase): + def test_writes_valid_json_without_leftovers(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "out.json") + data = {"strings": {"u1": {"Offset": 0, "Text": "Coração do Imperador"}}} + atomic_save(data, path) + with open(path, "r", encoding="utf-8") as f: + self.assertEqual(json.load(f), data) + # Only the target file may remain — no temp files left behind + self.assertEqual(os.listdir(tmp), ["out.json"]) + + def test_creates_missing_parent_dirs(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "sub", "dir", "out.json") + atomic_save({"a": 1}, path) + with open(path, "r", encoding="utf-8") as f: + self.assertEqual(json.load(f), {"a": 1}) + + +if __name__ == "__main__": + unittest.main()