ha-testcontainer 2.2.0__tar.gz → 2.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/PKG-INFO +1 -1
  2. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/ha_testcontainer/visual/scenario_runner.py +29 -3
  3. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/ha_testcontainer.egg-info/PKG-INFO +1 -1
  4. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/pyproject.toml +1 -1
  5. ha_testcontainer-2.3.0/tests/test_scenario_runner_unit.py +227 -0
  6. ha_testcontainer-2.2.0/tests/test_scenario_runner_unit.py +0 -84
  7. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/LICENSE +0 -0
  8. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/README.md +0 -0
  9. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/ha_testcontainer/__init__.py +0 -0
  10. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/ha_testcontainer/container.py +0 -0
  11. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/ha_testcontainer/ha_server.py +0 -0
  12. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/ha_testcontainer/plugins.py +0 -0
  13. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/ha_testcontainer/pytest_plugin.py +0 -0
  14. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/ha_testcontainer/visual/__init__.py +0 -0
  15. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/ha_testcontainer/visual/cursors.py +0 -0
  16. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/ha_testcontainer/visual/lovelace_helpers.py +0 -0
  17. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/ha_testcontainer.egg-info/SOURCES.txt +0 -0
  18. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/ha_testcontainer.egg-info/dependency_links.txt +0 -0
  19. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/ha_testcontainer.egg-info/entry_points.txt +0 -0
  20. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/ha_testcontainer.egg-info/requires.txt +0 -0
  21. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/ha_testcontainer.egg-info/top_level.txt +0 -0
  22. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/setup.cfg +0 -0
  23. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/tests/test_container.py +0 -0
  24. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/tests/test_container_unit.py +0 -0
  25. {ha_testcontainer-2.2.0 → ha_testcontainer-2.3.0}/tests/test_plugins_unit.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-testcontainer
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Full test container for Home Assistant with demo config, custom components, and visual testing
5
5
  License: MIT License
6
6
 
@@ -524,6 +524,9 @@ REPO_ROOT: Path = Path.cwd()
524
524
  # They participate in ``test_doc_images.py`` but not in ``test_scenarios.py``.
525
525
  DOCS_SCENARIOS_DIR: Path = REPO_ROOT / "docs" / "scenarios"
526
526
 
527
+ # Background color for normalized doc_animation canvases.
528
+ CANVAS_BACKGROUND_RGBA: tuple[int, int, int, int] = (255, 255, 255, 255)
529
+
527
530
  # ---------------------------------------------------------------------------
528
531
  # Extension registry
529
532
  # ---------------------------------------------------------------------------
