feat: M3 import & indexing engine delivery layer with durable collision handling, space checking and console

This commit is contained in:
Cline committed 2026-10-12 11:29:17 -05:00
1 parent 93d991703c
commit 9d277d9e72
48 files changed
+5147 -4

No files matched your search

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Console;
use MeshVault\Console\ConsoleServices;
use MeshVault\Console\LazyConsoleServices;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class ConsoleServicesWiringTest extends TestCase
{
public function testLazyServicesDoNotBuildTheEngineUntilUsed(): void
{
$built = 0;
$services = new LazyConsoleServices(function () use (&$built): ConsoleServices {
$built++;
return new FakeConsoleServices(importResult: 9);
});
self::assertSame(0, $built, 'Construction alone must not touch configuration.');
self::assertSame(9, $services->import('/srv/in', ['stl']));
self::assertSame(1, $built);
}
public function testLazyServicesBuildTheEngineOnlyOnce(): void
{
$built = 0;
$delegate = new FakeConsoleServices(jobs: [1, 2]);
$services = new LazyConsoleServices(function () use (&$built, $delegate): ConsoleServices {
$built++;
return $delegate;
});
$services->runWorkerOnce(static fn (): bool => false);
$services->runWorkerOnce(static fn (): bool => false);
self::assertSame(1, $built);
self::assertSame(2, $delegate->runs);
}
public function testLazyServicesPropagateConstructionFailures(): void
{
$services = new LazyConsoleServices(static function (): ConsoleServices {
throw new RuntimeException('bad configuration');
});
$this->expectException(RuntimeException::class);
$services->runWorkerOnce(static fn (): bool => false);
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Console;
use MeshVault\Console\Console;
use MeshVault\Console\ConsoleServices;
use MeshVault\Indexer\ImportException;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class ConsoleTest extends TestCase
{
public function testHelpIsSuccessfulAndListsEverySubcommand(): void
{
[$code, $out, $err] = $this->execute(['help']);
self::assertSame(0, $code);
self::assertSame('', $err);
foreach (['import', 'worker', 'worker-once', '--ext'] as $fragment) {
self::assertStringContainsString($fragment, $out);
}
}
public function testMissingCommandIsAUsageError(): void
{
[$code, $out, $err] = $this->execute([]);
self::assertSame(1, $code);
self::assertSame('', $out);
self::assertStringContainsString('Usage: meshvault', $err);
}
public function testUnknownCommandIsAUsageError(): void
{
[$code, , $err] = $this->execute(['nope']);
self::assertSame(1, $code);
self::assertStringContainsString('Unknown command', $err);
}
public function testImportWithoutSourceIsAUsageError(): void
{
[$code, , $err] = $this->execute(['import']);
self::assertSame(1, $code);
self::assertStringContainsString('import <source-path>', $err);
}
public function testImportRejectsExtraOperandsAndUnknownOptions(): void
{
[$code, , $err] = $this->execute(['import', 'a', 'b']);
self::assertSame(1, $code);
self::assertStringContainsString('one source path', $err);
[$code, , $err] = $this->execute(['import', 'a', '--deep']);
self::assertSame(1, $code);
self::assertStringContainsString('Unknown option', $err);
self::assertStringContainsString('--deep', $err);
}
public function testImportRejectsEmptyExtensionList(): void
{
foreach (['--ext=', '--ext= , '] as $option) {
[$code, , $err] = $this->execute(['import', 'a', $option]);
self::assertSame(1, $code, $option);
self::assertStringContainsString('--ext', $err);
}
}
public function testImportQueuesAJobWithDefaultExtensions(): void
{
$services = new FakeConsoleServices(importResult: 42);
[$code, $out] = $this->execute(['import', '/srv/incoming'], $services);
self::assertSame(0, $code);
self::assertSame(
[['/srv/incoming', ['stl', 'jpg', 'jpeg', 'png', 'gif', 'pdf', 'txt', 'md']]],
$services->imports
);
self::assertStringContainsString('42', $out);
}
public function testImportNormalizesTheExtensionOption(): void
{
$services = new FakeConsoleServices(importResult: 7);
[$code] = $this->execute(['import', '/srv/in', '--ext= STL , .3mf ,stl'], $services);
self::assertSame(0, $code);
self::assertSame([['/srv/in', ['stl', '3mf']]], $services->imports);
}
public function testImportFailureReportsTheReasonCodeAndExitsTwo(): void
{
$services = new FakeConsoleServices(importError: new ImportException('SCAN_EMPTY', 'Nothing found.'));
[$code, $out, $err] = $this->execute(['import', '/srv/in'], $services);
self::assertSame(2, $code);
self::assertSame('', $out);
self::assertStringContainsString('SCAN_EMPTY', $err);
self::assertStringContainsString('Nothing found.', $err);
}
public function testUnexpectedFailureIsReportedWithoutLeakingDetails(): void
{
$services = new FakeConsoleServices(
importError: new RuntimeException('SQLSTATE[08006] password=hunter2')
);
[$code, , $err] = $this->execute(['import', '/srv/in'], $services);
self::assertSame(2, $code);
self::assertStringNotContainsString('hunter2', $err);
self::assertStringNotContainsString('#0 ', $err);
self::assertStringContainsString('RuntimeException', $err);
}
public function testWorkerOnceReportsTheProcessedJob(): void
{
$services = new FakeConsoleServices(jobs: [11]);
[$code, $out] = $this->execute(['worker-once'], $services);
self::assertSame(0, $code);
self::assertSame(1, $services->runs);
self::assertStringContainsString('11', $out);
}
public function testWorkerOnceReportsAnIdleQueue(): void
{
$services = new FakeConsoleServices(jobs: [null]);
[$code, $out] = $this->execute(['worker-once'], $services);
self::assertSame(0, $code);
self::assertStringContainsString('No pending import jobs', $out);
}
public function testWorkerOnceRejectsOperands(): void
{
[$code, , $err] = $this->execute(['worker-once', 'x']);
self::assertSame(1, $code);
self::assertStringContainsString('takes no arguments', $err);
}
public function testWorkerLoopsUntilTheIterationBoundAndSleepsWhenIdle(): void
{
$services = new FakeConsoleServices(jobs: [5, null, null]);
$slept = [];
[$code, $out] = $this->execute(
['worker', '--max-iterations=3', '--sleep=2'],
$services,
static function (int $seconds) use (&$slept): void {
$slept[] = $seconds;
}
);
self::assertSame(0, $code);
self::assertSame(3, $services->runs);
// Only idle passes sleep; a processed job is followed immediately by the next claim.
self::assertSame([2, 2], $slept);
self::assertStringContainsString('stopped', $out);
}
public function testWorkerStopsWhenAStopIsRequested(): void
{
$services = new FakeConsoleServices(jobs: [1, 2, 3]);
$calls = 0;
[$code, $out] = $this->execute(
['worker', '--max-iterations=99'],
$services,
null,
static function () use (&$calls): bool {
$calls++;
return $calls > 2;
}
);
self::assertSame(0, $code);
self::assertSame(2, $services->runs);
self::assertStringContainsString('stopped', $out);
}
public function testWorkerRejectsInvalidOptions(): void
{
foreach (['--max-iterations=0', '--max-iterations=x', '--sleep=-1', '--bogus', 'operand'] as $argument) {
[$code, , $err] = $this->execute(['worker', $argument]);
self::assertSame(1, $code, $argument);
self::assertNotSame('', $err);
}
}
public function testWorkerFailureExitsTwo(): void
{
$services = new FakeConsoleServices(
workerError: new ImportException('LOCK_UNAVAILABLE', 'Another worker holds the library lock.')
);
[$code, , $err] = $this->execute(['worker', '--max-iterations=1'], $services);
self::assertSame(2, $code);
self::assertStringContainsString('LOCK_UNAVAILABLE', $err);
}
/** @return array{0: int, 1: string, 2: string} */
private function execute(
array $argv,
?ConsoleServices $services = null,
?callable $sleep = null,
?callable $stopRequested = null
): array {
$out = fopen('php://memory', 'w+');
$err = fopen('php://memory', 'w+');
$console = new Console(
$services ?? new FakeConsoleServices(),
$out,
$err,
$sleep ?? static function (int $seconds): void {
},
$stopRequested ?? static fn (): bool => false
);
$code = $console->run($argv);
rewind($out);
rewind($err);
return [$code, (string) stream_get_contents($out), (string) stream_get_contents($err)];
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Console;
use MeshVault\Config\AppConfig;
use MeshVault\Repository\CatalogRepositoryInterface;
use MeshVault\Repository\ImportJobRepositoryInterface;
use MeshVault\Repository\PdoCatalogRepository;
use MeshVault\Repository\PdoImportJobRepository;
use PDO;
use Phinx\Config\Config;
use Phinx\Migration\Manager;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
final class ContainerWiringTest extends TestCase
{
private PDO $pdo;
protected function setUp(): void
{
$this->pdo = new PDO('sqlite::memory:', null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$config = new Config([
'paths' => ['migrations' => __DIR__ . '/../../../db/migrations'],
'environments' => [
'default_migration_table' => 'phinxlog',
'default_database' => 'test',
'test' => ['adapter' => 'sqlite', 'connection' => $this->pdo],
],
]);
(new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test');
$this->pdo->exec('PRAGMA foreign_keys = ON');
}
public function testRepositoriesShareSamePdoInstance(): void
{
// L5: assert that the container would wire both repositories to the SAME PDO
// instance so the job repository's atomic() is a real transaction, not a no-op.
$catalog = new PdoCatalogRepository($this->pdo);
$jobs = new PdoImportJobRepository($this->pdo);
$reflectionCatalog = new \ReflectionObject($catalog);
$reflectionJobs = new \ReflectionObject($jobs);
$pdoCatalog = $reflectionCatalog->getProperty('pdo');
$pdoCatalog->setAccessible(true);
$pdoJobs = $reflectionJobs->getProperty('pdo');
$pdoJobs->setAccessible(true);
self::assertSame(
$pdoCatalog->getValue($catalog),
$pdoJobs->getValue($jobs),
'CatalogRepository and ImportJobRepository must share the same PDO instance for atomic() to work'
);
}
public function testAtomicCallbackUsesSharedConnection(): void
{
$catalog = new PdoCatalogRepository($this->pdo);
$jobs = new PdoImportJobRepository($this->pdo);
// Test that atomic() on jobs uses the same connection as catalog
$called = false;
$jobs->atomic(function () use ($jobs, $catalog, &$called): void {
$called = true;
// Inside the transaction, both repositories should see each other's changes
$jobId = $jobs->create('/tmp/source', [
['source_path' => '/tmp/source/file.stl', 'size' => 100, 'model_id' => null],
]);
$items = $jobs->items($jobId);
self::assertCount(1, $items);
});
self::assertTrue($called);
// After commit, the job should be visible
$allJobs = $this->pdo->query('SELECT * FROM import_jobs')->fetchAll(PDO::FETCH_ASSOC);
self::assertCount(1, $allJobs);
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Console;
use MeshVault\Console\ConsoleServices;
use Throwable;
final class FakeConsoleServices implements ConsoleServices
{
/** @var list<array{0: string, 1: list<string>}> */
public array $imports = [];
public int $runs = 0;
/** @param list<int|null> $jobs */
public function __construct(
private readonly int $importResult = 1,
private readonly ?Throwable $importError = null,
private array $jobs = [],
private readonly ?Throwable $workerError = null,
) {
}
public function import(string $sourcePath, array $extensions): int
{
$this->imports[] = [$sourcePath, $extensions];
if ($this->importError !== null) {
throw $this->importError;
}
return $this->importResult;
}
public function runWorkerOnce(callable $shouldStop): ?int
{
$this->runs++;
if ($this->workerError !== null) {
throw $this->workerError;
}
// The real worker consults the callback between items; mirror that contract.
$shouldStop();
return array_shift($this->jobs);
}
}