From 248d7a27af25babeb6658bf82c8fd06abf5a8b85 Mon Sep 17 00:00:00 2001 From: Gergely Polonkai Date: Wed, 21 May 2025 23:45:16 +0200 Subject: [PATCH] feat: Create the Object Generator Oracle --- gm_assistant/oracle/object_generator.py | 99 +++++++++++++ tests/test_object_generator_oracle.py | 180 ++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 gm_assistant/oracle/object_generator.py create mode 100644 tests/test_object_generator_oracle.py diff --git a/gm_assistant/oracle/object_generator.py b/gm_assistant/oracle/object_generator.py new file mode 100644 index 0000000..1e7528d --- /dev/null +++ b/gm_assistant/oracle/object_generator.py @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: 2025 2025 +# SPDX-FileContributor: Gergely Polonkai +# +# SPDX-License-Identifier: GPL-3.0-or-later +"""Object Generator Oracle""" + +from typing import Any + +from ..dice import Die +from .base import Oracle + + +class ObjectGeneratorProperty: # pylint: disable=too-few-public-methods + """Property for the Object Generator Oracle""" + + def __init__(self, property_data: dict[str, Any]) -> None: + self.question: str | None = None + self.die: Die | None = None + self.results: dict[int, str] = {} + + self.parse_and_validate(property_data) + + def parse_and_validate(self, property_data: dict[str, Any]) -> None: + """Parse and validate property data""" + + if "question" not in property_data: + raise ValueError("Missing question value") + + self.question = property_data["question"] + + if "roll-type" not in property_data: + raise ValueError("Missing roll-type value") + + self.die = Die(property_data["roll-type"]) + + if "results" not in property_data: + raise ValueError("Missing results value") + + new_results: dict[int, str | None] = { + roll_value: None for roll_value in range(self.die.minimum, self.die.maximum + 1) + } + + for idx, result_data in enumerate(property_data["results"]): + if "min-roll" not in result_data: + raise ValueError(f"Missing min-roll value in result {idx}") + + min_roll = int(result_data["min-roll"]) + max_roll = int(result_data.get("max-roll", min_roll)) + + if max_roll < min_roll: + raise ValueError(f"max-roll cannot be smaller than min-roll in result {idx}") + + if "value" not in result_data: + raise ValueError(f"Missing value value in result {idx}") + + for roll_value in range(min_roll, max_roll + 1): + if new_results[roll_value] is not None: + raise ValueError(f"Roll value {roll_value} is already registered in result {idx}") + + new_results[roll_value] = result_data["value"] + + if any(value is None for value in new_results.values()): + raise ValueError(f"Not all roll values yield a result for property {self.question}") + + # We just checked that every member of new_results is a string, so it’s safe to ignore type checking here + self.results = new_results # type: ignore[assignment] + + def generate(self) -> str: + """Generate a random value for this property""" + + assert self.die + value = self.die.roll() + answer = self.results[value] + + return f"""*{self.question}* + +{answer}""" + + +class ObjectGeneratorOracle(Oracle): + """Oracle that can generate objects""" + + TYPE_MARKER = "object-generator" + + def __init__(self, oracle_data: dict[str, Any]) -> None: + self.properties: list[ObjectGeneratorProperty] = [] + + super().__init__(oracle_data) + + def parse_and_validate(self, oracle_data: dict[str, Any]) -> None: + super().parse_and_validate(oracle_data) + + if "properties" not in oracle_data: + raise KeyError("properties") + + self.properties = [ObjectGeneratorProperty(prop_data) for prop_data in oracle_data["properties"]] + + def generate(self) -> str: + return "\n\n".join(prop.generate() for prop in self.properties) diff --git a/tests/test_object_generator_oracle.py b/tests/test_object_generator_oracle.py new file mode 100644 index 0000000..4165f40 --- /dev/null +++ b/tests/test_object_generator_oracle.py @@ -0,0 +1,180 @@ +# SPDX-FileCopyrightText: 2025 2025 +# SPDX-FileContributor: Gergely Polonkai +# +# SPDX-License-Identifier: GPL-3.0-or-later +"""Tests for the Object Generator Oracle""" + +from copy import deepcopy +from typing import Literal + +import pytest +from pytest_mock import MockerFixture + +from gm_assistant.dice import Die +from gm_assistant.oracle.object_generator import ObjectGeneratorOracle, ObjectGeneratorProperty + +TEST_PROPERTY_DATA = { + "question": "Test Question", + "roll-type": "d6", + "results": [ + {"min-roll": 1, "max-roll": 2, "value": "Value 1-2"}, + {"min-roll": 3, "value": "Value 3"}, + {"min-roll": 4, "max-roll": 6, "value": "Value 4-6"}, + ], +} +TEST_ORACLE_DATA = { + "name": "Test Oracle", + "source": "Test Source", + "type": "object-generator", + "properties": [ + TEST_PROPERTY_DATA, + { + "question": "Second Question", + "roll-type": "d2", + "results": [ + {"min-roll": 1, "value": "Value 1"}, + {"min-roll": 2, "value": "Value 2"}, + ], + }, + ], +} + + +@pytest.mark.parametrize("missing", ["question", "roll-type", "results", "min-roll", "value"]) +def test_property_missing_data(missing: Literal["question", "roll-type", "results", "min-roll", "value"]) -> None: + """Test initialising a property with missing data""" + + prop_data = deepcopy(TEST_PROPERTY_DATA) + + if missing in ("min-roll", "value"): + del prop_data["results"][0][missing] # type: ignore[attr-defined] + else: + del prop_data[missing] + + with pytest.raises(ValueError) as ctx: + ObjectGeneratorProperty(prop_data) + + if missing in ("min-roll", "value"): + assert str(ctx.value) == f"Missing {missing} value in result 0" + else: + assert str(ctx.value) == f"Missing {missing} value" + + +def test_property_result_max_smaller_than_min() -> None: + """Test initialising a property with a maximum roll smaller than the minimum roll""" + + prop_data = deepcopy(TEST_PROPERTY_DATA) + prop_data["results"][0]["min-roll"] = 3 # type: ignore[index] + + with pytest.raises(ValueError) as ctx: + ObjectGeneratorProperty(prop_data) + + assert str(ctx.value) == "max-roll cannot be smaller than min-roll in result 0" + + +def test_property_missing_roll_value() -> None: + """Test initialising a property with a missing roll value""" + + prop_data = deepcopy(TEST_PROPERTY_DATA) + prop_data["results"] = [prop_data["results"][0]] + + with pytest.raises(ValueError) as ctx: + ObjectGeneratorProperty(prop_data) + + assert str(ctx.value) == "Not all roll values yield a result for property Test Question" + + +def test_property_multiple_roll_value() -> None: + """Test initialising a property with a roll value appearing twice""" + + prop_data = deepcopy(TEST_PROPERTY_DATA) + prop_data["results"].append({"min-roll": 2, "value": "Second Value"}) # type: ignore[attr-defined] + + with pytest.raises(ValueError) as ctx: + ObjectGeneratorProperty(prop_data) + + assert str(ctx.value) == "Roll value 2 is already registered in result 3" + + +def test_property_init(mocker: MockerFixture) -> None: + """Test initialising a property with valid data""" + + mocked_die = Die("d6") + mocked_die_class = mocker.patch("gm_assistant.oracle.object_generator.Die", return_value=mocked_die) + + prop = ObjectGeneratorProperty(TEST_PROPERTY_DATA) + + assert prop.question == "Test Question" + assert prop.die == mocked_die + assert prop.results == { + 1: "Value 1-2", + 2: "Value 1-2", + 3: "Value 3", + 4: "Value 4-6", + 5: "Value 4-6", + 6: "Value 4-6", + } + mocked_die_class.assert_called_once_with("d6") + + +def test_property_generate(mocker: MockerFixture) -> None: + """Test the ObjectGeneratorProperty.generate() method""" + + mocked_roll = mocker.patch("gm_assistant.oracle.object_generator.Die.roll", return_value=3) + + prop = ObjectGeneratorProperty(TEST_PROPERTY_DATA) + + expected_output = """*Test Question* + +Value 3""" + assert prop.generate() == expected_output + mocked_roll.assert_called_once_with() + + +def test_oracle_init_missing_properties() -> None: + """Test initialising an Oracle with properties missing""" + + oracle_data = deepcopy(TEST_ORACLE_DATA) + del oracle_data["properties"] + + with pytest.raises(KeyError) as ctx: + ObjectGeneratorOracle(oracle_data) + + assert str(ctx.value) == "'properties'" + + +def test_oracle_init() -> None: + """Test initialising an Oracle with valid data""" + + oracle = ObjectGeneratorOracle(TEST_ORACLE_DATA) + + assert oracle.properties[0].question == "Test Question" + assert oracle.properties[1].question == "Second Question" + + +def test_oracle_generate(mocker: MockerFixture) -> None: + """Test the Oracle’s ``generate`` method""" + + mocked_die1 = Die("d6") + mocker.patch.object(mocked_die1, "roll", return_value=5) + mocked_die2 = Die("d2") + mocker.patch.object(mocked_die2, "roll", return_value=1) + + mocked_die_class = mocker.patch("gm_assistant.oracle.object_generator.Die", side_effect=[mocked_die1, mocked_die2]) + + oracle = ObjectGeneratorOracle(TEST_ORACLE_DATA) + expected_output = """*Test Question* + +Value 4-6 + +*Second Question* + +Value 1""" + + assert oracle.generate() == expected_output + assert mocked_die_class.call_count == 2 + mocked_die_class.assert_has_calls([mocker.call("d6"), mocker.call("d2")]) + + # pylint: disable=no-member + mocked_die1.roll.assert_called_once_with() # type: ignore[attr-defined] + mocked_die2.roll.assert_called_once_with() # type: ignore[attr-defined]