LED-mapper/qa_smoke.py

2124 lines
77 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.localization import localize_dialog_buttons, localize_message_box
from app.main_window import MainWindow, SIDEBAR_COLLAPSED_WIDTH, SIDEBAR_MIN_WIDTH
from app.settings import DEFAULT_SHORTCUTS, AppSettings
from app.settings_dialog import SettingsDialog
from app.theme import apply_app_theme, effective_theme
from PySide6.QtCore import QPoint, Qt
from PySide6.QtGui import QColor, QKeySequence
from PySide6.QtWidgets import QApplication, QDialogButtonBox, QGraphicsItem, QMessageBox, QToolBar
except ImportError:
LedCanvas = None
LUCIDE_PATHS = {}
localize_dialog_buttons = None
localize_message_box = None
MainWindow = None
AppSettings = None
DEFAULT_SHORTCUTS = {}
SettingsDialog = None
apply_app_theme = None
effective_theme = None
SIDEBAR_COLLAPSED_WIDTH = 34
SIDEBAR_MIN_WIDTH = 180
QPoint = None
Qt = None
QColor = None
QKeySequence = None
QApplication = None
QDialogButtonBox = None
QGraphicsItem = None
QMessageBox = 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_remove_block_save_load_preserves_line_identity,
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_draws_direction_arrows_on_connections,
test_canvas_can_toggle_direction_arrows,
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_main_window_direction_arrow_action_toggles_canvas,
test_settings_apply_to_canvas_and_generation,
test_settings_dialog_has_requested_categories,
test_theme_resolution_and_canvas_apply,
test_shortcut_settings_are_editable_and_layout_independent,
test_standard_dialog_buttons_are_localized,
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_pan_mode_helpers_follow_settings,
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_zoom_anchor_is_same_with_brush_enabled,
test_canvas_brush_preview_highlights_only_actionable_points,
test_brush_can_create_new_block_without_preselected_block,
test_brush_default_stroke_creates_new_block_even_with_selected_block,
test_brush_shift_stroke_continues_selected_block,
test_main_window_restores_table_state_after_selection_error,
test_main_window_restores_tree_state_after_refresh_error,
test_focus_change_does_not_run_full_ui_refresh,
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_remove_block_save_load_preserves_line_identity() -> None:
with temp_document_path() as path:
save_path = path.with_name("line_identity_after_block_delete.json")
doc = MappingDocument()
doc.load(path)
line_one = doc.add_line()
line_two = doc.add_line()
line_three = doc.add_line()
doc.add_block(line_one.id, [8, 9])
removed_block = doc.add_block(line_two.id, [0, 1])
doc.add_block(line_three.id, [4, 5])
expected_ids = [line.id for line in doc.lines]
expected_names = [line.name for line in doc.lines]
doc.remove_block(line_two.id, removed_block.id)
doc.save(save_path)
assert [line.id for line in doc.lines] == expected_ids
assert [line.name for line in doc.lines] == expected_names
loaded = MappingDocument()
loaded.load(save_path)
assert [line.id for line in loaded.lines] == expected_ids
assert [line.name for line in loaded.lines] == expected_names
assert loaded.get_line(line_one.id).blocks[0].point_ids == [8, 9]
assert loaded.get_line(line_two.id).blocks == []
assert loaded.get_line(line_three.id).blocks[0].point_ids == [4, 5]
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_draws_direction_arrows_on_connections() -> None:
if LedCanvas is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "arrows.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},
]
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])
canvas = LedCanvas()
canvas.set_document(doc)
canvas.set_active_line(line.id)
arrow_paths = [item.path() for item in canvas.connection_items if item.zValue() == 2]
assert arrow_paths
assert sum(path.elementCount() for path in arrow_paths) >= 6
def test_canvas_can_toggle_direction_arrows() -> None:
if LedCanvas is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "arrows-toggle.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},
]
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])
canvas = LedCanvas()
canvas.set_document(doc)
canvas.set_active_line(line.id)
assert any(item.zValue() == 2 for item in canvas.connection_items)
canvas.set_direction_arrows_visible(False)
assert canvas.direction_arrows_visible() is False
assert not any(item.zValue() == 2 for item in canvas.connection_items)
canvas.set_direction_arrows_visible(True)
assert canvas.direction_arrows_visible() is True
assert any(item.zValue() == 2 for item in canvas.connection_items)
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[:4] == ["Файл", "Правка", "Вид", "Инструменты"]
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",
"settings",
"generate_script",
"add_line",
"add_block",
"auto_blocks",
"clockwise",
"rebuild_route",
"brush",
"undo",
"redo",
"delete",
"clear_all",
"clear_selection",
"fit_view",
"grid",
"direction_arrows",
"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",
"settings": "Ctrl+,",
"clear_all": "Ctrl+Shift+Del",
"clear_selection": "Esc",
"fit_view": "Ctrl+0",
"grid": "G",
"direction_arrows": "Ctrl+Shift+A",
"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_main_window_direction_arrow_action_toggles_canvas() -> None:
if MainWindow is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
window = MainWindow()
app.processEvents()
action = window.actions["direction_arrows"]
assert action.isChecked()
assert window.canvas.direction_arrows_visible() is True
action.trigger()
assert not action.isChecked()
assert window.canvas.direction_arrows_visible() is False
action.trigger()
assert action.isChecked()
assert window.canvas.direction_arrows_visible() is True
window.close()
def test_settings_apply_to_canvas_and_generation() -> None:
if MainWindow is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
window = MainWindow()
with tempfile.TemporaryDirectory() as temp_dir:
window.settings.show_grid_by_default = True
window.settings.show_direction_arrows_by_default = False
window.settings.theme = "dark"
window.settings.brush_radius = 31
window.settings.zoom_focus_pull = 12
window.settings.pan_mode = "space"
window.settings.instant_space_pan = True
window.settings.default_output_dir = temp_dir
window.settings.confirm_partial_generation = False
window._apply_settings_to_actions()
window._apply_canvas_settings()
apply_app_theme(window.settings.theme)
assert window.actions["grid"].isChecked()
assert not window.actions["direction_arrows"].isChecked()
assert window.canvas._grid_visible is True
assert window.canvas.direction_arrows_visible() is False
assert window.canvas._brush_radius == 31
assert abs(window.canvas._zoom_focus_pull - 0.12) < 0.001
assert window.canvas._pan_mode == "space"
assert window.canvas._instant_space_pan is True
assert window.canvas._theme == "dark"
assert window.settings.theme == "dark"
window.close()
def test_settings_dialog_has_requested_categories() -> None:
if SettingsDialog is None or AppSettings is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
dialog = SettingsDialog(AppSettings())
section_titles = [dialog.section_list.item(index).text() for index in range(dialog.section_list.count())]
assert section_titles == ["Внешний вид", "Поведение", "Инструменты", "Файлы и генерация", "Комбинации клавиш"]
assert dialog.section_stack.count() == len(section_titles)
assert dialog.theme.itemText(0) == "Системная"
assert dialog.theme.itemText(1) == "Светлая"
assert dialog.theme.itemText(2) == "Темная"
assert dialog.theme.currentData() == "system"
assert dialog.pan_mode.currentData() == "middle_space"
assert dialog.instant_space_pan.isChecked() is False
dialog.close()
def test_theme_resolution_and_canvas_apply() -> None:
if QApplication is None or MainWindow is None or effective_theme is None:
return
app = QApplication.instance() or QApplication([])
assert effective_theme("dark") == "dark"
assert effective_theme("light") == "light"
assert effective_theme("system") in {"dark", "light"}
window = MainWindow()
window.settings.theme = "light"
window._apply_canvas_settings()
assert window.canvas._theme == "light"
window.settings.theme = "dark"
window._apply_canvas_settings()
assert window.canvas._theme == "dark"
window.close()
def test_shortcut_settings_are_editable_and_layout_independent() -> None:
if QApplication is None or MainWindow is None or SettingsDialog is None or AppSettings is None:
return
app = QApplication.instance() or QApplication([])
settings = AppSettings(shortcuts={**DEFAULT_SHORTCUTS, "grid": "Ctrl+Alt+G"})
dialog = SettingsDialog(settings)
assert dialog.shortcut_edits["grid"].keySequence().toString(QKeySequence.SequenceFormat.PortableText) == "Ctrl+Alt+G"
dialog.shortcut_edits["grid"].setKeySequence(QKeySequence("G"))
assert dialog.settings().shortcut_for("grid") == "G"
dialog.close()
window = MainWindow()
window.settings.shortcuts = {**DEFAULT_SHORTCUTS, "grid": "G"}
triggered = 0
window.actions["grid"].triggered.connect(lambda _checked=False: nonlocal_increment())
def nonlocal_increment():
nonlocal triggered
triggered += 1
class FakeKeyEvent:
def nativeVirtualKey(self):
return 0x47
def key(self):
return 0
def modifiers(self):
return Qt.NoModifier
assert window._physical_key_sequence(FakeKeyEvent()) == "G"
assert window._trigger_layout_independent_shortcut(FakeKeyEvent()) is True
assert triggered == 1
window.close()
def test_standard_dialog_buttons_are_localized() -> None:
if (
QApplication is None
or QDialogButtonBox is None
or QMessageBox is None
or localize_dialog_buttons is None
or localize_message_box is None
):
return
app = QApplication.instance() or QApplication([])
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
localize_dialog_buttons(button_box)
assert button_box.button(QDialogButtonBox.Ok).text() == "ОК"
assert button_box.button(QDialogButtonBox.Cancel).text() == "Отмена"
message_box = QMessageBox()
message_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel)
localize_message_box(message_box)
assert message_box.button(QMessageBox.Yes).text() == "Да"
assert message_box.button(QMessageBox.No).text() == "Нет"
assert message_box.button(QMessageBox.Cancel).text() == "Отмена"
message_box.close()
def test_lucide_icon_names_are_declared() -> None:
expected_names = {
"archive-restore",
"arrow-right",
"chevron-right",
"eraser",
"eye",
"file-plus",
"file-code",
"folder-open",
"git-branch",
"grid-2x2",
"layers",
"paintbrush",
"plus",
"redo-2",
"rotate-cw",
"save",
"save-all",
"settings",
"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_pan_mode_helpers_follow_settings() -> None:
if LedCanvas is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
canvas = LedCanvas()
canvas.set_pan_mode("middle")
assert canvas._can_pan_with_middle()
assert not canvas._can_pan_with_space()
canvas.set_pan_mode("space")
assert not canvas._can_pan_with_middle()
assert canvas._can_pan_with_space()
canvas.set_pan_mode("middle_space")
assert canvas._can_pan_with_middle()
assert canvas._can_pan_with_space()
canvas.set_pan_mode("unknown")
assert canvas._pan_mode == "middle_space"
assert canvas._can_pan_with_middle()
assert canvas._can_pan_with_space()
canvas._space_pressed = True
canvas._start_pan(QPoint(10, 10))
assert canvas._is_panning
canvas.set_instant_space_pan(False)
assert not canvas._is_panning
canvas.set_instant_space_pan(True)
assert canvas._instant_space_pan is True
app.processEvents()
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_zoom_anchor_is_same_with_brush_enabled() -> None:
if LedCanvas is None or QApplication is None or QPoint is None:
return
app = QApplication.instance() or QApplication([])
doc = MappingDocument()
doc.points = [
LedPoint(row * 20 + col, row * 20 + col, col * 34.0, row * 34.0, row, col)
for row in range(20)
for col in range(20)
]
viewport_pos = QPoint(120, 90)
for brush_enabled in (False, True):
canvas = LedCanvas()
canvas.resize(320, 240)
canvas.set_document(doc)
canvas.centerOn(canvas.items_by_id[210].pos())
canvas.set_brush_enabled(brush_enabled)
scene_before = canvas.mapToScene(viewport_pos)
viewport_center = canvas.viewport().rect().center()
distance_before = (viewport_pos - viewport_center).manhattanLength()
canvas._zoom_at_viewport_pos(viewport_pos, 1.15)
focused_viewport_pos = canvas.mapFromScene(scene_before)
distance_after = (focused_viewport_pos - viewport_center).manhattanLength()
assert distance_after < distance_before
assert distance_after > 2
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_default_stroke_creates_new_block_even_with_selected_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)
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.start_brush_stroke(False)
window.add_brush_point_to_block(2)
window.finish_brush_stroke()
app.processEvents()
line_after = window.document.get_line(line.id)
assert len(line_after.blocks) == 2
assert line_after.blocks[0].id == block.id
assert line_after.blocks[0].point_ids == [0, 1]
assert line_after.blocks[1].point_ids == [2]
window._dirty = False
window.close()
def test_brush_shift_stroke_continues_selected_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)
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.start_brush_stroke(True)
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_focus_change_does_not_run_full_ui_refresh() -> None:
if MainWindow is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
window = MainWindow()
calls = 0
original = window._refresh_ui_state
def counted_refresh():
nonlocal calls
calls += 1
return original()
window._refresh_ui_state = counted_refresh
window._on_application_focus_changed(None, window.tree)
assert calls == 0
window.close()
def test_package_imports_work_from_project_root() -> None:
importlib.import_module("app.main")
importlib.import_module("app.canvas")
importlib.import_module("app.localization")
importlib.import_module("app.main_window")
importlib.import_module("app.settings")
importlib.import_module("app.settings_dialog")
importlib.import_module("app.theme")
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()