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