1741 lines
62 KiB
Python
1741 lines
62 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import importlib
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
if __package__ is None or __package__ == "":
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
|
|
from app.models import (
|
|
LedPoint,
|
|
MappingDocument,
|
|
build_route_adjacency,
|
|
is_valid_route_neighbor,
|
|
min_neighbor_distance_for_points,
|
|
order_points_like_grid_svg,
|
|
)
|
|
from app.panel_mapping_generator import generate_panel_mapping_from_document
|
|
from app.unfolding import MAX_LINES
|
|
try:
|
|
from app.canvas import LedCanvas
|
|
from app.icons import LUCIDE_PATHS
|
|
from app.main_window import MainWindow, SIDEBAR_COLLAPSED_WIDTH, SIDEBAR_MIN_WIDTH
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtGui import QColor, QKeySequence
|
|
from PySide6.QtWidgets import QApplication, QGraphicsItem, QToolBar
|
|
except ImportError:
|
|
LedCanvas = None
|
|
LUCIDE_PATHS = {}
|
|
MainWindow = None
|
|
SIDEBAR_COLLAPSED_WIDTH = 34
|
|
SIDEBAR_MIN_WIDTH = 180
|
|
Qt = None
|
|
QColor = None
|
|
QKeySequence = None
|
|
QApplication = None
|
|
QGraphicsItem = None
|
|
QToolBar = None
|
|
|
|
|
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
|
|
def main() -> None:
|
|
tests: list[Callable[[], None]] = [
|
|
test_load_supported_json_files,
|
|
test_line_limit_rejects_fifth_line,
|
|
test_line_indexes_are_scoped_per_line,
|
|
test_remove_selected_leds_blocks_and_lines,
|
|
test_remove_block_preserves_line_numbers,
|
|
test_save_load_unfolding_roundtrip,
|
|
test_reassigning_leds_removes_old_memberships,
|
|
test_reassigning_leds_reindexes_affected_lines,
|
|
test_line_numbers_do_not_renumber_when_points_are_cleared,
|
|
test_auto_blocks_reindex_all_affected_lines,
|
|
test_legacy_rebuild_groups_by_line_and_block,
|
|
test_legacy_line_without_block_id_is_preserved,
|
|
test_points_without_grid_fields_load,
|
|
test_loaded_duplicate_memberships_are_deduplicated,
|
|
test_empty_blocks_are_preserved,
|
|
test_block_names_keep_increasing_in_line,
|
|
test_block_names_do_not_renumber_when_line_is_removed,
|
|
test_reindex_preserves_block_point_order,
|
|
test_add_block_preserves_start_point,
|
|
test_append_points_to_block_extends_existing_block,
|
|
test_clockwise_block_from_seed_starts_at_selected_point,
|
|
test_clockwise_block_from_seed_reverses_counterclockwise_corner,
|
|
test_clockwise_block_from_seed_uses_direction_priority,
|
|
test_far_neighbor_is_not_valid_route_neighbor,
|
|
test_diagonal_grid_neighbor_is_valid_route_neighbor,
|
|
test_rebuild_line_blocks_splits_far_segments,
|
|
test_saved_coordinate_unit_is_reused,
|
|
test_uid_point_id_mode_does_not_fallback_to_source_ids,
|
|
test_panel_mapping_generation_from_document,
|
|
test_canvas_uses_grid_svg_coordinates,
|
|
test_canvas_rebuild_can_preserve_user_zoom,
|
|
test_grid_toggle_adds_grid_items,
|
|
test_canvas_connects_new_block_to_previous_non_empty_block,
|
|
test_canvas_rebuild_drops_deleted_selection_items,
|
|
test_canvas_refresh_does_not_accumulate_connection_items,
|
|
test_main_window_has_classic_menu_bar,
|
|
test_classic_menu_contains_expected_actions,
|
|
test_side_panel_hides_removed_tools,
|
|
test_undo_redo_roundtrip_for_line_creation,
|
|
test_main_window_actions_use_lucide_icons,
|
|
test_main_window_action_shortcuts_are_assigned,
|
|
test_lucide_icon_names_are_declared,
|
|
test_table_hides_empty_line_index_columns,
|
|
test_sidebar_collapses_and_expands,
|
|
test_sidebar_action_toggle_updates_state_and_constraints,
|
|
test_sidebar_collapsed_tab_click_and_drag_expand,
|
|
test_canvas_select_points_skips_duplicate_selection_emit,
|
|
test_canvas_brush_cursor_survives_pan_cursor_reset,
|
|
test_canvas_brush_zone_is_visible_and_collects_points,
|
|
test_canvas_brush_zone_uses_viewport_radius_after_zoom,
|
|
test_canvas_brush_preview_highlights_only_actionable_points,
|
|
test_brush_can_create_new_block_without_preselected_block,
|
|
test_brush_continues_block_from_selected_led,
|
|
test_main_window_restores_table_state_after_selection_error,
|
|
test_main_window_restores_tree_state_after_refresh_error,
|
|
test_package_imports_work_from_project_root,
|
|
test_route_adjacency_large_grid_perf_guard,
|
|
test_route_prefers_up_left_down_right,
|
|
test_route_prefers_coordinate_up_when_grid_index_is_not_adjacent,
|
|
test_first_route_step_prefers_up_before_slightly_closer_right,
|
|
test_route_does_not_jump_over_intermediate_led,
|
|
test_route_does_not_use_grid_indexes_to_skip_nearer_physical_neighbor,
|
|
test_route_continues_after_curved_sparse_step,
|
|
test_auto_block_continues_past_point_neighboring_start,
|
|
test_auto_block_does_not_close_before_meaningful_continuation,
|
|
test_parallel_track_middle_edges_are_filtered,
|
|
test_route_keeps_close_point_on_smooth_bend,
|
|
test_new_block_does_not_inherit_previous_block_trajectory,
|
|
test_route_prefers_current_block_trajectory_after_first_step,
|
|
test_route_prefers_near_neighbor_before_far_trajectory,
|
|
test_route_continues_trajectory_across_sparse_gap,
|
|
test_clockwise_route_keeps_straight_track_at_branch,
|
|
test_clockwise_route_turns_right_when_straight_is_absent,
|
|
test_auto_block_continues_through_left_down_diagonal,
|
|
test_too_close_neighbor_has_lowest_priority,
|
|
test_meter_like_coordinates_use_34_mm_threshold,
|
|
]
|
|
for test in tests:
|
|
test()
|
|
print(f"ok - {test.__name__}")
|
|
print(f"All {len(tests)} QA smoke checks passed.")
|
|
|
|
|
|
def test_load_supported_json_files() -> None:
|
|
fixtures = [
|
|
PROJECT_ROOT / "circles.json",
|
|
PROJECT_ROOT / "circles_new.json",
|
|
PROJECT_ROOT / "panel_mapping_build" / "ordered_circles.json",
|
|
]
|
|
for path in fixtures:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
assert doc.points, f"{path} did not load any points"
|
|
assert doc.collection_name in {"circles", "leds"}
|
|
assert len(doc.lines) <= MAX_LINES
|
|
|
|
|
|
def test_line_limit_rejects_fifth_line() -> None:
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
for _ in range(MAX_LINES):
|
|
doc.add_line()
|
|
|
|
assert len(doc.lines) == MAX_LINES
|
|
try:
|
|
doc.add_line()
|
|
except ValueError as exc:
|
|
assert str(MAX_LINES) in str(exc)
|
|
else:
|
|
raise AssertionError("Adding a fifth line should fail.")
|
|
|
|
|
|
def test_line_indexes_are_scoped_per_line() -> None:
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
|
|
first_line = doc.add_line()
|
|
doc.add_block(first_line.id, [0, 1])
|
|
second_line = doc.add_line()
|
|
doc.add_block(second_line.id, [4, 5, 6])
|
|
|
|
indexes_by_line = {
|
|
line.id: sorted(point.line_index for point in doc.points if point.line_id == line.id)
|
|
for line in doc.lines
|
|
if line.blocks
|
|
}
|
|
assert sorted(indexes_by_line.values()) == [[0, 1], [0, 1, 2]]
|
|
|
|
|
|
def test_remove_selected_leds_blocks_and_lines() -> None:
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
|
|
first_line = doc.add_line()
|
|
first_block = doc.add_block(first_line.id, [0, 1, 2])
|
|
second_block = doc.add_block(first_line.id, [3, 4])
|
|
second_line = doc.add_line()
|
|
doc.add_block(second_line.id, [8, 9])
|
|
|
|
removed_leds = doc.clear_point_assignments([0, 8])
|
|
assert removed_leds == 2
|
|
assert doc.points[0].line_id is None
|
|
assert doc.points[8].line_id is None
|
|
assert all(0 not in block.point_ids and 8 not in block.point_ids for line in doc.lines for block in line.blocks)
|
|
|
|
removed_block_leds = doc.remove_block(first_line.id, first_block.id)
|
|
assert removed_block_leds == 2
|
|
assert doc.points[1].line_id is None
|
|
assert doc.points[2].line_id is None
|
|
assert doc.points[3].line_id == first_line.id
|
|
assert doc.points[4].line_id == first_line.id
|
|
|
|
removed_line_leds = doc.remove_line(first_line.id)
|
|
assert removed_line_leds == len(second_block.point_ids)
|
|
assert doc.points[3].line_id is None
|
|
assert doc.points[4].line_id is None
|
|
|
|
|
|
def test_remove_block_preserves_line_numbers() -> None:
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
first_line = doc.add_line()
|
|
second_line = doc.add_line()
|
|
doc.add_block(first_line.id, [0, 1])
|
|
second_block = doc.add_block(second_line.id, [4, 5])
|
|
|
|
removed = doc.remove_block(second_line.id, second_block.id)
|
|
|
|
assert removed == 2
|
|
assert [line.id for line in doc.lines] == [1, 2]
|
|
assert [line.name for line in doc.lines] == ["Линия 1", "Линия 2"]
|
|
assert doc.get_line(second_line.id).blocks == []
|
|
|
|
|
|
def test_save_load_unfolding_roundtrip() -> None:
|
|
with temp_document_path() as path:
|
|
save_path = path.with_name("saved_mapping.json")
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
|
|
first_line = doc.add_line()
|
|
doc.add_block(first_line.id, [0, 1])
|
|
second_line = doc.add_line()
|
|
doc.add_block(second_line.id, [4, 5, 6])
|
|
doc.unfolding.block_size = 2
|
|
doc.save(save_path)
|
|
|
|
payload = json.loads(save_path.read_text(encoding="utf-8"))
|
|
assert payload["unfolding"]["max_lines"] == MAX_LINES
|
|
assert payload["unfolding"]["led_index_scope"] == "line"
|
|
assert len(payload["unfolding"]["block_counts"]) == 2
|
|
|
|
loaded = MappingDocument()
|
|
loaded.load(save_path)
|
|
assert len(loaded.lines) == 2
|
|
assert loaded.unfolding.block_size == 2
|
|
assert loaded.describe_unfolding().led_index_scope == "line"
|
|
assert sorted(
|
|
sorted(point.line_index for point in loaded.points if point.line_id == line.id)
|
|
for line in loaded.lines
|
|
) == [[0, 1], [0, 1, 2]]
|
|
|
|
|
|
def test_reassigning_leds_removes_old_memberships() -> None:
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
first_line = doc.add_line()
|
|
first_block = doc.add_block(first_line.id, [0, 1, 2])
|
|
second_line = doc.add_line()
|
|
second_block = doc.add_block(second_line.id, [1, 3])
|
|
|
|
assert 1 not in first_block.point_ids
|
|
assert 1 in second_block.point_ids
|
|
memberships = [
|
|
(line.id, block.id)
|
|
for line in doc.lines
|
|
for block in line.blocks
|
|
if 1 in block.point_ids
|
|
]
|
|
assert memberships == [(second_line.id, second_block.id)]
|
|
|
|
|
|
def test_reassigning_leds_reindexes_affected_lines() -> None:
|
|
doc = MappingDocument()
|
|
doc.load(PROJECT_ROOT / "circles.json")
|
|
for _ in range(MAX_LINES):
|
|
doc.add_line()
|
|
|
|
doc.add_block(1, list(range(0, 20)))
|
|
doc.add_block(2, list(range(10, 30)))
|
|
doc.add_block(3, list(range(25, 40)))
|
|
|
|
for point_id in range(40):
|
|
memberships = sum(point_id in block.point_ids for line in doc.lines for block in line.blocks)
|
|
assert memberships <= 1, (point_id, memberships)
|
|
|
|
for line in doc.lines:
|
|
ids = [point_id for block in line.blocks for point_id in block.point_ids]
|
|
indexes = [doc.points[point_id].line_index for point_id in ids]
|
|
assert indexes == list(range(len(ids))), (line.id, indexes)
|
|
|
|
|
|
def test_line_numbers_do_not_renumber_when_points_are_cleared() -> None:
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
first_line = doc.add_line()
|
|
second_line = doc.add_line()
|
|
doc.add_block(first_line.id, [0, 1])
|
|
doc.add_block(second_line.id, [4, 5])
|
|
|
|
doc.clear_point_assignments([0, 1])
|
|
|
|
assert [line.id for line in doc.lines] == [1, 2]
|
|
assert [line.name for line in doc.lines] == ["Линия 1", "Линия 2"]
|
|
|
|
|
|
def test_auto_blocks_reindex_all_affected_lines() -> None:
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
first_line = doc.add_line()
|
|
second_line = doc.add_line()
|
|
doc.add_block(first_line.id, [0, 1, 2])
|
|
|
|
doc.auto_blocks_from_selection(second_line.id, [1, 4], [[1, 4]])
|
|
|
|
assert [doc.points[point_id].line_index for point_id in [0, 2]] == [0, 1]
|
|
assert [doc.points[point_id].line_index for point_id in [1, 4]] == [0, 1]
|
|
|
|
|
|
def test_legacy_rebuild_groups_by_line_and_block() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "legacy_blocks.json"
|
|
points = fixture_points()
|
|
for point in points[:2]:
|
|
point["line_id"] = 1
|
|
point["block_id"] = 1
|
|
for point in points[4:6]:
|
|
point["line_id"] = 2
|
|
point["block_id"] = 1
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
assert len(doc.lines) == 2
|
|
assert [len(line.blocks) for line in doc.lines] == [1, 1]
|
|
assert sorted(doc.points[point_id].line_id for point_id in doc.lines[0].blocks[0].point_ids) == [1, 1]
|
|
assert sorted(doc.points[point_id].line_id for point_id in doc.lines[1].blocks[0].point_ids) == [2, 2]
|
|
|
|
|
|
def test_legacy_line_without_block_id_is_preserved() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "legacy_line_only.json"
|
|
points = fixture_points()
|
|
for index, point in enumerate(points[:3]):
|
|
point["line_id"] = 1
|
|
point["line_index"] = index
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
assert len(doc.lines) == 1
|
|
assert len(doc.lines[0].blocks) == 1
|
|
ids = [point_id for block in doc.lines[0].blocks for point_id in block.point_ids]
|
|
assert len(ids) == 3
|
|
assert [doc.points[point_id].line_index for point_id in ids] == [0, 1, 2]
|
|
|
|
|
|
def test_points_without_grid_fields_load() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "xy_only.json"
|
|
path.write_text(
|
|
json.dumps({"leds": [{"id": 10, "x": 1.0, "y": 2.0}, {"id": 11, "x": 3.0, "y": 4.0}]}),
|
|
encoding="utf-8",
|
|
)
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
assert [(point.row, point.col) for point in doc.points] == [(0, 0), (2, 0)]
|
|
assert len(doc.auto_block_preview([0, 1])) == 2
|
|
|
|
|
|
def test_loaded_duplicate_memberships_are_deduplicated() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "dupes.json"
|
|
points = fixture_points()
|
|
payload = {
|
|
"circles": points,
|
|
"lines": [
|
|
{"id": 1, "blocks": [{"id": 1, "point_ids": [0, 1, 2]}]},
|
|
{"id": 2, "blocks": [{"id": 2, "point_ids": [1, 3]}]},
|
|
],
|
|
}
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
memberships = sum(1 in block.point_ids for line in doc.lines for block in line.blocks)
|
|
assert memberships == 1
|
|
|
|
|
|
def test_empty_blocks_are_preserved() -> None:
|
|
with temp_document_path() as path:
|
|
save_path = path.with_name("empty_block_saved.json")
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
empty_block = doc.add_block(line.id, [])
|
|
filled_block = doc.add_block(line.id, [0])
|
|
|
|
assert empty_block.point_ids == []
|
|
assert len(line.blocks) == 2
|
|
|
|
doc.clear_point_assignments([0])
|
|
assert len(line.blocks) == 2
|
|
assert filled_block.point_ids == []
|
|
|
|
doc.save(save_path)
|
|
loaded = MappingDocument()
|
|
loaded.load(save_path)
|
|
assert len(loaded.lines[0].blocks) == 2
|
|
assert all(block.point_ids == [] for block in loaded.lines[0].blocks)
|
|
|
|
|
|
def test_block_names_keep_increasing_in_line() -> None:
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
|
|
first = doc.add_block(line.id, [])
|
|
second = doc.add_block(line.id, [])
|
|
third = doc.add_block(line.id, [])
|
|
assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 2", "Блок 3"]
|
|
|
|
doc.reindex_all()
|
|
assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 2", "Блок 3"]
|
|
|
|
doc.remove_block(line.id, second.id)
|
|
fourth = doc.add_block(line.id, [])
|
|
|
|
assert fourth.name == "Блок 4"
|
|
assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 3", "Блок 4"]
|
|
|
|
|
|
def test_block_names_do_not_renumber_when_line_is_removed() -> None:
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
first_line = doc.add_line()
|
|
second_line = doc.add_line()
|
|
first = doc.add_block(first_line.id, [])
|
|
second = doc.add_block(first_line.id, [])
|
|
third = doc.add_block(first_line.id, [])
|
|
doc.add_block(second_line.id, [0])
|
|
|
|
doc.remove_block(first_line.id, second.id)
|
|
assert [block.name for block in doc.get_line(first_line.id).blocks] == ["Блок 1", "Блок 3"]
|
|
|
|
doc.remove_line(second_line.id)
|
|
assert [block.name for block in doc.get_line(first_line.id).blocks] == ["Блок 1", "Блок 3"]
|
|
assert [first.name, third.name] == ["Блок 1", "Блок 3"]
|
|
|
|
|
|
def test_reindex_preserves_block_point_order() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "manual_order.json"
|
|
points = [
|
|
{"id": 0, "x": 0, "y": 34, "row": 0, "col": 0},
|
|
{"id": 1, "x": 34, "y": 34, "row": 0, "col": 1},
|
|
{"id": 2, "x": 34, "y": 0, "row": 1, "col": 1},
|
|
{"id": 3, "x": 0, "y": 0, "row": 1, "col": 0},
|
|
]
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
block = doc.add_block(line.id, [0, 3, 2, 1])
|
|
block.point_ids = [0, 3, 2, 1]
|
|
doc.reindex_all()
|
|
|
|
assert block.point_ids == [0, 3, 2, 1]
|
|
assert [doc.points[point_id].line_index for point_id in block.point_ids] == [0, 1, 2, 3]
|
|
|
|
|
|
def test_add_block_preserves_start_point() -> None:
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
block = doc.add_block(line.id, [3, 1, 2])
|
|
assert block.point_ids[0] == 3
|
|
|
|
|
|
def test_append_points_to_block_extends_existing_block() -> None:
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
first_block = doc.add_block(line.id, [0, 1])
|
|
second_block = doc.add_block(line.id, [2, 3])
|
|
|
|
added = doc.append_points_to_block(line.id, first_block.id, [1, 2, 4])
|
|
|
|
assert added == 2
|
|
assert first_block.point_ids == [0, 1, 2, 4]
|
|
assert 2 not in second_block.point_ids
|
|
assert [doc.points[point_id].block_index for point_id in first_block.point_ids] == [0, 1, 2, 3]
|
|
assert [doc.points[point_id].line_index for point_id in first_block.point_ids] == [0, 1, 2, 3]
|
|
|
|
|
|
def test_clockwise_block_from_seed_starts_at_selected_point() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "ring.json"
|
|
points = [
|
|
{"id": 0, "x": 0, "y": 34, "row": 0, "col": 1},
|
|
{"id": 1, "x": 34, "y": 34, "row": 0, "col": 2},
|
|
{"id": 2, "x": 34, "y": 0, "row": 1, "col": 2},
|
|
{"id": 3, "x": 0, "y": 0, "row": 1, "col": 1},
|
|
]
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 2)
|
|
save_path = path.with_name("ring_saved.json")
|
|
doc.save(save_path)
|
|
loaded = MappingDocument()
|
|
loaded.load(save_path)
|
|
loaded_block = loaded.lines[0].blocks[0]
|
|
|
|
assert block.point_ids[0] == 2
|
|
assert doc.points[2].line_index == 0
|
|
assert [doc.points[point_id].block_index for point_id in block.point_ids] == list(range(4))
|
|
assert loaded_block.point_ids[0] == 2
|
|
assert loaded.points[2].line_index == 0
|
|
|
|
|
|
def test_clockwise_block_from_seed_reverses_counterclockwise_corner() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "counterclockwise_loop.json"
|
|
points = [
|
|
{"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0},
|
|
{"id": 1, "x": 34.0, "y": 0.0, "row": 0, "col": 1},
|
|
{"id": 2, "x": 68.0, "y": 0.0, "row": 0, "col": 2},
|
|
{"id": 3, "x": 68.0, "y": 34.0, "row": 1, "col": 2},
|
|
{"id": 4, "x": 68.0, "y": 68.0, "row": 2, "col": 2},
|
|
{"id": 5, "x": 34.0, "y": 68.0, "row": 2, "col": 1},
|
|
{"id": 6, "x": 0.0, "y": 68.0, "row": 2, "col": 0},
|
|
{"id": 7, "x": 0.0, "y": 34.0, "row": 1, "col": 0},
|
|
]
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 0)
|
|
|
|
assert block.point_ids == [0, 7, 6, 5, 4, 3, 2, 1]
|
|
|
|
|
|
def test_clockwise_block_from_seed_uses_direction_priority() -> None:
|
|
doc = MappingDocument()
|
|
doc.load(PROJECT_ROOT / "circles_new.json")
|
|
doc.clear_all_assignments()
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 492)
|
|
first_sources = [doc.points[point_id].source_id for point_id in block.point_ids[:12]]
|
|
assert first_sources == [493, 662, 787, 753, 560, 346, 347, 683, 684, 459, 460, 811]
|
|
threshold = min_neighbor_distance_for_points(doc.points)
|
|
assert is_valid_route_neighbor(
|
|
doc.points[block.point_ids[-1]],
|
|
doc.points[block.point_ids[0]],
|
|
threshold,
|
|
)
|
|
assert doc.points[492].line_index == 0
|
|
|
|
|
|
def test_far_neighbor_is_not_valid_route_neighbor() -> None:
|
|
current = LedPoint(0, 0, 0.0, 0.0, 0, 0)
|
|
far = LedPoint(1, 1, 100.0, 0.0, 1, 0)
|
|
near = LedPoint(2, 2, 34.0, 0.0, 1, 0)
|
|
tolerated = LedPoint(4, 4, 36.0, 0.0, 1, 0)
|
|
over_limit = LedPoint(3, 3, 34.1, 0.0, 1, 0)
|
|
assert is_valid_route_neighbor(current, near, 34.0)
|
|
assert is_valid_route_neighbor(current, tolerated, 34.0)
|
|
assert not is_valid_route_neighbor(current, far, 34.0)
|
|
assert is_valid_route_neighbor(current, over_limit, 34.0)
|
|
|
|
|
|
def test_diagonal_grid_neighbor_is_valid_route_neighbor() -> None:
|
|
current = LedPoint(0, 0, 34.0, 34.0, 0, 1)
|
|
diagonal = LedPoint(1, 1, 0.0, 0.0, 1, 0)
|
|
far_diagonal = LedPoint(2, 2, -10.0, -10.0, 1, 0)
|
|
|
|
assert is_valid_route_neighbor(current, diagonal, 34.0)
|
|
assert not is_valid_route_neighbor(current, far_diagonal, 34.0)
|
|
|
|
|
|
def test_rebuild_line_blocks_splits_far_segments() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "far_segments.json"
|
|
points = [
|
|
{"id": 0, "x": 0, "y": 0, "row": 0, "col": 0},
|
|
{"id": 1, "x": 34, "y": 0, "row": 1, "col": 0},
|
|
{"id": 2, "x": 200, "y": 0, "row": 2, "col": 0},
|
|
]
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
doc.add_block(line.id, [0, 1, 2])
|
|
block_count = doc.rebuild_line_blocks_by_route(line.id)
|
|
assert block_count == 2
|
|
assert sorted(block.point_ids for block in doc.get_line(line.id).blocks) == [[0, 1], [2]]
|
|
|
|
|
|
def test_saved_coordinate_unit_is_reused() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "meters.json"
|
|
save_path = Path(temp_dir) / "saved.json"
|
|
points = [
|
|
{"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0},
|
|
{"id": 1, "x": 0.034, "y": 0.0, "row": 1, "col": 0},
|
|
]
|
|
path.write_text(json.dumps({"circles": points, "coordinate_unit": "m"}), encoding="utf-8")
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
doc.save(save_path)
|
|
payload = json.loads(save_path.read_text(encoding="utf-8"))
|
|
assert payload["coordinate_unit"] == "m"
|
|
loaded = MappingDocument()
|
|
loaded.load(save_path)
|
|
assert loaded.coordinate_unit == "m"
|
|
assert min_neighbor_distance_for_points(loaded.points) == 0.034
|
|
|
|
|
|
def test_uid_point_id_mode_does_not_fallback_to_source_ids() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "uid_mode.json"
|
|
points = fixture_points()
|
|
payload = {
|
|
"circles": points,
|
|
"lines": [
|
|
{
|
|
"id": 1,
|
|
"point_id_mode": "uid",
|
|
"blocks": [{"id": 1, "point_ids": [0, 9999, 1]}],
|
|
}
|
|
],
|
|
}
|
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
assert doc.lines[0].blocks[0].point_ids == [0, 1]
|
|
|
|
|
|
def test_panel_mapping_generation_from_document() -> None:
|
|
with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
doc.add_block(line.id, list(range(len(doc.points))))
|
|
result = generate_panel_mapping_from_document(doc, Path(temp_dir))
|
|
|
|
assert result.led_count == len(doc.points)
|
|
assert result.block_count == 1
|
|
assert result.assigned_count == len(doc.points)
|
|
assert result.unassigned_count == 0
|
|
assert result.vm_rows > 0
|
|
assert result.vm_cols > 0
|
|
for output_path in result.generated_files.values():
|
|
assert output_path.exists(), output_path
|
|
vm_map = result.generated_files["vm_map"].read_text(encoding="utf-8")
|
|
assert "VM_LED_COUNT" in vm_map
|
|
assert "#define LED_BLOCK_COUNT 1" in vm_map
|
|
assert "{0, 0}" in vm_map
|
|
ordered_payload = json.loads(result.generated_files["ordered_json"].read_text(encoding="utf-8"))
|
|
assert len(ordered_payload["leds"]) == len(doc.points)
|
|
assert ordered_payload["leds"][0]["strip"] == 0
|
|
|
|
|
|
def test_canvas_uses_grid_svg_coordinates() -> None:
|
|
if LedCanvas is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
canvas = LedCanvas()
|
|
canvas.set_document(doc)
|
|
min_x = min(point.x for point in doc.points)
|
|
max_y = max(point.y for point in doc.points)
|
|
margin = 40
|
|
for point in doc.points:
|
|
item = canvas.items_by_id[point.uid]
|
|
assert item.pos().x() == (point.x - min_x) + margin
|
|
assert item.pos().y() == (max_y - point.y) + margin
|
|
|
|
|
|
def test_canvas_rebuild_can_preserve_user_zoom() -> None:
|
|
if LedCanvas is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
canvas = LedCanvas()
|
|
canvas.set_document(doc)
|
|
canvas.resetTransform()
|
|
canvas.scale(2.5, 2.5)
|
|
before = canvas.transform()
|
|
|
|
canvas.rebuild(reset_view=False)
|
|
app.processEvents()
|
|
|
|
after = canvas.transform()
|
|
assert after.m11() == before.m11()
|
|
assert after.m22() == before.m22()
|
|
|
|
|
|
def test_grid_toggle_adds_grid_items() -> None:
|
|
if LedCanvas is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
canvas = LedCanvas()
|
|
canvas.set_document(doc)
|
|
assert not canvas.grid_items
|
|
canvas.set_grid_visible(True)
|
|
assert canvas.grid_items
|
|
|
|
scene_rect = canvas.sceneRect()
|
|
vertical_lines = [item.line() for item in canvas.grid_items if item.line().x1() == item.line().x2()]
|
|
horizontal_lines = [item.line() for item in canvas.grid_items if item.line().y1() == item.line().y2()]
|
|
assert vertical_lines
|
|
assert horizontal_lines
|
|
assert all(line.y1() <= scene_rect.top() and line.y2() >= scene_rect.bottom() for line in vertical_lines)
|
|
assert all(line.x1() <= scene_rect.left() and line.x2() >= scene_rect.right() for line in horizontal_lines)
|
|
assert min(line.x1() for line in vertical_lines) <= scene_rect.left()
|
|
assert max(line.x1() for line in vertical_lines) >= scene_rect.right()
|
|
assert min(line.y1() for line in horizontal_lines) <= scene_rect.top()
|
|
assert max(line.y1() for line in horizontal_lines) >= scene_rect.bottom()
|
|
|
|
|
|
def test_canvas_connects_new_block_to_previous_non_empty_block() -> None:
|
|
if LedCanvas is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
doc.add_block(line.id, [0, 1])
|
|
doc.add_block(line.id, [])
|
|
doc.add_block(line.id, [2, 3])
|
|
|
|
canvas = LedCanvas()
|
|
canvas.set_document(doc)
|
|
canvas.refresh_styles()
|
|
|
|
expected_start = canvas.items_by_id[1].pos()
|
|
expected_end = canvas.items_by_id[2].pos()
|
|
bridge_found = any(
|
|
path_has_segment(item.path(), expected_start.x(), expected_start.y(), expected_end.x(), expected_end.y())
|
|
for item in canvas.connection_items
|
|
)
|
|
assert bridge_found
|
|
|
|
|
|
def test_canvas_rebuild_drops_deleted_selection_items() -> None:
|
|
if LedCanvas is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
canvas = LedCanvas()
|
|
canvas.set_document(doc)
|
|
canvas.select_points([0, 1, 2])
|
|
|
|
canvas.rebuild()
|
|
canvas.rebuild()
|
|
app.processEvents()
|
|
|
|
assert canvas.selected_point_ids() == []
|
|
assert canvas._last_selected_ids == set()
|
|
|
|
|
|
def test_canvas_refresh_does_not_accumulate_connection_items() -> None:
|
|
if LedCanvas is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
doc = MappingDocument()
|
|
doc.load(PROJECT_ROOT / "circles_new.json")
|
|
doc.clear_all_assignments()
|
|
line = doc.add_line()
|
|
doc.add_block(line.id, list(range(120)))
|
|
canvas = LedCanvas()
|
|
canvas.set_document(doc)
|
|
canvas.set_active_line(line.id)
|
|
first_count = len(canvas.connection_items)
|
|
for _ in range(10):
|
|
canvas.refresh_styles()
|
|
assert len(canvas.connection_items) == first_count
|
|
assert len(canvas.items_by_id) == len(doc.points)
|
|
|
|
|
|
def test_main_window_has_classic_menu_bar() -> None:
|
|
if MainWindow is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
window = MainWindow()
|
|
app.processEvents()
|
|
|
|
menu_titles = [action.text() for action in window.menuBar().actions()]
|
|
assert menu_titles[:5] == ["Файл", "Правка", "Вид", "Окно", "Инструменты"]
|
|
assert QToolBar is None or not window.findChildren(QToolBar)
|
|
window.close()
|
|
|
|
|
|
def test_classic_menu_contains_expected_actions() -> None:
|
|
if MainWindow is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
window = MainWindow()
|
|
app.processEvents()
|
|
|
|
expected = {
|
|
"Файл": ["Открыть", "Сохранить", "Сохранить как", "Сгенерировать скрипт"],
|
|
"Правка": ["Отменить", "Повторить", "Удалить фиксацию", "Удалить все", "Очистить выбор"],
|
|
"Вид": ["Показать всё", "Сетка 34 мм"],
|
|
"Окно": ["Скрыть боковую панель"],
|
|
"Инструменты": ["Новая линия", "Сгенерировать скрипт", "Автоблоки", "Кисть"],
|
|
}
|
|
for top_action in list(window.menuBar().actions()):
|
|
menu = top_action.menu()
|
|
if menu is not None and top_action.text() in expected:
|
|
menu_texts = [action.text() for action in menu.actions() if not action.isSeparator()]
|
|
assert menu_texts == expected[top_action.text()], top_action.text()
|
|
window.close()
|
|
|
|
|
|
def test_side_panel_hides_removed_tools() -> None:
|
|
if MainWindow is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
window = MainWindow()
|
|
app.processEvents()
|
|
|
|
assert set(window.side_buttons) == {"add_line", "auto_blocks"}
|
|
assert window.side_buttons["add_line"].text() == "Линия"
|
|
assert window.side_buttons["auto_blocks"].text() == "Автоблоки"
|
|
window.close()
|
|
|
|
|
|
def test_undo_redo_roundtrip_for_line_creation() -> None:
|
|
if MainWindow is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
window = MainWindow()
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
window.document = doc
|
|
window.canvas.set_document(doc)
|
|
|
|
assert not window.actions["undo"].isEnabled()
|
|
assert not window.actions["redo"].isEnabled()
|
|
|
|
window.add_line()
|
|
app.processEvents()
|
|
|
|
assert len(window.document.lines) == 1
|
|
assert window.actions["undo"].isEnabled()
|
|
assert not window.actions["redo"].isEnabled()
|
|
|
|
window.undo_last_change()
|
|
app.processEvents()
|
|
|
|
assert len(window.document.lines) == 0
|
|
assert not window.actions["undo"].isEnabled()
|
|
assert window.actions["redo"].isEnabled()
|
|
|
|
window.redo_last_change()
|
|
app.processEvents()
|
|
|
|
assert len(window.document.lines) == 1
|
|
assert window.actions["undo"].isEnabled()
|
|
assert not window.actions["redo"].isEnabled()
|
|
|
|
window.undo_last_change()
|
|
window.add_line()
|
|
app.processEvents()
|
|
|
|
assert len(window.document.lines) == 1
|
|
assert not window.actions["redo"].isEnabled()
|
|
window._dirty = False
|
|
window.close()
|
|
|
|
|
|
def test_main_window_actions_use_lucide_icons() -> None:
|
|
if MainWindow is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
window = MainWindow()
|
|
app.processEvents()
|
|
|
|
expected_icon_actions = {
|
|
"open",
|
|
"save",
|
|
"save_as",
|
|
"generate_script",
|
|
"add_line",
|
|
"add_block",
|
|
"auto_blocks",
|
|
"clockwise",
|
|
"rebuild_route",
|
|
"brush",
|
|
"undo",
|
|
"redo",
|
|
"delete",
|
|
"clear_all",
|
|
"clear_selection",
|
|
"fit_view",
|
|
"grid",
|
|
"toggle_sidebar",
|
|
}
|
|
missing = [key for key in expected_icon_actions if window.actions[key].icon().isNull()]
|
|
assert not missing
|
|
window.close()
|
|
|
|
|
|
def test_main_window_action_shortcuts_are_assigned() -> None:
|
|
if MainWindow is None or QApplication is None or QKeySequence is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
window = MainWindow()
|
|
app.processEvents()
|
|
|
|
expected = {
|
|
"generate_script": "Ctrl+G",
|
|
"clear_all": "Ctrl+Shift+Del",
|
|
"clear_selection": "Esc",
|
|
"fit_view": "Ctrl+0",
|
|
"grid": "G",
|
|
"toggle_sidebar": "F9",
|
|
"add_line": "Ctrl+L",
|
|
"auto_blocks": "Ctrl+Shift+B",
|
|
"brush": "B",
|
|
}
|
|
for key, shortcut in expected.items():
|
|
assert window.actions[key].shortcut().matches(QKeySequence(shortcut)) == QKeySequence.SequenceMatch.ExactMatch
|
|
assert window.actions[key].shortcutContext() == Qt.ApplicationShortcut
|
|
window.close()
|
|
|
|
|
|
def test_lucide_icon_names_are_declared() -> None:
|
|
expected_names = {
|
|
"archive-restore",
|
|
"chevron-right",
|
|
"eraser",
|
|
"eye",
|
|
"file-plus",
|
|
"file-code",
|
|
"folder-open",
|
|
"grid-2x2",
|
|
"layers",
|
|
"paintbrush",
|
|
"plus",
|
|
"redo-2",
|
|
"rotate-cw",
|
|
"save",
|
|
"save-all",
|
|
"trash-2",
|
|
"undo-2",
|
|
}
|
|
assert expected_names.issubset(set(LUCIDE_PATHS))
|
|
|
|
|
|
def test_table_hides_empty_line_index_columns() -> None:
|
|
if MainWindow is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
window = MainWindow()
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
first_line = doc.add_line()
|
|
second_line = doc.add_line()
|
|
doc.add_block(first_line.id, [0, 1])
|
|
doc.add_block(second_line.id, [4])
|
|
window.document = doc
|
|
|
|
window._on_selection_changed([0, 1])
|
|
app.processEvents()
|
|
assert [window.table.isColumnHidden(column) for column in range(6, 10)] == [False, True, True, True]
|
|
|
|
window._on_selection_changed([0, 4])
|
|
app.processEvents()
|
|
assert [window.table.isColumnHidden(column) for column in range(6, 10)] == [False, False, True, True]
|
|
|
|
window._on_selection_changed([2, 3])
|
|
app.processEvents()
|
|
assert [window.table.isColumnHidden(column) for column in range(6, 10)] == [True, True, True, True]
|
|
window.close()
|
|
|
|
|
|
def test_sidebar_collapses_and_expands() -> None:
|
|
if MainWindow is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
window = MainWindow()
|
|
app.processEvents()
|
|
|
|
assert window.sidebar_splitter is not None
|
|
assert window.sidebar_stack is not None
|
|
window.sidebar_splitter.setSizes([SIDEBAR_MIN_WIDTH - 20, 1000])
|
|
window._on_sidebar_splitter_moved(SIDEBAR_MIN_WIDTH - 20, 1)
|
|
app.processEvents()
|
|
|
|
assert window._sidebar_collapsed
|
|
assert window.sidebar_stack.currentIndex() == 1
|
|
assert window.sidebar_splitter.sizes()[0] == SIDEBAR_COLLAPSED_WIDTH
|
|
|
|
window.sidebar_splitter.moveSplitter(0, 1)
|
|
window._on_sidebar_splitter_moved(0, 1)
|
|
app.processEvents()
|
|
|
|
assert window._sidebar_collapsed
|
|
assert window.sidebar_stack.currentIndex() == 1
|
|
assert window.sidebar_tab is not None
|
|
assert not window.sidebar_tab.isHidden()
|
|
assert window.sidebar_splitter.sizes()[0] == SIDEBAR_COLLAPSED_WIDTH
|
|
|
|
window.sidebar_splitter.setSizes([0, 1000])
|
|
window._on_sidebar_splitter_moved(0, 1)
|
|
app.processEvents()
|
|
|
|
assert window._sidebar_collapsed
|
|
assert window.sidebar_stack.currentIndex() == 1
|
|
assert window.sidebar_splitter.sizes()[0] == SIDEBAR_COLLAPSED_WIDTH
|
|
|
|
window.expand_sidebar()
|
|
app.processEvents()
|
|
|
|
assert not window._sidebar_collapsed
|
|
assert window.sidebar_stack.currentIndex() == 0
|
|
assert window.sidebar_splitter.sizes()[0] >= SIDEBAR_MIN_WIDTH
|
|
window.close()
|
|
|
|
|
|
def test_sidebar_action_toggle_updates_state_and_constraints() -> None:
|
|
if MainWindow is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
window = MainWindow()
|
|
app.processEvents()
|
|
|
|
assert window.sidebar_splitter is not None
|
|
assert window.sidebar_stack is not None
|
|
window.sidebar_splitter.setSizes([240, 1000])
|
|
window._on_sidebar_splitter_moved(240, 1)
|
|
|
|
window.actions["toggle_sidebar"].trigger()
|
|
app.processEvents()
|
|
|
|
assert window._sidebar_collapsed
|
|
assert window.actions["toggle_sidebar"].text() == "Показать боковую панель"
|
|
assert window.sidebar_stack.minimumWidth() == SIDEBAR_COLLAPSED_WIDTH
|
|
assert window.sidebar_stack.maximumWidth() > SIDEBAR_MIN_WIDTH
|
|
|
|
window.actions["toggle_sidebar"].trigger()
|
|
app.processEvents()
|
|
|
|
assert not window._sidebar_collapsed
|
|
assert window.actions["toggle_sidebar"].text() == "Скрыть боковую панель"
|
|
assert window.sidebar_stack.minimumWidth() == 0
|
|
assert window.sidebar_stack.maximumWidth() > SIDEBAR_MIN_WIDTH
|
|
assert abs(window.sidebar_splitter.sizes()[0] - 240) <= 2
|
|
window.close()
|
|
|
|
|
|
def test_sidebar_collapsed_tab_click_and_drag_expand() -> None:
|
|
if MainWindow is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
window = MainWindow()
|
|
app.processEvents()
|
|
|
|
assert window.sidebar_splitter is not None
|
|
assert window.sidebar_tab_container is not None
|
|
|
|
window.collapse_sidebar()
|
|
app.processEvents()
|
|
window.sidebar_tab_container._expand_handler()
|
|
app.processEvents()
|
|
assert not window._sidebar_collapsed
|
|
assert window.sidebar_stack is not None
|
|
assert window.sidebar_stack.currentIndex() == 0
|
|
|
|
window.collapse_sidebar()
|
|
app.processEvents()
|
|
intermediate_width = SIDEBAR_MIN_WIDTH - 20
|
|
window.sidebar_splitter.setSizes([intermediate_width, 1000])
|
|
window._on_sidebar_splitter_moved(intermediate_width, 1)
|
|
app.processEvents()
|
|
|
|
assert window._sidebar_collapsed
|
|
assert window.sidebar_stack.currentIndex() == 1
|
|
|
|
window.sidebar_splitter.setSizes([SIDEBAR_MIN_WIDTH + 20, 1000])
|
|
window._on_sidebar_splitter_moved(SIDEBAR_MIN_WIDTH + 20, 1)
|
|
app.processEvents()
|
|
|
|
assert not window._sidebar_collapsed
|
|
assert window.sidebar_stack.currentIndex() == 0
|
|
assert window.sidebar_splitter.sizes()[0] >= SIDEBAR_MIN_WIDTH
|
|
window.close()
|
|
|
|
|
|
def test_canvas_select_points_skips_duplicate_selection_emit() -> None:
|
|
if LedCanvas is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
canvas = LedCanvas()
|
|
canvas.set_document(doc)
|
|
emitted: list[list[int]] = []
|
|
canvas.selectionChanged.connect(lambda ids: emitted.append(ids))
|
|
|
|
canvas.select_points([0, 1])
|
|
canvas.select_points([0, 1])
|
|
app.processEvents()
|
|
|
|
assert emitted == [[0, 1]]
|
|
|
|
|
|
def test_canvas_brush_cursor_survives_pan_cursor_reset() -> None:
|
|
if LedCanvas is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
canvas = LedCanvas()
|
|
canvas.set_brush_enabled(True)
|
|
|
|
assert canvas.cursor().shape() == Qt.CrossCursor
|
|
assert canvas.viewport().cursor().shape() == Qt.CrossCursor
|
|
|
|
canvas.setCursor(Qt.ClosedHandCursor)
|
|
canvas.viewport().setCursor(Qt.ClosedHandCursor)
|
|
canvas._apply_default_cursor()
|
|
app.processEvents()
|
|
|
|
assert canvas.cursor().shape() == Qt.CrossCursor
|
|
assert canvas.viewport().cursor().shape() == Qt.CrossCursor
|
|
|
|
canvas.set_brush_enabled(False)
|
|
assert canvas.cursor().shape() == Qt.ArrowCursor
|
|
assert canvas.viewport().cursor().shape() == Qt.ArrowCursor
|
|
|
|
|
|
def test_canvas_brush_zone_is_visible_and_collects_points() -> None:
|
|
if LedCanvas is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
canvas = LedCanvas()
|
|
canvas.set_document(doc)
|
|
touched: list[int] = []
|
|
canvas.brushPointTouched.connect(lambda point_id: touched.append(point_id))
|
|
|
|
canvas.set_brush_enabled(True)
|
|
viewport_pos = canvas.mapFromScene(canvas.items_by_id[0].pos())
|
|
canvas._update_brush_zone(viewport_pos)
|
|
canvas._touch_brush_point(viewport_pos)
|
|
app.processEvents()
|
|
|
|
assert canvas._brush_zone_item is not None
|
|
assert canvas._brush_zone_item.isVisible()
|
|
assert QGraphicsItem is not None
|
|
assert canvas._brush_zone_item.flags() & QGraphicsItem.ItemIgnoresTransformations
|
|
assert len(touched) > 1
|
|
assert 0 in touched
|
|
|
|
canvas.set_brush_enabled(False)
|
|
assert canvas._brush_zone_item is not None
|
|
assert not canvas._brush_zone_item.isVisible()
|
|
|
|
|
|
def test_canvas_brush_zone_uses_viewport_radius_after_zoom() -> None:
|
|
if LedCanvas is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
doc = MappingDocument()
|
|
doc.points = [
|
|
LedPoint(0, 0, 0.0, 0.0, 0, 0),
|
|
LedPoint(1, 1, 34.0, 0.0, 0, 1),
|
|
]
|
|
canvas = LedCanvas()
|
|
canvas.set_document(doc)
|
|
canvas.resetTransform()
|
|
canvas.scale(0.5, 0.5)
|
|
touched: list[int] = []
|
|
canvas.brushPointTouched.connect(lambda point_id: touched.append(point_id))
|
|
|
|
canvas.set_brush_enabled(True)
|
|
viewport_pos = canvas.mapFromScene(canvas.items_by_id[0].pos())
|
|
canvas._update_brush_zone(viewport_pos)
|
|
canvas._touch_brush_point(viewport_pos)
|
|
app.processEvents()
|
|
|
|
assert set(touched) == {0, 1}
|
|
|
|
|
|
def test_canvas_brush_preview_highlights_only_actionable_points() -> None:
|
|
if LedCanvas is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
doc = MappingDocument()
|
|
doc.points = [
|
|
LedPoint(0, 0, 0.0, 0.0, 0, 0),
|
|
LedPoint(1, 1, 8.0, 0.0, 0, 1),
|
|
LedPoint(2, 2, 80.0, 0.0, 0, 2),
|
|
]
|
|
canvas = LedCanvas()
|
|
canvas.set_document(doc)
|
|
canvas.resetTransform()
|
|
canvas.set_brush_enabled(True)
|
|
canvas.set_brush_excluded_point_ids({0})
|
|
|
|
viewport_pos = canvas.mapFromScene(canvas.items_by_id[0].pos())
|
|
canvas._update_brush_zone(viewport_pos)
|
|
app.processEvents()
|
|
|
|
assert canvas._brush_preview_ids == {1}
|
|
assert canvas.items_by_id[1].brush().color() == QColor("#fbbf24")
|
|
assert canvas.items_by_id[0].brush().color() != QColor("#fbbf24")
|
|
|
|
canvas._touch_brush_point(viewport_pos)
|
|
assert canvas._brush_touched_ids == {1}
|
|
assert canvas.items_by_id[1].brush().color() == QColor("#f43f5e")
|
|
|
|
canvas.set_brush_enabled(False)
|
|
assert canvas._brush_preview_ids == set()
|
|
assert canvas._brush_touched_ids == set()
|
|
assert canvas.items_by_id[1].brush().color() == QColor("#94a3b8")
|
|
|
|
|
|
def test_brush_can_create_new_block_without_preselected_block() -> None:
|
|
if MainWindow is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
window = MainWindow()
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
window.document = doc
|
|
window.canvas.set_document(doc)
|
|
|
|
window.actions["brush"].setChecked(True)
|
|
window.toggle_brush_mode()
|
|
window.add_brush_point_to_block(0)
|
|
window.add_brush_point_to_block(1)
|
|
window.finish_brush_stroke()
|
|
app.processEvents()
|
|
|
|
assert len(window.document.lines) == 1
|
|
assert len(window.document.lines[0].blocks) == 1
|
|
assert window.document.lines[0].blocks[0].point_ids == [0, 1]
|
|
window._dirty = False
|
|
window.close()
|
|
|
|
|
|
def test_brush_continues_block_from_selected_led() -> None:
|
|
if MainWindow is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
window = MainWindow()
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
block = doc.add_block(line.id, [0, 1])
|
|
window.document = doc
|
|
window.active_line_id = line.id
|
|
window.canvas.set_document(doc)
|
|
window.canvas.select_points([0])
|
|
|
|
window.actions["brush"].setChecked(True)
|
|
window.toggle_brush_mode()
|
|
window.add_brush_point_to_block(2)
|
|
window.finish_brush_stroke()
|
|
app.processEvents()
|
|
|
|
assert len(window.document.get_line(line.id).blocks) == 1
|
|
assert window.document.get_line(line.id).blocks[0].id == block.id
|
|
assert window.document.get_line(line.id).blocks[0].point_ids == [0, 1, 2]
|
|
window._dirty = False
|
|
window.close()
|
|
|
|
|
|
def test_main_window_restores_table_state_after_selection_error() -> None:
|
|
if MainWindow is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
window = MainWindow()
|
|
window.table.setSortingEnabled(True)
|
|
|
|
def failing_get_point(_point_id: int):
|
|
raise RuntimeError("synthetic selection failure")
|
|
|
|
window.document.get_point = failing_get_point
|
|
try:
|
|
window._on_selection_changed([0])
|
|
except RuntimeError:
|
|
pass
|
|
else:
|
|
raise AssertionError("selection failure should propagate")
|
|
|
|
assert not window._updating_table
|
|
assert not window.table.signalsBlocked()
|
|
assert window.table.updatesEnabled()
|
|
assert window.table.isSortingEnabled()
|
|
window.close()
|
|
|
|
|
|
def test_main_window_restores_tree_state_after_refresh_error() -> None:
|
|
if MainWindow is None or QApplication is None:
|
|
return
|
|
app = QApplication.instance() or QApplication([])
|
|
window = MainWindow()
|
|
with temp_document_path() as path:
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
doc.add_block(line.id, [0])
|
|
window.document = doc
|
|
|
|
def failing_attach(*_args, **_kwargs):
|
|
raise RuntimeError("synthetic tree failure")
|
|
|
|
window._attach_block_widget = failing_attach
|
|
try:
|
|
window.refresh_tree()
|
|
except RuntimeError:
|
|
pass
|
|
else:
|
|
raise AssertionError("tree refresh failure should propagate")
|
|
|
|
assert not window.tree.signalsBlocked()
|
|
assert window.tree.updatesEnabled()
|
|
window.close()
|
|
|
|
|
|
def test_package_imports_work_from_project_root() -> None:
|
|
importlib.import_module("app.canvas")
|
|
importlib.import_module("app.main_window")
|
|
|
|
|
|
def path_has_segment(path, x1: float, y1: float, x2: float, y2: float) -> bool: # noqa: ANN001
|
|
for index in range(path.elementCount() - 1):
|
|
first = path.elementAt(index)
|
|
second = path.elementAt(index + 1)
|
|
if first.x == x1 and first.y == y1 and second.x == x2 and second.y == y2:
|
|
return True
|
|
return False
|
|
|
|
|
|
def test_route_adjacency_large_grid_perf_guard() -> None:
|
|
points = [
|
|
LedPoint(uid, uid, (uid % 30) * 34.0, (uid // 30) * 34.0, uid // 30, uid % 30)
|
|
for uid in range(900)
|
|
]
|
|
started = time.perf_counter()
|
|
adjacency = build_route_adjacency(points, 34.0)
|
|
elapsed = time.perf_counter() - started
|
|
assert sum(len(neighbors) for neighbors in adjacency.values()) > 0
|
|
assert elapsed < 1.0
|
|
|
|
|
|
def test_route_prefers_up_left_down_right() -> None:
|
|
points = [
|
|
LedPoint(0, 0, 0.0, 0.0, 1, 1),
|
|
LedPoint(1, 1, 0.0, 34.0, 0, 1),
|
|
LedPoint(2, 2, -34.0, 0.0, 1, 0),
|
|
LedPoint(3, 3, 0.0, -34.0, 2, 1),
|
|
LedPoint(4, 4, 34.0, 0.0, 1, 2),
|
|
]
|
|
ordered = order_points_like_grid_svg(points, 0)
|
|
assert [point.uid for point in ordered[:2]] == [0, 1]
|
|
|
|
|
|
def test_route_prefers_coordinate_up_when_grid_index_is_not_adjacent() -> None:
|
|
points = [
|
|
LedPoint(0, 0, 0.0, 0.0, 10, 10),
|
|
LedPoint(1, 1, 0.0, 34.0, 50, 10),
|
|
LedPoint(2, 2, 34.0, 0.0, 10, 11),
|
|
]
|
|
doc = MappingDocument()
|
|
doc.points = points
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 0)
|
|
|
|
assert block.point_ids[:2] == [0, 1]
|
|
|
|
|
|
def test_first_route_step_prefers_up_before_slightly_closer_right() -> None:
|
|
points = [
|
|
LedPoint(0, 0, 0.0, 0.0, 10, 10),
|
|
LedPoint(1, 1, 0.0, 35.5, 11, 10),
|
|
LedPoint(2, 2, 34.0, 0.0, 10, 11),
|
|
]
|
|
doc = MappingDocument()
|
|
doc.points = points
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 0)
|
|
|
|
assert block.point_ids[:2] == [0, 1]
|
|
|
|
|
|
def test_route_does_not_jump_over_intermediate_led() -> None:
|
|
points = [
|
|
LedPoint(0, 0, 0.0, 0.0, 0, 0),
|
|
LedPoint(1, 1, 0.0, 17.0, 1, 0),
|
|
LedPoint(2, 2, 0.0, 34.0, 2, 0),
|
|
LedPoint(3, 3, 34.0, 0.0, 0, 1),
|
|
]
|
|
doc = MappingDocument()
|
|
doc.points = points
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 0)
|
|
|
|
assert block.point_ids[:3] == [0, 1, 2]
|
|
|
|
|
|
def test_route_does_not_use_grid_indexes_to_skip_nearer_physical_neighbor() -> None:
|
|
points = [
|
|
LedPoint(0, 0, 0.0, 0.0, 0, 0),
|
|
LedPoint(1, 1, 34.0, 0.0, 0, 1),
|
|
LedPoint(2, 2, 68.0, 0.0, 0, 2),
|
|
LedPoint(3, 3, 68.0, 28.0, 50, 50),
|
|
LedPoint(4, 4, 102.0, 0.0, 0, 3),
|
|
]
|
|
doc = MappingDocument()
|
|
doc.points = points
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 0)
|
|
|
|
assert block.point_ids[:4] == [0, 1, 2, 3]
|
|
|
|
|
|
def test_route_continues_after_curved_sparse_step() -> None:
|
|
points = [
|
|
LedPoint(0, 0, 0.0, 0.0, 0, 0),
|
|
LedPoint(1, 1, 34.0, -34.0, 1, 1),
|
|
LedPoint(2, 2, 68.0, -48.0, 2, 2),
|
|
LedPoint(3, 3, 102.0, -56.0, 3, 3),
|
|
]
|
|
doc = MappingDocument()
|
|
doc.points = points
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 0)
|
|
|
|
assert block.point_ids == [0, 1, 2, 3]
|
|
|
|
|
|
def test_auto_block_continues_past_point_neighboring_start() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "closed_with_tail.json"
|
|
points = [
|
|
{"id": 0, "x": 0.0, "y": 0.0, "row": 1, "col": 1},
|
|
{"id": 1, "x": 0.0, "y": 34.0, "row": 0, "col": 1},
|
|
{"id": 2, "x": 34.0, "y": 34.0, "row": 0, "col": 2},
|
|
{"id": 3, "x": 34.0, "y": 0.0, "row": 1, "col": 2},
|
|
{"id": 4, "x": 68.0, "y": 0.0, "row": 1, "col": 3},
|
|
]
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 0)
|
|
|
|
assert sorted(block.point_ids) == [0, 1, 2, 3, 4]
|
|
assert doc.points[4].line_id == line.id
|
|
|
|
|
|
def test_auto_block_does_not_close_before_meaningful_continuation() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "premature_close.json"
|
|
points = [
|
|
{"id": 0, "x": 0.0, "y": 0.0, "row": 1, "col": 1},
|
|
{"id": 1, "x": 0.0, "y": 34.0, "row": 0, "col": 1},
|
|
{"id": 2, "x": 34.0, "y": 34.0, "row": 0, "col": 2},
|
|
{"id": 3, "x": 34.0, "y": 0.0, "row": 1, "col": 2},
|
|
{"id": 4, "x": 68.0, "y": 0.0, "row": 1, "col": 3},
|
|
{"id": 5, "x": 68.0, "y": 34.0, "row": 0, "col": 3},
|
|
]
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 0)
|
|
|
|
assert block.point_ids[:4] != [0, 1, 2, 3]
|
|
assert 4 in block.point_ids
|
|
assert 5 in block.point_ids
|
|
|
|
|
|
def test_parallel_track_middle_edges_are_filtered() -> None:
|
|
points = [
|
|
LedPoint(0, 0, 0.0, 0.0, 0, 0),
|
|
LedPoint(1, 1, 0.0, 34.0, 1, 0),
|
|
LedPoint(2, 2, 0.0, 68.0, 2, 0),
|
|
LedPoint(3, 3, 34.0, 0.0, 0, 1),
|
|
LedPoint(4, 4, 34.0, 34.0, 1, 1),
|
|
LedPoint(5, 5, 34.0, 68.0, 2, 1),
|
|
]
|
|
adjacency = build_route_adjacency(points, 34.0)
|
|
assert 4 not in [point.uid for point in adjacency[1]]
|
|
assert 1 not in [point.uid for point in adjacency[4]]
|
|
|
|
|
|
def test_route_keeps_close_point_on_smooth_bend() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "smooth_bend.json"
|
|
points = [
|
|
{"id": 0, "x": 0.0, "y": 0.0, "row": 3, "col": 0},
|
|
{"id": 1, "x": 0.0, "y": 34.0, "row": 2, "col": 0},
|
|
{"id": 2, "x": 17.0, "y": 51.0, "row": 1, "col": 1},
|
|
{"id": 3, "x": 34.0, "y": 68.0, "row": 1, "col": 2},
|
|
{"id": 4, "x": 68.0, "y": 68.0, "row": 1, "col": 3},
|
|
]
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 0)
|
|
|
|
assert block.point_ids == [0, 1, 2, 3, 4]
|
|
|
|
|
|
def test_new_block_does_not_inherit_previous_block_trajectory() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "independent_new_block.json"
|
|
points = [
|
|
{"id": 0, "x": -34.0, "y": 0.0, "row": 0, "col": -1},
|
|
{"id": 1, "x": 0.0, "y": 0.0, "row": 0, "col": 0},
|
|
{"id": 2, "x": 0.0, "y": 34.0, "row": 1, "col": 0},
|
|
{"id": 3, "x": 34.0, "y": 0.0, "row": 0, "col": 1},
|
|
]
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
doc.add_block(line.id, [0])
|
|
block = doc.add_clockwise_block_from_seed(line.id, 1)
|
|
|
|
assert block.point_ids[:2] == [1, 2]
|
|
|
|
|
|
def test_route_prefers_current_block_trajectory_after_first_step() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "current_block_trajectory.json"
|
|
points = [
|
|
{"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0},
|
|
{"id": 1, "x": 34.0, "y": 0.0, "row": 0, "col": 1},
|
|
{"id": 2, "x": 68.0, "y": 0.0, "row": 0, "col": 2},
|
|
{"id": 3, "x": 34.0, "y": -34.0, "row": 1, "col": 1},
|
|
]
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 0)
|
|
|
|
assert block.point_ids[:3] == [0, 1, 2]
|
|
|
|
|
|
def test_route_prefers_near_neighbor_before_far_trajectory() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "near_before_far.json"
|
|
points = [
|
|
{"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0},
|
|
{"id": 1, "x": 34.0, "y": 0.0, "row": 0, "col": 1},
|
|
{"id": 2, "x": 34.0, "y": -34.0, "row": 1, "col": 1},
|
|
{"id": 3, "x": 102.0, "y": 0.0, "row": 0, "col": 3},
|
|
]
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 0)
|
|
|
|
assert block.point_ids[:3] == [0, 1, 2]
|
|
|
|
|
|
def test_route_continues_trajectory_across_sparse_gap() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "sparse_gap.json"
|
|
points = [
|
|
{"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0},
|
|
{"id": 1, "x": 0.0, "y": 34.0, "row": 1, "col": 0},
|
|
{"id": 2, "x": 0.0, "y": 102.0, "row": 3, "col": 0},
|
|
{"id": 3, "x": 0.0, "y": 136.0, "row": 4, "col": 0},
|
|
]
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 0)
|
|
|
|
assert block.point_ids == [0, 1, 2, 3]
|
|
|
|
|
|
def test_clockwise_route_keeps_straight_track_at_branch() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "straight_branch.json"
|
|
points = [
|
|
{"id": 0, "x": 0.0, "y": 0.0, "row": 3, "col": 0},
|
|
{"id": 1, "x": 0.0, "y": 34.0, "row": 2, "col": 0},
|
|
{"id": 2, "x": 0.0, "y": 68.0, "row": 1, "col": 0},
|
|
{"id": 3, "x": 0.0, "y": 102.0, "row": 0, "col": 0},
|
|
{"id": 4, "x": 34.0, "y": 34.0, "row": 2, "col": 1},
|
|
{"id": 5, "x": 68.0, "y": 34.0, "row": 2, "col": 2},
|
|
]
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 0)
|
|
|
|
assert block.point_ids[:4] == [0, 1, 2, 3]
|
|
|
|
|
|
def test_clockwise_route_turns_right_when_straight_is_absent() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "right_turn.json"
|
|
points = [
|
|
{"id": 0, "x": 0.0, "y": 34.0, "row": 0, "col": 0},
|
|
{"id": 1, "x": 34.0, "y": 34.0, "row": 0, "col": 1},
|
|
{"id": 2, "x": 34.0, "y": 0.0, "row": 1, "col": 1},
|
|
]
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 0)
|
|
|
|
assert block.point_ids[:3] == [0, 1, 2]
|
|
|
|
|
|
def test_auto_block_continues_through_left_down_diagonal() -> None:
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
path = Path(temp_dir) / "left_down_diagonal.json"
|
|
points = [
|
|
{"id": 0, "x": 68.0, "y": 68.0, "row": 0, "col": 2},
|
|
{"id": 1, "x": 34.0, "y": 68.0, "row": 0, "col": 1},
|
|
{"id": 2, "x": 0.0, "y": 34.0, "row": 1, "col": 0},
|
|
{"id": 3, "x": 0.0, "y": 0.0, "row": 2, "col": 0},
|
|
]
|
|
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
|
|
|
|
doc = MappingDocument()
|
|
doc.load(path)
|
|
line = doc.add_line()
|
|
block = doc.add_clockwise_block_from_seed(line.id, 0)
|
|
|
|
assert block.point_ids == [0, 1, 2, 3]
|
|
|
|
|
|
def test_too_close_neighbor_has_lowest_priority() -> None:
|
|
points = [
|
|
LedPoint(0, 0, 0.0, 0.0, 0, 0),
|
|
LedPoint(1, 1, 10.0, 0.0, 1, 0),
|
|
LedPoint(2, 2, 34.0, 0.0, 1, 1),
|
|
]
|
|
ordered = order_points_like_grid_svg(points, 0)
|
|
assert [point.uid for point in ordered[:2]] == [0, 2]
|
|
|
|
|
|
def test_meter_like_coordinates_use_34_mm_threshold() -> None:
|
|
points = [
|
|
LedPoint(0, 0, 0.0, 0.0, 0, 0),
|
|
LedPoint(1, 1, 0.010, 0.0, 1, 0),
|
|
LedPoint(2, 2, 0.034, 0.0, 1, 1),
|
|
LedPoint(3, 3, 0.037, 0.0, 1, 2),
|
|
]
|
|
assert min_neighbor_distance_for_points(points) == 0.034
|
|
assert not is_valid_route_neighbor(points[0], points[3], 0.034)
|
|
ordered = order_points_like_grid_svg(points, 0)
|
|
assert [point.uid for point in ordered[:2]] == [0, 2]
|
|
|
|
|
|
def temp_document_path():
|
|
temp_dir = tempfile.TemporaryDirectory()
|
|
path = Path(temp_dir.name) / "fixture.json"
|
|
path.write_text(json.dumps({"circles": fixture_points()}), encoding="utf-8")
|
|
|
|
class TempPath:
|
|
def __enter__(self) -> Path:
|
|
return path
|
|
|
|
def __exit__(self, exc_type, exc, traceback) -> None:
|
|
temp_dir.cleanup()
|
|
|
|
return TempPath()
|
|
|
|
|
|
def fixture_points() -> list[dict[str, int]]:
|
|
points: list[dict[str, int]] = []
|
|
uid = 0
|
|
for base_x, base_y in [(0, 0), (100, 0), (0, 100), (100, 100)]:
|
|
for offset in range(4):
|
|
points.append(
|
|
{
|
|
"id": uid,
|
|
"x": base_x + offset,
|
|
"y": base_y,
|
|
"row": base_y + offset,
|
|
"col": base_x + offset,
|
|
}
|
|
)
|
|
uid += 1
|
|
return points
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|