trianglengin 1.0.6__py3-none-any.whl

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 (72) hide show
  1. tests/__init__.py +0 -0
  2. tests/conftest.py +108 -0
  3. tests/core/__init__.py +2 -0
  4. tests/core/environment/README.md +47 -0
  5. tests/core/environment/__init__.py +2 -0
  6. tests/core/environment/test_action_codec.py +50 -0
  7. tests/core/environment/test_game_state.py +483 -0
  8. tests/core/environment/test_grid_data.py +205 -0
  9. tests/core/environment/test_grid_logic.py +362 -0
  10. tests/core/environment/test_shape_logic.py +171 -0
  11. tests/core/environment/test_step.py +372 -0
  12. tests/core/structs/__init__.py +0 -0
  13. tests/core/structs/test_shape.py +83 -0
  14. tests/core/structs/test_triangle.py +97 -0
  15. tests/utils/__init__.py +0 -0
  16. tests/utils/test_geometry.py +93 -0
  17. trianglengin/__init__.py +18 -0
  18. trianglengin/app.py +110 -0
  19. trianglengin/cli.py +134 -0
  20. trianglengin/config/__init__.py +9 -0
  21. trianglengin/config/display_config.py +47 -0
  22. trianglengin/config/env_config.py +103 -0
  23. trianglengin/core/__init__.py +8 -0
  24. trianglengin/core/environment/__init__.py +31 -0
  25. trianglengin/core/environment/action_codec.py +37 -0
  26. trianglengin/core/environment/game_state.py +217 -0
  27. trianglengin/core/environment/grid/README.md +46 -0
  28. trianglengin/core/environment/grid/__init__.py +18 -0
  29. trianglengin/core/environment/grid/grid_data.py +140 -0
  30. trianglengin/core/environment/grid/line_cache.py +189 -0
  31. trianglengin/core/environment/grid/logic.py +131 -0
  32. trianglengin/core/environment/logic/__init__.py +3 -0
  33. trianglengin/core/environment/logic/actions.py +38 -0
  34. trianglengin/core/environment/logic/step.py +134 -0
  35. trianglengin/core/environment/shapes/__init__.py +19 -0
  36. trianglengin/core/environment/shapes/logic.py +84 -0
  37. trianglengin/core/environment/shapes/templates.py +587 -0
  38. trianglengin/core/structs/__init__.py +27 -0
  39. trianglengin/core/structs/constants.py +28 -0
  40. trianglengin/core/structs/shape.py +61 -0
  41. trianglengin/core/structs/triangle.py +48 -0
  42. trianglengin/interaction/README.md +45 -0
  43. trianglengin/interaction/__init__.py +17 -0
  44. trianglengin/interaction/debug_mode_handler.py +96 -0
  45. trianglengin/interaction/event_processor.py +43 -0
  46. trianglengin/interaction/input_handler.py +82 -0
  47. trianglengin/interaction/play_mode_handler.py +141 -0
  48. trianglengin/utils/__init__.py +9 -0
  49. trianglengin/utils/geometry.py +73 -0
  50. trianglengin/utils/types.py +10 -0
  51. trianglengin/visualization/README.md +44 -0
  52. trianglengin/visualization/__init__.py +61 -0
  53. trianglengin/visualization/core/README.md +52 -0
  54. trianglengin/visualization/core/__init__.py +12 -0
  55. trianglengin/visualization/core/colors.py +117 -0
  56. trianglengin/visualization/core/coord_mapper.py +73 -0
  57. trianglengin/visualization/core/fonts.py +55 -0
  58. trianglengin/visualization/core/layout.py +101 -0
  59. trianglengin/visualization/core/visualizer.py +232 -0
  60. trianglengin/visualization/drawing/README.md +45 -0
  61. trianglengin/visualization/drawing/__init__.py +30 -0
  62. trianglengin/visualization/drawing/grid.py +156 -0
  63. trianglengin/visualization/drawing/highlight.py +30 -0
  64. trianglengin/visualization/drawing/hud.py +39 -0
  65. trianglengin/visualization/drawing/previews.py +172 -0
  66. trianglengin/visualization/drawing/shapes.py +36 -0
  67. trianglengin-1.0.6.dist-info/METADATA +367 -0
  68. trianglengin-1.0.6.dist-info/RECORD +72 -0
  69. trianglengin-1.0.6.dist-info/WHEEL +5 -0
  70. trianglengin-1.0.6.dist-info/entry_points.txt +2 -0
  71. trianglengin-1.0.6.dist-info/licenses/LICENSE +22 -0
  72. trianglengin-1.0.6.dist-info/top_level.txt +2 -0
