Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
44 changes: 44 additions & 0 deletions scripts/equipment_catalog/test_catalog.py
Original file line number Diff line number Diff line change
@@ -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
134 changes: 134 additions & 0 deletions scripts/equipment_catalog/test_classification.py
Original file line number Diff line number Diff line change
@@ -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"]
45 changes: 45 additions & 0 deletions scripts/equipment_catalog/test_prototypes.py
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions scripts/equipment_catalog/test_relations.py
Original file line number Diff line number Diff line change
@@ -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
81 changes: 81 additions & 0 deletions scripts/equipment_catalog/test_statistics.py
Original file line number Diff line number Diff line change
@@ -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"
Loading