assertJsonStringEqualsJsonString($expected, Json::encode($data, ['prettyPrint' => true]));
+ }
+
+ public function testEncodeEmptyArrayWithForceObjectOption(): void
+ {
+ $data = [];
+
+ $this->assertJsonStringEqualsJsonString('{}', Json::encode($data, ['forceObject' => true]));
+ }
+}
diff --git a/tests/Parsers/MarkdownTest.php b/tests/Parsers/MarkdownTest.php
new file mode 100644
index 000000000..bf7ddfa60
--- /dev/null
+++ b/tests/Parsers/MarkdownTest.php
@@ -0,0 +1,88 @@
+Hello, World!\nThis is a bold statement.
\n";
+
+ $this->assertSame($expectedHtml, Markdown::parse($markdown));
+ }
+
+ public function testParseWithSiteUri(): void
+ {
+ $site = $this->createStub(Site::class);
+
+ $site->method('uri')
+ ->willReturnArgument(0);
+
+ $markdown = "\n\n[Link text](https://example.com)";
+ $expectedHtml = "
\nLink text
\n";
+
+ $this->assertSame($expectedHtml, Markdown::parse($markdown, ['site' => $site]));
+ }
+
+ public function testParseReturnsHeadingsIdsWhenOptionEnabled(): void
+ {
+ $markdown = "## Section One\n\n## Section Two";
+ $expectedHtml = "Section One
\nSection Two
\n";
+
+ $this->assertSame($expectedHtml, Markdown::parse($markdown, ['addHeadingIds' => true]));
+ }
+
+ public function testParseWithCommonMarkExtensions(): void
+ {
+ $options = [
+ 'commonmarkExtensions' => [
+ CommonMarkExtensionFixture::class => [
+ 'enabled' => true,
+ ],
+ ],
+ ];
+
+ $this->expectNotToPerformAssertions();
+ Markdown::parse('', $options);
+ }
+
+ public function testParseWithCommonMarkExtensionsDoesNotAddEnvironmentExtensions(): void
+ {
+ $options = [
+ 'commonmarkExtensions' => [
+ CommonMarkCoreExtension::class => [
+ 'enabled' => true,
+ ],
+ ],
+ ];
+
+ $this->expectNotToPerformAssertions();
+ Markdown::parse('', $options);
+ }
+
+ public function testParseThrowsUnexpectedValueExceptionOnInvalidCommonMarkExtension(): void
+ {
+ $options = [
+ 'commonmarkExtensions' => [
+ stdClass::class => [
+ 'enabled' => true,
+ ],
+ ],
+ ];
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('Invalid CommonMark extension "stdClass"');
+ Markdown::parse('', $options);
+ }
+}
diff --git a/tests/Parsers/PhpTest.php b/tests/Parsers/PhpTest.php
new file mode 100644
index 000000000..8f431b2e7
--- /dev/null
+++ b/tests/Parsers/PhpTest.php
@@ -0,0 +1,147 @@
+tearDownTempDirectory();
+ }
+
+ public function testParseAlwaysThrowsException(): void
+ {
+ $this->expectException(LogicException::class);
+ $this->expectExceptionMessage('Parsing a string of Php code is not allowed');
+ Php::parse('assertSame([
+ 'title' => 'Test',
+ 'description' => 'This is a test.',
+ 'tags' => ['php', 'testing', 'formwork'],
+ ], Php::parseFile($filePath));
+ }
+
+ public function testEncodeExportsDataAsPhpString(): void
+ {
+ $enumFixtureClass = EnumFixture::class;
+
+ $arraySerializable = $this->createStub(ArraySerializable::class);
+ $arraySerializable->method('toArray')->willReturn([
+ 'key' => 'value',
+ ]);
+ $arraySerializableClass = $arraySerializable::class;
+
+ $setStateImplementingClass = SetStateImplementingClassFixture::class;
+
+ $serializable = new SerializableFixture('value');
+ $serialized = addcslashes(serialize($serializable), '\\');
+
+ $data = [
+ 'string' => 'Test',
+ 'int' => 3,
+ 'float' => 3.14,
+ 'boolean' => false,
+ 'null' => null,
+ 'array' => ['test', 29, 2.71, true, null, []],
+ 'object' => (object) ['key' => 'value'],
+ 'enum' => EnumFixture::Alpha,
+ 'arraySerializable' => $arraySerializable,
+ 'setStateImplementing' => new SetStateImplementingClassFixture('value'),
+ 'serializable' => $serializable,
+ ];
+
+ $expected = << 'Test',
+ 'int' => 3,
+ 'float' => 3.14,
+ 'boolean' => false,
+ 'null' => null,
+ 'array' => [
+ 'test',
+ 29,
+ 2.71,
+ true,
+ null,
+ []
+ ],
+ 'object' => (object) [
+ 'key' => 'value'
+ ],
+ 'enum' => \\$enumFixtureClass::Alpha,
+ 'arraySerializable' => \\$arraySerializableClass::fromArray([
+ 'key' => 'value'
+ ]),
+ 'setStateImplementing' => \\$setStateImplementingClass::__set_state([
+ 'key' => 'value'
+ ]),
+ 'serializable' => unserialize('$serialized')
+ ]
+ PHP;
+
+ $this->assertSame($expected, Php::encode($data));
+ }
+
+ public function testEncodeThrowsExceptionWithUnencodableClasses(): void
+ {
+ $data = [
+ 'closure' => fn() => 'Unencodable',
+ ];
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('Objects of class "Closure" cannot be encoded');
+ Php::encode($data);
+ }
+
+ public function testEncodeThrowsExceptionWithResources(): void
+ {
+ $stream = fopen('php://temp', 'r');
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('Data of type "resource" cannot be encoded');
+
+ try {
+ Php::encode(['resource' => $stream]);
+ } finally {
+ fclose($stream);
+ }
+ }
+
+ public function testEncodeToFile(): void
+ {
+ $data = [
+ 'title' => 'Test',
+ 'description' => 'This is a test.',
+ 'tags' => ['php', 'testing', 'formwork'],
+ ];
+
+ $filePath = TESTS_TMP_PATH . '/output.php';
+
+ Php::encodeToFile($data, $filePath);
+
+ $this->assertSame($data, include $filePath);
+ }
+}
diff --git a/tests/Parsers/YamlTest.php b/tests/Parsers/YamlTest.php
new file mode 100644
index 000000000..fbaf3d7d2
--- /dev/null
+++ b/tests/Parsers/YamlTest.php
@@ -0,0 +1,97 @@
+tearDownTempDirectory();
+ }
+
+ public function testParse(): void
+ {
+ $yamlString = << 'Test',
+ 'description' => 'This is a test.',
+ 'tags' => ['php', 'yaml', 'parser'],
+ ];
+
+ $this->assertSame($expected, Yaml::parse($yamlString));
+ }
+
+ public function testParseFile(): void
+ {
+ $yamlFilePath = TESTS_TMP_PATH . '/test.yaml';
+
+ $expected = [
+ 'title' => 'Test',
+ 'description' => 'This is a test.',
+ 'tags' => ['php', 'yaml', 'parser'],
+ ];
+
+ $this->assertSame($expected, Yaml::parseFile($yamlFilePath));
+ }
+
+ public function testEncode(): void
+ {
+ $data = [
+ 'title' => 'Test',
+ 'description' => 'This is a test.',
+ 'tags' => ['php', 'yaml', 'parser'],
+ ];
+
+ $expectedYamlString = <<assertSame($expectedYamlString, Yaml::encode($data));
+ }
+
+ public function testEncodeToFile(): void
+ {
+ $data = [
+ 'title' => 'Test',
+ 'description' => 'This is a test.',
+ 'tags' => ['php', 'yaml', 'parser'],
+ ];
+
+ $yamlFilePath = TESTS_TMP_PATH . '/output.yaml';
+
+ Yaml::encodeToFile($data, $yamlFilePath);
+
+ $this->assertFileExists($yamlFilePath);
+ $this->assertSame(Yaml::encode($data), FileSystem::read($yamlFilePath));
+ }
+
+ public function testEncodeReturnsEmptyStringForEmptyData(): void
+ {
+ $this->assertSame('', Yaml::encode([]));
+ }
+}
diff --git a/tests/TestCase.php b/tests/TestCase.php
new file mode 100644
index 000000000..aeedd69f2
--- /dev/null
+++ b/tests/TestCase.php
@@ -0,0 +1,37 @@
+getFileName());
+
+ if (FileSystem::exists($dir . '/fixtures/functions.php')) {
+ require_once $dir . '/fixtures/functions.php';
+ }
+ }
+
+ protected function setUpTempDirectory(): void
+ {
+ if (!FileSystem::isDirectory(TESTS_TMP_PATH, assertExists: false)) {
+ FileSystem::createDirectory(TESTS_TMP_PATH);
+ }
+ }
+
+ protected function tearDownTempDirectory(): void
+ {
+ if (FileSystem::isDirectory(TESTS_TMP_PATH, assertExists: false)) {
+ FileSystem::deleteDirectory(TESTS_TMP_PATH, recursive: true);
+ }
+ }
+}
diff --git a/tests/Utils/ArrTest.php b/tests/Utils/ArrTest.php
new file mode 100644
index 000000000..606f43858
--- /dev/null
+++ b/tests/Utils/ArrTest.php
@@ -0,0 +1,1361 @@
+ [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ],
+ 'date' => '2025-03-29',
+ ];
+
+ $this->assertSame('2025-03-29', Arr::get($data, 'date'));
+ $this->assertSame('Sempronius', Arr::get($data, 'user.name'));
+ $this->assertNull(Arr::get($data, 'user.age'));
+ }
+
+ public function testHas(): void
+ {
+ $data = [
+ 'user' => [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ],
+ 'date' => '2025-03-29',
+ ];
+
+ $this->assertTrue(Arr::has($data, 'date'));
+ $this->assertTrue(Arr::has($data, 'user.email'));
+ $this->assertFalse(Arr::has($data, 'user.age'));
+ }
+
+ public function testSet(): void
+ {
+ $data = [
+ 'user' => [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ],
+ 'date' => '2025-03-29',
+ ];
+
+ Arr::set($data, 'user.age', 30);
+ $this->assertSame(30, Arr::get($data, 'user.age'));
+
+ Arr::set($data, 'roles.available', 'admin');
+ $this->assertSame('admin', Arr::get($data, 'roles.available'));
+
+ Arr::set($data, 'date', '2025-04-01');
+ $this->assertSame('2025-04-01', Arr::get($data, 'date'));
+ }
+
+ public function testRemove(): void
+ {
+ $data = [
+ 'user' => [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ],
+ 'date' => '2025-03-29',
+ ];
+
+ Arr::remove($data, 'user.email');
+ $this->assertFalse(Arr::has($data, 'user.email'));
+
+ Arr::remove($data, 'roles.available');
+ $this->assertFalse(Arr::has($data, 'roles.available'));
+
+ Arr::remove($data, 'date');
+ $this->assertFalse(Arr::has($data, 'date'));
+ }
+
+ public function testFlatten(): void
+ {
+ $data = [
+ 'app' => [
+ 'theme' => 'dark',
+ 'notifications' => true,
+ 'roles' => ['admin', 'editor'],
+ 'permissions' => ['pages', 'files'],
+ 'cache' => [
+ 'enabled' => true,
+ 'duration' => 3600,
+ ],
+ ],
+
+ ];
+
+ $expected = [
+ 'app.theme' => 'dark',
+ 'app.notifications' => true,
+ 'app.roles' => ['admin', 'editor'],
+ 'app.permissions' => ['pages', 'files'],
+ 'app.cache.enabled' => true,
+ 'app.cache.duration' => 3600,
+ ];
+
+ $this->assertSame($expected, Arr::dot($data));
+ }
+
+ public function testExpand(): void
+ {
+ $data = [
+ 'app' => [
+ 'theme' => 'dark',
+ 'notifications' => true,
+ 'roles' => ['admin', 'editor'],
+ 'permissions' => ['pages', 'files'],
+ 'cache' => [
+ 'enabled' => true,
+ 'duration' => 3600,
+ ],
+ ],
+ ];
+
+ $dot = [
+ 'app.theme' => 'dark',
+ 'app.notifications' => true,
+ 'app.roles' => ['admin', 'editor'],
+ 'app.permissions' => ['pages', 'files'],
+ 'app.cache.enabled' => true,
+ 'app.cache.duration' => 3600,
+ ];
+
+ $this->assertSame($data, Arr::undot($dot));
+ }
+
+ public function testPull(): void
+ {
+ $data = ['apple', 'banana', 'cherry', 'banana'];
+
+ Arr::pull($data, 'banana');
+ $this->assertNotContains('banana', $data);
+ }
+
+ public function testSplice(): void
+ {
+ $data = ['apple', 'banana', 'cherry', 'banana'];
+
+ $removed = Arr::splice($data, 1, 2, ['orange', 'grapes']);
+
+ $this->assertSame(['banana', 'cherry'], $removed);
+ $this->assertSame(['apple', 'orange', 'grapes', 'banana'], $data);
+ }
+
+ public function testSpliceWithAssociativeArray(): void
+ {
+ $data = [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ];
+
+ $removed = Arr::splice($data, 1, 1, ['age' => 30]);
+
+ $this->assertSame(['email' => 'sempronius@example.com'], $removed);
+ $this->assertSame(['name' => 'Sempronius', 'age' => 30, 'country' => 'Italy'], $data);
+ }
+
+ public function testSpliceWithNegativeOffset(): void
+ {
+ $data = [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ];
+
+ $removed = Arr::splice($data, -2, 1, ['age' => 30]);
+
+ $this->assertSame(['email' => 'sempronius@example.com'], $removed);
+ $this->assertSame(['name' => 'Sempronius', 'age' => 30, 'country' => 'Italy'], $data);
+ }
+
+ public function testSpliceWithNegativeLength(): void
+ {
+ $data = [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ];
+
+ $removed = Arr::splice($data, 0, -1, ['age' => 30]);
+
+ $this->assertSame(['name' => 'Sempronius', 'email' => 'sempronius@example.com'], $removed);
+ $this->assertSame(['age' => 30, 'country' => 'Italy'], $data);
+ }
+
+ public function testSpliceThrowsOnDuplicateKeys(): void
+ {
+ $data = [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ];
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('Cannot replace 1 items from offset 1: some keys in the replacement array are the same of the resulting array');
+ Arr::splice($data, 1, 1, ['country' => 'Canada']);
+ }
+
+ public function testMove(): void
+ {
+ $data = ['apple', 'banana', 'cherry', 'banana'];
+
+ Arr::moveItem($data, 0, 2);
+ $this->assertSame(['banana', 'cherry', 'apple', 'banana'], $data);
+ }
+
+ public function testEntries(): void
+ {
+ $data = [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ];
+
+ $expected = [
+ ['name', 'Sempronius'],
+ ['email', 'sempronius@example.com'],
+ ['country', 'Italy'],
+ ];
+
+ $this->assertSame($expected, Arr::entries($data));
+ }
+
+ public function testNth(): void
+ {
+ $data = ['apple', 'banana', 'cherry', 'banana'];
+
+ $this->assertSame('cherry', Arr::nth($data, 2));
+ $this->assertNull(Arr::nth($data, 5));
+ }
+
+ public function testAt(): void
+ {
+ $data = ['apple', 'banana', 'cherry', 'banana'];
+
+ $this->assertSame('cherry', Arr::at($data, 2));
+ $this->assertNull(Arr::at($data, 5));
+
+ $this->assertSame('banana', Arr::at($data, -1));
+ $this->assertNull(Arr::at($data, -5));
+ }
+
+ public function testIndex(): void
+ {
+ $data = ['apple', 'banana', 'cherry', 'banana'];
+
+ $this->assertSame(1, Arr::indexOf($data, 'banana'));
+ $this->assertNull(Arr::indexOf($data, 'orange'));
+ }
+
+ public function testKey(): void
+ {
+ $data = [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ];
+
+ $this->assertSame('email', Arr::keyOf($data, 'sempronius@example.com'));
+ $this->assertNull(Arr::keyOf($data, 'Brazil'));
+ }
+
+ public function testDuplicates(): void
+ {
+ $data = ['apple', 'banana', 'cherry', 'banana'];
+
+ $this->assertSame([3 => 'banana'], Arr::duplicates($data));
+ }
+
+ public function testAppendMissing(): void
+ {
+ $data = [
+ 'theme' => 'dark',
+ 'notifications' => true,
+ 'roles' => ['admin', 'editor'],
+ 'permissions' => ['pages', 'files'],
+ 'cache' => [
+ 'enabled' => true,
+ 'duration' => 3600,
+ ],
+ ];
+
+ $expected = [
+ 'theme' => 'dark',
+ 'notifications' => true,
+ 'roles' => ['admin', 'editor', 'user'],
+ 'permissions' => ['pages', 'files'],
+ 'cache' => [
+ 'enabled' => true,
+ 'duration' => 3600,
+ ],
+ 'language' => 'en',
+ ];
+
+ $result = Arr::appendMissing($data, ['roles' => ['admin', 'editor', 'user'], 'language' => 'en', 'cache' => false]);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testExtend(): void
+ {
+ // Test basic array extension with associative arrays
+ $base = [
+ 'app' => [
+ 'name' => 'Formwork',
+ 'debug' => false,
+ ],
+ 'cache' => [
+ 'enabled' => true,
+ ],
+ ];
+
+ $extension = [
+ 'app' => [
+ 'version' => '2.0',
+ 'debug' => true,
+ ],
+ 'database' => [
+ 'host' => 'localhost',
+ ],
+ ];
+
+ $expected = [
+ 'app' => [
+ 'name' => 'Formwork',
+ 'debug' => true,
+ 'version' => '2.0',
+ ],
+ 'cache' => [
+ 'enabled' => true,
+ ],
+ 'database' => [
+ 'host' => 'localhost',
+ ],
+ ];
+
+ $result = Arr::extend($base, $extension);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testExtendWithListConcatenation(): void
+ {
+ // Test that lists are concatenated instead of merged element-by-element
+ $base = [
+ 'roles' => ['admin', 'editor'],
+ 'permissions' => ['read', 'write'],
+ ];
+
+ $extension = [
+ 'roles' => ['user', 'guest'],
+ ];
+
+ $expected = [
+ 'roles' => ['admin', 'editor', 'user', 'guest'],
+ 'permissions' => ['read', 'write'],
+ ];
+
+ $result = Arr::extend($base, $extension);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testExtendWithDeeplyNested(): void
+ {
+ // Test deeply nested structures
+ $base = [
+ 'config' => [
+ 'system' => [
+ 'cache' => [
+ 'enabled' => true,
+ 'lifetime' => 3600,
+ ],
+ 'features' => ['search', 'backup'],
+ ],
+ ],
+ ];
+
+ $extension = [
+ 'config' => [
+ 'system' => [
+ 'cache' => [
+ 'lifetime' => 7200,
+ 'driver' => 'file',
+ ],
+ 'features' => ['images'],
+ 'logging' => true,
+ ],
+ ],
+ ];
+
+ $expected = [
+ 'config' => [
+ 'system' => [
+ 'cache' => [
+ 'enabled' => true,
+ 'lifetime' => 7200,
+ 'driver' => 'file',
+ ],
+ 'features' => ['search', 'backup', 'images'],
+ 'logging' => true,
+ ],
+ ],
+ ];
+
+ $result = Arr::extend($base, $extension);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testExtendWithMultipleArrays(): void
+ {
+ // Test extending with multiple arrays
+ $base = [
+ 'a' => 1,
+ 'b' => ['x' => 10],
+ ];
+
+ $ext1 = [
+ 'b' => ['y' => 20],
+ 'c' => 3,
+ ];
+
+ $ext2 = [
+ 'a' => 2,
+ 'd' => 4,
+ ];
+
+ $expected = [
+ 'a' => 2,
+ 'b' => ['x' => 10, 'y' => 20],
+ 'c' => 3,
+ 'd' => 4,
+ ];
+
+ $result = Arr::extend($base, $ext1, $ext2);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testExtendWithEmptyArrays(): void
+ {
+ // Test extending empty arrays
+ $result = Arr::extend([], ['a' => 1]);
+ $this->assertSame(['a' => 1], $result);
+
+ $result = Arr::extend(['a' => 1], []);
+ $this->assertSame(['a' => 1], $result);
+
+ $result = Arr::extend([], []);
+ $this->assertSame([], $result);
+ }
+
+ public function testExtendWithMixedTypes(): void
+ {
+ // Test that scalar values override arrays and vice versa
+ $base = [
+ 'scalar_to_array' => 'value',
+ 'array_to_scalar' => ['a', 'b'],
+ 'keep_scalar' => 42,
+ ];
+
+ $extension = [
+ 'scalar_to_array' => ['new' => 'array'],
+ 'array_to_scalar' => 'string',
+ 'keep_scalar' => 100,
+ ];
+
+ $expected = [
+ 'scalar_to_array' => ['new' => 'array'],
+ 'array_to_scalar' => 'string',
+ 'keep_scalar' => 100,
+ ];
+
+ $result = Arr::extend($base, $extension);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testOverride(): void
+ {
+ // Test basic override functionality
+ $base = [
+ 'app' => [
+ 'name' => 'Formwork',
+ 'debug' => false,
+ ],
+ 'cache' => [
+ 'enabled' => true,
+ ],
+ ];
+
+ $override = [
+ 'app' => [
+ 'debug' => true,
+ ],
+ 'database' => [
+ 'host' => 'localhost',
+ ],
+ ];
+
+ $expected = [
+ 'app' => [
+ 'name' => 'Formwork',
+ 'debug' => true,
+ ],
+ 'cache' => [
+ 'enabled' => true,
+ ],
+ 'database' => [
+ 'host' => 'localhost',
+ ],
+ ];
+
+ $result = Arr::override($base, $override);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testOverrideReplacingLists(): void
+ {
+ // Test that lists are completely replaced, not merged
+ $base = [
+ 'roles' => ['admin', 'editor'],
+ 'permissions' => ['read', 'write', 'delete'],
+ ];
+
+ $override = [
+ 'roles' => ['user', 'guest'],
+ ];
+
+ $expected = [
+ 'roles' => ['user', 'guest'],
+ 'permissions' => ['read', 'write', 'delete'],
+ ];
+
+ $result = Arr::override($base, $override);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testOverrideWithDeeplyNested(): void
+ {
+ // Test deeply nested structures with lists
+ $base = [
+ 'config' => [
+ 'system' => [
+ 'features' => ['search', 'backup'],
+ 'cache' => [
+ 'enabled' => true,
+ 'lifetime' => 3600,
+ 'stores' => ['file', 'redis'],
+ ],
+ ],
+ ],
+ ];
+
+ $override = [
+ 'config' => [
+ 'system' => [
+ 'features' => ['images'],
+ 'cache' => [
+ 'lifetime' => 7200,
+ 'stores' => ['memcached'],
+ ],
+ ],
+ ],
+ ];
+
+ $expected = [
+ 'config' => [
+ 'system' => [
+ 'features' => ['images'],
+ 'cache' => [
+ 'enabled' => true,
+ 'lifetime' => 7200,
+ 'stores' => ['memcached'],
+ ],
+ ],
+ ],
+ ];
+
+ $result = Arr::override($base, $override);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testOverrideWithMultipleArrays(): void
+ {
+ // Test overriding with multiple arrays
+ $base = [
+ 'a' => 1,
+ 'b' => ['x' => 10, 'y' => [1, 2, 3]],
+ ];
+
+ $override1 = [
+ 'b' => ['y' => [4, 5]],
+ 'c' => 3,
+ ];
+
+ $override2 = [
+ 'a' => 2,
+ 'd' => 4,
+ ];
+
+ $expected = [
+ 'a' => 2,
+ 'b' => ['x' => 10, 'y' => [4, 5]],
+ 'c' => 3,
+ 'd' => 4,
+ ];
+
+ $result = Arr::override($base, $override1, $override2);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testOverrideWithEmptyArrays(): void
+ {
+ // Test overriding with empty arrays
+ $result = Arr::override([], ['a' => 1]);
+ $this->assertSame(['a' => 1], $result);
+
+ $result = Arr::override(['a' => 1], []);
+ $this->assertSame(['a' => 1], $result);
+
+ $result = Arr::override([], []);
+ $this->assertSame([], $result);
+ }
+
+ public function testOverrideReplacingListWithEmptyList(): void
+ {
+ // Test replacing a list with an empty list
+ $base = [
+ 'items' => [1, 2, 3],
+ 'other' => 'value',
+ ];
+
+ $override = [
+ 'items' => [],
+ ];
+
+ $expected = [
+ 'items' => [],
+ 'other' => 'value',
+ ];
+
+ $result = Arr::override($base, $override);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testOverrideWithMixedTypes(): void
+ {
+ // Test that mixed types are properly replaced
+ $base = [
+ 'scalar_to_array' => 'value',
+ 'array_to_scalar' => ['a', 'b'],
+ 'list_to_assoc' => [1, 2, 3],
+ 'assoc_to_list' => ['x' => 1, 'y' => 2],
+ ];
+
+ $override = [
+ 'scalar_to_array' => ['new' => 'array'],
+ 'array_to_scalar' => 'string',
+ 'list_to_assoc' => ['a' => 1],
+ 'assoc_to_list' => [1, 2],
+ ];
+
+ $expected = [
+ 'scalar_to_array' => ['new' => 'array'],
+ 'array_to_scalar' => 'string',
+ 'list_to_assoc' => ['a' => 1],
+ 'assoc_to_list' => [1, 2],
+ ];
+
+ $result = Arr::override($base, $override);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testExclude(): void
+ {
+ // Test basic exclusion functionality
+ $array = [
+ 'name' => 'Formwork',
+ 'version' => '2.0',
+ 'debug' => true,
+ 'cache' => [
+ 'enabled' => true,
+ 'lifetime' => 3600,
+ ],
+ ];
+
+ $exclusion = [
+ 'debug' => true,
+ 'cache' => [
+ 'lifetime' => 3600,
+ ],
+ ];
+
+ $expected = [
+ 'name' => 'Formwork',
+ 'version' => '2.0',
+ 'cache' => [
+ 'enabled' => true,
+ ],
+ ];
+
+ $result = Arr::exclude($array, $exclusion);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testExcludeRemovingEmptyArrays(): void
+ {
+ // Test that empty arrays resulting from recursion are removed
+ $array = [
+ 'settings' => [
+ 'cache' => [
+ 'enabled' => true,
+ ],
+ ],
+ 'other' => 'value',
+ ];
+
+ $exclusion = [
+ 'settings' => [
+ 'cache' => [
+ 'enabled' => true,
+ ],
+ ],
+ ];
+
+ $expected = [
+ 'other' => 'value',
+ ];
+
+ $result = Arr::exclude($array, $exclusion);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testExcludeWithDeeplyNested(): void
+ {
+ // Test deeply nested exclusions
+ $array = [
+ 'config' => [
+ 'system' => [
+ 'cache' => [
+ 'enabled' => true,
+ 'lifetime' => 3600,
+ 'driver' => 'file',
+ ],
+ 'features' => [
+ 'search' => true,
+ 'backup' => false,
+ ],
+ ],
+ 'other' => 'preserved',
+ ],
+ ];
+
+ $exclusion = [
+ 'config' => [
+ 'system' => [
+ 'cache' => [
+ 'lifetime' => 3600,
+ ],
+ 'features' => [
+ 'backup' => false,
+ ],
+ ],
+ ],
+ ];
+
+ $expected = [
+ 'config' => [
+ 'system' => [
+ 'cache' => [
+ 'enabled' => true,
+ 'driver' => 'file',
+ ],
+ 'features' => [
+ 'search' => true,
+ ],
+ ],
+ 'other' => 'preserved',
+ ],
+ ];
+
+ $result = Arr::exclude($array, $exclusion);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testExcludeWithMultipleArrays(): void
+ {
+ // Test excluding with multiple exclusion arrays
+ $array = [
+ 'a' => 1,
+ 'b' => 2,
+ 'c' => 3,
+ 'd' => 4,
+ ];
+
+ $exc1 = ['a' => 1];
+ $exc2 = ['c' => 3];
+
+ $expected = [
+ 'b' => 2,
+ 'd' => 4,
+ ];
+
+ $result = Arr::exclude($array, $exc1, $exc2);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testExcludePreservingNonMatchingValues(): void
+ {
+ // Test that non-matching values are preserved
+ $array = [
+ 'name' => 'Formwork',
+ 'debug' => true,
+ 'cache' => [
+ 'enabled' => true,
+ 'lifetime' => 3600,
+ ],
+ ];
+
+ $exclusion = [
+ 'debug' => false,
+ 'cache' => [
+ 'lifetime' => 7200,
+ ],
+ ];
+
+ $expected = [
+ 'name' => 'Formwork',
+ 'debug' => true,
+ 'cache' => [
+ 'enabled' => true,
+ 'lifetime' => 3600,
+ ],
+ ];
+
+ $result = Arr::exclude($array, $exclusion);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testExcludeWithEmptyArray(): void
+ {
+ // Test with empty exclusion array
+ $array = [
+ 'a' => 1,
+ 'b' => 2,
+ ];
+
+ $result = Arr::exclude($array, []);
+ $this->assertSame($array, $result);
+ }
+
+ public function testExcludeAllValues(): void
+ {
+ // Test when all items are excluded
+ $array = [
+ 'a' => 1,
+ 'b' => 2,
+ ];
+
+ $exclusion = [
+ 'a' => 1,
+ 'b' => 2,
+ ];
+
+ $expected = [];
+
+ $result = Arr::exclude($array, $exclusion);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testExcludeWithNonExistentKeys(): void
+ {
+ // Test excluding keys that don't exist in the array
+ $array = [
+ 'a' => 1,
+ 'b' => 2,
+ ];
+
+ $exclusion = [
+ 'c' => 3,
+ 'd' => 4,
+ ];
+
+ $expected = [
+ 'a' => 1,
+ 'b' => 2,
+ ];
+
+ $result = Arr::exclude($array, $exclusion);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testExcludeWithMixedTypes(): void
+ {
+ // Test excluding with mixed scalar and array values
+ $array = [
+ 'scalar' => 'value',
+ 'number' => 42,
+ 'nested' => [
+ 'a' => 1,
+ 'b' => 2,
+ ],
+ 'preserve_me' => 'keep',
+ ];
+
+ $exclusion = [
+ 'scalar' => 'value',
+ 'nested' => [
+ 'a' => 1,
+ ],
+ ];
+
+ $expected = [
+ 'number' => 42,
+ 'nested' => [
+ 'b' => 2,
+ ],
+ 'preserve_me' => 'keep',
+ ];
+
+ $result = Arr::exclude($array, $exclusion);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testExcludeDoesNotMatchScalarWithArray(): void
+ {
+ // Test that array exclusions don't match scalar values
+ $array = [
+ 'value' => 'scalar',
+ ];
+
+ $exclusion = [
+ 'value' => ['array'],
+ ];
+
+ $expected = [
+ 'value' => 'scalar',
+ ];
+
+ $result = Arr::exclude($array, $exclusion);
+ $this->assertSame($expected, $result);
+ }
+
+ public function testRandom(): void
+ {
+ $data = ['apple', 'banana', 'cherry', 'banana'];
+
+ $this->assertContains(Arr::random($data), $data);
+ $this->assertNull(Arr::random([]));
+ }
+
+ public function testShuffle(): void
+ {
+ $data = ['apple', 'banana', 'cherry', 'banana'];
+
+ $shuffled = Arr::shuffle($data);
+
+ $this->assertSameSize($data, $shuffled);
+
+ foreach ($shuffled as $fruit) {
+ $this->assertContains($fruit, $data);
+ }
+
+ $this->assertSame(['test'], Arr::shuffle(['test']));
+ }
+
+ public function testShufflePreservingKeys(): void
+ {
+ $data = [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ];
+
+ $shuffled = Arr::shuffle($data, preserveKeys: true);
+ $this->assertSameSize($data, $shuffled);
+
+ foreach ($shuffled as $key => $value) {
+ $this->assertArrayHasKey($key, $data);
+ $this->assertSame($data[$key], $value);
+ }
+ }
+
+ public function testIsAssociative(): void
+ {
+ $user = [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ];
+
+ $fruits = [
+ 'apple',
+ 'banana',
+ 'cherry',
+ 'banana',
+ ];
+
+ $this->assertTrue(Arr::isAssociative($user));
+ $this->assertFalse(Arr::isAssociative($fruits));
+ $this->assertFalse(Arr::isAssociative([]));
+ }
+
+ public function testMap(): void
+ {
+ $data = [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ];
+
+ $expected = [
+ 'name' => 'SEMPRONIUS',
+ 'email' => 'SEMPRONIUS@EXAMPLE.COM',
+ 'country' => 'ITALY',
+ ];
+
+ $this->assertSame($expected, Arr::map($data, fn($data) => strtoupper($data)));
+ }
+
+ public function testMapKeys(): void
+ {
+ $data = [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ];
+
+ $expected = [
+ 'USER_NAME' => 'Sempronius',
+ 'USER_EMAIL' => 'sempronius@example.com',
+ 'USER_COUNTRY' => 'Italy',
+ ];
+
+ $this->assertSame($expected, Arr::mapKeys($data, fn($key) => 'USER_' . strtoupper($key)));
+ }
+
+ public function testFilter(): void
+ {
+ $data = [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ];
+
+ $expected = [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ ];
+
+ $this->assertSame($expected, Arr::filter($data, fn($value, $key) => $value !== 'Italy'));
+ }
+
+ public function testReject(): void
+ {
+ $data = [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ];
+
+ $expected = [
+ 'email' => 'sempronius@example.com',
+ ];
+
+ $this->assertSame($expected, Arr::reject($data, fn($value, $key) => !str_contains($value, '@')));
+ }
+
+ public function testEvery(): void
+ {
+ $data = ['apple', 'banana', 'cherry', 'banana'];
+
+ $this->assertTrue(Arr::every($data, fn($value) => is_string($value)));
+ $this->assertFalse(Arr::every($data, fn($value) => $value === 'banana'));
+ }
+
+ public function testSome(): void
+ {
+ $data = ['apple', 'banana', 'cherry', 'banana'];
+
+ $this->assertTrue(Arr::some($data, fn($value) => $value === 'banana'));
+ $this->assertFalse(Arr::some($data, fn($value) => $value === 'orange'));
+ }
+
+ public function testFind(): void
+ {
+ $data = ['apple', 'banana', 'cherry', 'banana'];
+
+ $this->assertSame('banana', Arr::find($data, fn($value) => $value === 'banana'));
+ $this->assertNull(Arr::find($data, fn($value) => $value === 'orange'));
+ }
+
+ public function testPluck(): void
+ {
+ $data = [
+ ['id' => 1, 'name' => 'Item 1', 'group' => 'A', 'count' => 5],
+ ['id' => 2, 'name' => 'Item 2', 'group' => 'A', 'count' => 3],
+ ['id' => 3, 'name' => 'Item 3', 'group' => 'B', 'count' => 8],
+ ];
+
+ $expected = [
+ 'Item 1',
+ 'Item 2',
+ 'Item 3',
+ ];
+
+ $this->assertSame($expected, Arr::extract($data, 'name'));
+ }
+
+ public function testGroupBy(): void
+ {
+ $data = [
+ ['id' => 1, 'name' => 'Item 1', 'group' => 'A', 'count' => 5],
+ ['id' => 2, 'name' => 'Item 2', 'group' => 'A', 'count' => 3],
+ ['id' => 3, 'name' => 'Item 3', 'group' => 'B', 'count' => 8],
+
+ ];
+
+ $expected = [
+ 'A' => [
+ ['id' => 1, 'name' => 'Item 1', 'group' => 'A', 'count' => 5],
+ ['id' => 2, 'name' => 'Item 2', 'group' => 'A', 'count' => 3],
+ ],
+ 'B' => [
+ ['id' => 3, 'name' => 'Item 3', 'group' => 'B', 'count' => 8],
+ ],
+ ];
+
+ $this->assertSame($expected, Arr::group($data, fn($item) => $item['group']));
+
+ // Test with Stringable values
+ $this->assertSame($expected, Arr::group($data, fn($item) => new StringableFixture($item['group'])));
+ }
+
+ public function testCollapse(): void
+ {
+ $nested = [
+ 'a',
+ ['b', 'c'],
+ [['d', 'e'], 'f'],
+ ];
+
+ $this->assertSame($nested, Arr::flatten($nested, depth: 0));
+ $this->assertSame(['a', 'b', 'c', ['d', 'e'], 'f'], Arr::flatten($nested, depth: 1));
+
+ $this->assertSame(['a', 'b', 'c', 'd', 'e', 'f'], Arr::flatten($nested));
+ }
+
+ public function testCollapseWithArrayableObjects(): void
+ {
+ $nested = [
+ 'a',
+ new ArrayableFixture(['b', 'c']),
+ new TraversableFixture([new ArrayableFixture(['d', 'e']), 'f']),
+ ];
+
+ $this->assertSame(['a', 'b', 'c', 'd', 'e', 'f'], Arr::flatten($nested));
+ }
+
+ public function testFlattenThrowsOnNegativeDepth(): void
+ {
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('expects a non-negative depth');
+ Arr::flatten([], depth: -1);
+ }
+
+ public function testSort(): void
+ {
+ $fruits = ['banana', 'apple', 'cherry'];
+
+ $this->assertSame([1 => 'apple', 0 => 'banana', 2 => 'cherry'], Arr::sort($fruits));
+
+ $this->assertSame([2 => 'cherry', 0 => 'banana', 1 => 'apple'], Arr::sort($fruits, direction: SORT_DESC));
+
+ $this->assertSame(['apple', 'banana', 'cherry'], Arr::sort($fruits, preserveKeys: false));
+
+ $this->assertSame(['cherry', 'banana', 'apple'], Arr::sort($fruits, direction: SORT_DESC, preserveKeys: false));
+ }
+
+ public function testSortWithCallable(): void
+ {
+ $expected = [
+ ['id' => 2, 'name' => 'Item 2', 'group' => 'A', 'count' => 3],
+ ['id' => 1, 'name' => 'Item 1', 'group' => 'A', 'count' => 5],
+ ['id' => 3, 'name' => 'Item 3', 'group' => 'B', 'count' => 8],
+ ];
+
+ $data = [
+ ['id' => 1, 'name' => 'Item 1', 'group' => 'A', 'count' => 5],
+ ['id' => 2, 'name' => 'Item 2', 'group' => 'A', 'count' => 3],
+ ['id' => 3, 'name' => 'Item 3', 'group' => 'B', 'count' => 8],
+ ];
+
+ $this->assertSame($expected, Arr::sort($data, sortBy: fn($a, $b) => $a['count'] <=> $b['count'], preserveKeys: false));
+ }
+
+ public function testSortWithOrderArray(): void
+ {
+ $expected = [
+ 'roles' => ['admin', 'editor'],
+ 'permissions' => ['pages', 'files'],
+ 'cache' => [
+ 'enabled' => true,
+ 'duration' => 3600,
+ ],
+ 'notifications' => true,
+ 'theme' => 'dark',
+ ];
+
+ $data = [
+ 'theme' => 'dark',
+ 'notifications' => true,
+ 'roles' => ['admin', 'editor'],
+ 'permissions' => ['pages', 'files'],
+ 'cache' => [
+ 'enabled' => true,
+ 'duration' => 3600,
+ ],
+ ];
+
+ $this->assertSame($expected, Arr::sort($data, sortBy: ['roles' => 0, 'permissions' => 1, 'cache' => 2, 'notifications' => 3, 'theme' => 4]));
+ }
+
+ public function testSortWithOrderWithoutPreservingKeys(): void
+ {
+ $data = [
+ 'theme' => 'dark',
+ 'notifications' => true,
+ 'roles' => ['admin', 'editor'],
+ 'permissions' => ['pages', 'files'],
+ 'cache' => [
+ 'enabled' => true,
+ 'duration' => 3600,
+ ],
+ ];
+
+ $expected = [
+ ['admin', 'editor'],
+ ['pages', 'files'],
+ [
+ 'enabled' => true,
+ 'duration' => 3600,
+ ],
+ true,
+ 'dark',
+ ];
+
+ $this->assertSame($expected, Arr::sort($data, sortBy: ['roles' => 0, 'permissions' => 1, 'cache' => 2, 'notifications' => 3, 'theme' => 4], preserveKeys: false));
+ }
+
+ public function testSortThrowsOnInvalidDirection(): void
+ {
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('only accepts SORT_ASC and SORT_DESC as "direction" option');
+ Arr::sort([], direction: 123);
+ }
+
+ public function testSortThrowsOnInvalidType(): void
+ {
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('only accepts SORT_REGULAR, SORT_NUMERIC, SORT_STRING and SORT_NATURAL as "type" option');
+ Arr::sort([], type: 123);
+ }
+
+ public function testSortThrowsOnInvalidSortByCount(): void
+ {
+ $data = [
+ 'app' => [
+ 'theme' => 'dark',
+ 'notifications' => true,
+ 'roles' => ['admin', 'editor'],
+ 'permissions' => ['pages', 'files'],
+ 'cache' => [
+ 'enabled' => true,
+ 'duration' => 3600,
+ ],
+ ],
+ ];
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('Cannot sort array: the $sortBy array must have the same number of items as the array to sort');
+ Arr::sort($data, sortBy: []);
+ }
+
+ public function testSortThrowsOnMissingSortByKey(): void
+ {
+ $data = [
+ 'theme' => 'dark',
+ 'notifications' => true,
+ 'roles' => ['admin', 'editor'],
+ 'permissions' => ['pages', 'files'],
+ 'cache' => [
+ 'enabled' => true,
+ 'duration' => 3600,
+ ],
+ ];
+
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('Cannot sort array: key "test" from the $sortBy array is not present in the array to sort');
+ Arr::sort($data, sortBy: ['roles' => 0, 'permissions' => 1, 'cache' => 2, 'notifications' => 3, 'test' => 4]);
+ }
+
+ public function testToArray(): void
+ {
+ $user = [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ];
+
+ $fruits = [
+ 'apple',
+ 'banana',
+ 'cherry',
+ 'banana',
+ ];
+
+ $this->assertSame($user, Arr::from($user));
+ $this->assertSame($fruits, Arr::from(new TraversableFixture($fruits)));
+ $this->assertSame($user, Arr::from(new ArrayableFixture($user)));
+ }
+
+ public function testFromThrowsOnInvalidType(): void
+ {
+ $this->expectException(UnexpectedValueException::class);
+ $this->expectExceptionMessage('Cannot convert to array an object of type string');
+ Arr::from('invalid_value');
+ }
+
+ public function testFromEntries(): void
+ {
+ $entries = [
+ ['name', 'Sempronius'],
+ ['email', 'sempronius@example.com'],
+ ['country', 'Italy'],
+ ];
+
+ $expected = [
+ 'name' => 'Sempronius',
+ 'email' => 'sempronius@example.com',
+ 'country' => 'Italy',
+ ];
+
+ $this->assertSame($expected, Arr::fromEntries($entries));
+ }
+}
diff --git a/tests/Utils/ConstraintTest.php b/tests/Utils/ConstraintTest.php
new file mode 100644
index 000000000..66e8769ee
--- /dev/null
+++ b/tests/Utils/ConstraintTest.php
@@ -0,0 +1,178 @@
+assertTrue(Constraint::isTruthy($value));
+ }
+
+ $nonTruthyValues = [false, 0, 'false', '0', 'off', 'no', null, '', [], 2, 'random'];
+ foreach ($nonTruthyValues as $nonTruthyValue) {
+ $this->assertFalse(Constraint::isTruthy($nonTruthyValue));
+ }
+ }
+
+ public function testIsFalsy(): void
+ {
+ $falsyValues = [false, 0, 'false', '0', 'off', 'no'];
+ foreach ($falsyValues as $value) {
+ $this->assertTrue(Constraint::isFalsy($value));
+ }
+
+ $nonFalsyValues = [true, 1, 'true', '1', 'on', 'yes', null, '', [], 2, 'random'];
+ foreach ($nonFalsyValues as $nonFalsyValue) {
+ $this->assertFalse(Constraint::isFalsy($nonFalsyValue));
+ }
+ }
+
+ public function testIsEmpty(): void
+ {
+ $emptyValues = [null, '', []];
+ foreach ($emptyValues as $value) {
+ $this->assertTrue(Constraint::isEmpty($value));
+ }
+
+ $nonEmptyValues = [false, 0, 'false', '0', 'off', 'no', true, 1, 'true', '1', 'on', 'yes', 2, 'random'];
+ foreach ($nonEmptyValues as $nonEmptyValue) {
+ $this->assertFalse(Constraint::isEmpty($nonEmptyValue));
+ }
+ }
+
+ public function testIsEqual(): void
+ {
+ // Strict comparison
+ $this->assertTrue(Constraint::isEqualTo(1, 1, true));
+ $this->assertFalse(Constraint::isEqualTo(1, '1', true));
+
+ // Non-strict comparison
+ $this->assertTrue(Constraint::isEqualTo(1, '1', false));
+ $this->assertFalse(Constraint::isEqualTo(1, 2, false));
+ }
+
+ public function testIsNotEqual(): void
+ {
+ // Strict comparison
+ $this->assertTrue(Constraint::isNotEqualTo(1, '1', true));
+ $this->assertFalse(Constraint::isNotEqualTo(1, 1, true));
+
+ // Non-strict comparison
+ $this->assertTrue(Constraint::isNotEqualTo(1, 2, false));
+ $this->assertFalse(Constraint::isNotEqualTo(1, '1', false));
+ }
+
+ public function testIsGreaterThan(): void
+ {
+ $this->assertTrue(Constraint::isGreaterThan(2, 1));
+ $this->assertFalse(Constraint::isGreaterThan(1, 1));
+ $this->assertFalse(Constraint::isGreaterThan(1, 2));
+ }
+
+ public function testIsGreaterThanOrEqual(): void
+ {
+ $this->assertTrue(Constraint::isGreaterThanOrEqualTo(2, 1));
+ $this->assertTrue(Constraint::isGreaterThanOrEqualTo(1, 1));
+ $this->assertFalse(Constraint::isGreaterThanOrEqualTo(1, 2));
+ }
+
+ public function testIsLessThan(): void
+ {
+ $this->assertTrue(Constraint::isLessThan(1, 2));
+ $this->assertFalse(Constraint::isLessThan(1, 1));
+ $this->assertFalse(Constraint::isLessThan(2, 1));
+ }
+
+ public function testIsLessThanOrEqual(): void
+ {
+ $this->assertTrue(Constraint::isLessThanOrEqualTo(1, 2));
+ $this->assertTrue(Constraint::isLessThanOrEqualTo(1, 1));
+ $this->assertFalse(Constraint::isLessThanOrEqualTo(2, 1));
+ }
+
+ public function testMatches(): void
+ {
+ $this->assertTrue(Constraint::matchesRegex('hello', '/^h.*o$/'));
+ $this->assertFalse(Constraint::matchesRegex('hello', '/^H.*O$/i'));
+ }
+
+ public function testMatchesWithoutEntireMatch(): void
+ {
+ $this->assertTrue(Constraint::matchesRegex('hello', '/e/', entireMatch: false));
+ $this->assertFalse(Constraint::matchesRegex('hello', '/e/', entireMatch: true));
+ }
+
+ public function testIsInRange(): void
+ {
+ $this->assertTrue(Constraint::isInRange(5.25, 1, 10));
+ $this->assertTrue(Constraint::isInRange(5, 1, 10));
+ $this->assertFalse(Constraint::isInRange(11, 1, 10));
+
+ $this->assertTrue(Constraint::isInRange(M_PI, 10, 1));
+ $this->assertFalse(Constraint::isInRange(-1, 10, 1));
+
+ $this->assertFalse(Constraint::isInRange(1, 1, 10, includeMin: false));
+ $this->assertFalse(Constraint::isInRange(10, 1, 10, includeMax: false));
+ }
+
+ public function testIsInRangeWithIntegerRange(): void
+ {
+ $this->assertTrue(Constraint::isInIntegerRange(5, 1, 10));
+ $this->assertFalse(Constraint::isInIntegerRange(11, 1, 10));
+ $this->assertFalse(Constraint::isInIntegerRange(-1, 10, 1));
+
+ $this->assertFalse(Constraint::isInIntegerRange(1, 1, 10, includeMin: false));
+ $this->assertFalse(Constraint::isInIntegerRange(10, 1, 10, includeMax: false));
+ }
+
+ public function testIsInRangeWithStep(): void
+ {
+ $this->assertTrue(Constraint::isInIntegerRange(4, 0, 10, step: 2));
+ $this->assertFalse(Constraint::isInIntegerRange(5, 0, 10, step: 2));
+ }
+
+ public function testIsType(): void
+ {
+ $this->assertTrue(Constraint::isOfType(123, 'int'));
+ $this->assertTrue(Constraint::isOfType('hello', 'string'));
+ $this->assertTrue(Constraint::isOfType([], 'array'));
+ $this->assertTrue(Constraint::isOfType(12.34, 'float'));
+ $this->assertTrue(Constraint::isOfType(true, 'bool'));
+ $this->assertTrue(Constraint::isOfType(new stdClass(), 'stdClass'));
+
+ $this->assertFalse(Constraint::isOfType(123, 'string'));
+ $this->assertFalse(Constraint::isOfType('hello', 'array'));
+ $this->assertFalse(Constraint::isOfType([], 'int'));
+ $this->assertFalse(Constraint::isOfType(12.34, 'bool'));
+ $this->assertFalse(Constraint::isOfType(true, 'float'));
+ $this->assertFalse(Constraint::isOfType(new stdClass(), 'array'));
+ }
+
+ public function testIsTypeWithUnionTypes(): void
+ {
+ $this->assertTrue(Constraint::isOfType(123, 'int|string', unionTypes: true));
+ $this->assertTrue(Constraint::isOfType('hello', 'int|string', unionTypes: true));
+ $this->assertTrue(Constraint::isOfType(new stdClass(), 'array|stdClass', unionTypes: true));
+
+ $this->assertFalse(Constraint::isOfType(12.34, 'int|string', unionTypes: true));
+ $this->assertFalse(Constraint::isOfType(new stdClass(), 'int|string', unionTypes: true));
+ }
+
+ public function testHasKeys(): void
+ {
+ $array = ['a' => 1, 'b' => 2, 'c' => 3];
+
+ $this->assertTrue(Constraint::hasKeys($array, ['a', 'b']));
+ $this->assertTrue(Constraint::hasKeys($array, []));
+ $this->assertFalse(Constraint::hasKeys($array, ['a', 'd']));
+ }
+}
diff --git a/tests/Utils/DateTest.php b/tests/Utils/DateTest.php
new file mode 100644
index 000000000..334efdc6c
--- /dev/null
+++ b/tests/Utils/DateTest.php
@@ -0,0 +1,98 @@
+ '%s fa',
+ 'date.distance.in' => 'tra %s',
+ 'date.duration.days' => ['giorno', 'giorni'],
+ 'date.duration.hours' => ['ora', 'ore'],
+ 'date.duration.minutes' => ['minuto', 'minuti'],
+ 'date.duration.months' => ['mese', 'mesi'],
+ 'date.duration.seconds' => ['secondo', 'secondi'],
+ 'date.duration.weeks' => ['settimana', 'settimane'],
+ 'date.duration.years' => ['anno', 'anni'],
+ 'date.months.long' => ['Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'],
+ 'date.months.short' => ['Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu', 'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic'],
+ 'date.now' => 'adesso',
+ 'date.weekdays.long' => ['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato'],
+ 'date.weekdays.short' => ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'],
+ ];
+
+ public function testToTimestamp(): void
+ {
+ $this->assertSame(1869436800, Date::toTimestamp('2029-03-29', 'Y-m-d'));
+ $this->assertSame(1869436800, Date::toTimestamp('29/03/2029', ['Y-m-d', 'd/m/Y']));
+ $this->assertSame(1869436800, Date::toTimestamp('2029-03-29', ''));
+ }
+
+ public function testToTimestampThrowsOnInvalidDate(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ Date::toTimestamp('invalid-date', 'Y-m-d');
+ }
+
+ public function testFormatToPattern(): void
+ {
+ $this->assertSame('YYYY-MM-DD', Date::formatToPattern('Y-m-d'));
+ $this->assertSame('DD/MM/YYYY hh:mm:ss', Date::formatToPattern('d/m/Y H:i:s'));
+ $this->assertSame('DD/MM/YYYY [at] hh:mm:ss', Date::formatToPattern('d/m/Y \a\t H:i:s'));
+ }
+
+ public function testPatternToFormat(): void
+ {
+ $this->assertSame('Y-m-d', Date::patternToFormat('YYYY-MM-DD'));
+ $this->assertSame('d/m/Y H:i:s', Date::patternToFormat('DD/MM/YYYY hh:mm:ss'));
+ $this->assertSame('l d F Y \a\t h:i:s A \o\' \c\l\o\c\k', Date::patternToFormat('DDDD DD MMMM YYYY [at] HH:mm:ss A [o\' clock]'));
+ }
+
+ public function testFormat(): void
+ {
+ $dateTime = new DateTime('2029-03-29 15:30:00');
+
+ $translation = new Translation('it', $this->translation);
+
+ $this->assertSame('Gio, 29 Mar 2029 15:30:00 +0000', Date::formatDateTime(new DateTime('2029-03-29 15:30:00'), 'r', $translation));
+ $this->assertSame('Giovedì 29 Marzo 2029', Date::formatDateTime($dateTime, 'l d F Y', $translation));
+ }
+
+ public function testFormatWithTimestamp(): void
+ {
+ $translation = new Translation('it', $this->translation);
+
+ $this->assertSame('Gio, 29 Mar 2029 10:10:00 +0000', Date::formatTimestamp(1869473400, 'r', $translation));
+ $this->assertSame('Giovedì 29 Marzo 2029', Date::formatTimestamp(1869473400, 'l d F Y', $translation));
+ }
+
+ public function testFormatDistance(): void
+ {
+ $translation = new Translation('it', $this->translation);
+
+ $now = time();
+
+ $this->assertSame('adesso', Date::formatDateTimeAsDistance(new DateTime('@' . $now), $translation, $now));
+ $this->assertSame('5 giorni fa', Date::formatDateTimeAsDistance(new DateTime('@' . ($now - 5 * 86400)), $translation, $now));
+ $this->assertSame('tra 3 ore', Date::formatDateTimeAsDistance(new DateTime('@' . ($now + 3 * 3600)), $translation, $now));
+ }
+
+ public function testFormatDistanceWithTimestamp(): void
+ {
+ $translation = new Translation('it', $this->translation);
+
+ $now = time();
+
+ $this->assertSame('adesso', Date::formatTimestampAsDistance($now, $translation, $now));
+ $this->assertSame('2 mesi fa', Date::formatTimestampAsDistance($now - 60 * 86400, $translation, $now));
+ $this->assertSame('tra 10 minuti', Date::formatTimestampAsDistance($now + 10 * 60, $translation, $now));
+ }
+}
diff --git a/tests/Utils/FileSystemTest.php b/tests/Utils/FileSystemTest.php
new file mode 100644
index 000000000..22c41181a
--- /dev/null
+++ b/tests/Utils/FileSystemTest.php
@@ -0,0 +1,970 @@
+tearDownTempDirectory();
+ }
+
+ public function testNormalize(): void
+ {
+ $this->assertSame('path/to/directory', FileSystem::normalizePath('path\to/directory'));
+ $this->assertSame('path/to/directory', FileSystem::normalizePath('path//to\directory'));
+ }
+
+ public function testJoinPaths(): void
+ {
+ $this->assertSame('path/to/directory/file.txt', FileSystem::joinPaths('path/to', 'directory', 'file.txt'));
+ $this->assertSame('path/to/directory/file.txt', FileSystem::joinPaths('path\to\\', '/directory/', '\file.txt'));
+ }
+
+ public function testResolve(): void
+ {
+ $this->assertSame('/var/www/html', FileSystem::resolvePath('/var/www/html/../html'));
+ $this->assertSame('C:/Projects/Formwork', FileSystem::resolvePath('C:\Projects\Formwork\..\Formwork'));
+ }
+
+ public function testBasename(): void
+ {
+ $this->assertSame('file', FileSystem::name('/path/to/file.txt'));
+ $this->assertSame('directory', FileSystem::name('/path/to/directory/'));
+ }
+
+ public function testExtension(): void
+ {
+ $this->assertSame('txt', FileSystem::extension('/path/to/file.txt'));
+ $this->assertSame('', FileSystem::extension('/path/to/directory/'));
+ }
+
+ public function testCwd(): void
+ {
+ $this->assertSame(getcwd(), FileSystem::cwd());
+ }
+
+ #[RunInSeparateProcess]
+ public function testCwdThrowsOnUnresolved(): void
+ {
+ FileSystemFixture::disable('cwd');
+ $this->expectException(FileSystemException::class);
+ $this->expectExceptionMessage('Cannot get current working directory');
+ FileSystem::cwd();
+ }
+
+ public function testIsVisible(): void
+ {
+ $this->assertTrue(FileSystem::isVisible('/path/to/visibleFile.txt'));
+ $this->assertFalse(FileSystem::isVisible('/path/to/.hiddenFile.txt'));
+ }
+
+ public function testMimeType(): void
+ {
+ $this->assertSame('text/plain', FileSystem::mimeType(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testExists(): void
+ {
+ $this->assertTrue(FileSystem::exists(TESTS_TMP_PATH . '/sample.txt'));
+ $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/nonexistent.txt'));
+ }
+
+ public function testAssertExists(): void
+ {
+ FileSystem::assertExists(TESTS_TMP_PATH . '/sample.txt');
+ $this->assertTrue(true);
+
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::assertExists(TESTS_TMP_PATH . '/nonexistent.txt');
+ }
+
+ public function testAssertNotExists(): void
+ {
+ FileSystem::assertExists(TESTS_TMP_PATH . '/nonexistent.txt', false);
+ $this->assertTrue(true);
+
+ $this->expectException(FileSystemException::class);
+ FileSystem::assertExists(TESTS_TMP_PATH . '/sample.txt', false);
+ }
+
+ public function testIsReadable(): void
+ {
+ $this->assertTrue(FileSystem::isReadable(TESTS_TMP_PATH . '/sample.txt'));
+
+ $mode = fileperms(TESTS_TMP_PATH . '/sample.txt');
+ chmod(TESTS_TMP_PATH . '/sample.txt', $mode & ~0o444);
+
+ $this->assertFalse(FileSystem::isReadable(TESTS_TMP_PATH . '/sample.txt'));
+
+ chmod(TESTS_TMP_PATH . '/sample.txt', $mode);
+ }
+
+ public function testIsWritable(): void
+ {
+ $this->assertTrue(FileSystem::isWritable(TESTS_TMP_PATH . '/sample.txt'));
+
+ $mode = fileperms(TESTS_TMP_PATH . '/sample.txt');
+ chmod(TESTS_TMP_PATH . '/sample.txt', $mode & ~0o222);
+
+ $this->assertFalse(FileSystem::isWritable(TESTS_TMP_PATH . '/sample.txt'));
+
+ chmod(TESTS_TMP_PATH . '/sample.txt', $mode);
+ }
+
+ public function testIsFile(): void
+ {
+ $this->assertTrue(FileSystem::isFile(TESTS_TMP_PATH . '/sample.txt'));
+ $this->assertFalse(FileSystem::isFile(TESTS_TMP_PATH . '/dir'));
+ }
+
+ public function testIsDirectory(): void
+ {
+ $this->assertTrue(FileSystem::isDirectory(TESTS_TMP_PATH . '/dir'));
+ $this->assertTrue(FileSystem::isDirectory(TESTS_TMP_PATH . '/emptydir'));
+ $this->assertFalse(FileSystem::isDirectory(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testIsEmptyDirectory(): void
+ {
+ $this->assertTrue(FileSystem::isEmptyDirectory(TESTS_TMP_PATH . '/emptydir'));
+ $this->assertFalse(FileSystem::isEmptyDirectory(TESTS_TMP_PATH . '/dir'));
+ $this->assertFalse(FileSystem::isEmptyDirectory(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testIsLink(): void
+ {
+ $this->assertTrue(FileSystem::isLink(TESTS_TMP_PATH . '/symlink'));
+ $this->assertFalse(FileSystem::isLink(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testAccessTime(): void
+ {
+ $this->assertIsInt(FileSystem::accessTime(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testAccessTimeThrowsOnFileNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::accessTime(TESTS_TMP_PATH . '/nonexistent.txt');
+ }
+
+ #[RunInSeparateProcess]
+ public function testAccessTimeThrowsOnSystemError(): void
+ {
+ FileSystemFixture::disable('fileatime');
+ $this->expectException(FileSystemException::class);
+ FileSystem::accessTime(TESTS_TMP_PATH . '/dir/sample.txt');
+ }
+
+ public function testCreationTime(): void
+ {
+ $this->assertIsInt(FileSystem::creationTime(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testCreationTimeThrowsOnFileNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::creationTime(TESTS_TMP_PATH . '/nonexistent.txt');
+ }
+
+ #[RunInSeparateProcess]
+ public function testCreationTimeThrowsOnSystemError(): void
+ {
+ FileSystemFixture::disable('filectime');
+ $this->expectException(FileSystemException::class);
+ FileSystem::creationTime(TESTS_TMP_PATH . '/dir/sample.txt');
+ }
+
+ public function testLastModifiedTime(): void
+ {
+ $this->assertIsInt(FileSystem::lastModifiedTime(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testLastModifiedTimeThrowsOnNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::lastModifiedTime(TESTS_TMP_PATH . '/nonexistent.txt');
+ }
+
+ #[RunInSeparateProcess]
+ public function testLastModifiedTimeThrowsOnSystemError(): void
+ {
+ FileSystemFixture::disable('filemtime');
+ $this->expectException(FileSystemException::class);
+ FileSystem::lastModifiedTime(TESTS_TMP_PATH . '/dir/sample.txt');
+ }
+
+ public function testDirectoryModifiedSince(): void
+ {
+ $this->assertTrue(FileSystem::directoryModifiedSince(TESTS_TMP_PATH . '/dir', 0));
+ $this->assertFalse(FileSystem::directoryModifiedSince(TESTS_TMP_PATH . '/dir', time() + 3600));
+ }
+
+ public function testDirectoryModifiedSinceRecursively(): void
+ {
+ touch(TESTS_TMP_PATH . '/dir', 0);
+ touch(TESTS_TMP_PATH . '/dir/subdir', 0);
+ $this->assertTrue(FileSystem::directoryModifiedSince(TESTS_TMP_PATH . '/dir', 10));
+ }
+
+ public function testDirectoryModifiedThrowsOnNotDir(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::directoryModifiedSince(TESTS_TMP_PATH . '/sample.txt', 0);
+ }
+
+ public function testTouch(): void
+ {
+ $this->assertTrue(FileSystem::touch(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testTouchThrowsOnFileNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::touch(TESTS_TMP_PATH . '/nonexistent.txt');
+ }
+
+ #[RunInSeparateProcess]
+ public function testTouchThrowsOnSystemError(): void
+ {
+ FileSystemFixture::disable('touch');
+ $this->expectException(FileSystemException::class);
+ FileSystem::touch(TESTS_TMP_PATH . '/sample.txt');
+ }
+
+ public function testMode(): void
+ {
+ $this->assertIsInt(FileSystem::mode(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testModeThrowsOnFileNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::mode(TESTS_TMP_PATH . '/nonexistent.txt');
+ }
+
+ #[RunInSeparateProcess]
+ public function testModeThrowsOnSystemError(): void
+ {
+ FileSystemFixture::disable('fileperms');
+ $this->expectException(FileSystemException::class);
+ FileSystem::mode(TESTS_TMP_PATH . '/sample.txt');
+ }
+
+ public function testSize(): void
+ {
+ $this->assertIsInt(FileSystem::size(TESTS_TMP_PATH . '/sample.txt'));
+ $this->assertIsInt(FileSystem::size(TESTS_TMP_PATH . '/dir'));
+ }
+
+ public function testSizeThrowsOnUnsupportedType(): void
+ {
+ $this->expectException(FileSystemException::class);
+ $this->expectExceptionMessage('unsupported file type');
+ FileSystem::size('/dev/null');
+ }
+
+ public function testFileSizeReturnsSize(): void
+ {
+ $this->assertIsInt(FileSystem::fileSize(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testFileSizeThrowsOnNotFile(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::fileSize(TESTS_TMP_PATH . '/dir');
+ }
+
+ public function testFileSizeThrowsOnFileNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::fileSize(TESTS_TMP_PATH . '/nonexistent.txt');
+ }
+
+ #[RunInSeparateProcess]
+ public function testFileSizeThrowsOnSystemError(): void
+ {
+ FileSystemFixture::disable('filesize');
+ $this->expectException(FileSystemException::class);
+ FileSystem::fileSize(TESTS_TMP_PATH . '/sample.txt');
+ }
+
+ public function testDirectorySizeReturnsSize(): void
+ {
+ $this->assertIsInt(FileSystem::directorySize(TESTS_TMP_PATH . '/dir'));
+ }
+
+ public function testDirectorySizeThrowsOnNotDir(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::directorySize(TESTS_TMP_PATH . '/sample.txt');
+ }
+
+ public function testDirectorySizeThrowsOnNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::directorySize(TESTS_TMP_PATH . '/nonexistentdir');
+ }
+
+ #[RunInSeparateProcess]
+ public function testDirectorySizeThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('filesize');
+ $this->expectException(FileSystemException::class);
+ FileSystem::directorySize(TESTS_TMP_PATH . '/dir');
+ }
+
+ public function testDelete(): void
+ {
+ $this->assertTrue(FileSystem::delete(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testDeleteThrowsOnUnsupportedType(): void
+ {
+ // Create a FIFO special file for testing
+ posix_mkfifo(TESTS_TMP_PATH . '/myfifo', 0o600);
+
+ $this->expectException(FileSystemException::class);
+ $this->expectExceptionMessage('unsupported file type');
+
+ try {
+ FileSystem::delete(TESTS_TMP_PATH . '/myfifo');
+ } finally {
+ // Clean up the FIFO file
+ unlink(TESTS_TMP_PATH . '/myfifo');
+ }
+ }
+
+ public function testDeleteFile(): void
+ {
+ $this->assertTrue(FileSystem::deleteFile(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testDeleteFileThrowsOnNotFile(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::deleteFile(TESTS_TMP_PATH . '/dir');
+ }
+
+ public function testDeleteFileThrowsOnFileNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::deleteFile(TESTS_TMP_PATH . '/nonexistent.txt');
+ }
+
+ #[RunInSeparateProcess]
+ public function testDeleteFileThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('unlink');
+ $this->expectException(FileSystemException::class);
+ FileSystem::deleteFile(TESTS_TMP_PATH . '/sample.txt');
+ }
+
+ public function testDeleteDirectory(): void
+ {
+ $this->assertTrue(FileSystem::deleteDirectory(TESTS_TMP_PATH . '/emptydir'));
+ }
+
+ public function testDeleteDirectoryThrowsOnNotEmpty(): void
+ {
+ $this->expectException(FileSystemException::class);
+ $this->expectExceptionMessage('must be empty to be deleted');
+ FileSystem::deleteDirectory(TESTS_TMP_PATH . '/dir');
+ }
+
+ public function testDeleteDirectoryRecursively(): void
+ {
+ $this->assertTrue(FileSystem::deleteDirectory(TESTS_TMP_PATH . '/dir', recursive: true));
+ }
+
+ public function testDeleteDirectoryThrowsOnNotDir(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::deleteDirectory(TESTS_TMP_PATH . '/sample.txt');
+ }
+
+ public function testDeleteDirectoryThrowsOnNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::deleteDirectory(TESTS_TMP_PATH . '/nonexistentdir');
+ }
+
+ #[RunInSeparateProcess]
+ public function testDeleteDirectoryThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('rmdir');
+ $this->expectException(FileSystemException::class);
+ FileSystem::deleteDirectory(TESTS_TMP_PATH . '/emptydir');
+ }
+
+ public function testDeleteLink(): void
+ {
+ $this->assertTrue(FileSystem::deleteLink(TESTS_TMP_PATH . '/symlink'));
+ }
+
+ public function testDeleteLinkThrowsOnNotLink(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::deleteLink(TESTS_TMP_PATH . '/sample.txt');
+ }
+
+ public function testDeleteLinkThrowsOnLinkNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::deleteLink(TESTS_TMP_PATH . '/nonexistentlink');
+ }
+
+ #[RunInSeparateProcess]
+ public function testDeleteLinkThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('unlink');
+ $this->expectException(FileSystemException::class);
+ FileSystem::deleteLink(TESTS_TMP_PATH . '/symlink');
+ }
+
+ public function testCopy(): void
+ {
+ $this->assertTrue(FileSystem::copy(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_copy.txt'));
+
+ $this->assertTrue(FileSystem::copy(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_copy'));
+
+ $this->assertTrue(FileSystem::copy(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_copy'));
+ }
+
+ public function testCopyThrowsOnUnsupportedType(): void
+ {
+ $this->expectException(FileSystemException::class);
+ $this->expectExceptionMessage('unsupported file type');
+ FileSystem::copy('/dev/null', TESTS_TMP_PATH . '/null_copy');
+ }
+
+ public function testCopyFile(): void
+ {
+ $this->assertTrue(FileSystem::copyFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_copy.txt'));
+ }
+
+ public function testCopyFileThrowsOnNotFile(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::copyFile(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_copy');
+ }
+
+ public function testCopyFileThrowsOnFileNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::copyFile(TESTS_TMP_PATH . '/nonexistent.txt', TESTS_TMP_PATH . '/nonexistent_copy.txt');
+ }
+
+ public function testCopyFileThrowsOnDestExists(): void
+ {
+ $this->expectException(FileSystemException::class);
+ FileSystem::copyFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample.txt');
+ }
+
+ #[RunInSeparateProcess]
+ public function testCopyFileThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('copy');
+ $this->expectException(FileSystemException::class);
+ FileSystem::copyFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_copy.txt');
+ }
+
+ public function testCopyDirectory(): void
+ {
+ $this->assertTrue(FileSystem::copyDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_copy'));
+ }
+
+ public function testCopyDirectoryThrowsOnNotDir(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::copyDirectory(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_copy.txt');
+ }
+
+ public function testCopyDirectoryThrowsOnNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::copyDirectory(TESTS_TMP_PATH . '/nonexistentdir', TESTS_TMP_PATH . '/nonexistentdir_copy');
+ }
+
+ public function testCopyDirectoryThrowsOnDestExists(): void
+ {
+ $this->expectException(FileSystemException::class);
+ FileSystem::copyDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir');
+ }
+
+ #[RunInSeparateProcess]
+ public function testCopyDirectoryThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('copy');
+ $this->expectException(FileSystemException::class);
+ FileSystem::copyDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_copy');
+ }
+
+ public function testCopyLink(): void
+ {
+ $this->assertTrue(FileSystem::copyLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_copy'));
+ }
+
+ public function testCopyLinkWithOverwrite(): void
+ {
+ $this->assertTrue(FileSystem::copyLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/dir/b.txt', overwrite: true));
+ }
+
+ public function testCopyLinkThrowsOnNotLink(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::copyLink(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_link');
+ }
+
+ public function testCopyLinkThrowsOnLinkNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::copyLink(TESTS_TMP_PATH . '/nonexistentlink', TESTS_TMP_PATH . '/nonexistentlink_copy');
+ }
+
+ public function testCopyLinkThrowsOnDestExists(): void
+ {
+ $this->expectException(FileSystemException::class);
+ FileSystem::copyLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink');
+ }
+
+ #[RunInSeparateProcess]
+ public function testCopyLinkThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('symlink');
+ $this->expectException(FileSystemException::class);
+ FileSystem::copyLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_copy');
+ }
+
+ public function testMove(): void
+ {
+ $this->assertTrue(FileSystem::move(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_moved.txt'));
+ $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/sample.txt'));
+
+ $this->assertTrue(FileSystem::move(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_moved'));
+ $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/dir'));
+
+ $this->assertTrue(FileSystem::move(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_moved'));
+ $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/symlink'));
+ }
+
+ public function testMoveThrowsOnUnsupportedType(): void
+ {
+ $this->expectException(FileSystemException::class);
+ $this->expectExceptionMessage('unsupported file type');
+ FileSystem::move('/dev/null', TESTS_TMP_PATH . '/null_moved');
+ }
+
+ public function testMoveFile(): void
+ {
+ $this->assertTrue(FileSystem::moveFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_moved.txt'));
+ $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testMoveFileThrowsOnNotFile(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::moveFile(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_moved');
+ }
+
+ public function testMoveFileThrowsOnFileNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::moveFile(TESTS_TMP_PATH . '/nonexistent.txt', TESTS_TMP_PATH . '/nonexistent_moved.txt');
+ }
+
+ public function testMoveFileThrowsOnDestExists(): void
+ {
+ $this->expectException(FileSystemException::class);
+ FileSystem::moveFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample.txt');
+ }
+
+ #[RunInSeparateProcess]
+ public function testMoveFileThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('rename');
+ $this->expectException(FileSystemException::class);
+ FileSystem::moveFile(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_moved.txt');
+ }
+
+ public function testMoveDirectory(): void
+ {
+ $this->assertTrue(FileSystem::moveDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_moved'));
+ $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/dir'));
+ }
+
+ public function testMoveDirectoryThrowsOnNotDir(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::moveDirectory(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_moved.txt');
+ }
+
+ public function testMoveDirectoryThrowsOnNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::moveDirectory(TESTS_TMP_PATH . '/nonexistentdir', TESTS_TMP_PATH . '/nonexistentdir_moved');
+ }
+
+ public function testMoveDirectoryThrowsOnDestExists(): void
+ {
+ $this->expectException(FileSystemException::class);
+ FileSystem::moveDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir');
+ }
+
+ #[RunInSeparateProcess]
+ public function testMoveDirectoryThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('copy');
+ $this->expectException(FileSystemException::class);
+ FileSystem::moveDirectory(TESTS_TMP_PATH . '/dir', TESTS_TMP_PATH . '/dir_moved');
+ }
+
+ public function testMoveLink(): void
+ {
+ $this->assertTrue(FileSystem::moveLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_moved'));
+ $this->assertFalse(FileSystem::exists(TESTS_TMP_PATH . '/symlink'));
+ }
+
+ public function testMoveLinkThrowsOnNotLink(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::moveLink(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/sample_moved');
+ }
+
+ public function testMoveLinkThrowsOnLinkNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::moveLink(TESTS_TMP_PATH . '/nonexistentlink', TESTS_TMP_PATH . '/nonexistentlink_moved');
+ }
+
+ public function testMoveLinkThrowsOnDestExists(): void
+ {
+ $this->expectException(FileSystemException::class);
+ FileSystem::moveLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink');
+ }
+
+ #[RunInSeparateProcess]
+ public function testMoveLinkThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('symlink');
+ $this->expectException(FileSystemException::class);
+ FileSystem::moveLink(TESTS_TMP_PATH . '/symlink', TESTS_TMP_PATH . '/symlink_moved');
+ }
+
+ public function testRead(): void
+ {
+ $this->assertSame("This is a sample text file for testing purposes.\n", FileSystem::read(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testReadThrowsOnNotFile(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::read(TESTS_TMP_PATH . '/dir');
+ }
+
+ public function testReadThrowsOnFileNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::read(TESTS_TMP_PATH . '/nonexistent.txt');
+ }
+
+ public function testReadThrowsOnFileUnreadable(): void
+ {
+ $mode = fileperms(TESTS_TMP_PATH . '/sample.txt');
+ chmod(TESTS_TMP_PATH . '/sample.txt', $mode & ~0o444);
+
+ $this->expectException(FileSystemException::class);
+ FileSystem::read(TESTS_TMP_PATH . '/sample.txt');
+
+ chmod(TESTS_TMP_PATH . '/sample.txt', $mode);
+ }
+
+ #[RunInSeparateProcess]
+ public function testReadThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('file_get_contents');
+ $this->expectException(FileSystemException::class);
+ FileSystem::read(TESTS_TMP_PATH . '/sample.txt');
+ }
+
+ public function testListContents(): void
+ {
+ $contents = iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/dir'));
+ $this->assertCount(3, $contents);
+ }
+
+ public function testListContentsIncludingHidden(): void
+ {
+ $contents = iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/dir', FileSystem::LIST_ALL));
+ $this->assertContains('.hidden', $contents);
+ }
+
+ public function testListContentsFilesOnly(): void
+ {
+ $contents = iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/dir', FileSystem::LIST_FILES));
+ $this->assertNotContains('subdir', $contents);
+ }
+
+ public function testListContentsDirectoriesOnly(): void
+ {
+ $contents = iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/dir', FileSystem::LIST_DIRECTORIES));
+ $this->assertNotContains('.hidden', $contents);
+ $this->assertNotContains('b.txt', $contents);
+ $this->assertNotContains('sample.txt', $contents);
+ }
+
+ public function testListContentsExcludingEmptyDirectories(): void
+ {
+ $contents = iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH, FileSystem::LIST_VISIBLE | FileSystem::LIST_EXCLUDE_EMPTY_DIRECTORIES));
+ $this->assertNotContains('emptydir', $contents);
+ }
+
+ public function testListContentsThrowsOnNotDir(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testListContentsThrowsOnNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/nonexistentdir'));
+ }
+
+ #[RunInSeparateProcess]
+ public function testListContentsThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('opendir');
+ $this->expectException(FileSystemException::class);
+ iterator_to_array(FileSystem::listContents(TESTS_TMP_PATH . '/dir'));
+ }
+
+ public function testListContentsRecursively(): void
+ {
+ $contents = iterator_to_array(FileSystem::listRecursive(TESTS_TMP_PATH));
+ $this->assertContains('dir/subdir/a.txt', $contents);
+ }
+
+ public function testListRecursiveThrowsOnNotDir(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ iterator_to_array(FileSystem::listRecursive(TESTS_TMP_PATH . '/sample.txt'));
+ }
+
+ public function testListFiles(): void
+ {
+ $files = iterator_to_array(FileSystem::listFiles(TESTS_TMP_PATH . '/dir'));
+ $this->assertContains('b.txt', $files);
+ $this->assertContains('sample.txt', $files);
+ $this->assertNotContains('subdir', $files);
+ $this->assertNotContains('.hidden', $files);
+ }
+
+ public function testListFilesIncludingHidden(): void
+ {
+ $files = iterator_to_array(FileSystem::listFiles(TESTS_TMP_PATH . '/dir', includeHidden: true));
+ $this->assertContains('.hidden', $files);
+ }
+
+ public function testListDirectories(): void
+ {
+ $dirs = iterator_to_array(FileSystem::listDirectories(TESTS_TMP_PATH . '/dir'));
+ $this->assertContains('subdir', $dirs);
+ $this->assertNotContains('b.txt', $dirs);
+ $this->assertNotContains('sample.txt', $dirs);
+ $this->assertNotContains('.hidden', $dirs);
+ }
+
+ public function testListDirectoriesIncludingHidden(): void
+ {
+ $dirs = iterator_to_array(FileSystem::listDirectories(TESTS_TMP_PATH . '/dir', includeHidden: true));
+ $this->assertContains('.hiddendir', $dirs);
+ }
+
+ public function testListDirectoriesExcludingEmpty(): void
+ {
+ $dirs = iterator_to_array(FileSystem::listDirectories(TESTS_TMP_PATH, includeEmpty: false));
+ $this->assertNotContains('emptydir', $dirs);
+ }
+
+ public function testReadLink(): void
+ {
+ $this->assertSame('sample.txt', FileSystem::readLink(TESTS_TMP_PATH . '/symlink'));
+ }
+
+ public function testReadLinkThrowsOnNotLink(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::readLink(TESTS_TMP_PATH . '/sample.txt');
+ }
+
+ public function testReadLinkThrowsOnLinkNotFound(): void
+ {
+ $this->expectException(FileNotFoundException::class);
+ FileSystem::readLink(TESTS_TMP_PATH . '/nonexistentlink');
+ }
+
+ #[RunInSeparateProcess]
+ public function testReadLinkThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('readlink');
+ $this->expectException(FileSystemException::class);
+ FileSystem::readLink(TESTS_TMP_PATH . '/symlink');
+ }
+
+ public function testCreateFile(): void
+ {
+ $this->assertTrue(FileSystem::createFile(TESTS_TMP_PATH . '/newfile.txt'));
+ $this->assertTrue(FileSystem::exists(TESTS_TMP_PATH . '/newfile.txt'));
+ }
+
+ #[RunInSeparateProcess]
+ public function testCreateFileThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('fopen');
+ $this->expectException(FileSystemException::class);
+ FileSystem::createFile(TESTS_TMP_PATH . '/newfile.txt');
+ }
+
+ public function testCreateTemporaryFile(): void
+ {
+ $tempFile = FileSystem::createTemporaryFile(TESTS_TMP_PATH, '_tmp');
+ $this->assertTrue(FileSystem::exists($tempFile));
+ $this->assertStringStartsWith('_tmp', basename($tempFile));
+ }
+
+ #[RunInSeparateProcess]
+ public function testCreateTempFileThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('fopen');
+ $this->expectException(FileSystemException::class);
+ FileSystem::createTemporaryFile(TESTS_TMP_PATH, '_tmp');
+ }
+
+ public function testWrite(): void
+ {
+ FileSystem::write(TESTS_TMP_PATH . '/newfile.txt', 'Hello, World!');
+ $this->assertSame('Hello, World!', FileSystem::read(TESTS_TMP_PATH . '/newfile.txt'));
+ }
+
+ public function testWriteToExistingFile(): void
+ {
+ chmod(TESTS_TMP_PATH . '/sample.txt', 0o644);
+ FileSystem::write(TESTS_TMP_PATH . '/sample.txt', 'Hello, World!');
+ $this->assertSame('Hello, World!', FileSystem::read(TESTS_TMP_PATH . '/sample.txt'));
+ $this->assertSame(0o644, FileSystem::mode(TESTS_TMP_PATH . '/sample.txt') & 0o777);
+ }
+
+ public function testWriteThrowsOnNotFile(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::write(TESTS_TMP_PATH . '/dir', 'Hello, World!');
+ }
+
+ public function testWriteThrowsOnFileUnwritable(): void
+ {
+ $mode = fileperms(TESTS_TMP_PATH . '/sample.txt');
+ chmod(TESTS_TMP_PATH . '/sample.txt', $mode & ~0o222);
+
+ $this->expectException(FileSystemException::class);
+ FileSystem::write(TESTS_TMP_PATH . '/sample.txt', 'Hello, World!');
+
+ chmod(TESTS_TMP_PATH . '/sample.txt', $mode);
+ }
+
+ #[RunInSeparateProcess]
+ public function testWriteThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('file_put_contents');
+ $this->expectException(FileSystemException::class);
+ FileSystem::write(TESTS_TMP_PATH . '/newfile.txt', 'Hello, World!');
+ }
+
+ public function testCreateDirectory(): void
+ {
+ $this->assertTrue(FileSystem::createDirectory(TESTS_TMP_PATH . '/newdir'));
+ $this->assertTrue(FileSystem::exists(TESTS_TMP_PATH . '/newdir'));
+ }
+
+ #[RunInSeparateProcess]
+ public function testCreateDirectoryThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('mkdir');
+ $this->expectException(FileSystemException::class);
+ FileSystem::createDirectory(TESTS_TMP_PATH . '/newdir');
+ }
+
+ public function testCreateLink(): void
+ {
+ $this->assertTrue(FileSystem::createLink(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/newlink'));
+ $this->assertTrue(FileSystem::isLink(TESTS_TMP_PATH . '/newlink'));
+ }
+
+ #[RunInSeparateProcess]
+ public function testCreateLinkThrowsExceptionOnSystemError(): void
+ {
+ FileSystemFixture::disable('symlink');
+ $this->expectException(FileSystemException::class);
+ FileSystem::createLink(TESTS_TMP_PATH . '/sample.txt', TESTS_TMP_PATH . '/newlink');
+ }
+
+ public function testFormatSize(): void
+ {
+ $this->assertSame('0 B', FileSystem::formatSize(-1));
+ $this->assertSame('0 B', FileSystem::formatSize(0));
+ $this->assertSame('1 B', FileSystem::formatSize(1));
+ $this->assertSame('1 KB', FileSystem::formatSize(1024));
+ $this->assertSame('1 MB', FileSystem::formatSize(1024 ** 2));
+ $this->assertSame('1 GB', FileSystem::formatSize(1024 ** 3));
+ $this->assertSame('1 TB', FileSystem::formatSize(1024 ** 4));
+ $this->assertSame('1024 TB', FileSystem::formatSize(1024 ** 5));
+ }
+
+ public function testShorthandToBytes(): void
+ {
+ $this->assertSame(1048576, FileSystem::shorthandToBytes('1M'));
+ $this->assertSame(1073741824, FileSystem::shorthandToBytes('1G'));
+ $this->assertSame(512, FileSystem::shorthandToBytes('512'));
+ $this->assertSame(2048, FileSystem::shorthandToBytes('2K'));
+ }
+
+ public function testShorthandToBytesThrowsOnInvalid(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ FileSystem::shorthandToBytes('1T');
+ }
+
+ public function testRandomName(): void
+ {
+ $name1 = FileSystem::randomName();
+ $name2 = FileSystem::randomName();
+ $this->assertNotSame($name1, $name2);
+ $this->assertSame(16, strlen($name1));
+ $this->assertSame(16, strlen($name2));
+ }
+
+ public function testRandomNameWithPrefix(): void
+ {
+ $name = FileSystem::randomName('test_');
+ $this->assertStringStartsWith('test_', $name);
+ }
+}
diff --git a/tests/Utils/Fixtures/ArrayableFixture.php b/tests/Utils/Fixtures/ArrayableFixture.php
new file mode 100644
index 000000000..d41f6643f
--- /dev/null
+++ b/tests/Utils/Fixtures/ArrayableFixture.php
@@ -0,0 +1,15 @@
+data;
+ }
+}
diff --git a/tests/Utils/Fixtures/FileSystemFixture.php b/tests/Utils/Fixtures/FileSystemFixture.php
new file mode 100644
index 000000000..d398105eb
--- /dev/null
+++ b/tests/Utils/Fixtures/FileSystemFixture.php
@@ -0,0 +1,195 @@
+ true,
+ 'fileatime' => true,
+ 'filectime' => true,
+ 'filemtime' => true,
+ 'touch' => true,
+ 'fileperms' => true,
+ 'filesize' => true,
+ 'unlink' => true,
+ 'rmdir' => true,
+ 'copy' => true,
+ 'symlink' => true,
+ 'readlink' => true,
+ 'rename' => true,
+ 'file_get_contents' => true,
+ 'file_put_contents' => true,
+ 'opendir' => true,
+ 'fopen' => true,
+ 'mkdir' => true,
+ ];
+
+ public static function enable(string $function): void
+ {
+ self::$enabledFunctions[$function] = true;
+ }
+
+ public static function disable(string $function): void
+ {
+ self::$enabledFunctions[$function] = false;
+ }
+
+ public static function enableAll(): void
+ {
+ foreach (array_keys(self::$enabledFunctions) as $function) {
+ self::enable($function);
+ }
+ }
+
+ public static function disableAll(): void
+ {
+ foreach (array_keys(self::$enabledFunctions) as $function) {
+ self::disable($function);
+ }
+ }
+
+ public static function cwd(): string|false
+ {
+ if (self::$enabledFunctions['cwd']) {
+ return getcwd();
+ }
+ return false;
+ }
+
+ public static function fileatime(string $filename): int|false
+ {
+ if (self::$enabledFunctions['fileatime']) {
+ return fileatime($filename);
+ }
+ return false;
+ }
+
+ public static function filectime(string $filename): int|false
+ {
+ if (self::$enabledFunctions['filectime']) {
+ return filectime($filename);
+ }
+ return false;
+ }
+
+ public static function filemtime(string $filename): int|false
+ {
+ if (self::$enabledFunctions['filemtime']) {
+ return filemtime($filename);
+ }
+ return false;
+ }
+
+ public static function touch(string $filename, ?int $time = null, ?int $atime = null): bool
+ {
+ if (!self::$enabledFunctions['touch']) {
+ return false;
+ }
+ return touch($filename, $time, $atime);
+ }
+
+ public static function fileperms(string $filename): int|false
+ {
+ if (self::$enabledFunctions['fileperms']) {
+ return fileperms($filename);
+ }
+ return false;
+ }
+
+ public static function filesize(string $filename): int|false
+ {
+ if (self::$enabledFunctions['filesize']) {
+ return filesize($filename);
+ }
+ return false;
+ }
+
+ public static function unlink(string $filename): bool
+ {
+ if (!self::$enabledFunctions['unlink']) {
+ return false;
+ }
+ return unlink($filename);
+ }
+
+ public static function rmdir(string $dirname): bool
+ {
+ if (!self::$enabledFunctions['rmdir']) {
+ return false;
+ }
+ return rmdir($dirname);
+ }
+
+ public static function copy(string $from, string $to): bool
+ {
+ if (!self::$enabledFunctions['copy']) {
+ return false;
+ }
+ return copy($from, $to);
+ }
+
+ public static function symlink(string $target, string $link): bool
+ {
+ if (!self::$enabledFunctions['symlink']) {
+ return false;
+ }
+ return symlink($target, $link);
+ }
+
+ public static function readlink(string $path): string|false
+ {
+ if (!self::$enabledFunctions['readlink']) {
+ return false;
+ }
+ return readlink($path);
+ }
+
+ public static function rename(string $from, string $to): bool
+ {
+ if (!self::$enabledFunctions['rename']) {
+ return false;
+ }
+ return rename($from, $to);
+ }
+
+ public static function file_get_contents(string $filename): string|false
+ {
+ if (!self::$enabledFunctions['file_get_contents']) {
+ return false;
+ }
+ return file_get_contents($filename);
+ }
+
+ public static function file_put_contents(string $filename, mixed $data, int $flags = 0): int|false
+ {
+ if (!self::$enabledFunctions['file_put_contents']) {
+ return false;
+ }
+ return file_put_contents($filename, $data, $flags);
+ }
+
+ public static function opendir(string $directory): mixed
+ {
+ if (!self::$enabledFunctions['opendir']) {
+ return false;
+ }
+ return opendir($directory);
+ }
+
+ public static function fopen(string $filename, string $mode): mixed
+ {
+ if (!self::$enabledFunctions['fopen']) {
+ return false;
+ }
+ return fopen($filename, $mode);
+ }
+
+ public static function mkdir(string $directory, int $mode = 0o777, bool $recursive = false): bool
+ {
+ if (!self::$enabledFunctions['mkdir']) {
+ return false;
+ }
+ return mkdir($directory, $mode, $recursive);
+ }
+}
diff --git a/tests/Utils/Fixtures/StringableFixture.php b/tests/Utils/Fixtures/StringableFixture.php
new file mode 100644
index 000000000..c721734da
--- /dev/null
+++ b/tests/Utils/Fixtures/StringableFixture.php
@@ -0,0 +1,15 @@
+value;
+ }
+}
diff --git a/tests/Utils/Fixtures/TraversableFixture.php b/tests/Utils/Fixtures/TraversableFixture.php
new file mode 100644
index 000000000..c23a54390
--- /dev/null
+++ b/tests/Utils/Fixtures/TraversableFixture.php
@@ -0,0 +1,16 @@
+data = $data;
+ }
+}
diff --git a/tests/Utils/Fixtures/files/mimetype/invalid.svg b/tests/Utils/Fixtures/files/mimetype/invalid.svg
new file mode 100644
index 000000000..76c62df1e
--- /dev/null
+++ b/tests/Utils/Fixtures/files/mimetype/invalid.svg
@@ -0,0 +1 @@
+