diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 673cf6b8..699f874e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1499,3 +1499,27 @@ dialog). need their own rollback. - **Split invariant.** `splitRoomPath` guarantees a partition: the two parts' areas sum to the original (within epsilon) or the cut is rejected. + +## Schema as the source of truth (#33, 2026-08-30) + +The Voluptuous schema in `custom_components/houseplan/validation.py` is the +single owner of the persisted config/layout shape. Three artefacts keep every +other world honest against it: + +- `scripts/dump-config-schema.py` walks the schema into the deterministic + `scripts/config-schema-manifest.json` (265 leaf paths at introduction); + a pytest regenerates it and fails on any uncommitted drift. +- `test/config-schema-parity.test.mjs` compares manifest enums with the + exported frontend const lists (`DISPLAY_MODES`, `TAP_ACTIONS`, + `SPACE_FILL_MODES`/`ROOM_FILL_MODES`, `OPENING_TYPES`, + `VACUUM_TRAIL_MODES`, `ZERO_WALL_STYLES`, `BG_MODES`). Every divergence + must be blessed in `scripts/schema-compat-allowlist.mjs` with a reason and + an owning issue — and an allow-list entry that stops matching a real + divergence fails the test too, so the list cannot rot. +- `scripts/config-field-registry.mjs` stays the DECISION layer on top of the + manifest: only fields with a non-trivial fate live there, each resolving to + a manifest path or carrying an explicit `schema: 'allow-extra'` / + `'lovelace-card'` passport; implemented mechanisms cite their code point in + `enforcedBy`. The lifecycle fixtures in `test/fixtures/config-lifecycle/` + pin the load contract: oldest-supported and future-field configs pass the + schema losslessly. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7b8548de..f25e9f3d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,13 @@ ## Unreleased +- Old and future plan configs are now guarded by machinery instead of habit: + the backend schema is dumped into a committed manifest, frontend and + backend value lists are compared in CI with an explicit allow-list for the + few legitimate compatibility divergences, and lifecycle fixtures prove a + config from the oldest supported era — or one from the future — loads + without silent loss ([#33](https://github.com/Matysh/houseplan-card/issues/33)). + - Clicking the already-selected binding in the marker dialog no longer silently resets the configured value source and badge to automatic — the reset now happens only on an actual change of binding diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 851d270c..02d38e80 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,13 @@ ## Не выпущено +- Старые и будущие конфигурации плана теперь защищены механикой, а не + привычкой: схема бэкенда выгружается в закоммиченный манифест, списки + значений фронта и бэкенда сверяются в CI с явным allow-list'ом немногих + легитимных компат-расхождений, а lifecycle-фикстуры доказывают, что конфиг + старейшей поддерживаемой эпохи — или из будущего — загружается без тихих + потерь ([#33](https://github.com/Matysh/houseplan-card/issues/33)). + - Клик по уже выбранной привязке в диалоге маркера больше не сбрасывает молча настроенный источник значения и бейдж на «авто» — сброс происходит только при реальной смене привязки diff --git a/scripts/config-audit.mjs b/scripts/config-audit.mjs index 52c98667..25529747 100644 --- a/scripts/config-audit.mjs +++ b/scripts/config-audit.mjs @@ -77,6 +77,16 @@ if (isMain) { const parsed = JSON.parse(readFileSync(absolute, 'utf8')); return { file: absolute, findings: auditConfig(parsed) }; }); + // #33: exit-code contract — 0 = clean, 3 = migration available + // (any finding whose status names a pending mechanism), 2 = invalid + // input (unchanged). 3 is chosen to avoid colliding with 2. + const MIGRATION_STATUSES = new Set([ + 'migrate-on-write', 'migrate-on-settings-save', 'deprecated-read', + 'drop-on-validation', 'decision-required', + ]); + const migrationFindings = reports.some((report) => report.findings + .some((finding) => MIGRATION_STATUSES.has(finding.status))); + if (migrationFindings) process.exitCode = 3; if (jsonOutput) { console.log(JSON.stringify(reports, null, 2)); } else { diff --git a/scripts/config-field-registry.mjs b/scripts/config-field-registry.mjs index 447d1acd..5f38f7fb 100644 --- a/scripts/config-field-registry.mjs +++ b/scripts/config-field-registry.mjs @@ -9,6 +9,7 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([ { id: 'card.tap_action', + schema: 'lovelace-card', // #33: Lovelace card option — not in the backend manifest by design selector: { path: ['tap_action'] }, storage: 'Lovelace card config', type: "'info' | 'more-info' | 'toggle'", @@ -23,6 +24,8 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([ }, { id: 'settings.show_all', + schema: 'allow-extra', // #33: lives only through extra=ALLOW_EXTRA, absent from CONFIG_SCHEMA + enforcedBy: 'houseplan-card.ts materialisation: seeds hidden markers, then `delete st.show_all`', selector: { path: ['settings', 'show_all'] }, storage: 'House Plan server config', type: 'boolean', @@ -37,6 +40,7 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([ }, { id: 'settings.group_lights', + schema: 'allow-extra', // #33: lives only through extra=ALLOW_EXTRA, absent from CONFIG_SCHEMA selector: { path: ['settings', 'group_lights'] }, storage: 'House Plan server config', type: 'boolean', @@ -51,6 +55,7 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([ }, { id: 'settings.exclude_integrations', + schema: 'allow-extra', // #33: lives only through extra=ALLOW_EXTRA, absent from CONFIG_SCHEMA selector: { path: ['settings', 'exclude_integrations'] }, storage: 'House Plan server config', type: 'string[]', @@ -65,6 +70,7 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([ }, { id: 'settings.weather_entity', + enforcedBy: 'houseplan-editor-runtime.ts _saveSettingsDialog: `delete settings.weather_entity` on every save', selector: { path: ['settings', 'weather_entity'] }, storage: 'House Plan server config', type: 'string | null', @@ -79,6 +85,7 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([ }, { id: 'markers[].display=ripple', + enforcedBy: 'logic.ts normalizeDeviceDisplay (read) + editor-runtime _dropLegacySegments rewrites on every write', selector: { path: ['markers', '*', 'display'], equals: 'ripple' }, storage: 'House Plan server config', type: "legacy enum value 'ripple'", @@ -135,6 +142,7 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([ }, { id: 'spaces[].aspect', + enforcedBy: 'validation.py vol.Remove — the backend drops the field during validation', selector: { path: ['spaces', '*', 'aspect'] }, storage: 'House Plan server config', type: 'number', @@ -149,6 +157,7 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([ }, { id: 'spaces[].segments', + enforcedBy: 'validation.py vol.Remove — the backend drops the field during validation', selector: { path: ['spaces', '*', 'segments'] }, storage: 'House Plan server config', type: 'unknown[]', @@ -357,6 +366,48 @@ export const CONFIG_FIELD_REGISTRY = Object.freeze([ migration: 'embed the reference into text on text edit or explicit plan optimization', compatibility: 'backend accepts bounded values while old labels remain supported', })), + { + id: 'settings.decor_default_style', + selector: { path: ['settings', 'decor_default_style'] }, + storage: 'House Plan server config', + type: '{ color?, opacity?, width_cm?, fill?, fill_color?, fill_opacity? }', + default: 'unset (absence IS the factory default)', + level: 'global', + ui: 'Background editor main-toolbar picker', + runtime: 'seeds the editor default decor style once per page (#377)', + introduced: 'v1.69.0', + status: 'current', + migration: 'none — reset to default removes the key', + compatibility: 'optional; partial keys inherit the factory default per field', + }, + { + id: "spaces[].decor[]", + selector: { path: ['spaces', '*', 'decor', '*', 'kind'], equals: 'furniture' }, + storage: 'House Plan server config', + type: "decor item variant: symbol + box (x, y, w, h, angle)", + default: 'n/a', + level: 'space', + ui: 'Background editor furniture tool (#159/#359)', + runtime: 'plan-art rendering, placement preview, stroke zoom (#361)', + introduced: 'v1.69.0', + status: 'current', + migration: 'none', + compatibility: 'unknown symbols render as generic box; assets ship with the bundle', + }, + { + id: "spaces[].openings[].host=partition", + selector: { path: ['spaces', '*', 'openings', '*', 'host', 'kind'], equals: 'partition' }, + storage: 'House Plan server config', + type: "{ kind: 'partition', id, t }", + default: 'n/a', + level: 'space', + ui: 'Plan editor: openings hosted on partitions (model v9, v1.68)', + runtime: 'opening geometry, junction limits, glow cuts', + introduced: 'v1.68.0', + status: 'current', + migration: 'v9 migration resolves opening-vs-zero-wall conflicts (#316)', + compatibility: 'v8 configs without partition hosts stay valid', + }, ]); export const CONFIG_FIELD_STATUSES = Object.freeze([ diff --git a/scripts/config-schema-manifest.json b/scripts/config-schema-manifest.json new file mode 100644 index 00000000..b49a8b15 --- /dev/null +++ b/scripts/config-schema-manifest.json @@ -0,0 +1,1371 @@ +{ + "_comment": "#33: generated by scripts/dump-config-schema.py — do not edit by hand", + "fields": { + "config": { + "allowExtra": true + }, + "config.markers": { + "default": { + "factory": "list" + }, + "required": false + }, + "config.markers[]": { + "allowExtra": true + }, + "config.markers[].angle": { + "max": 360, + "min": -360, + "nullable": true, + "required": false, + "type": "float" + }, + "config.markers[].area": { + "nullable": true, + "required": false, + "type": "str" + }, + "config.markers[].binding": { + "maxLength": 500, + "minLength": 1, + "opaque": "Match('^(device:.+|entity:.+|virtual)$', msg=None)", + "required": true, + "type": "str" + }, + "config.markers[].controls": { + "list": { + "maxLength": 500, + "type": "str" + }, + "maxLength": 200, + "nullable": true, + "required": false + }, + "config.markers[].description": { + "maxLength": 4000, + "nullable": true, + "required": false, + "type": "str" + }, + "config.markers[].display": { + "required": false, + "variants": [ + { + "const": "badge" + }, + { + "const": "ripple" + }, + { + "const": "icon_ripple" + }, + { + "const": "value" + }, + { + "const": "static_icon" + }, + { + "type": "null" + } + ] + }, + "config.markers[].glow_color": { + "nullable": true, + "required": false + }, + "config.markers[].glow_color.bri": { + "max": 1.0, + "min": 0.01, + "nullable": true, + "opaque": "", + "required": false + }, + "config.markers[].glow_color.c": { + "opaque": "Match('\\\\A#[0-9a-fA-F]{6}\\\\Z', msg=None)", + "required": true + }, + "config.markers[].glow_radius_cm": { + "max": 10000, + "min": 10, + "nullable": true, + "required": false, + "type": "float" + }, + "config.markers[].hidden": { + "required": false, + "type": "bool" + }, + "config.markers[].icon": { + "maxLength": 500, + "nullable": true, + "required": false, + "type": "str" + }, + "config.markers[].id": { + "required": true, + "type": "str" + }, + "config.markers[].is_light": { + "nullable": true, + "required": false, + "type": "bool" + }, + "config.markers[].light_entity": { + "required": false, + "type": "object" + }, + "config.markers[].link": { + "maxLength": 2000, + "nullable": true, + "required": false, + "type": "str" + }, + "config.markers[].model": { + "maxLength": 500, + "nullable": true, + "required": false, + "type": "str" + }, + "config.markers[].name": { + "maxLength": 500, + "nullable": true, + "required": false, + "type": "str" + }, + "config.markers[].pdfs": { + "required": false + }, + "config.markers[].pdfs[]": { + "allowExtra": true + }, + "config.markers[].pdfs[].name": { + "maxLength": 500, + "required": true, + "type": "str" + }, + "config.markers[].pdfs[].url": { + "maxLength": 2000, + "required": true, + "type": "str" + }, + "config.markers[].removed": { + "required": false, + "type": "bool" + }, + "config.markers[].ripple_color": { + "nullable": true, + "opaque": "Match('\\\\A#[0-9a-fA-F]{6}\\\\Z', msg=None)", + "required": false + }, + "config.markers[].ripple_size": { + "max": 20, + "min": 1, + "nullable": true, + "required": false, + "type": "float" + }, + "config.markers[].room_id": { + "nullable": true, + "required": false, + "type": "str" + }, + "config.markers[].size": { + "max": 6, + "min": 0.2, + "nullable": true, + "required": false, + "type": "float" + }, + "config.markers[].space": { + "nullable": true, + "required": false, + "type": "str" + }, + "config.markers[].tap_action": { + "required": false, + "variants": [ + { + "const": "info" + }, + { + "const": "more-info" + }, + { + "const": "toggle" + }, + { + "const": "run" + }, + { + "const": "none" + }, + { + "const": "cover" + }, + { + "type": "null" + } + ] + }, + "config.markers[].tap_confirm": { + "nullable": true, + "required": false, + "type": "bool" + }, + "config.markers[].tap_target": { + "maxLength": 500, + "nullable": true, + "opaque": "Match('^(automation|script|scene)\\\\.[A-Za-z0-9_]+$', msg=None)", + "required": false, + "type": "str" + }, + "config.markers[].toggle_entity": { + "required": false, + "type": "object" + }, + "config.markers[].use_climate_temp": { + "nullable": true, + "required": false, + "type": "bool" + }, + "config.markers[].vacuum": { + "nullable": true, + "required": false + }, + "config.markers[].vacuum.calibration": { + "required": false + }, + "config.markers[].vacuum.calibration.*[]": { + "opaque": "" + }, + "config.markers[].vacuum.live": { + "nullable": true, + "required": false, + "type": "bool" + }, + "config.markers[].vacuum.room_highlight": { + "nullable": true, + "required": false, + "type": "bool" + }, + "config.markers[].vacuum.segment_map": { + "required": false + }, + "config.markers[].vacuum.segment_map.*": { + "type": "str" + }, + "config.markers[].vacuum.source": { + "nullable": true, + "required": false, + "type": "str" + }, + "config.markers[].vacuum.trail": { + "nullable": true, + "required": false, + "type": "bool" + }, + "config.markers[].vacuum.trail_mode": { + "enum": [ + "always", + "cleaning", + "never" + ], + "nullable": true, + "required": false + }, + "config.markers[].value_badge": { + "allowExtra": true, + "nullable": true, + "required": false + }, + "config.markers[].value_badge.enabled": { + "required": false, + "type": "object" + }, + "config.markers[].value_badge.position": { + "required": false, + "type": "object" + }, + "config.markers[].value_badge.source": { + "allowExtra": true, + "nullable": true, + "required": false + }, + "config.markers[].value_source": { + "allowExtra": true, + "nullable": true, + "required": false + }, + "config.model_version": { + "max": 1000000, + "min": 0, + "required": false, + "type": "int" + }, + "config.settings": { + "allowExtra": true, + "default": { + "factory": "dict" + }, + "required": false + }, + "config.settings.bg_color": { + "opaque": "Match('\\\\A#[0-9a-fA-F]{6}\\\\Z', msg=None)", + "required": false + }, + "config.settings.bg_mode": { + "enum": [ + "daynight", + "static" + ], + "required": false + }, + "config.settings.decor_default_style": { + "required": false + }, + "config.settings.decor_default_style.color": { + "opaque": "Match('\\\\A#[0-9a-fA-F]{6}\\\\Z', msg=None)", + "required": false + }, + "config.settings.decor_default_style.fill": { + "required": false, + "type": "bool" + }, + "config.settings.decor_default_style.fill_color": { + "opaque": "Match('\\\\A#[0-9a-fA-F]{6}\\\\Z', msg=None)", + "required": false + }, + "config.settings.decor_default_style.fill_opacity": { + "max": 1, + "min": 0, + "required": false, + "type": "float" + }, + "config.settings.decor_default_style.opacity": { + "max": 1, + "min": 0, + "required": false, + "type": "float" + }, + "config.settings.decor_default_style.width_cm": { + "max": 100, + "min": 0.1, + "required": false, + "type": "float" + }, + "config.settings.fill_colors": { + "required": false + }, + "config.settings.fill_colors.*.a": { + "max": 1, + "min": 0, + "required": true, + "type": "float" + }, + "config.settings.fill_colors.*.c": { + "opaque": "Match('\\\\A#[0-9a-fA-F]{6}\\\\Z', msg=None)", + "required": true + }, + "config.settings.glow_radius_cm": { + "max": 10000, + "min": 10, + "required": false, + "type": "float" + }, + "config.settings.known_devices": { + "required": false + }, + "config.settings.known_devices[]": { + "maxLength": 500, + "type": "str" + }, + "config.settings.new_device_ids": { + "required": false + }, + "config.settings.new_device_ids[]": { + "maxLength": 500, + "type": "str" + }, + "config.settings.north_deg": { + "opaque": "", + "required": false + }, + "config.settings.sun_rays": { + "required": false, + "type": "bool" + }, + "config.settings.weather_entity": { + "maxLength": 500, + "nullable": true, + "required": false, + "type": "str" + }, + "config.spaces": { + "required": true + }, + "config.spaces[]": { + "allowExtra": true + }, + "config.spaces[].aspect": { + "removed": true + }, + "config.spaces[].cell_cm": { + "max": 1000.0, + "min": 0.1, + "opaque": "", + "required": false + }, + "config.spaces[].decor": { + "required": false + }, + "config.spaces[].decor[]": { + "allowExtra": true + }, + "config.spaces[].decor[].angle": { + "max": 360.0, + "min": -360.0, + "opaque": "", + "required": false + }, + "config.spaces[].decor[].color": { + "opaque": "Match('\\\\A#[0-9a-fA-F]{6}\\\\Z', msg=None)", + "required": false + }, + "config.spaces[].decor[].fill": { + "required": false, + "type": "bool" + }, + "config.spaces[].decor[].fill_color": { + "opaque": "Match('\\\\A#[0-9a-fA-F]{6}\\\\Z', msg=None)", + "required": false + }, + "config.spaces[].decor[].fill_opacity": { + "max": 1.0, + "min": 0.0, + "opaque": "", + "required": false + }, + "config.spaces[].decor[].h": { + "max": 5000.0, + "min": 0.001, + "opaque": "", + "required": true + }, + "config.spaces[].decor[].id": { + "required": true, + "type": "str" + }, + "config.spaces[].decor[].kind": { + "enum": [ + "ellipse", + "rect" + ], + "required": true + }, + "config.spaces[].decor[].opacity": { + "max": 1.0, + "min": 0.0, + "opaque": "", + "required": false + }, + "config.spaces[].decor[].w": { + "max": 5000.0, + "min": 0.001, + "opaque": "", + "required": true + }, + "config.spaces[].decor[].width": { + "max": 30, + "min": 0.1, + "required": false, + "type": "float" + }, + "config.spaces[].decor[].width_cm": { + "max": 100, + "min": 0.1, + "opaque": "", + "required": false + }, + "config.spaces[].decor[].x": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + }, + "config.spaces[].decor[].y": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + }, + "config.spaces[].decor[]": { + "allowExtra": true + }, + "config.spaces[].decor[].angle": { + "max": 360.0, + "min": -360.0, + "opaque": "", + "required": false + }, + "config.spaces[].decor[].color": { + "opaque": "Match('\\\\A#[0-9a-fA-F]{6}\\\\Z', msg=None)", + "required": false + }, + "config.spaces[].decor[].flip_h": { + "required": false, + "type": "bool" + }, + "config.spaces[].decor[].flip_v": { + "required": false, + "type": "bool" + }, + "config.spaces[].decor[].h": { + "max": 5000.0, + "min": 1e-07, + "opaque": "", + "required": true + }, + "config.spaces[].decor[].id": { + "required": true, + "type": "str" + }, + "config.spaces[].decor[].kind": { + "const": "furniture", + "required": true + }, + "config.spaces[].decor[].opacity": { + "max": 1.0, + "min": 0.0, + "opaque": "", + "required": false + }, + "config.spaces[].decor[].symbol": { + "maxLength": 32, + "minLength": 1, + "opaque": "Match('^[a-z0-9_]+$', msg=None)", + "required": true, + "type": "str" + }, + "config.spaces[].decor[].w": { + "max": 5000.0, + "min": 1e-07, + "opaque": "", + "required": true + }, + "config.spaces[].decor[].width": { + "max": 30, + "min": 0.1, + "required": false, + "type": "float" + }, + "config.spaces[].decor[].width_cm": { + "max": 100, + "min": 0.1, + "opaque": "", + "required": false + }, + "config.spaces[].decor[].x": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + }, + "config.spaces[].decor[].y": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + }, + "config.spaces[].decor[]": { + "allowExtra": true + }, + "config.spaces[].decor[].color": { + "opaque": "Match('\\\\A#[0-9a-fA-F]{6}\\\\Z', msg=None)", + "required": false + }, + "config.spaces[].decor[].id": { + "required": true, + "type": "str" + }, + "config.spaces[].decor[].kind": { + "const": "line", + "required": true + }, + "config.spaces[].decor[].line_style": { + "enum": [ + "dashed", + "solid" + ], + "required": false + }, + "config.spaces[].decor[].opacity": { + "max": 1.0, + "min": 0.0, + "opaque": "", + "required": false + }, + "config.spaces[].decor[].width": { + "max": 30, + "min": 0.1, + "required": false, + "type": "float" + }, + "config.spaces[].decor[].width_cm": { + "max": 100, + "min": 0.1, + "opaque": "", + "required": false + }, + "config.spaces[].decor[].x1": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + }, + "config.spaces[].decor[].x2": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + }, + "config.spaces[].decor[].y1": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + }, + "config.spaces[].decor[].y2": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + }, + "config.spaces[].decor[]": { + "allowExtra": true + }, + "config.spaces[].decor[].angle": { + "max": 360.0, + "min": -360.0, + "opaque": "", + "required": false + }, + "config.spaces[].decor[].attr": { + "maxLength": 64, + "nullable": true, + "required": false, + "type": "str" + }, + "config.spaces[].decor[].color": { + "opaque": "Match('\\\\A#[0-9a-fA-F]{6}\\\\Z', msg=None)", + "required": false + }, + "config.spaces[].decor[].entity": { + "maxLength": 255, + "minLength": 3, + "nullable": true, + "opaque": "Match('^[a-z0-9_]+\\\\.[a-z0-9_]+$', msg=None)", + "required": false, + "type": "str" + }, + "config.spaces[].decor[].id": { + "required": true, + "type": "str" + }, + "config.spaces[].decor[].kind": { + "const": "text", + "required": true + }, + "config.spaces[].decor[].opacity": { + "max": 1.0, + "min": 0.0, + "opaque": "", + "required": false + }, + "config.spaces[].decor[].scale": { + "max": 20.0, + "min": 0.15, + "opaque": "", + "required": false + }, + "config.spaces[].decor[].size": { + "enum": [ + "l", + "m", + "s" + ], + "required": false + }, + "config.spaces[].decor[].size_cm": { + "max": 2000.0, + "min": 0.1, + "opaque": "", + "required": false + }, + "config.spaces[].decor[].text": { + "maxLength": 200, + "minLength": 1, + "required": true, + "type": "str" + }, + "config.spaces[].decor[].unit": { + "maxLength": 16, + "nullable": true, + "required": false, + "type": "str" + }, + "config.spaces[].decor[].width": { + "max": 30, + "min": 0.1, + "required": false, + "type": "float" + }, + "config.spaces[].decor[].width_cm": { + "max": 100, + "min": 0.1, + "opaque": "", + "required": false + }, + "config.spaces[].decor[].x": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + }, + "config.spaces[].decor[].y": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + }, + "config.spaces[].id": { + "opaque": "Match('^[a-z0-9_-]{1,64}$', msg=None)", + "required": true, + "type": "str" + }, + "config.spaces[].open_spans": { + "required": false + }, + "config.spaces[].open_spans[]": { + "opaque": "" + }, + "config.spaces[].openings": { + "required": false + }, + "config.spaces[].openings[]": { + "allowExtra": true + }, + "config.spaces[].openings[].angle": { + "max": 360.0, + "min": -360.0, + "opaque": "", + "required": true + }, + "config.spaces[].openings[].contact": { + "nullable": true, + "required": false, + "type": "str" + }, + "config.spaces[].openings[].flip_h": { + "required": false, + "type": "bool" + }, + "config.spaces[].openings[].flip_v": { + "required": false, + "type": "bool" + }, + "config.spaces[].openings[].host": { + "required": false + }, + "config.spaces[].openings[].host.id": { + "maxLength": 64, + "minLength": 1, + "required": true, + "type": "str" + }, + "config.spaces[].openings[].host.kind": { + "const": "partition", + "required": true + }, + "config.spaces[].openings[].host.t": { + "max": 1, + "min": 0, + "opaque": "", + "required": true + }, + "config.spaces[].openings[].host.id": { + "maxLength": 64, + "minLength": 1, + "required": true, + "type": "str" + }, + "config.spaces[].openings[].host.kind": { + "const": "wall", + "required": true + }, + "config.spaces[].openings[].host.t": { + "max": 1, + "min": 0, + "opaque": "", + "required": true + }, + "config.spaces[].openings[].id": { + "required": true, + "type": "str" + }, + "config.spaces[].openings[].invert": { + "required": false, + "type": "bool" + }, + "config.spaces[].openings[].length": { + "max": 5000.0, + "min": 0.001, + "opaque": "", + "required": true + }, + "config.spaces[].openings[].lock": { + "nullable": true, + "required": false, + "type": "str" + }, + "config.spaces[].openings[].type": { + "required": true, + "variants": [ + { + "const": "door" + }, + { + "const": "window" + }, + { + "const": "gate" + }, + { + "const": "passage" + } + ] + }, + "config.spaces[].openings[].x": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + }, + "config.spaces[].openings[].y": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + }, + "config.spaces[].partitions": { + "required": false + }, + "config.spaces[].partitions[]": { + "allowExtra": true + }, + "config.spaces[].partitions[].a": { + "required": true + }, + "config.spaces[].partitions[].a[]": { + "max": 5000.0, + "min": -5000.0, + "opaque": "" + }, + "config.spaces[].partitions[].b": { + "required": true + }, + "config.spaces[].partitions[].b[]": { + "max": 5000.0, + "min": -5000.0, + "opaque": "" + }, + "config.spaces[].partitions[].cm": { + "max": 100, + "min": 0, + "opaque": "", + "required": true + }, + "config.spaces[].partitions[].id": { + "maxLength": 64, + "minLength": 1, + "required": true, + "type": "str" + }, + "config.spaces[].plan_angle": { + "max": 360.0, + "min": -360.0, + "nullable": true, + "opaque": "", + "required": false + }, + "config.spaces[].plan_aspect": { + "max": 20, + "min": 0.05, + "nullable": true, + "required": false, + "type": "float" + }, + "config.spaces[].plan_scale": { + "max": 100.0, + "min": 0.01, + "nullable": true, + "opaque": "", + "required": false + }, + "config.spaces[].plan_scale_x": { + "max": 100.0, + "min": 0.01, + "nullable": true, + "opaque": "", + "required": false + }, + "config.spaces[].plan_scale_y": { + "max": 100.0, + "min": 0.01, + "nullable": true, + "opaque": "", + "required": false + }, + "config.spaces[].plan_url": { + "nullable": true, + "required": false, + "type": "str" + }, + "config.spaces[].plan_x": { + "max": 5000.0, + "min": -5000.0, + "nullable": true, + "opaque": "", + "required": false + }, + "config.spaces[].plan_y": { + "max": 5000.0, + "min": -5000.0, + "nullable": true, + "opaque": "", + "required": false + }, + "config.spaces[].room_drafts": { + "required": false + }, + "config.spaces[].room_drafts[]": { + "allowExtra": true + }, + "config.spaces[].room_drafts[].id": { + "maxLength": 64, + "minLength": 1, + "required": true, + "type": "str" + }, + "config.spaces[].room_drafts[].points": { + "required": true + }, + "config.spaces[].room_drafts[].points[][]": { + "max": 5000.0, + "min": -5000.0, + "opaque": "" + }, + "config.spaces[].room_drafts[].segments": { + "required": true + }, + "config.spaces[].room_drafts[].segments[]": { + "allowExtra": true + }, + "config.spaces[].room_drafts[].segments[].cm": { + "max": 100, + "min": 0, + "opaque": "", + "required": true + }, + "config.spaces[].room_drafts[].segments[].id": { + "maxLength": 64, + "minLength": 1, + "required": false, + "type": "str" + }, + "config.spaces[].rooms": { + "required": true + }, + "config.spaces[].rooms[]": { + "allowExtra": true + }, + "config.spaces[].rooms[].area": { + "maxLength": 500, + "nullable": true, + "required": false, + "type": "str" + }, + "config.spaces[].rooms[].h": { + "max": 5000.0, + "min": 0.001, + "opaque": "", + "required": false + }, + "config.spaces[].rooms[].id": { + "maxLength": 500, + "required": true, + "type": "str" + }, + "config.spaces[].rooms[].name": { + "maxLength": 500, + "required": true, + "type": "str" + }, + "config.spaces[].rooms[].open_to": { + "required": false + }, + "config.spaces[].rooms[].open_to[]": { + "maxLength": 500, + "type": "str" + }, + "config.spaces[].rooms[].poly": { + "required": false + }, + "config.spaces[].rooms[].poly[][]": { + "max": 5000.0, + "min": -5000.0, + "opaque": "" + }, + "config.spaces[].rooms[].settings": { + "allowExtra": true, + "nullable": true, + "required": false + }, + "config.spaces[].rooms[].settings.custom_fill": { + "nullable": true, + "required": false + }, + "config.spaces[].rooms[].settings.custom_fill.a": { + "max": 1.0, + "min": 0.0, + "opaque": "", + "required": true + }, + "config.spaces[].rooms[].settings.custom_fill.c": { + "opaque": "Match('\\\\A#[0-9a-fA-F]{6}\\\\Z', msg=None)", + "required": true + }, + "config.spaces[].rooms[].settings.fill_mode": { + "enum": [ + "custom", + "glow", + "light", + "lqi", + "none", + "temp" + ], + "nullable": true, + "required": false + }, + "config.spaces[].rooms[].settings.glow": { + "nullable": true, + "required": false, + "type": "bool" + }, + "config.spaces[].rooms[].settings.hum_source": { + "nullable": true, + "required": false, + "type": "str" + }, + "config.spaces[].rooms[].settings.label_scale": { + "max": 3, + "min": 0.5, + "nullable": true, + "required": false, + "type": "float" + }, + "config.spaces[].rooms[].settings.name_scale": { + "max": 3, + "min": 0.5, + "nullable": true, + "required": false, + "type": "float" + }, + "config.spaces[].rooms[].settings.temp_source": { + "nullable": true, + "required": false, + "type": "str" + }, + "config.spaces[].rooms[].w": { + "max": 5000.0, + "min": 0.001, + "opaque": "", + "required": false + }, + "config.spaces[].rooms[].wall_ids": { + "required": false + }, + "config.spaces[].rooms[].wall_ids[]": { + "maxLength": 64, + "minLength": 1, + "type": "str" + }, + "config.spaces[].rooms[].x": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": false + }, + "config.spaces[].rooms[].y": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": false + }, + "config.spaces[].segments": { + "removed": true + }, + "config.spaces[].settings": { + "allowExtra": true, + "required": false + }, + "config.spaces[].settings.bg_color": { + "opaque": "Match('\\\\A#[0-9a-fA-F]{6}\\\\Z', msg=None)", + "required": false + }, + "config.spaces[].settings.bg_mode": { + "enum": [ + "daynight", + "static" + ], + "nullable": true, + "required": false + }, + "config.spaces[].settings.card_font_scale": { + "max": 3, + "min": 0.5, + "required": false, + "type": "float" + }, + "config.spaces[].settings.custom_fill": { + "nullable": true, + "required": false + }, + "config.spaces[].settings.custom_fill.a": { + "max": 1.0, + "min": 0.0, + "opaque": "", + "required": true + }, + "config.spaces[].settings.custom_fill.c": { + "opaque": "Match('\\\\A#[0-9a-fA-F]{6}\\\\Z', msg=None)", + "required": true + }, + "config.spaces[].settings.fill_mode": { + "enum": [ + "custom", + "glow", + "light", + "lqi", + "none", + "temp" + ], + "required": false + }, + "config.spaces[].settings.glow_enabled": { + "required": false, + "type": "bool" + }, + "config.spaces[].settings.hide_decor": { + "required": false, + "type": "bool" + }, + "config.spaces[].settings.hide_openings": { + "required": false, + "type": "bool" + }, + "config.spaces[].settings.label_hum": { + "required": false, + "type": "bool" + }, + "config.spaces[].settings.label_light": { + "required": false, + "type": "bool" + }, + "config.spaces[].settings.label_lqi": { + "required": false, + "type": "bool" + }, + "config.spaces[].settings.label_temp": { + "required": false, + "type": "bool" + }, + "config.spaces[].settings.north_deg": { + "nullable": true, + "opaque": "", + "required": false + }, + "config.spaces[].settings.room_color": { + "opaque": "Match('\\\\A#[0-9a-fA-F]{6}\\\\Z', msg=None)", + "required": false + }, + "config.spaces[].settings.room_opacity": { + "max": 1, + "min": 0, + "required": false, + "type": "float" + }, + "config.spaces[].settings.show_borders": { + "required": false, + "type": "bool" + }, + "config.spaces[].settings.show_lqi": { + "required": false, + "type": "bool" + }, + "config.spaces[].settings.show_names": { + "required": false, + "type": "bool" + }, + "config.spaces[].settings.sun_rays": { + "nullable": true, + "required": false, + "type": "bool" + }, + "config.spaces[].settings.temp_max": { + "required": false, + "type": "float" + }, + "config.spaces[].settings.temp_min": { + "required": false, + "type": "float" + }, + "config.spaces[].title": { + "required": true, + "type": "str" + }, + "config.spaces[].view_box": { + "opaque": "", + "required": true + }, + "config.spaces[].wall_columns": { + "required": false + }, + "config.spaces[].wall_columns[]": { + "allowExtra": true + }, + "config.spaces[].wall_columns[].angle": { + "max": 90, + "min": 0, + "opaque": "", + "required": false + }, + "config.spaces[].wall_columns[].center": { + "required": true + }, + "config.spaces[].wall_columns[].center[]": { + "max": 5000.0, + "min": -5000.0, + "opaque": "" + }, + "config.spaces[].wall_columns[].cm": { + "max": 150, + "min": 1, + "opaque": "", + "required": true + }, + "config.spaces[].wall_columns[].id": { + "maxLength": 64, + "minLength": 1, + "required": true, + "type": "str" + }, + "config.spaces[].wall_columns[].shape": { + "enum": [ + "circle", + "square" + ], + "required": true + }, + "config.spaces[].wall_segments": { + "required": false + }, + "config.spaces[].wall_segments[]": { + "allowExtra": true + }, + "config.spaces[].wall_segments[].a": { + "required": true + }, + "config.spaces[].wall_segments[].a[]": { + "max": 5000.0, + "min": -5000.0, + "opaque": "" + }, + "config.spaces[].wall_segments[].b": { + "required": true + }, + "config.spaces[].wall_segments[].b[]": { + "max": 5000.0, + "min": -5000.0, + "opaque": "" + }, + "config.spaces[].wall_segments[].cm": { + "max": 100, + "min": 0, + "opaque": "", + "required": true + }, + "config.spaces[].wall_segments[].id": { + "maxLength": 64, + "minLength": 1, + "required": true, + "type": "str" + }, + "config.spaces[].walls": { + "required": false + }, + "config.spaces[].walls[]": { + "allowExtra": true + }, + "config.spaces[].walls[].a": { + "required": false + }, + "config.spaces[].walls[].a[]": { + "max": 5000.0, + "min": -5000.0, + "opaque": "" + }, + "config.spaces[].walls[].b": { + "required": false + }, + "config.spaces[].walls[].b[]": { + "max": 5000.0, + "min": -5000.0, + "opaque": "" + }, + "config.spaces[].walls[].cm": { + "max": 100, + "min": 1, + "opaque": "", + "required": true + }, + "config.spaces[].walls[].key": { + "maxLength": 64, + "minLength": 1, + "required": true, + "type": "str" + }, + "config.spaces[].zero_wall_style": { + "enum": [ + "dashed", + "solid" + ], + "required": false + }, + "layout.*": { + "allowExtra": true + }, + "layout.*.x": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + }, + "layout.*.y": { + "max": 5000.0, + "min": -5000.0, + "opaque": "", + "required": true + } + } +} diff --git a/scripts/dump-config-schema.py b/scripts/dump-config-schema.py new file mode 100644 index 00000000..f479d525 --- /dev/null +++ b/scripts/dump-config-schema.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +"""#33: dump the canonical persisted-config schema into a deterministic JSON. + +The Voluptuous schema in custom_components/houseplan/validation.py is the +single owner of the persisted config/layout shape. This walker turns it into +scripts/config-schema-manifest.json — the machine-readable manifest the +frontend parity test and the field registry completeness test consume. + +Determinism contract: two runs on the same tree produce byte-identical +output (sorted paths, sorted keys, stable value rendering). A validator this +walker does not understand is written fail-closed as {"opaque": ""} — +the manifest stays fresh and the parity test simply does not judge that node. + +The importer stubs the parent packages so custom_components/houseplan/ +__init__.py (which needs homeassistant) never executes; validation.py itself +is dependency-free apart from voluptuous. +""" +from __future__ import annotations + +import importlib.util +import json +import re +import sys +import types +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +OUT = REPO / "scripts" / "config-schema-manifest.json" + + +def _safe_repr(value) -> str: + """repr() without memory addresses — the manifest must be deterministic.""" + if callable(value) and hasattr(value, "__qualname__"): + return f"" + return re.sub(r" at 0x[0-9a-fA-F]+", "", repr(value)) + + +def _load_validation(): + for name, path in ( + ("custom_components", REPO / "custom_components"), + ("custom_components.houseplan", REPO / "custom_components" / "houseplan"), + ): + module = types.ModuleType(name) + module.__path__ = [str(path)] + sys.modules[name] = module + + def load(name: str, file: Path): + spec = importlib.util.spec_from_file_location(name, file) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + load( + "custom_components.houseplan.coordinate_canonicalization", + REPO / "custom_components" / "houseplan" / "coordinate_canonicalization.py", + ) + return load( + "custom_components.houseplan.validation", + REPO / "custom_components" / "houseplan" / "validation.py", + ) + + +def _render_default(value): + """Stable, JSON-safe rendering of a schema default.""" + try: + if callable(value): + produced = value() + return {"factory": type(produced).__name__} + json.dumps(value) + return value + except (TypeError, ValueError): + return {"opaque": _safe_repr(value)} + + +def build_manifest(): + import voluptuous as vol + + validation = _load_validation() + + def describe(validator): # noqa: C901 - a walker is naturally branchy + """Describe one value validator as a JSON-able dict.""" + if isinstance(validator, vol.Schema): + return describe(validator.schema) | _extra_flag(validator) + if isinstance(validator, vol.All): + merged: dict = {} + for part in validator.validators: + for key, value in describe(part).items(): + if key in merged and merged[key] != value: + merged.setdefault("conflicts", []).append({key: value}) + else: + merged[key] = value + return merged or {"opaque": _safe_repr(validator)} + if isinstance(validator, vol.Any): + variants = [describe(part) for part in validator.validators] + if any(v == {"type": "null"} for v in variants): + rest = [v for v in variants if v != {"type": "null"}] + if len(rest) == 1: + return rest[0] | {"nullable": True} + return {"variants": variants} + if isinstance(validator, vol.In): + return {"enum": sorted(validator.container, key=str)} + if isinstance(validator, vol.Range): + out = {} + if validator.min is not None: + out["min"] = validator.min + if validator.max is not None: + out["max"] = validator.max + return out + if isinstance(validator, vol.Length): + out = {} + if validator.min is not None: + out["minLength"] = validator.min + if validator.max is not None: + out["maxLength"] = validator.max + return out + if isinstance(validator, vol.Equal): + return {"const": validator.target} + if isinstance(validator, vol.Coerce): + return {"type": getattr(validator.type, "__name__", _safe_repr(validator.type))} + if validator is None or validator is type(None): + return {"type": "null"} + if validator is bool: + return {"type": "bool"} + if validator is int: + return {"type": "int"} + if validator is float: + return {"type": "float"} + if validator is str: + return {"type": "str"} + if validator is dict or validator is object: + return {"type": "object"} + if isinstance(validator, dict): + return {"object": True} + if isinstance(validator, (list, tuple)): + items = [describe(part) for part in validator] + return {"list": items[0] if len(items) == 1 else items} + if isinstance(validator, (str, int, float, bool)): + return {"const": validator} + if isinstance(validator, type): + return {"type": validator.__name__} + return {"opaque": _safe_repr(validator)} + + def _extra_flag(schema): + if getattr(schema, "extra", None) == vol.ALLOW_EXTRA: + return {"allowExtra": True} + return {} + + leaves: dict[str, dict] = {} + + def _variant_tag(schema, index: int) -> str: + """Discriminator for an Any-of-dicts variant: kind const/enum, else index.""" + inner = schema.schema if isinstance(schema, vol.Schema) else schema + if isinstance(inner, dict): + for key, value in inner.items(): + name = key.schema if isinstance(key, (vol.Optional, vol.Required)) else key + if name == "kind": + if isinstance(value, str): + return value + if isinstance(value, vol.In): + return "|".join(sorted(value.container, key=str)) + if isinstance(value, vol.Equal): + return str(value.target) + return f"var{index}" + + def _is_struct(candidate) -> bool: + return isinstance(candidate, (vol.Schema, dict)) and isinstance( + candidate.schema if isinstance(candidate, vol.Schema) else candidate, dict) + + def walk(node, path: str): + if isinstance(node, vol.Any): + non_null = [p for p in node.validators if p is not None and p is not type(None)] + nullable = len(non_null) != len(node.validators) + if non_null and all(_is_struct(p) for p in non_null): + if nullable: + leaves.setdefault(path or "", {})["nullable"] = True + if len(non_null) == 1: + walk(non_null[0], path) + return + for index, variant in enumerate(non_null): + walk(variant, f"{path}<{_variant_tag(variant, index)}>") + return + leaves[path or ""] = describe(node) + return + if isinstance(node, vol.Schema): + entry_extra = _extra_flag(node) + if entry_extra and path: + leaves.setdefault(path or "", {}).update(entry_extra) + walk(node.schema, path) + return + if isinstance(node, vol.All): + # A dict wrapped in vol.All carries semantic validators after the + # structural schema; walk the structural part only. + structural = [p for p in node.validators + if isinstance(p, (vol.Schema, dict, list, tuple))] + if structural: + for part in structural: + walk(part, path) + return + leaves[path] = describe(node) + return + if isinstance(node, dict): + for key, value in node.items(): + marker = key + info: dict = {} + if isinstance(key, vol.Remove): + inner = key.schema + name = inner if isinstance(inner, str) else _safe_repr(inner) + leaves[f"{path}.{name}".lstrip(".")] = {"removed": True} + continue + if isinstance(key, (vol.Optional, vol.Required)): + info["required"] = isinstance(key, vol.Required) + default = getattr(key, "default", vol.UNDEFINED) + if default is not vol.UNDEFINED: + info["default"] = _render_default(default) + marker = key.schema + if isinstance(marker, str): + child = f"{path}.{marker}".lstrip(".") + elif marker is str: + child = f"{path}.*".lstrip(".") + else: + child = f"{path}.<{getattr(marker, '__name__', _safe_repr(marker))}>".lstrip(".") + if isinstance(value, (vol.Schema, dict)): + if info: + leaves.setdefault(child, {}).update(info) + walk(value, child) + elif isinstance(value, (list, tuple)) and len(value) == 1 \ + and isinstance(value[0], (vol.Schema, dict, vol.All)): + if info: + leaves.setdefault(f"{child}[]", {}).update(info) + walk(value[0], f"{child}[]") + elif isinstance(value, vol.All) and any( + isinstance(p, (vol.Schema, dict, list, tuple)) + for p in value.validators): + if info: + leaves.setdefault(child, {}).update(info) + walk(value, child) + elif isinstance(value, vol.Any) and value.validators and all( + _is_struct(p) or p is None or p is type(None) + for p in value.validators) and any( + _is_struct(p) for p in value.validators): + if info: + leaves.setdefault(child, {}).update(info) + walk(value, child) + elif isinstance(value, (list, tuple)) and len(value) == 1 \ + and isinstance(value[0], vol.Any): + if info: + leaves.setdefault(f"{child}[]", {}).update(info) + walk(value[0], f"{child}[]") + else: + leaves[child] = info | describe(value) + return + if isinstance(node, (list, tuple)) and len(node) == 1: + walk(node[0], f"{path}[]") + return + leaves[path or ""] = describe(node) + + walk(validation.CONFIG_SCHEMA, "config") + walk(validation.LAYOUT_SCHEMA, "layout") + + return { + "_comment": "#33: generated by scripts/dump-config-schema.py — do not edit by hand", + "fields": {path: dict(sorted(entry.items())) for path, entry in sorted(leaves.items())}, + } + + +def main() -> int: + manifest = build_manifest() + rendered = json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + if "--check" in sys.argv: + current = OUT.read_text(encoding="utf-8") if OUT.exists() else "" + if current != rendered: + sys.stderr.write( + "config-schema-manifest.json is stale: run python3 scripts/dump-config-schema.py\n") + return 1 + print(f"manifest fresh: {len(manifest['fields'])} paths") + return 0 + OUT.write_text(rendered, encoding="utf-8") + print(f"manifest written: {len(manifest['fields'])} paths") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index 143292dc..d80d14d0 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -735,6 +735,29 @@ const MUTANT_DEFINITIONS = [ replace: " this._persistDecorStyle();\n }, 0);", }], }, + { + id: 'schema-manifest-enum-drift', + guard: 'node --test test/config-schema-parity.test.mjs', + because: 'a backend enum value the frontend does not know (and the allow-list does not ' + + 'bless) is exactly the schema drift #33 exists to catch — the manifest mutation ' + + 'simulates the backend change without the frontend pair', + patches: [{ + file: 'scripts/config-schema-manifest.json', + find: " \"config.spaces[].settings.fill_mode\": {\n \"enum\": [\n \"custom\",", + replace: " \"config.spaces[].settings.fill_mode\": {\n \"enum\": [\n \"phantom-33\",\n \"custom\",", + }], + }, + { + id: 'registry-selector-dead-decision', + guard: 'node --test test/config-schema-parity.test.mjs', + because: 'a registry decision whose selector no longer matches the schema is a dead ' + + 'decision — the completeness test must refuse to let them accumulate (#33 AC4)', + patches: [{ + file: 'scripts/config-field-registry.mjs', + find: " selector: { path: ['spaces', '*', 'zero_wall_style'] },", + replace: " selector: { path: ['spaces', '*', 'zero_wall_stylo'] },", + }], + }, { id: 'same-binding-click-resets-source', guard: 'node demo/smoke_value_face_source.mjs', diff --git a/scripts/schema-compat-allowlist.mjs b/scripts/schema-compat-allowlist.mjs new file mode 100644 index 00000000..c4b66e05 --- /dev/null +++ b/scripts/schema-compat-allowlist.mjs @@ -0,0 +1,42 @@ +/** + * #33: the machine-readable list of LEGITIMISED enum divergences between the + * backend schema manifest (scripts/config-schema-manifest.json) and the + * frontend const declarations. + * + * The parity test fails on any divergence that is not listed here — and on + * any entry here that no longer corresponds to a real divergence, so the + * list cannot rot. Every entry names the owning side, the value, the reason + * and the issue that owns the decision. + */ +export const SCHEMA_COMPAT_ALLOWLIST = Object.freeze([ + { + pair: 'space.fill_mode', + side: 'backend-only', + value: 'glow', + reason: 'legacy stored value from the pre-#20 glow era; the space editor ' + + 'projects it and never writes it back', + issue: '#33', + }, + { + pair: 'room.fill_mode', + side: 'backend-only', + value: 'glow', + reason: 'same read-compatibility as the space level', + issue: '#33', + }, + { + pair: 'marker.display', + side: 'backend-only', + value: 'ripple', + reason: 'read-compatibility value; normalizeDeviceDisplay canonicalises ' + + 'it to icon_ripple and the editor rewrites it on every write', + issue: '#33', + }, + { + pair: 'marker.tap_action', + side: 'backend-only', + value: 'cover', + reason: 'read/backend compatibility token; the editor no longer offers it', + issue: '#33', + }, +]); diff --git a/src/sun.ts b/src/sun.ts index dedcba05..1c200a7b 100644 --- a/src/sun.ts +++ b/src/sun.ts @@ -670,7 +670,8 @@ export function northDegOf(settings: any, spaceSettings: any): number | null { return intDeg(settings?.north_deg); } -export type BgMode = 'static' | 'daynight'; +export const BG_MODES = ['static', 'daynight'] as const; // #33 parity +export type BgMode = (typeof BG_MODES)[number]; /** Effective background mode; anything unknown falls back to 'static'. */ export function bgModeOf(settings: any, spaceSettings: any): BgMode { diff --git a/src/types.ts b/src/types.ts index fd94e8a1..b486da50 100644 --- a/src/types.ts +++ b/src/types.ts @@ -51,7 +51,10 @@ export interface WallSegmentEntry { [key: string]: unknown; } -export type ZeroWallStyle = 'dashed' | 'solid'; +// #33: exported list drives the schema parity test; the type derives from it +// so the two can never diverge. +export const ZERO_WALL_STYLES = ['dashed', 'solid'] as const; +export type ZeroWallStyle = (typeof ZERO_WALL_STYLES)[number]; /** Persisted open room contour. Coordinates are normalised in config and * render units in SpaceModel, exactly like rooms. */ @@ -110,6 +113,9 @@ export interface MarkerValueBadge { } /** Config marker: edits/augments an auto-discovered device OR describes a manual/virtual icon. */ +export const VACUUM_TRAIL_MODES = ['never', 'cleaning', 'always'] as const; // #33 parity +export type VacuumTrailMode = (typeof VACUUM_TRAIL_MODES)[number]; + export interface Marker { id: string; binding: string; // 'device:' | 'entity:' | 'virtual' @@ -136,7 +142,7 @@ export interface Marker { vacuum?: { live?: boolean | null; trail?: boolean | null; // legacy bool; trail_mode wins - trail_mode?: 'never' | 'cleaning' | 'always' | null; + trail_mode?: VacuumTrailMode | null; room_highlight?: boolean | null; source?: string | null; calibration?: Record; @@ -207,9 +213,12 @@ export interface WallOpeningHost { export type OpeningHost = PartitionOpeningHost | WallOpeningHost; +export const OPENING_TYPES = ['door', 'window', 'gate', 'passage'] as const; // #33 parity +export type OpeningType = (typeof OPENING_TYPES)[number]; + export interface OpeningCfg { id: string; - type: 'door' | 'window' | 'gate' | 'passage'; + type: OpeningType; x: number; // center, normalized by plan width y: number; // center, normalized by plan height angle: number; // wall angle, degrees diff --git a/test/config-audit.test.mjs b/test/config-audit.test.mjs index bdef2cf6..ec2136e7 100644 --- a/test/config-audit.test.mjs +++ b/test/config-audit.test.mjs @@ -63,3 +63,25 @@ test('config audit reports malformed JSON without a raw stack trace', () => { rmSync(directory, { recursive: true, force: true }); } }); + +// #33 AC6: the CLI exit-code contract — 0 clean, 3 migration available, +// 2 invalid input — proven on the lifecycle fixtures. +test('#33 config audit exit codes distinguish clean, migration and invalid', () => { + const run = (file) => spawnSync(process.execPath, ['scripts/config-audit.mjs', file], { + cwd: resolve('.'), encoding: 'utf8', + }); + const clean = run('test/fixtures/config-lifecycle/current.json'); + assert.equal(clean.status, 0, + `the current fixture must be clean, got ${clean.status}: ${clean.stdout}`); + const legacy = run('test/fixtures/config-lifecycle/oldest-supported.json'); + assert.equal(legacy.status, 3, + 'show_all/weather_entity/ripple in the oldest fixture must report "migration available"'); + const directory = mkdtempSync(resolve(tmpdir(), 'houseplan-audit-codes-')); + const broken = resolve(directory, 'broken.json'); + try { + writeFileSync(broken, '{nope', 'utf8'); + assert.equal(run(broken).status, 2, 'invalid input keeps the existing code 2'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/test/config-schema-parity.test.mjs b/test/config-schema-parity.test.mjs new file mode 100644 index 00000000..d0af7b41 --- /dev/null +++ b/test/config-schema-parity.test.mjs @@ -0,0 +1,99 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { SCHEMA_COMPAT_ALLOWLIST } from '../scripts/schema-compat-allowlist.mjs'; +import { CONFIG_FIELD_REGISTRY } from '../scripts/config-field-registry.mjs'; +import { + DISPLAY_MODES, TAP_ACTIONS, SPACE_FILL_MODES, ROOM_FILL_MODES, +} from '../test-build/logic.js'; +import { + OPENING_TYPES, VACUUM_TRAIL_MODES, ZERO_WALL_STYLES, +} from '../test-build/types.js'; +import { BG_MODES } from '../test-build/sun.js'; + +const manifest = JSON.parse(readFileSync( + new URL('../scripts/config-schema-manifest.json', import.meta.url), 'utf8')).fields; + +/** Enum values of a manifest entry: `enum` list or const-variants (null skipped). */ +const backendValues = (path) => { + const entry = manifest[path]; + assert.ok(entry, `manifest is missing ${path} — regenerate scripts/config-schema-manifest.json`); + if (entry.enum) return [...entry.enum]; + if (entry.variants) { + return entry.variants + .filter((variant) => 'const' in variant) + .map((variant) => variant.const); + } + assert.fail(`${path} carries neither enum nor const variants`); + return []; +}; + +/** #33 AC2/AC3: every backend<->frontend enum pair, judged through the allow-list. */ +const PAIRS = [ + { pair: 'space.fill_mode', path: 'config.spaces[].settings.fill_mode', front: SPACE_FILL_MODES }, + { pair: 'room.fill_mode', path: 'config.spaces[].rooms[].settings.fill_mode', front: ROOM_FILL_MODES }, + { pair: 'marker.display', path: 'config.markers[].display', front: DISPLAY_MODES }, + { pair: 'marker.tap_action', path: 'config.markers[].tap_action', front: TAP_ACTIONS }, + { pair: 'opening.type', path: 'config.spaces[].openings[].type', front: OPENING_TYPES }, + { pair: 'vacuum.trail_mode', path: 'config.markers[].vacuum.trail_mode', front: VACUUM_TRAIL_MODES }, + { pair: 'space.zero_wall_style', path: 'config.spaces[].zero_wall_style', front: ZERO_WALL_STYLES }, + { pair: 'settings.bg_mode', path: 'config.settings.bg_mode', front: BG_MODES }, +]; + +test('#33 AC2: backend and frontend enums agree, divergences only via the allow-list', () => { + const usedAllowEntries = new Set(); + for (const { pair, path, front } of PAIRS) { + const backend = new Set(backendValues(path)); + const frontend = new Set(front); + for (const value of backend) { + if (frontend.has(value)) continue; + const allowed = SCHEMA_COMPAT_ALLOWLIST.find((entry) => + entry.pair === pair && entry.side === 'backend-only' && entry.value === value); + assert.ok(allowed, + `${pair}: backend knows '${value}' but the frontend list does not — ` + + 'add the value or a schema-compat-allowlist entry with a reason'); + usedAllowEntries.add(allowed); + } + for (const value of frontend) { + if (backend.has(value)) continue; + const allowed = SCHEMA_COMPAT_ALLOWLIST.find((entry) => + entry.pair === pair && entry.side === 'frontend-only' && entry.value === value); + assert.ok(allowed, + `${pair}: frontend offers '${value}' but the backend schema rejects it — ` + + 'a write would fail validation'); + usedAllowEntries.add(allowed); + } + } + // AC3: the allow-list cannot rot — every entry must justify a REAL divergence. + for (const entry of SCHEMA_COMPAT_ALLOWLIST) { + assert.ok(usedAllowEntries.has(entry), + `allow-list entry ${entry.pair}/'${entry.value}' no longer matches a real ` + + 'divergence — the sides converged, remove the entry'); + } +}); + +/** #33 AC4: every registry decision must resolve against the schema reality. */ +test('#33 AC4: every registry entry resolves to a manifest path or an explicit passport', () => { + // Variant tags (, ...) are a manifest detail — registry + // selectors are variant-agnostic, so both sides compare untagged. + const untagged = new Set(Object.keys(manifest).map((key) => key.replace(/<[^>]+>/g, ''))); + for (const entry of CONFIG_FIELD_REGISTRY) { + if (entry.schema === 'allow-extra' || entry.schema === 'lovelace-card') continue; + const path = 'config.' + entry.selector.path + .map((segment) => (segment === '*' ? '[]' : segment)) + .join('.') + .replace(/\.\[\]/g, '[]'); + assert.ok(untagged.has(path), + `registry entry '${entry.id}' points at '${path}' which is not in the schema ` + + 'manifest — dead decisions must not accumulate (fix the selector, add a ' + + "schema: 'allow-extra' passport, or drop the entry)"); + } +}); + +/** #33 AC7: the manifest is a build/test artefact — the bundle must not grow. */ +test('#33 AC7: no production source imports the schema manifest', () => { + const source = readFileSync(new URL('../src/houseplan-card.ts', import.meta.url), 'utf8') + + readFileSync(new URL('../src/houseplan-editor-runtime.ts', import.meta.url), 'utf8'); + assert.ok(!source.includes('config-schema-manifest'), + 'the manifest is test infrastructure, not runtime data'); +}); diff --git a/test/fixtures/config-lifecycle/current.json b/test/fixtures/config-lifecycle/current.json new file mode 100644 index 00000000..3e75b406 --- /dev/null +++ b/test/fixtures/config-lifecycle/current.json @@ -0,0 +1,188 @@ +{ + "model_version": 9, + "spaces": [ + { + "id": "ground", + "title": "Ground", + "view_box": [ + 0, + 0, + 1000, + 750 + ], + "rooms": [ + { + "id": "hall", + "name": "Hall", + "poly": [ + [ + 100, + 100 + ], + [ + 400, + 100 + ], + [ + 400, + 400 + ], + [ + 100, + 400 + ] + ], + "wall_ids": [ + "w1", + "w2", + "w3", + "w4" + ] + } + ], + "wall_segments": [ + { + "id": "w1", + "a": [ + 100, + 100 + ], + "b": [ + 400, + 100 + ], + "cm": 15 + }, + { + "id": "w2", + "a": [ + 400, + 100 + ], + "b": [ + 400, + 400 + ], + "cm": 15 + }, + { + "id": "w3", + "a": [ + 400, + 400 + ], + "b": [ + 100, + 400 + ], + "cm": 15 + }, + { + "id": "w4", + "a": [ + 100, + 400 + ], + "b": [ + 100, + 100 + ], + "cm": 15 + } + ], + "walls": [ + { + "key": "[100, 100]|[400, 100]", + "a": [ + 100, + 100 + ], + "b": [ + 400, + 100 + ], + "cm": 15 + }, + { + "key": "[400, 100]|[400, 400]", + "a": [ + 400, + 100 + ], + "b": [ + 400, + 400 + ], + "cm": 15 + }, + { + "key": "[400, 400]|[100, 400]", + "a": [ + 400, + 400 + ], + "b": [ + 100, + 400 + ], + "cm": 15 + }, + { + "key": "[100, 400]|[100, 100]", + "a": [ + 100, + 400 + ], + "b": [ + 100, + 100 + ], + "cm": 15 + } + ], + "decor": [ + { + "id": "d1", + "kind": "line", + "x1": 0.1, + "y1": 0.1, + "x2": 0.3, + "y2": 0.1, + "color": "#8b0000", + "width_cm": 5 + }, + { + "id": "d2", + "kind": "furniture", + "symbol": "sofa", + "x": 0.2, + "y": 0.2, + "w": 0.1, + "h": 0.05 + } + ], + "settings": { + "bg_mode": "daynight", + "north_deg": 45 + } + } + ], + "markers": [ + { + "id": "sensor", + "binding": "entity:sensor.temp", + "space": "ground", + "display": "value", + "value_source": { + "kind": "entity_attr", + "attr": "temperature" + } + } + ], + "settings": { + "decor_default_style": { + "color": "#8b0000", + "width_cm": 5 + } + } +} \ No newline at end of file diff --git a/test/fixtures/config-lifecycle/future-fields.json b/test/fixtures/config-lifecycle/future-fields.json new file mode 100644 index 00000000..9d95c92e --- /dev/null +++ b/test/fixtures/config-lifecycle/future-fields.json @@ -0,0 +1,201 @@ +{ + "model_version": 9, + "spaces": [ + { + "id": "ground", + "title": "Ground", + "view_box": [ + 0, + 0, + 1000, + 750 + ], + "rooms": [ + { + "id": "hall", + "name": "Hall", + "poly": [ + [ + 100, + 100 + ], + [ + 400, + 100 + ], + [ + 400, + 400 + ], + [ + 100, + 400 + ] + ], + "wall_ids": [ + "w1", + "w2", + "w3", + "w4" + ] + } + ], + "wall_segments": [ + { + "id": "w1", + "a": [ + 100, + 100 + ], + "b": [ + 400, + 100 + ], + "cm": 15 + }, + { + "id": "w2", + "a": [ + 400, + 100 + ], + "b": [ + 400, + 400 + ], + "cm": 15 + }, + { + "id": "w3", + "a": [ + 400, + 400 + ], + "b": [ + 100, + 400 + ], + "cm": 15 + }, + { + "id": "w4", + "a": [ + 100, + 400 + ], + "b": [ + 100, + 100 + ], + "cm": 15 + } + ], + "walls": [ + { + "key": "[100, 100]|[400, 100]", + "a": [ + 100, + 100 + ], + "b": [ + 400, + 100 + ], + "cm": 15 + }, + { + "key": "[400, 100]|[400, 400]", + "a": [ + 400, + 100 + ], + "b": [ + 400, + 400 + ], + "cm": 15 + }, + { + "key": "[400, 400]|[100, 400]", + "a": [ + 400, + 400 + ], + "b": [ + 100, + 400 + ], + "cm": 15 + }, + { + "key": "[100, 400]|[100, 100]", + "a": [ + 100, + 400 + ], + "b": [ + 100, + 100 + ], + "cm": 15 + } + ], + "decor": [ + { + "id": "d1", + "kind": "line", + "x1": 0.1, + "y1": 0.1, + "x2": 0.3, + "y2": 0.1, + "color": "#8b0000", + "width_cm": 5 + }, + { + "id": "d2", + "kind": "furniture", + "symbol": "sofa", + "x": 0.2, + "y": 0.2, + "w": 0.1, + "h": 0.05 + } + ], + "settings": { + "bg_mode": "daynight", + "north_deg": 45, + "future_display": true + }, + "future_space_field": "kept" + } + ], + "markers": [ + { + "id": "sensor", + "binding": "entity:sensor.temp", + "space": "ground", + "display": "value", + "value_source": { + "kind": "entity_attr", + "attr": "temperature" + }, + "future_marker_field": { + "nested": "kept" + } + } + ], + "settings": { + "decor_default_style": { + "color": "#8b0000", + "width_cm": 5 + }, + "future_setting": [ + 1, + 2, + 3 + ] + }, + "future_root_field": { + "kept": true + } +} \ No newline at end of file diff --git a/test/fixtures/config-lifecycle/oldest-supported.json b/test/fixtures/config-lifecycle/oldest-supported.json new file mode 100644 index 00000000..b2a282d9 --- /dev/null +++ b/test/fixtures/config-lifecycle/oldest-supported.json @@ -0,0 +1,57 @@ +{ + "spaces": [ + { + "id": "ground", + "title": "Ground", + "view_box": [ + 0, + 0, + 1000, + 750 + ], + "rooms": [ + { + "id": "hall", + "name": "Hall", + "poly": [ + [ + 100, + 100 + ], + [ + 400, + 100 + ], + [ + 400, + 400 + ], + [ + 100, + 400 + ] + ] + } + ], + "aspect": 1.33, + "settings": { + "fill_mode": "glow" + } + } + ], + "markers": [ + { + "id": "lamp", + "binding": "entity:light.hall", + "space": "ground", + "display": "ripple", + "tap_action": "cover", + "is_light": true + } + ], + "settings": { + "show_all": true, + "weather_entity": "weather.home", + "bg_mode": "static" + } +} \ No newline at end of file diff --git a/tests_backend/test_config_schema_manifest.py b/tests_backend/test_config_schema_manifest.py new file mode 100644 index 00000000..efc5797d --- /dev/null +++ b/tests_backend/test_config_schema_manifest.py @@ -0,0 +1,82 @@ +"""#33: the committed schema manifest must stay fresh, and the lifecycle +fixtures must pass the real schema losslessly. + +The dump script stubs the package parents itself, so this module runs both in +the sandbox (no homeassistant) and in CI. +""" +from __future__ import annotations + +import copy +import importlib.util +import json +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent + + +def _load_dump_module(): + spec = importlib.util.spec_from_file_location( + "houseplan_dump_config_schema", REPO / "scripts" / "dump-config-schema.py") + module = importlib.util.module_from_spec(spec) + sys.modules["houseplan_dump_config_schema"] = module + spec.loader.exec_module(module) + return module + + +def _validation(): + _load_dump_module() # its importer registers the stubbed packages + return sys.modules["custom_components.houseplan.validation"] + + +def test_issue_33_manifest_is_fresh_and_deterministic(): + dump = _load_dump_module() + first = dump.build_manifest() + second = dump.build_manifest() + assert first == second, "the walker must be deterministic" + rendered = json.dumps(first, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + committed = (REPO / "scripts" / "config-schema-manifest.json").read_text(encoding="utf-8") + assert rendered == committed, ( + "scripts/config-schema-manifest.json is stale — the schema changed; " + "run python3 scripts/dump-config-schema.py and commit the diff" + ) + # AC1: the manifest is not a stub — it must cover the whole persisted shape. + assert len(first["fields"]) > 200 + + +def _collect_paths(node, prefix=""): + paths = set() + if isinstance(node, dict): + for key, value in node.items(): + paths |= _collect_paths(value, f"{prefix}.{key}" if prefix else key) + elif isinstance(node, list): + for item in node: + paths |= _collect_paths(item, f"{prefix}[]") + else: + paths.add(prefix) + return paths + + +def test_issue_33_lifecycle_fixtures_pass_the_schema_losslessly(): + validation = _validation() + fixtures = REPO / "test" / "fixtures" / "config-lifecycle" + dropped_by_design = {"spaces[].aspect", "spaces[].segments"} # vol.Remove + for name in ("oldest-supported", "current", "future-fields"): + raw = json.loads((fixtures / f"{name}.json").read_text(encoding="utf-8")) + validated = validation.CONFIG_SCHEMA(copy.deepcopy(raw)) + before = _collect_paths(raw) + after = _collect_paths(validated) + lost = before - after - dropped_by_design + assert not lost, f"{name}: validation silently dropped {sorted(lost)}" + + +def test_issue_33_future_fields_round_trip_exactly(): + validation = _validation() + fixtures = REPO / "test" / "fixtures" / "config-lifecycle" + raw = json.loads((fixtures / "future-fields.json").read_text(encoding="utf-8")) + validated = validation.CONFIG_SCHEMA(copy.deepcopy(raw)) + assert validated["future_root_field"] == {"kept": True} + assert validated["settings"]["future_setting"] == [1, 2, 3] + assert validated["spaces"][0]["future_space_field"] == "kept" + assert validated["spaces"][0]["settings"]["future_display"] is True + assert validated["markers"][0]["future_marker_field"] == {"nested": "kept"}