From ef53d6a05112a0a495ec022f9ce97a4cc5c12a5c Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 23 Jun 2026 15:21:49 +0400 Subject: [PATCH 1/2] feat(spec): Init `spec` plugin --- .github/.release-please-config.json | 6 + .github/workflows/split-publish.yml | 1 + composer.json | 3 + plugin/spec/CHANGELOG.md | 1 + plugin/spec/README.md | 145 ++++++++++++++ plugin/spec/Spec.php | 51 +++++ plugin/spec/composer.json | 44 ++++ .../src/Internal/SpecCaseOrderInterceptor.php | 44 ++++ plugin/spec/src/Internal/SpecCollector.php | 156 +++++++++++++++ plugin/spec/src/Internal/SpecEntry.php | 97 +++++++++ plugin/spec/src/Internal/SpecHeaderReader.php | 37 ++++ plugin/spec/src/Internal/SpecInput.php | 47 +++++ plugin/spec/src/Internal/SpecInterceptor.php | 98 +++++++++ plugin/spec/src/Internal/SpecNumberer.php | 189 ++++++++++++++++++ .../Internal/SpecSuiteOrderInterceptor.php | 35 ++++ plugin/spec/src/SpecHeader.php | 54 +++++ plugin/spec/src/SpecPlugin.php | 73 +++++++ .../tests/Feature/SpecDocumentFeatureTest.php | 142 +++++++++++++ plugin/spec/tests/Feature/SpecFeatureTest.php | 83 ++++++++ plugin/spec/tests/Stub/AuthStub.php | 28 +++ plugin/spec/tests/Stub/MiscStub.php | 28 +++ plugin/spec/tests/Stub/SpecStub.php | 33 +++ .../spec/tests/Unit/Fixture/HeaderedCase.php | 23 +++ .../Unit/Fixture/NumberedSectionHigh.php | 10 + .../tests/Unit/Fixture/NumberedSectionLow.php | 10 + .../spec/tests/Unit/Fixture/OrderingCase.php | 27 +++ .../tests/Unit/Fixture/UnnumberedSection.php | 7 + .../tests/Unit/Internal/SpecCollectorTest.php | 169 ++++++++++++++++ .../tests/Unit/Internal/SpecNumbererTest.php | 126 ++++++++++++ .../Internal/SpecOrderInterceptorTest.php | 87 ++++++++ plugin/spec/tests/Unit/SpecHeaderTest.php | 82 ++++++++ .../spec/tests/Unit/SpecInterceptorTest.php | 181 +++++++++++++++++ plugin/spec/tests/Unit/SpecPluginTest.php | 171 ++++++++++++++++ plugin/spec/tests/Unit/SpecTest.php | 59 ++++++ plugin/spec/tests/runtime/.gitignore | 2 + plugin/spec/tests/suites.php | 31 +++ resources/version.json | 1 + testo.php | 2 + 38 files changed, 2383 insertions(+) create mode 100644 plugin/spec/CHANGELOG.md create mode 100644 plugin/spec/README.md create mode 100644 plugin/spec/Spec.php create mode 100644 plugin/spec/composer.json create mode 100644 plugin/spec/src/Internal/SpecCaseOrderInterceptor.php create mode 100644 plugin/spec/src/Internal/SpecCollector.php create mode 100644 plugin/spec/src/Internal/SpecEntry.php create mode 100644 plugin/spec/src/Internal/SpecHeaderReader.php create mode 100644 plugin/spec/src/Internal/SpecInput.php create mode 100644 plugin/spec/src/Internal/SpecInterceptor.php create mode 100644 plugin/spec/src/Internal/SpecNumberer.php create mode 100644 plugin/spec/src/Internal/SpecSuiteOrderInterceptor.php create mode 100644 plugin/spec/src/SpecHeader.php create mode 100644 plugin/spec/src/SpecPlugin.php create mode 100644 plugin/spec/tests/Feature/SpecDocumentFeatureTest.php create mode 100644 plugin/spec/tests/Feature/SpecFeatureTest.php create mode 100644 plugin/spec/tests/Stub/AuthStub.php create mode 100644 plugin/spec/tests/Stub/MiscStub.php create mode 100644 plugin/spec/tests/Stub/SpecStub.php create mode 100644 plugin/spec/tests/Unit/Fixture/HeaderedCase.php create mode 100644 plugin/spec/tests/Unit/Fixture/NumberedSectionHigh.php create mode 100644 plugin/spec/tests/Unit/Fixture/NumberedSectionLow.php create mode 100644 plugin/spec/tests/Unit/Fixture/OrderingCase.php create mode 100644 plugin/spec/tests/Unit/Fixture/UnnumberedSection.php create mode 100644 plugin/spec/tests/Unit/Internal/SpecCollectorTest.php create mode 100644 plugin/spec/tests/Unit/Internal/SpecNumbererTest.php create mode 100644 plugin/spec/tests/Unit/Internal/SpecOrderInterceptorTest.php create mode 100644 plugin/spec/tests/Unit/SpecHeaderTest.php create mode 100644 plugin/spec/tests/Unit/SpecInterceptorTest.php create mode 100644 plugin/spec/tests/Unit/SpecPluginTest.php create mode 100644 plugin/spec/tests/Unit/SpecTest.php create mode 100644 plugin/spec/tests/runtime/.gitignore create mode 100644 plugin/spec/tests/suites.php diff --git a/.github/.release-please-config.json b/.github/.release-please-config.json index e86e6dee..b0d2fc21 100644 --- a/.github/.release-please-config.json +++ b/.github/.release-please-config.json @@ -35,6 +35,12 @@ "include-component-in-tag": true, "changelog-path": "CHANGELOG.md" }, + "plugin/spec": { + "package-name": "testo/spec", + "component": "spec", + "include-component-in-tag": true, + "changelog-path": "CHANGELOG.md" + }, "plugin/filter": { "package-name": "testo/filter", "component": "filter", diff --git a/.github/workflows/split-publish.yml b/.github/workflows/split-publish.yml index 7176ed51..c49d7e8d 100644 --- a/.github/workflows/split-publish.yml +++ b/.github/workflows/split-publish.yml @@ -29,6 +29,7 @@ on: # yamllint disable-line rule:truthy - 'lifecycle-[0-9]*' - 'repeat-[0-9]*' - 'retry-[0-9]*' + - 'spec-[0-9]*' - 'test-[0-9]*' name: 📦 Split publish diff --git a/composer.json b/composer.json index a84265fb..7e482713 100644 --- a/composer.json +++ b/composer.json @@ -60,6 +60,7 @@ "spiral/code-style": "^2.2.2", "testo/bridge-infection": "^0.1.6", "testo/facade": "^0.1.1", + "testo/spec": "^0.1.0", "vimeo/psalm": "^7.0@dev" }, "suggest": { @@ -91,6 +92,7 @@ "Tests\\Lifecycle\\": "plugin/lifecycle/tests/", "Tests\\Repeat\\": "plugin/repeat/tests/", "Tests\\Retry\\": "plugin/retry/tests/", + "Tests\\Spec\\": "plugin/spec/tests/", "Tests\\Test\\": "plugin/test/tests/" }, "files": [ @@ -115,6 +117,7 @@ "testo/lifecycle": "0.1.x-dev", "testo/repeat": "0.1.x-dev", "testo/retry": "0.1.x-dev", + "testo/spec": "0.1.x-dev", "testo/test": "0.1.x-dev" } } diff --git a/plugin/spec/CHANGELOG.md b/plugin/spec/CHANGELOG.md new file mode 100644 index 00000000..825c32f0 --- /dev/null +++ b/plugin/spec/CHANGELOG.md @@ -0,0 +1 @@ +# Changelog diff --git a/plugin/spec/README.md b/plugin/spec/README.md new file mode 100644 index 00000000..5cfd6cb2 --- /dev/null +++ b/plugin/spec/README.md @@ -0,0 +1,145 @@ +

+ TESTO +

+ +

Spec-driven plugin

+ +
+ +[![Documentation](https://img.shields.io/badge/Documentation-blue?style=for-the-badge&logo=gitbook&logoColor=white)](https://php-testo.github.io) +[![Support on Boosty](https://img.shields.io/static/v1?style=for-the-badge&label=&message=Sponsorship&logo=Boosty&logoColor=white&color=%23F15F2C)](https://boosty.to/roxblnfk) + +
+ +
+ +> [!IMPORTANT] +> ## 🪞 This is a read-only mirror. +> +> Active development of the Testo project lives in [**php-testo/testo**](https://github.com/php-testo/testo) under `plugin/spec/`. This repository is **automatically synchronized** from there on every release. +> +> File issues and pull requests in the [main monorepo](https://github.com/php-testo/testo/issues), not here. + +## About + +Blends **BDD**, **Spec-Driven** and **TDD** workflows: write the behaviour you expect as a `#[Spec(...)]` +fragment — a user story or a slice of the product specification — right next to the test that proves it. + +At runtime every fragment is published to the `spec.md` messenger channel, so it travels with the test +output. When you want living documentation, flip on generation (the `--spec` flag or the plugin's +`collect` option) and Testo renders the collected fragments into Markdown files, one per Test Case. + +## Install + +```bash +composer require --dev testo/spec +``` + +[![PHP](https://img.shields.io/packagist/php-v/testo/spec.svg?style=flat-square&logo=php)](https://packagist.org/packages/testo/spec) +[![Latest Version on Packagist](https://img.shields.io/packagist/v/testo/spec.svg?style=flat-square&logo=packagist)](https://packagist.org/packages/testo/spec) +[![License](https://img.shields.io/packagist/l/testo/spec.svg?style=flat-square)](https://github.com/php-testo/testo/blob/1.x/LICENSE.md) +[![Total Downloads](https://img.shields.io/packagist/dt/testo/spec.svg?style=flat-square)](https://packagist.org/packages/testo/spec/stats) + +## Usage + +Attach a spec fragment to a test (method, function, or a whole class). `#[Spec]` carries the +*content* (the story), `#[SpecHeader]` carries the *heading and number*: + +```php +use Testo\Spec; +use Testo\Spec\SpecHeader; +use Testo\Test; + +#[Test] +#[SpecHeader(title: 'Checkout', number: '5')] // class = numbered section +final class CheckoutTest +{ + #[Spec( + story: <<<'MD' + **As a** customer + **I want** my cart total to include tax + **so that** the price I pay matches the price I see. + MD, + tags: ['checkout', 'JIRA-128'], + )] + public function totalIncludesTax(): void // -> item 5.1 + { + // ...assertions that prove the story... + } + + #[Test] + #[Spec(story: 'A valid coupon lowers the total.')] + #[SpecHeader(title: 'Coupon applies')] // override the item title, still auto-numbered -> 5.2 + public function couponApplies(): void + { + // ... + } +} +``` + +### Numbering & ordering + +- `#[SpecHeader]` on a **class** opens a section: `number` is the section number (maps onto your + external spec document), `title` is the heading. Either may be omitted — a section with no number + falls to the end, a section with no title falls back to the class name. +- `#[SpecHeader]` on a **method** overrides that item's title and/or pins its number. +- Items are auto-numbered `{section}.{n}` in source order; a pinned method number is kept as-is. +- **Collisions** (e.g. two cases sharing a section number) are disambiguated with a ` (1)`, ` (2)` … + suffix in document order — numbers are never silently dropped. + +The example above renders to: + +```markdown +# 5. Checkout + +## 5.1 totalIncludesTax + +**As a** customer +**I want** my cart total to include tax +**so that** the price I pay matches the price I see. + +_Tags: `checkout` `JIRA-128`_ + +## 5.2 Coupon applies + +A valid coupon lowers the total. +``` + +Sections without a number are gathered into a trailing **Uncategorized** block — items with a header +render as a bullet list, items without one as plain paragraphs. + +### Execution order follows the numbers + +By default the plugin also **reorders test execution** to match the document: Test Cases run in +section-number order, and tests within a case run in item-number order (unnumbered ones keep source +order and run last). Turn it off with `new SpecPlugin(reorder: false)` if your tests must keep their +discovered order. + +### Generate documentation + +Register the plugin in `testo.php`: + +```php +use Testo\Spec\SpecPlugin; + +// In ApplicationConfig::$plugins or a SuiteConfig::$plugins: +new SpecPlugin(outputDir: __DIR__ . '/docs/specs'), +``` + +Reordering is on as soon as the plugin is registered. File generation is separate — enable it with +`collect: true` on the plugin, or from the CLI: + +```bash +# Generate into the plugin's configured directory +vendor/bin/testo --spec + +# Generate into a custom directory +vendor/bin/testo --spec-dir=docs/specs +``` + +The whole run is rendered into a single ordered `spec.md` in the target directory. Even without the +plugin the fragments are still emitted to the `spec.md` channel — generation and reordering are the +optional halves. diff --git a/plugin/spec/Spec.php b/plugin/spec/Spec.php new file mode 100644 index 00000000..5328e615 --- /dev/null +++ b/plugin/spec/Spec.php @@ -0,0 +1,51 @@ + */ + public array $tags; + + /** + * @param non-empty-string $story The user story or specification fragment, written in Markdown. This is the + * behaviour the test verifies — keep it human-readable, it ends up verbatim in the report. + * @param list $tags Free-form labels (e.g. a feature key, a Jira id) used to + * group or filter fragments in generated documents. + */ + public function __construct( + public string $story, + array $tags = [], + ) { + \trim($story) === '' and throw new \InvalidArgumentException('Spec story must not be empty.'); + + $this->tags = \array_values(\array_filter( + $tags, + static fn(string $tag): bool => $tag !== '', + )); + } +} diff --git a/plugin/spec/composer.json b/plugin/spec/composer.json new file mode 100644 index 00000000..e4ccfc81 --- /dev/null +++ b/plugin/spec/composer.json @@ -0,0 +1,44 @@ +{ + "name": "testo/spec", + "description": "Spec-driven plugin for the Testo testing framework: attach BDD/spec fragments to tests and generate living documentation.", + "license": "BSD-3-Clause", + "type": "library", + "keywords": [ + "testo", + "spec", + "bdd", + "documentation", + "test" + ], + "authors": [ + { + "name": "Aleksei Gagarin (roxblnfk)", + "homepage": "https://github.com/roxblnfk" + } + ], + "funding": [ + { + "type": "boosty", + "url": "https://boosty.to/roxblnfk" + } + ], + "require": { + "php": ">=8.2", + "testo/testo": "0.10.26 - 1" + }, + "autoload": { + "psr-4": { + "Testo\\Spec\\": "src/" + }, + "files": [ + "Spec.php" + ] + }, + "minimum-stability": "dev", + "prefer-stable": true, + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev" + } + } +} diff --git a/plugin/spec/src/Internal/SpecCaseOrderInterceptor.php b/plugin/spec/src/Internal/SpecCaseOrderInterceptor.php new file mode 100644 index 00000000..c514e176 --- /dev/null +++ b/plugin/spec/src/Internal/SpecCaseOrderInterceptor.php @@ -0,0 +1,44 @@ +definition->reflection)?->number; + + $items = []; + foreach ($info->definition->tests->getTests() as $name => $definition) { + $items[$name] = [ + 'number' => SpecHeaderReader::item($definition->reflection)?->number, + 'line' => $definition->reflection->getStartLine() ?: 0, + ]; + } + + $position = \array_flip(SpecNumberer::orderKeys($items, $section)); + + $info->definition->tests->sort(static fn(TestDefinition $a, TestDefinition $b): int => + ($position[$a->reflection->getShortName()] ?? 0) <=> ($position[$b->reflection->getShortName()] ?? 0)); + + return $next($info); + } +} diff --git a/plugin/spec/src/Internal/SpecCollector.php b/plugin/spec/src/Internal/SpecCollector.php new file mode 100644 index 00000000..53b43458 --- /dev/null +++ b/plugin/spec/src/Internal/SpecCollector.php @@ -0,0 +1,156 @@ + */ + private array $entries = []; + + /** + * @param non-empty-string $outputDir Directory the Markdown document is written to. + */ + public function __construct( + private readonly string $outputDir, + ) {} + + public function onTestSuiteFinished(TestSuiteFinished $event): void + { + $this->addSuite($event->suiteResult); + $this->flush(); + } + + /** + * Accumulate every spec fragment recorded in the given suite, in execution order. + */ + public function addSuite(SuiteResult $suite): void + { + foreach ($suite->results as $case) { + \assert($case instanceof CaseResult); + foreach ($case->results as $test) { + \assert($test instanceof TestResult); + foreach ($test->messages->channel(SpecInterceptor::CHANNEL) as $message) { + $entry = SpecEntry::fromMessage($message); + $entry === null or $this->entries[] = $entry; + } + } + } + } + + /** + * Render everything accumulated so far into the document file. Returns the written path, or null + * when nothing has been collected. + * + * @return non-empty-string|null + */ + public function flush(): ?string + { + if ($this->entries === []) { + return null; + } + + \is_dir($this->outputDir) || \mkdir($this->outputDir, 0o775, recursive: true); + + $path = $this->outputDir . \DIRECTORY_SEPARATOR . self::FILENAME; + \file_put_contents($path, $this->render(SpecNumberer::build($this->entries))); + + return $path; + } + + /** + * @param array{sections: list}>}>, extra: list}>}>} $model + */ + public function render(array $model): string + { + $blocks = []; + + foreach ($model['sections'] as $section) { + $out = "# {$section['number']}. {$section['title']}\n"; + foreach ($section['items'] as $item) { + $out .= "\n## {$item['number']} {$item['title']}\n\n" . \trim($item['story']) . "\n"; + $out .= self::tags($item['tags'], ''); + } + $blocks[] = $out; + } + + if ($model['extra'] !== []) { + $blocks[] = $this->renderExtra($model['extra']); + } + + return \implode("\n", $blocks); + } + + /** + * @param list $tags + */ + private static function tags(array $tags, string $indent): string + { + if ($tags === []) { + return ''; + } + + $rendered = \implode(' ', \array_map(static fn(string $t): string => '`' . $t . '`', $tags)); + + return "\n{$indent}_Tags: {$rendered}_\n"; + } + + private static function indent(string $text): string + { + return \implode("\n", \array_map( + static fn(string $line): string => $line === '' ? '' : ' ' . $line, + \explode("\n", $text), + )); + } + + /** + * @param list}>}> $extra + */ + private function renderExtra(array $extra): string + { + $out = "# Uncategorized\n"; + foreach ($extra as $block) { + $out .= "\n"; + $block['title'] === null or $out .= "## {$block['title']}\n\n"; + + foreach ($block['items'] as $item) { + if ($item['title'] !== null) { + // Has a header → bullet list item with the story indented under it. + $out .= "- {$item['title']}\n" . self::indent(\trim($item['story'])) . "\n"; + $out .= self::tags($item['tags'], ' '); + } else { + // No header → a plain paragraph. + $out .= \trim($item['story']) . "\n"; + $out .= self::tags($item['tags'], ''); + } + } + } + + return $out; + } +} diff --git a/plugin/spec/src/Internal/SpecEntry.php b/plugin/spec/src/Internal/SpecEntry.php new file mode 100644 index 00000000..cb17035a --- /dev/null +++ b/plugin/spec/src/Internal/SpecEntry.php @@ -0,0 +1,97 @@ + $tags + */ + public function __construct( + public string $case, + public string $test, + public ?string $title, + public ?string $number, + public ?string $sectionTitle, + public ?string $sectionNumber, + public int $line, + public string $story, + public array $tags, + ) {} + + /** + * Rebuild an entry from a channel message, or null when the message carries no spec context + * (e.g. it was written to the channel by something other than {@see SpecInterceptor}). + */ + public static function fromMessage(Message $message): ?self + { + $context = $message->context; + $story = $context['story'] ?? null; + $case = $context['case'] ?? null; + $test = $context['test'] ?? null; + + if (!\is_string($story) || !\is_string($case) || $case === '' || !\is_string($test) || $test === '') { + return null; + } + + /** @var list $tags */ + $tags = \is_array($context['tags'] ?? null) + ? \array_values(\array_filter($context['tags'], static fn(mixed $t): bool => \is_string($t) && $t !== '')) + : []; + + return new self( + case: $case, + test: $test, + title: self::nonEmptyString($context['title'] ?? null), + number: self::nonEmptyString($context['number'] ?? null), + sectionTitle: self::nonEmptyString($context['sectionTitle'] ?? null), + sectionNumber: self::nonEmptyString($context['sectionNumber'] ?? null), + line: \is_int($context['line'] ?? null) ? $context['line'] : 0, + story: $story, + tags: $tags, + ); + } + + /** + * Heading to show for this item: the explicit title or, failing that, the test name. + * + * @return non-empty-string + */ + public function heading(): string + { + return $this->title ?? $this->test; + } + + /** + * @return non-empty-string|null + */ + private static function nonEmptyString(mixed $value): ?string + { + return \is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/plugin/spec/src/Internal/SpecHeaderReader.php b/plugin/spec/src/Internal/SpecHeaderReader.php new file mode 100644 index 00000000..a6491e57 --- /dev/null +++ b/plugin/spec/src/Internal/SpecHeaderReader.php @@ -0,0 +1,37 @@ +getAttributes(SpecHeader::class)); + } + + public static function item(\ReflectionFunctionAbstract $reflection): ?SpecHeader + { + return self::first($reflection->getAttributes(SpecHeader::class)); + } + + /** + * @param array<\ReflectionAttribute> $attributes + */ + private static function first(array $attributes): ?SpecHeader + { + return ($attributes[0] ?? null)?->newInstance(); + } +} diff --git a/plugin/spec/src/Internal/SpecInput.php b/plugin/spec/src/Internal/SpecInput.php new file mode 100644 index 00000000..19779c6b --- /dev/null +++ b/plugin/spec/src/Internal/SpecInput.php @@ -0,0 +1,47 @@ +` enables generation and overrides the target directory. + * + * @internal + * @psalm-internal Testo\Spec + */ +#[InflectableConfig] +final class SpecInput +{ + /** Enable spec document generation into the default directory. */ + #[InputOption('spec')] + public bool $spec = false; + + /** Target directory for generated spec documents (`--spec-dir=`); implies `--spec`. */ + #[InputOption('spec-dir')] + public ?string $dir = null; + + /** + * Whether the user asked for spec generation via the CLI. + */ + public function isEnabled(): bool + { + return $this->spec || ($this->dir !== null && $this->dir !== ''); + } + + /** + * The directory requested on the CLI, or null to fall back to the plugin's configured directory. + * + * @return non-empty-string|null + */ + public function resolveDir(): ?string + { + return $this->dir !== null && $this->dir !== '' ? $this->dir : null; + } +} diff --git a/plugin/spec/src/Internal/SpecInterceptor.php b/plugin/spec/src/Internal/SpecInterceptor.php new file mode 100644 index 00000000..02c24ad5 --- /dev/null +++ b/plugin/spec/src/Internal/SpecInterceptor.php @@ -0,0 +1,98 @@ +testDefinition->reflection); + $section = SpecHeaderReader::section($info->caseInfo->definition->reflection); + + $this->messenger->log( + self::CHANNEL, + $this->render($item?->title ?? $info->name, $item?->number), + Level::Info, + [ + 'title' => $item?->title, + 'story' => $this->options->story, + 'tags' => $this->options->tags, + 'number' => $item?->number, + 'sectionTitle' => $section?->title, + 'sectionNumber' => $section?->number, + 'line' => $info->testDefinition->reflection->getStartLine() ?: 0, + 'test' => $info->name, + 'case' => $info->caseInfo->name, + ], + ); + + return $next($info); + } + + /** + * Render the fragment as a self-contained Markdown section. The final number is the collector's + * job; a manual item number is shown here, otherwise just the title. + */ + private function render(string $title, ?string $number): string + { + $heading = $number === null ? $title : "{$number} {$title}"; + $out = "### {$heading}\n\n" . \trim($this->options->story) . "\n"; + + if ($this->options->tags !== []) { + $tags = \implode(' ', \array_map( + static fn(string $tag): string => '`' . $tag . '`', + $this->options->tags, + )); + $out .= "\n_Tags: {$tags}_\n"; + } + + return $out; + } +} diff --git a/plugin/spec/src/Internal/SpecNumberer.php b/plugin/spec/src/Internal/SpecNumberer.php new file mode 100644 index 00000000..c6701fa2 --- /dev/null +++ b/plugin/spec/src/Internal/SpecNumberer.php @@ -0,0 +1,189 @@ +}` + * - numberedItem: `array{number: non-empty-string, title: non-empty-string, story: string, tags: list}` + * - extra section: `array{title: non-empty-string|null, items: list}` + * - plainItem: `array{title: non-empty-string|null, story: string, tags: list}` + * + * @internal + * @psalm-internal Testo\Spec + */ +final readonly class SpecNumberer +{ + /** + * @param list $entries + * @return array{sections: list}>}>, extra: list}>}>} + */ + public static function build(array $entries): array + { + // Group by case, preserving first-seen (run) order for the unnumbered tail. + $cases = []; + foreach ($entries as $entry) { + $cases[$entry->case] ??= [ + 'id' => $entry->case, + 'number' => $entry->sectionNumber, + 'title' => $entry->sectionTitle, + 'entries' => [], + ]; + $cases[$entry->case]['entries'][] = $entry; + } + + $numbered = []; + $unnumbered = []; + $index = 0; + foreach ($cases as $case) { + $case['index'] = $index++; + $case['number'] === null ? $unnumbered[] = $case : $numbered[] = $case; + } + + \usort($numbered, static function (array $a, array $b): int { + $cmp = \strnatcmp((string) $a['number'], (string) $b['number']); + return $cmp !== 0 ? $cmp : $a['index'] <=> $b['index']; + }); + + $sections = []; + foreach ($numbered as $case) { + $section = (string) $case['number']; + $counter = 0; + $items = []; + foreach (self::order($case['entries'], $section) as $entry) { + $items[] = [ + 'number' => $entry->number ?? $section . '.' . ++$counter, + 'title' => $entry->heading(), + 'story' => $entry->story, + 'tags' => $entry->tags, + ]; + } + $sections[] = ['number' => $section, 'title' => $case['title'] ?? $case['id'], 'items' => $items]; + } + + self::disambiguate($sections); + + $extra = []; + foreach ($unnumbered as $case) { + $items = []; + foreach (self::order($case['entries'], null) as $entry) { + $items[] = ['title' => $entry->title, 'story' => $entry->story, 'tags' => $entry->tags]; + } + $extra[] = ['title' => $case['title'], 'items' => $items]; + } + + return ['sections' => $sections, 'extra' => $extra]; + } + + /** + * Compare two section (case) numbers for execution/document ordering: numbered before unnumbered, + * numbered sorted naturally. Equal/both-null keep their original order (stable sort). + */ + public static function compareSections(?string $a, ?string $b): int + { + if (($a === null) !== ($b === null)) { + return $a === null ? 1 : -1; + } + + return $a === null ? 0 : \strnatcmp($a, $b); + } + + /** + * Order test keys within a case the same way {@see build()} orders items, so execution order + * matches the generated document. + * + * @param array $items + * @param non-empty-string|null $section + * @return list + * + * @template TKey of array-key + */ + public static function orderKeys(array $items, ?string $section): array + { + $byLine = $items; + \uasort($byLine, static fn(array $a, array $b): int => $a['line'] <=> $b['line']); + + $rank = []; + $position = 0; + foreach ($byLine as $key => $_) { + $rank[$key] = ++$position; + } + + $effective = []; + foreach ($items as $key => $item) { + $effective[$key] = $item['number'] ?? ($section !== null ? $section . '.' . $rank[$key] : null); + } + + $keys = \array_keys($items); + \usort($keys, static function ($x, $y) use ($effective, $items): int { + $a = $effective[$x]; + $b = $effective[$y]; + if (($a === null) !== ($b === null)) { + return $a === null ? 1 : -1; + } + if ($a !== null && ($cmp = \strnatcmp($a, $b)) !== 0) { + return $cmp; + } + + return $items[$x]['line'] <=> $items[$y]['line']; + }); + + return $keys; + } + + /** + * @param list $entries + * @param non-empty-string|null $section + * @return list + */ + private static function order(array $entries, ?string $section): array + { + $items = []; + foreach ($entries as $i => $entry) { + $items[$i] = ['number' => $entry->number, 'line' => $entry->line]; + } + + return \array_map(static fn(int $i): SpecEntry => $entries[$i], self::orderKeys($items, $section)); + } + + /** + * Append ` (1)`, ` (2)` … to item numbers that repeat across the whole document, in document order. + * + * @param list}>}> $sections + * @param-out list}>}> $sections + */ + private static function disambiguate(array &$sections): void + { + $counts = []; + foreach ($sections as $section) { + foreach ($section['items'] as $item) { + $counts[$item['number']] = ($counts[$item['number']] ?? 0) + 1; + } + } + + $seen = []; + foreach ($sections as $s => $section) { + foreach ($section['items'] as $i => $item) { + if (($counts[$item['number']] ?? 0) < 2) { + continue; + } + $n = $seen[$item['number']] = ($seen[$item['number']] ?? 0) + 1; + $sections[$s]['items'][$i]['number'] = $item['number'] . " ({$n})"; + } + } + } +} diff --git a/plugin/spec/src/Internal/SpecSuiteOrderInterceptor.php b/plugin/spec/src/Internal/SpecSuiteOrderInterceptor.php new file mode 100644 index 00000000..201601a3 --- /dev/null +++ b/plugin/spec/src/Internal/SpecSuiteOrderInterceptor.php @@ -0,0 +1,35 @@ +testCases->sort(static fn(CaseDefinition $a, CaseDefinition $b): int => SpecNumberer::compareSections( + SpecHeaderReader::section($a->reflection)?->number, + SpecHeaderReader::section($b->reflection)?->number, + )); + + return $next($info); + } +} diff --git a/plugin/spec/src/SpecHeader.php b/plugin/spec/src/SpecHeader.php new file mode 100644 index 00000000..ab6ae595 --- /dev/null +++ b/plugin/spec/src/SpecHeader.php @@ -0,0 +1,54 @@ +number = $number; + + $this->title === null && $this->number === null and throw new \InvalidArgumentException( + 'Spec header requires a title or a number.', + ); + } +} diff --git a/plugin/spec/src/SpecPlugin.php b/plugin/spec/src/SpecPlugin.php new file mode 100644 index 00000000..59bfba73 --- /dev/null +++ b/plugin/spec/src/SpecPlugin.php @@ -0,0 +1,73 @@ +reorder and $this->configureReordering($container); + + $input = $container->get(SpecInput::class); + ($this->collect || $input->isEnabled()) and $this->configureGeneration($container, $input); + } + + private function configureReordering(Container $container): void + { + $interceptors = $container->get(InterceptorCollector::class); + $interceptors->addInterceptor(new SpecSuiteOrderInterceptor()); + $interceptors->addInterceptor(new SpecCaseOrderInterceptor()); + } + + private function configureGeneration(Container $container, SpecInput $input): void + { + // Idempotent across suites: a single session-scoped collector owns the whole run tree. + if ($container->has(SpecCollector::class)) { + return; + } + + $collector = new SpecCollector($input->resolveDir() ?? $this->outputDir); + $container->set($collector); + + $container->get(EventListenerCollector::class) + ->addListener(TestSuiteFinished::class, $collector->onTestSuiteFinished(...)); + } +} diff --git a/plugin/spec/tests/Feature/SpecDocumentFeatureTest.php b/plugin/spec/tests/Feature/SpecDocumentFeatureTest.php new file mode 100644 index 00000000..97ccc379 --- /dev/null +++ b/plugin/spec/tests/Feature/SpecDocumentFeatureTest.php @@ -0,0 +1,142 @@ + $plugins + */ + private static function run(array $plugins, ?string $dir = null): RunResult + { + $app = Application::createFromInput( + inputOptions: $dir === null ? [] : ['spec-dir' => $dir], + ); + $app->getContainer()->set( + new ApplicationConfig( + src: [], + suites: [ + new SuiteConfig( + 'SpecStubs', + location: new FinderConfig(include: [__DIR__ . '/../Stub']), + ), + ], + plugins: $plugins, + ), + ApplicationConfig::class, + ); + + return $app->run(); + } + + /** + * Distinct Test Case names in execution order. + * + * @return list + */ + private static function caseOrder(RunResult $result): array + { + $order = []; + foreach ($result->results as $suite) { + foreach ($suite->results as $case) { + foreach ($case->results as $test) { + $name = $test->info->caseInfo->name; + \in_array($name, $order, true) or $order[] = $name; + } + } + } + + return $order; + } +} diff --git a/plugin/spec/tests/Feature/SpecFeatureTest.php b/plugin/spec/tests/Feature/SpecFeatureTest.php new file mode 100644 index 00000000..a9a79c51 --- /dev/null +++ b/plugin/spec/tests/Feature/SpecFeatureTest.php @@ -0,0 +1,83 @@ +messages->channel(SpecInterceptor::CHANNEL); + Assert::same(\count($messages), 1); + Assert::true(\str_contains($messages[0]->content, 'As a user I want X so that Y.')); + } + + #[Spec(story: <<<'MD' + A class-level `#[SpecHeader]` names and numbers the section that every test in the case belongs + to; the section title and number ride along with each fragment. + MD)] + #[SpecHeader(title: 'A class header opens a section')] + public function classLevelSectionHeaderReachesTheContext(): void + { + $result = TestRunner::runTest([SpecStub::class, 'methodLevelSpec']); + + $context = $result->messages->channel(SpecInterceptor::CHANNEL)[0]->context; + Assert::same($context['sectionTitle'], 'Checkout'); + Assert::same($context['sectionNumber'], '5'); + Assert::null($context['title']); + Assert::same($context['test'], 'methodLevelSpec'); + } + + #[Spec(story: <<<'MD' + A method-level `#[SpecHeader]` overrides the item heading, and tags declared on `#[Spec]` are + carried through to the fragment. + MD)] + #[SpecHeader(title: 'A method header titles an item')] + public function methodLevelItemHeaderReachesTheContext(): void + { + $result = TestRunner::runTest([SpecStub::class, 'specWithHeader']); + + $context = $result->messages->channel(SpecInterceptor::CHANNEL)[0]->context; + Assert::same($context['title'], 'Tax in total'); + Assert::null($context['number']); + Assert::same($context['tags'], ['checkout']); + } + + #[Spec(story: 'Documenting a test with `#[Spec]` is observational: it never changes the pass/fail outcome.')] + #[SpecHeader(title: 'Specs never change the verdict')] + public function specDoesNotAlterTestOutcome(): void + { + $result = TestRunner::runTest([SpecStub::class, 'methodLevelSpec']); + + Assert::same($result->status, Status::Passed); + } +} diff --git a/plugin/spec/tests/Stub/AuthStub.php b/plugin/spec/tests/Stub/AuthStub.php new file mode 100644 index 00000000..061e46ea --- /dev/null +++ b/plugin/spec/tests/Stub/AuthStub.php @@ -0,0 +1,28 @@ +addSuite(self::suite([ + self::test('Checkout [test]', 'tax', 'Tax is included.', sectionNumber: '5', sectionTitle: 'Checkout'), + ])); + $collector->addSuite(self::suite([ + self::test('Auth [test]', 'login', 'A user logs in.', sectionNumber: '2', sectionTitle: 'Auth'), + ])); + + $content = (string) \file_get_contents((string) $collector->flush()); + Assert::true(\str_contains($content, '# 2. Auth')); + Assert::true(\str_contains($content, '# 5. Checkout')); + } + + public function ignoresTestsWithoutSpecMessages(): void + { + $collector = self::collector(__FUNCTION__); + $collector->addSuite(self::suite([new TestResult(info: self::info(), status: Status::Passed)])); + + Assert::null($collector->flush()); + } + + public function flushReturnsNullWhenNothingCollected(): void + { + Assert::null(self::collector(__FUNCTION__)->flush()); + } + + public function writesASingleDocument(): void + { + $collector = self::collector(__FUNCTION__); + $collector->addSuite(self::suite([ + self::test('Checkout [test]', 'tax', 'Tax is included.', sectionNumber: '5', sectionTitle: 'Checkout'), + ])); + + $path = $collector->flush(); + + Assert::notNull($path); + Assert::true(\str_ends_with((string) $path, 'spec.md')); + $content = (string) \file_get_contents((string) $path); + Assert::true(\str_contains($content, '# 5. Checkout')); + Assert::true(\str_contains($content, '## 5.1 tax')); + } + + public function rendersNumberedSections(): void + { + $model = [ + 'sections' => [ + ['number' => '5', 'title' => 'Checkout', 'items' => [ + ['number' => '5.1', 'title' => 'tax', 'story' => 'Tax is included.', 'tags' => ['checkout']], + ]], + ], + 'extra' => [], + ]; + + $out = self::collector(__FUNCTION__)->render($model); + + Assert::true(\str_contains($out, '# 5. Checkout')); + Assert::true(\str_contains($out, "## 5.1 tax\n\nTax is included.")); + Assert::true(\str_contains($out, '`checkout`')); + } + + public function rendersUncategorizedTailWithBulletsAndParagraphs(): void + { + $model = [ + 'sections' => [], + 'extra' => [ + ['title' => 'Notes', 'items' => [ + ['title' => 'A side note', 'story' => 'Noted behaviour.', 'tags' => []], + ['title' => null, 'story' => 'Plain behaviour.', 'tags' => []], + ]], + ], + ]; + + $out = self::collector(__FUNCTION__)->render($model); + + Assert::true(\str_contains($out, '# Uncategorized')); + Assert::true(\str_contains($out, '## Notes')); + Assert::true(\str_contains($out, "- A side note\n Noted behaviour.")); + Assert::true(\str_contains($out, "\nPlain behaviour.\n")); + } + + private static function test( + string $case, + string $name, + string $story, + ?string $sectionNumber = null, + ?string $sectionTitle = null, + ): TestResult { + $message = new Message( + time: 0.0, + channel: SpecInterceptor::CHANNEL, + level: Level::Info, + content: "### {$name}\n\n{$story}\n", + context: [ + 'title' => null, + 'story' => $story, + 'tags' => [], + 'number' => null, + 'sectionTitle' => $sectionTitle, + 'sectionNumber' => $sectionNumber, + 'line' => 1, + 'test' => $name, + 'case' => $case, + ], + ); + + return new TestResult(info: self::info($name), status: Status::Passed, messages: new MessageLog([$message])); + } + + /** + * @param list $tests + */ + private static function suite(array $tests): SuiteResult + { + return new SuiteResult([new CaseResult($tests, Status::Passed)], Status::Passed); + } + + private static function info(string $name = 'test'): TestInfo + { + $reflection = new \ReflectionMethod(self::class, 'info'); + $caseInfo = new CaseInfo(definition: new CaseDefinition(name: 'TestCase', type: 'test')); + + return new TestInfo( + name: $name, + caseInfo: $caseInfo, + testDefinition: new TestDefinition(reflection: $reflection), + ); + } + + private static function collector(string $name): SpecCollector + { + $dir = __DIR__ . '/../../runtime/' . $name; + if (\is_dir($dir)) { + foreach ((array) \glob($dir . '/*.md') as $file) { + \is_string($file) and @\unlink($file); + } + } + + return new SpecCollector($dir); + } +} diff --git a/plugin/spec/tests/Unit/Internal/SpecNumbererTest.php b/plugin/spec/tests/Unit/Internal/SpecNumbererTest.php new file mode 100644 index 00000000..aafafba4 --- /dev/null +++ b/plugin/spec/tests/Unit/Internal/SpecNumbererTest.php @@ -0,0 +1,126 @@ + $s['number'], $model['sections']), ['2', '10']); + } + + public function autoNumbersItemsBySourceLine(): void + { + $model = SpecNumberer::build([ + self::entry('Checkout', 'second', line: 30, sectionNumber: '5'), + self::entry('Checkout', 'first', line: 10, sectionNumber: '5'), + ]); + + $items = $model['sections'][0]['items']; + Assert::same($items[0]['number'], '5.1'); + Assert::same($items[0]['title'], 'first'); + Assert::same($items[1]['number'], '5.2'); + Assert::same($items[1]['title'], 'second'); + } + + public function keepsManualItemNumber(): void + { + $model = SpecNumberer::build([ + self::entry('Checkout', 'pinned', line: 10, sectionNumber: '5', number: '5.9'), + ]); + + Assert::same($model['sections'][0]['items'][0]['number'], '5.9'); + } + + public function disambiguatesCollidingNumbersInDocumentOrder(): void + { + $model = SpecNumberer::build([ + self::entry('CheckoutA', 'a', line: 10, sectionNumber: '5', sectionTitle: 'Checkout A'), + self::entry('CheckoutB', 'b', line: 10, sectionNumber: '5', sectionTitle: 'Checkout B'), + ]); + + Assert::same($model['sections'][0]['items'][0]['number'], '5.1 (1)'); + Assert::same($model['sections'][1]['items'][0]['number'], '5.1 (2)'); + } + + public function unnumberedSectionsGoToExtra(): void + { + $model = SpecNumberer::build([ + self::entry('Numbered', 'a', line: 10, sectionNumber: '1'), + self::entry('Loose', 'b', line: 10, title: 'A note'), + ]); + + Assert::same(\count($model['sections']), 1); + Assert::same(\count($model['extra']), 1); + Assert::same($model['extra'][0]['items'][0]['title'], 'A note'); + } + + public function fallsBackToCaseNameForSectionTitle(): void + { + $model = SpecNumberer::build([ + self::entry('CheckoutCase', 'a', line: 10, sectionNumber: '5'), + ]); + + Assert::same($model['sections'][0]['title'], 'CheckoutCase'); + } + + public function compareSectionsPutsUnnumberedLast(): void + { + Assert::true(SpecNumberer::compareSections('5', null) < 0); + Assert::true(SpecNumberer::compareSections(null, '5') > 0); + Assert::same(SpecNumberer::compareSections(null, null), 0); + Assert::true(SpecNumberer::compareSections('2', '10') < 0); + } + + public function orderKeysSortsByEffectiveNumber(): void + { + $order = SpecNumberer::orderKeys([ + 'late' => ['number' => null, 'line' => 30], + 'early' => ['number' => null, 'line' => 10], + ], '5'); + + Assert::same($order, ['early', 'late']); + } + + /** + * @param list $tags + */ + private static function entry( + string $case, + string $test, + int $line, + ?string $sectionNumber = null, + ?string $sectionTitle = null, + ?string $number = null, + ?string $title = null, + array $tags = [], + ): SpecEntry { + return new SpecEntry( + case: $case, + test: $test, + title: $title, + number: $number, + sectionTitle: $sectionTitle, + sectionNumber: $sectionNumber, + line: $line, + story: "Story of {$test}.", + tags: $tags, + ); + } +} diff --git a/plugin/spec/tests/Unit/Internal/SpecOrderInterceptorTest.php b/plugin/spec/tests/Unit/Internal/SpecOrderInterceptorTest.php new file mode 100644 index 00000000..c750be93 --- /dev/null +++ b/plugin/spec/tests/Unit/Internal/SpecOrderInterceptorTest.php @@ -0,0 +1,87 @@ +runTestSuite($info, static fn(): SuiteResult => new SuiteResult([], Status::Passed)); + + $names = \array_map(static fn(CaseDefinition $c): ?string => $c->name, $cases->getCases()); + Assert::same($names, ['NumberedSectionLow', 'NumberedSectionHigh', 'UnnumberedSection']); + } + + public function sortsTestsWithinACaseBySpecNumber(): void + { + $class = new \ReflectionClass(OrderingCase::class); + $tests = TestDefinitions::fromArray( + third: new TestDefinition($class->getMethod('third')), + first: new TestDefinition($class->getMethod('first')), + second: new TestDefinition($class->getMethod('second')), + ); + $definition = new CaseDefinition(name: 'OrderingCase', type: 'test', reflection: $class, tests: $tests); + $info = new CaseInfo(definition: $definition); + + (new SpecCaseOrderInterceptor())->runTestCase($info, static fn(): CaseResult => new CaseResult([], Status::Passed)); + + Assert::same(\array_keys($definition->tests->getTests()), ['first', 'second', 'third']); + } + + public function callsTheNextHandler(): void + { + $info = new SuiteInfo(name: 'S', testCases: CaseDefinitions::fromArray(self::case(UnnumberedSection::class))); + $called = false; + $next = static function () use (&$called): SuiteResult { + $called = true; + return new SuiteResult([], Status::Passed); + }; + + (new SpecSuiteOrderInterceptor())->runTestSuite($info, $next); + + Assert::true($called); + } + + /** + * @param class-string $class + */ + private static function case(string $class): CaseDefinition + { + return new CaseDefinition( + name: (new \ReflectionClass($class))->getShortName(), + type: 'test', + reflection: new \ReflectionClass($class), + ); + } +} diff --git a/plugin/spec/tests/Unit/SpecHeaderTest.php b/plugin/spec/tests/Unit/SpecHeaderTest.php new file mode 100644 index 00000000..36351fb7 --- /dev/null +++ b/plugin/spec/tests/Unit/SpecHeaderTest.php @@ -0,0 +1,82 @@ +title, 'Checkout'); + } + + public function numberDefaultsToNull(): void + { + $header = new SpecHeader(title: 'Checkout'); + + Assert::null($header->number); + } + + public function stringNumberIsKept(): void + { + $header = new SpecHeader(title: 'Tax', number: '5.1'); + + Assert::same($header->number, '5.1'); + } + + public function intNumberIsCastToString(): void + { + $header = new SpecHeader(title: 'Checkout', number: 5); + + Assert::same($header->number, '5'); + } + + public function numberOnlyHeaderIsAllowed(): void + { + $header = new SpecHeader(number: '5'); + + Assert::null($header->title); + Assert::same($header->number, '5'); + } + + public function blankTitleFails(): never + { + Expect::exception(\InvalidArgumentException::class) + ->withMessage('Spec header title must not be empty when provided.'); + + new SpecHeader(title: ' '); + } + + public function emptyHeaderFails(): never + { + Expect::exception(\InvalidArgumentException::class) + ->withMessage('Spec header requires a title or a number.'); + + new SpecHeader(); + } + + /** + * @param string $number A provided number must carry an actual value. + */ + #[DataSet([''], 'empty')] + #[DataSet([' '], 'whitespace')] + public function blankNumberFails(string $number): never + { + Expect::exception(\InvalidArgumentException::class) + ->withMessage('Spec header number must not be empty when provided.'); + + new SpecHeader(title: 'Checkout', number: $number); + } +} diff --git a/plugin/spec/tests/Unit/SpecInterceptorTest.php b/plugin/spec/tests/Unit/SpecInterceptorTest.php new file mode 100644 index 00000000..c729ba66 --- /dev/null +++ b/plugin/spec/tests/Unit/SpecInterceptorTest.php @@ -0,0 +1,181 @@ +runTest(self::createTestInfo(), $next); + + Assert::same($callCount, 1); + Assert::same($result->status, Status::Passed); + } + + public function publishesFragmentToTheChannel(): void + { + $messenger = self::createMessenger(); + $interceptor = new SpecInterceptor(new Spec(story: 'As a user I want X.'), $messenger); + + $interceptor->runTest(self::createTestInfo(), self::passing()); + + $messages = $messenger->getMessages()->channel(SpecInterceptor::CHANNEL); + Assert::same(\count($messages), 1); + Assert::true(\str_contains($messages[0]->content, 'As a user I want X.')); + } + + public function titleIsNullWithoutAHeaderButRendersTheTestName(): void + { + $messenger = self::createMessenger(); + $interceptor = new SpecInterceptor(new Spec(story: 'story'), $messenger); + + $interceptor->runTest(self::createTestInfo(), self::passing()); + + $message = self::firstMessage($messenger); + Assert::null($message->context['title']); + Assert::null($message->context['number']); + Assert::null($message->context['sectionTitle']); + Assert::same($message->context['test'], 'testMethod'); + Assert::true(\str_contains($message->content, '### testMethod')); + } + + public function contextCarriesStructuredFields(): void + { + $messenger = self::createMessenger(); + $interceptor = new SpecInterceptor(new Spec(story: 'story', tags: ['checkout']), $messenger); + + $interceptor->runTest(self::createTestInfo(), self::passing()); + + $context = self::firstMessage($messenger)->context; + Assert::same($context['story'], 'story'); + Assert::same($context['tags'], ['checkout']); + Assert::same($context['case'], 'TestCase [test]'); + Assert::true($context['line'] > 0); + } + + public function readsSectionHeaderFromTheClass(): void + { + $context = self::runFixture('withoutHeader'); + + Assert::same($context['sectionTitle'], 'Checkout'); + Assert::same($context['sectionNumber'], '5'); + } + + public function readsItemHeaderFromTheMethod(): void + { + $context = self::runFixture('withHeader'); + + Assert::same($context['title'], 'Tax in total'); + Assert::same($context['number'], '5.1'); + } + + public function methodTitleOverridesTestNameInRenderedContent(): void + { + $messenger = self::createMessenger(); + $interceptor = new SpecInterceptor(new Spec(story: 'story'), $messenger); + + $interceptor->runTest(self::fixtureInfo('withHeader'), self::passing()); + + Assert::true(\str_contains(self::firstMessage($messenger)->content, '5.1 Tax in total')); + } + + public function rendersTagsLine(): void + { + $messenger = self::createMessenger(); + $interceptor = new SpecInterceptor(new Spec(story: 'story', tags: ['checkout', 'JIRA-1']), $messenger); + + $interceptor->runTest(self::createTestInfo(), self::passing()); + + Assert::true(\str_contains(self::firstMessage($messenger)->content, '`checkout` `JIRA-1`')); + } + + /** + * @return array + */ + private static function runFixture(string $method): array + { + $messenger = self::createMessenger(); + $interceptor = new SpecInterceptor(new Spec(story: 'story'), $messenger); + + $interceptor->runTest(self::fixtureInfo($method), self::passing()); + + return self::firstMessage($messenger)->context; + } + + /** + * @return \Closure(TestInfo): TestResult + */ + private static function passing(): \Closure + { + return static fn(TestInfo $info): TestResult => new TestResult(info: $info, status: Status::Passed); + } + + private static function firstMessage(Messenger $messenger): Message + { + return $messenger->getMessages()->channel(SpecInterceptor::CHANNEL)[0]; + } + + private static function createMessenger(): Messenger + { + return new MessengerHub(new class implements EventDispatcherInterface { + #[\Override] + public function dispatch(object $event): object + { + return $event; + } + }); + } + + private static function createTestInfo(): TestInfo + { + $reflection = new \ReflectionMethod(self::class, 'createTestInfo'); + $caseInfo = new CaseInfo(definition: new CaseDefinition(name: 'TestCase', type: 'test')); + + return new TestInfo( + name: 'testMethod', + caseInfo: $caseInfo, + testDefinition: new TestDefinition(reflection: $reflection), + ); + } + + private static function fixtureInfo(string $method): TestInfo + { + $class = new \ReflectionClass(HeaderedCase::class); + $caseInfo = new CaseInfo(definition: new CaseDefinition(name: HeaderedCase::class, type: 'test', reflection: $class)); + + return new TestInfo( + name: $method, + caseInfo: $caseInfo, + testDefinition: new TestDefinition(reflection: $class->getMethod($method)), + ); + } +} diff --git a/plugin/spec/tests/Unit/SpecPluginTest.php b/plugin/spec/tests/Unit/SpecPluginTest.php new file mode 100644 index 00000000..7a1bc88f --- /dev/null +++ b/plugin/spec/tests/Unit/SpecPluginTest.php @@ -0,0 +1,171 @@ +configure($container); + + Assert::same($container->interceptors, [SpecSuiteOrderInterceptor::class, SpecCaseOrderInterceptor::class]); + } + + public function reorderingCanBeDisabled(): void + { + $container = self::container(new SpecInput()); + + (new SpecPlugin(reorder: false))->configure($container); + + Assert::same($container->interceptors, []); + } + + public function generationStaysOffByDefault(): void + { + $container = self::container(new SpecInput()); + + (new SpecPlugin())->configure($container); + + Assert::false($container->has(SpecCollector::class)); + Assert::same($container->listenedEvents, []); + } + + public function generationActivatesWithTheCollectFlag(): void + { + $container = self::container(new SpecInput()); + + (new SpecPlugin(collect: true))->configure($container); + + Assert::true($container->has(SpecCollector::class)); + Assert::same($container->listenedEvents, [TestSuiteFinished::class]); + } + + public function generationActivatesFromTheCliFlag(): void + { + $input = new SpecInput(); + $input->spec = true; + $container = self::container($input); + + (new SpecPlugin())->configure($container); + + Assert::true($container->has(SpecCollector::class)); + } + + public function cliDirectoryOverridesConfiguredDirectory(): void + { + $input = new SpecInput(); + $input->dir = 'cli/dir'; + $container = self::container($input); + + (new SpecPlugin(outputDir: 'config/dir', collect: true))->configure($container); + + $collector = $container->services[SpecCollector::class]; + $dir = (new \ReflectionProperty(SpecCollector::class, 'outputDir'))->getValue($collector); + Assert::same($dir, 'cli/dir'); + } + + public function generationDoesNotRegisterTwice(): void + { + $input = new SpecInput(); + $input->spec = true; + $container = self::container($input); + $container->services[SpecCollector::class] = new SpecCollector('existing'); + + (new SpecPlugin(reorder: false))->configure($container); + + Assert::same($container->listenedEvents, []); + } + + private static function container(SpecInput $input): Container + { + return new class($input) implements Container { + /** @var array */ + public array $services = []; + + /** @var list */ + public array $listenedEvents = []; + + /** @var list */ + public array $interceptors = []; + + public function __construct(private readonly SpecInput $input) + { + $owner = $this; + $this->services[EventListenerCollector::class] = new class($owner) implements EventListenerCollector { + public function __construct(private readonly object $owner) {} + + #[\Override] + public function addListener(string $eventName, callable $callback, int $priority = 0): void + { + $this->owner->listenedEvents[] = $eventName; + } + }; + $this->services[InterceptorCollector::class] = new class($owner) implements InterceptorCollector { + public function __construct(private readonly object $owner) {} + + #[\Override] + public function addInterceptor(Interceptor|string $interceptor): void + { + $this->owner->interceptors[] = \is_string($interceptor) ? $interceptor : $interceptor::class; + } + }; + } + + #[\Override] + public function get(string $id, array $arguments = []): object + { + return $id === SpecInput::class ? $this->input : $this->services[$id]; + } + + #[\Override] + public function has(string $id): bool + { + return isset($this->services[$id]); + } + + #[\Override] + public function set(object $service, ?string $id = null, bool $destroy = false): void + { + $this->services[$id ?? $service::class] = $service; + } + + #[\Override] + public function make(string $class, array $arguments = []): object + { + return $this->get($class); + } + + #[\Override] + public function bind(string $id, \Closure|string|array|null $binding = null): void {} + + #[\Override] + public function scope(\Closure $scope): mixed + { + return $scope($this); + } + + #[\Override] + public function destroy(): void {} + }; + } +} diff --git a/plugin/spec/tests/Unit/SpecTest.php b/plugin/spec/tests/Unit/SpecTest.php new file mode 100644 index 00000000..4694e973 --- /dev/null +++ b/plugin/spec/tests/Unit/SpecTest.php @@ -0,0 +1,59 @@ +story, 'As a user I want X.'); + } + + public function tagsDefaultToEmpty(): void + { + $spec = new Spec(story: 'story'); + + Assert::same($spec->tags, []); + } + + public function tagsArePreserved(): void + { + $spec = new Spec(story: 'story', tags: ['checkout', 'JIRA-1']); + + Assert::same($spec->tags, ['checkout', 'JIRA-1']); + } + + public function emptyTagsAreFilteredOut(): void + { + $spec = new Spec(story: 'story', tags: ['', 'kept', '']); + + Assert::same($spec->tags, ['kept']); + } + + /** + * @param string $story Blank stories carry no specification and are rejected. + */ + #[DataSet([''], 'empty')] + #[DataSet([' '], 'whitespace')] + #[DataSet(["\n\t "], 'newlines')] + public function blankStoryFails(string $story): never + { + Expect::exception(\InvalidArgumentException::class) + ->withMessage('Spec story must not be empty.'); + + new Spec(story: $story); + } +} diff --git a/plugin/spec/tests/runtime/.gitignore b/plugin/spec/tests/runtime/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/plugin/spec/tests/runtime/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/plugin/spec/tests/suites.php b/plugin/spec/tests/suites.php new file mode 100644 index 00000000..f1574f1e --- /dev/null +++ b/plugin/spec/tests/suites.php @@ -0,0 +1,31 @@ + Date: Tue, 23 Jun 2026 15:32:22 +0400 Subject: [PATCH 2/2] refactor(spec): use positional SpecHeader when numbered Numbered headers now read `#[SpecHeader('5.2', 'My title')]` (number first, matching the constructor); unnumbered ones keep the named `title:` form. Co-Authored-By: Claude Opus 4.8 (1M context) --- plugin/spec/README.md | 2 +- plugin/spec/src/SpecHeader.php | 2 +- plugin/spec/tests/Feature/SpecDocumentFeatureTest.php | 2 +- plugin/spec/tests/Feature/SpecFeatureTest.php | 2 +- plugin/spec/tests/Stub/AuthStub.php | 2 +- plugin/spec/tests/Stub/SpecStub.php | 2 +- plugin/spec/tests/Unit/Fixture/HeaderedCase.php | 4 ++-- plugin/spec/tests/Unit/Fixture/NumberedSectionHigh.php | 2 +- plugin/spec/tests/Unit/Fixture/NumberedSectionLow.php | 2 +- plugin/spec/tests/Unit/Fixture/OrderingCase.php | 8 ++++---- plugin/spec/tests/Unit/SpecHeaderTest.php | 8 ++++---- 11 files changed, 18 insertions(+), 18 deletions(-) diff --git a/plugin/spec/README.md b/plugin/spec/README.md index 5cfd6cb2..ccdd17b0 100644 --- a/plugin/spec/README.md +++ b/plugin/spec/README.md @@ -54,7 +54,7 @@ use Testo\Spec\SpecHeader; use Testo\Test; #[Test] -#[SpecHeader(title: 'Checkout', number: '5')] // class = numbered section +#[SpecHeader('5', 'Checkout')] // class = numbered section final class CheckoutTest { #[Spec( diff --git a/plugin/spec/src/SpecHeader.php b/plugin/spec/src/SpecHeader.php index ab6ae595..10647427 100644 --- a/plugin/spec/src/SpecHeader.php +++ b/plugin/spec/src/SpecHeader.php @@ -7,7 +7,7 @@ /** * A numbered heading for the generated specification document — the *structure* around a {@see \Testo\Spec}. * - * - On a **class** it opens a section: `#[SpecHeader(number: '5', title: 'Checkout')]` renders as + * - On a **class** it opens a section: `#[SpecHeader('5', 'Checkout')]` renders as * `# 5. Checkout`, and every {@see \Testo\Spec} method in that case becomes an item under it. * - On a **method/function** it overrides that item's heading and/or number. * diff --git a/plugin/spec/tests/Feature/SpecDocumentFeatureTest.php b/plugin/spec/tests/Feature/SpecDocumentFeatureTest.php index 97ccc379..e43a73fe 100644 --- a/plugin/spec/tests/Feature/SpecDocumentFeatureTest.php +++ b/plugin/spec/tests/Feature/SpecDocumentFeatureTest.php @@ -29,7 +29,7 @@ #[Covers(SpecCollector::class)] #[Covers(SpecSuiteOrderInterceptor::class)] #[Covers(SpecCaseOrderInterceptor::class)] -#[SpecHeader(title: 'Generating the document', number: '2')] +#[SpecHeader('2', 'Generating the document')] final class SpecDocumentFeatureTest { #[Spec(story: <<<'MD' diff --git a/plugin/spec/tests/Feature/SpecFeatureTest.php b/plugin/spec/tests/Feature/SpecFeatureTest.php index a9a79c51..3d6723d9 100644 --- a/plugin/spec/tests/Feature/SpecFeatureTest.php +++ b/plugin/spec/tests/Feature/SpecFeatureTest.php @@ -23,7 +23,7 @@ #[Covers(Spec::class)] #[Covers(SpecHeader::class)] #[Covers(SpecInterceptor::class)] -#[SpecHeader(title: 'Attaching specs to tests', number: '1')] +#[SpecHeader('1', 'Attaching specs to tests')] #[TestingSuite(path: __DIR__ . '/../Stub')] final class SpecFeatureTest { diff --git a/plugin/spec/tests/Stub/AuthStub.php b/plugin/spec/tests/Stub/AuthStub.php index 061e46ea..4b21f7ca 100644 --- a/plugin/spec/tests/Stub/AuthStub.php +++ b/plugin/spec/tests/Stub/AuthStub.php @@ -9,7 +9,7 @@ use Testo\Spec\SpecHeader; use Testo\Test; -#[SpecHeader(title: 'Authentication', number: '2')] +#[SpecHeader('2', 'Authentication')] final class AuthStub { #[Test] diff --git a/plugin/spec/tests/Stub/SpecStub.php b/plugin/spec/tests/Stub/SpecStub.php index 2a1b6e85..379c8bc9 100644 --- a/plugin/spec/tests/Stub/SpecStub.php +++ b/plugin/spec/tests/Stub/SpecStub.php @@ -13,7 +13,7 @@ * Stub with a class-level section header and method-level spec items: the first item auto-numbers * under the section, the second overrides only its title (still auto-numbered). */ -#[SpecHeader(title: 'Checkout', number: '5')] +#[SpecHeader('5', 'Checkout')] final class SpecStub { #[Test] diff --git a/plugin/spec/tests/Unit/Fixture/HeaderedCase.php b/plugin/spec/tests/Unit/Fixture/HeaderedCase.php index f9887ed7..126f908c 100644 --- a/plugin/spec/tests/Unit/Fixture/HeaderedCase.php +++ b/plugin/spec/tests/Unit/Fixture/HeaderedCase.php @@ -11,11 +11,11 @@ * Fixture exercising class- and method-level {@see SpecHeader} reflection. Not a runnable test case * (no `#[Test]`): it only provides reflections for the interceptor unit test. */ -#[SpecHeader(title: 'Checkout', number: '5')] +#[SpecHeader('5', 'Checkout')] final class HeaderedCase { #[Spec(story: 'Tax is included in the total.')] - #[SpecHeader(title: 'Tax in total', number: '5.1')] + #[SpecHeader('5.1', 'Tax in total')] public function withHeader(): void {} #[Spec(story: 'Coupon lowers the total.')] diff --git a/plugin/spec/tests/Unit/Fixture/NumberedSectionHigh.php b/plugin/spec/tests/Unit/Fixture/NumberedSectionHigh.php index d2027837..56143291 100644 --- a/plugin/spec/tests/Unit/Fixture/NumberedSectionHigh.php +++ b/plugin/spec/tests/Unit/Fixture/NumberedSectionHigh.php @@ -6,5 +6,5 @@ use Testo\Spec\SpecHeader; -#[SpecHeader(title: 'Reports', number: '10')] +#[SpecHeader('10', 'Reports')] final class NumberedSectionHigh {} diff --git a/plugin/spec/tests/Unit/Fixture/NumberedSectionLow.php b/plugin/spec/tests/Unit/Fixture/NumberedSectionLow.php index 8bda26b7..d4582ece 100644 --- a/plugin/spec/tests/Unit/Fixture/NumberedSectionLow.php +++ b/plugin/spec/tests/Unit/Fixture/NumberedSectionLow.php @@ -6,5 +6,5 @@ use Testo\Spec\SpecHeader; -#[SpecHeader(title: 'Auth', number: '2')] +#[SpecHeader('2', 'Auth')] final class NumberedSectionLow {} diff --git a/plugin/spec/tests/Unit/Fixture/OrderingCase.php b/plugin/spec/tests/Unit/Fixture/OrderingCase.php index 68397c45..edaf592b 100644 --- a/plugin/spec/tests/Unit/Fixture/OrderingCase.php +++ b/plugin/spec/tests/Unit/Fixture/OrderingCase.php @@ -10,18 +10,18 @@ /** * Methods are declared out of number order to prove the interceptor reorders by spec number. */ -#[SpecHeader(number: '3')] +#[SpecHeader('3')] final class OrderingCase { #[Spec(story: 's')] - #[SpecHeader(title: 'Third', number: '3.3')] + #[SpecHeader('3.3', 'Third')] public function third(): void {} #[Spec(story: 's')] - #[SpecHeader(title: 'First', number: '3.1')] + #[SpecHeader('3.1', 'First')] public function first(): void {} #[Spec(story: 's')] - #[SpecHeader(title: 'Second', number: '3.2')] + #[SpecHeader('3.2', 'Second')] public function second(): void {} } diff --git a/plugin/spec/tests/Unit/SpecHeaderTest.php b/plugin/spec/tests/Unit/SpecHeaderTest.php index 36351fb7..6d54a5ec 100644 --- a/plugin/spec/tests/Unit/SpecHeaderTest.php +++ b/plugin/spec/tests/Unit/SpecHeaderTest.php @@ -31,21 +31,21 @@ public function numberDefaultsToNull(): void public function stringNumberIsKept(): void { - $header = new SpecHeader(title: 'Tax', number: '5.1'); + $header = new SpecHeader('5.1', 'Tax'); Assert::same($header->number, '5.1'); } public function intNumberIsCastToString(): void { - $header = new SpecHeader(title: 'Checkout', number: 5); + $header = new SpecHeader(5, 'Checkout'); Assert::same($header->number, '5'); } public function numberOnlyHeaderIsAllowed(): void { - $header = new SpecHeader(number: '5'); + $header = new SpecHeader('5'); Assert::null($header->title); Assert::same($header->number, '5'); @@ -77,6 +77,6 @@ public function blankNumberFails(string $number): never Expect::exception(\InvalidArgumentException::class) ->withMessage('Spec header number must not be empty when provided.'); - new SpecHeader(title: 'Checkout', number: $number); + new SpecHeader($number, 'Checkout'); } }