64 gets capped. self::assertSame(['2001:db8::/32', '2001:db8::/64'], IP6Helper::trimCIDRs(['2001:db8::/32', '2001:db8::/128'])); } public function testTrimCIDRsKeepsOnlyEntriesWithSlash(): void { self::assertSame(['2001:db8::/32'], IP6Helper::trimCIDRs(['2001:db8::/32', '2001:db8::', 'not-a-cidr'])); } public function testProcessCIDREmptyInputReturnsEmpty(): void { // Same contract as IP4Helper::processCIDR — empty input short-circuits // before any shell call and passes through minimizeSubnets (no-op on []). self::assertSame([], IP6Helper::processCIDR([])); } public function testProcessCIDRSkipsLoopbackWithoutShelling(): void { self::assertSame([], IP6Helper::processCIDR(['::1'])); } public function testProcessCIDRSkipsIpAlreadyInExistingRange(): void { // 2001:db8::5 is inside the pre-seeded /32 → isInRange short-circuits // the async body before any shell call. self::assertSame(['2001:db8::/32'], IP6Helper::processCIDR(['2001:db8::5'], ['2001:db8::/32'])); } // ------------------------------------------------------------------ // growReplace / applyReplace — the CIDR-replacement feature (IPv6) // ------------------------------------------------------------------ private function replace(array $cidr4 = [], array $cidr6 = []): object { $build = function (array $map): \stdClass { $obj = new \stdClass(); foreach ($map as $key => $values) { $obj->{$key} = $values; } return $obj; }; return (object) ['cidr4' => $build($cidr4), 'cidr6' => $build($cidr6)]; } public function testGrowReplaceAppendsContainedIpsAsSlash128(): void { $replace = $this->replace([], ['2001:db8::/32' => []]); IP6Helper::growReplace($replace, ['2001:db8::1', '2001:db8:ff::5', '2001:db9::1']); self::assertEqualsCanonicalizing(['2001:db8::1/128', '2001:db8:ff::5/128'], $replace->cidr6->{'2001:db8::/32'}); } public function testGrowReplaceIsIdempotent(): void { $replace = $this->replace([], ['2001:db8::/32' => []]); IP6Helper::growReplace($replace, ['2001:db8::1', '2001:db8::2']); $after1 = $replace->cidr6->{'2001:db8::/32'}; IP6Helper::growReplace($replace, ['2001:db8::1', '2001:db8::2']); self::assertSame($after1, $replace->cidr6->{'2001:db8::/32'}); } public function testGrowReplaceAbsorbsHostEntriesIntoAdminProvidedSubnet(): void { // /48 provided by admin absorbs /128 host entries inside it. $replace = $this->replace([], ['2001:db8::/32' => ['2001:db8:1::/48']]); IP6Helper::growReplace($replace, ['2001:db8:1::5', '2001:db8:2::9']); self::assertEqualsCanonicalizing(['2001:db8:1::/48', '2001:db8:2::9/128'], $replace->cidr6->{'2001:db8::/32'}); } public function testGrowReplaceNoOpWhenCidr6MissingOrWrongType(): void { $replace = (object) []; IP6Helper::growReplace($replace, ['2001:db8::1']); self::assertFalse(isset($replace->cidr6)); $replace = (object) ['cidr6' => []]; IP6Helper::growReplace($replace, ['2001:db8::1']); self::assertSame([], $replace->cidr6); } public function testApplyReplaceDropsKeyAndSubstitutesValues(): void { $replace = $this->replace([], ['2001:db8::/32' => ['2001:db8:1::/48']]); $result = IP6Helper::applyReplace(['fe80::/10', '2001:db8::/32'], $replace); self::assertSame(['fe80::/10', '2001:db8:1::/48'], $result); } public function testApplyReplaceEmptyValueArrayDropsKeyEntirely(): void { $replace = $this->replace([], ['2001:db8::/32' => []]); $result = IP6Helper::applyReplace(['2001:db8::/32', '2001:db9::/32'], $replace); self::assertSame(['2001:db9::/32'], $result); } public function testApplyReplaceReturnsCidrsUnchangedWhenCidr6Missing(): void { $replace = (object) []; self::assertSame(['2001:db8::/32'], IP6Helper::applyReplace(['2001:db8::/32'], $replace)); } // ------------------------------------------------------------------ // aggregateSubnets — CIDR supernetting (IPv6) // ------------------------------------------------------------------ public function testAggregateSubnetsEmptyReturnsEmpty(): void { self::assertSame([], IP6Helper::aggregateSubnets([])); } public function testAggregateSubnetsMergesFourConsecutiveSlash128IntoSlash126(): void { self::assertSame( ['2001:db8::/126'], IP6Helper::aggregateSubnets([ '2001:db8::/128', '2001:db8::1/128', '2001:db8::2/128', '2001:db8::3/128', ]) ); } public function testAggregateSubnetsMergesAdjacentRanges(): void { // /64 + adjacent /64 → /63. self::assertSame( ['2001:db8::/63'], IP6Helper::aggregateSubnets(['2001:db8::/64', '2001:db8:0:1::/64']) ); } public function testAggregateSubnetsCollapsesOverlappingRanges(): void { self::assertSame( ['2001:db8::/48'], IP6Helper::aggregateSubnets(['2001:db8::/48', '2001:db8::dead:beef/128']) ); } public function testAggregateSubnetsKeepsDisjointRanges(): void { $result = IP6Helper::aggregateSubnets(['2001:db8::/48', '2001:db9::/48']); self::assertSame(['2001:db8::/48', '2001:db9::/48'], $result); } public function testAggregateSubnetsRespectsParentCapBelowFullCoverage(): void { // Four /128 hosts fill /126 entirely; the parent is /126 → cap forces // strictly narrower /127 blocks. $result = IP6Helper::aggregateSubnets( ['2001:db8::/128', '2001:db8::1/128', '2001:db8::2/128', '2001:db8::3/128'], '2001:db8::/126' ); self::assertSame(['2001:db8::/127', '2001:db8::2/127'], $result); } public function testAggregateSubnetsNeverEmitsParentItself(): void { self::assertSame( ['2001:db8::/33', '2001:db8:8000::/33'], IP6Helper::aggregateSubnets(['2001:db8::/32'], '2001:db8::/32') ); } public function testAggregateSubnetsSkipsMalformedEntries(): void { self::assertSame( ['2001:db8::/48'], IP6Helper::aggregateSubnets(['not-a-cidr', '', '2001:db8::/48', '2001:db8::/200']) ); } public function testAggregateSubnetsIdempotent(): void { $first = IP6Helper::aggregateSubnets([ '2001:db8::/128', '2001:db8::1/128', '2001:db8::2/128', '2001:db8::3/128', ]); $second = IP6Helper::aggregateSubnets($first); self::assertSame($first, $second); } public function testGrowReplaceWithAggregateFusesContiguousIps(): void { $replace = $this->replace([], ['2001:db8::/32' => []]); IP6Helper::growReplace( $replace, ['2001:db8::', '2001:db8::1', '2001:db8::2', '2001:db8::3'], true ); self::assertSame(['2001:db8::/126'], $replace->cidr6->{'2001:db8::/32'}); } public function testGrowReplaceWithAggregateNeverProducesParentKey(): void { $replace = $this->replace([], ['2001:db8::/126' => []]); IP6Helper::growReplace( $replace, ['2001:db8::', '2001:db8::1', '2001:db8::2', '2001:db8::3'], true ); self::assertSame(['2001:db8::/127', '2001:db8::2/127'], $replace->cidr6->{'2001:db8::/126'}); } public function testGrowReplaceDefaultDoesNotAggregate(): void { $replace = $this->replace([], ['2001:db8::/32' => []]); IP6Helper::growReplace($replace, ['2001:db8::', '2001:db8::1']); self::assertEqualsCanonicalizing( ['2001:db8::/128', '2001:db8::1/128'], $replace->cidr6->{'2001:db8::/32'} ); } // ------------------------------------------------------------------ // Density collapse — lossy /64-bucket expansion (IPv6) // ------------------------------------------------------------------ public function testAggregateSubnetsDensityCollapsesScatteredSlash128(): void { // 5 scattered /128s in a single /64 — lossless would keep them all // (non-adjacent). With threshold 5, the whole /64 is claimed. $cidrs = [ '2001:db8::/128', '2001:db8::1/128', '2001:db8::5/128', '2001:db8::a/128', '2001:db8::b/128', ]; self::assertSame(['2001:db8::/64'], IP6Helper::aggregateSubnets($cidrs, '2001:db8::/32', 5)); } public function testAggregateSubnetsDensityBelowThresholdKeepsLosslessResult(): void { // Threshold 6 not reached — lossless aggregation runs its course: // ::/128 + ::1/128 → ::/127; ::a/128 + ::b/128 → ::a/127; ::5/128 alone. $cidrs = [ '2001:db8::/128', '2001:db8::1/128', '2001:db8::5/128', '2001:db8::a/128', '2001:db8::b/128', ]; self::assertSame( ['2001:db8::/127', '2001:db8::5/128', '2001:db8::a/127'], IP6Helper::aggregateSubnets($cidrs, '2001:db8::/32', 6) ); } public function testAggregateSubnetsDensityCountsCoveredAddressesNotBlocks(): void { // One /126 (4 addresses) + one /128 = 5 covered addresses in the /64. $cidrs = ['2001:db8::/126', '2001:db8::ff/128']; // Threshold 5 → claim the whole /64. Threshold 6 → keep originals. self::assertSame(['2001:db8::/64'], IP6Helper::aggregateSubnets($cidrs, '2001:db8::/32', 5)); self::assertSame($cidrs, IP6Helper::aggregateSubnets($cidrs, '2001:db8::/32', 6)); } public function testAggregateSubnetsDensitySkippedWhenParentPrefixAtLeast64(): void { // Parent /64 — no room for /64 buckets inside. Density skipped; // standard aggregation + cap applies (forces /65+). $result = IP6Helper::aggregateSubnets( ['2001:db8::/128', '2001:db8::1/128', '2001:db8::2/128'], '2001:db8::/64', 2 ); self::assertSame(['2001:db8::/127', '2001:db8::2/128'], $result); } public function testAggregateSubnetsDensityZeroIsIdentityToLossless(): void { $cidrs = ['2001:db8::1/128', '2001:db8::50/128', '2001:db8::100/128']; $lossless = IP6Helper::aggregateSubnets($cidrs, '2001:db8::/32'); $withZero = IP6Helper::aggregateSubnets($cidrs, '2001:db8::/32', 0); self::assertSame($lossless, $withZero); } public function testGrowReplacePassesDensityThresholdThrough(): void { $replace = $this->replace([], ['2001:db8::/32' => []]); $ips = array_map(fn(int $i) => '2001:db8::' . dechex($i * 16), range(0, 9)); IP6Helper::growReplace($replace, $ips, true, 10); self::assertSame(['2001:db8::/64'], $replace->cidr6->{'2001:db8::/32'}); } // ------------------------------------------------------------------ // Density collapse — wide /32 tier // ------------------------------------------------------------------ public function testAggregateSubnetsDensityWideCollapsesAcrossSlash64s(): void { // Four /64 blocks in different /64 buckets within one /32. Each /64 // is bucket-sized and passes through the narrow tier. At /32 level, // each /64 contributes 2^64 addresses (saturated to PHP_INT_MAX) → // threshold ≥ 1 is reached trivially. $cidrs = array_map(fn(int $s) => sprintf('2001:db8:%x::/64', $s), range(0, 3)); self::assertSame(['2001:db8::/32'], IP6Helper::aggregateSubnets($cidrs, '2001:db8::/16', 0, 1)); } public function testAggregateSubnetsDensityTieredPyramidFeedsWideFromNarrow(): void { // Each /64 has one scattered /128 → narrow threshold 1 inflates to // full /64. Four inflated /64s within one /32 then feed the wide // tier (threshold 1). $cidrs = []; for ($s = 0; $s < 4; $s++) { $cidrs[] = sprintf('2001:db8:%x::1/128', $s); } self::assertSame(['2001:db8::/32'], IP6Helper::aggregateSubnets($cidrs, '2001:db8::/16', 1, 1)); } public function testAggregateSubnetsDensityWideSkippedWhenParentPrefixAtLeast32(): void { // Parent /32 — no room for /32 buckets. Wide tier skipped; narrow // still runs (parent=32 < 64). $result = IP6Helper::aggregateSubnets( ['2001:db8::/128', '2001:db8::1/128', '2001:db8::2/128'], '2001:db8::/32', 2, 2 ); self::assertSame(['2001:db8::/64'], $result); } public function testAggregateSubnetsDensityWideZeroDoesNotTrigger(): void { // Two non-adjacent /64s across different /32 buckets. With both // tiers disabled (0, 0), lossless aggregation leaves them intact. $cidrs = ['2001:db8::/64', '2001:db9::/64']; $result = IP6Helper::aggregateSubnets($cidrs, '2001:0::/8', 0, 0); self::assertSame($cidrs, $result); } public function testGrowReplacePassesBothDensityThresholdsThrough(): void { $replace = $this->replace([], ['2001:db8::/16' => []]); $ips = array_map(fn(int $s) => sprintf('2001:db8:%x::1', $s), range(0, 3)); IP6Helper::growReplace($replace, $ips, true, 1, 1); self::assertSame(['2001:db8::/32'], $replace->cidr6->{'2001:db8::/16'}); } }