LED-mapper/qa_smoke.py

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()