diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-311.pyc b/tests/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/conftest.cpython-311-pytest-9.0.3.pyc b/tests/__pycache__/conftest.cpython-311-pytest-9.0.3.pyc new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/test_builtin_functions.cpython-311-pytest-9.0.3.pyc b/tests/__pycache__/test_builtin_functions.cpython-311-pytest-9.0.3.pyc new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/test_common.cpython-311-pytest-9.0.3.pyc b/tests/__pycache__/test_common.cpython-311-pytest-9.0.3.pyc new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/test_compile_interpreter.cpython-311-pytest-9.0.3.pyc b/tests/__pycache__/test_compile_interpreter.cpython-311-pytest-9.0.3.pyc new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/test_compile_types.cpython-311-pytest-9.0.3.pyc b/tests/__pycache__/test_compile_types.cpython-311-pytest-9.0.3.pyc new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/test_compiler.cpython-311-pytest-9.0.3.pyc b/tests/__pycache__/test_compiler.cpython-311-pytest-9.0.3.pyc new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/test_version_config.cpython-311-pytest-9.0.3.pyc b/tests/__pycache__/test_version_config.cpython-311-pytest-9.0.3.pyc new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..af1b0c7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,46 @@ +""" +Shared pytest fixtures for Minecraft-Script tests. +""" +import pytest +import minecraft_script.version_config as vc + + +@pytest.fixture(autouse=True) +def reset_version_context(): + """Clear the global version context and profile cache before/after each test.""" + vc.clear_version_context() + vc._profile_cache.clear() + yield + vc.clear_version_context() + vc._profile_cache.clear() + + +@pytest.fixture +def profile_1204(): + """Return the real 1.20.4 version profile.""" + return vc.load_version_profile("1.20.4") + + +@pytest.fixture +def version_ctx_1204(): + """Initialize and return a VersionContext for datapack 'test_pack' using 1.20.4.""" + return vc.init_version_context("test_pack") + + +@pytest.fixture +def mock_profile(): + """A minimal profile dict for unit-testing VersionRenderer in isolation.""" + return { + "minecraft_version": "test", + "pack_format": 99, + "paths": {"function_dir": "functions", "function_tag_dir": "functions"}, + "constants": {"MY_CONST": "hello"}, + "templates": { + "simple": "just text", + "with_var": "value={{myVar}}", + "multiline": "line1\nline2\nline3", + "with_const": "const={{MY_CONST}}", + "conditional": "{{#if flag}}yes{{else}}no{{/if}}", + "empty": "", + }, + } \ No newline at end of file diff --git a/tests/test_builtin_functions.py b/tests/test_builtin_functions.py new file mode 100644 index 0000000..22b99bb --- /dev/null +++ b/tests/test_builtin_functions.py @@ -0,0 +1,315 @@ +""" +Tests for minecraft_script/compiler/builtin_functions.py + +Covers the version-context-based rendering for: log, command, give_item, +set_block, concatenate, append - focusing on command generation correctness +introduced in this PR. +""" +import pytest +from unittest.mock import MagicMock, patch + +from minecraft_script.version_config import init_version_context +from minecraft_script.compiler.compile_types import ( + MCSNull, MCSNumber, MCSString, MCSList, MCSFunction, MCSBoolean +) +from minecraft_script.compiler.builtin_functions import ( + log, command, give_item, set_block, concatenate, append +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def ctx(version_ctx_1204): + from minecraft_script.compiler.compile_interpreter import CompileContext + return CompileContext("test_fn", top_level=True) + + +@pytest.fixture +def mock_interp(ctx): + interp = MagicMock() + interp.datapack_id = "test_pack" + interp.click_item_lookup = {} + return interp + + +# --------------------------------------------------------------------------- +# log() +# --------------------------------------------------------------------------- + +class TestLog: + def test_returns_null(self, mock_interp, ctx): + arg = MCSNumber(ctx) + cmds, ret = log(mock_interp, [arg], ctx) + assert isinstance(ret, MCSNull) + + def test_single_arg_produces_one_command(self, mock_interp, ctx): + arg = MCSNumber(ctx) + cmds, _ = log(mock_interp, [arg], ctx) + assert len(cmds) == 1 + + def test_command_calls_builtins_log(self, mock_interp, ctx): + arg = MCSNumber(ctx) + cmds, _ = log(mock_interp, [arg], ctx) + assert "builtins/log" in cmds[0] + + def test_storage_suffix_contains_arg_storage(self, mock_interp, ctx): + arg = MCSNumber(ctx) + cmds, _ = log(mock_interp, [arg], ctx) + assert arg.get_storage() in cmds[0] + + def test_storage_suffix_contains_arg_nbt(self, mock_interp, ctx): + arg = MCSNumber(ctx) + cmds, _ = log(mock_interp, [arg], ctx) + assert arg.get_nbt() in cmds[0] + + def test_no_args_pads_to_five_empty(self, mock_interp, ctx): + cmds, _ = log(mock_interp, [], ctx) + # All 5 slots should have empty storage/nbt + assert '"s0": ""' in cmds[0] + assert '"n4": "none"' in cmds[0] + + def test_five_args_no_padding(self, mock_interp, ctx): + args = [MCSNumber(ctx) for _ in range(5)] + cmds, _ = log(mock_interp, args, ctx) + assert '"n4"' in cmds[0] + assert '"n5"' not in cmds[0] + + def test_datapack_id_in_command(self, mock_interp, ctx): + arg = MCSString(ctx) + cmds, _ = log(mock_interp, [arg], ctx) + assert "test_pack" in cmds[0] + + +# --------------------------------------------------------------------------- +# command() +# --------------------------------------------------------------------------- + +class TestCommand: + def test_returns_null(self, mock_interp, ctx): + arg = MCSString(ctx) + cmds, ret = command(mock_interp, [arg], ctx) + assert isinstance(ret, MCSNull) + + def test_command_references_value_storage(self, mock_interp, ctx): + arg = MCSString(ctx) + cmds, _ = command(mock_interp, [arg], ctx) + full = "\n".join(cmds) + assert arg.get_storage() in full + + def test_command_calls_builtins_command(self, mock_interp, ctx): + arg = MCSString(ctx) + cmds, _ = command(mock_interp, [arg], ctx) + full = "\n".join(cmds) + assert "builtins/command" in full + + def test_command_uses_with_storage(self, mock_interp, ctx): + arg = MCSString(ctx) + cmds, _ = command(mock_interp, [arg], ctx) + full = "\n".join(cmds) + assert "with storage" in full + + def test_command_produces_multiple_lines(self, mock_interp, ctx): + arg = MCSString(ctx) + cmds, _ = command(mock_interp, [arg], ctx) + assert len(cmds) >= 2 + + +# --------------------------------------------------------------------------- +# give_item() +# --------------------------------------------------------------------------- + +class TestGiveItem: + def test_returns_null(self, mock_interp, ctx): + item = MCSString(ctx) + cmds, ret = give_item(mock_interp, [item], ctx) + assert isinstance(ret, MCSNull) + + def test_item_only_uses_default_components_and_count(self, mock_interp, ctx): + item = MCSString(ctx) + cmds, _ = give_item(mock_interp, [item], ctx) + full = "\n".join(cmds) + assert "current.components set value ''" in full + assert "current.count set value 1" in full + + def test_with_components_uses_storage(self, mock_interp, ctx): + item = MCSString(ctx) + comps = MCSString(ctx) + cmds, _ = give_item(mock_interp, [item, comps], ctx) + full = "\n".join(cmds) + assert "current.components set from storage" in full + assert comps.get_storage() in full + + def test_with_count_uses_storage(self, mock_interp, ctx): + item = MCSString(ctx) + comps = MCSString(ctx) + count = MCSNumber(ctx) + cmds, _ = give_item(mock_interp, [item, comps, count], ctx) + full = "\n".join(cmds) + assert "current.count set from storage" in full + assert count.get_storage() in full + + def test_calls_builtins_give_item(self, mock_interp, ctx): + item = MCSString(ctx) + cmds, _ = give_item(mock_interp, [item], ctx) + full = "\n".join(cmds) + assert "builtins/give_item" in full + + def test_item_storage_referenced(self, mock_interp, ctx): + item = MCSString(ctx) + cmds, _ = give_item(mock_interp, [item], ctx) + full = "\n".join(cmds) + assert item.get_storage() in full + assert item.get_nbt() in full + + +# --------------------------------------------------------------------------- +# set_block() +# --------------------------------------------------------------------------- + +class TestSetBlock: + def test_returns_null(self, mock_interp, ctx): + x = MCSNumber(ctx) + y = MCSNumber(ctx) + z = MCSNumber(ctx) + block = MCSString(ctx) + cmds, ret = set_block(mock_interp, [x, y, z, block], ctx) + assert isinstance(ret, MCSNull) + + def test_setup_contains_x_y_z_block(self, mock_interp, ctx): + x = MCSNumber(ctx) + y = MCSNumber(ctx) + z = MCSNumber(ctx) + block = MCSString(ctx) + cmds, _ = set_block(mock_interp, [x, y, z, block], ctx) + full = "\n".join(cmds) + assert "current.x set from storage" in full + assert "current.y set from storage" in full + assert "current.z set from storage" in full + assert "current.block set from storage" in full + + def test_calls_builtins_set_block(self, mock_interp, ctx): + x = MCSNumber(ctx) + y = MCSNumber(ctx) + z = MCSNumber(ctx) + block = MCSString(ctx) + cmds, _ = set_block(mock_interp, [x, y, z, block], ctx) + full = "\n".join(cmds) + assert "builtins/set_block" in full + + def test_each_coord_storage_is_referenced(self, mock_interp, ctx): + x = MCSNumber(ctx) + y = MCSNumber(ctx) + z = MCSNumber(ctx) + block = MCSString(ctx) + cmds, _ = set_block(mock_interp, [x, y, z, block], ctx) + full = "\n".join(cmds) + assert x.get_nbt() in full + assert y.get_nbt() in full + assert z.get_nbt() in full + assert block.get_nbt() in full + + +# --------------------------------------------------------------------------- +# concatenate() +# --------------------------------------------------------------------------- + +class TestConcatenate: + def test_returns_mcs_string(self, mock_interp, ctx): + s1 = MCSString(ctx) + s2 = MCSString(ctx) + cmds, ret = concatenate(mock_interp, [s1, s2], ctx) + assert isinstance(ret, MCSString) + + def test_setup_references_both_strings(self, mock_interp, ctx): + s1 = MCSString(ctx) + s2 = MCSString(ctx) + cmds, _ = concatenate(mock_interp, [s1, s2], ctx) + full = "\n".join(cmds) + assert s1.get_storage() in full + assert s2.get_storage() in full + + def test_setup_calls_macro_function(self, mock_interp, ctx): + s1 = MCSString(ctx) + s2 = MCSString(ctx) + cmds, _ = concatenate(mock_interp, [s1, s2], ctx) + full = "\n".join(cmds) + # macro path should be referenced with "with storage" + assert "with storage" in full + + def test_adds_macro_command_to_interpreter(self, mock_interp, ctx): + s1 = MCSString(ctx) + s2 = MCSString(ctx) + concatenate(mock_interp, [s1, s2], ctx) + mock_interp.add_command.assert_called_once() + # Macro should contain the string_1 / string_2 macro expansions + call_args = mock_interp.add_command.call_args + macro_cmd = call_args[0][1] + assert "$(string_1)$(string_2)" in macro_cmd + + def test_macro_references_output_storage(self, mock_interp, ctx): + s1 = MCSString(ctx) + s2 = MCSString(ctx) + cmds, result_str = concatenate(mock_interp, [s1, s2], ctx) + call_args = mock_interp.add_command.call_args + macro_cmd = call_args[0][1] + assert result_str.get_storage() in macro_cmd + assert result_str.get_nbt() in macro_cmd + + +# --------------------------------------------------------------------------- +# append() +# --------------------------------------------------------------------------- + +class TestAppend: + def test_returns_null(self, mock_interp, ctx): + lst = MCSList(ctx) + val = MCSNumber(ctx) + cmds, ret = append(mock_interp, [lst, val], ctx) + assert isinstance(ret, MCSNull) + + def test_setup_references_list(self, mock_interp, ctx): + lst = MCSList(ctx) + val = MCSNumber(ctx) + cmds, _ = append(mock_interp, [lst, val], ctx) + full = "\n".join(cmds) + assert lst.get_storage() in full + assert lst.get_nbt() in full + + def test_setup_increments_length(self, mock_interp, ctx): + lst = MCSList(ctx) + val = MCSNumber(ctx) + cmds, _ = append(mock_interp, [lst, val], ctx) + full = "\n".join(cmds) + assert ".length" in full + # Should increment via scoreboard operation + assert "mcs_math" in full + + def test_adds_macro_command_to_interpreter(self, mock_interp, ctx): + lst = MCSList(ctx) + val = MCSNumber(ctx) + append(mock_interp, [lst, val], ctx) + mock_interp.add_command.assert_called_once() + call_args = mock_interp.add_command.call_args + macro_cmd = call_args[0][1] + assert "$(index)" in macro_cmd + + def test_macro_references_value_storage(self, mock_interp, ctx): + lst = MCSList(ctx) + val = MCSNumber(ctx) + append(mock_interp, [lst, val], ctx) + call_args = mock_interp.add_command.call_args + macro_cmd = call_args[0][1] + assert val.get_storage() in macro_cmd + assert val.get_nbt() in macro_cmd + + def test_setup_calls_macro_with_index(self, mock_interp, ctx): + lst = MCSList(ctx) + val = MCSString(ctx) + cmds, _ = append(mock_interp, [lst, val], ctx) + full = "\n".join(cmds) + # Should load current index from list.length + assert "current.index set from storage" in full + assert lst.get_nbt() + ".length" in full \ No newline at end of file diff --git a/tests/test_common.py b/tests/test_common.py new file mode 100644 index 0000000..cec8ea3 --- /dev/null +++ b/tests/test_common.py @@ -0,0 +1,90 @@ +""" +Tests for minecraft_script/common.py + +Covers: module_folder (pathlib-based detection), generate_uuid, COMMON_CONFIG loading. +""" +import os +import re +from pathlib import Path + +import pytest + +from minecraft_script.common import module_folder, generate_uuid, COMMON_CONFIG, version + + +class TestModuleFolder: + def test_module_folder_is_string(self): + assert isinstance(module_folder, str) + + def test_module_folder_ends_with_minecraft_script(self): + # The module lives in .../minecraft_script/ + assert module_folder.endswith("minecraft_script") + + def test_module_folder_is_absolute(self): + assert os.path.isabs(module_folder) + + def test_module_folder_exists(self): + assert os.path.isdir(module_folder) + + def test_module_folder_contains_config_json(self): + assert os.path.isfile(os.path.join(module_folder, "config.json")) + + def test_module_folder_uses_pathlib(self): + # Verify the path resolution matches what Path would give + import minecraft_script.common as cm + expected = str(Path(cm.__file__).resolve().parent) + assert module_folder == expected + + def test_module_folder_is_cross_platform(self): + # Should never contain raw backslashes on any platform + # (pathlib normalises separators) + assert "\\" not in module_folder or os.sep == "\\" + + +class TestGenerateUuid: + def test_returns_string(self): + assert isinstance(generate_uuid(), str) + + def test_returns_valid_uuid_format(self): + uuid = generate_uuid() + pattern = re.compile( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + ) + assert pattern.match(uuid), f"UUID {uuid!r} does not match expected format" + + def test_generates_unique_values(self): + uuids = {generate_uuid() for _ in range(100)} + assert len(uuids) == 100 + + def test_uuid_length(self): + # Standard UUID string: 32 hex chars + 4 dashes = 36 chars + assert len(generate_uuid()) == 36 + + +class TestCommonConfig: + def test_config_is_dict(self): + assert isinstance(COMMON_CONFIG, dict) + + def test_config_has_minecraft_version(self): + assert "minecraft_version" in COMMON_CONFIG + + def test_config_has_debug_comments(self): + assert "debug_comments" in COMMON_CONFIG + + def test_config_has_verbose(self): + assert "verbose" in COMMON_CONFIG + + def test_config_has_default_output_path(self): + assert "default_output_path" in COMMON_CONFIG + + def test_minecraft_version_is_string(self): + assert isinstance(COMMON_CONFIG["minecraft_version"], str) + + +class TestVersion: + def test_version_is_string(self): + assert isinstance(version, str) + + def test_version_matches_semver_pattern(self): + pattern = re.compile(r'^\d+\.\d+\.\d+$') + assert pattern.match(version), f"Version {version!r} is not in semver format" \ No newline at end of file diff --git a/tests/test_compile_interpreter.py b/tests/test_compile_interpreter.py new file mode 100644 index 0000000..e98c58b --- /dev/null +++ b/tests/test_compile_interpreter.py @@ -0,0 +1,247 @@ +""" +Tests for changed parts of minecraft_script/compiler/compile_interpreter.py + +Covers: add_comment (list->tuple fix), init_version_context lifecycle inside +mcs_compile, CompileContext structure. +""" +import pytest +from unittest.mock import patch + +import minecraft_script.version_config as vc +from minecraft_script.common import COMMON_CONFIG +from minecraft_script.compiler.compile_interpreter import ( + add_comment, CompileContext, CompileCommands, CompileResult +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def debug_on(version_ctx_1204): + """Ensure debug_comments is True.""" + old = COMMON_CONFIG.get("debug_comments") + COMMON_CONFIG["debug_comments"] = True + yield + COMMON_CONFIG["debug_comments"] = old + + +@pytest.fixture +def debug_off(version_ctx_1204): + """Ensure debug_comments is False.""" + old = COMMON_CONFIG.get("debug_comments") + COMMON_CONFIG["debug_comments"] = False + yield + COMMON_CONFIG["debug_comments"] = old + + +# --------------------------------------------------------------------------- +# add_comment +# --------------------------------------------------------------------------- + +class TestAddComment: + def test_tuple_input_debug_on_prepends_comment(self, debug_on): + result = add_comment(("cmd1", "cmd2"), "my comment") + assert isinstance(result, tuple) + assert result[0] == "\n# my comment" + assert result[1] == "cmd1" + assert result[2] == "cmd2" + + def test_list_input_debug_on_converts_to_tuple_and_prepends(self, debug_on): + result = add_comment(["cmd1", "cmd2"], "list comment") + assert isinstance(result, tuple) + assert result[0] == "\n# list comment" + assert "cmd1" in result + assert "cmd2" in result + + def test_str_input_debug_on_prepends_comment(self, debug_on): + result = add_comment("single_cmd", "str comment") + assert isinstance(result, str) + assert "# str comment" in result + assert "single_cmd" in result + + def test_tuple_input_debug_off_returns_unchanged(self, debug_off): + original = ("cmd1", "cmd2") + result = add_comment(original, "ignored") + assert result == original + + def test_list_input_debug_off_converts_to_tuple(self, debug_off): + original = ["cmd1", "cmd2"] + result = add_comment(original, "ignored") + assert isinstance(result, tuple) + assert result == ("cmd1", "cmd2") + + def test_str_input_debug_off_returns_unchanged(self, debug_off): + result = add_comment("single_cmd", "ignored") + assert result == "single_cmd" + + def test_invalid_type_raises_value_error(self, debug_on): + with pytest.raises(ValueError, match="Commands have to be tuple, list, or str"): + add_comment(42, "comment") + + def test_invalid_type_none_raises_value_error(self, debug_on): + with pytest.raises(ValueError): + add_comment(None, "comment") + + def test_empty_tuple_debug_on(self, debug_on): + result = add_comment((), "empty") + assert isinstance(result, tuple) + assert result == ("\n# empty",) + + def test_empty_list_debug_on(self, debug_on): + result = add_comment([], "empty list") + assert isinstance(result, tuple) + assert result[0] == "\n# empty list" + + def test_empty_list_debug_off_returns_empty_tuple(self, debug_off): + result = add_comment([], "ignored") + assert result == () + + +# --------------------------------------------------------------------------- +# CompileContext +# --------------------------------------------------------------------------- + +class TestCompileContext: + def test_top_level_context_has_builtins(self, version_ctx_1204): + ctx = CompileContext("init", top_level=True) + # Check that builtin functions are loaded + assert ctx.symbols.symbols.get("log") is not None or ctx.get("log") is not None + + def test_context_has_unique_uuid(self, version_ctx_1204): + ctx1 = CompileContext("fn1") + ctx2 = CompileContext("fn2") + assert ctx1.uuid != ctx2.uuid + + def test_mcfunction_name_user_functions(self, version_ctx_1204): + ctx = CompileContext("my_fn") + assert ctx.mcfunction_name == "user_functions/my_fn" + + def test_mcfunction_name_code_blocks(self, version_ctx_1204): + # No name -> code block + ctx = CompileContext() + assert ctx.mcfunction_name.startswith("code_blocks/cb_") + + def test_child_context_has_parent_ref(self, version_ctx_1204): + parent = CompileContext("parent") + child = CompileContext(parent=parent) + assert child.parent is parent + + def test_declare_and_get(self, version_ctx_1204): + from minecraft_script.compiler.compile_types import MCSNumber + ctx = CompileContext("test") + num = MCSNumber(ctx) + ctx.declare("myvar", num) + assert ctx.get("myvar") is num + + def test_get_context_ownership_finds_self(self, version_ctx_1204): + from minecraft_script.compiler.compile_types import MCSNumber + ctx = CompileContext("owner") + num = MCSNumber(ctx) + ctx.declare("owned_var", num) + assert ctx.get_context_ownership("owned_var") is ctx + + def test_get_context_ownership_traverses_parent(self, version_ctx_1204): + from minecraft_script.compiler.compile_types import MCSNumber + parent = CompileContext("parent") + child = CompileContext(parent=parent) + num = MCSNumber(parent) + parent.declare("parent_var", num) + assert child.get_context_ownership("parent_var") is parent + + def test_undefined_var_raises_name_error(self, version_ctx_1204): + ctx = CompileContext("test") + with pytest.raises(NameError): + ctx.get_context_ownership("undefined_var") + + +# --------------------------------------------------------------------------- +# CompileCommands +# --------------------------------------------------------------------------- + +class TestCompileCommands: + def test_add_command_and_get_file_content(self): + cmds = CompileCommands() + cmds.add_command("fn1", "cmd_a") + cmds.add_command("fn1", "cmd_b") + content = cmds.get_file_content("fn1") + assert "cmd_a" in content + assert "cmd_b" in content + + def test_get_mcs_functions(self): + cmds = CompileCommands() + cmds.add_command("fn1", "cmd1") + cmds.add_command("fn2", "cmd2") + fns = cmds.get_mcs_functions() + assert "fn1" in fns + assert "fn2" in fns + + def test_empty_function_returns_empty_string(self): + cmds = CompileCommands() + assert cmds.get_file_content("nonexistent") == "" + + def test_commands_joined_with_newline(self): + cmds = CompileCommands() + cmds.add_command("fn", "line1") + cmds.add_command("fn", "line2") + content = cmds.get_file_content("fn") + assert content == "line1\nline2" + + +# --------------------------------------------------------------------------- +# CompileResult +# --------------------------------------------------------------------------- + +class TestCompileResult: + def test_default_value_is_none(self): + result = CompileResult() + assert result.get_value() is None + + def test_default_return_is_none(self): + result = CompileResult() + assert result.get_return() is None + + def test_value_is_returned(self, version_ctx_1204): + from minecraft_script.compiler.compile_types import MCSNumber + ctx = CompileContext("fn") + num = MCSNumber(ctx) + result = CompileResult(value=num) + assert result.get_value() is num + + def test_return_value_is_returned(self, version_ctx_1204): + from minecraft_script.compiler.compile_types import MCSString + ctx = CompileContext("fn") + s = MCSString(ctx) + result = CompileResult(return_value=s) + assert result.get_return() is s + + +# --------------------------------------------------------------------------- +# init_version_context in mcs_compile +# --------------------------------------------------------------------------- + +class TestMcsCompileVersionContextLifecycle: + """Verify that mcs_compile initialises the version context with the datapack_id.""" + + def test_init_called_with_datapack_id(self): + from minecraft_script.compiler.compile_interpreter import mcs_compile + import tempfile + import minecraft_script.compiler.compile_interpreter as ci + + captured = {} + + original_init = vc.init_version_context + def fake_init(datapack_id): + captured["datapack_id"] = datapack_id + return original_init(datapack_id) + + # Patch the name as it is imported inside compile_interpreter + with patch.object(ci, "init_version_context", side_effect=fake_init): + try: + with tempfile.TemporaryDirectory() as tmp: + mcs_compile([], tmp, "my_datapack") + except Exception: + pass # compilation may fail on empty AST - that's ok + + assert captured.get("datapack_id") == "my_datapack" \ No newline at end of file diff --git a/tests/test_compile_types.py b/tests/test_compile_types.py new file mode 100644 index 0000000..5646e60 --- /dev/null +++ b/tests/test_compile_types.py @@ -0,0 +1,341 @@ +""" +Tests for minecraft_script/compiler/compile_types.py + +Covers: MCSObject, MCSVariable, MCSList, MCSNull, MCSNumber, MCSString, +MCSBoolean, MCSUnknown, MCSFunction - focusing on the version-context-based +command generation introduced in this PR. +""" +import pytest +from unittest.mock import MagicMock + +import minecraft_script.version_config as vc +from minecraft_script.version_config import init_version_context +from minecraft_script.compiler.compile_types import ( + MCSObject, MCSVariable, MCSList, MCSNull, + MCSNumber, MCSString, MCSBoolean, MCSUnknown, + MCSFunction, +) + + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def ctx(version_ctx_1204): + """Return a minimal CompileContext-like mock for use in type construction.""" + from minecraft_script.compiler.compile_interpreter import CompileContext + return CompileContext("test_fn", top_level=True) + + +@pytest.fixture +def ctx2(version_ctx_1204): + """Return a second CompileContext for set_to_current_cmd tests.""" + from minecraft_script.compiler.compile_interpreter import CompileContext + return CompileContext("test_fn2", top_level=True) + + +# --------------------------------------------------------------------------- +# MCSObject +# --------------------------------------------------------------------------- + +class TestMCSObject: + def test_get_nbt_format(self, ctx): + obj = MCSObject(ctx, "number") + assert obj.get_nbt().startswith("number.") + assert len(obj.get_nbt()) > len("number.") + + def test_get_storage_format(self, ctx): + obj = MCSObject(ctx, "string") + storage = obj.get_storage() + assert storage.startswith("mcs_") + assert ctx.uuid in storage + + def test_save_to_storage_cmd_literal_42(self, ctx): + obj = MCSObject(ctx, "number") + cmd = obj.save_to_storage_cmd(42) + assert "data modify storage" in cmd + assert obj.get_storage() in cmd + assert obj.get_nbt() in cmd + assert "42" in cmd + + def test_save_to_storage_cmd_literal_bytes(self, ctx): + obj = MCSObject(ctx, "string") + cmd = obj.save_to_storage_cmd("0b") + assert "set value" in cmd + assert "0b" in cmd + + def test_delete_from_storage_cmd(self, ctx): + obj = MCSObject(ctx, "number") + cmd = obj.delete_from_storage_cmd() + assert "data remove storage" in cmd + assert obj.get_storage() in cmd + assert obj.get_nbt() in cmd + + def test_set_to_current_cmd(self, ctx, ctx2): + obj = MCSObject(ctx, "string") + cmd = obj.set_to_current_cmd(ctx2) + assert "data modify storage" in cmd + assert f"mcs_{ctx2.uuid}" in cmd + assert "current" in cmd + assert obj.get_storage() in cmd + assert obj.get_nbt() in cmd + + def test_each_object_has_unique_uuid(self, ctx): + obj1 = MCSObject(ctx, "number") + obj2 = MCSObject(ctx, "number") + assert obj1.uuid != obj2.uuid + + def test_storage_compartment_in_nbt(self, ctx): + obj = MCSObject(ctx, "boolean") + assert "boolean" in obj.get_nbt() + + +# --------------------------------------------------------------------------- +# MCSVariable +# --------------------------------------------------------------------------- + +class TestMCSVariable: + def test_get_nbt_includes_variable_prefix(self, ctx): + var = MCSVariable("x", ctx) + assert var.get_nbt() == "variable.x" + + def test_get_storage_matches_context(self, ctx): + var = MCSVariable("y", ctx) + assert var.get_storage() == f"mcs_{ctx.uuid}" + + def test_set_to_current_cmd_is_correct(self, ctx, ctx2): + var = MCSVariable("my_var", ctx) + cmd = var.set_to_current_cmd(ctx2) + assert "data modify storage" in cmd + assert f"mcs_{ctx2.uuid}" in cmd + assert "current" in cmd + assert "variable.my_var" in cmd + + def test_repr(self, ctx): + var = MCSVariable("z", ctx) + r = repr(var) + assert "MCSVariable" in r + assert "z" in r + + def test_different_names_yield_different_nbts(self, ctx): + var1 = MCSVariable("alpha", ctx) + var2 = MCSVariable("beta", ctx) + assert var1.get_nbt() != var2.get_nbt() + + +# --------------------------------------------------------------------------- +# MCSNull +# --------------------------------------------------------------------------- + +class TestMCSNull: + def test_save_to_storage_cmd_uses_0b(self, ctx): + null = MCSNull(ctx) + cmd = null.save_to_storage_cmd() + assert "0b" in cmd + assert "data modify storage" in cmd + + def test_set_to_current_cmd_uses_0b(self, ctx, ctx2): + null = MCSNull(ctx) + cmd = null.set_to_current_cmd(ctx2) + assert "0b" in cmd + assert f"mcs_{ctx2.uuid}" in cmd + assert "current" in cmd + + def test_repr(self, ctx): + null = MCSNull(ctx) + assert repr(null) == "MCSNull()" + + def test_storage_compartment_is_null(self, ctx): + null = MCSNull(ctx) + assert "null" in null.get_nbt() + + +# --------------------------------------------------------------------------- +# MCSNumber +# --------------------------------------------------------------------------- + +class TestMCSNumber: + def test_nbt_starts_with_number(self, ctx): + num = MCSNumber(ctx) + assert num.get_nbt().startswith("number.") + + def test_repr(self, ctx): + num = MCSNumber(ctx) + assert "MCSNumber" in repr(num) + + def test_delete_cmd_removes_storage(self, ctx): + num = MCSNumber(ctx) + cmd = num.delete_from_storage_cmd() + assert "data remove storage" in cmd + assert num.get_nbt() in cmd + + +# --------------------------------------------------------------------------- +# MCSString +# --------------------------------------------------------------------------- + +class TestMCSString: + def test_nbt_starts_with_string(self, ctx): + s = MCSString(ctx) + assert s.get_nbt().startswith("string.") + + def test_repr(self, ctx): + s = MCSString(ctx) + assert "MCSString" in repr(s) + + def test_save_cmd_uses_string_compartment(self, ctx): + s = MCSString(ctx) + cmd = s.save_to_storage_cmd('"test"') + assert "string." in cmd + + +# --------------------------------------------------------------------------- +# MCSBoolean +# --------------------------------------------------------------------------- + +class TestMCSBoolean: + def test_nbt_starts_with_boolean(self, ctx): + b = MCSBoolean(ctx) + assert b.get_nbt().startswith("boolean.") + + def test_repr(self, ctx): + b = MCSBoolean(ctx) + assert "MCSBoolean" in repr(b) + + +# --------------------------------------------------------------------------- +# MCSUnknown +# --------------------------------------------------------------------------- + +class TestMCSUnknown: + def test_nbt_starts_with_unknown(self, ctx): + u = MCSUnknown(ctx) + assert u.get_nbt().startswith("unknown.") + + def test_repr(self, ctx): + u = MCSUnknown(ctx) + assert "MCSUnknown" in repr(u) + + +# --------------------------------------------------------------------------- +# MCSList +# --------------------------------------------------------------------------- + +class TestMCSList: + def test_nbt_starts_with_list(self, ctx): + lst = MCSList(ctx) + assert lst.get_nbt().startswith("list.") + + def test_repr(self, ctx): + lst = MCSList(ctx) + assert "MCSList" in repr(lst) + + def test_save_to_storage_cmd_empty_list(self, ctx): + lst = MCSList(ctx) + cmds = lst.save_to_storage_cmd([]) + assert isinstance(cmds, list) + # Should still have the length command + assert len(cmds) == 1 + assert ".length set value 0" in cmds[0] + + def test_save_to_storage_cmd_single_element(self, ctx): + lst = MCSList(ctx) + elem = MCSNumber(ctx) + cmds = lst.save_to_storage_cmd([elem]) + assert isinstance(cmds, list) + # length cmd + (set_to_current_cmd + element cmd per item) + assert len(cmds) == 3 # length + current + element + assert ".length set value 1" in cmds[0] + + def test_save_to_storage_cmd_multiple_elements(self, ctx): + lst = MCSList(ctx) + elems = [MCSNumber(ctx), MCSString(ctx), MCSBoolean(ctx)] + cmds = lst.save_to_storage_cmd(elems) + assert isinstance(cmds, list) + # 1 length + 3 * (1 set_to_current + 1 element) = 7 + assert len(cmds) == 7 + assert ".length set value 3" in cmds[0] + + def test_save_to_storage_cmd_element_indices(self, ctx): + lst = MCSList(ctx) + elems = [MCSNumber(ctx), MCSString(ctx)] + cmds = lst.save_to_storage_cmd(elems) + # Check index 0 and 1 appear in the element commands + full = "\n".join(cmds) + nbt = lst.get_nbt() + assert f"{nbt}.0" in full + assert f"{nbt}.1" in full + + +# --------------------------------------------------------------------------- +# MCSFunction.call +# --------------------------------------------------------------------------- + +class TestMCSFunction: + def test_call_with_no_params_returns_null_and_invoke(self, ctx, version_ctx_1204): + """A function with no parameters should return an invoke command only.""" + from minecraft_script.compiler.compile_interpreter import CompileContext + local_ctx = CompileContext("my_fn") + + fnc = MCSFunction.__new__(MCSFunction) + fnc.name = "my_fn" + fnc.parameter_names = [] + fnc.local_context = local_ctx + + mock_interp = MagicMock() + mock_interp.datapack_id = "test_pack" + + cmds, ret = fnc.call(mock_interp, [], ctx) + assert isinstance(ret, MCSNull) + assert len(cmds) == 1 + assert "user_functions/my_fn" in cmds[0] + + def test_call_with_one_param_has_setup_and_invoke(self, ctx, version_ctx_1204): + """A function with one parameter should have param setup lines + invoke.""" + from minecraft_script.compiler.compile_interpreter import CompileContext + local_ctx = CompileContext("fn_with_param") + + fnc = MCSFunction.__new__(MCSFunction) + fnc.name = "fn_with_param" + fnc.parameter_names = ["arg0"] + fnc.local_context = local_ctx + + arg = MCSNumber(ctx) + mock_interp = MagicMock() + mock_interp.datapack_id = "test_pack" + + cmds, ret = fnc.call(mock_interp, [arg], ctx) + assert isinstance(ret, MCSNull) + # 2 param setup lines + 1 invoke = 3 total + assert len(cmds) == 3 + assert "user_functions/fn_with_param" in cmds[-1] + + def test_call_param_references_argument_storage(self, ctx, version_ctx_1204): + from minecraft_script.compiler.compile_interpreter import CompileContext + local_ctx = CompileContext("fn_param_check") + + fnc = MCSFunction.__new__(MCSFunction) + fnc.name = "fn_param_check" + fnc.parameter_names = ["myarg"] + fnc.local_context = local_ctx + + arg = MCSString(ctx) + mock_interp = MagicMock() + mock_interp.datapack_id = "test_pack" + + cmds, _ = fnc.call(mock_interp, [arg], ctx) + # The param setup should reference the argument storage/nbt + setup_cmds = "\n".join(cmds[:-1]) + assert arg.get_storage() in setup_cmds + assert "myarg" in setup_cmds + + def test_repr(self, ctx): + from minecraft_script.compiler.compile_interpreter import CompileContext + local_ctx = CompileContext("test_fn_repr") + fnc = MCSFunction.__new__(MCSFunction) + fnc.name = "test_fn_repr" + fnc.parameter_names = [] + fnc.local_context = local_ctx + assert "MCSFunction" in repr(fnc) + assert "test_fn_repr" in repr(fnc) \ No newline at end of file diff --git a/tests/test_compiler.py b/tests/test_compiler.py new file mode 100644 index 0000000..a484fc3 --- /dev/null +++ b/tests/test_compiler.py @@ -0,0 +1,221 @@ +""" +Tests for changed parts of minecraft_script/compiler/compiler.py + +Covers: Compiler.__init__ version context setup, function_path, version-aware +directory names, predefined_root for math/builtins/tags. +""" +import os +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + +import minecraft_script.version_config as vc +from minecraft_script.version_config import init_version_context, predefined_root + + +# --------------------------------------------------------------------------- +# predefined_root (also tested in test_version_config, but compiler-focused) +# --------------------------------------------------------------------------- + +class TestPredefinedRootForTemplates: + def test_math_folder_exists_for_1204(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + path = predefined_root("math") + assert os.path.isdir(path), f"Expected math template dir at {path}" + + def test_builtins_folder_exists_for_1204(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + path = predefined_root("builtins") + assert os.path.isdir(path), f"Expected builtins template dir at {path}" + + def test_tags_folder_exists_for_1204(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + path = predefined_root("tags") + assert os.path.isdir(path), f"Expected tags template dir at {path}" + + def test_math_folder_contains_mcfunction_files(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + path = predefined_root("math") + files = os.listdir(path) + assert any(f.endswith(".mcfunction") for f in files) + + def test_math_folder_contains_add(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + path = predefined_root("math") + assert "add.mcfunction" in os.listdir(path) + + def test_builtins_folder_contains_give_item(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + path = predefined_root("builtins") + assert "give_item.mcfunction" in os.listdir(path) + + def test_tags_folder_contains_block_dir(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + path = predefined_root("tags") + assert "block" in os.listdir(path) + + def test_no_collision_json_in_tags_block(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + path = predefined_root("tags") + block_path = os.path.join(path, "block") + assert "no_collision.json" in os.listdir(block_path) + + +# --------------------------------------------------------------------------- +# Compiler.__init__ - version context and path setup +# --------------------------------------------------------------------------- + +class TestCompilerInit: + def _make_compiler(self): + """Create a Compiler instance without touching the filesystem.""" + from minecraft_script.compiler.compiler import Compiler + return Compiler( + ast=[], + datapack_name="Test Pack", + output_path="/tmp", + verbose=False, + ) + + def test_compiler_sets_function_dir(self): + compiler = self._make_compiler() + assert compiler.function_dir == "functions" + + def test_compiler_sets_function_tag_dir(self): + compiler = self._make_compiler() + assert compiler.function_tag_dir == "functions" + + def test_compiler_datapack_id_lowercased(self): + compiler = self._make_compiler() + assert compiler.datapack_id == "test_pack" + + def test_compiler_initialises_version_context(self): + self._make_compiler() + ctx = vc.get_version_context() + assert ctx is not None + assert ctx.datapack_id == "test_pack" + + def test_compiler_version_profile_is_1204(self): + compiler = self._make_compiler() + assert compiler.version.profile["minecraft_version"] == "1.20.4" + + def test_compiler_pack_format_is_41(self): + compiler = self._make_compiler() + assert compiler.version.pack_format == "41" + + +# --------------------------------------------------------------------------- +# Compiler.function_path +# --------------------------------------------------------------------------- + +class TestCompilerFunctionPath: + def _make_compiler(self): + from minecraft_script.compiler.compiler import Compiler + return Compiler( + ast=[], + datapack_name="My Pack", + output_path="/out", + verbose=False, + ) + + def test_function_path_no_parts(self): + compiler = self._make_compiler() + path = compiler.function_path() + assert path == "/out/My Pack/data/my_pack/functions/" + + def test_function_path_single_part(self): + compiler = self._make_compiler() + path = compiler.function_path("init.mcfunction") + assert path == "/out/My Pack/data/my_pack/functions/init.mcfunction" + + def test_function_path_multiple_parts(self): + compiler = self._make_compiler() + path = compiler.function_path("user_functions", "main.mcfunction") + assert path == "/out/My Pack/data/my_pack/functions/user_functions/main.mcfunction" + + def test_function_path_uses_function_dir(self): + compiler = self._make_compiler() + path = compiler.function_path("foo") + # With 1.20.4, function_dir == "functions" + assert "/functions/" in path + + def test_function_path_includes_datapack_id(self): + compiler = self._make_compiler() + path = compiler.function_path("bar") + assert "/my_pack/" in path + + +# --------------------------------------------------------------------------- +# Build template files - mcfunction file content checks +# --------------------------------------------------------------------------- + +class TestBuildTemplateFiles: + """Ensure the moved .mcfunction files have the expected content.""" + + def _read_template(self, category: str, filename: str) -> str: + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + path = os.path.join(predefined_root(category), filename) + with open(path, "r") as f: + return f.read() + + def test_give_item_mcfunction_uses_give_command(self): + content = self._read_template("builtins", "give_item.mcfunction") + assert "give" in content.lower() + + def test_give_item_mcfunction_references_item_and_count(self): + content = self._read_template("builtins", "give_item.mcfunction") + assert "$(item)" in content + assert "$(count)" in content + + def test_give_item_new_format_uses_components_without_brackets(self): + content = self._read_template("builtins", "give_item.mcfunction") + # New format: $(components)$(count) without [brackets] + assert "[$(components)]" not in content + + def test_add_mcfunction_uses_scoreboard(self): + content = self._read_template("math", "add.mcfunction") + assert "scoreboard" in content + + def test_command_mcfunction_exists(self): + path = os.path.join(predefined_root("builtins"), "command.mcfunction") + assert os.path.isfile(path) + + def test_log_mcfunction_exists(self): + path = os.path.join(predefined_root("builtins"), "log.mcfunction") + assert os.path.isfile(path) + + def test_set_block_mcfunction_exists(self): + path = os.path.join(predefined_root("builtins"), "set_block.mcfunction") + assert os.path.isfile(path) + + def test_no_collision_json_is_valid_json(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + import json + path = os.path.join(predefined_root("tags"), "block", "no_collision.json") + with open(path) as f: + data = json.load(f) + assert "values" in data or "replace" in data or len(data) > 0 + + def test_math_template_files_present(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + math_path = predefined_root("math") + expected_ops = { + "add", "subtract", "multiply", "divide", "modulus", + "equals", "greater_than", "less_than", + "greater_equals_than", "less_equals_than", + "u_add", "u_subtract", "u_not", + } + files_in_dir = {f.replace(".mcfunction", "") for f in os.listdir(math_path)} + assert expected_ops.issubset(files_in_dir), ( + f"Missing math ops: {expected_ops - files_in_dir}" + ) \ No newline at end of file diff --git a/tests/test_version_config.py b/tests/test_version_config.py new file mode 100644 index 0000000..a5f6003 --- /dev/null +++ b/tests/test_version_config.py @@ -0,0 +1,510 @@ +""" +Tests for minecraft_script/version_config.py + +Covers: VersionRenderer, VersionContext, load_version_profile, +list_supported_versions, predefined_root, init/get/clear_version_context. +""" +import json +import os +import pytest +from pathlib import Path +from unittest.mock import patch + +import minecraft_script.version_config as vc +from minecraft_script.version_config import ( + VersionRenderer, + VersionContext, + load_version_profile, + list_supported_versions, + predefined_root, + init_version_context, + get_version_context, + clear_version_context, + get_minecraft_version, +) + + +# --------------------------------------------------------------------------- +# get_minecraft_version +# --------------------------------------------------------------------------- + +class TestGetMinecraftVersion: + def test_returns_configured_version(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + assert get_minecraft_version() == "1.20.4" + + def test_reflects_config_change(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "9.9.9" + assert get_minecraft_version() == "9.9.9" + COMMON_CONFIG["minecraft_version"] = "1.20.4" # restore + + +# --------------------------------------------------------------------------- +# load_version_profile +# --------------------------------------------------------------------------- + +class TestLoadVersionProfile: + def test_loads_known_version(self): + profile = load_version_profile("1.20.4") + assert profile["minecraft_version"] == "1.20.4" + assert profile["pack_format"] == 41 + + def test_profile_has_required_keys(self): + profile = load_version_profile("1.20.4") + assert "paths" in profile + assert "templates" in profile + assert "constants" in profile + + def test_unknown_version_raises_file_not_found(self): + with pytest.raises(FileNotFoundError, match="Unknown minecraft version"): + load_version_profile("0.0.0") + + def test_unknown_version_error_mentions_supported(self): + with pytest.raises(FileNotFoundError, match="1.20.4"): + load_version_profile("0.0.0") + + def test_caching_returns_same_object(self): + p1 = load_version_profile("1.20.4") + p2 = load_version_profile("1.20.4") + assert p1 is p2 + + def test_uses_common_config_version_when_none_given(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + profile = load_version_profile() + assert profile["minecraft_version"] == "1.20.4" + + def test_cache_cleared_between_tests(self): + # conftest clears cache; this profile load should succeed fresh + profile = load_version_profile("1.20.4") + assert profile is not None + + +# --------------------------------------------------------------------------- +# list_supported_versions +# --------------------------------------------------------------------------- + +class TestListSupportedVersions: + def test_contains_1204(self): + versions = list_supported_versions() + assert "1.20.4" in versions + + def test_excludes_index_json(self): + versions = list_supported_versions() + assert "index" not in versions + + def test_returns_sorted_list(self): + versions = list_supported_versions() + assert versions == sorted(versions) + + def test_returns_list(self): + versions = list_supported_versions() + assert isinstance(versions, list) + + def test_nonexistent_versions_dir_returns_empty(self): + with patch.object(Path, "is_dir", return_value=False): + result = list_supported_versions() + assert result == [] + + +# --------------------------------------------------------------------------- +# predefined_root +# --------------------------------------------------------------------------- + +class TestPredefinedRoot: + def test_math_root_for_1204(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + path = predefined_root("math") + assert path.endswith(os.path.join("math", "1.20.4")) + + def test_explicit_version_override(self): + path = predefined_root("math", version="9.9.9") + assert path.endswith(os.path.join("math", "9.9.9")) + + def test_builtins_category(self): + path = predefined_root("builtins", version="1.20.4") + assert "builtins" in path + assert "1.20.4" in path + + def test_tags_category(self): + path = predefined_root("tags", version="1.20.4") + assert "tags" in path + + def test_path_is_string(self): + path = predefined_root("math", version="1.20.4") + assert isinstance(path, str) + + +# --------------------------------------------------------------------------- +# VersionRenderer +# --------------------------------------------------------------------------- + +class TestVersionRenderer: + def test_render_simple_template(self, mock_profile): + renderer = VersionRenderer(mock_profile) + result = renderer.render("simple") + assert result == "just text" + + def test_render_with_variable(self, mock_profile): + renderer = VersionRenderer(mock_profile) + result = renderer.render("with_var", myVar="world") + assert result == "value=world" + + def test_render_missing_key_raises(self, mock_profile): + renderer = VersionRenderer(mock_profile) + with pytest.raises(KeyError, match="Template 'nonexistent' not defined"): + renderer.render("nonexistent") + + def test_render_strips_surrounding_whitespace(self, mock_profile): + mock_profile["templates"]["padded"] = " hello " + renderer = VersionRenderer(mock_profile) + result = renderer.render("padded") + assert result == "hello" + + def test_render_lines_splits_on_newline(self, mock_profile): + renderer = VersionRenderer(mock_profile) + lines = renderer.render_lines("multiline") + assert lines == ["line1", "line2", "line3"] + + def test_render_lines_empty_template_returns_empty_list(self, mock_profile): + renderer = VersionRenderer(mock_profile) + lines = renderer.render_lines("empty") + assert lines == [] + + def test_compiled_template_is_cached(self, mock_profile): + renderer = VersionRenderer(mock_profile) + renderer.render("simple") + assert "simple" in renderer._compiled + # Second call still works + assert renderer.render("simple") == "just text" + + def test_conditional_true_branch(self, mock_profile): + renderer = VersionRenderer(mock_profile) + result = renderer.render("conditional", flag=True) + assert result == "yes" + + def test_conditional_false_branch(self, mock_profile): + renderer = VersionRenderer(mock_profile) + result = renderer.render("conditional", flag=False) + assert result == "no" + + def test_missing_templates_key_raises(self): + profile_no_templates = {"minecraft_version": "x", "pack_format": 0} + renderer = VersionRenderer(profile_no_templates) + with pytest.raises(KeyError): + renderer.render("anything") + + +# --------------------------------------------------------------------------- +# VersionContext +# --------------------------------------------------------------------------- + +class TestVersionContext: + def test_init_sets_datapack_id(self, profile_1204): + ctx = VersionContext("my_pack", profile=profile_1204) + assert ctx.datapack_id == "my_pack" + + def test_init_sets_function_dir(self, profile_1204): + ctx = VersionContext("dp", profile=profile_1204) + assert ctx.function_dir == "functions" + + def test_init_sets_function_tag_dir(self, profile_1204): + ctx = VersionContext("dp", profile=profile_1204) + assert ctx.function_tag_dir == "functions" + + def test_init_sets_pack_format_as_string(self, profile_1204): + ctx = VersionContext("dp", profile=profile_1204) + assert ctx.pack_format == "41" + + def test_render_uses_datapack_id_constant(self, profile_1204): + ctx = VersionContext("mypack", profile=profile_1204) + result = ctx.render("function.call.invoke", functionName="foo") + assert "mypack" in result + assert "foo" in result + + def test_render_injects_version_constants(self, profile_1204): + ctx = VersionContext("dp", profile=profile_1204) + # click.check uses clickSelectedItemPath constant from profile + result = ctx.render("click.check") + assert "SelectedItem.tag.mcs_click" in result + + def test_params_merges_constants_with_extra(self, profile_1204): + ctx = VersionContext("dp", profile=profile_1204) + params = ctx._params(extra="value") + assert params["extra"] == "value" + assert params["datapack_id"] == "dp" + assert "clickScoreboardCriterion" in params + + def test_params_extra_overrides_constants(self, profile_1204): + ctx = VersionContext("dp", profile=profile_1204) + params = ctx._params(datapack_id="override") + assert params["datapack_id"] == "override" + + def test_render_lines_returns_list(self, profile_1204): + ctx = VersionContext("dp", profile=profile_1204) + lines = ctx.render_lines("control.while.tail", condStorage="s", condNbt="n", loopPath="lp") + assert isinstance(lines, list) + assert len(lines) > 0 + + def test_render_lines_pack_format_placeholder(self, profile_1204): + ctx = VersionContext("dp", profile=profile_1204) + # pack_format must be passed explicitly (same as compiler.py does) + result = ctx.render("pack.mcmeta", pack_format=ctx.pack_format) + assert "41" in result + + def test_render_datapack_kill_includes_datapack_name(self, profile_1204): + ctx = VersionContext("dp", profile=profile_1204) + result = ctx.render("datapack.kill", datapackName="my_datapack") + assert "my_datapack" in result + + def test_loads_profile_automatically_without_explicit_profile(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + ctx = VersionContext("auto_pack") + assert ctx.profile["minecraft_version"] == "1.20.4" + + +# --------------------------------------------------------------------------- +# Global context management: init / get / clear +# --------------------------------------------------------------------------- + +class TestVersionContextGlobals: + def test_get_before_init_raises_runtime_error(self): + with pytest.raises(RuntimeError, match="Version context not initialized"): + get_version_context() + + def test_init_returns_context(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + ctx = init_version_context("pack") + assert isinstance(ctx, VersionContext) + + def test_get_after_init_returns_same_context(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + ctx = init_version_context("pack") + assert get_version_context() is ctx + + def test_clear_resets_context(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + init_version_context("pack") + clear_version_context() + with pytest.raises(RuntimeError): + get_version_context() + + def test_init_overwrites_previous_context(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + ctx1 = init_version_context("pack1") + ctx2 = init_version_context("pack2") + assert get_version_context() is ctx2 + assert ctx1 is not ctx2 + + def test_context_datapack_id_matches_init_arg(self): + from minecraft_script.common import COMMON_CONFIG + COMMON_CONFIG["minecraft_version"] = "1.20.4" + ctx = init_version_context("custom_id") + assert ctx.datapack_id == "custom_id" + + +# --------------------------------------------------------------------------- +# Template rendering - 1.20.4 specific spot checks +# --------------------------------------------------------------------------- + +class TestTemplateRendering1204: + """Integration tests ensuring specific 1.20.4 templates render correctly.""" + + def test_literal_save(self, version_ctx_1204): + result = version_ctx_1204.render("literal.save", storage="mcs_abc", nbt="number.xyz", value=42) + assert result == "data modify storage mcs_abc number.xyz set value 42" + + def test_literal_delete(self, version_ctx_1204): + result = version_ctx_1204.render("literal.delete", storage="mcs_abc", nbt="number.xyz") + assert result == "data remove storage mcs_abc number.xyz" + + def test_literal_set_to_current(self, version_ctx_1204): + result = version_ctx_1204.render( + "literal.set_to_current", destStorage="mcs_dest", storage="mcs_src", nbt="number.n" + ) + assert "data modify storage mcs_dest current set from storage mcs_src number.n" == result + + def test_variable_declare(self, version_ctx_1204): + result = version_ctx_1204.render( + "variable.declare", + ownerStorage="mcs_owner", name="x", valueStorage="mcs_val", valueNbt="number.n" + ) + assert "variable.x" in result + assert "mcs_owner" in result + + def test_variable_assign(self, version_ctx_1204): + result = version_ctx_1204.render( + "variable.assign", + ownerStorage="mcs_owner", name="myVar", valueStorage="mcs_src", valueNbt="string.s" + ) + assert "variable.myVar" in result + + def test_kill_remove_compartment(self, version_ctx_1204): + result = version_ctx_1204.render( + "kill.remove_compartment", ctxStorage="mcs_abc", compartment="number" + ) + assert result == "data remove storage mcs_abc number" + + def test_function_call_invoke(self, version_ctx_1204): + result = version_ctx_1204.render("function.call.invoke", functionName="my_func") + assert result == "function test_pack:user_functions/my_func" + + def test_function_call_param(self, version_ctx_1204): + lines = version_ctx_1204.render_lines( + "function.call.param", + localStorage="mcs_local", argStorage="mcs_arg", argNbt="string.s", paramName="foo" + ) + assert len(lines) == 2 + assert "mcs_local" in lines[0] + assert "variable.foo" in lines[1] + + def test_math_binary_renders(self, version_ctx_1204): + lines = version_ctx_1204.render_lines( + "math.binary", + leftStorage="mcs_a", leftNbt="number.x", + rightStorage="mcs_b", rightNbt="number.y", + operation="add", + resultStorage="mcs_r", resultNbt="number.z", + ) + assert any(".a" in l for l in lines) + assert any(".b" in l for l in lines) + assert any("math/add" in l for l in lines) + + def test_math_unary_renders(self, version_ctx_1204): + lines = version_ctx_1204.render_lines( + "math.unary", + rootStorage="mcs_r", rootNbt="boolean.b", + operation="u_not", + resultStorage="mcs_out", resultNbt="boolean.b2", + ) + assert any("math/u_not" in l for l in lines) + + def test_control_if_branch_renders(self, version_ctx_1204): + lines = version_ctx_1204.render_lines( + "control.if.branch", + exprStorage="mcs_e", exprNbt="boolean.b", + branchStorage="mcs_br", parentStorage="mcs_p", + branchPath="code_blocks/cb_abc", + ) + assert any("score" in l for l in lines) + assert any("return 0" in l for l in lines) + + def test_control_for_init_renders(self, version_ctx_1204): + lines = version_ctx_1204.render_lines( + "control.for.init", + loopId="abc123", + iterableStorage="mcs_it", iterableNbt="list.l", + loopPath="code_blocks/loop", + ) + assert any("loop_iter_abc123" in l for l in lines) + assert any("loop_end_abc123" in l for l in lines) + + def test_click_check_uses_correct_path(self, version_ctx_1204): + result = version_ctx_1204.render("click.check") + assert "SelectedItem.tag.mcs_click" in result + assert "test_pack:clickable_items/run" in result + + def test_click_run_renders(self, version_ctx_1204): + result = version_ctx_1204.render("click.run") + assert "test_pack:clickable_items/$(id)" in result + + def test_datapack_init_uses_carrot_on_a_stick(self, version_ctx_1204): + result = version_ctx_1204.render("datapack.init") + assert "minecraft.used:minecraft.carrot_on_a_stick" in result + + def test_builtin_log_renders(self, version_ctx_1204): + suffix = ' {"s0": "mcs_abc", "n0": "number.x"}' + result = version_ctx_1204.render("builtin.log", storageSuffix=suffix) + assert "test_pack:builtins/log" in result + assert suffix in result + + def test_builtin_give_item_with_components(self, version_ctx_1204): + lines = version_ctx_1204.render_lines( + "builtin.give_item.setup", + ctxStorage="mcs_c", + itemStorage="mcs_i", itemNbt="string.s", + hasComponents=True, + componentsStorage="mcs_comp", componentsNbt="string.c", + hasCount=False, + ) + full = "\n".join(lines) + assert "current.components set from storage mcs_comp" in full + assert "current.count set value 1" in full + + def test_builtin_give_item_without_components(self, version_ctx_1204): + lines = version_ctx_1204.render_lines( + "builtin.give_item.setup", + ctxStorage="mcs_c", + itemStorage="mcs_i", itemNbt="string.s", + hasComponents=False, + hasCount=False, + ) + full = "\n".join(lines) + assert "current.components set value ''" in full + + def test_pack_mcmeta_has_pack_format(self, version_ctx_1204): + result = version_ctx_1204.render("pack.mcmeta", pack_format=version_ctx_1204.pack_format) + assert '"pack_format": 41' in result + + def test_index_setup_renders(self, version_ctx_1204): + lines = version_ctx_1204.render_lines( + "index.setup", + ctxStorage="mcs_ctx", + keyStorage="mcs_key", keyNbt="number.n", + macroPath="code_blocks/macro", + ) + full = "\n".join(lines) + assert "current.index" in full + assert "code_blocks/macro" in full + + def test_literal_list_length(self, version_ctx_1204): + result = version_ctx_1204.render( + "literal.list.length", storage="mcs_abc", nbt="list.l", length=5 + ) + assert ".length set value 5" in result + + def test_literal_list_element(self, version_ctx_1204): + result = version_ctx_1204.render( + "literal.list.element", storage="mcs_abc", nbt="list.l", index=2, srcStorage="mcs_src" + ) + assert "list.l.2" in result + assert "current" in result + + def test_builtin_give_clickable_simple(self, version_ctx_1204): + result = version_ctx_1204.render("builtin.give_clickable.simple", clickId=3) + assert "carrot_on_a_stick" in result + assert "mcs_click:3" in result + + def test_raycast_block_loop_with_loop_function(self, version_ctx_1204): + lines = version_ctx_1204.render_lines( + "builtin.raycast_block.loop", + raycastId="ray123", + hitFunction="my_hit", + hasLoopFunction=True, + loopFunction="my_loop", + loopPath="code_blocks/loop", + ) + full = "\n".join(lines) + assert "user_functions/my_hit" in full + assert "user_functions/my_loop" in full + + def test_raycast_block_loop_without_loop_function(self, version_ctx_1204): + lines = version_ctx_1204.render_lines( + "builtin.raycast_block.loop", + raycastId="ray123", + hitFunction="my_hit", + hasLoopFunction=False, + loopFunction="", + loopPath="code_blocks/loop", + ) + full = "\n".join(lines) + assert "# No loop function" in full + assert "user_functions/my_hit" in full