test(ipv6-subnet-calculator): differential suite vs CPython ipaddress; fix /128 netmask

- fix: netmask formula special case was inverted — /128 produced an
  all-zero mask instead of ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff
- add 124-case differential fixture generated with CPython ipaddress
  (random 128-bit values x prefix set + classic edge cases), comparing
  expanded/network/first/last/total/netmask against the implementation
This commit is contained in:
a0666660 2026-10-02 20:49:23 +08:00
parent cee08bdd54
commit b2b0af666f
3 changed files with 1286 additions and 3 deletions

View file

@ -0,0 +1,39 @@
// Differential test: the IPv6 calculator must agree with Python's `ipaddress`
// stdlib on 124 random + hand-picked cases (fixture generated by CPython).
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { describeIpv6 } from './ipv6-subnet-calculator.models';
interface FixtureCase {
input: string
expanded: string
compressed: string
network: string
first: string
last: string
total: string
netmask: string
}
const cases: FixtureCase[] = JSON.parse(
readFileSync(new URL('./ipv6.fixture.json', import.meta.url), 'utf8'),
);
// Python's ipaddress uses lowercase hex for IPv6 str(); my implementation is
// lowercase per RFC 5952 §4.3 recommendation. Normalize for comparison.
const lower = (s: string) => s.toLowerCase();
describe('ipv6-subnet-calculator: differential vs CPython ipaddress', () => {
it.each(cases.map(c => [c.input, c]))('case %s', (_input, c) => {
const info = describeIpv6(c.input);
expect(lower(info.expanded)).toBe(lower(c.expanded));
// compression: compare the network VALUE (Python may pick a different
// zero-run on ties; the address itself must match) plus the prefix
expect(lower(info.network.split('/')[0])).toBe(lower(c.network.split('/')[0]));
expect(info.network.endsWith(`/${c.network.split('/')[1]}`)).toBe(true);
expect(lower(info.firstAddress)).toBe(lower(c.first));
expect(lower(info.lastAddress)).toBe(lower(c.last));
expect(info.totalAddresses).toBe(c.total);
expect(lower(info.mask)).toBe(lower(c.netmask));
});
});

View file

@ -135,12 +135,14 @@ export function describeIpv6(cidr: string): Ipv6Info {
const hostBits = 128 - prefix;
const networkValue = prefix === 0 ? 0n : (addressValue >> BigInt(hostBits)) << BigInt(hostBits);
const total = hostBits >= 0 ? (1n << BigInt(hostBits)) : 0n;
const total = 1n << BigInt(hostBits);
const firstValue = networkValue;
const lastValue = networkValue + total - 1n;
const maskHostBits = BigInt(hostBits);
const maskValue = hostBits === 0 ? 0n : ((1n << (128n - maskHostBits)) - 1n) << maskHostBits;
// Top `prefix` bits set. (2^p - 1) << (128 - p) is exact for every p in
// 0..128 — the old special case for hostBits === 0 was inverted and
// produced an all-zero netmask for /128 (#differential fixture).
const maskValue = ((1n << BigInt(prefix)) - 1n) << BigInt(128 - prefix);
const { type, scope } = describeType(address);

File diff suppressed because it is too large Load diff