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,372 @@
1
+ # File: tests/core/environment/test_step.py
2
+
3
+ import pytest
4
+
5
+ # Import mocker fixture from pytest-mock
6
+ from pytest_mock import MockerFixture
7
+
8
+ # Import directly from the library being tested
9
+ from trianglengin.config import EnvConfig
10
+ from trianglengin.core.environment import GameState
11
+ from trianglengin.core.environment.grid import GridData
12
+ from trianglengin.core.environment.grid import logic as GridLogic
13
+ from trianglengin.core.environment.logic.step import calculate_reward, execute_placement
14
+ from trianglengin.core.structs import Shape
15
+
16
+ # Use fixtures from the local conftest.py
17
+ # Fixtures are implicitly injected by pytest
18
+
19
+
20
+ # Removed deprecated occupy_line function
21
+
22
+
23
+ def occupy_coords(grid_data: GridData, coords: set[tuple[int, int]]):
24
+ """Helper to occupy specific coordinates."""
25
+ for r, c in coords:
26
+ if grid_data.valid(r, c) and not grid_data.is_death(r, c):
27
+ grid_data._occupied_np[r, c] = True
28
+
29
+
30
+ # --- New Reward Calculation Tests (v3) ---
31
+
32
+
33
+ def test_calculate_reward_v3_placement_only(
34
+ simple_shape: Shape, default_env_config: EnvConfig
35
+ ):
36
+ """Test reward: only placement, game not over."""
37
+ placed_count = len(simple_shape.triangles)
38
+ unique_coords_cleared: set[tuple[int, int]] = set()
39
+ is_game_over = False
40
+ reward = calculate_reward(
41
+ placed_count, len(unique_coords_cleared), is_game_over, default_env_config
42
+ )
43
+ expected_reward = (
44
+ placed_count * default_env_config.REWARD_PER_PLACED_TRIANGLE
45
+ + default_env_config.REWARD_PER_STEP_ALIVE
46
+ )
47
+ assert reward == pytest.approx(expected_reward)
48
+
49
+
50
+ def test_calculate_reward_v3_single_line_clear(
51
+ simple_shape: Shape, default_env_config: EnvConfig
52
+ ):
53
+ """Test reward: placement + line clear, game not over."""
54
+ placed_count = len(simple_shape.triangles)
55
+ unique_coords_cleared: set[tuple[int, int]] = {(0, i) for i in range(9)}
56
+ is_game_over = False
57
+ reward = calculate_reward(
58
+ placed_count, len(unique_coords_cleared), is_game_over, default_env_config
59
+ )
60
+ expected_reward = (
61
+ placed_count * default_env_config.REWARD_PER_PLACED_TRIANGLE
62
+ + len(unique_coords_cleared) * default_env_config.REWARD_PER_CLEARED_TRIANGLE
63
+ + default_env_config.REWARD_PER_STEP_ALIVE
64
+ )
65
+ assert reward == pytest.approx(expected_reward)
66
+
67
+
68
+ def test_calculate_reward_v3_multi_line_clear(
69
+ simple_shape: Shape, default_env_config: EnvConfig
70
+ ):
71
+ """Test reward: placement + multi-line clear (overlapping coords), game not over."""
72
+ placed_count = len(simple_shape.triangles)
73
+ line1_coords = {(0, i) for i in range(9)}
74
+ line2_coords = {(i, 0) for i in range(5)}
75
+ unique_coords_cleared = line1_coords.union(line2_coords)
76
+ is_game_over = False
77
+ reward = calculate_reward(
78
+ placed_count, len(unique_coords_cleared), is_game_over, default_env_config
79
+ )
80
+ expected_reward = (
81
+ placed_count * default_env_config.REWARD_PER_PLACED_TRIANGLE
82
+ + len(unique_coords_cleared) * default_env_config.REWARD_PER_CLEARED_TRIANGLE
83
+ + default_env_config.REWARD_PER_STEP_ALIVE
84
+ )
85
+ assert reward == pytest.approx(expected_reward)
86
+
87
+
88
+ def test_calculate_reward_v3_game_over(
89
+ simple_shape: Shape, default_env_config: EnvConfig
90
+ ):
91
+ """Test reward: placement, no line clear, game IS over."""
92
+ placed_count = len(simple_shape.triangles)
93
+ unique_coords_cleared: set[tuple[int, int]] = set()
94
+ is_game_over = True
95
+ reward = calculate_reward(
96
+ placed_count, len(unique_coords_cleared), is_game_over, default_env_config
97
+ )
98
+ expected_reward = (
99
+ placed_count * default_env_config.REWARD_PER_PLACED_TRIANGLE
100
+ + default_env_config.PENALTY_GAME_OVER
101
+ )
102
+ assert reward == pytest.approx(expected_reward)
103
+
104
+
105
+ def test_calculate_reward_v3_game_over_with_clear(
106
+ simple_shape: Shape, default_env_config: EnvConfig
107
+ ):
108
+ """Test reward: placement + line clear, game IS over."""
109
+ placed_count = len(simple_shape.triangles)
110
+ unique_coords_cleared: set[tuple[int, int]] = {(0, i) for i in range(9)}
111
+ is_game_over = True
112
+ reward = calculate_reward(
113
+ placed_count, len(unique_coords_cleared), is_game_over, default_env_config
114
+ )
115
+ expected_reward = (
116
+ placed_count * default_env_config.REWARD_PER_PLACED_TRIANGLE
117
+ + len(unique_coords_cleared) * default_env_config.REWARD_PER_CLEARED_TRIANGLE
118
+ + default_env_config.PENALTY_GAME_OVER
119
+ )
120
+ assert reward == pytest.approx(expected_reward)
121
+
122
+
123
+ # --- Test execute_placement ---
124
+
125
+
126
+ def test_execute_placement_simple_no_refill_v3(
127
+ game_state: GameState,
128
+ ):
129
+ """Test placing a shape without clearing lines, verify stats."""
130
+ gs = game_state
131
+ config = gs.env_config
132
+ shape_idx = -1
133
+ shape_to_place = None
134
+ for i, s in enumerate(gs.shapes):
135
+ if s:
136
+ shape_idx = i
137
+ shape_to_place = s
138
+ break
139
+ if shape_idx == -1 or not shape_to_place:
140
+ pytest.skip("Requires at least one initial shape in game state")
141
+
142
+ original_other_shapes = [
143
+ s.copy() if s else None for j, s in enumerate(gs.shapes) if j != shape_idx
144
+ ]
145
+ placed_count_expected = len(shape_to_place.triangles)
146
+
147
+ r, c = -1, -1
148
+ found_spot = False
149
+ for r_try in range(config.ROWS):
150
+ start_c, end_c = config.PLAYABLE_RANGE_PER_ROW[r_try]
151
+ for c_try in range(start_c, end_c):
152
+ if GridLogic.can_place(gs.grid_data, shape_to_place, r_try, c_try):
153
+ r, c = r_try, c_try
154
+ found_spot = True
155
+ break
156
+ if found_spot:
157
+ break
158
+ if not found_spot:
159
+ pytest.skip(f"Could not find valid placement for shape {shape_idx}")
160
+
161
+ gs.game_score()
162
+ cleared_count_ret, placed_count_ret = execute_placement(gs, shape_idx, r, c)
163
+
164
+ assert placed_count_ret == placed_count_expected
165
+ assert cleared_count_ret == 0
166
+ # Score calculation is handled by GameState.step, not tested here directly
167
+ # expected_score_increase = placed_count_expected + cleared_count_ret * 2
168
+ # assert gs.game_score() == initial_score + expected_score_increase
169
+ for dr, dc, _ in shape_to_place.triangles:
170
+ assert gs.grid_data._occupied_np[r + dr, c + dc]
171
+ assert gs.shapes[shape_idx] is None
172
+ current_other_shapes = [s for j, s in enumerate(gs.shapes) if j != shape_idx]
173
+ assert current_other_shapes == original_other_shapes
174
+
175
+
176
+ def test_execute_placement_clear_line_no_refill_v3(
177
+ game_state: GameState,
178
+ ):
179
+ """Test placing a shape that clears a line, verify stats."""
180
+ gs = game_state
181
+
182
+ shape_idx = -1
183
+ shape_single = None
184
+ for i, s in enumerate(gs.shapes):
185
+ if s and len(s.triangles) == 1:
186
+ shape_idx = i
187
+ shape_single = s
188
+ break
189
+ if shape_idx == -1 or not shape_single:
190
+ gs.reset()
191
+ for i, s in enumerate(gs.shapes):
192
+ if s and len(s.triangles) == 1:
193
+ shape_idx = i
194
+ shape_single = s
195
+ break
196
+ if shape_idx == -1 or not shape_single:
197
+ pytest.skip("Requires a single-triangle shape")
198
+
199
+ placed_count_expected = len(shape_single.triangles)
200
+ original_other_shapes = [
201
+ s.copy() if s else None for j, s in enumerate(gs.shapes) if j != shape_idx
202
+ ]
203
+
204
+ # Find any precomputed maximal line
205
+ target_line_coords_tuple = None
206
+ if not gs.grid_data._lines:
207
+ pytest.skip("No precomputed lines found.")
208
+ # Just pick the first one for the test
209
+ target_line_coords_tuple = gs.grid_data._lines[0]
210
+ target_line_coords_fs = frozenset(target_line_coords_tuple)
211
+
212
+ r, c = -1, -1
213
+ placement_coord = None
214
+ for r_place, c_place in target_line_coords_tuple:
215
+ if GridLogic.can_place(gs.grid_data, shape_single, r_place, c_place):
216
+ r, c = r_place, c_place
217
+ placement_coord = (r, c)
218
+ break
219
+ if placement_coord is None:
220
+ pytest.skip(
221
+ f"Could not find valid placement for shape {shape_idx} on target line"
222
+ )
223
+
224
+ line_coords_to_occupy = set(target_line_coords_fs) - {
225
+ placement_coord
226
+ } # Convert to set
227
+ occupy_coords(gs.grid_data, line_coords_to_occupy)
228
+ gs.game_score()
229
+
230
+ cleared_count_ret, placed_count_ret = execute_placement(gs, shape_idx, r, c)
231
+
232
+ assert placed_count_ret == placed_count_expected
233
+ assert cleared_count_ret == len(target_line_coords_fs)
234
+ # Score calculation is handled by GameState.step, not tested here directly
235
+ # expected_score_increase = placed_count_expected + cleared_count_ret * 2
236
+ # assert gs.game_score() == initial_score + expected_score_increase
237
+ for row, col in target_line_coords_fs:
238
+ assert not gs.grid_data._occupied_np[row, col]
239
+ assert gs.shapes[shape_idx] is None
240
+ current_other_shapes = [s for j, s in enumerate(gs.shapes) if j != shape_idx]
241
+ assert current_other_shapes == original_other_shapes
242
+
243
+
244
+ def test_execute_placement_batch_refill_v3(
245
+ game_state: GameState, mocker: MockerFixture
246
+ ):
247
+ """Test execute_placement when placing the last shape - refill handled by caller."""
248
+ gs = game_state
249
+ config = gs.env_config
250
+ if config.NUM_SHAPE_SLOTS != 3:
251
+ pytest.skip("Test requires 3 shape slots")
252
+ if len(gs.shapes) != 3 or any(s is None for s in gs.shapes):
253
+ gs.reset()
254
+ if len(gs.shapes) != 3 or any(s is None for s in gs.shapes):
255
+ pytest.skip("Could not ensure 3 initial shapes")
256
+
257
+ placements = []
258
+ temp_gs = gs.copy()
259
+ placed_indices = set()
260
+ for i in range(config.NUM_SHAPE_SLOTS):
261
+ shape_to_place = temp_gs.shapes[i]
262
+ if not shape_to_place:
263
+ continue
264
+ found_spot = False
265
+ for r_try in range(config.ROWS):
266
+ start_c, end_c = config.PLAYABLE_RANGE_PER_ROW[r_try]
267
+ for c_try in range(start_c, end_c):
268
+ if GridLogic.can_place(temp_gs.grid_data, shape_to_place, r_try, c_try):
269
+ placements.append(
270
+ {
271
+ "idx": i,
272
+ "r": r_try,
273
+ "c": c_try,
274
+ "count": len(shape_to_place.triangles),
275
+ }
276
+ )
277
+ for dr, dc, _ in shape_to_place.triangles:
278
+ if temp_gs.grid_data.valid(r_try + dr, c_try + dc):
279
+ temp_gs.grid_data._occupied_np[r_try + dr, c_try + dc] = (
280
+ True
281
+ )
282
+ temp_gs.shapes[i] = None
283
+ placed_indices.add(i)
284
+ found_spot = True
285
+ break
286
+ if found_spot:
287
+ break
288
+ if not found_spot:
289
+ pytest.skip(f"Could not find sequential placement for shape {i}")
290
+ if len(placements) != config.NUM_SHAPE_SLOTS:
291
+ pytest.skip("Could not find valid sequential placements for all 3 shapes")
292
+
293
+ p1 = placements[0]
294
+ _, _ = execute_placement(gs, p1["idx"], p1["r"], p1["c"])
295
+ assert gs.shapes[p1["idx"]] is None
296
+ assert gs.shapes[placements[1]["idx"]] is not None
297
+ assert gs.shapes[placements[2]["idx"]] is not None
298
+
299
+ p2 = placements[1]
300
+ _, _ = execute_placement(gs, p2["idx"], p2["r"], p2["c"])
301
+ assert gs.shapes[p1["idx"]] is None
302
+ assert gs.shapes[p2["idx"]] is None
303
+ assert gs.shapes[placements[2]["idx"]] is not None
304
+
305
+ mock_clear = mocker.patch(
306
+ "trianglengin.core.environment.grid.logic.check_and_clear_lines",
307
+ return_value=(0, set(), set()),
308
+ )
309
+
310
+ p3 = placements[2]
311
+ cleared3, placed3 = execute_placement(gs, p3["idx"], p3["r"], p3["c"])
312
+ assert cleared3 == 0
313
+ assert placed3 == p3["count"]
314
+ mock_clear.assert_called_once()
315
+ assert all(s is None for s in gs.shapes)
316
+
317
+
318
+ def test_execute_placement_game_over_v3(game_state: GameState, mocker: MockerFixture):
319
+ """Test execute_placement when placement leads to game over state - reward handled by caller."""
320
+ config = game_state.env_config
321
+ playable_mask = ~game_state.grid_data._death_np
322
+ game_state.grid_data._occupied_np[playable_mask] = True
323
+
324
+ empty_r, empty_c = -1, -1
325
+ shape_to_place = None
326
+ shape_idx = -1
327
+ found_spot = False
328
+ for idx, s in enumerate(game_state.shapes):
329
+ if not s:
330
+ continue
331
+ for r_try in range(config.ROWS):
332
+ start_c, end_c = config.PLAYABLE_RANGE_PER_ROW[r_try]
333
+ for c_try in range(start_c, end_c):
334
+ if not game_state.grid_data._death_np[r_try, c_try]:
335
+ original_state = game_state.grid_data._occupied_np[r_try, c_try]
336
+ game_state.grid_data._occupied_np[r_try, c_try] = False
337
+ if GridLogic.can_place(game_state.grid_data, s, r_try, c_try):
338
+ shape_to_place = s
339
+ shape_idx = idx
340
+ empty_r, empty_c = r_try, c_try
341
+ found_spot = True
342
+ break
343
+ else:
344
+ game_state.grid_data._occupied_np[r_try, c_try] = original_state
345
+ if found_spot:
346
+ break
347
+ if found_spot:
348
+ break
349
+ if not found_spot:
350
+ pytest.skip("Could not find suitable shape and empty spot")
351
+
352
+ game_state.grid_data._occupied_np[playable_mask] = True
353
+ game_state.grid_data._occupied_np[empty_r, empty_c] = False
354
+
355
+ placed_count_expected = 0
356
+ if shape_to_place: # Check if shape_to_place is not None
357
+ placed_count_expected = len(shape_to_place.triangles)
358
+
359
+ mock_clear = mocker.patch(
360
+ "trianglengin.core.environment.grid.logic.check_and_clear_lines",
361
+ return_value=(0, set(), set()),
362
+ )
363
+
364
+ cleared_count_ret, placed_count_ret = execute_placement(
365
+ game_state, shape_idx, empty_r, empty_c
366
+ )
367
+
368
+ assert placed_count_ret == placed_count_expected
369
+ assert cleared_count_ret == 0
370
+ mock_clear.assert_called_once()
371
+ assert game_state.grid_data._occupied_np[playable_mask].all()
372
+ assert game_state.shapes[shape_idx] is None
File without changes
@@ -0,0 +1,83 @@
1
+ # File: trianglengin/tests/core/structs/test_shape.py
2
+
3
+ # Import directly from the library being tested
4
+ from trianglengin.core.structs import Shape
5
+
6
+
7
+ def test_shape_initialization():
8
+ """Test basic shape initialization."""
9
+ triangles = [(0, 0, False), (1, 0, True)]
10
+ color = (255, 0, 0)
11
+ shape = Shape(triangles, color)
12
+ assert shape.triangles == sorted(triangles) # Check sorting
13
+ assert shape.color == color
14
+
15
+
16
+ def test_shape_bbox():
17
+ """Test bounding box calculation."""
18
+ triangles1 = [(0, 0, False)]
19
+ shape1 = Shape(triangles1, (1, 1, 1))
20
+ assert shape1.bbox() == (0, 0, 0, 0)
21
+
22
+ triangles2 = [(0, 1, True), (1, 0, False), (1, 2, False)]
23
+ shape2 = Shape(triangles2, (2, 2, 2))
24
+ assert shape2.bbox() == (0, 0, 1, 2) # min_r, min_c, max_r, max_c
25
+
26
+ shape3 = Shape([], (3, 3, 3))
27
+ assert shape3.bbox() == (0, 0, 0, 0)
28
+
29
+
30
+ def test_shape_copy():
31
+ """Test the copy method."""
32
+ triangles = [(0, 0, False), (1, 0, True)]
33
+ color = (255, 0, 0)
34
+ shape1 = Shape(triangles, color)
35
+ shape2 = shape1.copy()
36
+
37
+ assert shape1 == shape2
38
+ assert shape1 is not shape2
39
+ assert shape1.triangles is not shape2.triangles # List should be copied
40
+ assert shape1.color is shape2.color # Color tuple is shared (immutable)
41
+
42
+ # Modify copy's triangle list
43
+ shape2.triangles.append((2, 2, True))
44
+ assert shape1.triangles != shape2.triangles
45
+
46
+
47
+ def test_shape_equality():
48
+ """Test shape equality comparison."""
49
+ t1 = [(0, 0, False)]
50
+ c1 = (1, 1, 1)
51
+ t2 = [(0, 0, False)]
52
+ c2 = (1, 1, 1)
53
+ t3 = [(0, 0, True)]
54
+ c3 = (2, 2, 2)
55
+
56
+ shape1 = Shape(t1, c1)
57
+ shape2 = Shape(t2, c2)
58
+ shape3 = Shape(t3, c1)
59
+ shape4 = Shape(t1, c3)
60
+
61
+ assert shape1 == shape2
62
+ assert shape1 != shape3
63
+ assert shape1 != shape4
64
+ assert shape1 != "not a shape"
65
+
66
+
67
+ def test_shape_hash():
68
+ """Test shape hashing."""
69
+ t1 = [(0, 0, False)]
70
+ c1 = (1, 1, 1)
71
+ t2 = [(0, 0, False)]
72
+ c2 = (1, 1, 1)
73
+ t3 = [(0, 0, True)]
74
+
75
+ shape1 = Shape(t1, c1)
76
+ shape2 = Shape(t2, c2)
77
+ shape3 = Shape(t3, c1)
78
+
79
+ assert hash(shape1) == hash(shape2)
80
+ assert hash(shape1) != hash(shape3)
81
+
82
+ shape_set = {shape1, shape2, shape3}
83
+ assert len(shape_set) == 2
@@ -0,0 +1,97 @@
1
+ # File: trianglengin/tests/core/structs/test_triangle.py
2
+
3
+ # Import directly from the library being tested
4
+ from trianglengin.core.structs import Triangle
5
+
6
+
7
+ def test_triangle_initialization():
8
+ """Test basic triangle initialization."""
9
+ tri1 = Triangle(row=1, col=2, is_up=True)
10
+ assert tri1.row == 1
11
+ assert tri1.col == 2
12
+ assert tri1.is_up
13
+ assert not tri1.is_death
14
+ assert not tri1.is_occupied
15
+ assert tri1.color is None
16
+
17
+ tri2 = Triangle(row=3, col=4, is_up=False, is_death=True)
18
+ assert tri2.row == 3
19
+ assert tri2.col == 4
20
+ assert not tri2.is_up
21
+ assert tri2.is_death
22
+ assert tri2.is_occupied # Occupied because it's death
23
+ assert tri2.color is None
24
+
25
+
26
+ def test_triangle_copy():
27
+ """Test the copy method."""
28
+ tri1 = Triangle(row=1, col=2, is_up=True)
29
+ tri1.is_occupied = True
30
+ tri1.color = (255, 0, 0)
31
+ tri1.neighbor_left = Triangle(1, 1, False) # Add a neighbor
32
+
33
+ tri2 = tri1.copy()
34
+
35
+ assert tri1 == tri2
36
+ assert tri1 is not tri2
37
+ assert tri2.row == 1
38
+ assert tri2.col == 2
39
+ assert tri2.is_up
40
+ assert tri2.is_occupied
41
+ assert tri2.color == (255, 0, 0)
42
+ assert not tri2.is_death
43
+ # Neighbors should not be copied
44
+ assert tri2.neighbor_left is None
45
+ assert tri2.neighbor_right is None
46
+ assert tri2.neighbor_vert is None
47
+
48
+ # Modify copy and check original
49
+ tri2.is_occupied = False
50
+ tri2.color = (0, 255, 0)
51
+ assert tri1.is_occupied
52
+ assert tri1.color == (255, 0, 0)
53
+
54
+
55
+ def test_triangle_equality():
56
+ """Test triangle equality based on row and col."""
57
+ tri1 = Triangle(1, 2, True)
58
+ tri2 = Triangle(1, 2, False) # Different orientation/state
59
+ tri3 = Triangle(1, 3, True)
60
+ tri4 = Triangle(2, 2, True)
61
+
62
+ assert tri1 == tri2 # Equality only checks row/col
63
+ assert tri1 != tri3
64
+ assert tri1 != tri4
65
+ assert tri1 != "not a triangle"
66
+
67
+
68
+ def test_triangle_hash():
69
+ """Test triangle hashing based on row and col."""
70
+ tri1 = Triangle(1, 2, True)
71
+ tri2 = Triangle(1, 2, False)
72
+ tri3 = Triangle(1, 3, True)
73
+
74
+ assert hash(tri1) == hash(tri2)
75
+ assert hash(tri1) != hash(tri3)
76
+
77
+ tri_set = {tri1, tri2, tri3}
78
+ assert len(tri_set) == 2 # tri1 and tri2 hash the same
79
+
80
+
81
+ def test_triangle_get_points():
82
+ """Test vertex point calculation."""
83
+ # Up triangle at origin (0,0) with cell width/height 100
84
+ tri_up = Triangle(0, 0, True)
85
+ pts_up = tri_up.get_points(ox=0, oy=0, cw=100, ch=100)
86
+ # Expected: [(0, 100), (100, 100), (50, 0)]
87
+ assert pts_up == [(0.0, 100.0), (100.0, 100.0), (50.0, 0.0)]
88
+
89
+ # Down triangle at (1,1) with cell width/height 50, offset (10, 20)
90
+ tri_down = Triangle(1, 1, False)
91
+ # ox = 10, oy = 20, cw = 50, ch = 50
92
+ # Base x = 10 + 1 * (50 * 0.75) = 10 + 37.5 = 47.5
93
+ # Base y = 20 + 1 * 50 = 70
94
+ pts_down = tri_down.get_points(ox=10, oy=20, cw=50, ch=50)
95
+ # Expected: [(47.5, 70), (47.5+50, 70), (47.5+25, 70+50)]
96
+ # Expected: [(47.5, 70.0), (97.5, 70.0), (72.5, 120.0)]
97
+ assert pts_down == [(47.5, 70.0), (97.5, 70.0), (72.5, 120.0)]
File without changes
@@ -0,0 +1,93 @@
1
+ # File: trianglengin/tests/utils/test_geometry.py
2
+
3
+ # Import directly from the library being tested
4
+ from trianglengin.utils import geometry
5
+
6
+
7
+ def test_is_point_in_polygon_square():
8
+ """Test point in polygon for a simple square."""
9
+ square = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]
10
+
11
+ # Inside
12
+ assert geometry.is_point_in_polygon((0.5, 0.5), square)
13
+
14
+ # On edge
15
+ assert geometry.is_point_in_polygon((0.5, 0.0), square)
16
+ assert geometry.is_point_in_polygon((1.0, 0.5), square)
17
+ assert geometry.is_point_in_polygon((0.5, 1.0), square)
18
+ assert geometry.is_point_in_polygon((0.0, 0.5), square)
19
+
20
+ # On vertex
21
+ assert geometry.is_point_in_polygon((0.0, 0.0), square)
22
+ assert geometry.is_point_in_polygon((1.0, 1.0), square)
23
+ assert geometry.is_point_in_polygon((1.0, 0.0), square)
24
+ assert geometry.is_point_in_polygon((0.0, 1.0), square)
25
+
26
+ # Outside
27
+ assert not geometry.is_point_in_polygon((1.5, 0.5), square)
28
+ assert not geometry.is_point_in_polygon((0.5, -0.5), square)
29
+ assert not geometry.is_point_in_polygon((-0.1, 0.1), square)
30
+ assert not geometry.is_point_in_polygon((0.5, 1.1), square) # Added top outside
31
+
32
+
33
+ def test_is_point_in_polygon_triangle():
34
+ """Test point in polygon for a triangle."""
35
+ triangle = [(0.0, 0.0), (2.0, 0.0), (1.0, 2.0)]
36
+
37
+ # Inside
38
+ assert geometry.is_point_in_polygon((1.0, 0.5), triangle)
39
+ assert geometry.is_point_in_polygon((1.0, 1.0), triangle)
40
+
41
+ # On edge
42
+ assert geometry.is_point_in_polygon((1.0, 0.0), triangle) # Base
43
+ assert geometry.is_point_in_polygon((0.5, 1.0), triangle) # Left edge
44
+ assert geometry.is_point_in_polygon((1.5, 1.0), triangle) # Right edge
45
+
46
+ # On vertex
47
+ assert geometry.is_point_in_polygon((0.0, 0.0), triangle)
48
+ assert geometry.is_point_in_polygon((2.0, 0.0), triangle)
49
+ assert geometry.is_point_in_polygon((1.0, 2.0), triangle)
50
+
51
+ # Outside
52
+ assert not geometry.is_point_in_polygon((1.0, 2.1), triangle)
53
+ assert not geometry.is_point_in_polygon((3.0, 0.5), triangle)
54
+ assert not geometry.is_point_in_polygon((-0.5, 0.5), triangle)
55
+ assert not geometry.is_point_in_polygon((1.0, -0.1), triangle)
56
+
57
+
58
+ def test_is_point_in_polygon_concave():
59
+ """Test point in polygon for a concave shape (e.g., Pacman)."""
60
+ # Simple concave shape (like a U)
61
+ concave = [(0, 0), (3, 0), (3, 1), (1, 1), (1, 2), (2, 2), (2, 3), (0, 3)]
62
+
63
+ # Inside
64
+ assert geometry.is_point_in_polygon((0.5, 0.5), concave)
65
+ assert geometry.is_point_in_polygon((2.5, 0.5), concave)
66
+ assert geometry.is_point_in_polygon((0.5, 2.5), concave)
67
+ assert geometry.is_point_in_polygon((1.5, 2.5), concave) # Inside the 'U' part
68
+
69
+ # Outside (in the 'mouth')
70
+ assert not geometry.is_point_in_polygon((1.5, 1.5), concave)
71
+
72
+ # Outside (general)
73
+ assert not geometry.is_point_in_polygon((4.0, 1.0), concave)
74
+ assert not geometry.is_point_in_polygon((1.0, 4.0), concave)
75
+
76
+ # On edge
77
+ assert geometry.is_point_in_polygon((1.5, 0.0), concave)
78
+ assert geometry.is_point_in_polygon(
79
+ (1.0, 1.5), concave
80
+ ) # On the inner vertical edge
81
+ assert geometry.is_point_in_polygon(
82
+ (1.5, 1.0), concave
83
+ ) # On the inner horizontal edge
84
+ assert geometry.is_point_in_polygon(
85
+ (2.0, 2.5), concave
86
+ ) # On the outer vertical edge
87
+ assert geometry.is_point_in_polygon((0.0, 1.5), concave) # On outer edge
88
+
89
+ # On vertex
90
+ assert geometry.is_point_in_polygon((1.0, 1.0), concave) # Inner corner
91
+ assert geometry.is_point_in_polygon((1.0, 2.0), concave) # Inner corner
92
+ assert geometry.is_point_in_polygon((3.0, 0.0), concave) # Outer corner
93
+ assert geometry.is_point_in_polygon((0.0, 3.0), concave) # Outer corner
@@ -0,0 +1,18 @@
1
+ # File: trianglengin/__init__.py
2
+ """
3
+ Triangle Engine Library (`trianglengin`)
4
+
5
+ Core components for a triangle puzzle game environment.
6
+ """
7
+
8
+ # Expose key components from submodules
9
+ from . import app, cli, config, core, interaction, visualization
10
+
11
+ __all__ = [
12
+ "core",
13
+ "config",
14
+ "visualization",
15
+ "interaction",
16
+ "app",
17
+ "cli",
18
+ ]