testing.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. # Part of ImGui Bundle - MIT License - Copyright (c) 2022-2026 Pascal Thomet - https://github.com/pthom/imgui_bundle
  2. """immapp.testing — drive an imgui_bundle GUI via ImGui Test Engine.
  3. Run a GUI together with a single test function; the test can click, type,
  4. capture screenshots, etc. The window exits automatically once the test
  5. finishes (unless exit_after_test=False).
  6. """
  7. from __future__ import annotations
  8. from pathlib import Path
  9. from typing import Callable, Optional, Tuple, Union
  10. from imgui_bundle import imgui, hello_imgui, immapp
  11. PathLike = Union[str, Path]
  12. TestFunction = Callable[["imgui.test_engine.TestContext"], None]
  13. TestRunSpeed = imgui.test_engine.TestRunSpeed
  14. def capture(
  15. ctx: "imgui.test_engine.TestContext",
  16. path: str,
  17. *,
  18. window: Optional[str] = None,
  19. flags: int = 0,
  20. ) -> None:
  21. """Write a PNG screenshot from inside a test function.
  22. Yields one frame first so pending animation/layout state has a chance to
  23. settle before the capture.
  24. Args:
  25. ctx: the TestContext passed to your test function.
  26. path: absolute output path. The file extension (usually `.png`) is
  27. honored by the test engine.
  28. window: if set, capture only that window. A bare label (e.g.
  29. "Dear ImGui Demo") is interpreted as an absolute root reference
  30. (prefixed with "//" automatically). Pass None to capture the
  31. full application framebuffer.
  32. flags: imgui.test_engine.CaptureFlags_ bitfield.
  33. """
  34. ctx.yield_()
  35. ctx.capture_reset()
  36. if window is not None:
  37. ref = window if window.startswith("//") else "//" + window
  38. ctx.capture_add_window(ref)
  39. ctx.capture_set_filename(path)
  40. ctx.capture_screenshot(flags)
  41. def run(
  42. gui_function: Callable[[], None],
  43. test_function: TestFunction,
  44. *,
  45. exit_after_test: bool = True,
  46. run_speed: TestRunSpeed = TestRunSpeed.fast,
  47. # Common RunnerParams / AddOns shortcuts (ignored if runner_params is provided)
  48. window_title: str = "",
  49. window_size: Optional[Tuple[int, int]] = None,
  50. window_size_auto: bool = False,
  51. fps_idle: float = 10.0,
  52. ini_disable: bool = True,
  53. with_markdown: bool = False,
  54. with_latex: bool = False,
  55. with_implot: bool = False,
  56. with_implot3d: bool = False,
  57. with_node_editor: bool = False,
  58. with_tex_inspect: bool = False,
  59. # Escape hatch: pass a fully configured RunnerParams instead of the shortcuts above.
  60. runner_params: Optional[hello_imgui.RunnerParams] = None,
  61. add_ons_params: Optional[immapp.AddOnsParams] = None,
  62. ) -> None:
  63. """Run `gui_function` and automatically drive it with `test_function`.
  64. `test_function(ctx)` receives an imgui.test_engine.TestContext and can use
  65. the full test engine API (item_click, item_open, key_chars,
  66. capture_screenshot_window, ...). It also works with `immapp.testing.capture`
  67. to grab screenshots at chosen moments.
  68. Args:
  69. gui_function: the GUI to drive (called each frame).
  70. test_function: the test body, called once by the engine. When it
  71. returns, the app exits (unless exit_after_test=False).
  72. exit_after_test: if True (default), set app_shall_exit once the test
  73. finishes. Set to False to keep the window open for inspection.
  74. run_speed: fast / normal / cinematic. Defaults to fast.
  75. runner_params: if given, the shortcut kwargs above are ignored and
  76. this RunnerParams is used as-is (we only wire the test engine
  77. on top of it).
  78. add_ons_params: immapp addons. If None, built from the with_* flags.
  79. """
  80. if runner_params is None:
  81. simple = hello_imgui.SimpleRunnerParams()
  82. simple.gui_function = gui_function
  83. simple.window_title = window_title
  84. simple.window_size_auto = window_size_auto
  85. if window_size is not None:
  86. simple.window_size = window_size
  87. simple.fps_idle = fps_idle
  88. runner_params = simple.to_runner_params()
  89. runner_params.ini_disable = ini_disable
  90. runner_params.use_imgui_test_engine = True
  91. if add_ons_params is None:
  92. add_ons_params = immapp.AddOnsParams(
  93. with_implot=with_implot,
  94. with_implot3d=with_implot3d,
  95. with_markdown=with_markdown or with_latex,
  96. with_latex=with_latex,
  97. with_node_editor=with_node_editor,
  98. with_tex_inspect=with_tex_inspect,
  99. )
  100. test_done = False
  101. test_error: Optional[BaseException] = None
  102. def _wrapped_test(ctx: "imgui.test_engine.TestContext") -> None:
  103. nonlocal test_done, test_error
  104. try:
  105. test_function(ctx)
  106. except BaseException as e:
  107. test_error = e
  108. finally:
  109. test_done = True
  110. prior_register_tests = runner_params.callbacks.register_tests
  111. def _register_tests() -> None:
  112. if callable(prior_register_tests):
  113. prior_register_tests()
  114. engine = hello_imgui.get_imgui_test_engine()
  115. io = imgui.test_engine.get_io(engine)
  116. io.config_run_speed = run_speed
  117. test = imgui.test_engine.register_test(engine, "immapp.testing", "auto")
  118. test.test_func = _wrapped_test
  119. imgui.test_engine.queue_test(engine, test)
  120. runner_params.callbacks.register_tests = _register_tests
  121. if exit_after_test:
  122. prior_before_render = runner_params.callbacks.before_imgui_render
  123. def _exit_when_done() -> None:
  124. if callable(prior_before_render):
  125. prior_before_render()
  126. if test_done:
  127. engine = hello_imgui.get_imgui_test_engine()
  128. if imgui.test_engine.is_test_queue_empty(engine):
  129. runner_params.app_shall_exit = True
  130. runner_params.callbacks.before_imgui_render = _exit_when_done
  131. immapp.run(runner_params, add_ons_params)
  132. if test_error is not None:
  133. raise test_error
  134. def capture_final_frame(
  135. gui_function: Callable[[], None],
  136. output_path: PathLike,
  137. *,
  138. exit_after_frames: int = 8,
  139. window_size: Tuple[int, int] = (900, 950),
  140. window_title: str = "capture_final_frame",
  141. ini_disable: bool = True,
  142. fps_idle: float = 0.0,
  143. with_markdown: bool = False,
  144. with_latex: bool = False,
  145. with_implot: bool = False,
  146. with_implot3d: bool = False,
  147. with_node_editor: bool = False,
  148. ) -> Path:
  149. """One-shot: run `gui_function` for a few frames, save the last
  150. framebuffer as PNG, return the absolute output path.
  151. Simple alternative to `run`/`capture` for the common case where you
  152. just want a snapshot of a GUI (no interaction, no test engine). Kept
  153. as the workhorse of the `screenshot-imgui-bundle` skill.
  154. Args:
  155. gui_function: per-frame draw callback.
  156. output_path: where to write the PNG (parent dir must exist).
  157. exit_after_frames: render N frames before exit (default 8). Bump
  158. higher (15-30) if the layout takes longer to settle.
  159. window_size: logical pixels. On HiDPI displays the PNG is scaled.
  160. window_title: cosmetic — window is only briefly visible.
  161. ini_disable: disable imgui .ini to avoid side effects.
  162. fps_idle: 0 (no throttle) — we want the few frames to render fast.
  163. with_*: standard immapp.run addons.
  164. """
  165. output_path = Path(output_path).expanduser().resolve()
  166. state = {"frames": 0}
  167. def _wrapped() -> None:
  168. gui_function()
  169. state["frames"] += 1
  170. if state["frames"] >= exit_after_frames:
  171. hello_imgui.get_runner_params().app_shall_exit = True
  172. immapp.run(
  173. gui_function=_wrapped,
  174. window_title=window_title,
  175. window_size=window_size,
  176. ini_disable=ini_disable,
  177. fps_idle=fps_idle,
  178. with_markdown=with_markdown,
  179. with_latex=with_latex,
  180. with_implot=with_implot,
  181. with_implot3d=with_implot3d,
  182. with_node_editor=with_node_editor,
  183. )
  184. img = hello_imgui.final_app_window_screenshot()
  185. if img is None or img.size == 0:
  186. raise RuntimeError(
  187. "final_app_window_screenshot() returned an empty buffer. "
  188. f"Did the app exit before any frame was rendered? "
  189. f"(exit_after_frames={exit_after_frames})"
  190. )
  191. from PIL import Image
  192. Image.fromarray(img).save(output_path)
  193. return output_path