diff --git a/main_window.py b/main_window.py index aaa8782..8b05361 100644 --- a/main_window.py +++ b/main_window.py @@ -820,6 +820,9 @@ class MainWindow(QMainWindow): self.status.setText("Генерация отменена: пользователь продолжает редактирование.") self._refresh_ui_state() return + if not self._save_document_before_generation(): + self._refresh_ui_state() + return if output_dir is None: if self.settings.default_output_dir: output_dir = self.settings.default_output_dir @@ -827,7 +830,7 @@ class MainWindow(QMainWindow): selected_dir = QFileDialog.getExistingDirectory( self, "Выберите папку для генерации скрипта", - str(ROOT_DIR / "panel_mapping_build"), + str(APP_DIR), ) if not selected_dir: return @@ -877,7 +880,21 @@ class MainWindow(QMainWindow): message.exec() return message.clickedButton() is generate_button - def _save_to(self, path: Path) -> None: + def _save_document_before_generation(self) -> bool: + if self.document.path is not None: + return self._save_to(self.document.path) + path, _ = QFileDialog.getSaveFileName( + self, + "Сохранить карту LED перед генерацией", + str(ROOT_DIR / "led_mapping_editor.json"), + "JSON files (*.json);;All files (*.*)", + ) + if not path: + self.status.setText("Генерация отменена: карта LED не сохранена.") + return False + return self._save_to(Path(path)) + + def _save_to(self, path: Path) -> bool: if len(self.document.lines) > MAX_LINES: show_warning( self, @@ -886,7 +903,7 @@ class MainWindow(QMainWindow): ) self.status.setText(f"Сохранение отменено: линий {len(self.document.lines)}/{MAX_LINES}") self._refresh_ui_state() - return + return False active_line = self._get_line_or_none(self.active_line_id) try: self.document.save(path) @@ -897,7 +914,7 @@ class MainWindow(QMainWindow): self._on_selection_changed(self.canvas.selected_point_ids()) show_critical(self, "Ошибка сохранения", str(exc)) self._refresh_ui_state() - return + return False self._dirty = False self._undo_stack.clear() self._redo_stack.clear() @@ -907,6 +924,7 @@ class MainWindow(QMainWindow): self._on_selection_changed(self.canvas.selected_point_ids()) self.status.setText(f"Сохранено: {self._document_summary()}: {path}") self._refresh_ui_state() + return True def add_line(self) -> None: if len(self.document.lines) >= MAX_LINES: diff --git a/models.py b/models.py index 81799be..a1b7111 100644 --- a/models.py +++ b/models.py @@ -16,6 +16,29 @@ except ImportError: # pragma: no cover - supports package imports in tests/tool MIN_NEIGHBOR_DISTANCE_MM = 34.0 MAX_NEIGHBOR_TOLERANCE_MM = 2.0 METER_LIKE_MAX_SPAN = 10.0 +_DOCUMENT_KNOWN_FIELDS = { + "background", + "circles", + "coordinate_unit", + "leds", + "lines", + "unit", + "unfolding", +} +_LED_POINT_KNOWN_FIELDS = { + "block_id", + "block_index", + "col", + "column", + "id", + "line_id", + "line_index", + "manual", + "row", + "source_id", + "x", + "y", +} @dataclass @@ -32,6 +55,7 @@ class LedPoint: block_index: int | None = None coordinate_unit: str = "auto" manual: bool = False + extra_payload: dict[str, Any] = field(default_factory=dict) @dataclass @@ -107,6 +131,7 @@ class MappingDocument: self.background = BackgroundImage() self.unfolding = UnfoldingDescription() self.coordinate_unit = "auto" + self.extra_payload: dict[str, Any] = {} self._next_line_id = 1 self._next_block_id = 1 @@ -118,6 +143,7 @@ class MappingDocument: self.background = BackgroundImage() self.unfolding = UnfoldingDescription() self.coordinate_unit = "auto" + self.extra_payload.clear() self._next_line_id = 1 self._next_block_id = 1 @@ -125,6 +151,11 @@ class MappingDocument: self.clear() self.path = Path(path) data = json.loads(self.path.read_text(encoding="utf-8-sig")) + self.extra_payload = { + key: value + for key, value in data.items() + if key not in _DOCUMENT_KNOWN_FIELDS + } self.coordinate_unit = str(data.get("coordinate_unit", data.get("unit", "auto"))) points_data = data.get("leds") self.collection_name = "leds" @@ -153,6 +184,11 @@ class MappingDocument: block_index=_optional_int(item.get("block_index")), coordinate_unit=self.coordinate_unit, manual=bool(item.get("manual", False)), + extra_payload={ + key: value + for key, value in item.items() + if key not in _LED_POINT_KNOWN_FIELDS + }, ) ) @@ -238,45 +274,70 @@ class MappingDocument: def save(self, path: str | Path) -> None: path = Path(path) self.reindex_all() - point_payload = [] - for point in self.points: - point_payload.append( - { - "id": point.source_id, - "x": point.x, - "y": point.y, - "row": point.row, - "col": point.col, - "line_id": point.line_id, - "block_id": point.block_id, - "line_index": point.line_index, - "block_index": point.block_index, - "manual": point.manual, - } - ) - - payload = { - self.collection_name: point_payload, - "unfolding": self.describe_unfolding().to_payload(), - "coordinate_unit": self._coordinate_unit_for_save(), - "background": self.background.to_payload(), - "lines": [ - { - "id": line.id, - "name": line.name, - "color": line.color, - "blocks": [ - {"id": block.id, "name": block.name, "point_ids": block.point_ids} - for block in line.blocks - ], - "point_id_mode": "uid", - } - for line in self.lines - ], - } + payload = self.to_payload() path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") self.path = path + def to_payload( + self, + *, + point_payloads: list[dict[str, Any]] | None = None, + lines_payload: list[dict[str, Any]] | None = None, + collection_name: str | None = None, + ) -> dict[str, Any]: + payload = dict(self.extra_payload) + payload[collection_name or self.collection_name] = ( + [self.point_to_payload(point) for point in self.points] + if point_payloads is None + else point_payloads + ) + payload["unfolding"] = self.describe_unfolding().to_payload() + payload["coordinate_unit"] = self._coordinate_unit_for_save() + payload["background"] = self.background.to_payload() + payload["lines"] = self.lines_to_payload() if lines_payload is None else lines_payload + return payload + + def point_to_payload(self, point: LedPoint, *, id_value: int | None = None) -> dict[str, Any]: + payload = dict(point.extra_payload) + payload.update( + { + "id": point.source_id if id_value is None else id_value, + "x": point.x, + "y": point.y, + "row": point.row, + "col": point.col, + "line_id": point.line_id, + "block_id": point.block_id, + "line_index": point.line_index, + "block_index": point.block_index, + "manual": point.manual, + } + ) + return payload + + def lines_to_payload(self, *, point_id_map: dict[int, int] | None = None) -> list[dict[str, Any]]: + return [ + { + "id": line.id, + "name": line.name, + "color": line.color, + "blocks": [ + { + "id": block.id, + "name": block.name, + "point_ids": [ + point_id_map[point_id] if point_id_map is not None else point_id + for point_id in block.point_ids + if point_id_map is None or point_id in point_id_map + ], + } + for block in line.blocks + ], + "point_id_mode": "uid", + } + for line in self.lines + ] + def add_line(self) -> LedLine: line_limit = self._line_limit() if len(self.lines) >= line_limit: diff --git a/panel_mapping_generator.py b/panel_mapping_generator.py index 331498a..be123c0 100644 --- a/panel_mapping_generator.py +++ b/panel_mapping_generator.py @@ -384,13 +384,13 @@ def write_legacy_ino( def write_ordered_json(path: Path, points: list[PanelLedPoint], document: MappingDocument) -> None: - payload = { - "source": str(document.path) if document.path is not None else "LED Mapper GUI", - "unit": OUTPUT_UNIT, - "cell_size_mm": CELL_SIZE_MM, - "leds": [ + point_id_map = {point.input_index: point.generated_id for point in points} + point_payloads = [] + for point in points: + source_point = document.points[point.input_index] + payload = document.point_to_payload(source_point, id_value=point.generated_id) + payload.update( { - "id": point.generated_id, "source_id": point.source_id, "input_index": point.input_index, "strip": point.strip, @@ -401,10 +401,24 @@ def write_ordered_json(path: Path, points: list[PanelLedPoint], document: Mappin "col": point.col, "matrix_row": point.matrix_row, "matrix_col": point.matrix_col, + "route_group": point.route_group, } - for point in points - ], - } + ) + point_payloads.append(payload) + + payload = document.to_payload( + point_payloads=point_payloads, + lines_payload=document.lines_to_payload(point_id_map=point_id_map), + collection_name=document.collection_name, + ) + payload.update( + { + "source": str(document.path) if document.path is not None else "LED Mapper GUI", + "unit": OUTPUT_UNIT, + "cell_size_mm": CELL_SIZE_MM, + "ordered_id_mode": "generated_uid", + } + ) path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") diff --git a/qa_smoke.py b/qa_smoke.py index ed7ea7b..abbdc12 100644 --- a/qa_smoke.py +++ b/qa_smoke.py @@ -107,6 +107,8 @@ def main() -> None: 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, @@ -165,6 +167,7 @@ def main() -> None: 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, @@ -821,8 +824,65 @@ def test_panel_mapping_generation_from_document() -> None: assert "#define LED_BLOCK_COUNT 1" in vm_map assert "{0, 0}" in vm_map ordered_payload = json.loads(result.generated_files["ordered_json"].read_text(encoding="utf-8")) - assert len(ordered_payload["leds"]) == len(doc.points) - assert ordered_payload["leds"][0]["strip"] == 0 + 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: @@ -2440,6 +2500,12 @@ 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")