1
0
Fork 0
mirror of https://github.com/shufflingpixels/php-io.git synced 2026-08-18 13:18:13 +02:00

Adopt Go-style minimal interfaces for streams

Replace PSR-7 StreamInterface with a set of small, composable interfaces
(ReaderInterface, WriterInterface, SeekerInterface and their
combinations).
BinaryReader now depends only on ReaderInterface, BinaryWriter on
WriterInterface, making both easy to satisfy with any byte source or
sink.

Buffer and Resource are simplified — no capability flags, no
detach/close
lifecycle, no PSR-7 metadata methods. LimitedResource is replaced by
LimitedReader, a lightweight reader that enforces a byte count limit.
BinaryReader::read() is renamed to readExact() for clarity.

Tests updated throughout to match the new APIs.
This commit is contained in:
Henrik Hautakoski 2026-06-27 08:40:11 +02:00
parent a27c0a87ea
commit 07ec405512
25 changed files with 451 additions and 1168 deletions

View file

@ -2,51 +2,32 @@
use Shufflingpixels\IO\BinaryReader;
use Shufflingpixels\IO\Buffer;
it('proxies length tell eof and seek', function () {
$reader = new BinaryReader(new Buffer('abcd'));
expect($reader->length())->toBe(4)
->and($reader->tell())->toBe(0)
->and($reader->eof())->toBeFalse();
$reader->seek(2);
expect($reader->tell())->toBe(2)
->and($reader->read(1))->toBe('c');
$reader->seek(-1, SEEK_END);
expect($reader->read(1))->toBe('d')
->and($reader->eof())->toBeTrue();
});
use Shufflingpixels\IO\ReaderInterface;
it('throws for negative read length', function () {
$reader = new BinaryReader(new Buffer('abc'));
expect(fn () => $reader->read(-1))->toThrow(InvalidArgumentException::class);
expect(fn () => $reader->readExact(-1))->toThrow(InvalidArgumentException::class);
});
it('throws when stream returns fewer bytes than requested', function () {
$stream = new class implements \Psr\Http\Message\StreamInterface {
public function __toString(): string { return ''; }
public function close(): void {}
public function detach(): mixed { return null; }
public function getSize(): ?int { return 0; }
public function eof(): bool { return false; }
public function isSeekable(): bool { return false; }
public function seek(int $position, int $whence = SEEK_SET): void {}
public function rewind(): void {}
public function tell(): int { return 0; }
public function isWritable(): bool { return false; }
public function write(string $string): int { return 0; }
public function isReadable(): bool { return true; }
public function read(int $length): string { return 'x'; }
public function getContents(): string { return ''; }
public function getMetadata(?string $key = null): mixed { return null; }
$stream = new class implements ReaderInterface {
public function read(int $length): string|false { return 'x'; }
};
$reader = new BinaryReader(new Buffer($stream));
$reader = new BinaryReader($stream);
expect(fn () => $reader->read(2))->toThrow(RuntimeException::class, 'Not enough bytes');
expect(fn () => $reader->readExact(2))->toThrow(RuntimeException::class, 'Not enough bytes');
});
it('throws when stream returns false', function () {
$stream = new class implements ReaderInterface {
public function read(int $length): string|false { return false; }
};
$reader = new BinaryReader($stream);
expect(fn () => $reader->readExact(1))->toThrow(RuntimeException::class, 'Not enough bytes');
});
it('reads 8 bit integers', function () {
@ -98,7 +79,7 @@ it('advances the cursor by the full field length after readPaddedString', functi
$reader->readPaddedString(4);
expect($reader->read(3))->toBe('end');
expect($reader->readExact(3))->toBe('end');
});
it('throws when not enough bytes remain for readPaddedString', function () {

View file

@ -1,5 +1,6 @@
<?php
use Shufflingpixels\IO\BinaryReader;
use Shufflingpixels\IO\BinaryWriter;
use Shufflingpixels\IO\Buffer;
@ -9,25 +10,14 @@ function writer(): array
return [new BinaryWriter($buffer), $buffer];
}
it('proxies tell and seek', function () {
[$writer, $buffer] = writer();
expect($writer->tell())->toBe(0);
$writer->write('abc');
expect($writer->tell())->toBe(3);
$writer->seek(1);
expect($writer->tell())->toBe(1);
});
it('writes raw bytes', function () {
[$writer, $buffer] = writer();
$writer->write('hello');
expect((string) $buffer)->toBe('hello');
});
function bufferContents(Buffer $buffer): string
{
$pos = $buffer->tell();
$buffer->seek(0);
$result = $buffer->read($buffer->length());
$buffer->seek($pos);
return $result;
}
it('writes 8 bit integers', function () {
[$writer, $buffer] = writer();
@ -37,7 +27,7 @@ it('writes 8 bit integers', function () {
$writer->writeInt8(-1);
$writer->writeInt8(-128);
expect((string) $buffer)->toBe("\x7f\xff\xff\x80");
expect(bufferContents($buffer))->toBe("\x7f\xff\xff\x80");
});
it('writes 16 bit integers in little and big endian', function () {
@ -48,7 +38,7 @@ it('writes 16 bit integers in little and big endian', function () {
$writer->writeInt16LE(-1);
$writer->writeInt16BE(-32768);
expect((string) $buffer)->toBe(
expect(bufferContents($buffer))->toBe(
pack('v', 0x1234) . pack('n', 0x5678) . pack('v', 0xffff) . pack('n', 0x8000)
);
});
@ -61,7 +51,7 @@ it('writes 32 bit integers in little and big endian', function () {
$writer->writeInt32LE(-1);
$writer->writeInt32BE(-2147483648);
expect((string) $buffer)->toBe(
expect(bufferContents($buffer))->toBe(
pack('V', 0x12345678) . pack('N', 0x10203040) . pack('V', 0xffffffff) . pack('N', 0x80000000)
);
});
@ -71,7 +61,7 @@ it('writes a padded string shorter than the field length', function () {
$writer->writePaddedString('hi', 5);
expect((string) $buffer)->toBe("hi\x00\x00\x00");
expect(bufferContents($buffer))->toBe("hi\x00\x00\x00");
});
it('writes a padded string exactly matching the field length', function () {
@ -79,7 +69,7 @@ it('writes a padded string exactly matching the field length', function () {
$writer->writePaddedString('hello', 5);
expect((string) $buffer)->toBe('hello');
expect(bufferContents($buffer))->toBe('hello');
});
it('truncates a string longer than the field length', function () {
@ -87,7 +77,7 @@ it('truncates a string longer than the field length', function () {
$writer->writePaddedString('toolong', 4);
expect((string) $buffer)->toBe('tool');
expect(bufferContents($buffer))->toBe('tool');
});
it('uses a custom pad character', function () {
@ -95,7 +85,7 @@ it('uses a custom pad character', function () {
$writer->writePaddedString('hi', 5, ' ');
expect((string) $buffer)->toBe('hi ');
expect(bufferContents($buffer))->toBe('hi ');
});
it('throws for a multi-byte pad character', function () {
@ -112,8 +102,8 @@ it('written values round-trip through BinaryReader', function () {
$writer->writeInt16LE(-300);
$writer->writeUInt32BE(0xdeadbeef);
$buffer->rewind();
$reader = new \Shufflingpixels\IO\BinaryReader($buffer);
$buffer->seek(0);
$reader = new BinaryReader($buffer);
expect($reader->readUInt8())->toBe(42)
->and($reader->readInt16LE())->toBe(-300)

View file

@ -5,14 +5,12 @@ use Shufflingpixels\IO\Buffer;
it('reads, seeks and tracks position', function () {
$buffer = new Buffer('abcdef');
expect($buffer->getSize())->toBe(6)
expect($buffer->length())->toBe(6)
->and($buffer->tell())->toBe(0)
->and($buffer->remaining())->toBe(6)
->and($buffer->eof())->toBeFalse();
expect($buffer->read(2))->toBe('ab')
->and($buffer->tell())->toBe(2)
->and($buffer->remaining())->toBe(4);
->and($buffer->tell())->toBe(2);
$buffer->seek(-1, SEEK_END);
@ -50,6 +48,13 @@ it('returns available bytes when reading beyond end of stream', function () {
->and($buffer->eof())->toBeTrue();
});
it('returns false when reading at end of stream', function () {
$buffer = new Buffer('a');
$buffer->read(1);
expect($buffer->read(1))->toBeFalse();
});
it('writes at current position and updates contents', function () {
$buffer = new Buffer('abcdef');
$buffer->seek(2);
@ -67,14 +72,15 @@ it('writes nothing for empty payload', function () {
expect($buffer->write(''))->toBe(0)
->and($buffer->tell())->toBe(0)
->and($buffer->getSize())->toBe(3);
->and($buffer->length())->toBe(3);
});
it('returns remaining contents and advances cursor', function () {
it('reads remaining bytes from cursor to end', function () {
$buffer = new Buffer('abcdef');
$buffer->seek(2);
expect($buffer->getContents())->toBe('cdef')
->and($buffer->tell())->toBe(6)
->and($buffer->getContents())->toBe('');
$remaining = $buffer->read($buffer->length() - $buffer->tell());
expect($remaining)->toBe('cdef')
->and($buffer->eof())->toBeTrue();
});

View file

@ -7,13 +7,3 @@ it('defines expected fopen mode values', function () {
->and(FileMode::WRITE->value)->toBe('w')
->and(FileMode::RW->value)->toBe('r+');
});
it('reports read and write capabilities for each mode', function () {
expect(FileMode::READ->readable())->toBeTrue()
->and(FileMode::READ->writable())->toBeFalse()
->and(FileMode::WRITE->readable())->toBeFalse()
->and(FileMode::WRITE->writable())->toBeTrue()
->and(FileMode::RW->readable())->toBeTrue()
->and(FileMode::RW->writable())->toBeTrue()
->and(FileMode::READ->seekable())->toBeTrue();
});

View file

@ -4,31 +4,27 @@ use Shufflingpixels\IO\Exception\IOException;
use Shufflingpixels\IO\File;
use Shufflingpixels\IO\FileMode;
it('opens readable files and reads contents', function () {
it('opens a file and reads its contents', function () {
$path = tempnam(sys_get_temp_dir(), 'php-io-');
file_put_contents($path, 'hello');
$file = File::open($path, FileMode::READ);
expect($file->isReadable())->toBeTrue()
->and($file->isWritable())->toBeFalse()
->and($file->getSize())->toBe(5)
expect($file->length())->toBe(5)
->and($file->read(5))->toBe('hello');
$file->close();
unlink($path);
});
it('opens read-write files and persists writes', function () {
it('opens a file for reading and writing', function () {
$path = tempnam(sys_get_temp_dir(), 'php-io-');
file_put_contents($path, 'abc');
$file = File::open($path, FileMode::RW);
$file->seek(0);
expect($file->isReadable())->toBeTrue()
->and($file->isWritable())->toBeTrue()
->and($file->write('X'))->toBe(1);
expect($file->write('X'))->toBe(1);
$file->seek(0);
expect($file->read(3))->toBe('Xbc');
@ -37,15 +33,13 @@ it('opens read-write files and persists writes', function () {
unlink($path);
});
it('opens write mode files and truncates existing contents', function () {
it('opens a file for writing and truncates existing contents', function () {
$path = tempnam(sys_get_temp_dir(), 'php-io-');
file_put_contents($path, 'abcdef');
$file = File::open($path, FileMode::WRITE);
expect($file->isReadable())->toBeFalse()
->and($file->isWritable())->toBeTrue()
->and($file->getSize())->toBe(0)
expect($file->length())->toBe(0)
->and($file->write('xy'))->toBe(2);
$file->close();
@ -53,7 +47,19 @@ it('opens write mode files and truncates existing contents', function () {
unlink($path);
});
it('throws an io exception when opening a missing path', function () {
it('throws when writing to a read-only file', function () {
$path = tempnam(sys_get_temp_dir(), 'php-io-');
file_put_contents($path, 'hello');
$file = File::open($path, FileMode::READ);
expect(fn () => $file->write('x'))->toThrow(IOException::class);
$file->close();
unlink($path);
});
it('throws an IOException when opening a missing path', function () {
$path = sys_get_temp_dir() . '/php-io-missing-dir/' . uniqid('', true) . '.txt';
expect(fn () => File::open($path, FileMode::READ))->toThrow(IOException::class);

View file

@ -1,334 +0,0 @@
<?php
use Shufflingpixels\IO\BinaryReader;
use Shufflingpixels\IO\Buffer;
use Shufflingpixels\IO\Exception\IOException;
use Shufflingpixels\IO\LimitedResource;
// --- Constructor validation ---
it('throws IOException when underlying stream is not seekable', function () {
$stream = new class implements \Psr\Http\Message\StreamInterface {
public function isSeekable(): bool { return false; }
public function close(): void {}
public function detach(): mixed { return null; }
public function getSize(): ?int { return null; }
public function eof(): bool { return true; }
public function tell(): int { return 0; }
public function seek(int $offset, int $whence = SEEK_SET): void {}
public function rewind(): void {}
public function isReadable(): bool { return false; }
public function read(int $length): string { return ''; }
public function isWritable(): bool { return false; }
public function write(string $string): int { return 0; }
public function getContents(): string { return ''; }
public function getMetadata(?string $key = null): mixed { return null; }
public function __toString(): string { return ''; }
};
expect(fn() => new LimitedResource($stream, 0, 5))->toThrow(IOException::class);
});
it('throws IOException for negative start offset', function () {
expect(fn() => new LimitedResource(new Buffer('hello'), -1, 5))->toThrow(IOException::class);
});
it('throws IOException for negative length', function () {
expect(fn() => new LimitedResource(new Buffer('hello'), 0, -1))->toThrow(IOException::class);
});
// --- Basic reads & position tracking ---
it('reads the correct bytes from a scoped window', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect($limited->read(5))->toBe('World')
->and($limited->tell())->toBe(5)
->and($limited->eof())->toBeTrue();
});
it('advances position correctly across partial reads', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect($limited->read(3))->toBe('Wor')
->and($limited->tell())->toBe(3);
expect($limited->read(2))->toBe('ld')
->and($limited->tell())->toBe(5);
});
it('clamps read to window boundary without throwing', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect($limited->read(9999))->toBe('World')
->and($limited->eof())->toBeTrue();
});
it('returns empty string when already at eof', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->seek(5);
expect($limited->read(1))->toBe('');
});
it('returns empty string for zero-length read', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect($limited->read(0))->toBe('');
expect($limited->tell())->toBe(0);
});
it('throws InvalidArgumentException for negative read length', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
expect(fn() => $limited->read(-1))->toThrow(InvalidArgumentException::class);
});
// --- Seek modes ---
it('supports SEEK_SET within the window', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->seek(2);
expect($limited->tell())->toBe(2)
->and($limited->read(3))->toBe('rld');
});
it('supports SEEK_CUR within the window', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->read(3);
$limited->seek(-1, SEEK_CUR);
expect($limited->tell())->toBe(2)
->and($limited->read(1))->toBe('r');
});
it('supports SEEK_END within the window', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->seek(-2, SEEK_END);
expect($limited->tell())->toBe(3)
->and($limited->read(2))->toBe('ld');
});
it('allows seeking to exactly the window length (eof position)', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->seek(5);
expect($limited->tell())->toBe(5)
->and($limited->eof())->toBeTrue();
});
it('throws OutOfBoundsException for seek past window end', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect(fn() => $limited->seek(6))->toThrow(OutOfBoundsException::class);
});
it('throws OutOfBoundsException for negative seek position', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect(fn() => $limited->seek(-1, SEEK_SET))->toThrow(OutOfBoundsException::class);
});
it('throws InvalidArgumentException for invalid seek whence', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect(fn() => $limited->seek(0, 99))->toThrow(InvalidArgumentException::class);
});
// --- getSize / eof / rewind / getContents / __toString ---
it('getSize returns window length regardless of underlying stream size', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
expect($limited->getSize())->toBe(5);
});
it('isSeekable returns true', function () {
expect((new LimitedResource(new Buffer('hello'), 0, 5))->isSeekable())->toBeTrue();
});
it('isReadable returns true', function () {
expect((new LimitedResource(new Buffer('hello'), 0, 5))->isReadable())->toBeTrue();
});
it('rewind resets position to zero', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->read(3);
$limited->rewind();
expect($limited->tell())->toBe(0)
->and($limited->read(5))->toBe('World');
});
it('getContents returns remaining window content from current position', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->read(2);
expect($limited->getContents())->toBe('rld')
->and($limited->getContents())->toBe('');
});
it('__toString returns full window content regardless of current position', function () {
$limited = new LimitedResource(new Buffer('Hello, World!'), 7, 5);
$limited->read(3);
expect((string) $limited)->toBe('World');
});
// --- Zero-length window ---
it('handles zero-length window: immediate eof, reads return empty', function () {
$limited = new LimitedResource(new Buffer('hello'), 3, 0);
expect($limited->getSize())->toBe(0)
->and($limited->eof())->toBeTrue()
->and($limited->read(5))->toBe('')
->and($limited->getContents())->toBe('');
});
it('zero-length window allows SEEK_END with offset 0 without throwing', function () {
$limited = new LimitedResource(new Buffer('hello'), 3, 0);
$limited->seek(0, SEEK_END);
expect($limited->tell())->toBe(0);
});
// --- Lifecycle ---
it('close does not close the underlying stream', function () {
$buffer = new Buffer('Hello, World!');
$limited = new LimitedResource($buffer, 7, 5);
$limited->close();
expect($buffer->read(5))->toBe('Hello');
});
it('detach returns null', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
expect($limited->detach())->toBeNull();
});
it('getSize returns null after detach', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
$limited->close();
expect($limited->getSize())->toBeNull();
});
it('eof returns true after detach', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
$limited->close();
expect($limited->eof())->toBeTrue();
});
it('throws RuntimeException on read after detach', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
$limited->close();
expect(fn() => $limited->read(1))->toThrow(RuntimeException::class);
});
it('throws RuntimeException on tell after detach', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
$limited->close();
expect(fn() => $limited->tell())->toThrow(RuntimeException::class);
});
it('throws RuntimeException on seek after detach', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
$limited->close();
expect(fn() => $limited->seek(0))->toThrow(RuntimeException::class);
});
it('throws RuntimeException on getContents after detach', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
$limited->close();
expect(fn() => $limited->getContents())->toThrow(RuntimeException::class);
});
it('__toString returns empty string after detach', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
$limited->close();
expect((string) $limited)->toBe('');
});
// --- Write rejection ---
it('isWritable returns false', function () {
expect((new LimitedResource(new Buffer('hello'), 0, 5))->isWritable())->toBeFalse();
});
it('write throws IOException', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
expect(fn() => $limited->write('x'))->toThrow(IOException::class);
});
// --- Underlying position independence ---
it('reads correctly regardless of where the underlying stream cursor is', function () {
$buffer = new Buffer('Hello, World!');
$limited = new LimitedResource($buffer, 7, 5);
$buffer->seek(0);
expect($limited->read(5))->toBe('World');
});
it('two LimitedResource windows on the same stream read independently', function () {
$buffer = new Buffer('Hello, World!');
$hello = new LimitedResource($buffer, 0, 5);
$world = new LimitedResource($buffer, 7, 5);
expect($hello->read(3))->toBe('Hel')
->and($world->read(3))->toBe('Wor')
->and($hello->read(2))->toBe('lo')
->and($world->read(2))->toBe('ld');
});
// --- BinaryReader integration ---
it('integrates with BinaryReader for binary reads from a scoped section', function () {
$value = 0xDEADBEEF;
$data = str_repeat("\x00", 4) . pack('V', $value) . str_repeat("\x00", 4);
$buffer = new Buffer($data);
$limited = new LimitedResource($buffer, 4, 4);
$reader = new BinaryReader($limited);
expect($reader->length())->toBe(4)
->and($reader->readUInt32LE())->toBe($value);
});
it('BinaryReader seek works within the window', function () {
$value = 0x0000CAFE;
$data = str_repeat("\x00", 8) . pack('V', $value);
$buffer = new Buffer($data);
$limited = new LimitedResource($buffer, 8, 4);
$reader = new BinaryReader($limited);
$first = $reader->readUInt32LE();
$reader->seek(0);
$second = $reader->readUInt32LE();
expect($first)->toBe($value)
->and($second)->toBe($value);
});
// --- getMetadata ---
it('getMetadata always returns null', function () {
$limited = new LimitedResource(new Buffer('hello'), 0, 5);
expect($limited->getMetadata())->toBeNull()
->and($limited->getMetadata('seekable'))->toBeNull();
});

View file

@ -3,66 +3,90 @@
use Shufflingpixels\IO\Exception\IOException;
use Shufflingpixels\IO\Resource;
it('reads, writes, seeks and reports stream state', function () {
$handle = fopen('php://temp', 'r+');
fwrite($handle, 'hello');
rewind($handle);
function makeResource(string $mode = 'r+', string $initial = ''): Resource
{
$handle = fopen('php://temp', $mode);
if ($initial !== '') {
fwrite($handle, $initial);
rewind($handle);
}
$stream = new class($handle, true, true, true) extends Resource {
public function __construct($resource, bool $seekable, bool $readable, bool $writeable)
{
parent::__construct($resource, $seekable, $readable, $writeable);
}
return new class($handle) extends Resource {
public function __construct(mixed $resource) { parent::__construct($resource); }
};
}
expect($stream->getSize())->toBe(5)
it('reads, writes, seeks and reports length', function () {
$stream = makeResource('r+', 'hello');
expect($stream->length())->toBe(5)
->and($stream->tell())->toBe(0)
->and($stream->isSeekable())->toBeTrue()
->and($stream->isReadable())->toBeTrue()
->and($stream->isWritable())->toBeTrue()
->and($stream->read(2))->toBe('he');
$stream->seek(0);
expect($stream->write('H'))->toBe(1);
$stream->seek(0);
expect($stream->read(5))->toBe('Hello')
->and($stream->getSize())->toBe(5)
->and($stream->eof())->toBeFalse();
expect($stream->read(5))->toBe('Hello');
});
it('length is recalculated after a write', function () {
$stream = makeResource('r+', 'abc');
expect($stream->length())->toBe(3);
$stream->seek(0, SEEK_END);
$stream->write('de');
expect($stream->length())->toBe(5);
});
it('eof is true only after reading past the end', function () {
$stream = makeResource('r+', 'ab');
expect($stream->eof())->toBeFalse();
$stream->read(3);
$stream->read(1);
expect($stream->eof())->toBeTrue();
$stream->close();
});
it('throws when seeking or getting length on non-seekable stream', function () {
$handle = fopen('php://temp', 'r+');
it('returns false when reading at end of stream', function () {
$stream = makeResource('r+', 'ab');
$stream->read(3);
$stream = new class($handle, false, true, false) extends Resource {
public function __construct($resource, bool $seekable, bool $readable, bool $writeable)
{
parent::__construct($resource, $seekable, $readable, $writeable);
}
expect($stream->read(1))->toBeFalse();
});
it('seek throws IOException on failure', function () {
$stream = makeResource('r+', 'abc');
expect(fn () => $stream->seek(-999))->toThrow(IOException::class);
});
it('write throws IOException when the underlying fwrite fails', function () {
$handle = fopen('php://temp', 'r');
$stream = new class($handle) extends Resource {
public function __construct(mixed $resource) { parent::__construct($resource); }
};
expect($stream->getSize())->toBeNull()
->and(fn () => $stream->seek(0))->toThrow(IOException::class);
$stream->close();
expect(fn () => $stream->write('x'))->toThrow(IOException::class);
});
it('throws when writing to a non-writeable stream', function () {
$handle = fopen('php://temp', 'r+');
it('close releases the resource', function () {
$stream = makeResource('r+', 'abc');
$stream->close();
$stream = new class($handle, true, true, false) extends Resource {
public function __construct($resource, bool $seekable, bool $readable, bool $writeable)
{
parent::__construct($resource, $seekable, $readable, $writeable);
}
expect(fn () => $stream->read(1))->toThrow(\TypeError::class);
});
it('detach returns the underlying resource', function () {
$handle = fopen('php://temp', 'r+');
$stream = new class($handle) extends Resource {
public function __construct(mixed $resource) { parent::__construct($resource); }
};
expect(fn () => $stream->write('x'))->toThrow(IOException::class, 'not writeable');
$detached = $stream->detach();
$stream->close();
expect($detached)->toBe($handle);
});