diff --git a/components/ILIAS/Component/src/Dependencies/LightMockBuilder.php b/components/ILIAS/Component/src/Dependencies/LightMockBuilder.php new file mode 100644 index 000000000000..4daf6b907bf1 --- /dev/null +++ b/components/ILIAS/Component/src/Dependencies/LightMockBuilder.php @@ -0,0 +1,622 @@ +> */ + private static array $returnTypeMap = []; + + /** @var array */ + private static array $generated = []; + + public static function createLazyShell(string $type): object + { + if (!class_exists($type)) { + throw new LogicException( + "Lazy-shell mocks only support concrete or abstract classes, not interfaces/unknown types: {$type}" + ); + } + + $ref = new ReflectionClass($type); + + if ($ref->isInterface()) { + throw new LogicException("Lazy-shell mocks do not support interfaces: {$type}"); + } + + if ($ref->isTrait()) { + throw new LogicException("Traits cannot be mocked directly: {$type}"); + } + + if ($ref->isEnum()) { + throw new LogicException("Enums cannot be mocked: {$type}"); + } + + if ($ref->isFinal()) { + throw new LogicException("Final classes cannot be mocked without extensions/code rewriting: {$type}"); + } + + return $ref->newLazyGhost( + static function (object $object): void { + // Intentionally left blank. + // This variant is only meant to provide a typed shell object. + }, + ReflectionClass::SKIP_INITIALIZATION_ON_SERIALIZE + ); + } + + public static function createBestEffort(string $type): object + { + if (interface_exists($type)) { + return self::create($type); + } + + try { + return self::createLazyShell($type); + } catch (\Throwable) { + return self::create($type); + } + } + + public static function create(string $type): object + { + $generatedClass = self::generatedClassName($type); + $generatedFqcn = __NAMESPACE__ . '\\' . $generatedClass; + + if (!isset(self::$generated[$generatedFqcn])) { + self::generate($type, $generatedClass); + self::$generated[$generatedFqcn] = true; + } + + return new $generatedFqcn(); + } + + private static function generate(string $type, string $generatedClass): void + { + if (!class_exists($type) && !interface_exists($type)) { + throw new LogicException("Unknown class or interface: {$type}"); + } + + $ref = new ReflectionClass($type); + + if ($ref->isEnum()) { + throw new LogicException("Enums cannot be mocked: {$type}"); + } + + if ($ref->isTrait()) { + throw new LogicException("Traits cannot be mocked directly: {$type}"); + } + + if ($ref->isFinal()) { + throw new LogicException("Final classes cannot be mocked without extensions/code rewriting: {$type}"); + } + + $methodCode = []; + $returnMap = []; + + if ($ref->isInterface()) { + foreach ($ref->getMethods() as $method) { + if ($method->returnsReference()) { + throw new LogicException( + "Methods returning by reference are not supported by this lightweight mock builder: " + . $ref->getName() . "::" . $method->getName() + ); + } + + $methodCode[] = self::buildMethod($method, $returnMap); + } + + $header = "class {$generatedClass} implements \\" . ltrim($type, '\\'); + } else { + // leeren Konstruktor nur dann hinzufügen, wenn der Eltern-Konstruktor nicht final ist + $constructor = $ref->getConstructor(); + if ($constructor === null || !$constructor->isFinal()) { + $methodCode[] = "public function __construct(...\$__args) {}"; + } + + foreach ($ref->getMethods() as $method) { + if (!self::shouldOverride($method)) { + continue; + } + + if ($method->returnsReference()) { + throw new LogicException( + "Methods returning by reference are not supported by this lightweight mock builder: " + . $ref->getName() . "::" . $method->getName() + ); + } + + $methodCode[] = self::buildMethod($method, $returnMap); + } + + $header = "class {$generatedClass} extends \\" . ltrim($type, '\\'); + } + + self::$returnTypeMap[__NAMESPACE__ . '\\' . $generatedClass] = $returnMap; + + $code = <<isConstructor()) { + return false; + } + + if ($method->isDestructor()) { + return false; + } + + if ($method->isFinal()) { + return false; + } + + if ($method->isPrivate()) { + return false; + } + + if ($method->isStatic()) { + return false; + } + // nur Methoden, die in der Zielklasse / ihren Eltern sichtbar überschrieben werden können + if ($method->isPublic()) { + return true; + } + if ($method->isProtected()) { + return true; + } + return $method->isAbstract(); + } + + /** + * @param array $returnMap + */ + private static function buildMethod(ReflectionMethod $method, array &$returnMap): string + { + $visibility = $method->isPublic() ? 'public' : 'protected'; + $static = $method->isStatic() ? ' static' : ''; + $name = $method->getName(); + $params = self::buildParameterList($method); + $returnType = self::renderType($method->getReturnType()); + + $returnMap[$name] = self::normalizeType($method->getReturnType()); + + $body = self::buildMethodBody($name, $method->getReturnType(), $method->isStatic()); + $attribute = self::buildMethodAttributes($method); + + return trim( + $attribute . + sprintf( + '%s%s function %s(%s)%s %s', + $visibility, + $static, + $name, + $params, + $returnType !== '' ? ': ' . $returnType : '', + $body + ) + ); + } + + private static function buildMethodAttributes(ReflectionMethod $method): string + { + if (self::needsReturnTypeWillChange($method)) { + return "#[\\ReturnTypeWillChange]\n"; + } + + return ''; + } + + private static function needsReturnTypeWillChange(ReflectionMethod $method): bool + { + if ($method->hasReturnType()) { + return false; + } + + if (method_exists($method, 'hasTentativeReturnType') && $method->hasTentativeReturnType()) { + return true; + } + + try { + $prototype = $method->getPrototype(); + + if ($prototype->hasReturnType()) { + return false; + } + + if (method_exists($prototype, 'hasTentativeReturnType') && $prototype->hasTentativeReturnType()) { + return true; + } + } catch (\ReflectionException) { + // Kein Prototype verfügbar. + } + + return false; + } + + private static function buildMethodBody( + string $methodName, + ?ReflectionType $returnType, + bool $isStatic = false + ): string { + $normalized = self::normalizeType($returnType); + + if ($normalized['kind'] === 'never') { + return <<__mockInvoke('{$methodName}', func_get_args()); + return; +} +PHP; + } + + return <<__mockInvoke('{$methodName}', func_get_args()); +} +PHP; + } + + private static function buildParameterList(ReflectionMethod $method): string + { + $parts = []; + + foreach ($method->getParameters() as $parameter) { + $parts[] = self::buildParameter($parameter); + } + + return implode(', ', $parts); + } + + private static function buildParameter(ReflectionParameter $parameter): string + { + $code = ''; + + $type = self::renderType($parameter->getType()); + if ($type !== '') { + $code .= $type . ' '; + } + + if ($parameter->isPassedByReference()) { + $code .= '&'; + } + + if ($parameter->isVariadic()) { + $code .= '...'; + } + + $code .= '$' . $parameter->getName(); + + if (!$parameter->isVariadic() && $parameter->isDefaultValueAvailable()) { + if ($parameter->isDefaultValueConstant()) { + $code .= ' = ' . $parameter->getDefaultValueConstantName(); + } else { + $code .= ' = ' . self::exportValue($parameter->getDefaultValue()); + } + } + + return $code; + } + + private static function renderType(?ReflectionType $type): string + { + if ($type === null) { + return ''; + } + + if ($type instanceof ReflectionNamedType) { + $name = $type->getName(); + $prefix = !$type->isBuiltin() && $name !== 'self' && $name !== 'parent' && $name !== 'static' + ? '\\' + : ''; + + if ($type->allowsNull() && $name !== 'mixed' && $name !== 'null') { + return '?' . $prefix . $name; + } + + return $prefix . $name; + } + + if ($type instanceof ReflectionUnionType) { + $parts = []; + foreach ($type->getTypes() as $subType) { + $parts[] = self::renderType($subType); + } + return implode('|', $parts); + } + + if ($type instanceof ReflectionIntersectionType) { + $parts = []; + foreach ($type->getTypes() as $subType) { + $parts[] = self::renderType($subType); + } + return implode('&', $parts); + } + + throw new LogicException('Unsupported reflection type: ' . $type::class); + } + + /** + * @return array + */ + private static function normalizeType(?ReflectionType $type): array + { + if ($type === null) { + return ['kind' => 'none']; + } + + if ($type instanceof ReflectionNamedType) { + $name = $type->getName(); + + if ($name === 'void') { + return ['kind' => 'void']; + } + + if ($name === 'never') { + return ['kind' => 'never']; + } + + return [ + 'kind' => 'named', + 'name' => $name, + 'builtin' => $type->isBuiltin(), + 'nullable' => $type->allowsNull(), + ]; + } + + if ($type instanceof ReflectionUnionType) { + return [ + 'kind' => 'union', + 'types' => array_map( + self::normalizeType(...), + $type->getTypes() + ), + ]; + } + + if ($type instanceof ReflectionIntersectionType) { + return [ + 'kind' => 'intersection', + 'types' => array_map( + self::normalizeType(...), + $type->getTypes() + ), + ]; + } + + throw new LogicException('Unsupported reflection type: ' . $type::class); + } + + public static function defaultValueFor(object $object, string $method): mixed + { + $class = $object::class; + $meta = self::$returnTypeMap[$class][$method] ?? ['kind' => 'none']; + + return self::defaultByMeta($object, $meta); + } + + public static function defaultValueForStatic(string $class, string $method): mixed + { + $meta = self::$returnTypeMap[$class][$method] ?? ['kind' => 'none']; + + return self::defaultByStaticMeta($meta); + } + + /** + * @param array $meta + */ + private static function defaultByMeta(object $object, array $meta): mixed + { + $kind = $meta['kind'] ?? 'none'; + + if ($kind === 'none') { + return null; + } + + if ($kind === 'void') { + return null; + } + + if ($kind === 'never') { + throw new LogicException('Cannot produce a default value for return type never'); + } + + if ($kind === 'intersection') { + throw new LogicException('Intersection return types need explicit stubbing'); + } + + if ($kind === 'union') { + foreach ($meta['types'] as $subType) { + if (($subType['kind'] ?? null) === 'named' && ($subType['name'] ?? null) === 'null') { + return null; + } + } + + foreach ($meta['types'] as $subType) { + if (($subType['kind'] ?? null) === 'named' && ($subType['name'] ?? null) !== 'null') { + return self::defaultByMeta($object, $subType); + } + } + + return null; + } + + $name = $meta['name'] ?? null; + $nullable = (bool) ($meta['nullable'] ?? false); + + if ($nullable) { + return null; + } + + return match ($name) { + 'void' => null, + 'never' => throw new LogicException('Cannot return from a never-returning method'), + 'null' => null, + 'mixed' => null, + 'bool', 'false' => false, + 'true' => true, + 'int' => 0, + 'float' => 0.0, + 'string' => '', + 'array' => [], + 'iterable' => [], + 'callable' => static fn(): null => null, + 'object' => new \stdClass(), + 'self', 'static' => $object, + 'parent' => self::create((string) get_parent_class($object)), + default => self::create($name), + }; + } + + /** + * @param array $meta + */ + private static function defaultByStaticMeta(array $meta): mixed + { + $kind = $meta['kind'] ?? 'none'; + + if ($kind === 'none') { + return null; + } + + if ($kind === 'void') { + return null; + } + + if ($kind === 'never') { + throw new LogicException('Cannot produce a default value for return type never'); + } + + if ($kind === 'intersection') { + throw new LogicException('Intersection return types need explicit stubbing'); + } + + if ($kind === 'union') { + foreach ($meta['types'] as $subType) { + if (($subType['kind'] ?? null) === 'named' && ($subType['name'] ?? null) === 'null') { + return null; + } + } + + foreach ($meta['types'] as $subType) { + if (($subType['kind'] ?? null) === 'named' && ($subType['name'] ?? null) !== 'null') { + return self::defaultByStaticMeta($subType); + } + } + + return null; + } + + $name = $meta['name'] ?? null; + $nullable = (bool) ($meta['nullable'] ?? false); + + if ($nullable) { + return null; + } + + return match ($name) { + 'null' => null, + 'mixed' => null, + 'bool', 'false' => false, + 'true' => true, + 'int' => 0, + 'float' => 0.0, + 'string' => '', + 'array' => [], + 'iterable' => [], + 'callable' => static fn(): null => null, + 'object' => new \stdClass(), + default => class_exists((string) $name) || interface_exists((string) $name) + ? self::create((string) $name) + : null, + }; + } + + private static function generatedClassName(string $type): string + { + return 'Mock_' . md5($type); + } + + private static function exportValue(mixed $value): string + { + return var_export($value, true); + } +} diff --git a/components/ILIAS/Component/src/Dependencies/MockObjectBehavior.php b/components/ILIAS/Component/src/Dependencies/MockObjectBehavior.php new file mode 100644 index 000000000000..51ad52bf85c8 --- /dev/null +++ b/components/ILIAS/Component/src/Dependencies/MockObjectBehavior.php @@ -0,0 +1,56 @@ + */ + private array $__mockConfiguredReturns = []; + + /** @var array */ + private array $__mockConfiguredCallbacks = []; + + public function __mockSetReturnValue(string $method, mixed $value): static + { + $this->__mockConfiguredReturns[$method] = $value; + return $this; + } + + public function __mockSetCallback(string $method, callable $callback): static + { + $this->__mockConfiguredCallbacks[$method] = $callback(...); + return $this; + } + + protected function __mockInvoke(string $method, array $args): mixed + { + if (isset($this->__mockConfiguredCallbacks[$method])) { + return ($this->__mockConfiguredCallbacks[$method])(...$args); + } + + if (array_key_exists($method, $this->__mockConfiguredReturns)) { + return $this->__mockConfiguredReturns[$method]; + } + + return LightMockBuilder::defaultValueFor($this, $method); + } +} diff --git a/components/ILIAS/Component/src/Dependencies/Reader.php b/components/ILIAS/Component/src/Dependencies/Reader.php index 9bd7bf9d138a..eb2902c27283 100644 --- a/components/ILIAS/Component/src/Dependencies/Reader.php +++ b/components/ILIAS/Component/src/Dependencies/Reader.php @@ -352,16 +352,6 @@ protected function compileResult(Component $component): OfComponent protected function createMock(string $class_name): object { - $mock_builder = new \PHPUnit\Framework\MockObject\MockBuilder(new class ('dummy') extends \PHPUnit\Framework\TestCase { - public function dummy() - { - } - }, $class_name); - return $mock_builder - ->disableOriginalConstructor() - ->disableOriginalClone() - ->disableArgumentCloning() - ->disallowMockingUnknownTypes() - ->getMock(); + return LightMockBuilder::createBestEffort($class_name); } }