- Исправлено отображение кнопок инструментов в QMenuBar: вместо QWidgetAction использован дочерний виджет QWidget с абсолютным позиционированием. - Добавлено позиционирование виджета кнопок справа от заголовка Инструменты с автоматическим пересчетом координат в resizeEvent. - Обновлен юнит-тест test_menu_bar_tools_buttons под новое представление.
3645 lines
136 KiB
Python
3645 lines
136 KiB
Python
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
|
||
QColor = None
|
||
QImage = None
|
||
QKeySequence = None
|
||
QApplication = None
|
||
QDialogButtonBox = None
|
||
QGraphicsItem = None
|
||
QMessageBox = None
|
||
QToolBar = None
|
||
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||
|
||
|
||
def main() -> None:
|
||
tests: list[Callable[[], None]] = [
|
||
test_load_empty_json_file,
|
||
test_load_supported_json_files,
|
||
test_load_raw_json_with_trailing_commas_and_bare_decimal_points,
|
||
test_non_utf8_json_is_converted_to_utf8_on_load,
|
||
test_utf16_le_json_without_bom_is_converted_to_utf8_on_load,
|
||
test_line_limit_rejects_fifth_line,
|
||
test_line_indexes_are_scoped_per_line,
|
||
test_remove_selected_leds_blocks_and_lines,
|
||
test_remove_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_new_file_action,
|
||
test_menu_bar_tools_buttons,
|
||
test_export_mapping_to_image,
|
||
test_save_preserves_unknown_source_fields,
|
||
test_ordered_json_preserves_document_and_remaps_uids,
|
||
test_background_load_save_roundtrip,
|
||
test_background_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_empty_json_file() -> None:
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
# Test 1: Completely empty file (0 bytes)
|
||
path = Path(temp_dir) / "empty.json"
|
||
path.write_bytes(b"")
|
||
doc = MappingDocument()
|
||
doc.load(path)
|
||
assert len(doc.points) == 0
|
||
assert path.read_text(encoding="utf-8") == '{"circles": []}'
|
||
|
||
# Test 2: Whitespace-only file
|
||
path_ws = Path(temp_dir) / "whitespace.json"
|
||
path_ws.write_text(" \n \t ", encoding="utf-8")
|
||
doc_ws = MappingDocument()
|
||
doc_ws.load(path_ws)
|
||
assert len(doc_ws.points) == 0
|
||
assert path_ws.read_text(encoding="utf-8") == '{"circles": []}'
|
||
|
||
|
||
def test_load_supported_json_files() -> None:
|
||
fixtures = [
|
||
PROJECT_ROOT / "circles.json",
|
||
PROJECT_ROOT / "circles_new.json",
|
||
PROJECT_ROOT / "panel_mapping_build" / "ordered_circles.json",
|
||
]
|
||
for path in fixtures:
|
||
doc = MappingDocument()
|
||
doc.load(path)
|
||
assert doc.points, f"{path} did not load any points"
|
||
assert doc.collection_name in {"circles", "leds"}
|
||
assert len(doc.lines) <= MAX_LINES
|
||
|
||
|
||
def test_load_raw_json_with_trailing_commas_and_bare_decimal_points() -> None:
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
path = Path(temp_dir) / "raw_circle_centers.json"
|
||
path.write_text(
|
||
"""
|
||
[
|
||
{"id":1,"x":1173.,"y":2159.019396,"row":35,"column":64,},
|
||
{"id":2,"x":1207.,"y":17.,"row":36,"column":1,}
|
||
]
|
||
""",
|
||
encoding="utf-8",
|
||
)
|
||
|
||
doc = MappingDocument()
|
||
doc.load(path)
|
||
|
||
assert doc.collection_name == "circles"
|
||
assert len(doc.points) == 2
|
||
assert doc.points[0].x == 1173.0
|
||
assert doc.points[0].col == 64
|
||
assert doc.points[1].y == 17.0
|
||
|
||
|
||
def test_non_utf8_json_is_converted_to_utf8_on_load() -> None:
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
path = Path(temp_dir) / "cp1251.json"
|
||
text = '{"circles":[{"id":1,"x":1.,"y":2.,"row":0,"column":0,"label":"точка"}]}'
|
||
path.write_bytes(text.encode("cp1251"))
|
||
|
||
doc = MappingDocument()
|
||
doc.load(path)
|
||
|
||
assert len(doc.points) == 1
|
||
assert doc.points[0].extra_payload["label"] == "точка"
|
||
assert path.read_bytes().decode("utf-8") == text
|
||
|
||
|
||
def test_utf16_le_json_without_bom_is_converted_to_utf8_on_load() -> None:
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
path = Path(temp_dir) / "utf16le.json"
|
||
text = '[{"id":1,"x":1173.,"y":17.,"row":35,"column":64}]'
|
||
path.write_bytes(text.encode("utf-16-le"))
|
||
|
||
doc = MappingDocument()
|
||
doc.load(path)
|
||
|
||
assert len(doc.points) == 1
|
||
assert doc.points[0].x == 1173.0
|
||
assert path.read_bytes().decode("utf-8") == text
|
||
|
||
|
||
def test_line_limit_rejects_fifth_line() -> None:
|
||
with temp_document_path() as path:
|
||
doc = MappingDocument()
|
||
doc.load(path)
|
||
for _ in range(MAX_LINES):
|
||
doc.add_line()
|
||
|
||
assert len(doc.lines) == MAX_LINES
|
||
try:
|
||
doc.add_line()
|
||
except ValueError as exc:
|
||
assert str(MAX_LINES) in str(exc)
|
||
else:
|
||
raise AssertionError("Adding a fifth line should fail.")
|
||
|
||
|
||
def test_line_indexes_are_scoped_per_line() -> None:
|
||
with temp_document_path() as path:
|
||
doc = MappingDocument()
|
||
doc.load(path)
|
||
|
||
first_line = doc.add_line()
|
||
doc.add_block(first_line.id, [0, 1])
|
||
second_line = doc.add_line()
|
||
doc.add_block(second_line.id, [4, 5, 6])
|
||
|
||
indexes_by_line = {
|
||
line.id: sorted(point.line_index for point in doc.points if point.line_id == line.id)
|
||
for line in doc.lines
|
||
if line.blocks
|
||
}
|
||
assert sorted(indexes_by_line.values()) == [[0, 1], [0, 1, 2]]
|
||
|
||
|
||
def test_remove_selected_leds_blocks_and_lines() -> None:
|
||
with temp_document_path() as path:
|
||
doc = MappingDocument()
|
||
doc.load(path)
|
||
|
||
first_line = doc.add_line()
|
||
first_block = doc.add_block(first_line.id, [0, 1, 2])
|
||
second_block = doc.add_block(first_line.id, [3, 4])
|
||
second_line = doc.add_line()
|
||
doc.add_block(second_line.id, [8, 9])
|
||
|
||
removed_leds = doc.clear_point_assignments([0, 8])
|
||
assert removed_leds == 2
|
||
assert doc.points[0].line_id is None
|
||
assert doc.points[8].line_id is None
|
||
assert all(0 not in block.point_ids and 8 not in block.point_ids for line in doc.lines for block in line.blocks)
|
||
|
||
removed_block_leds = doc.remove_block(first_line.id, first_block.id)
|
||
assert removed_block_leds == 2
|
||
assert doc.points[1].line_id is None
|
||
assert doc.points[2].line_id is None
|
||
assert doc.points[3].line_id == first_line.id
|
||
assert doc.points[4].line_id == first_line.id
|
||
|
||
removed_line_leds = doc.remove_line(first_line.id)
|
||
assert removed_line_leds == len(second_block.point_ids)
|
||
assert doc.points[3].line_id is None
|
||
assert doc.points[4].line_id is None
|
||
|
||
|
||
def test_remove_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_new_file_action() -> None:
|
||
if MainWindow is None or QApplication is None:
|
||
return
|
||
app = QApplication.instance() or QApplication([])
|
||
window = MainWindow()
|
||
|
||
from PySide6.QtWidgets import QFileDialog
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
new_path = Path(temp_dir) / "created_scheme.json"
|
||
|
||
original_getSaveFileName = QFileDialog.getSaveFileName
|
||
QFileDialog.getSaveFileName = lambda *args, **kwargs: (str(new_path), "JSON files (*.json)")
|
||
try:
|
||
window.new_file()
|
||
finally:
|
||
QFileDialog.getSaveFileName = original_getSaveFileName
|
||
|
||
assert new_path.exists()
|
||
assert new_path.stat().st_size > 0
|
||
assert len(window.document.points) == 0
|
||
assert window.document.path == new_path
|
||
|
||
window.close()
|
||
|
||
|
||
|
||
def test_menu_bar_tools_buttons() -> None:
|
||
if MainWindow is None or QApplication is None:
|
||
return
|
||
app = QApplication.instance() or QApplication([])
|
||
window = MainWindow()
|
||
app.processEvents()
|
||
|
||
from PySide6.QtWidgets import QToolButton, QFrame
|
||
container = window.menu_buttons_widget
|
||
assert container is not None
|
||
assert container.parent() == window.menuBar()
|
||
|
||
# Check layout
|
||
layout = container.layout()
|
||
assert layout is not None
|
||
|
||
# Find separator and buttons
|
||
frames = container.findChildren(QFrame)
|
||
assert len(frames) == 1
|
||
|
||
buttons = container.findChildren(QToolButton)
|
||
assert len(buttons) == 6
|
||
|
||
button_actions = [btn.defaultAction() for btn in buttons]
|
||
expected_keys = ["add_line", "add_led", "generate_script", "auto_blocks", "counterclockwise", "brush"]
|
||
for key in expected_keys:
|
||
assert window.actions[key] in button_actions
|
||
|
||
window.close()
|
||
|
||
|
||
def test_export_mapping_to_image() -> None:
|
||
if QApplication is None:
|
||
return
|
||
_app = QApplication.instance() or QApplication([])
|
||
from mapping_renderer import render_mapping_image
|
||
from models import MappingDocument
|
||
doc = MappingDocument()
|
||
doc.add_manual_point(0.0, 0.0)
|
||
doc.add_manual_point(100.0, 0.0)
|
||
doc.add_manual_point(100.0, 100.0)
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
# Test Dark Theme
|
||
image_path = Path(temp_dir) / "output_dark.png"
|
||
success = render_mapping_image(doc, str(image_path), theme="dark")
|
||
assert success
|
||
assert image_path.exists()
|
||
assert image_path.stat().st_size > 0
|
||
|
||
# Test Light Theme
|
||
image_path_light = Path(temp_dir) / "output_light.png"
|
||
success_light = render_mapping_image(doc, str(image_path_light), theme="light")
|
||
assert success_light
|
||
assert image_path_light.exists()
|
||
assert image_path_light.stat().st_size > 0
|
||
|
||
|
||
def test_save_preserves_unknown_source_fields() -> None:
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
source = Path(temp_dir) / "custom.json"
|
||
saved = Path(temp_dir) / "saved.json"
|
||
source.write_text(
|
||
json.dumps(
|
||
{
|
||
"circles": [
|
||
{
|
||
"id": 10,
|
||
"x": 0,
|
||
"y": 0,
|
||
"row": 1,
|
||
"col": 2,
|
||
"fixture_tag": "left",
|
||
}
|
||
],
|
||
"project_note": "keep me",
|
||
}
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
doc = MappingDocument()
|
||
doc.load(source)
|
||
doc.save(saved)
|
||
|
||
payload = json.loads(saved.read_text(encoding="utf-8"))
|
||
assert payload["project_note"] == "keep me"
|
||
assert payload["circles"][0]["fixture_tag"] == "left"
|
||
|
||
|
||
def test_ordered_json_preserves_document_and_remaps_uids() -> None:
|
||
with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir:
|
||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||
payload["source_meta"] = {"revision": 7}
|
||
payload["circles"][0]["custom_name"] = "first-source-point"
|
||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||
|
||
doc = MappingDocument()
|
||
doc.load(path)
|
||
line = doc.add_line()
|
||
block = doc.add_block(line.id, [3, 2, 1, 0])
|
||
result = generate_panel_mapping_from_document(doc, Path(temp_dir))
|
||
ordered_payload = json.loads(result.generated_files["ordered_json"].read_text(encoding="utf-8"))
|
||
ordered_points = ordered_payload["circles"]
|
||
id_by_input_index = {point["input_index"]: point["id"] for point in ordered_points}
|
||
|
||
assert ordered_payload["source_meta"] == {"revision": 7}
|
||
assert ordered_payload["ordered_id_mode"] == "generated_uid"
|
||
assert any(point.get("custom_name") == "first-source-point" for point in ordered_points)
|
||
assert ordered_payload["lines"][0]["blocks"][0]["point_ids"] == [
|
||
id_by_input_index[point_id]
|
||
for point_id in block.point_ids
|
||
]
|
||
assert all(point["source_id"] == doc.points[point["input_index"]].source_id for point in ordered_points)
|
||
|
||
|
||
def test_background_load_save_roundtrip() -> None:
|
||
with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir:
|
||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||
payload["background"] = {
|
||
"path": "drawing.png",
|
||
"offset_x": 12.5,
|
||
"offset_y": -7.25,
|
||
"scale_x_percent": 80,
|
||
"scale_y_percent": 125,
|
||
"opacity_percent": 45,
|
||
"visible": False,
|
||
}
|
||
source = Path(temp_dir) / "map.json"
|
||
source.write_text(json.dumps(payload), encoding="utf-8")
|
||
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()
|
||
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 = {
|
||
"new",
|
||
"open",
|
||
"save",
|
||
"save_as",
|
||
"settings",
|
||
"generate_script",
|
||
"add_line",
|
||
"add_led",
|
||
"add_block",
|
||
"auto_blocks",
|
||
"clockwise",
|
||
"counterclockwise",
|
||
"rebuild_route",
|
||
"brush",
|
||
"undo",
|
||
"redo",
|
||
"delete",
|
||
"delete_points",
|
||
"clear_all",
|
||
"clear_selection",
|
||
"fit_view",
|
||
"grid",
|
||
"direction_arrows",
|
||
"led_ids",
|
||
"background",
|
||
"background_visible",
|
||
"background_edit",
|
||
"background_clear",
|
||
"background_reset_scale",
|
||
"toggle_sidebar",
|
||
}
|
||
missing = [key for key in expected_icon_actions if window.actions[key].icon().isNull()]
|
||
assert not missing
|
||
window.close()
|
||
|
||
|
||
def test_main_window_action_shortcuts_are_assigned() -> None:
|
||
if MainWindow is None or QApplication is None or QKeySequence is None:
|
||
return
|
||
app = QApplication.instance() or QApplication([])
|
||
window = MainWindow()
|
||
app.processEvents()
|
||
|
||
expected = {
|
||
"new": "Ctrl+N",
|
||
"generate_script": "Ctrl+G",
|
||
"settings": "Ctrl+,",
|
||
"delete_points": "Ctrl+Del",
|
||
"clear_all": "Shift+Del",
|
||
"clear_selection": "Esc",
|
||
"fit_view": "Ctrl+0",
|
||
"grid": "G",
|
||
"direction_arrows": "Ctrl+Shift+A",
|
||
"led_ids": "I",
|
||
"background": "Ctrl+B",
|
||
"background_visible": "Ctrl+Alt+B",
|
||
"background_edit": "Ctrl+Shift+I",
|
||
"background_reset_scale": "Ctrl+Alt+0",
|
||
"background_clear": "Ctrl+Alt+Backspace",
|
||
"toggle_sidebar": "F9",
|
||
"add_line": "Ctrl+L",
|
||
"add_led": "N",
|
||
"add_block": "Ctrl+Shift+L",
|
||
"auto_blocks": "Ctrl+Shift+B",
|
||
"clockwise": "Ctrl+Alt+R",
|
||
"counterclockwise": "Ctrl+Alt+Shift+R",
|
||
"rebuild_route": "Ctrl+Shift+R",
|
||
"brush": "B",
|
||
}
|
||
for key, shortcut in expected.items():
|
||
assert window.actions[key].shortcut().matches(QKeySequence(shortcut)) == QKeySequence.SequenceMatch.ExactMatch
|
||
assert window.actions[key].shortcutContext() == Qt.ApplicationShortcut
|
||
window.close()
|
||
|
||
|
||
def test_main_window_direction_arrow_action_toggles_canvas() -> None:
|
||
if MainWindow is None or QApplication is None:
|
||
return
|
||
app = QApplication.instance() or QApplication([])
|
||
window = MainWindow()
|
||
app.processEvents()
|
||
|
||
action = window.actions["direction_arrows"]
|
||
assert action.isChecked()
|
||
assert window.canvas.direction_arrows_visible() is True
|
||
|
||
action.trigger()
|
||
assert not action.isChecked()
|
||
assert window.canvas.direction_arrows_visible() is False
|
||
|
||
action.trigger()
|
||
assert action.isChecked()
|
||
assert window.canvas.direction_arrows_visible() is True
|
||
window.close()
|
||
|
||
|
||
def test_settings_apply_to_canvas_and_generation() -> None:
|
||
if MainWindow is None or QApplication is None:
|
||
return
|
||
app = QApplication.instance() or QApplication([])
|
||
window = MainWindow()
|
||
with tempfile.TemporaryDirectory() as temp_dir:
|
||
window.settings.show_grid_by_default = True
|
||
window.settings.show_direction_arrows_by_default = False
|
||
window.settings.show_led_ids_by_default = False
|
||
window.settings.theme = "dark"
|
||
window.settings.brush_radius = 31
|
||
window.settings.zoom_focus_pull = 12
|
||
window.settings.pan_mode = "space"
|
||
window.settings.instant_space_pan = True
|
||
window.settings.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()
|