toBe(''); }); it('passes through a valid UTF-8 string with multibyte characters', function (): void { // Given $input = 'Héllo café résumé'; // When $result = StringEncoder::toUtf8($input, 'UTF-8'); // Then expect($result)->toBe('Héllo café résumé'); }); it('throws ValueError for an invalid UTF-8 string', function (): void { // Given $input = "Invalid \xC3\x28 sequence"; // When / Then StringEncoder::toUtf8($input, 'UTF-8'); })->throws(\ValueError::class, 'not valid UTF-8'); it('strips the UTF-8 BOM by default', function (): void { // Given $input = "\xEF\xBB\xBFBonjour"; // When $result = StringEncoder::toUtf8($input, 'UTF-8'); // Then expect($result)->toBe('Bonjour'); }); it('preserves the UTF-8 BOM when stripBom is false', function (): void { // Given $input = "\xEF\xBB\xBFBonjour"; // When $result = StringEncoder::toUtf8($input, 'UTF-8', stripBom: false); // Then expect($result)->toBe("\xEF\xBB\xBFBonjour"); }); it('converts Windows-1252 special characters to UTF-8', function (int $byte, string $expectedUtf8): void { // Given $input = chr($byte); // When $result = StringEncoder::toUtf8($input, 'Windows-1252'); // Then expect($result)->toBe($expectedUtf8); })->with([ 'euro sign (0x80)' => [0x80, '€'], 'single low-9 quote (0x82)' => [0x82, '‚'], 'latin small f (0x83)' => [0x83, 'ƒ'], 'double low-9 quote (0x84)' => [0x84, '„'], 'ellipsis (0x85)' => [0x85, '…'], 'dagger (0x86)' => [0x86, '†'], 'double dagger (0x87)' => [0x87, '‡'], 'circumflex (0x88)' => [0x88, 'ˆ'], 'per mille (0x89)' => [0x89, '‰'], 'S caron (0x8A)' => [0x8A, 'Š'], 'left single guillemet (0x8B)' => [0x8B, '‹'], 'OE ligature (0x8C)' => [0x8C, 'Œ'], 'Z caron (0x8E)' => [0x8E, 'Ž'], 'left single quote (0x91)' => [0x91, "\u{2018}"], 'right single quote (0x92)' => [0x92, "\u{2019}"], 'left double quote (0x93)' => [0x93, "\u{201C}"], 'right double quote (0x94)' => [0x94, "\u{201D}"], 'bullet (0x95)' => [0x95, '•'], 'en dash (0x96)' => [0x96, '–'], 'em dash (0x97)' => [0x97, '—'], 'tilde (0x98)' => [0x98, '˜'], 'trademark (0x99)' => [0x99, '™'], 's caron (0x9A)' => [0x9A, 'š'], 'right single guillemet (0x9B)' => [0x9B, '›'], 'oe ligature (0x9C)' => [0x9C, 'œ'], 'z caron (0x9E)' => [0x9E, 'ž'], 'Y diaeresis (0x9F)' => [0x9F, 'Ÿ'], ]); it('converts Windows-1252 high bytes (0xA0-0xFF) as ISO-8859-1 identity', function (): void { // Given $input = chr(0xE9); // When $result = StringEncoder::toUtf8($input, 'Windows-1252'); // Then the result is the UTF-8 encoded é expect($result)->toBe('é'); }); it('converts a full Windows-1252 sentence with mixed ranges', function (): void { // Given $input = "B\x9Cuf \xE0 5\x80"; // When $result = StringEncoder::toUtf8($input, 'Windows-1252'); // Then expect($result)->toBe('Bœuf à 5€'); }); it('handles undefined Windows-1252 bytes (0x81, 0x8D, 0x8F, 0x90, 0x9D) via Latin-1 fallback', function (int $byte): void { // Given $input = chr($byte); // When $result = StringEncoder::toUtf8($input, 'Windows-1252'); // Then expect(mb_check_encoding($result, 'UTF-8'))->toBeTrue() ->and(mb_strlen($result, 'UTF-8'))->toBe(1); })->with([0x81, 0x8D, 0x8F, 0x90, 0x9D]); it('converts ISO-8859-15 euro sign to UTF-8', function (): void { // Given $input = chr(0xA4); // When $result = StringEncoder::toUtf8($input, 'ISO-8859-15'); // Then expect($result)->toBe('€'); }); it('accepts encoding aliases', function (string $alias, string $expected): void { // Given $input = "caf\xE9"; // é in Latin-1/Win-1252 // When $result = StringEncoder::toUtf8($input, $alias); // Then expect($result)->toBe($expected); })->with([ 'latin1' => ['latin1', 'café'], 'latin-1' => ['latin-1', 'café'], 'win1252' => ['win1252', 'café'], 'windows1252' => ['windows1252', 'café'], 'cp1252' => ['cp1252', 'café'], ]); it('throws ValueError for an unsupported encoding', function (): void { // Given / When / Then StringEncoder::toUtf8('hello', 'EBCDIC-42'); })->throws(\ValueError::class, 'Unsupported encoding'); }); describe('detectAndConvertToUtf8()', function (): void { it('returns an empty string when given an empty string', function (): void { // Given $input = ''; // When $result = StringEncoder::detectAndConvertToUtf8($input); // Then expect($result)->toBe(''); }); it('detects and passes through valid UTF-8 with a hint', function (): void { // Given $input = 'Héllo world 🌍'; // When $result = StringEncoder::detectAndConvertToUtf8($input, 'Windows-1252'); // Then expect($result)->toBe('Héllo world 🌍'); }); it('strips the UTF-8 BOM from auto-detected UTF-8 strings', function (): void { // Given $input = "\xEF\xBB\xBFBonjour"; // When $result = StringEncoder::detectAndConvertToUtf8($input); // Then expect($result)->toBe('Bonjour'); }); it('uses encoding hints to guide detection', function (): void { // Given $input = "5\x80"; // When $result = StringEncoder::detectAndConvertToUtf8($input, 'Windows-1252'); // Then expect($result)->toBe('5€'); }); it('produces valid UTF-8 output regardless of detection result', function (): void { // Given $input = "\xFE\x01\xFE\x01"; // When / Then try { $result = StringEncoder::detectAndConvertToUtf8($input); expect(mb_check_encoding($result, 'UTF-8'))->toBeTrue(); } catch (\RuntimeException $e) { expect($e->getMessage())->toContain('Unable to detect encoding'); } }); }); describe('arrayToUtf8()', function (): void { it('handles an empty array', function (): void { // Given $input = []; // When $result = StringEncoder::arrayToUtf8($input, 'UTF-8'); // Then expect($result)->toBe([]); }); it('converts an array of Windows-1252 strings with special characters', function (): void { // Given $input = ["5\x80", "B\x9Cuf"]; // When $result = StringEncoder::arrayToUtf8($input, 'Windows-1252'); // Then expect($result)->toBe(['5€', 'Bœuf']); }); }); describe('supportedEncodings()', function (): void { it('returns a non-empty list of strings', function (): void { // Given / When $result = StringEncoder::supportedEncodings(); // Then expect($result) ->toBeArray() ->not->toBeEmpty() ->each->toBeString(); }); it('includes all canonical encodings', function (): void { // Given $expected = ['UTF-8', 'ISO-8859-15', 'Windows-1252']; // When $result = StringEncoder::supportedEncodings(); // Then foreach ($expected as $encoding) { expect($result)->toContain($encoding); } }); it('includes common aliases', function (): void { // Given $aliases = ['latin1', 'latin-1', 'latin9', 'cp1252', 'utf8', 'utf-8']; // When $result = StringEncoder::supportedEncodings(); // Then foreach ($aliases as $alias) { expect($result)->toContain($alias); } }); it('contains no duplicates', function (): void { // Given $result = StringEncoder::supportedEncodings(); // When / Then expect($result)->toBe(array_values(array_unique($result))); }); }); describe('encoding resolution (via toUtf8)', function (): void { it('resolves case-insensitively', function (string $variant): void { // Given $input = 'hello'; // When $result = StringEncoder::toUtf8($input, $variant); // Then expect($result)->toBe('hello'); })->with(['utf-8', 'UTF-8', 'Utf-8', 'UTF8', 'utf8']); it('trims whitespace around encoding names', function (): void { // Given $input = 'hello'; // When $result = StringEncoder::toUtf8($input, ' utf-8 '); // Then expect($result)->toBe('hello'); }); }); describe('regressions', function (): void { it('correctly converts "Bœuf" from Windows-1252 (œ = 0x9C)', function (): void { // Given $input = "B\x9Cuf"; // When $result = StringEncoder::toUtf8($input, 'Windows-1252'); // Then expect($result)->toBe('Bœuf') ->and(mb_check_encoding($result, 'UTF-8'))->toBeTrue(); }); it('round-trips UTF-8 → identity without corruption', function (): void { // Given $input = 'Héllo 世界 🎵 Ÿ œ € ™'; // When $result = StringEncoder::toUtf8($input, 'UTF-8'); // Then expect($result)->toBe($input); }); });