diff --git a/CHANGELOG.md b/CHANGELOG.md index 85b4346..69306e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] - - +### Added +- optional $tags argument in TaskTreeBuilder +- immutable setters in TaskNode +- optional TaskNodeFactory +- pattern filtering can exclude dependencies (PatternTaskNodeFactory/TaskTreeBuilder flag + `--no-deps` CLI option) ## [1.0.0] - 2025-11-07 ### Added diff --git a/README.md b/README.md index f287859..d4d815b 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,12 @@ To limit which tasks run, pass a pattern as the first argument. Wildcards work t php bin/console app:tasks cache-* # run only tasks whose ID or tag matches "cache-*" ``` +By default, Multitron will also include transitive dependencies of the matched tasks so the resulting execution graph stays valid. If you want to run only the matched tasks (and ignore dependencies outside the selection), use `--no-deps`: + +```bash +php bin/console app:tasks cache-* --no-deps +``` + You can combine multiple patterns by separating them with commas. The filter applies to both task IDs and tags and is an OR pattern. @@ -318,4 +324,3 @@ Your feedback, issues, and contributions are highly encouraged. Open a GitHub is ## License Multitron is MIT licensed. See the [LICENSE](LICENSE) file for full details. - diff --git a/composer.json b/composer.json index 4c24429..54e1bb2 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ ], "require": { "php": "^8.2", - "symfony/console": "^7.2", + "symfony/console": "^7.2|^8.0", "riki137/stream-ipc": "^1.0", "psr/container": "^2.0", "psr/container-implementation": "*" @@ -50,10 +50,10 @@ "psr/log": "^3.0", "nette/di": "^3.2", "phpstan/phpstan": "^2.1", - "contributte/psr11-container-interface": "^0.6.0", + "contributte/psr11-container-interface": "^0.7.0", "phpunit/phpunit": "^11.5", "squizlabs/php_codesniffer": "^3.13|^4.0", - "symfony/dependency-injection": "^7.3", + "symfony/dependency-injection": "^7.3|^8.0", "illuminate/support": "^12", "illuminate/container": "^12", "pimple/pimple": "^3.5" diff --git a/src/Bridge/Native/MultitronFactory.php b/src/Bridge/Native/MultitronFactory.php index b27e4fb..4371fd7 100644 --- a/src/Bridge/Native/MultitronFactory.php +++ b/src/Bridge/Native/MultitronFactory.php @@ -18,6 +18,7 @@ use Multitron\Orchestrator\Output\ProgressOutputFactory; use Multitron\Orchestrator\Output\TableOutputFactory; use Multitron\Orchestrator\TaskOrchestrator; +use Multitron\Tree\TaskNodeFactory; use Multitron\Tree\TaskTreeBuilderFactory; use Psr\Container\ContainerInterface; use StreamIpc\NativeIpcPeer; @@ -49,6 +50,8 @@ final class MultitronFactory private ?TaskTreeBuilderFactory $taskTreeBuilderFactory = null; + private ?TaskNodeFactory $taskNodeFactory = null; + public function __construct(private readonly ?ContainerInterface $container) { } @@ -92,7 +95,7 @@ public function setTaskCommandDeps(?TaskCommandDeps $taskCommandDeps): self public function getTaskTreeBuilderFactory(): TaskTreeBuilderFactory { - return $this->taskTreeBuilderFactory ??= new TaskTreeBuilderFactory($this->container); + return $this->taskTreeBuilderFactory ??= new TaskTreeBuilderFactory($this->container, $this->getTaskNodeFactory()); } public function setTaskTreeBuilderFactory(?TaskTreeBuilderFactory $taskTreeBuilderFactory): self @@ -101,6 +104,17 @@ public function setTaskTreeBuilderFactory(?TaskTreeBuilderFactory $taskTreeBuild return $this; } + public function getTaskNodeFactory(): ?TaskNodeFactory + { + return $this->taskNodeFactory; + } + + public function setTaskNodeFactory(?TaskNodeFactory $taskNodeFactory): self + { + $this->taskNodeFactory = $taskNodeFactory; + return $this; + } + public function getTaskOrchestrator(): TaskOrchestrator { return $this->taskOrchestrator ??= new TaskOrchestrator( diff --git a/src/Bridge/Native/README.md b/src/Bridge/Native/README.md index daf7f38..de93312 100644 --- a/src/Bridge/Native/README.md +++ b/src/Bridge/Native/README.md @@ -120,6 +120,11 @@ $app->run(); ```bash ./bin/multitron.php app:tasks Test% ``` + By default, Multitron includes transitive dependencies of the matched tasks. Use `--no-deps` to run only the matched tasks: + + ```bash + ./bin/multitron.php app:tasks Test% --no-deps + ``` * **Limit concurrency** ```bash diff --git a/src/Console/TaskCommand.php b/src/Console/TaskCommand.php index 4eb2c56..cdc3a37 100644 --- a/src/Console/TaskCommand.php +++ b/src/Console/TaskCommand.php @@ -20,6 +20,8 @@ abstract class TaskCommand extends Command { + public const OPTION_DEPS = 'deps'; + private readonly TaskTreeBuilderFactory $builderFactory; private readonly TaskOrchestrator $orchestrator; @@ -52,6 +54,13 @@ protected function configure(): void InputArgument::OPTIONAL, 'fnmatch() pattern to filter tasks. You can optionally use % instead of * for wildcards. Works for groups too.' ); + $this->addOption( + self::OPTION_DEPS, + null, + InputOption::VALUE_NEGATABLE, + 'Include transitive dependencies when filtering by pattern (use --no-deps to run only matched tasks)', + true + ); $this->addOption(TaskOrchestrator::OPTION_CONCURRENCY, 'c', InputOption::VALUE_REQUIRED, 'Max concurrent tasks executed', $this->defaultConcurrency); $this->addOption(TaskOrchestrator::OPTION_UPDATE_INTERVAL, 'u', InputOption::VALUE_REQUIRED, 'Update interval in seconds', TaskOrchestrator::DEFAULT_UPDATE_INTERVAL); $this->addOption(TaskOrchestrator::OPTION_MEMORY_LIMIT, 'm', InputOption::VALUE_REQUIRED, 'PHP memory_limit for all processes', TaskOrchestrator::DEFAULT_MEMORY_LIMIT); @@ -79,7 +88,13 @@ final public function getTaskList(?InputInterface $input = null): TaskList $builder = $this->builderFactory->create(); $pattern = $input?->getArgument('pattern'); if (is_string($pattern) && trim($pattern) !== '') { - $node = $builder->patternFilter('root', $pattern, $this->getNodes($builder)); + $includeDeps = (bool)($input?->getOption(self::OPTION_DEPS) ?? true); + $node = $builder->patternFilter( + 'root', + $pattern, + $this->getNodes($builder), + includeDependencies: $includeDeps + ); } else { $node = new TaskNode('root', null, $this->getNodes($builder)); } diff --git a/src/Execution/Handler/MasterCache/MasterCacheReadKeysPromise.php b/src/Execution/Handler/MasterCache/MasterCacheReadKeysPromise.php index 29ef6fe..c8a6ba0 100644 --- a/src/Execution/Handler/MasterCache/MasterCacheReadKeysPromise.php +++ b/src/Execution/Handler/MasterCache/MasterCacheReadKeysPromise.php @@ -17,7 +17,7 @@ public function __construct(private readonly ResponsePromise $promise) * Block until the read request completes and return the full set of * retrieved values keyed by cache identifier. * - * @return array + * @return array */ public function await(): array { diff --git a/src/Execution/Handler/MasterCache/MasterCacheReadKeysRequest.php b/src/Execution/Handler/MasterCache/MasterCacheReadKeysRequest.php index d97dd1b..e205e43 100644 --- a/src/Execution/Handler/MasterCache/MasterCacheReadKeysRequest.php +++ b/src/Execution/Handler/MasterCache/MasterCacheReadKeysRequest.php @@ -32,7 +32,7 @@ public function doRead(array &$storage): MasterCacheReadResponse * * @param array $storage data source to read from * @param array $keysSpec description of keys to fetch - * @return array fetched data + * @return array fetched data */ private function fetchKeys(array &$storage, array $keysSpec): array { diff --git a/src/Execution/Handler/MasterCache/MasterCacheReadResponse.php b/src/Execution/Handler/MasterCache/MasterCacheReadResponse.php index 0a68fb4..9024c1c 100644 --- a/src/Execution/Handler/MasterCache/MasterCacheReadResponse.php +++ b/src/Execution/Handler/MasterCache/MasterCacheReadResponse.php @@ -9,7 +9,7 @@ final readonly class MasterCacheReadResponse implements Message { /** - * @param array $data + * @param array $data */ public function __construct(public array $data) { diff --git a/src/Tree/PatternTaskNodeFactory.php b/src/Tree/PatternTaskNodeFactory.php index b2fb27a..6f4e0be 100644 --- a/src/Tree/PatternTaskNodeFactory.php +++ b/src/Tree/PatternTaskNodeFactory.php @@ -7,18 +7,31 @@ class PatternTaskNodeFactory { /** - * @param TaskNode[] $children + * @param string $id The ID of the task node. + * @param string $pattern Comma-separated list of fnmatch patterns to match task IDs or tags + * @param TaskNode[] $children Child task nodes to filter. + * @param string[] $tags Tags associated with this filter node. + * @param bool $includeDependencies When true (default), include transitive dependencies of matched tasks. + * When false, only matched tasks are returned and dependency edges pointing + * outside the matched set are removed. */ public static function create( string $id, string $pattern, array $children = [], + array $tags = [], + bool $includeDependencies = true, ): TaskNode { - $patterns = array_map(fn($p) => strtr($p, ['%' => '*']), explode(',', $pattern)); + $patterns = array_values(array_filter(array_map( + static fn(string $p): string => strtr(trim($p), ['%' => '*']), + explode(',', $pattern) + ), static fn(string $p): bool => $p !== '')); + return new TaskNode( $id, children: $children, - postProcess: function (array $tasks) use ($patterns): iterable { + tags: $tags, + postProcess: function (array $tasks) use ($patterns, $includeDependencies): iterable { $selected = []; foreach ($tasks as $task) { @@ -27,26 +40,48 @@ public static function create( } } - $queue = array_values($selected); - while ($queue) { - $current = array_pop($queue); - foreach ($current->dependencies as $dep) { - if (!isset($selected[$dep]) && isset($tasks[$dep])) { - $selected[$dep] = $tasks[$dep]; - $queue[] = $tasks[$dep]; + if ($includeDependencies) { + $queue = array_values($selected); + while ($queue) { + $current = array_pop($queue); + foreach ($current->dependencies as $dep) { + if (!isset($selected[$dep]) && isset($tasks[$dep])) { + $selected[$dep] = $tasks[$dep]; + $queue[] = $tasks[$dep]; + } } } + + foreach ($selected as $task) { + yield $task; + } + + return; } foreach ($selected as $task) { - yield $task; + $filteredDeps = array_values(array_filter( + $task->dependencies, + static fn(string $depId): bool => isset($selected[$depId]) + )); + + yield new CompiledTaskNode( + id: $task->id, + factory: $task->factory, + dependencies: $filteredDeps, + tags: $task->tags + ); } + + return; } ); } /** - * @param string[] $patterns + * Check if a task matches any of the given patterns by ID or tags. + * @param CompiledTaskNode $task The task to check. + * @param string[] $patterns Array of fnmatch patterns */ private static function matches(CompiledTaskNode $task, array $patterns): bool { diff --git a/src/Tree/TaskNode.php b/src/Tree/TaskNode.php index 6995a2e..2c20fb5 100644 --- a/src/Tree/TaskNode.php +++ b/src/Tree/TaskNode.php @@ -7,16 +7,18 @@ use Closure; use Multitron\Execution\Task; +/** + * Represents a node in the task tree before compilation. + */ final readonly class TaskNode { /** * @internal - * @param string $id - * @param ?Closure(): Task $factory - * @param TaskNode[] $children - * @param array $dependencies array of TaskNode IDs or TaskNode objects that this node depends on - * @param string[] $tags Tags can be used for post-processing, for example, to run only tasks with a specific tag. - * @param ?Closure(CompiledTaskNode[] $tasks): iterable $postProcess This can be used for filtering, for example. + * @param ?Closure(): Task $factory Creates the task instance. + * @param array $children Child task nodes. + * @param array $dependencies IDs or TaskNode objects this node depends on. + * @param array $tags Tags used for filtering or post-processing. + * @param ?Closure(array $tasks): iterable $postProcess Custom hook for post-processing compiled tasks. */ public function __construct( public string $id, @@ -27,4 +29,100 @@ public function __construct( public ?Closure $postProcess = null, ) { } + + /** + * Returns a new instance with the specified properties replaced. + * + * @param array|null $children + * @param array|null $dependencies + * @param array|null $tags + */ + public function with( + ?Closure $factory = null, + ?array $children = null, + ?array $dependencies = null, + ?array $tags = null, + ?Closure $postProcess = null, + ): self { + return new TaskNode( + id: $this->id, + factory: $factory ?? $this->factory, + children: $children ?? $this->children, + dependencies: $dependencies ?? $this->dependencies, + tags: $tags ?? $this->tags, + postProcess: $postProcess ?? $this->postProcess, + ); + } + + /** + * Returns a new instance with additional children, dependencies, and tags. + * + * @param array $children + * @param array $dependencies + * @param array $tags + */ + public function withAdded( + array $children = [], + array $dependencies = [], + array $tags = [], + ): self { + return $this->with( + children: array_merge($this->children, $children), + dependencies: array_merge($this->dependencies, $dependencies), + tags: array_merge($this->tags, $tags), + ); + } + + /** + * Returns a new instance with the specified properties removed or reset. + * + * @param bool|array $children Pass true to clear, or an array of nodes to remove. + * @param bool|array $dependencies Pass true to clear, or an array of items to remove. + * @param bool|array $tags Pass true to clear, or an array of tags to remove. + */ + public function without( + bool $factory = false, + bool|array $children = false, + bool|array $dependencies = false, + bool|array $tags = false, + bool $postProcess = false, + ): self { + return new TaskNode( + id: $this->id, + factory: $factory ? null : $this->factory, + children: $this->filterProperty($children, $this->children), + dependencies: $this->filterProperty($dependencies, $this->dependencies), + tags: $this->filterProperty($tags, $this->tags), + postProcess: $postProcess ? null : $this->postProcess, + ); + } + + /** + * Internal helper to handle the removal or clearing of collection properties. + * + * @template T + * @param bool|array $property + * @param array $current + * @return array + */ + private function filterProperty(bool|array $property, array $current): array + { + if ($property === false) { + return $current; + } + + if (is_array($property)) { + return array_values(array_filter($current, fn($item) => !in_array($item, $property, true))); + } + + return []; + } + + /** + * Whether the node has no children. + */ + public function isLeaf(): bool + { + return [] === $this->children; + } } diff --git a/src/Tree/TaskNodeFactory.php b/src/Tree/TaskNodeFactory.php new file mode 100644 index 0000000..01686ae --- /dev/null +++ b/src/Tree/TaskNodeFactory.php @@ -0,0 +1,17 @@ + $children + * @param array $dependencies + * @param string[] $tags + */ + public function create(string $id, ?Closure $factory, array $children = [], array $dependencies = [], array $tags = [], ?string $class = null): TaskNode; +} diff --git a/src/Tree/TaskTreeBuilder.php b/src/Tree/TaskTreeBuilder.php index d158044..0482b46 100644 --- a/src/Tree/TaskTreeBuilder.php +++ b/src/Tree/TaskTreeBuilder.php @@ -15,7 +15,7 @@ */ final readonly class TaskTreeBuilder { - public function __construct(private ?ContainerInterface $container) + public function __construct(private ?ContainerInterface $container = null, private ?TaskNodeFactory $nodeFactory = null) { } @@ -25,10 +25,12 @@ public function __construct(private ?ContainerInterface $container) * @param string $id Unique identifier for the task node. * @param Closure(): Task $factory Factory that returns the Task instance. * @param array $dependencies List of task IDs this node depends on. + * @param string[] $tags Tags associated with this task. */ - public function task(string $id, Closure $factory, array $dependencies = []): TaskNode + public function task(string $id, Closure $factory, array $dependencies = [], array $tags = [], ?string $class = null): TaskNode { - return new TaskNode($id, $factory, [], $dependencies); + return $this->nodeFactory?->create($id, $factory, [], $dependencies, $tags, $class) + ?? new TaskNode($id, $factory, [], $dependencies, $tags); } /** @@ -36,15 +38,17 @@ public function task(string $id, Closure $factory, array $dependencies = []): Ta * * @param class-string $class FQCN of the service to fetch from container. * @param array $dependencies Dependencies for the service task. + * @param string|null $id Optional identifier for the task. Defaults to short class name. + * @param string[] $tags Tags associated with this task. */ - public function service(string $class, array $dependencies = [], ?string $id = null): TaskNode + public function service(string $class, array $dependencies = [], ?string $id = null, array $tags = []): TaskNode { if ($this->container === null) { throw new LogicException('Cannot create service task: TaskTreeBuilderFactory has no container injected.' . ' Make sure an instance of ' . ContainerInterface::class . ' is autowired in your DI container OR is passed to ' . MultitronFactory::class . ' constructor.'); } - return $this->task($id ?? $this->shortClassName($class), fn() => $this->getTask($class), $dependencies); + return $this->task($id ?? $this->shortClassName($class), fn() => $this->getTask($class), $dependencies, $tags, $class); } /** Fetch a task service from the container and ensure it implements Task. */ @@ -82,10 +86,12 @@ private function getPartitionedTask(string $class): PartitionedTaskInterface * @param string $id Group identifier. * @param TaskNode[] $children Child task nodes. * @param array $dependencies Dependencies for the group. + * @param string[] $tags Tags associated with this group. */ - public function group(string $id, array $children, array $dependencies = []): TaskNode + public function group(string $id, array $children, array $dependencies = [], array $tags = []): TaskNode { - return new TaskNode($id, null, $children, $dependencies); + return $this->nodeFactory?->create($id, null, $children, $dependencies, $tags) + ?? new TaskNode($id, null, $children, $dependencies, $tags); } /** @@ -95,14 +101,22 @@ public function group(string $id, array $children, array $dependencies = []): Ta * @param int $partitionCount Number of partitions. * @param array $dependencies Dependencies for the partitioned tasks. * @param string|null $id Optional base identifier for the partitioned tasks. Defaults to short class name. + * @param string[] $tags Tags associated with the partitioned tasks. */ - public function partitioned(string $class, int $partitionCount, array $dependencies = [], ?string $id = null): TaskNode - { + public function partitioned( + string $class, + int $partitionCount, + array $dependencies = [], + ?string $id = null, + array $tags = [] + ): TaskNode { return $this->partitionedClosure( $id ?? $class, fn(): PartitionedTaskInterface => $this->getPartitionedTask($class), $partitionCount, - $dependencies + $dependencies, + $tags, + $class ); } @@ -113,12 +127,15 @@ public function partitioned(string $class, int $partitionCount, array $dependenc * @param Closure(): (PartitionedTaskInterface|Task) $factory Factory for creating each partition. * @param int $partitionCount Number of partitions. * @param array $dependencies Dependencies for partitioned tasks. + * @param string[] $tags Tags associated with the partitioned tasks. */ public function partitionedClosure( string $id, Closure $factory, int $partitionCount, - array $dependencies = [] + array $dependencies = [], + array $tags = [], + ?string $class = null ): TaskNode { $shortId = $this->shortClassName($id); $children = []; @@ -126,7 +143,7 @@ public function partitionedClosure( for ($i = 0; $i < $partitionCount; $i++) { $label = sprintf('%s %d/%d', $shortId, $i + 1, $partitionCount); - $children[] = new TaskNode( + $children[] = $this->task( $label, function () use ($factory, $i, $partitionCount): Task { $task = $factory(); @@ -138,19 +155,32 @@ function () use ($factory, $i, $partitionCount): Task { $task->setPartitioning($i, $partitionCount); return $task; - } + }, + tags: $tags, + class: $class ); } - return new TaskNode($shortId, null, $children, $dependencies); + return $this->group($shortId, $children, $dependencies, $tags); } /** - * @param TaskNode[] $children + * @param string $id Unique identifier for the pattern filter node. + * @param string $pattern Comma-separated fnmatch patterns to match task IDs or tags. + * @param TaskNode[] $children Child task nodes to filter. + * @param string[] $tags Tags associated with this filter node. + * @param bool $includeDependencies When true (default), include transitive dependencies of matched tasks. + * When false, only matched tasks are returned and dependency edges pointing + * outside the matched set are removed. */ - public function patternFilter(string $id, string $pattern, array $children = []): TaskNode - { - return PatternTaskNodeFactory::create($id, $pattern, $children); + public function patternFilter( + string $id, + string $pattern, + array $children = [], + array $tags = [], + bool $includeDependencies = true + ): TaskNode { + return PatternTaskNodeFactory::create($id, $pattern, $children, $tags, $includeDependencies); } /** diff --git a/src/Tree/TaskTreeBuilderFactory.php b/src/Tree/TaskTreeBuilderFactory.php index f3cef8a..3c24a61 100644 --- a/src/Tree/TaskTreeBuilderFactory.php +++ b/src/Tree/TaskTreeBuilderFactory.php @@ -8,12 +8,12 @@ final readonly class TaskTreeBuilderFactory { - public function __construct(private ?ContainerInterface $container) + public function __construct(private ?ContainerInterface $container, private ?TaskNodeFactory $nodeFactory = null) { } public function create(): TaskTreeBuilder { - return new TaskTreeBuilder($this->container); + return new TaskTreeBuilder($this->container, $this->nodeFactory); } } diff --git a/tests/Integration/PatternFilterIntegrationTest.php b/tests/Integration/PatternFilterIntegrationTest.php index 4f83747..afb37d4 100644 --- a/tests/Integration/PatternFilterIntegrationTest.php +++ b/tests/Integration/PatternFilterIntegrationTest.php @@ -12,7 +12,7 @@ final class PatternFilterIntegrationTest extends TestCase { - private function createTaskList(): TaskList + private function createTaskList(bool $includeDependencies = true): TaskList { $builder = new TaskTreeBuilder(new AppContainer()); @@ -34,7 +34,7 @@ private function createTaskList(): TaskList $dbGroup, $miscGroup, $report, - ]); + ], includeDependencies: $includeDependencies); return new TaskList($root); } @@ -92,5 +92,26 @@ public function testExecutionOrderRespectsDependencies(): void 'final-report', ], $order); } -} + public function testPatternFilterCanExcludeDependencies(): void + { + $list = $this->createTaskList(includeDependencies: false); + $nodes = $list->toArray(); + + $ids = array_keys($nodes); + sort($ids); + $this->assertSame([ + 'cache-clear', + 'cache-warm', + 'db-backup', + 'db-migrate', + 'final-report', + ], $ids); + + $this->assertSame([], $nodes['cache-clear']->dependencies); + $this->assertSame(['cache-clear'], $nodes['cache-warm']->dependencies); + $this->assertSame([], $nodes['db-backup']->dependencies); + $this->assertSame(['db-backup'], $nodes['db-migrate']->dependencies); + $this->assertSame(['cache-warm', 'db-migrate'], $nodes['final-report']->dependencies); + } +} diff --git a/tests/Integration/TaskCommandIntegrationTest.php b/tests/Integration/TaskCommandIntegrationTest.php index 9da0624..bbf0595 100644 --- a/tests/Integration/TaskCommandIntegrationTest.php +++ b/tests/Integration/TaskCommandIntegrationTest.php @@ -56,6 +56,33 @@ public function getNodes(TaskTreeBuilder $builder): array $this->assertArrayHasKey('first', $nodes); } + public function testGetTaskListCanExcludeDependenciesWhenFiltering(): void + { + $peer = new NativeIpcPeer(); + $deps = $this->createDeps($peer); + $command = new #[AsCommand('demo')] class($deps) extends TaskCommand { + public function getNodes(TaskTreeBuilder $builder): array + { + $b = $builder->task('b', fn() => new DummyTask()); + $a = $builder->task('a', fn() => new DummyTask(), [$b]); + return [$a, $b]; + } + }; + + $input = new ArrayInput( + [ + 'pattern' => 'a', + '--deps' => false, + ], + $command->getDefinition() + ); + $list = $command->getTaskList($input); + $nodes = $list->toArray(); + $this->assertCount(1, $nodes); + $this->assertArrayHasKey('a', $nodes); + $this->assertSame([], $nodes['a']->dependencies); + } + public function testExecuteFailsWhenWorkerMissing(): void { $peer = new NativeIpcPeer(); @@ -68,7 +95,7 @@ public function getNodes(TaskTreeBuilder $builder): array }; $app = new Application(); - $app->add($command); + $app->addCommand($command); $this->expectException(RuntimeException::class); $command->run(new ArrayInput([], $command->getDefinition()), new BufferedOutput()); @@ -90,11 +117,10 @@ public function getNodes(TaskTreeBuilder $builder): array $app = new Application(); $worker = new WorkerCommand($adapter); - $app->add($worker); - $app->add($command); + $app->addCommand($worker); + $app->addCommand($command); $result = $command->run(new ArrayInput([], $command->getDefinition()), new BufferedOutput()); $this->assertSame(0, $result); } } - diff --git a/tests/Integration/WorkerCommandIntegrationTest.php b/tests/Integration/WorkerCommandIntegrationTest.php index 68b447c..64cd006 100644 --- a/tests/Integration/WorkerCommandIntegrationTest.php +++ b/tests/Integration/WorkerCommandIntegrationTest.php @@ -24,8 +24,8 @@ protected function setUp(): void $factory = new MultitronFactory(new AppContainer()); $app = new Application(); -$app->add($factory->getWorkerCommand()); -$app->add(new DemoCommand($factory->getTaskCommandDeps())); +$app->addCommand($factory->getWorkerCommand()); +$app->addCommand(new DemoCommand($factory->getTaskCommandDeps())); $app->run(); PHP; $this->script = $this->createWorkerScript(sprintf($script, var_export(self::$autoloadPath, true))); diff --git a/tests/Unit/MultitronFactoryTest.php b/tests/Unit/MultitronFactoryTest.php index 6dbf23e..7c186c0 100644 --- a/tests/Unit/MultitronFactoryTest.php +++ b/tests/Unit/MultitronFactoryTest.php @@ -3,7 +3,9 @@ namespace Multitron\Tests\Unit; +use Closure; use InvalidArgumentException; +use LogicException; use Multitron\Bridge\Native\MultitronFactory; use Multitron\Comms\NativeIpcAdapter; use Multitron\Console\TaskCommandDeps; @@ -14,6 +16,8 @@ use Multitron\Orchestrator\Output\ProgressOutputFactory; use Multitron\Orchestrator\TaskOrchestrator; use Multitron\Tests\Mocks\AppContainer; +use Multitron\Tree\TaskNode; +use Multitron\Tree\TaskNodeFactory; use Multitron\Tree\TaskTreeBuilderFactory; use PHPUnit\Framework\TestCase; use StreamIpc\NativeIpcPeer; @@ -56,6 +60,22 @@ public function testAllSettersAndDefaults(): void $factory->setProgressOutputFactory($outFactory); $this->assertSame($outFactory, $factory->getProgressOutputFactory()); + $taskNodeFactory = new class() implements TaskNodeFactory { + public function create( + string $id, + ?Closure $factory, + array $children = [], + array $dependencies = [], + array $tags = [], + ?string $class = null + ): TaskNode { + throw new LogicException('Not needed for this test'); + } + }; + $factory->setTaskNodeFactory($taskNodeFactory); + $this->assertSame($taskNodeFactory, $factory->getTaskNodeFactory()); + + $factory->setIpcHandlerRegistryFactory($regFactory); $this->assertSame($regFactory, $factory->getIpcHandlerRegistryFactory()); diff --git a/tests/Unit/PatternTaskNodeFactoryTest.php b/tests/Unit/PatternTaskNodeFactoryTest.php index b36cd4a..688fee5 100644 --- a/tests/Unit/PatternTaskNodeFactoryTest.php +++ b/tests/Unit/PatternTaskNodeFactoryTest.php @@ -27,5 +27,20 @@ public function testFilteringAndDependencyAdjustment(): void $this->assertSame(['beta'], $compiled['gamma']->dependencies); $this->assertSame([], $compiled['alpha']->dependencies); } -} + public function testFilteringWithoutDependenciesRemovesDependencyEdgesOutsideSelection(): void + { + $t1 = new TaskNode('alpha', fn() => new DummyTask()); + $t2 = new TaskNode('beta', fn() => new DummyTask(), [], ['alpha']); + $t3 = new TaskNode('gamma', fn() => new DummyTask(), [], ['beta']); + + $root = PatternTaskNodeFactory::create('filter', 'beta,gamma', [$t1, $t2, $t3], includeDependencies: false); + $compiled = (new TaskTreeCompiler())->compile($root); + + $ids = array_keys($compiled); + sort($ids); + $this->assertSame(['beta', 'gamma'], $ids); + $this->assertSame([], $compiled['beta']->dependencies); + $this->assertSame(['beta'], $compiled['gamma']->dependencies); + } +} diff --git a/tests/Unit/TaskNodeTest.php b/tests/Unit/TaskNodeTest.php new file mode 100644 index 0000000..1af1c43 --- /dev/null +++ b/tests/Unit/TaskNodeTest.php @@ -0,0 +1,180 @@ +assertSame('test-id', $node->id); + $this->assertNull($node->factory); + $this->assertSame([], $node->children); + $this->assertSame([], $node->dependencies); + $this->assertSame([], $node->tags); + $this->assertNull($node->postProcess); + } + + public function testConstructExplicitValues(): void + { + $factory = fn() => $this->createMock(Task::class); + $children = [new TaskNode('child')]; + $dependencies = ['dep1']; + $tags = ['tag1']; + $postProcess = fn(array $tasks) => $tasks; + + $node = new TaskNode( + id: 'test-id', + factory: $factory, + children: $children, + dependencies: $dependencies, + tags: $tags, + postProcess: $postProcess + ); + + $this->assertSame('test-id', $node->id); + $this->assertSame($factory, $node->factory); + $this->assertSame($children, $node->children); + $this->assertSame($dependencies, $node->dependencies); + $this->assertSame($tags, $node->tags); + $this->assertSame($postProcess, $node->postProcess); + } + + public function testWith(): void + { + $node = new TaskNode('test-id'); + + $factory = fn() => $this->createMock(Task::class); + $children = [new TaskNode('child')]; + $dependencies = ['dep1']; + $tags = ['tag1']; + $postProcess = fn(array $tasks) => $tasks; + + $newNode = $node->with( + factory: $factory, + children: $children, + dependencies: $dependencies, + tags: $tags, + postProcess: $postProcess + ); + + $this->assertNotSame($node, $newNode); + $this->assertSame('test-id', $newNode->id); + $this->assertSame($factory, $newNode->factory); + $this->assertSame($children, $newNode->children); + $this->assertSame($dependencies, $newNode->dependencies); + $this->assertSame($tags, $newNode->tags); + $this->assertSame($postProcess, $newNode->postProcess); + + // Test partial with + $partialNode = $newNode->with(tags: ['new-tag']); + $this->assertSame(['new-tag'], $partialNode->tags); + $this->assertSame($factory, $partialNode->factory); + } + + public function testWithAdded(): void + { + $node = new TaskNode( + id: 'test-id', + children: [new TaskNode('child1')], + dependencies: ['dep1'], + tags: ['tag1'] + ); + + $child2 = new TaskNode('child2'); + $newNode = $node->withAdded( + children: [$child2], + dependencies: ['dep2'], + tags: ['tag2'] + ); + + $this->assertCount(2, $newNode->children); + $this->assertSame($child2, $newNode->children[1]); + $this->assertSame(['dep1', 'dep2'], $newNode->dependencies); + $this->assertSame(['tag1', 'tag2'], $newNode->tags); + } + + public function testWithout(): void + { + $factory = fn() => $this->createMock(Task::class); + $child1 = new TaskNode('child1'); + $child2 = new TaskNode('child2'); + $dependencies = ['dep1', 'dep2']; + $tags = ['tag1', 'tag2']; + $postProcess = fn(array $tasks) => $tasks; + + $node = new TaskNode( + id: 'test-id', + factory: $factory, + children: [$child1, $child2], + dependencies: $dependencies, + tags: $tags, + postProcess: $postProcess + ); + + // Test clearing everything + $cleared = $node->without( + factory: true, + children: true, + dependencies: true, + tags: true, + postProcess: true + ); + + $this->assertNull($cleared->factory); + $this->assertSame([], $cleared->children); + $this->assertSame([], $cleared->dependencies); + $this->assertSame([], $cleared->tags); + $this->assertNull($cleared->postProcess); + + // Test removing specific items + $filtered = $node->without( + children: [$child1], + dependencies: ['dep2'], + tags: ['tag1'] + ); + + $this->assertSame([$child2], $filtered->children); + $this->assertSame(['dep1'], $filtered->dependencies); + $this->assertSame(['tag2'], $filtered->tags); + + // Test false values (no change) + $same = $node->without(); + $this->assertSame($factory, $same->factory); + $this->assertSame([$child1, $child2], $same->children); + $this->assertSame($dependencies, $same->dependencies); + $this->assertSame($tags, $same->tags); + $this->assertSame($postProcess, $same->postProcess); + } + + public function testWithoutWithMixedDependencies(): void + { + $child1 = new TaskNode('child1'); + $node = new TaskNode( + id: 'test', + dependencies: [$child1, 'dep1'] + ); + + $filtered = $node->without(dependencies: [$child1]); + $this->assertSame(['dep1'], $filtered->dependencies); + + $filtered2 = $node->without(dependencies: ['dep1']); + $this->assertSame([$child1], $filtered2->dependencies); + } + + public function testIsLeaf(): void + { + $node = new TaskNode('leaf'); + $this->assertTrue($node->isLeaf()); + + $parentNode = new TaskNode('parent', children: [$node]); + $this->assertFalse($parentNode->isLeaf()); + } +} diff --git a/tests/Unit/WorkerCommandTest.php b/tests/Unit/WorkerCommandTest.php index 59e82cd..844778e 100644 --- a/tests/Unit/WorkerCommandTest.php +++ b/tests/Unit/WorkerCommandTest.php @@ -125,8 +125,8 @@ public function testExecuteFailsWhenCommandNotTaskCommand(): void $worker = new WorkerCommand($adapter); $app = new Application(); - $app->add($worker); - $app->add(new Command('foo')); + $app->addCommand($worker); + $app->addCommand(new Command('foo')); $tester = new CommandTester($worker); $this->expectException(RuntimeException::class); @@ -140,9 +140,9 @@ public function testExecuteFailsWhenTaskNotFound(): void $worker = new WorkerCommand($adapter); $app = new Application(); - $app->add($worker); + $app->addCommand($worker); $demo = new DemoCommand($this->createDeps()); - $app->add($demo); + $app->addCommand($demo); $tester = new CommandTester($worker); $this->expectException(RuntimeException::class); @@ -156,9 +156,9 @@ public function testExecuteRunsTaskSuccessfully(): void $worker = new WorkerCommand($adapter); $app = new Application(); - $app->add($worker); + $app->addCommand($worker); $demo = new DemoCommand($this->createDeps()); - $app->add($demo); + $app->addCommand($demo); $oldLimit = ini_get('memory_limit'); $tester = new CommandTester($worker);