feat: state, window and quiet rules

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BCrwHHnGCB5XH968Nxokqw
This commit is contained in:
nikita.hohlov
2026-09-04 06:39:23 -03:00
parent e48e48aa4a
commit c2fa8aa00d
8 changed files with 420 additions and 1 deletions

View File

@@ -0,0 +1,95 @@
<?php
namespace Tests\Unit\Scheduling;
use App\Enums\EventState;
use App\Models\User;
use App\Scheduling\Due;
use App\Scheduling\WindowResolver;
use Carbon\CarbonImmutable;
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory
class WindowResolverTest extends TestCase
{
private User $user;
private WindowResolver $windows;
protected function setUp(): void
{
parent::setUp();
$this->user = new User(['login' => 'n', 'tz' => 'UTC', 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
$this->windows = new WindowResolver(30, 10, [9, 14, 20]);
}
private function at(string $s): CarbonImmutable
{
return CarbonImmutable::parse($s, 'UTC');
}
private function wholeDay(string $ymd): Due
{
$day = $this->at($ymd);
return new Due($day, $day->addDay());
}
public function test_outside_active_hours_there_is_no_window(): void
{
$due = $this->wholeDay('2026-09-30');
$this->assertNull($this->windows->current(EventState::Preparing, $this->user, $due, $this->at('2026-09-20 08:59')));
$this->assertNull($this->windows->current(EventState::Preparing, $this->user, $due, $this->at('2026-09-20 22:00')));
$this->assertNull($this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 23:00')));
}
public function test_preparing_and_overdue_have_one_daily_window(): void
{
$due = $this->wholeDay('2026-09-30');
foreach ([EventState::Preparing, EventState::Overdue] as $state) {
$w = $this->windows->current($state, $this->user, $due, $this->at('2026-09-20 12:34'));
$this->assertSame('2026-09-20 09:00', $w->start->format('Y-m-d H:i'));
$this->assertSame('2026-09-20 22:00', $w->end->format('Y-m-d H:i'));
$this->assertTrue($w->fallbackAllowed);
$this->assertSame('2026-09-20 21:30', $w->fallbackAt->format('Y-m-d H:i'));
}
}
public function test_today_has_hourly_windows_with_fallback_only_in_listed_hours(): void
{
$due = $this->wholeDay('2026-09-30');
$w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 09:20'));
$this->assertSame('2026-09-30 09:00', $w->start->format('Y-m-d H:i'));
$this->assertSame('2026-09-30 10:00', $w->end->format('Y-m-d H:i'));
$this->assertTrue($w->fallbackAllowed);
$this->assertSame('2026-09-30 09:50', $w->fallbackAt->format('Y-m-d H:i'));
$w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 11:05'));
$this->assertSame('2026-09-30 11:00', $w->start->format('Y-m-d H:i'));
$this->assertFalse($w->fallbackAllowed);
$w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 21:59'));
$this->assertSame('2026-09-30 22:00', $w->end->format('Y-m-d H:i'));
}
public function test_today_window_is_truncated_by_due_moment_and_becomes_fallback(): void
{
$day = $this->at('2026-09-30');
$due = new Due($day, $day->setTime(9, 30));
$w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 09:05'));
$this->assertSame('2026-09-30 09:30', $w->end->format('Y-m-d H:i'));
$this->assertSame('2026-09-30 09:20', $w->fallbackAt->format('Y-m-d H:i'));
$due = new Due($day, $day->setTime(11, 15));
$w = $this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 11:02'));
$this->assertSame('2026-09-30 11:15', $w->end->format('Y-m-d H:i'));
$this->assertTrue($w->fallbackAllowed, 'обрезанное окно всегда fallback-окно');
$this->assertSame('2026-09-30 11:05', $w->fallbackAt->format('Y-m-d H:i'));
}
public function test_no_window_once_due_moment_passed(): void
{
$day = $this->at('2026-09-30');
$due = new Due($day, $day->setTime(11, 15));
$this->assertNull($this->windows->current(EventState::Today, $this->user, $due, $this->at('2026-09-30 11:20')));
}
}