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:
parent
d8c95a15e2
commit
ffa7be203e
@ -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
133
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:
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
|
||||
70
qa_smoke.py
70
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")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user