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.
- tests/__init__.py +0 -0
- tests/conftest.py +108 -0
- tests/core/__init__.py +2 -0
- tests/core/environment/README.md +47 -0
- tests/core/environment/__init__.py +2 -0
- tests/core/environment/test_action_codec.py +50 -0
- tests/core/environment/test_game_state.py +483 -0
- tests/core/environment/test_grid_data.py +205 -0
- tests/core/environment/test_grid_logic.py +362 -0
- tests/core/environment/test_shape_logic.py +171 -0
- tests/core/environment/test_step.py +372 -0
- tests/core/structs/__init__.py +0 -0
- tests/core/structs/test_shape.py +83 -0
- tests/core/structs/test_triangle.py +97 -0
- tests/utils/__init__.py +0 -0
- tests/utils/test_geometry.py +93 -0
- trianglengin/__init__.py +18 -0
- trianglengin/app.py +110 -0
- trianglengin/cli.py +134 -0
- trianglengin/config/__init__.py +9 -0
- trianglengin/config/display_config.py +47 -0
- trianglengin/config/env_config.py +103 -0
- trianglengin/core/__init__.py +8 -0
- trianglengin/core/environment/__init__.py +31 -0
- trianglengin/core/environment/action_codec.py +37 -0
- trianglengin/core/environment/game_state.py +217 -0
- trianglengin/core/environment/grid/README.md +46 -0
- trianglengin/core/environment/grid/__init__.py +18 -0
- trianglengin/core/environment/grid/grid_data.py +140 -0
- trianglengin/core/environment/grid/line_cache.py +189 -0
- trianglengin/core/environment/grid/logic.py +131 -0
- trianglengin/core/environment/logic/__init__.py +3 -0
- trianglengin/core/environment/logic/actions.py +38 -0
- trianglengin/core/environment/logic/step.py +134 -0
- trianglengin/core/environment/shapes/__init__.py +19 -0
- trianglengin/core/environment/shapes/logic.py +84 -0
- trianglengin/core/environment/shapes/templates.py +587 -0
- trianglengin/core/structs/__init__.py +27 -0
- trianglengin/core/structs/constants.py +28 -0
- trianglengin/core/structs/shape.py +61 -0
- trianglengin/core/structs/triangle.py +48 -0
- trianglengin/interaction/README.md +45 -0
- trianglengin/interaction/__init__.py +17 -0
- trianglengin/interaction/debug_mode_handler.py +96 -0
- trianglengin/interaction/event_processor.py +43 -0
- trianglengin/interaction/input_handler.py +82 -0
- trianglengin/interaction/play_mode_handler.py +141 -0
- trianglengin/utils/__init__.py +9 -0
- trianglengin/utils/geometry.py +73 -0
- trianglengin/utils/types.py +10 -0
- trianglengin/visualization/README.md +44 -0
- trianglengin/visualization/__init__.py +61 -0
- trianglengin/visualization/core/README.md +52 -0
- trianglengin/visualization/core/__init__.py +12 -0
- trianglengin/visualization/core/colors.py +117 -0
- trianglengin/visualization/core/coord_mapper.py +73 -0
- trianglengin/visualization/core/fonts.py +55 -0
- trianglengin/visualization/core/layout.py +101 -0
- trianglengin/visualization/core/visualizer.py +232 -0
- trianglengin/visualization/drawing/README.md +45 -0
- trianglengin/visualization/drawing/__init__.py +30 -0
- trianglengin/visualization/drawing/grid.py +156 -0
- trianglengin/visualization/drawing/highlight.py +30 -0
- trianglengin/visualization/drawing/hud.py +39 -0
- trianglengin/visualization/drawing/previews.py +172 -0
- trianglengin/visualization/drawing/shapes.py +36 -0
- trianglengin-1.0.6.dist-info/METADATA +367 -0
- trianglengin-1.0.6.dist-info/RECORD +72 -0
- trianglengin-1.0.6.dist-info/WHEEL +5 -0
- trianglengin-1.0.6.dist-info/entry_points.txt +2 -0
- trianglengin-1.0.6.dist-info/licenses/LICENSE +22 -0
- 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)]
|
tests/utils/__init__.py
ADDED
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
|
trianglengin/__init__.py
ADDED
@@ -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
|
+
]
|