@@ -0,0 +1,362 @@
1
+ # File: tests/core/environment/test_grid_logic.py
2
+ import logging
3
+
4
+ import pytest
5
+
6
+ from trianglengin.config import EnvConfig
7
+ from trianglengin.core.environment.grid import GridData
8
+ from trianglengin.core.environment.grid import logic as GridLogic
9
+ from trianglengin.core.structs import Shape
10
+
11
+ # Default color for fixture shapes
12
+ DEFAULT_TEST_COLOR = (100, 100, 100)
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ @pytest.fixture
17
+ def default_config() -> EnvConfig:
18
+ """Fixture for default environment configuration."""
19
+ return EnvConfig()
20
+
21
+
22
+ @pytest.fixture
23
+ def default_grid(default_config: EnvConfig) -> GridData:
24
+ """Fixture for a default GridData instance."""
25
+ # Ensure the cache is populated for the default config before tests run
26
+ # This will now use the fixed _compute_lines_and_map_v4
27
+ GridData(default_config)
28
+ # Return a fresh instance for the test, which will reuse the cache
29
+ return GridData(default_config)
30
+
31
+
32
+ def occupy_coords(grid_data: GridData, coords: set[tuple[int, int]]):
33
+ """Helper to occupy specific coordinates."""
34
+ for r, c in coords:
35
+ if grid_data.valid(r, c) and not grid_data.is_death(r, c):
36
+ grid_data._occupied_np[r, c] = True
37
+ # Assign a dummy color ID
38
+ grid_data._color_id_np[r, c] = 0
39
+
40
+
41
+ # --- Basic Placement Tests ---
42
+ # (Keep existing basic placement tests - they should still pass)
43
+
44
+
45
+ def test_can_place_basic(default_grid: GridData):
46
+ """Test basic placement in empty grid."""
47
+ shape = Shape([(0, 0, False), (0, 1, True)], DEFAULT_TEST_COLOR) # D, U
48
+ start_r, start_c = -1, -1
49
+ for r in range(default_grid.rows):
50
+ for c in range(default_grid.cols):
51
+ if not default_grid.is_death(r, c) and (r + c) % 2 == 0: # Find Down cell
52
+ start_r, start_c = r, c
53
+ break
54
+ if start_r != -1:
55
+ break
56
+ if start_r == -1:
57
+ pytest.skip("Could not find a valid starting Down cell.")
58
+ assert GridLogic.can_place(default_grid, shape, start_r, start_c)
59
+
60
+
61
+ def test_can_place_occupied(default_grid: GridData):
62
+ """Test placement fails if target is occupied."""
63
+ shape = Shape([(0, 0, False)], DEFAULT_TEST_COLOR) # Single Down
64
+ start_r, start_c = -1, -1
65
+ for r in range(default_grid.rows):
66
+ for c in range(default_grid.cols):
67
+ if not default_grid.is_death(r, c) and (r + c) % 2 == 0: # Find Down cell
68
+ start_r, start_c = r, c
69
+ break
70
+ if start_r != -1:
71
+ break
72
+ if start_r == -1:
73
+ pytest.skip("Could not find a valid starting Down cell.")
74
+ default_grid._occupied_np[start_r, start_c] = True
75
+ assert not GridLogic.can_place(default_grid, shape, start_r, start_c)
76
+
77
+
78
+ def test_can_place_death_zone(default_grid: GridData):
79
+ """Test placement fails if target is in death zone."""
80
+ Shape([(0, 0, False)], DEFAULT_TEST_COLOR) # Single Down
81
+ death_r, death_c = -1, -1
82
+ for r in range(default_grid.rows):
83
+ for c in range(default_grid.cols):
84
+ if default_grid.is_death(r, c):
85
+ death_r, death_c = r, c
86
+ break
87
+ if death_r != -1:
88
+ break
89
+ if death_r == -1:
90
+ pytest.skip("Could not find a death zone cell.")
91
+ shape_to_use = Shape([(0, 0, (death_r + death_c) % 2 != 0)], DEFAULT_TEST_COLOR)
92
+ assert not GridLogic.can_place(default_grid, shape_to_use, death_r, death_c)
93
+
94
+
95
+ def test_can_place_orientation_mismatch(default_grid: GridData):
96
+ """Test placement fails if shape orientation doesn't match grid."""
97
+ shape = Shape([(0, 0, True)], DEFAULT_TEST_COLOR) # Needs Up
98
+ start_r, start_c = -1, -1
99
+ for r in range(default_grid.rows):
100
+ for c in range(default_grid.cols):
101
+ if not default_grid.is_death(r, c) and (r + c) % 2 == 0: # Find Down cell
102
+ start_r, start_c = r, c
103
+ break
104
+ if start_r != -1:
105
+ break
106
+ if start_r == -1:
107
+ pytest.skip("Could not find a valid starting Down cell.")
108
+ assert not GridLogic.can_place(default_grid, shape, start_r, start_c)
109
+
110
+ shape_down = Shape([(0, 0, False)], DEFAULT_TEST_COLOR) # Needs Down
111
+ up_r, up_c = -1, -1
112
+ for r in range(default_grid.rows):
113
+ for c in range(default_grid.cols):
114
+ if not default_grid.is_death(r, c) and (r + c) % 2 != 0: # Find Up cell
115
+ up_r, up_c = r, c
116
+ break
117
+ if up_r != -1:
118
+ break
119
+ if up_r == -1:
120
+ pytest.skip("Could not find a valid starting Up cell.")
121
+ assert not GridLogic.can_place(default_grid, shape_down, up_r, up_c)
122
+
123
+
124
+ def test_can_place_out_of_bounds(default_grid: GridData):
125
+ """Test placement fails if shape goes out of bounds."""
126
+ shape = Shape([(0, 0, False), (0, 1, True)], DEFAULT_TEST_COLOR)
127
+ assert not GridLogic.can_place(default_grid, shape, 0, default_grid.cols - 1)
128
+ shape_tall = Shape([(0, 0, False), (1, 0, False)], DEFAULT_TEST_COLOR)
129
+ assert not GridLogic.can_place(default_grid, shape_tall, default_grid.rows - 1, 0)
130
+
131
+
132
+ # --- Line Clearing Tests ---
133
+
134
+
135
+ def test_check_and_clear_lines_simple(default_grid: GridData):
136
+ """Test clearing a single horizontal line."""
137
+ target_line = None
138
+ for line in default_grid._lines:
139
+ # Find a reasonably long horizontal line for a good test
140
+ if len(line) > 4 and all(c[0] == line[0][0] for c in line):
141
+ target_line = line
142
+ break
143
+ if not target_line:
144
+ pytest.skip("Could not find a suitable horizontal line.")
145
+ target_line_set = set(target_line)
146
+ occupy_coords(default_grid, target_line_set)
147
+ last_coord = target_line[-1]
148
+ lines_cleared, coords_cleared, cleared_line_sets = GridLogic.check_and_clear_lines(
149
+ default_grid, {last_coord}
150
+ )
151
+ assert lines_cleared == 1, f"Expected 1 line clear, got {lines_cleared}"
152
+ assert coords_cleared == target_line_set, "Cleared coordinates mismatch"
153
+ assert cleared_line_sets == {frozenset(target_line)}, "Cleared line set mismatch"
154
+ for r, c in target_line_set:
155
+ assert not default_grid._occupied_np[r, c], f"Cell ({r},{c}) was not cleared"
156
+ assert default_grid._color_id_np[r, c] == -1, (
157
+ f"Color ID for ({r},{c}) not reset"
158
+ )
159
+
160
+
161
+ def test_check_and_clear_lines_no_clear(default_grid: GridData):
162
+ """Test that no lines are cleared if none are complete."""
163
+ target_line = None
164
+ for line in default_grid._lines:
165
+ # Find a reasonably long line
166
+ if len(line) > 4:
167
+ target_line = line
168
+ break
169
+ if not target_line:
170
+ pytest.skip("Could not find a suitable line.")
171
+ # Occupy all but the last coordinate
172
+ coords_to_occupy = set(target_line[:-1])
173
+ occupy_coords(default_grid, coords_to_occupy)
174
+ # Check clear using the last occupied coordinate
175
+ last_occupied_coord = target_line[-2] # The one before the empty one
176
+ lines_cleared, coords_cleared, cleared_line_sets = GridLogic.check_and_clear_lines(
177
+ default_grid, {last_occupied_coord}
178
+ )
179
+ assert lines_cleared == 0, f"Expected 0 lines cleared, got {lines_cleared}"
180
+ assert not coords_cleared, "Coords should not be cleared"
181
+ assert not cleared_line_sets, "Cleared line sets should be empty"
182
+ # Verify grid cells remain occupied
183
+ for r, c in coords_to_occupy:
184
+ assert default_grid._occupied_np[r, c], (
185
+ f"Cell ({r},{c}) should still be occupied"
186
+ )
187
+
188
+
189
+ # --- Specific Boundary Line Clearing Scenarios ---
190
+
191
+ # Define the boundary lines explicitly based on default EnvConfig
192
+ # IMPORTANT: Use the natural traversal order expected from line_cache v4
193
+ BOUNDARY_LINES = {
194
+ "top_left_diag": (
195
+ (4, 0),
196
+ (3, 0),
197
+ (3, 1),
198
+ (2, 1),
199
+ (2, 2),
200
+ (1, 2),
201
+ (1, 3),
202
+ (0, 3),
203
+ (0, 4),
204
+ ),
205
+ "top_horiz": tuple((0, c) for c in range(3, 12)),
206
+ "top_right_diag": (
207
+ (0, 10),
208
+ (0, 11),
209
+ (1, 11),
210
+ (1, 12),
211
+ (2, 12),
212
+ (2, 13),
213
+ (3, 13),
214
+ (3, 14),
215
+ (4, 14),
216
+ ),
217
+ "bottom_right_diag": (
218
+ (3, 14),
219
+ (4, 14),
220
+ (4, 13),
221
+ (5, 13),
222
+ (5, 12),
223
+ (6, 12),
224
+ (6, 11),
225
+ (7, 11),
226
+ (7, 10),
227
+ ),
228
+ "bottom_horiz": tuple((7, c) for c in range(3, 12)),
229
+ "bottom_left_diag": (
230
+ (3, 0),
231
+ (4, 0),
232
+ (4, 1),
233
+ (5, 1),
234
+ (5, 2),
235
+ (6, 2),
236
+ (6, 3),
237
+ (7, 3),
238
+ (7, 4),
239
+ ),
240
+ }
241
+
242
+
243
+ @pytest.mark.parametrize("line_name, line_coords", BOUNDARY_LINES.items())
244
+ def test_boundary_line_clears_only_when_full(
245
+ line_name: str, line_coords: tuple[tuple[int, int], ...], default_grid: GridData
246
+ ):
247
+ """
248
+ Tests that boundary lines clear only when the final middle piece(s) are placed,
249
+ simulating placing pieces from the outside inwards.
250
+ """
251
+ grid_data = default_grid
252
+ grid_data.reset() # Start with a clean grid for each line test
253
+ n = len(line_coords)
254
+ line_set = set(line_coords)
255
+ line_fs = frozenset(line_coords)
256
+ log.info(f"Testing line '{line_name}' (len={n}): {line_coords}")
257
+
258
+ # Verify line exists in cache first (essential prerequisite)
259
+ first_coord = line_coords[0]
260
+ assert first_coord in grid_data._coord_to_lines_map, (
261
+ f"Coord {first_coord} not in map for line '{line_name}'"
262
+ )
263
+ found_mapping = False
264
+ if first_coord in grid_data._coord_to_lines_map:
265
+ for mapped_fs in grid_data._coord_to_lines_map[first_coord]:
266
+ if mapped_fs == line_fs:
267
+ found_mapping = True
268
+ break
269
+ assert found_mapping, (
270
+ f"Line {line_fs} not mapped to {first_coord} for line '{line_name}'"
271
+ )
272
+
273
+ # --- Simulate Inward Placement ---
274
+ placed_coords = set()
275
+ final_clear_occurred = False
276
+ for i in range(n // 2 + (n % 2)): # Iterate inwards (e.g., 0, 1, 2, 3, 4 for n=9)
277
+ coord1 = line_coords[i]
278
+ coord2 = line_coords[n - 1 - i]
279
+ is_last_pair_or_middle = i == (n // 2 + (n % 2) - 1)
280
+ log.debug(
281
+ f" Step {i}: Considering pair {coord1} / {coord2}. Is last: {is_last_pair_or_middle}"
282
+ )
283
+
284
+ # --- Place first cell of the pair ---
285
+ if coord1 not in placed_coords:
286
+ log.debug(f" Placing {coord1}...")
287
+ grid_data._occupied_np[coord1[0], coord1[1]] = True
288
+ grid_data._color_id_np[coord1[0], coord1[1]] = 0
289
+ placed_coords.add(coord1)
290
+ lines_cleared_1, coords_cleared_1, cleared_sets_1 = (
291
+ GridLogic.check_and_clear_lines(grid_data, {coord1})
292
+ )
293
+
294
+ should_clear_now = len(placed_coords) == n
295
+ if should_clear_now:
296
+ log.debug(f" Checking FINAL clear after placing {coord1}")
297
+ assert lines_cleared_1 == 1, (
298
+ f"Line '{line_name}' should clear after placing final piece {coord1}, but did not."
299
+ )
300
+ assert coords_cleared_1 == line_set, (
301
+ f"Cleared coords mismatch for '{line_name}' after final piece {coord1}."
302
+ )
303
+ assert cleared_sets_1 == {line_fs}, (
304
+ f"Cleared line set mismatch for '{line_name}' after final piece {coord1}."
305
+ )
306
+ final_clear_occurred = True
307
+ else:
308
+ log.debug(f" Checking NO clear after placing {coord1}")
309
+ assert lines_cleared_1 == 0, (
310
+ f"Line '{line_name}' cleared prematurely after placing {coord1} (step {i}, total placed: {len(placed_coords)}/{n})"
311
+ )
312
+ assert not coords_cleared_1, (
313
+ f"Coords cleared prematurely for '{line_name}' after {coord1}"
314
+ )
315
+ assert grid_data._occupied_np[coord1[0], coord1[1]], (
316
+ f"{coord1} should still be occupied after non-clearing step"
317
+ )
318
+
319
+ # --- Place second cell of the pair (if different from first) ---
320
+ if coord1 != coord2 and coord2 not in placed_coords:
321
+ log.debug(f" Placing {coord2}...")
322
+ grid_data._occupied_np[coord2[0], coord2[1]] = True
323
+ grid_data._color_id_np[coord2[0], coord2[1]] = 0
324
+ placed_coords.add(coord2)
325
+ lines_cleared_2, coords_cleared_2, cleared_sets_2 = (
326
+ GridLogic.check_and_clear_lines(grid_data, {coord2})
327
+ )
328
+
329
+ should_clear_now = len(placed_coords) == n
330
+ if should_clear_now:
331
+ log.debug(f" Checking FINAL clear after placing {coord2}")
332
+ assert lines_cleared_2 == 1, (
333
+ f"Line '{line_name}' should clear after placing final piece {coord2}, but did not."
334
+ )
335
+ assert coords_cleared_2 == line_set, (
336
+ f"Cleared coords mismatch for '{line_name}' after final piece {coord2}."
337
+ )
338
+ assert cleared_sets_2 == {line_fs}, (
339
+ f"Cleared line set mismatch for '{line_name}' after final piece {coord2}."
340
+ )
341
+ final_clear_occurred = True
342
+ else:
343
+ log.debug(f" Checking NO clear after placing {coord2}")
344
+ assert lines_cleared_2 == 0, (
345
+ f"Line '{line_name}' cleared prematurely after placing {coord2} (step {i}, total placed: {len(placed_coords)}/{n})"
346
+ )
347
+ assert not coords_cleared_2, (
348
+ f"Coords cleared prematurely for '{line_name}' after {coord2}"
349
+ )
350
+ assert grid_data._occupied_np[coord2[0], coord2[1]], (
351
+ f"{coord2} should still be occupied after non-clearing step"
352
+ )
353
+
354
+ # --- Final Verification ---
355
+ assert final_clear_occurred, (
356
+ f"The final clear event did not happen for line '{line_name}' after placing all {n} pieces."
357
+ )
358
+ # Check that all cells in the line are indeed clear *after* the loop finishes
359
+ for r_clr, c_clr in line_set:
360
+ assert not grid_data._occupied_np[r_clr, c_clr], (
361
+ f"Cell ({r_clr},{c_clr}) was not cleared for line '{line_name}' after test completion"
362
+ )
@@ -0,0 +1,171 @@
1
+ # File: trianglengin/tests/core/environment/test_shape_logic.py
2
+ import random
3
+
4
+ import pytest
5
+
6
+ # Import directly from the library being tested
7
+ from trianglengin.core.environment import GameState
8
+ from trianglengin.core.environment.shapes import logic as ShapeLogic
9
+ from trianglengin.core.structs import Shape
10
+
11
+ # Use fixtures from the local conftest.py
12
+ # Fixtures are implicitly injected by pytest
13
+
14
+
15
+ def test_generate_random_shape(fixed_rng: random.Random):
16
+ """Test generating a single random shape."""
17
+ shape = ShapeLogic.generate_random_shape(fixed_rng)
18
+ assert isinstance(shape, Shape)
19
+ assert shape.triangles is not None
20
+ assert shape.color is not None
21
+ assert len(shape.triangles) > 0
22
+ # Check connectivity (optional but good)
23
+ assert ShapeLogic.is_shape_connected(shape)
24
+
25
+
26
+ def test_generate_multiple_shapes(fixed_rng: random.Random):
27
+ """Test generating multiple shapes to ensure variety (or lack thereof with fixed seed)."""
28
+ shape1 = ShapeLogic.generate_random_shape(fixed_rng)
29
+ # Re-seed or use different rng instance if true randomness is needed per call
30
+ # For this test, using the same fixed_rng will likely produce the same shape again
31
+ shape2 = ShapeLogic.generate_random_shape(fixed_rng)
32
+ # --- REMOVED INCORRECT ASSERTION ---
33
+ # assert shape1 == shape2 # Expect same shape due to fixed seed - THIS IS INCORRECT
34
+ # --- END REMOVED ---
35
+ # Check that subsequent calls produce different results with the same RNG instance
36
+ assert shape1 != shape2, (
37
+ "Two consecutive calls with the same RNG produced the exact same shape (template and color), which is highly unlikely."
38
+ )
39
+
40
+ # Use a different seed for variation
41
+ rng2 = random.Random(54321)
42
+ shape3 = ShapeLogic.generate_random_shape(rng2)
43
+ # Check that different RNGs produce different results (highly likely)
44
+ assert shape1 != shape3 or shape1.color != shape3.color
45
+
46
+
47
+ def test_refill_shape_slots_empty(game_state: GameState, fixed_rng: random.Random):
48
+ """Test refilling when all slots are initially empty."""
49
+ game_state.shapes = [None] * game_state.env_config.NUM_SHAPE_SLOTS
50
+ ShapeLogic.refill_shape_slots(game_state, fixed_rng)
51
+ assert all(s is not None for s in game_state.shapes)
52
+ assert len(game_state.shapes) == game_state.env_config.NUM_SHAPE_SLOTS
53
+
54
+
55
+ def test_refill_shape_slots_partial(game_state: GameState, fixed_rng: random.Random):
56
+ """Test refilling when some slots are empty - SHOULD NOT REFILL."""
57
+ num_slots = game_state.env_config.NUM_SHAPE_SLOTS
58
+ if num_slots < 2:
59
+ pytest.skip("Test requires at least 2 shape slots")
60
+
61
+ # Start with full slots
62
+ ShapeLogic.refill_shape_slots(game_state, fixed_rng)
63
+ assert all(s is not None for s in game_state.shapes)
64
+
65
+ # Empty one slot
66
+ game_state.shapes[0] = None
67
+ # Store original state (important: copy shapes if they are mutable)
68
+ original_shapes = [s.copy() if s else None for s in game_state.shapes]
69
+
70
+ # Attempt refill - it should do nothing
71
+ ShapeLogic.refill_shape_slots(game_state, fixed_rng)
72
+
73
+ # Check that shapes remain unchanged
74
+ assert game_state.shapes == original_shapes, "Refill happened unexpectedly"
75
+
76
+
77
+ def test_refill_shape_slots_full(game_state: GameState, fixed_rng: random.Random):
78
+ """Test refilling when all slots are already full - SHOULD NOT REFILL."""
79
+ # Start with full slots
80
+ ShapeLogic.refill_shape_slots(game_state, fixed_rng)
81
+ assert all(s is not None for s in game_state.shapes)
82
+ original_shapes = [s.copy() if s else None for s in game_state.shapes]
83
+
84
+ # Attempt refill - should do nothing
85
+ ShapeLogic.refill_shape_slots(game_state, fixed_rng)
86
+
87
+ # Check shapes are unchanged
88
+ assert game_state.shapes == original_shapes, "Refill happened when slots were full"
89
+
90
+
91
+ def test_refill_shape_slots_batch_trigger(game_state: GameState) -> None:
92
+ """Test that refill only happens when ALL slots are empty."""
93
+ num_slots = game_state.env_config.NUM_SHAPE_SLOTS
94
+ if num_slots < 2:
95
+ pytest.skip("Test requires at least 2 shape slots")
96
+
97
+ # Fill all slots initially
98
+ ShapeLogic.refill_shape_slots(game_state, game_state._rng)
99
+ initial_shapes = [s.copy() if s else None for s in game_state.shapes]
100
+ assert all(s is not None for s in initial_shapes)
101
+
102
+ # Empty one slot - refill should NOT happen
103
+ game_state.shapes[0] = None
104
+ shapes_after_one_empty = [s.copy() if s else None for s in game_state.shapes]
105
+ ShapeLogic.refill_shape_slots(game_state, game_state._rng)
106
+ assert game_state.shapes == shapes_after_one_empty, (
107
+ "Refill happened when only one slot was empty"
108
+ )
109
+
110
+ # Empty all slots - refill SHOULD happen
111
+ game_state.shapes = [None] * num_slots
112
+ ShapeLogic.refill_shape_slots(game_state, game_state._rng)
113
+ assert all(s is not None for s in game_state.shapes), (
114
+ "Refill did not happen when all slots were empty"
115
+ )
116
+ # Check that the shapes are different from the initial ones (probabilistically)
117
+ assert game_state.shapes != initial_shapes, (
118
+ "Shapes after refill are identical to initial shapes (unlikely)"
119
+ )
120
+
121
+
122
+ # --- ADDED TESTS ---
123
+ def test_get_neighbors():
124
+ """Test neighbor calculation for up and down triangles."""
125
+ # Up triangle at (1, 1)
126
+ up_neighbors = ShapeLogic.get_neighbors(r=1, c=1, is_up=True)
127
+ # Expected: Left (1,0), Right (1,2), Vertical (Down) (2,1)
128
+ assert set(up_neighbors) == {(1, 0), (1, 2), (2, 1)}
129
+
130
+ # Down triangle at (1, 2)
131
+ down_neighbors = ShapeLogic.get_neighbors(r=1, c=2, is_up=False)
132
+ # Expected: Left (1,1), Right (1,3), Vertical (Up) (0,2)
133
+ assert set(down_neighbors) == {(1, 1), (1, 3), (0, 2)}
134
+
135
+
136
+ def test_is_shape_connected_true(simple_shape: Shape): # Use fixture
137
+ """Test connectivity for various connected shapes."""
138
+ # Single triangle
139
+ shape1 = Shape([(0, 0, True)], (1, 1, 1))
140
+ assert ShapeLogic.is_shape_connected(shape1)
141
+
142
+ # Domino (horizontal) - Down(0,0) connects to Up(0,1)
143
+ shape2 = Shape([(0, 0, False), (0, 1, True)], (1, 1, 1))
144
+ assert ShapeLogic.is_shape_connected(shape2)
145
+
146
+ # L-shape (from simple_shape fixture) - Down(0,0) connects Up(1,0), Up(1,0) connects Down(1,1)
147
+ assert ShapeLogic.is_shape_connected(simple_shape) # Test the fixture directly
148
+
149
+ # Empty shape
150
+ shape4 = Shape([], (1, 1, 1))
151
+ assert ShapeLogic.is_shape_connected(shape4)
152
+
153
+ # More complex connected shape
154
+ shape5 = Shape(
155
+ [(0, 0, False), (0, 1, True), (1, 1, False), (1, 0, True)], (1, 1, 1)
156
+ )
157
+ assert ShapeLogic.is_shape_connected(shape5)
158
+
159
+
160
+ def test_is_shape_connected_false():
161
+ """Test connectivity for disconnected shapes."""
162
+ # Two separate triangles
163
+ shape1 = Shape([(0, 0, True), (2, 2, False)], (1, 1, 1))
164
+ assert not ShapeLogic.is_shape_connected(shape1)
165
+
166
+ # Three triangles, two connected, one separate
167
+ shape2 = Shape([(0, 0, False), (0, 1, True), (3, 3, True)], (1, 1, 1))
168
+ assert not ShapeLogic.is_shape_connected(shape2)
169
+
170
+
171
+ # --- END ADDED TESTS ---