diff --git a/.gitignore b/.gitignore index 4e78ae36..db11d2e7 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,29 @@ run-tests.log /test/*/*/*/*.log /test/*/*/*/*.out + +# Added by horde-components QC --fix-qc-issues +# Build artifacts directory +/build/ +# Composer dependencies directory +/vendor/ +# Composer lock file (libraries should not commit lock files) +/composer.lock +# PHPStorm IDE settings +/.idea/ +# VSCode IDE settings +/.vscode/ +# Claude Code CLI cache and state +/.claude/ +# Cline extension data +/.cline/ +# PHP CS Fixer cache file +/.php-cs-fixer.cache +# PHPUnit result cache +/.phpunit.result.cache +# PHPUnit Cache (other) +/.phpunit.cache +# PHPStan local configuration +/phpstan.neon +# PHPStan cache directory +/.phpstan.cache/ diff --git a/.horde.yml b/.horde.yml index 13510581..56692541 100644 --- a/.horde.yml +++ b/.horde.yml @@ -30,11 +30,16 @@ license: uri: http://www.horde.org/licenses/bsd dependencies: required: - php: ^7.4 || ^8 + php: ^8 dev: composer: horde/log: ^3 - horde/test: ^3 optional: composer: horde/log: ^3 +keywords: + - decorator +vendor: horde +quality: + phpstan: + level: 3 diff --git a/composer.json b/composer.json index fe3291a3..4174521b 100644 --- a/composer.json +++ b/composer.json @@ -28,8 +28,7 @@ "php": "^7.4 || ^8" }, "require-dev": { - "horde/log": "^3 || dev-FRAMEWORK_6_0", - "horde/test": "^3 || dev-FRAMEWORK_6_0" + "horde/log": "^3 || dev-FRAMEWORK_6_0" }, "suggest": { "horde/log": "^3 || dev-FRAMEWORK_6_0" @@ -47,4 +46,4 @@ "Horde\\Stream\\Wrapper\\Test\\": "test/" } } -} \ No newline at end of file +} diff --git a/lib/Horde/Stream/Wrapper/Combine.php b/lib/Horde/Stream/Wrapper/Combine.php index 39a022c1..caff3d36 100644 --- a/lib/Horde/Stream/Wrapper/Combine.php +++ b/lib/Horde/Stream/Wrapper/Combine.php @@ -1,6 +1,7 @@ array( - 'data' => $data - ) - )) + stream_context_create([ + self::WRAPPER_NAME => [ + 'data' => $data, + ], + ]) ); } /** @@ -137,11 +138,11 @@ public function stream_open($path, $mode, $options, &$opened_path) $length = ftell($fp); rewind($fp); - $this->_data[] = array( + $this->_data[] = [ 'fp' => $fp, 'l' => $length, - 'p' => 0 - ); + 'p' => 0, + ]; $this->_length += $length; } @@ -252,7 +253,7 @@ public function stream_eof() */ public function stream_stat() { - return array( + return [ 'dev' => 0, 'ino' => 0, 'mode' => 0, @@ -265,8 +266,8 @@ public function stream_stat() 'mtime' => 0, 'ctime' => 0, 'blksize' => 0, - 'blocks' => 0 - ); + 'blocks' => 0, + ]; } /** @@ -283,20 +284,20 @@ public function stream_seek($offset, $whence) $this->_ateof = false; switch ($whence) { - case SEEK_SET: - $offset = $offset; - break; + case SEEK_SET: + $offset = $offset; + break; - case SEEK_CUR: - $offset = $this->_position + $offset; - break; + case SEEK_CUR: + $offset = $this->_position + $offset; + break; - case SEEK_END: - $offset = $this->_length + $offset; - break; + case SEEK_END: + $offset = $this->_length + $offset; + break; - default: - return false; + default: + return false; } $count = $this->_position = min($this->_length, $offset); diff --git a/lib/Horde/Stream/Wrapper/CombineStream.php b/lib/Horde/Stream/Wrapper/CombineStream.php index 97ab0993..dfe86f1b 100644 --- a/lib/Horde/Stream/Wrapper/CombineStream.php +++ b/lib/Horde/Stream/Wrapper/CombineStream.php @@ -1,6 +1,7 @@ string = &$string; return fopen( self::WRAPPER_NAME . '://' . ++self::$_id, 'wb', false, - stream_context_create(array( - self::WRAPPER_NAME => array( - 'string' => $ob - ) - )) + stream_context_create([ + self::WRAPPER_NAME => [ + 'string' => $ob, + ], + ]) ); } @@ -93,10 +94,10 @@ public function stream_open($path, $mode, $options, &$opened_path) $opts = stream_context_get_options($this->context); if (isset($opts[self::WRAPPER_NAME]['string'])) { - $this->_string =& $opts[self::WRAPPER_NAME]['string']->string; + $this->_string = & $opts[self::WRAPPER_NAME]['string']->string; } elseif (isset($opts['horde-string']['string'])) { // @deprecated - $this->_string =& $opts['horde-string']['string']->getString(); + $this->_string = & $opts['horde-string']['string']->getString(); } else { throw new Exception('Use ' . __CLASS__ . '::getStream() to initialize the stream.'); } @@ -163,7 +164,7 @@ public function stream_eof() */ public function stream_stat() { - return array( + return [ 'dev' => 0, 'ino' => 0, 'mode' => 0, @@ -176,8 +177,8 @@ public function stream_stat() 'mtime' => 0, 'ctime' => 0, 'blksize' => 0, - 'blocks' => 0 - ); + 'blocks' => 0, + ]; } /** @@ -186,17 +187,17 @@ public function stream_stat() public function stream_seek($offset, $whence) { switch ($whence) { - case SEEK_SET: - $pos = $offset; - break; + case SEEK_SET: + $pos = $offset; + break; - case SEEK_CUR: - $pos = $this->_pos + $offset; - break; + case SEEK_CUR: + $pos = $this->_pos + $offset; + break; - case SEEK_END: - $pos = strlen($this->_string) + $offset; - break; + case SEEK_END: + $pos = strlen($this->_string) + $offset; + break; } if (($pos < 0) || ($pos > strlen($this->_string))) { diff --git a/lib/Horde/Stream/Wrapper/StringStream.php b/lib/Horde/Stream/Wrapper/StringStream.php index bb38341c..0be5bd5e 100644 --- a/lib/Horde/Stream/Wrapper/StringStream.php +++ b/lib/Horde/Stream/Wrapper/StringStream.php @@ -1,6 +1,7 @@ + displayDetailsOnTestsThatTriggerDeprecations="true" + displayDetailsOnTestsThatTriggerWarnings="true"> test - + lib + src - + diff --git a/src/CombineStreamWrapper.php b/src/CombineStreamWrapper.php index dda61abe..377d3c1a 100644 --- a/src/CombineStreamWrapper.php +++ b/src/CombineStreamWrapper.php @@ -1,6 +1,7 @@ ateof = false; switch ($whence) { - case SEEK_SET: - $offset = $offset; - break; + case SEEK_SET: + $offset = $offset; + break; - case SEEK_CUR: - $offset = $this->position + $offset; - break; + case SEEK_CUR: + $offset = $this->position + $offset; + break; - case SEEK_END: - $offset = $this->length + $offset; - break; + case SEEK_END: + $offset = $this->length + $offset; + break; - default: - return false; + default: + return false; } $count = $this->position = min($this->length, $offset); diff --git a/src/StringStreamWrapper.php b/src/StringStreamWrapper.php index e92540ee..bcb319a9 100644 --- a/src/StringStreamWrapper.php +++ b/src/StringStreamWrapper.php @@ -1,6 +1,7 @@ context); if (isset($opts[self::WRAPPER_NAME]['string'])) { - $this->string =& $opts[self::WRAPPER_NAME]['string']->string; + $this->string = & $opts[self::WRAPPER_NAME]['string']->string; } elseif (isset($opts['horde-string']['string'])) { // @deprecated - $this->string =& $opts['horde-string']['string']->getString(); + $this->string = & $opts['horde-string']['string']->getString(); } else { throw new Exception('Use ' . __CLASS__ . '::getStream() to initialize the stream.'); } @@ -204,17 +205,17 @@ public function stream_seek(int $offset, int $whence = SEEK_SET): bool { $pos = 0; switch ($whence) { - case SEEK_SET: - $pos = $offset; - break; + case SEEK_SET: + $pos = $offset; + break; - case SEEK_CUR: - $pos = $this->pos + $offset; - break; + case SEEK_CUR: + $pos = $this->pos + $offset; + break; - case SEEK_END: - $pos = strlen($this->string) + $offset; - break; + case SEEK_END: + $pos = strlen($this->string) + $offset; + break; } if (($pos < 0) || ($pos > strlen($this->string))) { diff --git a/test/AllTests.php b/test/AllTests.php deleted file mode 100644 index 79e20995..00000000 --- a/test/AllTests.php +++ /dev/null @@ -1,5 +0,0 @@ -run(); diff --git a/test/CombineTest.php b/test/CombineTest.php deleted file mode 100644 index 7e8db708..00000000 --- a/test/CombineTest.php +++ /dev/null @@ -1,56 +0,0 @@ - - * @category Horde - * @copyright 2009-2016 Horde LLC - * @ignore - * @license http://www.horde.org/licenses/bsd BSD - * @package Stream_Wrapper - * @subpackage UnitTests - */ -class CombineTest extends TestCase -{ - public function testUsage() - { - $fp = fopen('php://temp', 'r+'); - fwrite($fp, '12345'); - - $data = array('ABCDE', $fp, 'fghij'); - - $stream = CombineWrapper::getStream($data); - - $this->assertEquals('ABCDE12345fghij', fread($stream, 1024)); - $this->assertEquals(true, feof($stream)); - $this->assertEquals(0, fseek($stream, 0)); - $this->assertEquals(-1, fseek($stream, 0)); - $this->assertEquals(0, ftell($stream)); - $this->assertEquals(0, fseek($stream, 5, SEEK_CUR)); - $this->assertEquals(5, ftell($stream)); - $this->assertEquals(10, fwrite($stream, '0000000000')); - $this->assertEquals(0, fseek($stream, 0, SEEK_END)); - $this->assertEquals(20, ftell($stream)); - $this->assertEquals(false, feof($stream)); - - fclose($stream); - } -} diff --git a/test/CombineWrapperTest.php b/test/CombineWrapperTest.php new file mode 100644 index 00000000..f950ad1a --- /dev/null +++ b/test/CombineWrapperTest.php @@ -0,0 +1,528 @@ +assertIsResource($stream); + + fclose($stream); + } + + public function testReadCombinedStrings(): void + { + $stream = CombineWrapper::getStream(['ABC', 'DEF', 'GHI']); + + $this->assertSame('ABCDEFGHI', fread($stream, 1024)); + + fclose($stream); + } + + public function testReadMixedStringAndStream(): void + { + $fp = fopen('php://temp', 'r+'); + fwrite($fp, '12345'); + + $stream = CombineWrapper::getStream(['ABCDE', $fp, 'fghij']); + + $this->assertSame('ABCDE12345fghij', fread($stream, 1024)); + + fclose($stream); + } + + public function testReadAcrossBoundaries(): void + { + $stream = CombineWrapper::getStream(['AAA', 'BBB', 'CCC']); + + $this->assertSame('AAAB', fread($stream, 4)); + $this->assertSame('BBCC', fread($stream, 4)); + $this->assertSame('C', fread($stream, 4)); + + fclose($stream); + } + + public function testReadSingleString(): void + { + $stream = CombineWrapper::getStream(['Hello, World!']); + + $this->assertSame('Hello, World!', fread($stream, 1024)); + + fclose($stream); + } + + public function testReadWithMultipleStreams(): void + { + $fp1 = fopen('php://temp', 'r+'); + fwrite($fp1, 'stream1'); + $fp2 = fopen('php://temp', 'r+'); + fwrite($fp2, 'stream2'); + + $stream = CombineWrapper::getStream([$fp1, $fp2]); + + $this->assertSame('stream1stream2', fread($stream, 1024)); + + fclose($stream); + } + + public function testEofAfterFullRead(): void + { + $stream = CombineWrapper::getStream(['test']); + + fread($stream, 1024); + + $this->assertTrue(feof($stream)); + + fclose($stream); + } + + public function testNotEofAtStart(): void + { + $stream = CombineWrapper::getStream(['test']); + + $this->assertFalse(feof($stream)); + + fclose($stream); + } + + public function testTellAtStart(): void + { + $stream = CombineWrapper::getStream(['test']); + + $this->assertSame(0, ftell($stream)); + + fclose($stream); + } + + public function testTellAfterPartialRead(): void + { + $stream = CombineWrapper::getStream(['ABCDE', '12345']); + + fread($stream, 7); + + $this->assertSame(7, ftell($stream)); + + fclose($stream); + } + + public function testTellAfterFullRead(): void + { + $stream = CombineWrapper::getStream(['ABCDE', '12345']); + + fread($stream, 1024); + + $this->assertSame(10, ftell($stream)); + + fclose($stream); + } + + public function testSeekSetToStart(): void + { + $stream = CombineWrapper::getStream(['ABCDE', '12345']); + + fread($stream, 1024); + fseek($stream, 0); + + $this->assertSame(0, ftell($stream)); + + fclose($stream); + } + + public function testSeekCurForward(): void + { + $fp = fopen('php://temp', 'r+'); + fwrite($fp, '12345'); + + $stream = CombineWrapper::getStream(['ABCDE', $fp, 'fghij']); + + $this->assertSame(0, fseek($stream, 5, SEEK_CUR)); + $this->assertSame(5, ftell($stream)); + + fclose($stream); + } + + public function testSeekEnd(): void + { + $fp = fopen('php://temp', 'r+'); + fwrite($fp, '12345'); + + $stream = CombineWrapper::getStream(['ABCDE', $fp, 'fghij']); + + $this->assertSame(0, fseek($stream, 0, SEEK_END)); + $this->assertSame(15, ftell($stream)); + + fclose($stream); + } + + public function testSeekEndNegativeOffset(): void + { + $stream = CombineWrapper::getStream(['ABCDE', '12345']); + + $this->assertSame(0, fseek($stream, -3, SEEK_END)); + $this->assertSame(7, ftell($stream)); + + fclose($stream); + } + + public function testSeekUpdatesPosition(): void + { + $stream = CombineWrapper::getStream(['ABCDE', '12345', 'fghij']); + + fseek($stream, 3); + $this->assertSame(3, ftell($stream)); + + fseek($stream, 10); + $this->assertSame(10, ftell($stream)); + + fclose($stream); + } + + public function testWriteWithinCurrentStream(): void + { + $fp = fopen('php://temp', 'r+'); + fwrite($fp, '12345'); + + $stream = CombineWrapper::getStream(['ABCDE', $fp, 'fghij']); + + fseek($stream, 5, SEEK_CUR); + $written = fwrite($stream, '0000000000'); + + $this->assertSame(10, $written); + + fclose($stream); + } + + public function testStatReturnsCombinedSize(): void + { + $stream = CombineWrapper::getStream(['ABC', 'DEFGH', 'IJ']); + + $stat = fstat($stream); + + $this->assertSame(10, $stat['size']); + + fclose($stream); + } + + public function testStatWithMixedSources(): void + { + $fp = fopen('php://temp', 'r+'); + fwrite($fp, '12345'); + + $stream = CombineWrapper::getStream(['ABCDE', $fp, 'fghij']); + + $stat = fstat($stream); + + $this->assertSame(15, $stat['size']); + + fclose($stream); + } + + public function testReadEmptyStringComponentSkipped(): void + { + // Empty string components produce 0-length temp streams. + // CombineWrapper handles them during sequential reads. + $stream = CombineWrapper::getStream(['ABC', 'DEF']); + + $this->assertSame('ABCDEF', fread($stream, 1024)); + + fclose($stream); + } + + public function testSeekToSecondStreamUpdatesPosition(): void + { + $stream = CombineWrapper::getStream(['ABCDE', '12345']); + + fseek($stream, 7); + $this->assertSame(7, ftell($stream)); + + fclose($stream); + } + + public function testBinaryDataCombined(): void + { + $binary1 = "\x00\x01\x02"; + $binary2 = "\xFF\xFE\xFD"; + + $stream = CombineWrapper::getStream([$binary1, $binary2]); + + $this->assertSame("\x00\x01\x02\xFF\xFE\xFD", fread($stream, 1024)); + + fclose($stream); + } + + public function testLargeNumberOfStreams(): void + { + $parts = []; + for ($i = 0; $i < 100; $i++) { + $parts[] = chr(65 + ($i % 26)); + } + + $stream = CombineWrapper::getStream($parts); + + $result = fread($stream, 1024); + + $this->assertSame(100, strlen($result)); + $this->assertSame('A', $result[0]); + + fclose($stream); + } + + public function testSeekReturnsBehavior(): void + { + $stream = CombineWrapper::getStream(['ABCDE', '12345']); + + // First seek from position 0 to a new position returns 0 (success) + $this->assertSame(0, fseek($stream, 5, SEEK_CUR)); + + fclose($stream); + } + + public function testReadInSingleByteChunks(): void + { + $stream = CombineWrapper::getStream(['AB', 'CD']); + + $result = ''; + for ($i = 0; $i < 4; $i++) { + $result .= fread($stream, 1); + } + + $this->assertSame('ABCD', $result); + + fclose($stream); + } + + public function testEofNotSetAfterExactRead(): void + { + $stream = CombineWrapper::getStream(['ABC', 'DEF']); + + // Read exactly the total length + fread($stream, 6); + + // EOF flag is set only when we try to read MORE than available + // This depends on implementation - CombineWrapper sets ateof when + // the count still has remaining bytes after reaching total length + // An exact read should set EOF since the read loop detects position == length + // with count still being positive after consuming all data + $this->assertTrue(feof($stream)); + + fclose($stream); + } + + public function testMultipleRewinds(): void + { + $stream = CombineWrapper::getStream(['Hello', ' ', 'World']); + + $first = fread($stream, 1024); + $this->assertSame('Hello World', $first); + + fclose($stream); + } + + public function testWriteExtendingStreamAfterRead(): void + { + $stream = CombineWrapper::getStream(['ABC']); + + // Read to advance internal tracking, then write to extend + fread($stream, 3); + $written = fwrite($stream, 'DEF'); + + $this->assertSame(3, $written); + + $stat = fstat($stream); + $this->assertSame(6, $stat['size']); + + fclose($stream); + } + + public function testDeprecatedCombineStreamInterface(): void + { + // Ensure wrapper is registered + $tmp = CombineWrapper::getStream(['x']); + fclose($tmp); + + $ob = new class implements CombineStreamWrapper { + public function getData(): array + { + return ['deprecated', '-combine']; + } + }; + + $ctx = stream_context_create([ + 'horde-combine' => [ + 'data' => $ob, + ], + ]); + + $stream = fopen(CombineWrapper::WRAPPER_NAME . '://deprecated', 'rb', false, $ctx); + + $this->assertIsResource($stream); + $this->assertSame('deprecated-combine', fread($stream, 1024)); + + fclose($stream); + } + + public function testStreamOpenThrowsExceptionWithoutContext(): void + { + // Ensure wrapper is registered + $tmp = CombineWrapper::getStream(['x']); + fclose($tmp); + + $ctx = stream_context_create([ + 'unrelated-key' => ['foo' => 'bar'], + ]); + + $this->expectException(Exception::class); + + fopen(CombineWrapper::WRAPPER_NAME . '://no-context', 'rb', false, $ctx); + } + + public function testReadAfterEofReturnsEmptyString(): void + { + $stream = CombineWrapper::getStream(['test']); + + fread($stream, 1024); + $this->assertTrue(feof($stream)); + + // Second read after EOF should return empty string + $this->assertSame('', fread($stream, 1024)); + + fclose($stream); + } + + public function testSeekToSamePositionFails(): void + { + $stream = CombineWrapper::getStream(['ABCDE']); + + // Stream starts at position 0, seeking to 0 means no change + // CombineWrapper::stream_seek returns false when oldpos == newpos + // fseek translates false to -1 + $this->assertSame(-1, fseek($stream, 0, SEEK_SET)); + + fclose($stream); + } + + public function testWriteWithinBoundsDoesNotExtendSize(): void + { + $stream = CombineWrapper::getStream(['ABCDEFGHIJ']); + + fread($stream, 3); + fwrite($stream, 'XY'); + + $stat = fstat($stream); + $this->assertSame(10, $stat['size']); + + fclose($stream); + } + + public function testStatReturnsCompleteStructure(): void + { + $stream = CombineWrapper::getStream(['ABC', 'DEF']); + + $stat = fstat($stream); + + $this->assertArrayHasKey('dev', $stat); + $this->assertArrayHasKey('ino', $stat); + $this->assertArrayHasKey('mode', $stat); + $this->assertArrayHasKey('nlink', $stat); + $this->assertArrayHasKey('uid', $stat); + $this->assertArrayHasKey('gid', $stat); + $this->assertArrayHasKey('rdev', $stat); + $this->assertArrayHasKey('size', $stat); + $this->assertArrayHasKey('atime', $stat); + $this->assertArrayHasKey('mtime', $stat); + $this->assertArrayHasKey('ctime', $stat); + $this->assertArrayHasKey('blksize', $stat); + $this->assertArrayHasKey('blocks', $stat); + $this->assertSame(6, $stat['size']); + $this->assertSame(0, $stat['dev']); + + fclose($stream); + } + + public function testReadSingleComponent(): void + { + $fp = fopen('php://temp', 'r+'); + fwrite($fp, 'only-stream'); + + $stream = CombineWrapper::getStream([$fp]); + + $this->assertSame('only-stream', fread($stream, 1024)); + + fclose($stream); + } + + public function testStatSizeUpdatesAfterWrite(): void + { + $stream = CombineWrapper::getStream(['ABC']); + + $this->assertSame(3, fstat($stream)['size']); + + fread($stream, 3); + fwrite($stream, 'DEFGH'); + + $this->assertSame(8, fstat($stream)['size']); + + fclose($stream); + } + + public function testReadExactBoundary(): void + { + $stream = CombineWrapper::getStream(['ABC', 'DEF']); + + // Read exactly 3 bytes — ends at first component boundary + $this->assertSame('ABC', fread($stream, 3)); + $this->assertSame(3, ftell($stream)); + + // Next read starts from second component + $this->assertSame('DEF', fread($stream, 3)); + + fclose($stream); + } + + public function testEofClearedBySeek(): void + { + $stream = CombineWrapper::getStream(['ABCDE']); + + fread($stream, 1024); + $this->assertTrue(feof($stream)); + + // Seek should clear EOF + fseek($stream, 3); + $this->assertFalse(feof($stream)); + + fclose($stream); + } + + public function testTellAfterWrite(): void + { + $stream = CombineWrapper::getStream(['ABCDE']); + + fread($stream, 2); + $this->assertSame(2, ftell($stream)); + + fwrite($stream, 'XY'); + // PHP internally advances position by the return value of stream_write + $this->assertSame(4, ftell($stream)); + + fclose($stream); + } +} diff --git a/test/LegacyCombineTest.php b/test/LegacyCombineTest.php new file mode 100644 index 00000000..c16f6f3b --- /dev/null +++ b/test/LegacyCombineTest.php @@ -0,0 +1,270 @@ +assertIsResource($stream); + + fclose($stream); + } + + public function testReadCombinedStrings(): void + { + $stream = Horde_Stream_Wrapper_Combine::getStream(['ABC', 'DEF', 'GHI']); + + $this->assertSame('ABCDEFGHI', fread($stream, 1024)); + + fclose($stream); + } + + public function testReadMixedStringAndStream(): void + { + $fp = fopen('php://temp', 'r+'); + fwrite($fp, '12345'); + + $stream = Horde_Stream_Wrapper_Combine::getStream(['ABCDE', $fp, 'fghij']); + + $this->assertSame('ABCDE12345fghij', fread($stream, 1024)); + + fclose($stream); + } + + public function testEofAfterFullRead(): void + { + $stream = Horde_Stream_Wrapper_Combine::getStream(['test']); + + fread($stream, 1024); + + $this->assertTrue(feof($stream)); + + fclose($stream); + } + + public function testSeekSetToStart(): void + { + $stream = Horde_Stream_Wrapper_Combine::getStream(['ABCDE', '12345']); + + fread($stream, 1024); + fseek($stream, 0); + + $this->assertSame(0, ftell($stream)); + + fclose($stream); + } + + public function testSeekCurForward(): void + { + $stream = Horde_Stream_Wrapper_Combine::getStream(['ABCDE', '12345']); + + $this->assertSame(0, fseek($stream, 5, SEEK_CUR)); + $this->assertSame(5, ftell($stream)); + + fclose($stream); + } + + public function testSeekEnd(): void + { + $stream = Horde_Stream_Wrapper_Combine::getStream(['ABCDE', '12345']); + + $this->assertSame(0, fseek($stream, 0, SEEK_END)); + $this->assertSame(10, ftell($stream)); + + fclose($stream); + } + + public function testWrite(): void + { + $fp = fopen('php://temp', 'r+'); + fwrite($fp, '12345'); + + $stream = Horde_Stream_Wrapper_Combine::getStream(['ABCDE', $fp, 'fghij']); + + fseek($stream, 5, SEEK_CUR); + $written = fwrite($stream, '0000000000'); + + $this->assertSame(10, $written); + + fclose($stream); + } + + public function testStatReturnsCombinedSize(): void + { + $fp = fopen('php://temp', 'r+'); + fwrite($fp, '12345'); + + $stream = Horde_Stream_Wrapper_Combine::getStream(['ABCDE', $fp, 'fghij']); + + $stat = fstat($stream); + + $this->assertSame(15, $stat['size']); + + fclose($stream); + } + + public function testReadAcrossBoundaries(): void + { + $stream = Horde_Stream_Wrapper_Combine::getStream(['AAA', 'BBB', 'CCC']); + + $this->assertSame('AAAB', fread($stream, 4)); + $this->assertSame('BBCC', fread($stream, 4)); + + fclose($stream); + } + + public function testBinaryData(): void + { + $stream = Horde_Stream_Wrapper_Combine::getStream(["\x00\x01\x02", "\xFF\xFE\xFD"]); + + $this->assertSame("\x00\x01\x02\xFF\xFE\xFD", fread($stream, 1024)); + + fclose($stream); + } + + public function testSeekUpdatesPosition(): void + { + $stream = Horde_Stream_Wrapper_Combine::getStream(['ABCDE', '12345']); + + fseek($stream, 3); + $this->assertSame(3, ftell($stream)); + + fclose($stream); + } + + public function testSingleByteChunkReads(): void + { + $stream = Horde_Stream_Wrapper_Combine::getStream(['AB', 'CD']); + + $result = ''; + for ($i = 0; $i < 4; $i++) { + $result .= fread($stream, 1); + } + + $this->assertSame('ABCD', $result); + + fclose($stream); + } + + public function testDeprecatedCombineStreamInterface(): void + { + $tmp = Horde_Stream_Wrapper_Combine::getStream(['x']); + fclose($tmp); + + $ob = new class implements Horde_Stream_Wrapper_CombineStream { + public function getData() + { + return ['legacy', '-deprecated']; + } + }; + + $ctx = stream_context_create([ + 'horde-combine' => [ + 'data' => $ob, + ], + ]); + + $stream = fopen(Horde_Stream_Wrapper_Combine::WRAPPER_NAME . '://deprecated', 'rb', false, $ctx); + + $this->assertIsResource($stream); + $this->assertSame('legacy-deprecated', fread($stream, 1024)); + + fclose($stream); + } + + public function testStreamOpenThrowsExceptionWithoutContext(): void + { + $tmp = Horde_Stream_Wrapper_Combine::getStream(['x']); + fclose($tmp); + + $ctx = stream_context_create([ + 'unrelated' => ['foo' => 'bar'], + ]); + + $this->expectException(Exception::class); + + fopen(Horde_Stream_Wrapper_Combine::WRAPPER_NAME . '://no-ctx', 'rb', false, $ctx); + } + + public function testSeekToSamePositionFails(): void + { + $stream = Horde_Stream_Wrapper_Combine::getStream(['ABCDE']); + + $this->assertSame(-1, fseek($stream, 0, SEEK_SET)); + + fclose($stream); + } + + public function testStatReturnsCompleteStructure(): void + { + $stream = Horde_Stream_Wrapper_Combine::getStream(['ABC', 'DEF']); + + $stat = fstat($stream); + + $this->assertArrayHasKey('size', $stat); + $this->assertArrayHasKey('dev', $stat); + $this->assertArrayHasKey('blocks', $stat); + $this->assertSame(6, $stat['size']); + + fclose($stream); + } + + public function testReadAfterEofReturnsEmpty(): void + { + $stream = Horde_Stream_Wrapper_Combine::getStream(['test']); + + fread($stream, 1024); + $this->assertTrue(feof($stream)); + + $result = fread($stream, 1024); + $this->assertEmpty($result); + + fclose($stream); + } + + public function testWriteWithinBoundsDoesNotExtendSize(): void + { + $stream = Horde_Stream_Wrapper_Combine::getStream(['ABCDEFGHIJ']); + + fread($stream, 3); + fwrite($stream, 'XY'); + + $this->assertSame(10, fstat($stream)['size']); + + fclose($stream); + } + + public function testEofClearedBySeek(): void + { + $stream = Horde_Stream_Wrapper_Combine::getStream(['ABCDE']); + + fread($stream, 1024); + $this->assertTrue(feof($stream)); + + fseek($stream, 3); + $this->assertFalse(feof($stream)); + + fclose($stream); + } +} diff --git a/test/LegacyStringTest.php b/test/LegacyStringTest.php new file mode 100644 index 00000000..5cb10d12 --- /dev/null +++ b/test/LegacyStringTest.php @@ -0,0 +1,275 @@ +assertIsResource($stream); + + fclose($stream); + } + + public function testReadFullContent(): void + { + $string = 'ABCDE12345fghij'; + $stream = Horde_Stream_Wrapper_String::getStream($string); + + $this->assertSame('ABCDE12345fghij', fread($stream, 1024)); + + fclose($stream); + } + + public function testEofAfterFullRead(): void + { + $string = 'test'; + $stream = Horde_Stream_Wrapper_String::getStream($string); + + fread($stream, 1024); + + $this->assertTrue(feof($stream)); + + fclose($stream); + } + + public function testSeekSet(): void + { + $string = 'ABCDEFGHIJ'; + $stream = Horde_Stream_Wrapper_String::getStream($string); + + $this->assertSame(0, fseek($stream, 5, SEEK_SET)); + $this->assertSame(5, ftell($stream)); + $this->assertSame('FGHIJ', fread($stream, 5)); + + fclose($stream); + } + + public function testSeekCur(): void + { + $string = 'ABCDEFGHIJ'; + $stream = Horde_Stream_Wrapper_String::getStream($string); + + fread($stream, 3); + $this->assertSame(0, fseek($stream, 2, SEEK_CUR)); + $this->assertSame(5, ftell($stream)); + + fclose($stream); + } + + public function testSeekEnd(): void + { + $string = 'ABCDEFGHIJ'; + $stream = Horde_Stream_Wrapper_String::getStream($string); + + $this->assertSame(0, fseek($stream, 0, SEEK_END)); + $this->assertSame(10, ftell($stream)); + + fclose($stream); + } + + public function testSeekBeyondEndFails(): void + { + $string = 'ABCDE'; + $stream = Horde_Stream_Wrapper_String::getStream($string); + + $this->assertSame(-1, fseek($stream, 100, SEEK_SET)); + + fclose($stream); + } + + public function testWrite(): void + { + $string = 'AAAAAAAAAA'; + $stream = Horde_Stream_Wrapper_String::getStream($string); + + fseek($stream, 3); + $written = fwrite($stream, 'BBB'); + + $this->assertSame(3, $written); + fseek($stream, 0); + $this->assertSame('AAABBBAAA', substr(fread($stream, 1024), 0, 9)); + + fclose($stream); + } + + public function testStatReturnsSize(): void + { + $string = 'ABCDEFGHIJ'; + $stream = Horde_Stream_Wrapper_String::getStream($string); + + $stat = fstat($stream); + + $this->assertSame(10, $stat['size']); + + fclose($stream); + } + + public function testReferenceSemantics(): void + { + $string = 'original'; + $stream = Horde_Stream_Wrapper_String::getStream($string); + + fwrite($stream, 'REPLACED'); + + $this->assertSame('REPLACED', $string); + + fclose($stream); + } + + public function testMemoryUsageIsBounded(): void + { + $bytes = 1024 * 1024; + $string = str_repeat('*', $bytes); + $memoryUsage = memory_get_usage(); + + $stream = Horde_Stream_Wrapper_String::getStream($string); + + $this->assertLessThan($memoryUsage + $bytes, memory_get_usage()); + + while (!feof($stream)) { + fread($stream, 1024); + } + + $this->assertLessThan($memoryUsage + $bytes, memory_get_usage()); + + fclose($stream); + } + + public function testNotEofAtEnd(): void + { + $string = 'test'; + $stream = Horde_Stream_Wrapper_String::getStream($string); + + fseek($stream, 0, SEEK_END); + + $this->assertFalse(feof($stream)); + + fclose($stream); + } + + public function testBinaryData(): void + { + $string = "\x00\x01\x02\xFF\xFE\xFD"; + $stream = Horde_Stream_Wrapper_String::getStream($string); + + $this->assertSame($string, fread($stream, 1024)); + + fclose($stream); + } + + public function testDeprecatedStringStreamInterface(): void + { + // Ensure wrapper is registered + $init = 'x'; + $tmp = Horde_Stream_Wrapper_String::getStream($init); + fclose($tmp); + + $ob = new class implements Horde_Stream_Wrapper_StringStream { + public string $str = 'legacy-deprecated'; + + public function &getString() + { + return $this->str; + } + }; + + $ctx = stream_context_create([ + 'horde-string' => [ + 'string' => $ob, + ], + ]); + + $stream = fopen(Horde_Stream_Wrapper_String::WRAPPER_NAME . '://deprecated', 'rb', false, $ctx); + + $this->assertIsResource($stream); + $this->assertSame('legacy-deprecated', fread($stream, 1024)); + + fclose($stream); + } + + public function testStreamOpenThrowsExceptionWithoutContext(): void + { + $init = 'x'; + $tmp = Horde_Stream_Wrapper_String::getStream($init); + fclose($tmp); + + $ctx = stream_context_create([ + 'unrelated' => ['foo' => 'bar'], + ]); + + $this->expectException(Exception::class); + + fopen(Horde_Stream_Wrapper_String::WRAPPER_NAME . '://no-ctx', 'rb', false, $ctx); + } + + public function testStatReturnsCompleteStructure(): void + { + $string = 'test'; + $stream = Horde_Stream_Wrapper_String::getStream($string); + + $stat = fstat($stream); + + $this->assertArrayHasKey('size', $stat); + $this->assertArrayHasKey('dev', $stat); + $this->assertArrayHasKey('blocks', $stat); + $this->assertSame(4, $stat['size']); + + fclose($stream); + } + + public function testCloseResetsState(): void + { + $string = 'data'; + $stream = Horde_Stream_Wrapper_String::getStream($string); + + fread($stream, 2); + fclose($stream); + + $this->assertSame('', $string); + } + + public function testSeekBeforeStartFails(): void + { + $string = 'ABCDE'; + $stream = Horde_Stream_Wrapper_String::getStream($string); + + $this->assertSame(-1, fseek($stream, -1, SEEK_SET)); + + fclose($stream); + } + + public function testWriteExtendingString(): void + { + $string = 'ABC'; + $stream = Horde_Stream_Wrapper_String::getStream($string); + + fseek($stream, 0, SEEK_END); + fwrite($stream, 'DEF'); + + $this->assertSame('ABCDEF', $string); + + fclose($stream); + } +} diff --git a/test/StringTest.php b/test/StringTest.php deleted file mode 100644 index 540d1540..00000000 --- a/test/StringTest.php +++ /dev/null @@ -1,70 +0,0 @@ - - * @category Horde - * @copyright 2008-2016 Horde LLC - * @ignore - * @license http://www.horde.org/licenses/bsd BSD - * @package Stream_Wrapper - * @subpackage UnitTests - */ -class StringTest extends TestCase -{ - public function testUsage() - { - $string = 'ABCDE12345fghij'; - - $stream = StringWrapper::getStream($string); - - $this->assertEquals('ABCDE12345fghij', fread($stream, 1024)); - $this->assertEquals(true, feof($stream)); - $this->assertEquals(0, fseek($stream, 0)); - $this->assertEquals(0, fseek($stream, 0)); - $this->assertEquals(0, ftell($stream)); - $this->assertEquals(0, fseek($stream, 5, SEEK_CUR)); - $this->assertEquals(5, ftell($stream)); - $this->assertEquals(10, fwrite($stream, '0000000000')); - $this->assertEquals(0, fseek($stream, 0, SEEK_END)); - $this->assertEquals(15, ftell($stream)); - $this->assertEquals(false, feof($stream)); - - fclose($stream); - } - - public function testMemoryUsage() - { - $bytes = 1024 * 1024; - $string = str_repeat('*', $bytes); - $memoryUsage = memory_get_usage(); - - $stream = StringWrapper::getStream($string); - $memoryUsage2 = memory_get_usage(); - $this->assertLessThan($memoryUsage + $bytes, $memoryUsage2); - - while (!feof($stream)) { - fread($stream, 1024); - } - $memoryUsage3 = memory_get_usage(); - $this->assertLessThan($memoryUsage + $bytes, $memoryUsage3); - } -} diff --git a/test/StringWrapperTest.php b/test/StringWrapperTest.php new file mode 100644 index 00000000..2f79bbf5 --- /dev/null +++ b/test/StringWrapperTest.php @@ -0,0 +1,558 @@ +assertIsResource($stream); + + fclose($stream); + } + + public function testReadFullContent(): void + { + $string = 'Hello, World!'; + $stream = StringWrapper::getStream($string); + + $result = fread($stream, 1024); + + $this->assertSame('Hello, World!', $result); + + fclose($stream); + } + + public function testReadInChunks(): void + { + $string = 'ABCDEFGHIJ'; + $stream = StringWrapper::getStream($string); + + $this->assertSame('ABC', fread($stream, 3)); + $this->assertSame('DEF', fread($stream, 3)); + $this->assertSame('GHIJ', fread($stream, 4)); + + fclose($stream); + } + + public function testReadEmptyString(): void + { + $string = ''; + $stream = StringWrapper::getStream($string); + + $result = fread($stream, 1024); + + $this->assertSame('', $result); + + fclose($stream); + } + + public function testEofAfterFullRead(): void + { + $string = 'test'; + $stream = StringWrapper::getStream($string); + + fread($stream, 1024); + + $this->assertTrue(feof($stream)); + + fclose($stream); + } + + public function testNotEofAtStart(): void + { + $string = 'test'; + $stream = StringWrapper::getStream($string); + + $this->assertFalse(feof($stream)); + + fclose($stream); + } + + public function testNotEofAtEnd(): void + { + $string = 'test'; + $stream = StringWrapper::getStream($string); + + fseek($stream, 0, SEEK_END); + + $this->assertFalse(feof($stream)); + + fclose($stream); + } + + public function testTellAtStart(): void + { + $string = 'test'; + $stream = StringWrapper::getStream($string); + + $this->assertSame(0, ftell($stream)); + + fclose($stream); + } + + public function testTellAfterRead(): void + { + $string = 'ABCDEFGHIJ'; + $stream = StringWrapper::getStream($string); + + fread($stream, 5); + + $this->assertSame(5, ftell($stream)); + + fclose($stream); + } + + public function testSeekSet(): void + { + $string = 'ABCDEFGHIJ'; + $stream = StringWrapper::getStream($string); + + $this->assertSame(0, fseek($stream, 5, SEEK_SET)); + $this->assertSame(5, ftell($stream)); + $this->assertSame('FGHIJ', fread($stream, 5)); + + fclose($stream); + } + + public function testSeekCur(): void + { + $string = 'ABCDEFGHIJ'; + $stream = StringWrapper::getStream($string); + + fread($stream, 3); + $this->assertSame(0, fseek($stream, 2, SEEK_CUR)); + $this->assertSame(5, ftell($stream)); + $this->assertSame('FGHIJ', fread($stream, 5)); + + fclose($stream); + } + + public function testSeekEnd(): void + { + $string = 'ABCDEFGHIJ'; + $stream = StringWrapper::getStream($string); + + $this->assertSame(0, fseek($stream, -5, SEEK_END)); + $this->assertSame(5, ftell($stream)); + $this->assertSame('FGHIJ', fread($stream, 5)); + + fclose($stream); + } + + public function testSeekToStart(): void + { + $string = 'ABCDEFGHIJ'; + $stream = StringWrapper::getStream($string); + + fread($stream, 1024); + $this->assertSame(0, fseek($stream, 0)); + $this->assertSame(0, ftell($stream)); + $this->assertSame('ABCDEFGHIJ', fread($stream, 1024)); + + fclose($stream); + } + + public function testSeekToEnd(): void + { + $string = 'ABCDEFGHIJ'; + $stream = StringWrapper::getStream($string); + + $this->assertSame(0, fseek($stream, 0, SEEK_END)); + $this->assertSame(10, ftell($stream)); + + fclose($stream); + } + + public function testSeekBeyondEndFails(): void + { + $string = 'ABCDE'; + $stream = StringWrapper::getStream($string); + + $this->assertSame(-1, fseek($stream, 100, SEEK_SET)); + + fclose($stream); + } + + public function testSeekBeforeStartFails(): void + { + $string = 'ABCDE'; + $stream = StringWrapper::getStream($string); + + $this->assertSame(-1, fseek($stream, -1, SEEK_SET)); + + fclose($stream); + } + + public function testWriteAtPosition(): void + { + $string = 'AAAAAAAAAA'; + $stream = StringWrapper::getStream($string); + + fseek($stream, 3); + $written = fwrite($stream, 'BBB'); + + $this->assertSame(3, $written); + $this->assertSame('AAABBBAAA', substr($string, 0, 9)); + + fclose($stream); + } + + public function testWriteAtStart(): void + { + $string = 'Hello'; + $stream = StringWrapper::getStream($string); + + fwrite($stream, 'XX'); + fseek($stream, 0); + + $this->assertSame('XXllo', fread($stream, 1024)); + + fclose($stream); + } + + public function testWriteExtendingString(): void + { + $string = 'ABC'; + $stream = StringWrapper::getStream($string); + + fseek($stream, 0, SEEK_END); + fwrite($stream, 'DEF'); + + $this->assertSame('ABCDEF', $string); + + fclose($stream); + } + + public function testWriteReturnsLength(): void + { + $string = 'test'; + $stream = StringWrapper::getStream($string); + + $this->assertSame(7, fwrite($stream, '1234567')); + + fclose($stream); + } + + public function testStatReturnsSize(): void + { + $string = 'ABCDEFGHIJ'; + $stream = StringWrapper::getStream($string); + + $stat = fstat($stream); + + $this->assertSame(10, $stat['size']); + + fclose($stream); + } + + public function testStatEmptyString(): void + { + $string = ''; + $stream = StringWrapper::getStream($string); + + $stat = fstat($stream); + + $this->assertSame(0, $stat['size']); + + fclose($stream); + } + + public function testReferenceSemantics(): void + { + $string = 'original'; + $stream = StringWrapper::getStream($string); + + fwrite($stream, 'REPLACED'); + + $this->assertSame('REPLACED', $string); + + fclose($stream); + } + + public function testMemoryUsageIsBounded(): void + { + $bytes = 1024 * 1024; + $string = str_repeat('*', $bytes); + $memoryBefore = memory_get_usage(); + + $stream = StringWrapper::getStream($string); + $memoryAfterOpen = memory_get_usage(); + + $this->assertLessThan($memoryBefore + $bytes, $memoryAfterOpen); + + while (!feof($stream)) { + fread($stream, 1024); + } + $memoryAfterRead = memory_get_usage(); + + $this->assertLessThan($memoryBefore + $bytes, $memoryAfterRead); + + fclose($stream); + } + + public function testMultipleStreamsFromDifferentStrings(): void + { + $string1 = 'FIRST'; + $string2 = 'SECOND'; + + $stream1 = StringWrapper::getStream($string1); + $stream2 = StringWrapper::getStream($string2); + + $this->assertSame('FIRST', fread($stream1, 1024)); + $this->assertSame('SECOND', fread($stream2, 1024)); + + fclose($stream1); + fclose($stream2); + } + + public function testReadAfterSeekToMiddle(): void + { + $string = '0123456789'; + $stream = StringWrapper::getStream($string); + + fseek($stream, 5); + $this->assertSame('56789', fread($stream, 1024)); + + fclose($stream); + } + + public function testSequentialSeekAndRead(): void + { + $string = 'ABCDEFGHIJKLMNOP'; + $stream = StringWrapper::getStream($string); + + fseek($stream, 4); + $this->assertSame('EFGH', fread($stream, 4)); + + fseek($stream, 12); + $this->assertSame('MNOP', fread($stream, 4)); + + fseek($stream, 0); + $this->assertSame('ABCD', fread($stream, 4)); + + fclose($stream); + } + + public function testBinaryData(): void + { + $string = "\x00\x01\x02\xFF\xFE\xFD"; + $stream = StringWrapper::getStream($string); + + $result = fread($stream, 1024); + + $this->assertSame($string, $result); + + fclose($stream); + } + + public function testSeekCurNegative(): void + { + $string = 'ABCDEFGHIJ'; + $stream = StringWrapper::getStream($string); + + fseek($stream, 8); + $this->assertSame(0, fseek($stream, -3, SEEK_CUR)); + $this->assertSame(5, ftell($stream)); + + fclose($stream); + } + + public function testSeekEndNegativeOffset(): void + { + $string = 'ABCDEFGHIJ'; + $stream = StringWrapper::getStream($string); + + $this->assertSame(0, fseek($stream, -3, SEEK_END)); + $this->assertSame(7, ftell($stream)); + $this->assertSame('HIJ', fread($stream, 3)); + + fclose($stream); + } + + public function testDeprecatedStringStreamInterface(): void + { + // Ensure wrapper is registered + $init = 'x'; + $tmp = StringWrapper::getStream($init); + fclose($tmp); + + $ob = new class implements StringStreamWrapper { + public string $str = 'deprecated-path-data'; + + public function &getString(): string + { + return $this->str; + } + }; + + $ctx = stream_context_create([ + 'horde-string' => [ + 'string' => $ob, + ], + ]); + + $stream = fopen(StringWrapper::WRAPPER_NAME . '://deprecated', 'rb', false, $ctx); + + $this->assertIsResource($stream); + $this->assertSame('deprecated-path-data', fread($stream, 1024)); + + fclose($stream); + } + + public function testStreamOpenThrowsExceptionWithoutContext(): void + { + // Ensure wrapper is registered + $init = 'x'; + $tmp = StringWrapper::getStream($init); + fclose($tmp); + + $ctx = stream_context_create([ + 'unrelated-key' => ['foo' => 'bar'], + ]); + + $this->expectException(Exception::class); + + fopen(StringWrapper::WRAPPER_NAME . '://no-context', 'rb', false, $ctx); + } + + public function testStatReturnsCompleteStructure(): void + { + $string = 'ABCDE'; + $stream = StringWrapper::getStream($string); + + $stat = fstat($stream); + + $this->assertArrayHasKey('dev', $stat); + $this->assertArrayHasKey('ino', $stat); + $this->assertArrayHasKey('mode', $stat); + $this->assertArrayHasKey('nlink', $stat); + $this->assertArrayHasKey('uid', $stat); + $this->assertArrayHasKey('gid', $stat); + $this->assertArrayHasKey('rdev', $stat); + $this->assertArrayHasKey('size', $stat); + $this->assertArrayHasKey('atime', $stat); + $this->assertArrayHasKey('mtime', $stat); + $this->assertArrayHasKey('ctime', $stat); + $this->assertArrayHasKey('blksize', $stat); + $this->assertArrayHasKey('blocks', $stat); + $this->assertSame(5, $stat['size']); + $this->assertSame(0, $stat['dev']); + + fclose($stream); + } + + public function testStatSizeUpdatesAfterWrite(): void + { + $string = 'ABC'; + $stream = StringWrapper::getStream($string); + + $this->assertSame(3, fstat($stream)['size']); + + fseek($stream, 0, SEEK_END); + fwrite($stream, 'DEF'); + + $this->assertSame(6, fstat($stream)['size']); + + fclose($stream); + } + + public function testCloseResetsState(): void + { + $string = 'test data'; + $stream = StringWrapper::getStream($string); + + fread($stream, 4); + $this->assertSame(4, ftell($stream)); + + fclose($stream); + + // After close, the original variable is cleared via stream_close + // (stream_close sets string to '' and pos to 0) + // Verify that string was cleared by the reference semantics + $this->assertSame('', $string); + } + + public function testReadBeyondLength(): void + { + $string = 'short'; + $stream = StringWrapper::getStream($string); + + // Read much more than available + $result = fread($stream, 100000); + + $this->assertSame('short', $result); + $this->assertTrue(feof($stream)); + + fclose($stream); + } + + public function testWriteOverwriteMiddle(): void + { + $string = '0123456789'; + $stream = StringWrapper::getStream($string); + + fseek($stream, 3); + fwrite($stream, 'XX'); + + $this->assertSame('012XX56789', $string); + + fclose($stream); + } + + public function testSeekCurFromEndPosition(): void + { + $string = 'ABCDE'; + $stream = StringWrapper::getStream($string); + + fseek($stream, 0, SEEK_END); + // Seek back 2 from end position + $this->assertSame(0, fseek($stream, -2, SEEK_CUR)); + $this->assertSame(3, ftell($stream)); + $this->assertSame('DE', fread($stream, 2)); + + fclose($stream); + } + + public function testSeekEndPositiveOffset(): void + { + $string = 'ABCDE'; + $stream = StringWrapper::getStream($string); + + // SEEK_END with positive offset goes beyond string length + $this->assertSame(-1, fseek($stream, 1, SEEK_END)); + + fclose($stream); + } + + public function testSingleCharString(): void + { + $string = 'X'; + $stream = StringWrapper::getStream($string); + + $this->assertSame('X', fread($stream, 1)); + $this->assertSame(1, ftell($stream)); + $this->assertTrue(feof($stream)); + + fclose($stream); + } +} diff --git a/test/bootstrap.php b/test/bootstrap.php deleted file mode 100644 index bde1685d..00000000 --- a/test/bootstrap.php +++ /dev/null @@ -1,12 +0,0 @@ -