241 lines
10 KiB
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);
|
|
}
|
|
}
|