setConstraint('http:aria2', '^1.2'); $m->coreConstraint = '>=1.1'; $m->save($dir); $loaded = ProjectManifest::load($dir); eq('^1.2', $loaded->constraint('http:aria2')); eq('>=1.1', $loaded->coreConstraint); rrmdir($dir); }); Harness::it('lock guarda core y componentes con schema esperado', function () { $dir = tmpdir(); $file = $dir . '/src/Libs/Aria2.php'; write_file($file, "setCore(['version' => '1.5.0', 'commit' => 'abc', 'files' => ['src/Libs/Router.php' => 'sha256:x']]); $lock->setComponent('http:aria2', [ 'package' => 'http', 'version' => '1.2.0', 'toolbox_commit' => 'D', 'mode' => 'copy', 'files' => ['src/Libs/Aria2.php' => Drift::hash($file)], ]); $lock->save($dir); $loaded = Lock::load($dir); eq('1.5.0', $loaded->core()['version']); eq('http', $loaded->component('http:aria2')['package']); eq('copy', $loaded->component('http:aria2')['mode']); rrmdir($dir); }); Harness::it('ownership detecta colisión de rutas', function () { $lock = Lock::empty(); $lock->setCore(['files' => ['src/Libs/X.php' => 'sha256:a']]); $lock->setComponent('a:b', ['package' => 'a', 'version' => '1', 'toolbox_commit' => null, 'mode' => 'copy', 'files' => ['src/Libs/X.php' => 'sha256:b']]); $ownership = Ownership::fromLock($lock); ok($ownership->collisions() !== [], 'debía detectar colisión'); throws(fn () => $ownership->assertNoCollisions()); }); Harness::it('ownership separa core y componentes sin colisión', function () { $lock = Lock::empty(); $lock->setCore(['files' => ['src/Libs/Router.php' => 'sha256:a']]); $lock->setComponent('a:b', ['package' => 'a', 'version' => '1', 'toolbox_commit' => null, 'mode' => 'copy', 'files' => ['src/Libs/B.php' => 'sha256:b']]); $ownership = Ownership::fromLock($lock); eq('core', $ownership->ownerOf('src/Libs/Router.php')); eq('a:b', $ownership->ownerOf('src/Libs/B.php')); $ownership->assertNoCollisions(); }); Harness::it('drift distingue match, drifted y missing', function () { $dir = tmpdir(); $file = $dir . '/f.php'; write_file($file, 'original'); $hash = Drift::hash($file); eq(Drift::MATCH, Drift::status($file, $hash)); write_file($file, 'editado'); eq(Drift::DRIFTED, Drift::status($file, $hash)); eq(Drift::MISSING, Drift::status($dir . '/nope.php', $hash)); rrmdir($dir); }); Harness::it('destino aplica la regla src y fuera de src', function () { $rootDir = '/proj'; $rootCore = '/proj/src'; eq('src/Libs/Crypto.php', Destination::relative('src/Libs/Crypto.php', $rootDir, $rootCore)); eq('duckbrain', Destination::relative('duckbrain', $rootDir, $rootCore)); eq('/proj/src/Libs/Crypto.php', Destination::resolve('src/Libs/Crypto.php', $rootDir, $rootCore)); eq('/proj/duckbrain', Destination::resolve('duckbrain', $rootDir, $rootCore)); eq('custom/Libs/Crypto.php', Destination::relative('src/Libs/Crypto.php', $rootDir, '/proj/custom')); }); Harness::it('destino explícito sobreescribe la regla', function () { $dest = Destination::relative('src/Libs/Crypto.php', '/proj', '/proj/src', ['src/Libs/Crypto.php' => 'elsewhere/Crypto.php']); eq('elsewhere/Crypto.php', $dest); }); Harness::it('el lock persiste la descripción y tolera locks sin el campo', function () { $dir = tmpdir(); $lock = Lock::empty(); $lock->setComponent('db:sql', [ 'package' => 'db', 'version' => '1.0.0', 'mode' => 'copy', 'description' => 'SQL', 'files' => [], ]); $lock->save($dir); eq('SQL', Lock::load($dir)->component('db:sql')['description'], 'roundtrip de description'); $raw = json_decode((string) file_get_contents($dir . '/.duckbrain/lock.json'), true); unset($raw['components']['db:sql']['description']); file_put_contents($dir . '/.duckbrain/lock.json', json_encode($raw)); eq(null, Lock::load($dir)->component('db:sql')['description'], 'lock antiguo sin campo se lee tolerante'); rrmdir($dir); }); Harness::it('add captura la descripción efectiva del catálogo en el lock', function () { $root = tmpdir('proj'); $tool = tmpdir('tool'); write_file($tool . '/packages/mix/duckbrain.json', json_encode([ 'name' => 'mix', 'version' => '1.0.0', 'description' => 'Descripción del paquete', 'components' => [ 'own' => ['files' => ['src/Libs/Own.php'], 'description' => 'Descripción propia'], 'inh' => ['files' => ['src/Libs/Inh.php']], ], 'default' => ['own', 'inh'], ])); write_file($tool . '/packages/mix/src/Libs/Own.php', "add(['mix'], ['yes' => true]); $lock = Lock::load($root); eq('Descripción propia', $lock->component('mix:own')['description'], 'propia gana en el lock'); eq('Descripción del paquete', $lock->component('mix:inh')['description'], 'sin propia, hereda del paquete'); rrmdir($root); rrmdir($tool); });