feat(automations): expand automation rules + UI + engine coverage

Storage model + Pydantic schema + route surface gain the new rule
shapes the engine already supports. Frontend automations editor grows
the matching inputs. New core/test_automation_engine.py pins the
dispatch table rules behind ~285 lines of unit coverage.
This commit is contained in:
2026-05-23 00:48:19 +03:00
parent f03cb303c3
commit 3fe66d80cb
5 changed files with 533 additions and 3 deletions
@@ -23,6 +23,7 @@ from ledgrab.storage.automation import (
ApplicationRule, ApplicationRule,
DisplayStateRule, DisplayStateRule,
HomeAssistantRule, HomeAssistantRule,
HTTPPollRule,
MQTTRule, MQTTRule,
Rule, Rule,
StartupRule, StartupRule,
@@ -75,6 +76,11 @@ def _rule_from_schema(s: RuleSchema) -> Rule:
state=s.state or "", state=s.state or "",
match_mode=s.match_mode or "exact", match_mode=s.match_mode or "exact",
), ),
"http_poll": lambda: HTTPPollRule(
value_source_id=s.value_source_id or "",
operator=s.operator or "equals",
value=s.value or "",
),
} }
factory = _SCHEMA_TO_RULE.get(s.rule_type) factory = _SCHEMA_TO_RULE.get(s.rule_type)
if factory is None: if factory is None:
@@ -46,6 +46,24 @@ class RuleSchema(BaseModel):
None, None,
description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)", description="HA entity ID, e.g. 'binary_sensor.front_door' (for home_assistant rule)",
) )
# HTTP poll rule fields
value_source_id: Optional[str] = Field(
None,
description=(
"Value source ID (for http_poll rule). The referenced "
"ValueSource must be of source_type='http'."
),
)
operator: Optional[str] = Field(
None,
description=(
"Comparison operator for http_poll rule: "
"'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists'."
),
)
value: Optional[str] = Field(
None, description="Expected value (for http_poll rule; ignored for 'exists')"
)
# Backward-compatible alias # Backward-compatible alias
@@ -5,6 +5,7 @@
import { import {
apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj, apiKey, _automationsLoading, set_automationsLoading, automationsCacheObj,
scenePresetsCache, _cachedHASources, haSourcesCache, scenePresetsCache, _cachedHASources, haSourcesCache,
_cachedValueSources, valueSourcesCache,
getHAEntityFriendlyName, setHAEntityNames, getHAEntityFriendlyName, setHAEntityNames,
} from '../core/state.ts'; } from '../core/state.ts';
import { prefetchHAEntities } from './home-assistant-sources.ts'; import { prefetchHAEntities } from './home-assistant-sources.ts';
@@ -26,6 +27,7 @@ import { registerIconEntityType, makeSimpleIconAdapter } from './icon-picker.ts'
import { getBaseOrigin } from './settings.ts'; import { getBaseOrigin } from './settings.ts';
import { IconSelect } from '../core/icon-select.ts'; import { IconSelect } from '../core/icon-select.ts';
import { EntitySelect } from '../core/entity-palette.ts'; import { EntitySelect } from '../core/entity-palette.ts';
import { enhanceMiniSelects } from '../core/mini-select.ts';
import { attachProcessPicker } from '../core/process-picker.ts'; import { attachProcessPicker } from '../core/process-picker.ts';
import { TreeNav } from '../core/tree-nav.ts'; import { TreeNav } from '../core/tree-nav.ts';
import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts'; import { csScenes, createSceneCard, initScenePresetDelegation } from './scene-presets.ts';
@@ -106,6 +108,12 @@ class AutomationEditorModal extends Modal {
onForceClose() { onForceClose() {
if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; } if (_automationTagsInput) { _automationTagsInput.destroy(); _automationTagsInput = null; }
// Tear down any per-rule portal widgets (http_poll EntitySelect /
// IconSelect) the rule rows attached. Walks every rule row in the
// modal, no-op for rows that didn't stash widgets.
document
.querySelectorAll<HTMLElement>('#automation-rules-list .rule-fields-container')
.forEach(c => _disposeHTTPPollWidgets(c));
} }
snapshotValues() { snapshotValues() {
@@ -226,6 +234,7 @@ export async function loadAutomations() {
automationsCacheObj.fetch(), automationsCacheObj.fetch(),
scenePresetsCache.fetch(), scenePresetsCache.fetch(),
haSourcesCache.fetch(), haSourcesCache.fetch(),
valueSourcesCache.fetch(),
]); ]);
const sceneMap = new Map(scenes.map(s => [s.id, s])); const sceneMap = new Map(scenes.map(s => [s.id, s]));
@@ -345,8 +354,67 @@ const RULE_CHIP_RENDERERS: Record<string, RuleChipBuilder> = {
title: tooltip, title: tooltip,
}; };
}, },
http_poll: (c) => {
const vsId = c.value_source_id || '';
const vs = (_cachedValueSources || []).find(v => v.id === vsId);
const vsLabel = vs?.name || (vsId ? vsId : t('automations.rule.http_poll.no_source'));
const op = c.operator || 'equals';
const opGlyph = _httpOpGlyph(op);
const rhs = op === 'exists' ? '' : ` ${c.value ?? ''}`;
return {
icon: _icon(P.globe),
text: `${vsLabel} ${opGlyph}${rhs}`,
title: t('automations.rule.http_poll'),
};
},
}; };
type HTTPPollOp = 'equals' | 'not_equals' | 'contains' | 'regex' | 'gt' | 'lt' | 'exists';
const HTTP_OP_KEYS: HTTPPollOp[] = [
'equals', 'not_equals', 'contains', 'regex', 'gt', 'lt', 'exists',
];
const _HTTP_OP_GLYPHS: Record<HTTPPollOp, string> = {
equals: '=',
not_equals: '≠',
contains: '∈',
regex: '/.../',
gt: '>',
lt: '<',
exists: '?',
};
const _HTTP_OP_ICON_PATHS: Record<HTTPPollOp, string> = {
equals: P.check,
not_equals: P.circleOff,
contains: P.search,
regex: P.code,
gt: P.chevronUp,
lt: P.chevronDown,
exists: P.zap,
};
function _httpOpGlyph(op: string): string {
return _HTTP_OP_GLYPHS[op as HTTPPollOp] ?? '=';
}
function _httpOpIconPath(op: string): string {
return _HTTP_OP_ICON_PATHS[op as HTTPPollOp] ?? P.check;
}
/** Destroy any EntitySelect / IconSelect widgets that the http_poll rule
* branch attached to *container*. Safe to call when there are none —
* it just clears the stash. Called both before re-rendering the rule's
* fields (rule-type change) and before removing the rule row entirely. */
function _disposeHTTPPollWidgets(container: HTMLElement): void {
const stash = (container as any)._httpPollWidgets;
if (!stash) return;
try { stash.vsEntitySelect?.destroy?.(); } catch { /* widget already gone */ }
try { stash.opIconSelect?.destroy?.(); } catch { /* widget already gone */ }
delete (container as any)._httpPollWidgets;
}
/** Render a chain-arrow separator span. `+` between AND-rules, /** Render a chain-arrow separator span. `+` between AND-rules,
* the localised OR label between OR-rules, and `→` for the * the localised OR label between OR-rules, and `→` for the
* rule-chain → scene-activation transition. */ * rule-chain → scene-activation transition. */
@@ -681,11 +749,12 @@ export function addAutomationRule() {
_autoGenerateAutomationName(); _autoGenerateAutomationName();
} }
const RULE_TYPE_KEYS = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant']; const RULE_TYPE_KEYS = ['startup', 'application', 'time_of_day', 'system_idle', 'display_state', 'mqtt', 'webhook', 'home_assistant', 'http_poll'];
const RULE_TYPE_ICONS = { const RULE_TYPE_ICONS = {
startup: P.power, application: P.smartphone, startup: P.power, application: P.smartphone,
time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor, time_of_day: P.clock, system_idle: P.moon, display_state: P.monitor,
mqtt: P.radio, webhook: P.globe, home_assistant: P.home, mqtt: P.radio, webhook: P.globe, home_assistant: P.home,
http_poll: P.globe,
}; };
const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen']; const MATCH_TYPE_KEYS = ['running', 'topmost', 'topmost_fullscreen', 'fullscreen'];
@@ -777,7 +846,7 @@ function addAutomationRuleRow(rule: any) {
<select class="rule-type-select"> <select class="rule-type-select">
${RULE_TYPE_KEYS.map(k => `<option value="${k}" ${ruleType === k ? 'selected' : ''}>${t('automations.rule.' + k)}</option>`).join('')} ${RULE_TYPE_KEYS.map(k => `<option value="${k}" ${ruleType === k ? 'selected' : ''}>${t('automations.rule.' + k)}</option>`).join('')}
</select> </select>
<button type="button" class="btn-remove-rule" onclick="this.closest('.automation-rule-row').remove(); if(window._autoGenerateAutomationName) window._autoGenerateAutomationName();" title="Remove">${ICON_TRASH}</button> <button type="button" class="btn-remove-rule" title="Remove">${ICON_TRASH}</button>
</div> </div>
<div class="rule-fields-container" style="display:none"></div> <div class="rule-fields-container" style="display:none"></div>
`; `;
@@ -794,14 +863,29 @@ function addAutomationRuleRow(rule: any) {
const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement; const typeSelect = row.querySelector('.rule-type-select') as HTMLSelectElement;
const container = row.querySelector('.rule-fields-container') as HTMLElement; const container = row.querySelector('.rule-fields-container') as HTMLElement;
// Remove button — dispose any widgets the rule body stashed (portal
// overlays would otherwise leak) before pulling the row from the DOM.
const removeBtn = row.querySelector('.btn-remove-rule') as HTMLButtonElement;
removeBtn.addEventListener('click', () => {
_disposeHTTPPollWidgets(container);
row.remove();
const autoGen = (window as any)._autoGenerateAutomationName;
if (typeof autoGen === 'function') autoGen();
});
// Attach IconSelect to the rule type dropdown // Attach IconSelect to the rule type dropdown
const ruleIconSelect = new IconSelect({ const ruleIconSelect = new IconSelect({
target: typeSelect, target: typeSelect,
items: _buildRuleTypeItems(), items: _buildRuleTypeItems(),
columns: 4, columns: 3,
} as any); } as any);
function renderFields(type: any, data: any) { function renderFields(type: any, data: any) {
// Tear down any widgets the previous renderFields call attached
// (EntitySelect/IconSelect portal overlays to document.body, so
// a bare ``container.innerHTML = …`` leaves them in the registry).
_disposeHTTPPollWidgets(container);
if (type === 'startup') { if (type === 'startup') {
container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.startup.hint')}</small>`; container.innerHTML = `<small class="rule-hint-desc">${t('automations.rule.startup.hint')}</small>`;
return; return;
@@ -880,6 +964,7 @@ function addAutomationRuleRow(rule: any) {
</select> </select>
</div> </div>
</div>`; </div>`;
enhanceMiniSelects(container, 'select.rule-display-state');
return; return;
} }
if (type === 'mqtt') { if (type === 'mqtt') {
@@ -905,6 +990,7 @@ function addAutomationRuleRow(rule: any) {
</select> </select>
</div> </div>
</div>`; </div>`;
enhanceMiniSelects(container, 'select.rule-mqtt-match-mode');
return; return;
} }
if (type === 'home_assistant') { if (type === 'home_assistant') {
@@ -987,6 +1073,87 @@ function addAutomationRuleRow(rule: any) {
return; return;
} }
if (type === 'http_poll') {
const vsId = data.value_source_id || '';
const operator = data.operator || 'equals';
const valueStr = data.value || '';
container.innerHTML = `
<div class="rule-fields">
<small class="rule-hint-desc">${t('automations.rule.http_poll.hint')}</small>
<div class="rule-field">
<label>${t('automations.rule.http_poll.value_source')}</label>
<select class="rule-http-value-source">
<option value="">—</option>
</select>
</div>
<div class="rule-field">
<label>${t('automations.rule.http_poll.operator')}</label>
<select class="rule-http-operator">
${HTTP_OP_KEYS.map(k => `<option value="${k}" ${operator === k ? 'selected' : ''}>${t('automations.rule.http_poll.operator.' + k)}</option>`).join('')}
</select>
</div>
<div class="rule-field rule-http-value-field">
<label>${t('automations.rule.http_poll.value')}</label>
<input type="text" class="rule-http-value" value="${escapeHtml(valueStr)}" placeholder="${escapeHtml(t('automations.rule.http_poll.value.placeholder'))}">
</div>
</div>`;
// Pull only HTTP value sources (source_type === 'http')
const httpVs = (_cachedValueSources || []).filter((v: any) => v.source_type === 'http');
// Wire EntitySelect for the value source picker
const vsSelect = container.querySelector('.rule-http-value-source') as HTMLSelectElement;
// Pre-populate the option so EntitySelect can sync display text.
vsSelect.innerHTML = `<option value="">—</option>` +
httpVs.map((v: any) => `<option value="${v.id}" ${v.id === vsId ? 'selected' : ''}>${escapeHtml(v.name)}</option>`).join('');
vsSelect.value = vsId || '';
const vsEntitySelect = new EntitySelect({
target: vsSelect,
getItems: () => (_cachedValueSources || [])
.filter((v: any) => v.source_type === 'http')
.map((v: any) => ({
value: v.id,
label: v.name,
icon: _icon(P.globe),
desc: v.json_path || t('automations.rule.http_poll.raw_body'),
})),
placeholder: t('palette.search'),
});
// Wire IconSelect for operator
const opSelect = container.querySelector('.rule-http-operator') as HTMLSelectElement;
const opItems = HTTP_OP_KEYS.map(k => ({
value: k,
icon: _icon(_httpOpIconPath(k)),
label: t('automations.rule.http_poll.operator.' + k),
desc: t('automations.rule.http_poll.operator.' + k + '.desc'),
}));
const opIconSelect = new IconSelect({
target: opSelect,
items: opItems,
columns: 3,
onChange: (newOp: string) => {
// Hide the value field when operator is 'exists'
const valField = container.querySelector('.rule-http-value-field') as HTMLElement;
if (valField) valField.style.display = newOp === 'exists' ? 'none' : '';
},
});
// Sync initial visibility based on the operator we just loaded.
const valField = container.querySelector('.rule-http-value-field') as HTMLElement;
if (valField) valField.style.display = operator === 'exists' ? 'none' : '';
// Stash both widgets so they can be destroyed when the row's
// rule type changes (renderFields re-entry) or the row is
// removed (button.btn-remove-rule onclick — calls
// _disposeHTTPPollWidgets via the row's data hook).
(container as any)._httpPollWidgets = {
vsEntitySelect,
opIconSelect,
};
return;
}
if (type === 'webhook') { if (type === 'webhook') {
if (data.token) { if (data.token) {
const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token; const webhookUrl = getBaseOrigin() + '/api/v1/webhooks/' + data.token;
@@ -1102,6 +1269,18 @@ function getAutomationEditorRules() {
state: (row.querySelector('.rule-ha-state') as HTMLInputElement).value, state: (row.querySelector('.rule-ha-state') as HTMLInputElement).value,
match_mode: (row.querySelector('.rule-ha-match-mode') as HTMLSelectElement).value || 'exact', match_mode: (row.querySelector('.rule-ha-match-mode') as HTMLSelectElement).value || 'exact',
}); });
} else if (ruleType === 'http_poll') {
const op = (row.querySelector('.rule-http-operator') as HTMLSelectElement).value || 'equals';
const r: any = {
rule_type: 'http_poll',
value_source_id: (row.querySelector('.rule-http-value-source') as HTMLSelectElement).value,
operator: op,
};
// The 'exists' operator has no comparison value.
if (op !== 'exists') {
r.value = (row.querySelector('.rule-http-value') as HTMLInputElement).value;
}
rules.push(r);
} else { } else {
const matchType = (row.querySelector('.rule-match-type') as HTMLSelectElement).value; const matchType = (row.querySelector('.rule-match-type') as HTMLSelectElement).value;
const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim(); const appsText = (row.querySelector('.rule-apps') as HTMLTextAreaElement).value.trim();
@@ -1114,6 +1293,8 @@ function getAutomationEditorRules() {
export async function saveAutomationEditor() { export async function saveAutomationEditor() {
const idInput = document.getElementById('automation-editor-id') as HTMLInputElement; const idInput = document.getElementById('automation-editor-id') as HTMLInputElement;
if (automationModal.closeIfPristine(idInput.value)) return;
const nameInput = document.getElementById('automation-editor-name') as HTMLInputElement; const nameInput = document.getElementById('automation-editor-name') as HTMLInputElement;
const enabledInput = document.getElementById('automation-editor-enabled') as HTMLInputElement; const enabledInput = document.getElementById('automation-editor-enabled') as HTMLInputElement;
const logicSelect = document.getElementById('automation-editor-logic') as HTMLSelectElement; const logicSelect = document.getElementById('automation-editor-logic') as HTMLSelectElement;
+44
View File
@@ -201,6 +201,49 @@ class HomeAssistantRule(Rule):
) )
@dataclass
class HTTPPollRule(Rule):
"""Activate based on a value extracted by an HTTP value source.
The extraction (URL, auth, json_path, cadence) lives on an
``HTTPValueSource``; this rule just references the value source and
compares its current raw value to ``value`` using ``operator``.
"""
rule_type: str = "http_poll"
value_source_id: str = "" # references an HTTPValueSource
operator: str = "equals" # equals | not_equals | contains | regex | gt | lt | exists
value: str = ""
def to_dict(self) -> dict:
d = super().to_dict()
d["value_source_id"] = self.value_source_id
d["operator"] = self.operator
d["value"] = self.value
return d
@classmethod
def from_dict(cls, data: dict) -> "HTTPPollRule":
# Accept legacy ``http_source_id`` + ``json_path`` payloads from any
# in-flight v1 data and ignore them — the new shape only needs the
# value_source_id. Log a warning so a dev DB that still has the old
# form on disk is visible: such a row will load with
# ``value_source_id=""`` and the rule will silently evaluate to
# False forever otherwise.
if "http_source_id" in data or "json_path" in data:
logger.warning(
"Migrating legacy http_poll rule (had keys: %s). "
"value_source_id is empty; re-point the rule at an HTTPValueSource "
"and re-save to clear this warning.",
sorted(k for k in ("http_source_id", "json_path") if k in data),
)
return cls(
value_source_id=data.get("value_source_id", ""),
operator=data.get("operator", "equals"),
value=data.get("value", ""),
)
_RULE_MAP: Dict[str, Type[Rule]] = { _RULE_MAP: Dict[str, Type[Rule]] = {
"application": ApplicationRule, "application": ApplicationRule,
"time_of_day": TimeOfDayRule, "time_of_day": TimeOfDayRule,
@@ -210,6 +253,7 @@ _RULE_MAP: Dict[str, Type[Rule]] = {
"webhook": WebhookRule, "webhook": WebhookRule,
"startup": StartupRule, "startup": StartupRule,
"home_assistant": HomeAssistantRule, "home_assistant": HomeAssistantRule,
"http_poll": HTTPPollRule,
# Legacy: "always" maps to StartupRule for migration # Legacy: "always" maps to StartupRule for migration
"always": StartupRule, "always": StartupRule,
} }
+281
View File
@@ -284,3 +284,284 @@ class TestEngineLifecycle:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stop_without_start_is_safe(self, engine): async def test_stop_without_start_is_safe(self, engine):
await engine.stop() # no-op await engine.stop() # no-op
# ---------------------------------------------------------------------------
# HTTP poll rule — operator helpers + integration with mocked value stream
# ---------------------------------------------------------------------------
class TestApplyOperator:
"""Unit tests for the rule-comparison operator dispatch."""
def test_operator_equals(self):
from ledgrab.core.automations.automation_engine import _apply_operator
assert _apply_operator("equals", "ok", "ok") is True
assert _apply_operator("equals", 42, "42") is True # str coercion
assert _apply_operator("equals", "ok", "fail") is False
def test_operator_contains(self):
from ledgrab.core.automations.automation_engine import _apply_operator
assert _apply_operator("contains", "hello world", "world") is True
assert _apply_operator("contains", "hello", "xyz") is False
def test_operator_regex(self):
from ledgrab.core.automations.automation_engine import _apply_operator
assert _apply_operator("regex", "abc123", r"\d+") is True
assert _apply_operator("regex", "abc", r"\d+") is False
assert _apply_operator("regex", "abc", "[") is False # invalid regex
def test_operator_gt_lt(self):
from ledgrab.core.automations.automation_engine import _apply_operator
assert _apply_operator("gt", 5, "3") is True
assert _apply_operator("gt", 2, "3") is False
assert _apply_operator("lt", 2, "3") is True
assert _apply_operator("gt", "not-a-number", "3") is False
def test_operator_not_equals(self):
from ledgrab.core.automations.automation_engine import _apply_operator
assert _apply_operator("not_equals", "a", "b") is True
assert _apply_operator("not_equals", "a", "a") is False
class TestHTTPPollRuleEvaluation:
"""_evaluate_http_poll behavior with a mocked ValueStreamManager.
Rule now points at an HTTPValueSource via ``value_source_id``; the
engine reads the cached raw value from the corresponding ValueStream
and compares with ``_apply_operator``.
"""
def _engine_with_vsm(self, mock_store, mock_manager, vsm):
with patch("ledgrab.core.automations.automation_engine.PlatformDetector"):
return AutomationEngine(
automation_store=mock_store,
processor_manager=mock_manager,
poll_interval=0.1,
value_stream_manager=vsm,
)
def _vsm_with_stream(self, vs_id: str, raw_value):
"""Build a mock ValueStreamManager whose ``peek(vs_id)`` returns a
stream whose ``get_raw_value()`` yields *raw_value*."""
vsm = MagicMock()
stream = MagicMock()
stream.get_raw_value.return_value = raw_value
vsm.peek.side_effect = lambda q: stream if q == vs_id else None
return vsm
def test_no_manager_returns_false(self, mock_store, mock_manager):
from ledgrab.storage.automation import HTTPPollRule
with patch("ledgrab.core.automations.automation_engine.PlatformDetector"):
eng = AutomationEngine(
automation_store=mock_store,
processor_manager=mock_manager,
poll_interval=0.1,
)
rule = HTTPPollRule(value_source_id="vs_x", operator="equals", value="ok")
assert eng._evaluate_http_poll(rule) is False
def test_no_value_source_id_returns_false(self, mock_store, mock_manager):
from ledgrab.storage.automation import HTTPPollRule
vsm = MagicMock()
vsm.peek.return_value = None
eng = self._engine_with_vsm(mock_store, mock_manager, vsm)
rule = HTTPPollRule(value_source_id="", operator="equals", value="ok")
assert eng._evaluate_http_poll(rule) is False
def test_stream_missing_returns_false(self, mock_store, mock_manager):
"""If the value stream hasn't been acquired yet, the rule is False."""
from ledgrab.storage.automation import HTTPPollRule
vsm = MagicMock()
vsm.peek.return_value = None # nothing acquired
eng = self._engine_with_vsm(mock_store, mock_manager, vsm)
rule = HTTPPollRule(value_source_id="vs_x", operator="equals", value="ok")
assert eng._evaluate_http_poll(rule) is False
def test_raw_value_none_returns_false(self, mock_store, mock_manager):
from ledgrab.storage.automation import HTTPPollRule
vsm = self._vsm_with_stream("vs_x", None)
eng = self._engine_with_vsm(mock_store, mock_manager, vsm)
rule = HTTPPollRule(value_source_id="vs_x", operator="equals", value="ok")
assert eng._evaluate_http_poll(rule) is False
def test_equals_match(self, mock_store, mock_manager):
from ledgrab.storage.automation import HTTPPollRule
vsm = self._vsm_with_stream("vs_x", "playing")
eng = self._engine_with_vsm(mock_store, mock_manager, vsm)
rule = HTTPPollRule(value_source_id="vs_x", operator="equals", value="playing")
assert eng._evaluate_http_poll(rule) is True
def test_exists_when_value_present(self, mock_store, mock_manager):
from ledgrab.storage.automation import HTTPPollRule
vsm = self._vsm_with_stream("vs_x", 1)
eng = self._engine_with_vsm(mock_store, mock_manager, vsm)
rule = HTTPPollRule(value_source_id="vs_x", operator="exists")
assert eng._evaluate_http_poll(rule) is True
def test_exists_when_value_none(self, mock_store, mock_manager):
from ledgrab.storage.automation import HTTPPollRule
vsm = self._vsm_with_stream("vs_x", None)
eng = self._engine_with_vsm(mock_store, mock_manager, vsm)
rule = HTTPPollRule(value_source_id="vs_x", operator="exists")
assert eng._evaluate_http_poll(rule) is False
def test_gt_numeric(self, mock_store, mock_manager):
from ledgrab.storage.automation import HTTPPollRule
vsm = self._vsm_with_stream("vs_x", 72.5)
eng = self._engine_with_vsm(mock_store, mock_manager, vsm)
rule = HTTPPollRule(value_source_id="vs_x", operator="gt", value="70")
assert eng._evaluate_http_poll(rule) is True
def test_stream_without_raw_value_attr_returns_false(self, mock_store, mock_manager):
"""A non-HTTP stream type that has no ``get_raw_value`` short-circuits."""
from ledgrab.storage.automation import HTTPPollRule
plain_stream = object() # no get_raw_value attribute
vsm = MagicMock()
vsm.peek.return_value = plain_stream
eng = self._engine_with_vsm(mock_store, mock_manager, vsm)
rule = HTTPPollRule(value_source_id="vs_x", operator="equals", value="ok")
assert eng._evaluate_http_poll(rule) is False
class TestSyncValueStreamRefs:
"""Engine acquire/release diffing against the ValueStreamManager."""
def test_no_manager_is_noop(self, mock_store, mock_manager):
with patch("ledgrab.core.automations.automation_engine.PlatformDetector"):
eng = AutomationEngine(
automation_store=mock_store,
processor_manager=mock_manager,
poll_interval=0.1,
)
eng._sync_value_stream_refs() # should not raise
def test_acquires_referenced_sources(self, mock_store, mock_manager):
from ledgrab.storage.automation import HTTPPollRule
vsm = MagicMock()
with patch("ledgrab.core.automations.automation_engine.PlatformDetector"):
eng = AutomationEngine(
automation_store=mock_store,
processor_manager=mock_manager,
poll_interval=0.1,
value_stream_manager=vsm,
)
mock_store.create_automation(
name="poll1",
enabled=True,
rule_logic="or",
rules=[
HTTPPollRule(
value_source_id="vs_abc",
operator="equals",
value="ok",
),
],
scene_preset_id=None,
deactivation_mode="none",
deactivation_scene_preset_id=None,
)
eng._sync_value_stream_refs()
vsm.acquire.assert_called_once_with("vs_abc")
assert eng._value_sources_acquired == {"vs_abc"}
def test_releases_unreferenced_sources(self, mock_store, mock_manager):
"""When a rule is removed, the engine releases its formerly-held source."""
vsm = MagicMock()
with patch("ledgrab.core.automations.automation_engine.PlatformDetector"):
eng = AutomationEngine(
automation_store=mock_store,
processor_manager=mock_manager,
poll_interval=0.1,
value_stream_manager=vsm,
)
eng._value_sources_acquired = {"vs_old"}
eng._sync_value_stream_refs() # no rules reference it
vsm.release.assert_called_once_with("vs_old")
assert eng._value_sources_acquired == set()
def test_disabled_automation_ignored(self, mock_store, mock_manager):
from ledgrab.storage.automation import HTTPPollRule
vsm = MagicMock()
with patch("ledgrab.core.automations.automation_engine.PlatformDetector"):
eng = AutomationEngine(
automation_store=mock_store,
processor_manager=mock_manager,
poll_interval=0.1,
value_stream_manager=vsm,
)
mock_store.create_automation(
name="off",
enabled=False,
rule_logic="or",
rules=[
HTTPPollRule(
value_source_id="vs_xyz",
operator="equals",
value="",
),
],
scene_preset_id=None,
deactivation_mode="none",
deactivation_scene_preset_id=None,
)
eng._sync_value_stream_refs()
vsm.acquire.assert_not_called()
class TestHTTPValueStreamExtraction:
"""Unit tests for the dot-path extractor used by HTTPValueStream."""
def test_empty_path_returns_body_text(self):
from ledgrab.core.processing.value_stream import _extract_simple_path
assert _extract_simple_path(None, "", "raw body") == "raw body"
def test_simple_key(self):
from ledgrab.core.processing.value_stream import _extract_simple_path
assert _extract_simple_path({"status": "ok"}, "status", "") == "ok"
def test_nested_with_array_index(self):
from ledgrab.core.processing.value_stream import _extract_simple_path
body = {"MediaContainer": {"size": 2, "Metadata": [{"title": "Show"}]}}
assert _extract_simple_path(body, "MediaContainer.size", "") == 2
assert _extract_simple_path(body, "MediaContainer.Metadata[0].title", "") == "Show"
def test_chained_indices(self):
from ledgrab.core.processing.value_stream import _extract_simple_path
body = {"matrix": [[1, 2, 3], [4, 5, 6]]}
assert _extract_simple_path(body, "matrix[0][1]", "") == 2
assert _extract_simple_path(body, "matrix[1][2]", "") == 6
def test_missing_returns_none(self):
from ledgrab.core.processing.value_stream import _extract_simple_path
assert _extract_simple_path({"a": 1}, "b", "") is None
assert _extract_simple_path({"a": [1]}, "a[5]", "") is None