Merge branch 'main' into rework_accessibility

# Conflicts:
#	worlds/alttp/test/dungeons/TestDungeon.py
#	worlds/messenger/rules.py
#	worlds/pokemon_rb/options.py
This commit is contained in:
alwaysintreble
2023-11-25 12:28:12 -06:00
430 changed files with 75044 additions and 14323 deletions

View File

@@ -1,7 +1,10 @@
import random
import sys
import typing
import unittest
from argparse import Namespace
from Generate import get_seed_name
from test.general import gen_steps
from worlds import AutoWorld
from worlds.AutoWorld import call_all
@@ -107,11 +110,36 @@ class WorldTestBase(unittest.TestCase):
game: typing.ClassVar[str] # define game name in subclass, example "Secret of Evermore"
auto_construct: typing.ClassVar[bool] = True
""" automatically set up a world for each test in this class """
memory_leak_tested: typing.ClassVar[bool] = False
""" remember if memory leak test was already done for this class """
def setUp(self) -> None:
if self.auto_construct:
self.world_setup()
def tearDown(self) -> None:
if self.__class__.memory_leak_tested or not self.options or not self.constructed or \
sys.version_info < (3, 11, 0): # the leak check in tearDown fails in py<3.11 for an unknown reason
# only run memory leak test once per class, only for constructed with non-default options
# default options will be tested in test/general
super().tearDown()
return
import gc
import weakref
weak = weakref.ref(self.multiworld)
for attr_name in dir(self): # delete all direct references to MultiWorld and World
attr: object = typing.cast(object, getattr(self, attr_name))
if type(attr) is MultiWorld or isinstance(attr, AutoWorld.World):
delattr(self, attr_name)
state_cache: typing.Optional[typing.Dict[typing.Any, typing.Any]] = getattr(self, "_state_cache", None)
if state_cache is not None: # in case of multiple inheritance with TestBase, we need to clear its cache
state_cache.clear()
gc.collect()
self.__class__.memory_leak_tested = True
self.assertFalse(weak(), f"World {getattr(self, 'game', '')} leaked MultiWorld object")
super().tearDown()
def world_setup(self, seed: typing.Optional[int] = None) -> None:
if type(self) is WorldTestBase or \
(hasattr(WorldTestBase, self._testMethodName)
@@ -126,6 +154,8 @@ class WorldTestBase(unittest.TestCase):
self.multiworld.player_name = {1: "Tester"}
self.multiworld.set_seed(seed)
self.multiworld.state = CollectionState(self.multiworld)
random.seed(self.multiworld.seed)
self.multiworld.seed_name = get_seed_name(random) # only called to get same RNG progression as Generate.py
args = Namespace()
for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].options_dataclass.type_hints.items():
setattr(args, name, {
@@ -284,7 +314,7 @@ class WorldTestBase(unittest.TestCase):
# basically a shortened reimplementation of this method from core, in order to force the check is done
def fulfills_accessibility() -> bool:
locations = self.multiworld.get_locations(1).copy()
locations = list(self.multiworld.get_locations(1))
state = CollectionState(self.multiworld)
while locations:
sphere: typing.List[Location] = []

View File

@@ -442,6 +442,47 @@ class TestFillRestrictive(unittest.TestCase):
self.assertTrue(sphere1_loc.item, "Did not swap required item into Sphere 1")
self.assertEqual(sphere1_loc.item, allowed_item, "Wrong item in Sphere 1")
def test_swap_to_earlier_location_with_item_rule2(self):
"""Test that swap works before all items are placed"""
multi_world = generate_multi_world(1)
player1 = generate_player_data(multi_world, 1, 5, 5)
locations = player1.locations[:] # copy required
items = player1.prog_items[:] # copy required
# Two items provide access to sphere 2.
# One of them is forbidden in sphere 1, the other is first placed in sphere 4 because of placement order,
# requiring a swap.
# There are spheres in between, so for the swap to work, it'll have to assume all other items are collected.
one_to_two1 = items[4].name
one_to_two2 = items[3].name
three_to_four = items[2].name
two_to_three1 = items[1].name
two_to_three2 = items[0].name
# Sphere 4
set_rule(locations[0], lambda state: ((state.has(one_to_two1, player1.id) or state.has(one_to_two2, player1.id))
and state.has(two_to_three1, player1.id)
and state.has(two_to_three2, player1.id)
and state.has(three_to_four, player1.id)))
# Sphere 3
set_rule(locations[1], lambda state: ((state.has(one_to_two1, player1.id) or state.has(one_to_two2, player1.id))
and state.has(two_to_three1, player1.id)
and state.has(two_to_three2, player1.id)))
# Sphere 2
set_rule(locations[2], lambda state: state.has(one_to_two1, player1.id) or state.has(one_to_two2, player1.id))
# Sphere 1
sphere1_loc1 = locations[3]
sphere1_loc2 = locations[4]
# forbid one_to_two2 in sphere 1 to make the swap happen as described above
add_item_rule(sphere1_loc1, lambda item_to_place: item_to_place.name != one_to_two2)
add_item_rule(sphere1_loc2, lambda item_to_place: item_to_place.name != one_to_two2)
# Now fill should place one_to_two1 in sphere1_loc1 or sphere1_loc2 via swap,
# which it will attempt before two_to_three and three_to_four are placed, testing the behavior.
fill_restrictive(multi_world, multi_world.state, player1.locations, player1.prog_items)
# assert swap happened
self.assertTrue(sphere1_loc1.item and sphere1_loc2.item, "Did not swap required item into Sphere 1")
self.assertTrue(sphere1_loc1.item.name == one_to_two1 or
sphere1_loc2.item.name == one_to_two1, "Wrong item in Sphere 1")
def test_double_sweep(self):
"""Test that sweep doesn't duplicate Event items when sweeping"""
# test for PR1114
@@ -455,8 +496,8 @@ class TestFillRestrictive(unittest.TestCase):
location.place_locked_item(item)
multi_world.state.sweep_for_events()
multi_world.state.sweep_for_events()
self.assertTrue(multi_world.state.prog_items[item.name, item.player], "Sweep did not collect - Test flawed")
self.assertEqual(multi_world.state.prog_items[item.name, item.player], 1, "Sweep collected multiple times")
self.assertTrue(multi_world.state.prog_items[item.player][item.name], "Sweep did not collect - Test flawed")
self.assertEqual(multi_world.state.prog_items[item.player][item.name], 1, "Sweep collected multiple times")
def test_correct_item_instance_removed_from_pool(self):
"""Test that a placed item gets removed from the submitted pool"""

View File

@@ -16,7 +16,7 @@ class TestIDs(unittest.TestCase):
def test_utils_in_yaml(self) -> None:
"""Tests that the auto generated host.yaml has default settings in it"""
for option_key, option_set in Utils.get_default_options().items():
for option_key, option_set in Settings(None).items():
with self.subTest(option_key):
self.assertIn(option_key, self.yaml_options)
for sub_option_key in option_set:
@@ -24,7 +24,7 @@ class TestIDs(unittest.TestCase):
def test_yaml_in_utils(self) -> None:
"""Tests that the auto generated host.yaml shows up in reference calls"""
utils_options = Utils.get_default_options()
utils_options = Settings(None)
for option_key, option_set in self.yaml_options.items():
with self.subTest(option_key):
self.assertIn(option_key, utils_options)

View File

@@ -40,8 +40,8 @@ class TestImplemented(unittest.TestCase):
# has an await for generate_output which isn't being called
if game_name in {"Ocarina of Time", "Zillion"}:
continue
with self.subTest(game_name):
multiworld = setup_solo_multiworld(world_type)
multiworld = setup_solo_multiworld(world_type)
with self.subTest(game=game_name, seed=multiworld.seed):
distribute_items_restrictive(multiworld)
call_all(multiworld, "post_fill")
for key, data in multiworld.worlds[1].fill_slot_data().items():

View File

@@ -60,3 +60,12 @@ class TestBase(unittest.TestCase):
multiworld = setup_solo_multiworld(world_type)
for item in multiworld.itempool:
self.assertIn(item.name, world_type.item_name_to_id)
def test_item_descriptions_have_valid_names(self):
"""Ensure all item descriptions match an item name or item group name"""
for game_name, world_type in AutoWorldRegister.world_types.items():
valid_names = world_type.item_names.union(world_type.item_name_groups)
for name in world_type.item_descriptions:
with self.subTest("Name should be valid", game=game_name, item=name):
self.assertIn(name, valid_names,
"All item descriptions must match defined item names")

View File

@@ -36,7 +36,6 @@ class TestBase(unittest.TestCase):
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game_name=game_name):
multiworld = setup_solo_multiworld(world_type, gen_steps)
multiworld._recache()
region_count = len(multiworld.get_regions())
location_count = len(multiworld.get_locations())
@@ -46,14 +45,12 @@ class TestBase(unittest.TestCase):
self.assertEqual(location_count, len(multiworld.get_locations()),
f"{game_name} modified locations count during rule creation")
multiworld._recache()
call_all(multiworld, "generate_basic")
self.assertEqual(region_count, len(multiworld.get_regions()),
f"{game_name} modified region count during generate_basic")
self.assertGreaterEqual(location_count, len(multiworld.get_locations()),
f"{game_name} modified locations count during generate_basic")
multiworld._recache()
call_all(multiworld, "pre_fill")
self.assertEqual(region_count, len(multiworld.get_regions()),
f"{game_name} modified region count during pre_fill")
@@ -69,3 +66,12 @@ class TestBase(unittest.TestCase):
for location in locations:
self.assertIn(location, world_type.location_name_to_id)
self.assertNotIn(group_name, world_type.location_name_to_id)
def test_location_descriptions_have_valid_names(self):
"""Ensure all location descriptions match a location name or location group name"""
for game_name, world_type in AutoWorldRegister.world_types.items():
valid_names = world_type.location_names.union(world_type.location_name_groups)
for name in world_type.location_descriptions:
with self.subTest("Name should be valid", game=game_name, location=name):
self.assertIn(name, valid_names,
"All location descriptions must match defined location names")

View File

@@ -0,0 +1,16 @@
import unittest
from worlds.AutoWorld import AutoWorldRegister
from . import setup_solo_multiworld
class TestWorldMemory(unittest.TestCase):
def test_leak(self):
"""Tests that worlds don't leak references to MultiWorld or themselves with default options."""
import gc
import weakref
for game_name, world_type in AutoWorldRegister.world_types.items():
with self.subTest("Game", game_name=game_name):
weak = weakref.ref(setup_solo_multiworld(world_type))
gc.collect()
self.assertFalse(weak(), "World leaked a reference")

66
test/utils/test_caches.py Normal file
View File

@@ -0,0 +1,66 @@
# Tests for caches in Utils.py
import unittest
from typing import Any
from Utils import cache_argsless, cache_self1
class TestCacheArgless(unittest.TestCase):
def test_cache(self) -> None:
@cache_argsless
def func_argless() -> object:
return object()
self.assertTrue(func_argless() is func_argless())
if __debug__: # assert only available with __debug__
def test_invalid_decorator(self) -> None:
with self.assertRaises(Exception):
@cache_argsless # type: ignore[arg-type]
def func_with_arg(_: Any) -> None:
pass
class TestCacheSelf1(unittest.TestCase):
def test_cache(self) -> None:
class Cls:
@cache_self1
def func(self, _: Any) -> object:
return object()
o1 = Cls()
o2 = Cls()
self.assertTrue(o1.func(1) is o1.func(1))
self.assertFalse(o1.func(1) is o1.func(2))
self.assertFalse(o1.func(1) is o2.func(1))
def test_gc(self) -> None:
# verify that we don't keep a global reference
import gc
import weakref
class Cls:
@cache_self1
def func(self, _: Any) -> object:
return object()
o = Cls()
_ = o.func(o) # keep a hard ref to the result
r = weakref.ref(o) # keep weak ref to the cache
del o # remove hard ref to the cache
gc.collect()
self.assertFalse(r()) # weak ref should be dead now
if __debug__: # assert only available with __debug__
def test_no_self(self) -> None:
with self.assertRaises(Exception):
@cache_self1 # type: ignore[arg-type]
def func() -> Any:
pass
def test_too_many_args(self) -> None:
with self.assertRaises(Exception):
@cache_self1 # type: ignore[arg-type]
def func(_1: Any, _2: Any, _3: Any) -> Any:
pass

View File

@@ -0,0 +1,63 @@
import unittest
from worlds import AutoWorldRegister
from Options import Choice, NamedRange, Toggle, Range
class TestOptionPresets(unittest.TestCase):
def test_option_presets_have_valid_options(self):
"""Test that all predefined option presets are valid options."""
for game_name, world_type in AutoWorldRegister.world_types.items():
presets = world_type.web.options_presets
for preset_name, preset in presets.items():
for option_name, option_value in preset.items():
with self.subTest(game=game_name, preset=preset_name, option=option_name):
try:
option = world_type.options_dataclass.type_hints[option_name].from_any(option_value)
supported_types = [Choice, Toggle, Range, NamedRange]
if not any([issubclass(option.__class__, t) for t in supported_types]):
self.fail(f"'{option_name}' in preset '{preset_name}' for game '{game_name}' "
f"is not a supported type for webhost. "
f"Supported types: {', '.join([t.__name__ for t in supported_types])}")
except AssertionError as ex:
self.fail(f"Option '{option_name}': '{option_value}' in preset '{preset_name}' for game "
f"'{game_name}' is not valid. Error: {ex}")
except KeyError as ex:
self.fail(f"Option '{option_name}' in preset '{preset_name}' for game '{game_name}' is "
f"not a defined option. Error: {ex}")
def test_option_preset_values_are_explicitly_defined(self):
"""Test that option preset values are not a special flavor of 'random' or use from_text to resolve another
value.
"""
for game_name, world_type in AutoWorldRegister.world_types.items():
presets = world_type.web.options_presets
for preset_name, preset in presets.items():
for option_name, option_value in preset.items():
with self.subTest(game=game_name, preset=preset_name, option=option_name):
# Check for non-standard random values.
self.assertFalse(
str(option_value).startswith("random-"),
f"'{option_name}': '{option_value}' in preset '{preset_name}' for game '{game_name}' "
f"is not supported for webhost. Special random values are not supported for presets."
)
option = world_type.options_dataclass.type_hints[option_name].from_any(option_value)
# Check for from_text resolving to a different value. ("random" is allowed though.)
if option_value != "random" and isinstance(option_value, str):
# Allow special named values for NamedRange option presets.
if isinstance(option, NamedRange):
self.assertTrue(
option_value in option.special_range_names,
f"Invalid preset '{option_name}': '{option_value}' in preset '{preset_name}' "
f"for game '{game_name}'. Expected {option.special_range_names.keys()} or "
f"{option.range_start}-{option.range_end}."
)
else:
self.assertTrue(
option.name_lookup.get(option.value, None) == option_value,
f"'{option_name}': '{option_value}' in preset '{preset_name}' for game "
f"'{game_name}' is not supported for webhost. Values must not be resolved to a "
f"different option via option.from_text (or an alias)."
)