feat: Create the Object Generator Oracle
This commit is contained in:
parent
d556159f2a
commit
248d7a27af
99
gm_assistant/oracle/object_generator.py
Normal file
99
gm_assistant/oracle/object_generator.py
Normal file
@ -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)
|
180
tests/test_object_generator_oracle.py
Normal file
180
tests/test_object_generator_oracle.py
Normal file
@ -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]
|
Loading…
x
Reference in New Issue
Block a user