Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
.pytest_cache/
81 changes: 81 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Shared fixtures for BattleMinecraft datapack tests."""

import json
import re
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parent.parent
DATA_DIR = REPO_ROOT / "data"
BF_FUNCTIONS_DIR = DATA_DIR / "bf" / "functions"


@pytest.fixture(scope="session")
def repo_root():
return REPO_ROOT


@pytest.fixture(scope="session")
def data_dir():
return DATA_DIR


@pytest.fixture(scope="session")
def bf_functions_dir():
return BF_FUNCTIONS_DIR


@pytest.fixture(scope="session")
def all_mcfunction_files():
"""Return all .mcfunction files in the datapack."""
return sorted(BF_FUNCTIONS_DIR.rglob("*.mcfunction"))


@pytest.fixture(scope="session")
def all_json_files():
"""Return all .json files in the data directory (excluding .git)."""
return sorted(p for p in DATA_DIR.rglob("*.json"))


@pytest.fixture(scope="session")
def mcfunction_contents(all_mcfunction_files):
"""Map from relative function path to list of non-comment, non-blank lines."""
result = {}
for path in all_mcfunction_files:
rel = path.relative_to(BF_FUNCTIONS_DIR)
lines = []
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped and not stripped.startswith("#"):
lines.append(stripped)
result[str(rel)] = lines
return result


@pytest.fixture(scope="session")
def function_call_pattern():
"""Compiled regex that matches `function <namespace>:<path>` calls."""
return re.compile(r"\bfunction\s+([\w]+:[\w/]+)")


@pytest.fixture(scope="session")
def setup_lines():
"""Return the non-comment lines of setup.mcfunction."""
path = BF_FUNCTIONS_DIR / "setup.mcfunction"
return [
line.strip()
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.strip().startswith("#")
]


@pytest.fixture(scope="session")
def uninstall_lines():
"""Return the non-comment lines of uninstall.mcfunction."""
path = BF_FUNCTIONS_DIR / "uninstall.mcfunction"
return [
line.strip()
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.strip().startswith("#")
]
122 changes: 122 additions & 0 deletions tests/test_class_system.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Tests for the class/loadout selection system."""

import re

import pytest

from conftest import BF_FUNCTIONS_DIR

CLASS_DIR = BF_FUNCTIONS_DIR / "class"
CLASS_FILES = {
1: "assault",
2: "medic",
3: "support",
4: "recon",
5: "assassin",
}


class TestClassSelect:
def test_select_file_exists(self):
assert (CLASS_DIR / "select.mcfunction").is_file()

def test_select_maps_all_class_ids(self):
text = (CLASS_DIR / "select.mcfunction").read_text(encoding="utf-8")
for class_id, class_name in CLASS_FILES.items():
expected = f"bf:class/{class_name}"
assert expected in text, (
f"select.mcfunction must reference {expected} for class ID {class_id}"
)

def test_select_resets_class_score(self):
text = (CLASS_DIR / "select.mcfunction").read_text(encoding="utf-8")
assert "scoreboard players set @s bf_class 0" in text, (
"select.mcfunction must reset bf_class to 0 after processing"
)

@pytest.mark.parametrize(
"class_id,class_name",
CLASS_FILES.items(),
ids=CLASS_FILES.values(),
)
def test_select_checks_correct_score(self, class_id, class_name):
text = (CLASS_DIR / "select.mcfunction").read_text(encoding="utf-8")
pattern = f"bf_class={class_id}"
assert pattern in text, (
f"select.mcfunction must check bf_class={class_id} for {class_name}"
)


class TestClassFiles:
@pytest.mark.parametrize("class_name", CLASS_FILES.values())
def test_class_file_exists(self, class_name):
path = CLASS_DIR / f"{class_name}.mcfunction"
assert path.is_file(), f"Missing class file: {class_name}.mcfunction"

@pytest.mark.parametrize("class_name", CLASS_FILES.values())
def test_class_clears_inventory(self, class_name):
"""Each class should clear the player's inventory before equipping."""
text = (CLASS_DIR / f"{class_name}.mcfunction").read_text(encoding="utf-8")
assert "clear @s" in text, (
f"{class_name}.mcfunction must clear player inventory"
)

@pytest.mark.parametrize("class_name", CLASS_FILES.values())
def test_class_sets_health(self, class_name):
"""Each class should set player health via sh_health."""
text = (CLASS_DIR / f"{class_name}.mcfunction").read_text(encoding="utf-8")
assert "sh_health set @s" in text, (
f"{class_name}.mcfunction must set player health"
)

@pytest.mark.parametrize("class_name", CLASS_FILES.values())
def test_class_gives_speed_effect(self, class_name):
"""Each class should grant a speed effect."""
text = (CLASS_DIR / f"{class_name}.mcfunction").read_text(encoding="utf-8")
assert "effect give @s speed" in text, (
f"{class_name}.mcfunction must grant a speed effect"
)

