mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-04-15 12:13:30 -07:00
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:
@@ -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] = []
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
16
test/general/test_memory.py
Normal file
16
test/general/test_memory.py
Normal 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
66
test/utils/test_caches.py
Normal 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
|
||||
63
test/webhost/test_option_presets.py
Normal file
63
test/webhost/test_option_presets.py
Normal 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)."
|
||||
)
|
||||
Reference in New Issue
Block a user