feat: due resolution and cascade math

Also: StoresUtc trait normalizes datetime casts to UTC (controller ruling).

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:19:00 -03:00
parent 774e0bce1a
commit e48e48aa4a
9 changed files with 297 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
<?php
namespace Tests\Unit\Scheduling;
use App\Enums\AfterDue;
use App\Enums\DueMode;
use App\Enums\EventState;
use App\Models\Event;
use App\Models\User;
use App\Scheduling\DueResolver;
use Carbon\CarbonImmutable;
use Tests\TestCase; // Laravel TestCase: Eloquent date-casts need the connection grammar even in memory
class DueResolverTest extends TestCase
{
private function user(string $tz = 'America/Montevideo'): User
{
return new User(['login' => 'n', 'tz' => $tz, 'quiet_start' => '22:00:00', 'quiet_end' => '09:00:00']);
}
private function local(string $date, string $time = '24:00:00', ?string $fireOn = null): Event
{
return new Event([
'due_mode' => DueMode::Local, 'due_date' => $date, 'due_time' => $time,
'fire_on' => $fireOn ?? $date, 'after_due' => AfterDue::Expire,
'state' => EventState::Scheduled, 'payload' => ['title' => 't'],
]);
}
private function fixed(string $instant, string $fireOn): Event
{
return new Event([
'due_mode' => DueMode::Fixed, 'due_instant' => CarbonImmutable::parse($instant),
'fire_on' => $fireOn, 'after_due' => AfterDue::Expire,
'state' => EventState::Scheduled, 'payload' => ['title' => 't'],
]);
}
public function test_whole_day_local_event(): void
{
$due = (new DueResolver)->resolve($this->local('2026-08-14'), $this->user());
$this->assertSame('2026-08-14 00:00 America/Montevideo', $due->day->format('Y-m-d H:i e'));
$this->assertSame('2026-08-15 00:00 America/Montevideo', $due->moment->format('Y-m-d H:i e'));
}
public function test_timed_local_event(): void
{
$due = (new DueResolver)->resolve($this->local('2026-09-10', '14:00:00'), $this->user());
$this->assertSame('2026-09-10 00:00', $due->day->format('Y-m-d H:i'));
$this->assertSame('2026-09-10 14:00', $due->moment->format('Y-m-d H:i'));
}
public function test_local_event_follows_user_timezone(): void
{
$event = $this->local('2026-09-10', '14:00:00');
$uy = (new DueResolver)->resolve($event, $this->user('America/Montevideo'));
$cn = (new DueResolver)->resolve($event, $this->user('Asia/Shanghai'));
$this->assertSame('2026-09-10 14:00', $uy->moment->format('Y-m-d H:i'));
$this->assertSame('2026-09-10 14:00', $cn->moment->format('Y-m-d H:i'));
$this->assertNotEquals($uy->moment->utc()->toIso8601String(), $cn->moment->utc()->toIso8601String());
}
public function test_fixed_event_is_converted_into_user_timezone(): void
{
// Вылет из Мадрида 08:15+02:00 = 06:15Z = 03:15 в Монтевидео (-03:00)
$due = (new DueResolver)->resolve($this->fixed('2026-09-10T08:15:00+02:00', '2026-09-03'), $this->user());
$this->assertSame('2026-09-10 03:15 America/Montevideo', $due->moment->format('Y-m-d H:i e'));
$this->assertSame('2026-09-10 00:00', $due->day->format('Y-m-d H:i'));
}
public function test_fire_start_is_local_midnight(): void
{
$start = (new DueResolver)->fireStart($this->local('2026-09-30', '24:00:00', '2026-08-31'), $this->user());
$this->assertSame('2026-08-31 00:00 America/Montevideo', $start->format('Y-m-d H:i e'));
}
public function test_today_starts_at_midnight_for_normal_moment(): void
{
$r = new DueResolver;
$due = $r->resolve($this->local('2026-09-10', '14:00:00'), $this->user());
$this->assertSame('2026-09-10 00:00', $r->todayStart($due, $this->user())->format('Y-m-d H:i'));
}
public function test_today_starts_the_evening_before_for_early_moment(): void
{
$r = new DueResolver;
$due = $r->resolve($this->local('2026-09-10', '07:00:00'), $this->user());
// quiet_start 22:00 2h = 20:00 накануне
$this->assertSame('2026-09-09 20:00', $r->todayStart($due, $this->user())->format('Y-m-d H:i'));
$exactlyAtActiveStart = $r->resolve($this->local('2026-09-10', '09:00:00'), $this->user());
$this->assertSame('2026-09-09 20:00', $r->todayStart($exactlyAtActiveStart, $this->user())->format('Y-m-d H:i'));
}
}