mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
@@ -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: Записать маркер успеха
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -25,7 +25,6 @@ from .coordinate_canonicalization import (
|
||||
canonicalize_layout_geometry,
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_BG_MODES = frozenset({"static", "daynight"})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
движения масштаб меняется мгновенно
|
||||
([#82](https://github.com/Matysh/houseplan-card/issues/82)).
|
||||
|
||||
- Ошибки бэкенда теперь всегда приходят локализованными: у каждого
|
||||
стабильного кода есть перевод на все четыре языка, неизвестный код даёт
|
||||
локализованный текст с кодом вместо сырой английской фразы, а детали
|
||||
ошибок проёмов передаются структурированно. Под капотом бэкенд получил
|
||||
гейты покрытия, линта и типизации в CI
|
||||
([#42](https://github.com/Matysh/houseplan-card/issues/42)).
|
||||
|
||||
- В редакторе устройств появилась отдельная история отмены и повтора позиций
|
||||
маркеров. Одно завершённое перетаскивание — один шаг; отменённые и неудачные
|
||||
перемещения в историю не попадают. До 50 шагов текущей сессии доступны через
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
80.0
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -30,3 +30,5 @@ home-assistant-frontend==20260826.1
|
||||
# Ставится транзитивно из phcc; закреплён явно, чтобы версия была видна тому,
|
||||
# кто читает этот файл, а не только тому, кто читает лог.
|
||||
homeassistant==2026.8.3
|
||||
# #42: линт бэкенда (шаг «Линт бэкенда» в validate.yml ставится отсюда же).
|
||||
ruff==0.16.5
|
||||
|
||||
@@ -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")
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user