LED-mapper/qa_smoke.py
Danil Iarosh 207d6ca86c fix: Accept raw JSON encodings
- Добавлена мягкая загрузка сырых JSON-файлов с корневым массивом точек.

- Исправляются trailing commas перед закрывающими скобками и числа вида 1173. без дробной части.

- При открытии файлов в UTF-16, UTF-16 LE/BE, CP1251 или CP1252 содержимое автоматически конвертируется и сохраняется в UTF-8.

- Добавлены smoke-проверки для сырого circle_centers JSON, CP1251 и UTF-16 LE без BOM.
2026-06-10 14:05:07 +03:00

3181 lines
119 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

from __future__ import annotations
import json
import importlib
import 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:
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:
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 = 8
QPoint = None
QPointF = None
QRectF = None
Qt = None
QColor = None
QImage = None
QKeySequence = None
QApplication = None
QDialogButtonBox = None
QGraphicsItem = None
QMessageBox = None
QToolBar = None
PROJECT_ROOT = Path(__file__).resolve().parent.parent
def main() -> None:
tests: list[Callable[[], None]] = [
test_load_supported_json_files,
test_load_raw_json_with_trailing_commas_and_bare_decimal_points,
test_non_utf8_json_is_converted_to_utf8_on_load,
test_utf16_le_json_without_bom_is_converted_to_utf8_on_load,
test_line_limit_rejects_fifth_line,
test_line_indexes_are_scoped_per_line,
test_remove_selected_leds_blocks_and_lines,
test_remove_block_preserves_line_numbers,
test_remove_block_save_load_preserves_line_identity,
test_save_load_unfolding_roundtrip,
test_reassigning_leds_removes_old_memberships,
test_reassigning_leds_reindexes_affected_lines,
test_line_numbers_do_not_renumber_when_points_are_cleared,
test_auto_blocks_reindex_all_affected_lines,
test_legacy_rebuild_groups_by_line_and_block,
test_legacy_line_without_block_id_is_preserved,
test_points_without_grid_fields_load,
test_manual_led_save_load_roundtrip,
test_manual_led_uses_grid_coordinates,
test_manual_led_uses_transposed_source_grid_coordinates,
test_remove_points_reindexes_uids_and_blocks,
test_loaded_duplicate_memberships_are_deduplicated,
test_empty_blocks_are_preserved,
test_block_numbers_renumber_after_block_changes,
test_block_numbers_are_local_to_each_line,
test_reindex_preserves_block_point_order,
test_add_block_preserves_start_point,
test_append_points_to_block_extends_existing_block,
test_add_block_can_insert_after_selected_block_and_renumber,
test_merge_blocks_preserves_first_block_and_reindexes,
test_clockwise_block_from_seed_starts_at_selected_point,
test_clockwise_block_from_seed_reverses_counterclockwise_corner,
test_clockwise_block_from_seed_uses_direction_priority,
test_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_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_pan_mode_helpers_follow_settings,
test_canvas_brush_cursor_survives_pan_cursor_reset,
test_canvas_brush_zone_is_visible_and_collects_points,
test_canvas_brush_zone_uses_viewport_radius_after_zoom,
test_canvas_zoom_anchor_is_same_with_brush_enabled,
test_canvas_brush_preview_highlights_only_actionable_points,
test_brush_can_create_new_block_without_preselected_block,
test_brush_creates_new_block_after_selected_tree_block,
test_brush_default_stroke_creates_new_block_even_with_selected_block,
test_brush_shift_stroke_continues_selected_block,
test_main_window_restores_table_state_after_selection_error,
test_main_window_restores_tree_state_after_refresh_error,
test_focus_change_does_not_run_full_ui_refresh,
test_open_dialog_default_dir_uses_application_dir,
test_generate_dialog_default_dir_uses_application_dir,
test_package_imports_work_from_project_root,
test_route_adjacency_large_grid_perf_guard,
test_route_prefers_up_left_down_right,
test_route_prefers_coordinate_up_when_grid_index_is_not_adjacent,
test_first_route_step_prefers_up_before_slightly_closer_right,
test_route_does_not_jump_over_intermediate_led,
test_route_does_not_use_grid_indexes_to_skip_nearer_physical_neighbor,
test_route_continues_after_curved_sparse_step,
test_auto_block_continues_past_point_neighboring_start,
test_auto_block_does_not_close_before_meaningful_continuation,
test_parallel_track_middle_edges_are_filtered,
test_route_keeps_close_point_on_smooth_bend,
test_new_block_does_not_inherit_previous_block_trajectory,
test_route_prefers_current_block_trajectory_after_first_step,
test_route_prefers_near_neighbor_before_far_trajectory,
test_route_continues_trajectory_across_sparse_gap,
test_clockwise_route_keeps_straight_track_at_branch,
test_clockwise_route_turns_right_when_straight_is_absent,
test_auto_block_continues_through_left_down_diagonal,
test_too_close_neighbor_has_lowest_priority,
test_meter_like_coordinates_use_34_mm_threshold,
test_pyinstaller_spec_build_ref_rules,
]
for test in tests:
test()
print(f"ok - {test.__name__}")
print(f"All {len(tests)} QA smoke checks passed.")
def test_load_supported_json_files() -> None:
fixtures = [
PROJECT_ROOT / "circles.json",
PROJECT_ROOT / "circles_new.json",
PROJECT_ROOT / "panel_mapping_build" / "ordered_circles.json",
]
for path in fixtures:
doc = MappingDocument()
doc.load(path)
assert doc.points, f"{path} did not load any points"
assert doc.collection_name in {"circles", "leds"}
assert len(doc.lines) <= MAX_LINES
def test_load_raw_json_with_trailing_commas_and_bare_decimal_points() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "raw_circle_centers.json"
path.write_text(
"""
[
{"id":1,"x":1173.,"y":2159.019396,"row":35,"column":64,},
{"id":2,"x":1207.,"y":17.,"row":36,"column":1,}
]
""",
encoding="utf-8",
)
doc = MappingDocument()
doc.load(path)
assert doc.collection_name == "circles"
assert len(doc.points) == 2
assert doc.points[0].x == 1173.0
assert doc.points[0].col == 64
assert doc.points[1].y == 17.0
def test_non_utf8_json_is_converted_to_utf8_on_load() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "cp1251.json"
text = '{"circles":[{"id":1,"x":1.,"y":2.,"row":0,"column":0,"label":"точка"}]}'
path.write_bytes(text.encode("cp1251"))
doc = MappingDocument()
doc.load(path)
assert len(doc.points) == 1
assert doc.points[0].extra_payload["label"] == "точка"
assert path.read_bytes().decode("utf-8") == text
def test_utf16_le_json_without_bom_is_converted_to_utf8_on_load() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "utf16le.json"
text = '[{"id":1,"x":1173.,"y":17.,"row":35,"column":64}]'
path.write_bytes(text.encode("utf-16-le"))
doc = MappingDocument()
doc.load(path)
assert len(doc.points) == 1
assert doc.points[0].x == 1173.0
assert path.read_bytes().decode("utf-8") == text
def test_line_limit_rejects_fifth_line() -> None:
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
for _ in range(MAX_LINES):
doc.add_line()
assert len(doc.lines) == MAX_LINES
try:
doc.add_line()
except ValueError as exc:
assert str(MAX_LINES) in str(exc)
else:
raise AssertionError("Adding a fifth line should fail.")
def test_line_indexes_are_scoped_per_line() -> None:
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
first_line = doc.add_line()
doc.add_block(first_line.id, [0, 1])
second_line = doc.add_line()
doc.add_block(second_line.id, [4, 5, 6])
indexes_by_line = {
line.id: sorted(point.line_index for point in doc.points if point.line_id == line.id)
for line in doc.lines
if line.blocks
}
assert sorted(indexes_by_line.values()) == [[0, 1], [0, 1, 2]]
def test_remove_selected_leds_blocks_and_lines() -> None:
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
first_line = doc.add_line()
first_block = doc.add_block(first_line.id, [0, 1, 2])
second_block = doc.add_block(first_line.id, [3, 4])
second_line = doc.add_line()
doc.add_block(second_line.id, [8, 9])
removed_leds = doc.clear_point_assignments([0, 8])
assert removed_leds == 2
assert doc.points[0].line_id is None
assert doc.points[8].line_id is None
assert all(0 not in block.point_ids and 8 not in block.point_ids for line in doc.lines for block in line.blocks)
removed_block_leds = doc.remove_block(first_line.id, first_block.id)
assert removed_block_leds == 2
assert doc.points[1].line_id is None
assert doc.points[2].line_id is None
assert doc.points[3].line_id == first_line.id
assert doc.points[4].line_id == first_line.id
removed_line_leds = doc.remove_line(first_line.id)
assert removed_line_leds == len(second_block.point_ids)
assert doc.points[3].line_id is None
assert doc.points[4].line_id is None
def test_remove_block_preserves_line_numbers() -> None:
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
first_line = doc.add_line()
second_line = doc.add_line()
doc.add_block(first_line.id, [0, 1])
second_block = doc.add_block(second_line.id, [4, 5])
removed = doc.remove_block(second_line.id, second_block.id)
assert removed == 2
assert [line.id for line in doc.lines] == [1, 2]
assert [line.name for line in doc.lines] == ["Линия 1", "Линия 2"]
assert doc.get_line(second_line.id).blocks == []
def test_remove_block_save_load_preserves_line_identity() -> None:
with temp_document_path() as path:
save_path = path.with_name("line_identity_after_block_delete.json")
doc = MappingDocument()
doc.load(path)
line_one = doc.add_line()
line_two = doc.add_line()
line_three = doc.add_line()
doc.add_block(line_one.id, [8, 9])
removed_block = doc.add_block(line_two.id, [0, 1])
doc.add_block(line_three.id, [4, 5])
expected_ids = [line.id for line in doc.lines]
expected_names = [line.name for line in doc.lines]
doc.remove_block(line_two.id, removed_block.id)
doc.save(save_path)
assert [line.id for line in doc.lines] == expected_ids
assert [line.name for line in doc.lines] == expected_names
loaded = MappingDocument()
loaded.load(save_path)
assert [line.id for line in loaded.lines] == expected_ids
assert [line.name for line in loaded.lines] == expected_names
assert loaded.get_line(line_one.id).blocks[0].point_ids == [8, 9]
assert loaded.get_line(line_two.id).blocks == []
assert loaded.get_line(line_three.id).blocks[0].point_ids == [4, 5]
def test_save_load_unfolding_roundtrip() -> None:
with temp_document_path() as path:
save_path = path.with_name("saved_mapping.json")
doc = MappingDocument()
doc.load(path)
first_line = doc.add_line()
doc.add_block(first_line.id, [0, 1])
second_line = doc.add_line()
doc.add_block(second_line.id, [4, 5, 6])
doc.unfolding.block_size = 2
doc.save(save_path)
payload = json.loads(save_path.read_text(encoding="utf-8"))
assert payload["unfolding"]["max_lines"] == MAX_LINES
assert payload["unfolding"]["led_index_scope"] == "line"
assert len(payload["unfolding"]["block_counts"]) == 2
loaded = MappingDocument()
loaded.load(save_path)
assert len(loaded.lines) == 2
assert loaded.unfolding.block_size == 2
assert loaded.describe_unfolding().led_index_scope == "line"
assert sorted(
sorted(point.line_index for point in loaded.points if point.line_id == line.id)
for line in loaded.lines
) == [[0, 1], [0, 1, 2]]
def test_reassigning_leds_removes_old_memberships() -> None:
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
first_line = doc.add_line()
first_block = doc.add_block(first_line.id, [0, 1, 2])
second_line = doc.add_line()
second_block = doc.add_block(second_line.id, [1, 3])
assert 1 not in first_block.point_ids
assert 1 in second_block.point_ids
memberships = [
(line.id, block.id)
for line in doc.lines
for block in line.blocks
if 1 in block.point_ids
]
assert memberships == [(second_line.id, second_block.id)]
def test_reassigning_leds_reindexes_affected_lines() -> None:
doc = MappingDocument()
doc.load(PROJECT_ROOT / "circles.json")
for _ in range(MAX_LINES):
doc.add_line()
doc.add_block(1, list(range(0, 20)))
doc.add_block(2, list(range(10, 30)))
doc.add_block(3, list(range(25, 40)))
for point_id in range(40):
memberships = sum(point_id in block.point_ids for line in doc.lines for block in line.blocks)
assert memberships <= 1, (point_id, memberships)
for line in doc.lines:
ids = [point_id for block in line.blocks for point_id in block.point_ids]
indexes = [doc.points[point_id].line_index for point_id in ids]
assert indexes == list(range(len(ids))), (line.id, indexes)
def test_line_numbers_do_not_renumber_when_points_are_cleared() -> None:
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
first_line = doc.add_line()
second_line = doc.add_line()
doc.add_block(first_line.id, [0, 1])
doc.add_block(second_line.id, [4, 5])
doc.clear_point_assignments([0, 1])
assert [line.id for line in doc.lines] == [1, 2]
assert [line.name for line in doc.lines] == ["Линия 1", "Линия 2"]
def test_auto_blocks_reindex_all_affected_lines() -> None:
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
first_line = doc.add_line()
second_line = doc.add_line()
doc.add_block(first_line.id, [0, 1, 2])
doc.auto_blocks_from_selection(second_line.id, [1, 4], [[1, 4]])
assert [doc.points[point_id].line_index for point_id in [0, 2]] == [0, 1]
assert [doc.points[point_id].line_index for point_id in [1, 4]] == [0, 1]
def test_legacy_rebuild_groups_by_line_and_block() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "legacy_blocks.json"
points = fixture_points()
for point in points[:2]:
point["line_id"] = 1
point["block_id"] = 1
for point in points[4:6]:
point["line_id"] = 2
point["block_id"] = 1
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
doc = MappingDocument()
doc.load(path)
assert len(doc.lines) == 2
assert [len(line.blocks) for line in doc.lines] == [1, 1]
assert sorted(doc.points[point_id].line_id for point_id in doc.lines[0].blocks[0].point_ids) == [1, 1]
assert sorted(doc.points[point_id].line_id for point_id in doc.lines[1].blocks[0].point_ids) == [2, 2]
def test_legacy_line_without_block_id_is_preserved() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "legacy_line_only.json"
points = fixture_points()
for index, point in enumerate(points[:3]):
point["line_id"] = 1
point["line_index"] = index
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
doc = MappingDocument()
doc.load(path)
assert len(doc.lines) == 1
assert len(doc.lines[0].blocks) == 1
ids = [point_id for block in doc.lines[0].blocks for point_id in block.point_ids]
assert len(ids) == 3
assert [doc.points[point_id].line_index for point_id in ids] == [0, 1, 2]
def test_points_without_grid_fields_load() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "xy_only.json"
path.write_text(
json.dumps({"leds": [{"id": 10, "x": 1.0, "y": 2.0}, {"id": 11, "x": 3.0, "y": 4.0}]}),
encoding="utf-8",
)
doc = MappingDocument()
doc.load(path)
assert [(point.row, point.col) for point in doc.points] == [(0, 0), (2, 0)]
assert len(doc.auto_block_preview([0, 1])) == 2
def test_manual_led_save_load_roundtrip() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "manual_led.json"
path.write_text(json.dumps({"circles": []}), encoding="utf-8")
doc = MappingDocument()
doc.load(path)
point = doc.add_manual_point(12.5, 34.75)
assert point.manual
assert point.line_id is None
assert point.block_id is None
save_path = Path(temp_dir) / "saved.json"
doc.save(save_path)
loaded = MappingDocument()
loaded.load(save_path)
assert len(loaded.points) == 1
assert loaded.points[0].manual is True
assert loaded.points[0].x == 12.5
assert loaded.points[0].y == 34.75
assert loaded.points[0].line_id is None
assert loaded.points[0].block_id is None
def test_manual_led_uses_grid_coordinates() -> None:
points = [
LedPoint(0, 0, 0.0, 0.0, 0, 0),
LedPoint(1, 1, 34.0, 0.0, 0, 1),
LedPoint(2, 2, 0.0, 34.0, 1, 0),
]
doc = MappingDocument()
doc.points = points
manual = doc.add_manual_point(68.0, 34.0)
assert (manual.row, manual.col) == (1, 2)
doc.update_manual_point_position(manual.uid, x=34.0, y=68.0)
assert (manual.row, manual.col) == (2, 1)
def test_manual_led_uses_transposed_source_grid_coordinates() -> None:
points = [
LedPoint(0, 522, 332.777828, 906.447564, 10, 27),
LedPoint(1, 524, 347.222172, 906.447564, 11, 27),
LedPoint(2, 525, 362.557268, 986.575745, 11, 30),
LedPoint(3, 487, 328.943049, 995.582648, 10, 30),
]
doc = MappingDocument()
doc.points = points
manual = doc.add_manual_point(340.9004318581997, 922.081576799795)
assert (manual.row, manual.col) == (11, 27)
doc.update_manual_point_position(manual.uid, x=340.9004318581997, y=990.0)
assert (manual.row, manual.col) == (11, 30)
def test_remove_points_reindexes_uids_and_blocks() -> None:
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
first = doc.add_block(line.id, [0, 1, 2])
second = doc.add_block(line.id, [3, 4, 5])
removed = doc.remove_points([1, 4])
assert removed == 2
assert [point.uid for point in doc.points] == list(range(len(doc.points)))
assert [point.source_id for point in doc.points[:4]] == [0, 2, 3, 5]
assert first.point_ids == [0, 1]
assert second.point_ids == [2, 3]
assert [doc.points[point_id].block_id for point_id in first.point_ids] == [1, 1]
assert [doc.points[point_id].block_id for point_id in second.point_ids] == [2, 2]
def test_loaded_duplicate_memberships_are_deduplicated() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "dupes.json"
points = fixture_points()
payload = {
"circles": points,
"lines": [
{"id": 1, "blocks": [{"id": 1, "point_ids": [0, 1, 2]}]},
{"id": 2, "blocks": [{"id": 2, "point_ids": [1, 3]}]},
],
}
path.write_text(json.dumps(payload), encoding="utf-8")
doc = MappingDocument()
doc.load(path)
memberships = sum(1 in block.point_ids for line in doc.lines for block in line.blocks)
assert memberships == 1
def test_empty_blocks_are_preserved() -> None:
with temp_document_path() as path:
save_path = path.with_name("empty_block_saved.json")
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
empty_block = doc.add_block(line.id, [])
filled_block = doc.add_block(line.id, [0])
assert empty_block.point_ids == []
assert len(line.blocks) == 2
doc.clear_point_assignments([0])
assert len(line.blocks) == 2
assert filled_block.point_ids == []
doc.save(save_path)
loaded = MappingDocument()
loaded.load(save_path)
assert len(loaded.lines[0].blocks) == 2
assert all(block.point_ids == [] for block in loaded.lines[0].blocks)
def test_block_numbers_renumber_after_block_changes() -> None:
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
first = doc.add_block(line.id, [])
second = doc.add_block(line.id, [])
third = doc.add_block(line.id, [])
assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 2", "Блок 3"]
doc.reindex_all()
assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 2", "Блок 3"]
doc.remove_block(line.id, second.id)
assert [(block.id, block.name) for block in doc.get_line(line.id).blocks] == [(1, "Блок 1"), (2, "Блок 2")]
assert doc.points[0].block_id is None
assert third.id == 2
fourth = doc.add_block(line.id, [])
assert fourth.id == 3
assert fourth.name == "Блок 3"
assert [block.name for block in doc.get_line(line.id).blocks] == ["Блок 1", "Блок 2", "Блок 3"]
def test_block_numbers_are_local_to_each_line() -> None:
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
first_line = doc.add_line()
second_line = doc.add_line()
first = doc.add_block(first_line.id, [])
second = doc.add_block(first_line.id, [])
third = doc.add_block(first_line.id, [])
other_line_block = doc.add_block(second_line.id, [0])
assert [block.id for block in doc.get_line(first_line.id).blocks] == [1, 2, 3]
assert other_line_block.id == 1
doc.remove_block(first_line.id, second.id)
assert [(block.id, block.name) for block in doc.get_line(first_line.id).blocks] == [(1, "Блок 1"), (2, "Блок 2")]
doc.remove_line(second_line.id)
assert [(block.id, block.name) for block in doc.get_line(first_line.id).blocks] == [(1, "Блок 1"), (2, "Блок 2")]
assert [first.name, third.name] == ["Блок 1", "Блок 2"]
def test_reindex_preserves_block_point_order() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "manual_order.json"
points = [
{"id": 0, "x": 0, "y": 34, "row": 0, "col": 0},
{"id": 1, "x": 34, "y": 34, "row": 0, "col": 1},
{"id": 2, "x": 34, "y": 0, "row": 1, "col": 1},
{"id": 3, "x": 0, "y": 0, "row": 1, "col": 0},
]
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
block = doc.add_block(line.id, [0, 3, 2, 1])
block.point_ids = [0, 3, 2, 1]
doc.reindex_all()
assert block.point_ids == [0, 3, 2, 1]
assert [doc.points[point_id].line_index for point_id in block.point_ids] == [0, 1, 2, 3]
def test_add_block_preserves_start_point() -> None:
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
block = doc.add_block(line.id, [3, 1, 2])
assert block.point_ids[0] == 3
def test_append_points_to_block_extends_existing_block() -> None:
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
first_block = doc.add_block(line.id, [0, 1])
second_block = doc.add_block(line.id, [2, 3])
added = doc.append_points_to_block(line.id, first_block.id, [1, 2, 4])
assert added == 2
assert first_block.point_ids == [0, 1, 2, 4]
assert 2 not in second_block.point_ids
assert [doc.points[point_id].block_index for point_id in first_block.point_ids] == [0, 1, 2, 3]
assert [doc.points[point_id].line_index for point_id in first_block.point_ids] == [0, 1, 2, 3]
def test_add_block_can_insert_after_selected_block_and_renumber() -> None:
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
first = doc.add_block(line.id, [0, 1])
second = doc.add_block(line.id, [4, 5])
third = doc.add_block(line.id, [8, 9])
inserted = doc.add_block(line.id, [2, 3], after_block_id=first.id)
assert [(block.id, block.name, block.point_ids) for block in line.blocks] == [
(1, "Блок 1", [0, 1]),
(2, "Блок 2", [2, 3]),
(3, "Блок 3", [4, 5]),
(4, "Блок 4", [8, 9]),
]
assert inserted.id == 2
assert second.id == 3
assert third.id == 4
assert [doc.points[point_id].block_id for point_id in [0, 2, 4, 8]] == [1, 2, 3, 4]
assert [doc.points[point_id].line_index for point_id in [0, 1, 2, 3, 4, 5, 8, 9]] == list(range(8))
def test_merge_blocks_preserves_first_block_and_reindexes() -> None:
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
first = doc.add_block(line.id, [0, 1])
second = doc.add_block(line.id, [2, 3])
third = doc.add_block(line.id, [4])
merged = doc.merge_blocks(line.id, [third.id, first.id, second.id])
line_after = doc.get_line(line.id)
assert merged.id == first.id
assert [block.id for block in line_after.blocks] == [first.id]
assert line_after.blocks[0].point_ids == [0, 1, 2, 3, 4]
assert [doc.points[point_id].line_index for point_id in [0, 1, 2, 3, 4]] == [0, 1, 2, 3, 4]
assert [doc.points[point_id].block_index for point_id in [0, 1, 2, 3, 4]] == [0, 1, 2, 3, 4]
def test_clockwise_block_from_seed_starts_at_selected_point() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "ring.json"
points = [
{"id": 0, "x": 0, "y": 34, "row": 0, "col": 1},
{"id": 1, "x": 34, "y": 34, "row": 0, "col": 2},
{"id": 2, "x": 34, "y": 0, "row": 1, "col": 2},
{"id": 3, "x": 0, "y": 0, "row": 1, "col": 1},
]
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
block = doc.add_clockwise_block_from_seed(line.id, 2)
save_path = path.with_name("ring_saved.json")
doc.save(save_path)
loaded = MappingDocument()
loaded.load(save_path)
loaded_block = loaded.lines[0].blocks[0]
assert block.point_ids[0] == 2
assert doc.points[2].line_index == 0
assert [doc.points[point_id].block_index for point_id in block.point_ids] == list(range(4))
assert loaded_block.point_ids[0] == 2
assert loaded.points[2].line_index == 0
def test_clockwise_block_from_seed_reverses_counterclockwise_corner() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "counterclockwise_loop.json"
points = [
{"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0},
{"id": 1, "x": 34.0, "y": 0.0, "row": 0, "col": 1},
{"id": 2, "x": 68.0, "y": 0.0, "row": 0, "col": 2},
{"id": 3, "x": 68.0, "y": 34.0, "row": 1, "col": 2},
{"id": 4, "x": 68.0, "y": 68.0, "row": 2, "col": 2},
{"id": 5, "x": 34.0, "y": 68.0, "row": 2, "col": 1},
{"id": 6, "x": 0.0, "y": 68.0, "row": 2, "col": 0},
{"id": 7, "x": 0.0, "y": 34.0, "row": 1, "col": 0},
]
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
block = doc.add_clockwise_block_from_seed(line.id, 0)
assert block.point_ids == [0, 7, 6, 5, 4, 3, 2, 1]
def test_clockwise_block_from_seed_uses_direction_priority() -> None:
doc = MappingDocument()
doc.load(PROJECT_ROOT / "circles_new.json")
doc.clear_all_assignments()
line = doc.add_line()
block = doc.add_clockwise_block_from_seed(line.id, 492)
first_sources = [doc.points[point_id].source_id for point_id in block.point_ids[:12]]
assert first_sources == [493, 662, 787, 753, 560, 346, 347, 683, 684, 459, 460, 811]
threshold = min_neighbor_distance_for_points(doc.points)
assert is_valid_route_neighbor(
doc.points[block.point_ids[-1]],
doc.points[block.point_ids[0]],
threshold,
)
assert doc.points[492].line_index == 0
def test_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_save_preserves_unknown_source_fields() -> None:
with tempfile.TemporaryDirectory() as temp_dir:
source = Path(temp_dir) / "custom.json"
saved = Path(temp_dir) / "saved.json"
source.write_text(
json.dumps(
{
"circles": [
{
"id": 10,
"x": 0,
"y": 0,
"row": 1,
"col": 2,
"fixture_tag": "left",
}
],
"project_note": "keep me",
}
),
encoding="utf-8",
)
doc = MappingDocument()
doc.load(source)
doc.save(saved)
payload = json.loads(saved.read_text(encoding="utf-8"))
assert payload["project_note"] == "keep me"
assert payload["circles"][0]["fixture_tag"] == "left"
def test_ordered_json_preserves_document_and_remaps_uids() -> None:
with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir:
payload = json.loads(path.read_text(encoding="utf-8"))
payload["source_meta"] = {"revision": 7}
payload["circles"][0]["custom_name"] = "first-source-point"
path.write_text(json.dumps(payload), encoding="utf-8")
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
block = doc.add_block(line.id, [3, 2, 1, 0])
result = generate_panel_mapping_from_document(doc, Path(temp_dir))
ordered_payload = json.loads(result.generated_files["ordered_json"].read_text(encoding="utf-8"))
ordered_points = ordered_payload["circles"]
id_by_input_index = {point["input_index"]: point["id"] for point in ordered_points}
assert ordered_payload["source_meta"] == {"revision": 7}
assert ordered_payload["ordered_id_mode"] == "generated_uid"
assert any(point.get("custom_name") == "first-source-point" for point in ordered_points)
assert ordered_payload["lines"][0]["blocks"][0]["point_ids"] == [
id_by_input_index[point_id]
for point_id in block.point_ids
]
assert all(point["source_id"] == doc.points[point["input_index"]].source_id for point in ordered_points)
def test_background_load_save_roundtrip() -> None:
with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir:
payload = json.loads(path.read_text(encoding="utf-8"))
payload["background"] = {
"path": "drawing.png",
"offset_x": 12.5,
"offset_y": -7.25,
"scale_x_percent": 80,
"scale_y_percent": 125,
"opacity_percent": 45,
"visible": False,
}
source = Path(temp_dir) / "map.json"
source.write_text(json.dumps(payload), encoding="utf-8")
doc = MappingDocument()
doc.load(source)
assert doc.background.path == "drawing.png"
assert doc.background.offset_x == 12.5
assert doc.background.offset_y == -7.25
assert doc.background.scale_x_percent == 80
assert doc.background.scale_y_percent == 125
assert doc.background.opacity_percent == 45
assert doc.background.visible is False
saved = Path(temp_dir) / "saved.json"
doc.save(saved)
reloaded = MappingDocument()
reloaded.load(saved)
assert reloaded.background.to_payload() == doc.background.to_payload()
legacy = Path(temp_dir) / "legacy.json"
payload["background"] = {"path": None, "scale_percent": 70}
legacy.write_text(json.dumps(payload), encoding="utf-8")
reloaded.load(legacy)
assert reloaded.background.path == ""
assert reloaded.background.scale_x_percent == 70
assert reloaded.background.scale_y_percent == 70
assert reloaded.background.opacity_percent == 100
assert reloaded.background.visible is True
payload["background"] = {"path": "x.png", "offset_x": "bad", "offset_y": None}
legacy.write_text(json.dumps(payload), encoding="utf-8")
reloaded.load(legacy)
assert reloaded.background.offset_x == 0
assert reloaded.background.offset_y == 0
def test_background_dialog_preserves_non_uniform_scale() -> None:
if QApplication is None or BackgroundDialog is None:
return
app = QApplication.instance() or QApplication([])
dialog = BackgroundDialog(BackgroundImage("drawing.png", 1, 2, 80, 125))
background = dialog.background()
assert background.scale_x_percent == 80
assert background.scale_y_percent == 125
assert background.opacity_percent == 100
assert background.visible is True
dialog.close()
def test_canvas_background_layer_and_geometry() -> None:
if LedCanvas is None or QApplication is None or QImage is None:
return
app = QApplication.instance() or QApplication([])
with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir:
image_path = Path(temp_dir) / "background.png"
image = QImage(20, 10, QImage.Format.Format_ARGB32)
image.fill(QColor("#ff0000"))
image.save(str(image_path))
doc = MappingDocument()
doc.load(path)
doc.background = BackgroundImage(str(image_path), 5.0, 7.0, 200.0, 300.0, 55.0, True)
canvas = LedCanvas()
canvas.set_document(doc)
assert canvas._background_item is not None
assert canvas._background_item.zValue() == -30
assert canvas._background_item.transformationMode() == Qt.SmoothTransformation
assert abs(canvas._background_item.opacity() - 0.55) < 0.01
assert canvas._background_item.isVisible()
rect = canvas._background_scene_rect()
assert rect is not None
origin = canvas._background_origin_scene_point()
assert rect.width() == 40.0
assert rect.height() == 30.0
assert rect.center().x() == origin.x() + 5.0
assert rect.center().y() == origin.y() + 7.0
canvas.close()
doc.background.visible = False
canvas = LedCanvas()
canvas.set_document(doc)
assert canvas._background_item is not None
assert not canvas._background_item.isVisible()
assert canvas._background_scene_rect() is None
canvas.close()
relative_doc = MappingDocument()
relative_doc.load(path)
relative_doc.path = Path(temp_dir) / "map.json"
relative_doc.background = BackgroundImage("background.png", 0, 0, 100, 100)
canvas = LedCanvas()
canvas.set_document(relative_doc)
assert canvas.background_loaded()
canvas.close()
def test_canvas_background_zero_offset_uses_scheme_center() -> None:
if LedCanvas is None or QApplication is None or QImage is None:
return
app = QApplication.instance() or QApplication([])
with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir:
image_path = Path(temp_dir) / "background.png"
image = QImage(20, 10, QImage.Format.Format_ARGB32)
image.fill(QColor("#aa0000"))
image.save(str(image_path))
doc = MappingDocument()
doc.load(path)
doc.background = BackgroundImage(str(image_path), 0.0, 0.0, 100.0, 100.0)
canvas = LedCanvas()
canvas.set_document(doc)
rect = canvas._background_scene_rect()
origin = canvas._background_origin_scene_point()
assert rect is not None
assert rect.center() == origin
moved_rect = QRectF(rect).translated(12.0, -8.0)
start = BackgroundImage(str(image_path), 0.0, 0.0, 100.0, 100.0)
canvas._apply_background_rect(moved_rect, start, rect)
assert doc.background.offset_x == 12.0
assert doc.background.offset_y == -8.0
canvas.close()
def test_canvas_background_edit_move_and_resize_helpers() -> None:
if LedCanvas is None or QApplication is None or QImage is None:
return
app = QApplication.instance() or QApplication([])
with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir:
image_path = Path(temp_dir) / "background.png"
image = QImage(20, 10, QImage.Format.Format_ARGB32)
image.fill(QColor("#00ff00"))
image.save(str(image_path))
doc = MappingDocument()
doc.load(path)
doc.background = BackgroundImage(str(image_path), 0.0, 0.0, 100.0, 100.0)
canvas = LedCanvas()
canvas.set_document(doc)
canvas.set_background_edit_mode(True)
assert canvas._background_handles
starts = []
changes = []
canvas.backgroundEditStarted.connect(lambda: starts.append(True))
canvas.backgroundChanged.connect(lambda: changes.append(True))
canvas._background_drag = {
"changed": False,
"start_payload": doc.background.to_payload(),
}
canvas._finish_background_edit()
assert starts == []
assert changes == []
start = BackgroundImage(str(image_path), 0.0, 0.0, 100.0, 100.0)
canvas._apply_background_rect(canvas._background_scene_rect().adjusted(0, 0, 20, 10), start, canvas._background_scene_rect())
assert doc.background.scale_x_percent == 200.0
assert doc.background.scale_y_percent == 200.0
canvas.set_background_edit_mode(False)
assert not canvas._background_handles
canvas.close()
def test_canvas_background_large_image_downsamples_and_grid_degrades() -> None:
if LedCanvas is None or QApplication is None or QImage is None:
return
app = QApplication.instance() or QApplication([])
with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir:
image_path = Path(temp_dir) / "large_background.png"
image = QImage(8200, 4, QImage.Format.Format_ARGB32)
image.fill(QColor("#123456"))
image.save(str(image_path))
doc = MappingDocument()
doc.load(path)
doc.background = BackgroundImage(str(image_path), 0.0, 0.0, 100.0, 100.0)
canvas = LedCanvas()
canvas.set_document(doc)
assert canvas._background_item.pixmap().width() <= 8192
canvas.set_grid_visible(True)
assert len(canvas.grid_items) <= 802
canvas.close()
def test_main_window_background_reset_noops_at_100() -> None:
if MainWindow is None or QApplication is None or QImage is None:
return
app = QApplication.instance() or QApplication([])
with tempfile.TemporaryDirectory() as temp_dir:
image_path = Path(temp_dir) / "background.png"
image = QImage(20, 10, QImage.Format.Format_ARGB32)
image.fill(QColor("#abcdef"))
image.save(str(image_path))
window = MainWindow()
window.document.background = BackgroundImage(str(image_path), 0, 0, 100, 100)
window.canvas.set_document(window.document)
undo_before = len(window._undo_stack)
window.reset_background_scale()
assert len(window._undo_stack) == undo_before
assert not window._dirty
window.document.background.scale_x_percent = 120
window.reset_background_scale()
assert len(window._undo_stack) == undo_before + 1
assert window._dirty
window._dirty = False
window.close()
def test_main_window_background_actions() -> None:
if MainWindow is None or QApplication is None or QImage is None:
return
app = QApplication.instance() or QApplication([])
with tempfile.TemporaryDirectory() as temp_dir:
image_path = Path(temp_dir) / "background.png"
image = QImage(20, 10, QImage.Format.Format_ARGB32)
image.fill(QColor("#0000ff"))
image.save(str(image_path))
window = MainWindow()
assert "background" in window.actions
assert "background_visible" in window.actions
assert "background_edit" in window.actions
assert not window.actions["background_edit"].isEnabled()
window.document.background = BackgroundImage(str(image_path))
window.canvas.set_document(window.document)
window._refresh_ui_state()
assert window.actions["background_edit"].isEnabled()
assert window.actions["background_visible"].isChecked()
window.actions["background_visible"].setChecked(False)
window.toggle_background_visibility()
assert window.document.background.visible is False
assert not window.canvas._background_item.isVisible()
assert not window.actions["background_edit"].isEnabled()
window.actions["background_visible"].setChecked(True)
window.toggle_background_visibility()
assert window.document.background.visible is True
window.actions["background_edit"].setChecked(True)
window.toggle_background_edit()
assert window.canvas.background_edit_mode()
window.document.background = BackgroundImage("dummy.png")
window.canvas.set_document(window.document)
window._refresh_ui_state()
assert not window.actions["background_edit"].isEnabled()
window._dirty = False
window.close()
def test_main_window_background_noop_edit_preserves_undo_redo() -> None:
if MainWindow is None or QApplication is None or QImage is None:
return
app = QApplication.instance() or QApplication([])
with tempfile.TemporaryDirectory() as temp_dir:
image_path = Path(temp_dir) / "background.png"
image = QImage(20, 10, QImage.Format.Format_ARGB32)
image.fill(QColor("#654321"))
image.save(str(image_path))
window = MainWindow()
window.document.background = BackgroundImage(str(image_path))
window.canvas.set_document(window.document)
redo_state = window._capture_state()
window._redo_stack.append(redo_state)
undo_before = len(window._undo_stack)
window._on_background_edit_started()
window._on_background_edit_finished(False)
assert len(window._undo_stack) == undo_before
assert window._redo_stack == [redo_state]
assert not window._dirty
window.close()
def test_delete_unassigned_selection_preserves_redo() -> None:
if MainWindow is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
with temp_document_path() as path:
window = MainWindow()
doc = MappingDocument()
doc.load(path)
window.document = doc
window.canvas.set_document(doc)
window.canvas.select_points([0])
redo_state = window._capture_state()
window._redo_stack.append(redo_state)
undo_before = len(window._undo_stack)
window.clear_selected_point_assignments()
assert len(window._undo_stack) == undo_before
assert window._redo_stack == [redo_state]
assert not window._dirty
window.close()
def test_canvas_background_changes_preserve_camera() -> None:
if LedCanvas is None or QApplication is None or QImage is None or QPointF is None:
return
app = QApplication.instance() or QApplication([])
with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir:
image_path = Path(temp_dir) / "background.png"
image = QImage(80, 40, QImage.Format.Format_ARGB32)
image.fill(QColor("#445566"))
image.save(str(image_path))
doc = MappingDocument()
doc.load(path)
canvas = LedCanvas()
canvas.resize(640, 480)
canvas.set_document(doc)
canvas.scale(2.0, 2.0)
canvas.centerOn(QPointF(70.0, 80.0))
app.processEvents()
before_transform = canvas.transform()
before_center = canvas.mapToScene(canvas.viewport().rect().center())
canvas.set_background(BackgroundImage(str(image_path), 500.0, -400.0, 300.0, 250.0))
after_center = canvas.mapToScene(canvas.viewport().rect().center())
assert canvas.transform().m11() == before_transform.m11()
assert canvas.transform().m22() == before_transform.m22()
assert abs(after_center.x() - before_center.x()) < 1.0
assert abs(after_center.y() - before_center.y()) < 1.0
rect = canvas._background_scene_rect()
assert rect is not None
start_background = BackgroundImage(
str(image_path),
canvas.document.background.offset_x,
canvas.document.background.offset_y,
canvas.document.background.scale_x_percent,
canvas.document.background.scale_y_percent,
)
canvas._apply_background_rect(rect.adjusted(-250.0, -250.0, 300.0, 300.0), start_background, rect)
canvas._apply_background_geometry()
resized_center = canvas.mapToScene(canvas.viewport().rect().center())
assert canvas.transform().m11() == before_transform.m11()
assert abs(resized_center.x() - before_center.x()) < 1.0
assert abs(resized_center.y() - before_center.y()) < 1.0
canvas.reset_background_scale()
reset_center = canvas.mapToScene(canvas.viewport().rect().center())
assert canvas.transform().m11() == before_transform.m11()
assert abs(reset_center.x() - before_center.x()) < 1.0
assert abs(reset_center.y() - before_center.y()) < 1.0
canvas.clear_background()
cleared_center = canvas.mapToScene(canvas.viewport().rect().center())
assert canvas.transform().m11() == before_transform.m11()
assert abs(cleared_center.x() - before_center.x()) < 1.0
assert abs(cleared_center.y() - before_center.y()) < 1.0
canvas.close()
def test_canvas_uses_grid_svg_coordinates() -> None:
if LedCanvas is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
canvas = LedCanvas()
canvas.set_document(doc)
min_x = min(point.x for point in doc.points)
max_y = max(point.y for point in doc.points)
margin = 40
for point in doc.points:
item = canvas.items_by_id[point.uid]
assert item.pos().x() == (point.x - min_x) + margin
assert item.pos().y() == (max_y - point.y) + margin
def test_canvas_shows_selected_line_index_labels() -> None:
if LedCanvas is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
doc.add_block(line.id, [0, 1])
canvas = LedCanvas()
canvas.set_document(doc)
canvas.select_points([0, 2])
assert set(canvas.line_index_label_items) == {0}
assert canvas.line_index_label_items[0].text() == "0"
assert canvas.line_index_label_items[0].flags() & QGraphicsItem.ItemIgnoresTransformations
initial_delta = canvas.items_by_id[0].pos().y() - canvas.line_index_label_items[0].pos().y()
canvas.scale(8.0, 8.0)
canvas._position_line_index_labels()
zoomed_delta = canvas.items_by_id[0].pos().y() - canvas.line_index_label_items[0].pos().y()
assert zoomed_delta < initial_delta
canvas.select_points([])
assert canvas.line_index_label_items == {}
canvas.close()
def test_canvas_rebuild_can_preserve_user_zoom() -> None:
if LedCanvas is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
canvas = LedCanvas()
canvas.set_document(doc)
canvas.resetTransform()
canvas.scale(2.5, 2.5)
before = canvas.transform()
canvas.rebuild(reset_view=False)
app.processEvents()
after = canvas.transform()
assert after.m11() == before.m11()
assert after.m22() == before.m22()
def test_grid_toggle_adds_grid_items() -> None:
if LedCanvas is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
canvas = LedCanvas()
canvas.set_document(doc)
assert not canvas.grid_items
canvas.set_grid_visible(True)
assert canvas.grid_items
scene_rect = canvas.sceneRect()
vertical_lines = [item.line() for item in canvas.grid_items if item.line().x1() == item.line().x2()]
horizontal_lines = [item.line() for item in canvas.grid_items if item.line().y1() == item.line().y2()]
assert vertical_lines
assert horizontal_lines
assert all(line.y1() <= scene_rect.top() and line.y2() >= scene_rect.bottom() for line in vertical_lines)
assert all(line.x1() <= scene_rect.left() and line.x2() >= scene_rect.right() for line in horizontal_lines)
assert min(line.x1() for line in vertical_lines) <= scene_rect.left()
assert max(line.x1() for line in vertical_lines) >= scene_rect.right()
assert min(line.y1() for line in horizontal_lines) <= scene_rect.top()
assert max(line.y1() for line in horizontal_lines) >= scene_rect.bottom()
def test_canvas_connects_new_block_to_previous_non_empty_block() -> None:
if LedCanvas is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
doc.add_block(line.id, [0, 1])
doc.add_block(line.id, [])
doc.add_block(line.id, [2, 3])
canvas = LedCanvas()
canvas.set_document(doc)
canvas.refresh_styles()
expected_start = canvas.items_by_id[1].pos()
expected_end = canvas.items_by_id[2].pos()
bridge_found = any(
path_has_segment(item.path(), expected_start.x(), expected_start.y(), expected_end.x(), expected_end.y())
for item in canvas.connection_items
)
assert bridge_found
def test_canvas_bends_overlapping_connections() -> None:
if LedCanvas is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "overlap.json"
points = [
{"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0},
{"id": 1, "x": 34.0, "y": 0.0, "row": 0, "col": 1},
]
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
doc = MappingDocument()
doc.load(path)
first_line = doc.add_line()
second_line = doc.add_line()
doc.add_block(first_line.id, [0, 1])
doc.add_block(second_line.id, [1, 0])
canvas = LedCanvas()
canvas.set_document(doc)
connection_paths = [item.path() for item in canvas.connection_items if item.zValue() == 1]
assert any(path_has_curve(path) for path in connection_paths)
def test_canvas_bends_partially_overlapping_connections() -> None:
if LedCanvas is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "partial-overlap.json"
points = [
{"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0},
{"id": 1, "x": 0.0, "y": 34.0, "row": 1, "col": 0},
{"id": 2, "x": 0.0, "y": 68.0, "row": 2, "col": 0},
{"id": 3, "x": 0.0, "y": 102.0, "row": 3, "col": 0},
]
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
doc = MappingDocument()
doc.load(path)
first_line = doc.add_line()
second_line = doc.add_line()
doc.add_block(first_line.id, [0, 2])
doc.add_block(second_line.id, [1, 3])
canvas = LedCanvas()
canvas.set_document(doc)
connection_paths = [item.path() for item in canvas.connection_items if item.zValue() == 1]
assert any(path_has_curve(path) for path in connection_paths)
def test_canvas_active_line_switch_does_not_redraw_connections() -> None:
if LedCanvas is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
first_line = doc.add_line()
second_line = doc.add_line()
doc.add_block(first_line.id, [0, 1, 2])
doc.add_block(second_line.id, [3, 4, 5])
canvas = LedCanvas()
canvas.set_document(doc)
before_ids = [id(item) for item in canvas.connection_items]
canvas.set_active_line(first_line.id)
canvas.set_active_line(second_line.id)
assert [id(item) for item in canvas.connection_items] == before_ids
def test_canvas_draws_direction_arrows_on_connections() -> None:
if LedCanvas is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "arrows.json"
points = [
{"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0},
{"id": 1, "x": 34.0, "y": 0.0, "row": 0, "col": 1},
{"id": 2, "x": 68.0, "y": 0.0, "row": 0, "col": 2},
]
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
doc.add_block(line.id, [0, 1, 2])
canvas = LedCanvas()
canvas.set_document(doc)
canvas.set_active_line(line.id)
arrow_paths = [item.path() for item in canvas.connection_items if item.zValue() == 2]
assert arrow_paths
assert sum(path.elementCount() for path in arrow_paths) >= 6
def test_canvas_can_toggle_direction_arrows() -> None:
if LedCanvas is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "arrows-toggle.json"
points = [
{"id": 0, "x": 0.0, "y": 0.0, "row": 0, "col": 0},
{"id": 1, "x": 34.0, "y": 0.0, "row": 0, "col": 1},
{"id": 2, "x": 68.0, "y": 0.0, "row": 0, "col": 2},
]
path.write_text(json.dumps({"circles": points}), encoding="utf-8")
doc = MappingDocument()
doc.load(path)
line = doc.add_line()
doc.add_block(line.id, [0, 1, 2])
canvas = LedCanvas()
canvas.set_document(doc)
canvas.set_active_line(line.id)
assert any(item.zValue() == 2 for item in canvas.connection_items)
canvas.set_direction_arrows_visible(False)
assert canvas.direction_arrows_visible() is False
assert not any(item.zValue() == 2 for item in canvas.connection_items)
canvas.set_direction_arrows_visible(True)
assert canvas.direction_arrows_visible() is True
assert any(item.zValue() == 2 for item in canvas.connection_items)
def test_canvas_rebuild_drops_deleted_selection_items() -> None:
if LedCanvas is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
canvas = LedCanvas()
canvas.set_document(doc)
canvas.select_points([0, 1, 2])
canvas.rebuild()
canvas.rebuild()
app.processEvents()
assert canvas.selected_point_ids() == []
assert canvas._last_selected_ids == set()
def test_canvas_refresh_does_not_accumulate_connection_items() -> None:
if LedCanvas is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
doc = MappingDocument()
doc.load(PROJECT_ROOT / "circles_new.json")
doc.clear_all_assignments()
line = doc.add_line()
doc.add_block(line.id, list(range(120)))
canvas = LedCanvas()
canvas.set_document(doc)
canvas.set_active_line(line.id)
first_count = len(canvas.connection_items)
for _ in range(10):
canvas.refresh_styles()
assert len(canvas.connection_items) == first_count
assert len(canvas.items_by_id) == len(doc.points)
def test_main_window_has_classic_menu_bar() -> None:
if MainWindow is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
window = MainWindow()
app.processEvents()
menu_titles = [action.text() for action in window.menuBar().actions()]
assert menu_titles[:4] == ["Файл", "Правка", "Вид", "Инструменты"]
assert QToolBar is None or not window.findChildren(QToolBar)
window.close()
def test_classic_menu_contains_expected_actions() -> None:
if MainWindow is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
window = MainWindow()
app.processEvents()
expected = {
"Файл": ["Открыть", "Сохранить", "Сохранить как", "Сгенерировать скрипт", "Параметры"],
"Правка": ["Отменить", "Повторить", "Удалить фиксацию", "Удалить точки", "Удалить все", "Очистить выбор"],
"Вид": [
"Показать всё",
"Сетка 34 мм",
"Стрелки направления",
"Отображать id светодиодов",
"Подложка",
"Скрыть боковую панель",
],
"Инструменты": ["Новая линия", "Новый светодиод", "Сгенерировать скрипт", "Автоблоки", "Кисть"],
}
for top_action in list(window.menuBar().actions()):
menu = top_action.menu()
if menu is not None and top_action.text() in expected:
menu_texts = [action.text() for action in menu.actions() if not action.isSeparator()]
assert menu_texts == expected[top_action.text()], top_action.text()
if top_action.text() == "Вид":
background_action = next(action for action in menu.actions() if action.text() == "Подложка")
background_menu = background_action.menu()
assert background_menu is not None
assert [action.text() for action in background_menu.actions() if not action.isSeparator()] == [
"Подложка",
"Показать подложку",
"Редактировать подложку",
"Сбросить масштаб подложки",
"Удалить подложку",
]
window.close()
def test_side_panel_hides_removed_tools() -> None:
if MainWindow is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
window = MainWindow()
app.processEvents()
assert set(window.side_buttons) == {"add_line", "auto_blocks"}
assert window.side_buttons["add_line"].text() == "Линия"
assert window.side_buttons["auto_blocks"].text() == "Автоблоки"
window.close()
def test_undo_redo_roundtrip_for_line_creation() -> None:
if MainWindow is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
window = MainWindow()
with temp_document_path() as path:
doc = MappingDocument()
doc.load(path)
window.document = doc
window.canvas.set_document(doc)
assert not window.actions["undo"].isEnabled()
assert not window.actions["redo"].isEnabled()
window.add_line()
app.processEvents()
assert len(window.document.lines) == 1
assert window.actions["undo"].isEnabled()
assert not window.actions["redo"].isEnabled()
window.undo_last_change()
app.processEvents()
assert len(window.document.lines) == 0
assert not window.actions["undo"].isEnabled()
assert window.actions["redo"].isEnabled()
window.redo_last_change()
app.processEvents()
assert len(window.document.lines) == 1
assert window.actions["undo"].isEnabled()
assert not window.actions["redo"].isEnabled()
window.undo_last_change()
window.add_line()
app.processEvents()
assert len(window.document.lines) == 1
assert not window.actions["redo"].isEnabled()
window._dirty = False
window.close()
def test_main_window_actions_use_lucide_icons() -> None:
if MainWindow is None or QApplication is None:
return
app = QApplication.instance() or QApplication([])
window = MainWindow()
app.processEvents()
expected_icon_actions = {
"open",
"save",
"save_as",
"settings",
"generate_script",
"add_line",
"add_led",
"add_block",
"auto_blocks",
"clockwise",
"rebuild_route",
"brush",
"undo",
"redo",
"delete",
"delete_points",
"clear_all",
"clear_selection",
"fit_view",
"grid",
"direction_arrows",
"led_ids",
"background",
"background_visible",
"background_edit",
"background_clear",
"background_reset_scale",
"toggle_sidebar",
}
missing = [key for key in expected_icon_actions if window.actions[key].icon().isNull()]
assert not missing
window.close()
def test_main_window_action_shortcuts_are_assigned() -> None:
if MainWindow is None or QApplication is None or QKeySequence is None:
return
app = QApplication.instance() or QApplication([])
window = MainWindow()
app.processEvents()
expected = {
"generate_script": "Ctrl+G",
"settings": "Ctrl+,",
"delete_points": "Ctrl+Del",
"clear_all": "Ctrl+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",
"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-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, 8).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_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()