Portiere PHP-Cronjobs auf Spring-Tasks und ergänze Legacy-Tests

Migriert die weiterhin benötigten Zeitaufgaben einschließlich SOAP-Importen, Betriebsstatus und Wiederanlaufkontrolle. Aktiviert 115 native Spring-Tasks im Docker-Image ohne Cron-Daemon oder PHP und dokumentiert die entfallenen HHA-Aufrufe. Ergänzt PHP-/UI-Testwerkzeuge und aktualisiert den Projektstand auf Version 0.2.0.
This commit is contained in:
Sven committed 2026-10-27 09:53:23 +01:00
1 parent 781a2b82dc
commit 9de2358480
57 files changed
+3251 -7

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class CalendarTest extends LegacyTestCase
{
#[DataProvider('leapYears')]
public function testGregorianLeapYears(int $year, bool $expected): void
{
self::assertSame($expected, leapYear($year));
}
public static function leapYears(): array
{
return ['ordinary' => [2026, false], 'leap' => [2024, true],
'century exception' => [1900, false], '400 years' => [2000, true],
'future century' => [2100, false]];
}
#[DataProvider('monthLengths')]
public function testMonthLength(int $year, int $month, int $expected): void
{
self::assertSame($expected, daysPerMonth($year, $month));
}
public static function monthLengths(): array
{
return [[2026, 1, 31], [2026, 2, 28], [2024, 2, 29], [1900, 2, 28],
[2000, 2, 29], [2026, 4, 30], [2026, 12, 31]];
}
#[DataProvider('dayOffsets')]
public function testDayArithmeticProducesValidCalendarDates(array $date, int $offset, array $expected): void
{
$actual = addDaysComplete(...[...$date, $offset]);
self::assertSame($expected, $actual);
self::assertTrue(checkdate($actual[1], $actual[2], $actual[0]));
}
public static function dayOffsets(): array
{
return [
'unchanged' => [[2026, 10, 26], 0, [2026, 10, 26]],
'next day' => [[2026, 10, 26], 1, [2026, 10, 27]],
'year rollover' => [[2026, 12, 31], 1, [2027, 1, 1]],
'February non-leap' => [[2026, 2, 28], 1, [2026, 3, 1]],
'February leap' => [[2024, 2, 28], 1, [2024, 2, 29]],
'leap rollover' => [[2024, 2, 29], 1, [2024, 3, 1]],
'multiple months' => [[2026, 1, 31], 60, [2026, 4, 1]],
'previous day same month' => [[2026, 10, 26], -1, [2026, 10, 25]],
'previous year' => [[2026, 1, 1], -1, [2025, 12, 31]],
'backwards into February' => [[2026, 3, 1], -1, [2026, 2, 28]],
'backwards into leap February' => [[2024, 3, 1], -1, [2024, 2, 29]],
'backwards into 30-day month' => [[2026, 5, 1], -1, [2026, 4, 30]],
'backwards from short month' => [[2026, 4, 1], -1, [2026, 3, 31]],
];
}
#[DataProvider('monthOffsets')]
public function testMonthArithmetic(int $year, int $month, int $offset, array $expected): void
{
self::assertSame($expected, addMonths($year, $month, $offset));
}
public static function monthOffsets(): array
{
return [[2026, 1, 0, [2026, 1]], [2026, 12, 1, [2027, 1]],
[2026, 1, -1, [2025, 12]], [2026, 1, 25, [2028, 2]],
[2026, 1, -13, [2024, 12]]];
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class ConditionTest extends LegacyTestCase
{
#[DataProvider('conditions')]
public function testComparisonOperators(mixed $value, string $operator, mixed $compare, bool $expected): void
{
self::assertSame($expected, mcCheckCondition($value, $operator, $compare));
}
public static function conditions(): array
{
return [[5, '==', '5', true], [5, '==', 6, false], [5, '!=', 6, true],
[5, '!=', '5', false], [4, '<', 5, true], [5, '<', 5, false],
[6, '>', 5, true], [5, '>', 5, false], [5, '<=', 5, true],
[6, '<=', 5, false], [5, '>=', 5, true], [4, '>=', 5, false],
['10', '<', '2', false], ['text', '==', 'text', true],
[1, '===', 1, false], [1, 'invalid', 1, false]];
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class FormattingTest extends LegacyTestCase
{
#[DataProvider('formats')]
public function testLegacyOutput(string $value, string $type, string $format, string $decimals, string $expected): void
{
self::assertSame($expected, formatOutput($value, $type, $format, $decimals));
}
public static function formats(): array
{
return [
'empty cell' => ['', 'varchar', '1', '', '&nbsp;'],
'trim text' => [' Test ', 'text', '1', '', 'Test'],
'zero number' => ['0', 'number', '1', '2', '0,00'],
'positive half cent' => ['1234.565', 'number', '1', '2', '1.234,57'],
'negative half cent' => ['-1234.565', 'number', '1', '2', '-1.234,57'],
'integer rounding' => ['1234.5', 'number', '1', '0', '1.235'],
'numeric string unformatted' => ['001.20', 'number', '1', '', '001.20'],
'non-numeric preserved' => ['12,30', 'number', '1', '2', '12,30'],
'invalid number preserved' => ['bad <value>', 'number', '1', '2', 'bad <value>'],
'rounded float' => ['1234.565', 'float_rounded_2', '1', '', '1.234,57'],
'comma decimal normalized' => ['12,3', 'float', '1', '', '12.30'],
'float with comma' => ['12.345', 'float', '2', '', '12,35'],
'float as cents' => ['12.3', 'float', '3', '', '1230'],
'boolean false' => ['0', 'bool', '1', '', '&nbsp;'],
'boolean true' => ['1', 'bool', '1', '', '<img src="../images/circle.png" height="8" width="8">'],
'datetime time' => ['2026-10-26 14:35:59', 'datetime', '2', '', '14.35.59'],
'datetime full' => ['2026-10-26 14:35:59', 'datetime', '3', '', '26.10.26 : 14.35.59'],
'date four digit year' => ['2026-10-26 14:35:59', 'datetime', '4', '', '26.10.2026'],
'date two digit year' => ['2026-10-26 14:35:59', 'datetime', '5', '', '26.10.26'],
'sortable date' => ['2026-10-26 14:35:59', 'datetime', '8', '', '2026.10.26'],
'compact date' => ['2026-10-26 14:35:59', 'datetime', '9', '', '20261026'],
'hours minutes' => ['2026-10-26 14:35:59', 'datetime', '10', '', '14:35'],
'month year' => ['2026-10-26 14:35:59', 'datetime', '11', '', '10/26'],
'compact timestamp' => ['2026-10-26 14:35:59', 'datetime', '12', '', '20261026143559'],
'full year time display' => ['2026-10-26 14:35:59', 'datetime', '14', '', '14:35 (26.10.2026)'],
'classic timestamp' => ['20261026143559', 'timestamp_classic', '1', '', '26.10.26 : 14.35.59'],
];
}
public function testGlobalTimestampWrapperParameter(): void
{
FixtureState::$parameters[0]['DB_FIELDTYPE_WRAPPER_1'] = '1';
self::assertSame('26.10.26 : 14.35.59', formatOutput('2026-10-26 14:35:59', 'timestamp'));
}
public function testHeadquartersParameterOverridesGlobalTimestampWrapper(): void
{
FixtureState::$parameters[0]['DB_FIELDTYPE_WRAPPER_1'] = '1';
FixtureState::$parameters[7]['DB_FIELDTYPE_WRAPPER_1'] = '0';
self::assertSame('26.10.26 : 14.35.59', formatOutput('20261026143559', 'timestamp'));
}
#[DataProvider('phoneNumbers')]
public function testPhoneNormalization(string $input, array $parameters, string $expected): void
{
FixtureState::$parameters = $parameters;
self::assertSame($expected, eregPhoneNo($input));
}
public static function phoneNumbers(): array
{
return ['local' => ['0171 123-456', [], '171123456'],
'international plus' => ['+49 171 123456', [], '171123456'],
'international zeros' => ['0049 171 123456', [], '171123456'],
'foreign prefix kept' => ['0044 20 123456', [], '4420123456'],
'empty' => ['', [], ''],
'headquarters prefix' => ['+43 664 123456', [7 => ['COUNTRY_FON_PREFIX' => '43']], '664123456'],
'global prefix' => ['+43 664 123456', [0 => ['COUNTRY_FON_PREFIX' => '43']], '664123456'],
'headquarters overrides global' => ['+43 664 123456', [0 => ['COUNTRY_FON_PREFIX' => '49'], 7 => ['COUNTRY_FON_PREFIX' => '43']], '664123456']];
}
#[DataProvider('lists')]
public function testRemoveExactListItem(string $list, string $item, string $delimiter, string $expected): void
{
self::assertSame($expected, removeItemStringList($list, $item, $delimiter));
}
public static function lists(): array
{
return [['123|23|234', '23', '|', '123|234'], ['23|123', '23', '|', '123'],
['123|23', '23', '|', '123'], ['23', '23', '|', ''],
['123|234', '23', '|', '123|234'], ['', '23', '|', ''],
['a,b,c', 'b', ',', 'a,c'], ['23|23|123', '23', '|', '23|123']];
}
}
@@ -0,0 +1,95 @@
<?php
final class InvoiceDiscountTest extends LegacyTestCase
{
private function rows(array $rows, float $selfDiscount = 0, ?float $original = null): FixtureRows
{
$GLOBALS['db']->expect('getOne', 'SELECT jbprc_price FROM jobprice WHERE jb_id = 123 AND mt_sort = 13', $selfDiscount);
$result = new FixtureRows($rows);
$GLOBALS['db']->expect('query', "SELECT trs_discount, trs_price, trs_srv_name FROM tourservice WHERE jb_id = '123'", $result);
if ($original !== null) {
$GLOBALS['db']->expect('getOne', 'SELECT jbprc_remark FROM jobprice WHERE jb_id = 123 AND mt_sort = 13', $original);
}
return $result;
}
private function row(string $name, float $price, float $discount = 0): array
{
return ['trs_srv_name' => $name, 'trs_price' => $price, 'trs_discount' => $discount];
}
private function item(int $index): array
{
[$table, $row] = FixtureState::$writes[$index];
self::assertSame('phoenix_log.b2b_objects', $table);
$fields = [];
for ($i = 0; $i < count($row); $i += 2) {
$fields[$row[$i]] = $row[$i + 1];
}
self::assertSame(300, $fields['bo_type']);
self::assertSame(123, $fields['bo_ext_id0']);
self::assertSame(7, $fields['bo_ext_id1']);
return json_decode($fields['bo_obj_data'], true, 512, JSON_THROW_ON_ERROR);
}
public function testNoDiscountProducesNoTextOrBiItems(): void
{
$rows = $this->rows([$this->row('Fixpreis', 100)]);
self::assertSame('', checkDiscountStr(123, 0, 100, 0));
self::assertSame([], FixtureState::$writes);
self::assertTrue($rows->freed);
}
public function testDiscountTextAndBiItemUseSameAmounts(): void
{
FixtureState::$parameters[7]['INV_PRINT_DISCOUNT'] = '1';
$this->rows([$this->row('Fixpreis', 90, 10)]);
self::assertSame("\nRabatt 10,00% auf 90,00 EUR = -9,00 EUR", checkDiscountStr(123, 0, 90, 0));
self::assertCount(1, FixtureState::$writes);
self::assertSame(['c', '0.1', 's', 'Rabatt', 'p', '90', 't', '-9'], $this->item(0));
}
public function testHiddenDiscountDoesNotCreateBiItem(): void
{
FixtureState::$parameters[7]['INV_PRINT_DISCOUNT'] = '0';
$this->rows([$this->row('Fixpreis', 90, 10)]);
self::assertSame('', checkDiscountStr(123, 0, 90, 0));
self::assertSame([], FixtureState::$writes);
}
public function testFuelMarkupExcludesServicePrice(): void
{
FixtureState::$parameters[7]['MASK_MARKUP_MODE'] = '1';
$this->rows([$this->row('Fixpreis', 110), $this->row('Servicepreis', 12)]);
self::assertSame("\nTreibstoffzuschlag 10,00% auf 100,00 EUR = 10,00 EUR", checkDiscountStr(123, 10, 110, 12));
self::assertCount(1, FixtureState::$writes);
self::assertSame(['c', '0.1', 's', 'Treibstoffzuschlag', 'p', '100', 't', '10'], $this->item(0));
}
public function testSelfEntryDiscountUsesStoredOriginalAmount(): void
{
$this->rows([$this->row('Fixpreis', 90), $this->row('Servicepreis', 12)], 10, 100);
self::assertSame("\nSelbsterfasserrabatt 10,00% auf 100,00 EUR = -10,00 EUR", checkDiscountStr(123, 0, 90, 12));
self::assertCount(1, FixtureState::$writes);
self::assertSame(['c', '0.1', 's', 'Selbsterfasserrabatt', 'p', '100', 't', '-10'], $this->item(0));
}
public function testMarkupThenSelfEntryDiscountKeepTextAndBiOrder(): void
{
FixtureState::$parameters[7]['MASK_MARKUP_MODE'] = '1';
$this->rows([$this->row('Fixpreis', 99)], 10, 110);
self::assertSame("\nTreibstoffzuschlag 10,00% auf 100,00 EUR = 10,00 EUR\nSelbsterfasserrabatt 10,00% auf 110,00 EUR = -11,00 EUR",
checkDiscountStr(123, 10, 99, 0));
self::assertCount(2, FixtureState::$writes);
self::assertSame(['c', '0.1', 's', 'Treibstoffzuschlag', 'p', '100', 't', '10'], $this->item(0));
self::assertSame(['c', '0.1', 's', 'Selbsterfasserrabatt', 'p', '110', 't', '-11'], $this->item(1));
}
public function testLegacyEuroByteIsPreserved(): void
{
FixtureState::$parameters[7] = ['MASK_MARKUP_MODE' => '1', 'INV_USE_EURO_SIGN' => '1'];
$this->rows([$this->row('Fixpreis', 110)]);
self::assertSame("\nTreibstoffzuschlag 10,00% auf 100,00 " . chr(128) . ' = 10,00 ' . chr(128),
checkDiscountStr(123, 10, 110, 0));
}
}
@@ -0,0 +1,44 @@
<?php
final class LegacyFunctionsTest extends PHPUnit\Framework\TestCase
{
public function testOnlySelectedTopLevelFunctionIsExtracted(): void
{
$source = <<<'SOURCE'
<?php
file_put_contents('/never-execute', 'startup');
// function wanted() { wrong }
class Example { function wanted() { return 'method'; } }
function unwanted() { return '}'; }
function wanted($name) { /* } */ return "{$name}: }"; }
SOURCE;
$found = LegacyFunctions::extract($source, ['wanted']);
self::assertSame(['wanted'], array_keys($found));
self::assertSame('function wanted($name) { /* } */ return "{$name}: }"; }', $found['wanted']['code']);
self::assertSame(6, $found['wanted']['line']);
}
public function testMissingFunctionFailsClosed(): void
{
$this->expectException(RuntimeException::class);
LegacyFunctions::extract('<?php function different() {}', ['wanted']);
}
public function testNestedFunctionCannotBeMistakenForGlobalDeclaration(): void
{
$this->expectException(RuntimeException::class);
LegacyFunctions::extract('<?php function outer() { function wanted() {} }', ['wanted']);
}
public function testIncludesCannotLoadApplicationBootstrap(): void
{
$this->expectException(RuntimeException::class);
LegacyFunctions::extract('<?php function wanted() { require "db.php"; }', ['wanted']);
}
public function testDuplicateDefinitionsFailClosed(): void
{
$this->expectException(RuntimeException::class);
LegacyFunctions::extract('<?php function wanted() {} function wanted() {}', ['wanted']);
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class PricingTest extends LegacyTestCase
{
private const DATE = '2026-10-26 10:00:00';
#[DataProvider('prices')]
public function testCustomerAndStandardTariffs(array $customer, ?array $standard, array $expected): void
{
$this->expectServiceIds(7);
FixtureState::$serviceCalls[] = [[101, 201, 42, 7, self::DATE, 4, 3], $customer];
if ($standard !== null) {
FixtureState::$serviceCalls[] = [[101, 201, 0, 7, self::DATE, 4, 3], $standard];
}
self::assertSame($expected, getServicePrices('Transport', 'PKW', 42, 7, self::DATE, 0, 0, 0, false, 3));
}
public static function prices(): array
{
$standard = [100, 70, 5, 9, '2026-01-01'];
return [
'customer tariff' => [[80, 60, 10, 3, '2026-10-01'], null, [80, 0, 10, 0, 60, 0, true, '2026-10-01']],
'standard fallback' => [[0, 0, 0, 0, ''], $standard, [100, 0, 5, 0, 70, 0, false, '2026-01-01']],
'negative customer price is correction' => [[-15, 0, 0, 0, ''], $standard, [100, -15, 5, 0, 70, 0, true, '2026-01-01']],
'negative discount is correction' => [[0, 0, -3, 0, ''], $standard, [100, 0, 5, -3, 70, 0, true, '2026-01-01']],
'discount only keeps customer discount' => [[0, 0, 12, 0, ''], $standard, [100, 0, 12, 0, 70, 0, true, '2026-01-01']],
'one cent denotes free tariff' => [[0.01, 0.01, 0, 0, '2026-10-01'], null, [0, 0, 0, 0, 0, 0, true, '2026-10-01']],
'two cents remains charged' => [[0.02, 0.02, 0, 0, '2026-10-01'], null, [0.02, 0, 0, 0, 0.02, 0, true, '2026-10-01']],
];
}
public function testMissingHeadquartersTypeSwitchesBothLookupsToGlobalScope(): void
{
$GLOBALS['db']->expect('getOne', "SELECT srvt_id FROM servicetype WHERE srvt_name = 'PKW' AND srvt_mode = 0 AND hq_id = '7'", '');
$this->expectServiceIds(0);
FixtureState::$serviceCalls[] = [[101, 201, 42, 0, self::DATE, 4, 3], [80, 60, 0, 0, '2026-01-01']];
self::assertSame([80, 0, 0, 0, 60, 0, true, '2026-01-01'],
getServicePrices('Transport', 'PKW', 42, 7, self::DATE, 0, 0, 0, false, 3));
}
public function testExplicitDiscountAndCorrectionsArePreserved(): void
{
$this->expectServiceIds(7);
FixtureState::$serviceCalls[] = [[101, 201, 42, 7, self::DATE, 4, 3], [80, 60, 10, 0, '2026-01-01']];
self::assertSame([80, -2, 15, -1, 60, 0, true, '2026-01-01'],
getServicePrices('Transport', 'PKW', 42, 7, self::DATE, -2, 15, -1, false, 3));
}
private function expectServiceIds(int $hq): void
{
$GLOBALS['db']->expect('getOne', "SELECT srvt_id FROM servicetype WHERE srvt_name = 'PKW' AND srvt_mode = 0 AND hq_id = '$hq'", 201);
$GLOBALS['db']->expect('getOne', "SELECT srv_id FROM service WHERE srv_name = 'Transport' AND srv_mode = 0 AND hq_id = '$hq'", 101);
}
#[DataProvider('courierPrices')]
public function testGlobalCourierDeduction(mixed $price, mixed $rate, float $expected): void
{
$this->expectCourierIds('Globaler<br>Frachtverg.-Abschlag [%]');
FixtureState::$serviceCalls[] = [[301, 201, 42, 7, self::DATE, 4], [0, $rate, 0, 0, '2026-01-01']];
self::assertSame([$expected, is_numeric($rate) ? $rate : 0],
getCr_price_global($price, 7, 42, self::DATE, 'PKW', 0));
}
public static function courierPrices(): array
{
return ['percentage' => [100, 20, 80.0], 'cent rounding' => [10.01, 15, 8.51],
'negative credit' => [-100, 20, -80.0], 'full deduction' => [100, 100, 0.0],
'invalid base' => ['invalid', 20, 0.0], 'invalid deduction' => [100, 'invalid', 100.0]];
}
public function testZeroCustomerDeductionFallsBackToStandard(): void
{
$this->expectCourierIds('Globaler<br>Frachtverg.-Abschlag [%]');
FixtureState::$serviceCalls = [
[[301, 201, 42, 7, self::DATE, 4], [0, 0, 0, 0, '']],
[[301, 201, 0, 7, self::DATE, 4], [0, 25, 0, 0, '2026-01-01']],
];
self::assertSame([75.0, 25], getCr_price_global(100, 7, 42, self::DATE, 'PKW', 0));
}
public function testVehicleIdOverridesVehicleNameAndEmptyCustomerIsZero(): void
{
$GLOBALS['db']->expect('getOne', "SELECT mt_value FROM metatype WHERE mt_sort = '3' AND mt_type = 'vehicletype'", 'PKW');
$this->expectCourierIds('Globaler<br>Frachtverg.-Abschlag [%]');
FixtureState::$serviceCalls[] = [[301, 201, 0, 7, self::DATE, 4], [0, 20, 0, 0, '2026-01-01']];
self::assertSame([80.0, 20], getCr_price_global(100, 7, '', self::DATE, 'ignored', 3));
}
public function testCourierRateUsesSeparateTariffAndStandardFallback(): void
{
$this->expectCourierIds('Globaler<br>Fuhrlohnabschlag [%]');
FixtureState::$serviceCalls = [
[[301, 201, 42, 7, self::DATE, 4], [0, 0, 0, 0, '']],
[[301, 201, 0, 7, self::DATE, 4], [0, 17, 0, 0, '2026-01-01']],
];
self::assertSame(17, get_cr_price_rate(7, 42, self::DATE, 'PKW', 0));
}
private function expectCourierIds(string $service): void
{
$GLOBALS['db']->expect('getOne', "SELECT srvt_id FROM servicetype WHERE srvt_name = 'PKW' AND srvt_mode = 0 AND hq_id = '7'", 201);
$GLOBALS['db']->expect('getOne', "SELECT srv_id FROM service WHERE srv_name = '$service' AND srv_mode = 0 AND hq_id = '7'", 301);
}
}
@@ -0,0 +1,58 @@
<?php
use PHPUnit\Framework\Attributes\DataProvider;
final class RecurringOrderTest extends LegacyTestCase
{
#[DataProvider('schedules')]
public function testNextOrderDate(array $date, int $rhythm, string $weekdays, array $holidays, array $expected): void
{
$GLOBALS['ph'] = $holidays;
$actual = getNextOrderDate(...[...$date, $rhythm, $weekdays, false, 2028, 12, 31]);
self::assertSame($expected, array_map('intval', $actual));
self::assertTrue(checkdate((int)$actual[1], (int)$actual[2], (int)$actual[0]));
}
public static function schedules(): array
{
return [
'weekdays skip weekend' => [[2026, 10, 23], 1, '1111100', [], [2026, 10, 26]],
'weekdays skip configured holiday' => [[2026, 10, 23], 1, '1111100', [[2026, 10, 26]], [2026, 10, 27]],
'Saturday enabled' => [[2026, 10, 23], 2, '1111110', [], [2026, 10, 24]],
'daily includes Sunday' => [[2026, 10, 24], 3, '1111111', [], [2026, 10, 25]],
'daily excludes configured holiday' => [[2026, 10, 24], 3, '1111111', [[2026, 10, 25]], [2026, 10, 26]],
'individual weekday mask' => [[2026, 10, 26], 1, '0010000', [], [2026, 10, 28]],
'weekly' => [[2026, 10, 26], 4, '', [], [2026, 11, 2]],
'weekly holiday moves forward' => [[2026, 10, 26], 4, '', [[2026, 11, 2], [2026, 11, 3]], [2026, 11, 4]],
'fortnightly' => [[2026, 10, 26], 5, '', [], [2026, 11, 9]],
'monthly clips short month' => [[2026, 1, 31], 6, '', [], [2026, 2, 28]],
'monthly clips leap month' => [[2024, 1, 31], 6, '', [], [2024, 2, 29]],
'monthly holiday moves forward' => [[2026, 1, 31], 6, '', [[2026, 2, 28]], [2026, 3, 1]],
'monthly year rollover' => [[2026, 12, 15], 6, '', [], [2027, 1, 15]],
'month end' => [[2026, 4, 12], 7, '', [], [2026, 5, 31]],
'month end holiday moves backward' => [[2026, 4, 12], 7, '', [[2026, 5, 31]], [2026, 5, 30]],
'half month from fifteenth' => [[2026, 4, 15], 8, '', [], [2026, 4, 30]],
'half month from month end' => [[2026, 4, 30], 8, '', [], [2026, 5, 15]],
'half month holiday moves backward' => [[2026, 4, 30], 8, '', [[2026, 5, 15]], [2026, 5, 14]],
'holiday only' => [[2026, 12, 23], 9, '0000000', [[2026, 12, 25]], [2026, 12, 25]],
'holiday or weekday: weekday sooner' => [[2026, 12, 23], 9, '1111100', [[2026, 12, 25]], [2026, 12, 24]],
'holiday or weekday: holiday sooner' => [[2026, 12, 23], 9, '0000001', [[2026, 12, 25]], [2026, 12, 25]],
'sixteenth from first' => [[2026, 4, 1], 10, '', [], [2026, 4, 16]],
'first from sixteenth' => [[2026, 4, 16], 10, '', [], [2026, 5, 1]],
'first holiday moves forward' => [[2026, 4, 16], 10, '', [[2026, 5, 1]], [2026, 5, 2]],
'four weeks over new year' => [[2026, 12, 15], 11, '', [], [2027, 1, 12]],
'four weeks holiday moves forward' => [[2026, 12, 15], 11, '', [[2027, 1, 12]], [2027, 1, 13]],
'DST spring keeps calendar day' => [[2026, 3, 28], 3, '1111111', [], [2026, 3, 29]],
'DST autumn keeps calendar day' => [[2026, 10, 24], 3, '1111111', [], [2026, 10, 25]],
];
}
public function testHolidayLookupUsesExactDateFromConfiguredList(): void
{
$GLOBALS['ph'] = [[2026, 12, 25], [2026, 12, 26]];
self::assertTrue(isPublicHoliday(2026, 12, 25));
self::assertFalse(isPublicHoliday(2025, 12, 25));
self::assertFalse(isPublicHoliday(2026, 11, 25));
self::assertFalse(isPublicHoliday(2026, 12, 24));
}
}