from __future__ import annotations import json import importlib 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, 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.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 QMessageBox = None QToolBar = None PROJECT_ROOT = Path(__file__).resolve().parent.parent def main() -> None: tests: list[Callable[[], None]] = [ 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_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_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_panel_mapping_generation_with_strategies, test_block_renaming_and_naming_strategy, test_line_renaming_and_tree_shortcuts, test_save_preserves_unknown_source_fields, test_ordered_json_preserves_document_and_remaps_uids, test_background_load_save_roundtrip, test_background_dialog_preserves_non_uniform_scale, 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_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_main_window_can_delete_selected_points, test_table_selection_uses_selected_rows, test_tree_can_select_and_merge_multiple_blocks, 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_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: test() print(f"ok - {test.__name__}") print(f"All {len(tests)} QA smoke checks passed.") def test_load_supported_json_files() -> None: fixtures = [ PROJECT_ROOT / "circles.json", PROJECT_ROOT / "circles_new.json", PROJECT_ROOT / "panel_mapping_build" / "ordered_circles.json", ] for path in fixtures: doc = MappingDocument() doc.load(path) assert doc.points, f"{path} did not load any points" assert doc.collection_name in {"circles", "leds"} assert len(doc.lines) <= MAX_LINES def test_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_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_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.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) line = doc.add_line() doc.add_block(line.id, list(range(len(doc.points)))) result = generate_panel_mapping_from_document(doc, Path(temp_dir)) assert result.led_count == len(doc.points) assert result.block_count == 1 assert result.assigned_count == len(doc.points) assert result.unassigned_count == 0 assert result.vm_rows > 0 assert result.vm_cols > 0 for output_path in result.generated_files.values(): assert output_path.exists(), output_path vm_map = result.generated_files["vm_map"].read_text(encoding="utf-8") assert "VM_LED_COUNT" in vm_map assert "#define LED_BLOCK_COUNT 1" in vm_map assert "{0, 0}" in vm_map ordered_payload = json.loads(result.generated_files["ordered_json"].read_text(encoding="utf-8")) assert len(ordered_payload["circles"]) == len(doc.points) assert ordered_payload["circles"][0]["strip"] == 0 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 legacy_ino = result.generated_files["legacy_ino"].read_text(encoding="utf-8") assert "BLOCK_LEDS" in legacy_ino assert "setBlockLED" in legacy_ino assert "MATRIX_ROWS" not in legacy_ino assert "vmCount" not in legacy_ino # 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 legacy_ino = result.generated_files["legacy_ino"].read_text(encoding="utf-8") assert "BLOCK_LEDS" in legacy_ino assert "setBlockLED" in legacy_ino assert "MATRIX_ROWS" in legacy_ino assert "vmCount" in legacy_ino def test_block_renaming_and_naming_strategy() -> None: from 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 legacy_ino = result.generated_files["legacy_ino"].read_text(encoding="utf-8") assert "#define BLOCK_Levoe_Krylo 0" in legacy_ino assert "static const char BLOCK_NAME_0[] PROGMEM = \"Левое Крыло\";" in legacy_ino assert "getBlockIndexByName" in legacy_ino 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 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_delete"].shortcut().toString() == "Ctrl+Del" assert window.actions["tree_merge"].shortcut().toString() == "Ctrl+M" window.close() 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") 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_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_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_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() app.processEvents() menu_titles = [action.text() for action in window.menuBar().actions()] assert menu_titles[:4] == ["Файл", "Правка", "Вид", "Инструменты"] assert QToolBar is None or not window.findChildren(QToolBar) window.close() def test_classic_menu_contains_expected_actions() -> None: if MainWindow is None or QApplication is None: return app = QApplication.instance() or QApplication([]) window = MainWindow() app.processEvents() expected = { "Файл": ["Открыть", "Сохранить", "Сохранить как", "Сгенерировать скрипт", "Параметры"], "Правка": ["Отменить", "Повторить", "Удалить фиксацию", "Удалить точки", "Удалить все", "Очистить выбор"], "Вид": [ "Показать всё", "Сетка 34 мм", "Стрелки направления", "Отображать 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 = { "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() app.processEvents() expected = { "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.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 abs(window.canvas._zoom_focus_pull - 0.12) < 0.001 assert window.canvas._pan_mode == "space" assert window.canvas._instant_space_pan is True assert window.canvas._theme == "dark" assert window.settings.theme == "dark" window.close() def test_settings_dialog_has_requested_categories() -> None: if SettingsDialog is None or AppSettings is None or QApplication is None: return app = QApplication.instance() or QApplication([]) dialog = SettingsDialog(AppSettings()) section_titles = [dialog.section_list.item(index).text() for index in range(dialog.section_list.count())] assert section_titles == ["Внешний вид", "Поведение", "Инструменты", "Файлы и генерация", "Комбинации клавиш"] assert dialog.section_stack.count() == len(section_titles) assert dialog.theme.itemText(0) == "Системная" assert dialog.theme.itemText(1) == "Светлая" assert dialog.theme.itemText(2) == "Темная" assert dialog.theme.currentData() == "system" assert dialog.pan_mode.currentData() == "middle_space" assert dialog.instant_space_pan.isChecked() is False dialog.close() def test_theme_resolution_and_canvas_apply() -> None: if QApplication is None or MainWindow is None or effective_theme is None: return app = QApplication.instance() or QApplication([]) assert effective_theme("dark") == "dark" assert effective_theme("light") == "light" assert effective_theme("system") in {"dark", "light"} window = MainWindow() window.settings.theme = "light" window._apply_canvas_settings() assert window.canvas._theme == "light" window.settings.theme = "dark" window._apply_canvas_settings() assert window.canvas._theme == "dark" window.close() def test_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_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_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.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()