mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
@@ -27,8 +27,8 @@ concurrency:
|
||||
jobs:
|
||||
mutants:
|
||||
runs-on: ubuntu-latest
|
||||
# Шесть мутантов × (сборка + браузерный смок) — это десятки минут, и это
|
||||
# нормально: гейт предрелизный. Час — потолок против зависшего Chromium.
|
||||
# Все мутанты × (сборка + свой guard) — это десятки минут, и это нормально:
|
||||
# гейт предрелизный. Час — потолок против зависшего Chromium.
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
@@ -43,6 +43,13 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Установить backend test dependencies
|
||||
run: pip install pytest voluptuous pytest-homeassistant-custom-component home-assistant-frontend
|
||||
|
||||
- name: Кэш браузеров Playwright
|
||||
id: pw
|
||||
uses: actions/cache@v6
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -53,6 +53,34 @@ from .validation import (
|
||||
FORMAT = "houseplan-export"
|
||||
_PROTO_KEYS = {"__proto__", "prototype", "constructor"}
|
||||
_SAFE_FILE = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
_LIVE_TEXT_TOKEN = re.compile(r"\{([^{}\r\n]+)\}")
|
||||
_LIVE_TEXT_ENTITY = re.compile(r"^[a-z0-9_]+\.[a-z0-9_]+$")
|
||||
_LIVE_TEXT_ATTRIBUTE = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
||||
_PLAN_ONLY_DASH = "—"
|
||||
|
||||
_SPACE_PLAN_FIELDS = (
|
||||
"id", "title", "cell_cm", "plan_url", "plan_aspect", "plan_x", "plan_y",
|
||||
"plan_scale", "plan_scale_x", "plan_scale_y", "plan_angle", "view_box",
|
||||
)
|
||||
_SPACE_DISPLAY_FIELDS = (
|
||||
"show_borders", "show_names", "room_color", "bg_color", "room_opacity",
|
||||
"fill_mode", "custom_fill", "glow_enabled", "temp_min", "temp_max",
|
||||
"show_lqi", "hide_decor", "hide_openings", "label_temp", "label_hum",
|
||||
"label_lqi", "label_light", "card_font_scale", "north_deg", "bg_mode",
|
||||
"sun_rays",
|
||||
)
|
||||
_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly")
|
||||
_ROOM_DISPLAY_FIELDS = (
|
||||
"fill_mode", "custom_fill", "glow", "name_scale", "label_scale",
|
||||
)
|
||||
_DECOR_COMMON_FIELDS = ("id", "kind", "color", "opacity", "width_cm", "width")
|
||||
_DECOR_KIND_FIELDS = {
|
||||
"line": ("x1", "y1", "x2", "y2", "line_style"),
|
||||
"rect": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
|
||||
"ellipse": ("x", "y", "w", "h", "angle", "fill", "fill_color", "fill_opacity"),
|
||||
"text": ("x", "y", "text", "size", "size_cm", "scale", "angle"),
|
||||
"furniture": ("symbol", "x", "y", "w", "h", "angle"),
|
||||
}
|
||||
|
||||
|
||||
class ImportFailure(Exception):
|
||||
@@ -139,6 +167,126 @@ def live_layout(config: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any
|
||||
}
|
||||
|
||||
|
||||
def _pick_fields(source: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]:
|
||||
"""Copy only fields explicitly classified as portable plan data."""
|
||||
return {key: _json_copy(source[key]) for key in fields if key in source}
|
||||
|
||||
|
||||
def _is_live_text_reference(raw: str) -> bool:
|
||||
"""Mirror ``liveTextReference`` without evaluating Home Assistant state."""
|
||||
ref = raw.strip()
|
||||
if not ref:
|
||||
return False
|
||||
entity = ref
|
||||
attribute = ""
|
||||
colon = ref.find(":")
|
||||
if colon >= 0:
|
||||
entity = ref[:colon].strip()
|
||||
attribute = ref[colon + 1:].strip()
|
||||
else:
|
||||
parts = ref.split(".")
|
||||
if len(parts) > 2:
|
||||
entity = ".".join(parts[:2])
|
||||
attribute = ".".join(parts[2:])
|
||||
if _LIVE_TEXT_ENTITY.fullmatch(entity) is None:
|
||||
return False
|
||||
if colon >= 0 and not attribute:
|
||||
return False
|
||||
return not attribute or _LIVE_TEXT_ATTRIBUTE.fullmatch(attribute) is not None
|
||||
|
||||
|
||||
def _plan_only_text(value: str) -> str:
|
||||
"""Freeze every recognized live reference while preserving authored copy."""
|
||||
replaced = _LIVE_TEXT_TOKEN.sub(
|
||||
lambda match: _PLAN_ONLY_DASH
|
||||
if _is_live_text_reference(match.group(1)) else match.group(0),
|
||||
value,
|
||||
)
|
||||
return replaced.replace("{}", _PLAN_ONLY_DASH)
|
||||
|
||||
|
||||
def _project_plan_only_room(room: dict[str, Any]) -> dict[str, Any]:
|
||||
projected = _pick_fields(room, _ROOM_PLAN_FIELDS)
|
||||
if "settings" in room:
|
||||
settings = room.get("settings")
|
||||
projected["settings"] = (
|
||||
_pick_fields(settings, _ROOM_DISPLAY_FIELDS)
|
||||
if isinstance(settings, dict) else None
|
||||
)
|
||||
return projected
|
||||
|
||||
|
||||
def _project_plan_only_decor(shape: dict[str, Any]) -> dict[str, Any]:
|
||||
kind = str(shape.get("kind", ""))
|
||||
projected = _pick_fields(
|
||||
shape, _DECOR_COMMON_FIELDS + _DECOR_KIND_FIELDS.get(kind, ()),
|
||||
)
|
||||
if kind == "text" and isinstance(projected.get("text"), str):
|
||||
projected["text"] = _plan_only_text(projected["text"])
|
||||
return projected
|
||||
|
||||
|
||||
def _project_plan_only_space(space: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the fail-closed geometry/presentation projection for #167."""
|
||||
projected = _pick_fields(space, _SPACE_PLAN_FIELDS)
|
||||
if "settings" in space:
|
||||
projected["settings"] = _pick_fields(
|
||||
space.get("settings") or {}, _SPACE_DISPLAY_FIELDS,
|
||||
)
|
||||
projected["rooms"] = [
|
||||
_project_plan_only_room(room) for room in space.get("rooms") or []
|
||||
]
|
||||
collections: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
("walls", ("key", "cm", "a", "b")),
|
||||
("room_drafts", ("id", "points", "segments")),
|
||||
("partitions", ("id", "a", "b", "cm")),
|
||||
("wall_columns", ("id", "shape", "center", "cm", "angle")),
|
||||
("open_spans", ("a", "b")),
|
||||
)
|
||||
for name, fields in collections:
|
||||
if name not in space:
|
||||
continue
|
||||
values = []
|
||||
for item in space.get(name) or []:
|
||||
selected = _pick_fields(item, fields)
|
||||
if name == "room_drafts" and "segments" in selected:
|
||||
selected["segments"] = [
|
||||
_pick_fields(segment, ("cm",))
|
||||
for segment in selected.get("segments") or []
|
||||
]
|
||||
values.append(selected)
|
||||
projected[name] = values
|
||||
if "openings" in space:
|
||||
projected["openings"] = [
|
||||
_pick_fields(
|
||||
opening,
|
||||
("id", "type", "x", "y", "angle", "length", "flip_h", "flip_v"),
|
||||
)
|
||||
for opening in space.get("openings") or []
|
||||
]
|
||||
if "decor" in space:
|
||||
projected["decor"] = [
|
||||
_project_plan_only_decor(shape) for shape in space.get("decor") or []
|
||||
]
|
||||
return projected
|
||||
|
||||
|
||||
def _plan_only_room_label_layout(
|
||||
layout: dict[str, Any], space: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
space_id = str(space.get("id", ""))
|
||||
room_ids = {str(room.get("id", "")) for room in space.get("rooms") or []}
|
||||
return {
|
||||
key: _pick_fields(pos, ("x", "y", "s"))
|
||||
for key, pos in layout.items()
|
||||
if isinstance(key, str)
|
||||
and key.startswith("rl_")
|
||||
and key[3:] in room_ids
|
||||
and isinstance(pos, dict)
|
||||
and str(pos.get("s", "")) == space_id
|
||||
}
|
||||
|
||||
|
||||
def _marker_owned(marker: dict[str, Any], space: dict[str, Any], layout: dict[str, Any]) -> bool:
|
||||
marker_id = str(marker.get("id", ""))
|
||||
room_ids = {str(room.get("id")) for room in space.get("rooms") or []}
|
||||
@@ -254,9 +402,12 @@ def create_export(
|
||||
*,
|
||||
kind: str,
|
||||
space_id: str | None,
|
||||
plan_only: bool = False,
|
||||
card_version: str,
|
||||
config_root: Path,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
if not isinstance(plan_only, bool) or plan_only and kind != "space":
|
||||
raise ImportFailure("invalid_format", "Plan-only export requires one space")
|
||||
raw_config = _json_copy(
|
||||
config_data.get("config") or {"spaces": [], "markers": [], "settings": {}}
|
||||
)
|
||||
@@ -283,36 +434,41 @@ def create_export(
|
||||
key: pos for key, pos in layout.items()
|
||||
if isinstance(pos, dict) and str(pos.get("s")) == str(space_id)
|
||||
}
|
||||
selected_markers = [
|
||||
m for m in config.get("markers") or []
|
||||
if m.get("removed") is not True and _marker_owned(m, space, selected_layout)
|
||||
]
|
||||
selected_ids = {str(marker.get("id")) for marker in selected_markers}
|
||||
for marker in selected_markers:
|
||||
controls = marker.get("controls")
|
||||
if isinstance(controls, list):
|
||||
kept = []
|
||||
for ref in controls:
|
||||
if isinstance(ref, str) and ref.startswith("marker:") \
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
dropped_marker_links += 1
|
||||
continue
|
||||
kept.append(ref)
|
||||
marker["controls"] = kept or None
|
||||
badge = marker.get("value_badge")
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
ref = source.get("ref") if isinstance(source, dict) \
|
||||
and source.get("kind") == "derived_marker_state" else None
|
||||
if isinstance(ref, str) and ref.startswith("marker:") \
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
badge["enabled"] = False
|
||||
badge["source"] = None
|
||||
dropped_marker_links += 1
|
||||
config = {
|
||||
"spaces": [_json_copy(space)],
|
||||
"markers": _json_copy(selected_markers),
|
||||
}
|
||||
layout = selected_layout
|
||||
if plan_only:
|
||||
projected_space = _project_plan_only_space(space)
|
||||
config = {"spaces": [projected_space], "markers": []}
|
||||
layout = _plan_only_room_label_layout(selected_layout, projected_space)
|
||||
else:
|
||||
selected_markers = [
|
||||
m for m in config.get("markers") or []
|
||||
if m.get("removed") is not True and _marker_owned(m, space, selected_layout)
|
||||
]
|
||||
selected_ids = {str(marker.get("id")) for marker in selected_markers}
|
||||
for marker in selected_markers:
|
||||
controls = marker.get("controls")
|
||||
if isinstance(controls, list):
|
||||
kept = []
|
||||
for ref in controls:
|
||||
if isinstance(ref, str) and ref.startswith("marker:") \
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
dropped_marker_links += 1
|
||||
continue
|
||||
kept.append(ref)
|
||||
marker["controls"] = kept or None
|
||||
badge = marker.get("value_badge")
|
||||
source = badge.get("source") if isinstance(badge, dict) else None
|
||||
ref = source.get("ref") if isinstance(source, dict) \
|
||||
and source.get("kind") == "derived_marker_state" else None
|
||||
if isinstance(ref, str) and ref.startswith("marker:") \
|
||||
and ref[len("marker:"):] not in selected_ids:
|
||||
badge["enabled"] = False
|
||||
badge["source"] = None
|
||||
dropped_marker_links += 1
|
||||
config = {
|
||||
"spaces": [_json_copy(space)],
|
||||
"markers": _json_copy(selected_markers),
|
||||
}
|
||||
layout = selected_layout
|
||||
elif kind != "full":
|
||||
raise ImportFailure("invalid_format", "Unknown export kind")
|
||||
document = {
|
||||
@@ -327,7 +483,10 @@ def create_export(
|
||||
"payload": {"config": config, "layout": layout},
|
||||
"placement_manifest": placement_manifest(config, layout),
|
||||
"content_manifest": content_manifest(config, config_root),
|
||||
"transfer": {"dropped_marker_links": dropped_marker_links},
|
||||
"transfer": {
|
||||
"dropped_marker_links": dropped_marker_links,
|
||||
**({"plan_only": True} if plan_only else {}),
|
||||
},
|
||||
}
|
||||
if len(json.dumps(
|
||||
document, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
||||
@@ -416,10 +575,13 @@ def parse_document(raw: bytes) -> dict[str, Any]:
|
||||
if document["kind"] == "space" and placement_ids != set(layout):
|
||||
raise ImportFailure("invalid_format", "Placement manifest does not match layout")
|
||||
dropped_marker_links = _transfer_dropped_marker_links(document)
|
||||
plan_only = _transfer_plan_only(document)
|
||||
document["transfer"] = {
|
||||
**(document.get("transfer") or {}),
|
||||
"dropped_marker_links": dropped_marker_links,
|
||||
}
|
||||
if plan_only:
|
||||
_validate_plan_only_document(document, config, layout, placement)
|
||||
if len(json.dumps(
|
||||
config, ensure_ascii=False, separators=(",", ":"), allow_nan=False
|
||||
).encode("utf-8")) > MAX_CONFIG_BYTES:
|
||||
@@ -490,6 +652,51 @@ def _transfer_dropped_marker_links(document: dict[str, Any]) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def _transfer_plan_only(document: dict[str, Any]) -> bool:
|
||||
"""Read strict additive plan-only metadata without widening old exports."""
|
||||
transfer = document.get("transfer")
|
||||
if transfer is None:
|
||||
return False
|
||||
if not isinstance(transfer, dict):
|
||||
raise ImportFailure("invalid_format", "Transfer metadata must be an object")
|
||||
value = transfer.get("plan_only", False)
|
||||
if not isinstance(value, bool):
|
||||
raise ImportFailure("invalid_format", "Plan-only metadata must be a boolean")
|
||||
if value and document.get("kind") != "space":
|
||||
raise ImportFailure("invalid_format", "Plan-only metadata requires one space")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_plan_only_document(
|
||||
document: dict[str, Any],
|
||||
config: dict[str, Any],
|
||||
layout: dict[str, Any],
|
||||
placement: list[Any],
|
||||
) -> None:
|
||||
"""Reject forged plan-only flags unless every privacy invariant is true."""
|
||||
spaces = config.get("spaces") or []
|
||||
if len(spaces) != 1 or config.get("markers") != []:
|
||||
raise ImportFailure("invalid_format", "Plan-only export has invalid owners")
|
||||
expected_config = CONFIG_SCHEMA({
|
||||
"spaces": [_project_plan_only_space(spaces[0])],
|
||||
"markers": [],
|
||||
})
|
||||
if config != expected_config:
|
||||
raise ImportFailure("invalid_format", "Plan-only export contains private fields")
|
||||
expected_layout = _plan_only_room_label_layout(layout, spaces[0])
|
||||
if layout != expected_layout:
|
||||
raise ImportFailure("invalid_format", "Plan-only export contains device layout")
|
||||
expected_placement = placement_manifest(config, layout)
|
||||
if placement != expected_placement:
|
||||
raise ImportFailure("invalid_format", "Plan-only placement manifest is not canonical")
|
||||
content = document.get("content_manifest")
|
||||
if not isinstance(content, list) or any(
|
||||
not isinstance(item, dict) or item.get("owner") != "space"
|
||||
for item in content
|
||||
):
|
||||
raise ImportFailure("invalid_format", "Plan-only export contains private content")
|
||||
|
||||
|
||||
def _drop_invalid_import_marker_links(
|
||||
config: dict[str, Any], *, clean_ids: set[str] | None = None,
|
||||
) -> int:
|
||||
@@ -964,6 +1171,7 @@ def create_preview(
|
||||
runtime.import_previews[token] = candidate
|
||||
preview = {
|
||||
"kind": document["kind"],
|
||||
"plan_only": _transfer_plan_only(document),
|
||||
"created_at": document.get("created_at"),
|
||||
"card_version": document.get("card_version"),
|
||||
"integration_version": document.get("integration_version"),
|
||||
@@ -1043,6 +1251,7 @@ def revalidate_candidate(
|
||||
return {
|
||||
"preview": {
|
||||
"kind": document["kind"],
|
||||
"plan_only": _transfer_plan_only(document),
|
||||
"counts": _counts(incoming["config"], incoming["layout"]),
|
||||
"current_counts": _counts(current_config, current_layout),
|
||||
"source": "same" if candidate.get("same_source") else "foreign",
|
||||
|
||||
@@ -263,6 +263,7 @@ async def _commit_import_pair(
|
||||
vol.Required("type"): "houseplan/export/create",
|
||||
vol.Required("kind"): vol.In(["full", "space"]),
|
||||
vol.Optional("space_id"): str,
|
||||
vol.Optional("plan_only", default=False): bool,
|
||||
vol.Optional("card_version", default=""): str,
|
||||
}
|
||||
)
|
||||
@@ -287,6 +288,7 @@ async def ws_export_create(hass: HomeAssistant, connection, msg: dict[str, Any])
|
||||
layout_data,
|
||||
kind=msg["kind"],
|
||||
space_id=msg.get("space_id"),
|
||||
plan_only=msg.get("plan_only", False),
|
||||
card_version=msg.get("card_version", ""),
|
||||
config_root=Path(hass.config.path("")),
|
||||
)
|
||||
|
||||
+11
-3
@@ -561,6 +561,13 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await dialog?.updateComplete;
|
||||
dialog?.renderRoot?.querySelector('.close')?.focus();
|
||||
}
|
||||
} else if (scenario.dialog === 'backup-export-plan-only') {
|
||||
card._openBackupExport();
|
||||
card._backupExportDialog = {
|
||||
...card._backupExportDialog, kind: 'space', planOnly: true,
|
||||
};
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
} else if (scenario.dialog === 'backup-full' || scenario.dialog === 'backup-space') {
|
||||
const full = scenario.dialog === 'backup-full';
|
||||
card._backupImportDialog = {
|
||||
@@ -568,10 +575,11 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
size: 12345,
|
||||
token: 'golden-token',
|
||||
preview: {
|
||||
kind: full ? 'full' : 'space', source: full ? 'foreign' : 'same',
|
||||
kind: full ? 'full' : 'space', plan_only: !full,
|
||||
source: full ? 'foreign' : 'same',
|
||||
created_at: '2026-08-11T10:00:00Z', space_title: 'Ground (2)',
|
||||
counts: { spaces: 1, rooms: 4, markers: 12, layout: 15 },
|
||||
duplicates: full ? 0 : 2,
|
||||
counts: { spaces: 1, rooms: 4, markers: full ? 12 : 0, layout: full ? 15 : 4 },
|
||||
duplicates: 0,
|
||||
confirmation_required: full,
|
||||
content: full
|
||||
? [{ url: '/api/houseplan/content/plans/_/ground.svg', state: 'detach_required' }]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 23;
|
||||
export const GOLDEN_MATRIX_VERSION = 24;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -248,6 +248,9 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'backup-full-preview-desktop-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'backup-full', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1000, height: 900 }, ...page },
|
||||
{ id: 'backup-plan-only-export-desktop-en', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'backup-export-plan-only', language: 'en', theme: 'dark',
|
||||
viewport: { width: 1000, height: 900 }, ...page },
|
||||
{ id: 'backup-space-preview-mobile-ru', fixture: 'visual', space: 'golden-geometry',
|
||||
dialog: 'backup-space', language: 'ru', theme: 'light',
|
||||
viewport: { width: 390, height: 820 }, ...page },
|
||||
|
||||
@@ -19,6 +19,40 @@ const result = await page.evaluate(async () => {
|
||||
await card.updateComplete;
|
||||
const exportRadios = root().querySelectorAll('input[name="backup-kind"]').length;
|
||||
const exportWarning = !!root().querySelector('.backupwarn');
|
||||
const planOnlyHiddenForFull = !root().querySelector('.backupplanonly');
|
||||
root().querySelector('input[name="backup-kind"][value="space"]')?.click();
|
||||
await card.updateComplete;
|
||||
const planOnly = root().querySelector('.backupplanonly input[type="checkbox"]');
|
||||
planOnly?.click();
|
||||
await card.updateComplete;
|
||||
const planOnlySpace = {
|
||||
visible: !!planOnly,
|
||||
checked: card._backupExportDialog?.planOnly === true,
|
||||
labelled: root().querySelector('.backupplanonly')?.textContent
|
||||
.includes(card._t('backup.plan_only')) === true,
|
||||
};
|
||||
root().querySelector('input[name="backup-kind"][value="full"]')?.click();
|
||||
await card.updateComplete;
|
||||
const planOnlyResetForFull = card._backupExportDialog?.planOnly === false
|
||||
&& !root().querySelector('.backupplanonly');
|
||||
|
||||
let sentExport = null;
|
||||
card.hass = {
|
||||
...card.hass,
|
||||
callWS: async (message) => {
|
||||
sentExport = message;
|
||||
return { document: { smoke: true }, filename: 'plan-only-smoke.json' };
|
||||
},
|
||||
};
|
||||
card._backupExportDialog = {
|
||||
kind: 'space', planOnly: true, busy: false, error: '',
|
||||
};
|
||||
await card._runBackupExport();
|
||||
const planOnlyRequest = {
|
||||
kind: sentExport?.kind,
|
||||
planOnly: sentExport?.plan_only,
|
||||
space: sentExport?.space_id,
|
||||
};
|
||||
|
||||
card._backupExportDialog = null;
|
||||
card._backupImportDialog = {
|
||||
@@ -40,7 +74,31 @@ const result = await page.evaluate(async () => {
|
||||
noHorizontalOverflow: root().querySelector('hp-dialog').scrollWidth
|
||||
<= root().querySelector('hp-dialog').clientWidth,
|
||||
};
|
||||
return { group, actions, keyboardImport, exportRadios, exportWarning, importSafe };
|
||||
card._backupImportDialog = {
|
||||
filename: 'houseplan-space-plan-only.json', size: 2048, token: 'plan-only',
|
||||
preview: {
|
||||
kind: 'space', plan_only: true, source: 'same',
|
||||
created_at: '2026-08-17T00:00:00Z', space_title: 'Ground (2)',
|
||||
counts: { spaces: 1, rooms: 2, markers: 0, layout: 2 },
|
||||
bindings: { device: 0, entity: 0, virtual: 0, active: 0, disabled: 0, missing: 0 },
|
||||
duplicates: 0, confirmation_required: false, content: [],
|
||||
},
|
||||
expectedConfigRev: 1, expectedLayoutRev: 2, duplicatePolicy: 'skip',
|
||||
confirmMissing: false, busy: false, error: '',
|
||||
};
|
||||
await card.updateComplete;
|
||||
const planOnlyPreview = {
|
||||
visible: root().querySelector('.backupplanonlystatus')?.textContent
|
||||
=== card._t('backup.plan_only_preview'),
|
||||
noDuplicatePolicy: !root().querySelector('.backupchoices'),
|
||||
noHorizontalOverflow: root().querySelector('hp-dialog').scrollWidth
|
||||
<= root().querySelector('hp-dialog').clientWidth,
|
||||
};
|
||||
return {
|
||||
group, actions, keyboardImport, exportRadios, exportWarning,
|
||||
planOnlyHiddenForFull, planOnlySpace, planOnlyResetForFull, planOnlyRequest,
|
||||
importSafe, planOnlyPreview,
|
||||
};
|
||||
});
|
||||
|
||||
checkAll(result, {
|
||||
@@ -49,6 +107,11 @@ checkAll(result, {
|
||||
keyboardImport: true,
|
||||
exportRadios: 2,
|
||||
exportWarning: true,
|
||||
planOnlyHiddenForFull: true,
|
||||
planOnlySpace: { visible: true, checked: true, labelled: true },
|
||||
planOnlyResetForFull: true,
|
||||
planOnlyRequest: { kind: 'space', planOnly: true, space: 'f1' },
|
||||
importSafe: { danger: true, disabledUntilConfirmed: true, noHorizontalOverflow: true },
|
||||
planOnlyPreview: { visible: true, noDuplicatePolicy: true, noHorizontalOverflow: true },
|
||||
});
|
||||
await finish(browser, result);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+16
-5
File diff suppressed because one or more lines are too long
@@ -539,7 +539,7 @@ spans are clipped per atomic body.
|
||||
| `houseplan/files/migrate` | `from_id`, `to_id` | `{mapping}` — COPY, never move |
|
||||
| `houseplan/files/cleanup` | `marker_id`, `keep?` | replacement-only collection |
|
||||
| `houseplan/content/sign` | `paths[]` | `{urls}` — authSig for `<image>`/`<a>` fetches |
|
||||
| `houseplan/export/create` | `kind`, `space_id?`, `card_version` | consistent versioned JSON document + safe filename |
|
||||
| `houseplan/export/create` | `kind`, `space_id?`, `plan_only?`, `card_version` | consistent versioned JSON document + safe filename; plan-only is valid only for one space |
|
||||
| `houseplan/import/revalidate` | preview `token`, `duplicate_policy?` | refreshed bounded preview and current expected revisions |
|
||||
| `houseplan/import/apply` | token, both expected revisions, content confirmation | crash-resumable paired config/layout commit; full import gets one-deep undo |
|
||||
|
||||
@@ -570,6 +570,14 @@ versions, and retains the parsed candidate only in memory for ten minutes. Its
|
||||
opaque token is bound to the HA user, normalized-candidate digest and the exact
|
||||
config/layout revisions. Parsed candidates are capped globally as well as per
|
||||
user.
|
||||
|
||||
Plan-only export is a server-owned, fail-closed projection rather than a
|
||||
client-side scrub. It removes every marker and all device layout, preserves
|
||||
only canonical room-label placements, and copies one space through explicit
|
||||
geometry/presentation allowlists. The parser recomputes that projection and
|
||||
its placement manifest before showing a plan-only preview, so manually adding
|
||||
a private field while keeping `transfer.plan_only: true` is rejected.
|
||||
|
||||
The browser never parses imported configuration. Full import and maintenance
|
||||
share the `optimize_pending` crash-recovery intent and the one-deep backup slot;
|
||||
the backup carries `kind: optimize|import`, while every layout-store writer
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Current-space export can now create a **Plan only** JSON template with rooms,
|
||||
walls, openings, decor, backdrop and room-label positions, while removing
|
||||
devices and structural Home Assistant bindings. Import preview identifies
|
||||
the template before adding it as a new space
|
||||
([#167](https://github.com/Matysh/houseplan-card/issues/167)).
|
||||
- Composite appliances such as washing machines now use an explicit Home
|
||||
Assistant Status/Run state/Job state to show the yellow working marker during
|
||||
an active cycle. Power-on alone remains neutral, Power-off still fades stale
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Экспорт текущего пространства теперь может создать JSON-шаблон **«Только
|
||||
планировка»** с комнатами, стенами, проёмами, декором, подложкой и позициями
|
||||
подписей комнат, но без устройств и структурных привязок Home Assistant.
|
||||
Предпросмотр помечает такой файл до добавления новым пространством
|
||||
([#167](https://github.com/Matysh/houseplan-card/issues/167)).
|
||||
- Составная техника, например стиральная машина, теперь использует явный
|
||||
Status/Run state/Job state Home Assistant и получает жёлтую подложку во время
|
||||
активного цикла. Одного Power=`on` по-прежнему недостаточно, Power=`off`
|
||||
|
||||
@@ -69,6 +69,23 @@ that fallback:
|
||||
Import preview and apply therefore operate on the same normalized candidate.
|
||||
Explicit `static` and `daynight` survive same-instance and foreign transfer.
|
||||
|
||||
## Additive plan-only space transfer (#167)
|
||||
|
||||
`houseplan/export/create` accepts `plan_only: true` only for a one-space
|
||||
export. The resulting version-1 envelope adds `transfer.plan_only: true`,
|
||||
contains no markers and retains only canonical `rl_<room_id>` room-label
|
||||
layout. Normal full/space exports never write `plan_only: false`, so their
|
||||
existing document shape and lossless compatibility remain unchanged; an
|
||||
absent field still means an ordinary export.
|
||||
|
||||
Plan-only data is a fail-closed allowlist projection of supported geometry,
|
||||
presentation and content references. Known Area, temperature/humidity,
|
||||
opening and decor bindings are removed and recognized live-text references are
|
||||
frozen as `—`. Import rejects a true flag on a full export, non-boolean values,
|
||||
or any document whose projected config, layout, placement or content owner no
|
||||
longer satisfies that privacy contract. There is no persisted config/layout
|
||||
migration: the new field exists only in the portable envelope.
|
||||
|
||||
## Legacy device tap action
|
||||
|
||||
The historical marker token `tap_action: cover` remains accepted indefinitely.
|
||||
|
||||
@@ -507,6 +507,19 @@ assigns new internal IDs and adds the copy without replacing global settings.
|
||||
Internal uploaded files are not embedded in JSON; an import to another HA
|
||||
instance must explicitly detach those links.
|
||||
|
||||
For **Current space**, enable **Plan only** to transfer the architectural
|
||||
template without devices or Home Assistant bindings. It keeps rooms, walls,
|
||||
openings, decor, backdrop transforms and manually positioned room labels, but
|
||||
removes real and virtual markers, device positions, Area assignments,
|
||||
temperature/humidity sources and opening contacts/locks. Live values in text
|
||||
labels become `—`; surrounding static text stays intact. The import preview
|
||||
marks this file as plan-only and adds it through the normal space-import flow.
|
||||
|
||||
Plan-only is not full anonymisation: space and room names, static text, file
|
||||
names, exact coordinates and external URLs remain in the JSON. Internal plan
|
||||
files are still referenced rather than embedded and may need to be detached on
|
||||
another Home Assistant instance.
|
||||
|
||||
### Storage locations
|
||||
|
||||
| Data | Location |
|
||||
|
||||
@@ -1158,6 +1158,14 @@ show_signal: true
|
||||
импорте оно получает новые внутренние ID и добавляется рядом с существующими;
|
||||
глобальные настройки не меняются и отмена не создаётся.
|
||||
|
||||
Для текущего пространства можно включить **Только планировка**. Такой файл
|
||||
сохраняет комнаты, стены, проёмы, декор, трансформации подложки и вручную
|
||||
расставленные подписи комнат, но удаляет реальные и виртуальные маркеры,
|
||||
позиции устройств, Area, источники температуры/влажности и контакты/замки
|
||||
проёмов. Живые значения в текстовых подписях заменяются на `—`, а окружающий
|
||||
статический текст остаётся. Предпросмотр явно помечает файл как планировку и
|
||||
добавляет её обычным импортом пространства.
|
||||
|
||||
Файл сначала загружается в безопасный серверный предпросмотр: House Plan
|
||||
показывает тип, версии, количество объектов, источник и состояние ссылок, но
|
||||
ничего не записывает до подтверждения. Если привязка к устройству HA уже есть,
|
||||
@@ -1169,6 +1177,11 @@ JSON хранит названия, идентификаторы HA и точн
|
||||
переносе на другой экземпляр такие ссылки нужно явно согласиться отвязать.
|
||||
Текущие состояния устройств и маршруты пылесосов в экспорт не входят.
|
||||
|
||||
Режим «Только планировка» не является полной анонимизацией: в файле остаются
|
||||
названия пространства и комнат, статический текст, имена файлов, точные
|
||||
координаты и внешние URL. Структурные привязки Home Assistant при этом не
|
||||
переносятся.
|
||||
|
||||
### Где лежат данные
|
||||
|
||||
| Данные | Хранение |
|
||||
|
||||
@@ -37,14 +37,15 @@ Issue: [#167](https://github.com/Matysh/houseplan-card/issues/167)
|
||||
|
||||
## 2. Что человек увидит до и после
|
||||
|
||||
**До:** экспорт текущего пространства всегда содержит его маркеры, layout и
|
||||
**До:** экспорт текущего пространства всегда содержит его маркеры, device layout и
|
||||
HA-привязки. Для чистого переноса пользователь должен вручную редактировать
|
||||
JSON, рискуя повредить структуру или случайно оставить идентификаторы.
|
||||
|
||||
**После:** рядом с выбором текущего пространства доступен выключенный по
|
||||
умолчанию флажок «Plan only». В этом режиме файл сохраняет переносимую
|
||||
планировку, но не содержит маркеров, layout и известных структурных
|
||||
HA-привязок. Preview импорта явно сообщает, что файл содержит только
|
||||
планировку и вручную расставленные подписи комнат, но не содержит маркеров,
|
||||
device layout и известных структурных HA-привязок. Preview импорта явно
|
||||
сообщает, что файл содержит только
|
||||
планировку; импорт добавляет новое несвязанное пространство существующим
|
||||
безопасным механизмом #50.
|
||||
|
||||
@@ -72,7 +73,9 @@ HA-привязок. Preview импорта явно сообщает, что ф
|
||||
|
||||
1. опция «Plan only» только для экспорта текущего пространства;
|
||||
2. schema-aware проекция переносимой геометрии и визуальных настроек;
|
||||
3. полное удаление маркеров, marker layout и структурных HA-привязок;
|
||||
3. полное удаление реальных и виртуальных маркеров, marker/auto-device/light-
|
||||
group layout и структурных HA-привязок при сохранении безопасных позиций
|
||||
подписей комнат `rl_<room_id>`;
|
||||
4. статическая нейтрализация live-text токенов по решению владельца;
|
||||
5. аддитивный признак `transfer.plan_only: true` в JSON;
|
||||
6. строгая проверка plan-only инварианта при чтении файла;
|
||||
@@ -96,6 +99,9 @@ HA-привязок. Preview импорта явно сообщает, что ф
|
||||
- сопоставление Area, устройств и сущностей при импорте;
|
||||
- перенос marker icon, actions, vacuum paths, runtime states, histories,
|
||||
trails, known/new-device bookkeeping;
|
||||
- сохранение виртуальных маркеров вроде пользовательских заметок «Котёл»:
|
||||
`binding: virtual` не делает marker частью архитектурной геометрии, поэтому
|
||||
он удаляется вместе со всеми остальными маркерами;
|
||||
- создание PDF/изображения чистого плана — это сценарий #53;
|
||||
- дополнительное privacy-предупреждение специально для plan-only;
|
||||
- изменение редакторов, View, kiosk или touch-жестов;
|
||||
@@ -129,7 +135,8 @@ HA-привязок. Preview импорта явно сообщает, что ф
|
||||
Для plan-only файла preview:
|
||||
|
||||
- явно показывает информационную строку «Файл содержит только планировку»;
|
||||
- показывает `markers = 0`, `layout = 0`, device/entity/virtual bindings = 0;
|
||||
- показывает `markers = 0`, device/entity/virtual bindings = 0, а `layout`
|
||||
считает только сохранённые позиции подписей комнат;
|
||||
- не показывает duplicate policy, поскольку дубликатов устройств нет;
|
||||
- не показывает missing Area, поскольку `room.area` очищен;
|
||||
- показывает обычные counts комнат, стен, проёмов, декора и content;
|
||||
@@ -154,9 +161,21 @@ HA-привязок. Preview импорта явно сообщает, что ф
|
||||
},
|
||||
"payload": {
|
||||
"config": { "spaces": ["…"], "markers": [] },
|
||||
"layout": {}
|
||||
"layout": {
|
||||
"rl_room-kitchen": { "x": 0.42, "y": 0.31, "s": "floor-1" }
|
||||
}
|
||||
},
|
||||
"placement_manifest": [],
|
||||
"placement_manifest": [
|
||||
{
|
||||
"layout_id": "rl_room-kitchen",
|
||||
"space_id": "floor-1",
|
||||
"owner": "room_label",
|
||||
"owner_id": "room-kitchen",
|
||||
"binding": null,
|
||||
"label": null,
|
||||
"icon": null
|
||||
}
|
||||
],
|
||||
"content_manifest": ["…"]
|
||||
}
|
||||
```
|
||||
@@ -196,8 +215,8 @@ HA-привязок. Preview импорта явно сообщает, что ф
|
||||
| Источник | Результат plan-only |
|
||||
|---|---|
|
||||
| `config.markers` | `[]`; marker config целиком отсутствует |
|
||||
| `payload.layout` | `{}`; удаляются все позиции, включая room labels |
|
||||
| `placement_manifest` | `[]` |
|
||||
| `payload.layout` | только `rl_<room_id>` для комнаты экспортируемого пространства; marker, `v_*`, `lg_*`, auto-device и неизвестные позиции удаляются |
|
||||
| `placement_manifest` | только canonical `room_label` entries, точно соответствующие сохранённым `rl_*` ключам |
|
||||
| marker attachment/content entries | отсутствуют |
|
||||
| `room.area` | отсутствует или canonical unbound value |
|
||||
| `room.settings.temp_source` | отсутствует |
|
||||
@@ -208,6 +227,9 @@ HA-привязок. Preview импорта явно сообщает, что ф
|
||||
| valid live tokens и legacy `{}` в `decor.text` | заменены на `—` |
|
||||
| `known_devices` / `new_device_ids` | не переносятся |
|
||||
|
||||
Реальные и виртуальные markers удаляются одинаково: `binding: virtual`, имя или
|
||||
статичная иконка не переводят marker в архитектурный decor.
|
||||
|
||||
`flip_h`, `flip_v` и другие геометрические параметры проёма не являются
|
||||
HA-binding behavior и сохраняются.
|
||||
|
||||
@@ -264,7 +286,12 @@ presentation, user content или HA binding. Это fail-closed защита о
|
||||
`kind == "space" && transfer.plan_only == true` он дополнительно проверяет:
|
||||
|
||||
- ровно одно пространство;
|
||||
- `markers == []`, `layout == {}`, `placement_manifest == []`;
|
||||
- `markers == []`;
|
||||
- каждый layout key строго равен `rl_<room_id>` существующей комнаты
|
||||
экспортируемого пространства, а `pos.s` равен id этого пространства;
|
||||
- каждый placement entry canonical: `owner == "room_label"`, `owner_id`
|
||||
совпадает с room id, `binding|label|icon == null`, и set записей точно
|
||||
совпадает с layout;
|
||||
- отсутствие marker-owned content;
|
||||
- отсутствие Area/temp/hum/opening/decor legacy bindings;
|
||||
- отсутствие валидных live-text токенов и legacy `{}`;
|
||||
@@ -282,7 +309,8 @@ presentation, user content или HA binding. Это fail-closed защита о
|
||||
title и не меняет global settings.
|
||||
- Content availability/detach повторно проверяется перед apply под действующим
|
||||
lock по контракту #50.
|
||||
- Apply не добавляет маркеры и layout, а комнаты остаются unbound.
|
||||
- Apply не добавляет маркеры; существующий remap переносит только room-label
|
||||
layout на новые room/space ids, а комнаты остаются unbound.
|
||||
- Events, revision conflict, token ownership/expiry и capacity limits не
|
||||
меняются.
|
||||
|
||||
@@ -323,8 +351,9 @@ first по `TOUCH-SUPPORT`, но диалог не должен переполн
|
||||
|
||||
1. При Current space пользователь видит выключенный «Plan only»; при Full
|
||||
backup опции нет, а request не содержит true.
|
||||
2. Plan-only export содержит одно пространство, `markers: []`, `layout: {}`,
|
||||
empty placement manifest и `transfer.plan_only: true`.
|
||||
2. Plan-only export содержит одно пространство, `markers: []`, только валидные
|
||||
`rl_<room_id>` layout/room-label placement entries и
|
||||
`transfer.plan_only: true`.
|
||||
3. Геометрия rooms/walls/drafts/partitions/columns/openings/open spans,
|
||||
decor/backdrop и переносимые визуальные настройки сохраняются по allowlist.
|
||||
4. Area, temperature/humidity source, opening contact/lock/invert, marker data,
|
||||
@@ -334,11 +363,12 @@ first по `TOUCH-SUPPORT`, но диалог не должен переполн
|
||||
6. Названия, статический текст, filenames и external URLs сохраняются; UX не
|
||||
обещает полную анонимизацию и не добавляет отдельного предупреждения.
|
||||
7. Импорт plan-only файла на чистый целевой instance создаёт новое пространство
|
||||
с той же планировкой, нулём устройств/позиций/привязок и unbound rooms.
|
||||
с той же планировкой, нулём устройств/HA-привязок, remap-нутыми позициями
|
||||
подписей комнат и unbound rooms.
|
||||
8. Preview и revalidate явно сохраняют `plan_only: true`, показывают нулевые
|
||||
binding counts и не предлагают duplicate policy.
|
||||
9. File с true, но с маркером, layout или известной HA-привязкой отклоняется
|
||||
как `invalid_format` до preview.
|
||||
9. File с true, но с маркером, не-room-label layout, несогласованным placement
|
||||
или известной HA-привязкой отклоняется как `invalid_format` до preview.
|
||||
10. Обычные full/space export и import проходят неизменённые regression
|
||||
fixtures; normal space document не получает `plan_only: false`.
|
||||
11. RU/EN тексты синхронны, checkbox доступен с клавиатуры и диалог проходит
|
||||
@@ -361,11 +391,13 @@ first по `TOUCH-SUPPORT`, но диалог не должен переполн
|
||||
- export normal space с фиксированным временем — прежний fixture без нового
|
||||
поля;
|
||||
- plan-only projection полного representative space со всеми типами geometry,
|
||||
marker/layout, room/opening bindings, modern и legacy live text;
|
||||
real/virtual marker layout, safe room-label layout, room/opening bindings,
|
||||
modern и legacy live text;
|
||||
- preserve static names/text/URLs/content owner и drop marker attachments;
|
||||
- reject `plan_only=true` для full;
|
||||
- reject non-boolean plan_only;
|
||||
- reject forged plan-only files по одному для marker, layout, room area,
|
||||
- reject forged plan-only files по одному для marker, чужого/невалидного
|
||||
room-label layout/placement, room area,
|
||||
temp/hum, opening refs, legacy decor refs и inline token;
|
||||
- preview/revalidate/apply happy path на same и foreign instance;
|
||||
- missing internal backdrop + detach confirmation по действующему контракту;
|
||||
@@ -383,7 +415,8 @@ first по `TOUCH-SUPPORT`, но диалог не должен переполн
|
||||
- import smoke: `plan_only` line есть, duplicate controls отсутствуют;
|
||||
- RU/EN i18n parity;
|
||||
- добавить/обновить deterministic golden scenarios для export dialog с
|
||||
включённой опцией и import preview, поднять версию golden matrix;
|
||||
включённой опцией и import preview; после слитого #166 поднять жёстко
|
||||
проверяемый `GOLDEN_MATRIX_VERSION` с 23 до 24;
|
||||
- review actual/expected/diff до принятия baseline.
|
||||
|
||||
В implementation loop выполняются только действующие быстрые gates:
|
||||
@@ -396,8 +429,8 @@ guard, получить non-zero и восстановить файл:
|
||||
|
||||
1. оставить один inline live token либо `room.area` в проекции — backend
|
||||
plan-only privacy test падает;
|
||||
2. проигнорировать `plan_only` и вернуть marker/layout — projection/roundtrip
|
||||
test падает;
|
||||
2. проигнорировать `plan_only` и вернуть marker либо не-room-label layout —
|
||||
projection/roundtrip test падает;
|
||||
3. прогнать normal space export через lossy projection или записать
|
||||
`plan_only: false` — fixed ordinary-export regression падает;
|
||||
4. не перенести `plan_only` через preview/revalidate или не показать строку —
|
||||
@@ -461,11 +494,14 @@ Push ветки выполняется после задачи; issue не за
|
||||
|
||||
1. `plan_only` — optional additive metadata внутри существующего export
|
||||
version, а не новый kind или новая версия формата.
|
||||
2. Геометрический `flip_h|flip_v` сохраняется, contact-specific `invert`
|
||||
2. Безопасные ручные позиции подписей комнат `rl_<room_id>` сохраняются и
|
||||
remap-ятся; весь остальной layout удаляется.
|
||||
3. Реальные и виртуальные markers удаляются одинаково.
|
||||
4. Геометрический `flip_h|flip_v` сохраняется, contact-specific `invert`
|
||||
удаляется вместе с binding.
|
||||
3. Неизвестные поля в plan-only проекцию автоматически не попадают; обычный
|
||||
5. Неизвестные поля в plan-only проекцию автоматически не попадают; обычный
|
||||
export остаётся lossless.
|
||||
4. Privacy invariant относится к структурным HA-полям и валидным live tokens,
|
||||
6. Privacy invariant относится к структурным HA-полям и валидным live tokens,
|
||||
но не к произвольным пользовательским строкам.
|
||||
5. Новый informational label/hint допустим; отдельное предупреждение или новое
|
||||
7. Новый informational label/hint допустим; отдельное предупреждение или новое
|
||||
подтверждение по решению владельца запрещено.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const pattern = process.argv[2];
|
||||
if (!pattern) {
|
||||
console.error('usage: node scripts/backend-test-guard.mjs <pytest-k-pattern>');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const result = spawnSync(process.env.PYTHON || 'python', [
|
||||
'-m', 'pytest', 'tests_backend/test_ha_import_export.py', '-q', '-k', pattern,
|
||||
], { stdio: 'inherit' });
|
||||
process.exit(result.status ?? 2);
|
||||
@@ -165,6 +165,72 @@ export const MUTANTS = [
|
||||
replace: ' glowEnabled: false, allLightsOff: true, northDeg: 0,',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'plan-only-room-area-restored',
|
||||
guard: 'node scripts/backend-test-guard.mjs plan_only_export_projects',
|
||||
because: 'возврат room.area нарушает основное обещание чистого шаблона; '
|
||||
+ 'projection/roundtrip тест обязан увидеть HA Area даже при нулевых markers',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/import_export.py',
|
||||
find: '_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly")',
|
||||
replace: '_ROOM_PLAN_FIELDS = ("id", "name", "area", "open_to", "x", "y", "w", "h", "poly")',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'plan-only-projector-bypassed',
|
||||
guard: 'node scripts/backend-test-guard.mjs plan_only_export_projects',
|
||||
because: 'игнорирование request-флага возвращает markers и device layout; '
|
||||
+ 'полный representative export обязан доказать, что true включает lossy projector',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/import_export.py',
|
||||
find: ' if plan_only:\n projected_space = _project_plan_only_space(space)',
|
||||
replace: ' if False and plan_only:\n projected_space = _project_plan_only_space(space)',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'ordinary-export-plan-only-false-emitted',
|
||||
guard: 'node scripts/backend-test-guard.mjs ordinary_space_export_is_unchanged',
|
||||
because: 'аддитивное поле не должно менять обычный space export даже значением false; '
|
||||
+ 'fixed regression обязан сохранить прежнюю структуру документа',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/import_export.py',
|
||||
find: '**({"plan_only": True} if plan_only else {}),',
|
||||
replace: '**({"plan_only": plan_only}),',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'plan-only-revalidate-flag-dropped',
|
||||
guard: 'node scripts/backend-test-guard.mjs plan_only_export_projects',
|
||||
because: 'revalidate не имеет права превращать plan-only preview в обычный space preview; '
|
||||
+ 'roundtrip test проверяет сохранение флага после обновления revisions',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/import_export.py',
|
||||
find: ' "plan_only": _transfer_plan_only(document),\n "counts":',
|
||||
replace: ' "plan_only": False,\n "counts":',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'plan-only-forged-file-trusted',
|
||||
guard: 'node scripts/backend-test-guard.mjs forged_plan_only_privacy_claim',
|
||||
because: 'флаг файла не является доказательством приватности; negative parser matrix '
|
||||
+ 'обязана покраснеть, если schema-aware проверка больше не вызывается',
|
||||
patches: [{
|
||||
file: 'custom_components/houseplan/import_export.py',
|
||||
find: ' if plan_only:\n _validate_plan_only_document(document, config, layout, placement)',
|
||||
replace: ' if False and plan_only:\n _validate_plan_only_document(document, config, layout, placement)',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'plan-only-preview-label-hidden',
|
||||
guard: 'node demo/smoke_backup_transfer.mjs',
|
||||
because: 'валидный plan-only файл без явной строки в preview выглядит обычной копией; '
|
||||
+ 'browser smoke обязан проверять пользовательский статус, а не только backend-флаг',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: "${p.plan_only ? html`<span class=\"backupplanonlystatus\">${this._t('backup.plan_only_preview')}</span>` : nothing}",
|
||||
replace: "${false ? html`<span class=\"backupplanonlystatus\">${this._t('backup.plan_only_preview')}</span>` : nothing}",
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
// --- механика ---------------------------------------------------------------
|
||||
|
||||
+13
-3
@@ -1396,7 +1396,7 @@ class HouseplanCard extends LitElement {
|
||||
busy: boolean;
|
||||
} | null = null;
|
||||
private _backupExportDialog: {
|
||||
kind: 'full' | 'space'; busy: boolean; error: string;
|
||||
kind: 'full' | 'space'; planOnly: boolean; busy: boolean; error: string;
|
||||
} | null = null;
|
||||
private _backupImportDialog: {
|
||||
filename: string; size: number; token: string; preview: any;
|
||||
@@ -13088,7 +13088,7 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
private _openBackupExport = (): void => {
|
||||
this._settingsDialog = null;
|
||||
this._backupExportDialog = { kind: 'full', busy: false, error: '' };
|
||||
this._backupExportDialog = { kind: 'full', planOnly: false, busy: false, error: '' };
|
||||
};
|
||||
|
||||
private async _runBackupExport(): Promise<void> {
|
||||
@@ -13100,6 +13100,7 @@ class HouseplanCard extends LitElement {
|
||||
type: 'houseplan/export/create',
|
||||
kind: d.kind,
|
||||
space_id: d.kind === 'space' ? this._space : undefined,
|
||||
...(d.kind === 'space' && d.planOnly ? { plan_only: true } : {}),
|
||||
card_version: CARD_VERSION,
|
||||
});
|
||||
const blob = new Blob([JSON.stringify(response.document, null, 2) + '\n'], {
|
||||
@@ -13285,7 +13286,8 @@ class HouseplanCard extends LitElement {
|
||||
<div class="body backupbody">
|
||||
<div class="rhint">${this._t('backup.export_hint')}</div>
|
||||
<label class="srcrow"><input type="radio" name="backup-kind" value="full"
|
||||
.checked=${d.kind === 'full'} @change=${() => (this._backupExportDialog = { ...d, kind: 'full' })} />
|
||||
.checked=${d.kind === 'full'}
|
||||
@change=${() => (this._backupExportDialog = { ...d, kind: 'full', planOnly: false })} />
|
||||
<span>${this._t('backup.full')}</span></label>
|
||||
<label class="srcrow"><input type="radio" name="backup-kind" value="space"
|
||||
.checked=${d.kind === 'space'} ?disabled=${!currentSpace}
|
||||
@@ -13293,6 +13295,13 @@ class HouseplanCard extends LitElement {
|
||||
<span>${currentSpace
|
||||
? this._t('backup.current_space_title', { title: currentSpace.title || currentSpace.id })
|
||||
: this._t('backup.no_current_space')}</span></label>
|
||||
${d.kind === 'space' && currentSpace ? html`<label class="srcrow backupplanonly">
|
||||
<input type="checkbox" .checked=${d.planOnly}
|
||||
@change=${(event: Event) => (this._backupExportDialog = {
|
||||
...d, planOnly: (event.target as HTMLInputElement).checked,
|
||||
})} />
|
||||
<span><b>${this._t('backup.plan_only')}</b><small>${this._t('backup.plan_only_hint')}</small></span>
|
||||
</label>` : nothing}
|
||||
<div class="backupwarn">${this._t('backup.privacy_warning')}</div>
|
||||
${d.error ? html`<div class="backuperror" role="alert">${d.error}</div>` : nothing}
|
||||
</div>
|
||||
@@ -13320,6 +13329,7 @@ class HouseplanCard extends LitElement {
|
||||
${p ? html`
|
||||
<div class="backupsummary">
|
||||
<b>${this._t(p.kind === 'full' ? 'backup.full' : 'backup.current_space')}</b>
|
||||
${p.plan_only ? html`<span class="backupplanonlystatus">${this._t('backup.plan_only_preview')}</span>` : nothing}
|
||||
<span>${this._t(p.source === 'same' ? 'backup.same_source' : 'backup.foreign_source')}</span>
|
||||
<span>${this._t('backup.created', { value: p.created_at || '—' })}</span>
|
||||
<span>${this._t('backup.versions', {
|
||||
|
||||
@@ -825,6 +825,9 @@
|
||||
"backup.current_space": "Current space",
|
||||
"backup.current_space_title": "Current space: {title}",
|
||||
"backup.no_current_space": "No current space",
|
||||
"backup.plan_only": "Plan only",
|
||||
"backup.plan_only_hint": "Keep rooms, walls, openings, decor and room-label positions without devices or Home Assistant bindings.",
|
||||
"backup.plan_only_preview": "This file contains the plan only",
|
||||
"backup.privacy_warning": "The archive keeps names, Home Assistant identifiers and exact coordinates. Internal plans and attachments are referenced, not embedded; runtime states and vacuum trails are not included.",
|
||||
"backup.download": "Download JSON",
|
||||
"backup.export_done": "Backup downloaded",
|
||||
|
||||
@@ -825,6 +825,9 @@
|
||||
"backup.current_space": "Текущее пространство",
|
||||
"backup.current_space_title": "Текущее пространство: {title}",
|
||||
"backup.no_current_space": "Нет текущего пространства",
|
||||
"backup.plan_only": "Только планировка",
|
||||
"backup.plan_only_hint": "Сохранить комнаты, стены, проёмы, декор и позиции подписей комнат без устройств и привязок Home Assistant.",
|
||||
"backup.plan_only_preview": "Файл содержит только планировку",
|
||||
"backup.privacy_warning": "Архив сохраняет названия, идентификаторы Home Assistant и точные координаты. Внутренние планы и вложения указываются ссылками, но не вкладываются; текущие состояния и маршруты пылесосов не включаются.",
|
||||
"backup.download": "Скачать JSON",
|
||||
"backup.export_done": "Резервная копия скачана",
|
||||
|
||||
@@ -2867,6 +2867,10 @@ export const cardStyles = css`
|
||||
.backupupload > .btn { width: 100%; justify-content: center; }
|
||||
.backupupload input { display: none; }
|
||||
.backupbody { min-width: 0; }
|
||||
.backupplanonly { margin-inline-start: var(--sp-4) !important; align-items: flex-start !important; }
|
||||
.backupplanonly > span:first-of-type { display: grid; gap: 2px; white-space: normal; }
|
||||
.backupplanonly small { color: var(--secondary-text-color); line-height: 1.35; }
|
||||
.backupplanonlystatus { color: var(--hp-accent) !important; font-weight: 700; }
|
||||
.backupfile, .backupsummary, .backupcontent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -85,7 +85,7 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
|
||||
'fill-light', 'fill-temp', 'fill-lqi', 'lighting', 'hover', 'zoom-040', 'zoom-250',
|
||||
'warm-remount', 'dialog-mobile', 'color-popover', 'tray-wide', 'tray-medium', 'sun-window',
|
||||
'tray-narrow', 'opaque-glow-two-doorways', 'filled-tunnel', 'opening-placement',
|
||||
'backup-full', 'backup-space', 'value-badge-positions', 'isometric-geometry',
|
||||
'backup-full', 'backup-space', 'backup-plan-only', 'value-badge-positions', 'isometric-geometry',
|
||||
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
|
||||
'isometric-large-warm-remount', 'split-corner-wall', 'plan-snap-endpoint',
|
||||
'plan-snap-line-gaps', 'wall-junctions', 'isometric-wall-junctions',
|
||||
@@ -193,7 +193,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
|
||||
assert.ok(scenario);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 23);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 24);
|
||||
assert.equal(space.settings.sun_rays, true);
|
||||
assert.equal(scenario.northDeg, 90,
|
||||
'the sign-sensitive golden must keep a non-zero north direction');
|
||||
|
||||
@@ -270,6 +270,249 @@ def test_full_export_has_versioned_envelope_and_live_layout(tmp_path: Path) -> N
|
||||
assert filename.startswith("houseplan-full-") and filename.endswith(".json")
|
||||
|
||||
|
||||
def _plan_only_source() -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
config = _config()
|
||||
space = config["spaces"][0]
|
||||
space.update({
|
||||
"cell_cm": 5,
|
||||
"plan_url": "https://example.invalid/floor.svg",
|
||||
"plan_x": 0.1,
|
||||
"plan_y": -0.2,
|
||||
"plan_scale_x": 1.2,
|
||||
"plan_scale_y": 0.8,
|
||||
"plan_angle": 15,
|
||||
"settings": {
|
||||
"show_names": True, "north_deg": 90, "sun_rays": True,
|
||||
"future_binding": "sensor.secret",
|
||||
},
|
||||
"future_space": {"entity": "sensor.secret"},
|
||||
"openings": [{
|
||||
"id": "window", "type": "window", "x": 0.5, "y": 0,
|
||||
"angle": 0, "length": 0.2, "contact": "binary_sensor.window",
|
||||
"lock": "lock.window", "invert": True, "flip_h": True,
|
||||
"future_opening": "sensor.secret",
|
||||
}],
|
||||
"walls": [{"key": "wall-1", "cm": 20, "future_wall": "sensor.secret"}],
|
||||
"room_drafts": [{
|
||||
"id": "draft", "points": [[0, 0], [0.2, 0]],
|
||||
"segments": [{"cm": 10, "future_segment": "sensor.secret"}],
|
||||
}],
|
||||
"partitions": [{"id": "partition", "a": [0, 0.5], "b": [1, 0.5], "cm": 12}],
|
||||
"wall_columns": [{"id": "column", "shape": "circle", "center": [0.2, 0.2], "cm": 30}],
|
||||
"open_spans": [{"a": [0.4, 0], "b": [0.6, 0], "future_span": "sensor.secret"}],
|
||||
"decor": [
|
||||
{
|
||||
"id": "modern", "kind": "text", "x": 0.2, "y": 0.2,
|
||||
"text": "Temp {sensor.kitchen} / {climate.hall.current_temperature} °C",
|
||||
"future_decor": "sensor.secret",
|
||||
},
|
||||
{
|
||||
"id": "legacy", "kind": "text", "x": 0.3, "y": 0.3,
|
||||
"text": "Tank {} / {}", "entity": "sensor.tank",
|
||||
"attr": "level", "unit": "%",
|
||||
},
|
||||
{
|
||||
"id": "static", "kind": "text", "x": 0.4, "y": 0.4,
|
||||
"text": "Literal {not a reference} and sensor.user_text",
|
||||
},
|
||||
{
|
||||
"id": "furniture", "kind": "furniture", "symbol": "sofa",
|
||||
"x": 0.5, "y": 0.5, "w": 0.2, "h": 0.1, "angle": 10,
|
||||
},
|
||||
],
|
||||
})
|
||||
room = space["rooms"][0]
|
||||
room.update({
|
||||
"area": "living-area",
|
||||
"settings": {
|
||||
"fill_mode": "custom", "custom_fill": {"c": "#123456", "a": 0.4},
|
||||
"temp_source": "sensor.room_temp", "hum_source": "sensor.room_humidity",
|
||||
"future_room_binding": "sensor.secret",
|
||||
},
|
||||
"future_room": "sensor.secret",
|
||||
})
|
||||
config["markers"].append({
|
||||
"id": "note", "binding": "virtual", "space": "ground",
|
||||
"room_id": "living", "name": "Boiler", "icon": "mdi:fire",
|
||||
})
|
||||
layout = {
|
||||
"lamp": {"x": 0.4, "y": 0.5, "s": "ground"},
|
||||
"note": {"x": 0.2, "y": 0.3, "s": "ground"},
|
||||
"lg_light.group": {"x": 0.1, "y": 0.1, "s": "ground"},
|
||||
"auto-device": {"x": 0.8, "y": 0.8, "s": "ground"},
|
||||
"rl_living": {"x": 0.45, "y": 0.55, "s": "ground", "future": "drop"},
|
||||
"rl_other": {"x": 0.1, "y": 0.1, "s": "other"},
|
||||
}
|
||||
return config, layout
|
||||
|
||||
|
||||
def test_plan_only_export_projects_geometry_and_round_trips_room_labels(tmp_path: Path) -> None:
|
||||
config, layout = _plan_only_source()
|
||||
runtime = SimpleNamespace(instance_id="instance-a", import_previews={})
|
||||
document, _ = create_export(
|
||||
runtime, {"config": config}, {"layout": layout}, kind="space",
|
||||
space_id="ground", plan_only=True, card_version="review", config_root=tmp_path,
|
||||
)
|
||||
payload = document["payload"]
|
||||
exported = payload["config"]["spaces"][0]
|
||||
assert document["transfer"] == {"dropped_marker_links": 0, "plan_only": True}
|
||||
assert payload["config"]["markers"] == []
|
||||
assert payload["layout"] == {
|
||||
"rl_living": {"x": 0.45, "y": 0.55, "s": "ground"},
|
||||
}
|
||||
assert document["placement_manifest"] == [{
|
||||
"layout_id": "rl_living", "space_id": "ground", "owner": "room_label",
|
||||
"owner_id": "living", "binding": None, "label": None, "icon": None,
|
||||
}]
|
||||
assert document["content_manifest"][0]["url"] == "https://example.invalid/floor.svg"
|
||||
assert all(item.get("owner") != "marker" for item in document["content_manifest"])
|
||||
|
||||
assert "future_space" not in exported
|
||||
assert exported["settings"] == {
|
||||
"show_names": True, "north_deg": 90, "sun_rays": True, "bg_mode": "static",
|
||||
}
|
||||
room = exported["rooms"][0]
|
||||
assert "area" not in room and "future_room" not in room
|
||||
assert room["settings"] == {
|
||||
"fill_mode": "custom", "custom_fill": {"c": "#123456", "a": 0.4},
|
||||
}
|
||||
opening = exported["openings"][0]
|
||||
assert opening["flip_h"] is True
|
||||
assert not {"contact", "lock", "invert", "future_opening"} & set(opening)
|
||||
by_id = {item["id"]: item for item in exported["decor"]}
|
||||
assert by_id["modern"]["text"] == "Temp — / — °C"
|
||||
assert by_id["legacy"]["text"] == "Tank — / —"
|
||||
assert not {"entity", "attr", "unit"} & set(by_id["legacy"])
|
||||
assert by_id["static"]["text"] == "Literal {not a reference} and sensor.user_text"
|
||||
assert by_id["furniture"]["symbol"] == "sofa"
|
||||
|
||||
parsed = parse_document(json.dumps(document).encode())
|
||||
preview = create_preview(
|
||||
runtime, json.dumps(document).encode(), owner_id="alice", duplicate_policy="skip",
|
||||
current_config_data={"config": {"spaces": [], "markers": []}, "rev": 0},
|
||||
current_layout_data={"layout": {}, "rev": 0}, config_root=tmp_path,
|
||||
registry_snapshot={"areas": set()},
|
||||
)
|
||||
assert preview["preview"]["plan_only"] is True
|
||||
assert preview["preview"]["counts"]["markers"] == 0
|
||||
assert preview["preview"]["counts"]["layout"] == 1
|
||||
assert preview["preview"]["duplicates"] == 0
|
||||
assert preview["preview"]["missing_areas"] == []
|
||||
assert preview["preview"]["bindings"] == {
|
||||
"device": 0, "entity": 0, "virtual": 0,
|
||||
"active": 0, "disabled": 0, "missing": 0,
|
||||
}
|
||||
candidate = get_candidate(runtime, preview["token"], "alice")
|
||||
refreshed = revalidate_candidate(
|
||||
candidate, {"config": {"spaces": [], "markers": []}, "rev": 2},
|
||||
{"layout": {}, "rev": 3}, duplicate_policy="skip", config_root=tmp_path,
|
||||
)
|
||||
assert refreshed["preview"]["plan_only"] is True
|
||||
merged, merged_layout, details = prepare_apply(
|
||||
candidate, {"spaces": [], "markers": []}, {}, confirm_missing_content=False,
|
||||
)
|
||||
assert merged["markers"] == []
|
||||
imported_room = merged["spaces"][0]["rooms"][0]
|
||||
assert imported_room.get("area") is None
|
||||
assert merged_layout == {
|
||||
"rl_" + imported_room["id"]: {
|
||||
"x": 0.45, "y": 0.55, "s": details["space_id"],
|
||||
},
|
||||
}
|
||||
assert parsed["transfer"]["plan_only"] is True
|
||||
|
||||
|
||||
def test_ordinary_space_export_is_unchanged_when_plan_only_is_false(tmp_path: Path) -> None:
|
||||
config, layout = _plan_only_source()
|
||||
runtime = SimpleNamespace(instance_id="instance-a")
|
||||
implicit, _implicit_name = create_export(
|
||||
runtime, {"config": config}, {"layout": layout}, kind="space",
|
||||
space_id="ground", card_version="review", config_root=tmp_path,
|
||||
)
|
||||
explicit, _explicit_name = create_export(
|
||||
runtime, {"config": config}, {"layout": layout}, kind="space",
|
||||
space_id="ground", plan_only=False, card_version="review", config_root=tmp_path,
|
||||
)
|
||||
for value in (implicit, explicit):
|
||||
value.pop("created_at")
|
||||
assert implicit == explicit
|
||||
assert "plan_only" not in implicit["transfer"]
|
||||
assert implicit["payload"]["config"]["markers"]
|
||||
assert set(implicit["payload"]["layout"]) == set(layout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation",
|
||||
[
|
||||
"marker", "layout", "area", "temperature", "opening", "legacy_decor",
|
||||
"live_token", "unknown", "placement", "marker_content", "invalid_content",
|
||||
],
|
||||
)
|
||||
def test_parser_rejects_forged_plan_only_privacy_claim(
|
||||
tmp_path: Path, mutation: str,
|
||||
) -> None:
|
||||
config, layout = _plan_only_source()
|
||||
document, _ = create_export(
|
||||
SimpleNamespace(instance_id="instance-a"), {"config": config}, {"layout": layout},
|
||||
kind="space", space_id="ground", plan_only=True,
|
||||
card_version="review", config_root=tmp_path,
|
||||
)
|
||||
space = document["payload"]["config"]["spaces"][0]
|
||||
if mutation == "marker":
|
||||
document["payload"]["config"]["markers"].append({
|
||||
"id": "forged", "binding": "virtual", "name": "Forged",
|
||||
})
|
||||
elif mutation == "layout":
|
||||
document["payload"]["layout"]["sensor.secret"] = {
|
||||
"x": 0.1, "y": 0.2, "s": "ground",
|
||||
}
|
||||
document["placement_manifest"].append({
|
||||
"layout_id": "sensor.secret", "space_id": "ground",
|
||||
"owner": "auto_device", "owner_id": "sensor.secret",
|
||||
"binding": "device:sensor.secret", "label": None, "icon": None,
|
||||
})
|
||||
elif mutation == "area":
|
||||
space["rooms"][0]["area"] = "secret-area"
|
||||
elif mutation == "temperature":
|
||||
space["rooms"][0].setdefault("settings", {})["temp_source"] = "sensor.secret"
|
||||
elif mutation == "opening":
|
||||
space["openings"][0]["contact"] = "binary_sensor.secret"
|
||||
elif mutation == "legacy_decor":
|
||||
space["decor"][0]["entity"] = "sensor.secret"
|
||||
elif mutation == "live_token":
|
||||
space["decor"][0]["text"] = "Leaked {sensor.secret}"
|
||||
elif mutation == "unknown":
|
||||
space["future_binding"] = "sensor.secret"
|
||||
elif mutation == "placement":
|
||||
document["placement_manifest"][0]["owner"] = "auto_device"
|
||||
elif mutation == "marker_content":
|
||||
document["content_manifest"].append({"owner": "marker"})
|
||||
elif mutation == "invalid_content":
|
||||
document["content_manifest"].append("sensor.secret")
|
||||
with pytest.raises(ImportFailure) as invalid:
|
||||
parse_document(json.dumps(document).encode())
|
||||
assert invalid.value.code == "invalid_format"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [1, "true", None, {}, []])
|
||||
def test_parser_requires_strict_plan_only_boolean(tmp_path: Path, value: Any) -> None:
|
||||
document = _document(tmp_path, "space")
|
||||
document["transfer"]["plan_only"] = value
|
||||
with pytest.raises(ImportFailure) as invalid:
|
||||
parse_document(json.dumps(document).encode())
|
||||
assert invalid.value.code == "invalid_format"
|
||||
|
||||
|
||||
def test_plan_only_cannot_be_requested_for_full_export(tmp_path: Path) -> None:
|
||||
with pytest.raises(ImportFailure) as invalid:
|
||||
create_export(
|
||||
SimpleNamespace(instance_id="instance-a"), {"config": _config()}, {"layout": {}},
|
||||
kind="full", space_id=None, plan_only=True,
|
||||
card_version="review", config_root=tmp_path,
|
||||
)
|
||||
assert invalid.value.code == "invalid_format"
|
||||
|
||||
|
||||
def test_full_export_import_round_trip_restores_model_version(tmp_path: Path) -> None:
|
||||
"""The portable envelope version must return to the persisted config."""
|
||||
document = _document(tmp_path)
|
||||
@@ -830,6 +1073,17 @@ async def test_export_and_revalidate_ws_endpoints_use_server_owned_state(
|
||||
assert document["model_version"] == PLAN_MODEL_VERSION
|
||||
assert "model_version" not in document["payload"]["config"]
|
||||
|
||||
plan_exported = _Connection()
|
||||
await wsapi.ws_export_create.__wrapped__(hass, plan_exported, {
|
||||
"id": 44, "type": "houseplan/export/create", "kind": "space",
|
||||
"space_id": "ground", "plan_only": True, "card_version": "review",
|
||||
})
|
||||
assert plan_exported.error is None and plan_exported.result
|
||||
plan_document = plan_exported.result["document"]
|
||||
assert plan_document["transfer"]["plan_only"] is True
|
||||
assert plan_document["payload"]["config"]["markers"] == []
|
||||
assert plan_document["payload"]["layout"] == {}
|
||||
|
||||
refreshed = _Connection()
|
||||
await wsapi.ws_import_revalidate.__wrapped__(hass, refreshed, {
|
||||
"id": 41, "type": "houseplan/import/revalidate",
|
||||
|
||||
Reference in New Issue
Block a user