Files
portal/app/tests/Feature/ClientSms/QuietHoursVtoroeOknoTest.php
T
Дмитрий 5dfe32dc91 feat: окно часов обзвона — общая рамка и своё окно клиента, З-3.1
Правило часов остаётся ОДНО и живёт в боевом SmsQuietHours — решение
владельца Р76. Обзвон получает не копию правила, а тонкую обёртку
ObzvonOknoChasov со своими числами: Р76 отдал в общее пользование
ПРАВИЛО, а не ГРАНИЦЫ. Админ, поправив окно рассылок, окно обзвона
не двигает.

Два окна живут в разных часах, и рамка — их пересечение, Р115. Часы
менеджера приходят вместе со СВОИМ смещением, LocalHoursWindow, и
меряются им, а не смещением получателя. Иначе московские 14:00-18:00
были бы прочитаны как владивостокские, робот перевёл бы звонок на
спящего менеджера, а на экране и в журнале всё бы сошлось.

Окно клиента только сужает рамку, Р64: 09:00-22:00 принимается как
10:00-20:00. Часы целиком вне рамки и непересекающиеся из-за поясов
окна дают пустой ответ «звонков не будет» — это ответ, а не молчание.

Входящие окном не ограничены, Р35: спросить обёртку про приём звонка
физически нечем, и на это стоит сторож.

Живая рассылка СМС не изменилась ни на один час — доказано вычитанием
против старого правила из git HEAD: 144 816 сверок на 13 поясах, всех
1440 минутах суток и 7 наборах границ, расхождений 0. Прибор показан
красным: подложенная поломка в один знак дала 6049 расхождений.

Подпись canSendNow не менялась — новое условие вошло третьим
необязательным доводом с умолчанием. nextWindowOpensAt, earliestOpening
и isValidWindow не тронуты вовсе. Границы читаются один раз на объект:
20 000 номеров — ровно один запрос к базе.

Сторожа показаны красными пятью врезами; дословные сообщения, разбор
задания и незакрытые хвосты — в отчёте
docs/superpowers/priyomka/stroyka-6/otchyot-pomoshchnika-z-3-1-okno-chasov-2026-08-07.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 05:58:59 +03:00

