diff --git a/composer.json b/composer.json index b6a0d16..d5411c7 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,8 @@ "test": "phpunit", "test-coverage": "phpunit --coverage-html coverage", "lint": "pint --test", - "fix": "pint" + "fix": "pint", + "docs": "php generate-api-docs.php > API.md" }, "config": { "optimize-autoloader": true, diff --git a/generate-api-docs.php b/generate-api-docs.php new file mode 100644 index 0000000..1aeeea5 --- /dev/null +++ b/generate-api-docs.php @@ -0,0 +1,418 @@ +getShortName(); + + // Skip enums in main TOC - they'll be in the Enums section + if ($reflection->isEnum()) { + continue; + } + + $toc[] = "* [`\\$className`](#$shortName)"; + + // Add properties to TOC + $properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC); + foreach ($properties as $property) { + $propName = $property->getName(); + $toc[] = " * [`$shortName::\$$propName`](#$shortName$propName)"; + } + + // Add methods to TOC + $methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC); + $publicMethods = array_filter($methods, fn ($method) => !$method->isConstructor() && !$method->isDestructor()); + foreach ($publicMethods as $method) { + $methodName = $method->getName(); + $paramCount = count($method->getParameters()); + $paramSuffix = $paramCount > 0 ? '(...)' : '()'; + $toc[] = " * [`$shortName::$methodName$paramSuffix`](#$shortName$methodName)"; + } +} + +// Add enum section if any +$hasEnums = false; +foreach ($classes as $className) { + $reflection = new \ReflectionClass($className); + if ($reflection->isEnum()) { + if (!$hasEnums) { + $toc[] = "* [Enums](#enums)"; + $hasEnums = true; + } + $shortName = $reflection->getShortName(); + $toc[] = " * [`\\$className`](#$shortName)"; + } +} + +$markdown .= implode("\n", $toc) . "\n\n"; + +foreach ($classes as $className) { + $reflection = new \ReflectionClass($className); + + // Skip enums in main section - they'll be handled in the Enums section + if ($reflection->isEnum()) { + continue; + } + + $markdown .= "### " . $reflection->getShortName() . "\n\n"; + + // Class description from docblock + $docComment = $reflection->getDocComment(); + if ($docComment) { + $lines = explode("\n", $docComment); + $description = ''; + foreach ($lines as $line) { + $line = trim($line); + if (str_starts_with($line, '/**') || str_starts_with($line, '*/') || str_starts_with($line, '*')) { + $line = ltrim($line, '/*'); + $line = trim($line); + if (!empty($line) && !str_starts_with($line, '@')) { + $description .= $line . " "; + } + } + } + if (!empty($description)) { + $markdown .= trim($description) . "\n\n"; + } + } + + // Namespace + $markdown .= "**Namespace:** `" . $reflection->getNamespaceName() . "`\n\n"; + + // Class type + if ($reflection->isEnum()) { + $markdown .= "**Type:** Enum\n\n"; + } elseif ($reflection->isFinal()) { + $markdown .= "**Type:** Final Class\n\n"; + } else { + $markdown .= "**Type:** Class\n\n"; + } + + // Public constants (for enums) + $constants = $reflection->getConstants(); + if (!empty($constants) && $reflection->isEnum()) { + $markdown .= "### Cases\n\n"; + foreach ($constants as $name => $value) { + $markdown .= "- `$name`\n"; + } + $markdown .= "\n"; + } + + // Public properties + $properties = $reflection->getProperties(\ReflectionProperty::IS_PUBLIC); + if (!empty($properties)) { + $markdown .= "#### Properties\n\n"; + foreach ($properties as $property) { + $name = $property->getName(); + $type = $property->getType() ? $property->getType()->__toString() : 'mixed'; + $shortName = $reflection->getShortName(); + + $markdown .= "#### `\$$name`\n\n"; + $markdown .= "```php\n"; + $markdown .= "$type \$$name\n"; + $markdown .= "```\n\n"; + + // Property description + $propDoc = $property->getDocComment(); + if ($propDoc) { + $lines = explode("\n", $propDoc); + $description = ''; + foreach ($lines as $line) { + $line = trim($line); + if (str_starts_with($line, '/**') || str_starts_with($line, '*/') || str_starts_with($line, '*')) { + $line = ltrim($line, '/*'); + $line = trim($line); + if (!empty($line) && !str_starts_with($line, '@')) { + $description .= $line . " "; + } + } + } + if (!empty($description)) { + $markdown .= trim($description) . "\n\n"; + } + } + + $markdown .= "--------------------\n\n"; + } + } + + // Public methods + $methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC); + $publicMethods = array_filter($methods, fn ($method) => !$method->isConstructor() && !$method->isDestructor()); + + if (!empty($publicMethods)) { + $markdown .= "#### Methods\n\n"; + + foreach ($publicMethods as $method) { + $name = $method->getName(); + $params = []; + $paramInfo = []; + + foreach ($method->getParameters() as $param) { + $paramType = $param->getType() ? $param->getType()->__toString() : 'mixed'; + $paramName = '$' . $param->getName(); + $paramStr = "$paramType $paramName"; + + if ($param->isDefaultValueAvailable()) { + $default = $param->getDefaultValue(); + if (is_null($default)) { + $paramStr .= ' = null'; + } elseif (is_bool($default)) { + $paramStr .= ' = ' . ($default ? 'true' : 'false'); + } elseif (is_string($default)) { + $paramStr .= ' = "' . $default . '"'; + } else { + $paramStr .= ' = ' . $default; + } + } + + $params[] = $paramStr; + $paramInfo[] = [ + 'name' => $param->getName(), + 'type' => $paramType, + 'optional' => $param->isDefaultValueAvailable() + ]; + } + + $returnType = $method->getReturnType() ? $method->getReturnType()->__toString() : 'mixed'; + + $paramSuffix = count($method->getParameters()) > 0 ? '(...)' : '()'; + $markdown .= "##### $name$paramSuffix\n\n"; + $markdown .= "```php\n"; + + // Format method signature with full namespace + $fullClassName = '\\' . $reflection->getName(); + $fullReturnType = $returnType; + if (str_contains($returnType, 'KDuma\\BinaryTools\\') && !str_starts_with($returnType, '\\')) { + $fullReturnType = '\\' . $returnType; + } + + if (count($method->getParameters()) > 0) { + // Multiline format for methods with parameters + $markdown .= "$fullClassName::$name(\n"; + $formattedParams = []; + foreach ($params as $param) { + // Add namespace prefix to types + $param = preg_replace('/\bKDuma\\\\BinaryTools\\\\([A-Za-z]+)/', '\\\\KDuma\\\\BinaryTools\\\\$1', $param); + $formattedParams[] = " $param"; + } + $markdown .= implode(",\n", $formattedParams) . "\n"; + $markdown .= "): $fullReturnType\n"; + } else { + // Single line for methods without parameters + $markdown .= "$fullClassName::$name(): $fullReturnType\n"; + } + + $markdown .= "```\n\n"; + + // Method description + $methodDoc = $method->getDocComment(); + $description = ''; + $docParams = []; + $returnDoc = ''; + + if ($methodDoc) { + $lines = explode("\n", $methodDoc); + + foreach ($lines as $line) { + $line = trim($line); + if (str_starts_with($line, '/**') || str_starts_with($line, '*/')) { + continue; + } + + $line = ltrim($line, '*'); + $line = trim($line); + + if (str_starts_with($line, '@param')) { + $parts = explode(' ', $line, 4); + if (count($parts) >= 3) { + $paramName = trim($parts[2], '$'); + $paramDesc = isset($parts[3]) ? $parts[3] : ''; + $docParams[$paramName] = $paramDesc; + } + } elseif (str_starts_with($line, '@return')) { + $returnDoc = str_replace('@return', '', $line); + $returnDoc = trim($returnDoc); + } elseif (str_starts_with($line, '@throws')) { + $description .= "\n\n**Throws:** " . str_replace('@throws', '', $line); + } elseif (!empty($line) && !str_starts_with($line, '@')) { + $description .= $line . " "; + } + } + } + + if (!empty($description)) { + $markdown .= trim($description) . "\n\n"; + } + + // Parameter table + if (!empty($paramInfo)) { + $markdown .= "| Param | Type | Description |\n"; + $markdown .= "| ----- | ---- | ----------- |\n"; + + foreach ($paramInfo as $info) { + $paramName = "**`{$info['name']}`**"; + + // Format type with namespace prefix and optional indicator + $paramType = $info['type']; + if (str_contains($paramType, 'KDuma\\BinaryTools\\') && !str_starts_with($paramType, '\\')) { + $paramType = '\\' . $paramType; + } + + // Escape pipe characters for markdown table + $escapedParamType = str_replace('|', '\\|', $paramType); + $typeColumn = "$escapedParamType"; + + if ($info['optional']) { + $typeColumn .= ' (optional)'; + } + + // Get description from docblock + $description = isset($docParams[$info['name']]) ? $docParams[$info['name']] : ''; + + $markdown .= "| $paramName | $typeColumn | $description |\n"; + } + $markdown .= "\n"; + } + + // Return type + $escapedReturnType = str_replace('|', '\\|', $fullReturnType); + if (!empty($returnDoc)) { + $markdown .= "**Returns:** $escapedReturnType - $returnDoc\n\n"; + } else { + $markdown .= "**Returns:** $escapedReturnType\n\n"; + } + + $markdown .= "--------------------\n\n"; + } + } + + $markdown .= "---\n\n"; +} + +// Add Enums section +$enumClasses = array_filter($classes, function ($className) { + $reflection = new \ReflectionClass($className); + return $reflection->isEnum(); +}); + +if (!empty($enumClasses)) { + $markdown .= "### Enums\n\n"; + + foreach ($enumClasses as $enumClass) { + $reflection = new \ReflectionClass($enumClass); + $shortName = $reflection->getShortName(); + + $markdown .= "#### $shortName\n\n"; + + // Get enum description from docblock + $docComment = $reflection->getDocComment(); + if ($docComment) { + $lines = explode("\n", $docComment); + $description = ''; + foreach ($lines as $line) { + $line = trim($line); + if (str_starts_with($line, '/**') || str_starts_with($line, '*/') || str_starts_with($line, '*')) { + $line = ltrim($line, '/*'); + $line = trim($line); + if (!empty($line) && !str_starts_with($line, '@')) { + $description .= $line . " "; + } + } + } + if (!empty($description)) { + $markdown .= trim($description) . "\n\n"; + } + } + + // Namespace + $markdown .= "**Namespace:** `\\{$reflection->getName()}`\n\n"; + + // Enum cases table + $constants = $reflection->getConstants(); + if (!empty($constants)) { + $markdown .= "| Members | Value | Description |\n"; + $markdown .= "| ------- | ----- | ----------- |\n"; + + foreach ($constants as $name => $value) { + $memberName = "**`$name`**"; + + // For pure enums (not backed), just show the case name + // For backed enums, we'd need to use the enum's backing value + $caseValue = "'$name'"; + + // Try to get more specific value info if it's a backed enum + try { + if (method_exists($reflection, 'getBackingType')) { + $backingType = $reflection->getBackingType(); + if ($backingType) { + // This is a backed enum, but we can't easily get the backing value + // without instantiating the enum case + $caseValue = "'$name'"; + } + } + } catch (\Exception $e) { + // Fallback to case name + } + + // Try to get description from case docblock by reading the source file + $description = ''; + try { + $filename = $reflection->getFileName(); + if ($filename) { + $source = file_get_contents($filename); + $lines = explode("\n", $source); + + // Look for the case definition and preceding docblock + for ($i = 0; $i < count($lines); $i++) { + if (preg_match('/case\s+' . preg_quote($name) . '\s*;/', $lines[$i])) { + // Found the case, look back for docblock + for ($j = $i - 1; $j >= 0; $j--) { + $line = trim($lines[$j]); + if (str_starts_with($line, '/**')) { + // Found docblock start, extract description + $docLine = str_replace('/**', '', $line); + $docLine = str_replace('*/', '', $docLine); + $description = trim($docLine); + break; + } + if (!str_starts_with($line, '*') && !empty($line)) { + // Found non-docblock content, stop looking + break; + } + } + break; + } + } + } + } catch (\Exception $e) { + // Fallback to empty description + } + + $markdown .= "| $memberName | $caseValue | $description |\n"; + } + $markdown .= "\n"; + } + + $markdown .= "--------------------\n\n"; + } +} + +echo $markdown; diff --git a/src/BinaryReader.php b/src/BinaryReader.php index 241eb33..4b2b316 100644 --- a/src/BinaryReader.php +++ b/src/BinaryReader.php @@ -2,6 +2,7 @@ namespace KDuma\BinaryTools; +use Deprecated; use RuntimeException; final class BinaryReader @@ -22,7 +23,9 @@ final class BinaryReader } set { if ($value < 0 || $value > $this->length) { - throw new RuntimeException('Invalid seek position: ' . $value); + throw new RuntimeException( + sprintf('Invalid seek position: %d (valid range: 0-%d)', $value, $this->length) + ); } $this->position = $value; @@ -47,12 +50,22 @@ final class BinaryReader } } + /** + * Creates a new reader positioned at the start of the supplied buffer. + * + * @param BinaryString $data Buffer to read from. + */ public function __construct(BinaryString $data) { $this->_data = $data->toString(); $this->length = $data->size(); } + /** + * Reads the next byte from the stream. + * + * @throws RuntimeException When no more data is available. + */ public function readByte(): int { if ($this->position >= $this->length) { @@ -62,6 +75,12 @@ public function readByte(): int return ord($this->_data[$this->position++]); } + /** + * Reads exactly $count bytes from the current position. + * + * @param int $count Number of bytes to read. + * @throws RuntimeException When fewer than $count bytes remain. + */ public function readBytes(int $count): BinaryString { if ($this->position + $count > $this->length) { @@ -74,28 +93,107 @@ public function readBytes(int $count): BinaryString return BinaryString::fromString($result); } - public function readBytesWithLength(bool $use16BitLength = false): BinaryString - { - if ($use16BitLength) { - $length = $this->readUint16BE(); - } else { - $length = $this->readByte(); + /** + * Reads variable-length data using exactly one of the supplied strategies (length, terminator, optional terminator, or padding). + * + * @param IntType|null $length Integer type that stores the byte length when using length mode. + * @param Terminator|BinaryString|null $terminator Required terminator sequence when using terminator mode. + * @param Terminator|BinaryString|null $optional_terminator Terminator sequence that may be absent (fully consumes buffer when missing). + * @param Terminator|BinaryString|null $padding Single-byte padding value used for fixed-width fields. + * @param int|null $padding_size Total field width in bytes when padding is enabled. + * @throws \InvalidArgumentException When mutually exclusive modes are combined or configuration is invalid. + * @throws RuntimeException When the data violates the expectations of the chosen mode. + */ + public function readBytesWith( + ?IntType $length = null, + Terminator|BinaryString|null $terminator = null, + Terminator|BinaryString|null $optional_terminator = null, + Terminator|BinaryString|null $padding = null, + ?int $padding_size = null, + ): BinaryString { + if ($padding === null && $padding_size !== null) { + $padding = Terminator::NUL; } - try { - return $this->readBytes($length); - } catch (\RuntimeException $exception) { - $this->position -= ($use16BitLength ? 2 : 1); - throw $exception; + $modes = array_filter([ + 'length' => $length, + 'terminator' => $terminator, + 'optional_terminator' => $optional_terminator, + 'padding' => $padding, + ], static fn ($value) => $value !== null); + + if (count($modes) !== 1) { + throw new \InvalidArgumentException('Exactly one of length, terminator, optional_terminator, or padding must be provided'); + } + + $selectedMode = array_key_first($modes); + + if ($selectedMode === 'length') { + return $this->_readWithLength($length); + } + + if ($selectedMode === 'padding') { + return $this->_readWithPadding($padding, $padding_size); } + + return $this->_readWithTerminator($modes[$selectedMode], $selectedMode === 'optional_terminator'); } - public function readUint16BE(): int + /** + * Reads an integer using the provided {@see IntType} definition. + * + * @param IntType $type Integer description covering width, signedness, and byte order. + * @throws RuntimeException When the type is unsupported or the value cannot be represented. + */ + public function readInt(IntType $type): int { - $bytes = $this->readBytes(2)->value; - return (ord($bytes[0]) << 8) | ord($bytes[1]); + if (!$type->isSupported()) { + // @codeCoverageIgnoreStart + throw new RuntimeException( + sprintf('Cannot read %d-byte integers on %d-byte platform', $type->bytes(), PHP_INT_SIZE) + ); + // @codeCoverageIgnoreEnd + } + + $bytesCount = $type->bytes(); + $bytes = $this->readBytes($bytesCount)->value; + + if ($type->isLittleEndian()) { + $bytes = strrev($bytes); + } + + $value = 0; + for ($i = 0; $i < $bytesCount; $i++) { + $value = ($value << 8) | ord($bytes[$i]); + } + + if ($type->isSigned()) { + $bits = $bytesCount * 8; + if ($bits <= PHP_INT_SIZE * 8) { + $signBit = 1 << ($bits - 1); + if (($value & $signBit) !== 0) { + $value -= 1 << $bits; + } + } + } + + // Validate unsigned integers that may have wrapped due to PHP integer limits + if (!$type->isSigned() && !$type->isValid($value)) { + throw new RuntimeException( + sprintf('Value exceeds maximum for %s on this platform', $type->name) + ); + } + + return $value; } + + /** + * Reads a fixed-length UTF-8 string. + * + * @param int $length Number of bytes to consume. + * @throws RuntimeException When insufficient data remains or decoding fails. + */ public function readString(int $length): BinaryString { $bytes = $this->readBytes($length); @@ -109,18 +207,41 @@ public function readString(int $length): BinaryString return $bytes; } - public function readStringWithLength(bool $use16BitLength = false): BinaryString - { - $string = $this->readBytesWithLength($use16BitLength); + /** + * Reads a UTF-8 string using one of the variable-length strategies (length, terminator, optional terminator, or padding). + * + * @param IntType|null $length Integer type specifying the length field when using length mode. + * @param Terminator|BinaryString|null $terminator Required terminator. + * @param Terminator|BinaryString|null $optional_terminator Optional terminator. + * @param Terminator|BinaryString|null $padding Single-byte padding value for fixed-width fields. + * @param int|null $padding_size Total field width when padding is enabled. + * @throws \InvalidArgumentException When configuration is invalid. + * @throws RuntimeException When decoding fails or the data violates mode rules. + */ + public function readStringWith( + ?IntType $length = null, + Terminator|BinaryString|null $terminator = null, + Terminator|BinaryString|null $optional_terminator = null, + Terminator|BinaryString|null $padding = null, + ?int $padding_size = null, + ): BinaryString { + $startPosition = $this->position; + $string = $this->readBytesWith($length, $terminator, $optional_terminator, $padding, $padding_size); if (!mb_check_encoding($string->value, 'UTF-8')) { - $this->position -= ($use16BitLength ? 2 : 1) + $string->size(); + $this->position = $startPosition; throw new RuntimeException('Invalid UTF-8 string'); } return $string; } + /** + * Returns the next byte without advancing the read pointer. + * + * @return int Unsigned byte value. + * @throws RuntimeException When no more data remains. + */ public function peekByte(): int { if ($this->position >= $this->length) { @@ -130,6 +251,12 @@ public function peekByte(): int return ord($this->_data[$this->position]); } + /** + * Returns the next $count bytes without advancing the read pointer. + * + * @param int $count Number of bytes to inspect. + * @throws RuntimeException When fewer than $count bytes remain. + */ public function peekBytes(int $count): BinaryString { if ($this->position + $count > $this->length) { @@ -139,6 +266,12 @@ public function peekBytes(int $count): BinaryString return BinaryString::fromString(substr($this->_data, $this->position, $count)); } + /** + * Advances the read pointer by $count bytes. + * + * @param int $count Number of bytes to skip. + * @throws RuntimeException When insufficient data remains. + */ public function skip(int $count): void { if ($this->position + $count > $this->length) { @@ -148,8 +281,132 @@ public function skip(int $count): void $this->position += $count; } + /** + * Moves the read pointer to an absolute offset inside the buffer. + * + * @param int $position Zero-based offset to seek to. + * @throws RuntimeException When the target lies outside the buffer. + */ public function seek(int $position): void { $this->position = $position; } + + // Deprecated methods + + #[Deprecated('Use readInt(IntType::UINT16) instead')] + /** + * @deprecated Use {@see readInt()} with {@see IntType::UINT16} instead. + */ + public function readUint16BE(): int + { + return $this->readInt(IntType::UINT16); + } + + #[Deprecated('Use readBytesWith(length: IntType::UINT8) or readBytesWith(length: IntType::UINT16) instead')] + /** + * @deprecated Use {@see readBytesWith()} with an explicit length type instead. + * + * @param bool $use16BitLength When true, reads a 16-bit length; otherwise an 8-bit length. + */ + public function readBytesWithLength(bool $use16BitLength = false): BinaryString + { + return $this->readBytesWith($use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null, null, null); + } + + #[Deprecated('Use readStringWith(length: IntType::UINT8) or readStringWith(length: IntType::UINT16) instead')] + /** + * @deprecated Use {@see readStringWith()} with an explicit length type instead. + * + * @param bool $use16BitLength When true, reads a 16-bit length; otherwise an 8-bit length. + */ + public function readStringWithLength(bool $use16BitLength = false): BinaryString + { + return $this->readStringWith($use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null, null, null); + } + + // Private methods + + private function _readWithLength(IntType $length): BinaryString + { + $dataLength = $this->readInt($length); + + if ($dataLength < 0) { + throw new RuntimeException(sprintf('Negative length %d is invalid for %s', $dataLength, $length->name)); + } + + try { + return $this->readBytes($dataLength); + } catch (\RuntimeException $exception) { + $this->position -= $length->bytes(); + throw $exception; + } + } + + private function _readWithTerminator(Terminator|BinaryString $terminator, bool $terminatorIsOptional): BinaryString + { + $terminatorBytes = $terminator instanceof Terminator ? $terminator->toBytes() : $terminator; + $terminatorSize = $terminatorBytes->size(); + + if ($terminatorSize === 0) { + throw new \InvalidArgumentException('Terminator cannot be empty'); + } + + $remainingData = substr($this->_data, $this->position); + $terminatorPosition = strpos($remainingData, $terminatorBytes->value); + + if ($terminatorPosition === false) { + if ($terminatorIsOptional) { + $this->position = $this->length; + return BinaryString::fromString($remainingData); + } + + throw new RuntimeException('Terminator not found before end of data'); + } + + $result = substr($remainingData, 0, $terminatorPosition); + $this->position += $terminatorPosition + $terminatorSize; + + return BinaryString::fromString($result); + } + + private function _readWithPadding(BinaryString|Terminator $padding, ?int $paddingSize): BinaryString + { + if ($paddingSize === null) { + throw new \InvalidArgumentException('Padding size must be provided when padding is used'); + } + + $padding = $padding instanceof Terminator ? $padding->toBytes() : $padding; + + if ($paddingSize < 0) { + throw new RuntimeException('Padding size cannot be negative'); + } + + if ($padding->size() !== 1) { + throw new \InvalidArgumentException('Padding must be exactly one byte'); + } + + if ($paddingSize === 0) { + return BinaryString::fromString(''); + } + + $raw = $this->readBytes($paddingSize); + $dataString = $raw->value; + $padByte = $padding->value; + $padPosition = strpos($dataString, $padByte); + + if ($padPosition === false) { + return $raw; + } + + for ($i = $padPosition; $i < $paddingSize; $i++) { + if ($dataString[$i] !== $padByte) { + throw new RuntimeException('Invalid padding sequence encountered'); + } + } + + $data = substr($dataString, 0, $padPosition); + + return BinaryString::fromString($data); + } } diff --git a/src/BinaryString.php b/src/BinaryString.php index da928aa..ec872ee 100644 --- a/src/BinaryString.php +++ b/src/BinaryString.php @@ -8,16 +8,25 @@ protected function __construct(public string $value) { } + /** + * Returns the raw binary value as a PHP string. + */ public function toString(): string { return $this->value; } + /** + * Serialises the binary value into an ASCII hexadecimal string. + */ public function toHex(): string { return bin2hex($this->value); } + /** + * Serialises the binary value using Base64 encoding. + */ public function toBase64(): string { return base64_encode($this->value); @@ -26,48 +35,83 @@ public function toBase64(): string /** * Returns a Base32-encoded string representation of the binary value. * - * @param string $alphabet The alphabet to use for Base32 encoding. - * @return string The Base32-encoded string. + * @param string $alphabet Alphabet to use when encoding. */ public function toBase32(string $alphabet = Base32::DEFAULT_ALPHABET): string { return Base32::toBase32($this->value, $alphabet); } + /** + * Returns the number of bytes contained in the value. + */ public function size(): int { return strlen($this->value); } + /** + * Creates a BinaryString from an existing PHP string without validation. + * + * @param string $value Raw binary data. + */ public static function fromString(string $value): static { return new static($value); } + /** + * Creates a BinaryString from a hexadecimal dump. + * + * @param string $hex Hexadecimal representation of the data. + */ public static function fromHex(string $hex): static { return new static(hex2bin($hex)); } + /** + * Creates a BinaryString from a Base64-encoded payload. + * + * @param string $base64 Base64 representation of the data. + */ public static function fromBase64(string $base64): static { return new static(base64_decode($base64, true)); } /** - * Decodes a Base32-encoded string to a BinaryString instance. + * Decodes a Base32-encoded string to a BinaryString instance using the specified alphabet. * - * @param string $base32 The Base32-encoded string to decode. - * @param string $alphabet The alphabet used for Base32 encoding. Defaults to Base32::DEFAULT_ALPHABET. - * @return static A new BinaryString instance containing the decoded binary data. + * @param string $base32 Base32 payload to decode. + * @param string $alphabet Alphabet that was used during encoding. */ public static function fromBase32(string $base32, string $alphabet = Base32::DEFAULT_ALPHABET): static { return new static(Base32::fromBase32($base32, $alphabet)); } + /** + * Performs a timing-safe comparison with another BinaryString. + * + * @param BinaryString $other Value to compare against. + */ public function equals(BinaryString $other): bool { return hash_equals($this->value, $other->value); } + + /** + * Determines whether the provided binary fragment appears in the value. + * + * @param BinaryString $needle Fragment to look for. + */ + public function contains(BinaryString $needle): bool + { + if ($needle->size() === 0) { + return true; + } + + return str_contains($this->value, $needle->value); + } } diff --git a/src/BinaryWriter.php b/src/BinaryWriter.php index 0b41a53..f41738c 100644 --- a/src/BinaryWriter.php +++ b/src/BinaryWriter.php @@ -2,25 +2,42 @@ namespace KDuma\BinaryTools; +use Deprecated; + final class BinaryWriter { private string $buffer = ''; + /** + * Returns the buffered bytes as a BinaryString without resetting the writer. + */ public function getBuffer(): BinaryString { return BinaryString::fromString($this->buffer); } + /** + * Returns the number of bytes written so far. + */ public function getLength(): int { return strlen($this->buffer); } + /** + * Clears the buffer so subsequent writes start from an empty state. + */ public function reset(): void { $this->buffer = ''; } + /** + * Appends a single byte value (0-255) to the buffer. + * + * @param int $byte Byte value to write. + * @throws \InvalidArgumentException When the value is outside the valid byte range. + */ public function writeByte(int $byte): self { if ($byte < 0 || $byte > 255) { @@ -31,6 +48,11 @@ public function writeByte(int $byte): self return $this; } + /** + * Appends raw bytes to the buffer. + * + * @param BinaryString $bytes Data to append. + */ public function writeBytes(BinaryString $bytes): self { $this->buffer .= $bytes->value; @@ -38,37 +60,113 @@ public function writeBytes(BinaryString $bytes): self return $this; } - public function writeBytesWithLength(BinaryString $bytes, bool $use16BitLength = false): self - { - $length = $bytes->size(); - if ($use16BitLength) { - if ($length > 65535) { - throw new \InvalidArgumentException('String too long for 16-bit length field'); - } - $this->writeUint16BE($length); - } else { - if ($length > 255) { - throw new \InvalidArgumentException('String too long for 8-bit length field'); - } - $this->writeByte($length); + /** + * Writes variable-length data using one of the available strategies: typed length, terminator or fixed padding. + * + * @note When {@code optional_terminator} is supplied it currently behaves the same as {@code terminator} but emits a notice. + * + * @param BinaryString $bytes Data to write. + * @param IntType|null $length Integer type describing the length field when using length mode. + * @param Terminator|BinaryString|null $terminator Mandatory terminator sequence. + * @param Terminator|BinaryString|null $optional_terminator Optional terminator sequence (currently emits a notice and behaves like $terminator). + * @param Terminator|BinaryString|null $padding Single-byte padding value for fixed-width fields. + * @param int|null $padding_size Total field width when padding is enabled. + * @throws \InvalidArgumentException When configuration is invalid or the data violates the chosen mode. + */ + public function writeBytesWith( + BinaryString $bytes, + ?IntType $length = null, + Terminator|BinaryString|null $terminator = null, + Terminator|BinaryString|null $optional_terminator = null, + Terminator|BinaryString|null $padding = null, + ?int $padding_size = null, + ): self { + if ($padding === null && $padding_size !== null) { + $padding = Terminator::NUL; } - $this->writeBytes($bytes); + $modes = array_filter([ + 'length' => $length, + 'terminator' => $terminator, + 'optional_terminator' => $optional_terminator, + 'padding' => $padding, + ], static fn ($value) => $value !== null); - return $this; + if (count($modes) !== 1) { + throw new \InvalidArgumentException('Exactly one of length, terminator, optional_terminator, or padding must be provided'); + } + + $modeKey = array_key_first($modes); + + if ($modeKey === 'length') { + return $this->_writeWithLength($bytes, $length); + } + + if ($modeKey === 'padding') { + return $this->_writeWithPadding($bytes, $padding, $padding_size); + } + + if ($modeKey === 'optional_terminator') { + trigger_error( + 'UNSTABLE API: optional_terminator has no effect when writing IN THIS VERSION; data will always be terminated IN THIS VERSION; it will probably change in future', + E_USER_NOTICE + ); + } + + return $this->_writeWithTerminator($bytes, $modes[$modeKey]); } - public function writeUint16BE(int $value): self + /** + * Serialises an integer according to the provided {@see IntType} definition. + * + * @param IntType $type Integer description covering width, signedness, and byte order. + * @param int $value Value to serialise. + * @throws \RuntimeException When the type is unsupported on this platform. + * @throws \InvalidArgumentException When the value lies outside the type's range. + */ + public function writeInt(IntType $type, int $value): self { - if ($value < 0 || $value > 65535) { - throw new \InvalidArgumentException('Uint16 value must be between 0 and 65535'); + if (!$type->isSupported()) { + // @codeCoverageIgnoreStart + throw new \RuntimeException( + sprintf('Cannot write %d-byte integers on %d-byte platform', $type->bytes(), PHP_INT_SIZE) + ); + // @codeCoverageIgnoreEnd + } + + $bytesCount = $type->bytes(); + + if (!$type->isValid($value)) { + throw new \InvalidArgumentException( + sprintf('Value %d is out of range for %s', $value, $type->name) + ); + } + + // Handle negative values for signed types + if ($type->isSigned() && $value < 0) { + $value = (1 << ($bytesCount * 8)) + $value; } - $this->buffer .= chr(($value >> 8) & 0xFF); - $this->buffer .= chr($value & 0xFF); + $bytes = ''; + for ($i = $bytesCount - 1; $i >= 0; $i--) { + $bytes .= chr(($value >> ($i * 8)) & 0xFF); + } + + if ($type->isLittleEndian()) { + $bytes = strrev($bytes); + } + + $this->buffer .= $bytes; return $this; } + + /** + * Writes a UTF-8 validated string without terminator or padding. + * + * @param BinaryString $string UTF-8 string data to emit. + * @throws \InvalidArgumentException When the data is not valid UTF-8. + */ public function writeString(BinaryString $string): self { if (!mb_check_encoding($string->value, 'UTF-8')) { @@ -80,13 +178,136 @@ public function writeString(BinaryString $string): self return $this; } - public function writeStringWithLength(BinaryString $string, bool $use16BitLength = false): self - { + /** + * Writes a UTF-8 string using one of the variable-length strategies. + * + * @param BinaryString $string UTF-8 string data to emit. + * @param IntType|null $length Integer type describing the length field when using length mode. + * @param Terminator|BinaryString|null $terminator Mandatory terminator sequence. + * @param Terminator|BinaryString|null $optional_terminator Optional terminator sequence (currently emits a notice and behaves like $terminator). + * @param Terminator|BinaryString|null $padding Single-byte padding value for fixed-width fields. + * @param int|null $padding_size Total field width when padding is enabled. + * @throws \InvalidArgumentException When configuration is invalid or the string is not UTF-8. + */ + public function writeStringWith( + BinaryString $string, + ?IntType $length = null, + Terminator|BinaryString|null $terminator = null, + Terminator|BinaryString|null $optional_terminator = null, + Terminator|BinaryString|null $padding = null, + ?int $padding_size = null, + ): self { if (!mb_check_encoding($string->value, 'UTF-8')) { throw new \InvalidArgumentException('String must be valid UTF-8'); } - $this->writeBytesWithLength($string, $use16BitLength); + return $this->writeBytesWith($string, $length, $terminator, $optional_terminator, $padding, $padding_size); + } + + // Deprecated methods + + #[Deprecated('Use writeInt(IntType::UINT16, $value) instead')] + /** + * @deprecated Use {@see writeInt()} with {@see IntType::UINT16} instead. + * + * @param int $value Unsigned 16-bit value. + */ + public function writeUint16BE(int $value): self + { + return $this->writeInt(IntType::UINT16, $value); + } + + #[Deprecated('Use writeBytesWith($bytes, length: IntType::UINT8) or writeBytesWith($bytes, length: IntType::UINT16) instead')] + /** + * @deprecated Use {@see writeBytesWith()} with an explicit length type instead. + * + * @param BinaryString $bytes Payload to write. + * @param bool $use16BitLength When true, emits a 16-bit length; otherwise an 8-bit length. + */ + public function writeBytesWithLength(BinaryString $bytes, bool $use16BitLength = false): self + { + return $this->writeBytesWith($bytes, $use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null, null, null); + } + + #[Deprecated('Use writeStringWith($string, length: IntType::UINT8) or writeStringWith($string, length: IntType::UINT16) instead')] + /** + * @deprecated Use {@see writeStringWith()} with an explicit length type instead. + * + * @param BinaryString $string UTF-8 string to write. + * @param bool $use16BitLength When true, emits a 16-bit length; otherwise an 8-bit length. + */ + public function writeStringWithLength(BinaryString $string, bool $use16BitLength = false): self + { + return $this->writeStringWith($string, $use16BitLength ? IntType::UINT16 : IntType::UINT8, null, null, null, null); + } + + // Private methods + + private function _writeWithLength(BinaryString $data, IntType $length): self + { + $dataLength = $data->size(); + $maxLength = $length->maxValue(); + + if ($dataLength > $maxLength) { + throw new \InvalidArgumentException("Data too long for {$length->name} length field (max: {$maxLength})"); + } + + $this->writeInt($length, $dataLength); + $this->writeBytes($data); + + return $this; + } + + private function _writeWithTerminator(BinaryString $data, Terminator|BinaryString $terminator): self + { + $terminatorBytes = $terminator instanceof Terminator ? $terminator->toBytes() : $terminator; + + if ($terminatorBytes->size() === 0) { + throw new \InvalidArgumentException('Terminator cannot be empty'); + } + + if ($data->contains($terminatorBytes)) { + throw new \InvalidArgumentException('Data contains terminator sequence'); + } + + $this->writeBytes($data); + $this->writeBytes($terminatorBytes); + + return $this; + } + + private function _writeWithPadding(BinaryString $data, BinaryString|Terminator $padding, ?int $paddingSize): self + { + $padding = $padding instanceof Terminator ? $padding->toBytes() : $padding; + + if ($paddingSize === null) { + throw new \InvalidArgumentException('Padding size must be provided when padding is used'); + } + + if ($paddingSize < 0) { + throw new \InvalidArgumentException('Padding size cannot be negative'); + } + + if ($padding->size() !== 1) { + throw new \InvalidArgumentException('Padding must be exactly one byte'); + } + + if ($data->contains($padding)) { + throw new \InvalidArgumentException('Data contains padding byte'); + } + + $dataLength = $data->size(); + + if ($dataLength > $paddingSize) { + throw new \InvalidArgumentException('Data too long for padding size'); + } + + $this->writeBytes($data); + + if ($dataLength < $paddingSize) { + $padByte = $padding->value; + $this->buffer .= str_repeat($padByte, $paddingSize - $dataLength); + } return $this; } diff --git a/src/IntType.php b/src/IntType.php new file mode 100644 index 0000000..4746dd8 --- /dev/null +++ b/src/IntType.php @@ -0,0 +1,135 @@ + 1, + self::UINT16, + self::INT16, + self::UINT16_LE, + self::INT16_LE => 2, + self::UINT32, + self::INT32, + self::UINT32_LE, + self::INT32_LE => 4, + self::UINT64, + self::INT64, + self::UINT64_LE, + self::INT64_LE => 8, + }; + } + + public function isSigned(): bool + { + return match ($this) { + self::INT8, + self::INT16, + self::INT32, + self::INT16_LE, + self::INT32_LE, + self::INT64, + self::INT64_LE => true, + default => false, + }; + } + + public function isLittleEndian(): bool + { + return match ($this) { + self::UINT16_LE, + self::INT16_LE, + self::UINT32_LE, + self::INT32_LE, + self::UINT64_LE, + self::INT64_LE => true, + default => false, + }; + } + + public function isSupported(): bool + { + return $this->bytes() <= PHP_INT_SIZE; + } + + public function minValue(): int + { + if ($this->isSigned()) { + $bits = $this->bytes() * 8; + if ($bits >= PHP_INT_SIZE * 8) { + return PHP_INT_MIN; + } + return -(1 << ($bits - 1)); + } + + return 0; + } + + public function maxValue(): int + { + $bits = $this->bytes() * 8; + + if ($this->isSigned()) { + if ($bits >= PHP_INT_SIZE * 8) { + return PHP_INT_MAX; + } + return (1 << ($bits - 1)) - 1; + } else { + if ($bits >= PHP_INT_SIZE * 8) { + return PHP_INT_MAX; + } + return (1 << $bits) - 1; + } + } + + public function isValid(int $value): bool + { + return $value >= $this->minValue() && $value <= $this->maxValue(); + } +} diff --git a/src/Terminator.php b/src/Terminator.php new file mode 100644 index 0000000..bfb7965 --- /dev/null +++ b/src/Terminator.php @@ -0,0 +1,152 @@ + BinaryString::fromString("\x00"), + self::SOH => BinaryString::fromString("\x01"), + self::STX => BinaryString::fromString("\x02"), + self::ETX => BinaryString::fromString("\x03"), + self::EOT => BinaryString::fromString("\x04"), + self::ENQ => BinaryString::fromString("\x05"), + self::ACK => BinaryString::fromString("\x06"), + self::BEL => BinaryString::fromString("\x07"), + self::BS => BinaryString::fromString("\x08"), + self::HT => BinaryString::fromString("\x09"), + self::LF => BinaryString::fromString("\x0A"), + self::VT => BinaryString::fromString("\x0B"), + self::FF => BinaryString::fromString("\x0C"), + self::CR => BinaryString::fromString("\x0D"), + self::SO => BinaryString::fromString("\x0E"), + self::SI => BinaryString::fromString("\x0F"), + self::DLE => BinaryString::fromString("\x10"), + self::DC1 => BinaryString::fromString("\x11"), + self::DC2 => BinaryString::fromString("\x12"), + self::DC3 => BinaryString::fromString("\x13"), + self::DC4 => BinaryString::fromString("\x14"), + self::NAK => BinaryString::fromString("\x15"), + self::SYN => BinaryString::fromString("\x16"), + self::ETB => BinaryString::fromString("\x17"), + self::CAN => BinaryString::fromString("\x18"), + self::EM => BinaryString::fromString("\x19"), + self::SUB => BinaryString::fromString("\x1A"), + self::ESC => BinaryString::fromString("\x1B"), + self::FS => BinaryString::fromString("\x1C"), + self::GS => BinaryString::fromString("\x1D"), + self::RS => BinaryString::fromString("\x1E"), + self::US => BinaryString::fromString("\x1F"), + self::SP => BinaryString::fromString("\x20"), + self::CRLF => BinaryString::fromString("\x0D\x0A"), + }; + } +} diff --git a/tests/BinaryReaderTest.php b/tests/BinaryReaderTest.php index 5422e35..3d66615 100644 --- a/tests/BinaryReaderTest.php +++ b/tests/BinaryReaderTest.php @@ -4,7 +4,10 @@ use KDuma\BinaryTools\BinaryReader; use KDuma\BinaryTools\BinaryString; +use KDuma\BinaryTools\IntType; +use KDuma\BinaryTools\Terminator; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversClass(BinaryReader::class)] @@ -94,11 +97,63 @@ public function testReadBytesWithLength() } } - public function testReadUint16BE() + public static function readIntProvider(): iterable { - $this->reader = new BinaryReader(BinaryString::fromString("\x04\xd2")); - $this->assertEquals(1234, $this->reader->readUint16BE()); - $this->assertEquals(2, $this->reader->position); + yield 'uint8 max' => [255, "\xFF", IntType::UINT8]; + yield 'int8 positive' => [127, "\x7F", IntType::INT8]; + yield 'int8 negative' => [-1, "\xFF", IntType::INT8]; + yield 'uint16 positive' => [1234, "\x04\xD2", IntType::UINT16]; + yield 'uint16 little endian positive' => [1234, "\xD2\x04", IntType::UINT16_LE]; + yield 'int16 positive' => [1234, "\x04\xD2", IntType::INT16]; + yield 'int16 little endian positive' => [1234, "\xD2\x04", IntType::INT16_LE]; + yield 'int16 negative' => [-1234, "\xFB\x2E", IntType::INT16]; + yield 'int16 little endian negative' => [-1234, "\x2E\xFB", IntType::INT16_LE]; + yield 'uint32 positive' => [0xDEADBEEF, "\xDE\xAD\xBE\xEF", IntType::UINT32]; + yield 'uint32 little endian positive' => [0xDEADBEEF, "\xEF\xBE\xAD\xDE", IntType::UINT32_LE]; + yield 'int32 positive' => [1234, "\x00\x00\x04\xD2", IntType::INT32]; + yield 'int32 little endian positive' => [1234, "\xD2\x04\x00\x00", IntType::INT32_LE]; + yield 'int32 negative' => [-1234, "\xFF\xFF\xFB\x2E", IntType::INT32]; + yield 'int32 little endian negative' => [-1234, "\x2E\xFB\xFF\xFF", IntType::INT32_LE]; + yield 'uint64 positive' => [0x0123456789ABCDEF, "\x01\x23\x45\x67\x89\xAB\xCD\xEF", IntType::UINT64]; + yield 'uint64 little endian positive' => [0x0123456789ABCDEF, "\xEF\xCD\xAB\x89\x67\x45\x23\x01", IntType::UINT64_LE]; + yield 'int64 positive' => [1234, "\x00\x00\x00\x00\x00\x00\x04\xD2", IntType::INT64]; + yield 'int64 little endian positive' => [1234, "\xD2\x04\x00\x00\x00\x00\x00\x00", IntType::INT64_LE]; + yield 'int64 negative' => [-1234, "\xFF\xFF\xFF\xFF\xFF\xFF\xFB\x2E", IntType::INT64]; + yield 'int64 little endian negative' => [-1234, "\x2E\xFB\xFF\xFF\xFF\xFF\xFF\xFF", IntType::INT64_LE]; + } + + /** + * @dataProvider readIntProvider + */ + public function testReadInt(int $expected, string $payload, IntType $type): void + { + $this->reader = new BinaryReader(BinaryString::fromString($payload)); + + $this->assertSame($expected, $this->reader->readInt($type)); + $this->assertSame(strlen($payload), $this->reader->position); + } + + public function testReadIntUnsupportedSize(): void + { + // This test only runs on 32-bit systems where 64-bit integers are not supported + if (PHP_INT_SIZE >= 8) { + $this->markTestSkipped('64-bit integers are supported on this platform'); + } + + $this->reader = new BinaryReader(BinaryString::fromString("\x01\x23\x45\x67\x89\xAB\xCD\xEF")); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot read 8-byte integers on 4-byte platform'); + + $this->reader->readInt(IntType::UINT64); + } + + public function testReadUint16BEDeprecated(): void + { + $this->reader = new BinaryReader(BinaryString::fromString("\x04\xD2")); + + $this->assertSame(1234, $this->reader->readUint16BE()); + $this->assertSame(2, $this->reader->position); } public function testPeekByte() @@ -287,7 +342,7 @@ public function testSeek() $this->reader->seek(5); $this->fail("Expected exception not thrown"); } catch (\RuntimeException $exception) { - $this->assertEquals('Invalid seek position: 5', $exception->getMessage()); + $this->assertEquals('Invalid seek position: 5 (valid range: 0-4)', $exception->getMessage()); $this->assertEquals(4, $this->reader->position); } @@ -295,7 +350,7 @@ public function testSeek() $this->reader->seek(-1); $this->fail("Expected exception not thrown"); } catch (\RuntimeException $exception) { - $this->assertEquals('Invalid seek position: -1', $exception->getMessage()); + $this->assertEquals('Invalid seek position: -1 (valid range: 0-4)', $exception->getMessage()); $this->assertEquals(4, $this->reader->position); } } @@ -307,6 +362,28 @@ public function testGetPosition() $this->assertEquals(2, $this->reader->position); } + public function testSetPosition() + { + // Test direct position property assignment + $this->reader->position = 2; + $this->assertEquals(2, $this->reader->position); + + // Test validation through direct assignment + try { + $this->reader->position = -1; + $this->fail("Expected exception not thrown"); + } catch (\RuntimeException $exception) { + $this->assertEquals('Invalid seek position: -1 (valid range: 0-4)', $exception->getMessage()); + } + + try { + $this->reader->position = 5; + $this->fail("Expected exception not thrown"); + } catch (\RuntimeException $exception) { + $this->assertEquals('Invalid seek position: 5 (valid range: 0-4)', $exception->getMessage()); + } + } + public function testGetRemainingBytes() { $this->reader->seek(0); @@ -318,4 +395,484 @@ public function testGetRemainingBytes() $this->reader->seek(4); $this->assertEquals(0, $this->reader->remaining_bytes); } + + /** + * @return array + */ + public static function readBytesWithProvider(): array + { + return [ + 'UINT8 short' => ['data' => "\x03abc", 'length' => IntType::UINT8, 'expected' => 'abc'], + 'UINT8 empty' => ['data' => "\x00", 'length' => IntType::UINT8, 'expected' => ''], + 'UINT16 short' => ['data' => "\x00\x03abc", 'length' => IntType::UINT16, 'expected' => 'abc'], + 'UINT16_LE short' => ['data' => "\x03\x00abc", 'length' => IntType::UINT16_LE, 'expected' => 'abc'], + 'UINT32 short' => ['data' => "\x00\x00\x00\x03abc", 'length' => IntType::UINT32, 'expected' => 'abc'], + ]; + } + + #[DataProvider('readBytesWithProvider')] + public function testReadBytesWith(string $data, IntType $length, string $expected): void + { + $reader = new BinaryReader(BinaryString::fromString($data)); + $result = $reader->readBytesWith($length); + $this->assertEquals($expected, $result->toString()); + $this->assertEquals(strlen($data), $reader->position); + } + + #[DataProvider('readBytesWithProvider')] + public function testReadStringWith(string $data, IntType $length, string $expected): void + { + $reader = new BinaryReader(BinaryString::fromString($data)); + $result = $reader->readStringWith($length); + $this->assertEquals($expected, $result->toString()); + $this->assertEquals(strlen($data), $reader->position); + } + + public function testReadBytesWithInsufficientData(): void + { + $reader = new BinaryReader(BinaryString::fromString("\x05abc")); // Claims 5 bytes but only has 3 + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Unexpected end of data while reading 5 bytes'); + + $reader->readBytesWith(IntType::UINT8); + } + + public function testReadStringWithInvalidUTF8(): void + { + $reader = new BinaryReader(BinaryString::fromString("\x02\xFF\xFE")); // 2 bytes of invalid UTF-8 + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid UTF-8 string'); + + $reader->readStringWith(IntType::UINT8); + } + + public function testReadBytesWithLengthDeprecated(): void + { + $reader = new BinaryReader(BinaryString::fromString("\x03abc")); + $result = $reader->readBytesWithLength(); + $this->assertEquals('abc', $result->toString()); + + $reader = new BinaryReader(BinaryString::fromString("\x00\x03abc")); + $result = $reader->readBytesWithLength(true); + $this->assertEquals('abc', $result->toString()); + } + + public function testReadStringWithLengthDeprecated(): void + { + $reader = new BinaryReader(BinaryString::fromString("\x03abc")); + $result = $reader->readStringWithLength(); + $this->assertEquals('abc', $result->toString()); + + $reader = new BinaryReader(BinaryString::fromString("\x00\x03abc")); + $result = $reader->readStringWithLength(true); + $this->assertEquals('abc', $result->toString()); + } + + public function testRoundTripCompatibility(): void + { + $writer = new \KDuma\BinaryTools\BinaryWriter(); + $testData = BinaryString::fromString("Hello, World!"); + + // Test UINT8 + $writer->writeBytesWith($testData, IntType::UINT8); + $reader = new BinaryReader($writer->getBuffer()); + $result = $reader->readBytesWith(IntType::UINT8); + $this->assertTrue($testData->equals($result)); + + // Test UINT16 + $writer->reset(); + $writer->writeBytesWith($testData, IntType::UINT16); + $reader = new BinaryReader($writer->getBuffer()); + $result = $reader->readBytesWith(IntType::UINT16); + $this->assertTrue($testData->equals($result)); + } + + public function testReadIntUnsignedOverflow(): void + { + // Test UINT64 overflow detection on 64-bit systems + if (PHP_INT_SIZE < 8) { + $this->markTestSkipped('64-bit integers are not supported on this platform'); + } + + $reader = new BinaryReader(BinaryString::fromHex("FFFFFFFFFFFFFFFF")); // Would wrap to -1 + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Value exceeds maximum for UINT64 on this platform'); + + $reader->readInt(IntType::UINT64); + } + + public function testReadBytesWithTerminatorNUL(): void + { + $reader = new BinaryReader(BinaryString::fromString("Hello\x00World")); + $result = $reader->readBytesWith(terminator: Terminator::NUL); + $this->assertEquals("Hello", $result->toString()); + $this->assertEquals(6, $reader->position); // 5 bytes + 1 terminator consumed + } + + public function testReadBytesWithTerminatorGS(): void + { + $reader = new BinaryReader(BinaryString::fromString("Data\x1DMore")); + $result = $reader->readBytesWith(terminator: Terminator::GS); + $this->assertEquals("Data", $result->toString()); + $this->assertEquals(5, $reader->position); // 4 bytes + 1 terminator consumed + } + + public function testReadBytesWithCustomTerminator(): void + { + $reader = new BinaryReader(BinaryString::fromString("Line 1\r\nLine 2")); + $customTerminator = BinaryString::fromString("\r\n"); + $result = $reader->readBytesWith(terminator: $customTerminator); + $this->assertEquals("Line 1", $result->toString()); + $this->assertEquals(8, $reader->position); // 6 bytes + 2 terminator bytes consumed + } + + public function testReadStringWithTerminatorNUL(): void + { + $reader = new BinaryReader(BinaryString::fromString("Hello World\x00Rest")); + $result = $reader->readStringWith(terminator: Terminator::NUL); + $this->assertEquals("Hello World", $result->toString()); + $this->assertEquals(12, $reader->position); // 11 bytes + 1 terminator consumed + } + + public function testReadBytesWithTerminatorAtStart(): void + { + $reader = new BinaryReader(BinaryString::fromString("\x00Data")); + $result = $reader->readBytesWith(terminator: Terminator::NUL); + $this->assertEquals("", $result->toString()); + $this->assertEquals(1, $reader->position); // Just the terminator consumed + } + + public function testReadBytesWithMultipleTerminators(): void + { + $reader = new BinaryReader(BinaryString::fromString("First\x00\x00Second")); + + // First read should stop at first terminator + $result1 = $reader->readBytesWith(terminator: Terminator::NUL); + $this->assertEquals("First", $result1->toString()); + $this->assertEquals(6, $reader->position); + + // Second read should get empty string (next char is terminator) + $result2 = $reader->readBytesWith(terminator: Terminator::NUL); + $this->assertEquals("", $result2->toString()); + $this->assertEquals(7, $reader->position); + } + + public function testReadBytesWithNoTerminatorFound(): void + { + $reader = new BinaryReader(BinaryString::fromString("NoTerminator")); + $result = $reader->readBytesWith(optional_terminator: Terminator::NUL); + $this->assertEquals("NoTerminator", $result->toString()); + $this->assertEquals(12, $reader->position); // All data consumed, no terminator found + } + + public function testReadBytesWithMissingRequiredTerminatorThrows(): void + { + $reader = new BinaryReader(BinaryString::fromString("Incomplete")); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Terminator not found before end of data'); + + $reader->readBytesWith(terminator: Terminator::NUL); + } + + public function testReadBytesWithNegativeLengthThrows(): void + { + $writer = new \KDuma\BinaryTools\BinaryWriter(); + $writer->writeInt(IntType::INT8, -1); // length stored as signed byte -1 + + $reader = new BinaryReader($writer->getBuffer()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Negative length -1 is invalid for INT8'); + + $reader->readBytesWith(length: IntType::INT8); + } + + public function testReadBytesWithPaddingReturnsDataUntilPad(): void + { + $data = BinaryString::fromString("Hi\x00\x00\x00"); + $reader = new BinaryReader($data); + + $result = $reader->readBytesWith(padding_size: 5, padding: BinaryString::fromString("\x00")); + + $this->assertEquals('Hi', $result->toString()); + $this->assertEquals(5, $reader->position); + } + + public function testReadBytesWithPaddingWithoutPadByte(): void + { + $data = BinaryString::fromString('Hello'); + $reader = new BinaryReader($data); + + $result = $reader->readBytesWith(padding_size: 5, padding: BinaryString::fromString("\x00")); + + $this->assertEquals('Hello', $result->toString()); + $this->assertEquals(5, $reader->position); + } + + public function testReadBytesWithPaddingInvalidSequenceThrows(): void + { + $data = BinaryString::fromString("Hi\x00A\x00"); + $reader = new BinaryReader($data); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid padding sequence encountered'); + + $reader->readBytesWith(padding_size: 5, padding: BinaryString::fromString("\x00")); + } + + public function testReadBytesWithPaddingZeroSizeReturnsEmpty(): void + { + $data = BinaryString::fromString('ABC'); + $reader = new BinaryReader($data); + + $result = $reader->readBytesWith(padding: BinaryString::fromString("\x00"), padding_size: 0); + + $this->assertEquals('', $result->toString()); + $this->assertEquals(0, $reader->position); + } + + public function testReadBytesWithPaddingCannotCombineWithLength(): void + { + $data = BinaryString::fromString('Data'); + $reader = new BinaryReader($data); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Exactly one of length, terminator, optional_terminator, or padding must be provided'); + + $reader->readBytesWith(length: IntType::UINT8, padding_size: 4); + } + + public function testReadBytesWithPaddingNegativeSizeThrows(): void + { + $data = BinaryString::fromString('Data'); + $reader = new BinaryReader($data); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Padding size cannot be negative'); + + $reader->readBytesWith(padding_size: -1, padding: BinaryString::fromString("\x00")); + } + + public function testReadBytesWithPaddingRequiresSize(): void + { + $data = BinaryString::fromString('Hello'); + $reader = new BinaryReader($data); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Padding size must be provided when padding is used'); + + $reader->readBytesWith(padding: BinaryString::fromString("\x00")); + } + + public function testReadBytesWithPaddingDefaultPadByte(): void + { + $data = BinaryString::fromString("Data\x00\x00"); + $reader = new BinaryReader($data); + + $result = $reader->readBytesWith(padding_size: 6); + + $this->assertEquals('Data', $result->toString()); + $this->assertEquals(6, $reader->position); + } + + public function testReadBytesWithPaddingRejectsMultiBytePad(): void + { + $data = BinaryString::fromString("Hi\r\n\r\n"); + $reader = new BinaryReader($data); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Padding must be exactly one byte'); + + $reader->readBytesWith(padding_size: 6, padding: Terminator::CRLF); + } + + public function testReadStringWithMissingRequiredTerminatorThrows(): void + { + $reader = new BinaryReader(BinaryString::fromString("No terminator")); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Terminator not found before end of data'); + + $reader->readStringWith(terminator: Terminator::NUL); + } + + public function testReadBytesWithEmptyOptionalTerminatorThrows(): void + { + $reader = new BinaryReader(BinaryString::fromString("Anything")); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Terminator cannot be empty'); + + $reader->readBytesWith(optional_terminator: BinaryString::fromString('')); + } + + public function testReadBytesWithEmptyRequiredTerminatorThrows(): void + { + $reader = new BinaryReader(BinaryString::fromString("Anything")); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Terminator cannot be empty'); + + $reader->readBytesWith(terminator: BinaryString::fromString('')); + } + + public function testReadStringWithTerminatorParameterValidation(): void + { + $reader = new BinaryReader(BinaryString::fromString("test")); + + // Test both parameters provided + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Exactly one of length, terminator, optional_terminator, or padding must be provided'); + + $reader->readStringWith(IntType::UINT8, Terminator::NUL); + } + + public function testReadBytesWithNoParameters(): void + { + $reader = new BinaryReader(BinaryString::fromString("test")); + + // Test no parameters provided + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Exactly one of length, terminator, optional_terminator, or padding must be provided'); + + $reader->readBytesWith(); + } + + public function testReadBytesWithBothTerminatorsProvided(): void + { + $reader = new BinaryReader(BinaryString::fromString("data")); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Exactly one of length, terminator, optional_terminator, or padding must be provided'); + + $reader->readBytesWith(terminator: Terminator::NUL, optional_terminator: Terminator::NUL); + } + + public function testReadStringWithInvalidUTF8AndTerminator(): void + { + $reader = new BinaryReader(BinaryString::fromString("\xFF\xFE\x00")); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid UTF-8 string'); + + $reader->readStringWith(terminator: Terminator::NUL); + } + + public function testReadStringWithOptionalTerminatorUntilEnd(): void + { + $reader = new BinaryReader(BinaryString::fromString("Partial")); + $result = $reader->readStringWith(optional_terminator: Terminator::NUL); + + $this->assertEquals("Partial", $result->toString()); + $this->assertEquals(7, $reader->position); + } + + public function testRoundTripWithTerminators(): void + { + $writer = new \KDuma\BinaryTools\BinaryWriter(); + $testData = BinaryString::fromString("Hello, World!"); + + // Test NUL terminator round trip + $writer->writeBytesWith($testData, terminator: Terminator::NUL); + $reader = new BinaryReader($writer->getBuffer()); + $result = $reader->readBytesWith(terminator: Terminator::NUL); + $this->assertTrue($testData->equals($result)); + + // Test custom terminator round trip + $writer->reset(); + $customTerminator = BinaryString::fromString("||"); + $writer->writeStringWith($testData, terminator: $customTerminator); + $reader = new BinaryReader($writer->getBuffer()); + $result = $reader->readStringWith(terminator: $customTerminator); + $this->assertTrue($testData->equals($result)); + } + + public function testReadBytesWithCRLFTerminator(): void + { + $reader = new BinaryReader(BinaryString::fromString("HTTP/1.1 200 OK\x0D\x0AContent-Type: text/html")); + $result = $reader->readBytesWith(terminator: Terminator::CRLF); + $this->assertEquals("HTTP/1.1 200 OK", $result->toString()); + $this->assertEquals(17, $reader->position); // 15 chars + 2 CRLF bytes consumed + } + + public function testReadStringWithCommonControlCharacters(): void + { + // Test with Line Feed + $reader = new BinaryReader(BinaryString::fromString("Unix line\x0ANext line")); + $result = $reader->readStringWith(terminator: Terminator::LF); + $this->assertEquals("Unix line", $result->toString()); + $this->assertEquals(10, $reader->position); + + // Test with Tab separator + $reader = new BinaryReader(BinaryString::fromString("field1\x09field2\x09field3")); + $field1 = $reader->readBytesWith(terminator: Terminator::HT); + $field2 = $reader->readBytesWith(terminator: Terminator::HT); + $this->assertEquals("field1", $field1->toString()); + $this->assertEquals("field2", $field2->toString()); + + // Test with Record Separator + $reader = new BinaryReader(BinaryString::fromString("record1\x1Erecord2\x1E")); + $rec1 = $reader->readStringWith(terminator: Terminator::RS); + $rec2 = $reader->readStringWith(terminator: Terminator::RS); + $this->assertEquals("record1", $rec1->toString()); + $this->assertEquals("record2", $rec2->toString()); + } + + public function testReadBytesWithProtocolSpecificTerminators(): void + { + // Test STX/ETX boundaries + $reader = new BinaryReader(BinaryString::fromString("\x02Important Message\x03More data")); + + // Skip STX + $stx = $reader->readBytesWith(terminator: Terminator::STX); + $this->assertEquals("", $stx->toString()); + + // Read message until ETX + $message = $reader->readBytesWith(terminator: Terminator::ETX); + $this->assertEquals("Important Message", $message->toString()); + + // Test XON/XOFF flow control characters + $reader = new BinaryReader(BinaryString::fromString("data\x11moredata\x13")); + $beforeXon = $reader->readBytesWith(terminator: Terminator::DC1); // XON + $beforeXoff = $reader->readBytesWith(terminator: Terminator::DC3); // XOFF + $this->assertEquals("data", $beforeXon->toString()); + $this->assertEquals("moredata", $beforeXoff->toString()); + } + + public function testReadWithMultipleNewTerminators(): void + { + // Test multiple different terminators in sequence + $data = "file1\x1Cgroup1\x1Drecord1\x1Eunit1\x1Fend\x20"; + $reader = new BinaryReader(BinaryString::fromString($data)); + + $file = $reader->readStringWith(terminator: Terminator::FS); // File Separator + $group = $reader->readStringWith(terminator: Terminator::GS); // Group Separator + $record = $reader->readStringWith(terminator: Terminator::RS); // Record Separator + $unit = $reader->readStringWith(terminator: Terminator::US); // Unit Separator + $end = $reader->readStringWith(terminator: Terminator::SP); // Space + + $this->assertEquals("file1", $file->toString()); + $this->assertEquals("group1", $group->toString()); + $this->assertEquals("record1", $record->toString()); + $this->assertEquals("unit1", $unit->toString()); + $this->assertEquals("end", $end->toString()); + } + + public function testCrlfVsLfTerminators(): void + { + // Test that CRLF and LF are handled differently + $reader = new BinaryReader(BinaryString::fromString("line1\x0D\x0Aline2\x0Aline3")); + + // Read first line with CRLF + $line1 = $reader->readStringWith(terminator: Terminator::CRLF); + $this->assertEquals("line1", $line1->toString()); + + // Read second line with just LF + $line2 = $reader->readStringWith(terminator: Terminator::LF); + $this->assertEquals("line2", $line2->toString()); + } } diff --git a/tests/BinaryStringTest.php b/tests/BinaryStringTest.php index f18f4cb..f8d7d4e 100644 --- a/tests/BinaryStringTest.php +++ b/tests/BinaryStringTest.php @@ -65,6 +65,14 @@ public function testEquals() $this->assertFalse($this->binaryString->equals(BinaryString::fromString("\xFF\xFF\xFF\xFF"))); } + public function testContains(): void + { + $this->assertTrue($this->binaryString->contains(BinaryString::fromString("\x01"))); + $this->assertTrue($this->binaryString->contains(BinaryString::fromString("\x02\x03"))); + $this->assertFalse($this->binaryString->contains(BinaryString::fromString("\xFF"))); + $this->assertTrue($this->binaryString->contains(BinaryString::fromString(''))); + } + public function testToBase32() { $binaryString = BinaryString::fromString("foobar"); diff --git a/tests/BinaryWriterTest.php b/tests/BinaryWriterTest.php index 16180d7..a42b959 100644 --- a/tests/BinaryWriterTest.php +++ b/tests/BinaryWriterTest.php @@ -4,7 +4,10 @@ use KDuma\BinaryTools\BinaryString; use KDuma\BinaryTools\BinaryWriter; +use KDuma\BinaryTools\IntType; +use KDuma\BinaryTools\Terminator; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversClass(BinaryWriter::class)] @@ -63,7 +66,7 @@ public function testWriteUint16BE() $this->writer->writeUint16BE(65535 + 1); $this->fail("Expected exception not thrown"); } catch (\InvalidArgumentException $exception) { - $this->assertEquals('Uint16 value must be between 0 and 65535', $exception->getMessage()); + $this->assertEquals('Value 65536 is out of range for UINT16', $exception->getMessage()); $this->assertEquals(0, $this->writer->getLength()); } } @@ -130,7 +133,7 @@ public function testWriteBytesWithLength() $this->writer->writeBytesWithLength(BinaryString::fromString(str_repeat("\x00", 255 + 1))); $this->fail("Expected exception not thrown"); } catch (\InvalidArgumentException $exception) { - $this->assertEquals('String too long for 8-bit length field', $exception->getMessage()); + $this->assertEquals('Data too long for UINT8 length field (max: 255)', $exception->getMessage()); $this->assertEquals(0, $this->writer->getLength()); } @@ -143,7 +146,7 @@ public function testWriteBytesWithLength() $this->writer->writeBytesWithLength(BinaryString::fromString(str_repeat("\x00", 65535 + 1)), true); $this->fail("Expected exception not thrown"); } catch (\InvalidArgumentException $exception) { - $this->assertEquals('String too long for 16-bit length field', $exception->getMessage()); + $this->assertEquals('Data too long for UINT16 length field (max: 65535)', $exception->getMessage()); $this->assertEquals(0, $this->writer->getLength()); } } @@ -179,4 +182,457 @@ public function testWriteString() $this->assertEquals(0, $this->writer->getLength()); } } + + public static function writeIntProvider(): iterable + { + yield 'uint8 zero' => [0, "\x00", IntType::UINT8]; + yield 'uint8 max' => [255, "\xFF", IntType::UINT8]; + yield 'int8 positive' => [127, "\x7F", IntType::INT8]; + yield 'int8 negative' => [-1, "\xFF", IntType::INT8]; + yield 'int8 min' => [-128, "\x80", IntType::INT8]; + yield 'uint16 positive' => [1234, "\x04\xD2", IntType::UINT16]; + yield 'uint16 little endian positive' => [1234, "\xD2\x04", IntType::UINT16_LE]; + yield 'int16 positive' => [1234, "\x04\xD2", IntType::INT16]; + yield 'int16 little endian positive' => [1234, "\xD2\x04", IntType::INT16_LE]; + yield 'int16 negative' => [-1234, "\xFB\x2E", IntType::INT16]; + yield 'int16 little endian negative' => [-1234, "\x2E\xFB", IntType::INT16_LE]; + yield 'uint32 positive' => [0xDEADBEEF, "\xDE\xAD\xBE\xEF", IntType::UINT32]; + yield 'uint32 little endian positive' => [0xDEADBEEF, "\xEF\xBE\xAD\xDE", IntType::UINT32_LE]; + yield 'int32 positive' => [1234, "\x00\x00\x04\xD2", IntType::INT32]; + yield 'int32 little endian positive' => [1234, "\xD2\x04\x00\x00", IntType::INT32_LE]; + yield 'int32 negative' => [-1234, "\xFF\xFF\xFB\x2E", IntType::INT32]; + yield 'int32 little endian negative' => [-1234, "\x2E\xFB\xFF\xFF", IntType::INT32_LE]; + yield 'uint64 positive' => [0x0123456789ABCDEF, "\x01\x23\x45\x67\x89\xAB\xCD\xEF", IntType::UINT64]; + yield 'uint64 little endian positive' => [0x0123456789ABCDEF, "\xEF\xCD\xAB\x89\x67\x45\x23\x01", IntType::UINT64_LE]; + yield 'int64 positive' => [1234, "\x00\x00\x00\x00\x00\x00\x04\xD2", IntType::INT64]; + yield 'int64 little endian positive' => [1234, "\xD2\x04\x00\x00\x00\x00\x00\x00", IntType::INT64_LE]; + yield 'int64 negative' => [-1234, "\xFF\xFF\xFF\xFF\xFF\xFF\xFB\x2E", IntType::INT64]; + yield 'int64 little endian negative' => [-1234, "\x2E\xFB\xFF\xFF\xFF\xFF\xFF\xFF", IntType::INT64_LE]; + } + + /** + * @dataProvider writeIntProvider + */ + public function testWriteInt(int $value, string $expected, IntType $type): void + { + if (!$type->isSupported()) { + $this->markTestSkipped(sprintf('IntType %s is not supported on this platform', $type->name)); + } + + $this->writer->reset(); + $this->writer->writeInt($type, $value); + $this->assertEquals($expected, $this->writer->getBuffer()->toString()); + } + + public function testWriteIntUnsupportedType(): void + { + // This test only runs on 32-bit systems where 64-bit integers are not supported + if (PHP_INT_SIZE >= 8) { + $this->markTestSkipped('64-bit integers are supported on this platform'); + } + + $this->writer->reset(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Cannot write 8-byte integers on 4-byte platform'); + + $this->writer->writeInt(IntType::UINT64, 1234); + } + + public function testWriteIntOutOfRange(): void + { + $this->writer->reset(); + + // Test uint8 overflow + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Value 256 is out of range for UINT8'); + + $this->writer->writeInt(IntType::UINT8, 256); + } + + public function testWriteIntNegativeUnsigned(): void + { + $this->writer->reset(); + + // Test negative value for unsigned type + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Value -1 is out of range for UINT8'); + + $this->writer->writeInt(IntType::UINT8, -1); + } + + public function testWriteIntSignedOverflow(): void + { + $this->writer->reset(); + + // Test int8 overflow + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Value 128 is out of range for INT8'); + + $this->writer->writeInt(IntType::INT8, 128); + } + + public function testWriteIntSignedUnderflow(): void + { + $this->writer->reset(); + + // Test int8 underflow + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Value -129 is out of range for INT8'); + + $this->writer->writeInt(IntType::INT8, -129); + } + + public function testWriteUint16BEDeprecated(): void + { + $this->writer->reset(); + $this->writer->writeUint16BE(1234); + $this->assertEquals("\x04\xD2", $this->writer->getBuffer()->toString()); + } + + /** + * @return array + */ + public static function writeBytesWithProvider(): array + { + return [ + 'UINT8 short' => ['data' => 'abc', 'length' => IntType::UINT8, 'expected' => "\x03abc"], + 'UINT8 empty' => ['data' => '', 'length' => IntType::UINT8, 'expected' => "\x00"], + 'UINT16 short' => ['data' => 'abc', 'length' => IntType::UINT16, 'expected' => "\x00\x03abc"], + 'UINT16_LE short' => ['data' => 'abc', 'length' => IntType::UINT16_LE, 'expected' => "\x03\x00abc"], + 'UINT32 short' => ['data' => 'abc', 'length' => IntType::UINT32, 'expected' => "\x00\x00\x00\x03abc"], + ]; + } + + #[DataProvider('writeBytesWithProvider')] + public function testWriteBytesWith(string $data, IntType $length, string $expected): void + { + $this->writer->reset(); + $this->writer->writeBytesWith(BinaryString::fromString($data), $length); + $this->assertEquals($expected, $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithOverflow(): void + { + $this->writer->reset(); + $data = BinaryString::fromString(str_repeat('a', 256)); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Data too long for UINT8 length field (max: 255)'); + + $this->writer->writeBytesWith($data, IntType::UINT8); + } + + #[DataProvider('writeBytesWithProvider')] + public function testWriteStringWith(string $data, IntType $length, string $expected): void + { + $this->writer->reset(); + $this->writer->writeStringWith(BinaryString::fromString($data), $length); + $this->assertEquals($expected, $this->writer->getBuffer()->toString()); + } + + public function testWriteStringWithInvalidUTF8(): void + { + $this->writer->reset(); + $invalidUTF8 = BinaryString::fromString("\xFF\xFE"); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('String must be valid UTF-8'); + + $this->writer->writeStringWith($invalidUTF8, IntType::UINT8); + } + + public function testWriteBytesWithLengthDeprecated(): void + { + $this->writer->reset(); + $this->writer->writeBytesWithLength(BinaryString::fromString("abc")); + $this->assertEquals("\x03abc", $this->writer->getBuffer()->toString()); + + $this->writer->reset(); + $this->writer->writeBytesWithLength(BinaryString::fromString("abc"), true); + $this->assertEquals("\x00\x03abc", $this->writer->getBuffer()->toString()); + } + + public function testWriteStringWithLengthDeprecated(): void + { + $this->writer->reset(); + $this->writer->writeStringWithLength(BinaryString::fromString("abc")); + $this->assertEquals("\x03abc", $this->writer->getBuffer()->toString()); + + $this->writer->reset(); + $this->writer->writeStringWithLength(BinaryString::fromString("abc"), true); + $this->assertEquals("\x00\x03abc", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithTerminatorNUL(): void + { + $this->writer->reset(); + $data = BinaryString::fromString("Hello"); + $this->writer->writeBytesWith($data, terminator: Terminator::NUL); + $this->assertEquals("Hello\x00", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithTerminatorGS(): void + { + $this->writer->reset(); + $data = BinaryString::fromString("World"); + $this->writer->writeBytesWith($data, terminator: Terminator::GS); + $this->assertEquals("World\x1D", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithCustomTerminator(): void + { + $this->writer->reset(); + $data = BinaryString::fromString("test"); + $customTerminator = BinaryString::fromString("\r\n"); + $this->writer->writeBytesWith($data, terminator: $customTerminator); + $this->assertEquals("test\r\n", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithTerminatorRejectsEmbeddedTerminator(): void + { + $this->writer->reset(); + $data = BinaryString::fromString("Hello\x00World"); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Data contains terminator sequence'); + + $this->writer->writeBytesWith($data, terminator: Terminator::NUL); + } + + public function testWriteBytesWithOptionalTerminatorTriggersNotice(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('Hello'); + + $notice = null; + set_error_handler(function (int $errno, string $errstr) use (&$notice) { + $notice = ['errno' => $errno, 'message' => $errstr]; + + return true; + }, E_USER_NOTICE); + + try { + $this->writer->writeBytesWith($data, optional_terminator: Terminator::NUL); + } finally { + restore_error_handler(); + } + + $this->assertNotNull($notice, 'Expected notice not triggered'); + $this->assertSame(E_USER_NOTICE, $notice['errno']); + $this->assertSame( + 'UNSTABLE API: optional_terminator has no effect when writing IN THIS VERSION; data will always be terminated IN THIS VERSION; it will probably change in future', + $notice['message'] + ); + + $this->assertEquals("Hello\x00", $this->writer->getBuffer()->toString()); + } + + public function testWriteStringWithTerminatorNUL(): void + { + $this->writer->reset(); + $string = BinaryString::fromString("Hello World"); + $this->writer->writeStringWith($string, terminator: Terminator::NUL); + $this->assertEquals("Hello World\x00", $this->writer->getBuffer()->toString()); + } + + public function testWriteStringWithCustomTerminator(): void + { + $this->writer->reset(); + $string = BinaryString::fromString("Line 1"); + $newlineTerminator = BinaryString::fromString("\n"); + $this->writer->writeStringWith($string, terminator: $newlineTerminator); + $this->assertEquals("Line 1\n", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithParameterValidation(): void + { + $this->writer->reset(); + $data = BinaryString::fromString("test"); + + // Test both parameters provided + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Exactly one of length, terminator, optional_terminator, or padding must be provided'); + + $this->writer->writeBytesWith($data, IntType::UINT8, Terminator::NUL); + } + + public function testWriteBytesWithNoParameters(): void + { + $this->writer->reset(); + $data = BinaryString::fromString("test"); + + // Test no parameters provided + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Exactly one of length, terminator, optional_terminator, or padding must be provided'); + + $this->writer->writeBytesWith($data); + } + + public function testWriteStringWithInvalidUTF8AndTerminator(): void + { + $this->writer->reset(); + $invalidUTF8 = BinaryString::fromString("\xFF\xFE"); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('String must be valid UTF-8'); + + $this->writer->writeStringWith($invalidUTF8, terminator: Terminator::NUL); + } + + public function testWriteBytesWithCRLFTerminator(): void + { + $this->writer->reset(); + $data = BinaryString::fromString("HTTP/1.1 200 OK"); + $this->writer->writeBytesWith($data, terminator: Terminator::CRLF); + $this->assertEquals("HTTP/1.1 200 OK\x0D\x0A", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithPaddingExactSize(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('ABCDE'); + + $this->writer->writeBytesWith($data, padding: BinaryString::fromString("\x20"), padding_size: 5); + + $this->assertEquals('ABCDE', $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithPaddingShorterThanSize(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('Hi'); + + $this->writer->writeBytesWith($data, padding: BinaryString::fromString("\x20"), padding_size: 5); + + $this->assertEquals("Hi ", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithPaddingDefaultPadByte(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('AB'); + + $this->writer->writeBytesWith($data, padding_size: 4); + + $this->assertEquals("AB\x00\x00", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithPaddingTooLongThrows(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('TooLong'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Data too long for padding size'); + + $this->writer->writeBytesWith($data, padding: BinaryString::fromString("\x20"), padding_size: 5); + } + + public function testWriteBytesWithPaddingRejectsDataContainingPadByte(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('Hello World'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Data contains padding byte'); + + $this->writer->writeBytesWith($data, padding: BinaryString::fromString("\x20"), padding_size: 20); + } + + public function testWriteBytesWithEmptyTerminatorThrows(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('Hello'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Terminator cannot be empty'); + + $this->writer->writeBytesWith($data, terminator: BinaryString::fromString('')); + } + + public function testWriteBytesWithPaddingRequiresSize(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('Data'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Padding size must be provided when padding is used'); + + $this->writer->writeBytesWith($data, padding: BinaryString::fromString("\x20")); + } + + public function testWriteBytesWithPaddingRejectsMultiBytePadding(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('Data'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Padding must be exactly one byte'); + + $this->writer->writeBytesWith($data, padding: Terminator::CRLF, padding_size: 10); + } + + public function testWriteBytesWithPaddingNegativeSizeThrows(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('AB'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Padding size cannot be negative'); + + $this->writer->writeBytesWith($data, padding: BinaryString::fromString("\x20"), padding_size: -1); + } + + public function testWriteBytesWithPaddingCannotCombineWithLength(): void + { + $this->writer->reset(); + $data = BinaryString::fromString('AB'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Exactly one of length, terminator, optional_terminator, or padding must be provided'); + + $this->writer->writeBytesWith($data, length: IntType::UINT8, padding_size: 4); + } + + public function testWriteStringWithPadding(): void + { + $this->writer->reset(); + $string = BinaryString::fromString('Hi'); + + $this->writer->writeStringWith($string, padding: BinaryString::fromString("\x20"), padding_size: 4); + + $this->assertEquals("Hi ", $this->writer->getBuffer()->toString()); + } + + public function testWriteStringWithCommonControlCharacters(): void + { + $this->writer->reset(); + + // Test with Line Feed + $this->writer->writeStringWith(BinaryString::fromString("Unix line"), terminator: Terminator::LF); + $this->assertEquals("Unix line\x0A", $this->writer->getBuffer()->toString()); + + // Test with Tab separator + $this->writer->reset(); + $this->writer->writeBytesWith(BinaryString::fromString("field1"), terminator: Terminator::HT); + $this->assertEquals("field1\x09", $this->writer->getBuffer()->toString()); + + // Test with Record Separator + $this->writer->reset(); + $this->writer->writeStringWith(BinaryString::fromString("record"), terminator: Terminator::RS); + $this->assertEquals("record\x1E", $this->writer->getBuffer()->toString()); + } + + public function testWriteBytesWithProtocolSpecificTerminators(): void + { + $this->writer->reset(); + + // STX/ETX for text boundaries + $this->writer->writeBytesWith(BinaryString::fromString(""), terminator: Terminator::STX); + $this->writer->writeBytesWith(BinaryString::fromString("Important Message"), terminator: Terminator::ETX); + + $expected = "\x02Important Message\x03"; + $this->assertEquals($expected, $this->writer->getBuffer()->toString()); + } } diff --git a/tests/IntTypeTest.php b/tests/IntTypeTest.php new file mode 100644 index 0000000..d557954 --- /dev/null +++ b/tests/IntTypeTest.php @@ -0,0 +1,252 @@ + + */ + public static function bytesProvider(): array + { + return [ + 'UINT8' => ['type' => IntType::UINT8, 'bytes' => 1], + 'INT8' => ['type' => IntType::INT8, 'bytes' => 1], + 'UINT16' => ['type' => IntType::UINT16, 'bytes' => 2], + 'INT16' => ['type' => IntType::INT16, 'bytes' => 2], + 'UINT16_LE' => ['type' => IntType::UINT16_LE, 'bytes' => 2], + 'INT16_LE' => ['type' => IntType::INT16_LE, 'bytes' => 2], + 'UINT32' => ['type' => IntType::UINT32, 'bytes' => 4], + 'INT32' => ['type' => IntType::INT32, 'bytes' => 4], + 'UINT32_LE' => ['type' => IntType::UINT32_LE, 'bytes' => 4], + 'INT32_LE' => ['type' => IntType::INT32_LE, 'bytes' => 4], + 'UINT64' => ['type' => IntType::UINT64, 'bytes' => 8], + 'INT64' => ['type' => IntType::INT64, 'bytes' => 8], + 'UINT64_LE' => ['type' => IntType::UINT64_LE, 'bytes' => 8], + 'INT64_LE' => ['type' => IntType::INT64_LE, 'bytes' => 8], + ]; + } + + #[DataProvider('bytesProvider')] + public function testBytes(IntType $type, int $bytes): void + { + $this->assertSame($bytes, $type->bytes()); + } + + /** + * @return array + */ + public static function signedProvider(): array + { + return [ + 'UINT8' => ['type' => IntType::UINT8, 'signed' => false], + 'INT8' => ['type' => IntType::INT8, 'signed' => true], + 'UINT16' => ['type' => IntType::UINT16, 'signed' => false], + 'INT16' => ['type' => IntType::INT16, 'signed' => true], + 'UINT16_LE' => ['type' => IntType::UINT16_LE, 'signed' => false], + 'INT16_LE' => ['type' => IntType::INT16_LE, 'signed' => true], + 'UINT32' => ['type' => IntType::UINT32, 'signed' => false], + 'INT32' => ['type' => IntType::INT32, 'signed' => true], + 'UINT32_LE' => ['type' => IntType::UINT32_LE, 'signed' => false], + 'INT32_LE' => ['type' => IntType::INT32_LE, 'signed' => true], + 'UINT64' => ['type' => IntType::UINT64, 'signed' => false], + 'INT64' => ['type' => IntType::INT64, 'signed' => true], + 'UINT64_LE' => ['type' => IntType::UINT64_LE, 'signed' => false], + 'INT64_LE' => ['type' => IntType::INT64_LE, 'signed' => true], + ]; + } + + #[DataProvider('signedProvider')] + public function testIsSigned(IntType $type, bool $signed): void + { + $this->assertSame($signed, $type->isSigned()); + } + + /** + * @return array + */ + public static function endianProvider(): array + { + return [ + 'UINT8' => ['type' => IntType::UINT8, 'littleEndian' => false], + 'INT8' => ['type' => IntType::INT8, 'littleEndian' => false], + 'UINT16' => ['type' => IntType::UINT16, 'littleEndian' => false], + 'INT16' => ['type' => IntType::INT16, 'littleEndian' => false], + 'UINT16_LE' => ['type' => IntType::UINT16_LE, 'littleEndian' => true], + 'INT16_LE' => ['type' => IntType::INT16_LE, 'littleEndian' => true], + 'UINT32' => ['type' => IntType::UINT32, 'littleEndian' => false], + 'INT32' => ['type' => IntType::INT32, 'littleEndian' => false], + 'UINT32_LE' => ['type' => IntType::UINT32_LE, 'littleEndian' => true], + 'INT32_LE' => ['type' => IntType::INT32_LE, 'littleEndian' => true], + 'UINT64' => ['type' => IntType::UINT64, 'littleEndian' => false], + 'INT64' => ['type' => IntType::INT64, 'littleEndian' => false], + 'UINT64_LE' => ['type' => IntType::UINT64_LE, 'littleEndian' => true], + 'INT64_LE' => ['type' => IntType::INT64_LE, 'littleEndian' => true], + ]; + } + + #[DataProvider('endianProvider')] + public function testIsLittleEndian(IntType $type, bool $littleEndian): void + { + $this->assertSame($littleEndian, $type->isLittleEndian()); + } + + /** + * @return array + */ + public static function supportedProvider(): array + { + $is64Bit = PHP_INT_SIZE >= 8; + + return [ + 'UINT8' => ['type' => IntType::UINT8, 'supported' => true], + 'INT8' => ['type' => IntType::INT8, 'supported' => true], + 'UINT16' => ['type' => IntType::UINT16, 'supported' => true], + 'INT16' => ['type' => IntType::INT16, 'supported' => true], + 'UINT16_LE' => ['type' => IntType::UINT16_LE, 'supported' => true], + 'INT16_LE' => ['type' => IntType::INT16_LE, 'supported' => true], + 'UINT32' => ['type' => IntType::UINT32, 'supported' => true], + 'INT32' => ['type' => IntType::INT32, 'supported' => true], + 'UINT32_LE' => ['type' => IntType::UINT32_LE, 'supported' => true], + 'INT32_LE' => ['type' => IntType::INT32_LE, 'supported' => true], + 'UINT64' => ['type' => IntType::UINT64, 'supported' => $is64Bit], + 'INT64' => ['type' => IntType::INT64, 'supported' => $is64Bit], + 'UINT64_LE' => ['type' => IntType::UINT64_LE, 'supported' => $is64Bit], + 'INT64_LE' => ['type' => IntType::INT64_LE, 'supported' => $is64Bit], + ]; + } + + #[DataProvider('supportedProvider')] + public function testIsSupported(IntType $type, bool $supported): void + { + $this->assertSame($supported, $type->isSupported()); + } + + /** + * @return array + */ + public static function minValueProvider(): array + { + return [ + 'UINT8' => ['type' => IntType::UINT8, 'minValue' => 0], + 'INT8' => ['type' => IntType::INT8, 'minValue' => -128], + 'UINT16' => ['type' => IntType::UINT16, 'minValue' => 0], + 'INT16' => ['type' => IntType::INT16, 'minValue' => -32768], + 'UINT16_LE' => ['type' => IntType::UINT16_LE, 'minValue' => 0], + 'INT16_LE' => ['type' => IntType::INT16_LE, 'minValue' => -32768], + 'UINT32' => ['type' => IntType::UINT32, 'minValue' => 0], + 'INT32' => ['type' => IntType::INT32, 'minValue' => -2147483648], + 'UINT32_LE' => ['type' => IntType::UINT32_LE, 'minValue' => 0], + 'INT32_LE' => ['type' => IntType::INT32_LE, 'minValue' => -2147483648], + 'UINT64' => ['type' => IntType::UINT64, 'minValue' => 0], + 'INT64' => ['type' => IntType::INT64, 'minValue' => PHP_INT_MIN], + 'UINT64_LE' => ['type' => IntType::UINT64_LE, 'minValue' => 0], + 'INT64_LE' => ['type' => IntType::INT64_LE, 'minValue' => PHP_INT_MIN], + ]; + } + + #[DataProvider('minValueProvider')] + public function testMinValue(IntType $type, int $minValue): void + { + $this->assertSame($minValue, $type->minValue()); + } + + /** + * @return array + */ + public static function maxValueProvider(): array + { + $is64Bit = PHP_INT_SIZE >= 8; + + return [ + 'UINT8' => ['type' => IntType::UINT8, 'maxValue' => 255], + 'INT8' => ['type' => IntType::INT8, 'maxValue' => 127], + 'UINT16' => ['type' => IntType::UINT16, 'maxValue' => 65535], + 'INT16' => ['type' => IntType::INT16, 'maxValue' => 32767], + 'UINT16_LE' => ['type' => IntType::UINT16_LE, 'maxValue' => 65535], + 'INT16_LE' => ['type' => IntType::INT16_LE, 'maxValue' => 32767], + 'UINT32' => ['type' => IntType::UINT32, 'maxValue' => 4294967295], + 'INT32' => ['type' => IntType::INT32, 'maxValue' => 2147483647], + 'UINT32_LE' => ['type' => IntType::UINT32_LE, 'maxValue' => 4294967295], + 'INT32_LE' => ['type' => IntType::INT32_LE, 'maxValue' => 2147483647], + 'UINT64' => ['type' => IntType::UINT64, 'maxValue' => PHP_INT_MAX], // Limited by PHP_INT_MAX + 'INT64' => ['type' => IntType::INT64, 'maxValue' => PHP_INT_MAX], + 'UINT64_LE' => ['type' => IntType::UINT64_LE, 'maxValue' => PHP_INT_MAX], + 'INT64_LE' => ['type' => IntType::INT64_LE, 'maxValue' => PHP_INT_MAX], + ]; + } + + #[DataProvider('maxValueProvider')] + public function testMaxValue(IntType $type, int $maxValue): void + { + $this->assertSame($maxValue, $type->maxValue()); + } + + /** + * @return array + */ + public static function isValidProvider(): array + { + return [ + 'UINT8' => ['type' => IntType::UINT8, 'validValue' => 100, 'belowMin' => -1, 'aboveMax' => 256], + 'INT8' => ['type' => IntType::INT8, 'validValue' => 50, 'belowMin' => -129, 'aboveMax' => 128], + 'UINT16' => ['type' => IntType::UINT16, 'validValue' => 1000, 'belowMin' => -1, 'aboveMax' => 65536], + 'INT16' => ['type' => IntType::INT16, 'validValue' => 1000, 'belowMin' => -32769, 'aboveMax' => 32768], + 'UINT16_LE' => ['type' => IntType::UINT16_LE, 'validValue' => 1000, 'belowMin' => -1, 'aboveMax' => 65536], + 'INT16_LE' => ['type' => IntType::INT16_LE, 'validValue' => 1000, 'belowMin' => -32769, 'aboveMax' => 32768], + 'UINT32' => ['type' => IntType::UINT32, 'validValue' => 100000, 'belowMin' => -1, 'aboveMax' => 4294967296], + 'INT32' => ['type' => IntType::INT32, 'validValue' => 100000, 'belowMin' => -2147483649, 'aboveMax' => 2147483648], + 'UINT32_LE' => ['type' => IntType::UINT32_LE, 'validValue' => 100000, 'belowMin' => -1, 'aboveMax' => 4294967296], + 'INT32_LE' => ['type' => IntType::INT32_LE, 'validValue' => 100000, 'belowMin' => -2147483649, 'aboveMax' => 2147483648], + ]; + } + + #[DataProvider('isValidProvider')] + public function testIsValid(IntType $type, int $validValue, int $belowMin, int $aboveMax): void + { + // Test valid value + $this->assertTrue($type->isValid($validValue)); + + // Test boundary values + $this->assertTrue($type->isValid($type->minValue())); + $this->assertTrue($type->isValid($type->maxValue())); + + // Test invalid values (if they don't cause overflow issues) + if ($belowMin > PHP_INT_MIN) { + $this->assertFalse($type->isValid($belowMin)); + } + if ($aboveMax < PHP_INT_MAX) { + $this->assertFalse($type->isValid($aboveMax)); + } + } + + public function testIsValidFor64BitTypes(): void + { + if (PHP_INT_SIZE < 8) { + $this->markTestSkipped('64-bit integers not supported on this platform'); + } + + $types = [IntType::UINT64, IntType::INT64, IntType::UINT64_LE, IntType::INT64_LE]; + + foreach ($types as $type) { + // Test valid values + $this->assertTrue($type->isValid(0)); + $this->assertTrue($type->isValid(1234)); + + // Test boundary values + $this->assertTrue($type->isValid($type->minValue())); + $this->assertTrue($type->isValid($type->maxValue())); + + // For signed types, test negative values + if ($type->isSigned()) { + $this->assertTrue($type->isValid(-1234)); + } + } + } +} diff --git a/tests/TerminatorTest.php b/tests/TerminatorTest.php new file mode 100644 index 0000000..124c596 --- /dev/null +++ b/tests/TerminatorTest.php @@ -0,0 +1,147 @@ +toBytes(); + + $this->assertEquals("\x00", $bytes->toString()); + $this->assertEquals(1, $bytes->size()); + } + + public function testGsTerminator(): void + { + $terminator = Terminator::GS; + $bytes = $terminator->toBytes(); + + $this->assertEquals("\x1D", $bytes->toString()); + $this->assertEquals(1, $bytes->size()); + } + + public function testTerminatorEquality(): void + { + $nul1 = Terminator::NUL; + $nul2 = Terminator::NUL; + $gs = Terminator::GS; + + $this->assertEquals($nul1, $nul2); + $this->assertNotEquals($nul1, $gs); + $this->assertNotEquals($nul2, $gs); + } + + public function testTerminatorBytes(): void + { + $nullBytes = BinaryString::fromString("\x00"); + $gsBytes = BinaryString::fromString("\x1D"); + + $this->assertTrue(Terminator::NUL->toBytes()->equals($nullBytes)); + $this->assertTrue(Terminator::GS->toBytes()->equals($gsBytes)); + $this->assertFalse(Terminator::NUL->toBytes()->equals($gsBytes)); + $this->assertFalse(Terminator::GS->toBytes()->equals($nullBytes)); + } + + public function testCrlfTerminator(): void + { + $terminator = Terminator::CRLF; + $bytes = $terminator->toBytes(); + + $this->assertEquals("\x0D\x0A", $bytes->toString()); + $this->assertEquals(2, $bytes->size()); + } + + public function testCommonControlCharacters(): void + { + // Test some commonly used control characters + $this->assertEquals("\x09", Terminator::HT->toBytes()->toString()); // Tab + $this->assertEquals("\x0A", Terminator::LF->toBytes()->toString()); // Line Feed + $this->assertEquals("\x0D", Terminator::CR->toBytes()->toString()); // Carriage Return + $this->assertEquals("\x20", Terminator::SP->toBytes()->toString()); // Space + $this->assertEquals("\x1C", Terminator::FS->toBytes()->toString()); // File Separator + $this->assertEquals("\x1E", Terminator::RS->toBytes()->toString()); // Record Separator + $this->assertEquals("\x1F", Terminator::US->toBytes()->toString()); // Unit Separator + } + + public function testAllASCIIControlCharacters(): void + { + // Test that all ASCII control characters 0x00-0x20 are covered + $testCases = [ + [Terminator::NUL, "\x00"], + [Terminator::SOH, "\x01"], + [Terminator::STX, "\x02"], + [Terminator::ETX, "\x03"], + [Terminator::EOT, "\x04"], + [Terminator::ENQ, "\x05"], + [Terminator::ACK, "\x06"], + [Terminator::BEL, "\x07"], + [Terminator::BS, "\x08"], + [Terminator::HT, "\x09"], + [Terminator::LF, "\x0A"], + [Terminator::VT, "\x0B"], + [Terminator::FF, "\x0C"], + [Terminator::CR, "\x0D"], + [Terminator::SO, "\x0E"], + [Terminator::SI, "\x0F"], + [Terminator::DLE, "\x10"], + [Terminator::DC1, "\x11"], + [Terminator::DC2, "\x12"], + [Terminator::DC3, "\x13"], + [Terminator::DC4, "\x14"], + [Terminator::NAK, "\x15"], + [Terminator::SYN, "\x16"], + [Terminator::ETB, "\x17"], + [Terminator::CAN, "\x18"], + [Terminator::EM, "\x19"], + [Terminator::SUB, "\x1A"], + [Terminator::ESC, "\x1B"], + [Terminator::FS, "\x1C"], + [Terminator::GS, "\x1D"], + [Terminator::RS, "\x1E"], + [Terminator::US, "\x1F"], + [Terminator::SP, "\x20"], + [Terminator::CRLF, "\x0D\x0A"], + ]; + + foreach ($testCases as [$terminator, $expectedBytes]) { + $actualBytes = $terminator->toBytes()->toString(); + $this->assertEquals( + $expectedBytes, + $actualBytes, + "Terminator {$terminator->name} should produce expected bytes" + ); + } + } + + public function testProtocolSpecificTerminators(): void + { + // Test terminators commonly used in protocols + + // XON/XOFF flow control + $this->assertEquals("\x11", Terminator::DC1->toBytes()->toString()); // XON + $this->assertEquals("\x13", Terminator::DC3->toBytes()->toString()); // XOFF + + // Text boundaries + $this->assertEquals("\x02", Terminator::STX->toBytes()->toString()); // Start of Text + $this->assertEquals("\x03", Terminator::ETX->toBytes()->toString()); // End of Text + + // Data structure separators + $this->assertEquals("\x1C", Terminator::FS->toBytes()->toString()); // File Separator + $this->assertEquals("\x1D", Terminator::GS->toBytes()->toString()); // Group Separator + $this->assertEquals("\x1E", Terminator::RS->toBytes()->toString()); // Record Separator + $this->assertEquals("\x1F", Terminator::US->toBytes()->toString()); // Unit Separator + + // Communication control + $this->assertEquals("\x04", Terminator::EOT->toBytes()->toString()); // End of Transmission + $this->assertEquals("\x06", Terminator::ACK->toBytes()->toString()); // Acknowledge + $this->assertEquals("\x15", Terminator::NAK->toBytes()->toString()); // Negative Acknowledge + } +}