diff --git a/resources/skills/governed-authoring/SKILL.md b/resources/skills/governed-authoring/SKILL.md index c65f07b..1b11418 100644 --- a/resources/skills/governed-authoring/SKILL.md +++ b/resources/skills/governed-authoring/SKILL.md @@ -80,7 +80,11 @@ unjudged when no test declares what the class must do*. A body with no test is s 3. **Declare the evidence FIRST — `make what=test`.** Before you write a line of the body, scaffold the test that says what the code must *do* — the - observable criterion, the falsifier. Promote it, then write it. + observable criterion, the falsifier. Promote it, then write it. **Scaffold an operation's judge once + the operation's own scaffold is in the house**: the judge then already holds what `run()` works + through — its entity's repository, in memory — and shows the call the house makes, so you write it + from what it gives you. A judge runs over the house as it is: if it calls other operations of the + plugin, land their bodies first — a scaffold answers `ok: false`, and no judge goes green over that. 4. **Scaffold the structure — `make`, then promote.** `make what=plugin|page|controller|entity|crud|resource|service|operation|tool|test` writes real, diff --git a/src/Make/Generators/OperationGenerator.php b/src/Make/Generators/OperationGenerator.php index 7a5c459..11e24f3 100644 --- a/src/Make/Generators/OperationGenerator.php +++ b/src/Make/Generators/OperationGenerator.php @@ -70,6 +70,9 @@ final class OperationGenerator implements GeneratorInterface /** The inputs a scaffold declares, and the PHP type each one is. */ private const INPUTS = ['string' => 'string', 'text' => 'string', 'int' => 'int', 'bigint' => 'int', 'bool' => 'bool', 'float' => 'float', 'decimal' => 'float']; + /** How the sentence an unfilled scaffold answers with ends, after its class name: what «unfilled» is read by. */ + public const UNFILLED = '::run() is a scaffold — fill it with implement'; + private const COMMAND_PROVIDER = 'Milpa\\Command\\CommandProvider'; private StubLocator $stubs; @@ -283,6 +286,39 @@ private static function repositoryParameter(string $entity): string return lcfirst($entity) . 's'; } + /** + * The operations of a plugin whose body is still the scaffold's, by class name. + * + * Read from the sentence the scaffold itself answers with: the house wrote it, so the house can tell an unfilled + * operation from a filled one without calling either (greenhouse evidence/1156). + * + * @return list + */ + public static function unfilledIn(string $pluginDir): array + { + $unfilled = []; + foreach (glob($pluginDir . '/Operations/*.php') ?: [] as $file) { + $class = basename($file, '.php'); + if (str_contains((string) file_get_contents($file), $class . self::UNFILLED)) { + $unfilled[] = $class; + } + } + + return $unfilled; + } + + /** + * The entity whose repository a plugin hands the operation's `run()`, read back from the entry {@see entry()} + * wrote — or `null` when the entry finds everything by its class. + */ + public static function entityHandedTo(string $pluginSource, string $class): ?string + { + $entry = '/(?plugin; [$judge, $target] = $this->judgeAndTarget($context->name); $path = "tests/Plugins/{$plugin}/{$judge}.php"; + $operation = $this->operation($context, $target); + if ($operation !== null) { + return new GenerationResult( + files: [new PlannedFile($path, $this->judgeOfAnOperation($plugin, $judge, $target, $operation))], + guidance: "This judge (class {$judge}) will run when {$target} lands. " + . ($operation['entity'] === null ? 'It shows' : "It already holds what {$target}::run() works through — the repository of " + . "{$operation['entity']}, in memory — and shows") + . " the call the house makes: fill it with `implement` declaring what {$target} must do. If it calls " + . "another operation of this plugin, land that one's body first: a judge runs over the house as it " + . 'is, and a scaffold answers ok: false.', + ); + } $contents = <<, optional: list, hands: list, entity: ?string, entityFqcn: ?string, repository: ?string}|null + */ + private function operation(GenerationContext $context, string $target): ?array + { + $dir = $context->root . '/src/Plugins/' . $context->plugin; + $file = $dir . '/Operations/' . $target . '.php'; + $source = is_file($file) ? (string) file_get_contents($file) : ''; + if (!str_contains($source, '#[Operation(') || preg_match('/^namespace ([^;]+);/m', $source, $namespace) !== 1) { + return null; + } + + $parameter = '/(\??[A-Za-z_\\\\][A-Za-z0-9_\\\\|]*)\s+\$(\w+)(\s*=)?/'; + $input = []; + $optional = []; + if (preg_match('/function __construct\((.*?)\)\s*\{/s', $source, $constructor) === 1) { + preg_match_all($parameter, $constructor[1], $fields, PREG_SET_ORDER); + foreach ($fields as $field) { + if (($field[3] ?? '') !== '') { + $optional[] = $field[2]; + continue; + } + $input[] = $field[2] . ': ' . self::sample($field[1]); + } + } + + $pluginFile = $dir . '/' . $context->plugin . '.php'; + $entityFqcn = is_file($pluginFile) ? OperationGenerator::entityHandedTo((string) file_get_contents($pluginFile), $target) : null; + $hands = []; + $repository = null; + if (preg_match('/function run\((.*?)\)\s*:/s', $source, $run) === 1) { + preg_match_all($parameter, $run[1], $takes, PREG_SET_ORDER); + foreach ($takes as $taken) { + if ($entityFqcn !== null && $repository === null && str_ends_with($taken[1], 'RepositoryInterface')) { + $repository = $taken[2]; + $hands[] = '$this->' . $taken[2]; + continue; + } + $hands[] = "/* {$taken[1]} \${$taken[2]} */"; + } + } + // The judge holds a repository only when run() takes one AND its plugin says of which entity. + if ($repository === null) { + $entityFqcn = null; + } + + return [ + 'fqcn' => $namespace[1] . '\\' . $target, + 'input' => $input, + 'optional' => $optional, + 'hands' => $hands, + 'entity' => $entityFqcn === null ? null : substr((string) strrchr('\\' . $entityFqcn, '\\'), 1), + 'entityFqcn' => $entityFqcn, + 'repository' => $repository, + ]; + } + + /** A value of an input's type that the call can be made with — the judge's author replaces it. */ + private static function sample(string $type): string + { + return match (ltrim($type, '?')) { + 'string' => "'…'", + 'int' => '1', + 'float' => '1.0', + 'bool' => 'true', + 'array' => '[]', + default => 'null /* ' . ltrim($type, '?') . ' */', + }; + } + + /** + * The judge of an operation: it holds what `run()` works through and shows the call the house makes. + * + * @param array{fqcn: string, input: list, optional: list, hands: list, entity: ?string, entityFqcn: ?string, repository: ?string} $operation + */ + private function judgeOfAnOperation(string $plugin, string $judge, string $target, array $operation): string + { + $uses = [$operation['fqcn'], 'PHPUnit\\Framework\\TestCase']; + $holds = ''; + $after = '$answer'; + if ($operation['repository'] !== null && $operation['entityFqcn'] !== null) { + array_push($uses, $operation['entityFqcn'], 'Milpa\\Data\\InMemoryRepository', 'Milpa\\Data\\RepositoryInterface'); + $after = "\$answer, and on what \$this->{$operation['repository']} holds afterwards"; + $holds = << + */ + private RepositoryInterface \${$operation['repository']}; + + protected function setUp(): void + { + \$this->{$operation['repository']} = new InMemoryRepository({$operation['entity']}::class); + } + + +PHP; + } + sort($uses); + $uses = implode("\n", array_map(static fn (string $class): string => "use {$class};", $uses)); + $input = implode(', ', $operation['input']); + $hands = implode(', ', $operation['hands']); + $optional = $operation['optional'] === [] ? '' : " // Left out of that call because it is optional: " . implode(', ', $operation['optional']) . ".\n"; + + return <<run({$hands}); + // +{$optional} // Assert on {$after}. + // + // Replace this with real behavior. It fails ON PURPOSE: a judge that judges nothing must + // not green-light anything — until this is real, no body of {$target} lands past the gate. + self::fail('this judge does not judge anything yet — declare what {$target} must DO'); + } +} + +PHP; + } + /** * Splits `$name` into the judge class and the production class it judges. * diff --git a/src/Operations/ImplementHandler.php b/src/Operations/ImplementHandler.php index c1e97ae..d78c807 100644 --- a/src/Operations/ImplementHandler.php +++ b/src/Operations/ImplementHandler.php @@ -14,6 +14,7 @@ namespace Milpa\DevTools\Operations; +use Milpa\DevTools\Make\Generators\OperationGenerator; use Milpa\DevTools\Support\RootResolver; /** @@ -595,10 +596,16 @@ private function land(string $root, string $file, string $plugin, string $class, static fn (string $l): bool => trim($l) !== '', )), -10)); + // A RED JUDGE IS NOT ALWAYS ABOUT THE BODY (greenhouse evidence/1156): a judge runs over the house + // as it is, and a scaffold the judge calls answers `ok: false` whatever this body does. The + // house wrote those scaffolds and can read that they are unfilled, so the refusal names them. + $scaffolds = self::scaffoldsNamedBy($root, $plugin, $class, (string) file_get_contents($testFile)); + return [ 'ok' => false, 'error' => 'refused: the class\'s own test judges this behavior red — ' - . basename($testFile, '.php') . " said:\n{$tail}", + . basename($testFile, '.php') . " said:\n{$tail}" + . self::overScaffolds($plugin, $class, basename($testFile, '.php'), $scaffolds), // This is a judgment of the transient proposal, never a positive verification. // Missing counts, timeouts and changed subjects cannot earn diagnostic credit. 'diagnostic' => [ @@ -613,6 +620,7 @@ private function land(string $root, string $file, string $plugin, string $class, 'stable_subject' => $stable, 'rolled_back' => $restored, 'result' => ['exit' => $verdictCode, ...PhpUnitSummary::counts(implode("\n", $verdictLines))], + 'scaffolds' => $scaffolds, ], ]; } @@ -687,6 +695,54 @@ private function stageAtomically(string $staging, string $content, string $live) return hash_file('sha256', $staging) === hash('sha256', $content); } + /** + * The other operations of the plugin that are still scaffolds and that the judge names — by their class, or by + * the operation they declare. The class being landed is the proposal, never its own obstacle. + * + * @return list + */ + private static function scaffoldsNamedBy(string $root, string $plugin, string $class, string $judge): array + { + $dir = $root . '/src/Plugins/' . $plugin; + $named = []; + foreach (OperationGenerator::unfilledIn($dir) as $scaffold) { + if ($scaffold === $class) { + continue; + } + $declares = preg_match( + "/#\\[Operation\\(name: '([^']+)'/", + (string) file_get_contents($dir . '/Operations/' . $scaffold . '.php'), + $name, + ) === 1 ? $name[1] : null; + if (preg_match('/(? $scaffolds + */ + private static function overScaffolds(string $plugin, string $class, string $judge, array $scaffolds): string + { + if ($scaffolds === []) { + return ''; + } + $one = \count($scaffolds) === 1; + + return "\n{$judge} names " . implode(', ', $scaffolds) . ', and in this copy of the house ' + . ($one ? 'it is still a scaffold: its run() answers' : 'they are still scaffolds: their run() answers') + . " «not implemented», and no judge goes green over that. Whatever else this body needs, in this order:\n" + . "1. implement plugin={$plugin} class={$scaffolds[0]}, and promote it" + . ($one ? '.' : '; then the same for ' . implode(', ', \array_slice($scaffolds, 1)) . '.') . "\n" + . "2. Then send this same implement plugin={$plugin} class={$class} again."; + } + /** The class's behavioral test under `tests/Plugins//`, or `null` when none declares it. */ private function testFor(string $root, string $plugin, string $class): ?string { diff --git a/tests/Make/Generators/TheJudgeOfAnOperationHoldsWhatItWorksThroughTest.php b/tests/Make/Generators/TheJudgeOfAnOperationHoldsWhatItWorksThroughTest.php new file mode 100644 index 0000000..81d2be9 --- /dev/null +++ b/tests/Make/Generators/TheJudgeOfAnOperationHoldsWhatItWorksThroughTest.php @@ -0,0 +1,360 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/devtools + */ + +declare(strict_types=1); + +namespace Milpa\DevTools\Tests\Make\Generators; + +use Milpa\DevTools\Make\GenerationContext; +use Milpa\DevTools\Make\GenerationResult; +use Milpa\DevTools\Make\Generators\EntityGenerator; +use Milpa\DevTools\Make\Generators\OperationGenerator; +use Milpa\DevTools\Make\Generators\PluginGenerator; +use Milpa\DevTools\Make\Generators\TestGenerator; +use PHPUnit\Framework\TestCase; + +/** + * WHAT THE HOUSE TELLS A BUILDER TO WRITE FIRST, IT LEAVES READY TO BE WRITTEN (greenhouse evidence/1156). + * + * The house says the judge comes before the body. Three real residents out of three, having scaffolded their + * operations, spent their next fourteen to seventeen calls to the model finding out one thing the house knew all + * along: how a test gets the repository an operation's `run()` receives. One of them never found it, and filled no + * body at all. The judge's scaffold was a bare `TestCase` that fails on purpose and holds nothing. + * + * So the judge of an operation is scaffolded holding what that operation works through, and showing the call the + * house makes. Everything here is scaffolded by the real generators, and the judge is RUN by a real phpunit: a + * scaffold that only looked right would not pass. + */ +final class TheJudgeOfAnOperationHoldsWhatItWorksThroughTest extends TestCase +{ + private static int $n = 0; + + private string $root; + private string $plugin; + + protected function setUp(): void + { + $this->root = sys_get_temp_dir() . '/milpa-devtools-judge-' . uniqid(); + mkdir($this->root, 0o775, true); + file_put_contents( + $this->root . '/composer.json', + (string) json_encode(['autoload' => ['psr-4' => ['App\\' => 'src/']]], JSON_PRETTY_PRINT), + ); + $this->plugin = 'Bodega' . (++self::$n) . 'x' . getmypid(); + } + + protected function tearDown(): void + { + exec('rm -rf ' . escapeshellarg($this->root)); + } + + public function testTheJudgeOfAnOperationBuildsTheRepositoryItsRunReceives(): void + { + $this->house(); + + $judge = $this->judge('GuardarCaja')->files[0]->contents; + + self::assertStringContainsString("use App\\Plugins\\{$this->plugin}\\Entities\\Caja;", $judge); + self::assertStringContainsString('use Milpa\\Data\\InMemoryRepository;', $judge); + self::assertStringContainsString('private RepositoryInterface $cajas;', $judge, 'named as run() names it'); + self::assertStringContainsString('$this->cajas = new InMemoryRepository(Caja::class);', $judge); + } + + public function testItShowsTheCallTheHouseMakes(): void + { + $this->house(); + + $judge = $this->judge('GuardarCaja')->files[0]->contents; + + self::assertStringContainsString("use App\\Plugins\\{$this->plugin}\\Operations\\GuardarCaja;", $judge); + self::assertStringContainsString("\$answer = (new GuardarCaja(etiqueta: '…'))->run(\$this->cajas);", $judge); + } + + /** The red is still TDD's starting position — and it is the ONLY thing wrong with the scaffold. */ + public function testTheScaffoldStillFailsOnPurposeAndForNoOtherReason(): void + { + $this->house(); + + [$exit, $said] = $this->run_($this->judge('GuardarCaja')->files[0]->contents); + + self::assertNotSame(0, $exit, $said); + self::assertStringContainsString('this judge does not judge anything yet — declare what GuardarCaja must DO', $said); + self::assertMatchesRegularExpression('/Tests: 1, Assertions: \d+, Failures: 1\./', $said, 'one failure, and no error: what it holds is built'); + } + + /** The call in the comment is not prose: taken out of its comment, the scaffolded operation answers it. */ + public function testTheCallItShowsIsOneTheScaffoldedOperationAnswers(): void + { + $this->house('etiqueta:string,cantidad:int,activa:bool,precio:float,?nota:string'); + $judge = $this->judge('GuardarCaja')->files[0]->contents; + self::assertStringContainsString("(new GuardarCaja(etiqueta: '…', cantidad: 1, activa: true, precio: 1.0))->run(\$this->cajas);", $judge); + self::assertStringContainsString('optional: nota', $judge); + + [$exit, $said] = $this->run_($this->withItsCallMade($judge)); + + self::assertSame(0, $exit, $said); + self::assertStringContainsString('OK (1 test, 2 assertions)', $said); + } + + /** An operation the plugin hands no repository gets none here either: the judge holds what run() takes, no more. */ + public function testAnOperationHandedNoRepositoryIsShownItsCallAndNothingElse(): void + { + $this->house(entities: []); + + $judge = $this->judge('GuardarCaja')->files[0]->contents; + + self::assertStringNotContainsString('InMemoryRepository', $judge); + self::assertStringNotContainsString('setUp', $judge); + self::assertStringContainsString("\$answer = (new GuardarCaja(etiqueta: '…'))->run();", $judge); + [$exit, $said] = $this->run_($this->withItsCallMade($judge)); + self::assertSame(0, $exit, $said); + } + + /** With several entities and none named, the scaffold handed run() nothing — and neither does its judge. */ + public function testWithSeveralEntitiesAndNoneNamedTheJudgeGuessesNoRepository(): void + { + $this->house(entities: ['Caja', 'Estante']); + + $judge = $this->judge('GuardarCaja')->files[0]->contents; + + self::assertStringNotContainsString('InMemoryRepository', $judge); + self::assertStringContainsString('->run();', $judge); + } + + /** Read from the entry of THIS operation: another operation of the plugin, over another entity, is listed first. */ + public function testTheNamedEntityIsTheOneTheJudgeHolds(): void + { + $this->write((new PluginGenerator())->generate(new GenerationContext($this->plugin, $this->plugin, ['flavor' => 'runtime'], $this->root))); + foreach (['Caja', 'Estante'] as $entity) { + $this->write((new EntityGenerator())->generate(new GenerationContext($this->plugin, $entity, ['flavor' => 'runtime', 'fields' => 'etiqueta:string'], $this->root))); + } + foreach (['ContarCajas' => 'Caja', 'GuardarCaja' => 'Estante'] as $operation => $entity) { + $this->write((new OperationGenerator())->generate(new GenerationContext( + $this->plugin, + $operation, + ['flavor' => 'runtime', 'operation' => 'bodega:' . strtolower($operation), 'fields' => 'etiqueta:string', 'entity' => $entity], + $this->root, + ))); + } + + $judge = $this->judge('GuardarCaja')->files[0]->contents; + + self::assertStringContainsString('$this->estantes = new InMemoryRepository(Estante::class);', $judge); + self::assertStringNotContainsString('Caja::class', $judge); + } + + /** + * The state a resident left by hand: `run()` written against a repository while the entry that lists the + * operation hands none. The judge holds what the house hands, so it holds nothing — and shows what is missing. + */ + public function testARepositoryTheEntryDoesNotHandIsNotHeldByTheJudge(): void + { + $this->house(entities: []); + $this->write((new EntityGenerator())->generate(new GenerationContext($this->plugin, 'Caja', ['flavor' => 'runtime', 'fields' => 'etiqueta:string'], $this->root))); + $file = "{$this->root}/src/Plugins/{$this->plugin}/Operations/GuardarCaja.php"; + file_put_contents($file, str_replace('public function run(): array', 'public function run(\\Milpa\\Data\\RepositoryInterface $cajas): array', (string) file_get_contents($file), $replaced)); + self::assertSame(1, $replaced); + + $judge = $this->judge('GuardarCaja')->files[0]->contents; + + self::assertStringNotContainsString('InMemoryRepository', $judge); + self::assertStringNotContainsString('$this->cajas', $judge); + self::assertStringContainsString("->run(/* \\Milpa\\Data\\RepositoryInterface \$cajas */);", $judge); + self::assertStringContainsString('It shows the call the house makes', (string) $this->judge('GuardarCaja')->guidance); + } + + /** + * An entry hands ONE repository: `run()` is handed by type. A second one in the signature is not held as if it + * were the same — the judge shows it is missing, as the house would find it missing. + */ + public function testASecondRepositoryInTheSignatureIsShownMissingAndNotHeld(): void + { + $this->house(); + $file = "{$this->root}/src/Plugins/{$this->plugin}/Operations/GuardarCaja.php"; + file_put_contents($file, str_replace('RepositoryInterface $cajas): array', 'RepositoryInterface $cajas, RepositoryInterface $estantes): array', (string) file_get_contents($file), $replaced)); + self::assertSame(1, $replaced); + + $judge = $this->judge('GuardarCaja')->files[0]->contents; + + self::assertStringContainsString("->run(\$this->cajas, /* RepositoryInterface \$estantes */);", $judge); + self::assertStringContainsString('private RepositoryInterface $cajas;', $judge); + self::assertStringNotContainsString('$this->estantes', $judge); + } + + /** A class that lives beside the operations and is not one gets the bare judge. */ + public function testAClassBesideTheOperationsThatIsNotOneGetsTheBareJudge(): void + { + $this->house(); + file_put_contents( + "{$this->root}/src/Plugins/{$this->plugin}/Operations/Etiquetador.php", + "plugin}\\Operations;\n\nfinal class Etiquetador\n{\n public function run(\\Milpa\\Data\\RepositoryInterface \$cajas): array\n {\n return [];\n }\n}\n", + ); + + self::assertSame(self::bare($this->plugin, 'Etiquetador'), $this->judge('Etiquetador')->files[0]->contents); + } + + public function testTheGuidanceSaysWhatTheJudgeHoldsAndWhatItRunsOver(): void + { + $this->house(); + + $guidance = (string) $this->judge('GuardarCaja')->guidance; + + self::assertStringContainsString('This judge (class GuardarCajaTest) will run when GuardarCaja lands.', $guidance); + self::assertStringContainsString('It already holds what GuardarCaja::run() works through — the repository of Caja, in memory', $guidance); + self::assertStringContainsString('shows the call the house makes', $guidance); + self::assertStringContainsString('If it calls another operation of this plugin, land that one\'s body first', $guidance); + } + + /** A judge named with its suffix is the same judge. */ + public function testANameThatEndsInTestIsTheSameJudge(): void + { + $this->house(); + + self::assertSame( + $this->judge('GuardarCaja')->files[0]->contents, + $this->judge('GuardarCajaTest')->files[0]->contents, + ); + } + + /** Nothing changes for what is not an operation: its judge is the bare one, byte for byte. */ + public function testTheJudgeOfSomethingThatIsNotAnOperationIsWhatItWas(): void + { + $this->house(); + mkdir("{$this->root}/src/Plugins/{$this->plugin}/Services", 0o775, true); + file_put_contents( + "{$this->root}/src/Plugins/{$this->plugin}/Services/Etiquetas.php", + "plugin}\\Services;\n\nfinal class Etiquetas\n{\n public function run(): array\n {\n return [];\n }\n}\n", + ); + + $result = $this->judge('Etiquetas'); + + self::assertSame(self::bare($this->plugin, 'Etiquetas'), $result->files[0]->contents); + self::assertSame('This judge (class EtiquetasTest) will run when Etiquetas lands. Fill it with `implement` declaring what Etiquetas must do.', $result->guidance); + } + + /** Nor for a judge scaffolded before the class it judges exists. */ + public function testTheJudgeOfAClassThatDoesNotExistYetIsWhatItWas(): void + { + $this->house(); + + $result = $this->judge('ListarCajas'); + + self::assertSame(self::bare($this->plugin, 'ListarCajas'), $result->files[0]->contents); + self::assertStringContainsString('No ListarCajas exists yet', (string) $result->guidance); + } + + /** + * A plugin with its entities and one operation over them, scaffolded by the real generators in the order a + * resident scaffolds them. + * + * @param list $entities + * @param array $operation + */ + private function house(string $fields = 'etiqueta:string', array $entities = ['Caja'], array $operation = []): void + { + $this->write((new PluginGenerator())->generate(new GenerationContext($this->plugin, $this->plugin, ['flavor' => 'runtime'], $this->root))); + foreach ($entities as $entity) { + $this->write((new EntityGenerator())->generate(new GenerationContext($this->plugin, $entity, ['flavor' => 'runtime', 'fields' => 'etiqueta:string'], $this->root))); + } + $this->write((new OperationGenerator())->generate(new GenerationContext( + $this->plugin, + 'GuardarCaja', + ['flavor' => 'runtime', 'operation' => 'bodega:guardar', 'fields' => $fields] + $operation, + $this->root, + ))); + } + + private function judge(string $name): GenerationResult + { + return (new TestGenerator())->generate(new GenerationContext($this->plugin, $name, [], $this->root)); + } + + private function write(GenerationResult $result): void + { + foreach ($result->files as $file) { + $path = str_starts_with($file->path, '/') ? $file->path : $this->root . '/' . $file->path; + @mkdir(\dirname($path), 0o775, true); + file_put_contents($path, $file->contents); + } + } + + /** The scaffold with the call it shows taken out of its comment, and the scaffold's own answer asserted. */ + private function withItsCallMade(string $judge): string + { + self::assertSame(1, preg_match('/^\s*\/\/\s+(\$answer = .+;)$/m', $judge, $call), 'the scaffold shows a call'); + + return (string) preg_replace( + '/^\s*self::fail\(.*$/m', + " {$call[1]}\n self::assertFalse(\$answer['ok']);\n self::assertStringContainsString('is a scaffold', \$answer['error']);", + $judge, + ); + } + + /** + * Runs a judge with a real phpunit, over the plugin as it is on disk. + * + * @return array{0: int, 1: string} + */ + private function run_(string $judge): array + { + $file = "{$this->root}/tests/Plugins/{$this->plugin}/GuardarCajaTest.php"; + @mkdir(\dirname($file), 0o775, true); + file_put_contents($file, $judge); + $requires = "root}/src/Plugins/{$this->plugin}/{$kind}/*.php") ?: [] as $source) { + $requires .= 'require ' . var_export($source, true) . ";\n"; + } + } + file_put_contents($this->root . '/bootstrap.php', $requires); + exec( + escapeshellarg(\dirname(__DIR__, 3) . '/vendor/bin/phpunit') . ' --no-configuration --bootstrap ' + . escapeshellarg($this->root . '/bootstrap.php') . ' ' . escapeshellarg($file) . ' 2>&1', + $lines, + $exit, + ); + + return [$exit, implode("\n", $lines)]; + } + + /** The judge's scaffold as it has always been. */ + private static function bare(string $plugin, string $target): string + { + return << + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/devtools + */ + +declare(strict_types=1); + +namespace Milpa\DevTools\Tests\Operations; + +use Milpa\DevTools\Make\GenerationContext; +use Milpa\DevTools\Make\GenerationResult; +use Milpa\DevTools\Make\Generators\EntityGenerator; +use Milpa\DevTools\Make\Generators\OperationGenerator; +use Milpa\DevTools\Make\Generators\PluginGenerator; +use Milpa\DevTools\Operations\ImplementHandler; +use Milpa\DevTools\Support\RootResolver; +use PHPUnit\Framework\TestCase; + +/** + * A REFUSAL NAMES ITS CAUSE WHEN THE HOUSE KNOWS IT (greenhouse evidence/1156). + * + * A real resident wrote one judge for the whole cycle of its plugin — register, lend, take back — and named it + * after one operation. That operation's body was refused three times in a row as «judged red by its own test», + * and the run ended with it still a scaffold. The body was never the reason: the judge called the plugin's other + * operations, which in that copy of the house were still scaffolds, and a scaffold answers `ok: false`. The house + * wrote those scaffolds and could read that they were unfilled; the refusal showed ten lines of phpunit. + * + * Everything here is scaffolded by the real generators and judged by a real phpunit. + */ +final class ARedJudgeNamesTheScaffoldsItRunsOverTest extends TestCase +{ + private static int $n = 0; + + private string $root; + private string $plugin; + + protected function setUp(): void + { + $this->root = sys_get_temp_dir() . '/milpa-devtools-scaffolds-' . uniqid(); + mkdir($this->root, 0o775, true); + file_put_contents( + $this->root . '/composer.json', + (string) json_encode(['autoload' => ['psr-4' => ['App\\' => 'src/']]], JSON_PRETTY_PRINT), + ); + $this->plugin = 'Bodega' . (++self::$n) . 'y' . getmypid(); + $this->write((new PluginGenerator())->generate($this->context($this->plugin))); + $this->write((new EntityGenerator())->generate($this->context('Caja', ['fields' => 'etiqueta:string']))); + $this->scaffold('GuardarCaja', ['operation' => 'bodega:guardar', 'fields' => 'etiqueta:string']); + $this->scaffold('ListarCajas', ['operation' => 'bodega:listar', 'reads' => true]); + $this->judge("\$guardada = (new GuardarCaja(etiqueta: 'Tornillos'))->run(\$this->cajas);\n self::assertTrue(\$guardada['ok']);"); + } + + protected function tearDown(): void + { + exec('rm -rf ' . escapeshellarg($this->root)); + } + + public function testARedJudgeNamesTheScaffoldItRunsOverAndTheOrderThatGetsPastIt(): void + { + $before = (string) file_get_contents($this->operation('ListarCajas')); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => $this->listar()]); + + self::assertFalse($r['ok']); + self::assertSame('behavior', $r['diagnostic']['phase']); + self::assertSame(['GuardarCaja'], $r['diagnostic']['scaffolds']); + self::assertStringContainsString('ListarCajasTest names GuardarCaja, and in this copy of the house it is still a scaffold', $r['error']); + self::assertStringContainsString('its run() answers «not implemented»', $r['error']); + self::assertStringContainsString("1. implement plugin={$this->plugin} class=GuardarCaja, and promote it.", $r['error']); + self::assertStringContainsString("2. Then send this same implement plugin={$this->plugin} class=ListarCajas again.", $r['error']); + self::assertSame($before, (string) file_get_contents($this->operation('ListarCajas')), 'and the scaffold is back, byte for byte'); + } + + /** What phpunit said still travels: the note is added to it, it does not replace it. */ + public function testWhatTheJudgeSaidStillTravels(): void + { + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => $this->listar()]); + + self::assertStringContainsString("refused: the class's own test judges this behavior red — ListarCajasTest said:\n", $r['error']); + self::assertStringContainsString('Failed asserting that false is true.', $r['error']); + self::assertGreaterThan(strpos($r['error'], 'Failed asserting'), strpos($r['error'], 'ListarCajasTest names GuardarCaja'), 'the cause closes the refusal'); + } + + public function testFollowedInThatOrderTheSameBodyLands(): void + { + file_put_contents($this->operation('GuardarCaja'), $this->guardar()); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => $this->listar()]); + + self::assertTrue($r['ok'], json_encode($r, JSON_UNESCAPED_UNICODE) ?: ''); + self::assertStringContainsString('behavior (ListarCajasTest green)', $r['verified']); + } + + /** A judge red over no scaffold is red for what the body does: nothing is said of scaffolds. */ + public function testARedJudgeOverNoScaffoldSaysNothingOfScaffolds(): void + { + file_put_contents($this->operation('GuardarCaja'), $this->guardar()); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', + 'content' => str_replace('$cajas->all()', '[]', $this->listar())]); + + self::assertFalse($r['ok']); + self::assertSame('behavior', $r['diagnostic']['phase']); + self::assertSame([], $r['diagnostic']['scaffolds']); + self::assertStringNotContainsString('ListarCajasTest names', $r['error']); + self::assertStringNotContainsString('in this order', $r['error']); + self::assertStringEndsNotWith("\n", $r['error']); + } + + /** A scaffold the judge does not name is nobody's obstacle here. */ + public function testAScaffoldTheJudgeDoesNotNameIsNotBlamed(): void + { + $this->scaffold('BorrarCaja', ['operation' => 'bodega:borrar', 'fields' => 'id:int']); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => $this->listar()]); + + self::assertSame(['GuardarCaja'], $r['diagnostic']['scaffolds']); + self::assertStringNotContainsString('BorrarCaja', $r['error']); + } + + /** With several, each is named, and the order fills them one at a time. */ + public function testSeveralScaffoldsAreNamedEachWithItsCall(): void + { + $this->scaffold('BorrarCaja', ['operation' => 'bodega:borrar', 'fields' => 'id:int']); + $this->judge("\$guardada = (new GuardarCaja(etiqueta: 'Tornillos'))->run(\$this->cajas);\n self::assertTrue(\$guardada['ok']);\n" + . " self::assertTrue((new \\App\\Plugins\\{$this->plugin}\\Operations\\BorrarCaja(id: 99))->run(\$this->cajas)['ok']);"); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => $this->listar()]); + + self::assertSame(['BorrarCaja', 'GuardarCaja'], $r['diagnostic']['scaffolds']); + self::assertStringContainsString('ListarCajasTest names BorrarCaja, GuardarCaja, and in this copy of the house they are still scaffolds', $r['error']); + self::assertStringContainsString("1. implement plugin={$this->plugin} class=BorrarCaja, and promote it; then the same for GuardarCaja.", $r['error']); + } + + /** A judge reaches an operation by the name it declares too. */ + public function testAScaffoldNamedByTheOperationItDeclaresIsNamedToo(): void + { + $this->judge("self::assertSame([], \$this->cajas->all(), 'nothing until bodega:guardar has run');\n self::fail('bodega:guardar is not callable here yet');"); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => $this->listar()]); + + self::assertSame(['GuardarCaja'], $r['diagnostic']['scaffolds']); + } + + /** A name that only contains a scaffold's name is another name. */ + public function testANameThatOnlyContainsAScaffoldsNameIsNotIt(): void + { + $this->judge("self::fail('MiGuardarCaja, GuardarCajaGrande y GuardarCajas no son de este plugin');"); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => $this->listar()]); + + self::assertFalse($r['ok']); + self::assertSame([], $r['diagnostic']['scaffolds']); + } + + /** The class being landed is the proposal: it is never its own obstacle, whatever its text still says. */ + public function testTheClassBeingLandedIsNeverNamedAsItsOwnObstacle(): void + { + file_put_contents($this->operation('GuardarCaja'), $this->guardar()); + + $r = $this->handler()->handle(['plugin' => $this->plugin, 'class' => 'ListarCajas', 'content' => str_replace( + "return ['ok' => true, 'cajas' => \\count(\$cajas->all())];", + "return ['ok' => false, 'error' => 'not implemented: ListarCajas::run() is a scaffold — fill it with implement'];", + $this->listar(), + )]); + + self::assertFalse($r['ok']); + self::assertSame([], $r['diagnostic']['scaffolds']); + } + + /** The house reads which operations are unfilled from the sentence its own scaffold answers with. */ + public function testTheScaffoldStillCarriesTheSentenceTheHouseReadsItBy(): void + { + $dir = "{$this->root}/src/Plugins/{$this->plugin}"; + + self::assertSame(['GuardarCaja', 'ListarCajas'], OperationGenerator::unfilledIn($dir)); + self::assertStringContainsString('GuardarCaja' . OperationGenerator::UNFILLED, (string) file_get_contents($this->operation('GuardarCaja'))); + + // Filled — and a filled body that quotes ANOTHER class's sentence is still filled. + file_put_contents($this->operation('GuardarCaja'), str_replace( + "return ['ok' => true];", + "// ListarCajas::run() is a scaffold — fill it with implement\n return ['ok' => true];", + $this->guardar(), + )); + + self::assertSame(['ListarCajas'], OperationGenerator::unfilledIn($dir)); + self::assertSame([], OperationGenerator::unfilledIn($dir . '/nowhere')); + } + + /** @param array $options */ + private function context(string $name, array $options = []): GenerationContext + { + return new GenerationContext($this->plugin, $name, ['flavor' => 'runtime'] + $options, $this->root); + } + + /** @param array $options */ + private function scaffold(string $name, array $options): void + { + $this->write((new OperationGenerator())->generate($this->context($name, $options))); + } + + private function write(GenerationResult $result): void + { + foreach ($result->files as $file) { + $path = str_starts_with($file->path, '/') ? $file->path : $this->root . '/' . $file->path; + @mkdir(\dirname($path), 0o775, true); + file_put_contents($path, $file->contents); + } + } + + private function operation(string $class): string + { + return "{$this->root}/src/Plugins/{$this->plugin}/Operations/{$class}.php"; + } + + /** The judge of ListarCajas: it stores a row first — through another operation — and then lists. */ + private function judge(string $arrange): void + { + $uses = preg_match('/(?plugin}\\Operations\\GuardarCaja;\n" : ''; + @mkdir("{$this->root}/tests/Plugins/{$this->plugin}", 0o775, true); + file_put_contents("{$this->root}/tests/Plugins/{$this->plugin}/ListarCajasTest.php", <<plugin}; + +use App\\Plugins\\{$this->plugin}\\Entities\\Caja; +{$uses}use App\\Plugins\\{$this->plugin}\\Operations\\ListarCajas; +use Milpa\\Data\\InMemoryRepository; +use Milpa\\Data\\RepositoryInterface; +use PHPUnit\\Framework\\TestCase; + +final class ListarCajasTest extends TestCase +{ + private RepositoryInterface \$cajas; + + protected function setUp(): void + { + \$this->cajas = new InMemoryRepository(Caja::class); + } + + public function testItCountsWhatWasStored(): void + { + {$arrange} + self::assertSame(1, (new ListarCajas())->run(\$this->cajas)['cajas']); + } +} + +PHP); + } + + /** A handler whose judge is a real phpunit over the plugin as it is on disk when the judge runs. */ + private function handler(): ImplementHandler + { + $requires = "root}/src/Plugins/{$this->plugin}/", true) + . " . \$kind . '/*.php') ?: [] as \$source) { require \$source; } }\n"; + file_put_contents($this->root . '/bootstrap.php', $requires); + $runner = $this->root . '/judge.sh'; + file_put_contents($runner, "#!/bin/sh\n" . escapeshellarg(\dirname(__DIR__, 2) . '/vendor/bin/phpunit') + . ' --no-configuration --bootstrap ' . escapeshellarg($this->root . '/bootstrap.php') . " \"\$1\" 2>&1\n"); + chmod($runner, 0o755); + + return new ImplementHandler(new RootResolver($this->root), behaviorRunner: $runner); + } + + /** The body of GuardarCaja, on its scaffold. */ + private function guardar(): string + { + return str_replace( + "return ['ok' => false, 'error' => 'not implemented: GuardarCaja::run() is a scaffold — fill it with implement'];", + "\$cajas->save(new \\App\\Plugins\\{$this->plugin}\\Entities\\Caja(null, \$this->etiqueta));\n\n return ['ok' => true];", + (string) file_get_contents($this->operation('GuardarCaja')), + ); + } + + /** The body of ListarCajas, on its scaffold: a right one. */ + private function listar(): string + { + return str_replace( + "return ['ok' => false, 'error' => 'not implemented: ListarCajas::run() is a scaffold — fill it with implement'];", + "return ['ok' => true, 'cajas' => \\count(\$cajas->all())];", + (string) file_get_contents($this->operation('ListarCajas')), + ); + } +}