diff --git a/setup/modulediscovery.class.inc.php b/setup/modulediscovery.class.inc.php index 3be25ceb1..3f97421b9 100644 --- a/setup/modulediscovery.class.inc.php +++ b/setup/modulediscovery.class.inc.php @@ -68,6 +68,188 @@ HTML; } } +class ModuleDependency { + private array $aPotentialPrerequisites; + private array $aParamsPerModuleId; + private string $sDepString; + private bool $bAlwaysUnresolved=false; + + public function __construct(string $sDepString) + { + $this->sDepString = $sDepString; + $this->aParamsPerModuleId = []; + $this->aPotentialPrerequisites = []; + + if (preg_match_all('/([^\(\)&| ]+)/', $sDepString, $aMatches)) + { + foreach($aMatches as $aMatch) + { + foreach($aMatch as $sModuleId) + { + if (! array_key_exists($sModuleId, $this->aParamsPerModuleId)) { + // $sModuleId in the dependency string is made of a / + // where the operator is < <= = > >= (by default >=) + $aModuleMatches = array(); + if (preg_match('|^([^/]+)/(?=?)([^><=]+)$|', $sModuleId, $aModuleMatches)) { + $sModuleName = $aModuleMatches[1]; + $this->aPotentialPrerequisites[$sModuleName] = true; + $sOperator = $aModuleMatches[2]; + if ($sOperator == '') { + $sOperator = '>='; + } + $sExpectedVersion = $aModuleMatches[3]; + $this->aParamsPerModuleId[$sModuleId] = [$sModuleName, $sOperator, $sExpectedVersion]; + } + } + } + } + } else { + $this->bAlwaysUnresolved=true; + } + } + + public function GetPotentialPrerequisites() : array + { + return array_keys($this->aPotentialPrerequisites); + } + + public function IsDependencyResolved(array $aModuleVersions, array $aSelectedModules) : bool + { + if ($this->bAlwaysUnresolved){ + return false; + } + + $aReplacements=[]; + foreach ($this->aParamsPerModuleId as $sModuleId => list($sModuleName, $sOperator, $sExpectedVersion)){ + if (array_key_exists($sModuleName, $aModuleVersions)) + { + // module is present, check the version + $sCurrentVersion = $aModuleVersions[$sModuleName]; + if (version_compare($sCurrentVersion, $sExpectedVersion, $sOperator)) + { + if (array_key_exists($sModuleName, $this->aPotentialPrerequisites)) { + unset($this->aPotentialPrerequisites[$sModuleName]); + } + $aReplacements[$sModuleId] = '(true)'; // Add parentheses to protect against invalid condition causing + // a function call that results in a runtime fatal error + } + else + { + $aReplacements[$sModuleId] = '(false)'; // Add parentheses to protect against invalid condition causing + // a function call that results in a runtime fatal error + } + } + else + { + // module is not present + $aReplacements[$sModuleId] = '(false)'; // Add parentheses to protect against invalid condition causing + // a function call that results in a runtime fatal error + } + } + + foreach ($this->aPotentialPrerequisites as $sModuleName) + { + if (array_key_exists($sModuleName, $aSelectedModules)) + { + // This module is actually a prerequisite + if (!array_key_exists($sModuleName, $aModuleVersions)) + { + return false; + } + } + } + + $bResult=false; + $sBooleanExpr = str_replace(array_keys($aReplacements), array_values($aReplacements), $this->sDepString); + $bOk = @eval('$bResult = '.$sBooleanExpr.'; return true;'); + if ($bOk == false) + { + SetupLog::Warning("Eval of '$sBooleanExpr' returned false"); + echo "Failed to parse the boolean Expression = '$sBooleanExpr'
"; + } + return $bResult; + } +} + +class Module { + private string $sModuleId; + private string $sModuleName; + private string $sVersion; + + public array $aAllDependencies; + public array $aOngoingDependencies; + + public function __construct(string $sModuleId) + { + $this->sModuleId = $sModuleId; + list($this->sModuleName, $this->sVersion) = ModuleDiscovery::GetModuleName($sModuleId); + if (strlen($this->sVersion) == 0) { + // No version number found, assume 1.0.0 + $this->sVersion = '1.0.0'; + } + } + + public function GetModuleName() + { + return $this->sModuleName; + } + + public function GetVersion() + { + return $this->sVersion; + } + + public function GetModuleId() + { + return $this->sModuleId; + } + + public function SetDependencies(array $aAllDependencies) + { + $this->aAllDependencies = $aAllDependencies; + $this->aOngoingDependencies = []; + + foreach ($aAllDependencies as $sDepString){ + $this->aOngoingDependencies[$sDepString]= new ModuleDependency($sDepString); + } + } + + public function IsModuleResolved(array $aModuleVersions, array $aSelectedModules) : bool + { + $aNextDependencies=[]; + $bDependenciesSolved = true; + foreach($this->aOngoingDependencies as $sDepId => $oModuleDependency) + { + /** @var ModuleDependency $oModuleDependency*/ + if (!$oModuleDependency->IsDependencyResolved($aModuleVersions, $aSelectedModules)) + { + $aNextDependencies[$sDepId]=$oModuleDependency; + $bDependenciesSolved = false; + } + } + + $this->aOngoingDependencies=$aNextDependencies; + + if ($bDependenciesSolved) + { + return true; + } + + return false; + } + + public function GetUnresolvedDependencyModuleNames(): array + { + $aRes=[]; + foreach($this->aOngoingDependencies as $sDepId => $oModuleDependency) { + /** @var ModuleDependency $oModuleDependency */ + $aRes = array_merge($aRes, $oModuleDependency->GetPotentialPrerequisites()); + } + + return array_unique($aRes); + } +} + class ModuleDiscovery { static $m_aModuleArgs = array( @@ -221,11 +403,76 @@ class ModuleDiscovery return self::OrderModulesByDependencies(self::$m_aModules, $bAbortOnMissingDependency, $aModulesToLoad); } - public static function SortModulesByCountOfDepencenciesDescending(array &$aOngoingDependencies) : void + public static function SortModulesByCountOfDepencenciesDescending(array &$aUnresolvedDependencyModules) : void { - uasort($aOngoingDependencies, function (array $aDeps1, array $aDeps2){ - return count($aDeps1) - count($aDeps2); - }); + $aCountDepsByModuleId=[]; + $aDependsOnModuleName=[]; + + foreach($aUnresolvedDependencyModules as $sModuleId => $oModule) { + /** @var Module $oModule */ + $aDependsOnModuleName[$oModule->GetModuleName()]=[]; + } + + foreach ($aUnresolvedDependencyModules as $sModuleId => $oModule) { + $iDepsCount = 0; + /** @var Module $oModule */ + $aUnresolvedDependencyModuleNames = $oModule->GetUnresolvedDependencyModuleNames(); + foreach ($aUnresolvedDependencyModuleNames as $sModuleName) { + if (array_key_exists($sModuleName, $aDependsOnModuleName)) { + $aDependsOnModuleName[$sModuleName][] = $sModuleId; + $iDepsCount++; + } + } + $iDepsCountIncludingOutsideModules = count($oModule->GetUnresolvedDependencyModuleNames()); + $aCountDepsByModuleId[$sModuleId] = [$iDepsCount, $iDepsCountIncludingOutsideModules]; + } + + $aRes=[]; + while(count($aUnresolvedDependencyModules)>0) { + asort($aCountDepsByModuleId); + + uasort($aCountDepsByModuleId, function (array $aDeps1, array $aDeps2){ + //compare only + $res = $aDeps1[0] - $aDeps2[0]; + if ($res != 0){ + return $res; + } + + return $aDeps1[1] - $aDeps2[1]; + }); + + $bOneLoopAtLeast=false; + foreach ($aCountDepsByModuleId as $sModuleId => $iDepsCount){ + $oModule=$aUnresolvedDependencyModules[$sModuleId]; + + if ($bOneLoopAtLeast && $iDepsCount>0){ + break; + } + + unset($aUnresolvedDependencyModules[$sModuleId]); + unset($aCountDepsByModuleId[$sModuleId]); + + $aRes[$sModuleId]=$oModule; + + //when 2 versions of the same module (name) below array has been removed already + if (array_key_exists($oModule->GetModuleName(), $aDependsOnModuleName)) { + foreach ($aDependsOnModuleName[$oModule->GetModuleName()] as $sModuleId2) { + if (! array_key_exists($sModuleId2, $aCountDepsByModuleId)){ + continue; + } + $aDepCount = $aCountDepsByModuleId[$sModuleId2]; + $iDepsCount = $aDepCount[0] - 1; + $aCountDepsByModuleId[$sModuleId2] = [ $iDepsCount, $aDepCount[1]]; + } + + unset($aDependsOnModuleName[$oModule->GetModuleName()]); + } + + $bOneLoopAtLeast=true; + } + } + + $aUnresolvedDependencyModules=$aRes; } /** @@ -235,72 +482,63 @@ class ModuleDiscovery * @param array $aModulesToLoad List of modules to search for, defaults to all if omitted * @return array * @throws \MissingDependencyException - */ - public static function OrderModulesByDependencies($aModules, $bAbortOnMissingDependency = false, $aModulesToLoad = null) +*/ + public static function OrderModulesByDependencies($aModules, $bAbortOnMissingDependency = false, $aModulesToLoad = null, ?int &$iLoopCount=0) { + $iLoopCount=0; + // Order the modules to take into account their inter-dependencies - $aDependencies = []; - $aOngoingDependencies = []; + $aUnresolvedDependencyModules = []; $aSelectedModules = []; - foreach($aModules as $sId => $aModule) + foreach($aModules as $sModuleId => $aModule) { - list($sModuleName, ) = self::GetModuleName($sId); + $oModule = new Module($sModuleId); + $sModuleName = $oModule->GetModuleName(); if (is_null($aModulesToLoad) || in_array($sModuleName, $aModulesToLoad)) { - $aCurrentDependencies = $aModule['dependencies']; - $aDependencies[$sId] = $aCurrentDependencies; - $aOngoingDependencies[$sId] = $aCurrentDependencies; + $oModule->SetDependencies($aModule['dependencies']); + $aUnresolvedDependencyModules[$sModuleId]=$oModule; $aSelectedModules[$sModuleName] = true; } } - self::SortModulesByCountOfDepencenciesDescending($aOngoingDependencies); + self::SortModulesByCountOfDepencenciesDescending($aUnresolvedDependencyModules); $aOrderedModules = []; + $aModuleVersions=[]; $iPreviousLoopDepencyCount=-1; - $iNextLoopCount=count($aOngoingDependencies); + $iNextLoopCount=count($aUnresolvedDependencyModules); while(($iNextLoopCount!=$iPreviousLoopDepencyCount) //stop loop when no new dependency is resolved && ($iNextLoopCount > 0) //still remaining dependencies ) { + $iLoopCount++; $iPreviousLoopDepencyCount=$iNextLoopCount; - foreach($aOngoingDependencies as $sId => $aCurrentRemainingDeps) + foreach($aUnresolvedDependencyModules as $sModuleId => $oModule) { - $aNextDependencies=[]; - $bDependenciesSolved = true; - foreach($aCurrentRemainingDeps as $sDepId) - { - if (!self::DependencyIsResolved($sDepId, $aOrderedModules, $aSelectedModules)) - { - $aNextDependencies[]=$sDepId; - $bDependenciesSolved = false; - } + /** @var Module $oModule */ + if ($oModule->IsModuleResolved($aModuleVersions, $aSelectedModules)){ + $aOrderedModules[] = $sModuleId; + $aModuleVersions[$oModule->GetModuleName()] = $oModule->GetVersion(); + unset($aUnresolvedDependencyModules[$sModuleId]); } - if ($bDependenciesSolved) - { - $aOrderedModules[] = $sId; - unset($aDependencies[$sId]); - unset($aOngoingDependencies[$sId]); - continue; - } - - $aOngoingDependencies[$sId]=$aNextDependencies; } - $iNextLoopCount=count($aOngoingDependencies); - self::SortModulesByCountOfDepencenciesDescending($aOngoingDependencies); + $iNextLoopCount=count($aUnresolvedDependencyModules); + self::SortModulesByCountOfDepencenciesDescending($aUnresolvedDependencyModules); } - if ($bAbortOnMissingDependency && count($aOngoingDependencies) > 0) + + if ($bAbortOnMissingDependency && count($aUnresolvedDependencyModules) > 0) { - self::SortModulesByCountOfDepencenciesDescending($aOngoingDependencies); + self::SortModulesByCountOfDepencenciesDescending($aUnresolvedDependencyModules); $aModulesInfo = []; $aModuleDeps = []; - foreach($aOngoingDependencies as $sId => $aCurrentRemainingDeps) + /** @var Module $oModule */ + foreach($aUnresolvedDependencyModules as $sModuleId => $oModule) { - $aModule = $aModules[$sId]; + $aModule = $aModules[$sModuleId]; $aDepsWithIcons = []; - $aDeps=$aDependencies[$sId]; - foreach($aDeps as $sIndex => $sDepId) + foreach($oModule->aAllDependencies as $sIndex => $sDepId) { - if (in_array($sDepId, $aCurrentRemainingDeps)) + if (array_key_exists($sDepId, $oModule->aOngoingDependencies)) { $aDepsWithIcons[$sIndex] = '❌ ' . $sDepId; } else @@ -308,8 +546,8 @@ class ModuleDiscovery $aDepsWithIcons[$sIndex] = '✅ ' . $sDepId; } } - $aModuleDeps[] = "{$aModule['label']} (id: $sId) depends on: ".implode(' + ', $aDepsWithIcons); - $aModulesInfo[$sId] = array('module' => $aModule, 'dependencies' => $aDepsWithIcons); + $aModuleDeps[] = "{$aModule['label']} (id: $sModuleId) depends on: ".implode(' + ', $aDepsWithIcons); + $aModulesInfo[$sModuleId] = array('module' => $aModule, 'dependencies' => $aDepsWithIcons); } $sMessage = "The following modules have unmet dependencies:\n".implode(",\n", $aModuleDeps); $oException = new MissingDependencyException($sMessage); @@ -318,9 +556,9 @@ class ModuleDiscovery } // Return the ordered list, so that the dependencies are met... $aResult = array(); - foreach($aOrderedModules as $sId) + foreach($aOrderedModules as $sModuleId) { - $aResult[$sId] = $aModules[$sId]; + $aResult[$sModuleId] = $aModules[$sModuleId]; } return $aResult; } diff --git a/tests/php-unit-tests/unitary-tests/setup/ModuleDependencyTest.php b/tests/php-unit-tests/unitary-tests/setup/ModuleDependencyTest.php new file mode 100644 index 000000000..13d668b71 --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/setup/ModuleDependencyTest.php @@ -0,0 +1,138 @@ +RequireOnceItopFile('setup/modulediscovery.class.inc.php'); + } + + public function testModuleDependencyInit_Invalid() + { + $oModuleDependency = new \ModuleDependency('||'); + $this->assertEquals(true, $this->GetNonPublicProperty($oModuleDependency, 'bAlwaysUnresolved')); + } + + public function testModuleDependencyInit() + { + $oModuleDependency = new \ModuleDependency('itop-config-mgmt/2.4.0'); + $this->assertEquals(['itop-config-mgmt/2.4.0' => [ 'itop-config-mgmt', '>=', '2.4.0']], $this->GetNonPublicProperty($oModuleDependency, 'aParamsPerModuleId')); + $this->assertEquals(false, $this->GetNonPublicProperty($oModuleDependency, 'bAlwaysUnresolved')); + $this->assertEquals(['itop-config-mgmt'], $oModuleDependency->GetPotentialPrerequisites()); + } + + public static function WithOperatorProvider() + { + $aUsecases=[]; + foreach (['>', '>=', '<', '<='] as $sOperator){ + $aUsecases[$sOperator]=[$sOperator]; + } + return $aUsecases; + } + + /** + * @dataProvider WithOperatorProvider + */ + public function testModuleDependencyInit_WithOperator($sOperator) + { + $sDepId = "itop-config-mgmt/{$sOperator}2.4.0"; + $oModuleDependency = new \ModuleDependency($sDepId); + $this->assertEquals([$sDepId => [ 'itop-config-mgmt', $sOperator, '2.4.0']], $this->GetNonPublicProperty($oModuleDependency, 'aParamsPerModuleId')); + $this->assertEquals(false, $this->GetNonPublicProperty($oModuleDependency, 'bAlwaysUnresolved')); + $this->assertEquals(['itop-config-mgmt'], $oModuleDependency->GetPotentialPrerequisites()); + } + + public static function WithOperatorOperand() + { + $aUsecases=[]; + foreach (['&&', '||'] as $sOperand){ + $aUsecases[$sOperand]=[$sOperand, "itop-structure/3.0.0 $sOperand itop-portal/<3.2.1"]; + $aUsecases["$sOperand + parenthesis"]=[$sOperand, "(itop-structure/3.0.0 $sOperand itop-portal/<3.2.1)"]; + } + return $aUsecases; + } + + /** + * @dataProvider WithOperatorOperand + */ + public function testModuleDependencyInit_WithOperand($sOperand, $sDepId) + { + $sDepId = "itop-structure/3.0.0 $sOperand itop-portal/<3.2.1"; + $oModuleDependency = new \ModuleDependency($sDepId); + $this->assertEquals(['itop-structure/3.0.0' => [ 'itop-structure', ">=", '3.0.0'], 'itop-portal/<3.2.1' => [ 'itop-portal', "<", '3.2.1']], $this->GetNonPublicProperty($oModuleDependency, 'aParamsPerModuleId')); + $this->assertEquals(false, $this->GetNonPublicProperty($oModuleDependency, 'bAlwaysUnresolved')); + $this->assertEquals(['itop-structure', 'itop-portal'], $oModuleDependency->GetPotentialPrerequisites()); + } + + public function testModuleIsDependencyResolved_SimpleCase_UnresolvedDueToMissingModule() + { + $oModuleDependency = new \ModuleDependency('itop-config-mgmt/2.4.0'); + $this->assertEquals(false, $oModuleDependency->IsDependencyResolved([], ['itop-config-mgmt' => true])); + } + + public function testModuleIsDependencyResolved_SimpleCase_UnresolvedDueToWrongModuleVersion() + { + $oModuleDependency = new \ModuleDependency('itop-config-mgmt/2.4.0'); + $this->assertEquals(false, $oModuleDependency->IsDependencyResolved(['itop-config-mgmt' => '1.2.3'], ['itop-config-mgmt' => true])); + } + + public function testModuleIsDependencyResolved_SimpleCase_Resolved() + { + $oModuleDependency = new \ModuleDependency('itop-config-mgmt/2.4.0'); + $this->assertEquals(['itop-config-mgmt'], $oModuleDependency->GetPotentialPrerequisites()); + $this->assertEquals(true, $oModuleDependency->IsDependencyResolved(['itop-config-mgmt' => '2.4.1'], ['itop-config-mgmt' => true])); + $this->assertEquals([], $oModuleDependency->GetPotentialPrerequisites()); + } + + public function testIsDependencyResolved_AndOperand_UnresolvedDueToMissingModule() + { + $sDepId = "itop-structure/3.0.0 && itop-portal/3.2.1"; + $oModuleDependency = new \ModuleDependency($sDepId); + $this->assertEquals(['itop-structure/3.0.0' => [ 'itop-structure', ">=", '3.0.0'], 'itop-portal/3.2.1' => [ 'itop-portal', ">=", '3.2.1']], $this->GetNonPublicProperty($oModuleDependency, 'aParamsPerModuleId')); + $this->assertEquals(false, $this->GetNonPublicProperty($oModuleDependency, 'bAlwaysUnresolved')); + $this->assertEquals(['itop-structure', 'itop-portal'], $oModuleDependency->GetPotentialPrerequisites()); + + $this->assertEquals(false, $oModuleDependency->IsDependencyResolved(['itop-structure' => '3.0.0'], ['itop-structure' => true, 'itop-portal' => true])); + $this->assertEquals(['itop-portal'], $oModuleDependency->GetPotentialPrerequisites()); + } + + public function testIsDependencyResolved_AndOperand_UnresolvedDueToWrongModuleVersion() + { + $sDepId = "itop-structure/3.0.0 && itop-portal/3.2.1"; + $oModuleDependency = new \ModuleDependency($sDepId); + $this->assertEquals(['itop-structure/3.0.0' => [ 'itop-structure', ">=", '3.0.0'], 'itop-portal/3.2.1' => [ 'itop-portal', ">=", '3.2.1']], $this->GetNonPublicProperty($oModuleDependency, 'aParamsPerModuleId')); + $this->assertEquals(false, $this->GetNonPublicProperty($oModuleDependency, 'bAlwaysUnresolved')); + $this->assertEquals(['itop-structure', 'itop-portal'], $oModuleDependency->GetPotentialPrerequisites()); + + $this->assertEquals(false, $oModuleDependency->IsDependencyResolved(['itop-structure' => '3.0.0', 'itop-portal' => '1.0.0'], ['itop-structure' => true, 'itop-portal' => true])); + $this->assertEquals(['itop-portal'], $oModuleDependency->GetPotentialPrerequisites()); + } + + public function testIsDependencyResolved_AndOperand_Resolved() + { + $sDepId = "itop-structure/3.0.0 && itop-portal/3.2.1"; + $oModuleDependency = new \ModuleDependency($sDepId); + $this->assertEquals(['itop-structure/3.0.0' => [ 'itop-structure', ">=", '3.0.0'], 'itop-portal/3.2.1' => [ 'itop-portal', ">=", '3.2.1']], $this->GetNonPublicProperty($oModuleDependency, 'aParamsPerModuleId')); + $this->assertEquals(false, $this->GetNonPublicProperty($oModuleDependency, 'bAlwaysUnresolved')); + $this->assertEquals(['itop-structure', 'itop-portal'], $oModuleDependency->GetPotentialPrerequisites()); + + $this->assertEquals(false, $oModuleDependency->IsDependencyResolved(['itop-structure' => '3.0.0'], ['itop-structure' => true])); + $this->assertEquals(['itop-portal'], $oModuleDependency->GetPotentialPrerequisites()); + } + + public function testIsDependencyResolved_OrOperand_ResolvedDueToMissingModule() + { + $sDepId = "itop-structure/3.0.0 || itop-portal/3.2.1"; + $oModuleDependency = new \ModuleDependency($sDepId); + $this->assertEquals(['itop-structure/3.0.0' => [ 'itop-structure', ">=", '3.0.0'], 'itop-portal/3.2.1' => [ 'itop-portal', ">=", '3.2.1']], $this->GetNonPublicProperty($oModuleDependency, 'aParamsPerModuleId')); + $this->assertEquals(false, $this->GetNonPublicProperty($oModuleDependency, 'bAlwaysUnresolved')); + $this->assertEquals(['itop-structure', 'itop-portal'], $oModuleDependency->GetPotentialPrerequisites()); + + $this->assertEquals(true, $oModuleDependency->IsDependencyResolved(['itop-structure' => '3.0.0'], ['itop-structure' => true])); + $this->assertEquals(['itop-portal'], $oModuleDependency->GetPotentialPrerequisites()); + } +} \ No newline at end of file diff --git a/tests/php-unit-tests/unitary-tests/setup/ModuleDiscoveryTest.php b/tests/php-unit-tests/unitary-tests/setup/ModuleDiscoveryTest.php index cc42e3c08..80ac469e2 100644 --- a/tests/php-unit-tests/unitary-tests/setup/ModuleDiscoveryTest.php +++ b/tests/php-unit-tests/unitary-tests/setup/ModuleDiscoveryTest.php @@ -2,45 +2,18 @@ namespace Combodo\iTop\Test\UnitTest\Setup; -use Combodo\iTop\Test\UnitTest\ItopTestCase; +use Combodo\iTop\Test\UnitTest\ItopDataTestCase; +use ModuleDiscovery; -class ModuleDiscoveryTest extends ItopTestCase +class ModuleDiscoveryTest extends ItopDataTestCase { public function setUp(): void { parent::setUp(); $this->RequireOnceItopFile('setup/modulediscovery.class.inc.php'); } - public function testSortModulesByCountOfDepencenciesDescending() - { - $aOngoingDependencies=[]; - $aExpectedKeys=[]; - for($i=5; $i>0; $i--){ - $sKey = "k$i"; - $aExpectedKeys[]=$sKey; - $aDeps=[]; - for ($j=0; $j<$i; $j++){ - $aDeps[]=$j; - } - $aOngoingDependencies[$sKey]=$aDeps; - } - sort($aExpectedKeys); - - \ModuleDiscovery::SortModulesByCountOfDepencenciesDescending($aOngoingDependencies); - - $this->assertEquals($aExpectedKeys, array_keys($aOngoingDependencies)); - } - public function testOrderModulesByDependencies_CheckMissingDependenciesAreCorrectlyOrderedInTheException() { - $sExpectedMessage = <<expectExceptionMessage($sExpectedMessage); - $aModules=[ "id1/123" => [ 'dependencies' => [ 'id3/666', 'id4/666'], @@ -51,34 +24,115 @@ MSG; 'label' => 'label2', ], ]; - \ModuleDiscovery::OrderModulesByDependencies($aModules, true); + $iLoopCount=0; + try{ + ModuleDiscovery::OrderModulesByDependencies($aModules, true, null, $iLoopCount); + } catch(\MissingDependencyException $e){ + $sExpectedMessage = <<assertEquals($sExpectedMessage, $e->getMessage()); + $this->assertEquals(1, $iLoopCount); + } } public function testOrderModulesByDependencies_ValidateExceptionWithSomeDependenciesResolved() { - $sExpectedMessage = <<expectExceptionMessage($sExpectedMessage); - $aModules=[ "id1/123" => [ - 'dependencies' => [ 'id2/456', 'id4/666'], + 'dependencies' => [ 'id2/456', 'id4/666', 'id3/789'], 'label' => 'label1', ], "id2/456" => [ 'dependencies' => [], 'label' => 'label2', ], + "id3/789" => [ + 'dependencies' => [ 'id2/456', 'id4/666'], + 'label' => 'label3', + ], ]; - \ModuleDiscovery::OrderModulesByDependencies($aModules, true); + $iLoopCount=0; + try{ + ModuleDiscovery::OrderModulesByDependencies($aModules, true, null, $iLoopCount); + } catch(\MissingDependencyException $e){ + $sExpectedMessage = <<assertEquals($sExpectedMessage, $e->getMessage()); + $this->assertEquals(2, $iLoopCount); + } + } + + public function testOrderModulesByDependencies_KeepGoingEvenWithFailure_WithSomeDependenciesResolved() + { + $aModules=[ + "id1/123" => [ + 'dependencies' => [ 'id2/456', 'id4/666', 'id3/789'], + 'label' => 'label1', + ], + "id2/456" => [ + 'dependencies' => [], + 'label' => 'label2', + ], + "id3/789" => [ + 'dependencies' => [ 'id2/456', 'id4/666'], + 'label' => 'label3', + ], + ]; + $iLoopCount=0; + $aResult = ModuleDiscovery::OrderModulesByDependencies($aModules, false, null, $iLoopCount); + + $aExpected = [ + 'id2/456' + ]; + $this->assertEquals($aExpected, array_keys($aResult)); + $this->assertEquals(2, $iLoopCount); + } + + public function testOrderModulesByDependencies_UnResolveWithCircularDependency() + { + $aModules=[ + "id1/1" => [ + 'dependencies' => [ 'id2/2'], + 'label' => 'label1', + ], + "id2/2" => [ + 'dependencies' => ['id3/3'], + 'label' => 'label2', + ], + "id3/3" => [ + 'dependencies' => ['id4/4'], + 'label' => 'label3', + ], + "id4/4" => [ + 'dependencies' => ['id1/1'], + 'label' => 'label4', + ], + ]; + $iLoopCount=0; + + try{ + ModuleDiscovery::OrderModulesByDependencies($aModules, true, null, $iLoopCount); + } catch(\MissingDependencyException $e){ + $sExpectedMessage = <<assertEquals($sExpectedMessage, $e->getMessage()); + $this->assertEquals(1, $iLoopCount); + } } public function testOrderModulesByDependencies_ResolveOk() { - $aModules=[ "id1/1" => [ 'dependencies' => [ 'id2/2'], @@ -97,7 +151,8 @@ MSG; 'label' => 'label4', ], ]; - $aResult = \ModuleDiscovery::OrderModulesByDependencies($aModules, true); + $iLoopCount=0; + $aResult = ModuleDiscovery::OrderModulesByDependencies($aModules, true, null, $iLoopCount); $aExpected = [ "id4/4", @@ -106,5 +161,79 @@ MSG; "id1/1", ]; $this->assertEquals($aExpected, array_keys($aResult)); + $this->assertEquals(1, $iLoopCount); + } + + public function testOrderModulesByDependencies_RealExample(){ + $aModules = json_decode(file_get_contents(__DIR__ . '/ressources/module_deps.json'), true); + $iLoopCount=0; + $aResult = ModuleDiscovery::OrderModulesByDependencies($aModules, true, null, $iLoopCount); + + $aExpected = json_decode(file_get_contents(__DIR__ . '/ressources/expected_ordered_module_ids.json'), true); + $this->assertEquals($aExpected, array_keys($aResult)); + $this->assertEquals(1, $iLoopCount); + } + + public function testSortModulesByCountOfDepencenciesDescending_NoDependencies(){ + $aUnresolvedDependencyModules = []; + foreach (['a', 'b', 'c'] as $sModuleId){ + $this->AddModule($aUnresolvedDependencyModules, $sModuleId, []); + } + ModuleDiscovery::SortModulesByCountOfDepencenciesDescending($aUnresolvedDependencyModules); + $this->assertEquals(['a', 'b', 'c'], array_keys($aUnresolvedDependencyModules)); + } + + public function testSortModulesByCountOfDepencenciesDescending_NominalUseCase(){ + $aUnresolvedDependencyModules = []; + $this->AddModule($aUnresolvedDependencyModules, 'itop-change-mgmt/456', ['itop-config-mgmt/2.2.0', 'itop-tickets/2.0.0']); + $this->AddModule($aUnresolvedDependencyModules, 'itop-tickets/2.0.0', ['itop-structure/2.7.1']); + $this->AddModule($aUnresolvedDependencyModules, 'itop-config-mgmt/123', ['itop-structure/2.7.1']); + $this->AddModule($aUnresolvedDependencyModules, 'itop-structure/2.7.1', []); + + ModuleDiscovery::SortModulesByCountOfDepencenciesDescending($aUnresolvedDependencyModules); + $this->assertEquals( + [ + 'itop-structure/2.7.1', + 'itop-tickets/2.0.0', + 'itop-config-mgmt/123', + 'itop-change-mgmt/456', + ], array_keys($aUnresolvedDependencyModules)); + } + + public function testSortModulesByCountOfDepencenciesDescending_NominalUseCaseWithMissingDependency(){ + $aUnresolvedDependencyModules = []; + $this->AddModule($aUnresolvedDependencyModules, 'itop-change-mgmt/456', ['itop-config-mgmt/2.2.0', 'itop-tickets/2.0.0']); + $this->AddModule($aUnresolvedDependencyModules, 'itop-tickets/2.0.0', ['itop-structure/2.7.1']); + $this->AddModule($aUnresolvedDependencyModules, 'itop-config-mgmt/123', ['itop-structure/2.7.1']); + + ModuleDiscovery::SortModulesByCountOfDepencenciesDescending($aUnresolvedDependencyModules); + $this->assertEquals( + [ + 'itop-tickets/2.0.0', + 'itop-config-mgmt/123', + 'itop-change-mgmt/456', + ], + array_keys($aUnresolvedDependencyModules)); + } + + private function AddModule(array &$aUnresolvedDependencyModules, string $sModuleId, array $aDeps){ + $oModule = new \Module($sModuleId); + $oModule->SetDependencies($aDeps); + $aUnresolvedDependencyModules[$sModuleId]= $oModule; + } + + public function testSortModulesByCountOfDepencenciesDescending_RealExample(){ + $aUnresolvedDependencyModules = []; + $aDependencies = json_decode(file_get_contents(__DIR__ . '/ressources/module_deps.json'), true); + foreach ($aDependencies as $sModuleId => $aModuleData){ + $this->AddModule($aUnresolvedDependencyModules, $sModuleId, $aModuleData['dependencies']); + } + + ModuleDiscovery::SortModulesByCountOfDepencenciesDescending($aUnresolvedDependencyModules); + + $aExpected = json_decode(file_get_contents(__DIR__ . '/ressources/expected_ordered_module_ids.json'), true); + $this->assertEquals( + $aExpected, + array_keys($aUnresolvedDependencyModules)); } } \ No newline at end of file diff --git a/tests/php-unit-tests/unitary-tests/setup/ModuleTest.php b/tests/php-unit-tests/unitary-tests/setup/ModuleTest.php new file mode 100644 index 000000000..c274477d6 --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/setup/ModuleTest.php @@ -0,0 +1,80 @@ +RequireOnceItopFile('setup/modulediscovery.class.inc.php'); + } + + public function testModuleInit() + { + $oModule = new \Module("itop-config-mgmt/2.4.0"); + $this->assertEquals("itop-config-mgmt", $oModule->GetModuleName()); + $this->assertEquals("2.4.0", $oModule->GetVersion()); + $this->assertEquals("itop-config-mgmt/2.4.0", $oModule->GetModuleId()); + } + + public function testModuleInit_NoVersion() + { + $oModule = new \Module("itop-config-mgmt"); + $this->assertEquals("itop-config-mgmt", $oModule->GetModuleName()); + $this->assertEquals("1.0.0", $oModule->GetVersion()); + $this->assertEquals("itop-config-mgmt", $oModule->GetModuleId()); + } + + public function testIsResolved_Unresolved() + { + $oModule = new \Module("itop-bridge-cmdb-ticket"); + $oModule->SetDependencies(['itop-config-mgmt/2.7.1', 'itop-tickets/2.7.0']); + $this->assertEquals(['itop-config-mgmt', 'itop-tickets'], $oModule->GetUnresolvedDependencyModuleNames()); + + $this->assertEquals(false, $oModule->IsModuleResolved([],[])); + $this->assertEquals(['itop-config-mgmt', 'itop-tickets'], $oModule->GetUnresolvedDependencyModuleNames()); + $this->assertEquals(['itop-config-mgmt/2.7.1', 'itop-tickets/2.7.0'], array_keys($oModule->aOngoingDependencies)); + } + + public function testSetDependencies() + { + $oModule = new \Module("itop-bridge-datacenter-mgmt-services"); + $oModule->SetDependencies([ + 'itop-config-mgmt/2.7.1', + 'itop-service-mgmt/2.7.1 || itop-service-mgmt-provider/2.7.1', + 'itop-datacenter-mgmt/3.1.0', + ]); + $this->assertEquals(['itop-config-mgmt', 'itop-service-mgmt', 'itop-service-mgmt-provider', 'itop-datacenter-mgmt' ], + $oModule->GetUnresolvedDependencyModuleNames()); + + $this->assertEquals(false, $oModule->IsModuleResolved([],[])); + $this->assertEquals(['itop-config-mgmt', 'itop-service-mgmt', 'itop-service-mgmt-provider', 'itop-datacenter-mgmt'], + $oModule->GetUnresolvedDependencyModuleNames()); + $this->assertEquals(['itop-config-mgmt/2.7.1', 'itop-service-mgmt/2.7.1 || itop-service-mgmt-provider/2.7.1', 'itop-datacenter-mgmt/3.1.0'], + array_keys($oModule->aOngoingDependencies)); + } + + public function testIsResolved_PartialResolution() + { + $oModule = new \Module("itop-bridge-cmdb-ticket"); + $oModule->SetDependencies(['itop-config-mgmt/2.7.1', 'itop-tickets/2.7.0']); + $this->assertEquals(['itop-config-mgmt', 'itop-tickets'], $oModule->GetUnresolvedDependencyModuleNames()); + + $this->assertEquals(false, $oModule->IsModuleResolved(['itop-config-mgmt' => '2.7.1'],['itop-config-mgmt'=>true])); + $this->assertEquals(['itop-tickets'], $oModule->GetUnresolvedDependencyModuleNames()); + $this->assertEquals(['itop-tickets/2.7.0'], array_keys($oModule->aOngoingDependencies)); + } + + public function testIsResolved_OK() + { + $oModule = new \Module("itop-bridge-cmdb-ticket"); + $oModule->SetDependencies(['itop-config-mgmt/2.7.1', 'itop-tickets/2.7.0']); + $this->assertEquals(['itop-config-mgmt', 'itop-tickets'], $oModule->GetUnresolvedDependencyModuleNames()); + + $this->assertEquals(true, $oModule->IsModuleResolved(['itop-config-mgmt' => '2.7.1', 'itop-tickets' => '2.7.0'],['itop-config-mgmt'=>true, 'itop-tickets' => true])); + $this->assertEquals([], $oModule->GetUnresolvedDependencyModuleNames()); + $this->assertEquals([], array_keys($oModule->aOngoingDependencies)); + } +} \ No newline at end of file diff --git a/tests/php-unit-tests/unitary-tests/setup/ressources/expected_ordered_module_ids.json b/tests/php-unit-tests/unitary-tests/setup/ressources/expected_ordered_module_ids.json new file mode 100644 index 000000000..eb0b175de --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/setup/ressources/expected_ordered_module_ids.json @@ -0,0 +1,49 @@ +[ + "authent-cas\/3.2.1", + "authent-external\/3.2.1", + "authent-ldap\/3.2.1", + "authent-local\/3.2.1", + "combodo-backoffice-darkmoon-theme\/3.2.1", + "combodo-backoffice-fullmoon-high-contrast-theme\/3.2.1", + "combodo-backoffice-fullmoon-protanopia-deuteranopia-theme\/3.2.1", + "combodo-backoffice-fullmoon-tritanopia-theme\/3.2.1", + "itop-attachments\/3.2.1", + "itop-backup\/3.2.1", + "itop-config\/3.2.1", + "itop-files-information\/3.2.1", + "itop-portal-base\/3.2.1", + "itop-profiles-itil\/3.2.1", + "itop-sla-computation\/3.2.1", + "itop-structure\/3.2.1", + "itop-welcome-itil\/3.2.1", + "itop-portal\/3.2.1", + "combodo-db-tools\/3.2.1", + "itop-config-mgmt\/3.2.1", + "itop-themes-compat\/3.2.1", + "itop-tickets\/3.2.1", + "itop-oauth-client\/3.2.1", + "itop-datacenter-mgmt\/3.2.1", + "itop-endusers-devices\/3.2.1", + "itop-hub-connector\/3.2.1", + "itop-knownerror-mgmt\/3.2.1", + "itop-storage-mgmt\/3.2.1", + "itop-virtualization-mgmt\/3.2.1", + "itop-problem-mgmt\/3.2.1", + "itop-request-mgmt-itil\/3.2.1", + "itop-request-mgmt\/3.2.1", + "itop-service-mgmt-provider\/3.2.1", + "itop-service-mgmt\/3.2.1", + "itop-faq-light\/3.2.1", + "itop-core-update\/3.2.1", + "itop-bridge-cmdb-ticket\/3.2.1", + "itop-change-mgmt-itil\/3.2.1", + "itop-change-mgmt\/3.2.1", + "itop-bridge-virtualization-storage\/3.2.1", + "itop-incident-mgmt-itil\/3.2.1", + "itop-full-itil\/3.2.1", + "itop-bridge-cmdb-services\/3.2.1", + "itop-bridge-datacenter-mgmt-services\/3.2.1", + "itop-bridge-endusers-devices-services\/3.2.1", + "itop-bridge-storage-mgmt-services\/3.2.1", + "itop-bridge-virtualization-mgmt-services\/3.2.1" +] \ No newline at end of file diff --git a/tests/php-unit-tests/unitary-tests/setup/ressources/module_deps.json b/tests/php-unit-tests/unitary-tests/setup/ressources/module_deps.json new file mode 100644 index 000000000..02946914b --- /dev/null +++ b/tests/php-unit-tests/unitary-tests/setup/ressources/module_deps.json @@ -0,0 +1,267 @@ +{ + "authent-cas\/3.2.1": { + "label": "CAS SSO", + "dependencies": [] + }, + "authent-external\/3.2.1": { + "label": "External user authentication", + "dependencies": [] + }, + "authent-ldap\/3.2.1": { + "label": "User authentication based on LDAP", + "dependencies": [] + }, + "authent-local\/3.2.1": { + "label": "User authentication based on the local DB", + "dependencies": [] + }, + "combodo-backoffice-darkmoon-theme\/3.2.1": { + "label": "Backoffice: Darkmoon theme", + "dependencies": [] + }, + "combodo-backoffice-fullmoon-high-contrast-theme\/3.2.1": { + "label": "Backoffice: Fullmoon with high contrast accessibility theme", + "dependencies": [] + }, + "combodo-backoffice-fullmoon-protanopia-deuteranopia-theme\/3.2.1": { + "label": "Backoffice: Fullmoon with protonopia & deuteranopia accessibility theme", + "dependencies": [] + }, + "combodo-backoffice-fullmoon-tritanopia-theme\/3.2.1": { + "label": "Backoffice: Fullmoon with tritanopia accessibility theme", + "dependencies": [] + }, + "combodo-db-tools\/3.2.1": { + "label": "Database maintenance tools", + "dependencies": [ + "itop-structure\/3.0.0" + ] + }, + "itop-attachments\/3.2.1": { + "label": "Tickets Attachments", + "dependencies": [] + }, + "itop-backup\/3.2.1": { + "label": "Backup utilities", + "dependencies": [] + }, + "itop-bridge-cmdb-services\/3.2.1": { + "label": "Bridge for CMDB and Services", + "dependencies": [ + "itop-config-mgmt\/2.7.1", + "itop-service-mgmt\/2.7.1 || itop-service-mgmt-provider\/2.7.1" + ] + }, + "itop-bridge-cmdb-ticket\/3.2.1": { + "label": "Bridge for CMDB and Ticket", + "dependencies": [ + "itop-config-mgmt\/2.7.1", + "itop-tickets\/2.7.0" + ] + }, + "itop-bridge-datacenter-mgmt-services\/3.2.1": { + "label": "Bridge for CMDB Virtualization objects and Services", + "dependencies": [ + "itop-config-mgmt\/2.7.1", + "itop-service-mgmt\/2.7.1 || itop-service-mgmt-provider\/2.7.1", + "itop-datacenter-mgmt\/3.1.0" + ] + }, + "itop-bridge-endusers-devices-services\/3.2.1": { + "label": "Bridge for CMDB endusers objects and Services", + "dependencies": [ + "itop-config-mgmt\/2.7.1", + "itop-service-mgmt\/2.7.1 || itop-service-mgmt-provider\/2.7.1", + "itop-endusers-devices\/3.1.0" + ] + }, + "itop-bridge-storage-mgmt-services\/3.2.1": { + "label": "Bridge for CMDB Virtualization objects and Services", + "dependencies": [ + "itop-config-mgmt\/2.7.1", + "itop-service-mgmt\/2.7.1 || itop-service-mgmt-provider\/2.7.1", + "itop-storage-mgmt\/3.1.0" + ] + }, + "itop-bridge-virtualization-mgmt-services\/3.2.1": { + "label": "Bridge for CMDB Virtualization objects and Services", + "dependencies": [ + "itop-config-mgmt\/2.7.1", + "itop-service-mgmt\/2.7.1 || itop-service-mgmt-provider\/2.7.1", + "itop-virtualization-mgmt\/3.1.0" + ] + }, + "itop-bridge-virtualization-storage\/3.2.1": { + "label": "Links between virtualization and storage", + "dependencies": [ + "itop-storage-mgmt\/2.2.0", + "itop-virtualization-mgmt\/2.2.0" + ] + }, + "itop-change-mgmt-itil\/3.2.1": { + "label": "Change Management ITIL", + "dependencies": [ + "itop-config-mgmt\/2.2.0", + "itop-tickets\/2.0.0" + ] + }, + "itop-change-mgmt\/3.2.1": { + "label": "Change Management", + "dependencies": [ + "itop-config-mgmt\/2.2.0", + "itop-tickets\/2.0.0" + ] + }, + "itop-config-mgmt\/3.2.1": { + "label": "Configuration Management (CMDB)", + "dependencies": [ + "itop-structure\/2.7.1" + ] + }, + "itop-config\/3.2.1": { + "label": "Configuration editor", + "dependencies": [] + }, + "itop-core-update\/3.2.1": { + "label": "iTop Core Update", + "dependencies": [ + "itop-files-information\/2.7.0", + "combodo-db-tools\/2.7.0" + ] + }, + "itop-datacenter-mgmt\/3.2.1": { + "label": "Datacenter Management", + "dependencies": [ + "itop-config-mgmt\/2.2.0" + ] + }, + "itop-endusers-devices\/3.2.1": { + "label": "End-user Devices Management", + "dependencies": [ + "itop-config-mgmt\/2.2.0" + ] + }, + "itop-faq-light\/3.2.1": { + "label": "Frequently Asked Questions Database", + "dependencies": [ + "itop-structure\/3.0.0 || itop-portal\/3.0.0" + ] + }, + "itop-files-information\/3.2.1": { + "label": "iTop files information", + "dependencies": [] + }, + "itop-full-itil\/3.2.1": { + "label": "Bridge - Request management ITIL + Incident management ITIL", + "dependencies": [ + "itop-request-mgmt-itil\/2.3.0", + "itop-incident-mgmt-itil\/2.3.0" + ] + }, + "itop-hub-connector\/3.2.1": { + "label": "iTop Hub Connector", + "dependencies": [ + "itop-config-mgmt\/2.4.0" + ] + }, + "itop-incident-mgmt-itil\/3.2.1": { + "label": "Incident Management ITIL", + "dependencies": [ + "itop-config-mgmt\/2.4.0", + "itop-tickets\/2.4.0", + "itop-profiles-itil\/2.3.0" + ] + }, + "itop-knownerror-mgmt\/3.2.1": { + "label": "Known Errors Database", + "dependencies": [ + "itop-config-mgmt\/2.2.0" + ] + }, + "itop-oauth-client\/3.2.1": { + "label": "OAuth 2.0 client", + "dependencies": [ + "itop-welcome-itil\/3.1.0," + ] + }, + "itop-portal-base\/3.2.1": { + "label": "Portal Development Library", + "dependencies": [] + }, + "itop-portal\/3.2.1": { + "label": "Enhanced Customer Portal", + "dependencies": [ + "itop-portal-base\/2.7.0" + ] + }, + "itop-problem-mgmt\/3.2.1": { + "label": "Problem Management", + "dependencies": [ + "itop-tickets\/2.0.0" + ] + }, + "itop-profiles-itil\/3.2.1": { + "label": "Create standard ITIL profiles", + "dependencies": [] + }, + "itop-request-mgmt-itil\/3.2.1": { + "label": "User request Management ITIL", + "dependencies": [ + "itop-tickets\/2.4.0" + ] + }, + "itop-request-mgmt\/3.2.1": { + "label": "Simple Ticket Management", + "dependencies": [ + "itop-tickets\/2.4.0" + ] + }, + "itop-service-mgmt-provider\/3.2.1": { + "label": "Service Management for Service Providers", + "dependencies": [ + "itop-tickets\/2.0.0" + ] + }, + "itop-service-mgmt\/3.2.1": { + "label": "Service Management", + "dependencies": [ + "itop-tickets\/2.0.0" + ] + }, + "itop-sla-computation\/3.2.1": { + "label": "SLA Computation", + "dependencies": [] + }, + "itop-storage-mgmt\/3.2.1": { + "label": "Advanced Storage Management", + "dependencies": [ + "itop-config-mgmt\/2.4.0" + ] + }, + "itop-structure\/3.2.1": { + "label": "Core iTop Structure", + "dependencies": [] + }, + "itop-themes-compat\/3.2.1": { + "label": "Light grey and Test red themes compatibility", + "dependencies": [ + "itop-structure\/3.1.0" + ] + }, + "itop-tickets\/3.2.1": { + "label": "Tickets Management", + "dependencies": [ + "itop-structure\/2.7.1" + ] + }, + "itop-virtualization-mgmt\/3.2.1": { + "label": "Virtualization Management", + "dependencies": [ + "itop-config-mgmt\/2.4.0" + ] + }, + "itop-welcome-itil\/3.2.1": { + "label": "ITIL skin", + "dependencies": [] + } +} \ No newline at end of file