from __future__ import annotations import base64 import hashlib import json import importlib import gc import re import subprocess 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 ( BackgroundImage, LedPoint, MappingDocument, build_route_adjacency, is_valid_route_neighbor, min_neighbor_distance_for_points, neighbor_tolerance, order_points_like_grid_svg, ) from app.panel_mapping_generator import generate_panel_mapping_from_document from app.unfolding import MAX_LINES try: import app.canvas as canvas_module from app.canvas import LedCanvas from app.background_dialog import BackgroundDialog from app.icons import LUCIDE_PATHS from app.localization import localize_dialog_buttons, localize_message_box from app.main_window import APP_DIR, MainWindow, ROOT_DIR, SIDEBAR_COLLAPSED_WIDTH, SIDEBAR_MIN_WIDTH, UID_COLUMN from app.settings import DEFAULT_SHORTCUTS, AppSettings from app.settings_dialog import SettingsDialog from app.theme import apply_app_theme, dark_stylesheet, effective_theme, light_stylesheet from PySide6.QtCore import QPoint, QPointF, QRectF, Qt from PySide6.QtGui import QColor, QImage, QKeySequence from PySide6.QtTest import QTest from PySide6.QtWidgets import QApplication, QDialogButtonBox, QGraphicsItem, QMessageBox, QToolBar except ImportError: canvas_module = None LedCanvas = None BackgroundDialog = None LUCIDE_PATHS = {} localize_dialog_buttons = None localize_message_box = None MainWindow = None APP_DIR = PROJECT_ROOT ROOT_DIR = PROJECT_ROOT AppSettings = None DEFAULT_SHORTCUTS = {} SettingsDialog = None apply_app_theme = None dark_stylesheet = None effective_theme = None light_stylesheet = None SIDEBAR_COLLAPSED_WIDTH = 34 SIDEBAR_MIN_WIDTH = 180 UID_COLUMN = 7 QPoint = None QPointF = None QRectF = None Qt = None QColor = None QImage = None QKeySequence = None QApplication = None QDialogButtonBox = None QGraphicsItem = None QTest = None QMessageBox = None QColor = None QImage = 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_empty_json_file, test_load_supported_json_files, test_load_raw_json_with_trailing_commas_and_bare_decimal_points, test_non_utf8_json_is_converted_to_utf8_on_load, test_utf16_le_json_without_bom_is_converted_to_utf8_on_load, test_line_limit_rejects_fifth_line, test_line_indexes_are_scoped_per_line, test_remove_selected_leds_blocks_and_lines, test_remove_blocks_across_lines_is_atomic, 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_manual_led_save_load_roundtrip, test_coordinate_unit_and_exact_positions_survive_span_change, test_failed_atomic_save_preserves_existing_file, test_grid_step_roundtrip_and_meter_layout, test_manual_led_uses_grid_coordinates, test_manual_led_uses_transposed_source_grid_coordinates, test_remove_points_reindexes_uids_and_blocks, test_loaded_duplicate_memberships_are_deduplicated, test_empty_blocks_are_preserved, test_block_numbers_renumber_after_block_changes, test_block_numbers_are_local_to_each_line, test_reindex_preserves_block_point_order, test_add_block_preserves_start_point, test_append_points_to_block_extends_existing_block, test_add_block_can_insert_after_selected_block_and_renumber, test_merge_blocks_preserves_first_block_and_reindexes, 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_counterclockwise_block_from_seed_reverses_clockwise_route, test_counterclockwise_block_from_point_handler, 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_ordered_json_meter_coordinates_reload_as_mm, test_legacy_cleanup_waits_for_success, test_standalone_panel_mapping_generation_without_legacy_ino, test_panel_mapping_generation_with_strategies, test_block_renaming_and_naming_strategy, test_line_renaming_and_tree_shortcuts, test_new_file_action, test_scheme_editing_mode_actions, test_menu_bar_tools_buttons, test_export_mapping_to_image, test_save_preserves_unknown_source_fields, test_ordered_json_preserves_document_and_remaps_uids, test_background_load_save_roundtrip, test_background_image_is_embedded_and_survives_source_changes, test_background_embedding_failure_keeps_document_and_target_unchanged, test_background_dialog_preserves_non_uniform_scale, test_background_dialog_marks_and_preserves_embedded_image, test_canvas_background_layer_and_geometry, test_canvas_background_zero_offset_uses_scheme_center, test_canvas_background_edit_move_and_resize_helpers, test_canvas_background_large_image_downsamples_and_grid_degrades, test_main_window_background_reset_noops_at_100, test_main_window_background_actions, test_main_window_background_noop_edit_preserves_undo_redo, test_delete_unassigned_selection_preserves_redo, test_canvas_background_changes_preserve_camera, test_canvas_uses_grid_svg_coordinates, test_canvas_shows_selected_line_index_labels, test_canvas_rebuild_can_preserve_user_zoom, test_grid_toggle_adds_grid_items, test_edit_mode_keeps_regular_grid_spacing, test_edit_mode_preserves_camera_and_selection, test_led_points_keep_absolute_screen_size, test_rubber_selection_uses_absolute_led_geometry, test_fit_updates_absolute_selection_geometry, test_large_absolute_leds_fit_without_clipping, test_absolute_led_label_stays_centered_after_zoom, test_canvas_connects_new_block_to_previous_non_empty_block, test_canvas_bends_overlapping_connections, test_canvas_bends_partially_overlapping_connections, test_canvas_active_line_switch_does_not_redraw_connections, 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_menu_disabled_items_are_visually_distinct, test_shortcut_settings_are_editable_and_layout_independent, test_standard_dialog_buttons_are_localized, test_lucide_icon_names_are_declared, test_table_uses_single_line_index_column, test_table_uid_column_displays_source_id_but_selects_internal_uid, test_led_coordinates_are_editable_in_table, test_coordinate_editors_preserve_precision, test_main_window_can_delete_selected_points, test_table_selection_uses_selected_rows, test_tree_can_select_and_merge_multiple_blocks, test_tree_can_delete_multiple_blocks_without_deleting_selected_lines, test_sidebar_collapses_and_expands, test_sidebar_inner_splitter_persists_sizes, 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_context_menu_can_request_edit_and_counterclockwise_block, test_canvas_alt_drag_emits_point_move, test_canvas_alt_drag_ignores_line_index_label_hit, test_main_window_updates_point_from_canvas_drag, test_save_commits_active_canvas_item_position, test_main_window_edit_point_dialog_imports_localized_buttons, 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_creates_new_block_after_selected_tree_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_open_dialog_default_dir_uses_application_dir, test_generate_dialog_default_dir_uses_application_dir, 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, test_pyinstaller_spec_build_ref_rules, ] for test in tests: gc.collect() if QApplication is not None and QApplication.instance() is not None: QApplication.instance().processEvents() print(f"run - {test.__name__}", flush=True) test() if QApplication is not None and QApplication.instance() is not None: app = QApplication.instance() for widget in app.topLevelWidgets(): widget.deleteLater() app.processEvents() print(f"ok - {test.__name__}", flush=True) print(f"All {len(tests)} QA smoke checks passed.", flush=True) def test_load_empty_json_file() -> None: with tempfile.TemporaryDirectory() as temp_dir: # Test 1: Completely empty file (0 bytes) path = Path(temp_dir) / "empty.json" path.write_bytes(b"") doc = MappingDocument() doc.load(path) assert len(doc.points) == 0 assert path.read_text(encoding="utf-8") == '{"circles": []}' # Test 2: Whitespace-only file path_ws = Path(temp_dir) / "whitespace.json" path_ws.write_text(" \n \t ", encoding="utf-8") doc_ws = MappingDocument() doc_ws.load(path_ws) assert len(doc_ws.points) == 0 assert path_ws.read_text(encoding="utf-8") == '{"circles": []}' 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_load_raw_json_with_trailing_commas_and_bare_decimal_points() -> None: with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "raw_circle_centers.json" path.write_text( """ [ {"id":1,"x":1173.,"y":2159.019396,"row":35,"column":64,}, {"id":2,"x":1207.,"y":17.,"row":36,"column":1,} ] """, encoding="utf-8", ) doc = MappingDocument() doc.load(path) assert doc.collection_name == "circles" assert len(doc.points) == 2 assert doc.points[0].x == 1173.0 assert doc.points[0].col == 64 assert doc.points[1].y == 17.0 def test_non_utf8_json_is_converted_to_utf8_on_load() -> None: with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "cp1251.json" text = '{"circles":[{"id":1,"x":1.,"y":2.,"row":0,"column":0,"label":"точка"}]}' path.write_bytes(text.encode("cp1251")) doc = MappingDocument() doc.load(path) assert len(doc.points) == 1 assert doc.points[0].extra_payload["label"] == "точка" assert path.read_bytes().decode("utf-8") == text def test_utf16_le_json_without_bom_is_converted_to_utf8_on_load() -> None: with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "utf16le.json" text = '[{"id":1,"x":1173.,"y":17.,"row":35,"column":64}]' path.write_bytes(text.encode("utf-16-le")) doc = MappingDocument() doc.load(path) assert len(doc.points) == 1 assert doc.points[0].x == 1173.0 assert path.read_bytes().decode("utf-8") == text 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_blocks_across_lines_is_atomic() -> 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]) kept_first = doc.add_block(first_line.id, [2]) second_line = doc.add_line() second_block = doc.add_block(second_line.id, [4, 5]) kept_second = doc.add_block(second_line.id, [6]) snapshot = doc.to_payload() try: doc.remove_blocks([(first_line.id, first_block.id), (second_line.id, 999)]) except KeyError: pass else: raise AssertionError("Removing an unknown block must fail atomically.") assert doc.to_payload() == snapshot removed = doc.remove_blocks([ (first_line.id, first_block.id), (second_line.id, second_block.id), (first_line.id, first_block.id), ]) assert removed == 4 assert [line.id for line in doc.lines] == [first_line.id, second_line.id] assert doc.get_line(first_line.id).blocks[0].point_ids == kept_first.point_ids assert doc.get_line(second_line.id).blocks[0].point_ids == kept_second.point_ids assert all(doc.points[point_id].line_id is None for point_id in [0, 1, 4, 5]) assert doc.points[2].line_index == 0 assert doc.points[6].line_index == 0 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_manual_led_save_load_roundtrip() -> None: with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "manual_led.json" path.write_text(json.dumps({"circles": []}), encoding="utf-8") doc = MappingDocument() doc.load(path) point = doc.add_manual_point(12.5, 34.75) assert point.manual assert point.line_id is None assert point.block_id is None save_path = Path(temp_dir) / "saved.json" doc.save(save_path) loaded = MappingDocument() loaded.load(save_path) assert len(loaded.points) == 1 assert loaded.points[0].manual is True assert loaded.points[0].x == 12.5 assert loaded.points[0].y == 34.75 assert loaded.points[0].line_id is None assert loaded.points[0].block_id is None def test_coordinate_unit_and_exact_positions_survive_span_change() -> None: with tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) / "legacy_without_unit.json" saved = Path(temp_dir) / "saved.json" source.write_text( json.dumps({"circles": [{"id": 0, "x": 0.001, "y": 0.002}, {"id": 1, "x": 9.0, "y": 0.002}]}), encoding="utf-8", ) doc = MappingDocument() doc.load(source) assert doc.coordinate_unit == "m" expected = (20.1234567890123, -3.9876543210987) doc.update_point_position(1, x=expected[0], y=expected[1]) doc.save(saved) payload = json.loads(saved.read_text(encoding="utf-8")) assert payload["coordinate_unit"] == "m" assert payload["circles"][1]["x"] == expected[0] assert payload["circles"][1]["y"] == expected[1] reloaded = MappingDocument() reloaded.load(saved) assert (reloaded.points[1].x, reloaded.points[1].y) == expected assert reloaded.coordinate_unit == "m" def test_failed_atomic_save_preserves_existing_file() -> None: with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "mapping.json" original = '{"sentinel": "unchanged"}' path.write_text(original, encoding="utf-8") doc = MappingDocument() doc.extra_payload["not_json_serializable"] = {1, 2, 3} try: doc.save(path) except TypeError: pass else: raise AssertionError("Save with non-serializable payload must fail.") assert path.read_text(encoding="utf-8") == original assert not list(Path(temp_dir).glob(".mapping.json.*.tmp")) def test_grid_step_roundtrip_and_meter_layout() -> None: with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "mapping.json" doc = MappingDocument() doc.coordinate_unit = "m" doc.grid_step_mm = 50.0 doc.points = [ LedPoint(0, 0, 0.0, 0.0, 0, 0, coordinate_unit="m"), LedPoint(1, 1, 0.05, 0.0, 0, 1, coordinate_unit="m"), ] manual = doc.add_manual_point(0.10, 0.05) assert (manual.row, manual.col) == (1, 2) assert abs(doc.grid_step_in_document_units() - 0.05) < 1e-12 assert abs(neighbor_tolerance(doc.grid_step_in_document_units()) - 0.002) < 1e-12 doc.save(path) reloaded = MappingDocument() reloaded.load(path) assert reloaded.grid_step_mm == 50.0 assert abs(reloaded.grid_step_in_document_units() - 0.05) < 1e-12 path.write_text(json.dumps({"circles": [], "grid_step_mm": 0.5}), encoding="utf-8") reloaded.load(path) assert reloaded.grid_step_mm == 1.0 def test_manual_led_uses_grid_coordinates() -> None: points = [ LedPoint(0, 0, 0.0, 0.0, 0, 0), LedPoint(1, 1, 34.0, 0.0, 0, 1), LedPoint(2, 2, 0.0, 34.0, 1, 0), ] doc = MappingDocument() doc.points = points manual = doc.add_manual_point(68.0, 34.0) assert (manual.row, manual.col) == (1, 2) doc.update_manual_point_position(manual.uid, x=34.0, y=68.0) assert (manual.row, manual.col) == (2, 1) def test_manual_led_uses_transposed_source_grid_coordinates() -> None: points = [ LedPoint(0, 522, 332.777828, 906.447564, 10, 27), LedPoint(1, 524, 347.222172, 906.447564, 11, 27), LedPoint(2, 525, 362.557268, 986.575745, 11, 30), LedPoint(3, 487, 328.943049, 995.582648, 10, 30), ] doc = MappingDocument() doc.points = points manual = doc.add_manual_point(340.9004318581997, 922.081576799795) assert (manual.row, manual.col) == (11, 27) doc.update_manual_point_position(manual.uid, x=340.9004318581997, y=990.0) assert (manual.row, manual.col) == (11, 30) def test_remove_points_reindexes_uids_and_blocks() -> None: with temp_document_path() as path: doc = MappingDocument() doc.load(path) line = doc.add_line() first = doc.add_block(line.id, [0, 1, 2]) second = doc.add_block(line.id, [3, 4, 5]) removed = doc.remove_points([1, 4]) assert removed == 2 assert [point.uid for point in doc.points] == list(range(len(doc.points))) assert [point.source_id for point in doc.points[:4]] == [0, 2, 3, 5] assert first.point_ids == [0, 1] assert second.point_ids == [2, 3] assert [doc.points[point_id].block_id for point_id in first.point_ids] == [1, 1] assert [doc.points[point_id].block_id for point_id in second.point_ids] == [2, 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_numbers_renumber_after_block_changes() -> 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) assert [(block.id, block.name) for block in doc.get_line(line.id).blocks] == [(1, "Блок 1"), (2, "Блок 2")] assert doc.points[0].block_id is None assert third.id == 2 fourth = doc.add_block(line.id, []) assert fourth.id == 3 assert fourth.name == "Блок 3" assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 2", "Блок 3"] def test_block_numbers_are_local_to_each_line() -> 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, []) other_line_block = doc.add_block(second_line.id, [0]) assert [block.id for block in doc.get_line(first_line.id).blocks] == [1, 2, 3] assert other_line_block.id == 1 doc.remove_block(first_line.id, second.id) assert [(block.id, block.name) for block in doc.get_line(first_line.id).blocks] == [(1, "Блок 1"), (2, "Блок 2")] doc.remove_line(second_line.id) assert [(block.id, block.name) for block in doc.get_line(first_line.id).blocks] == [(1, "Блок 1"), (2, "Блок 2")] assert [first.name, third.name] == ["Блок 1", "Блок 2"] 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_add_block_can_insert_after_selected_block_and_renumber() -> None: with temp_document_path() as path: doc = MappingDocument() doc.load(path) line = doc.add_line() first = doc.add_block(line.id, [0, 1]) second = doc.add_block(line.id, [4, 5]) third = doc.add_block(line.id, [8, 9]) inserted = doc.add_block(line.id, [2, 3], after_block_id=first.id) assert [(block.id, block.name, block.point_ids) for block in line.blocks] == [ (1, "Блок 1", [0, 1]), (2, "Блок 2", [2, 3]), (3, "Блок 3", [4, 5]), (4, "Блок 4", [8, 9]), ] assert inserted.id == 2 assert second.id == 3 assert third.id == 4 assert [doc.points[point_id].block_id for point_id in [0, 2, 4, 8]] == [1, 2, 3, 4] assert [doc.points[point_id].line_index for point_id in [0, 1, 2, 3, 4, 5, 8, 9]] == list(range(8)) def test_merge_blocks_preserves_first_block_and_reindexes() -> None: with temp_document_path() as path: doc = MappingDocument() doc.load(path) line = doc.add_line() first = doc.add_block(line.id, [0, 1]) second = doc.add_block(line.id, [2, 3]) third = doc.add_block(line.id, [4]) merged = doc.merge_blocks(line.id, [third.id, first.id, second.id]) line_after = doc.get_line(line.id) assert merged.id == first.id assert [block.id for block in line_after.blocks] == [first.id] assert line_after.blocks[0].point_ids == [0, 1, 2, 3, 4] assert [doc.points[point_id].line_index for point_id in [0, 1, 2, 3, 4]] == [0, 1, 2, 3, 4] assert [doc.points[point_id].block_index for point_id in [0, 1, 2, 3, 4]] == [0, 1, 2, 3, 4] 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_counterclockwise_block_from_seed_reverses_clockwise_route() -> None: with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "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() clockwise = doc.add_clockwise_block_from_seed(line.id, 0).point_ids doc.clear_all_assignments() line = doc.add_line() counterclockwise = doc.add_counterclockwise_block_from_seed(line.id, 0).point_ids assert clockwise == [0, 7, 6, 5, 4, 3, 2, 1] assert counterclockwise == [0, 1, 2, 3, 4, 5, 6, 7] def test_counterclockwise_block_from_point_handler() -> None: if MainWindow is None or QApplication is None: return app = QApplication.instance() or QApplication([]) window = MainWindow() with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "counter-handler.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": 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() window.document = doc window.active_line_id = line.id window.canvas.set_document(doc) window.canvas.set_active_line(line.id) window._confirm_reassignment = lambda _point_ids: True window.auto_block_from_point_counterclockwise(0) assert doc.lines[0].blocks[0].point_ids == [0, 1, 2, 3] window._dirty = False window.close() 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) doc.grid_step_mm = 50.0 line = doc.add_line() doc.add_block(line.id, list(range(len(doc.points)))) output_dir = Path(temp_dir) obsolete_legacy = output_dir / "virtual_matrix_legacy.ino.txt" unrelated_ino = output_dir / "custom_controller.ino" obsolete_legacy.write_text("stale", encoding="utf-8") unrelated_ino.write_text("keep", encoding="utf-8") result = generate_panel_mapping_from_document(doc, output_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 assert set(result.generated_files) == { "vm_map", "ordered_json", "validation_report", "readme", } assert not obsolete_legacy.exists() assert unrelated_ino.read_text(encoding="utf-8") == "keep" 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 "#define GRID_CELL_SIZE_MM 50.0f" in vm_map assert "{0, 0}" in vm_map ordered_payload = json.loads(result.generated_files["ordered_json"].read_text(encoding="utf-8")) assert ordered_payload["cell_size_mm"] == 50.0 assert len(ordered_payload["circles"]) == len(doc.points) assert ordered_payload["circles"][0]["strip"] == 0 def test_ordered_json_meter_coordinates_reload_as_mm() -> None: with tempfile.TemporaryDirectory() as temp_dir: doc = MappingDocument() doc.coordinate_unit = "m" doc.grid_step_mm = 50.0 doc.points = [ LedPoint(0, 10, 0.0, 0.0, 0, 0, coordinate_unit="m"), LedPoint(1, 11, 0.05, 0.0, 0, 1, coordinate_unit="m"), ] line = doc.add_line() doc.add_block(line.id, [0, 1]) result = generate_panel_mapping_from_document(doc, temp_dir) payload = json.loads(result.generated_files["ordered_json"].read_text(encoding="utf-8")) assert payload["coordinate_unit"] == "mm" assert payload["unit"] == "mm" assert payload["circles"][1]["x"] == 50.0 reloaded = MappingDocument() reloaded.load(result.generated_files["ordered_json"]) assert reloaded.coordinate_unit == "mm" assert reloaded.points[1].x == 50.0 def test_legacy_cleanup_waits_for_success() -> None: import app.panel_mapping_generator as gui_generator import panel_mapping_build.build_panel_mapping as standalone_generator 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)))) legacy = Path(temp_dir) / "virtual_matrix_legacy.ino.txt" legacy.write_text("keep until success", encoding="utf-8") original_writer = gui_generator.write_vm_map_header gui_generator.write_vm_map_header = lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("write failed")) try: try: gui_generator.generate_panel_mapping_from_document(doc, temp_dir) except OSError: pass else: raise AssertionError("Generator write failure must be propagated.") finally: gui_generator.write_vm_map_header = original_writer assert legacy.read_text(encoding="utf-8") == "keep until success" with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: legacy = Path(temp_dir) / "virtual_matrix_legacy.ino.txt" legacy.write_text("keep until success", encoding="utf-8") original_writer = standalone_generator.write_vm_map_header standalone_generator.write_vm_map_header = lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("write failed")) try: try: standalone_generator.build_panel_mapping(path, temp_dir, input_unit="mm") except OSError: pass else: raise AssertionError("Standalone write failure must be propagated.") finally: standalone_generator.write_vm_map_header = original_writer assert legacy.read_text(encoding="utf-8") == "keep until success" def test_standalone_panel_mapping_generation_without_legacy_ino() -> None: from panel_mapping_build.build_panel_mapping import build_panel_mapping with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: payload = json.loads(path.read_text(encoding="utf-8")) payload["grid_step_mm"] = 50.0 path.write_text(json.dumps(payload), encoding="utf-8") output_dir = Path(temp_dir) obsolete_legacy = output_dir / "virtual_matrix_legacy.ino.txt" unrelated_ino = output_dir / "custom_controller.ino" obsolete_legacy.write_text("stale", encoding="utf-8") unrelated_ino.write_text("keep", encoding="utf-8") result = build_panel_mapping(path, output_dir, input_unit="mm") assert set(result.generated_files) == { "vm_map", "ordered_json", "validation_report", "readme", } assert all(output_path.exists() for output_path in result.generated_files.values()) assert not obsolete_legacy.exists() assert unrelated_ino.read_text(encoding="utf-8") == "keep" ordered_payload = json.loads(result.generated_files["ordered_json"].read_text(encoding="utf-8")) assert ordered_payload["cell_size_mm"] == 50.0 assert "#define GRID_CELL_SIZE_MM 50.0f" in result.generated_files["vm_map"].read_text(encoding="utf-8") assert [point["id"] for point in ordered_payload["leds"]] == list(range(result.led_count)) validation_report = result.generated_files["validation_report"].read_text(encoding="utf-8") assert "Generated ids contiguous: True" in validation_report assert "{0, 0}" in result.generated_files["vm_map"].read_text(encoding="utf-8") def test_panel_mapping_generation_with_strategies() -> None: with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: doc = MappingDocument() doc.load(path) line = doc.add_line() # Add a block with two points doc.add_block(line.id, [0, 1]) # 1. Test Grid=False, Blocks=True result = generate_panel_mapping_from_document( doc, Path(temp_dir), generate_grid=False, generate_blocks=True ) vm_map = result.generated_files["vm_map"].read_text(encoding="utf-8") assert "BLOCK_LEDS" in vm_map assert "BLOCK_RANGES" in vm_map assert "setBlockLED" in vm_map assert "VM_ROWS" not in vm_map assert "VM_LED_COUNT" not in vm_map # 2. Test Grid=True, Blocks=True result = generate_panel_mapping_from_document( doc, Path(temp_dir), generate_grid=True, generate_blocks=True ) vm_map = result.generated_files["vm_map"].read_text(encoding="utf-8") assert "BLOCK_LEDS" in vm_map assert "BLOCK_RANGES" in vm_map assert "setBlockLED" in vm_map assert "VM_ROWS" in vm_map assert "VM_LED_COUNT" in vm_map def test_block_renaming_and_naming_strategy() -> None: from app.panel_mapping_generator import sanitize_cpp_identifier assert sanitize_cpp_identifier("Блок 1") == "Blok_1" assert sanitize_cpp_identifier("Left Wing") == "Left_Wing" assert sanitize_cpp_identifier("123-Block") == "_123_Block" assert sanitize_cpp_identifier("") == "BLOCK" with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: doc = MappingDocument() doc.load(path) line = doc.add_line() block = doc.add_block(line.id, [0, 1]) block.name = "Левое Крыло" result = generate_panel_mapping_from_document( doc, Path(temp_dir), generate_grid=True, generate_blocks=True ) vm_map = result.generated_files["vm_map"].read_text(encoding="utf-8") assert "#define BLOCK_Levoe_Krylo 0" in vm_map assert "const char BLOCK_NAME_0[] PROGMEM = \"Левое Крыло\";" in vm_map assert "getBlockIndexByName" in vm_map def test_line_renaming_and_tree_shortcuts() -> None: if MainWindow is None or QApplication is None: return app = QApplication.instance() or QApplication([]) window = MainWindow() line = window.document.add_line() assert line.name == f"Линия {line.id}" line.name = "Верхняя Линия" assert line.name == "Верхняя Линия" block = window.document.add_block(line.id, []) block.name = "Левое Крыло" assert block.name == "Левое Крыло" with tempfile.TemporaryDirectory() as temp_dir: save_path = Path(temp_dir) / "test_save.json" window.document.save(save_path) from app.models import MappingDocument new_doc = MappingDocument() new_doc.load(save_path) assert len(new_doc.lines) == 1 assert new_doc.lines[0].name == "Верхняя Линия" assert len(new_doc.lines[0].blocks) == 1 assert new_doc.lines[0].blocks[0].name == "Левое Крыло" assert "tree_rename" in window.actions assert "tree_delete" in window.actions assert "tree_merge" in window.actions assert window.actions["tree_rename"].shortcut().toString() == "F2" assert window.actions["tree_merge"].shortcut().toString() == "Ctrl+M" window.close() def test_new_file_action() -> None: if MainWindow is None or QApplication is None: return app = QApplication.instance() or QApplication([]) window = MainWindow() from PySide6.QtWidgets import QFileDialog with tempfile.TemporaryDirectory() as temp_dir: new_path = Path(temp_dir) / "created_scheme.json" original_getSaveFileName = QFileDialog.getSaveFileName QFileDialog.getSaveFileName = lambda *args, **kwargs: (str(new_path), "JSON files (*.json)") try: window.new_file() finally: QFileDialog.getSaveFileName = original_getSaveFileName assert new_path.exists() assert new_path.stat().st_size > 0 assert len(window.document.points) == 0 assert window.document.path == new_path window.close() def test_scheme_editing_mode_actions() -> None: if MainWindow is None or QApplication is None: return app = QApplication.instance() or QApplication([]) window = MainWindow() # Load a document with 3 points doc = MappingDocument() from app.models import LedPoint doc.points = [ LedPoint(uid=0, source_id=0, x=10.0, y=20.0, col=0, row=0), LedPoint(uid=1, source_id=1, x=15.0, y=10.0, col=1, row=0), LedPoint(uid=2, source_id=2, x=30.0, y=40.0, col=2, row=0), ] window.document = doc window.canvas.set_document(doc, reset_view=True) # 1. Edit mode visibility and toggling assert window.right_panel is not None assert window.right_panel.isHidden() edit_action = window.actions.get("toggle_edit_mode") assert edit_action is not None edit_action.setChecked(True) window.toggle_edit_mode() assert not window.right_panel.isHidden() assert window.canvas._edit_tool == "select" # Switch to move tool window._on_edit_tool_changed("move") assert window.canvas._edit_tool == "move" assert not window.canvas._add_led_enabled # Switch to draw tool window._on_edit_tool_changed("draw") assert window.canvas._edit_tool == "draw" assert window.canvas._add_led_enabled # Switch back to select window._on_edit_tool_changed("select") assert window.canvas._edit_tool == "select" # Select point 0 and 1 window.canvas.select_points([0, 1]) assert window.canvas.selected_point_ids() == [0, 1] # 2. Align Horizontally (Y becomes average of 20 and 10 -> 15) window.align_selected_horizontally() assert abs(window.document.get_point(0).y - 15.0) < 1e-5 assert abs(window.document.get_point(1).y - 15.0) < 1e-5 assert abs(window.document.get_point(2).y - 40.0) < 1e-5 # unchanged # Undo window.undo_last_change() assert abs(window.document.get_point(0).y - 20.0) < 1e-5 assert abs(window.document.get_point(1).y - 10.0) < 1e-5 # 3. Align Vertically (X becomes average of 10 and 15 -> 12.5) window.canvas.select_points([0, 1]) window.align_selected_vertically() assert abs(window.document.get_point(0).x - 12.5) < 1e-5 assert abs(window.document.get_point(1).x - 12.5) < 1e-5 # Undo window.undo_last_change() assert abs(window.document.get_point(0).x - 10.0) < 1e-5 assert abs(window.document.get_point(1).x - 15.0) < 1e-5 # 4. Grid Horizontal (Point 0 remains at 10, point 1 placed at 10 + 34 = 44) window.canvas.select_points([0, 1]) window.arrange_selected_horizontally_grid() assert abs(window.document.get_point(0).x - 10.0) < 1e-5 assert abs(window.document.get_point(1).x - 44.0) < 1e-5 assert abs(window.document.get_point(0).y - 20.0) < 1e-5 assert abs(window.document.get_point(1).y - 20.0) < 1e-5 # Y aligns to first point's Y window._dirty = False window.close() def test_menu_bar_tools_buttons() -> None: if MainWindow is None or QApplication is None: return app = QApplication.instance() or QApplication([]) window = MainWindow() app.processEvents() from PySide6.QtWidgets import QToolButton, QFrame container = window.menu_buttons_widget assert container is not None assert container.parent() == window.menuBar() # Check layout layout = container.layout() assert layout is not None # Find separator and buttons frames = container.findChildren(QFrame) assert len(frames) == 1 buttons = container.findChildren(QToolButton) assert len(buttons) == 7 button_actions = [btn.defaultAction() for btn in buttons] expected_keys = ["add_line", "add_led", "generate_script", "auto_blocks", "counterclockwise", "brush", "toggle_edit_mode"] for key in expected_keys: assert window.actions[key] in button_actions window.close() def test_export_mapping_to_image() -> None: if QApplication is None: return _app = QApplication.instance() or QApplication([]) from app.mapping_renderer import render_mapping_image from app.models import MappingDocument doc = MappingDocument() doc.add_manual_point(0.0, 0.0) doc.add_manual_point(100.0, 0.0) doc.add_manual_point(100.0, 100.0) with tempfile.TemporaryDirectory() as temp_dir: # Test Dark Theme image_path = Path(temp_dir) / "output_dark.png" success = render_mapping_image(doc, str(image_path), theme="dark") assert success assert image_path.exists() assert image_path.stat().st_size > 0 # Test Light Theme image_path_light = Path(temp_dir) / "output_light.png" success_light = render_mapping_image(doc, str(image_path_light), theme="light") assert success_light assert image_path_light.exists() assert image_path_light.stat().st_size > 0 def test_save_preserves_unknown_source_fields() -> None: with tempfile.TemporaryDirectory() as temp_dir: source = Path(temp_dir) / "custom.json" saved = Path(temp_dir) / "saved.json" source.write_text( json.dumps( { "circles": [ { "id": 10, "x": 0, "y": 0, "row": 1, "col": 2, "fixture_tag": "left", } ], "project_note": "keep me", } ), encoding="utf-8", ) doc = MappingDocument() doc.load(source) doc.save(saved) payload = json.loads(saved.read_text(encoding="utf-8")) assert payload["project_note"] == "keep me" assert payload["circles"][0]["fixture_tag"] == "left" def test_ordered_json_preserves_document_and_remaps_uids() -> None: with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: payload = json.loads(path.read_text(encoding="utf-8")) payload["source_meta"] = {"revision": 7} payload["circles"][0]["custom_name"] = "first-source-point" path.write_text(json.dumps(payload), encoding="utf-8") doc = MappingDocument() doc.load(path) line = doc.add_line() block = doc.add_block(line.id, [3, 2, 1, 0]) result = generate_panel_mapping_from_document(doc, Path(temp_dir)) ordered_payload = json.loads(result.generated_files["ordered_json"].read_text(encoding="utf-8")) ordered_points = ordered_payload["circles"] id_by_input_index = {point["input_index"]: point["id"] for point in ordered_points} assert ordered_payload["source_meta"] == {"revision": 7} assert ordered_payload["ordered_id_mode"] == "generated_uid" assert any(point.get("custom_name") == "first-source-point" for point in ordered_points) assert ordered_payload["lines"][0]["blocks"][0]["point_ids"] == [ id_by_input_index[point_id] for point_id in block.point_ids ] assert all(point["source_id"] == doc.points[point["input_index"]].source_id for point in ordered_points) def test_background_load_save_roundtrip() -> None: with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: payload = json.loads(path.read_text(encoding="utf-8")) payload["background"] = { "path": "drawing.png", "offset_x": 12.5, "offset_y": -7.25, "scale_x_percent": 80, "scale_y_percent": 125, "opacity_percent": 45, "visible": False, } source = Path(temp_dir) / "map.json" source.write_text(json.dumps(payload), encoding="utf-8") (Path(temp_dir) / "drawing.png").write_bytes( base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" ) ) doc = MappingDocument() doc.load(source) assert doc.background.path == "drawing.png" assert doc.background.offset_x == 12.5 assert doc.background.offset_y == -7.25 assert doc.background.scale_x_percent == 80 assert doc.background.scale_y_percent == 125 assert doc.background.opacity_percent == 45 assert doc.background.visible is False saved = Path(temp_dir) / "saved.json" doc.save(saved) reloaded = MappingDocument() reloaded.load(saved) assert reloaded.background.to_payload() == doc.background.to_payload() legacy = Path(temp_dir) / "legacy.json" payload["background"] = {"path": None, "scale_percent": 70} legacy.write_text(json.dumps(payload), encoding="utf-8") reloaded.load(legacy) assert reloaded.background.path == "" assert reloaded.background.scale_x_percent == 70 assert reloaded.background.scale_y_percent == 70 assert reloaded.background.opacity_percent == 100 assert reloaded.background.visible is True payload["background"] = {"path": "x.png", "offset_x": "bad", "offset_y": None} legacy.write_text(json.dumps(payload), encoding="utf-8") reloaded.load(legacy) assert reloaded.background.offset_x == 0 assert reloaded.background.offset_y == 0 def test_background_image_is_embedded_and_survives_source_changes() -> None: if LedCanvas is None or QApplication is None or QImage is None: return app = QApplication.instance() or QApplication([]) with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: image_path = Path(temp_dir) / "background.png" image = QImage(20, 10, QImage.Format.Format_ARGB32) image.fill(QColor("#123456")) assert image.save(str(image_path)) original_bytes = image_path.read_bytes() doc = MappingDocument() doc.load(path) doc.background = BackgroundImage(str(image_path), 12.5, -7.25, 200.0, 300.0, 45.0, True) saved = Path(temp_dir) / "embedded-map.json" doc.save(saved) payload = json.loads(saved.read_text(encoding="utf-8")) background_payload = payload["background"] embedded_payload = background_payload["embedded_image"] assert background_payload["path"] == "" assert embedded_payload["encoding"] == "base64" assert embedded_payload["mime_type"] == "image/png" assert embedded_payload["name"] == "background.png" assert embedded_payload["sha256"] == hashlib.sha256(original_bytes).hexdigest() assert base64.b64decode(embedded_payload["data"], validate=True) == original_bytes assert doc.background.has_embedded_image() assert doc.background.path == "" image_path.unlink() first_embedded_data = doc.background.embedded_image_base64 doc.points[0].x += 1000.0 doc.save(saved) assert doc.background.embedded_image_base64 == first_embedded_data reloaded = MappingDocument() reloaded.load(saved) assert reloaded.background.to_payload() == doc.background.to_payload() canvas = LedCanvas() canvas.set_document(reloaded) assert canvas.background_loaded() assert canvas._background_item is not None assert canvas._background_item.pixmap().width() == 20 assert canvas._background_item.pixmap().height() == 10 rect = canvas._background_scene_rect() assert rect is not None assert rect.width() == 40.0 assert rect.height() == 30.0 canvas.close() def test_background_embedding_failure_keeps_document_and_target_unchanged() -> None: with temp_document_path() as source_path, tempfile.TemporaryDirectory() as temp_dir: doc = MappingDocument() doc.load(source_path) missing_path = Path(temp_dir) / "missing-background.png" original_background = BackgroundImage(path=str(missing_path), offset_x=12.0, scale_x_percent=150.0) doc.background = original_background target = Path(temp_dir) / "existing-map.json" original_target = '{"preserve": true}\n' target.write_text(original_target, encoding="utf-8") try: doc.save(target) except FileNotFoundError: pass else: raise AssertionError("Сохранение должно завершаться ошибкой, если файл подложки недоступен.") assert target.read_text(encoding="utf-8") == original_target assert doc.path == source_path assert doc.background is original_background assert not doc.background.has_embedded_image() def test_background_dialog_preserves_non_uniform_scale() -> None: if QApplication is None or BackgroundDialog is None: return app = QApplication.instance() or QApplication([]) dialog = BackgroundDialog(BackgroundImage("drawing.png", 1, 2, 80, 125)) background = dialog.background() assert background.scale_x_percent == 80 assert background.scale_y_percent == 125 assert background.opacity_percent == 100 assert background.visible is True dialog.close() def test_background_dialog_marks_and_preserves_embedded_image() -> None: if QApplication is None or BackgroundDialog is None: return app = QApplication.instance() or QApplication([]) image_bytes = b"embedded-background-image" encoded = base64.b64encode(image_bytes).decode("ascii") background = BackgroundImage( path="", offset_x=1.0, offset_y=2.0, scale_x_percent=80.0, scale_y_percent=125.0, opacity_percent=60.0, visible=True, embedded_image_base64=encoded, embedded_image_mime_type="image/png", embedded_image_name="source.png", embedded_image_sha256=hashlib.sha256(image_bytes).hexdigest(), ) dialog = BackgroundDialog(background) assert dialog.path.text() == "Встроенная" assert dialog.path.isReadOnly() dialog.scale_x.setValue(90.0) updated = dialog.background() assert updated.path == "" assert updated.scale_x_percent == 90.0 assert updated.embedded_image_base64 == encoded assert updated.embedded_image_sha256 == background.embedded_image_sha256 dialog._set_external_path("replacement.jpg") replacement = dialog.background() assert not dialog.path.isReadOnly() assert replacement.path == "replacement.jpg" assert not replacement.has_embedded_image() dialog.close() def test_canvas_background_layer_and_geometry() -> None: if LedCanvas is None or QApplication is None or QImage is None: return app = QApplication.instance() or QApplication([]) with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: image_path = Path(temp_dir) / "background.png" image = QImage(20, 10, QImage.Format.Format_ARGB32) image.fill(QColor("#ff0000")) image.save(str(image_path)) doc = MappingDocument() doc.load(path) doc.background = BackgroundImage(str(image_path), 5.0, 7.0, 200.0, 300.0, 55.0, True) canvas = LedCanvas() canvas.set_document(doc) assert canvas._background_item is not None assert canvas._background_item.zValue() == -30 assert canvas._background_item.transformationMode() == Qt.SmoothTransformation assert abs(canvas._background_item.opacity() - 0.55) < 0.01 assert canvas._background_item.isVisible() rect = canvas._background_scene_rect() assert rect is not None origin = canvas._background_origin_scene_point() assert rect.width() == 40.0 assert rect.height() == 30.0 assert rect.center().x() == origin.x() + 5.0 assert rect.center().y() == origin.y() + 7.0 canvas.close() doc.background.visible = False canvas = LedCanvas() canvas.set_document(doc) assert canvas._background_item is not None assert not canvas._background_item.isVisible() assert canvas._background_scene_rect() is None canvas.close() relative_doc = MappingDocument() relative_doc.load(path) relative_doc.path = Path(temp_dir) / "map.json" relative_doc.background = BackgroundImage("background.png", 0, 0, 100, 100) canvas = LedCanvas() canvas.set_document(relative_doc) assert canvas.background_loaded() canvas.close() def test_canvas_background_zero_offset_uses_scheme_center() -> None: if LedCanvas is None or QApplication is None or QImage is None: return app = QApplication.instance() or QApplication([]) with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: image_path = Path(temp_dir) / "background.png" image = QImage(20, 10, QImage.Format.Format_ARGB32) image.fill(QColor("#aa0000")) image.save(str(image_path)) doc = MappingDocument() doc.load(path) doc.background = BackgroundImage(str(image_path), 0.0, 0.0, 100.0, 100.0) canvas = LedCanvas() canvas.set_document(doc) rect = canvas._background_scene_rect() origin = canvas._background_origin_scene_point() assert rect is not None assert rect.center() == origin moved_rect = QRectF(rect).translated(12.0, -8.0) start = BackgroundImage(str(image_path), 0.0, 0.0, 100.0, 100.0) canvas._apply_background_rect(moved_rect, start, rect) assert doc.background.offset_x == 12.0 assert doc.background.offset_y == -8.0 canvas.close() def test_canvas_background_edit_move_and_resize_helpers() -> None: if LedCanvas is None or QApplication is None or QImage is None: return app = QApplication.instance() or QApplication([]) with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: image_path = Path(temp_dir) / "background.png" image = QImage(20, 10, QImage.Format.Format_ARGB32) image.fill(QColor("#00ff00")) image.save(str(image_path)) doc = MappingDocument() doc.load(path) doc.background = BackgroundImage(str(image_path), 0.0, 0.0, 100.0, 100.0) canvas = LedCanvas() canvas.set_document(doc) canvas.set_background_edit_mode(True) assert canvas._background_handles starts = [] changes = [] canvas.backgroundEditStarted.connect(lambda: starts.append(True)) canvas.backgroundChanged.connect(lambda: changes.append(True)) canvas._background_drag = { "changed": False, "start_payload": doc.background.to_payload(), } canvas._finish_background_edit() assert starts == [] assert changes == [] start = BackgroundImage(str(image_path), 0.0, 0.0, 100.0, 100.0) canvas._apply_background_rect(canvas._background_scene_rect().adjusted(0, 0, 20, 10), start, canvas._background_scene_rect()) assert doc.background.scale_x_percent == 200.0 assert doc.background.scale_y_percent == 200.0 canvas.set_background_edit_mode(False) assert not canvas._background_handles canvas.close() def test_canvas_background_large_image_downsamples_and_grid_degrades() -> None: if LedCanvas is None or QApplication is None or QImage is None: return app = QApplication.instance() or QApplication([]) with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: image_path = Path(temp_dir) / "large_background.png" image = QImage(8200, 4, QImage.Format.Format_ARGB32) image.fill(QColor("#123456")) image.save(str(image_path)) doc = MappingDocument() doc.load(path) doc.background = BackgroundImage(str(image_path), 0.0, 0.0, 100.0, 100.0) canvas = LedCanvas() canvas.set_document(doc) assert canvas._background_item.pixmap().width() <= 8192 canvas.set_grid_visible(True) assert len(canvas.grid_items) <= 802 canvas.close() def test_main_window_background_reset_noops_at_100() -> None: if MainWindow is None or QApplication is None or QImage is None: return app = QApplication.instance() or QApplication([]) with tempfile.TemporaryDirectory() as temp_dir: image_path = Path(temp_dir) / "background.png" image = QImage(20, 10, QImage.Format.Format_ARGB32) image.fill(QColor("#abcdef")) image.save(str(image_path)) window = MainWindow() window.document.background = BackgroundImage(str(image_path), 0, 0, 100, 100) window.canvas.set_document(window.document) undo_before = len(window._undo_stack) window.reset_background_scale() assert len(window._undo_stack) == undo_before assert not window._dirty window.document.background.scale_x_percent = 120 window.reset_background_scale() assert len(window._undo_stack) == undo_before + 1 assert window._dirty window._dirty = False window.close() def test_main_window_background_actions() -> None: if MainWindow is None or QApplication is None or QImage is None: return app = QApplication.instance() or QApplication([]) with tempfile.TemporaryDirectory() as temp_dir: image_path = Path(temp_dir) / "background.png" image = QImage(20, 10, QImage.Format.Format_ARGB32) image.fill(QColor("#0000ff")) image.save(str(image_path)) window = MainWindow() assert "background" in window.actions assert "background_visible" in window.actions assert "background_edit" in window.actions assert not window.actions["background_edit"].isEnabled() window.document.background = BackgroundImage(str(image_path)) window.canvas.set_document(window.document) window._refresh_ui_state() assert window.actions["background_edit"].isEnabled() assert window.actions["background_visible"].isChecked() window.actions["background_visible"].setChecked(False) window.toggle_background_visibility() assert window.document.background.visible is False assert not window.canvas._background_item.isVisible() assert not window.actions["background_edit"].isEnabled() window.actions["background_visible"].setChecked(True) window.toggle_background_visibility() assert window.document.background.visible is True window.actions["background_edit"].setChecked(True) window.toggle_background_edit() assert window.canvas.background_edit_mode() window.document.background = BackgroundImage("dummy.png") window.canvas.set_document(window.document) window._refresh_ui_state() assert not window.actions["background_edit"].isEnabled() window._dirty = False window.close() def test_main_window_background_noop_edit_preserves_undo_redo() -> None: if MainWindow is None or QApplication is None or QImage is None: return app = QApplication.instance() or QApplication([]) with tempfile.TemporaryDirectory() as temp_dir: image_path = Path(temp_dir) / "background.png" image = QImage(20, 10, QImage.Format.Format_ARGB32) image.fill(QColor("#654321")) image.save(str(image_path)) window = MainWindow() window.document.background = BackgroundImage(str(image_path)) window.canvas.set_document(window.document) redo_state = window._capture_state() window._redo_stack.append(redo_state) undo_before = len(window._undo_stack) window._on_background_edit_started() window._on_background_edit_finished(False) assert len(window._undo_stack) == undo_before assert window._redo_stack == [redo_state] assert not window._dirty window.close() def test_delete_unassigned_selection_preserves_redo() -> None: if MainWindow is None or QApplication is None: return app = QApplication.instance() or QApplication([]) with temp_document_path() as path: window = MainWindow() doc = MappingDocument() doc.load(path) window.document = doc window.canvas.set_document(doc) window.canvas.select_points([0]) redo_state = window._capture_state() window._redo_stack.append(redo_state) undo_before = len(window._undo_stack) window.clear_selected_point_assignments() assert len(window._undo_stack) == undo_before assert window._redo_stack == [redo_state] assert not window._dirty window.close() def test_canvas_background_changes_preserve_camera() -> None: if LedCanvas is None or QApplication is None or QImage is None or QPointF is None: return app = QApplication.instance() or QApplication([]) with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir: image_path = Path(temp_dir) / "background.png" image = QImage(80, 40, QImage.Format.Format_ARGB32) image.fill(QColor("#445566")) image.save(str(image_path)) doc = MappingDocument() doc.load(path) canvas = LedCanvas() canvas.resize(640, 480) canvas.set_document(doc) canvas.scale(2.0, 2.0) canvas.centerOn(QPointF(70.0, 80.0)) app.processEvents() before_transform = canvas.transform() before_center = canvas.mapToScene(canvas.viewport().rect().center()) canvas.set_background(BackgroundImage(str(image_path), 500.0, -400.0, 300.0, 250.0)) after_center = canvas.mapToScene(canvas.viewport().rect().center()) assert canvas.transform().m11() == before_transform.m11() assert canvas.transform().m22() == before_transform.m22() assert abs(after_center.x() - before_center.x()) < 1.0 assert abs(after_center.y() - before_center.y()) < 1.0 rect = canvas._background_scene_rect() assert rect is not None start_background = BackgroundImage( str(image_path), canvas.document.background.offset_x, canvas.document.background.offset_y, canvas.document.background.scale_x_percent, canvas.document.background.scale_y_percent, ) canvas._apply_background_rect(rect.adjusted(-250.0, -250.0, 300.0, 300.0), start_background, rect) canvas._apply_background_geometry() resized_center = canvas.mapToScene(canvas.viewport().rect().center()) assert canvas.transform().m11() == before_transform.m11() assert abs(resized_center.x() - before_center.x()) < 1.0 assert abs(resized_center.y() - before_center.y()) < 1.0 canvas.reset_background_scale() reset_center = canvas.mapToScene(canvas.viewport().rect().center()) assert canvas.transform().m11() == before_transform.m11() assert abs(reset_center.x() - before_center.x()) < 1.0 assert abs(reset_center.y() - before_center.y()) < 1.0 canvas.clear_background() cleared_center = canvas.mapToScene(canvas.viewport().rect().center()) assert canvas.transform().m11() == before_transform.m11() assert abs(cleared_center.x() - before_center.x()) < 1.0 assert abs(cleared_center.y() - before_center.y()) < 1.0 canvas.close() 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_shows_selected_line_index_labels() -> 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]) canvas = LedCanvas() canvas.set_document(doc) canvas.select_points([0, 2]) assert set(canvas.line_index_label_items) == {0} assert canvas.line_index_label_items[0].text() == "0" assert canvas.line_index_label_items[0].flags() & QGraphicsItem.ItemIgnoresTransformations initial_delta = canvas.items_by_id[0].pos().y() - canvas.line_index_label_items[0].pos().y() canvas.scale(8.0, 8.0) canvas._position_line_index_labels() zoomed_delta = canvas.items_by_id[0].pos().y() - canvas.line_index_label_items[0].pos().y() assert zoomed_delta < initial_delta canvas.select_points([]) assert canvas.line_index_label_items == {} canvas.close() 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_edit_mode_keeps_regular_grid_spacing() -> 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.resize(640, 480) canvas.set_document(doc) canvas.set_grid_visible(True) app.processEvents() def visible_grid_spacing() -> tuple[float, float]: vertical_x = sorted( {round(item.line().x1(), 9) for item in canvas.grid_items if item.line().x1() == item.line().x2()} ) horizontal_y = sorted( {round(item.line().y1(), 9) for item in canvas.grid_items if item.line().y1() == item.line().y2()} ) assert len(vertical_x) >= 2 assert len(horizontal_y) >= 2 return vertical_x[1] - vertical_x[0], horizontal_y[1] - horizontal_y[0] regular_spacing = visible_grid_spacing() canvas.set_edit_mode_enabled(True) app.processEvents() edit_spacing = visible_grid_spacing() assert abs(regular_spacing[0] - doc.grid_step_mm) < 1e-9 assert abs(regular_spacing[1] - doc.grid_step_mm) < 1e-9 assert edit_spacing == regular_spacing def test_edit_mode_preserves_camera_and_selection() -> 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.resize(640, 480) canvas.set_document(doc) canvas.resetTransform() canvas.scale(1.75, 1.75) canvas.centerOn(canvas.items_by_id[0].pos()) canvas.select_points([0, 1]) app.processEvents() original_items = dict(canvas.items_by_id) original_transform = canvas.transform() original_positions = { point_id: canvas.mapFromScene(item.pos()) for point_id, item in original_items.items() } for enabled in (True, False): canvas.set_edit_mode_enabled(enabled) app.processEvents() assert canvas.transform() == original_transform assert canvas.selected_point_ids() == [0, 1] assert all(canvas.items_by_id[point_id] is item for point_id, item in original_items.items()) for point_id, item in canvas.items_by_id.items(): current = canvas.mapFromScene(item.pos()) original = original_positions[point_id] assert abs(current.x() - original.x()) <= 1 assert abs(current.y() - original.y()) <= 1 def test_led_points_keep_absolute_screen_size() -> None: if LedCanvas is None or QApplication is None or QGraphicsItem is None: return app = QApplication.instance() or QApplication([]) doc = MappingDocument() doc.points = [LedPoint(0, 0, 0.0, 0.0, 0, 0, coordinate_unit="mm")] canvas = LedCanvas() canvas.resize(640, 480) canvas.set_document(doc) canvas.set_point_diameter(17) item = canvas.items_by_id[0] assert item.flags() & QGraphicsItem.GraphicsItemFlag.ItemIgnoresTransformations assert abs(item.rect().width() - 17.0) < 1e-9 before = item.deviceTransform(canvas.viewportTransform()).mapRect(item.rect()).width() canvas.scale(3.0, 3.0) app.processEvents() after = item.deviceTransform(canvas.viewportTransform()).mapRect(item.rect()).width() assert abs(after - before) < 0.01 canvas.close() def test_rubber_selection_uses_absolute_led_geometry() -> None: if LedCanvas is None or QApplication is None or QPointF is None: return app = QApplication.instance() or QApplication([]) doc = MappingDocument() doc.points = [LedPoint(0, 0, 0.0, 0.0, 0, 0, coordinate_unit="mm")] canvas = LedCanvas() canvas.resize(640, 480) canvas.set_document(doc) canvas.set_point_diameter(20) canvas.resetTransform() canvas.scale(0.25, 0.25) center = canvas.viewportTransform().map(canvas.items_by_id[0].pos()) canvas._rubber_origin = canvas.viewportTransform().inverted()[0].map(center + QPointF(4.0, -1.0)) end = canvas.viewportTransform().inverted()[0].map(center + QPointF(6.0, 1.0)) canvas._finish_rubber_band(end, Qt.NoModifier) assert canvas.selected_point_ids() == [0] canvas.select_points([]) canvas.resetTransform() canvas.scale(4.0, 4.0) center = canvas.viewportTransform().map(canvas.items_by_id[0].pos()) canvas._rubber_origin = canvas.viewportTransform().inverted()[0].map(center + QPointF(20.0, -1.0)) end = canvas.viewportTransform().inverted()[0].map(center + QPointF(22.0, 1.0)) canvas._finish_rubber_band(end, Qt.NoModifier) assert canvas.selected_point_ids() == [] canvas.close() def test_fit_updates_absolute_selection_geometry() -> 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, coordinate_unit="mm"), LedPoint(1, 1, 1000.0, 0.0, 0, 1, coordinate_unit="mm"), ] canvas = LedCanvas() canvas.resize(640, 480) canvas.set_document(doc) canvas.select_points([0]) canvas.scale(3.0, 3.0) canvas.fit_to_content() app.processEvents() item = canvas.items_by_id[0] bbox = canvas._selection_bbox_item.rect() screen_padding = (item.pos().x() - bbox.left()) * abs(canvas.transform().m11()) assert abs(screen_padding - (canvas._point_radius + 4.0)) < 0.1 canvas.close() def test_large_absolute_leds_fit_without_clipping() -> 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, coordinate_unit="mm"), LedPoint(1, 1, 1000.0, 0.0, 0, 1, coordinate_unit="mm"), ] canvas = LedCanvas() canvas.resize(640, 480) canvas.set_document(doc) canvas.set_point_diameter(64) canvas.fit_to_content() app.processEvents() viewport_width = canvas.viewport().width() centers = [canvas.mapFromScene(canvas.items_by_id[index].pos()).x() for index in (0, 1)] assert min(centers) >= 32 assert max(centers) <= viewport_width - 32 canvas.close() def test_absolute_led_label_stays_centered_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, coordinate_unit="mm")] line = doc.add_line() doc.add_block(line.id, [0]) canvas = LedCanvas() canvas.resize(640, 480) canvas.set_document(doc) canvas.set_active_line(line.id) canvas.select_points([0]) canvas.resetTransform() canvas.scale(5.85, 5.85) canvas._position_line_index_labels() label = canvas.line_index_label_items[0] label_center = label.deviceTransform(canvas.viewportTransform()).mapRect(label.boundingRect()).center().x() point_center = canvas.viewportTransform().map(canvas.items_by_id[0].pos()).x() assert abs(label_center - point_center) < 0.1 canvas.close() 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_bends_overlapping_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) / "overlap.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}, ] path.write_text(json.dumps({"circles": points}), encoding="utf-8") 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, [1, 0]) canvas = LedCanvas() canvas.set_document(doc) connection_paths = [item.path() for item in canvas.connection_items if item.zValue() == 1] assert any(path_has_curve(path) for path in connection_paths) def test_canvas_bends_partially_overlapping_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) / "partial-overlap.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": 68.0, "row": 2, "col": 0}, {"id": 3, "x": 0.0, "y": 102.0, "row": 3, "col": 0}, ] path.write_text(json.dumps({"circles": points}), encoding="utf-8") doc = MappingDocument() doc.load(path) first_line = doc.add_line() second_line = doc.add_line() doc.add_block(first_line.id, [0, 2]) doc.add_block(second_line.id, [1, 3]) canvas = LedCanvas() canvas.set_document(doc) connection_paths = [item.path() for item in canvas.connection_items if item.zValue() == 1] assert any(path_has_curve(path) for path in connection_paths) def test_canvas_active_line_switch_does_not_redraw_connections() -> 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) first_line = doc.add_line() second_line = doc.add_line() doc.add_block(first_line.id, [0, 1, 2]) doc.add_block(second_line.id, [3, 4, 5]) canvas = LedCanvas() canvas.set_document(doc) before_ids = [id(item) for item in canvas.connection_items] canvas.set_active_line(first_line.id) canvas.set_active_line(second_line.id) assert [id(item) for item in canvas.connection_items] == before_ids 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() 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 = { "Файл": ["Создать...", "Открыть", "Сохранить", "Сохранить как", "Экспортировать в изображение...", "Сгенерировать скрипт", "Параметры"], "Правка": ["Отменить", "Повторить", "Удалить фиксацию", "Удалить точки", "Удалить все", "Очистить выбор"], "Вид": [ "Показать всё", "Сетка", "Стрелки направления", "Отображать id светодиодов", "Подложка", "Скрыть боковую панель", ], "Инструменты": ["Новая линия", "Новый светодиод", "Сгенерировать скрипт", "Автоблоки", "Против часовой", "Кисть"], } 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() if top_action.text() == "Вид": background_action = next(action for action in menu.actions() if action.text() == "Подложка") background_menu = background_action.menu() assert background_menu is not None assert [action.text() for action in background_menu.actions() if not action.isSeparator()] == [ "Подложка", "Показать подложку", "Редактировать подложку", "Сбросить масштаб подложки", "Удалить подложку", ] 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 = { "new", "open", "save", "save_as", "settings", "generate_script", "add_line", "add_led", "add_block", "auto_blocks", "clockwise", "counterclockwise", "rebuild_route", "brush", "undo", "redo", "delete", "delete_points", "clear_all", "clear_selection", "fit_view", "grid", "direction_arrows", "led_ids", "background", "background_visible", "background_edit", "background_clear", "background_reset_scale", "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() window.settings.shortcuts = dict(DEFAULT_SHORTCUTS) window._apply_shortcuts_to_actions() app.processEvents() expected = { "new": "Ctrl+N", "generate_script": "Ctrl+G", "settings": "Ctrl+,", "delete_points": "Ctrl+Del", "clear_all": "Shift+Del", "clear_selection": "Esc", "fit_view": "Ctrl+0", "grid": "G", "direction_arrows": "Ctrl+Shift+A", "led_ids": "I", "background": "Ctrl+B", "background_visible": "Ctrl+Alt+B", "background_edit": "Ctrl+Shift+I", "background_reset_scale": "Ctrl+Alt+0", "background_clear": "Ctrl+Alt+Backspace", "toggle_sidebar": "F9", "add_line": "Ctrl+L", "add_led": "N", "add_block": "Ctrl+Shift+L", "auto_blocks": "Ctrl+Shift+B", "clockwise": "Ctrl+Alt+R", "counterclockwise": "Ctrl+Alt+Shift+R", "rebuild_route": "Ctrl+Shift+R", "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.show_led_ids_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.led_point_diameter_px = 17 window.document.grid_step_mm = 47.5 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 not window.actions["led_ids"].isChecked() assert window.canvas._grid_visible is True assert window.canvas.direction_arrows_visible() is False assert window.canvas.line_index_labels_visible() is False assert window.canvas._brush_radius == 31 assert window.canvas._grid_step == 47.5 assert abs(window.canvas._point_radius - 8.5) < 1e-9 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.grid_step.setValue(50.0) dialog.led_point_diameter.setValue(17) updated = dialog.settings() assert updated.grid_step_mm == 50.0 assert updated.led_point_diameter_px == 17 dialog.grid_step_reset.click() dialog.led_point_diameter_reset.click() assert dialog.grid_step.value() == 34.0 assert dialog.led_point_diameter.value() == 9 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_menu_disabled_items_are_visually_distinct() -> None: if dark_stylesheet is None or light_stylesheet is None: return for stylesheet, disabled_color in ((dark_stylesheet(), "#737373"), (light_stylesheet(), "#94a3b8")): assert "QMenu::item:disabled" in stylesheet assert "QMenu::item:selected:disabled" in stylesheet assert disabled_color in stylesheet 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", "image", "layers", "paintbrush", "plus", "redo-2", "rotate-ccw", "rotate-cw", "save", "save-all", "settings", "trash-2", "undo-2", } assert expected_names.issubset(set(LUCIDE_PATHS)) def test_table_uses_single_line_index_column() -> None: if MainWindow is None or QApplication is None: return app = QApplication.instance() or QApplication([]) window = MainWindow() assert [window.table.horizontalHeaderItem(column).text() for column in range(window.table.columnCount())] == [ "ID", "X", "Y", "Ряд", "Кол.", "Линия", "Блок", "UID", ] 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.item(0, 0).text() == "0" assert window.table.item(1, 0).text() == "1" assert window.table.item(0, UID_COLUMN).text() == "0" window._on_selection_changed([0, 4]) app.processEvents() assert [window.table.item(row, 0).text() for row in range(2)] == ["0", "0"] window._on_selection_changed([2, 3]) app.processEvents() assert [window.table.item(row, 0).text() for row in range(2)] == ["-", "-"] window.close() def test_table_uid_column_displays_source_id_but_selects_internal_uid() -> 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) manual = doc.add_manual_point(10.0, 20.0) window.document = doc window.canvas.set_document(doc) window._on_selection_changed([manual.uid]) app.processEvents() uid_item = window.table.item(0, UID_COLUMN) assert uid_item.text() == str(manual.source_id) assert uid_item.data(Qt.UserRole) == manual.uid window.table.clearSelection() window.table.selectRow(0) app.processEvents() assert window.canvas.selected_point_ids() == [manual.uid] window.close() def test_led_coordinates_are_editable_in_table() -> 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.canvas.select_points([0]) app.processEvents() x_item = window.table.item(0, 1) y_item = window.table.item(0, 2) assert x_item.flags() & Qt.ItemIsEditable assert y_item.flags() & Qt.ItemIsEditable x_item.setText("15.5") app.processEvents() assert window.document.points[0].x == 15.5 assert window._dirty assert window.canvas.items_by_id[0].pos() == window.canvas._to_scene_point(15.5, window.document.points[0].y) window._dirty = False window.close() def test_coordinate_editors_preserve_precision() -> None: if MainWindow is None or QApplication is None: return app = QApplication.instance() or QApplication([]) window = MainWindow() value = 0.0123456789012345 text = window._format_coordinate(value) assert float(text) == value spin = window._coordinate_spin_box(value) assert spin.decimals() == 15 assert abs(spin.value() - value) < 1e-15 window.close() def test_main_window_can_delete_selected_points() -> 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, 1, 2]) window.document = doc window.active_line_id = line.id window.canvas.set_document(doc) window.refresh_tree() window._confirm_delete_points = lambda _count: True window.canvas.select_points([1]) window.delete_selected_points() app.processEvents() assert len(window.document.points) == len(fixture_points()) - 1 assert [point.uid for point in window.document.points] == list(range(len(window.document.points))) assert window.document.get_line(line.id).blocks[0].point_ids == [0, 1] assert window.canvas.selected_point_ids() == [] assert 1 in window.canvas.items_by_id assert window._dirty window._dirty = False window.close() def test_table_selection_uses_selected_rows() -> 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._on_selection_changed([0, 1]) window.table.clearSelection() window.table.selectRow(1) app.processEvents() assert window.canvas.selected_point_ids() == [1] window.close() def test_tree_can_select_and_merge_multiple_blocks() -> 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() first = doc.add_block(line.id, [0, 1]) second = doc.add_block(line.id, [2, 3]) third = doc.add_block(line.id, [4]) window.document = doc window.active_line_id = line.id window.canvas.set_document(doc) window.refresh_tree() line_item = window.tree.topLevelItem(0) first_item = line_item.child(0) second_item = line_item.child(1) window.tree.setCurrentItem(first_item) first_item.setSelected(True) assert window._can_merge_selected_tree_blocks() is False second_item.setSelected(True) window._on_tree_selection() assert window._can_merge_selected_tree_blocks() is True assert window.canvas.selected_point_ids() == [0, 1, 2, 3] assert window.brush_target is None window.merge_selected_blocks() line_after = window.document.get_line(line.id) assert len(line_after.blocks) == 2 assert line_after.blocks[0].id == first.id assert line_after.blocks[0].point_ids == [0, 1, 2, 3] assert line_after.blocks[1].id == third.id assert [window.document.points[point_id].line_index for point_id in [0, 1, 2, 3, 4]] == [0, 1, 2, 3, 4] assert window.canvas.selected_point_ids() == [0, 1, 2, 3] window._dirty = False window.close() def test_tree_can_delete_multiple_blocks_without_deleting_selected_lines() -> 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() first_block = doc.add_block(first_line.id, [0, 1]) kept_first = doc.add_block(first_line.id, [2]) second_line = doc.add_line() second_block = doc.add_block(second_line.id, [4, 5]) kept_second = doc.add_block(second_line.id, [6]) window.document = doc window.active_line_id = first_line.id window.canvas.set_document(doc) window.refresh_tree() window.show() app.processEvents() first_line_item = window.tree.topLevelItem(0) second_line_item = window.tree.topLevelItem(1) first_block_item = first_line_item.child(0) second_block_item = second_line_item.child(0) window.tree.clearSelection() first_line_item.setSelected(True) first_block_item.setSelected(True) second_block_item.setSelected(True) app.processEvents() confirmations: list[tuple[int, int]] = [] window._confirm_delete_blocks = lambda count, affected: confirmations.append((count, affected)) or True window._confirm_delete_points = lambda _count: False window._confirm_delete = lambda *_args: False undo_before = len(window._undo_stack) assert window._tree_delete_label(first_line_item.data(0, Qt.UserRole)) == "Удалить выбранные блоки\tCtrl+Del" window.tree.setFocus() app.processEvents() QTest.keyClick(window.tree, Qt.Key_Delete, Qt.ControlModifier) app.processEvents() assert confirmations == [(2, 4)] assert len(window._undo_stack) == undo_before + 1 assert [line.id for line in window.document.lines] == [first_line.id, second_line.id] assert window.document.get_line(first_line.id).blocks[0].point_ids == kept_first.point_ids assert window.document.get_line(second_line.id).blocks[0].point_ids == kept_second.point_ids assert all(window.document.points[point_id].line_id is None for point_id in [0, 1, 4, 5]) assert window.canvas.selected_point_ids() == [] window.undo_last_change() assert len(window.document.get_line(first_line.id).blocks) == 2 assert len(window.document.get_line(second_line.id).blocks) == 2 window._dirty = False 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_inner_splitter_persists_sizes() -> None: if MainWindow is None or QApplication is None: return app = QApplication.instance() or QApplication([]) window = MainWindow() window.settings.save = lambda: None assert window.sidebar_content_splitter is not None window.sidebar_content_splitter.setSizes([220, 480]) window._on_sidebar_content_splitter_moved(220, 1) app.processEvents() sizes = window.settings.sidebar_inner_sizes assert sizes is not None assert len(sizes) == 2 assert all(size > 0 for size in sizes) ratio = sizes[0] / sum(sizes) assert 0.25 <= ratio <= 0.40 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_context_menu_can_request_edit_and_counterclockwise_block() -> None: if LedCanvas is None or QApplication is None or canvas_module is None: return app = QApplication.instance() or QApplication([]) class FakeAction: def __init__(self, text: str) -> None: self.text = text self.enabled = True def setEnabled(self, enabled: bool) -> None: self.enabled = enabled class FakeMenu: next_choice = "" def __init__(self, _parent=None) -> None: self.actions: list[FakeAction] = [] def addAction(self, text: str) -> FakeAction: action = FakeAction(text) self.actions.append(action) return action def exec(self, _global_pos): # noqa: ANN001 return next(action for action in self.actions if action.text == self.next_choice) class FakeContextEvent: def pos(self) -> QPoint: return QPoint(0, 0) def globalPos(self) -> QPoint: return QPoint(0, 0) with temp_document_path() as path: doc = MappingDocument() doc.load(path) canvas = LedCanvas() canvas.set_document(doc) canvas.select_points([0]) edited: list[int] = [] counterclockwise: list[int] = [] canvas.editPointRequested.connect(lambda point_id: edited.append(point_id)) canvas.counterclockwiseBlockFromPointRequested.connect(lambda point_id: counterclockwise.append(point_id)) original_menu = canvas_module.QMenu canvas_module.QMenu = FakeMenu try: FakeMenu.next_choice = "Редактировать координаты" canvas.contextMenuEvent(FakeContextEvent()) FakeMenu.next_choice = "Автоматически добавить блок против часовой" canvas.contextMenuEvent(FakeContextEvent()) finally: canvas_module.QMenu = original_menu canvas.close() assert edited == [0] assert counterclockwise == [0] def test_canvas_alt_drag_emits_point_move() -> 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) / "single-point.json" path.write_text( json.dumps({"circles": [{"id": 1, "x": 100.0, "y": 200.0, "row": 0, "col": 0}]}), encoding="utf-8", ) doc = MappingDocument() doc.load(path) canvas = LedCanvas() canvas.resize(320, 240) canvas.set_document(doc) canvas.show() app.processEvents() start_pos = canvas.mapFromScene(canvas.items_by_id[0].pos()) moved: list[tuple[int, float, float]] = [] canvas.pointMoveFinished.connect(lambda point_id, x, y: moved.append((point_id, x, y))) assert canvas._start_point_drag(start_pos) canvas._finish_point_drag(start_pos + QPoint(30, -20)) assert moved assert moved[0][0] == 0 assert abs(moved[0][1] - doc.points[0].x) > 1e-6 or abs(moved[0][2] - doc.points[0].y) > 1e-6 canvas.close() def test_canvas_alt_drag_ignores_line_index_label_hit() -> 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) / "labeled-point.json" path.write_text( json.dumps({"circles": [{"id": 1, "x": 100.0, "y": 200.0, "row": 0, "col": 0}]}), encoding="utf-8", ) doc = MappingDocument() doc.load(path) line = doc.add_line() doc.add_block(line.id, [0]) canvas = LedCanvas() canvas.resize(320, 240) canvas.set_document(doc) canvas.set_active_line(line.id) canvas.select_points([0]) canvas.show() app.processEvents() label = canvas.line_index_label_items[0] assert not label.isEnabled() assert label.acceptedMouseButtons() == Qt.NoButton label_pos = canvas.mapFromScene(canvas.items_by_id[0].pos()) assert canvas._led_item_at(label_pos) is canvas.items_by_id[0] assert canvas._start_point_drag(label_pos) assert canvas._point_drag["point_id"] == 0 canvas._finish_point_drag(label_pos + QPoint(15, 0)) canvas.close() def test_main_window_updates_point_from_canvas_drag() -> 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) source_id = doc.points[0].source_id assert window.move_point_from_canvas(0, doc.points[0].x + 12.5, doc.points[0].y + 7.25) is None assert doc.points[0].source_id == source_id assert abs(doc.points[0].x - (fixture_points()[0]["x"] + 12.5)) < 1e-6 assert abs(doc.points[0].y - (fixture_points()[0]["y"] + 7.25)) < 1e-6 assert window._dirty assert window._undo_stack window._dirty = False window.close() def test_save_commits_active_canvas_item_position() -> None: if MainWindow is None or QApplication is None or QPointF is None: return app = QApplication.instance() or QApplication([]) window = MainWindow() with tempfile.TemporaryDirectory() as temp_dir: path = Path(temp_dir) / "mapping.json" doc = MappingDocument() doc.coordinate_unit = "mm" doc.points = [LedPoint(0, 7, 12.25, -4.5, 0, 0, coordinate_unit="mm", manual=True)] window.document = doc window.canvas.set_document(doc) item = window.canvas.items_by_id[0] item.setPos(item.pos() + QPointF(13.125, -8.75)) expected = window.canvas._document_point_from_scene(item.pos()) assert window._save_to(path) reloaded = MappingDocument() reloaded.load(path) assert abs(reloaded.points[0].x - expected.x()) < 1e-12 assert abs(reloaded.points[0].y - expected.y()) < 1e-12 window.close() def test_main_window_edit_point_dialog_imports_localized_buttons() -> None: if MainWindow is None or QApplication is None: return import app.main_window as main_window_module app = QApplication.instance() or QApplication([]) window = MainWindow() with temp_document_path() as path: doc = MappingDocument() doc.load(path) window.document = doc original_exec = main_window_module.QDialog.exec main_window_module.QDialog.exec = lambda _dialog: main_window_module.QDialog.Rejected try: window.edit_point_coordinates(0) finally: main_window_module.QDialog.exec = original_exec window.close() 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_creates_new_block_after_selected_tree_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() doc.add_block(line.id, [0, 1]) doc.add_block(line.id, [4, 5]) window.document = doc window.active_line_id = line.id window.canvas.set_document(doc) window.refresh_tree() line_item = window.tree.topLevelItem(0) first_block_item = line_item.child(0) window.tree.setCurrentItem(first_block_item) first_block_item.setSelected(True) window.actions["brush"].setChecked(True) window.toggle_brush_mode() window.start_brush_stroke(False) window.add_brush_point_to_block(2) window.add_brush_point_to_block(3) window.finish_brush_stroke() app.processEvents() line_after = window.document.get_line(line.id) assert [(block.id, block.point_ids) for block in line_after.blocks] == [ (1, [0, 1]), (2, [2, 3]), (3, [4, 5]), ] assert [window.document.points[point_id].block_id for point_id in [0, 2, 4]] == [1, 2, 3] current = window.tree.currentItem() assert current is not None assert current.data(0, Qt.UserRole) == ("block", line.id, 2) assert window.canvas.selected_point_ids() == [2, 3] 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_open_dialog_default_dir_uses_application_dir() -> None: assert APP_DIR == ROOT_DIR def test_generate_dialog_default_dir_uses_application_dir() -> None: source = (PROJECT_ROOT / "app" / "main_window.py").read_text(encoding="utf-8") assert 'QFileDialog.getExistingDirectory(\n self,\n "Выберите папку для генерации скрипта",\n str(APP_DIR),' in source assert 'str(ROOT_DIR / "panel_mapping_build")' not in source def test_package_imports_work_from_project_root() -> None: importlib.import_module("app.main") importlib.import_module("app.background_dialog") 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 test_pyinstaller_spec_build_ref_rules() -> None: spec_path = PROJECT_ROOT / "app" / "LEDMapper.spec" spec_source = spec_path.read_text(encoding="utf-8") helper_source = spec_source.split("app_name = f'LEDMapper-{git_build_ref()}'", 1)[0] helper_source = helper_source.replace("spec_dir = Path(SPECPATH)\n\n\n", "") with tempfile.TemporaryDirectory() as temp_dir: repo = Path(temp_dir) runner = repo / "runner.py" runner.write_text( "from pathlib import Path\n" f"{helper_source}\n" "spec_dir = Path(__file__).resolve().parent\n" "print(git_build_ref())\n", encoding="utf-8", ) subprocess.run(["git", "init"], cwd=repo, check=True, stdout=subprocess.DEVNULL) subprocess.run(["git", "config", "user.email", "qa@example.local"], cwd=repo, check=True) subprocess.run(["git", "config", "user.name", "QA"], cwd=repo, check=True) (repo / "tracked.txt").write_text("clean\n", encoding="utf-8") subprocess.run(["git", "add", "tracked.txt"], cwd=repo, check=True) subprocess.run(["git", "commit", "-m", "initial"], cwd=repo, check=True, stdout=subprocess.DEVNULL) commit = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=repo, text=True).strip() clean_ref = subprocess.check_output([sys.executable, str(runner)], cwd=repo, text=True).strip() assert clean_ref == commit subprocess.run(["git", "tag", "v9.9"], cwd=repo, check=True) tagged_ref = subprocess.check_output([sys.executable, str(runner)], cwd=repo, text=True).strip() assert tagged_ref == "v9.9" (repo / "tracked.txt").write_text("dirty\n", encoding="utf-8") dirty_ref = subprocess.check_output([sys.executable, str(runner)], cwd=repo, text=True).strip() assert re.fullmatch(r"\d{6}-\d{8}", dirty_ref) 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 path_has_curve(path) -> bool: # noqa: ANN001 for index in range(path.elementCount()): if path.elementAt(index).type != 0: 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()