editor_test_helper.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. #
  2. # All or portions of this file Copyright (c) Amazon.com, Inc. or its affiliates or
  3. # its licensors.
  4. #
  5. # For complete copyright and license terms please see the LICENSE at the root of this
  6. # distribution (the "License"). All use of this software is governed by the License,
  7. # or, if provided, by the license below or the license accompanying this file. Do not
  8. # remove or modify any license notices. This file is distributed on an "AS IS" BASIS,
  9. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. #
  11. # NOTE: This code is used for tests in several feature areas. If changes are made to this file, please verify all
  12. # dependent tests continue to run without issue.
  13. #
  14. import sys
  15. import time
  16. from typing import Sequence
  17. from .report import Report
  18. # Lumberyard specific imports
  19. import azlmbr.legacy.general as general
  20. import azlmbr.legacy.settings as settings
  21. class EditorTestHelper:
  22. def __init__(self, log_prefix: str, args: Sequence[str] = None) -> None:
  23. self.log_prefix = log_prefix + ": "
  24. self.test_success = True
  25. # If the idle loop has already been enabled at test init time, the Editor is already running.
  26. # If that's the case, we'll skip the "exit_no_prompt" at the end.
  27. self.editor_already_running = general.is_idle_enabled()
  28. self.args = {}
  29. if args:
  30. # Get the level name and heightmap name from command-line args
  31. if len(sys.argv) == (len(args) + 1):
  32. for arg_index in range(len(args)):
  33. self.args[args[arg_index]] = sys.argv[arg_index + 1]
  34. else:
  35. self.test_success = False
  36. self.log(f"Expected command-line args: {args}")
  37. self.log(f"Check that cfg_args were passed into the test class")
  38. # Test Setup
  39. # Set helpers
  40. # Set viewport size
  41. # Turn off display mode, antialiasing
  42. # set log prefix, log test started
  43. def setup(self) -> None:
  44. self.log("test started")
  45. def after_level_load(self, bypass_viewport_resize: bool = False) -> bool:
  46. success = True
  47. # Enable the Editor to start running its idle loop.
  48. # This is needed for Python scripts passed into the Editor startup. Since they're executed
  49. # during the startup flow, they run before idle processing starts. Without this, the engine loop
  50. # won't run during idle_wait, which will prevent our test level from working.
  51. general.idle_enable(True)
  52. # Give everything a second to initialize
  53. general.idle_wait(1.0)
  54. general.update_viewport()
  55. general.idle_wait(0.5) # half a second is more than enough for updating the viewport.
  56. self.original_settings = settings.get_misc_editor_settings()
  57. self.helpers_visible = general.is_helpers_shown()
  58. self.viewport_size = general.get_viewport_size()
  59. self.viewport_layout = general.get_view_pane_layout()
  60. # Turn off the helper gizmos if visible
  61. if self.helpers_visible:
  62. general.toggle_helpers()
  63. general.idle_wait(1.0)
  64. # Close the Error Report window so it doesn't interfere with testing hierarchies and focus
  65. if general.is_pane_visible("Error Report"):
  66. general.close_pane("Error Report")
  67. if general.is_pane_visible("Error Log"):
  68. general.close_pane("Error Log")
  69. general.idle_wait(1.0)
  70. if not bypass_viewport_resize:
  71. # Set Editor viewport to a well-defined size
  72. screen_width = 1600
  73. screen_height = 900
  74. general.set_viewport_expansion_policy("FixedSize")
  75. general.set_viewport_size(screen_width, screen_height)
  76. general.update_viewport()
  77. general.idle_wait(1.0)
  78. new_viewport_size = general.get_viewport_size()
  79. new_viewport_width = int(new_viewport_size.x)
  80. new_viewport_height = int(new_viewport_size.y)
  81. if (new_viewport_width != screen_width) or (new_viewport_height != screen_height):
  82. self.log(
  83. f"set_viewport_size failed - expected ({screen_width},{screen_height}), got ({new_viewport_width},{new_viewport_height})"
  84. )
  85. self.test_success = False
  86. success = False
  87. # Turn off any display info like FPS, as that will mess up our image comparisons
  88. # Turn off antialiasing as well
  89. general.run_console("r_displayInfo=0")
  90. general.run_console("r_antialiasingmode=0")
  91. general.idle_wait(1.0)
  92. return success
  93. # Test Teardown
  94. # Restore everything from above
  95. # log test results, exit editor
  96. def teardown(self) -> None:
  97. # Restore the original Editor settings
  98. settings.set_misc_editor_settings(self.original_settings)
  99. # If the helper gizmos were on at the start, restore them
  100. if self.helpers_visible:
  101. general.toggle_helpers()
  102. # Set the viewport back to whatever size it was at the start and restore the pane layout
  103. general.set_viewport_size(int(self.viewport_size.x), int(self.viewport_size.y))
  104. general.set_viewport_expansion_policy("AutoExpand")
  105. general.set_view_pane_layout(self.viewport_layout)
  106. general.update_viewport()
  107. general.idle_wait(1.0)
  108. self.log("test finished")
  109. if self.test_success:
  110. self.log("result=SUCCESS")
  111. general.set_result_to_success()
  112. else:
  113. self.log("result=FAILURE")
  114. general.set_result_to_failure()
  115. if not self.editor_already_running:
  116. general.exit_no_prompt()
  117. def run_test(self) -> None:
  118. self.log("run")
  119. def run(self) -> None:
  120. self.setup()
  121. # Only run the actual test if we didn't have setup issues
  122. if self.test_success:
  123. self.run_test()
  124. self.teardown()
  125. def get_arg(self, arg_name: str) -> str:
  126. if arg_name in self.args:
  127. return self.args[arg_name]
  128. return ""
  129. # general logger that adds prefix?
  130. def log(self, log_line: str) -> None:
  131. Report.info(self.log_prefix + log_line)
  132. # isclose: Compares two floating-point values for "nearly-equal"
  133. def isclose(self, a: float, b: float, rel_tol: float = 1e-9, abs_tol: float = 0.0) -> bool:
  134. return abs(a - b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol)
  135. # Create a new empty level
  136. def create_level(
  137. self,
  138. level_name: str,
  139. heightmap_resolution: int,
  140. heightmap_meters_per_pixel: int,
  141. terrain_texture_resolution: int,
  142. use_terrain: bool,
  143. bypass_viewport_resize: bool = False,
  144. ) -> bool:
  145. self.log(f"Creating level {level_name}")
  146. result = general.create_level_no_prompt(
  147. level_name, heightmap_resolution, heightmap_meters_per_pixel, terrain_texture_resolution, use_terrain
  148. )
  149. # Result codes are ECreateLevelResult defined in CryEdit.h
  150. if result == 1:
  151. self.log(f"{level_name} level already exists")
  152. elif result == 2:
  153. self.log("Failed to create directory")
  154. elif result == 3:
  155. self.log("Directory length is too long")
  156. elif result != 0:
  157. self.log("Unknown error, failed to create level")
  158. else:
  159. self.log(f"{level_name} level created successfully")
  160. # If the editor is already running, allow "level already exists" to count as success
  161. if (result == 0) or (self.editor_already_running and (result == 1)):
  162. # For successful level creation, call the post-load step.
  163. if self.after_level_load(bypass_viewport_resize):
  164. result = 0
  165. else:
  166. result = -1
  167. return result == 0
  168. def open_level(self, level_name: str, bypass_viewport_resize: bool = False) -> bool:
  169. # Open the level non-interactively
  170. if self.editor_already_running and (general.get_current_level_name() == level_name):
  171. self.log(f"Level {level_name} already open")
  172. result = True
  173. else:
  174. self.log(f"Opening level {level_name}")
  175. result = general.open_level_no_prompt(level_name)
  176. result = result and self.after_level_load(bypass_viewport_resize)
  177. if result:
  178. self.log(f"Successfully opened {level_name}")
  179. else:
  180. self.log(f"Unknown error, {level_name} level failed to open")
  181. return result
  182. # Take Screenshot
  183. def take_viewport_screenshot(
  184. self, posX: float, posY: float, posZ: float, rotX: float, rotY: float, rotZ: float
  185. ) -> None:
  186. # Set our camera position / rotation and wait for the Editor to acknowledge it
  187. general.set_current_view_position(posX, posY, posZ)
  188. general.set_current_view_rotation(rotX, rotY, rotZ)
  189. general.idle_wait(1.0)
  190. # Request a screenshot and wait for the Editor to process it
  191. general.run_console("r_GetScreenShot=2")
  192. general.idle_wait(1.0)
  193. def enter_game_mode(self, success_message: str) -> None:
  194. """
  195. :param success_message: The str with the expected message for entering game mode.
  196. :return: None
  197. """
  198. Report.info("Entering game mode")
  199. general.enter_game_mode()
  200. general.idle_wait_frames(1)
  201. self.critical_result(success_message, general.is_in_game_mode())
  202. def exit_game_mode(self, success_message: str) -> None:
  203. """
  204. :param success_message: The str with the expected message for exiting game mode.
  205. :return: None
  206. """
  207. Report.info("Exiting game mode")
  208. general.exit_game_mode()
  209. general.idle_wait_frames(1)
  210. self.critical_result(success_message, not general.is_in_game_mode())
  211. def critical_result(self, success_message: str, condition: bool, fast_fail_message: str = None) -> None:
  212. """
  213. if condition is False we will fail fast
  214. :param success_message: messages to print based on the condition
  215. :param condition: success (True) or failure (False)
  216. :param fast_fail_message: [optional] message to include on fast fail
  217. """
  218. if not isinstance(condition, bool):
  219. raise TypeError("condition argument must be a bool")
  220. if not Report.result(success_message, condition):
  221. self.test_success = False
  222. self.fail_fast(fast_fail_message)
  223. def fail_fast(self, message: str = None) -> None:
  224. """
  225. A state has been reached where progressing in the test is not viable.
  226. raises FailFast
  227. :return: None
  228. """
  229. Report.info("Failing fast. Raising an exception and shutting down the editor.")
  230. if message:
  231. Report.info(f"Fail fast message: {message}")
  232. self.teardown()
  233. raise RuntimeError
  234. def wait_for_condition(self, function, timeout_in_seconds=1.0):
  235. # type: (function, float) -> bool
  236. """
  237. **** Will be replaced by a function of the same name exposed in the Engine*****
  238. a function to run until it returns True or timeout is reached
  239. the function can have no parameters and
  240. waiting idle__wait_* is handled here not in the function
  241. :param function: a function that returns a boolean indicating a desired condition is achieved
  242. :param timeout_in_seconds: when reached, function execution is abandoned and False is returned
  243. """
  244. with Timeout(timeout_in_seconds) as t:
  245. while True:
  246. try:
  247. general.idle_wait_frames(1)
  248. except Exception:
  249. print("WARNING: Couldn't wait for frame")
  250. if t.timed_out:
  251. return False
  252. ret = function()
  253. if not isinstance(ret, bool):
  254. raise TypeError("return value for wait_for_condition function must be a bool")
  255. if ret:
  256. return True
  257. class Timeout:
  258. # type: (float) -> None
  259. """
  260. contextual timeout
  261. :param seconds: float seconds to allow before timed_out is True
  262. """
  263. def __init__(self, seconds):
  264. self.seconds = seconds
  265. def __enter__(self):
  266. self.die_after = time.time() + self.seconds
  267. return self
  268. def __exit__(self, type, value, traceback):
  269. pass
  270. @property
  271. def timed_out(self):
  272. return time.time() > self.die_after