@pytest.mark.parametrize("class_name", CLASS_FILES.values())
def test_class_has_melee_weapon(self, class_name):
"""Each class should include a melee weapon (netherite_sword)."""
text = (CLASS_DIR / f"{class_name}.mcfunction").read_text(encoding="utf-8")
assert "netherite_sword" in text, (
f"{class_name}.mcfunction must include a melee weapon"
)

@pytest.mark.parametrize("class_name", CLASS_FILES.values())
def test_class_equips_armor(self, class_name):
"""Each class should equip all four armor slots."""
text = (CLASS_DIR / f"{class_name}.mcfunction").read_text(encoding="utf-8")
for slot in ["armor.head", "armor.chest", "armor.legs", "armor.feet"]:
assert slot in text, (
f"{class_name}.mcfunction must equip {slot}"
)

@pytest.mark.parametrize("class_name", CLASS_FILES.values())
def test_class_sends_tellraw(self, class_name):
"""Each class should display a confirmation message."""
text = (CLASS_DIR / f"{class_name}.mcfunction").read_text(encoding="utf-8")
assert "tellraw @s" in text, (
f"{class_name}.mcfunction must send a tellraw confirmation"
)


class TestClassBalance:
def test_assassin_has_low_health(self):
text = (CLASS_DIR / "assassin.mcfunction").read_text(encoding="utf-8")
m = re.search(r"sh_health set @s (\d+)", text)
assert m, "assassin must set health"
health = int(m.group(1))
assert health < 10, f"Assassin should have low health, got {health}"

def test_non_assassin_classes_have_standard_health(self):
for name in ["assault", "medic", "support", "recon"]:
text = (CLASS_DIR / f"{name}.mcfunction").read_text(encoding="utf-8")
m = re.search(r"sh_health set @s (\d+)", text)
assert m, f"{name} must set health"
health = int(m.group(1))
assert health >= 20, (
f"{name} should have ≥20 health, got {health}"
)
72 changes: 72 additions & 0 deletions tests/test_function_references.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Tests that every `function bf:...` call in .mcfunction files
resolves to an existing .mcfunction file on disk."""

import re
from pathlib import Path

import pytest

from conftest import BF_FUNCTIONS_DIR


def _extract_function_calls(text):
"""Extract all 'function <namespace>:<path>' references from mcfunction text."""
return re.findall(r"\bfunction\s+([\w]+:[\w/]+)", text)


def _collect_all_calls():
"""Yield (source_file_rel, called_function) tuples."""
for mcf in sorted(BF_FUNCTIONS_DIR.rglob("*.mcfunction")):
text = mcf.read_text(encoding="utf-8")
rel = mcf.relative_to(BF_FUNCTIONS_DIR)
for call in _extract_function_calls(text):
yield str(rel), call


ALL_CALLS = list(_collect_all_calls())


# Calls where the on-disk path uses different casing than the function reference.
# These are real bugs: Minecraft function namespaces are lowercase on Linux.
KNOWN_CASE_BUGS = {
"bf:mechanics/squad/confirm_join",
"bf:mechanics/squad/check_and_print_line",
"bf:mechanics/squad/print_join_list",
}


@pytest.mark.parametrize(
"source_file,func_ref",
ALL_CALLS,
ids=[f"{src} -> {ref}" for src, ref in ALL_CALLS],
)
def test_function_reference_resolves(source_file, func_ref):
"""Each `function bf:<path>` call must point to an existing .mcfunction file."""
namespace, func_path = func_ref.split(":", 1)
if namespace != "bf":
pytest.skip(f"Skipping non-bf namespace: {namespace}")
if func_ref in KNOWN_CASE_BUGS:
pytest.xfail(
f"Known bug: '{func_ref}' uses lowercase 'squad' but directory is 'Squad'"
)
expected_file = BF_FUNCTIONS_DIR / f"{func_path}.mcfunction"
assert expected_file.is_file(), (
f"{source_file} calls 'function {func_ref}' but "
f"{expected_file.relative_to(BF_FUNCTIONS_DIR)} does not exist"
)


def test_no_self_referencing_functions(all_mcfunction_files):
"""A function should not directly call itself (infinite recursion)
unless it is an intentional recursive iterator."""
known_recursive = {"mechanics/menu/iterator.mcfunction"}
for mcf in all_mcfunction_files:
rel = str(mcf.relative_to(BF_FUNCTIONS_DIR)).replace("\\", "/")
if rel in known_recursive:
continue
func_ns_path = "bf:" + rel.replace(".mcfunction", "").replace("\\", "/")
text = mcf.read_text(encoding="utf-8")
calls = _extract_function_calls(text)
assert func_ns_path not in calls, (
f"{rel} calls itself ({func_ns_path}) — possible infinite recursion"
)
Loading