diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index c4f65de26..7d85e8744 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -50,7 +50,7 @@ - name: Check Mobile App enrichment coverage - Report run: pipenv run coverage report --fail-under 95 scripts/enrich_mobileapp_mappings/*.py - name: Check PDF generation test coverage - Report - run: pipenv run coverage report --fail-under 52 scripts/pdf_generation/*.py + run: pipenv run coverage report --fail-under 95 scripts/pdf_generation/*.py # Upload Code Coverage for Codeclimate - uses: qltysh/qlty-action/coverage@a19242102d17e497f437d7466aa01b528537e899 # v2.2.0 with: diff --git a/.github/workflows/run-tests.yaml b/.github/workflows/run-tests.yaml index e364d6e54..a2aca04ad 100644 --- a/.github/workflows/run-tests.yaml +++ b/.github/workflows/run-tests.yaml @@ -45,7 +45,7 @@ jobs: - name: Check Mobile App enrichment coverage - Report run: pipenv run coverage report --fail-under 95 scripts/enrich_mobileapp_mappings/*.py - name: Check PDF generation test coverage - Report - run: pipenv run coverage report --fail-under 52 scripts/pdf_generation/*.py + run: pipenv run coverage report --fail-under 95 scripts/pdf_generation/*.py # Check formatting of files - name: Check formatting of files with Black run: pipenv run black --line-length=120 --check . diff --git a/tests/scripts/pdf_generation_utest.py b/tests/scripts/pdf_generation_utest.py index 7d79de417..84ababd84 100644 --- a/tests/scripts/pdf_generation_utest.py +++ b/tests/scripts/pdf_generation_utest.py @@ -26,6 +26,8 @@ import cornucopia_common as cc # noqa: E402 (needs the sys.path line above) import check_environment # noqa: E402 import merge_pdfs # noqa: E402 +import generate_deck # noqa: E402 +import runpy # noqa: E402 # Repeated fixture values, named once so a change lands in one place. _DATA_PATTERN = "source/%edition%.yaml" @@ -593,6 +595,31 @@ def test_report_explains_missing_packages(self) -> None: self.assertIn("Missing: yaml", text) self.assertIn("These must be installed into THIS interpreter", text) + def test_check_optional_finds_modules(self) -> None: + original_import = __import__ + + def fake_import(name, *args, **kwargs): + if name == "defusedxml": + return mock.MagicMock() + return original_import(name, *args, **kwargs) + + with mock.patch("builtins.__import__", side_effect=fake_import): + lines = check_environment.check_optional() + self.assertTrue(any("found defusedxml" in line for line in lines)) + + def test_report_inside_scribus(self) -> None: + with mock.patch.dict("sys.modules", {"scribus": mock.MagicMock()}): + text = check_environment.report() + self.assertIn("Running inside: Scribus", text) + + def test_check_environment_main(self) -> None: + runpy.run_path(os.path.join(_PDF_GENERATION, "check_environment.py"), run_name="__main__") + out_txt = os.path.join(_PDF_GENERATION, "check_environment.txt") + self.assertTrue(os.path.exists(out_txt)) + + with mock.patch("builtins.open", side_effect=OSError("Read only")): + runpy.run_path(os.path.join(_PDF_GENERATION, "check_environment.py"), run_name="__main__") + class TestLoadConfig(unittest.TestCase): def test_find_config_path_locates_root_config(self) -> None: @@ -666,6 +693,1199 @@ def test_legacy_config_discovery_and_path_resolution(self) -> None: os.path.normpath(os.path.join(tmp, "source")), ) + def test_find_config_path_empty_defaults_to_pdf_config_yaml(self) -> None: + self.assertEqual(cc.find_config_path(config_name=None), cc.find_config_path(config_name="pdf_config.yaml")) + + def test_find_config_path_repo_and_script_dir(self) -> None: + self.assertTrue(os.path.isfile(cc.find_config_path(config_name="pdf_config.yaml"))) + with tempfile.TemporaryDirectory() as tmp: + with mock.patch("cornucopia_common._SCRIPT_DIR", tmp): + _write(os.path.join(tmp, "script_only.yaml"), "x: 1") + self.assertEqual( + cc.find_config_path(config_name="script_only.yaml"), os.path.join(tmp, "script_only.yaml") + ) + + def test_infer_base_dir_none(self) -> None: + self.assertEqual(cc.infer_base_dir(None, None), cc._REPO_ROOT) + + def test_load_assets_variants(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + abs_yaml = _write(os.path.join(tmp, "abs_assets.yaml"), "default: {}\n") + self.assertEqual(cc.load_assets({"paths": {"assets_config_path": abs_yaml}}), {"default": {}}) + _write(os.path.join(tmp, "local_assets.yaml"), "default: {}\n") + self.assertEqual( + cc.load_assets({"paths": {"assets_config_path": "local_assets.yaml"}}, base_dir=tmp), + {"default": {}}, + ) + with mock.patch("cornucopia_common._SCRIPT_DIR", tmp): + _write(os.path.join(tmp, "script_assets.yaml"), "default: {}\n") + self.assertEqual( + cc.load_assets({"paths": {"assets_config_path": "script_assets.yaml"}}), + {"default": {}}, + ) + self.assertEqual(cc.load_assets({"paths": {"assets_config_path": "missing_assets.yaml"}}, base_dir=tmp), {}) + + +class TestCornucopiaCommonEdgeCases(unittest.TestCase): + def test_resolve_asset_path_assets_prefix_and_abs_root(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = {"paths": {"asset_root": "resources"}} + res = cc.resolve_asset_path(tmp, cfg, "assets/images/pic.png") + self.assertEqual(res, os.path.join(tmp, "resources", "images", "pic.png").replace("\\", "/")) + + cfg_abs = {"paths": {"asset_root": "/var/assets"}} + res_abs = cc.resolve_asset_path(tmp, cfg_abs, "pic.png") + self.assertEqual(res_abs, "/var/assets/pic.png") + + def test_card_data_path_edition_version(self) -> None: + cfg = {"paths": {"card_data_path": "source/%edition%-%edition_version%-%language%.yaml"}} + p = cc.card_data_path(cfg, "/tmp", "webapp", "en", version="3.0") + self.assertEqual(p, "/tmp/source/webapp-3.0-en.yaml") + with mock.patch("cornucopia_common.edition_data_version", return_value="3.0"): + p2 = cc.card_data_path(cfg, "/tmp", "webapp", "en") + self.assertEqual(p2, "/tmp/source/webapp-3.0-en.yaml") + + def test_data_pattern_regex_unknown_token(self) -> None: + cfg = {"paths": {"card_data_path": "source/%edition%-%custom_token%-%language%.yaml"}} + regex = cc._data_pattern_regex(cfg) + self.assertIsNotNone(regex) + + def test_scan_and_misnamed_non_directory(self) -> None: + cfg = {"paths": {"source_root": "nonexistent_source"}} + self.assertEqual(cc.scan_card_files(cfg, "/tmp"), []) + self.assertEqual(cc.find_misnamed_files(cfg, "/tmp"), []) + + def test_discover_editions_unconfigured(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = _base_config("source/%edition%-cards-1.0-%language%.yaml") + cfg["editions"] = {} + _write(os.path.join(tmp, "source", "alpha-cards-1.0-en.yaml"), _card_yaml(_EMPTY_SUIT)) + eds = cc.discover_editions(cfg, tmp) + self.assertEqual(eds, ["alpha"]) + + def test_expand_selection_none(self) -> None: + warnings = [] + sel = cc.expand_selection(None, ["a", "b"], "items", warnings) + self.assertEqual(sel, ["a", "b"]) + + def test_get_font_size_non_dict_scaling(self) -> None: + cfg = { + "size_profiles": {"bridge": {"base_font_sizes": {"frame": 10.0}}}, + "font_scaling": {"frame": 2.5}, + } + self.assertEqual(cc.get_font_size(cfg, "bridge", "frame", "en"), 12.5) + + def test_suit_entry_and_colors_flat_suits(self) -> None: + assets_data = {"suits": [{"id": "aa", "color": "Custom_AA", "court_color": "Custom_Court_AA"}]} + cfg = _base_config(_DATA_PATTERN) + cfg["editions"]["demo"]["suit_colors"] = {"aa": "Edition_AA"} + card = {"suit_id": "aa", "card_id": "AA1"} + suit_col, num_col, _defs = cc.card_colors(cfg, assets_data, "demo", card) + self.assertEqual(suit_col, "Custom_AA") + self.assertTrue(cc.has_suit_color(cfg, assets_data, "demo", "aa")) + + self.assertTrue(cc.has_suit_color(cfg, {}, "demo", "aa")) + + def test_assets_override_flat_suits(self) -> None: + assets_data = { + "suits": [ + { + "id": "aa", + "cards": [{"id": "aa1", "small": "custom_card.png"}], + "court_backgrounds": {"small": "court.png"}, + "backgrounds": {"small": "bg.png"}, + } + ] + } + card_level, court_level, suit_level = cc._assets_override(assets_data, "demo", "aa", "aa1", "small") + self.assertEqual(card_level, "custom_card.png") + self.assertEqual(court_level, "court.png") + self.assertEqual(suit_level, "bg.png") + + def test_resolve_background_global_default(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + bg_path = _write(os.path.join(tmp, "resources", "Backgrounds", "global_bg.png"), "image") + assets_data = {"default": {"backgrounds": {"small": "global_bg.png"}}} + cfg = _base_config(_DATA_PATTERN) + cfg["paths"]["asset_root"] = "resources" + cfg["assets"]["image_dir"] = "Backgrounds" + card = {"card_id": "AA1", "suit_id": "aa"} + full, found = cc.resolve_background(cfg, assets_data, tmp, "demo", card, "bridge") + self.assertTrue(found) + self.assertEqual(full, bg_path.replace("\\", "/")) + + def test_zip_name_and_sla_name(self) -> None: + cfg = { + "project": {"version": "2.0"}, + "packaging": {"zip_name": "Archive_%edition%_%version%.zip"}, + "output": {"sla_filename_format": "SLA_%edition%_%card_id%_%size%_%language%.sla"}, + } + self.assertEqual(cc.zip_name(cfg, "demo"), "Archive_demo_2.0.zip") + self.assertEqual(cc.sla_name(cfg, "demo", "AA1", "bridge", "en"), "SLA_demo_AA1_bridge_en.sla") + + +class TestMergePdfsCli(unittest.TestCase): + def test_parse_args_defaults_and_options(self) -> None: + test_argv = [ + "merge_pdfs.py", + "--config", + "custom_config.yaml", + "--edition", + "webapp", + "--edition", + "mobileapp", + "--language", + "en", + "--size", + "bridge", + "--profile", + "print", + "--bleed-mm", + "3.0", + "--printers-marks", + "--output-dir", + "custom_out", + "--zip", + "--clean", + "--dry-run", + "--base-dir", + "/tmp/base", + ] + with mock.patch("sys.argv", test_argv): + args = merge_pdfs.parse_args() + self.assertEqual(args.config, "custom_config.yaml") + self.assertEqual(args.edition, ["webapp", "mobileapp"]) + self.assertEqual(args.language, ["en"]) + self.assertEqual(args.size, ["bridge"]) + self.assertEqual(args.profile, "print") + self.assertEqual(args.bleed_mm, 3.0) + self.assertTrue(args.printers_marks) + self.assertEqual(args.output_dir, "custom_out") + self.assertTrue(args.do_zip) + self.assertTrue(args.clean) + self.assertTrue(args.dry_run) + self.assertEqual(args.base_dir, "/tmp/base") + + def test_parse_args_negated_flags(self) -> None: + test_argv = [ + "merge_pdfs.py", + "--no-printers-marks", + "--no-zip", + ] + with mock.patch("sys.argv", test_argv): + args = merge_pdfs.parse_args() + self.assertFalse(args.printers_marks) + self.assertFalse(args.do_zip) + + +class TestMergePdfsHelpers(unittest.TestCase): + def test_files_with_suffix_non_directory(self) -> None: + self.assertEqual(merge_pdfs.files_with_suffix("/nonexistent/dir", ".pdf"), []) + + def test_files_with_suffix_matches(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + _write(os.path.join(tmp, "a.PDF")) + _write(os.path.join(tmp, "b.pdf")) + _write(os.path.join(tmp, "c.txt")) + matched = merge_pdfs.files_with_suffix(tmp, ".pdf") + self.assertEqual(len(matched), 2) + self.assertTrue(all(p.lower().endswith(".pdf") for p in matched)) + + def test_clean_intermediates_handles_os_errors(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + out = os.path.join(tmp, "output") + qr_dir = os.path.join(out, "qrcodes") + pdf1 = _write(os.path.join(out, "card1.pdf"), "content") + _write(os.path.join(out, "card1.sla"), "sla") + _write(os.path.join(qr_dir, "qr1.png"), "qr") + cfg = {"paths": {"qr_code_dir": "output/qrcodes"}} + + with mock.patch("os.remove", side_effect=OSError("Permission denied")): + with mock.patch("os.rmdir", side_effect=OSError("Dir not empty")): + removed, freed = merge_pdfs.clean_intermediates([pdf1], cfg, tmp, out) + self.assertEqual(removed, 0) + self.assertEqual(freed, 0) + + def test_match_card_pdfs(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = _base_config(_DATA_PATTERN) + deck = [{"card_id": "AA1"}, {"card_id": "AA2"}] + c1_name = cc.card_pdf_name(cfg, "demo", "AA1", "bridge", "en", 3.0, False) + _write(os.path.join(tmp, c1_name), "pdf") + + found, missing = merge_pdfs.match_card_pdfs(deck, "demo", "en", "bridge", cfg, tmp, 3.0, False) + self.assertEqual(len(found), 1) + self.assertEqual(missing, ["AA2"]) + + +class TestMergeDeck(unittest.TestCase): + def test_merge_deck_raises_if_pymupdf_missing(self) -> None: + with mock.patch.object(merge_pdfs, "pymupdf", None): + with self.assertRaises(RuntimeError) as ctx: + merge_pdfs.merge_deck(["dummy.pdf"], "out.pdf") + self.assertIn("PyMuPDF", str(ctx.exception)) + + def test_merge_deck_success_multi_page_and_single_page(self) -> None: + mock_merged = mock.MagicMock() + mock_merged.__len__.return_value = 4 + + mock_card2 = mock.MagicMock() + mock_card2.__len__.return_value = 2 + + mock_card1 = mock.MagicMock() + mock_card1.__len__.return_value = 1 + + mock_pymupdf = mock.MagicMock() + mock_pymupdf.open.side_effect = [mock_merged, mock_card2, mock_card1] + + with mock.patch.object(merge_pdfs, "pymupdf", mock_pymupdf): + pages = merge_pdfs.merge_deck(["card2.pdf", "card1.pdf"], "deck.pdf") + self.assertEqual(pages, 4) + mock_merged.insert_pdf.assert_any_call(mock_card2, from_page=1, to_page=1) + mock_merged.insert_pdf.assert_any_call(mock_card2, from_page=0, to_page=0) + mock_merged.insert_pdf.assert_any_call(mock_card1, from_page=0, to_page=0) + mock_merged.save.assert_called_once_with("deck.pdf", garbage=4, deflate=True) + mock_merged.close.assert_called_once() + + +class TestPackageDecks(unittest.TestCase): + def test_package_decks_per_edition(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = { + "packaging": {"zip_scope": "per_edition", "zip_name": "OWASP_%edition%.zip"}, + "project": {"version": "1.0"}, + } + deck1 = _write(os.path.join(tmp, "deck1.pdf"), "deck1") + merge_pdfs.package_decks({"demo": [deck1]}, cfg["packaging"], cfg, tmp) + archive = os.path.join(tmp, "OWASP_demo.zip") + self.assertTrue(os.path.exists(archive)) + + def test_package_decks_single_scope(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = { + "packaging": {"zip_scope": "single", "zip_name": "OWASP_%edition%.zip"}, + "project": {"version": "1.0"}, + } + deck1 = _write(os.path.join(tmp, "deck1.pdf"), "deck1") + deck2 = _write(os.path.join(tmp, "deck2.pdf"), "deck2") + merge_pdfs.package_decks({"demo1": [deck1], "demo2": [deck2]}, cfg["packaging"], cfg, tmp) + archive = os.path.join(tmp, "OWASP_demo1.zip") + self.assertTrue(os.path.exists(archive)) + + def test_maybe_package_skipped_when_empty_or_disabled(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + args = mock.Mock(do_zip=False) + merge_pdfs.maybe_package(args, {}, {}, tmp) + merge_pdfs.maybe_package(args, {}, {"demo": ["deck.pdf"]}, tmp) + + def test_maybe_package_enabled_by_args_or_config(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + deck = _write(os.path.join(tmp, "deck.pdf"), "deck") + args = mock.Mock(do_zip=True) + cfg = { + "packaging": {"create_zip": False, "zip_name": "bundle_%edition%.zip"}, + "project": {"version": "1.0"}, + } + merge_pdfs.maybe_package(args, cfg, {"demo": [deck]}, tmp) + self.assertTrue(os.path.exists(os.path.join(tmp, "bundle_demo.zip"))) + + args_none = mock.Mock(do_zip=None) + cfg["packaging"]["create_zip"] = True + cfg["packaging"]["zip_name"] = "bundle2_%edition%.zip" + merge_pdfs.maybe_package(args_none, cfg, {"demo": [deck]}, tmp) + self.assertTrue(os.path.exists(os.path.join(tmp, "bundle2_demo.zip"))) + + +class TestMergePdfsReportingAndTidy(unittest.TestCase): + def test_report_gaps(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + merge_pdfs.report_gaps([], tmp) + self.assertFalse(os.path.exists(os.path.join(tmp, "merge_gaps.json"))) + + merge_pdfs.report_gaps([{"target": "demo", "missing": ["AA1"]}], tmp) + report_file = os.path.join(tmp, "merge_gaps.json") + self.assertTrue(os.path.exists(report_file)) + + def test_tidy_up_conditions(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = {"paths": {"qr_code_dir": "output/qrcodes"}} + out = os.path.join(tmp, "output") + merge_pdfs.tidy_up([{"target": "demo"}], {"demo": ["deck.pdf"]}, ["card.pdf"], cfg, tmp, out) + merge_pdfs.tidy_up([], {}, ["card.pdf"], cfg, tmp, out) + card = _write(os.path.join(out, "card.pdf"), "card") + merge_pdfs.tidy_up([], {"demo": ["deck.pdf"]}, [card], cfg, tmp, out) + self.assertFalse(os.path.exists(card)) + + +class TestMergeOneTarget(unittest.TestCase): + def test_merge_one_target_no_cards(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = _base_config(_DATA_PATTERN) + target = {"edition": "demo", "language": "en", "size": "bridge"} + deck_path, used, gap = merge_pdfs.merge_one_target(target, cfg, tmp, tmp, 3.0, False, False) + self.assertIsNone(deck_path) + self.assertEqual(used, []) + self.assertIsNone(gap) + + def test_merge_one_target_not_built(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = _base_config(_DATA_PATTERN) + _write(os.path.join(tmp, "source", "demo.yaml"), _card_yaml(" - id: AA\n cards:\n - id: AA1\n")) + target = {"edition": "demo", "language": "en", "size": "bridge"} + deck_path, used, gap = merge_pdfs.merge_one_target(target, cfg, tmp, tmp, 3.0, False, False) + self.assertIsNone(deck_path) + self.assertEqual(used, []) + self.assertIsNone(gap) + + def test_merge_one_target_dry_run_and_missing_gaps(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = _base_config(_DATA_PATTERN) + cards_yaml = " - id: AA\n cards:\n" + "".join(" - id: AA{0}\n".format(i) for i in range(1, 13)) + _write(os.path.join(tmp, "source", "demo.yaml"), _card_yaml(cards_yaml)) + c1_name = cc.card_pdf_name(cfg, "demo", "AA1", "bridge", "en", 3.0, False) + _write(os.path.join(tmp, c1_name), "pdf") + + target = {"edition": "demo", "language": "en", "size": "bridge"} + deck_path, used, gap = merge_pdfs.merge_one_target(target, cfg, tmp, tmp, 3.0, False, dry_run=True) + self.assertIsNone(deck_path) + self.assertIsNotNone(gap) + self.assertEqual(len(gap["missing"]), 11) + + def test_merge_one_target_success(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = _base_config(_DATA_PATTERN) + _write(os.path.join(tmp, "source", "demo.yaml"), _card_yaml(" - id: AA\n cards:\n - id: AA1\n")) + c1_name = cc.card_pdf_name(cfg, "demo", "AA1", "bridge", "en", 3.0, False) + _write(os.path.join(tmp, c1_name), "pdf") + target = {"edition": "demo", "language": "en", "size": "bridge"} + + with mock.patch("merge_pdfs.merge_deck", return_value=2): + deck_path, used, gap = merge_pdfs.merge_one_target(target, cfg, tmp, tmp, 3.0, False, dry_run=False) + self.assertIsNotNone(deck_path) + self.assertEqual(len(used), 1) + self.assertIsNone(gap) + + +class TestPrepareMerge(unittest.TestCase): + def test_prepare_merge_config_none_or_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + args = mock.Mock(config="missing.yaml", output_dir=None) + self.assertIsNone(merge_pdfs.prepare_merge(args, tmp, config={})) + + def test_prepare_merge_output_dir_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = _base_config(_DATA_PATTERN) + args = mock.Mock(config="cfg.yaml", output_dir=os.path.join(tmp, "nonexistent")) + self.assertIsNone(merge_pdfs.prepare_merge(args, tmp, config=cfg)) + + def test_prepare_merge_invalid_profile(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = _base_config(_DATA_PATTERN) + out = os.path.join(tmp, "out") + os.makedirs(out, exist_ok=True) + args = mock.Mock( + config="cfg.yaml", + output_dir=out, + profile="unknown_profile", + bleed_mm=None, + printers_marks=None, + ) + self.assertIsNone(merge_pdfs.prepare_merge(args, tmp, config=cfg)) + + def test_prepare_merge_no_targets(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = _base_config(_DATA_PATTERN) + cfg["generation_targets"] = {"editions": [], "languages": [], "sizes": []} + out = os.path.join(tmp, "out") + os.makedirs(out, exist_ok=True) + args = mock.Mock( + config="cfg.yaml", + output_dir=out, + profile=None, + bleed_mm=None, + printers_marks=None, + edition=None, + language=None, + size=None, + ) + self.assertIsNone(merge_pdfs.prepare_merge(args, tmp, config=cfg)) + + def test_prepare_merge_success(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = _base_config("source/%edition%-cards-%edition_version%-%language%.yaml") + cfg["editions"] = {"demo": {}} + _write( + os.path.join(tmp, "source", "demo-cards-1.0-en.yaml"), + _card_yaml(" - id: AA\n cards:\n - id: AA1\n"), + ) + out = os.path.join(tmp, "out") + os.makedirs(out, exist_ok=True) + cfg_path = _write(os.path.join(tmp, "custom_cfg.yaml"), "paths:\n output_dir: out\n") + args = mock.Mock( + config=cfg_path, + output_dir=out, + profile="main", + bleed_mm=3.0, + printers_marks=False, + edition=["demo"], + language=["en"], + size=["bridge"], + ) + # Test when config=None so line 294 executes + with mock.patch("cornucopia_common.load_config", return_value=cfg): + res = merge_pdfs.prepare_merge(args, tmp, config=None) + self.assertIsNotNone(res) + c, o, bleed, marks, targets = res + self.assertEqual(bleed, 3.0) + self.assertFalse(marks) + self.assertEqual(len(targets), 1) + + def test_prepare_merge_logs_warnings(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = _base_config("source/%edition%-cards-%edition_version%-%language%.yaml") + cfg["editions"] = {"demo": {}} + _write( + os.path.join(tmp, "source", "demo-cards-1.0-en.yaml"), + _card_yaml(" - id: AA\n cards:\n - id: AA1\n"), + ) + out = os.path.join(tmp, "out") + os.makedirs(out, exist_ok=True) + args = mock.Mock( + config="cfg.yaml", + output_dir=out, + profile="main", + bleed_mm=3.0, + printers_marks=False, + edition=["demo", "unknown_edition"], + language=["en"], + size=["bridge"], + ) + res = merge_pdfs.prepare_merge(args, tmp, config=cfg) + self.assertIsNotNone(res) + + +class TestMergePdfsMain(unittest.TestCase): + def test_main_config_load_fail(self) -> None: + with mock.patch("sys.argv", ["merge_pdfs.py", "--config", "nonexistent.yaml"]): + with mock.patch("cornucopia_common.load_yaml", return_value=None): + self.assertEqual(merge_pdfs.main(), 1) + + def test_main_base_dir_missing(self) -> None: + with mock.patch("sys.argv", ["merge_pdfs.py", "--base-dir", "/nonexistent/base/dir"]): + with mock.patch("cornucopia_common.load_yaml", return_value={"project": "test"}): + self.assertEqual(merge_pdfs.main(), 1) + + def test_main_missing_pymupdf_without_dry_run(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg_path = _write(os.path.join(tmp, "pdf_config.yaml"), "project: test\n") + with mock.patch.object(merge_pdfs, "pymupdf", None): + with mock.patch("sys.argv", ["merge_pdfs.py", "--config", cfg_path, "--base-dir", tmp]): + self.assertEqual(merge_pdfs.main(), 1) + + def test_main_prepare_merge_fails(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg_path = _write(os.path.join(tmp, "pdf_config.yaml"), "project: test\n") + with mock.patch.object(merge_pdfs, "pymupdf", mock.MagicMock()): + with mock.patch("merge_pdfs.prepare_merge", return_value=None): + with mock.patch("sys.argv", ["merge_pdfs.py", "--config", cfg_path, "--base-dir", tmp]): + self.assertEqual(merge_pdfs.main(), 1) + + def test_main_dry_run_with_and_without_clean(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = _base_config("source/%edition%-cards-%edition_version%-%language%.yaml") + cfg["editions"] = {"demo": {}} + _write( + os.path.join(tmp, "source", "demo-cards-1.0-en.yaml"), + _card_yaml(" - id: AA\n cards:\n - id: AA1\n"), + ) + out = os.path.join(tmp, "out") + os.makedirs(out, exist_ok=True) + cfg_path = _write(os.path.join(tmp, "pdf_config.yaml"), "project: test\n") + + with mock.patch("cornucopia_common.load_yaml", return_value=cfg): + with mock.patch( + "sys.argv", + [ + "merge_pdfs.py", + "--config", + cfg_path, + "--base-dir", + tmp, + "--output-dir", + out, + "--dry-run", + "--clean", + ], + ): + self.assertEqual(merge_pdfs.main(), 0) + + def test_main_success_flow(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + cfg = _base_config("source/%edition%-cards-%edition_version%-%language%.yaml") + cfg["editions"] = {"demo": {}} + _write( + os.path.join(tmp, "source", "demo-cards-1.0-en.yaml"), + _card_yaml(" - id: AA\n cards:\n - id: AA1\n"), + ) + out = os.path.join(tmp, "out") + os.makedirs(out, exist_ok=True) + c1_name = cc.card_pdf_name(cfg, "demo", "AA1", "bridge", "en", 3.0, False) + _write(os.path.join(out, c1_name), "pdf") + cfg_path = _write(os.path.join(tmp, "pdf_config.yaml"), "project: test\n") + + with mock.patch.object(merge_pdfs, "pymupdf", mock.MagicMock()): + with mock.patch("merge_pdfs.merge_deck", return_value=2): + with mock.patch("cornucopia_common.load_yaml", return_value=cfg): + with mock.patch( + "sys.argv", + [ + "merge_pdfs.py", + "--config", + cfg_path, + "--base-dir", + tmp, + "--output-dir", + out, + "--clean", + "--zip", + ], + ): + self.assertEqual(merge_pdfs.main(), 0) + + def test_run_as_script(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + with mock.patch( + "sys.argv", + [ + "merge_pdfs.py", + "--dry-run", + "--edition", + "webapp", + "--language", + "en", + "--size", + "bridge", + "--output-dir", + tmp, + ], + ): + with self.assertRaises(SystemExit) as ctx: + runpy.run_path(os.path.join(_PDF_GENERATION, "merge_pdfs.py"), run_name="__main__") + self.assertEqual(ctx.exception.code, 0) + + +_MINIMAL_SLA = """ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +""" + + +class TestGenerateDeckBuildLog(unittest.TestCase): + def test_build_log(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + log_path = os.path.join(tmp, "subdir", "build.log") + log = generate_deck.BuildLog(log_path) + self.assertTrue(os.path.exists(log_path)) + log.info("info msg") + log.warn("warn msg") + self.assertIn("warn msg", log.warnings) + with open(log_path, "r", encoding="utf-8") as f: + content = f.read() + self.assertIn("info msg", content) + self.assertIn("WARNING: warn msg", content) + + +class TestGenerateDeckXmlAndColorHelpers(unittest.TestCase): + def test_inject_color_definitions_document_fallback_and_missing_spec(self) -> None: + import xml.etree.ElementTree as ET + + root = ET.Element("SCRIBUSUTF8NEW") + # Add existing color + color_elem = ET.SubElement(root, "COLOR") + color_elem.set("NAME", "Existing") + + log = generate_deck.BuildLog(os.path.join(tempfile.mkdtemp(), "b.log")) + config = {"color_definitions": {"ValidColor": {"c": 10, "m": 20, "y": 30, "k": 40}}} + added = generate_deck.inject_color_definitions( + root, config, ["", "Existing", "MissingColor", "ValidColor"], log + ) + self.assertEqual(added, ["ValidColor"]) + self.assertTrue(any("MissingColor" in w for w in log.warnings)) + + def test_move_sla_object_y_and_fill_card_number(self) -> None: + import xml.etree.ElementTree as ET + + frame = ET.Element("PAGEOBJECT") + frame.set("gYpos", "10.0") + generate_deck.move_sla_object_y(frame, 5.0) + self.assertEqual(frame.get("gYpos"), "15.0") + + # fill_card_number with joker + frame_num = ET.fromstring("") + style = {"joker": True, "joker_label": "Joker", "number_color": "Red"} + card = {"card_kind": "SpecialJoker"} + generate_deck.fill_card_number(frame_num, card, style) + self.assertEqual(frame_num.find("ITEXT").get("CH"), "SpecialJoker") + + def test_recolour_suit_band_cleans_trans_blend(self) -> None: + import xml.etree.ElementTree as ET + + frame = ET.Element("PAGEOBJECT") + frame.set("TransBlend", "1") + frame.set("TransBlendS", "1") + generate_deck.recolour_suit_band(frame, "CustomRed") + self.assertEqual(frame.get("PCOLOR"), "CustomRed") + self.assertNotIn("TransBlend", frame.attrib) + self.assertNotIn("TransBlendS", frame.attrib) + + def test_text_frame_handlers_handle_para_elements(self) -> None: + import xml.etree.ElementTree as ET + + style = { + "defaults": { + "special_text_color": "Black", + "special_text_shade": 70, + "body_text_color": "Black", + "body_text_shade": 100, + }, + "profile": {"special_text_y_offset": 5.0}, + "special_size": "8", + "special_font": "Noto Sans Medium", + "body_font": "Noto Sans Light", + "heading_font": "Noto Sans Medium", + "attack_size": "7", + "number_color": "Black", + "joker": False, + } + card = { + "misc_text": "Read more", + "attack_text": "Attack description", + "value": "A", + "suit_name": "Authentication", + "url": "https://owasp.org", + } + + # special_text with para + frame_spec = ET.fromstring("") + generate_deck.fill_special_text(frame_spec, card, style) + self.assertEqual(frame_spec.find("para").get("FONT"), "Noto Sans Medium") + + # attack_text with para + frame_att = ET.fromstring("") + generate_deck.fill_attack_text(frame_att, card, style) + self.assertEqual(frame_att.find("para").get("FONT"), "Noto Sans Light") + + # suit_name with para + frame_suit = ET.fromstring("") + generate_deck.fill_suit_name(frame_suit, card, style) + self.assertEqual(frame_suit.find("para").get("FONT"), "Noto Sans Medium") + + def test_populate_frame_ptype_6(self) -> None: + import xml.etree.ElementTree as ET + + frame = ET.fromstring('') + style = {"suit_color": "Custom_Color"} + res = generate_deck.populate_frame(frame, {}, style, {}, {"Data_Color"}) + self.assertFalse(res) + self.assertEqual(frame.get("PCOLOR"), "Custom_Color") + + +class TestGenerateDeckQrAndArtwork(unittest.TestCase): + def test_create_qr_image_pypng_and_pillow_fallbacks(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + qr_file = os.path.join(tmp, "qr.png") + # Normal create + generate_deck.create_qr_image("https://owasp.org", qr_file, {}) + self.assertTrue(os.path.exists(qr_file)) + + # Simulate png missing but Pillow present + original_import = __import__ + + def fake_import_pil_fallback(name, *args, **kwargs): + if name == "png": + raise ImportError("No png module") + if name == "PIL": + return mock.MagicMock() + return original_import(name, *args, **kwargs) + + mock_img = mock.MagicMock() + with mock.patch("builtins.__import__", side_effect=fake_import_pil_fallback): + with mock.patch.object( + generate_deck.qrcode.QRCode, "make_image", return_value=mock_img + ) as mock_make_image: + qr_file2 = os.path.join(tmp, "qr2.png") + generate_deck.create_qr_image("https://owasp.org", qr_file2, {}) + mock_make_image.assert_called_once_with(fill_color="black", back_color="transparent") + mock_img.save.assert_called_once_with(qr_file2) + + # Simulate both png and PIL missing + def fake_import_no_png_no_pil(name, *args, **kwargs): + if name in ("png", "PIL"): + raise ImportError("Missing image library") + return original_import(name, *args, **kwargs) + + with mock.patch("builtins.__import__", side_effect=fake_import_no_png_no_pil): + with self.assertRaises(ImportError) as ctx: + generate_deck.create_qr_image("https://owasp.org", os.path.join(tmp, "qr3.png"), {}) + self.assertIn("Cannot render QR codes", str(ctx.exception)) + + def test_card_artwork_missing_warnings(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + log_path = os.path.join(tmp, "build.log") + log = generate_deck.BuildLog(log_path) + cfg = _base_config(_DATA_PATTERN) + os.makedirs(cc.qr_dir(cfg, tmp), exist_ok=True) + card = {"card_id": "AA1", "suit_id": "aa", "value": "A", "url": "https://owasp.org"} + generate_deck.card_artwork(card, "demo", "en", "bridge", cfg, {}, tmp, log) + self.assertTrue(any("Card back missing" in w for w in log.warnings)) + self.assertTrue(any("Artwork missing" in w for w in log.warnings)) + + +class TestGenerateDeckScribusExport(unittest.TestCase): + def test_apply_scribus_colours_and_failures(self) -> None: + mock_scribus = mock.MagicMock() + mock_log = mock.MagicMock() + cfg = _base_config(_DATA_PATTERN) + card = {"card_id": "AA1", "suit_id": "aa", "value": "A"} + + with mock.patch.object(generate_deck, "scribus", mock_scribus, create=True): + # Success with Polygon1 + generate_deck.apply_scribus_colours(card, "demo", "en", cfg, {}, mock_log) + mock_scribus.setTextColor.assert_any_call("Demo_AA", "card_number") + mock_scribus.setFillColor.assert_any_call("Demo_AA", "Polygon1") + + # Failure on setTextColor warns + mock_scribus.setTextColor.side_effect = Exception("Scribus error") + generate_deck.apply_scribus_colours(card, "demo", "en", cfg, {}, mock_log) + mock_log.warn.assert_called() + + def test_back_frame_name_and_place_artwork_frame(self) -> None: + mock_scribus = mock.MagicMock() + with mock.patch.object(generate_deck, "scribus", mock_scribus, create=True): + name = generate_deck.back_frame_name() + self.assertEqual(name, "card_back") + mock_scribus.gotoPage.assert_called_with(2) + + generate_deck.place_artwork_frame("frame1", 68.0, 99.0, -6.0) + mock_scribus.sizeObject.assert_called_with(68.0, 99.0, "frame1") + mock_scribus.moveObjectAbs.assert_called_with(-6.0, -6.0, "frame1") + + def test_write_pdf_options_and_icc_handling(self) -> None: + mock_scribus = mock.MagicMock() + mock_pdf = mock.MagicMock() + mock_scribus.PDFfile.return_value = mock_pdf + mock_log = mock.MagicMock() + + with tempfile.TemporaryDirectory() as tmp: + with mock.patch.object(generate_deck, "scribus", mock_scribus, create=True): + cfg = _base_config(_DATA_PATTERN) + card = {"card_id": "AA1"} + + # Successful ICC profile setting + cfg["output"]["color_mode"] = "cmyk" + cfg["output"]["icc_profile"] = "Coated_FOGRA39" + generate_deck.write_pdf( + {"bleed_mm": 3.0, "printers_marks": False}, + card, + "demo", + "en", + "bridge", + cfg, + tmp, + mock_log, + ) + self.assertEqual(mock_pdf.profiles, 1) + self.assertEqual(mock_pdf.solidpr, "Coated_FOGRA39") + self.assertEqual(mock_pdf.imagepr, "Coated_FOGRA39") + self.assertEqual(mock_pdf.printprofc, "Coated_FOGRA39") + + # Unknown color mode and failing ICC profile + cfg["output"]["color_mode"] = "invalid_mode" + cfg["output"]["icc_profile"] = "Coated_FOGRA39" + type(mock_pdf).solidpr = mock.PropertyMock(side_effect=Exception("ICC error")) + + res = generate_deck.write_pdf( + {"bleed_mm": 3.0, "printers_marks": True}, + card, + "demo", + "en", + "bridge", + cfg, + tmp, + mock_log, + ) + self.assertEqual(res["bleed"], "3") + self.assertTrue(res["marks"]) + self.assertEqual(mock_pdf.outdst, 1) # CMYK fallback + self.assertEqual(mock_pdf.profiles, 0) + mock_log.warn.assert_any_call("Unknown color_mode 'invalid_mode'; using cmyk") + mock_pdf.save.assert_called() + + def test_export_pdfs_flow_and_exception_handling(self) -> None: + # Scribus unavailable + with mock.patch.object(generate_deck, "SCRIBUS_AVAILABLE", False): + self.assertEqual( + generate_deck.export_pdfs("sla", {}, "demo", "en", "bridge", {}, {}, "", "", [], mock.MagicMock()), + [], + ) + + # Scribus available - success + mock_scribus = mock.MagicMock() + mock_log = mock.MagicMock() + cfg = _base_config(_DATA_PATTERN) + card = {"card_id": "AA1", "suit_id": "aa", "value": "A"} + + with mock.patch.object(generate_deck, "SCRIBUS_AVAILABLE", True): + with mock.patch.object(generate_deck, "scribus", mock_scribus, create=True): + with mock.patch("generate_deck.write_pdf", return_value={"path": "out.pdf"}): + with mock.patch("cornucopia_common.resolve_background", return_value=("bg.png", True)): + res = generate_deck.export_pdfs( + "sla.sla", card, "demo", "en", "bridge", cfg, {}, "", "", [{"bleed_mm": 3.0}], mock_log + ) + self.assertEqual(len(res), 1) + mock_scribus.openDoc.assert_called_once_with("sla.sla") + mock_scribus.closeDoc.assert_called_once() + + # loadImage failure + mock_scribus.reset_mock() + mock_scribus.loadImage.side_effect = Exception("Load image error") + with mock.patch("generate_deck.write_pdf", return_value={"path": "out.pdf"}): + with mock.patch("cornucopia_common.resolve_background", return_value=("bg.png", True)): + res = generate_deck.export_pdfs( + "sla.sla", card, "demo", "en", "bridge", cfg, {}, "", "", [{"bleed_mm": 3.0}], mock_log + ) + mock_log.warn.assert_any_call("loadImage failed for AA1: Load image error") + + # Background not found + mock_scribus.reset_mock() + with mock.patch("generate_deck.write_pdf", return_value={"path": "out.pdf"}): + with mock.patch("cornucopia_common.resolve_background", return_value=("bg.png", False)): + res = generate_deck.export_pdfs( + "sla.sla", card, "demo", "en", "bridge", cfg, {}, "", "", [{"bleed_mm": 3.0}], mock_log + ) + mock_log.warn.assert_any_call("Artwork not found for AA1: bg.png") + + # Export crash and closeDoc fails + mock_scribus.openDoc.side_effect = Exception("Fatal open error") + mock_scribus.closeDoc.side_effect = Exception("Fatal close error") + res = generate_deck.export_pdfs( + "sla.sla", card, "demo", "en", "bridge", cfg, {}, "", "", [{"bleed_mm": 3.0}], mock_log + ) + self.assertEqual(res, []) + mock_log.warn.assert_any_call("Export crashed on AA1: Fatal open error") + mock_log.warn.assert_any_call("Could not close document after that failure: Fatal close error") + + +class TestGenerateDeckSelectionAndOrchestration(unittest.TestCase): + def test_parse_args(self) -> None: + argv = [ + "generate_deck.py", + "--config", + "custom.yaml", + "--edition", + "demo", + "--language", + "en", + "--size", + "bridge", + "--cards", + "AA1,AA2", + "--profile", + "print", + "--output-dir", + "out", + "--dry-run", + "--skip-existing", + "--base-dir", + "/tmp/base", + ] + with mock.patch("sys.argv", argv): + args = generate_deck.parse_args() + self.assertEqual(args.config, "custom.yaml") + self.assertEqual(args.edition, ["demo"]) + self.assertEqual(args.cards, "AA1,AA2") + self.assertTrue(args.dry_run) + self.assertTrue(args.skip_existing) + + def test_select_deck_conditions(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + log_path = os.path.join(tmp, "b.log") + log = generate_deck.BuildLog(log_path) + cfg = _base_config("source/%edition%-cards-%edition_version%-%language%.yaml") + cfg["size_profiles"]["bridge"]["template"] = "missing_template.sla" + target = {"edition": "demo", "language": "en", "size": "bridge"} + args = mock.Mock(cards=None) + + # Template missing + self.assertIsNone(generate_deck.select_deck(target, args, cfg, tmp, log)) + + # Template found in SCRIPT_DIR + cfg["size_profiles"]["bridge"]["template"] = "small_master.sla" + # No cards found at data path + self.assertIsNone(generate_deck.select_deck(target, args, cfg, tmp, log)) + + # Cards found and filtered by --cards + _write( + os.path.join(tmp, "source", "demo-cards-1.0-en.yaml"), + _card_yaml(" - id: AA\n name: AA\n cards:\n - id: AA1\n - id: AA2\n"), + ) + _write(os.path.join(tmp, "tmpl.sla"), _MINIMAL_SLA) + cfg["size_profiles"]["bridge"]["template"] = "tmpl.sla" + args_cards = mock.Mock(cards="AA1") + res = generate_deck.select_deck(target, args_cards, cfg, tmp, log) + self.assertIsNotNone(res) + deck, suits, template = res + self.assertEqual(len(deck), 1) + self.assertEqual(deck[0]["card_id"], "AA1") + + # Cards filtered by --cards but none match + args_nomatch = mock.Mock(cards="UNKNOWN") + self.assertIsNone(generate_deck.select_deck(target, args_nomatch, cfg, tmp, log)) + + def test_report_missing_artwork_and_already_exported(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + log_path = os.path.join(tmp, "b.log") + log = generate_deck.BuildLog(log_path) + cfg = _base_config(_DATA_PATTERN) + target = {"edition": "demo", "language": "en", "size": "bridge"} + deck = [{"card_id": "AA{0}".format(i), "suit_id": "aa"} for i in range(12)] + + # report_missing_artwork with missing back and cards + generate_deck.report_missing_artwork(deck, target, cfg, {}, tmp, log) + self.assertTrue(any("artwork missing for 12 card(s)" in w for w in log.warnings)) + + # all artwork resolved + with mock.patch("cornucopia_common.resolve_card_back", return_value=("back.png", True)): + with mock.patch("cornucopia_common.resolve_background", return_value=("bg.png", True)): + generate_deck.report_missing_artwork(deck, target, cfg, {}, tmp, log) + + # already_exported + profiles = [{"bleed_mm": 3.0, "printers_marks": False}] + card = {"card_id": "AA1"} + self.assertFalse(generate_deck.already_exported(card, target, cfg, tmp, profiles)) + c_name = cc.card_pdf_name(cfg, "demo", "AA1", "bridge", "en", 3.0, False) + _write(os.path.join(tmp, c_name), "pdf") + self.assertTrue(generate_deck.already_exported(card, target, cfg, tmp, profiles)) + + def test_build_target(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + log_path = os.path.join(tmp, "b.log") + log = generate_deck.BuildLog(log_path) + cfg = _base_config("source/%edition%-cards-%edition_version%-%language%.yaml") + cfg["editions"] = {"demo": {}} + _write(os.path.join(tmp, "tmpl.sla"), _MINIMAL_SLA) + cfg["size_profiles"]["bridge"]["template"] = "tmpl.sla" + _write( + os.path.join(tmp, "source", "demo-cards-1.0-en.yaml"), + _card_yaml(" - id: AA\n cards:\n - id: AA1\n"), + ) + out = os.path.join(tmp, "out") + os.makedirs(out, exist_ok=True) + target = {"edition": "demo", "language": "en", "size": "bridge"} + profiles = [{"name": "main", "bleed_mm": 3.0, "printers_marks": False}] + args_dry = mock.Mock(cards=None, dry_run=True, skip_existing=False) + + # Select deck returns None + with mock.patch("generate_deck.select_deck", return_value=None): + self.assertIsNone(generate_deck.build_target(target, args_dry, cfg, {}, tmp, out, profiles, log)) + + # Dry run + rec_dry = generate_deck.build_target(target, args_dry, cfg, {}, tmp, out, profiles, log) + self.assertIsNotNone(rec_dry) + self.assertEqual(rec_dry["cards"], 1) + + # Normal build with skip_existing + args_run = mock.Mock(cards=None, dry_run=False, skip_existing=True) + with mock.patch.object(generate_deck, "SCRIBUS_AVAILABLE", True): + with mock.patch("generate_deck.already_exported", return_value=True): + rec = generate_deck.build_target(target, args_run, cfg, {}, tmp, out, profiles, log) + self.assertEqual(rec["pdfs"], 0) + + # Normal build executing generate_card + with mock.patch.object(generate_deck, "SCRIBUS_AVAILABLE", False): + args_build = mock.Mock(cards=None, dry_run=False, skip_existing=False) + rec = generate_deck.build_target(target, args_build, cfg, {}, tmp, out, profiles, log) + self.assertEqual(rec["cards"], 1) + + +class TestGenerateDeckMain(unittest.TestCase): + def test_main_failures(self) -> None: + # Config missing + with mock.patch("sys.argv", ["generate_deck.py", "--config", "nonexistent.yaml"]): + with mock.patch("cornucopia_common.load_yaml", return_value=None): + self.assertEqual(generate_deck.main(), 1) + + # Base dir missing + with mock.patch("sys.argv", ["generate_deck.py", "--base-dir", "/nonexistent/base"]): + with mock.patch("cornucopia_common.load_yaml", return_value={"project": "test"}): + self.assertEqual(generate_deck.main(), 1) + + # No targets + with tempfile.TemporaryDirectory() as tmp: + cfg_path = _write(os.path.join(tmp, "pdf_config.yaml"), "project: test\n") + with mock.patch("sys.argv", ["generate_deck.py", "--config", cfg_path, "--base-dir", tmp]): + with mock.patch("cornucopia_common.resolve_targets", return_value=([], ["No targets"])): + self.assertEqual(generate_deck.main(), 1) + + # Invalid profile + with mock.patch( + "sys.argv", ["generate_deck.py", "--config", cfg_path, "--base-dir", tmp, "--profile", "bad"] + ): + with mock.patch("cornucopia_common.resolve_targets", return_value=([{"edition": "demo"}], [])): + with mock.patch( + "cornucopia_common.get_export_profiles", side_effect=ValueError("Profile not found") + ): + self.assertEqual(generate_deck.main(), 1) + + def test_main_success_dry_run_and_build(self) -> None: + import yaml + + with tempfile.TemporaryDirectory() as tmp: + cfg = _base_config("source/%edition%-cards-%edition_version%-%language%.yaml") + cfg["editions"] = {"demo": {}} + _write(os.path.join(tmp, "tmpl.sla"), _MINIMAL_SLA) + cfg["size_profiles"]["bridge"]["template"] = "tmpl.sla" + _write( + os.path.join(tmp, "source", "demo-cards-1.0-en.yaml"), + _card_yaml( + " - id: AA\n" + " name: AA\n" + " cards:\n" + " - id: AA1\n" + " value: '1'\n" + " desc: atk\n" + " url: ''\n" + ), + ) + cfg_path = os.path.join(tmp, "pdf_config.yaml") + with open(cfg_path, "w", encoding="utf-8") as f: + yaml.dump(cfg, f) + out = os.path.join(tmp, "out") + + # Dry run + with mock.patch( + "sys.argv", + [ + "generate_deck.py", + "--config", + cfg_path, + "--base-dir", + tmp, + "--output-dir", + out, + "--edition", + "demo", + "--language", + "en", + "--size", + "bridge", + "--dry-run", + ], + ): + self.assertEqual(generate_deck.main(), 0) + + # Real build + with mock.patch( + "sys.argv", + [ + "generate_deck.py", + "--config", + cfg_path, + "--base-dir", + tmp, + "--output-dir", + out, + "--edition", + "demo", + "--language", + "en", + "--size", + "bridge", + ], + ): + self.assertEqual(generate_deck.main(), 0) + + def test_module_import_fallbacks(self) -> None: + original_import = __import__ + + # cornucopia_common missing raises ImportError and writes to stderr + def fake_import_no_cc(name, *args, **kwargs): + if name == "cornucopia_common": + raise ImportError("mock cc missing") + return original_import(name, *args, **kwargs) + + with mock.patch("builtins.__import__", side_effect=fake_import_no_cc): + with self.assertRaises(ImportError): + runpy.run_path(os.path.join(_PDF_GENERATION, "generate_deck.py")) + + # defusedxml missing sets XML_HARDENED = False + def fake_import_no_defusedxml(name, *args, **kwargs): + if name == "defusedxml.ElementTree": + raise ImportError("mock defusedxml missing") + return original_import(name, *args, **kwargs) + + with mock.patch("builtins.__import__", side_effect=fake_import_no_defusedxml): + runpy.run_path(os.path.join(_PDF_GENERATION, "generate_deck.py")) + + # qrcode missing sets QR_AVAILABLE = False + def fake_import_no_qr(name, *args, **kwargs): + if name == "qrcode": + raise ImportError("mock qrcode missing") + return original_import(name, *args, **kwargs) + + with mock.patch("builtins.__import__", side_effect=fake_import_no_qr): + runpy.run_path(os.path.join(_PDF_GENERATION, "generate_deck.py")) + + def test_main_fatal_exception_and_system_exit(self) -> None: + # SystemExit passes through + with mock.patch("sys.argv", ["generate_deck.py", "--config", "nonexistent.yaml"]): + with self.assertRaises(SystemExit) as ctx: + runpy.run_path(os.path.join(_PDF_GENERATION, "generate_deck.py"), run_name="__main__") + self.assertEqual(ctx.exception.code, 1) + + # Exception caught, logged to build_log.txt, and re-raised + with mock.patch("sys.argv", ["generate_deck.py", "--config", "nonexistent.yaml"]): + with mock.patch("cornucopia_common.find_config_path", side_effect=RuntimeError("Fatal deck crash")): + with self.assertRaises(RuntimeError): + runpy.run_path(os.path.join(_PDF_GENERATION, "generate_deck.py"), run_name="__main__") + log_file = os.path.join(_PDF_GENERATION, "build_log.txt") + if os.path.exists(log_file): + os.remove(log_file) + if __name__ == "__main__": unittest.main()