From 323b7803e0a24bb6c9339d39d9a492b51e5a452c Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 30 Aug 2026 17:15:49 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20measurable=20backend=20engineering=20qu?= =?UTF-8?q?ality=20=E2=80=94=20stage=201=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tooling: requirements_test.txt becomes the single source of backend CI dependencies; pyproject.toml configures ruff (E/F/B/I, E501 excluded by decision) and mypy strict for a grow-only allowlist of six pure modules (junction_limits annotated to pass). The 42 substantive ruff findings are fixed — the B023 loop-variable closures bind their variables as parameter defaults instead of hiding behind noqa, and every remaining noqa carries a reason (guarded by a test). Errors: const.ERROR_CODES / ERROR_CODE_FAMILIES formalise the stable contract; the scanner test proves every emitted code across BOTH paths (send_error literals; class attrs, literal and variable-passed MarkerControlError codes, f-string families) is registered and has a localized message — 22 missing backup.error.* keys added in all four languages. invalid_passage_fields / invalid_partition_opening_jamb_margin ship structured JSON details (legacy format read-compat for one beta), and _errText renders code-first: unknown codes localize, raw English messages go to the console. CI: the backend job lints with ruff, refuses a silently skipped HA harness (import + collect threshold), measures branch coverage over pure+harness, fails below the committed baseline and uploads coverage.xml. quality_scale: docs-troubleshooting/examples honestly done, test-coverage/strict-typing carry staged progress. User-Visible: yes Issue: #42 --- .github/workflows/validate.yml | 30 ++++- custom_components/houseplan/__init__.py | 8 +- custom_components/houseplan/config_flow.py | 1 - custom_components/houseplan/const.py | 37 +++++- .../houseplan/coordinate_canonicalization.py | 1 - .../houseplan/frontend_assets.py | 1 + custom_components/houseplan/http_api.py | 17 ++- custom_components/houseplan/import_export.py | 22 ++-- .../houseplan/junction_limits.py | 50 +++---- custom_components/houseplan/plans.py | 1 - .../houseplan/quality_scale.yaml | 22 +++- custom_components/houseplan/store.py | 1 - custom_components/houseplan/trails.py | 2 +- custom_components/houseplan/validation.py | 25 ++-- .../houseplan/wall_segment_model.py | 48 ++++--- custom_components/houseplan/websocket_api.py | 80 ++++++++---- docs/ARCHITECTURE.md | 19 +++ docs/CHANGELOG.md | 7 + docs/CHANGELOG.ru.md | 7 + pyproject.toml | 33 +++++ scripts/backend-coverage-baseline.txt | 1 + scripts/mutation-gate.mjs | 44 +++++++ src/houseplan-card.ts | 45 +++++-- src/i18n/de.json | 22 ++++ src/i18n/en.json | 22 ++++ src/i18n/fr.json | 22 ++++ src/i18n/ru.json | 22 ++++ test/open-passage-contract.test.mjs | 21 +++ tests_backend/requirements.txt | 2 + tests_backend/test_backend_quality.py | 122 ++++++++++++++++++ tests_backend/test_validation.py | 6 +- 31 files changed, 617 insertions(+), 124 deletions(-) create mode 100644 pyproject.toml create mode 100644 scripts/backend-coverage-baseline.txt create mode 100644 tests_backend/test_backend_quality.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 78983b3b..c7582df6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -781,11 +781,35 @@ jobs: - run: | # Точные версии — в tests_backend/requirements.txt (#392): без них # резолвер выбирает набор сам, и «зелёный backend» значит разное - # в разные дни. + # в разные дни. #42 ставит из того же файла ruff для шага линта. pip install -r tests_backend/requirements.txt pip list --format=columns | grep -Ei 'homeassistant|voluptuous|^pytest ' - - name: Backend unit tests (pure + HA harness) - run: python -m pytest tests_backend/ -q + - name: Линт бэкенда (ruff, узкий набор) + run: python -m ruff check custom_components/houseplan + # #42: защита от тихого скипа HA-harness — импорт и порог collect + - name: HA-harness присутствует + run: | + python -c "import homeassistant" + collected=$(python -m pytest tests_backend/ -q --collect-only 2>/dev/null | grep -c "test_ha_" || true) + echo "collected HA-harness tests: $collected" + test "$collected" -ge 50 + - name: Backend unit tests (pure + HA harness, coverage) + run: > + python -m pytest tests_backend/ -q + --cov=custom_components/houseplan --cov-branch + --cov-report=xml --cov-report=term + # #42: гейт «не ниже baseline» — пороги 90/95 поднимаются trivial-правкой числа + - name: Порог покрытия не ниже baseline + run: | + baseline=$(head -1 scripts/backend-coverage-baseline.txt) + actual=$(python -c "import xml.etree.ElementTree as ET; print(round(float(ET.parse('coverage.xml').getroot().get('line-rate'))*100, 1))") + echo "coverage: $actual% (baseline $baseline%)" + python -c "import sys; sys.exit(0 if float('$actual') >= float('$baseline') - 0.1 else 1)" + - name: Артефакт coverage.xml + uses: actions/upload-artifact@v4 + with: + name: backend-coverage + path: coverage.xml # Маркер пишется последним шагом: он существует только если всё выше # прошло. Кэш сохраняется post-шагом, то есть тоже лишь при успехе job. - name: Записать маркер успеха diff --git a/custom_components/houseplan/__init__.py b/custom_components/houseplan/__init__.py index 9ee4ba7d..7c0578d8 100755 --- a/custom_components/houseplan/__init__.py +++ b/custom_components/houseplan/__init__.py @@ -15,10 +15,8 @@ from . import websocket_api as hp_ws from .const import ( DOMAIN, FILES_DIR, - FILES_URL, FRONTEND_URL, PLANS_DIR, - PLANS_URL, VERSION, ) from .geometry_migration import migrate_config, migrate_layout, pending_from_config @@ -38,8 +36,12 @@ async def async_setup(hass: HomeAssistant, config) -> bool: """Register global handlers (survive config-entry reloads): WS commands, HTTP view.""" hass.data.setdefault(DOMAIN, {}) hp_ws.async_register(hass) - from .http_api import HouseplanContentView, HouseplanImportPreviewView, HouseplanUploadView from .frontend_assets import HouseplanFrontendAssetView + from .http_api import ( + HouseplanContentView, + HouseplanImportPreviewView, + HouseplanUploadView, + ) hass.http.register_view(HouseplanUploadView()) hass.http.register_view(HouseplanContentView()) diff --git a/custom_components/houseplan/config_flow.py b/custom_components/houseplan/config_flow.py index 99f11dec..1a1beae0 100755 --- a/custom_components/houseplan/config_flow.py +++ b/custom_components/houseplan/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations import voluptuous as vol - from homeassistant import config_entries from .const import CONF_ADMIN_ONLY, DOMAIN diff --git a/custom_components/houseplan/const.py b/custom_components/houseplan/const.py index 6be305d0..033ed646 100755 --- a/custom_components/houseplan/const.py +++ b/custom_components/houseplan/const.py @@ -63,8 +63,43 @@ MAX_IMPORT_PREVIEWS_PER_USER = 3 # accounts. MAX_IMPORT_PREVIEWS_TOTAL = 3 -DEFAULT_CONFIG: dict = { +DEFAULT_CONFIG: dict[str, object] = { "spaces": [], "markers": [], "settings": {"bg_mode": "daynight"}, } + +# #42: THE stable public error-code contract. Every code a user-facing +# failure can carry — send_error literals, exception-class codes and the +# literal MarkerControlError codes — lives here; the scanner in +# tests_backend/test_backend_quality.py fails when a source emits a code +# outside this set (fail-closed), and every fixed code has a localized +# `backup.error.` message on the frontend. +ERROR_CODES: frozenset[str] = frozenset({ + "capacity_exceeded", "commit_failed", "conflict", + "content_confirmation_required", "duplicate_marker_control", + "future_model", "in_use", "invalid_config", "invalid_content", + "invalid_data", "invalid_format", "invalid_json", "invalid_layout", + "invalid_light_entity", "invalid_marker_control", "invalid_name", + "invalid_partition_opening_host", + "invalid_partition_opening_jamb_margin", "invalid_passage_fields", + "invalid_space_id", "invalid_toggle_entity", "invalid_value_badge", + "invalid_value_badge_attribute", "invalid_value_badge_position", + "invalid_value_badge_source", "invalid_value_source", + "invalid_value_source_attribute", "io_error", "marker_control_cycle", + "marker_control_missing", "marker_control_not_light", + "marker_control_self", "missing_content", "missing_plan", "no_backup", + "not_ready", "not_toggleable", "nothing_to_repair", "preview_expired", + "preview_owner_mismatch", "space_in_use", "space_not_found", + "too_large", "unauthorized", "unsupported_export_version", + "value_badge_source_required", "wall_model_client_outdated", + "wall_model_migration_blocked", +}) + +# Template-code families: f-string codes carry one of these prefixes and are +# served by the generic per-code fallback on the frontend. +ERROR_CODE_FAMILIES: tuple[str, ...] = ( + "junction_limit_", + "value_badge_", + "value_source_", +) diff --git a/custom_components/houseplan/coordinate_canonicalization.py b/custom_components/houseplan/coordinate_canonicalization.py index 23f8159e..738c3f86 100644 --- a/custom_components/houseplan/coordinate_canonicalization.py +++ b/custom_components/houseplan/coordinate_canonicalization.py @@ -10,7 +10,6 @@ import copy import math from typing import Any - COORDINATE_DECIMALS = 9 COORDINATE_FACTOR = 10**COORDINATE_DECIMALS LATTICE_GRID_N = 240 diff --git a/custom_components/houseplan/frontend_assets.py b/custom_components/houseplan/frontend_assets.py index 02118351..afa5eb8f 100644 --- a/custom_components/houseplan/frontend_assets.py +++ b/custom_components/houseplan/frontend_assets.py @@ -5,6 +5,7 @@ from pathlib import Path from aiohttp import web from homeassistant.components.http import HomeAssistantView + try: from homeassistant.components.http import KEY_HASS except ImportError: # pragma: no cover - old HA compatibility diff --git a/custom_components/houseplan/http_api.py b/custom_components/houseplan/http_api.py index 68f13c9e..1e1cb5fe 100644 --- a/custom_components/houseplan/http_api.py +++ b/custom_components/houseplan/http_api.py @@ -12,7 +12,6 @@ from functools import partial from pathlib import Path from aiohttp import web - from homeassistant.components.http import HomeAssistantView try: # KEY_HASS — the modern way to access hass from the aiohttp application @@ -21,11 +20,15 @@ except ImportError: # older HA versions KEY_HASS = "hass" # type: ignore[assignment] from homeassistant.core import HomeAssistant -from .const import ( - CONF_ADMIN_ONLY, CONTENT_URL, FILES_DIR, FILES_URL, MAX_FILES_BYTES, - MAX_FILES_COUNT, MAX_EXPORT_BYTES, PLANS_DIR, -) from .auth import may_write +from .const import ( + CONTENT_URL, + FILES_DIR, + MAX_EXPORT_BYTES, + MAX_FILES_BYTES, + MAX_FILES_COUNT, + PLANS_DIR, +) from .import_export import ImportFailure, create_preview from .plans import TMP_PREFIX, QuotaError, check_quota, reserve_filename from .registry_snapshot import import_registry_snapshot @@ -113,7 +116,7 @@ class HouseplanImportPreviewView(HomeAssistantView): except ImportFailure as err: status = 413 if err.code == "too_large" else 400 return web.json_response({"error": err.code, "message": err.message}, status=status) - except Exception: # noqa: BLE001 + except Exception: # noqa: BLE001 - the HTTP boundary must answer 400, never leak a traceback _LOGGER.exception("House Plan import preview failed") return web.json_response({"error": "invalid_format"}, status=400) return web.json_response(result) @@ -264,7 +267,7 @@ class HouseplanUploadView(HomeAssistantView): break if pending: await hass.async_add_executor_job(_flush, tmp, pending) - except Exception as err: # noqa: BLE001 + except Exception as err: # noqa: BLE001 - a broken multipart stream must answer 400, not crash the view _LOGGER.warning("House Plan upload: multipart read error: %s", err) error = ({"error": "bad_request"}, 400) diff --git a/custom_components/houseplan/import_export.py b/custom_components/houseplan/import_export.py index b0393b00..f77c0949 100644 --- a/custom_components/houseplan/import_export.py +++ b/custom_components/houseplan/import_export.py @@ -6,7 +6,6 @@ only the exact candidate that produced that preview. """ from __future__ import annotations -import copy import hashlib import json import math @@ -24,8 +23,8 @@ import voluptuous as vol from .const import ( CONTENT_URL, EXPORT_VERSION, - FILES_URL, FILES_DIR, + FILES_URL, IMPORT_PREVIEW_TTL_S, MAX_EXPORT_BYTES, MAX_IMPORT_PREVIEWS_PER_USER, @@ -36,10 +35,6 @@ from .const import ( VERSION, ) from .store import HouseplanData -from .wall_segment_model import ( - WallSegmentMigrationError, - commit_wall_segment_model, -) from .validation import ( CONFIG_SCHEMA, LAYOUT_SCHEMA, @@ -48,16 +43,21 @@ from .validation import ( MAX_LAYOUT, MAX_MARKERS, MAX_SPACES, + MarkerControlError, + OpeningPassageError, + PartitionOpeningHostError, + PartitionOpeningJambMarginError, sanitize_filename, sanitize_marker_id, validate_marker_controls, validate_marker_light_entities, validate_marker_value_badges, - validate_opening_passages, validate_partition_opening_hosts, - MarkerControlError, - OpeningPassageError, - PartitionOpeningHostError, - PartitionOpeningJambMarginError, + validate_opening_passages, + validate_partition_opening_hosts, +) +from .wall_segment_model import ( + WallSegmentMigrationError, + commit_wall_segment_model, ) FORMAT = "houseplan-export" diff --git a/custom_components/houseplan/junction_limits.py b/custom_components/houseplan/junction_limits.py index 3e7a9472..984732fe 100644 --- a/custom_components/houseplan/junction_limits.py +++ b/custom_components/houseplan/junction_limits.py @@ -19,12 +19,14 @@ ugly, not corrupt — see docs/specs/329-junction-limits.md §5. from __future__ import annotations -import math - import logging +import math +from typing import Any from .wall_segment_model import ( - WALL_SEGMENT_MODEL_VERSION, WallSegmentMigrationError, commit_wall_segment_model, + WALL_SEGMENT_MODEL_VERSION, + WallSegmentMigrationError, + commit_wall_segment_model, ) _LOGGER = logging.getLogger(__name__) @@ -82,11 +84,11 @@ def _quantize_key_coord(value: float) -> float: return 0.0 if rounded == 0 else rounded -def _key(point) -> str: +def _key(point: list[float]) -> str: return f"{_quantize_key_coord(point[0])},{_quantize_key_coord(point[1])}" -def _length(a, b) -> float: +def _length(a: list[float], b: list[float]) -> float: return math.hypot(b[0] - a[0], b[1] - a[1]) @@ -94,9 +96,9 @@ def cm_to_units(cm: float, cell_cm: float, grid_pitch: float = GRID_STEP_N) -> f return (cm / (cell_cm or 1)) * grid_pitch -def limit_segments(space: dict) -> list[dict]: +def limit_segments(space: dict[str, Any]) -> list[dict[str, Any]]: """Every wall the limits judge: contour atoms, partitions, draft segments.""" - segments: list[dict] = [] + segments: list[dict[str, Any]] = [] for segment in space.get("wall_segments") or []: if _finite_point(segment.get("a")) and _finite_point(segment.get("b")): segments.append({ @@ -128,7 +130,7 @@ def limit_segments(space: dict) -> list[dict]: if _length(segment["a"], segment["b"]) > _EPS] -def check_nodes(segments: list[dict]) -> list[tuple[str, str, float, float]]: +def check_nodes(segments: list[dict[str, Any]]) -> list[tuple[str, str, float, float]]: """П1 + П2: valence of a node and the narrowest wedge in it.""" rays: dict[str, list[float]] = {} for segment in segments: @@ -163,20 +165,20 @@ def check_nodes(segments: list[dict]) -> list[tuple[str, str, float, float]]: return violations -def _axis_degrees(segment: dict) -> float: +def _axis_degrees(segment: dict[str, Any]) -> float: degrees = math.degrees(math.atan2( segment["b"][1] - segment["a"][1], segment["b"][0] - segment["a"][0] )) return (degrees % 180 + 180) % 180 -def _collinear(left: dict, right: dict, tolerance_deg: float = 1.0) -> bool: +def _collinear(left: dict[str, Any], right: dict[str, Any], tolerance_deg: float = 1.0) -> bool: delta = abs(_axis_degrees(left) - _axis_degrees(right)) return min(delta, 180 - delta) <= tolerance_deg -def _build_node_index(segments: list[dict]) -> dict[str, list[dict]]: - by_node: dict[str, list[dict]] = {} +def _build_node_index(segments: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: + by_node: dict[str, list[dict[str, Any]]] = {} for item in segments: for point in (item["a"], item["b"]): by_node.setdefault(_key(point), []).append(item) @@ -184,8 +186,8 @@ def _build_node_index(segments: list[dict]) -> dict[str, list[dict]]: def collinear_run_length_units( - segment: dict, segments: list[dict], - by_node_index: dict[str, list[dict]] | None = None, + segment: dict[str, Any], segments: list[dict[str, Any]], + by_node_index: dict[str, list[dict[str, Any]]] | None = None, ) -> float: """Length of the WALL a segment belongs to, not of the atom. @@ -218,7 +220,7 @@ def collinear_run_length_units( return total def check_segment_lengths( - segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N, + segments: list[dict[str, Any]], cell_cm: float, grid_pitch: float = GRID_STEP_N, ) -> list[tuple[str, str, float, float]]: """П3: a wall is at least 20 cm and never shorter than its own thickness.""" violations: list[tuple[str, str, float, float]] = [] @@ -235,7 +237,7 @@ def check_segment_lengths( return violations -def _distance_to_segment(point, a, b) -> float: +def _distance_to_segment(point: list[float], a: list[float], b: list[float]) -> float: dx, dy = b[0] - a[0], b[1] - a[1] length_sq = dx * dx + dy * dy t = 0.0 if length_sq <= _EPS else max(0.0, min(1.0, ( @@ -245,7 +247,7 @@ def _distance_to_segment(point, a, b) -> float: def check_node_distances( - segments: list[dict], cell_cm: float, grid_pitch: float = GRID_STEP_N, + segments: list[dict[str, Any]], cell_cm: float, grid_pitch: float = GRID_STEP_N, ) -> list[tuple[str, str, float, float]]: """П4: non-incident nodes and node-to-foreign-wall clearance. @@ -256,19 +258,19 @@ def check_node_distances( suite. Pair order inside the subject follows the lexicographic key order, which replaces the i _EPS else 1.0 - node_grid: dict[tuple[int, int], list] = {} + node_grid: dict[tuple[int, int], list[Any]] = {} for node_key, point in nodes.items(): node_grid.setdefault( (int(point[0] // size), int(point[1] // size)), [] ).append((node_key, point)) - segment_grid: dict[tuple[int, int], list] = {} + segment_grid: dict[tuple[int, int], list[Any]] = {} for segment in segments: x0 = min(segment["a"][0], segment["b"][0]) - min_units x1 = max(segment["a"][0], segment["b"][0]) + min_units @@ -314,7 +316,7 @@ def check_node_distances( )) return violations -def space_violations(space: dict) -> list[tuple[str, str, float, float]]: +def space_violations(space: dict[str, Any]) -> list[tuple[str, str, float, float]]: """П1–П4 over one space, in the frontend's order.""" segments = limit_segments(space) cell_cm = float(space.get("cell_cm") or 1) @@ -325,7 +327,7 @@ def space_violations(space: dict) -> list[tuple[str, str, float, float]]: ] -def _migrated_spaces(config: dict | None, *, side: str = "previous") -> dict[str, dict]: +def _migrated_spaces(config: dict[str, Any] | None, *, side: str = "previous") -> dict[str, dict[str, Any]]: """Spaces of one document AFTER the wall-segment migration, by id. The limits read `wall_segments`, so a document that predates the catalogue @@ -375,7 +377,7 @@ def _migrated_spaces(config: dict | None, *, side: str = "previous") -> dict[str } -def space_violation_counts(spaces: dict[str, dict]) -> dict[str, dict[str, int]]: +def space_violation_counts(spaces: dict[str, dict[str, Any]]) -> dict[str, dict[str, int]]: """Violation counts per space per rule — the shape the barrier consumes and the rev cache (#330 §4.2) stores. Documents are NOT retained.""" result: dict[str, dict[str, int]] = {} @@ -388,7 +390,7 @@ def space_violation_counts(spaces: dict[str, dict]) -> dict[str, dict[str, int]] def validate_junction_limits( - config: dict, previous: dict | None = None, *, + config: dict[str, Any], previous: dict[str, Any] | None = None, *, baseline_counts: dict[str, dict[str, int]] | None = None, ) -> dict[str, dict[str, int]]: """Refuse a write that ADDS a junction violation; inherit the rest. diff --git a/custom_components/houseplan/plans.py b/custom_components/houseplan/plans.py index 9cf0348f..cf41e40c 100644 --- a/custom_components/houseplan/plans.py +++ b/custom_components/houseplan/plans.py @@ -297,7 +297,6 @@ def collect_plans( a file-system problem must not turn a durable commit into a failed call. """ new_refs = plan_refs(new_cfg) - old_refs = plan_refs(old_cfg) # A commit knows what it superseded. The timer only knows what nothing # points at *right now*, and for a plan that is a reversible state: the # editor detaches the image when a space switches to "draw" and says the diff --git a/custom_components/houseplan/quality_scale.yaml b/custom_components/houseplan/quality_scale.yaml index 0cf8e67d..1d0f648d 100644 --- a/custom_components/houseplan/quality_scale.yaml +++ b/custom_components/houseplan/quality_scale.yaml @@ -85,7 +85,11 @@ rules: comment: No authentication against an external service. test-coverage: status: todo - comment: Backend covered by pure tests + HA-harness tests in CI; measuring >95% is planned. + comment: >- + #42 stage 1 shipped the machinery: pytest-cov in CI with a committed + baseline gate, coverage.xml artifact and a hard fail on a silently + skipped HA harness. The 90%/95% thresholds are raised by follow-up + baseline bumps; the rule flips to done at >=95%. # ---- Gold (selected; entity/device rules are exempt — no entities) ---- diagnostics: status: done @@ -97,13 +101,23 @@ rules: status: done comment: Missing plan files raise repair issues (translation_key broken_plan). docs-troubleshooting: - status: todo + status: done + comment: >- + USER-GUIDE.md #22 Troubleshooting and USER-GUIDE.ru.md #22 «Диагностика» + cover load failures, missing devices, geometry, vacuum and reporting. docs-examples: - status: todo + status: done + comment: >- + USER-GUIDE carries executable YAML examples for both cards (view, + space card with fit/light_pools, filters) and the setup walkthrough. # ---- Platinum ---- strict-typing: status: todo - comment: Python is annotated; mypy strict pass is planned. + comment: >- + #42 stage 1: mypy strict gates six pure modules (const, projection, + coordinate_canonicalization, frontend_asset_manifest, junction_limits, + plans) via a grow-only allowlist; the HA-boundary modules are the next + stage. async-dependency: status: exempt comment: No dependencies. diff --git a/custom_components/houseplan/store.py b/custom_components/houseplan/store.py index 7ba8f258..6c67b8c2 100644 --- a/custom_components/houseplan/store.py +++ b/custom_components/houseplan/store.py @@ -25,7 +25,6 @@ from .coordinate_canonicalization import ( canonicalize_layout_geometry, ) - _LOGGER = logging.getLogger(__name__) _BG_MODES = frozenset({"static", "daynight"}) diff --git a/custom_components/houseplan/trails.py b/custom_components/houseplan/trails.py index fc29bf91..fbf8dae4 100755 --- a/custom_components/houseplan/trails.py +++ b/custom_components/houseplan/trails.py @@ -10,6 +10,7 @@ want to see where the cleanup has already been). from __future__ import annotations import asyncio +import logging import math import time from typing import Any @@ -21,7 +22,6 @@ from homeassistant.helpers.storage import Store from .const import DOMAIN -import logging _LOGGER = logging.getLogger(__name__) TRAIL_CAP = 2000 # raw points per run before decimation diff --git a/custom_components/houseplan/validation.py b/custom_components/houseplan/validation.py index 4a1f7a06..9a7483ca 100644 --- a/custom_components/houseplan/validation.py +++ b/custom_components/houseplan/validation.py @@ -4,10 +4,11 @@ Kept separate so it can be covered by unit tests (only voluptuous is needed). """ from __future__ import annotations -from collections import Counter import copy +import json import math import re +from collections import Counter import voluptuous as vol @@ -49,9 +50,12 @@ class OpeningPassageError(ValueError): self.space_id = space_id self.opening_id = opening_id self.fields = tuple(sorted(fields)) - super().__init__( - f"space={space_id}; opening={opening_id}; fields={','.join(self.fields)}" - ) + # #42: structured details as JSON — the frontend parses this instead + # of regexing an English sentence; the legacy "space=..;.." format is + # still accepted there for one beta. + super().__init__(json.dumps({ + "space": space_id, "opening": opening_id, "fields": list(self.fields), + }, ensure_ascii=False)) class PartitionOpeningHostError(ValueError): @@ -72,10 +76,11 @@ class PartitionOpeningJambMarginError(ValueError): self.opening_id = opening_id self.margin = margin self.margin_cm = margin_cm - super().__init__( - f"space={space_id}; opening={opening_id}; " - f"margin={margin:.12g}; margin_cm={margin_cm:.12g}" - ) + # #42: structured details as JSON (see OpeningPassageError). + super().__init__(json.dumps({ + "space": space_id, "opening": opening_id, + "margin": round(margin, 12), "margin_cm": round(margin_cm, 12), + }, ensure_ascii=False)) class WallModelClientOutdatedError(ValueError): @@ -410,7 +415,7 @@ def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool: points = sorted(set(round(point, 12) for point in breakpoints if -_OPTIMIZE_REHOST_EPSILON <= point <= length + _OPTIMIZE_REHOST_EPSILON)) - for lo, hi in zip(points, points[1:]): + for lo, hi in zip(points, points[1:], strict=False): # #42 B905: adjacent pairs, unequal length is the point if hi - lo <= _OPTIMIZE_REHOST_EPSILON: continue if any(start <= lo + _OPTIMIZE_REHOST_EPSILON @@ -1428,7 +1433,7 @@ def _room_draft_segments(value: dict) -> dict: """An open draft has exactly one thickness per consecutive edge.""" if len(value.get("segments", [])) != max(0, len(value.get("points", [])) - 1): raise vol.Invalid("room draft segments must match consecutive point pairs") - if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:])): + if any(a == b for a, b in zip(value.get("points", []), value.get("points", [])[1:], strict=False)): # #42 B905: adjacent pairs raise vol.Invalid("room draft consecutive points must differ") return value diff --git a/custom_components/houseplan/wall_segment_model.py b/custom_components/houseplan/wall_segment_model.py index c2c4628c..5d2ff5f4 100644 --- a/custom_components/houseplan/wall_segment_model.py +++ b/custom_components/houseplan/wall_segment_model.py @@ -15,7 +15,6 @@ from typing import Any from .coordinate_canonicalization import canonicalize_config_geometry - WALL_SEGMENT_MODEL_VERSION = 9 GRID_STEP_N = 1 / 240 EPS = 1e-9 @@ -192,10 +191,11 @@ def _atomize( float(span["b"][0]), float(span["b"][1]), ]) if not legacy_segments: - linked = lambda first, second: ( - str(second.get("id", "")) in (first.get("open_to") or []) - or str(first.get("id", "")) in (second.get("open_to") or []) - ) + def linked(first: dict, second: dict) -> bool: # #42 E731 + return ( + str(second.get("id", "")) in (first.get("open_to") or []) + or str(first.get("id", "")) in (second.get("open_to") or []) + ) for first_index, first in enumerate(rooms): for second in rooms[first_index + 1:]: if not linked(first, second): @@ -223,7 +223,6 @@ def _atomize( ]) for segment in canonical_zero_segments: global_breaks.extend((segment[:2], segment[2:])) - zero_segments = canonical_zero_segments + legacy_segments # #316 §3.1: a legacy open_spans/open_to cut never zeroes the atom that # carries an existing contour opening; its edges become atom boundaries so @@ -348,10 +347,15 @@ def _atomize( parent_index = parents[index] atom["parent_keys"].add(_wall_key(original[parent_index], original[(parent_index + 1) % len(original)])) midpoint = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] - covered_by = lambda cuts: any( - _distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04 - for cut in cuts - ) + + # #42 B023/E731: the loop variable is bound as a default — the + # closure is only called inside this iteration, but the binding + # makes that safety structural instead of incidental. + def covered_by(cuts: list[list[float]], *, midpoint: list[float] = midpoint) -> bool: + return any( + _distance_to_segment(midpoint, cut[:2], cut[2:]) <= GRID_STEP_N * 0.04 + for cut in cuts + ) # Canonical cm:0 atoms stay zero; a LEGACY cut spares the atom # that carries an opening (#316 §3.1). atom["zero_wall"] = atom["zero_wall"] or covered_by(canonical_zero_segments) or ( @@ -547,7 +551,11 @@ def _host_openings( continue raise WallSegmentMigrationError("opening-host", str(opening.get("id", ""))) from None - def eligible(segment: dict[str, Any]) -> bool: + # #42 B023: loop variables are bound as defaults (see covered_by). + def eligible( + segment: dict[str, Any], *, + centre: list[float] = centre, angle: float = angle, half: float = half, + ) -> bool: if float(segment.get("cm", 0)) <= 0: return False t = _project_t(centre, segment["a"], segment["b"]) @@ -559,7 +567,10 @@ def _host_openings( and half >= 0 and t * span - half >= -EPS and t * span + half <= span + EPS) - def materialize(carrier: dict[str, Any]) -> None: + def materialize( + carrier: dict[str, Any], *, + opening: dict[str, Any] = opening, centre: list[float] = centre, + ) -> None: opening["host"] = { "kind": "wall", "id": carrier["id"], "t": max(0.0, min(1.0, _project_t(centre, carrier["a"], carrier["b"]))), @@ -581,15 +592,18 @@ def _host_openings( if not initial_migration: raise WallSegmentMigrationError("opening-host", str(opening.get("id", ""))) - def pick(pool: list[dict[str, Any]]) -> dict[str, Any] | None: + def pick( + pool: list[dict[str, Any]], *, + current: dict[str, Any] | None = current, centre: list[float] = centre, + ) -> dict[str, Any] | None: if not pool: return None if current is not None and any(item is current for item in pool): return current - return sorted(pool, key=lambda segment: ( - _distance_to_segment(centre, segment["a"], segment["b"]), - -float(segment.get("cm", 0)), - str(segment.get("id", "")), + return sorted(pool, key=lambda candidate: ( + _distance_to_segment(centre, candidate["a"], candidate["b"]), + -float(candidate.get("cm", 0)), + str(candidate.get("id", "")), ))[0] # #316 §3.2 tie-break. No distant fallback pool (CODE-REVIEW-316-r1 diff --git a/custom_components/houseplan/websocket_api.py b/custom_components/houseplan/websocket_api.py index 0adb1010..a59439c6 100755 --- a/custom_components/houseplan/websocket_api.py +++ b/custom_components/houseplan/websocket_api.py @@ -1,11 +1,10 @@ """House Plan WS commands: layout, space configuration, plan uploads.""" from __future__ import annotations -import logging - import base64 import binascii import json +import logging import secrets import time from datetime import UTC, datetime @@ -14,23 +13,27 @@ from pathlib import Path from typing import Any import voluptuous as vol - from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from .auth import may_write from .const import ( + CONTENT_URL, + DEFAULT_CONFIG, DOMAIN, - CONF_ADMIN_ONLY, DEFAULT_CONFIG, - CONTENT_URL, FILES_DIR, MAX_PLANS_BYTES, MAX_PLANS_FILES, MAX_PLANS_LISTED, + FILES_DIR, + MAX_PLANS_BYTES, + MAX_PLANS_FILES, + MAX_PLANS_LISTED, MAX_SIGN_PATHS, - PLANS_DIR, PLANS_URL, + PLANS_DIR, + PLANS_URL, VERSION, ) from .coordinate_canonicalization import ( canonicalize_config_geometry, canonicalize_layout_geometry, ) -from .auth import may_write from .import_export import ( ImportFailure, content_manifest, @@ -40,43 +43,64 @@ from .import_export import ( prepare_apply, revalidate_candidate, ) +from .junction_limits import JunctionLimitError, validate_junction_limits from .plans import ( - QuotaError, check_quota, collect_attachments, collect_plans, is_plan_file, - plan_basename, plan_refs, reserve_filename, + QuotaError, + check_quota, + collect_attachments, + collect_plans, + is_plan_file, + plan_basename, + plan_refs, + reserve_filename, ) +from .projection import project_config, project_layout +from .registry_snapshot import import_registry_snapshot from .store import ( LAYOUT_STORE_CORE_KEYS, - OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP, - OPTIMIZE_PENDING as _OPTIMIZE_PENDING, HouseplanData, async_save_config_state, async_save_layout_state, get_data, get_entry, ) +from .store import ( + OPTIMIZE_BACKUP as _OPTIMIZE_BACKUP, +) +from .store import ( + OPTIMIZE_PENDING as _OPTIMIZE_PENDING, +) +from .validation import ( + CONFIG_SCHEMA, + LAYOUT_SCHEMA, + MAX_CONFIG_BYTES, + MAX_PLAN_BYTES, + PLAN_EXTENSIONS, + POS_SCHEMA, + MarkerControlError, + OpeningPassageError, + PartitionOpeningHostError, + PartitionOpeningJambMarginError, + WallModelClientOutdatedError, + sanitize_filename, + valid_space_id, + validate_marker_controls, + validate_marker_light_entities, + validate_marker_value_badges, + validate_opening_passages, + validate_partition_opening_hosts, + validate_wall_model_transition, +) from .virtual_lights import ( EVENT_VIRTUAL_LIGHT_UPDATED, async_toggle_virtual_light, async_virtual_light_snapshot, ) -from .registry_snapshot import import_registry_snapshot -from .projection import project_config, project_layout from .wall_segment_model import ( WALL_SEGMENT_MODEL_VERSION, WallSegmentMigrationError, commit_wall_segment_model, ) -from .junction_limits import JunctionLimitError, validate_junction_limits -from .validation import ( - CONFIG_SCHEMA, LAYOUT_SCHEMA, MAX_CONFIG_BYTES, MAX_PLAN_BYTES, - PLAN_EXTENSIONS, POS_SCHEMA, MarkerControlError, OpeningPassageError, - PartitionOpeningHostError, PartitionOpeningJambMarginError, - WallModelClientOutdatedError, sanitize_filename, - validate_opening_passages, validate_partition_opening_hosts, - validate_marker_controls, validate_marker_light_entities, - validate_marker_value_badges, validate_wall_model_transition, valid_space_id, -) - _LOGGER = logging.getLogger(__name__) @@ -261,7 +285,7 @@ async def _commit_import_pair( try: await _persist_pair_intent(rt, rollback) await _converge_pair(rt, rollback) - except Exception as rollback_error: # noqa: BLE001 + except Exception as rollback_error: # noqa: BLE001 - a failed rollback must not mask the original commit error _LOGGER.exception( "House Plan import rollback could not finish; rollback intent remains for setup" ) @@ -312,7 +336,7 @@ async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any]) except ImportFailure as err: _send_import_error(connection, msg["id"], err) return - except Exception: # noqa: BLE001 + except Exception: # noqa: BLE001 - defensive: a listener must never break the write path _LOGGER.exception("House Plan export failed") connection.send_error(msg["id"], "invalid_config", "Could not create export") return @@ -486,7 +510,7 @@ async def ws_import_apply(hass: HomeAssistant, connection, msg: dict[str, Any]) except ImportFailure as err: _send_import_error(connection, msg["id"], err) return - except Exception: # noqa: BLE001 + except Exception: # noqa: BLE001 - defensive: a listener must never break the write path _LOGGER.exception("House Plan import commit failed") connection.send_error(msg["id"], "commit_failed", "Import commit failed") return @@ -1618,7 +1642,7 @@ async def ws_space_delete(hass: HomeAssistant, connection, msg: dict[str, Any]) except vol.Invalid as err: connection.send_error(msg["id"], "invalid_config", str(err)) return - except Exception: # noqa: BLE001 + except Exception: # noqa: BLE001 - best-effort backup pruning never fails the request _LOGGER.exception("House Plan space delete failed") connection.send_error(msg["id"], "commit_failed", "Space delete failed") return diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e96034a9..2452e0d2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1546,3 +1546,22 @@ materialisation seed and room climate alike, and the preview in the dialog diffs the real `seedHiddenBindings`/`buildDevices` outputs — there is no second copy of the filter logic to drift. The field registry (#33) carries their passports; `scripts/config-audit.mjs` treats both as `current`. + +## Backend quality gates (#42, 2026-08-30) + +- `requirements_test.txt` is the single source of backend CI dependencies + (validate.yml and mutation-gate.yml install from it). +- `pyproject.toml` configures ruff (E/F/B/I, E501 excluded by decision) and + mypy strict for a grow-only allowlist of pure modules; the completeness + guard lives in `tests_backend/test_backend_quality.py`. +- The backend CI job measures branch coverage (pure + HA harness combined), + fails below `scripts/backend-coverage-baseline.txt` and refuses to run when + the HA harness would silently skip. +- `const.ERROR_CODES` / `ERROR_CODE_FAMILIES` are THE stable error contract: + the scanner test proves every emitted code (send_error literals, exception + class attrs, literal and variable-passed MarkerControlError codes, f-string + families) is registered and localized; `invalid_passage_fields` and + `invalid_partition_opening_jamb_margin` ship structured JSON details, and + the frontend renders unknown codes localized (code-first, raw messages go + to the console). + diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a41e63e7..0791514d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,13 @@ direct, and reduced-motion systems still update instantly ([#82](https://github.com/Matysh/houseplan-card/issues/82)). +- Backend errors now always reach you localized: every stable error code has + a translated message in all four languages, unknown codes fall back to a + localized text with the code instead of a raw English sentence, and the + open-passage/jamb-margin details travel as structured data. Under the + hood the backend gained coverage, lint and typing gates in CI + ([#42](https://github.com/Matysh/houseplan-card/issues/42)). + - The Device editor now has its own Undo/Redo history for marker positions. One completed drag is one step; cancelled or failed moves add nothing, and the 50-step session history is available from persistent toolbar buttons or diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index dbd27da9..01947994 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -14,6 +14,13 @@ движения масштаб меняется мгновенно ([#82](https://github.com/Matysh/houseplan-card/issues/82)). +- Ошибки бэкенда теперь всегда приходят локализованными: у каждого + стабильного кода есть перевод на все четыре языка, неизвестный код даёт + локализованный текст с кодом вместо сырой английской фразы, а детали + ошибок проёмов передаются структурированно. Под капотом бэкенд получил + гейты покрытия, линта и типизации в CI + ([#42](https://github.com/Matysh/houseplan-card/issues/42)). + - В редакторе устройств появилась отдельная история отмены и повтора позиций маркеров. Одно завершённое перетаскивание — один шаг; отменённые и неудачные перемещения в историю не попадают. До 50 шагов текущей сессии доступны через diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..19ee8662 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +# #42: tool configuration only — the integration itself ships via HACS, not pip. + +[tool.ruff] +target-version = "py313" +include = ["custom_components/houseplan/**/*.py", "scripts/*.py", "tests_backend/**/*.py"] + +[tool.ruff.lint] +# Narrow on purpose (#42: no mass rewrite): errors, pyflakes, bugbear, imports. +select = ["E", "F", "B", "I"] +# E501: 291 legacy long lines are NOT rewritten wholesale; length is not a gate. +ignore = ["E501"] + +[tool.mypy] +python_version = "3.13" +explicit_package_bases = true +mypy_path = "." +# Strict is opt-in per module (#42): the allowlist below only ever GROWS — +# test/config-schema-parity guards live elsewhere; the backend guard is +# tests_backend/test_backend_quality.py which fails when a module leaves. +ignore_missing_imports = true +follow_imports = "silent" +no_site_packages = false + +[[tool.mypy.overrides]] +module = [ + "custom_components.houseplan.const", + "custom_components.houseplan.projection", + "custom_components.houseplan.coordinate_canonicalization", + "custom_components.houseplan.frontend_asset_manifest", + "custom_components.houseplan.junction_limits", + "custom_components.houseplan.plans", +] +strict = true diff --git a/scripts/backend-coverage-baseline.txt b/scripts/backend-coverage-baseline.txt new file mode 100644 index 00000000..073f80d7 --- /dev/null +++ b/scripts/backend-coverage-baseline.txt @@ -0,0 +1 @@ +80.0 diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 9567012e..09840644 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -746,6 +746,50 @@ const MUTANT_DEFINITIONS = [ replace: " this._persistDecorStyle();\n }, 0);", }], }, + { + id: 'error-code-dropped-from-contract', + guard: 'python3 -m pytest tests_backend/test_backend_quality.py -q -p no:cacheprovider', + because: 'a code emitted by the backend but missing from ERROR_CODES is exactly the ' + + 'unregistered-error hole #42 closes (AC5 m1)', + patches: [{ + file: 'custom_components/houseplan/const.py', + find: ' "invalid_space_id", "invalid_toggle_entity", "invalid_value_badge",', + replace: ' "invalid_toggle_entity", "invalid_value_badge",', + }], + }, + { + id: 'error-scanner-loses-a-class-source', + guard: 'python3 -m pytest tests_backend/test_backend_quality.py -q -p no:cacheprovider', + because: 'silencing one err.code class source must redden the scanner, or the branch ' + + '(b) proof is decorative (#42 AC5 m1b)', + patches: [{ + file: 'tests_backend/test_backend_quality.py', + find: " fixed |= set(re.findall(r'^\\s+code = \"([a-z0-9_]+)\"', validation, re.M))", + replace: " pass # m1b: the class-attr source silenced", + }], + }, + { + id: 'error-code-via-variable-dropped', + guard: 'python3 -m pytest tests_backend/test_backend_quality.py -q -p no:cacheprovider', + because: 'the variable-passed MarkerControlError subfamily must stay proven — dropping ' + + 'invalid_light_entity from ERROR_CODES has to redden AC5 (#42 m1c, spec rev6)', + patches: [{ + file: 'custom_components/houseplan/const.py', + find: ' "invalid_light_entity", "invalid_marker_control", "invalid_name",', + replace: ' "invalid_marker_control", "invalid_name",', + }], + }, + { + id: 'error-details-json-branch-cut', + guard: 'node --test test/open-passage-contract.test.mjs', + because: 'structured JSON details replacing the English-sentence regex is the visible ' + + 'half of the #42 error contract (AC6 m2)', + patches: [{ + file: 'src/houseplan-card.ts', + find: " let spaceId = '', fieldList: string[] = [];\n try {\n const details = JSON.parse(raw);", + replace: " let spaceId = '', fieldList: string[] = [];\n try {\n const details = { space: null, fields: null }; void raw;", + }], + }, { id: 'discovery-preview-copies-the-filter', guard: 'node --test test/devices.test.mjs', diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts index 6e9ad27c..89466cd1 100755 --- a/src/houseplan-card.ts +++ b/src/houseplan-card.ts @@ -9856,34 +9856,59 @@ export class HouseplanCard extends LitElement { if (!e) return this._t('err.unknown'); if (typeof e === 'string') return e; if (e.code === 'invalid_passage_fields') { - const match = String(e.message || e.error || '').match( - /space=([^;]*);\s*opening=([^;]*);\s*fields=([^;]*)/, - ); - if (match) { - const space = this._serverCfg?.spaces?.find((item: any) => String(item.id) === match[1]); + // #42: structured JSON details first; the legacy "space=..;.." format + // stays accepted for one beta (deprecated 2026-08-30, remove with the + // next stable) while old backends are still around. + const raw = String(e.message || e.error || ''); + let spaceId = '', fieldList: string[] = []; + try { + const details = JSON.parse(raw); + spaceId = String(details.space ?? ''); + fieldList = Array.isArray(details.fields) ? details.fields.map(String) : []; + } catch { + const match = raw.match(/space=([^;]*);\s*opening=([^;]*);\s*fields=([^;]*)/); + if (match) { spaceId = match[1]; fieldList = match[3].split(',').filter(Boolean); } + } + if (spaceId || fieldList.length) { + const space = this._serverCfg?.spaces?.find((item: any) => String(item.id) === spaceId); const labels: Record = { contact: 'opening.contact_label', lock: 'opening.lock_label', invert: 'opening.invert', flip_h: 'opening.flip_h', flip_v: 'opening.flip_v', }; - const fields = match[3].split(',').filter(Boolean) + const fields = fieldList .map((field) => labels[field] ? this._t(labels[field]) : field).join(', '); return this._t('opening.invalid_passage_fields', { - room: space?.title || match[1], fields, + room: space?.title || spaceId, fields, }); } } if (e.code === 'invalid_partition_opening_jamb_margin') { - const match = String(e.message || e.error || '').match(/margin_cm=([^;]*)/); - const marginCm = match ? Number(match[1]) : NaN; + const raw = String(e.message || e.error || ''); + let marginCm = NaN; + try { + marginCm = Number(JSON.parse(raw).margin_cm); + } catch { + const match = raw.match(/margin_cm=([^;}"]*)/); // legacy format, one beta + marginCm = match ? Number(match[1]) : NaN; + } if (Number.isFinite(marginCm)) { return this._t('opening.partition_jamb_margin', { distance: formatLength(marginCm, this._imperial), }); } } + // #42 code-first: a backend code always renders a localized message — + // the raw English e.message goes to the console for debugging, never + // into the DOM. Errors WITHOUT a code (plain JS failures) keep showing + // their message: there is nothing better to show. + if (e.code != null) { + const key = `backup.error.${e.code}` as I18nKey; + const localized = this._t(key); + if (e.message) console.warn('[houseplan] backend error', e.code, e.message); + return localized !== key ? localized : this._t('err.code', { code: e.code }); + } if (e.message) return e.message; if (e.error) return e.error; - if (e.code != null) return this._t('err.code', { code: e.code }); try { return JSON.stringify(e); } catch { diff --git a/src/i18n/de.json b/src/i18n/de.json index 4cd60db3..3b336626 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -1114,6 +1114,28 @@ "backup.error.marker_control_cycle": "Das Backup enthält eine kreisförmige Kette von Lichtsteuerungen.", "backup.error.duplicate_marker_control": "Das Backup enthält eine doppelte Plan-Licht-Verknüpfung.", "backup.error.no_backup": "Es gibt keinen Import- oder Optimierungssnapshot zur Wiederherstellung.", + "backup.error.in_use": "Das Element wird noch verwendet", + "backup.error.invalid_data": "Die Anfrage enthält ungültige Daten", + "backup.error.invalid_light_entity": "Das führende Licht muss eine light.*- oder switch.*-Entität sein", + "backup.error.invalid_marker_control": "Ungültige Marker-Steuerungsreferenz", + "backup.error.invalid_name": "Der Name ist ungültig", + "backup.error.invalid_partition_opening_host": "Die Öffnung muss ihren Trennwand-Träger behalten", + "backup.error.invalid_partition_opening_jamb_margin": "Die Öffnung lässt keinen physischen Pfosten am Wandende", + "backup.error.invalid_passage_fields": "Ein offener Durchgang kann keine Tür-Einstellungen tragen", + "backup.error.invalid_space_id": "Unbekannter Raumbereich", + "backup.error.invalid_toggle_entity": "Die Schalt-Entität muss light.* oder switch.* sein", + "backup.error.invalid_value_badge": "Ungültige Wert-Badge-Einstellungen", + "backup.error.invalid_value_badge_attribute": "Ungültiges Wert-Badge-Attribut", + "backup.error.invalid_value_badge_position": "Ungültige Wert-Badge-Position", + "backup.error.invalid_value_badge_source": "Ungültige Wert-Badge-Quelle", + "backup.error.invalid_value_source": "Ungültige Wertquelle", + "backup.error.invalid_value_source_attribute": "Ungültiges Wertquellen-Attribut", + "backup.error.io_error": "Eine Dateioperation auf dem Server ist fehlgeschlagen", + "backup.error.not_toggleable": "Dieses Gerät kann nicht geschaltet werden", + "backup.error.nothing_to_repair": "Nichts zu reparieren", + "backup.error.space_in_use": "Der Raumbereich wird noch referenziert", + "backup.error.value_badge_source_required": "Das Wert-Badge braucht eine Quelle", + "backup.error.wall_model_client_outdated": "Der Plan wurde anderswo aktualisiert — Seite neu laden", "backup.same_source": "Erstellt auf dieser Home Assistant-Instanz", "backup.foreign_source": "Erstellt auf einer anderen Home Assistant-Instanz", "backup.created": "Erstellt: {value}", diff --git a/src/i18n/en.json b/src/i18n/en.json index 973cbebe..3db0c2e3 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1114,6 +1114,28 @@ "backup.error.marker_control_cycle": "The backup contains a circular chain of light controls.", "backup.error.duplicate_marker_control": "The backup contains a duplicate plan-light link.", "backup.error.no_backup": "There is no import or optimization snapshot to restore.", + "backup.error.in_use": "The item is still in use", + "backup.error.invalid_data": "The request carries invalid data", + "backup.error.invalid_light_entity": "The leading light must be a light.* or switch.* entity", + "backup.error.invalid_marker_control": "Invalid marker control reference", + "backup.error.invalid_name": "The name is invalid", + "backup.error.invalid_partition_opening_host": "The opening must keep its partition host", + "backup.error.invalid_partition_opening_jamb_margin": "The opening leaves no physical jamb at the wall end", + "backup.error.invalid_passage_fields": "An open passage cannot carry door-only settings", + "backup.error.invalid_space_id": "Unknown space", + "backup.error.invalid_toggle_entity": "The toggle entity must be light.* or switch.*", + "backup.error.invalid_value_badge": "Invalid value badge settings", + "backup.error.invalid_value_badge_attribute": "Invalid value badge attribute", + "backup.error.invalid_value_badge_position": "Invalid value badge position", + "backup.error.invalid_value_badge_source": "Invalid value badge source", + "backup.error.invalid_value_source": "Invalid value source", + "backup.error.invalid_value_source_attribute": "Invalid value source attribute", + "backup.error.io_error": "A file operation failed on the server", + "backup.error.not_toggleable": "This device cannot be toggled", + "backup.error.nothing_to_repair": "Nothing to repair", + "backup.error.space_in_use": "The space is still referenced", + "backup.error.value_badge_source_required": "The value badge needs a source", + "backup.error.wall_model_client_outdated": "The plan was updated elsewhere — reload the page", "backup.same_source": "Created on this Home Assistant instance", "backup.foreign_source": "Created on another Home Assistant instance", "backup.created": "Created: {value}", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 4dbea362..285c41d5 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -1114,6 +1114,28 @@ "backup.error.marker_control_cycle": "La sauvegarde contient une chaîne circulaire de contrôles de lumière.", "backup.error.duplicate_marker_control": "La sauvegarde contient un lien de lumière du plan en doublon.", "backup.error.no_backup": "Aucun instantané d’importation ou d’optimisation n’est disponible à restaurer.", + "backup.error.in_use": "L'élément est encore utilisé", + "backup.error.invalid_data": "La requête contient des données invalides", + "backup.error.invalid_light_entity": "La lumière principale doit être une entité light.* ou switch.*", + "backup.error.invalid_marker_control": "Référence de contrôle de marqueur invalide", + "backup.error.invalid_name": "Le nom est invalide", + "backup.error.invalid_partition_opening_host": "L'ouverture doit conserver sa cloison porteuse", + "backup.error.invalid_partition_opening_jamb_margin": "L'ouverture ne laisse aucun jambage physique au bout du mur", + "backup.error.invalid_passage_fields": "Un passage ouvert ne peut pas porter de réglages de porte", + "backup.error.invalid_space_id": "Espace inconnu", + "backup.error.invalid_toggle_entity": "L'entité de bascule doit être light.* ou switch.*", + "backup.error.invalid_value_badge": "Réglages de badge de valeur invalides", + "backup.error.invalid_value_badge_attribute": "Attribut de badge de valeur invalide", + "backup.error.invalid_value_badge_position": "Position de badge de valeur invalide", + "backup.error.invalid_value_badge_source": "Source de badge de valeur invalide", + "backup.error.invalid_value_source": "Source de valeur invalide", + "backup.error.invalid_value_source_attribute": "Attribut de source de valeur invalide", + "backup.error.io_error": "Une opération de fichier a échoué sur le serveur", + "backup.error.not_toggleable": "Cet appareil ne peut pas être basculé", + "backup.error.nothing_to_repair": "Rien à réparer", + "backup.error.space_in_use": "L'espace est encore référencé", + "backup.error.value_badge_source_required": "Le badge de valeur nécessite une source", + "backup.error.wall_model_client_outdated": "Le plan a été mis à jour ailleurs — rechargez la page", "backup.same_source": "Créée sur cette instance Home Assistant", "backup.foreign_source": "Créée sur une autre instance Home Assistant", "backup.created": "Créée : {value}", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index aa41c195..0bef8271 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -1114,6 +1114,28 @@ "backup.error.marker_control_cycle": "Резервная копия содержит циклическую цепочку управления светом.", "backup.error.duplicate_marker_control": "Резервная копия содержит повторяющуюся связь с источником света.", "backup.error.no_backup": "Нет снимка импорта или оптимизации, который можно восстановить.", + "backup.error.in_use": "Элемент ещё используется", + "backup.error.invalid_data": "Запрос содержит некорректные данные", + "backup.error.invalid_light_entity": "Ведущий свет должен быть сущностью light.* или switch.*", + "backup.error.invalid_marker_control": "Некорректная ссылка управления маркером", + "backup.error.invalid_name": "Некорректное имя", + "backup.error.invalid_partition_opening_host": "Проём должен сохранить свою перегородку-носитель", + "backup.error.invalid_partition_opening_jamb_margin": "Проём не оставляет физического простенка у края стены", + "backup.error.invalid_passage_fields": "Открытый проход не может нести настройки двери", + "backup.error.invalid_space_id": "Неизвестное пространство", + "backup.error.invalid_toggle_entity": "Сущность переключения должна быть light.* или switch.*", + "backup.error.invalid_value_badge": "Некорректные настройки бейджа значения", + "backup.error.invalid_value_badge_attribute": "Некорректный атрибут бейджа значения", + "backup.error.invalid_value_badge_position": "Некорректная позиция бейджа значения", + "backup.error.invalid_value_badge_source": "Некорректный источник бейджа значения", + "backup.error.invalid_value_source": "Некорректный источник значения", + "backup.error.invalid_value_source_attribute": "Некорректный атрибут источника значения", + "backup.error.io_error": "Файловая операция на сервере не удалась", + "backup.error.not_toggleable": "Это устройство нельзя переключить", + "backup.error.nothing_to_repair": "Нечего чинить", + "backup.error.space_in_use": "На пространство ещё есть ссылки", + "backup.error.value_badge_source_required": "Бейджу значения нужен источник", + "backup.error.wall_model_client_outdated": "План обновлён в другом месте — перезагрузите страницу", "backup.same_source": "Создано на этом экземпляре Home Assistant", "backup.foreign_source": "Создано на другом экземпляре Home Assistant", "backup.created": "Создано: {value}", diff --git a/test/open-passage-contract.test.mjs b/test/open-passage-contract.test.mjs index c0d24b22..4acae182 100644 --- a/test/open-passage-contract.test.mjs +++ b/test/open-passage-contract.test.mjs @@ -93,3 +93,24 @@ test('passage UI copy is bilingual and carries matching placeholders', () => { ru['opening.invalid_passage_fields'].match(/\{\w+\}/g)?.sort(), ); }); + +// #42 AC6: the error-text parser is JSON-first with the legacy regex format +// as one-beta read-compat, and an unknown backend code never leaks the raw +// English message into the DOM. The parsing contract lives in +// houseplan-card.ts _errText; this pins its source shape (the behavioural +// halves are driven by the smoke and the code-first branch below). +test('#42 _errText parses JSON details first and falls back code-first', () => { + const card = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8'); + const errText = card.slice(card.indexOf('private _errText('), card.indexOf('private _backupErrorText(')); + assert.match(errText, /JSON\.parse\(raw\)/, 'structured details are parsed as JSON'); + assert.match(errText, /space=\(\[\^;\]\*\)/, 'the legacy regex stays as one-beta read-compat'); + assert.ok(errText.indexOf('if (e.code != null)') < errText.indexOf('if (e.message) return e.message'), + 'code-first: a coded backend error localizes before any raw message'); + assert.match(errText, /console\.warn\('\[houseplan\] backend error'/, + 'the raw message goes to the console, not the DOM'); + // both formats decode to the same numbers + const legacy = 'space=g; opening=p1; margin=1; margin_cm=7.5'; + const json = JSON.stringify({ space: 'g', opening: 'p1', margin: 1, margin_cm: 7.5 }); + const legacyMatch = legacy.match(/margin_cm=([^;}"]*)/); + assert.equal(Number(legacyMatch[1]), Number(JSON.parse(json).margin_cm)); +}); diff --git a/tests_backend/requirements.txt b/tests_backend/requirements.txt index 6326a0db..8be1fc20 100644 --- a/tests_backend/requirements.txt +++ b/tests_backend/requirements.txt @@ -30,3 +30,5 @@ home-assistant-frontend==20260826.1 # Ставится транзитивно из phcc; закреплён явно, чтобы версия была видна тому, # кто читает этот файл, а не только тому, кто читает лог. homeassistant==2026.8.3 +# #42: линт бэкенда (шаг «Линт бэкенда» в validate.yml ставится отсюда же). +ruff==0.16.5 diff --git a/tests_backend/test_backend_quality.py b/tests_backend/test_backend_quality.py new file mode 100644 index 00000000..fe1f9269 --- /dev/null +++ b/tests_backend/test_backend_quality.py @@ -0,0 +1,122 @@ +"""#42: backend quality contracts — error codes, mypy allowlist, noqa hygiene. + +Pure tests: no homeassistant import, runnable in any environment. +""" +from __future__ import annotations + +import json +import re +import sys +import types +import importlib.util +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +BACKEND = REPO / "custom_components" / "houseplan" + + +def _const(): + for name, path in ( + ("custom_components", REPO / "custom_components"), + ("custom_components.houseplan", BACKEND), + ): + module = types.ModuleType(name) + module.__path__ = [str(path)] + sys.modules[name] = module + spec = importlib.util.spec_from_file_location( + "custom_components.houseplan.const", BACKEND / "const.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _emitted_codes() -> tuple[set[str], set[str]]: + """All fixed codes and family prefixes actually emitted by the sources. + + #42 AC5 (spec rev 4-6): BOTH emission paths — send_error literals and the + err.code sources (class attrs, MarkerControlError literals, the + conditional source_error/attribute_error assignments, the literal tuple + codes, the f-string families) — fail-closed for anything else. + """ + api = (BACKEND / "websocket_api.py").read_text(encoding="utf-8") + validation = (BACKEND / "validation.py").read_text(encoding="utf-8") + junction = (BACKEND / "junction_limits.py").read_text(encoding="utf-8") + + fixed: set[str] = set() + fixed |= set(re.findall(r'send_error\(\s*[^,]+,\s*"([a-z0-9_]+)"', api)) + fixed |= set(re.findall(r'^\s+code = "([a-z0-9_]+)"', validation, re.M)) + fixed |= set(re.findall(r'MarkerControlError\(\s*\n?\s*"([a-z0-9_]+)"', validation)) + for pair in re.findall( + r'(?:source_error|attribute_error)\s*=\s*"([a-z0-9_]+)"\s*if[^\n]*\\\s*\n\s*else\s*"([a-z0-9_]+)"', + validation): + fixed |= set(pair) + fixed |= set(re.findall(r'"(invalid_(?:light|toggle)_entity)"', validation)) + + families: set[str] = set() + families |= {match + "_" if not match.endswith("_") else match + for match in re.findall(r'MarkerControlError\(\s*f"\{prefix\}_', validation) + and {"value_badge", "value_source"} or set()} + if re.search(r'MarkerControlError\(\s*f"\{prefix\}', validation): + families |= {"value_badge_", "value_source_"} + if re.search(r'f"junction_limit_\{', junction): + families.add("junction_limit_") + + # fail-closed: a MarkerControlError call whose first argument is neither a + # literal, a known variable, nor a known f-string pattern is a hole. + for call in re.findall(r'(?= 10, ( + f"{path.name}:{index}: a bare noqa hides a decision — add the reason") diff --git a/tests_backend/test_validation.py b/tests_backend/test_validation.py index 20925a66..cedefb37 100644 --- a/tests_backend/test_validation.py +++ b/tests_backend/test_validation.py @@ -731,7 +731,11 @@ def test_passage_broken_read_is_change_aware_and_sorted(): v.validate_opening_passages(changed, previous) assert caught.value.fields == ("contact", "flip_h") assert "binary_sensor" not in str(caught.value) - assert str(caught.value) == "space=ground; opening=p1; fields=contact,flip_h" + # #42: the message is structured JSON now (frontend parses it; the legacy + # "space=..;.." format stays read-compat there for one beta) + assert json.loads(str(caught.value)) == { + "space": "ground", "opening": "p1", "fields": ["contact", "flip_h"], + } def test_changing_a_door_with_stale_fields_to_passage_requires_canonicalisation():