mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-28 16:04:00 +01:00
* Add dashboard visibility condition classifier, translator and splitter Pure-logic foundation for delegating dashboard visibility conditions to core (#52836). - Add a VisibilityCondition type spanning the client-only lovelace conditions (screen, user, view_columns, location, time) and core automation conditions, alongside the existing lovelace Condition. - translate.ts: classify conditions as client- or server-evaluated and translate server ones to core format (entity -> entity_id, state_not -> not-wrap, numeric bound coercion), preserving lovelace's not = not(AND) semantics. - split.ts: split a tree into maximal server subtrees (one subscription each, sibling-grouped) plus a three-valued client combiner. * Add reactive condition evaluator controller Phase B of delegating dashboard visibility conditions to core (#52836). - ConditionEvaluatorController opens one subscribe_condition per server subtree, evaluates client leaves locally, observes screen/time boundaries, and exposes a tri-state visible/hidden/unknown result plus error. It recomputes on push/listener/hass/context change, debounces re-subscription when the tree changes, and tears down on disconnect. - Add observeConditionChanges to listeners.ts (notify-only, decoupled from checkConditionsMet) and widen extract.ts to the VisibilityCondition tree, factoring time-boundary scheduling into a shared helper. * Harden condition translation for incomplete and odd numeric inputs Follow-up to the adversarial review of the translator (#52836): - Incomplete/garbage state conditions (no entity, no value, or an empty object) now translate to an always-false core condition instead of a schema-invalid `state`, matching checkConditionsMet and avoiding a broken grouped subscription. - numeric_state bounds: coerce only finite numeric strings (incl. "" -> 0) to numbers, pass genuine entity-id references through, and drop junk or non-finite strings (matching lovelace's "NaN -> ignored" and never emitting a non-JSON-serializable Infinity). * Fix condition evaluator controller lifecycle edge cases Follow-up to the adversarial review of the controller (#52836): - Key re-subscription on a structural signature of the condition tree rather than array reference identity, so a host re-deriving the array each render neither starves the debounce nor churns subscriptions. - Reset the published result to `unknown` on host disconnect so a detached/reconnecting host never renders a stale, no-longer-live result. - Read hass lazily in the time-boundary listeners so timezone changes are picked up on the next boundary instead of being pinned at subscribe time. * Evaluate dashboard visibility through the reactive condition evaluator Rework ConditionalListenerMixin to derive visibility from ConditionEvaluatorController instead of evaluating checkConditionsMet synchronously. Stateful conditions (state, numeric_state, template, sun, zone, device, integration) are delegated to core via subscribe_condition; client-only conditions (screen, user, view_columns, location, time) stay local. The mixin re-feeds the evaluator on connect and on hass/config/ column changes, and drives _updateVisibility from its tri-state verdict. Consumers (hui-card, hui-badge, hui-section, hui-heading-badge, hui-view-sidebar, hui-conditional-base) now read the mixin's _conditionsVisible(), which prefers the server-aware verdict and falls back to an optimistic synchronous seed while a server subtree is pending — exact for legacy lovelace conditions (no flash for existing dashboards) and hidden for core-only conditions until the server reports. - fold the host entity_id context into the evaluator path via addEntityToCondition, and read core-format entity_id in checkStateCondition / checkStateNumericCondition so seed and server agree - addEntityToCondition no longer grafts a context entity onto an already-core condition that carries its own entity_id - the conditional card/row now evaluates legacy {entity, state} conditions - cache the entity-folded array so the evaluator's signature memo holds across hass updates, and drop the cached verdict when the tree changes by value so the seed is used for the new tree * Add server condition types to the dashboard visibility editor Let the visibility editor add and edit the core-format server condition types (template, sun, zone, device) by embedding the automation condition editors, which already speak core format. ha-card-condition-editor dispatches these types to ha-automation-condition-editor; the existing lovelace editors and the and/or/not containers are unchanged, and because the logical editors nest ha-card-condition-editor, mixed trees dispatch each child correctly. - extend the add-condition menu with the new types (icons + labels) - suppress the client-side live-test for server-class conditions (and any logical tree containing one); checkConditionsMet can't evaluate them, so the indicator stays neutral instead of showing a misleading failure Server-backed live-test and read-both/write-new conversion of lovelace state/numeric_state conditions are follow-ups. * Evaluate the visibility condition editor live-test through the reactive evaluator Drive the per-condition live-test indicator with the same ConditionEvaluatorController the dashboard uses at runtime, so server-class conditions (template/sun/zone/device and core-format state/numeric_state) get a real subscribe_condition-backed verdict instead of a neutral indicator. Client-only conditions stay evaluated locally and mixed logical trees combine both via three-valued logic. - Fold the card entity into the observed condition exactly as the runtime mixin does, and memoize the folded array so the evaluator's signature memo keeps hitting on hass-only updates. - Map the evaluator verdict to the indicator: visible -> pass, hidden -> fail, pending -> unknown, server error -> invalid (raw error shown as the tooltip detail, localized label kept as the aria-label). - Keep a client-side validity check for purely client trees so a malformed client-only config still surfaces as invalid. - Recurse the no-entity (filter-mode) suppression so nested entity-less conditions are handled, and report an as-yet-unknown manual test as no result rather than a failure. - Drop the now-unused invalid-config alert and its orphaned translation keys. * Evaluate the visibility status banner server-side Drive the card-level visibility summary banner with the same ConditionEvaluatorController used by the per-condition live-test, so a set containing server-class conditions reports its real visible/hidden verdict instead of being flagged as an invalid configuration. - Add a distinct "unknown" banner state for while a server result is still pending, separate from "invalid" (a genuine configuration error). - Keep a client-side validity check for purely client trees, and fold the card entity into the observed conditions, matching the per-condition editor. - Extract isPureClientCondition (every leaf client-side, as opposed to isClientCondition's any-leaf) so both consumers share one classifier. * Edit dashboard state conditions via the core condition editors Route `state` / `numeric_state` visibility conditions to the core automation condition editors (outside entity-filter mode), so they share one editing surface with the server-class types and are authored in core format. - Read both: existing lovelace-format conditions (`entity`, `state_not`, …) are translated to core for display; a `state_not` shows as `not(state)`. - Write new with touch-to-convert: opening a condition leaves it untouched; editing or adding one persists it in core format (`entity_id`, `state` list). - Entity-filter mode keeps the lovelace no-entity syntax and editors. - Register the core `state` / `numeric_state` editors (dynamicElement only renders a tag, it does not define the element) and widen the editor chain's condition arrays to the mixed visibility union. * Remove the superseded client-only condition listeners The dashboard now evaluates visibility through the reactive condition evaluator (which uses `observeConditionChanges`), so the old synchronous client-only listener path has no remaining callers. - Delete `ConditionListenersController` and the `setupConditionListeners` / `setupMediaQueryListeners` / `setupTimeListeners` helpers. - Keep `observeConditionChanges` and the shared time-boundary scheduler, and re-point their tests onto it so the scheduling edge cases stay covered. * Pin the condition editor live-test for hidden and invalid configs The per-row live-test set `invalid` (or hidden) directly but left its evaluator callback unguarded, so the evaluator's torn-down `unknown` result — fired ~500ms after observing `undefined` — clobbered the pinned state until the next hass tick re-pinned it, causing a transient flicker. Add the same `_override` guard the sibling visibility-status banner already uses: the hidden / client-invalid branches set the result directly and pin it, and the evaluator callback ignores results while pinned. * Remove the orphaned conditional-listener no-op methods `addConditionalListener` / `clearConditionalListeners` became no-op shims once the evaluator took over subscriptions and teardown; their only callers were the listener-wiring removed in the migration. Drop both methods, the mixin's now-redundant `disconnectedCallback` override, and the stale `clearConditionalListeners()` call in hui-conditional-base. * Accept server-evaluated conditions in validateConditionalConfig The conditional card/row/element gate their config on validateConditionalConfig, which only knew the client-side condition types and rejected the server-evaluated ones (template, sun, zone, device, and integration-provided conditions). Now that those are authorable and delegated to core, accept them — core validates them — so configuring one no longer throws "Invalid configuration". * Evaluate conditional picture element visibility server-side The picture-elements `conditional` element was the one visibility consumer still evaluating fully client-side, so its stateful conditions were not delegated to core (and server-class types it could not evaluate fell through to a permanently-hidden result). Convert it to a ReactiveElement driven by ConditionalListenerMixin, exactly like its sibling hui-conditional-base, so the evaluator delegates stateful conditions through subscribe_condition and evaluates the client-only ones locally. * Keep conditional cards mounted while hidden for server conditions A conditional card gated on a server-evaluated condition (template, sun, zone, device) never reappeared: hui-card removes a hidden child card from the DOM, tearing down the evaluator, and the synchronous seed can revive a client condition but not a server one, so the subscription was never (re)opened and the server result that would show the card never arrived. Set connectedWhileHidden so the conditional card/row stay mounted while hidden and keep their subscriptions alive, like the other cards that must keep working while hidden. The inner element is still unmounted when hidden, so there is no extra render cost. * Load config translations for the reused condition editors The dashboard visibility editor reuses the automation condition editors (state, numeric_state, template, sun, zone, device), which label their form fields from the `config` translation fragment. The lovelace panel never loads that fragment, so those labels rendered blank — e.g. the numeric_state limit-type selectors and the above/below fields. Load the `config` fragment when the conditions editor first renders, so the embedded editors resolve their labels. * Fold the card entity into entity-less conditions on read A card with a host entity can carry entity-less state / numeric_state visibility conditions that implicitly target that entity (folded in at runtime). The editor translated them with an empty entity_id, so the reused automation editor showed an empty, invalid entity field. Fold the current-mode context entity into the displayed condition, and recompute it when the entity context arrives (it can follow the condition). Opening still does not rewrite the stored config; only editing converts it to explicit core format. * Demote the unused state/numeric_state condition editors to base classes With the automation condition editors handling state / numeric_state in the dashboard editor, the lovelace `ha-card-condition-state` and `ha-card-condition-numeric_state` elements are no longer rendered. Their only remaining role is as the base class for the entity-filter `-no_entity` variants, so drop the now-dead custom-element registrations (and the redundant side-effect imports) while keeping the shared logic. * Format condition editors * Fix condition editor indentation * Invalidate the condition evaluator result as soon as the tree changes When the observed tree changed by value, the controller kept the old split and subscriptions alive until the debounced re-subscribe fired, so it kept publishing (and the editor's Test action reported) the previous tree's verdict, and a host that had already reset to unknown never got a notification when the new tree settled on the same result. Tear the old tree down when the signature changes and publish unknown until the new subscription reports. Trees with nothing to subscribe to re-evaluate immediately rather than waiting out the debounce. Track a pending re-subscribe with its own flag: undefined is a valid signature (observation cleared), so using it as the "nothing pending" marker kept rescheduling the timer on every hass update and could leave the previous subscription open indefinitely. * Translate bound-less numeric_state conditions to a schema-valid core condition Dropping a junk or non-finite bound could leave a numeric_state condition with neither above nor below, which core's schema rejects; because sibling server conditions are grouped into one subscription, that single invalid leaf failed the whole group and hid the content. Lovelace merely ignored such bounds and only required the value to be numeric, so express that residual check as a template condition instead. An entity-less numeric_state resolves to always-false, matching checkConditionsMet. * Validate every visibility condition client-side and handle unsupported automation editor configs validateConditionalConfig already accepts the server-evaluated types, so gating it behind "every condition is client-only" only served to skip validation as soon as a server condition was present; a malformed screen condition next to a template condition was reported as hidden rather than invalid. Run it for the whole set. The embedded automation condition editors fire ui-mode-not-available when an existing core config fails their UI struct. Fall back to YAML with the struct warnings, as the automation condition row does, instead of rendering a blank editor while still claiming UI support. * Type dashboard visibility contracts as VisibilityCondition Card, badge, section, view sidebar and heading badge visibility, plus the conditional card, row and element conditions, still declared the lovelace-only Condition type, so the newly supported template, sun, zone, device and integration conditions were invalid at the config boundary and needed casts throughout. Declare them as VisibilityCondition, widen validateConditionalConfig accordingly and drop the casts that made redundant. Entity-filter conditions stay lovelace-only. * Subscribe when hass arrives after the conditions were observed The subscribed signature was recorded before checking that hass was available. A host that connected with its config set but received hass later therefore never subscribed: the later observe saw the same signature, found no split and stayed unknown for good. Only record the signature once a subscription is actually opened. * Seed pending visibility with a three-valued local evaluation While a server result was pending, the mixin ran the whole tree through the legacy client evaluator. Core-only leaves default to false there, so not: [template] was inverted to visible until core replied, and core state / numeric_state options such as for, match and value_template were ignored, both contradicting the no-flash guarantee. Evaluate locally only the leaves the legacy evaluator reproduces exactly (client-only types, lovelace state / numeric_state, and core ones without those options), keep every other leaf unknown through and / or / not, and hide while the outcome still depends on an unknown leaf. * Hide server-only condition types in entity-filter mode The map and entity-filter cards still evaluate their conditions locally with checkConditionsMet, where template / sun / zone / device fall through to a failing state check and filter out every entity. Don't offer them in the add menu for that mode. * Leave conditions carrying enabled to the server in the local seed Core treats a disabled condition as neutral (and enabled may be a template), while the legacy evaluator ignores enabled altogether. A pending not: [disabled failing state, ...] was therefore seeded visible although core reports hidden. Treat any node carrying enabled as unknown. * Reject filter-incompatible pasted conditions in entity-filter mode The condition clipboard is shared across editors in the session, so a template / sun / zone / device condition, or a core-format leaf pinned to its own entity_id, could be pasted into a map or entity-filter editor, where the local evaluator would filter out every entity or evaluate the wrong one. Hide the paste entry and ignore the action for such clipboard contents while in filter mode. * Re-feed the section visibility evaluator after a strategy config resolves _config is not reactive, and for a strategy section it is assigned after the Lit update that last fed the evaluator, so _conditionsVisible kept reading the verdict for the raw or previous config until the next hass update. Re-run setupConditionalListeners right after assigning it. * Hide content whenever a server condition subtree errors An errored subtree was recorded as false and fed through the combinators, so a client-side not inverted it and an invalid server condition could show content; the runtime mixin only looks at the result, not the error. Force the result to hidden while any subtree is in error. * Honor enabled: false on visibility conditions Core skips a disabled condition inside a compound: it neither passes nor fails. Rebuilding logical conditions during translation dropped enabled (and the other row metadata), so a disabled server-side group was evaluated, and the mixed-tree split and the local seed ignored it as well. Preserve the row metadata on translated logical conditions, skip disabled nodes when splitting and when seeding, and leave a template-valued enabled on a seeded node unknown since only core can render it. * Type the conditional card conditions as VisibilityCondition ConditionalCardConfig, the contract HuiConditionalBase actually consumes, still declared the lovelace-only union, unlike the row and element contracts. * Keep a client-side node with a template-valued enabled unknown Only core can render enabled: "{{ ... }}", and core never sees a mixed or client-only node, so the split silently treated such a node as enabled and let its children decide visibility. Keep it unknown instead, erring toward hiding and matching the optimistic seed. * Whitelist the condition types usable as entity filters The filter-compatibility check only excluded the four built-in server types, so an integration-provided condition copied from a visibility editor could still be pasted into an entity-filter editor, where the local evaluator cannot evaluate it and filters out every entity. Check against the types checkConditionsMet supports instead. * Fold the host entity into legacy entity-less conditions as well A legacy condition such as { state: "on" } has no condition key, so addEntityToCondition never stamped the card entity on it. That used to be harmless because checkConditionsMet fell back to the evaluation context, but the server translation has no context and turned the condition into always-false once core reported. Treat the legacy shape as the state condition it is, and make the helper generic over the visibility union so callers no longer need casts. * Keep the lovelace outcome for infinite numeric bounds Number("1e400") is Infinity, which lovelace does not ignore: above +inf or below -inf can never pass, while above -inf or below +inf always do. Dropping such a bound could flip an untouched condition from hidden to visible. Resolve the unsatisfiable cases to always-false and drop only the vacuous ones. Infinity is not JSON-serializable, so neither is ever sent. * Only leave a template-valued enabled unknown in the local seed The check also matched enabled: true, so an explicitly enabled condition was seeded unknown and the card hidden until core responded, exactly the flash the seed is meant to avoid. * Show no test chip while the condition evaluator reports an error An invalid server condition evaluates to hidden plus an error; reading only the result turned Test into a transient "not met" chip that contradicted the invalid live indicator. * Seed only core conditions the client compares identically The optimistic seed still admitted core-format shapes the legacy evaluator handles differently: an attribute comparison (core compares the raw value, the client stringifies it) and an entity-valued numeric bound (core errors when the entity is missing, the client ignores the bound). Leave those unknown until core reports. * Accept a single condition as the children of a logical condition Core's LogicalCondition allows conditions to be one condition or a list, but every traversal here assumed a list and crashed on the single shape. Widen the type accordingly and route all traversals through one helper. * Keep lovelace leaves with legacy-only semantics client-side Core compares raw attribute values, only dereferences input_* comparison values and errors on a missing bound entity, where lovelace stringifies the attribute, resolves any existing entity and ignores the bound. Delegating such existing conditions changed their visibility silently. Classify a lovelace-format state / numeric_state leaf that relies on any of those as client-side so it keeps its exact legacy evaluation; once the user edits it, it is saved in core format and evaluated by core. * Drop the pinned editor indicators when leaving an override branch The empty and client-invalid branches pin the indicator and clear the evaluator, which is then already unknown; when the condition becomes valid the pending observation is unknown too, so no notification arrives and the stale pinned state stayed visible until core first responded. * Reject core's single-child logical shorthand as an entity filter Filter consumers and the filter-mode editor expect the lovelace list shape for logical children, so a pasted core condition using the single-child shorthand caused a runtime error there. Require a list in the filter-compatibility check and traverse defensively when looking for no-entity conditions. * Seed only core leaves whose entity is present and, for numeric_state, bounded Core errors on a missing entity and rejects a bound-less numeric_state, while the client evaluates a missing entity as unknown and passes any numeric value, so both shapes could flash visible before the subscription error arrived. Leave them unknown until core reports. * Re-subscribe when the hass connection object is replaced Subscriptions are bound to the connection they were opened on. An unchanged tree observed with a hass carrying a different connection kept the stale subscriptions; re-open them in that case while still not churning on ordinary hass updates. * Keep non-finite bounds distinguishable in the condition signature JSON.stringify serializes Infinity, -Infinity and NaN alike as null, so flipping a YAML .inf bound produced the same signature and the old subscription stayed active. * Seed lovelace leaves only while their target entity exists Core reports an error for a condition on a missing entity, which hides the tree, while the legacy evaluator compares against the literal unknown. Seeding such a leaf optimistically could therefore flash the content before the error arrived; leave it unknown instead. * Do not seed a core state condition that compares against another entity checkConditionsMet dereferences any existing entity used as a comparison value, while core only dereferences input_* entities, so such a core leaf could flash visible before the subscription reported false. Apply the same entity-reference guard the legacy classification uses. * Preserve row metadata when rebuilding lovelace leaves Both the evaluation translation and the editor's display conversion rebuilt lovelace state / numeric_state leaves without enabled, alias and note, so editing a disabled legacy condition silently re-enabled it and a template-valued enabled was dropped before reaching core. * Surface template rendering errors from condition subscriptions subscribe_condition events carry template_errors next to a result when a template inside the condition failed to render. Forward them through the evaluator's error so the editor flags the condition, without overriding core's result the way a hard error does. * Treat a null numeric bound as absent Lovelace ignores a null bound (== null), but the translator only checked for undefined and Number(null) turned it into an unintended 0. The editor conversion now skips null bounds as well. * Keep the and wrapper for a single-child not and row metadata on fallbacks Core skips a disabled child, so a bare not over one child evaluates to true where lovelace's not (¬ of the AND of its children) gives false; the and wrapper restores that for any arity. The always-false and numeric value fallbacks also dropped enabled, alias and note, so a template-disabled leaf would have participated in its parent regardless. * Treat an empty legacy entity as absent everywhere checkStateCondition falls back to the host entity with entity || context, so an existing condition with entity: "" targeted the card entity. The entity fold, the optimistic seed, the editor conversion and the translation used nullish checks instead, which kept the empty string and turned such a condition into an error or an always-false leaf. Use the same truthy fallback throughout. * Append non-finite values to the condition signature instead of substituting A marker string standing in for Infinity could in principle collide with an equal user string. Keep plain JSON and append the ordered non-finite values, which is unambiguous. * Remove a stray brace rendered under every condition editor A leftover interpolation terminator outside the template expression rendered a literal "}" below the condition content. Also reject pasting a condition pinned to an explicit entity into an entity filter, where the fold-in would keep it and judge every candidate by the copied entity. * Tidy the optimistic seed's leaf check Condense its doc comment, take the states map rather than the whole hass object, and simplify the bound checks. * Tighten comments on dashboard condition evaluation. --------- Co-authored-by: Aidan Timson <aidan@timmo.dev>
485 lines
16 KiB
TypeScript
485 lines
16 KiB
TypeScript
import type { ReactiveControllerHost } from "@lit/reactive-element/reactive-controller";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { ConditionEvaluation } from "../../../src/common/controllers/condition-evaluator-controller";
|
|
import { ConditionEvaluatorController } from "../../../src/common/controllers/condition-evaluator-controller";
|
|
import type { VisibilityCondition } from "../../../src/panels/lovelace/common/validate-condition";
|
|
import type { HomeAssistant } from "../../../src/types";
|
|
|
|
const cond = (c: any): VisibilityCondition => c as VisibilityCondition;
|
|
|
|
const tick = () =>
|
|
new Promise<void>((resolve) => {
|
|
setTimeout(resolve, 10);
|
|
});
|
|
|
|
interface CapturedSubscription {
|
|
condition: any;
|
|
push: (message: {
|
|
result?: boolean;
|
|
error?: string | { code: string; message: string };
|
|
template_errors?: string[];
|
|
}) => void;
|
|
unsub: ReturnType<typeof vi.fn>;
|
|
}
|
|
|
|
let subs: CapturedSubscription[];
|
|
|
|
const createHost = (): ReactiveControllerHost => ({
|
|
addController: vi.fn(),
|
|
removeController: vi.fn(),
|
|
requestUpdate: vi.fn(),
|
|
updateComplete: Promise.resolve(true),
|
|
});
|
|
|
|
const createHass = (overrides: Partial<HomeAssistant> = {}): HomeAssistant =>
|
|
({
|
|
connection: {
|
|
subscribeMessage: vi.fn((onChange: any, msg: any) => {
|
|
const unsub = vi.fn();
|
|
subs.push({ condition: msg.condition, push: onChange, unsub });
|
|
return Promise.resolve(unsub);
|
|
}),
|
|
},
|
|
states: {},
|
|
user: { id: "user1" },
|
|
locale: { time_zone: "local" },
|
|
config: { time_zone: "America/New_York" },
|
|
...overrides,
|
|
}) as unknown as HomeAssistant;
|
|
|
|
interface Harness {
|
|
controller: ConditionEvaluatorController;
|
|
host: ReactiveControllerHost;
|
|
results: { result: ConditionEvaluation; error?: string }[];
|
|
last: () => { result: ConditionEvaluation; error?: string } | undefined;
|
|
}
|
|
|
|
const setup = async (
|
|
conditions: VisibilityCondition[],
|
|
hass: HomeAssistant = createHass()
|
|
): Promise<Harness> => {
|
|
const host = createHost();
|
|
const results: { result: ConditionEvaluation; error?: string }[] = [];
|
|
const controller = new ConditionEvaluatorController(host, {
|
|
resubscribeDelay: 0,
|
|
onResult: (result, error) => results.push({ result, error }),
|
|
});
|
|
controller.hostConnected();
|
|
controller.observe(conditions, hass);
|
|
await tick();
|
|
return { controller, host, results, last: () => results[results.length - 1] };
|
|
};
|
|
|
|
describe("ConditionEvaluatorController", () => {
|
|
beforeEach(() => {
|
|
subs = [];
|
|
global.matchMedia = vi.fn().mockImplementation((query) => ({
|
|
matches: false,
|
|
media: query,
|
|
onchange: null,
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("evaluates an all-client tree without opening subscriptions", async () => {
|
|
const { controller, last } = await setup([
|
|
cond({ condition: "user", users: ["user1"] }),
|
|
]);
|
|
expect(subs).toHaveLength(0);
|
|
expect(controller.result).toBe("visible");
|
|
expect(last()?.result).toBe("visible");
|
|
});
|
|
|
|
it("hides when an all-client condition is not met", async () => {
|
|
const { controller } = await setup([
|
|
cond({ condition: "user", users: ["other"] }),
|
|
]);
|
|
expect(controller.result).toBe("hidden");
|
|
});
|
|
|
|
it("opens one subscription per server leaf with the translated core condition", async () => {
|
|
const { controller } = await setup([
|
|
cond({ condition: "state", entity: "light.a", state: "on" }),
|
|
]);
|
|
expect(subs).toHaveLength(1);
|
|
expect(subs[0].condition).toEqual({
|
|
condition: "state",
|
|
entity_id: "light.a",
|
|
state: "on",
|
|
});
|
|
// unknown until the first push
|
|
expect(controller.result).toBe("unknown");
|
|
});
|
|
|
|
it("updates the result from server pushes", async () => {
|
|
const { controller } = await setup([
|
|
cond({ condition: "state", entity: "light.a", state: "on" }),
|
|
]);
|
|
subs[0].push({ result: true });
|
|
expect(controller.result).toBe("visible");
|
|
subs[0].push({ result: false });
|
|
expect(controller.result).toBe("hidden");
|
|
});
|
|
|
|
it("groups sibling server conditions into a single subscription", async () => {
|
|
await setup([
|
|
cond({ condition: "state", entity: "light.a", state: "on" }),
|
|
cond({ condition: "template", value_template: "{{ true }}" }),
|
|
]);
|
|
expect(subs).toHaveLength(1);
|
|
expect(subs[0].condition).toEqual({
|
|
condition: "and",
|
|
conditions: [
|
|
{ condition: "state", entity_id: "light.a", state: "on" },
|
|
{ condition: "template", value_template: "{{ true }}" },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("combines a server subtree with a client leaf", async () => {
|
|
// user1 matches → client leaf true; result follows the server push
|
|
const { controller } = await setup([
|
|
cond({ condition: "state", entity: "light.a", state: "on" }),
|
|
cond({ condition: "user", users: ["user1"] }),
|
|
]);
|
|
expect(subs).toHaveLength(1);
|
|
subs[0].push({ result: true });
|
|
expect(controller.result).toBe("visible");
|
|
subs[0].push({ result: false });
|
|
expect(controller.result).toBe("hidden");
|
|
});
|
|
|
|
it("hides and surfaces the message on a subscription error", async () => {
|
|
const { controller, last } = await setup([
|
|
cond({ condition: "state", entity: "light.a", state: "on" }),
|
|
]);
|
|
subs[0].push({ error: "Invalid condition" });
|
|
expect(controller.result).toBe("hidden");
|
|
expect(controller.error).toBe("Invalid condition");
|
|
expect(last()?.error).toBe("Invalid condition");
|
|
});
|
|
|
|
it("stays hidden on a subscription error even under a client-side not", async () => {
|
|
// Don't treat an error as `false` that `not` would invert to visible.
|
|
const { controller } = await setup([
|
|
cond({
|
|
condition: "not",
|
|
conditions: [
|
|
{ condition: "user", users: ["other"] },
|
|
{ condition: "template", value_template: "{{ broken" },
|
|
],
|
|
}),
|
|
]);
|
|
expect(subs).toHaveLength(1);
|
|
subs[0].push({ error: { code: "invalid_format", message: "bad" } });
|
|
expect(controller.result).toBe("hidden");
|
|
expect(controller.error).toBe("bad");
|
|
});
|
|
|
|
it("surfaces template errors without overriding the result", async () => {
|
|
// Template render errors are flagged; they don't force hidden.
|
|
const { controller, last } = await setup([
|
|
cond({ condition: "template", value_template: "{{ x }}" }),
|
|
]);
|
|
subs[0].push({ result: true, template_errors: ["'x' is undefined"] });
|
|
expect(controller.result).toBe("visible");
|
|
expect(controller.error).toBe("'x' is undefined");
|
|
expect(last()?.error).toBe("'x' is undefined");
|
|
subs[0].push({ result: true });
|
|
expect(controller.error).toBeUndefined();
|
|
});
|
|
|
|
it("clears the error once the subscription recovers", async () => {
|
|
const { controller } = await setup([
|
|
cond({ condition: "state", entity: "light.a", state: "on" }),
|
|
]);
|
|
subs[0].push({ error: "boom" });
|
|
expect(controller.error).toBe("boom");
|
|
subs[0].push({ result: true });
|
|
expect(controller.result).toBe("visible");
|
|
expect(controller.error).toBeUndefined();
|
|
});
|
|
|
|
it("re-subscribes when the condition tree changes and tears the old one down", async () => {
|
|
const hass = createHass();
|
|
const host = createHost();
|
|
const controller = new ConditionEvaluatorController(host, {
|
|
resubscribeDelay: 0,
|
|
onResult: () => undefined,
|
|
});
|
|
controller.hostConnected();
|
|
|
|
controller.observe(
|
|
[cond({ condition: "state", entity: "light.a", state: "on" })],
|
|
hass
|
|
);
|
|
await tick();
|
|
expect(subs).toHaveLength(1);
|
|
const firstUnsub = subs[0].unsub;
|
|
|
|
controller.observe(
|
|
[cond({ condition: "state", entity: "light.b", state: "off" })],
|
|
hass
|
|
);
|
|
await tick();
|
|
|
|
expect(firstUnsub).toHaveBeenCalledTimes(1);
|
|
expect(subs).toHaveLength(2);
|
|
expect(subs[1].condition).toEqual({
|
|
condition: "state",
|
|
entity_id: "light.b",
|
|
state: "off",
|
|
});
|
|
});
|
|
|
|
it("publishes unknown and ignores stale pushes while a re-subscribe is pending", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const hass = createHass();
|
|
const host = createHost();
|
|
const results: ConditionEvaluation[] = [];
|
|
const controller = new ConditionEvaluatorController(host, {
|
|
resubscribeDelay: 1000,
|
|
onResult: (result) => results.push(result),
|
|
});
|
|
controller.hostConnected();
|
|
|
|
controller.observe(
|
|
[cond({ condition: "state", entity: "light.a", state: "on" })],
|
|
hass
|
|
);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
const stale = subs[0];
|
|
stale.push({ result: true });
|
|
expect(controller.result).toBe("visible");
|
|
|
|
// Drop the old subscription immediately; don't keep its result.
|
|
controller.observe(
|
|
[cond({ condition: "state", entity: "light.a", state: "off" })],
|
|
hass
|
|
);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(stale.unsub).toHaveBeenCalledTimes(1);
|
|
expect(controller.result).toBe("unknown");
|
|
expect(results[results.length - 1]).toBe("unknown");
|
|
stale.push({ result: true });
|
|
expect(controller.result).toBe("unknown");
|
|
expect(subs).toHaveLength(1);
|
|
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(subs).toHaveLength(2);
|
|
subs[1].push({ result: false });
|
|
expect(controller.result).toBe("hidden");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("re-subscribes an all-client tree change immediately", async () => {
|
|
const hass = createHass();
|
|
const host = createHost();
|
|
const controller = new ConditionEvaluatorController(host, {
|
|
resubscribeDelay: 1000,
|
|
onResult: () => undefined,
|
|
});
|
|
controller.hostConnected();
|
|
controller.observe([cond({ condition: "user", users: ["user1"] })], hass);
|
|
expect(controller.result).toBe("visible");
|
|
controller.observe([cond({ condition: "user", users: ["other"] })], hass);
|
|
// no debounce for a tree with nothing to subscribe to
|
|
expect(controller.result).toBe("hidden");
|
|
});
|
|
|
|
it("drops the subscription when observation is cleared, even under hass churn", async () => {
|
|
const host = createHost();
|
|
const controller = new ConditionEvaluatorController(host, {
|
|
resubscribeDelay: 20,
|
|
onResult: () => undefined,
|
|
});
|
|
controller.hostConnected();
|
|
controller.observe(
|
|
[cond({ condition: "state", entity: "light.a", state: "on" })],
|
|
createHass()
|
|
);
|
|
await new Promise<void>((resolve) => {
|
|
setTimeout(resolve, 30);
|
|
});
|
|
expect(subs).toHaveLength(1);
|
|
|
|
// Clearing used to keep rescheduling because `undefined` meant "nothing pending".
|
|
controller.observe(undefined, createHass());
|
|
controller.observe(undefined, createHass());
|
|
controller.observe(undefined, createHass());
|
|
await tick();
|
|
expect(subs[0].unsub).toHaveBeenCalledTimes(1);
|
|
expect(subs).toHaveLength(1);
|
|
expect(controller.result).toBe("unknown");
|
|
});
|
|
|
|
it("subscribes once hass arrives after the conditions were observed", async () => {
|
|
const host = createHost();
|
|
const controller = new ConditionEvaluatorController(host, {
|
|
resubscribeDelay: 0,
|
|
onResult: () => undefined,
|
|
});
|
|
const conditions = [
|
|
cond({ condition: "state", entity: "light.a", state: "on" }),
|
|
];
|
|
// Host connected with its config set, hass not yet provided.
|
|
controller.hostConnected();
|
|
controller.observe(conditions, undefined);
|
|
await tick();
|
|
expect(subs).toHaveLength(0);
|
|
expect(controller.result).toBe("unknown");
|
|
|
|
controller.observe(conditions, createHass());
|
|
await tick();
|
|
expect(subs).toHaveLength(1);
|
|
subs[0].push({ result: true });
|
|
expect(controller.result).toBe("visible");
|
|
});
|
|
|
|
it("re-subscribes when a non-finite bound flips sign", async () => {
|
|
// JSON.stringify maps both infinities to null, so the signature would match.
|
|
const hass = createHass();
|
|
const { controller } = await setup(
|
|
[
|
|
cond({
|
|
condition: "numeric_state",
|
|
entity: "sensor.a",
|
|
above: 5,
|
|
below: Infinity,
|
|
}),
|
|
],
|
|
hass
|
|
);
|
|
expect(subs).toHaveLength(1);
|
|
controller.observe(
|
|
[
|
|
cond({
|
|
condition: "numeric_state",
|
|
entity: "sensor.a",
|
|
above: 5,
|
|
below: -Infinity,
|
|
}),
|
|
],
|
|
hass
|
|
);
|
|
await tick();
|
|
expect(subs).toHaveLength(2);
|
|
});
|
|
|
|
it("does not re-subscribe when only hass changes", async () => {
|
|
const conditions = [
|
|
cond({ condition: "state", entity: "light.a", state: "on" }),
|
|
];
|
|
const hass = createHass();
|
|
const { controller } = await setup(conditions, hass);
|
|
expect(subs).toHaveLength(1);
|
|
|
|
// same conditions reference, new hass object on the same connection →
|
|
// recompute only, no new subscription
|
|
controller.observe(conditions, { ...hass, states: {} } as HomeAssistant);
|
|
await tick();
|
|
expect(subs).toHaveLength(1);
|
|
});
|
|
|
|
it("re-subscribes when the connection object is replaced", async () => {
|
|
const conditions = [
|
|
cond({ condition: "state", entity: "light.a", state: "on" }),
|
|
];
|
|
const { controller } = await setup(conditions);
|
|
expect(subs).toHaveLength(1);
|
|
const firstUnsub = subs[0].unsub;
|
|
|
|
// a replacement connection carries none of the old subscriptions
|
|
controller.observe(conditions, createHass());
|
|
await tick();
|
|
expect(firstUnsub).toHaveBeenCalledTimes(1);
|
|
expect(subs).toHaveLength(2);
|
|
});
|
|
|
|
it("tears down subscriptions on host disconnect", async () => {
|
|
const { controller } = await setup([
|
|
cond({ condition: "state", entity: "light.a", state: "on" }),
|
|
]);
|
|
const { unsub } = subs[0];
|
|
controller.hostDisconnected();
|
|
await tick();
|
|
expect(unsub).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("ignores pushes that arrive after teardown", async () => {
|
|
const { controller } = await setup([
|
|
cond({ condition: "state", entity: "light.a", state: "on" }),
|
|
]);
|
|
const stale = subs[0];
|
|
controller.hostDisconnected();
|
|
await tick();
|
|
stale.push({ result: true });
|
|
// still unknown — the stale push from the torn-down subscription is ignored
|
|
expect(controller.result).toBe("unknown");
|
|
});
|
|
|
|
it("does not re-subscribe when a fresh array of equal content is passed", async () => {
|
|
// A new array with the same content must not churn subscriptions.
|
|
const make = () => [
|
|
cond({ condition: "state", entity: "light.a", state: "on" }),
|
|
];
|
|
const hass = createHass();
|
|
const { controller } = await setup(make(), hass);
|
|
expect(subs).toHaveLength(1);
|
|
const firstUnsub = subs[0].unsub;
|
|
|
|
controller.observe(make(), { ...hass, states: {} } as HomeAssistant);
|
|
await tick();
|
|
|
|
expect(subs).toHaveLength(1);
|
|
expect(firstUnsub).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports unknown after disconnect rather than a stale result", async () => {
|
|
const { controller, last } = await setup([
|
|
cond({ condition: "state", entity: "light.a", state: "on" }),
|
|
]);
|
|
subs[0].push({ result: true });
|
|
expect(controller.result).toBe("visible");
|
|
|
|
controller.hostDisconnected();
|
|
await tick();
|
|
|
|
expect(controller.result).toBe("unknown");
|
|
expect(last()?.result).toBe("unknown");
|
|
});
|
|
|
|
it("notifies onResult only when the result actually changes", async () => {
|
|
const { results } = await setup([
|
|
cond({ condition: "state", entity: "light.a", state: "on" }),
|
|
]);
|
|
const countBefore = results.length;
|
|
subs[0].push({ result: true });
|
|
subs[0].push({ result: true });
|
|
subs[0].push({ result: true });
|
|
// one change (unknown → visible), the repeats are coalesced
|
|
expect(results.length).toBe(countBefore + 1);
|
|
expect(results[results.length - 1].result).toBe("visible");
|
|
});
|
|
|
|
it("requests a host update when the result changes", async () => {
|
|
const { host } = await setup([
|
|
cond({ condition: "state", entity: "light.a", state: "on" }),
|
|
]);
|
|
(host.requestUpdate as ReturnType<typeof vi.fn>).mockClear();
|
|
subs[0].push({ result: true });
|
|
expect(host.requestUpdate).toHaveBeenCalled();
|
|
});
|
|
});
|