feat: Preserve mappings during generation

- Перед генерацией скрипта текущая схема теперь обязательно сохраняется.

- Для несохраненной схемы перед генерацией открывается диалог сохранения, а отмена останавливает генерацию.

- ordered_circles.json теперь сохраняет полный документ с метаданными, линиями, блоками, подложкой и unfolding.

- UID точек и point_ids блоков в ordered_circles.json пересчитываются под новый порядок генерации, а исходный id сохраняется в source_id.

- Неизвестные поля исходного JSON сохраняются при обычном сохранении и при генерации ordered_circles.json.

- Диалог выбора папки генерации теперь по умолчанию открывается в папке приложения.

- Добавлены smoke-проверки на сохранение полей, ремап UID и стартовую папку генерации.
This commit is contained in:
Данил Ярош 2026-05-26 14:34:40 +03:00
parent d8c95a15e2
commit ffa7be203e
4 changed files with 210 additions and 51 deletions

View File

@ -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:

133
models.py
View File

@ -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:

View File

@ -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")

View File

@ -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")