diff --git a/src/Command/TestCommand.php b/src/Command/TestCommand.php
index be2c4c28..f48e99e2 100644
--- a/src/Command/TestCommand.php
+++ b/src/Command/TestCommand.php
@@ -55,6 +55,7 @@ class TestCommand extends BakeCommand
'Command' => 'Command',
'CommandHelper' => 'Command\Helper',
'Middleware' => 'Middleware',
+ 'Class' => '',
];
/**
@@ -75,6 +76,7 @@ class TestCommand extends BakeCommand
'Command' => 'Command',
'CommandHelper' => 'Helper',
'Middleware' => 'Middleware',
+ 'Class' => '',
];
/**
@@ -123,7 +125,11 @@ public function execute(Arguments $args, ConsoleIo $io): ?int
$name = $args->getArgument('name');
$name = $this->_getName($name);
- if ($this->bake($type, $name, $args, $io)) {
+ $result = $this->bake($type, $name, $args, $io);
+ if ($result === static::CODE_ERROR) {
+ return static::CODE_ERROR;
+ }
+ if ($result) {
$io->success('Done');
}
@@ -212,10 +218,29 @@ protected function _getClassOptions(string $namespace): array
}
$path = $base . str_replace('\\', DS, $namespace);
- $files = (new Filesystem())->find($path);
- foreach ($files as $fileObj) {
- if ($fileObj->isFile()) {
- $classes[] = substr($fileObj->getFileName(), 0, -4) ?: '';
+
+ // For generic Class type (empty namespace), search recursively
+ if ($namespace === '') {
+ $files = (new Filesystem())->findRecursive($path, '/\.php$/');
+ foreach ($files as $fileObj) {
+ if ($fileObj->isFile() && $fileObj->getFileName() !== 'Application.php') {
+ // Build the namespace path relative to App directory
+ $relativePath = str_replace($base, '', $fileObj->getPath());
+ $relativePath = trim(str_replace(DS, '\\', $relativePath), '\\');
+ $className = substr($fileObj->getFileName(), 0, -4) ?: '';
+ if ($relativePath) {
+ $classes[] = $relativePath . '\\' . $className;
+ } else {
+ $classes[] = $className;
+ }
+ }
+ }
+ } else {
+ $files = (new Filesystem())->find($path);
+ foreach ($files as $fileObj) {
+ if ($fileObj->isFile()) {
+ $classes[] = substr($fileObj->getFileName(), 0, -4) ?: '';
+ }
}
}
sort($classes);
@@ -230,18 +255,43 @@ protected function _getClassOptions(string $namespace): array
* @param string $className the 'cake name' for the class ie. Posts for the PostsController
* @param \Cake\Console\Arguments $args Arguments
* @param \Cake\Console\ConsoleIo $io ConsoleIo instance
- * @return string|bool
+ * @return string|bool|int Returns the generated code as string on success, false on failure, or CODE_ERROR for validation errors
*/
- public function bake(string $type, string $className, Arguments $args, ConsoleIo $io): string|bool
+ public function bake(string $type, string $className, Arguments $args, ConsoleIo $io): string|bool|int
{
$type = $this->normalize($type);
if (!isset($this->classSuffixes[$type]) || !isset($this->classTypes[$type])) {
return false;
}
+ // For Class type, validate that backslashes are properly escaped
+ if ($type === 'Class' && !str_contains($className, '\\')) {
+ $io->error('Class name appears to have no namespace separators.');
+ $io->out('');
+ $io->out('If you meant to specify a namespaced class, please use quotes:');
+ $io->out(" bin/cake bake test class '{$className}'");
+ $io->out('');
+ $io->out('Or specify without the base namespace:');
+ $io->out(' bin/cake bake test class YourNamespace\\ClassName');
+
+ return static::CODE_ERROR;
+ }
+
$prefix = $this->getPrefix($args);
$fullClassName = $this->getRealClassName($type, $className, $prefix);
+ // For Class type, validate that the class exists
+ if ($type === 'Class' && !class_exists($fullClassName)) {
+ $io->error("Class '{$fullClassName}' does not exist or cannot be loaded.");
+ $io->out('');
+ $io->out('Please check:');
+ $io->out(' - The class file exists in the correct location');
+ $io->out(' - The class is properly autoloaded');
+ $io->out(' - The namespace and class name are correct');
+
+ return static::CODE_ERROR;
+ }
+
// Check if fixture factories plugin is available
$hasFixtureFactories = $this->hasFixtureFactories();
@@ -266,8 +316,14 @@ public function bake(string $type, string $className, Arguments $args, ConsoleIo
[$preConstruct, $construction, $postConstruct] = $this->generateConstructor($type, $fullClassName);
$uses = $this->generateUses($type, $fullClassName);
- $subject = $className;
- [$namespace, $className] = namespaceSplit($fullClassName);
+ // For generic Class type, extract just the class name for the subject
+ if ($type === 'Class') {
+ [$namespace, $className] = namespaceSplit($fullClassName);
+ $subject = $className;
+ } else {
+ $subject = $className;
+ [$namespace, $className] = namespaceSplit($fullClassName);
+ }
$baseNamespace = Configure::read('App.namespace');
if ($this->plugin) {
@@ -381,6 +437,17 @@ public function getRealClassName(string $type, string $class, ?string $prefix =
if ($this->plugin) {
$namespace = str_replace('/', '\\', $this->plugin);
}
+
+ // For generic Class type, the class name contains the full subnamespace path
+ if ($type === 'Class') {
+ // Strip base namespace if user included it
+ if (str_starts_with($class, $namespace . '\\')) {
+ $class = substr($class, strlen($namespace) + 1);
+ }
+
+ return $namespace . '\\' . $class;
+ }
+
$suffix = $this->classSuffixes[$type];
$subSpace = $this->mapType($type);
if ($suffix && strpos($class, $suffix) === false) {
@@ -415,7 +482,7 @@ public function getSubspacePath(string $type): string
*/
public function mapType(string $type): string
{
- if (empty($this->classTypes[$type])) {
+ if (!isset($this->classTypes[$type])) {
throw new CakeException('Invalid object type: ' . $type);
}
@@ -585,6 +652,18 @@ public function generateConstructor(string $type, string $fullClassName): array
$pre .= ' $this->io = new ConsoleIo($this->stub);';
$construct = "new {$className}(\$this->io);";
}
+ if ($type === 'Class') {
+ // Check if class has required constructor parameters
+ if (class_exists($fullClassName)) {
+ $reflection = new ReflectionClass($fullClassName);
+ $constructor = $reflection->getConstructor();
+ if (!$constructor || $constructor->getNumberOfRequiredParameters() === 0) {
+ $construct = "new {$className}();";
+ }
+ } else {
+ $construct = "new {$className}();";
+ }
+ }
return [$pre, $construct, $post];
}
@@ -635,7 +714,17 @@ public function generateProperties(string $type, string $subject, string $fullCl
break;
}
- if (!in_array($type, ['Controller', 'Command'])) {
+ // Skip test subject property for Controller, Command, and Class types with required constructor params
+ $skipProperty = in_array($type, ['Controller', 'Command'], true);
+ if ($type === 'Class' && class_exists($fullClassName)) {
+ $reflection = new ReflectionClass($fullClassName);
+ $constructor = $reflection->getConstructor();
+ if ($constructor && $constructor->getNumberOfRequiredParameters() > 0) {
+ $skipProperty = true;
+ }
+ }
+
+ if (!$skipProperty) {
$properties[] = [
'description' => 'Test subject',
'type' => '\\' . $fullClassName,
diff --git a/tests/TestCase/Command/TestCommandTest.php b/tests/TestCase/Command/TestCommandTest.php
index 41db8451..b2552ce6 100644
--- a/tests/TestCase/Command/TestCommandTest.php
+++ b/tests/TestCase/Command/TestCommandTest.php
@@ -707,6 +707,7 @@ public static function mapTypeProvider()
['Entity', 'Model\Entity'],
['Behavior', 'Model\Behavior'],
['Helper', 'View\Helper'],
+ ['Class', ''],
];
}
@@ -761,4 +762,169 @@ public function testGenerateUsesDocBlockTable()
$testsPath . 'TestCase/Model/Table/ProductsTableTest.php',
);
}
+
+ /**
+ * Test baking generic Class type without constructor args
+ *
+ * @return void
+ */
+ public function testBakeGenericClassWithoutConstructor()
+ {
+ $testsPath = ROOT . 'tests' . DS;
+ $this->generatedFiles = [
+ $testsPath . 'TestCase/Service/SimpleCalculatorTest.php',
+ ];
+
+ $this->exec('bake test Class Service\SimpleCalculator', ['y']);
+
+ $this->assertExitCode(CommandInterface::CODE_SUCCESS);
+ $this->assertFilesExist($this->generatedFiles);
+ $this->assertFileContains(
+ 'class SimpleCalculatorTest extends TestCase',
+ $this->generatedFiles[0],
+ );
+ $this->assertFileContains(
+ 'protected $SimpleCalculator;',
+ $this->generatedFiles[0],
+ );
+ $this->assertFileContains(
+ 'protected function setUp(): void',
+ $this->generatedFiles[0],
+ );
+ $this->assertFileContains(
+ '$this->SimpleCalculator = new SimpleCalculator();',
+ $this->generatedFiles[0],
+ );
+ $this->assertFileContains(
+ 'public function testAdd(): void',
+ $this->generatedFiles[0],
+ );
+ $this->assertFileContains(
+ 'public function testSubtract(): void',
+ $this->generatedFiles[0],
+ );
+ }
+
+ /**
+ * Test baking generic Class type with required constructor args
+ *
+ * @return void
+ */
+ public function testBakeGenericClassWithRequiredConstructor()
+ {
+ $testsPath = ROOT . 'tests' . DS;
+ $this->generatedFiles = [
+ $testsPath . 'TestCase/Service/UserServiceTest.php',
+ ];
+
+ $this->exec('bake test Class Service\UserService', ['y']);
+
+ $this->assertExitCode(CommandInterface::CODE_SUCCESS);
+ $this->assertFilesExist($this->generatedFiles);
+ $this->assertFileContains(
+ 'class UserServiceTest extends TestCase',
+ $this->generatedFiles[0],
+ );
+ $this->assertFileNotContains(
+ 'protected UserService $UserService;',
+ $this->generatedFiles[0],
+ );
+ $this->assertFileNotContains(
+ 'protected function setUp(): void',
+ $this->generatedFiles[0],
+ );
+ $this->assertFileNotContains(
+ 'protected function tearDown(): void',
+ $this->generatedFiles[0],
+ );
+ $this->assertFileContains(
+ 'public function testGetUserById(): void',
+ $this->generatedFiles[0],
+ );
+ $this->assertFileContains(
+ 'public function testCreateUser(): void',
+ $this->generatedFiles[0],
+ );
+ }
+
+ /**
+ * Test that Class type generates correct namespace
+ *
+ * @return void
+ */
+ public function testBakeGenericClassNamespace()
+ {
+ $testsPath = ROOT . 'tests' . DS;
+ $this->generatedFiles = [
+ $testsPath . 'TestCase/Service/SimpleCalculatorTest.php',
+ ];
+
+ $this->exec('bake test Class Service\SimpleCalculator', ['y']);
+
+ $this->assertExitCode(CommandInterface::CODE_SUCCESS);
+ $this->assertFileContains(
+ 'namespace Bake\Test\App\Test\TestCase\Service;',
+ $this->generatedFiles[0],
+ );
+ $this->assertFileContains(
+ 'class SimpleCalculatorTest extends TestCase',
+ $this->generatedFiles[0],
+ );
+ $this->assertFileNotContains(
+ 'ServiceSimpleCalculator',
+ $this->generatedFiles[0],
+ );
+ }
+
+ /**
+ * Test that Class type handles user including base namespace
+ *
+ * @return void
+ */
+ public function testBakeGenericClassWithBaseNamespace()
+ {
+ $testsPath = ROOT . 'tests' . DS;
+ $this->generatedFiles = [
+ $testsPath . 'TestCase/Service/UserServiceTest.php',
+ ];
+
+ // User includes "Bake\Test\App\" in the class name
+ $this->exec('bake test Class Bake\Test\App\Service\UserService', ['y']);
+
+ $this->assertExitCode(CommandInterface::CODE_SUCCESS);
+ $this->assertFileContains(
+ 'namespace Bake\Test\App\Test\TestCase\Service;',
+ $this->generatedFiles[0],
+ );
+ $this->assertFileContains(
+ 'class UserServiceTest extends TestCase',
+ $this->generatedFiles[0],
+ );
+ // Should not have duplicated namespace
+ $this->assertFileNotContains(
+ 'namespace Bake\Test\App\Test\TestCase\Bake\Test\App',
+ $this->generatedFiles[0],
+ );
+ $this->assertFileNotContains(
+ 'BakeTestAppService',
+ $this->generatedFiles[0],
+ );
+ }
+
+ /**
+ * Test that Class type validates backslash escaping
+ *
+ * @return void
+ */
+ public function testBakeGenericClassValidatesBackslashes()
+ {
+ // Simulate what happens when user doesn't quote: App\Error\ErrorLogger
+ // Bash strips backslashes resulting in: AppErrorErrorLogger
+ $this->exec('bake test Class AppErrorErrorLogger');
+
+ $this->assertExitCode(CommandInterface::CODE_ERROR);
+ $this->assertErrorContains('Class name appears to have no namespace separators');
+ $this->assertOutputContains('please use quotes');
+ $this->assertOutputContains("bin/cake bake test class 'AppErrorErrorLogger'");
+ }
}
diff --git a/tests/test_app/App/Service/SimpleCalculator.php b/tests/test_app/App/Service/SimpleCalculator.php
new file mode 100644
index 00000000..be1c7082
--- /dev/null
+++ b/tests/test_app/App/Service/SimpleCalculator.php
@@ -0,0 +1,34 @@
+ $id];
+ }
+
+ /**
+ * Create a new user
+ *
+ * @param array $data User data
+ * @return bool Success status
+ */
+ public function createUser(array $data): bool
+ {
+ return true;
+ }
+}