homeos/tests/Feature/AutomationTest.php

241 lines
10 KiB
PHP

<?php
namespace Tests\Feature;
use App\Models\Automation;
use App\Models\Device;
use App\Models\DeviceState;
use App\Models\Entity;
use App\Services\AutomationEngine;
use App\Support\Drivers\DeviceDriver;
use App\Support\Drivers\ShellyMqttDriver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AutomationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Fake driver so automations never touch a real broker.
$this->app->bind(ShellyMqttDriver::class, fn () => new class implements DeviceDriver
{
public function turnOn(Entity $entity): void {}
public function turnOff(Entity $entity): void {}
public function setState(Entity $entity, array $state): void {}
public function setLight(Entity $entity, array $params): void {}
public function reboot(Device $device): void {}
public function capabilities(): array
{
return [];
}
});
}
/** @return array{0:Device,1:Entity} */
private function makeSwitch(): array
{
$device = Device::create(['name' => 'Lampe', 'vendor' => 'Shelly', 'protocol' => 'mqtt', 'status' => 'active']);
$entity = Entity::create(['device_id' => $device->id, 'type' => 'switch', 'key' => 'switch:0']);
DeviceState::create(['entity_id' => $entity->id, 'state' => ['on' => true]]);
return [$device, $entity];
}
public function test_state_change_trigger_runs_switch_action_and_audits_as_automation(): void
{
[$trigger, $triggerEntity] = $this->makeSwitch();
[$target, $targetEntity] = $this->makeSwitch();
Automation::create([
'name' => 'Motion turns light off',
'enabled' => true,
'trigger_type' => 'state_change',
'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true],
'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]],
'cooldown_seconds' => 0,
]);
app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]);
$this->assertDatabaseHas('commands', ['source' => 'automation', 'command' => 'turn_off', 'result' => 'ok']);
}
public function test_trigger_does_not_fire_when_value_differs(): void
{
[$trigger, $triggerEntity] = $this->makeSwitch();
[$target, $targetEntity] = $this->makeSwitch();
Automation::create([
'name' => 'On only',
'enabled' => true,
'trigger_type' => 'state_change',
'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true],
'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]],
'cooldown_seconds' => 0,
]);
app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => false]);
$this->assertDatabaseCount('commands', 0);
}
public function test_cooldown_prevents_a_second_run(): void
{
[$trigger, $triggerEntity] = $this->makeSwitch();
[$target, $targetEntity] = $this->makeSwitch();
Automation::create([
'name' => 'Debounced',
'enabled' => true,
'trigger_type' => 'state_change',
'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true],
'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]],
'cooldown_seconds' => 300,
]);
$engine = app(AutomationEngine::class);
$engine->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]);
$engine->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]);
$this->assertDatabaseCount('commands', 1);
}
public function test_dry_run_does_not_issue_a_command(): void
{
[$trigger, $triggerEntity] = $this->makeSwitch();
[$target, $targetEntity] = $this->makeSwitch();
Automation::create([
'name' => 'Dry',
'enabled' => true,
'trigger_type' => 'state_change',
'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true],
'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]],
'cooldown_seconds' => 0,
'dry_run' => true,
]);
app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]);
$this->assertDatabaseCount('commands', 0);
}
public function test_time_trigger_fires_on_matching_minute(): void
{
[$target, $targetEntity] = $this->makeSwitch();
Automation::create([
'name' => 'Morning light',
'enabled' => true,
'trigger_type' => 'time',
'trigger_config' => ['at' => now()->format('H:i')],
'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => true]],
'cooldown_seconds' => 0,
]);
app(AutomationEngine::class)->tick();
$this->assertDatabaseHas('commands', ['source' => 'automation', 'command' => 'turn_on']);
}
public function test_condition_blocks_action_when_false(): void
{
[$trigger, $triggerEntity] = $this->makeSwitch();
[$target, $targetEntity] = $this->makeSwitch();
// A separate sensor whose state the condition inspects.
[$sensor, $sensorEntity] = $this->makeSwitch();
$sensorEntity->state()->update(['state' => ['on' => false]]);
Automation::create([
'name' => 'Only when sensor on',
'enabled' => true,
'trigger_type' => 'state_change',
'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true],
'conditions' => [['device' => $sensor->uuid, 'entity_key' => $sensorEntity->key, 'field' => 'on', 'equals' => true]],
'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]],
'cooldown_seconds' => 0,
]);
app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]);
$this->assertDatabaseCount('commands', 0);
}
public function test_condition_allows_action_when_true(): void
{
[$trigger, $triggerEntity] = $this->makeSwitch();
[$target, $targetEntity] = $this->makeSwitch();
[$sensor, $sensorEntity] = $this->makeSwitch();
$sensorEntity->state()->update(['state' => ['on' => true]]);
Automation::create([
'name' => 'Only when sensor on',
'enabled' => true,
'trigger_type' => 'state_change',
'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true],
'conditions' => [['device' => $sensor->uuid, 'entity_key' => $sensorEntity->key, 'field' => 'on', 'equals' => true]],
'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]],
'cooldown_seconds' => 0,
]);
app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]);
$this->assertDatabaseHas('commands', ['source' => 'automation', 'command' => 'turn_off']);
}
public function test_failed_condition_does_not_start_the_cooldown(): void
{
[$trigger, $triggerEntity] = $this->makeSwitch();
[$target, $targetEntity] = $this->makeSwitch();
[$sensor, $sensorEntity] = $this->makeSwitch();
$sensorEntity->state()->update(['state' => ['on' => false]]);
$automation = Automation::create([
'name' => 'Armed',
'enabled' => true,
'trigger_type' => 'state_change',
'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true],
'conditions' => [['device' => $sensor->uuid, 'entity_key' => $sensorEntity->key, 'field' => 'on', 'equals' => true]],
'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]],
'cooldown_seconds' => 300,
]);
// Condition false → nothing happens and the cooldown must stay unarmed …
app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]);
$this->assertNull($automation->refresh()->last_triggered_at);
// … so once the condition becomes true, the very next trigger fires immediately.
$sensorEntity->state()->update(['state' => ['on' => true]]);
app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]);
$this->assertDatabaseCount('commands', 1);
}
public function test_disabled_automation_never_runs(): void
{
[$trigger, $triggerEntity] = $this->makeSwitch();
[$target, $targetEntity] = $this->makeSwitch();
Automation::create([
'name' => 'Off',
'enabled' => false,
'trigger_type' => 'state_change',
'trigger_config' => ['device' => $trigger->uuid, 'entity_key' => $triggerEntity->key, 'field' => 'on', 'equals' => true],
'actions' => [['type' => 'switch', 'device' => $target->uuid, 'entity_key' => $targetEntity->key, 'on' => false]],
'cooldown_seconds' => 0,
]);
app(AutomationEngine::class)->onStateChange($trigger->uuid, $triggerEntity->key, ['on' => true]);
$this->assertDatabaseCount('commands', 0);
}
}