test: close device presentation review findings

Issue: #267
User-Visible: no
This commit is contained in:
Matysh
2026-08-27 22:41:46 +03:00
parent 8b0ad08db2
commit 118062bb8c
10 changed files with 145 additions and 92 deletions
File diff suppressed because one or more lines are too long
+27 -27
View File
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -27,9 +27,9 @@ mutation evidence в одном pull request.
| L01 | `marker.removed:true` | `pre.lifecycle.removed` | marker отсутствует в roster/DOM | отсутствует | `devices` tombstone test; `presentation-row-contract` |
| L02 | HA-disabled, View/киоск/static | `lifecycle.ha_disabled_hidden` | face скрыт, live state не участвует | отсутствует на View | `device-presentation-policy-lifecycle`; `presentation-row-contract` |
| L03 | HA-disabled, Device editor | `lifecycle.ha_disabled_hidden` | служебный ghost/preview с причиной `ha_disabled`, без live face | editor-owned, без service action | `device-presentation-policy-lifecycle`; `presentation-row-contract` |
| L04 | user-hidden, View/киоск/static | `lifecycle.user_hidden` | marker и hit-area скрыты | отсутствует | `device-presentation-policy-lifecycle`; `presentation-row-contract` |
| L05 | user-hidden, design preview | `lifecycle.user_hidden_preview` | сохранённый дизайн видим; notice `hidden_design_preview` | preview inert | `device-presentation-policy-lifecycle`; `presentation-row-contract` |
| L06 | orphaned/unverified | `lifecycle.orphaned_diagnostic` / `lifecycle.unverified_diagnostic` | нейтральная диагностическая проекция с причиной, pulse/service не оживают | surface-owned safe path | `device-presentation-policy-lifecycle`; `presentation-row-contract` |
| L04 | user-hidden, View/киоск/static | `lifecycle.user_hidden` | marker и hit-area скрыты | отсутствует | `device-presentation-policy-user-hidden`; `presentation-row-contract` |
| L05 | user-hidden, design preview | `lifecycle.user_hidden_preview` | сохранённый дизайн видим; notice `hidden_design_preview` | preview inert | `device-presentation-policy-user-hidden-preview`; `presentation-row-contract` |
| L06 | orphaned binding | `lifecycle.orphaned_diagnostic` | нейтральная диагностическая проекция с причиной, pulse/service не оживают | surface-owned safe path | `device-presentation-policy-orphaned`; `presentation-row-contract` |
## Источник лица и доступность
@@ -48,6 +48,7 @@ mutation evidence в одном pull request.
| S11 | registry role временно отсутствует, есть primary | `source.primary_fallback` | deterministic primary fallback | normal | `presentation-source-decision-trace`; `presentation-row-contract` |
| S12 | пригодного source нет | `source.none` | neutral base-icon fallback | normal | `presentation-source-decision-trace`; `presentation-row-contract` |
| S13 | critical alarm sibling вне обычного source | `source.critical_sibling` + `status.alarm` | alarm добавлен в aggregate и побеждает normal status | normal | `device-presentation-policy-alarm`; `presentation-row-contract` |
| S14 | static plan fast path без source details | `source.skipped_static_fast_path` | source graph намеренно не вычисляется; static face остаётся neutral | normal; без source-derived данных | `presentation-static-source-fast-path`; `presentation-row-contract` |
## Финальное лицо, контент и диагностика
+11 -11
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "fb7f3cbc180b24cb91421c5cd0b8808cd4e2d0c38dd0c6f00f46d59f94e4a6d7",
"sourceFingerprint": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fb7f3cbc180b24cb91421c5cd0b8808cd4e2d0c38dd0c6f00f46d59f94e4a6d7",
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fb7f3cbc180b24cb91421c5cd0b8808cd4e2d0c38dd0c6f00f46d59f94e4a6d7",
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fb7f3cbc180b24cb91421c5cd0b8808cd4e2d0c38dd0c6f00f46d59f94e4a6d7",
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
"imageSha256": "0dc024726327f60f4a9ceaf3044381691f81f1090af81d1812e870f22d9343ba"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fb7f3cbc180b24cb91421c5cd0b8808cd4e2d0c38dd0c6f00f46d59f94e4a6d7",
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
"imageSha256": "44cfaa95bd51e2cd628400e28db0ad8b2f0cd904385845bd402494f3f5c0d93c"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fb7f3cbc180b24cb91421c5cd0b8808cd4e2d0c38dd0c6f00f46d59f94e4a6d7",
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
"imageSha256": "e0662429b423fb74151b583dcc2c8635b001b637d03bbf7a6b16aec46399c3f8"
},
"device-editor": {
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fb7f3cbc180b24cb91421c5cd0b8808cd4e2d0c38dd0c6f00f46d59f94e4a6d7",
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fb7f3cbc180b24cb91421c5cd0b8808cd4e2d0c38dd0c6f00f46d59f94e4a6d7",
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fb7f3cbc180b24cb91421c5cd0b8808cd4e2d0c38dd0c6f00f46d59f94e4a6d7",
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fb7f3cbc180b24cb91421c5cd0b8808cd4e2d0c38dd0c6f00f46d59f94e4a6d7",
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fb7f3cbc180b24cb91421c5cd0b8808cd4e2d0c38dd0c6f00f46d59f94e4a6d7",
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
}
}
@@ -135,7 +135,7 @@
| L03 | HA-disabled в служебном Device editor | диагностический marker/preview с причиной, без live state/action |
| L04 | user-hidden в View/киоске/static card | marker и hit-area отсутствуют |
| L05 | user-hidden в design preview | реальный сохранённый дизайн видим, notice `hidden_design_preview`, preview inert |
| L06 | orphaned/unverified binding | сохраняется действующая диагностическая проекция и причина; pulse/service не оживают |
| L06 | orphaned binding | сохраняется действующая диагностическая проекция и причина; pulse/service не оживают |
### 6.2 Source / availability
@@ -154,6 +154,7 @@
| S11 | registry role временно отсутствует, есть live primary | deterministic `primary` fallback без смены сохранённого binding |
| S12 | нет пригодного source | neutral icon fallback; не выбирается случайная sibling entity |
| S13 | critical alarm sibling вне обычного face source | alarm добавляется к semantic aggregate и имеет публичный приоритет |
| S14 | static plan fast path с `sourceDetails:false` | source graph не вычисляется; trace явно фиксирует пропуск, static face остаётся neutral |
### 6.3 Face / content / diagnostics
@@ -209,7 +210,7 @@ semantic facts, не читает HA registry/state и не строит light g
```ts
interface DevicePresentationPolicyInput {
bindingLifecycle: 'active' | 'ha_disabled' | 'orphaned' | 'unverified';
bindingLifecycle: 'active' | 'ha_disabled' | 'orphaned';
userHidden: boolean;
designPreview: boolean;
display: DeviceDisplayMode;
@@ -250,7 +251,7 @@ Production decision IDs описывают **правила**, а не test fixt
- `device-toggle.ts` и surface handlers владеют action/security/interactivity;
- `resolvePresentationSources()` продолжает владеть HA/light graph, но
возвращает дополнительно явный source-decision ID либо эквивалентный trace,
чтобы S01…S13 не зависели от чтения порядка `if`;
чтобы S01…S14 не зависели от чтения порядка `if`;
- `houseplan-card.ts`, `space-card.ts`, `space-render.ts` не получают права
переопределять policy result.
@@ -409,7 +410,7 @@ renderer и surfaces не принимают альтернативных реш
### AC3 — source decisions названы
Каждая ветка `cover|controls|light|device_role|primary|none` и critical sibling
возвращает стабильный decision ID/trace; S01…S13 проверяют наблюдаемый output.
возвращает стабильный decision ID/trace; S01…S14 проверяют наблюдаемый output.
**Доказательство:** parameterized unit.
@@ -545,4 +546,3 @@ node scripts/mutation-gate.mjs --id=<каждый новый/изменённы
является самоцелью.
5. Existing browser smoke расширяется только если это остаётся узким сценарием;
иначе создаётся новый `smoke_device_presentation_matrix.mjs`.
+58 -2
View File
@@ -2146,14 +2146,70 @@ export const MUTANTS = [
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="every documented decision row" '
+ 'test/device-presentation-policy.test.mjs',
because: 'HA-disabled and user-hidden markers must not leak live faces back into View; '
+ 'the pure policy test exercises both hidden output and the explicit design-preview exception',
because: 'HA-disabled markers must not leak live faces back into View or the device editor; '
+ 'the pure policy test exercises the shared lifecycle gate on both surfaces',
patches: [{
file: 'src/device-presentation-policy.ts',
find: " if (input.bindingLifecycle === 'ha_disabled') {\n effectiveHidden = true;",
replace: " if (input.bindingLifecycle === 'ha_disabled') {\n effectiveHidden = false;",
}],
},
{
id: 'device-presentation-policy-user-hidden',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="every documented decision row" '
+ 'test/device-presentation-policy.test.mjs',
because: 'a user-hidden marker must stay out of View while the separate design-preview '
+ 'exception remains explicit and independently protected',
patches: [{
file: 'src/device-presentation-policy.ts',
find: ' } else if (input.userHidden && !input.designPreview) {\n'
+ ' effectiveHidden = true;',
replace: ' } else if (input.userHidden && !input.designPreview) {\n'
+ ' effectiveHidden = false;',
}],
},
{
id: 'device-presentation-policy-user-hidden-preview',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="every documented decision row" '
+ 'test/device-presentation-policy.test.mjs',
because: 'design preview must keep the saved face of a user-hidden marker instead of '
+ 'collapsing it into the ordinary active lifecycle',
patches: [{
file: 'src/device-presentation-policy.ts',
find: " } else if (input.userHidden) {\n decisions.push('lifecycle.user_hidden_preview');",
replace: " } else if (false && input.userHidden) {\n decisions.push('lifecycle.user_hidden_preview');",
}],
},
{
id: 'device-presentation-policy-orphaned',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="every documented decision row" '
+ 'test/device-presentation-policy.test.mjs',
because: 'an orphaned saved binding must retain its diagnostic lifecycle decision and '
+ 'must not be treated as an ordinary active marker',
patches: [{
file: 'src/device-presentation-policy.ts',
find: " } else if (input.bindingLifecycle === 'orphaned') {\n"
+ " decisions.push('lifecycle.orphaned_diagnostic');",
replace: " } else if (false && input.bindingLifecycle === 'orphaned') {\n"
+ " decisions.push('lifecycle.orphaned_diagnostic');",
}],
},
{
id: 'presentation-static-source-fast-path',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="every documented decision row" '
+ 'test/device-presentation-policy.test.mjs',
because: 'the static plan fast path deliberately skips source discovery and must expose '
+ 'that bounded decision instead of silently looking like an evaluated empty graph',
patches: [{
file: 'src/device-presentation.ts',
find: " sourceKind: 'none', decisionIds: ['source.skipped_static_fast_path'],",
replace: " sourceKind: 'none', decisionIds: ['source.none'],",
}],
},
{
id: 'device-presentation-policy-static',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+2 -5
View File
@@ -27,7 +27,7 @@ export type PresentationReason =
| 'orphaned';
export type BindingPresentationLifecycle =
| 'active' | 'ha_disabled' | 'orphaned' | 'unverified';
| 'active' | 'ha_disabled' | 'orphaned';
export type PresentationFace = 'icon' | 'value';
export type ValueFallbackReason =
@@ -77,8 +77,7 @@ export function resolveDevicePresentationPolicy(
): DevicePresentationPolicyResult {
const decisions: string[] = [];
const bindingUnavailable = input.bindingLifecycle === 'ha_disabled'
|| input.bindingLifecycle === 'orphaned'
|| input.bindingLifecycle === 'unverified';
|| input.bindingLifecycle === 'orphaned';
let effectiveHidden = false;
if (input.bindingLifecycle === 'ha_disabled') {
@@ -91,8 +90,6 @@ export function resolveDevicePresentationPolicy(
decisions.push('lifecycle.user_hidden_preview');
} else if (input.bindingLifecycle === 'orphaned') {
decisions.push('lifecycle.orphaned_diagnostic');
} else if (input.bindingLifecycle === 'unverified') {
decisions.push('lifecycle.unverified_diagnostic');
} else {
decisions.push('lifecycle.active');
}
+1 -1
View File
@@ -585,7 +585,7 @@ export function resolveDevicePresentation(
const haDisabled = status?.kind === 'ha_disabled';
const orphaned = status?.kind === 'orphaned';
const lifecycle: BindingPresentationLifecycle = haDisabled ? 'ha_disabled'
: orphaned ? 'orphaned' : status?.kind === 'unverified' ? 'unverified' : 'active';
: orphaned ? 'orphaned' : 'active';
const userHidden = d.userHidden === true || d.marker?.hidden === true;
const combined = combineVisualSamples(sources.samples);
const lockSource = sources.visualSources.find((source) => source.eid.startsWith('lock.'));
+6 -8
View File
@@ -135,10 +135,7 @@ const rowRunners = {
}),
L06: () => {
const orphaned = resolveDevicePresentationPolicy(basePolicy({ bindingLifecycle: 'orphaned' }));
const unverified = resolveDevicePresentationPolicy(basePolicy({ bindingLifecycle: 'unverified' }));
assert.equal(orphaned.bindingUnavailable, true);
assert.equal(unverified.bindingUnavailable, true);
assertDecision(unverified, 'lifecycle.unverified_diagnostic');
return orphaned;
},
S01: sourceRow({ entities: ['cover.main'], primary: 'cover.main', tapAction: 'info' },
@@ -205,6 +202,12 @@ const rowRunners = {
assert.equal(r.visual.status, 'alarm');
assert.equal(r.criticalSources[0].eid, 'binary_sensor.smoke');
}),
S14: presentationRow({
marker: { id: 'd1', binding: 'device:d1', display: 'static_icon' },
}, { sourceDetails: false }, (r) => {
assert.equal(r.sourceKind, 'none');
assert.deepEqual(r.visual, neutral);
}),
F01: policyRow({
sourceVisual: { availability: 'available', status: 'alarm', activity: 'none' },
}, (r) => assert.equal(r.visual.status, 'alarm')),
@@ -399,11 +402,6 @@ test('device presentation decision table rows exercise lifecycle and ordered pol
const orphaned = resolveDevicePresentationPolicy(basePolicy({ bindingLifecycle: 'orphaned' }));
assertDecision(orphaned, 'lifecycle.orphaned_diagnostic');
assert.equal(orphaned.bindingUnavailable, true);
const unverified = resolveDevicePresentationPolicy(basePolicy({ bindingLifecycle: 'unverified' }));
assertDecision(unverified, 'lifecycle.unverified_diagnostic');
assert.equal(unverified.bindingUnavailable, true);
assert.equal(unverified.pulseEligible, false);
const staticFace = resolveDevicePresentationPolicy(basePolicy({
display: 'static_icon', valueAvailable: true, vacuumLiveRequested: true,
sourceVisual: { availability: 'available', status: 'alarm', activity: 'running' },
+4 -3
View File
@@ -8,9 +8,9 @@ const ROWS = [
['L01', 'pre.lifecycle.removed', 'presentation-row-contract'],
['L02', 'lifecycle.ha_disabled_hidden', 'device-presentation-policy-lifecycle'],
['L03', 'lifecycle.ha_disabled_hidden', 'device-presentation-policy-lifecycle'],
['L04', 'lifecycle.user_hidden', 'device-presentation-policy-lifecycle'],
['L05', 'lifecycle.user_hidden_preview', 'device-presentation-policy-lifecycle'],
['L06', 'lifecycle.orphaned_diagnostic', 'device-presentation-policy-lifecycle'],
['L04', 'lifecycle.user_hidden', 'device-presentation-policy-user-hidden'],
['L05', 'lifecycle.user_hidden_preview', 'device-presentation-policy-user-hidden-preview'],
['L06', 'lifecycle.orphaned_diagnostic', 'device-presentation-policy-orphaned'],
['S01', 'source.cover', 'presentation-source-decision-trace'],
['S02', 'source.cover_capability_bypassed', 'presentation-source-decision-trace'],
['S03', 'source.controls', 'controller-availability-follows-target'],
@@ -24,6 +24,7 @@ const ROWS = [
['S11', 'source.primary_fallback', 'presentation-source-decision-trace'],
['S12', 'source.none', 'presentation-source-decision-trace'],
['S13', 'source.critical_sibling', 'device-presentation-policy-alarm'],
['S14', 'source.skipped_static_fast_path', 'presentation-static-source-fast-path'],
['F01', 'status.alarm', 'device-presentation-policy-alarm'],
['F02', 'status.unavailable', 'device-presentation-policy-unavailable'],
['F03', 'source.device_role', 'presentation-source-decision-trace'],