@@ -2005,7 +2008,7 @@ def capture_doc_animation(
2005
2008
  _inject_click_circle(page)
2006
2009
  n: int = seg.get("frames", 10)
2007
2010
  for i in range(n):
2008
- frame_images.append(take_frame(fixed_clip))
2011
+ raw_frame_images.append(take_frame(fixed_clip))
2009
2012
  if i < n - 1:
2010
2013
  page.wait_for_timeout(interval_ms)
2011
2014
 
@@ -2019,7 +2022,7 @@ def capture_doc_animation(
2019
2022
 
2020
2023
  # --- capture frames ---
2021
2024
  # Each frame is an Image.Image object (PIL dynamically imported above).
2022
- frame_images: list[Any] = []
2025
+ raw_frame_images: list[Any] = []
2023
2026
 
2024
2027
  segments = doc_animation.get("segments")
2025
2028
  if segments is not None:
@@ -2038,7 +2041,7 @@ def capture_doc_animation(
2038
2041
  if cursor_type:
2039
2042
  _inject_cursor(page, cursor_type)
2040
2043
  for i in range(frame_count):
2041
- frame_images.append(take_frame(clip))
2044
+ raw_frame_images.append(take_frame(clip))
2042
2045
  if i < frame_count - 1:
2043
2046
  page.wait_for_timeout(interval_ms)
2044
2047
 
@@ -2048,6 +2051,28 @@ def capture_doc_animation(
2048
2051
  _remove_cursor(page)
2049
2052
  _remove_click_circle(page)
2050
2053
 
2054
+ if not raw_frame_images:
2055
+ raise AssertionError(
2056
+ f"Doc animation '{doc_animation['output']}': captured 0 frames. "
2057
+ "Set frames > 0 (or at least one segment with frames > 0)."
2058
+ )
2059
+
2060
+ # --- normalize frame sizes ---
2061
+ # When viewport size changes within one animation, later screenshots may be
2062
+ # smaller than earlier ones. Composite every frame onto a fixed-size canvas
2063
+ # so uncovered regions are explicitly cleared and no stale pixels bleed
2064
+ # through during GIF playback.
2065
+ max_w = max(f.size[0] for f in raw_frame_images)
2066
+ max_h = max(f.size[1] for f in raw_frame_images)
2067
+ frame_images: list[Any] = []
2068
+ for f in raw_frame_images:
2069
+ if f.size == (max_w, max_h):
2070
+ frame_images.append(f)
2071
+ continue
2072
+ canvas = Image.new("RGBA", (max_w, max_h), CANVAS_BACKGROUND_RGBA)
2073
+ canvas.paste(f, (0, 0))
2074
+ frame_images.append(canvas)
2075
+
2051
2076
  # --- assemble GIF ---
2052
2077
  # Build a global palette by stacking all frames vertically into one image
2053
2078
  # so the quantiser sees the actual pixel distribution across the entire
@@ -2075,6 +2100,7 @@ def capture_doc_animation(
2075
2100
  loop=0,
2076
2101
  duration=interval_ms,
2077
2102
  optimize=False,
2103
+ disposal=2,
2078
2104
  )
2079
2105
  actual_gif = gif_buf.getvalue()
2080
2106
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ha-testcontainer
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Full test container for Home Assistant with demo config, custom components, and visual testing
5
5
  License: MIT License
6
6
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ha-testcontainer"
7
- version = "2.2.0"
7
+ version = "2.3.0"
8
8
  description = "Full test container for Home Assistant with demo config, custom components, and visual testing"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -0,0 +1,227 @@
1
+ """Unit tests for scenario_runner interaction types — no Docker or browser required.
2
+
3
+ Covers interaction dispatch logic using mocks so that a running Playwright
4
+ browser is not needed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import io
10
+ from unittest.mock import MagicMock, call
11
+
12
+ import pytest
13
+ from PIL import Image, ImageSequence
14
+
15
+ import ha_testcontainer.visual.scenario_runner as sr
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Helpers
20
+ # ---------------------------------------------------------------------------
21
+
22
+
23
+ def _make_page() -> MagicMock:
24
+ """Return a lightweight mock that records Playwright Page calls."""
25
+ return MagicMock()
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # set_viewport interaction
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ class TestSetViewportInteraction:
34
+ """run_interactions dispatches set_viewport correctly."""
35
+
36
+ def test_set_viewport_calls_set_viewport_size(self):
37
+ page = _make_page()
38
+ scenario = {
39
+ "interactions": [
40
+ {"type": "set_viewport", "width": 375, "height": 812},
41
+ ]
42
+ }
43
+ sr.run_interactions(page, scenario)
44
+ page.set_viewport_size.assert_called_once_with({"width": 375, "height": 812})
45
+
46
+ def test_set_viewport_no_settle_ms_by_default(self):
47
+ page = _make_page()
48
+ scenario = {
49
+ "interactions": [
50
+ {"type": "set_viewport", "width": 1280, "height": 800},
51
+ ]
52
+ }
53
+ sr.run_interactions(page, scenario)
54
+ page.wait_for_timeout.assert_not_called()
55
+
56
+ def test_set_viewport_settle_ms_waits(self):
57
+ page = _make_page()
58
+ scenario = {
59
+ "interactions": [
60
+ {"type": "set_viewport", "width": 768, "height": 1024, "settle_ms": 300},
61
+ ]
62
+ }
63
+ sr.run_interactions(page, scenario)
64
+ page.set_viewport_size.assert_called_once_with({"width": 768, "height": 1024})
65
+ page.wait_for_timeout.assert_called_once_with(300)
66
+
67
+ def test_set_viewport_coerces_string_dimensions(self):
68
+ """width/height may arrive as strings from YAML and must be coerced to int."""
69
+ page = _make_page()
70
+ scenario = {
71
+ "interactions": [
72
+ {"type": "set_viewport", "width": "390", "height": "844"},
73
+ ]
74
+ }
75
+ sr.run_interactions(page, scenario)
76
+ page.set_viewport_size.assert_called_once_with({"width": 390, "height": 844})
77
+
78
+ def test_unknown_type_raises(self):
79
+ page = _make_page()
80
+ scenario = {
81
+ "interactions": [
82
+ {"type": "not_a_real_type"},
83
+ ]
84
+ }
85
+ with pytest.raises(ValueError, match="Unknown interaction type"):
86
+ sr.run_interactions(page, scenario)
87
+
88
+
89
+ def _png_bytes(width: int, height: int, color: tuple[int, int, int]) -> bytes:
90
+ """Return an in-memory PNG of the requested size and solid RGB color."""
91
+ img = Image.new("RGB", (width, height), color)
92
+ buf = io.BytesIO()
93
+ img.save(buf, format="PNG")
94
+ return buf.getvalue()
95
+
96
+
97
+ def _read_gif_frames(path) -> list[Image.Image]:
98
+ """Return frames from the GIF at *path* as per-frame RGB Pillow images."""
99
+ with Image.open(path) as gif:
100
+ if gif.format != "GIF":
101
+ raise ValueError(f"Expected GIF file, got: {gif.format}")
102
+ return [f.copy().convert("RGB") for f in ImageSequence.Iterator(gif)]
103
+
104
+
105
+ class TestDocAnimationViewportNormalization:
106
+ """doc_animation normalizes variable-size viewport frames to one canvas."""
107
+
108
+ def test_large_to_small_viewport_clears_uncovered_area(self, tmp_path, monkeypatch):
109
+ page = _make_page()
110
+ page.screenshot.side_effect = [
111
+ _png_bytes(8, 6, (255, 0, 0)),
112
+ _png_bytes(4, 3, (0, 255, 0)),
113
+ ]
114
+
115
+ save_kwargs: dict[str, object] = {}
116
+ orig_save = Image.Image.save
117
+
118
+ def _save_with_capture(self, file_obj, format=None, **params):
119
+ save_kwargs.update(params)
120
+ return orig_save(self, file_obj, format=format, **params)
121
+
122
+ monkeypatch.setattr(Image.Image, "save", _save_with_capture)
123
+ monkeypatch.setattr(sr, "REPO_ROOT", tmp_path)
124
+ monkeypatch.setenv("DOC_IMAGE_UPDATE", "1")
125
+
126
+ scenario = {
127
+ "doc_animation": {
128
+ "output": "docs/assets/viewport-large-small.gif",
129
+ "interval_ms": 1,
130
+ "dither": False,
131
+ "segments": [
132
+ {"interactions": [{"type": "set_viewport", "width": 8, "height": 6}], "frames": 1},
133
+ {"interactions": [{"type": "set_viewport", "width": 4, "height": 3}], "frames": 1},
134
+ ],
135
+ }
136
+ }
137
+
138
+ sr.capture_doc_animation(page, scenario)
139
+
140
+ output = tmp_path / "docs/assets/viewport-large-small.gif"
141
+ frames = _read_gif_frames(output)
142
+ assert [f.size for f in frames] == [(8, 6), (8, 6)]
143
+ # (6,4) sits outside the 4x3 second frame; it must be cleared to white.
144
+ assert frames[1].getpixel((6, 4)) == (255, 255, 255)
145
+ assert save_kwargs.get("disposal") == 2
146
+ assert page.set_viewport_size.call_args_list == [
147
+ call({"width": 8, "height": 6}),
148
+ call({"width": 4, "height": 3}),
149
+ ]
150
+
151
+ def test_small_to_large_viewport_uses_max_canvas(self, tmp_path, monkeypatch):
152
+ page = _make_page()
153
+ page.screenshot.side_effect = [
154
+ _png_bytes(4, 3, (0, 255, 0)),
155
+ _png_bytes(8, 6, (0, 0, 255)),
156
+ ]
157
+
158
+ monkeypatch.setattr(sr, "REPO_ROOT", tmp_path)
159
+ monkeypatch.setenv("DOC_IMAGE_UPDATE", "1")
160
+
161
+ scenario = {
162
+ "doc_animation": {
163
+ "output": "docs/assets/viewport-small-large.gif",
164
+ "interval_ms": 1,
165
+ "dither": False,
166
+ "segments": [
167
+ {"interactions": [{"type": "set_viewport", "width": 4, "height": 3}], "frames": 1},
168
+ {"interactions": [{"type": "set_viewport", "width": 8, "height": 6}], "frames": 1},
169
+ ],
170
+ }
171
+ }
172
+
173
+ sr.capture_doc_animation(page, scenario)
174
+
175
+ output = tmp_path / "docs/assets/viewport-small-large.gif"
176
+ frames = _read_gif_frames(output)
177
+ assert [f.size for f in frames] == [(8, 6), (8, 6)]
178
+ # (6,4) sits outside the 4x3 first frame; it must be cleared to white.
179
+ assert frames[0].getpixel((6, 4)) == (255, 255, 255)
180
+
181
+ def test_mixed_segment_interactions_keep_consistent_frame_size(self, tmp_path, monkeypatch):
182
+ page = _make_page()
183
+ page.screenshot.side_effect = [
184
+ _png_bytes(8, 6, (255, 0, 0)),
185
+ _png_bytes(5, 4, (0, 255, 0)),
186
+ _png_bytes(3, 2, (0, 0, 255)),
187
+ ]
188
+
189
+ monkeypatch.setattr(sr, "REPO_ROOT", tmp_path)
190
+ monkeypatch.setenv("DOC_IMAGE_UPDATE", "1")
191
+
192
+ scenario = {
193
+ "doc_animation": {
194
+ "output": "docs/assets/viewport-mixed-segments.gif",
195
+ "interval_ms": 1,
196
+ "dither": False,
197
+ "segments": [
198
+ {"interactions": [{"type": "set_viewport", "width": 8, "height": 6}], "frames": 1},
199
+ {"interactions": [{"type": "set_viewport", "width": 5, "height": 4}], "frames": 1},
200
+ {"interactions": [{"type": "set_viewport", "width": 3, "height": 2}], "frames": 1},
201
+ ],
202
+ }
203
+ }
204
+
205
+ sr.capture_doc_animation(page, scenario)
206
+
207
+ output = tmp_path / "docs/assets/viewport-mixed-segments.gif"
208
+ frames = _read_gif_frames(output)
209
+ assert [f.size for f in frames] == [(8, 6), (8, 6), (8, 6)]
210
+ # (7,5) is outside both smaller later frames and must be white.
211
+ assert frames[1].getpixel((7, 5)) == (255, 255, 255)
212
+ assert frames[2].getpixel((7, 5)) == (255, 255, 255)
213
+
214
+ def test_raises_when_no_frames_captured(self, tmp_path, monkeypatch):
215
+ page = _make_page()
216
+ monkeypatch.setattr(sr, "REPO_ROOT", tmp_path)
217
+ monkeypatch.setenv("DOC_IMAGE_UPDATE", "1")
218
+
219
+ scenario = {
220
+ "doc_animation": {
221
+ "output": "docs/assets/no-frames.gif",
222
+ "frames": 0,
223
+ }
224
+ }
225
+
226
+ with pytest.raises(AssertionError, match="captured 0 frames"):
227
+ sr.capture_doc_animation(page, scenario)
@@ -1,84 +0,0 @@
1
- """Unit tests for scenario_runner interaction types — no Docker or browser required.
2
-
3
- Covers interaction dispatch logic using mocks so that a running Playwright
4
- browser is not needed.
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from unittest.mock import MagicMock, call
10
-
11
- import pytest
12
-
13
- import ha_testcontainer.visual.scenario_runner as sr
14
-
15
-
16
- # ---------------------------------------------------------------------------
17
- # Helpers
18
- # ---------------------------------------------------------------------------
19
-
20
-
21
- def _make_page() -> MagicMock:
22
- """Return a lightweight mock that records Playwright Page calls."""
23
- return MagicMock()
24
-
25
-
26
- # ---------------------------------------------------------------------------
27
- # set_viewport interaction
28
- # ---------------------------------------------------------------------------
29
-
30
-
31
- class TestSetViewportInteraction:
32
- """run_interactions dispatches set_viewport correctly."""
33
-
34
- def test_set_viewport_calls_set_viewport_size(self):
35
- page = _make_page()
36
- scenario = {
37
- "interactions": [
38
- {"type": "set_viewport", "width": 375, "height": 812},
39
- ]
40
- }
41
- sr.run_interactions(page, scenario)
42
- page.set_viewport_size.assert_called_once_with({"width": 375, "height": 812})
43
-
44
- def test_set_viewport_no_settle_ms_by_default(self):
45
- page = _make_page()
46
- scenario = {
47
- "interactions": [
48
- {"type": "set_viewport", "width": 1280, "height": 800},
49
- ]
50
- }
51
- sr.run_interactions(page, scenario)
52
- page.wait_for_timeout.assert_not_called()
53
-
54
- def test_set_viewport_settle_ms_waits(self):
55
- page = _make_page()
56
- scenario = {
57
- "interactions": [
58
- {"type": "set_viewport", "width": 768, "height": 1024, "settle_ms": 300},
59
- ]
60
- }
61
- sr.run_interactions(page, scenario)
62
- page.set_viewport_size.assert_called_once_with({"width": 768, "height": 1024})
63
- page.wait_for_timeout.assert_called_once_with(300)
64
-
65
- def test_set_viewport_coerces_string_dimensions(self):
66
- """width/height may arrive as strings from YAML and must be coerced to int."""
67
- page = _make_page()
68
- scenario = {
69
- "interactions": [
70
- {"type": "set_viewport", "width": "390", "height": "844"},
71
- ]
72
- }
73
- sr.run_interactions(page, scenario)
74
- page.set_viewport_size.assert_called_once_with({"width": 390, "height": 844})
75
-
76
- def test_unknown_type_raises(self):
77
- page = _make_page()
78
- scenario = {
79
- "interactions": [
80
- {"type": "not_a_real_type"},
81
- ]
82
- }
83
- with pytest.raises(ValueError, match="Unknown interaction type"):
84
- sr.run_interactions(page, scenario)