From 1711cc504a19cca0e90cb961cba668e7321f5127 Mon Sep 17 00:00:00 2001 From: Mechanica Date: Thu, 13 Aug 2026 09:12:26 +0500 Subject: [PATCH] =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D1=8B=20=D0=B5=D0=B1?= =?UTF-8?q?=D0=B0=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-checks.yml | 15 ++ scripts/equipment_catalog/test_catalog.py | 44 ++++++ .../equipment_catalog/test_classification.py | 134 ++++++++++++++++++ scripts/equipment_catalog/test_prototypes.py | 45 ++++++ scripts/equipment_catalog/test_relations.py | 50 +++++++ scripts/equipment_catalog/test_statistics.py | 81 +++++++++++ scripts/test_build_chemistry_catalog.py | 131 +++++++++++++++++ scripts/test_chemistry_yaml.py | 15 ++ 8 files changed, 515 insertions(+) create mode 100644 scripts/equipment_catalog/test_catalog.py create mode 100644 scripts/equipment_catalog/test_classification.py create mode 100644 scripts/equipment_catalog/test_prototypes.py create mode 100644 scripts/equipment_catalog/test_relations.py create mode 100644 scripts/equipment_catalog/test_statistics.py create mode 100644 scripts/test_build_chemistry_catalog.py create mode 100644 scripts/test_chemistry_yaml.py diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 6f45626..afbfed1 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -19,6 +19,21 @@ jobs: - name: Lint scripts run: ruff check scripts/ + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v7.0.1 + + - name: Install test tools + run: | + python3 -m pip install -r requirements.txt + python3 -m pip install pytest + + - name: Run tests + run: python3 -m pytest scripts/ + validate-data: runs-on: ubuntu-latest diff --git a/scripts/equipment_catalog/test_catalog.py b/scripts/equipment_catalog/test_catalog.py new file mode 100644 index 0000000..b49bc9c --- /dev/null +++ b/scripts/equipment_catalog/test_catalog.py @@ -0,0 +1,44 @@ +from equipment_catalog.catalog import capitalize_first, catalog_display_name, should_publish_component + + +def test_capitalize_first_uppercases_first_letter(): + assert capitalize_first("нож") == "Нож" + + +def test_capitalize_first_leaves_digit_led_names_untouched(): + assert capitalize_first("9mm пистолет") == "9mm пистолет" + + +def test_capitalize_first_skips_leading_punctuation(): + assert capitalize_first("«винтовка»") == "«Винтовка»" + + +def test_catalog_display_name_no_suffix_returns_base_name(): + assert catalog_display_name("Ящик", "") == "Ящик" + + +def test_catalog_display_name_ignores_load_state_qualifiers(): + assert catalog_display_name("Ящик", "empty") == "Ящик" + assert catalog_display_name("Ящик", "пуст") == "Ящик" + + +def test_catalog_display_name_keeps_real_qualifiers(): + assert catalog_display_name("Ящик", "синий") == "Ящик (синий)" + + +def test_catalog_display_name_keeps_only_non_ignored_qualifiers(): + assert catalog_display_name("Ящик", "empty, синий") == "Ящик (синий)" + + +def test_should_publish_component_rejects_visuals(): + assert should_publish_component("SpriteVisuals") is False + assert should_publish_component("SomeVisualizer") is False + + +def test_should_publish_component_accepts_weapon_prefixes(): + assert should_publish_component("AttachableSizeMods") is True + assert should_publish_component("GunDamageModifier") is True + + +def test_should_publish_component_rejects_unrelated_component(): + assert should_publish_component("Appearance") is False diff --git a/scripts/equipment_catalog/test_classification.py b/scripts/equipment_catalog/test_classification.py new file mode 100644 index 0000000..0ee4267 --- /dev/null +++ b/scripts/equipment_catalog/test_classification.py @@ -0,0 +1,134 @@ +from equipment_catalog.classification import ( + classify_item, + has_meaningful_armor, + infer_types, + is_ammunition_container, + is_dedicated_melee_weapon, + source_category_hint, +) + +EMPTY_POLICY: dict = {"excludePrototypeIds": [], "categoryOverrides": {}} + + +def test_classify_item_excluded_by_policy(): + item = {"id": "Secret", "componentTypes": [], "tags": []} + policy = {"excludePrototypeIds": ["Secret"], "categoryOverrides": {}} + result = classify_item(item, policy) + assert result["status"] == "excluded" + + +def test_classify_item_category_override_wins(): + item = {"id": "Weird", "componentTypes": ["Gun"], "tags": []} + policy = {"excludePrototypeIds": [], "categoryOverrides": {"Weird": "gear"}} + result = classify_item(item, policy) + assert result["status"] == "public" + assert result["categoryId"] == "gear" + + +def test_classify_item_weapon_via_gun_component(): + item = {"id": "RMCWeaponM13", "componentTypes": ["Gun"], "tags": []} + result = classify_item(item, EMPTY_POLICY) + assert result["categoryId"] == "weapon" + + +def test_classify_item_attachment_via_attachable_component(): + item = {"id": "RMCAttachmentScope", "componentTypes": ["Attachable"], "tags": []} + result = classify_item(item, EMPTY_POLICY) + assert result["categoryId"] == "attachment" + + +def test_classify_item_ammunition_via_cartridge(): + item = {"id": "RMCCartridge9mm", "componentTypes": ["CartridgeAmmo"], "tags": []} + result = classify_item(item, EMPTY_POLICY) + assert result["categoryId"] == "ammunition" + + +def test_classify_item_armor_via_meaningful_cmarmor_and_slot(): + item = { + "id": "CMArmorM3", + "componentTypes": [], + "tags": [], + "properties": {"CMArmor": {"bullet": 10}}, + "equipmentSlots": ["outerclothing"], + } + result = classify_item(item, EMPTY_POLICY) + assert result["categoryId"] == "armor" + + +def test_classify_item_melee_via_dedicated_melee_weapon(): + item = {"id": "RMCKnifeCombat", "componentTypes": ["MeleeWeapon"], "tags": []} + result = classify_item(item, EMPTY_POLICY) + assert result["categoryId"] == "melee" + + +def test_classify_item_medicine_via_pill_component(): + item = {"id": "CMPillTramadol", "componentTypes": ["Pill"], "tags": []} + result = classify_item(item, EMPTY_POLICY) + assert result["categoryId"] == "medicine" + + +def test_classify_item_fallback_other_when_nothing_matches(): + item = {"id": "MysteryItem", "componentTypes": [], "tags": []} + result = classify_item(item, EMPTY_POLICY) + assert result["categoryId"] == "other" + assert result["reason"] == "no stronger universal functional rule matched" + + +def test_classify_item_packaging_box_defaults_to_other(): + item = { + "id": "RMCBoxSupplies", + "name": "ящик снабжения", + "componentTypes": ["Storage"], + "tags": [], + } + result = classify_item(item, EMPTY_POLICY) + assert result["categoryId"] == "other" + assert "container" in result["signals"][0] + + +def test_has_meaningful_armor_true_for_nonzero_stat(): + assert has_meaningful_armor({"CMArmor": {"bullet": 10}}) is True + + +def test_has_meaningful_armor_false_for_empty_marker(): + assert has_meaningful_armor({"Armor": {}}) is False + + +def test_is_ammunition_container_by_tag(): + assert is_ammunition_container("SomeBox", set(), {"RMCAmmoBox"}) is True + + +def test_is_ammunition_container_by_prefix(): + assert is_ammunition_container("RMCBoxMagazinePistol", set(), set()) is True + + +def test_is_ammunition_container_false_for_unrelated_item(): + assert is_ammunition_container("RandomItem", set(), set()) is False + + +def test_is_dedicated_melee_weapon_requires_melee_component(): + assert is_dedicated_melee_weapon("RMCKnife", set(), set()) is False + + +def test_is_dedicated_melee_weapon_matches_keyword(): + assert is_dedicated_melee_weapon("RMCKnifeCombat", {"MeleeWeapon"}, set()) is True + + +def test_source_category_hint_direct_match(): + assert source_category_hint("Armor") == "armor" + + +def test_source_category_hint_fragment_match(): + assert source_category_hint("Restricted Firearm Ammunition") == "magazine-or-ammo-container" + + +def test_source_category_hint_no_match_returns_none(): + assert source_category_hint("Not A Real Section") is None + + +def test_infer_types_detects_weapon(): + assert "weapon" in infer_types("RMCWeaponM13", {"Gun": {}}, set()) + + +def test_infer_types_falls_back_to_misc(): + assert infer_types("Unknown", {}, set()) == ["misc"] diff --git a/scripts/equipment_catalog/test_prototypes.py b/scripts/equipment_catalog/test_prototypes.py new file mode 100644 index 0000000..71aaa7f --- /dev/null +++ b/scripts/equipment_catalog/test_prototypes.py @@ -0,0 +1,45 @@ +from equipment_catalog.prototypes import normalize_parents, origin_from_path, parse_box2i + + +def test_normalize_parents_single_string(): + assert normalize_parents("Base") == ("Base",) + + +def test_normalize_parents_list_filters_non_strings(): + assert normalize_parents(["A", 2, "B"]) == ("A", "B") + + +def test_normalize_parents_other_types_return_empty_tuple(): + assert normalize_parents(None) == () + + +def test_origin_from_path_stories(): + assert origin_from_path("Resources/Prototypes/_Stories/Reagents/foo.yml") == "stories" + + +def test_origin_from_path_rmc14(): + assert origin_from_path("Resources/Prototypes/_RMC14/Reagents/foo.yml") == "rmc14" + + +def test_origin_from_path_defaults_to_upstream(): + assert origin_from_path("Resources/Prototypes/Reagents/foo.yml") == "upstream" + + +def test_parse_box2i_from_string(): + assert parse_box2i("0,0,1,1") == (0, 0, 1, 1) + + +def test_parse_box2i_from_list(): + assert parse_box2i([0, 1, 2, 3]) == (0, 1, 2, 3) + + +def test_parse_box2i_wrong_length_returns_none(): + assert parse_box2i("0,0,1") is None + + +def test_parse_box2i_non_numeric_returns_none(): + assert parse_box2i("a,b,c,d") is None + + +def test_parse_box2i_unsupported_type_returns_none(): + assert parse_box2i(None) is None diff --git a/scripts/equipment_catalog/test_relations.py b/scripts/equipment_catalog/test_relations.py new file mode 100644 index 0000000..c72dd7c --- /dev/null +++ b/scripts/equipment_catalog/test_relations.py @@ -0,0 +1,50 @@ +from equipment_catalog.relations import add_relation, relation_key, whitelist_matches + + +def test_relation_key_is_stable_regardless_of_key_order(): + a = {"from": "X", "to": "Y", "type": "contains"} + b = {"type": "contains", "to": "Y", "from": "X"} + assert relation_key(a) == relation_key(b) + + +def test_relation_key_differs_for_different_relations(): + a = {"from": "X", "to": "Y", "type": "contains"} + b = {"from": "X", "to": "Z", "type": "contains"} + assert relation_key(a) != relation_key(b) + + +def test_add_relation_appends_new_relation(): + relations = [] + known = set() + added = add_relation(relations, known, {"from": "X", "to": "Y", "type": "contains"}) + assert added is True + assert relations == [{"from": "X", "to": "Y", "type": "contains"}] + + +def test_add_relation_rejects_duplicate(): + relations = [] + known = set() + add_relation(relations, known, {"from": "X", "to": "Y", "type": "contains"}) + added_again = add_relation(relations, known, {"from": "X", "to": "Y", "type": "contains"}) + assert added_again is False + assert len(relations) == 1 + + +def test_whitelist_matches_by_entity_id(): + assert whitelist_matches({"entities": ["RMCWeaponM13"]}, "RMCWeaponM13", set(), set()) is True + + +def test_whitelist_matches_by_tag(): + assert whitelist_matches({"tags": ["Rifle"]}, "x", {"Rifle"}, set()) is True + + +def test_whitelist_matches_by_component(): + assert whitelist_matches({"components": ["Gun"]}, "x", set(), {"Gun"}) is True + + +def test_whitelist_matches_no_overlap_returns_false(): + assert whitelist_matches({"tags": ["Rifle"]}, "x", {"Pistol"}, set()) is False + + +def test_whitelist_matches_non_dict_returns_false(): + assert whitelist_matches(None, "x", set(), set()) is False diff --git a/scripts/equipment_catalog/test_statistics.py b/scripts/equipment_catalog/test_statistics.py new file mode 100644 index 0000000..7a818f4 --- /dev/null +++ b/scripts/equipment_catalog/test_statistics.py @@ -0,0 +1,81 @@ +from equipment_catalog.statistics import ( + box_cells, + default_storage_max_size, + packing_capacity, + parse_vector2i, + shape_cells, + storage_whitelist_matches, +) + + +def test_box_cells_single_1x1_box(): + assert box_cells([(0, 0, 0, 0)]) == {(0, 0)} + + +def test_box_cells_covers_full_rectangle(): + assert box_cells([(0, 0, 1, 1)]) == {(0, 0), (0, 1), (1, 0), (1, 1)} + + +def test_shape_cells_normalizes_to_origin(): + # A box that doesn't start at (0, 0) should be shifted so its minimum corner is (0, 0). + assert shape_cells([(2, 3, 3, 4)]) == {(0, 0), (0, 1), (1, 0), (1, 1)} + + +def test_packing_capacity_fits_expected_count(): + grid = [(0, 0, 1, 1)] # 2x2 grid = 4 cells + item = [(0, 0, 0, 0)] # 1x1 item + assert packing_capacity(grid, item) == 4 + + +def test_packing_capacity_item_bigger_than_grid(): + grid = [(0, 0, 0, 0)] # 1x1 grid + item = [(0, 0, 1, 1)] # 2x2 item + assert packing_capacity(grid, item) == 0 + + +def test_packing_capacity_empty_grid_or_item_returns_zero(): + assert packing_capacity([], [(0, 0, 0, 0)]) == 0 + assert packing_capacity([(0, 0, 0, 0)], []) == 0 + + +def test_parse_vector2i_from_string(): + assert parse_vector2i("3,4", default=(0, 0)) == (3, 4) + + +def test_parse_vector2i_from_list(): + assert parse_vector2i([3, 4], default=(0, 0)) == (3, 4) + + +def test_parse_vector2i_invalid_returns_default(): + assert parse_vector2i("not-a-vector", default=(1, 1)) == (1, 1) + assert parse_vector2i(None, default=(1, 1)) == (1, 1) + + +def test_storage_whitelist_matches_true_when_no_rule(): + assert storage_whitelist_matches(None, {}) is True + + +def test_storage_whitelist_matches_by_component(): + rule = {"components": ["Gun"]} + item = {"componentTypes": ["Gun"], "tags": []} + assert storage_whitelist_matches(rule, item) is True + + +def test_storage_whitelist_matches_require_all(): + rule = {"requireAll": True, "components": ["Gun"], "sizes": ["Small"]} + item = {"componentTypes": ["Gun"], "itemSize": "Normal", "tags": []} + assert storage_whitelist_matches(rule, item) is False + + +def test_default_storage_max_size_picks_size_below_container(): + item_sizes = { + "Small": {"weight": 1}, + "Normal": {"weight": 2}, + "Large": {"weight": 3}, + } + assert default_storage_max_size("Large", item_sizes) == "Normal" + + +def test_default_storage_max_size_unknown_container_prefers_normal(): + item_sizes = {"Small": {"weight": 1}, "Normal": {"weight": 2}} + assert default_storage_max_size("Unknown", item_sizes) == "Normal" diff --git a/scripts/test_build_chemistry_catalog.py b/scripts/test_build_chemistry_catalog.py new file mode 100644 index 0000000..5c11714 --- /dev/null +++ b/scripts/test_build_chemistry_catalog.py @@ -0,0 +1,131 @@ +import pytest + +from build_chemistry_catalog import ( + automatic_section, + localized, + normalize_amounts, + resolve_field, +) + + +def make_prototype(definition, parents=(), origin="rmc14"): + return {"definition": definition, "parents": list(parents), "origin": origin} + + +def test_resolve_field_own_definition_wins(): + prototypes = {"Child": make_prototype({"name": "child-name"})} + assert resolve_field("Child", "name", prototypes, {}) == "child-name" + + +def test_resolve_field_falls_back_to_parent(): + prototypes = { + "Child": make_prototype({}, parents=["Parent"]), + "Parent": make_prototype({"name": "parent-name"}), + } + assert resolve_field("Child", "name", prototypes, {}) == "parent-name" + + +def test_resolve_field_missing_field_returns_none(): + prototypes = {"Child": make_prototype({})} + assert resolve_field("Child", "name", prototypes, {}) is None + + +def test_resolve_field_unknown_prototype_returns_none(): + assert resolve_field("Ghost", "name", {}, {}) is None + + +def test_resolve_field_later_parent_has_precedence(): + # SS14 multiple inheritance: later parents in the list win over earlier ones. + prototypes = { + "Child": make_prototype({}, parents=["First", "Second"]), + "First": make_prototype({"name": "first-name"}), + "Second": make_prototype({"name": "second-name"}), + } + assert resolve_field("Child", "name", prototypes, {}) == "second-name" + + +def test_resolve_field_detects_circular_inheritance(): + prototypes = { + "A": make_prototype({}, parents=["B"]), + "B": make_prototype({}, parents=["A"]), + } + with pytest.raises(RuntimeError, match="Circular reagent inheritance"): + resolve_field("A", "name", prototypes, {}) + + +def test_resolve_field_caches_results(): + cache = {} + prototypes = {"Child": make_prototype({"name": "child-name"})} + resolve_field("Child", "name", prototypes, cache) + assert cache[("Child", "name")] == "child-name" + + +def test_localized_returns_translation(): + assert localized("chem-water-name", {"chem-water-name": "Вода"}) == "Вода" + + +def test_localized_falls_back_to_key_when_missing(): + assert localized("chem-unknown", {}) == "chem-unknown" + + +def test_localized_non_string_returns_none(): + assert localized(None, {}) is None + assert localized(42, {}) is None + + +def test_normalize_amounts_uses_linked_reagent_name(): + records = {"Water": {"name": "Вода"}} + result = normalize_amounts({"Water": 5}, records) + assert result == [{"id": "Water", "name": "Вода", "amount": 5}] + + +def test_normalize_amounts_falls_back_to_id_when_unlinked(): + result = normalize_amounts({"Unknown": 3}, {}) + assert result == [{"id": "Unknown", "name": "Unknown", "amount": 3}] + + +def test_normalize_amounts_reads_catalyst_flag(): + result = normalize_amounts({"Acid": {"amount": 2, "catalyst": True}}, {}) + assert result == [{"id": "Acid", "name": "Acid", "amount": 2, "catalyst": True}] + + +def test_normalize_amounts_non_dict_returns_empty(): + assert normalize_amounts(None, {}) == [] + assert normalize_amounts([1, 2], {}) == [] + + +CHEMICAL_GROUP_SECTIONS = {"Medicine": ["Медицина"], "Elements": ["Элементы"]} + + +def test_automatic_section_explicit_override_wins(): + tab_id, section_path, included_by = automatic_section( + "RMCTableSalt", {"properties": {}, "sourceFile": ""}, CHEMICAL_GROUP_SECTIONS + ) + assert (tab_id, included_by) == ("other", "explicit-override") + assert section_path == ["Продукты"] + + +def test_automatic_section_uses_chemical_group(): + record = {"properties": {"group": "Medicine"}, "sourceFile": ""} + tab_id, section_path, included_by = automatic_section("SomeReagent", record, CHEMICAL_GROUP_SECTIONS) + assert tab_id == "medicine" + assert section_path == ["Медицина"] + assert included_by == "rmc-chemicals-guide" + + +def test_automatic_section_source_file_fallback(): + record = {"properties": {}, "sourceFile": "Resources/Prototypes/Reagents/explosives.yml"} + tab_id, section_path, included_by = automatic_section("SomeReagent", record, CHEMICAL_GROUP_SECTIONS) + assert (tab_id, section_path, included_by) == ("ordnance", ["Прекурсоры"], "source-file-fallback") + + +def test_automatic_section_metabolism_fallback(): + record = {"properties": {"metabolisms": {"Narcotic": {}}}, "sourceFile": ""} + tab_id, section_path, included_by = automatic_section("SomeReagent", record, CHEMICAL_GROUP_SECTIONS) + assert (tab_id, section_path, included_by) == ("other", ["Наркотики"], "metabolism-fallback") + + +def test_automatic_section_unclassified_fallback(): + record = {"properties": {}, "sourceFile": ""} + tab_id, section_path, included_by = automatic_section("SomeReagent", record, CHEMICAL_GROUP_SECTIONS) + assert (tab_id, section_path, included_by) == ("other", ["Прочее"], "unclassified-fallback") diff --git a/scripts/test_chemistry_yaml.py b/scripts/test_chemistry_yaml.py new file mode 100644 index 0000000..ac1e5d8 --- /dev/null +++ b/scripts/test_chemistry_yaml.py @@ -0,0 +1,15 @@ +from chemistry_yaml import normalize_parents + + +def test_normalize_parents_single_string(): + assert normalize_parents("BaseReagent") == ["BaseReagent"] + + +def test_normalize_parents_list_filters_non_strings(): + assert normalize_parents(["A", 1, "B", None]) == ["A", "B"] + + +def test_normalize_parents_other_types_return_empty(): + assert normalize_parents(None) == [] + assert normalize_parents(42) == [] + assert normalize_parents({}) == []