feat: measurable backend engineering quality — stage 1 (#42)

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
This commit is contained in:
Codex
2026-08-30 21:34:31 +03:00
parent 066cf44c2f
commit 323b7803e0
31 changed files with 617 additions and 124 deletions
+27 -3
View File
@@ -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: Записать маркер успеха
+5 -3
View File
@@ -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())
@@ -2,7 +2,6 @@
from __future__ import annotations
import voluptuous as vol
from homeassistant import config_entries
from .const import CONF_ADMIN_ONLY, DOMAIN
+36 -1
View File
@@ -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.<code>` 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_",
)
@@ -10,7 +10,6 @@ import copy
import math
from typing import Any
COORDINATE_DECIMALS = 9
COORDINATE_FACTOR = 10**COORDINATE_DECIMALS
LATTICE_GRID_N = 240
@@ -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
+10 -7
View File
@@ -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)
+11 -11
View File
@@ -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"
+26 -24
View File
@@ -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<j of the all-pairs loop.
"""
nodes: dict[str, list] = {}
nodes: dict[str, list[Any]] = {}
for segment in segments:
nodes[_key(segment["a"])] = segment["a"]
nodes[_key(segment["b"])] = segment["b"]
min_units = cm_to_units(MIN_NODE_DISTANCE_CM, cell_cm, grid_pitch)
size = min_units if min_units > _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.
-1
View File
@@ -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
+18 -4
View File
@@ -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.
-1
View File
@@ -25,7 +25,6 @@ from .coordinate_canonicalization import (
canonicalize_layout_geometry,
)
_LOGGER = logging.getLogger(__name__)
_BG_MODES = frozenset({"static", "daynight"})
+1 -1
View File
@@ -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
+15 -10
View File
@@ -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
@@ -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
+52 -28
View File
@@ -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
+19
View File
@@ -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).
+7
View File
@@ -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
+7
View File
@@ -14,6 +14,13 @@
движения масштаб меняется мгновенно
([#82](https://github.com/Matysh/houseplan-card/issues/82)).
- Ошибки бэкенда теперь всегда приходят локализованными: у каждого
стабильного кода есть перевод на все четыре языка, неизвестный код даёт
локализованный текст с кодом вместо сырой английской фразы, а детали
ошибок проёмов передаются структурированно. Под капотом бэкенд получил
гейты покрытия, линта и типизации в CI
([#42](https://github.com/Matysh/houseplan-card/issues/42)).
- В редакторе устройств появилась отдельная история отмены и повтора позиций
маркеров. Одно завершённое перетаскивание — один шаг; отменённые и неудачные
перемещения в историю не попадают. До 50 шагов текущей сессии доступны через
+33
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
80.0
+44
View File
@@ -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',
+35 -10
View File
@@ -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<string, I18nKey> = {
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 {
+22
View File
@@ -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}",
+22
View File
@@ -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}",
+22
View File
@@ -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}",
+22
View File
@@ -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}",
+21
View File
@@ -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));
});
+2
View File
@@ -30,3 +30,5 @@ home-assistant-frontend==20260826.1
# Ставится транзитивно из phcc; закреплён явно, чтобы версия была видна тому,
# кто читает этот файл, а не только тому, кто читает лог.
homeassistant==2026.8.3
# #42: линт бэкенда (шаг «Линт бэкенда» в validate.yml ставится отсюда же).
ruff==0.16.5
+122
View File
@@ -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'(?<!class )MarkerControlError\(\s*\n?\s*([^",\s][^,\)]*)', validation):
head = call.strip()
assert head in {"source_error", "attribute_error", "code"} or head.startswith('f"{prefix}'), (
f"MarkerControlError emits a code the scanner cannot prove: {head!r} — "
"extend the scanner or use a literal")
return fixed, families
def test_issue_42_every_emitted_code_is_registered_and_localized():
const = _const()
fixed, families = _emitted_codes()
assert fixed, "the scanner must find codes — an empty set means it broke"
unregistered = fixed - const.ERROR_CODES
assert not unregistered, (
f"codes emitted but missing from ERROR_CODES: {sorted(unregistered)}")
for family in families:
assert family in const.ERROR_CODE_FAMILIES, (
f"family prefix {family!r} missing from ERROR_CODE_FAMILIES")
# the two flagship structured-details codes are proven present by name
assert "invalid_passage_fields" in fixed
assert "invalid_partition_opening_jamb_margin" in fixed
# variable-passed subfamily is proven (spec rev6 м1c target)
assert "invalid_light_entity" in fixed and "invalid_value_source" in fixed
en = json.loads((REPO / "src" / "i18n" / "en.json").read_text(encoding="utf-8"))
missing = {code for code in const.ERROR_CODES
if f"backup.error.{code}" not in en}
assert not missing, f"ERROR_CODES without an en message: {sorted(missing)}"
def test_issue_42_mypy_strict_allowlist_only_grows():
committed = {
"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",
}
pyproject = (REPO / "pyproject.toml").read_text(encoding="utf-8")
section = pyproject.split("[[tool.mypy.overrides]]", 1)[1]
listed = set(re.findall(r'"(custom_components\.houseplan\.[a-z_]+)"', section))
removed = committed - listed
assert not removed, (
f"strict modules were REMOVED from the mypy allowlist: {sorted(removed)} — "
"the list only ever grows (#42)")
def test_issue_42_every_noqa_carries_a_reason():
for path in sorted(BACKEND.glob("*.py")):
for index, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if "# noqa" not in line:
continue
tail = line.split("# noqa", 1)[1]
comment = tail.split(":", 1)[1] if ":" in tail else tail
explanation = comment.split(" ", 1)[1] if " " in comment.strip() else ""
assert len(explanation.strip()) >= 10, (
f"{path.name}:{index}: a bare noqa hides a decision — add the reason")
+5 -1
View File
@@ -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():