236 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\ClientSmsSettings;
use App\Services\ClientSms\LocalHoursWindow;
use App\Services\ClientSms\SmsQuietHours;
use App\Support\RegionTimezoneMap;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
/**
* Второе окно — часы менеджера клиента (задача З-3.1, решения владельца Р64, Р76, Р115).
*
* 🔴 Здесь сторожат САМО ПРАВИЛО. Проверки обёртки обзвона — в
* `tests/Feature/Obzvon/ObzvonOknoChasovTest.php`.
*
* Почему это надо сторожить отдельно и заранее: ошибка на час здесь не видна ни в
* одном прогоне — она видна только менеджеру, которого робот разбудил переводом
* звонка в четыре утра, и разбуженному человеку на том конце.
*/
uses(RefreshDatabase::class);
/** Момент UTC по человеческой записи — чтобы в тесте было видно, который час. */
function vtoroeOknoUtc(string $iso): CarbonImmutable
{
return CarbonImmutable::parse($iso, 'UTC');
}
$MSK = 3 * 60;
$VLADIVOSTOK = 10 * 60;
$KAMCHATKA = 12 * 60;
it('🔴 ЛОВУШКА: часы менеджера меряются ЕГО поясом, а не поясом получателя', function () use ($MSK, $VLADIVOSTOK) {
// Менеджер в Москве работает 10:00–18:00 — это 07:0015:00 UTC.
// Получатель во Владивостоке: его рамка 10:00–20:00 — это 00:0010:00 UTC.
// Общее окно у них — 07:0010:00 UTC (1013 МСК = 17–20 по Владивостоку).
$rule = app(SmsQuietHours::class);
$menedzher = new LocalHoursWindow($MSK, 10, 18);
// 07:30 UTC = 10:30 МСК (менеджер на месте) = 17:30 Владивосток (рамка открыта).
expect($rule->canSendNow($VLADIVOSTOK, vtoroeOknoUtc('2026-08-03 07:30'), $menedzher))->toBeTrue();
// 🔴 Вот на чём краснеет «просто сузили окно». 04:30 UTC = 14:30 ВЛАДИВОСТОКА:
// сузив окно получателя часами менеджера, портал прочёл бы «10:00–18:00» как
// владивостокские и набрал бы номер. А в Москве в этот миг 07:30 — менеджер спит.
expect($rule->canSendNow($VLADIVOSTOK, vtoroeOknoUtc('2026-08-03 04:30'), $menedzher))->toBeFalse();
// И наоборот: 10:30 UTC = 13:30 МСК (менеджер на месте), но во Владивостоке
// уже 20:30 — рамка получателя закрыта. Набора нет.
expect($rule->canSendNow($VLADIVOSTOK, vtoroeOknoUtc('2026-08-03 10:30'), $menedzher))->toBeFalse();
});
it('пустое второе окно не меняет сегодняшнего ответа ни на один час', function () use ($MSK) {
$rule = app(SmsQuietHours::class);
for ($chas = 0; $chas < 24; $chas++) {
$mig = vtoroeOknoUtc('2026-08-03 00:00')->addHours($chas);
expect($rule->canSendNow($MSK, $mig, null))
->toBe($rule->canSendNow($MSK, $mig), "час UTC {$chas}");
}
});
it('окно клиента только СУЖАЕТ рамку и никогда не расширяет', function () use ($MSK) {
$rule = app(SmsQuietHours::class);
// Внутри рамки — остаётся собой.
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 11, 17))->podpis())->toBe('11:0017:00');
// Шире рамки — принимается как рамка (Р64).
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 9, 22))->podpis())->toBe('10:0020:00');
// Одним краем наружу — подрезается этот край.
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 8, 15))->podpis())->toBe('10:0015:00');
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 15, 23))->podpis())->toBe('15:0020:00');
});
it('окно 09:00–22:00 не даёт набрать ни в 09 часов, ни в 21', function () use ($MSK) {
$rule = app(SmsQuietHours::class);
$menedzher = new LocalHoursWindow($MSK, 9, 22);
// Получатель тоже в Москве — тогда весь спор только про сужение.
expect($rule->canSendNow($MSK, vtoroeOknoUtc('2026-08-03 06:30'), $menedzher))->toBeFalse(); // 09:30 МСК
expect($rule->canSendNow($MSK, vtoroeOknoUtc('2026-08-03 07:30'), $menedzher))->toBeTrue(); // 10:30 МСК
expect($rule->canSendNow($MSK, vtoroeOknoUtc('2026-08-03 16:30'), $menedzher))->toBeTrue(); // 19:30 МСК
expect($rule->canSendNow($MSK, vtoroeOknoUtc('2026-08-03 17:30'), $menedzher))->toBeFalse(); // 20:30 МСК
});
it('окно целиком вне рамки говорит «звонков не будет», а не молчит', function () use ($MSK) {
$rule = app(SmsQuietHours::class);
$menedzher = new LocalHoursWindow($MSK, 20, 23);
// Сужение пусто — экрану есть что сказать человеку.
expect($rule->narrowedWindow($menedzher))->toBeNull();
// И набора нет ни в один час суток.
for ($chas = 0; $chas < 24; $chas++) {
expect($rule->canSendNow($MSK, vtoroeOknoUtc('2026-08-03 00:00')->addHours($chas), $menedzher))
->toBeFalse("час UTC {$chas}");
}
// «Когда откроется» отвечает пустотой — это ответ «никогда», а не «прямо сейчас».
expect($rule->nextCommonWindowOpensAt($MSK, $menedzher, vtoroeOknoUtc('2026-08-03 09:00')))->toBeNull();
});
it('🔴 общее окно может не существовать вовсе из-за поясов, и это сказано вслух', function () use ($MSK, $KAMCHATKA, $VLADIVOSTOK) {
// Менеджер в Москве 14:0018:00 = 11:0015:00 UTC — часы сами по себе годные,
// они целиком внутри рамки, сужать нечего.
// Камчатка: рамка 10:0020:00 = 22:0008:00 UTC. Общего часа НЕТ ни одного.
// Владивосток: рамка 10:0020:00 = 00:0010:00 UTC. Тоже ни одного.
$rule = app(SmsQuietHours::class);
$menedzher = new LocalHoursWindow($MSK, 14, 18);
foreach ([$KAMCHATKA, $VLADIVOSTOK] as $poyas) {
for ($chas = 0; $chas < 24; $chas++) {
expect($rule->canSendNow($poyas, vtoroeOknoUtc('2026-08-03 00:00')->addHours($chas), $menedzher))
->toBeFalse("пояс {$poyas}, час UTC {$chas}");
}
// 🔴 Пустота здесь означает «никогда», и молчать об этом нельзя.
expect($rule->nextCommonWindowOpensAt($poyas, $menedzher, vtoroeOknoUtc('2026-08-03 09:00')))->toBeNull();
}
// А сужение при этом НЕ пусто: часы клиента сами по себе годные — беда в поясах.
// Две причины «звонков не будет» портал различает, потому что говорит о них разное.
expect($rule->narrowedWindow($menedzher))->not->toBeNull();
});
it('говорит, когда откроется ОБЩЕЕ окно, и не промахивается мимо него', function () use ($MSK, $VLADIVOSTOK) {
$rule = app(SmsQuietHours::class);
$menedzher = new LocalHoursWindow($MSK, 10, 18);
// Общее окно у Москвы-менеджера и Владивостока-получателя: 07:0010:00 UTC.
// Спрашиваем в 00:00 UTC — ждём до 07:00 UTC.
expect($rule->nextCommonWindowOpensAt($VLADIVOSTOK, $menedzher, vtoroeOknoUtc('2026-08-03 00:00'))?->toIso8601String())
->toBe(vtoroeOknoUtc('2026-08-03 07:00')->toIso8601String());
// Открыто прямо сейчас — отдаёт этот же миг, а не завтрашний.
expect($rule->nextCommonWindowOpensAt($VLADIVOSTOK, $menedzher, vtoroeOknoUtc('2026-08-03 08:00'))?->toIso8601String())
->toBe(vtoroeOknoUtc('2026-08-03 08:00')->toIso8601String());
// Окно уже закрылось — ждём ЗАВТРАШНЕГО, а не сегодняшнего.
expect($rule->nextCommonWindowOpensAt($VLADIVOSTOK, $menedzher, vtoroeOknoUtc('2026-08-03 12:00'))?->toIso8601String())
->toBe(vtoroeOknoUtc('2026-08-04 07:00')->toIso8601String());
});
it('🔴 обещанный миг открытия действительно открыт, а до него закрыто — на всех поясах', function () use ($MSK) {
// Сторож на промах «когда откроется»: обещание сверяется с самим правилом.
// Ошибка на час тут иначе не видна — она видна разбуженному человеку.
$rule = app(SmsQuietHours::class);
$menedzher = new LocalHoursWindow($MSK, 11, 17);
$poyasa = array_values(array_unique(array_map(
static fn (int $ch): int => $ch * 60,
RegionTimezoneMap::CODE_TO_UTC_HOURS
)));
$proverok = 0;
foreach ($poyasa as $poyas) {
for ($chas = 0; $chas < 24; $chas++) {
$seychas = vtoroeOknoUtc('2026-08-03 00:00')->addHours($chas);
$otkroetsya = $rule->nextCommonWindowOpensAt($poyas, $menedzher, $seychas);
$proverok++;
if ($otkroetsya === null) {
// «Никогда» обязано означать, что и правда никогда — ни в одну минуту суток.
for ($m = 0; $m < 1440; $m += 7) {
expect($rule->canSendNow($poyas, $seychas->addMinutes($m), $menedzher))
->toBeFalse("пояс {$poyas}: обещано «никогда», а в +{$m} мин открыто");
}
continue;
}
expect($rule->canSendNow($poyas, $otkroetsya, $menedzher))
->toBeTrue("пояс {$poyas}, спросили в {$seychas->toIso8601String()}: обещан {$otkroetsya->toIso8601String()}, а там закрыто");
// И ни одной минуты между «сейчас» и обещанным мигом открытым быть не должно.
$minut = (int) $seychas->diffInMinutes($otkroetsya);
for ($m = 0; $m < $minut; $m++) {
if ($rule->canSendNow($poyas, $seychas->addMinutes($m), $menedzher)) {
throw new RuntimeException(
"пояс {$poyas}: открыто уже в +{$m} мин, а обещано только через {$minut} мин"
);
}
}
}
}
expect($proverok)->toBe(count($poyasa) * 24);
});
it('бессмысленное окно менеджера отвергается тем же правилом, что и окно рассылки', function () use ($MSK) {
// «С 20 до 10» означало бы работу всю ночь. Второй проверки на это в портале нет.
expect(fn () => new LocalHoursWindow($MSK, 20, 10))->toThrow(InvalidArgumentException::class);
expect(fn () => new LocalHoursWindow($MSK, 12, 12))->toThrow(InvalidArgumentException::class);
expect(fn () => new LocalHoursWindow($MSK, -1, 10))->toThrow(InvalidArgumentException::class);
expect(fn () => new LocalHoursWindow($MSK, 10, 25))->toThrow(InvalidArgumentException::class);
});
it('второе окно слушается границ, выставленных владельцем в админке', function () use ($MSK) {
// Рамку двигает админ — и сужение обязано двигаться вместе с ней.
ClientSmsSettings::query()->update(['quiet_from_hour' => 12, 'quiet_to_hour' => 18]);
$rule = app(SmsQuietHours::class);
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 9, 22))->podpis())->toBe('12:0018:00');
expect($rule->narrowedWindow(new LocalHoursWindow($MSK, 10, 20))->podpis())->toBe('12:0018:00');
});
it('правило со своими границами не ходит в базу ни разу', function () {
// Границы названы снаружи — значит на 20 000 номеров не будет ни одного запроса.
$rule = SmsQuietHours::withWindow(10, 20);
$zaprosov = 0;
DB::listen(function () use (&$zaprosov): void {
$zaprosov++;
});
for ($i = 0; $i < 5000; $i++) {
$rule->canSendNow(180, vtoroeOknoUtc('2026-08-03 09:00'));
}
expect($rule->fromHour())->toBe(10);
expect($rule->toHour())->toBe(20);
expect($zaprosov)->toBe(0);
});
it('бессмысленные границы снаружи правило не принимает', function () {
expect(fn () => SmsQuietHours::withWindow(20, 10))->toThrow(InvalidArgumentException::class);
});