feat(presence): who's-home overview, person edit/delete, avatars, faster away
Issue 1 — presence was half-built:
- Away detection: the debounce was 8 min (you disconnect, still "home"). Now
3 min and configurable (homeos.presence.away_debounce_minutes /
PRESENCE_AWAY_DEBOUNCE).
- Person management: ManagePerson modal handles BOTH add and edit (name, UniFi
client, avatar). Delete from the person card (confirm). Replaces AddPerson.
- Avatar upload: image stored on the public disk (storage:link), shown on cards
+ the who's-home strip; initials fallback (green when home).
- Who's home: a summary strip ("N Zuhause · M abwesend" with avatars) + clear
per-person home/away, so you can see at a glance who is in.
5 tests (debounce, create+avatar, edit, delete+avatar removal). Suite 78 green,
12/12 tabs clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/phase-1-bootstrap
parent
f3ae29d65a
commit
60d5275819
|
|
@ -101,3 +101,6 @@ MQTT_HOST_PORT=1883
|
|||
HOMEOS_ADMIN_EMAIL=admin@homeos.local
|
||||
HOMEOS_ADMIN_NAME=Admin
|
||||
HOMEOS_ADMIN_PASSWORD=homeos-dev
|
||||
|
||||
# Minutes off-WLAN before a person flips to "away" (rides out phone WLAN sleeps)
|
||||
PRESENCE_AWAY_DEBOUNCE=3
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@ class PresencePollCommand extends Command
|
|||
|
||||
protected $description = 'Poll UniFi for client presence and update persons.';
|
||||
|
||||
private const AWAY_DEBOUNCE_MINUTES = 8;
|
||||
|
||||
public function handle(UnifiClient $unifi): int
|
||||
{
|
||||
if (! $unifi->isConfigured()) {
|
||||
|
|
@ -38,7 +36,7 @@ class PresencePollCommand extends Command
|
|||
}
|
||||
|
||||
$this->info('UniFi active clients: '.count($connected));
|
||||
$debounce = now()->subMinutes(self::AWAY_DEBOUNCE_MINUTES);
|
||||
$debounce = now()->subMinutes((int) config('homeos.presence.away_debounce_minutes', 3));
|
||||
|
||||
foreach (Person::whereNotNull('mac')->get() as $person) {
|
||||
$home = isset($connected[strtolower($person->mac)]);
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\Person;
|
||||
use App\Services\UnifiClient;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class AddPerson extends ModalComponent
|
||||
{
|
||||
public string $name = '';
|
||||
|
||||
public string $mac = '';
|
||||
|
||||
/** @var array<int, array{mac:string,label:string}> */
|
||||
public array $clientOptions = [];
|
||||
|
||||
public bool $unifiError = false;
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'lg';
|
||||
}
|
||||
|
||||
public function mount(UnifiClient $unifi): void
|
||||
{
|
||||
try {
|
||||
$this->clientOptions = collect($unifi->clients())
|
||||
->map(fn ($c) => [
|
||||
'mac' => strtolower($c->mac ?? ''),
|
||||
'label' => $c->name ?? $c->hostname ?? ($c->mac ?? '?'),
|
||||
])
|
||||
->filter(fn ($o) => $o['mac'] !== '')
|
||||
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->values()->all();
|
||||
} catch (\Throwable) {
|
||||
$this->unifiError = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'mac' => ['nullable', 'string', 'max:32', 'unique:persons,mac'],
|
||||
]);
|
||||
|
||||
Person::create([
|
||||
'name' => $this->name,
|
||||
'mac' => $this->mac ?: null,
|
||||
'presence' => 'unknown',
|
||||
]);
|
||||
|
||||
$this->closeModal();
|
||||
|
||||
return redirect()->route('persons.index');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.add-person');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\Person;
|
||||
use App\Services\UnifiClient;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Features\SupportFileUploads\WithFileUploads;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
/** Add or edit a person — name, the device (UniFi client MAC) that marks presence, and an avatar. */
|
||||
class ManagePerson extends ModalComponent
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public ?string $personUuid = null;
|
||||
|
||||
public bool $editing = false;
|
||||
|
||||
public string $name = '';
|
||||
|
||||
public string $mac = '';
|
||||
|
||||
public $avatar = null;
|
||||
|
||||
public ?string $currentAvatarUrl = null;
|
||||
|
||||
/** @var array<int, array{mac:string,label:string}> */
|
||||
public array $clientOptions = [];
|
||||
|
||||
public bool $unifiError = false;
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return 'lg';
|
||||
}
|
||||
|
||||
public function mount(UnifiClient $unifi, ?string $person = null): void
|
||||
{
|
||||
try {
|
||||
$this->clientOptions = collect($unifi->clients())
|
||||
->map(fn ($c) => ['mac' => strtolower($c->mac ?? ''), 'label' => $c->name ?? $c->hostname ?? ($c->mac ?? '?')])
|
||||
->filter(fn ($o) => $o['mac'] !== '')
|
||||
->sortBy('label', SORT_NATURAL | SORT_FLAG_CASE)
|
||||
->values()->all();
|
||||
} catch (\Throwable) {
|
||||
$this->unifiError = true;
|
||||
}
|
||||
|
||||
if ($person !== null) {
|
||||
$model = Person::where('uuid', $person)->firstOrFail();
|
||||
$this->editing = true;
|
||||
$this->personUuid = $model->uuid;
|
||||
$this->name = $model->name;
|
||||
$this->mac = $model->mac ?? '';
|
||||
$this->currentAvatarUrl = $model->avatarUrl();
|
||||
}
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->validate([
|
||||
'name' => 'required|string|max:100',
|
||||
'mac' => ['nullable', 'string', 'max:32', Rule::unique('persons', 'mac')->ignore($this->personUuid, 'uuid')],
|
||||
'avatar' => ['nullable', 'image', 'max:4096'],
|
||||
]);
|
||||
|
||||
$person = $this->editing
|
||||
? Person::where('uuid', $this->personUuid)->firstOrFail()
|
||||
: new Person(['presence' => 'unknown']);
|
||||
|
||||
$person->name = $this->name;
|
||||
$person->mac = $this->mac ?: null;
|
||||
|
||||
if ($this->avatar !== null) {
|
||||
if ($person->avatar_path) {
|
||||
Storage::disk('public')->delete($person->avatar_path);
|
||||
}
|
||||
$person->avatar_path = $this->avatar->store('avatars', 'public');
|
||||
}
|
||||
|
||||
$person->save();
|
||||
|
||||
$this->closeModal();
|
||||
|
||||
return redirect()->route('persons.index');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.manage-person');
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Livewire\Persons;
|
||||
|
||||
use App\Models\Person;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\On;
|
||||
use Livewire\Component;
|
||||
|
|
@ -10,13 +11,38 @@ use Livewire\Component;
|
|||
#[Layout('layouts.app')]
|
||||
class Index extends Component
|
||||
{
|
||||
/** Person awaiting a delete confirmation. */
|
||||
public ?string $pendingDelete = null;
|
||||
|
||||
#[On('echo-private:presence,.PresenceChanged')]
|
||||
public function onPresenceChanged(): void {}
|
||||
|
||||
#[On('delete-person')]
|
||||
public function deletePerson(): void
|
||||
{
|
||||
if ($this->pendingDelete === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$person = Person::where('uuid', $this->pendingDelete)->first();
|
||||
if ($person !== null) {
|
||||
if ($person->avatar_path) {
|
||||
Storage::disk('public')->delete($person->avatar_path);
|
||||
}
|
||||
$person->delete();
|
||||
}
|
||||
|
||||
$this->pendingDelete = null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
$persons = Person::orderBy('name')->get();
|
||||
|
||||
return view('livewire.persons.index', [
|
||||
'persons' => Person::orderBy('name')->get(),
|
||||
'persons' => $persons,
|
||||
'home' => $persons->where('presence', 'home')->values(),
|
||||
'awayCount' => $persons->where('presence', '!=', 'home')->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ namespace App\Models;
|
|||
use App\Models\Concerns\HasUuid;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Person extends Model
|
||||
{
|
||||
|
|
@ -13,7 +15,7 @@ class Person extends Model
|
|||
protected $table = 'persons';
|
||||
|
||||
protected $fillable = [
|
||||
'uuid', 'name', 'avatar_color', 'user_id', 'mac', 'presence', 'presence_changed_at', 'last_seen_home_at',
|
||||
'uuid', 'name', 'avatar_color', 'avatar_path', 'user_id', 'mac', 'presence', 'presence_changed_at', 'last_seen_home_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
|
@ -30,4 +32,15 @@ class Person extends Model
|
|||
{
|
||||
return $this->presence === 'home';
|
||||
}
|
||||
|
||||
public function avatarUrl(): ?string
|
||||
{
|
||||
return $this->avatar_path ? Storage::disk('public')->url($this->avatar_path) : null;
|
||||
}
|
||||
|
||||
public function initials(): string
|
||||
{
|
||||
return Str::of($this->name)->explode(' ')->take(2)
|
||||
->map(fn ($w) => Str::upper(Str::substr($w, 0, 1)))->implode('');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,4 +27,15 @@ return [
|
|||
// exhaust the DB via auto-onboarding. 0 disables the cap.
|
||||
'max_devices' => (int) env('MQTT_MAX_DEVICES', 250),
|
||||
],
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------------------
|
||||
| Presence (UniFi)
|
||||
|----------------------------------------------------------------------
|
||||
*/
|
||||
'presence' => [
|
||||
// Minutes a person must be off the WLAN before flipping to "away". A short debounce
|
||||
// rides out brief phone WLAN sleeps without leaving you "home" long after you left.
|
||||
'away_debounce_minutes' => (int) env('PRESENCE_AWAY_DEBOUNCE', 3),
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('persons', function (Blueprint $table) {
|
||||
$table->string('avatar_path')->nullable()->after('avatar_color');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('persons', function (Blueprint $table) {
|
||||
$table->dropColumn('avatar_path');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -13,4 +13,16 @@ return [
|
|||
'pick_client' => 'Client auswählen…',
|
||||
'client_hint' => 'Das Gerät, das die Person repräsentiert — Anwesenheit folgt seiner WLAN-Verbindung.',
|
||||
'unifi_error' => 'UniFi nicht erreichbar — MAC-Adresse manuell eingeben.',
|
||||
'edit_title' => 'Person bearbeiten',
|
||||
'edit' => 'Bearbeiten',
|
||||
'save' => 'Speichern',
|
||||
'avatar_upload' => 'Bild wählen',
|
||||
'avatar_hint' => 'JPG/PNG, bis 4 MB. Optional.',
|
||||
'delete' => 'Person löschen',
|
||||
'delete_title' => 'Person löschen?',
|
||||
'delete_body' => ':name wird gelöscht. Die Zuordnung zum Gerät geht verloren.',
|
||||
'home_title' => 'Zuhause',
|
||||
'home_none' => 'Gerade ist niemand zuhause.',
|
||||
'away_count' => '{0}niemand abwesend|{1}:count abwesend|[2,*]:count abwesend',
|
||||
'since' => 'seit',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -13,4 +13,16 @@ return [
|
|||
'pick_client' => 'Pick a client…',
|
||||
'client_hint' => 'The device that represents this person — presence follows its WLAN connection.',
|
||||
'unifi_error' => 'UniFi unreachable — enter the MAC manually.',
|
||||
'edit_title' => 'Edit person',
|
||||
'edit' => 'Edit',
|
||||
'save' => 'Save',
|
||||
'avatar_upload' => 'Choose image',
|
||||
'avatar_hint' => 'JPG/PNG, up to 4 MB. Optional.',
|
||||
'delete' => 'Delete person',
|
||||
'delete_title' => 'Delete person?',
|
||||
'delete_body' => ':name will be deleted. The device link is lost.',
|
||||
'home_title' => 'Home',
|
||||
'home_none' => 'Nobody is home right now.',
|
||||
'away_count' => '{0}nobody away|{1}:count away|[2,*]:count away',
|
||||
'since' => 'since',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,6 +1,29 @@
|
|||
<form wire:submit="save">
|
||||
<x-modal :title="__('persons.add_title')" icon="persons">
|
||||
<x-modal :title="$editing ? __('persons.edit_title') : __('persons.add_title')" icon="persons">
|
||||
<div class="p-5 flex flex-col gap-4">
|
||||
{{-- Avatar --}}
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative shrink-0">
|
||||
@php $preview = $avatar ? $avatar->temporaryUrl() : $currentAvatarUrl; @endphp
|
||||
@if ($preview)
|
||||
<img src="{{ $preview }}" alt="" class="w-16 h-16 rounded-full object-cover border border-line-soft">
|
||||
@else
|
||||
<span class="grid place-items-center w-16 h-16 rounded-full bg-accent/15 text-accent font-bold text-[20px] uppercase">{{ mb_substr($name ?: '?', 0, 1) }}</span>
|
||||
@endif
|
||||
<div wire:loading wire:target="avatar" class="absolute inset-0 grid place-items-center rounded-full bg-base/70">
|
||||
<x-icon name="activity" :size="18" class="text-accent pulse-live" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="inline-flex items-center gap-1.5 rounded-lg border border-line px-3 py-2 text-[12.5px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors cursor-pointer w-max">
|
||||
<x-icon name="persons" :size="14" /> {{ __('persons.avatar_upload') }}
|
||||
<input type="file" wire:model="avatar" accept="image/*" class="hidden">
|
||||
</label>
|
||||
<span class="text-[11px] text-ink-3">{{ __('persons.avatar_hint') }}</span>
|
||||
@error('avatar') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label for="p-name" class="text-[12px] font-semibold text-ink-2">{{ __('devices.name') }}</label>
|
||||
<input wire:model="name" id="p-name" type="text" autofocus
|
||||
|
|
@ -29,7 +52,7 @@
|
|||
|
||||
<x-slot:footer>
|
||||
<button type="button" wire:click="closeModal" class="rounded-lg border border-line px-3.5 py-2 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">{{ __('common.cancel') }}</button>
|
||||
<button type="submit" class="rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">{{ __('persons.add') }}</button>
|
||||
<button type="submit" class="rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">{{ $editing ? __('persons.save') : __('persons.add') }}</button>
|
||||
</x-slot:footer>
|
||||
</x-modal>
|
||||
</form>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<div wire:poll.30s>
|
||||
<x-topbar :title="__('nav.persons')" :subtitle="__('persons.subtitle')">
|
||||
<x-slot:actions>
|
||||
<button type="button" wire:click="$dispatch('openModal', { component: 'modals.add-person' })"
|
||||
<button type="button" wire:click="$dispatch('openModal', { component: 'modals.manage-person' })"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg bg-accent px-3 py-1.5 text-[12px] font-bold text-base hover:brightness-110 transition-[filter]">
|
||||
<x-icon name="plus" :size="15" /> <span class="hidden sm:inline">{{ __('persons.add') }}</span>
|
||||
</button>
|
||||
|
|
@ -20,19 +20,63 @@
|
|||
</div>
|
||||
</x-panel>
|
||||
@else
|
||||
{{-- Who's home --}}
|
||||
<section class="relative overflow-hidden rounded-card border border-line-soft {{ $home->isNotEmpty() ? 'bg-online/10' : 'bg-surface' }}">
|
||||
<span class="absolute inset-y-0 left-0 w-[3px] {{ $home->isNotEmpty() ? 'bg-online' : 'bg-line' }}"></span>
|
||||
<div class="flex flex-wrap items-center gap-4 p-4 pl-5">
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-[15px] font-bold text-ink"><span class="font-mono tabular-nums">{{ $home->count() }}</span> {{ __('persons.home_title') }}</h2>
|
||||
<p class="text-[12px] text-ink-2">{{ trans_choice('persons.away_count', $awayCount, ['count' => $awayCount]) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center -space-x-2 ml-auto">
|
||||
@forelse ($home as $p)
|
||||
@if ($p->avatarUrl())
|
||||
<img src="{{ $p->avatarUrl() }}" alt="{{ $p->name }}" title="{{ $p->name }}" class="w-9 h-9 rounded-full object-cover border-2 border-base">
|
||||
@else
|
||||
<span title="{{ $p->name }}" class="grid place-items-center w-9 h-9 rounded-full bg-online/20 text-online font-bold text-[13px] uppercase border-2 border-base">{{ $p->initials() }}</span>
|
||||
@endif
|
||||
@empty
|
||||
<span class="text-[12.5px] text-ink-3">{{ __('persons.home_none') }}</span>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- People --}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
@foreach ($persons as $person)
|
||||
<x-panel>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="grid place-items-center w-11 h-11 rounded-full bg-inset text-ink font-bold text-[15px] uppercase">{{ mb_substr($person->name, 0, 1) }}</span>
|
||||
@if ($person->avatarUrl())
|
||||
<img src="{{ $person->avatarUrl() }}" alt="{{ $person->name }}" class="w-11 h-11 rounded-full object-cover shrink-0">
|
||||
@else
|
||||
<span @class([
|
||||
'grid place-items-center w-11 h-11 rounded-full font-bold text-[15px] uppercase shrink-0',
|
||||
'bg-online/20 text-online' => $person->isHome(),
|
||||
'bg-inset text-ink-2' => ! $person->isHome(),
|
||||
])>{{ $person->initials() }}</span>
|
||||
@endif
|
||||
<div class="min-w-0">
|
||||
<div class="text-[14px] font-bold text-ink truncate">{{ $person->name }}</div>
|
||||
<div class="text-[11.5px] text-ink-3">{{ $person->presence_changed_at?->diffForHumans() ?? '—' }}</div>
|
||||
<div class="text-[11.5px] text-ink-3">{{ __('persons.since') }} {{ $person->presence_changed_at?->diffForHumans() ?? '—' }}</div>
|
||||
</div>
|
||||
<x-status-pill :state="$person->isHome() ? 'online' : ($person->presence === 'away' ? 'neutral' : 'warning')" class="ml-auto">
|
||||
<div class="ml-auto flex items-center gap-1.5 shrink-0">
|
||||
<x-status-pill :state="$person->isHome() ? 'online' : ($person->presence === 'away' ? 'neutral' : 'warning')">
|
||||
{{ __('persons.presence_'.$person->presence) }}
|
||||
</x-status-pill>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mt-3 pt-3 border-t border-line-soft">
|
||||
<button type="button" wire:click="$dispatch('openModal', { component: 'modals.manage-person', arguments: { person: @js($person->uuid) } })"
|
||||
class="inline-flex items-center gap-1.5 rounded-lg border border-line px-3 py-1.5 text-[12px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">
|
||||
<x-icon name="settings" :size="13" /> {{ __('persons.edit') }}
|
||||
</button>
|
||||
<button type="button"
|
||||
x-on:click="$wire.set('pendingDelete', @js($person->uuid)); $wire.$dispatch('openModal', { component: 'modals.confirm', arguments: { title: @js(__('persons.delete_title')), body: @js(__('persons.delete_body', ['name' => $person->name])), confirmLabel: @js(__('persons.delete')), event: 'delete-person', to: 'persons', danger: true } })"
|
||||
class="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-line px-3 py-1.5 text-[12px] font-semibold text-ink-3 hover:text-offline hover:border-offline/40 transition-colors">
|
||||
<x-icon name="close" :size="13" /> {{ __('persons.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</x-panel>
|
||||
@endforeach
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,105 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Modals\ManagePerson;
|
||||
use App\Livewire\Persons\Index;
|
||||
use App\Models\Person;
|
||||
use App\Models\User;
|
||||
use App\Services\UnifiClient;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PresenceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Never touch a real UniFi controller in tests.
|
||||
$this->app->bind(UnifiClient::class, fn () => new class extends UnifiClient
|
||||
{
|
||||
public function __construct() {}
|
||||
|
||||
public function clients(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function connectedMacs(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function test_person_flips_to_away_only_after_the_debounce(): void
|
||||
{
|
||||
config()->set('homeos.presence.away_debounce_minutes', 3);
|
||||
|
||||
// Seen 1 min ago (within debounce) → still home.
|
||||
$recent = Person::create(['name' => 'A', 'mac' => 'aa:bb', 'presence' => 'home', 'last_seen_home_at' => now()->subMinute()]);
|
||||
// Seen 10 min ago (past debounce) and off wifi → away.
|
||||
$stale = Person::create(['name' => 'B', 'mac' => 'cc:dd', 'presence' => 'home', 'last_seen_home_at' => now()->subMinutes(10)]);
|
||||
|
||||
$this->artisan('presence:poll')->assertSuccessful();
|
||||
|
||||
$this->assertSame('home', $recent->fresh()->presence);
|
||||
$this->assertSame('away', $stale->fresh()->presence);
|
||||
}
|
||||
|
||||
public function test_manage_person_creates_with_an_avatar(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
Livewire::test(ManagePerson::class)
|
||||
->set('name', 'Boban')
|
||||
->set('avatar', UploadedFile::fake()->create('me.jpg', 200, 'image/jpeg'))
|
||||
->call('save')
|
||||
->assertRedirect(route('persons.index'));
|
||||
|
||||
$person = Person::where('name', 'Boban')->first();
|
||||
$this->assertNotNull($person->avatar_path);
|
||||
Storage::disk('public')->assertExists($person->avatar_path);
|
||||
}
|
||||
|
||||
public function test_manage_person_edits_an_existing_person(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
$person = Person::create(['name' => 'Old', 'presence' => 'unknown']);
|
||||
|
||||
Livewire::test(ManagePerson::class, ['person' => $person->uuid])
|
||||
->assertSet('editing', true)
|
||||
->set('name', 'New')
|
||||
->call('save');
|
||||
|
||||
$this->assertSame('New', $person->fresh()->name);
|
||||
}
|
||||
|
||||
public function test_deleting_a_person_removes_it_and_its_avatar(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$path = UploadedFile::fake()->create('a.jpg', 200, 'image/jpeg')->store('avatars', 'public');
|
||||
$person = Person::create(['name' => 'X', 'presence' => 'home', 'avatar_path' => $path]);
|
||||
|
||||
Livewire::test(Index::class)
|
||||
->set('pendingDelete', $person->uuid)
|
||||
->call('deletePerson');
|
||||
|
||||
$this->assertModelMissing($person);
|
||||
Storage::disk('public')->assertMissing($path);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue