diff --git a/bin/moodle-plugin-ci b/bin/moodle-plugin-ci index ce3b1598..0bfd5de6 100755 --- a/bin/moodle-plugin-ci +++ b/bin/moodle-plugin-ci @@ -21,6 +21,7 @@ use MoodlePluginCI\Command\CoverallsUploadCommand; use MoodlePluginCI\Command\GruntCommand; use MoodlePluginCI\Command\InstallCommand; use MoodlePluginCI\Command\MessDetectorCommand; +use MoodlePluginCI\Command\MissingStringsCommand; use MoodlePluginCI\Command\MustacheCommand; use MoodlePluginCI\Command\ParallelCommand; use MoodlePluginCI\Command\PHPDocCommand; @@ -89,6 +90,7 @@ $application->add(new CoverallsUploadCommand()); $application->add(new GruntCommand()); $application->add(new InstallCommand(ENV_FILE)); $application->add(new MessDetectorCommand()); +$application->add(new MissingStringsCommand()); $application->add(new MustacheCommand()); $application->add(new ParallelCommand()); $application->add(new PHPDocCommand()); diff --git a/src/Command/MissingStringsCommand.php b/src/Command/MissingStringsCommand.php new file mode 100644 index 00000000..4ba87bb7 --- /dev/null +++ b/src/Command/MissingStringsCommand.php @@ -0,0 +1,206 @@ +setName('missingstrings') + ->setAliases(['missing-strings']) + ->setDescription('Find missing language strings in a plugin') + ->addOption( + 'lang', + 'l', + InputOption::VALUE_REQUIRED, + 'Language to validate against', + 'en' + ) + ->addOption( + 'strict', + null, + InputOption::VALUE_NONE, + 'Strict mode - treat warnings as errors' + ) + ->addOption( + 'unused', + 'u', + InputOption::VALUE_NONE, + 'Report unused strings as warnings' + ) + ->addOption( + 'exclude-patterns', + null, + InputOption::VALUE_REQUIRED, + 'Comma-separated list of string patterns to exclude from validation', + '' + ) + ->addOption( + 'debug', + 'd', + InputOption::VALUE_NONE, + 'Enable debug mode for detailed error information' + ); + } + + /** + * Execute the command. + * + * @param InputInterface $input the input interface + * @param OutputInterface $output the output interface + * + * @return int the exit code + */ + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->outputHeading($output, 'Checking for missing language strings in %s'); + + // Create configuration from command line options + $config = ValidationConfig::fromOptions([ + 'lang' => $input->getOption('lang'), + 'strict' => $input->getOption('strict'), + 'unused' => $input->getOption('unused'), + 'exclude-patterns' => $input->getOption('exclude-patterns'), + 'debug' => $input->getOption('debug'), + ]); + + // Convert MoodlePlugin to Plugin object + list($type, $name) = $this->moodle->normalizeComponent($this->plugin->getComponent()); + $plugin = new Plugin($this->plugin->getComponent(), $type, $name, $this->plugin->directory); + + $validator = new StringValidator( + $plugin, + $this->moodle, + $config + ); + + $result = $validator->validate(); + + // Show only errors and warnings + foreach ($result->getMessages() as $message) { + $output->writeln($message); + } + + // Show summary statistics + $this->outputSummary($output, $result); + + // Show debug information if debug mode is enabled + if ($config->isDebugEnabled()) { + $this->outputDebugInformation($output, $result); + } + + return $result->isValid() ? 0 : 1; + } + + /** + * Output summary statistics. + * + * @param OutputInterface $output the output interface + * @param ValidationResult $result the validation result + */ + private function outputSummary(OutputInterface $output, ValidationResult $result): void + { + $output->writeln(''); + $output->writeln('Summary:'); + + $summary = $result->getSummary(); + + if ($summary['errors'] > 0) { + $output->writeln(sprintf('- Errors: %d', $summary['errors'])); + } + + if ($summary['warnings'] > 0) { + $output->writeln(sprintf('- Warnings: %d', $summary['warnings'])); + } + + if ($summary['total_issues'] === 0) { + $output->writeln('- No issues found'); + } else { + $output->writeln(sprintf('- Total issues: %d', $summary['total_issues'])); + } + + $output->writeln(''); + + if ($summary['is_valid']) { + $output->writeln('✓ All language strings are valid'); + } else { + $output->writeln('✗ Language string validation failed'); + } + } + + /** + * Output debug performance information. + * + * @param OutputInterface $output the output interface + * @param ValidationResult $result the validation result + */ + private function outputDebugInformation(OutputInterface $output, ValidationResult $result): void + { + $debugData = $result->getDebugData(); + + $output->writeln(''); + $output->writeln('Debug Performance Information:'); + + // Overall timing + if ($debugData['processing_time'] > 0) { + $output->writeln(sprintf('- Total processing time: %.3f seconds', $debugData['processing_time'])); + } + + // Plugin counts + $totalPlugins = $debugData['plugin_count'] + $debugData['subplugin_count']; + $output->writeln(sprintf('- Plugins processed: %d', $totalPlugins)); + if ($debugData['subplugin_count'] > 0) { + $output->writeln(sprintf(' - Main: %d, Subplugins: %d', $debugData['plugin_count'], $debugData['subplugin_count'])); + } + + // Total files count + if (!empty($debugData['file_counts'])) { + $totalFiles = $debugData['file_counts']['total_files'] ?? 0; + if ($totalFiles > 0) { + $output->writeln(sprintf('- Files processed: %d', $totalFiles)); + } + } + + // String processing metrics + if (!empty($debugData['string_counts'])) { + $output->writeln('- String processing metrics:'); + foreach ($debugData['string_counts'] as $type => $count) { + if ($count > 0) { + /** @var string $type */ + $displayName = str_replace('_', ' ', $type); + $output->writeln(sprintf(' - %s: %d', ucfirst($displayName), $count)); + } + } + } + + $output->writeln(''); + } +} diff --git a/src/MissingStrings/Cache/FileContentCache.php b/src/MissingStrings/Cache/FileContentCache.php new file mode 100644 index 00000000..3a575adf --- /dev/null +++ b/src/MissingStrings/Cache/FileContentCache.php @@ -0,0 +1,168 @@ += self::MAX_CACHED_FILES) { + // Remove oldest cache entry (simple FIFO) + $oldestKey = array_key_first(self::$contentCache); + if (null !== $oldestKey) { + unset(self::$contentCache[$oldestKey], self::$mtimeCache[$oldestKey]); + } + } + + self::$contentCache[$normalizedPath] = $content; + self::$mtimeCache[$normalizedPath] = $currentMtime; + + return $content; + } + + /** + * Check if a file exists and is readable (with caching). + * + * @param string $filePath absolute path to the file + * + * @return bool true if file exists and is readable + */ + public static function fileExists(string $filePath): bool + { + $normalizedPath = realpath($filePath); + if (false === $normalizedPath) { + return file_exists($filePath) && is_readable($filePath); + } + + // If we have the file in cache, it exists and is readable + if (isset(self::$contentCache[$normalizedPath])) { + return true; + } + + return is_file($normalizedPath) && is_readable($normalizedPath); + } + + /** + * Get file lines as array with caching. + * + * @param string $filePath absolute path to the file + * @param int $flags flags for file() function + * + * @return array|false array of lines or false on failure + */ + /** + * @return array|false + */ + public static function getLines(string $filePath, int $flags = FILE_IGNORE_NEW_LINES) + { + $content = self::getContent($filePath); + if (false === $content) { + return false; + } + + if ($flags & FILE_IGNORE_NEW_LINES) { + return explode("\n", rtrim($content, "\n")); + } + + return explode("\n", $content); + } + + /** + * Clear the entire cache. + * + * Useful for testing or when memory usage needs to be reduced. + */ + public static function clearCache(): void + { + self::$contentCache = []; + self::$mtimeCache = []; + } + + /** + * Get cache statistics for debugging. + * + * @return array cache statistics + */ + public static function getStats(): array + { + return [ + 'cached_files' => count(self::$contentCache), + 'max_files' => self::MAX_CACHED_FILES, + 'memory_usage' => array_sum(array_map('strlen', self::$contentCache)), + ]; + } +} diff --git a/src/MissingStrings/Checker/CheckerUtils.php b/src/MissingStrings/Checker/CheckerUtils.php new file mode 100644 index 00000000..289f85c3 --- /dev/null +++ b/src/MissingStrings/Checker/CheckerUtils.php @@ -0,0 +1,505 @@ += max(0, $i - 5); --$j) { + if (is_array($tokens[$j]) && T_STRING === $tokens[$j][0] && 'defined' === $tokens[$j][1]) { + $isDefinedCall = true; + break; + } + } + + if ($isDefinedCall) { + $constants[] = $stringValue; + } + } + } + } + } + + return array_unique($constants); + } + + /** + * Check if a string looks like a PHP constant. + * + * @param string $name The string to check + * + * @return bool True if it looks like a constant + */ + private static function looksLikeConstant(string $name): bool + { + // Constants are typically all uppercase with underscores + // and don't start with numbers + return 1 === preg_match('/^[A-Z][A-Z0-9_]*$/', $name); + } + + /** + * Load and parse a JSON file safely. + * + * @param string $filePath Path to the JSON file + * + * @return array|null Parsed data or null if file doesn't exist or has errors + */ + public static function loadJsonFile(string $filePath): ?array + { + if (!file_exists($filePath)) { + return null; + } + + $content = file_get_contents($filePath); + if (false === $content) { + return null; + } + + $data = json_decode($content, true); + + return JSON_ERROR_NONE === json_last_error() ? $data : null; + } + + /** + * Find PHP files in a directory recursively. + * + * @param string $directory Directory to search + * @param string $pattern Optional filename pattern (e.g., '*.php') + * + * @return array Array of file paths + */ + public static function findPhpFiles(string $directory, string $pattern = '*.php'): array + { + if (!is_dir($directory)) { + return []; + } + + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY + ); + + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if ($file->isFile() && fnmatch($pattern, $file->getFilename())) { + $files[] = $file->getPathname(); + } + } + + return $files; + } + + /** + * Parse PHP tokens from file content. + * + * @param string $content PHP file content + * + * @return array Array of tokens + */ + public static function parsePhpTokens(string $content): array + { + return token_get_all($content); + } + + /** + * Extract class information from PHP tokens. + * + * @param array $tokens PHP tokens + * + * @return array Class information: ['name' => string, 'interfaces' => array, 'parent' => string|null, 'methods' => array] + */ + public static function extractClassInfo(array $tokens): array + { + $classes = []; + $tokenCount = count($tokens); + + for ($i = 0; $i < $tokenCount; ++$i) { + if (is_array($tokens[$i]) && T_CLASS === $tokens[$i][0]) { + $classInfo = self::parseClassDeclaration($tokens, $i); + if ($classInfo) { + $classes[] = $classInfo; + } + } + } + + return $classes; + } + + /** + * Parse a class declaration from tokens. + * + * @param array $tokens PHP tokens + * @param int $startIndex Index where class token was found + * + * @return array|null Class information or null if parsing failed + */ + private static function parseClassDeclaration(array $tokens, int $startIndex): ?array + { + $tokenCount = count($tokens); + $className = null; + $parentClass = null; + $interfaces = []; + $methods = []; + + // Find class name + for ($i = $startIndex + 1; $i < $tokenCount; ++$i) { + if (is_array($tokens[$i]) && T_STRING === $tokens[$i][0]) { + $className = $tokens[$i][1]; + break; + } + } + + if (!$className) { + return null; + } + + // Find extends and implements + for ($i = $startIndex; $i < $tokenCount; ++$i) { + if (is_array($tokens[$i])) { + if (T_EXTENDS === $tokens[$i][0]) { + $parentClass = self::getNextStringToken($tokens, $i); + } elseif (T_IMPLEMENTS === $tokens[$i][0]) { + $interfaces = self::getImplementedInterfaces($tokens, $i); + } elseif (T_FUNCTION === $tokens[$i][0]) { + $methodName = self::getNextStringToken($tokens, $i); + if ($methodName) { + $methods[] = $methodName; + } + } + } + + // Stop at class opening brace + if ('{' === $tokens[$i]) { + break; + } + } + + return [ + 'name' => $className, + 'parent' => $parentClass, + 'interfaces' => $interfaces, + 'methods' => $methods, + ]; + } + + /** + * Get the next string token after a given position. + * + * @param array $tokens PHP tokens + * @param int $startIndex Starting index + * + * @return string|null Next string token or null + */ + private static function getNextStringToken(array $tokens, int $startIndex): ?string + { + $tokenCount = count($tokens); + + for ($i = $startIndex + 1; $i < $tokenCount; ++$i) { + if (is_array($tokens[$i])) { + // Handle different token types for class/interface names + $tokenType = $tokens[$i][0]; + if (T_STRING === $tokenType) { + return $tokens[$i][1]; + } + + // Handle PHP 8.0+ tokens if they exist and are not our fallback values + if (defined('T_NAME_QUALIFIED') && T_NAME_QUALIFIED !== -1 && T_NAME_QUALIFIED === $tokenType) { + return $tokens[$i][1]; + } + if (defined('T_NAME_FULLY_QUALIFIED') && T_NAME_FULLY_QUALIFIED !== -2 && T_NAME_FULLY_QUALIFIED === $tokenType) { + return $tokens[$i][1]; + } + } + } + + return null; + } + + /** + * Get implemented interfaces from tokens. + * + * @param array $tokens PHP tokens + * @param int $startIndex Starting index (implements token) + * + * @return array Array of interface names + */ + private static function getImplementedInterfaces(array $tokens, int $startIndex): array + { + $interfaces = []; + $tokenCount = count($tokens); + + for ($i = $startIndex + 1; $i < $tokenCount; ++$i) { + if (is_array($tokens[$i])) { + // Handle different token types for interface names + $tokenType = $tokens[$i][0]; + if (T_STRING === $tokenType) { + $interfaces[] = $tokens[$i][1]; + } + + // Handle PHP 8.0+ tokens if they exist and are not our fallback values + if (defined('T_NAME_QUALIFIED') && T_NAME_QUALIFIED !== -1 && T_NAME_QUALIFIED === $tokenType) { + $interfaces[] = $tokens[$i][1]; + } + if (defined('T_NAME_FULLY_QUALIFIED') && T_NAME_FULLY_QUALIFIED !== -2 && T_NAME_FULLY_QUALIFIED === $tokenType) { + $interfaces[] = $tokens[$i][1]; + } + } elseif ('{' === $tokens[$i]) { + break; + } + } + + return $interfaces; + } + + /** + * Check if a class implements a specific interface. + * + * @param array $classInfo Class information from extractClassInfo() + * @param string $interfaceName Interface name to check (can be partial) + * + * @return bool True if class implements the interface + */ + public static function implementsInterface(array $classInfo, string $interfaceName): bool + { + $interfaces = $classInfo['interfaces'] ?? []; + + // Handle individual interface matching (for single tokens) + foreach ($interfaces as $implementedInterface) { + if (false !== strpos($implementedInterface, $interfaceName)) { + return true; + } + } + + // Handle namespaced interface matching (for tokenized interfaces) + // Join all interface tokens with backslashes to reconstruct the full interface name + $fullInterfaceName = '\\' . implode('\\', $interfaces); + if (false !== strpos($fullInterfaceName, $interfaceName)) { + return true; + } + + // Also check for pattern matching in the token sequence + // For example, to find "metadata\provider" in ["core_privacy", "local", "metadata", "provider"] + $interfaceParts = preg_split('/[\\\\\/]/', $interfaceName); + if (count($interfaceParts) > 1) { + // Look for consecutive matches in the interface tokens + $targetCount = count($interfaceParts); + $interfaceCount = count($interfaces); + + for ($i = 0; $i <= $interfaceCount - $targetCount; ++$i) { + $matches = true; + for ($j = 0; $j < $targetCount; ++$j) { + if ($interfaces[$i + $j] !== $interfaceParts[$j]) { + $matches = false; + break; + } + } + if ($matches) { + return true; + } + } + } + + return false; + } + + /** + * Check if a class extends a specific parent class. + * + * @param array $classInfo Class information from extractClassInfo() + * @param string $parentName Parent class name to check + * + * @return bool True if class extends the parent + */ + public static function extendsClass(array $classInfo, string $parentName): bool + { + return ($classInfo['parent'] ?? null) === $parentName; + } + + /** + * Check if a class has a specific method. + * + * @param array $classInfo Class information from extractClassInfo() + * @param string $methodName Method name to check + * + * @return bool True if class has the method + */ + public static function hasMethod(array $classInfo, string $methodName): bool + { + return in_array($methodName, $classInfo['methods'] ?? [], true); + } + + /** + * Get the database file path for a plugin. + * + * @param Plugin $plugin The plugin + * @param string $filename Database filename (e.g., 'access.php', 'caches.php') + * + * @return string Full path to the database file + */ + public static function getDatabaseFilePath(Plugin $plugin, string $filename): string + { + return $plugin->directory . '/db/' . $filename; + } + + /** + * Check if a plugin has a specific database file. + * + * @param Plugin $plugin The plugin + * @param string $filename Database filename + * + * @return bool True if the file exists + */ + public static function hasDatabaseFile(Plugin $plugin, string $filename): bool + { + return file_exists(self::getDatabaseFilePath($plugin, $filename)); + } + + /** + * Remove component prefix from a string key. + * + * @param string $key String key that might have component prefix + * @param string $component Component name to remove + * + * @return string Key without component prefix + */ + public static function removeComponentPrefix(string $key, string $component): string + { + $prefix = $component . ':'; + if (0 === strpos($key, $prefix)) { + return substr($key, strlen($prefix)); + } + + return $key; + } + + /** + * Normalize a string key by removing common prefixes and suffixes. + * + * @param string $key String key to normalize + * + * @return string Normalized key + */ + public static function normalizeStringKey(string $key): string + { + // Remove common prefixes + $prefixes = ['mod_', 'block_', 'local_', 'admin_', 'core_']; + foreach ($prefixes as $prefix) { + if (0 === strpos($key, $prefix)) { + $key = substr($key, strlen($prefix)); + break; + } + } + + return $key; + } +} diff --git a/src/MissingStrings/Checker/CheckersRegistry.php b/src/MissingStrings/Checker/CheckersRegistry.php new file mode 100644 index 00000000..8dbf15f2 --- /dev/null +++ b/src/MissingStrings/Checker/CheckersRegistry.php @@ -0,0 +1,79 @@ +analyzeClasses($plugin); + } catch (\Exception $e) { + $result = new ValidationResult(); + $result->addRawError('Error analyzing classes: ' . $e->getMessage()); + + return $result; + } + } + + /** + * Find PHP class files in the plugin. + * + * @param Plugin $plugin the plugin to search + * @param string $subdirectory Optional subdirectory to search in (e.g., 'classes/privacy'). + * + * @return array array of file paths + */ + protected function findClassFiles(Plugin $plugin, string $subdirectory = 'classes'): array + { + $classesDir = $plugin->directory . '/' . $subdirectory; + if (!is_dir($classesDir)) { + return []; + } + + $files = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($classesDir, \RecursiveDirectoryIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + if ($file->isFile() && 'php' === $file->getExtension()) { + $files[] = $file->getPathname(); + } + } + + return $files; + } + + /** + * Parse a PHP file and extract basic class information. + * + * @param string $filePath path to the PHP file + * + * @throws \Exception if the file cannot be parsed + * + * @return array class information including name, interfaces, parent class, methods + */ + protected function parseClassFile(string $filePath): array + { + if (!is_readable($filePath)) { + throw new \Exception("File is not readable: {$filePath}"); + } + + $content = file_get_contents($filePath); + if (false === $content) { + throw new \Exception("Cannot read file: {$filePath}"); + } + + $tokens = token_get_all($content); + $classInfo = [ + 'name' => null, + 'namespace' => null, + 'interfaces' => [], + 'parent' => null, + 'methods' => [], + 'file' => $filePath, + ]; + + $this->parseTokens($tokens, $classInfo); + + return $classInfo; + } + + /** + * Parse PHP tokens to extract class information. + * + * @param array $tokens PHP tokens from token_get_all() + * @param array &$classInfo Class information array to populate + */ + private function parseTokens(array $tokens, array &$classInfo): void + { + $tokenCount = count($tokens); + $currentNamespace = ''; + + for ($i = 0; $i < $tokenCount; ++$i) { + $token = $tokens[$i]; + + if (!is_array($token)) { + continue; + } + + switch ($token[0]) { + case T_NAMESPACE: + $currentNamespace = $this->extractNamespace($tokens, $i); + $classInfo['namespace'] = $currentNamespace; + break; + case T_CLASS: + $this->parseClassDeclaration($tokens, $i, $classInfo); + break; + case T_FUNCTION: + if ($classInfo['name']) { // Only parse methods if we're inside a class + $methodName = $this->extractMethodName($tokens, $i); + if ($methodName) { + $classInfo['methods'][] = $methodName; + } + } + break; + } + } + } + + /** + * Extract namespace from tokens. + * + * @param array $tokens PHP tokens + * @param int $startIndex index of T_NAMESPACE token + * + * @return string the namespace name + */ + private function extractNamespace(array $tokens, int $startIndex): string + { + $namespace = ''; + $i = $startIndex + 1; + + while ($i < count($tokens)) { + $token = $tokens[$i]; + + if (is_array($token)) { + if (T_STRING === $token[0] || T_NAME_QUALIFIED === $token[0]) { + $namespace .= $token[1]; + } elseif (T_NS_SEPARATOR === $token[0]) { + $namespace .= '\\'; + } + } elseif (';' === $token || '{' === $token) { + break; + } + + ++$i; + } + + return trim($namespace); + } + + /** + * Parse class declaration to extract name, parent, and interfaces. + * + * @param array $tokens PHP tokens + * @param int $startIndex index of T_CLASS token + * @param array &$classInfo Class information array to populate + */ + private function parseClassDeclaration(array $tokens, int $startIndex, array &$classInfo): void + { + $i = $startIndex + 1; + $mode = 'name'; // name, extends, implements + $currentParent = ''; + + while ($i < count($tokens)) { + $token = $tokens[$i]; + + if ('{' === $token) { + break; + } + + if (is_array($token)) { + switch ($token[0]) { + case T_STRING: + case T_NAME_QUALIFIED: + case T_NAME_FULLY_QUALIFIED: + if ('name' === $mode && !$classInfo['name']) { + $classInfo['name'] = $token[1]; + } elseif ('extends' === $mode) { + $currentParent .= $token[1]; + } elseif ('implements' === $mode) { + $classInfo['interfaces'][] = $token[1]; + } + break; + case T_NS_SEPARATOR: + if ('extends' === $mode) { + $currentParent .= '\\'; + } + break; + case T_EXTENDS: + $mode = 'extends'; + $currentParent = ''; + break; + case T_IMPLEMENTS: + if ('extends' === $mode && $currentParent) { + $classInfo['parent'] = $currentParent; + } + $mode = 'implements'; + break; + } + } elseif (',' === $token && 'implements' === $mode && $currentParent) { + // Handle multiple interfaces + continue; + } + + ++$i; + } + + // Handle case where class ends without implements clause + if ('extends' === $mode && $currentParent) { + $classInfo['parent'] = $currentParent; + } + } + + /** + * Extract method name from tokens. + * + * @param array $tokens PHP tokens + * @param int $startIndex index of T_FUNCTION token + * + * @return string|null the method name or null if not found + */ + private function extractMethodName(array $tokens, int $startIndex): ?string + { + $i = $startIndex + 1; + + while ($i < count($tokens)) { + $token = $tokens[$i]; + + if (is_array($token) && T_STRING === $token[0]) { + return $token[1]; + } + + if ('(' === $token) { + break; + } + + ++$i; + } + + return null; + } + + /** + * Check if a class implements a specific interface. + * + * @param array $classInfo class information from parseClassFile() + * @param string $interface interface name to check (can be partial) + * + * @return bool true if the interface is implemented + */ + protected function implementsInterface(array $classInfo, string $interface): bool + { + foreach ($classInfo['interfaces'] as $implementedInterface) { + if (false !== strpos($implementedInterface, $interface)) { + return true; + } + } + + return false; + } + + /** + * Check if a class has a specific method. + * + * @param array $classInfo class information from parseClassFile() + * @param string $methodName method name to check + * + * @return bool true if the method exists + */ + protected function hasMethod(array $classInfo, string $methodName): bool + { + return in_array($methodName, $classInfo['methods'], true); + } + + /** + * Check if a class extends a specific parent class. + * + * @param array $classInfo class information from parseClassFile() + * @param string $parentClass parent class name to check (can be partial) + * + * @return bool true if the class extends the parent + */ + protected function extendsClass(array $classInfo, string $parentClass): bool + { + return $classInfo['parent'] && false !== strpos($classInfo['parent'], $parentClass); + } +} diff --git a/src/MissingStrings/Checker/ClassMethodChecker/ExceptionChecker.php b/src/MissingStrings/Checker/ClassMethodChecker/ExceptionChecker.php new file mode 100644 index 00000000..c644cdb3 --- /dev/null +++ b/src/MissingStrings/Checker/ClassMethodChecker/ExceptionChecker.php @@ -0,0 +1,337 @@ +usageFinder = new StringUsageFinder(); + } + + /** + * Get the name of this checker. + */ + public function getName(): string + { + return 'Exception'; + } + + /** + * Check if this checker applies to the given plugin. + * + * @param Plugin $plugin the plugin to check + * + * @return bool true if exception classes or exception throws exist + */ + public function appliesTo(Plugin $plugin): bool + { + // Check all PHP files for exception usage + $phpFiles = $this->findClassFiles($plugin, ''); + + foreach ($phpFiles as $filePath) { + $content = file_get_contents($filePath); + if (false === $content) { + continue; + } + + // Look for exception-related patterns + if ($this->hasExceptionPatterns($content)) { + return true; + } + } + + return false; + } + + /** + * Analyze classes and files for exception-related string requirements. + * + * @param Plugin $plugin the plugin to analyze + * + * @return ValidationResult the result containing required strings + */ + protected function analyzeClasses(Plugin $plugin): ValidationResult + { + $result = new ValidationResult(); + + // Find all PHP files in the plugin + $phpFiles = $this->findClassFiles($plugin, ''); + + foreach ($phpFiles as $filePath) { + try { + // Analyze file content for exception patterns + $this->analyzeFileForExceptions($filePath, $result, $plugin->component); + + // Also analyze class structure if it's a class file + if (false !== strpos($filePath, '/classes/')) { + $classInfo = $this->parseClassFile($filePath); + $this->analyzeExceptionClass($classInfo, $result); + } + } catch (\Exception $e) { + $result->addRawError("Error analyzing file {$filePath}: " . $e->getMessage()); + } + } + + return $result; + } + + /** + * Check if content has exception-related patterns. + * + * @param string $content file content to check + * + * @return bool true if exception patterns found + */ + private function hasExceptionPatterns(string $content): bool + { + $patterns = [ + '/throw\s+new\s+.*exception/i', + '/extends\s+.*exception/i', + '/moodle_exception/i', + '/coding_exception/i', + '/invalid_parameter_exception/i', + ]; + + foreach ($patterns as $pattern) { + if (preg_match($pattern, $content)) { + return true; + } + } + + return false; + } + + /** + * Analyze a file for exception throws and custom exception classes. + * + * @param string $filePath path to the file to analyze + * @param ValidationResult $result result object to add strings to + * @param string $pluginComponent the component name of the current plugin + */ + private function analyzeFileForExceptions(string $filePath, ValidationResult $result, string $pluginComponent): void + { + $content = file_get_contents($filePath); + if (false === $content) { + return; + } + + $lines = explode("\n", $content); + + foreach ($lines as $lineNumber => $line) { + $actualLineNumber = $lineNumber + 1; + + // moodle_exception with explicit component + if (preg_match_all('/throw\s+new\s+moodle_exception\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]/', $line, $matches)) { + for ($i = 0; $i < count($matches[1]); ++$i) { + $errorCode = $matches[1][$i]; + $component = $matches[2][$i]; + + // For module plugins, the language component is just the module name, not the full plugin component + $expectedComponent = $this->getLanguageComponent($pluginComponent); + if ($component === $expectedComponent || $component === $pluginComponent) { + $result->addRequiredString( + $errorCode, + new StringContext($filePath, $actualLineNumber, 'moodle_exception') + ); + } + } + } + + // moodle_exception with only error code (defaults to 'error' component, skip) + if (preg_match_all('/throw\s+new\s+moodle_exception\s*\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $line, $matches)) { + // Skip: defaults to 'error' component, not current plugin + } + + // moodle_exception with empty component (defaults to 'error' component, skip) + if (preg_match_all('/throw\s+new\s+moodle_exception\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"][\'"]/', $line, $matches)) { + // Skip: empty component defaults to 'error' + } + + // moodle_exception with 'moodle' or 'core' component (defaults to 'error' component, skip) + if (preg_match_all('/throw\s+new\s+moodle_exception\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"](?:moodle|core)[\'"]/', $line, $matches)) { + // Skip: 'moodle'/'core' components default to 'error' + } + + // Other exception types + $exceptionTypes = [ + 'coding_exception', + 'invalid_parameter_exception', + 'invalid_response_exception', + 'file_exception', + 'dml_exception', + ]; + + foreach ($exceptionTypes as $exceptionType) { + $pattern = "/throw\\s+new\\s+{$exceptionType}\\s*\\(\\s*['\"]([^'\"]+)['\"]/"; + if (preg_match_all($pattern, $line, $matches)) { + foreach ($matches[1] as $errorMessage) { + if ($this->looksLikeStringKey($errorMessage)) { + $result->addRequiredString( + $errorMessage, + new StringContext($filePath, $actualLineNumber, $exceptionType) + ); + } + } + } + } + + // print_error with explicit component + if (preg_match_all('/print_error\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]/', $line, $matches)) { + for ($i = 0; $i < count($matches[1]); ++$i) { + $errorCode = $matches[1][$i]; + $component = $matches[2][$i]; + + // For module plugins, the language component is just the module name, not the full plugin component + $expectedComponent = $this->getLanguageComponent($pluginComponent); + if (($component === $expectedComponent || $component === $pluginComponent) && $this->looksLikeStringKey($errorCode)) { + $result->addRequiredString( + $errorCode, + new StringContext($filePath, $actualLineNumber, 'print_error') + ); + } + } + } + + // print_error with empty component (defaults to 'error' component, skip) + if (preg_match_all('/print_error\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"][\'"]/', $line, $matches)) { + // Skip: empty component defaults to 'error' + } + + // print_error with 'moodle' or 'core' component (defaults to 'error' component, skip) + if (preg_match_all('/print_error\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"](?:moodle|core)[\'"]/', $line, $matches)) { + // Skip: 'moodle'/'core' components default to 'error' + } + + // print_error with only error code (defaults to current component) + if (preg_match_all('/print_error\s*\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $line, $matches)) { + foreach ($matches[1] as $errorCode) { + if ($this->looksLikeStringKey($errorCode)) { + $result->addRequiredString( + $errorCode, + new StringContext($filePath, $actualLineNumber, 'print_error') + ); + } + } + } + } + } + + /** + * Analyze a class that might be a custom exception. + * + * @param array $classInfo class information from parseClassFile() + * @param ValidationResult $result result object to add strings to + */ + private function analyzeExceptionClass(array $classInfo, ValidationResult $result): void + { + // Check if class extends any exception class + if ($classInfo['parent'] + && (false !== strpos($classInfo['parent'], 'exception') + || false !== strpos($classInfo['parent'], 'Exception'))) { + $className = $classInfo['name']; + $filePath = $classInfo['file'] ?? null; + + // Custom exception classes often need error message strings + $baseClassName = str_replace('Exception', '', $className); + if (is_string($baseClassName)) { + $possibleStringKeys = [ + strtolower($className), + 'error_' . strtolower($className), + strtolower($baseClassName), + ]; + } else { + $possibleStringKeys = [ + strtolower($className), + 'error_' . strtolower($className), + ]; + } + + foreach ($possibleStringKeys as $stringKey) { + $context = new StringContext($filePath, null, "Error message for custom exception class {$className}"); + + // Try to find the line where this string key might be used if we have a file path + if ($filePath) { + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, $stringKey); + if (null !== $lineNumber) { + $context->setLine($lineNumber); + } + } + + $result->addRequiredString($stringKey, $context); + } + } + } + + /** + * Check if a string looks like a language string key rather than a direct message. + * + * @param string $string the string to check + * + * @return bool true if it looks like a string key + */ + private function looksLikeStringKey(string $string): bool + { + // String keys are typically: + // - lowercase with underscores + // - no spaces + // - no punctuation except underscores and colons + return preg_match('/^[a-z][a-z0-9_:]*$/', $string) + && !preg_match('/\s/', $string) + && strlen($string) > 3; + } + + /** + * Get the language component name for a plugin component. + * + * For module plugins (mod_forum), the language component is just the module name (forum). + * For other plugin types, it's usually the full component name. + * + * @param string $pluginComponent The full plugin component (e.g., 'mod_forum') + * + * @return string The language component (e.g., 'forum') + */ + private function getLanguageComponent(string $pluginComponent): string + { + // For module plugins, strip the 'mod_' prefix + if (0 === strpos($pluginComponent, 'mod_')) { + return substr($pluginComponent, 4); // Remove 'mod_' prefix + } + + // For other plugin types, return the full component + return $pluginComponent; + } +} diff --git a/src/MissingStrings/Checker/ClassMethodChecker/GradeItemChecker.php b/src/MissingStrings/Checker/ClassMethodChecker/GradeItemChecker.php new file mode 100644 index 00000000..60f6cffe --- /dev/null +++ b/src/MissingStrings/Checker/ClassMethodChecker/GradeItemChecker.php @@ -0,0 +1,259 @@ +initializeStringUsageFinder(); + } + + /** + * Get the name of this checker. + */ + public function getName(): string + { + return 'Grade Item'; + } + + /** + * Check if this checker applies to the given plugin. + * + * @param Plugin $plugin the plugin to check + * + * @return bool true if grade item mapping classes exist + */ + public function appliesTo(Plugin $plugin): bool + { + // Look for classes/grades/gradeitems.php file + $gradeitemsFile = $plugin->directory . '/classes/grades/gradeitems.php'; + if (file_exists($gradeitemsFile)) { + return true; + } + + // Look for any class that implements itemnumber_mapping interface + $phpFiles = $this->findClassFiles($plugin, ''); + foreach ($phpFiles as $filePath) { + $content = file_get_contents($filePath); + if (false === $content) { + continue; + } + + if ($this->hasGradeItemMapping($content)) { + return true; + } + } + + return false; + } + + /** + * Analyze classes for grade item mapping requirements. + * + * @param Plugin $plugin the plugin to analyze + * + * @return ValidationResult the result containing required strings + */ + protected function analyzeClasses(Plugin $plugin): ValidationResult + { + $result = new ValidationResult(); + + // Check the standard gradeitems.php file first + $gradeitemsFile = $plugin->directory . '/classes/grades/gradeitems.php'; + if (file_exists($gradeitemsFile)) { + $this->analyzeGradeItemsFile($gradeitemsFile, $result); + } + + // Check all PHP files for grade item mapping implementations + $phpFiles = $this->findClassFiles($plugin, ''); + foreach ($phpFiles as $filePath) { + if ($filePath === $gradeitemsFile) { + continue; // Already analyzed above + } + + try { + $this->analyzeFileForGradeItemMapping($filePath, $result); + } catch (\Exception $e) { + $result->addRawError("Error analyzing file {$filePath}: " . $e->getMessage()); + } + } + + return $result; + } + + /** + * Check if content has grade item mapping patterns. + * + * @param string $content file content to check + * + * @return bool true if grade item mapping patterns found + */ + private function hasGradeItemMapping(string $content): bool + { + return false !== strpos($content, 'itemnumber_mapping') + || false !== strpos($content, 'advancedgrading_mapping') + || false !== strpos($content, 'get_itemname_mapping_for_component') + || false !== strpos($content, 'get_advancedgrading_itemnames'); + } + + /** + * Analyze a gradeitems.php file for required strings. + * + * @param string $filePath Path to the gradeitems.php file. + * @param ValidationResult $result result object to add strings to + */ + private function analyzeGradeItemsFile(string $filePath, ValidationResult $result): void + { + $content = file_get_contents($filePath); + if (false === $content) { + $result->addRawError("Could not read file: {$filePath}"); + + return; + } + + // Extract item names from get_itemname_mapping_for_component method + $itemNames = $this->extractItemNamesFromMapping($content); + + foreach ($itemNames as $itemName) { + if (!empty($itemName)) { // Skip empty item names (itemnumber 0 often has empty name) + // Add grade_{itemname}_name string requirement + $gradeStringKey = "grade_{$itemName}_name"; + $gradeDescription = "Grade item display name for '{$itemName}' from get_itemname_mapping_for_component()"; + $this->addRequiredStringWithStringLiteral($result, $gradeStringKey, $filePath, $itemName, $gradeDescription); + } + } + + // Extract item names from get_advancedgrading_itemnames method + $advancedItemNames = $this->extractAdvancedGradingItemNames($content); + + foreach ($advancedItemNames as $itemName) { + if (!empty($itemName)) { + $stringKey = "gradeitem:{$itemName}"; + $description = "Advanced grading item name for '{$itemName}' from get_advancedgrading_itemnames()"; + $this->addRequiredStringWithStringLiteral($result, $stringKey, $filePath, $itemName, $description); + } + } + } + + /** + * Analyze a file for grade item mapping implementations. + * + * @param string $filePath path to the file to analyze + * @param ValidationResult $result result object to add strings to + */ + private function analyzeFileForGradeItemMapping(string $filePath, ValidationResult $result): void + { + $content = file_get_contents($filePath); + if (false === $content) { + return; + } + + if (!$this->hasGradeItemMapping($content)) { + return; + } + + // Extract item names from any get_itemname_mapping_for_component method + $itemNames = $this->extractItemNamesFromMapping($content); + + foreach ($itemNames as $itemName) { + if (!empty($itemName)) { + // Add grade_{itemname}_name string requirement + $gradeStringKey = "grade_{$itemName}_name"; + $gradeDescription = "Grade item display name for '{$itemName}' from grade item mapping"; + $this->addRequiredStringWithStringLiteral($result, $gradeStringKey, $filePath, $itemName, $gradeDescription); + } + } + + // Extract item names from get_advancedgrading_itemnames method + $advancedItemNames = $this->extractAdvancedGradingItemNames($content); + + foreach ($advancedItemNames as $itemName) { + if (!empty($itemName)) { + $stringKey = "gradeitem:{$itemName}"; + $description = "Advanced grading item name for '{$itemName}' from get_advancedgrading_itemnames()"; + $this->addRequiredStringWithStringLiteral($result, $stringKey, $filePath, $itemName, $description); + } + } + } + + /** + * Extract item names from get_itemname_mapping_for_component method. + * + * @param string $content file content to analyze + * + * @return array array of item names found in the mapping + */ + private function extractItemNamesFromMapping(string $content): array + { + $itemNames = []; + + // Look for get_itemname_mapping_for_component method and extract the return array + // Match the method and its return statement more simply + if (preg_match('/function\s+get_itemname_mapping_for_component\s*\(\s*\)\s*:\s*array\s*\{.*?return\s*\[(.*?)\];/s', $content, $matches)) { + $arrayContent = $matches[1]; + + // Extract quoted strings from the array content + // This matches patterns like: 0 => 'submissions', 1 => 'grading', etc. + // Use a more precise regex to match key => "value" patterns + if (preg_match_all('/=>\s*[\'"]([^\'"]*)[\'"]/', $arrayContent, $stringMatches)) { + $itemNames = array_merge($itemNames, $stringMatches[1]); + } + } + + return array_unique(array_filter($itemNames)); // Remove duplicates and empty values + } + + /** + * Extract item names from get_advancedgrading_itemnames method. + * + * @param string $content file content to analyze + * + * @return array array of item names found in the advanced grading method + */ + private function extractAdvancedGradingItemNames(string $content): array + { + $itemNames = []; + + // Look for get_advancedgrading_itemnames method and extract the return array + if (preg_match('/function\s+get_advancedgrading_itemnames\s*\(\s*\)\s*:\s*array\s*\{.*?return\s*\[(.*?)\];/s', $content, $matches)) { + $arrayContent = $matches[1]; + + // Extract quoted strings from the array content + // This matches patterns like: 'forum', 'submissions', etc. + // Match standalone quoted strings in the array + if (preg_match_all('/^\s*[\'"]([^\'"]+)[\'"]\s*,?\s*$/m', $arrayContent, $stringMatches)) { + $itemNames = array_merge($itemNames, $stringMatches[1]); + } + } + + return array_unique(array_filter($itemNames)); // Remove duplicates and empty values + } +} diff --git a/src/MissingStrings/Checker/ClassMethodChecker/PrivacyProviderChecker.php b/src/MissingStrings/Checker/ClassMethodChecker/PrivacyProviderChecker.php new file mode 100644 index 00000000..6b92dadb --- /dev/null +++ b/src/MissingStrings/Checker/ClassMethodChecker/PrivacyProviderChecker.php @@ -0,0 +1,312 @@ +initializeStringUsageFinder(); + } + + /** + * Get the name of this checker. + */ + public function getName(): string + { + return 'Privacy Provider'; + } + + /** + * Check if this checker applies to the given plugin. + * + * @param Plugin $plugin the plugin to check + * + * @return bool true if privacy provider file exists + */ + public function appliesTo(Plugin $plugin): bool + { + return file_exists($plugin->directory . '/classes/privacy/provider.php'); + } + + /** + * Check the plugin for required strings. + * + * @param Plugin $plugin the plugin to check + * + * @return ValidationResult the result of the check + */ + public function check(Plugin $plugin): ValidationResult + { + $result = new ValidationResult(); + + $providerFile = $plugin->directory . '/classes/privacy/provider.php'; + if (!file_exists($providerFile)) { + return $result; + } + + // Check if it's actually a file (not a directory) + if (!is_file($providerFile)) { + $result->addRawError('Error analyzing privacy provider: path exists but is not a readable file'); + + return $result; + } + + try { + $content = file_get_contents($providerFile); + if (false === $content || empty($content)) { + $result->addRawError('Could not read privacy provider file'); + + return $result; + } + + $tokens = CheckerUtils::parsePhpTokens($content); + $classes = CheckerUtils::extractClassInfo($tokens); + + foreach ($classes as $classInfo) { + $this->analyzePrivacyClass($classInfo, $content, $plugin, $result, $providerFile); + } + } catch (\Exception $e) { + $result->addRawError('Error analyzing privacy provider: ' . $e->getMessage()); + } + + return $result; + } + + /** + * Analyze a privacy provider class. + * + * @param array $classInfo Class information from CheckerUtils::extractClassInfo() + * @param string $content File content for method analysis + * @param Plugin $plugin The plugin being analyzed + * @param ValidationResult $result The result to add strings to + * @param string $filePath The path to the provider file + */ + private function analyzePrivacyClass(array $classInfo, string $content, Plugin $plugin, ValidationResult $result, string $filePath): void + { + // Check for null provider (no data stored) + if (CheckerUtils::implementsInterface($classInfo, 'null_provider')) { + // Find the actual string returned from get_reason() method + $reasonStrings = $this->findGetReasonStrings($content, $filePath); + + if (empty($reasonStrings)) { + // Fallback to default if no strings found + $reasonStrings[self::PRIVACY_METADATA_STRING] = 'Privacy metadata for null provider (default)'; + } + + foreach ($reasonStrings as $stringKey => $description) { + $this->addRequiredStringWithStringLiteral($result, $stringKey, $filePath, $stringKey, $description); + } + } + + // Check for metadata provider (data stored) + if (CheckerUtils::implementsInterface($classInfo, 'metadata\\provider')) { + // Metadata providers only need specific field/preference description strings. + $metadataStrings = $this->analyzeGetMetadataMethod($content, $plugin, $filePath); + foreach ($metadataStrings as $stringKey => $description) { + // Use the trait helper method for string literal detection + $this->addRequiredStringWithStringLiteral($result, $stringKey, $filePath, $stringKey, $description); + } + } + + // Check for request provider (handles data requests) + if (CheckerUtils::implementsInterface($classInfo, 'request\\provider')) { + // These providers typically need additional strings for data export/deletion + // but the specific strings depend on implementation + } + } + + /** + * Analyze the get_metadata method to find required metadata strings. + * + * @param string $content file content + * @param Plugin $plugin the plugin being analyzed + * @param string $filePath the path to the provider file + * + * @return array array of string keys and their descriptions + * + * @psalm-suppress ArgumentTypeCoercion + */ + private function analyzeGetMetadataMethod(string $content, Plugin $plugin, string $filePath): array + { + $strings = []; + + // Look for add_database_table calls + // Pattern: add_database_table('table_name', ['field' => 'privacy:metadata:field'], 'privacy:metadata:table') + // @psalm-suppress ArgumentTypeCoercion + if (preg_match_all(RegexPatterns::addDatabaseTable(), $content, $matches)) { + for ($i = 0; $i < count($matches[0]); ++$i) { + $tableName = $matches[1][$i]; + $fieldsArray = $matches[2][$i]; + $tableDescription = isset($matches[3][$i]) && !empty($matches[3][$i]) ? $matches[3][$i] : null; + + // Extract field mappings + // @psalm-suppress ArgumentTypeCoercion + if (preg_match_all(RegexPatterns::fieldMapping(), $fieldsArray, $fieldMatches)) { + for ($j = 0; $j < count($fieldMatches[1]); ++$j) { + $fieldName = $fieldMatches[1][$j]; + $stringKey = $fieldMatches[2][$j]; + + // Only include strings for this plugin component + if (0 === strpos($stringKey, self::PRIVACY_METADATA_STRING . ':')) { + $strings[$stringKey] = "Privacy metadata for table '{$tableName}', field '{$fieldName}'"; + } + } + } + + // Add table description string if provided + if ($tableDescription && 0 === strpos($tableDescription, self::PRIVACY_METADATA_STRING . ':')) { + $strings[$tableDescription] = "Privacy metadata for table '{$tableName}'"; + } + } + } + + // Look for add_external_location_link calls + // Pattern 1: add_external_location_link('service', 'privacy:metadata:service', 'link') + // @psalm-suppress ArgumentTypeCoercion + if (preg_match_all(RegexPatterns::addExternalLocationLinkSimple(), $content, $matches)) { + for ($i = 0; $i < count($matches[1]); ++$i) { + $serviceName = $matches[1][$i]; + $stringKey = $matches[2][$i]; + + if (0 === strpos($stringKey, self::PRIVACY_METADATA_STRING . ':')) { + $strings[$stringKey] = "Privacy metadata for external service '{$serviceName}'"; + } + } + } + + // Pattern 2: add_external_location_link('service', ['field' => 'privacy:metadata:field'], 'privacy:metadata:service') + // @psalm-suppress ArgumentTypeCoercion + if (preg_match_all(RegexPatterns::addExternalLocationLinkArray(), $content, $matches)) { + for ($i = 0; $i < count($matches[1]); ++$i) { + $serviceName = $matches[1][$i]; + $fieldsArray = $matches[2][$i]; + $serviceDescription = $matches[3][$i]; + + // Extract field mappings from the array + // @psalm-suppress ArgumentTypeCoercion + if (preg_match_all(RegexPatterns::fieldMapping(), $fieldsArray, $fieldMatches)) { + for ($j = 0; $j < count($fieldMatches[1]); ++$j) { + $fieldName = $fieldMatches[1][$j]; + $stringKey = $fieldMatches[2][$j]; + + // Only include strings for this plugin component + if (0 === strpos($stringKey, self::PRIVACY_METADATA_STRING . ':')) { + $strings[$stringKey] = "Privacy metadata for external service '{$serviceName}', field '{$fieldName}'"; + } + } + } + + // Add service description string + if ($serviceDescription && 0 === strpos($serviceDescription, self::PRIVACY_METADATA_STRING . ':')) { + $strings[$serviceDescription] = "Privacy metadata for external service '{$serviceName}'"; + } + } + } + + // Look for add_subsystem_link calls + // Pattern: add_subsystem_link('subsystem', [], 'privacy:metadata:subsystem') + // @psalm-suppress ArgumentTypeCoercion + if (preg_match_all(RegexPatterns::addSubsystemLink(), $content, $matches)) { + for ($i = 0; $i < count($matches[1]); ++$i) { + $subsystemName = $matches[1][$i]; + $stringKey = $matches[2][$i]; + + if (0 === strpos($stringKey, self::PRIVACY_METADATA_STRING . ':')) { + $strings[$stringKey] = "Privacy metadata for subsystem '{$subsystemName}'"; + } + } + } + + // Look for link_subsystem calls (alternative method) + // Pattern: link_subsystem('subsystem', 'privacy:metadata:subsystem') + // @psalm-suppress ArgumentTypeCoercion + if (preg_match_all(RegexPatterns::linkSubsystem(), $content, $matches)) { + for ($i = 0; $i < count($matches[1]); ++$i) { + $subsystemName = $matches[1][$i]; + $stringKey = $matches[2][$i]; + + if (0 === strpos($stringKey, self::PRIVACY_METADATA_STRING . ':')) { + $strings[$stringKey] = "Privacy metadata for linked subsystem '{$subsystemName}'"; + } + } + } + + // Look for add_user_preference calls + // Pattern: add_user_preference('preference_name', 'privacy:preference:name') or 'privacy:metadata:preference:name' + // @psalm-suppress ArgumentTypeCoercion + if (preg_match_all(RegexPatterns::addUserPreference(), $content, $matches)) { + for ($i = 0; $i < count($matches[1]); ++$i) { + $stringKey = $matches[1][$i]; + + // Accept both privacy:metadata: and privacy:preference: strings + if (0 === strpos($stringKey, 'privacy:')) { + $strings[$stringKey] = 'Privacy metadata for user preference'; + } + } + } + + return $strings; + } + + /** + * Find strings returned from get_reason() method in null providers. + * + * @param string $content file content + * @param string $filePath the path to the provider file + * + * @return array array of string keys and their descriptions + * + * @psalm-suppress ArgumentTypeCoercion + */ + private function findGetReasonStrings(string $content, string $filePath): array + { + $strings = []; + + // Look for return statements in get_reason method + // This approach is more robust - find any return statement with string literals + // @psalm-suppress ArgumentTypeCoercion + if (preg_match_all(RegexPatterns::returnStatement(), $content, $returnMatches)) { + foreach ($returnMatches[1] as $stringKey) { + // Only include privacy-related strings + if (0 === strpos($stringKey, 'privacy:')) { + $strings[$stringKey] = 'Privacy metadata string returned from get_reason() method'; + } + } + } + + return $strings; + } +} diff --git a/src/MissingStrings/Checker/ClassMethodChecker/SearchAreaChecker.php b/src/MissingStrings/Checker/ClassMethodChecker/SearchAreaChecker.php new file mode 100644 index 00000000..6b6172f3 --- /dev/null +++ b/src/MissingStrings/Checker/ClassMethodChecker/SearchAreaChecker.php @@ -0,0 +1,116 @@ +initializeStringUsageFinder(); + } + + /** + * Get the name of this checker. + */ + public function getName(): string + { + return 'Search Area'; + } + + /** + * Check if this checker applies to the given plugin. + * + * @param Plugin $plugin the plugin to check + * + * @return bool true if search area classes exist + */ + public function appliesTo(Plugin $plugin): bool + { + $searchFiles = $this->findClassFiles($plugin, 'classes/search'); + + return !empty($searchFiles); + } + + /** + * Analyze search area classes and extract required strings. + * + * @param Plugin $plugin the plugin to analyze + * + * @return ValidationResult the result containing required strings + */ + protected function analyzeClasses(Plugin $plugin): ValidationResult + { + $result = new ValidationResult(); + + // Look for search area classes in classes/search/ + $searchFiles = $this->findClassFiles($plugin, 'classes/search'); + + foreach ($searchFiles as $filePath) { + try { + $classInfo = $this->parseClassFile($filePath); + + // Check if class extends core_search\base or its subclasses + if ($this->extendsClass($classInfo, 'core_search\\base') + || $this->extendsClass($classInfo, 'core_search\\base_mod') + || $this->extendsClass($classInfo, 'core_search\\base_activity') + || $this->extendsClass($classInfo, 'base') + || $this->extendsClass($classInfo, 'base_mod') + || $this->extendsClass($classInfo, 'base_activity')) { + $this->analyzeSearchAreaClass($classInfo, $result, $filePath); + } + } catch (\Exception $e) { + $result->addRawError("Error analyzing search class {$filePath}: " . $e->getMessage()); + } + } + + return $result; + } + + /** + * Analyze a specific search area class for required strings. + * + * @param array $classInfo class information from parseClassFile() + * @param ValidationResult $result result object to add strings to + * @param string $filePath the path to the search class file + */ + private function analyzeSearchAreaClass(array $classInfo, ValidationResult $result, string $filePath): void + { + $className = $classInfo['name']; + + // The pattern is simply search:{classname} + // Example: classes/search/post.php requires search:post string + $stringKey = 'search:' . strtolower($className); + $description = "Search area display name for {$className} search class"; + $pattern = '/class\s+' . preg_quote($className, '/') . '\s/'; + + // Use the trait helper method for custom pattern detection + $this->addRequiredStringWithCustomPattern($result, $stringKey, $filePath, $className, $pattern, $description); + } +} diff --git a/src/MissingStrings/Checker/DatabaseFileChecker/AbstractDatabaseChecker.php b/src/MissingStrings/Checker/DatabaseFileChecker/AbstractDatabaseChecker.php new file mode 100644 index 00000000..f688fa08 --- /dev/null +++ b/src/MissingStrings/Checker/DatabaseFileChecker/AbstractDatabaseChecker.php @@ -0,0 +1,205 @@ +directory . '/' . $this->getDatabaseFile(); + + if (!file_exists($filePath)) { + // File doesn't exist, no strings required + return $result; + } + + try { + return $this->parseFile($filePath, $plugin); + } catch (\Exception $e) { + $result->addRawError("Error parsing {$this->getDatabaseFile()}: " . $e->getMessage()); + + return $result; + } + } + + /** + * Check if this checker applies to the given plugin. + * + * @param Plugin $plugin the plugin to check + * + * @return bool true if the database file exists + */ + public function appliesTo(Plugin $plugin): bool + { + $filePath = $plugin->directory . '/' . $this->getDatabaseFile(); + + return file_exists($filePath); + } + + /** + * Load and evaluate a PHP database file safely. + * + * @param string $filePath the path to the PHP file + * + * @throws \Exception if the file cannot be loaded or parsed + * + * @return array the extracted data array + * + * @psalm-suppress UnresolvableInclude + */ + protected function loadPhpFile(string $filePath): array + { + if (!is_readable($filePath)) { + throw new \Exception("File is not readable: {$filePath}"); + } + + // Use CheckerUtils to load the file with proper constant definitions + $data = CheckerUtils::loadPhpFile($filePath, 'data'); + if (null === $data) { + throw new \Exception("Cannot load or parse file: {$filePath}"); + } + + // Capture any variables defined in the file + $originalVars = get_defined_vars(); + + // Include the file again to get all variables + // @psalm-suppress UnresolvableInclude + include $filePath; + + // Get new variables defined by the file + $newVars = array_diff_key(get_defined_vars(), $originalVars); + + // Remove our own variables + unset($newVars['originalVars'], $newVars['filePath'], $newVars['data']); + + return $newVars; + } + + /** + * Load a JSON file safely. + * + * @param string $filePath the path to the JSON file + * + * @throws \Exception if the file cannot be loaded or parsed + * + * @return array the decoded JSON data + */ + protected function loadJsonFile(string $filePath): array + { + if (!is_readable($filePath)) { + throw new \Exception("File is not readable: {$filePath}"); + } + + $content = file_get_contents($filePath); + if (false === $content) { + throw new \Exception("Cannot read file: {$filePath}"); + } + + $data = json_decode($content, true); + if (JSON_ERROR_NONE !== json_last_error()) { + throw new \Exception("Invalid JSON in {$filePath}: " . json_last_error_msg()); + } + + return $data; + } + + /** + * Generate a string key based on a pattern and value. + * + * @param string $pattern The pattern (e.g., 'capability_{name}'). + * @param string $value the value to substitute + * + * @return string the generated string key + */ + protected function generateStringKey(string $pattern, string $value): string + { + return str_replace('{name}', $value, $pattern); + } + + /** + * Clean a string key by removing plugin prefix if present. + * + * @param string $key the string key + * @param string $component the plugin component + * + * @return string the cleaned key + */ + protected function cleanStringKey(string $key, string $component): string + { + // Remove component prefix if present (e.g., 'mod_forum:addnews' -> 'addnews') + $prefix = $component . ':'; + if (0 === strpos($key, $prefix)) { + return substr($key, strlen($prefix)); + } + + return $key; + } + + /** + * Validate that a required array key exists. + * + * @param array $data the data array + * @param string $key the required key + * @param string $context context for error messages + * + * @throws \Exception if the key is missing + */ + protected function requireKey(array $data, string $key, string $context = ''): void + { + if (!array_key_exists($key, $data)) { + $message = "Missing required key '{$key}'"; + if ($context) { + $message .= " in {$context}"; + } + throw new \Exception($message); + } + } +} diff --git a/src/MissingStrings/Checker/DatabaseFileChecker/CachesChecker.php b/src/MissingStrings/Checker/DatabaseFileChecker/CachesChecker.php new file mode 100644 index 00000000..44a3df29 --- /dev/null +++ b/src/MissingStrings/Checker/DatabaseFileChecker/CachesChecker.php @@ -0,0 +1,136 @@ +initializeStringUsageFinder(); + } + + /** + * Get the name of this checker. + */ + public function getName(): string + { + return 'Caches'; + } + + /** + * Check if this checker applies to the given plugin. + * + * @param Plugin $plugin the plugin to check + * + * @return bool true if this checker should run for the plugin + */ + public function appliesTo(Plugin $plugin): bool + { + return CheckerUtils::hasDatabaseFile($plugin, 'caches.php'); + } + + /** + * Check the plugin for required strings. + * + * @param Plugin $plugin the plugin to check + * + * @return ValidationResult the result of the check + * + * @psalm-suppress TypeDoesNotContainType + */ + public function check(Plugin $plugin): ValidationResult + { + $result = new ValidationResult(); + $filePath = CheckerUtils::getDatabaseFilePath($plugin, 'caches.php'); + + try { + $definitions = CheckerUtils::loadPhpFile($filePath, 'definitions'); + + if (null === $definitions) { + $result->addRawWarning('Could not load db/caches.php file'); + + return $result; + } + + // @psalm-suppress TypeDoesNotContainType + if (!is_array($definitions)) { + $result->addRawWarning('$definitions is not an array in db/caches.php'); + + return $result; + } + + foreach ($definitions as $cacheName => $cacheDefinition) { + $this->processCacheDefinition($cacheName, $cacheDefinition, $plugin, $result, $filePath); + } + } catch (\Exception $e) { + $result->addRawWarning('Error parsing db/caches.php: ' . $e->getMessage()); + } + + return $result; + } + + /** + * Process a single cache definition. + * + * @param string $cacheName the cache name + * @param array $cacheDefinition the cache definition + * @param Plugin $plugin the plugin being checked + * @param ValidationResult $result the result to add strings to + * @param string $filePath The path to the caches.php file. + * + * @psalm-suppress DocblockTypeContradiction + * @psalm-suppress TypeDoesNotContainType + */ + private function processCacheDefinition(string $cacheName, $cacheDefinition, Plugin $plugin, ValidationResult $result, string $filePath): void + { + // @psalm-suppress DocblockTypeContradiction + if (!is_array($cacheDefinition)) { + $result->addRawWarning("Cache definition '{$cacheName}' is not an array"); + + return; + } + + // Generate the required string key for this cache definition + $stringKey = self::CACHE_DEFINITION_STRING_PATTERN . $cacheName; + $description = "Cache definition: {$cacheName}"; + + // Use the trait helper method for array key detection + $this->addRequiredStringWithArrayKey($result, $stringKey, $filePath, $cacheName, $description); + } +} diff --git a/src/MissingStrings/Checker/DatabaseFileChecker/CapabilitiesChecker.php b/src/MissingStrings/Checker/DatabaseFileChecker/CapabilitiesChecker.php new file mode 100644 index 00000000..6334c0dd --- /dev/null +++ b/src/MissingStrings/Checker/DatabaseFileChecker/CapabilitiesChecker.php @@ -0,0 +1,193 @@ +usageFinder = new StringUsageFinder(); + } + + /** + * Set the file discovery service. + * + * @param FileDiscovery $fileDiscovery the file discovery service + */ + public function setFileDiscovery(FileDiscovery $fileDiscovery): void + { + $this->fileDiscovery = $fileDiscovery; + } + + /** + * Get the name of this checker. + */ + public function getName(): string + { + return 'Capabilities'; + } + + /** + * Check if this checker applies to the given plugin. + * + * @param Plugin $plugin the plugin to check + * + * @return bool true if this checker should run for the plugin + */ + public function appliesTo(Plugin $plugin): bool + { + if ($this->fileDiscovery) { + return $this->fileDiscovery->hasDatabaseFile('access.php'); + } + + // Fallback to CheckerUtils + return CheckerUtils::hasDatabaseFile($plugin, 'access.php'); + } + + /** + * Check the plugin for required strings. + * + * @param Plugin $plugin the plugin to check + * + * @return ValidationResult the result of the check + * + * @psalm-suppress TypeDoesNotContainType + */ + public function check(Plugin $plugin): ValidationResult + { + $result = new ValidationResult(); + + // Use FileDiscovery if available, otherwise fall back to CheckerUtils + $filePath = $this->fileDiscovery + ? $this->fileDiscovery->getDatabaseFile('access.php') + : CheckerUtils::getDatabaseFilePath($plugin, 'access.php'); + + try { + if (null === $filePath) { + $result->addRawWarning('Could not find db/access.php file'); + + return $result; + } + + $capabilities = CheckerUtils::loadPhpFile($filePath, 'capabilities'); + + if (null === $capabilities) { + $result->addRawWarning('Could not load db/access.php file'); + + return $result; + } + + // @psalm-suppress TypeDoesNotContainType + if (!is_array($capabilities)) { + $result->addRawWarning('$capabilities is not an array in db/access.php'); + + return $result; + } + + foreach ($capabilities as $capabilityName => $capabilityDef) { + $this->processCapability($capabilityName, $capabilityDef, $plugin, $result); + } + } catch (\Exception $e) { + $result->addRawWarning('Error parsing db/access.php: ' . $e->getMessage()); + } + + return $result; + } + + /** + * Process a single capability definition. + * + * @param string $capabilityName the capability name + * @param array $capabilityDef the capability definition + * @param Plugin $plugin the plugin being checked + * @param ValidationResult $result the result to add strings to + * + * @psalm-suppress DocblockTypeContradiction + * @psalm-suppress TypeDoesNotContainType + */ + private function processCapability(string $capabilityName, $capabilityDef, Plugin $plugin, ValidationResult $result): void + { + // @psalm-suppress DocblockTypeContradiction + if (!is_array($capabilityDef)) { + $result->addRawWarning("Capability '{$capabilityName}' definition is not an array"); + + return; + } + + // Get the file path + $filePath = $this->fileDiscovery + ? $this->fileDiscovery->getDatabaseFile('access.php') + : CheckerUtils::getDatabaseFilePath($plugin, 'access.php'); + + // Extract the plugin name and capability from the full capability name + // Capability format: "plugintype/pluginname:capability" -> we want "pluginname:capability" + if (false !== strpos($capabilityName, '/') && false !== strpos($capabilityName, ':')) { + // Split by '/' to get plugintype and pluginname:capability + $parts = explode('/', $capabilityName, 2); + if (2 === count($parts)) { + $stringKey = $parts[1]; // This gives us "pluginname:capability" + } else { + $stringKey = $capabilityName; + } + } else { + $stringKey = $capabilityName; + } + + // Create context with file and description + $context = new StringContext($filePath, null, "Capability: {$capabilityName}"); + + // Find the line number where this capability is defined + if (null !== $filePath) { + $lineNumber = $this->usageFinder->findArrayKeyLine($filePath, $capabilityName); + if (null !== $lineNumber) { + $context->setLine($lineNumber); + } + } + + $result->addRequiredString($stringKey, $context); + } +} diff --git a/src/MissingStrings/Checker/DatabaseFileChecker/MessagesChecker.php b/src/MissingStrings/Checker/DatabaseFileChecker/MessagesChecker.php new file mode 100644 index 00000000..721f8f90 --- /dev/null +++ b/src/MissingStrings/Checker/DatabaseFileChecker/MessagesChecker.php @@ -0,0 +1,128 @@ +initializeStringUsageFinder(); + } + + /** + * Get the database file path. + */ + protected function getDatabaseFile(): string + { + return 'db/messages.php'; + } + + /** + * Get the name of this checker. + */ + public function getName(): string + { + return 'Messages'; + } + + /** + * Parse the messages.php file and extract required strings. + * + * @param string $filePath The full path to the messages.php file. + * @param Plugin $plugin the plugin being checked + * + * @return ValidationResult the result containing required strings + */ + protected function parseFile(string $filePath, Plugin $plugin): ValidationResult + { + $result = new ValidationResult(); + + try { + $fileVars = $this->loadPhpFile($filePath); + + if (!isset($fileVars['messageproviders'])) { + $result->addRawWarning('No $messageproviders array found in db/messages.php'); + + return $result; + } + + $messageproviders = $fileVars['messageproviders']; + + if (!is_array($messageproviders)) { + $result->addRawWarning('$messageproviders is not an array in db/messages.php'); + + return $result; + } + + foreach ($messageproviders as $providerName => $providerDefinition) { + $this->processMessageProvider($providerName, $providerDefinition, $plugin, $result, $filePath); + } + } catch (\Exception $e) { + $result->addRawWarning('Error parsing db/messages.php: ' . $e->getMessage()); + } + + return $result; + } + + /** + * Process a single message provider definition. + * + * @param string $providerName the provider name + * @param array $providerDefinition the provider definition + * @param Plugin $plugin the plugin being checked + * @param ValidationResult $result the result to add strings to + * @param string $filePath The path to the messages.php file. + * + * @psalm-suppress DocblockTypeContradiction + */ + private function processMessageProvider(string $providerName, $providerDefinition, Plugin $plugin, ValidationResult $result, string $filePath): void + { + // @psalm-suppress DocblockTypeContradiction + if (!is_array($providerDefinition)) { + $result->addRawWarning("Message provider '{$providerName}' definition is not an array"); + + return; + } + + // Generate the required string key for this message provider + $stringKey = self::MESSAGE_PROVIDER_STRING_PATTERN . $providerName; + $description = "Message provider: {$providerName}"; + + // Use the trait helper method for array key detection + $this->addRequiredStringWithArrayKey($result, $stringKey, $filePath, $providerName, $description); + } +} diff --git a/src/MissingStrings/Checker/DatabaseFileChecker/MobileChecker.php b/src/MissingStrings/Checker/DatabaseFileChecker/MobileChecker.php new file mode 100644 index 00000000..7aaa9cb4 --- /dev/null +++ b/src/MissingStrings/Checker/DatabaseFileChecker/MobileChecker.php @@ -0,0 +1,104 @@ +initializeStringUsageFinder(); + } + + /** + * Get the database file path. + */ + protected function getDatabaseFile(): string + { + return 'db/mobile.php'; + } + + /** + * Get the name of this checker. + */ + public function getName(): string + { + return 'Mobile'; + } + + /** + * Parse the database file and extract required strings. + * + * @param string $filePath the full path to the database file + * @param Plugin $plugin the plugin being checked + */ + protected function parseFile(string $filePath, Plugin $plugin): \MoodlePluginCI\MissingStrings\ValidationResult + { + $result = new \MoodlePluginCI\MissingStrings\ValidationResult(); + + try { + $vars = $this->loadPhpFile($filePath); + + if (!isset($vars['addons']) || !is_array($vars['addons'])) { + $result->addRawError('No valid $addons array found in db/mobile.php'); + + return $result; + } + + foreach ($vars['addons'] as $addonName => $addon) { + if (!is_array($addon)) { + continue; + } + + // Check if this addon has language strings defined + if (!isset($addon['lang']) || !is_array($addon['lang'])) { + continue; + } + + // Process each language string requirement + foreach ($addon['lang'] as $langEntry) { + if (!is_array($langEntry) || count($langEntry) < 2) { + continue; + } + + $stringKey = $langEntry[0]; + $component = $langEntry[1]; + + // Only check strings for the current plugin component + if ($component === $plugin->component) { + $description = "Mobile addon '{$addonName}' language string"; + + // Use the trait helper method for string literal detection + $this->addRequiredStringWithStringLiteral($result, $stringKey, $filePath, $stringKey, $description); + } + } + } + } catch (\Exception $e) { + $result->addRawError('Error parsing db/mobile.php: ' . $e->getMessage()); + } + + return $result; + } +} diff --git a/src/MissingStrings/Checker/DatabaseFileChecker/SubpluginsChecker.php b/src/MissingStrings/Checker/DatabaseFileChecker/SubpluginsChecker.php new file mode 100644 index 00000000..07317490 --- /dev/null +++ b/src/MissingStrings/Checker/DatabaseFileChecker/SubpluginsChecker.php @@ -0,0 +1,199 @@ +initializeStringUsageFinder(); + } + + /** + * Get the database file path. + */ + protected function getDatabaseFile(): string + { + // Prefer JSON format (Moodle 3.8+) over PHP format + return 'db/subplugins.json'; + } + + /** + * Get the name of this checker. + */ + public function getName(): string + { + return 'Subplugins'; + } + + /** + * Check if this checker applies to the given plugin. + * + * @param Plugin $plugin the plugin to check + * + * @return bool true if either subplugins file exists + */ + public function appliesTo(Plugin $plugin): bool + { + $jsonFile = $plugin->directory . '/db/subplugins.json'; + $phpFile = $plugin->directory . '/db/subplugins.php'; + + return file_exists($jsonFile) || file_exists($phpFile); + } + + /** + * Check the plugin for required strings. + * + * @param Plugin $plugin the plugin to check + * + * @return \MoodlePluginCI\MissingStrings\ValidationResult the result of the check + */ + public function check(Plugin $plugin): \MoodlePluginCI\MissingStrings\ValidationResult + { + $result = new \MoodlePluginCI\MissingStrings\ValidationResult(); + + // Check for both JSON and PHP format files + $jsonFile = $plugin->directory . '/db/subplugins.json'; + $phpFile = $plugin->directory . '/db/subplugins.php'; + + if (!file_exists($jsonFile) && !file_exists($phpFile)) { + // No subplugins file exists - this is an error since check was called + $result->addRawError('No subplugins file found (db/subplugins.json or db/subplugins.php)'); + + return $result; + } + + try { + return $this->parseFile('', $plugin); // parseFile handles file detection internally + } catch (\Exception $e) { + $result->addRawError('Error parsing subplugins file: ' . $e->getMessage()); + + return $result; + } + } + + /** + * Parse the database file and extract required strings. + * + * @param string $filePath the full path to the database file + * @param Plugin $plugin the plugin being checked + */ + protected function parseFile(string $filePath, Plugin $plugin): \MoodlePluginCI\MissingStrings\ValidationResult + { + $result = new \MoodlePluginCI\MissingStrings\ValidationResult(); + + try { + // Try JSON format first (preferred) + $jsonFile = $plugin->directory . '/db/subplugins.json'; + $phpFile = $plugin->directory . '/db/subplugins.php'; + + $subpluginTypes = []; + $actualFilePath = ''; + + if (file_exists($jsonFile)) { + $subpluginTypes = $this->parseJsonFile($jsonFile); + $actualFilePath = $jsonFile; + } elseif (file_exists($phpFile)) { + $subpluginTypes = $this->parsePhpFile($phpFile); + $actualFilePath = $phpFile; + } else { + $result->addRawError('No subplugins file found (db/subplugins.json or db/subplugins.php)'); + + return $result; + } + + // Generate required strings for each subplugin type + foreach ($subpluginTypes as $typeName => $path) { + // Each subplugin type requires two strings: singular and plural + $singularKey = self::SUBPLUGIN_TYPE_STRING_PATTERN . $typeName; + $pluralKey = self::SUBPLUGIN_TYPE_STRING_PATTERN . $typeName . '_plural'; + + $singularDescription = "Subplugin type: {$typeName} (singular)"; + $pluralDescription = "Subplugin type: {$typeName} (plural)"; + + // Use the trait helper method for string literal detection + $this->addRequiredStringWithStringLiteral($result, $singularKey, $actualFilePath, $typeName, $singularDescription); + $this->addRequiredStringWithStringLiteral($result, $pluralKey, $actualFilePath, $typeName, $pluralDescription); + } + } catch (\Exception $e) { + $result->addRawError('Error parsing subplugins file: ' . $e->getMessage()); + } + + return $result; + } + + /** + * Parse JSON subplugins file. + * + * @param string $filePath path to the JSON file + * + * @throws \Exception if the file cannot be parsed + * + * @return array array of subplugin types + */ + private function parseJsonFile(string $filePath): array + { + $data = $this->loadJsonFile($filePath); + + // Support both new format (subplugintypes) and legacy format (plugintypes) + if (isset($data['subplugintypes']) && is_array($data['subplugintypes'])) { + return $data['subplugintypes']; + } + + if (isset($data['plugintypes']) && is_array($data['plugintypes'])) { + return $data['plugintypes']; + } + + throw new \Exception('No valid subplugin types found in db/subplugins.json'); + } + + /** + * Parse PHP subplugins file. + * + * @param string $filePath path to the PHP file + * + * @throws \Exception if the file cannot be parsed + * + * @return array array of subplugin types + */ + private function parsePhpFile(string $filePath): array + { + $vars = $this->loadPhpFile($filePath); + + if (!isset($vars['subplugins']) || !is_array($vars['subplugins'])) { + throw new \Exception('No valid $subplugins array found in db/subplugins.php'); + } + + return $vars['subplugins']; + } +} diff --git a/src/MissingStrings/Checker/DatabaseFileChecker/TagsChecker.php b/src/MissingStrings/Checker/DatabaseFileChecker/TagsChecker.php new file mode 100644 index 00000000..c5fe42d4 --- /dev/null +++ b/src/MissingStrings/Checker/DatabaseFileChecker/TagsChecker.php @@ -0,0 +1,95 @@ +initializeStringUsageFinder(); + } + + /** + * Get the database file path. + */ + protected function getDatabaseFile(): string + { + return 'db/tag.php'; + } + + /** + * Get the name of this checker. + */ + public function getName(): string + { + return 'Tags'; + } + + /** + * Parse the database file and extract required strings. + * + * @param string $filePath the full path to the database file + * @param Plugin $plugin the plugin being checked + */ + protected function parseFile(string $filePath, Plugin $plugin): \MoodlePluginCI\MissingStrings\ValidationResult + { + $result = new \MoodlePluginCI\MissingStrings\ValidationResult(); + + try { + $vars = $this->loadPhpFile($filePath); + + if (!isset($vars['tagareas']) || !is_array($vars['tagareas'])) { + $result->addRawError('No valid $tagareas array found in db/tag.php'); + + return $result; + } + + foreach ($vars['tagareas'] as $index => $tagarea) { + if (!is_array($tagarea) || !isset($tagarea['itemtype'])) { + continue; + } + + // Generate the required string key for this tag area + $stringKey = self::TAG_AREA_STRING_PATTERN . $tagarea['itemtype']; + $description = "Tag area: {$tagarea['itemtype']}"; + + // Use the trait helper method for string literal detection + $this->addRequiredStringWithStringLiteral($result, $stringKey, $filePath, $tagarea['itemtype'], $description); + } + } catch (\Exception $e) { + $result->addRawError('Error parsing db/tag.php: ' . $e->getMessage()); + } + + return $result; + } +} diff --git a/src/MissingStrings/Checker/FileDiscoveryAwareInterface.php b/src/MissingStrings/Checker/FileDiscoveryAwareInterface.php new file mode 100644 index 00000000..ca20dc7c --- /dev/null +++ b/src/MissingStrings/Checker/FileDiscoveryAwareInterface.php @@ -0,0 +1,34 @@ +usageFinder = new StringUsageFinder(); + } + + /** + * Add a required string with context based on array key detection. + * Suitable for database files like db/access.php, db/messages.php, etc. + * + * @param ValidationResult $result the result to add the string to + * @param string $stringKey the language string key to add + * @param string $filePath the file path where the key is defined + * @param string $arrayKey the array key to search for in the file + * @param string $description description of what this string is for + */ + protected function addRequiredStringWithArrayKey( + ValidationResult $result, + string $stringKey, + string $filePath, + string $arrayKey, + string $description + ): void { + $context = new StringContext($filePath, null, $description); + + $lineNumber = $this->usageFinder->findArrayKeyLine($filePath, $arrayKey); + if (null !== $lineNumber) { + $context->setLine($lineNumber); + } + + $result->addRequiredString($stringKey, $context); + } + + /** + * Add a required string with context based on string literal detection. + * Suitable for finding quoted strings in PHP code. + * + * @param ValidationResult $result the result to add the string to + * @param string $stringKey the language string key to add + * @param string $filePath the file path where the key is used + * @param string $searchKey the string literal to search for + * @param string $description description of what this string is for + */ + protected function addRequiredStringWithStringLiteral( + ValidationResult $result, + string $stringKey, + string $filePath, + string $searchKey, + string $description + ): void { + $context = new StringContext($filePath, null, $description); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, $searchKey); + if (null !== $lineNumber) { + $context->setLine($lineNumber); + } + + $result->addRequiredString($stringKey, $context); + } + + /** + * Add a required string with context based on custom pattern detection. + * Suitable for complex pattern matching scenarios. + * + * @param ValidationResult $result the result to add the string to + * @param string $stringKey the language string key to add + * @param string $filePath the file path where the key is used + * @param string $searchKey the key to search for + * @param string $pattern the regex pattern to use for matching + * @param string $description description of what this string is for + */ + protected function addRequiredStringWithCustomPattern( + ValidationResult $result, + string $stringKey, + string $filePath, + string $searchKey, + string $pattern, + string $description + ): void { + $context = new StringContext($filePath, null, $description); + + $lineNumber = $this->usageFinder->findLineInFile($filePath, $searchKey, $pattern); + if (null !== $lineNumber) { + $context->setLine($lineNumber); + } + + $result->addRequiredString($stringKey, $context); + } + + /** + * Create a StringContext object with optional line number detection. + * Generic helper for cases that don't fit the standard patterns. + * + * @param string $filePath the file path where the string is used + * @param string $description description of what this string is for + * @param string|null $searchKey optional key to search for line number + * @param string|null $pattern optional custom pattern for line detection + * + * @return StringContext the created context object + */ + protected function createStringContext( + string $filePath, + string $description, + ?string $searchKey = null, + ?string $pattern = null + ): StringContext { + $context = new StringContext($filePath, null, $description); + + if (null !== $searchKey) { + $lineNumber = null; + if (null !== $pattern) { + $lineNumber = $this->usageFinder->findLineInFile($filePath, $searchKey, $pattern); + } else { + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, $searchKey); + } + + if (null !== $lineNumber) { + $context->setLine($lineNumber); + } + } + + return $context; + } +} diff --git a/src/MissingStrings/Discovery/SubpluginDiscovery.php b/src/MissingStrings/Discovery/SubpluginDiscovery.php new file mode 100644 index 00000000..b5cab32b --- /dev/null +++ b/src/MissingStrings/Discovery/SubpluginDiscovery.php @@ -0,0 +1,324 @@ +getSubpluginPaths($mainPlugin); + + foreach ($subpluginPaths as $subpluginType => $basePath) { + $typeSubplugins = $this->discoverSubpluginsOfType($mainPlugin, $subpluginType, $basePath); + $subplugins = array_merge($subplugins, $typeSubplugins); + } + + return $subplugins; + } + + /** + * Get subplugin type definitions and their base paths. + * + * @param Plugin $mainPlugin The main plugin + * + * @return array Array mapping subplugin type to base path + */ + public function getSubpluginPaths(Plugin $mainPlugin): array + { + $paths = []; + + // Try JSON format first (preferred) + $jsonPaths = $this->readSubpluginsJson($mainPlugin); + if (!empty($jsonPaths)) { + $paths = array_merge($paths, $jsonPaths); + } + + // Try PHP format as fallback + $phpPaths = $this->readSubpluginsPhp($mainPlugin); + if (!empty($phpPaths)) { + $paths = array_merge($paths, $phpPaths); + } + + return $paths; + } + + /** + * Discover subplugins of a specific type in a base directory. + * + * @param Plugin $mainPlugin The main plugin + * @param string $subpluginType The subplugin type (e.g., 'assessfreqreport') + * @param string $basePath The base path to scan for subplugins (relative to Moodle root) + * + * @return Plugin[] Array of discovered subplugins + */ + private function discoverSubpluginsOfType(Plugin $mainPlugin, string $subpluginType, string $basePath): array + { + $subplugins = []; + + // Paths in subplugins.json are relative to Moodle root, not plugin directory + $moodleRoot = $this->getMoodleRoot($mainPlugin); + $fullBasePath = $moodleRoot . '/' . $basePath; + + if (!is_dir($fullBasePath)) { + return $subplugins; + } + + $subpluginDirs = $this->scanSubpluginDirectories($fullBasePath); + + foreach ($subpluginDirs as $subpluginName) { + $subpluginPath = $fullBasePath . '/' . $subpluginName; + + // Check if this looks like a valid Moodle plugin + if ($this->isValidSubplugin($subpluginPath)) { + $component = $subpluginType . '_' . $subpluginName; + $subplugins[] = new Plugin($component, $subpluginType, $subpluginName, $subpluginPath); + } + } + + return $subplugins; + } + + /** + * Read subplugin definitions from db/subplugins.json. + * + * @param Plugin $mainPlugin The main plugin + * + * @return array Array mapping subplugin type to base path + */ + private function readSubpluginsJson(Plugin $mainPlugin): array + { + $jsonFile = $mainPlugin->directory . '/db/subplugins.json'; + + if (!file_exists($jsonFile)) { + return []; + } + + $content = file_get_contents($jsonFile); + if (false === $content) { + return []; + } + + $data = json_decode($content, true); + if (!is_array($data)) { + return []; + } + + // Support both 'plugintypes' (preferred) and 'subplugintypes' (legacy) + $pluginTypes = $data['plugintypes'] ?? $data['subplugintypes'] ?? []; + + if (!is_array($pluginTypes)) { + return []; + } + + return $pluginTypes; + } + + /** + * Read subplugin definitions from db/subplugins.php. + * + * @param Plugin $mainPlugin The main plugin + * + * @return array Array mapping subplugin type to base path + * + * @psalm-suppress UnresolvableInclude + */ + private function readSubpluginsPhp(Plugin $mainPlugin): array + { + $phpFile = $mainPlugin->directory . '/db/subplugins.php'; + + if (!file_exists($phpFile)) { + return []; + } + + // Safely include the PHP file and extract subplugin types + $subplugins = []; + + // Create isolated scope to prevent variable pollution + $extractSubplugins = function () use ($phpFile): array { + $subplugins = []; + // @psalm-suppress UnresolvableInclude + include $phpFile; + + return $subplugins; + }; + + try { + return $extractSubplugins(); + } catch (\Throwable $e) { + // If there's any error reading the PHP file, return empty array + return []; + } + } + + /** + * Scan a directory for potential subplugin directories. + * + * @param string $basePath The base path to scan + * + * @return string[] Array of directory names that could be subplugins + */ + private function scanSubpluginDirectories(string $basePath): array + { + $directories = []; + + if (!is_readable($basePath)) { + return $directories; + } + + $iterator = new \DirectoryIterator($basePath); + + foreach ($iterator as $item) { + if ($item->isDot() || !$item->isDir()) { + continue; + } + + $dirName = $item->getFilename(); + + // Skip hidden directories and common non-plugin directories + if (0 === strpos($dirName, '.') || in_array($dirName, ['tests', 'backup', 'tmp'], true)) { + continue; + } + + $directories[] = $dirName; + } + + sort($directories); + + return $directories; + } + + /** + * Check if a directory contains a valid Moodle subplugin. + * + * @param string $subpluginPath Path to the potential subplugin directory + * + * @return bool True if this looks like a valid subplugin + */ + private function isValidSubplugin(string $subpluginPath): bool + { + // A valid subplugin should have at least one of these files + $requiredFiles = [ + 'version.php', // Standard version file + 'lang/en/*.php', // Language files + 'lib.php', // Library file + ]; + + foreach ($requiredFiles as $pattern) { + if (false !== strpos($pattern, '*')) { + // Handle glob patterns + $files = glob($subpluginPath . '/' . $pattern); + if (!empty($files)) { + return true; + } + } else { + // Handle exact file matches + if (file_exists($subpluginPath . '/' . $pattern)) { + return true; + } + } + } + + return false; + } + + /** + * Get the Moodle root directory from a plugin directory. + * + * @param Plugin $plugin The main plugin + * + * @return string The Moodle root directory path + */ + private function getMoodleRoot(Plugin $plugin): string + { + // Plugin directory structure: {moodle_root}/{plugin_type}/{plugin_name} + // For local plugins: {moodle_root}/local/{plugin_name} + // For mod plugins: {moodle_root}/mod/{plugin_name} + // etc. + + $pluginDir = $plugin->directory; + $pluginType = $plugin->type; + + // Calculate how many levels up we need to go to reach Moodle root + if ('mod' === $pluginType || 'local' === $pluginType || 'block' === $pluginType + || 'theme' === $pluginType || 'filter' === $pluginType || 'format' === $pluginType + || 'repository' === $pluginType || 'portfolio' === $pluginType || 'qtype' === $pluginType + || 'qformat' === $pluginType || 'auth' === $pluginType || 'enrol' === $pluginType + || 'message' === $pluginType || 'dataformat' === $pluginType || 'webservice' === $pluginType + || 'cachestore' === $pluginType || 'cachelock' === $pluginType || 'fileconverter' === $pluginType) { + // These are direct subdirectories of Moodle root + return dirname($pluginDir, 2); + } + + if (0 === strpos($pluginType, 'tool_')) { + // Admin tools: {moodle_root}/admin/tool/{plugin_name} + return dirname($pluginDir, 3); + } + + if (0 === strpos($pluginType, 'report_')) { + // Reports: {moodle_root}/admin/report/{plugin_name} or {moodle_root}/course/report/{plugin_name} + $parentDir = dirname($pluginDir); + if ('report' === basename($parentDir)) { + return dirname($pluginDir, 3); + } + } + + if (0 === strpos($pluginType, 'gradereport_')) { + // Grade reports: {moodle_root}/grade/report/{plugin_name} + return dirname($pluginDir, 3); + } + + if (0 === strpos($pluginType, 'gradeimport_') || 0 === strpos($pluginType, 'gradeexport_')) { + // Grade import/export: {moodle_root}/grade/import/{plugin_name} or {moodle_root}/grade/export/{plugin_name} + return dirname($pluginDir, 3); + } + + // For subplugin types (like assessfreqreport), the plugin might be in a custom location + // Try to find Moodle root by looking for config.php or version.php + $currentDir = $pluginDir; + for ($i = 0; $i < 10; ++$i) { // Prevent infinite loop + $parentDir = dirname($currentDir); + if ($parentDir === $currentDir) { + break; // Reached filesystem root + } + + // Check if this looks like Moodle root + if (file_exists($parentDir . '/config.php') && file_exists($parentDir . '/version.php')) { + return $parentDir; + } + + $currentDir = $parentDir; + } + + // Fallback: assume plugin is 2 levels deep (most common case) + return dirname($pluginDir, 2); + } +} diff --git a/src/MissingStrings/ErrorHandler.php b/src/MissingStrings/ErrorHandler.php new file mode 100644 index 00000000..a01c2d66 --- /dev/null +++ b/src/MissingStrings/ErrorHandler.php @@ -0,0 +1,262 @@ +result = $result; + $this->debug = $debug; + } + + /** + * Handle a string validation exception. + * + * @param StringValidationException $exception exception to handle + */ + public function handleException(StringValidationException $exception): void + { + $message = $this->formatExceptionMessage($exception); + + if ($exception->isError()) { + $this->result->addError($message); + } elseif ($exception->isWarning()) { + $this->result->addWarning($message); + } else { + // Info level - add as success with message + $this->result->addSuccess($message); + } + } + + /** + * Handle a generic exception with context. + * + * @param \Throwable $exception exception to handle + * @param string $context context description + * @param string $severity error severity + */ + public function handleGenericException( + \Throwable $exception, + string $context = '', + string $severity = 'error' + ): void { + $contextInfo = []; + if (!empty($context)) { + $contextInfo['context'] = $context; + } + + $validationException = new StringValidationException( + $exception->getMessage(), + $contextInfo, + $severity, + (int) $exception->getCode(), + $exception + ); + + $this->handleException($validationException); + } + + /** + * Handle a checker error with graceful degradation. + * + * @param string $checkerName name of the checker that failed + * @param \Throwable $exception exception that occurred + * @param bool $continueOnError whether to continue validation after error + * + * @return bool true if validation should continue, false otherwise + */ + public function handleCheckerError( + string $checkerName, + \Throwable $exception, + bool $continueOnError = true + ): bool { + $checkerException = CheckerException::checkerError( + $checkerName, + $exception->getMessage(), + ['original_error' => get_class($exception)], + $exception + ); + + if ($continueOnError) { + // Convert to warning to allow validation to continue + $checkerException = CheckerException::checkerWarning( + $checkerName, + 'Checker failed but validation continues: ' . $exception->getMessage(), + ['original_error' => get_class($exception)], + $exception + ); + } + + $this->handleException($checkerException); + + return $continueOnError; + } + + /** + * Handle a file operation error. + * + * @param string $filePath file path that caused the error + * @param \Throwable $exception exception that occurred + * @param string $operation Operation that failed (e.g., 'read', 'parse'). + */ + public function handleFileError(string $filePath, \Throwable $exception, string $operation = 'process'): void + { + $fileException = FileException::parsingError( + $filePath, + "Failed to {$operation} file: " . $exception->getMessage(), + ['operation' => $operation, 'original_error' => get_class($exception)], + $exception + ); + + $this->handleException($fileException); + } + + /** + * Add a contextual error message. + * + * @param string $message error message + * @param array $context context information + * @param string $severity error severity + */ + public function addError(string $message, array $context = [], string $severity = 'error'): void + { + $exception = new StringValidationException($message, $context, $severity); + $this->handleException($exception); + } + + /** + * Add a contextual warning message. + * + * @param string $message warning message + * @param array $context context information + */ + public function addWarning(string $message, array $context = []): void + { + $this->addError($message, $context, 'warning'); + } + + /** + * Add a contextual info message. + * + * @param string $message info message + * @param array $context context information + */ + public function addInfo(string $message, array $context = []): void + { + $this->addError($message, $context, 'info'); + } + + /** + * Format an exception message for display. + * + * @param StringValidationException $exception exception to format + * + * @return string formatted message + */ + private function formatExceptionMessage(StringValidationException $exception): string + { + $message = $exception->getFormattedMessage(); + + // Add debug information if enabled + if ($this->debug && $exception->getPrevious()) { + $previous = $exception->getPrevious(); + $message .= ' [Debug: ' . get_class($previous) . ' in ' . + basename($previous->getFile()) . ':' . $previous->getLine() . ']'; + } + + return $message; + } + + /** + * Create a safe execution wrapper that handles exceptions. + * + * @param callable $callback callback to execute safely + * @param string $context context description for errors + * @param bool $continueOnError whether to continue on error + * + * @return mixed result of callback or null on error + */ + public function safeExecute(callable $callback, string $context = '', bool $continueOnError = true) + { + try { + return $callback(); + } catch (StringValidationException $e) { + $this->handleException($e); + + return $continueOnError ? null : false; + } catch (\Throwable $e) { + $this->handleGenericException($e, $context, $continueOnError ? 'warning' : 'error'); + + return $continueOnError ? null : false; + } + } + + /** + * Get the validation result. + * + * @return ValidationResult current validation result + */ + public function getResult(): ValidationResult + { + return $this->result; + } + + /** + * Check if debug mode is enabled. + * + * @return bool true if debug mode is enabled + */ + public function isDebugEnabled(): bool + { + return $this->debug; + } + + /** + * Enable or disable debug mode. + * + * @param bool $debug whether to enable debug mode + */ + public function setDebug(bool $debug): void + { + $this->debug = $debug; + } +} diff --git a/src/MissingStrings/Exception/CheckerException.php b/src/MissingStrings/Exception/CheckerException.php new file mode 100644 index 00000000..847fe711 --- /dev/null +++ b/src/MissingStrings/Exception/CheckerException.php @@ -0,0 +1,106 @@ +checkerName = $checkerName; + + // Add checker name to context + $context['checker'] = $checkerName; + + parent::__construct($message, $context, $severity, 0, $previous); + } + + /** + * Get the name of the checker that failed. + * + * @return string checker name + */ + public function getCheckerName(): string + { + return $this->checkerName; + } + + /** + * Create a checker error. + * + * @param string $checkerName name of the checker + * @param string $message error message + * @param array $context context information + * @param \Throwable|null $previous previous exception + * + * @return static + * + * @psalm-suppress UnsafeInstantiation + */ + public static function checkerError( + string $checkerName, + string $message, + array $context = [], + ?\Throwable $previous = null + ): self { + return new static($checkerName, $message, $context, 'error', $previous); + } + + /** + * Create a checker warning. + * + * @param string $checkerName name of the checker + * @param string $message warning message + * @param array $context context information + * @param \Throwable|null $previous previous exception + * + * @return static + * + * @psalm-suppress UnsafeInstantiation + */ + public static function checkerWarning( + string $checkerName, + string $message, + array $context = [], + ?\Throwable $previous = null + ): self { + return new static($checkerName, $message, $context, 'warning', $previous); + } +} diff --git a/src/MissingStrings/Exception/FileException.php b/src/MissingStrings/Exception/FileException.php new file mode 100644 index 00000000..d964cc23 --- /dev/null +++ b/src/MissingStrings/Exception/FileException.php @@ -0,0 +1,152 @@ +filePath = $filePath; + + // Add file path to context + $context['file'] = $filePath; + + parent::__construct($message, $context, $severity, 0, $previous); + } + + /** + * Get the file path that caused the error. + * + * @return string file path + */ + public function getFilePath(): string + { + return $this->filePath; + } + + /** + * Create a file not found error. + * + * @param string $filePath file path + * @param array $context context information + * + * @return static + * + * @psalm-suppress UnsafeInstantiation + */ + public static function fileNotFound(string $filePath, array $context = []): self + { + return new static( + $filePath, + "File not found: {$filePath}", + $context, + 'error' + ); + } + + /** + * Create a file not readable error. + * + * @param string $filePath file path + * @param array $context context information + * + * @return static + * + * @psalm-suppress UnsafeInstantiation + */ + public static function fileNotReadable(string $filePath, array $context = []): self + { + return new static( + $filePath, + "File not readable: {$filePath}", + $context, + 'error' + ); + } + + /** + * Create a file parsing error. + * + * @param string $filePath file path + * @param string $reason parsing error reason + * @param array $context context information + * @param \Throwable|null $previous previous exception + * + * @return static + * + * @psalm-suppress UnsafeInstantiation + */ + public static function parsingError( + string $filePath, + string $reason, + array $context = [], + ?\Throwable $previous = null + ): self { + return new static( + $filePath, + "Failed to parse file {$filePath}: {$reason}", + $context, + 'error', + $previous + ); + } + + /** + * Create a file content warning. + * + * @param string $filePath file path + * @param string $message warning message + * @param array $context context information + * + * @return static + * + * @psalm-suppress UnsafeInstantiation + */ + public static function contentWarning(string $filePath, string $message, array $context = []): self + { + return new static( + $filePath, + $message, + $context, + 'warning' + ); + } +} diff --git a/src/MissingStrings/Exception/StringValidationException.php b/src/MissingStrings/Exception/StringValidationException.php new file mode 100644 index 00000000..94abe399 --- /dev/null +++ b/src/MissingStrings/Exception/StringValidationException.php @@ -0,0 +1,171 @@ +context = $context; + $this->severity = $severity; + } + + /** + * Get error context. + * + * @return array context information + */ + public function getContext(): array + { + return $this->context; + } + + /** + * Get error severity. + * + * @return string severity level + */ + public function getSeverity(): string + { + return $this->severity; + } + + /** + * Check if this is a warning-level error. + * + * @return bool true if warning level + */ + public function isWarning(): bool + { + return 'warning' === $this->severity; + } + + /** + * Check if this is an error-level error. + * + * @return bool true if error level + */ + public function isError(): bool + { + return 'error' === $this->severity; + } + + /** + * Get formatted error message with context. + * + * @return string formatted message + */ + public function getFormattedMessage(): string + { + $message = $this->getMessage(); + + if (!empty($this->context)) { + $contextParts = []; + foreach ($this->context as $key => $value) { + if (is_scalar($value)) { + $contextParts[] = "{$key}: {$value}"; + } + } + + if (!empty($contextParts)) { + $message .= ' (' . implode(', ', $contextParts) . ')'; + } + } + + return $message; + } + + /** + * Create an error-level exception. + * + * @param string $message error message + * @param array $context context information + * @param \Throwable|null $previous previous exception + * + * @return static + * + * @psalm-suppress UnsafeInstantiation + */ + public static function error(string $message, array $context = [], ?\Throwable $previous = null): self + { + return new static($message, $context, 'error', 0, $previous); + } + + /** + * Create a warning-level exception. + * + * @param string $message warning message + * @param array $context context information + * @param \Throwable|null $previous previous exception + * + * @return static + * + * @psalm-suppress UnsafeInstantiation + */ + public static function warning(string $message, array $context = [], ?\Throwable $previous = null): self + { + return new static($message, $context, 'warning', 0, $previous); + } + + /** + * Create an info-level exception. + * + * @param string $message info message + * @param array $context context information + * @param \Throwable|null $previous previous exception + * + * @return static + * + * @psalm-suppress UnsafeInstantiation + */ + public static function info(string $message, array $context = [], ?\Throwable $previous = null): self + { + return new static($message, $context, 'info', 0, $previous); + } +} diff --git a/src/MissingStrings/Extractor/JavaScriptStringExtractor.php b/src/MissingStrings/Extractor/JavaScriptStringExtractor.php new file mode 100644 index 00000000..3300eaa3 --- /dev/null +++ b/src/MissingStrings/Extractor/JavaScriptStringExtractor.php @@ -0,0 +1,212 @@ + $line) { + $lineStrings = $this->extractFromLine($line, $component, $filePath, $lineNumber + 1); + $strings = array_merge_recursive($strings, $lineStrings); + } + + return $strings; + } + + /** + * Check if this extractor can handle the given file. + * + * @param string $filePath Path to the file + * + * @return bool True if this extractor can handle the file + */ + public function canHandle(string $filePath): bool + { + $extension = pathinfo($filePath, PATHINFO_EXTENSION); + + // Handle .js files, especially in amd/src/ directories + return 'js' === $extension && ( + false !== strpos($filePath, '/amd/src/') + || false !== strpos($filePath, '/amd/build/') + ); + } + + /** + * Get the name of this extractor. + * + * @return string Extractor name + */ + public function getName(): string + { + return 'JavaScript String Extractor'; + } + + /** + * Extract strings from a single line of JavaScript. + * + * @param string $line Line content + * @param string $component Plugin component to filter by + * @param string $filePath File path for context + * @param int $lineNumber Line number + * + * @return array Array of strings found in this line + */ + private function extractFromLine(string $line, string $component, string $filePath, int $lineNumber): array + { + $strings = []; + + // Pattern 1: str.get_string('stringkey', 'component', ...) - handles optional parameters + if (preg_match_all('/str\.get_string\s*\(\s*[\'"]([^\'\"]+)[\'"]\s*,\s*[\'"]([^\'\"]+)[\'"](?:\s*,.*?)?\s*\)/', $line, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $stringKey = $match[1]; + $stringComponent = $match[2]; + + if ($stringComponent === $component) { + $strings[$stringKey][] = [ + 'file' => basename($filePath), + 'line' => $lineNumber, + 'context' => trim($line), + ]; + } + } + } + + // Pattern 2: str.get_strings([{key: 'stringkey', component: 'component'}]) + if (preg_match_all('/\{\s*key\s*:\s*[\'"]([^\'\"]+)[\'"]\s*,\s*component\s*:\s*[\'"]([^\'\"]+)[\'"]\s*\}/', $line, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $stringKey = $match[1]; + $stringComponent = $match[2]; + + if ($stringComponent === $component) { + $strings[$stringKey][] = [ + 'file' => basename($filePath), + 'line' => $lineNumber, + 'context' => trim($line), + ]; + } + } + } + + // Pattern 3: Alternative str.get_strings format with separate arrays + // str.get_strings(['stringkey1', 'stringkey2'], 'component') + if (preg_match('/str\.get_strings\s*\(\s*\[(.*?)\]\s*,\s*[\'"]([^\'\"]+)[\'"]\s*\)/', $line, $match)) { + $stringKeysStr = $match[1]; + $stringComponent = $match[2]; + + if ($stringComponent === $component) { + // Extract individual string keys from the array + if (preg_match_all('/[\'"]([^\'\"]+)[\'"]/', $stringKeysStr, $keyMatches)) { + foreach ($keyMatches[1] as $stringKey) { + $strings[$stringKey][] = [ + 'file' => basename($filePath), + 'line' => $lineNumber, + 'context' => trim($line), + ]; + } + } + } + } + + // Pattern 4: Core/str getString method - core/str module getString function + if (preg_match_all('/getString\s*\(\s*[\'"]([^\'\"]+)[\'"]\s*,\s*[\'"]([^\'\"]+)[\'"](?:\s*,.*?)?\s*\)/', $line, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $stringKey = $match[1]; + $stringComponent = $match[2]; + + if ($stringComponent === $component) { + $strings[$stringKey][] = [ + 'file' => basename($filePath), + 'line' => $lineNumber, + 'context' => trim($line), + ]; + } + } + } + + // Pattern 5: Core/str getStrings method - core/str module getStrings function + if (preg_match_all('/getStrings\s*\(\s*\[(.*?)\]\s*\)/', $line, $stringArrayMatches)) { + foreach ($stringArrayMatches[1] as $stringArrayContent) { + // Extract {key: 'stringkey', component: 'component'} objects + if (preg_match_all('/\{\s*key\s*:\s*[\'"]([^\'\"]+)[\'"]\s*,\s*component\s*:\s*[\'"]([^\'\"]+)[\'"]\s*\}/', $stringArrayContent, $objectMatches, PREG_SET_ORDER)) { + foreach ($objectMatches as $match) { + $stringKey = $match[1]; + $stringComponent = $match[2]; + + if ($stringComponent === $component) { + $strings[$stringKey][] = [ + 'file' => basename($filePath), + 'line' => $lineNumber, + 'context' => trim($line), + ]; + } + } + } + } + } + + // Pattern 6: Prefetch.prefetchString method + if (preg_match_all('/Prefetch\.prefetchString\s*\(\s*[\'"]([^\'\"]+)[\'"]\s*,\s*[\'"]([^\'\"]+)[\'"]\s*\)/', $line, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $stringKey = $match[1]; + $stringComponent = $match[2]; + + if ($stringComponent === $component) { + $strings[$stringKey][] = [ + 'file' => basename($filePath), + 'line' => $lineNumber, + 'context' => trim($line), + ]; + } + } + } + + // Pattern 7: Prefetch.prefetchStrings method + if (preg_match('/Prefetch\.prefetchStrings\s*\(\s*[\'"]([^\'\"]+)[\'"]\s*,\s*\[(.*?)\]\s*\)/', $line, $match)) { + $stringComponent = $match[1]; + $stringKeysStr = $match[2]; + + if ($stringComponent === $component) { + // Extract individual string keys from the array + if (preg_match_all('/[\'"]([^\'\"]+)[\'"]/', $stringKeysStr, $keyMatches)) { + foreach ($keyMatches[1] as $stringKey) { + $strings[$stringKey][] = [ + 'file' => basename($filePath), + 'line' => $lineNumber, + 'context' => trim($line), + ]; + } + } + } + } + + return $strings; + } +} diff --git a/src/MissingStrings/Extractor/MustacheStringExtractor.php b/src/MissingStrings/Extractor/MustacheStringExtractor.php new file mode 100644 index 00000000..ff7dc838 --- /dev/null +++ b/src/MissingStrings/Extractor/MustacheStringExtractor.php @@ -0,0 +1,314 @@ + $line) { + // Extract strings from Mustache helpers + $helperStrings = $this->extractMustacheHelpers($line, $component, $filePath, $lineNumber + 1); + $strings = array_merge_recursive($strings, $helperStrings); + + // Extract strings from JavaScript blocks + $jsStrings = $this->extractJavaScriptBlocks($line, $component, $filePath, $lineNumber + 1); + $strings = array_merge_recursive($strings, $jsStrings); + } + + // Also extract from multi-line JavaScript blocks + $multiLineJsStrings = $this->extractMultiLineJavaScript($content, $component, $filePath); + $strings = array_merge_recursive($strings, $multiLineJsStrings); + + return $strings; + } + + /** + * Check if this extractor can handle the given file. + * + * @param string $filePath Path to the file + * + * @return bool True if this extractor can handle the file + */ + public function canHandle(string $filePath): bool + { + return 'mustache' === pathinfo($filePath, PATHINFO_EXTENSION); + } + + /** + * Get the name of this extractor. + * + * @return string Extractor name + */ + public function getName(): string + { + return 'Mustache String Extractor'; + } + + /** + * Extract strings from Mustache helpers like {{#str}} and {{#cleanstr}}. + * + * @param string $line Line content + * @param string $component Plugin component to filter by + * @param string $filePath File path for context + * @param int $lineNumber Line number + * + * @return array Array of strings found + */ + private function extractMustacheHelpers(string $line, string $component, string $filePath, int $lineNumber): array + { + $strings = []; + + // Combined pattern that handles both 2 and 3 parameter cases + // For the 3rd parameter, we need to match everything until we find the closing {{/str}} or {{/cleanstr}} + // This uses a more sophisticated approach to handle nested {{}} in the 3rd parameter + // Component pattern [^,\s{}]+ ensures we don't match past }} characters + $pattern = '/\{\{\#(str|cleanstr)\}\}\s*([^,\s]+)\s*,\s*([^,\s{}]+)(?:\s*,\s*((?:(?!\{\{\/\1\}\}).)*))?\s*\{\{\/\1\}\}/'; + + if (preg_match_all($pattern, $line, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $stringKey = trim($match[2]); + $stringComponent = trim($match[3]); + + // Skip dynamic strings (containing variables or expressions) + if ($this->isDynamicString($stringKey)) { + continue; + } + + if ($stringComponent === $component) { + $strings[$stringKey][] = [ + 'file' => $this->getRelativeFilePath($filePath), + 'line' => $lineNumber, + 'context' => trim($line), + ]; + } + } + } + + return $strings; + } + + /** + * Extract strings from JavaScript blocks within single lines. + * + * @param string $line Line content + * @param string $component Plugin component to filter by + * @param string $filePath File path for context + * @param int $lineNumber Line number + * + * @return array Array of strings found + */ + private function extractJavaScriptBlocks(string $line, string $component, string $filePath, int $lineNumber): array + { + $strings = []; + + // Check if line contains JavaScript string calls + if (false !== strpos($line, 'str.get_string')) { + // Pattern: str.get_string('stringkey', 'component', ...) - handles optional parameters + if (preg_match_all('/str\.get_string\s*\(\s*[\'"]([^\'\"]+)[\'"]\s*,\s*[\'"]([^\'\"]+)[\'"](?:\s*,.*?)?\s*\)/', $line, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $stringKey = $match[1]; + $stringComponent = $match[2]; + + // Skip dynamic strings (containing variables or expressions) + if ($this->isDynamicString($stringKey)) { + continue; + } + + if ($stringComponent === $component) { + $strings[$stringKey][] = [ + 'file' => $this->getRelativeFilePath($filePath), + 'line' => $lineNumber, + 'context' => trim($line), + ]; + } + } + } + } + + return $strings; + } + + /** + * Extract strings from multi-line JavaScript blocks {{#js}}...{{/js}}. + * + * @param string $content Full file content + * @param string $component Plugin component to filter by + * @param string $filePath File path for context + * + * @return array Array of strings found + */ + private function extractMultiLineJavaScript(string $content, string $component, string $filePath): array + { + $strings = []; + + // Extract JavaScript blocks + if (preg_match_all('/\{\{\#js\}\}(.*?)\{\{\/js\}\}/s', $content, $jsBlocks, PREG_SET_ORDER)) { + foreach ($jsBlocks as $block) { + $jsCode = $block[1]; + $jsStrings = $this->extractJavaScriptStrings($jsCode, $component, $filePath); + $strings = array_merge_recursive($strings, $jsStrings); + } + } + + return $strings; + } + + /** + * Extract strings from JavaScript code. + * + * @param string $jsCode JavaScript code + * @param string $component Plugin component to filter by + * @param string $filePath File path for context + * + * @return array Array of strings found + */ + private function extractJavaScriptStrings(string $jsCode, string $component, string $filePath): array + { + $strings = []; + $lines = explode("\n", $jsCode); + + foreach ($lines as $lineNumber => $line) { + // Pattern 1: str.get_string('stringkey', 'component', ...) - handles optional parameters + if (preg_match_all('/str\.get_string\s*\(\s*[\'"]([^\'\"]+)[\'"]\s*,\s*[\'"]([^\'\"]+)[\'"](?:\s*,.*?)?\s*\)/', $line, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $stringKey = $match[1]; + $stringComponent = $match[2]; + + // Skip dynamic strings (containing variables or expressions) + if ($this->isDynamicString($stringKey)) { + continue; + } + + if ($stringComponent === $component) { + $strings[$stringKey][] = [ + 'file' => $this->getRelativeFilePath($filePath), + 'line' => $lineNumber + 1, // Approximate line number + 'context' => 'JavaScript block: ' . trim($line), + ]; + } + } + } + + // Pattern 2: str.get_strings([{key: 'stringkey', component: 'component'}]) + if (preg_match_all('/\{\s*key\s*:\s*[\'"]([^\'\"]+)[\'"]\s*,\s*component\s*:\s*[\'"]([^\'\"]+)[\'"]\s*\}/', $line, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $stringKey = $match[1]; + $stringComponent = $match[2]; + + // Skip dynamic strings (containing variables or expressions) + if ($this->isDynamicString($stringKey)) { + continue; + } + + if ($stringComponent === $component) { + $strings[$stringKey][] = [ + 'file' => $this->getRelativeFilePath($filePath), + 'line' => $lineNumber + 1, + 'context' => 'JavaScript block: ' . trim($line), + ]; + } + } + } + } + + return $strings; + } + + /** + * Check if a string key contains dynamic content that should be ignored. + * + * @param string $stringKey The string key to check + * + * @return bool True if the string is dynamic and should be ignored + */ + private function isDynamicString(string $stringKey): bool + { + // Check for PHP variables ($variable) + if (false !== strpos($stringKey, '$')) { + return true; + } + + // Check for template variables ({$variable} or {variable}) + if (preg_match('/\{[^}]*\$[^}]*\}/', $stringKey)) { + return true; + } + + // Check for mustache-style variables ({variable}) + if (preg_match('/\{[^}]+\}/', $stringKey)) { + return true; + } + + // Check for concatenation operators (.something) + if (preg_match('/\.\s*\$/', $stringKey)) { + return true; + } + + return false; + } + + /** + * Get a relative file path that's more informative than just the basename. + * + * @param string $filePath The full file path + * + * @return string A relative path or enhanced basename + */ + private function getRelativeFilePath(string $filePath): string + { + // Try to make the path relative to common Moodle directories + $moodlePatterns = [ + '/.*\/moodle\/(.*?)$/' => '$1', // Remove everything before /moodle/ + '/.*\/moodle\.local\/(.*?)$/' => '$1', // Remove everything before /moodle.local/ + '/.*\/var\/www\/html\/(.*?)$/' => '$1', // Remove everything before /var/www/html/ + ]; + + foreach ($moodlePatterns as $pattern => $replacement) { + if (preg_match($pattern, $filePath, $matches)) { + return $matches[1]; + } + } + + // If no pattern matches, include the last 2-3 directory levels for context + $parts = explode('/', $filePath); + $count = count($parts); + + if ($count >= 3) { + // Show last 3 parts: {parent_dir}/{dir}/{file.ext} + return implode('/', array_slice($parts, -3)); + } elseif ($count >= 2) { + // Show last 2 parts: {dir}/{file.ext} + return implode('/', array_slice($parts, -2)); + } + + // Fallback to just the filename + return basename($filePath); + } +} diff --git a/src/MissingStrings/Extractor/PhpStringExtractor.php b/src/MissingStrings/Extractor/PhpStringExtractor.php new file mode 100644 index 00000000..46c6642a --- /dev/null +++ b/src/MissingStrings/Extractor/PhpStringExtractor.php @@ -0,0 +1,265 @@ + $line) { + $lineStrings = $this->extractFromLine($line, $component, $filePath, $lineNumber + 1); + $strings = array_merge_recursive($strings, $lineStrings); + } + + return $strings; + } + + /** + * Check if this extractor can handle the given file. + * + * @param string $filePath Path to the file + * + * @return bool True if this extractor can handle the file + */ + public function canHandle(string $filePath): bool + { + return 'php' === strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + } + + /** + * Get the name of this extractor. + * + * @return string Extractor name + */ + public function getName(): string + { + return 'PHP String Extractor'; + } + + /** + * Extract strings from a single line. + * + * @param string $line Line content + * @param string $component Plugin component to filter by + * @param string $filePath File path for context + * @param int $lineNumber Line number + * + * @return array Array of strings found in this line + */ + private function extractFromLine(string $line, string $component, string $filePath, int $lineNumber): array + { + $strings = []; + + // Pattern 1: get_string('stringkey', 'component', ...) - handles optional parameters + if (preg_match_all('/get_string\s*\(\s*[\'"]([^\'\"]+)[\'"]\s*,\s*[\'"]([^\'\"]+)[\'"](?:\s*,.*?)?\s*\)/', $line, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $stringKey = $match[1]; + $stringComponent = $match[2]; + + // Skip dynamic strings (containing variables or expressions) + if ($this->isDynamicString($stringKey)) { + continue; + } + + if ($this->isValidComponent($stringComponent, $component)) { + $strings[$stringKey][] = [ + 'file' => $this->getRelativeFilePath($filePath), + 'line' => $lineNumber, + 'context' => trim($line), + ]; + } + } + } + + // Pattern 2: new lang_string('stringkey', 'component', ...) - handles optional parameters + if (preg_match_all('/new\s+lang_string\s*\(\s*[\'"]([^\'\"]+)[\'"]\s*,\s*[\'"]([^\'\"]+)[\'"](?:\s*,.*?)?\s*\)/', $line, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $stringKey = $match[1]; + $stringComponent = $match[2]; + + // Skip dynamic strings (containing variables or expressions) + if ($this->isDynamicString($stringKey)) { + continue; + } + + if ($this->isValidComponent($stringComponent, $component)) { + $strings[$stringKey][] = [ + 'file' => $this->getRelativeFilePath($filePath), + 'line' => $lineNumber, + 'context' => trim($line), + ]; + } + } + } + + // Pattern 3: addHelpButton('stringkey', 'component', ...) - handles optional parameters + if (preg_match_all('/addHelpButton\s*\(\s*[\'"]([^\'\"]+)[\'"]\s*,\s*[\'"]([^\'\"]+)[\'"](?:\s*,.*?)?\s*\)/', $line, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $stringKey = $match[1]; + $stringComponent = $match[2]; + + // Skip dynamic strings (containing variables or expressions) + if ($this->isDynamicString($stringKey)) { + continue; + } + + if ($this->isValidComponent($stringComponent, $component)) { + $strings[$stringKey][] = [ + 'file' => $this->getRelativeFilePath($filePath), + 'line' => $lineNumber, + 'context' => trim($line), + ]; + } + } + } + + // Pattern 4: String manager calls - get_string_manager()->get_string(...) - handles optional parameters + if (preg_match_all('/get_string_manager\s*\(\s*\)\s*->\s*get_string\s*\(\s*[\'"]([^\'\"]+)[\'"]\s*,\s*[\'"]([^\'\"]+)[\'"](?:\s*,.*?)?\s*\)/', $line, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $stringKey = $match[1]; + $stringComponent = $match[2]; + + // Skip dynamic strings (containing variables or expressions) + if ($this->isDynamicString($stringKey)) { + continue; + } + + if ($this->isValidComponent($stringComponent, $component)) { + $strings[$stringKey][] = [ + 'file' => $this->getRelativeFilePath($filePath), + 'line' => $lineNumber, + 'context' => trim($line), + ]; + } + } + } + + return $strings; + } + + /** + * Check if a string component is valid for the given plugin component. + * + * For mod_* plugins, both 'mod_pluginname' and 'pluginname' are valid. + * For other plugins, exact match is required. + * + * @param string $stringComponent Component from the string call + * @param string $pluginComponent Plugin component being validated + * + * @return bool True if the component is valid for this plugin + */ + private function isValidComponent(string $stringComponent, string $pluginComponent): bool + { + // Exact match + if ($stringComponent === $pluginComponent) { + return true; + } + + // For mod_* plugins, also accept the short form (e.g., 'quiz' for 'mod_quiz') + if (0 === strpos($pluginComponent, 'mod_')) { + $shortComponent = substr($pluginComponent, 4); // Remove 'mod_' prefix + if ($stringComponent === $shortComponent) { + return true; + } + } + + return false; + } + + /** + * Check if a string key contains dynamic content that should be ignored. + * + * @param string $stringKey The string key to check + * + * @return bool True if the string is dynamic and should be ignored + */ + private function isDynamicString(string $stringKey): bool + { + // Check for PHP variables ($variable) + if (false !== strpos($stringKey, '$')) { + return true; + } + + // Check for template variables ({$variable} or {variable}) + if (preg_match('/\{[^}]*\$[^}]*\}/', $stringKey)) { + return true; + } + + // Check for mustache-style variables ({variable}) + if (preg_match('/\{[^}]+\}/', $stringKey)) { + return true; + } + + // Check for concatenation operators (.something) + if (preg_match('/\.\s*\$/', $stringKey)) { + return true; + } + + return false; + } + + /** + * Get a relative file path that's more informative than just the basename. + * + * @param string $filePath The full file path + * + * @return string A relative path or enhanced basename + */ + private function getRelativeFilePath(string $filePath): string + { + // Try to make the path relative to common Moodle directories + $moodlePatterns = [ + '/.*\/moodle\/(.*?)$/' => '$1', // Remove everything before /moodle/ + '/.*\/moodle\.local\/(.*?)$/' => '$1', // Remove everything before /moodle.local/ + '/.*\/var\/www\/html\/(.*?)$/' => '$1', // Remove everything before /var/www/html/ + ]; + + foreach ($moodlePatterns as $pattern => $replacement) { + if (preg_match($pattern, $filePath, $matches)) { + return $matches[1]; + } + } + + // If no pattern matches, include the last 2-3 directory levels for context + $parts = explode('/', $filePath); + $count = count($parts); + + if ($count >= 3) { + // Show last 3 parts: {parent_dir}/{dir}/{file.ext} + return implode('/', array_slice($parts, -3)); + } elseif ($count >= 2) { + // Show last 2 parts: {dir}/{file.ext} + return implode('/', array_slice($parts, -2)); + } + + // Fallback to just the filename + return basename($filePath); + } +} diff --git a/src/MissingStrings/Extractor/StringExtractor.php b/src/MissingStrings/Extractor/StringExtractor.php new file mode 100644 index 00000000..fa3ceaef --- /dev/null +++ b/src/MissingStrings/Extractor/StringExtractor.php @@ -0,0 +1,217 @@ + 0.0, + 'files_processed' => 0, + 'strings_extracted' => 0, + 'string_usages_found' => 0, + ]; + + /** + * Constructor. + */ + public function __construct() + { + $this->extractors = [ + new PhpStringExtractor(), + new MustacheStringExtractor(), + new JavaScriptStringExtractor(), + ]; + } + + /** + * Set file discovery service. + * + * @param FileDiscovery $fileDiscovery file discovery service + */ + public function setFileDiscovery(FileDiscovery $fileDiscovery): void + { + $this->fileDiscovery = $fileDiscovery; + } + + /** + * Extract strings from a plugin. + * + * @param Plugin $plugin plugin to extract strings from + * + * @return array array of string keys with their usage information + */ + public function extractFromPlugin(Plugin $plugin): array + { + if (!$this->fileDiscovery) { + throw new \RuntimeException('File discovery service not set'); + } + + $startTime = microtime(true); + + // Reset metrics for this extraction + $this->metrics = [ + 'extraction_time' => 0.0, + 'files_processed' => 0, + 'strings_extracted' => 0, + 'string_usages_found' => 0, + ]; + + $allStrings = []; + $filesByCategory = $this->fileDiscovery->getAllFiles(); + + // Flatten the categorized files into a single array + $allFiles = []; + foreach ($filesByCategory as $category => $files) { + $allFiles = array_merge($allFiles, $files); + } + + foreach ($allFiles as $file) { + $extractor = $this->getExtractorForFile($file); + if (!$extractor) { + continue; + } + + // Read file content + if (!file_exists($file) || !is_readable($file)) { + continue; + } + + $content = file_get_contents($file); + if (false === $content) { + continue; + } + + ++$this->metrics['files_processed']; + + $strings = $extractor->extract($content, $plugin->component, $file); + + // Track string extraction metrics + $this->metrics['strings_extracted'] += count($strings); + foreach ($strings as $stringUsages) { + $this->metrics['string_usages_found'] += count($stringUsages); + } + + $allStrings = $this->mergeStringUsages($allStrings, $strings); + } + + $this->metrics['extraction_time'] = microtime(true) - $startTime; + + return $allStrings; + } + + /** + * Get appropriate extractor for a file. + * + * @param string $file file path + * + * @return StringExtractorInterface|null extractor or null if none found + */ + private function getExtractorForFile(string $file): ?StringExtractorInterface + { + foreach ($this->extractors as $extractor) { + if ($extractor->canHandle($file)) { + return $extractor; + } + } + + return null; + } + + /** + * Merge string usages from multiple sources. + * + * @param array $existing existing string usages + * @param array $new new string usages to merge + * + * @return array merged string usages + */ + private function mergeStringUsages(array $existing, array $new): array + { + foreach ($new as $stringKey => $usages) { + if (!isset($existing[$stringKey])) { + $existing[$stringKey] = []; + } + $existing[$stringKey] = array_merge($existing[$stringKey], $usages); + } + + return $existing; + } + + /** + * Add a custom extractor. + * + * @param StringExtractorInterface $extractor extractor to add + */ + public function addExtractor(StringExtractorInterface $extractor): void + { + $this->extractors[] = $extractor; + } + + /** + * Set custom extractors (replaces all existing ones). + * + * @param StringExtractorInterface[] $extractors array of extractors + */ + public function setExtractors(array $extractors): void + { + $this->extractors = $extractors; + } + + /** + * Get all registered extractors. + * + * @return StringExtractorInterface[] array of extractors + */ + public function getExtractors(): array + { + return $this->extractors; + } + + /** + * Get string processing performance metrics. + * + * @return array performance metrics for string extraction + */ + public function getPerformanceMetrics(): array + { + return $this->metrics; + } +} diff --git a/src/MissingStrings/Extractor/StringExtractorInterface.php b/src/MissingStrings/Extractor/StringExtractorInterface.php new file mode 100644 index 00000000..8e308d29 --- /dev/null +++ b/src/MissingStrings/Extractor/StringExtractorInterface.php @@ -0,0 +1,48 @@ + [['file' => 'path', 'line' => 123, 'context' => '...']]] + */ + public function extract(string $content, string $component, string $filePath): array; + + /** + * Check if this extractor can handle the given file. + * + * @param string $filePath Path to the file + * + * @return bool True if this extractor can handle the file + */ + public function canHandle(string $filePath): bool; + + /** + * Get the name of this extractor for debugging/logging. + * + * @return string Extractor name + */ + public function getName(): string; +} diff --git a/src/MissingStrings/FileDiscovery/FileDiscovery.php b/src/MissingStrings/FileDiscovery/FileDiscovery.php new file mode 100644 index 00000000..00b0f95b --- /dev/null +++ b/src/MissingStrings/FileDiscovery/FileDiscovery.php @@ -0,0 +1,357 @@ + 0.0, + 'directories_scanned' => 0, + 'files_processed' => 0, + ]; + + public function __construct(Plugin $plugin) + { + $this->plugin = $plugin; + } + + /** + * Perform file discovery if not already done. + */ + private function ensureDiscovered(): void + { + if ($this->discovered) { + return; + } + + $this->discoverFiles(); + $this->discovered = true; + } + + /** + * Discover and categorize all files in the plugin. + */ + private function discoverFiles(): void + { + $startTime = microtime(true); + + $this->files = [ + 'php' => [], + 'mustache' => [], + 'javascript' => [], + 'database' => [], + 'classes' => [], + 'templates' => [], + 'amd' => [], + ]; + + $this->scanDirectory($this->plugin->directory); + + $this->metrics['discovery_time'] = microtime(true) - $startTime; + } + + /** + * Recursively scan a directory and categorize files. + * + * @param string $directory directory to scan + * @param string $relativePath relative path from plugin root + */ + private function scanDirectory(string $directory, string $relativePath = ''): void + { + if (!is_dir($directory)) { + return; + } + + ++$this->metrics['directories_scanned']; + $iterator = new \DirectoryIterator($directory); + + foreach ($iterator as $item) { + if ($item->isDot()) { + continue; + } + + $itemPath = $item->getPathname(); + $itemRelativePath = $relativePath ? $relativePath . '/' . $item->getFilename() : $item->getFilename(); + + if ($item->isDir()) { + $this->scanDirectory($itemPath, $itemRelativePath); + } elseif ($item->isFile()) { + ++$this->metrics['files_processed']; + $this->categorizeFile($itemPath, $itemRelativePath); + } + } + } + + /** + * Categorize a file based on its path and extension. + * + * @param string $filePath full path to the file + * @param string $relativePath relative path from plugin root + */ + private function categorizeFile(string $filePath, string $relativePath): void + { + $extension = pathinfo($filePath, PATHINFO_EXTENSION); + $dirname = dirname($relativePath); + + // All PHP files + if ('php' === $extension) { + $this->files['php'][] = $filePath; + + // Database files + if (str_starts_with($relativePath, 'db/')) { + $this->files['database'][] = $filePath; + } + + // Class files + if (str_starts_with($relativePath, 'classes/')) { + $this->files['classes'][] = $filePath; + } + } + + // Mustache templates + if ('mustache' === $extension) { + $this->files['mustache'][] = $filePath; + + if (str_starts_with($relativePath, 'templates/')) { + $this->files['templates'][] = $filePath; + } + } + + // JavaScript files + if ('js' === $extension) { + $this->files['javascript'][] = $filePath; + + if (str_starts_with($relativePath, 'amd/src/')) { + $this->files['amd'][] = $filePath; + } + } + } + + /** + * Get all PHP files in the plugin. + * + * @return array array of file paths + */ + public function getPhpFiles(): array + { + $this->ensureDiscovered(); + + return $this->files['php']; + } + + /** + * Get all Mustache template files. + * + * @return array array of file paths + */ + public function getMustacheFiles(): array + { + $this->ensureDiscovered(); + + return $this->files['mustache']; + } + + /** + * Get all JavaScript files. + * + * @return array array of file paths + */ + public function getJavaScriptFiles(): array + { + $this->ensureDiscovered(); + + return $this->files['javascript']; + } + + /** + * Get all database definition files (db/*.php). + * + * @return array array of file paths + */ + public function getDatabaseFiles(): array + { + $this->ensureDiscovered(); + + return $this->files['database']; + } + + /** + * Get all class files (classes/*.php). + * + * @return array array of file paths + */ + public function getClassFiles(): array + { + $this->ensureDiscovered(); + + return $this->files['classes']; + } + + /** + * Get template files (templates/*.mustache). + * + * @return array array of file paths + */ + public function getTemplateFiles(): array + { + $this->ensureDiscovered(); + + return $this->files['templates']; + } + + /** + * Get AMD JavaScript files (amd/src/*.js). + * + * @return array array of file paths + */ + public function getAmdFiles(): array + { + $this->ensureDiscovered(); + + return $this->files['amd']; + } + + /** + * Get a specific database file if it exists. + * + * @param string $filename Database filename (e.g., 'access.php'). + * + * @return string|null full path to the file or null if not found + */ + public function getDatabaseFile(string $filename): ?string + { + $targetPath = $this->plugin->directory . '/db/' . $filename; + + foreach ($this->getDatabaseFiles() as $file) { + if ($file === $targetPath) { + return $file; + } + } + + return null; + } + + /** + * Check if a specific database file exists. + * + * @param string $filename Database filename (e.g., 'access.php'). + * + * @return bool true if the file exists + */ + public function hasDatabaseFile(string $filename): bool + { + return null !== $this->getDatabaseFile($filename); + } + + /** + * Get class files in a specific subdirectory. + * + * @param string $subdirectory Subdirectory within classes/ (e.g., 'privacy'). + * + * @return array array of file paths + */ + public function getClassFilesInSubdirectory(string $subdirectory): array + { + $targetPrefix = $this->plugin->directory . '/classes/' . trim($subdirectory, '/') . '/'; + $files = []; + + foreach ($this->getClassFiles() as $file) { + if (str_starts_with($file, $targetPrefix)) { + $files[] = $file; + } + } + + return $files; + } + + /** + * Get all discovered files with their categories. + * + * @return array array of category => files pairs + */ + public function getAllFiles(): array + { + $this->ensureDiscovered(); + + return $this->files; + } + + /** + * Get discovery statistics. + * + * @return array statistics about discovered files + */ + public function getStatistics(): array + { + $this->ensureDiscovered(); + + return [ + 'total_files' => array_sum(array_map('count', $this->files)), + 'php_files' => count($this->files['php']), + 'mustache_files' => count($this->files['mustache']), + 'javascript_files' => count($this->files['javascript']), + 'database_files' => count($this->files['database']), + 'class_files' => count($this->files['classes']), + 'template_files' => count($this->files['templates']), + 'amd_files' => count($this->files['amd']), + ]; + } + + /** + * Get performance metrics. + * + * @return array performance metrics including timing and file counts + */ + public function getPerformanceMetrics(): array + { + $this->ensureDiscovered(); + + return array_merge($this->metrics, [ + 'file_types' => $this->getStatistics(), + ]); + } +} diff --git a/src/MissingStrings/Pattern/RegexPatterns.php b/src/MissingStrings/Pattern/RegexPatterns.php new file mode 100644 index 00000000..87fee6b6 --- /dev/null +++ b/src/MissingStrings/Pattern/RegexPatterns.php @@ -0,0 +1,141 @@ + 'string'], 'description'). + */ + public static function addDatabaseTable(): string + { + return '/add_database_table\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*\[(.*?)\](?:\s*,\s*[\'"]([^\'"]+)[\'"])?\s*\)/s'; + } + + /** + * Pattern for add_external_location_link calls (simple format). + * Matches: add_external_location_link('service', 'privacy:metadata:service', 'url'). + */ + public static function addExternalLocationLinkSimple(): string + { + return '/add_external_location_link\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/'; + } + + /** + * Pattern for add_external_location_link calls (array format). + * Matches: add_external_location_link('service', ['field' => 'string'], 'privacy:metadata:service'). + */ + public static function addExternalLocationLinkArray(): string + { + return '/add_external_location_link\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*\[(.*?)\]\s*,\s*[\'"]([^\'"]+)[\'"]\s*\)/s'; + } + + /** + * Pattern for add_subsystem_link calls. + * Matches: add_subsystem_link('subsystem', [], 'privacy:metadata:subsystem'). + */ + public static function addSubsystemLink(): string + { + return '/add_subsystem_link\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,.*?[\'"]([^\'"]+)[\'"]\)/'; + } + + /** + * Pattern for link_subsystem calls. + * Matches: link_subsystem('subsystem', 'privacy:metadata:subsystem'). + */ + public static function linkSubsystem(): string + { + return '/link_subsystem\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\)/'; + } + + /** + * Pattern for add_user_preference calls. + * Matches: add_user_preference('preference', 'privacy:metadata:preference'). + */ + public static function addUserPreference(): string + { + return '/add_user_preference\s*\(\s*[^,]+,\s*[\'"]([^\'"]+)[\'"]\)/'; + } + + /** + * Pattern for field mappings within arrays. + * Matches: 'field' => 'privacy:metadata:field'. + */ + public static function fieldMapping(): string + { + return '/[\'"]([^\'"]+)[\'"]\s*=>\s*[\'"]([^\'"]+)[\'"]/'; + } + + /** + * Pattern for return statements with string literals. + * Matches: return 'privacy:metadata:string';. + */ + public static function returnStatement(): string + { + return '/return\s+[\'"]([^\'"]+)[\'"];/'; + } + + /** + * Pattern for get_string() function calls. + * Matches: get_string('identifier', 'component') and get_string('identifier', 'component', $param). + */ + public static function getString(): string + { + return '/get_string\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]/'; + } + + /** + * Pattern for JavaScript getString() calls. + * Matches: getString('identifier', 'component'). + */ + public static function jsGetString(): string + { + return '/getString\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*[,\)]/'; + } + + /** + * Pattern for JavaScript getStrings() calls. + * Matches: getStrings([{key: 'identifier', component: 'component'}]). + */ + public static function jsGetStrings(): string + { + return '/getStrings\s*\(\s*\[(.*?)\]\s*\)/s'; + } + + /** + * Pattern for Prefetch.prefetchString() calls. + * Matches: Prefetch.prefetchString('identifier', 'component'). + */ + public static function prefetchString(): string + { + return '/Prefetch\.prefetchString\s*\(\s*[\'"]([^\'"]+)[\'"]\s*,\s*[\'"]([^\'"]+)[\'"]\s*[,\)]/'; + } + + /** + * Pattern for Prefetch.prefetchStrings() calls. + * Matches: Prefetch.prefetchStrings([{key: 'identifier', component: 'component'}]). + */ + public static function prefetchStrings(): string + { + return '/Prefetch\.prefetchStrings\s*\(\s*\[(.*?)\]\s*\)/s'; + } +} diff --git a/src/MissingStrings/README.md b/src/MissingStrings/README.md new file mode 100644 index 00000000..337eacbd --- /dev/null +++ b/src/MissingStrings/README.md @@ -0,0 +1,113 @@ +# Missing Strings Validation for Moodle Plugin CI + +## Overview + +Validates language strings in Moodle plugins to ensure all required strings are defined and properly referenced. Detects missing strings from PHP code, JavaScript, templates, database files, and class implementations. Automatically includes subplugin validation for comprehensive coverage. + +## What It Checks + +### Code Usage +- `get_string()` and `new lang_string()` calls in PHP +- JavaScript string methods (`str.get_string()`, `str.get_strings()`, `getString()`, `getStrings()`, `Prefetch` methods) +- Mustache template strings (`{{#str}}`, `{{#cleanstr}}`) +- Help button strings (`->addHelpButton()`) +- Dynamic strings automatically filtered (variables like `$row->state` are ignored) + +### Plugin Requirements +- **All plugins**: `pluginname` +- **Activity modules**: `modulename`, `modulenameplural` +- **Database files**: capabilities, caches, messages, tags, mobile addons, subplugins +- **Class implementations**: Privacy providers, search areas, grade items, exceptions + +### Subplugin Support +- **Automatic discovery**: Reads `db/subplugins.json` and `db/subplugins.php` +- **Recursive validation**: Validates main plugin + all discovered subplugins + +## Usage + +```bash +# Basic validation +moodle-plugin-ci missingstrings /path/to/plugin + +# Strict mode (warnings as errors) +moodle-plugin-ci missingstrings --strict /path/to/plugin + +# Check for unused strings +moodle-plugin-ci missingstrings --unused /path/to/plugin + +# Exclude specific string patterns +moodle-plugin-ci missingstrings --exclude-patterns="test_*,debug_*" /path/to/plugin + +# Combined options +moodle-plugin-ci missingstrings --strict --unused --exclude-patterns="temp_*" /path/to/plugin +``` + +## Options + +- `--lang=LANG`: Language to validate (default: en) +- `--strict`: Treat warnings as errors +- `--unused`: Report unused strings as warnings +- `--exclude-patterns=PATTERNS`: Comma-separated exclusion patterns (supports wildcards) +- `--debug`: Enable debug mode for detailed information + +## Output + +```bash + RUN Checking for missing language strings in mod/quiz + +✗ Missing required string (string_key: pluginname, component: mod_quiz, + file: mod/quiz/version.php, line: 28) +✗ Missing used string (string_key: error_invalid_data, component: quizaccess_timelimit, + file: mod/quiz/accessrule/timelimit/classes/output/renderer.php, line: 134) +⚠ Unused string (defined but not used) (string_key: old_feature, component: mod_quiz) + +Subplugins validated: +- quiz_grading +- quiz_overview +- quiz_responses +- quiz_statistics +- quizaccess_delaybetweenattempts +- quizaccess_ipaddress +- quizaccess_numattempts +- quizaccess_openclosedate +- quizaccess_password +- quizaccess_securewindow +- quizaccess_timelimit +- quizaccess_seb + +Summary: +- Main plugin: mod_quiz (1 plugin) +- Subplugins: 12 plugins discovered and validated +- Total plugins validated: 13 +- Errors: 2 +- Warnings: 1 + +✗ Language string validation failed +``` + +The tool provides: +- **Component identification**: Shows which specific plugin has issues +- **Full file paths**: Relative paths from Moodle root for easy location +- **Line numbers**: Exact location of string usage +- **Subplugin coverage**: Automatic discovery and validation +- **Summary statistics**: Clear breakdown of all validation results + +## Common Issues + +**"Missing required string" for existing strings:** +- Check string key spelling (including colons and underscores) +- For modules, strings go in `lang/en/{modulename}.php`, not `lang/en/{component}.php` +- For subplugins, check that the language file uses the correct component name + +**"Unused string" warnings:** +- Use `--exclude-patterns` to exclude test/debug strings +- Consider if the string is actually needed + +**Dynamic string false positives:** +- Strings with variables (e.g., `"studentattempt:{$row->state}"`) are automatically ignored +- If legitimate dynamic strings are incorrectly filtered, use static string alternatives + +**Subplugin validation:** +- Ensure `db/subplugins.json` or `db/subplugins.php` is correctly formatted +- Subplugin directories must contain `version.php`, language files +- Each subplugin is validated independently with its own component context \ No newline at end of file diff --git a/src/MissingStrings/Requirements/AbstractStringRequirements.php b/src/MissingStrings/Requirements/AbstractStringRequirements.php new file mode 100644 index 00000000..ef09b3f7 --- /dev/null +++ b/src/MissingStrings/Requirements/AbstractStringRequirements.php @@ -0,0 +1,105 @@ +plugin = $plugin; + $this->moodleVersion = $moodleVersion; + } + + /** + * Get required strings that must exist for this plugin type. + * + * @return array Array of string keys that are required + */ + abstract public function getRequiredStrings(): array; + + /** + * Get plugin-type specific string patterns. + * These are strings that follow specific naming conventions for the plugin type. + * + * @return array Array of string patterns specific to this plugin type + */ + public function getPluginTypePatterns(): array + { + return []; + } + + /** + * Check if a file exists in the plugin directory. + * + * @param string $file Relative path to file + * + * @return bool True if file exists + */ + protected function fileExists(string $file): bool + { + return file_exists($this->plugin->directory . '/' . $file); + } + + /** + * Get the plugin component name. + * + * @return string The plugin component + */ + protected function getComponent(): string + { + return $this->plugin->component; + } + + /** + * Get the plugin type. + * + * @return string The plugin type (e.g., 'local', 'mod', 'block') + */ + protected function getPluginType(): string + { + return $this->plugin->type; + } + + /** + * Get the plugin name. + * + * @return string The plugin name (e.g., 'wikicreator') + */ + protected function getPluginName(): string + { + return $this->plugin->name; + } +} diff --git a/src/MissingStrings/Requirements/GenericStringRequirements.php b/src/MissingStrings/Requirements/GenericStringRequirements.php new file mode 100644 index 00000000..45da858e --- /dev/null +++ b/src/MissingStrings/Requirements/GenericStringRequirements.php @@ -0,0 +1,36 @@ +type) { + case 'mod': + return new ModuleStringRequirements($plugin, $moodleVersion); + default: + // Use generic requirements for all other plugin types + return new GenericStringRequirements($plugin, $moodleVersion); + } + } +} diff --git a/src/MissingStrings/StringContext.php b/src/MissingStrings/StringContext.php new file mode 100644 index 00000000..3d60954d --- /dev/null +++ b/src/MissingStrings/StringContext.php @@ -0,0 +1,143 @@ +file = $file; + $this->line = $line; + $this->description = $description; + } + + /** + * Get the file name. + * + * @return string|null the file name + */ + public function getFile(): ?string + { + return $this->file; + } + + /** + * Get the line number. + * + * @return int|null the line number + */ + public function getLine(): ?int + { + return $this->line; + } + + /** + * Get the description. + * + * @return string|null the description + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * Set the line number. + * + * @param int $line the line number + */ + public function setLine(int $line): void + { + $this->line = $line; + } + + /** + * Check if this context has file and line information. + * + * @return bool true if file and line are available + */ + public function hasLocation(): bool + { + return !empty($this->file) && null !== $this->line; + } + + /** + * Convert to array format for error context. + * + * @return array array representation for error handlers + */ + public function toArray(): array + { + $array = []; + + if ($this->hasLocation()) { + $array['file'] = $this->file; + $array['line'] = $this->line; + } + + if (!empty($this->description)) { + $array['context'] = $this->description; + } + + return $array; + } + + /** + * Convert to string for display. + * + * @return string string representation + */ + public function __toString(): string + { + $parts = []; + + if (!empty($this->description)) { + $parts[] = $this->description; + } + + if ($this->hasLocation()) { + $parts[] = "in {$this->file}:{$this->line}"; + } + + return implode(' ', $parts); + } +} diff --git a/src/MissingStrings/StringUsageFinder.php b/src/MissingStrings/StringUsageFinder.php new file mode 100644 index 00000000..2df2ee75 --- /dev/null +++ b/src/MissingStrings/StringUsageFinder.php @@ -0,0 +1,157 @@ +getDefaultPattern($stringKey); + + // Look for the string key in the file + foreach ($lines as $lineNumber => $line) { + // @psalm-suppress ArgumentTypeCoercion + if (preg_match($searchPattern, $line)) { + return (int) $lineNumber + 1; // Convert to 1-based line numbers + } + } + + return null; + } + + /** + * Find line number for array key definitions (like in db/access.php). + * + * @param string $filePath The file to search in + * @param string $arrayKey The array key to find + * + * @return int|null Line number if found, null otherwise + */ + public function findArrayKeyLine(string $filePath, string $arrayKey): ?int + { + // Use ~ as delimiter to avoid conflicts with / in capability names + $pattern = '~[\'"](?:' . preg_quote($arrayKey, '~') . ')[\'\"]\s*=>~'; + + return $this->findLineInFile($filePath, $arrayKey, $pattern); + } + + /** + * Find line number for string literals in code. + * + * @param string $filePath The file to search in + * @param string $stringKey The string key to find + * + * @return int|null Line number if found, null otherwise + */ + public function findStringLiteralLine(string $filePath, string $stringKey): ?int + { + if (!FileContentCache::fileExists($filePath)) { + return null; + } + + $lines = FileContentCache::getLines($filePath); + if (false === $lines) { + return null; + } + + // Look for the string key in the file, handling escaped quotes + foreach ($lines as $lineNumber => $line) { + // Handle both single and double quoted strings with escaping + if ($this->containsStringLiteral($line, $stringKey)) { + return (int) $lineNumber + 1; // Convert to 1-based line numbers + } + } + + return null; + } + + /** + * Get the default search pattern for a string key. + * + * @param string $stringKey The string key + * + * @return string Regex pattern to match the string key + */ + private function getDefaultPattern(string $stringKey): string + { + // Default pattern looks for string literals in quotes + // Use ~ as delimiter to avoid conflicts with / in string keys + return '~[\'"](?:' . preg_quote($stringKey, '~') . ')[\'"]~'; + } + + /** + * Check if a line contains a string literal with the given content. + * + * @param string $line The line to check + * @param string $stringKey The string key to find + * + * @return bool True if the string literal is found + */ + private function containsStringLiteral(string $line, string $stringKey): bool + { + // Handle single-quoted strings (with escaped single quotes) + if (preg_match_all("~'((?:[^'\\\\]|\\\\.)*)'~", $line, $matches)) { + foreach ($matches[1] as $match) { + // Unescape the content - handle escaped single quotes + $unescaped = str_replace("\\'", "'", $match); + if ($unescaped === $stringKey) { + return true; + } + } + } + + // Handle double-quoted strings (with escaped double quotes) + if (preg_match_all('~"((?:[^"\\\\]|\\\\.)*)"~', $line, $matches)) { + foreach ($matches[1] as $match) { + // Unescape the content - handle escaped double quotes + $unescaped = str_replace('\\"', '"', $match); + if ($unescaped === $stringKey) { + return true; + } + } + } + + return false; + } +} diff --git a/src/MissingStrings/StringValidator.php b/src/MissingStrings/StringValidator.php new file mode 100644 index 00000000..0bddf55a --- /dev/null +++ b/src/MissingStrings/StringValidator.php @@ -0,0 +1,545 @@ +plugin = $plugin; + $this->moodle = $moodle; + $this->config = $config; + + // Initialize checkers from registry and config + $this->checkers = $this->config->shouldUseDefaultCheckers() + ? CheckersRegistry::getCheckers() + : []; + + // Add custom checkers from config + $this->checkers = array_merge($this->checkers, $this->config->getCustomCheckers()); + + // Initialize requirements resolver + $this->requirementsResolver = new StringRequirementsResolver(); + + // Initialize extraction service + $this->extractor = new StringExtractor(); + + // Initialize file discovery service + $this->fileDiscovery = new FileDiscovery($plugin); + + // Inject file discovery into extraction service + $this->extractor->setFileDiscovery($this->fileDiscovery); + + // Initialize subplugin discovery service + /* @psalm-suppress UndefinedClass */ + $this->subpluginDiscovery = new SubpluginDiscovery(); + } + + /** + * Validate all strings in the plugin and its subplugins. + * + * @return ValidationResult the result of the validation + */ + public function validate(): ValidationResult + { + $startTime = microtime(true); + + $result = new ValidationResult($this->config->isStrict()); + $this->errorHandler = new ErrorHandler($result, $this->config->isDebugEnabled()); + + // Count main plugin + $result->incrementPluginCount(); + + // Validate the main plugin + $this->validateSinglePlugin($this->plugin); + + // Discover and validate subplugins + $this->errorHandler->safeExecute( + fn () => $this->validateSubplugins(), + 'Validating subplugins' + ); + + // Record total processing time if debug enabled + if ($this->config->isDebugEnabled()) { + $totalTime = microtime(true) - $startTime; + $result->setProcessingTime($totalTime); + } + + return $result; + } + + /** + * Validate a single plugin (main plugin or subplugin). + * + * @param Plugin $plugin the plugin to validate + */ + private function validateSinglePlugin(Plugin $plugin): void + { + // Store original plugin and file discovery + $originalPlugin = $this->plugin; + $originalFileDiscovery = $this->fileDiscovery; + $originalExtractor = $this->extractor; + + try { + // Temporarily switch to the plugin being validated + $this->plugin = $plugin; + $this->fileDiscovery = new FileDiscovery($plugin); + $this->extractor = new StringExtractor(); + $this->extractor->setFileDiscovery($this->fileDiscovery); + + // Track debug information if enabled + if ($this->config->isDebugEnabled()) { + // Collect file discovery metrics + $fileMetrics = $this->fileDiscovery->getPerformanceMetrics(); + $this->errorHandler->getResult()->addFileCounts($fileMetrics['file_types']); + $this->errorHandler->getResult()->addPhaseTime('file_discovery_' . $plugin->component, $fileMetrics['discovery_time']); + } + + // Get defined strings from language file + $phaseStart = microtime(true); + $definedStrings = $this->errorHandler->safeExecute( + fn () => $this->getDefinedStrings(), + "Loading language file for {$plugin->component}" + ); + if ($this->config->isDebugEnabled()) { + $this->errorHandler->getResult()->addPhaseTime('lang_loading_' . $plugin->component, microtime(true) - $phaseStart); + $this->errorHandler->getResult()->addStringCounts(['defined_strings' => count($definedStrings)]); + } + + // Basic validation - check if language file exists + if (empty($definedStrings)) { + $langFileName = $this->getLangFileName(); + $langFile = "lang/{$this->config->getLanguage()}/{$langFileName}.php"; + $this->errorHandler->addError( + 'Language file not found or empty', + [ + 'file' => $langFile, + 'component' => $plugin->component, + ] + ); + + return; + } + + // Get plugin-specific requirements + $phaseStart = microtime(true); + $requirements = $this->errorHandler->safeExecute( + fn () => $this->requirementsResolver->resolve($plugin, $this->moodle->getBranch()), + "Resolving plugin requirements for {$plugin->component}" + ); + if ($this->config->isDebugEnabled()) { + $this->errorHandler->getResult()->addPhaseTime('requirements_resolve_' . $plugin->component, microtime(true) - $phaseStart); + } + + if ($requirements) { + // Validate required strings from requirements based on the plugin type. + $phaseStart = microtime(true); + $this->errorHandler->safeExecute( + fn () => $this->validateRequiredStrings($requirements->getRequiredStrings(), $definedStrings), + "Validating required strings for {$plugin->component}" + ); + if ($this->config->isDebugEnabled()) { + $this->errorHandler->getResult()->addPhaseTime('required_validation_' . $plugin->component, microtime(true) - $phaseStart); + $this->errorHandler->getResult()->addStringCounts(['required_strings' => count($requirements->getRequiredStrings())]); + } + } + + // Run string checkers for database files and other sources. + $phaseStart = microtime(true); + $this->errorHandler->safeExecute( + fn () => $this->runStringCheckers($definedStrings), + "Running string checkers for {$plugin->component}" + ); + if ($this->config->isDebugEnabled()) { + $this->errorHandler->getResult()->addPhaseTime('checkers_' . $plugin->component, microtime(true) - $phaseStart); + } + + // Find and validate used strings in the plugin code. + $phaseStart = microtime(true); + $this->errorHandler->safeExecute( + fn () => $this->validateUsedStrings($definedStrings), + "Validating used strings for {$plugin->component}" + ); + if ($this->config->isDebugEnabled()) { + $this->errorHandler->getResult()->addPhaseTime('used_validation_' . $plugin->component, microtime(true) - $phaseStart); + + // Collect string extraction metrics + $extractorMetrics = $this->extractor->getPerformanceMetrics(); + $this->errorHandler->getResult()->addStringCounts([ + 'strings_extracted' => $extractorMetrics['strings_extracted'], + 'string_usages_found' => $extractorMetrics['string_usages_found'], + ]); + $this->errorHandler->getResult()->addPhaseTime('string_extraction_' . $plugin->component, $extractorMetrics['extraction_time']); + } + + // Check for unused strings if requested. + if ($this->config->shouldCheckUnused()) { + $phaseStart = microtime(true); + $this->errorHandler->safeExecute( + fn () => $this->validateUnusedStrings($definedStrings, $requirements), + "Checking for unused strings in {$plugin->component}" + ); + if ($this->config->isDebugEnabled()) { + $this->errorHandler->getResult()->addPhaseTime('unused_validation_' . $plugin->component, microtime(true) - $phaseStart); + } + } + } finally { + // Restore original plugin and file discovery + $this->plugin = $originalPlugin; + $this->fileDiscovery = $originalFileDiscovery; + $this->extractor = $originalExtractor; + } + } + + /** + * Discover and validate all subplugins. + */ + private function validateSubplugins(): void + { + $subplugins = $this->subpluginDiscovery->discoverSubplugins($this->plugin); + + if (empty($subplugins)) { + return; + } + + foreach ($subplugins as $subplugin) { + // Count each subplugin + $this->errorHandler->getResult()->incrementSubpluginCount(); + + $this->errorHandler->safeExecute( + fn () => $this->validateSinglePlugin($subplugin), + "Validating subplugin {$subplugin->component}", + true // Allow continuing on errors for subplugins + ); + } + } + + /** + * Get defined strings from language file. + * + * @return array the defined strings + */ + private function getDefinedStrings(): array + { + // Get the correct language file name based on plugin type + $langFileName = $this->getLangFileName(); + $langFile = $this->plugin->directory . "/lang/{$this->config->getLanguage()}/{$langFileName}.php"; + + if (!file_exists($langFile)) { + throw FileException::fileNotFound($langFile, ['component' => $this->plugin->component]); + } + + if (!is_readable($langFile)) { + throw FileException::fileNotReadable($langFile, ['component' => $this->plugin->component]); + } + + $string = []; + + try { + include $langFile; + } catch (\Throwable $e) { + throw FileException::parsingError($langFile, $e->getMessage(), [], $e); + } + + return $string; + } + + /** + * Get the correct language file name based on plugin type. + * For modules (mod), use just the plugin name. For others, use the full component name. + * + * @return string The language file name without .php extension + */ + private function getLangFileName(): string + { + return 'mod' === $this->plugin->type + ? $this->plugin->name + : $this->plugin->component; + } + + /** + * Validate required strings against defined strings. + * + * @param array $requiredStrings the required strings to validate (string key => context pairs) + * @param array $definedStrings the defined strings + */ + private function validateRequiredStrings(array $requiredStrings, array $definedStrings): void + { + foreach ($requiredStrings as $stringKey => $context) { + // Handle both array formats: ['key1', 'key2'] and ['key1' => 'context1', 'key2' => 'context2'] + if (is_numeric($stringKey)) { + // Array of string keys without context + $stringKey = $context; + $context = ''; + } + + if ($this->config->shouldExcludeString($stringKey)) { + continue; + } + + if (!array_key_exists($stringKey, $definedStrings)) { + $errorContext = [ + 'string_key' => $stringKey, + 'component' => $this->plugin->component, + ]; + + // Add context information if available + if ($context instanceof StringContext) { + // Convert StringContext to array format + $contextArray = $context->toArray(); + $errorContext = array_merge($errorContext, $contextArray); + } elseif (!empty($context)) { + $errorContext['context'] = $context; + } + + $this->errorHandler->addError( + 'Missing required string', + $errorContext + ); + } else { + // Count successful validations without displaying them + $this->errorHandler->getResult()->addSuccess(''); + } + } + } + + /** + * Validate used strings against defined strings. + * + * @param array $definedStrings the defined strings + */ + private function validateUsedStrings(array $definedStrings): void + { + $usedStrings = $this->extractor->extractFromPlugin($this->plugin); + + foreach ($usedStrings as $stringKey => $usages) { + if ($this->config->shouldExcludeString($stringKey)) { + continue; + } + + if (!array_key_exists($stringKey, $definedStrings)) { + // Get the first usage for context + $firstUsage = $usages[0]; + $this->errorHandler->addError( + 'Missing used string', + [ + 'string_key' => $stringKey, + 'file' => $firstUsage['file'], + 'line' => $firstUsage['line'], + 'component' => $this->plugin->component, + ] + ); + } else { + // Count successful validations without displaying them + $this->errorHandler->getResult()->addSuccess(''); + } + } + } + + /** + * Validate unused strings (defined but not used). + * + * @param array $definedStrings the defined strings + * @param mixed $requirements plugin requirements (may be null if resolution failed) + */ + private function validateUnusedStrings(array $definedStrings, $requirements): void + { + $usedStrings = $this->extractor->extractFromPlugin($this->plugin); + + // Get required strings to avoid marking them as unused + $requiredStrings = []; + if ($requirements) { + $requiredStrings = $requirements->getRequiredStrings(); + } + + foreach ($definedStrings as $stringKey => $stringValue) { + if ($this->config->shouldExcludeString($stringKey)) { + continue; + } + + // Don't report required strings as unused + if (in_array($stringKey, $requiredStrings, true)) { + continue; + } + + if (!isset($usedStrings[$stringKey])) { + $this->errorHandler->addWarning( + 'Unused string (defined but not used)', + [ + 'string_key' => $stringKey, + 'component' => $this->plugin->component, + ] + ); + } + } + } + + /** + * Set custom checkers. + * + * @param StringCheckerInterface[] $checkers array of checker instances + */ + public function setCheckers(array $checkers): void + { + $this->checkers = $checkers; + } + + /** + * Add a string checker. + * + * @param StringCheckerInterface $checker the checker to add + */ + public function addChecker(StringCheckerInterface $checker): void + { + $this->checkers[] = $checker; + } + + /** + * Run all applicable string checkers. + * + * @param array $definedStrings the defined strings + */ + private function runStringCheckers(array $definedStrings): void + { + foreach ($this->checkers as $checker) { + // Inject file discovery service if the checker supports it + if ($checker instanceof FileDiscoveryAwareInterface) { + $checker->setFileDiscovery($this->fileDiscovery); + } + + if (!$checker->appliesTo($this->plugin)) { + continue; + } + + $this->errorHandler->safeExecute(function () use ($checker, $definedStrings) { + $checkResult = $checker->check($this->plugin); + + // Add any errors or warnings from the checker + foreach ($checkResult->getErrors() as $error) { + $this->errorHandler->addError( + $error, + ['checker' => $checker->getName()] + ); + } + + foreach ($checkResult->getWarnings() as $warning) { + $this->errorHandler->addWarning( + $warning, + ['checker' => $checker->getName()] + ); + } + + // Validate required strings found by the checker + $this->validateRequiredStrings( + $checkResult->getRequiredStrings(), + $definedStrings + ); + }, "Running checker {$checker->getName()}", true); + } + } +} diff --git a/src/MissingStrings/ValidationConfig.php b/src/MissingStrings/ValidationConfig.php new file mode 100644 index 00000000..fbb799f5 --- /dev/null +++ b/src/MissingStrings/ValidationConfig.php @@ -0,0 +1,202 @@ +language = $language; + $this->strict = $strict; + $this->checkUnused = $checkUnused; + $this->excludePatterns = $excludePatterns; + $this->customCheckers = $customCheckers; + $this->useDefaultCheckers = $useDefaultCheckers; + $this->debug = $debug; + } + + /** + * Create configuration from command line options. + * + * @param array $options Array of options (e.g., from Symfony Console Input). + */ + public static function fromOptions(array $options): self + { + $excludePatterns = []; + if (!empty($options['exclude-patterns'])) { + $patterns = explode(',', $options['exclude-patterns']); + $excludePatterns = array_filter(array_map('trim', $patterns)); + } + + return new self( + $options['lang'] ?? 'en', + $options['strict'] ?? false, + $options['unused'] ?? false, + $excludePatterns, + [], + true, + $options['debug'] ?? false + ); + } + + // === Getter Methods === + + /** + * Get the language to validate against. + * + * @return string language code + */ + public function getLanguage(): string + { + return $this->language; + } + + /** + * Check if strict mode is enabled. + * + * @return bool true if strict mode is enabled + */ + public function isStrict(): bool + { + return $this->strict; + } + + /** + * Check if unused string checking is enabled. + * + * @return bool true if unused checking is enabled + */ + public function shouldCheckUnused(): bool + { + return $this->checkUnused; + } + + /** + * Get exclude patterns. + * + * @return array array of exclude patterns + */ + public function getExcludePatterns(): array + { + return $this->excludePatterns; + } + + /** + * Get custom checkers. + * + * @return array array of custom checkers + */ + public function getCustomCheckers(): array + { + return $this->customCheckers; + } + + /** + * Check if default checkers should be used. + * + * @return bool true if default checkers should be used + */ + public function shouldUseDefaultCheckers(): bool + { + return $this->useDefaultCheckers; + } + + /** + * Check if debug mode is enabled. + * + * @return bool true if debug mode is enabled + */ + public function isDebugEnabled(): bool + { + return $this->debug; + } + + /** + * Check if a string should be excluded based on patterns. + * + * @param string $stringKey the string key to check + * + * @return bool true if the string should be excluded + */ + public function shouldExcludeString(string $stringKey): bool + { + foreach ($this->excludePatterns as $pattern) { + if (fnmatch($pattern, $stringKey)) { + return true; + } + } + + return false; + } +} diff --git a/src/MissingStrings/ValidationResult.php b/src/MissingStrings/ValidationResult.php new file mode 100644 index 00000000..63a0ad43 --- /dev/null +++ b/src/MissingStrings/ValidationResult.php @@ -0,0 +1,435 @@ + + */ + private $requiredStrings = []; + + /** + * Raw error messages (without formatting). + * + * @var string[] + */ + private $errors = []; + + /** + * Raw warning messages (without formatting). + * + * @var string[] + */ + private $warnings = []; + + /** + * Formatted messages for display. + * + * @var string[] + */ + private $messages = []; + + /** + * Success count for statistics. + */ + private int $successCount = 0; + + /** + * Strict mode flag. + */ + private $strict; + + /** + * Debug performance data. + * + * @var array + */ + private $debugData = [ + 'processing_time' => 0.0, + 'file_counts' => [], + 'string_counts' => [], + 'phase_timings' => [], + 'plugin_count' => 0, + 'subplugin_count' => 0, + ]; + + public function __construct(bool $strict = false) + { + $this->strict = $strict; + } + + // === Data Collection Methods (for checkers) === + + /** + * Add a required string. + * + * @param string $stringKey the string identifier + * @param StringContext $context context information about why this string is required + */ + public function addRequiredString(string $stringKey, StringContext $context): void + { + $this->requiredStrings[$stringKey] = $context; + } + + /** + * Add a raw error message (without formatting). + * + * @param string $message the error message + */ + public function addRawError(string $message): void + { + $this->errors[] = $message; + } + + /** + * Add a raw warning message (without formatting). + * + * @param string $message the warning message + */ + public function addRawWarning(string $message): void + { + $this->warnings[] = $message; + } + + // === Presentation Methods (for main validator) === + + /** + * Add a formatted error message for display. + * + * @param string $message the error message + */ + public function addError(string $message): void + { + $this->errors[] = $message; + $this->messages[] = sprintf('✗ %s', $message); + } + + /** + * Add a formatted warning message for display. + * + * @param string $message the warning message + */ + public function addWarning(string $message): void + { + $this->warnings[] = $message; + $this->messages[] = sprintf('⚠ %s', $message); + } + + /** + * Add a success (for statistics, optionally with display message). + * + * @param string $message optional success message for display + */ + public function addSuccess(string $message = ''): void + { + // Only add to messages if message is not empty (for display purposes) + if (!empty($message)) { + $this->messages[] = sprintf('✓ %s', $message); + } + ++$this->successCount; + } + + // === Data Access Methods === + + /** + * Get required strings. + * + * @return array array of string key => context pairs + */ + public function getRequiredStrings(): array + { + return $this->requiredStrings; + } + + /** + * Get all required string keys (without context). + * + * @return array array of string keys + */ + public function getRequiredStringKeys(): array + { + return array_keys($this->requiredStrings); + } + + /** + * Get raw error messages. + * + * @return array array of error messages + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * Get raw warning messages. + * + * @return array array of warning messages + */ + public function getWarnings(): array + { + return $this->warnings; + } + + /** + * Get formatted messages for display. + * + * @return array the formatted messages + */ + public function getMessages(): array + { + return $this->messages; + } + + // === Statistics and Status Methods === + + /** + * Get error count. + * + * @return int the error count + */ + public function getErrorCount(): int + { + return count($this->errors); + } + + /** + * Get warning count. + * + * @return int the warning count + */ + public function getWarningCount(): int + { + return count($this->warnings); + } + + /** + * Get success count. + * + * @return int the success count + */ + public function getSuccessCount(): int + { + return $this->successCount; + } + + /** + * Get total issues count. + * + * @return int the total issues count + */ + public function getTotalIssues(): int + { + return $this->getErrorCount() + $this->getWarningCount(); + } + + /** + * Check if there are any required strings. + * + * @return bool true if there are required strings + */ + public function hasRequiredStrings(): bool + { + return !empty($this->requiredStrings); + } + + /** + * Check if there are any errors. + * + * @return bool true if there are errors + */ + public function hasErrors(): bool + { + return !empty($this->errors); + } + + /** + * Check if there are any warnings. + * + * @return bool true if there are warnings + */ + public function hasWarnings(): bool + { + return !empty($this->warnings); + } + + /** + * Check if validation is valid. + * + * @return bool true if the validation is valid, false otherwise + */ + public function isValid(): bool + { + if ($this->strict) { + return 0 === $this->getErrorCount() && 0 === $this->getWarningCount(); + } + + return 0 === $this->getErrorCount(); + } + + /** + * Get summary statistics. + * + * @return array the summary statistics + */ + public function getSummary(): array + { + return [ + 'errors' => $this->getErrorCount(), + 'warnings' => $this->getWarningCount(), + 'successes' => $this->successCount, + 'total_issues' => $this->getTotalIssues(), + 'is_valid' => $this->isValid(), + ]; + } + + /** + * Merge another result into this one. + * + * @param ValidationResult $other the other result to merge + */ + public function merge(self $other): void + { + // Merge required strings + foreach ($other->getRequiredStrings() as $key => $context) { + $this->addRequiredString($key, $context); + } + + // Merge raw errors and warnings (without formatting) + foreach ($other->getErrors() as $error) { + $this->addRawError($error); + } + + foreach ($other->getWarnings() as $warning) { + $this->addRawWarning($warning); + } + + // Add success count + $this->successCount += $other->getSuccessCount(); + + // Merge debug data + $otherDebug = $other->getDebugData(); + $this->debugData['processing_time'] += $otherDebug['processing_time']; + $this->debugData['plugin_count'] += $otherDebug['plugin_count']; + $this->debugData['subplugin_count'] += $otherDebug['subplugin_count']; + + // Merge file counts + foreach ($otherDebug['file_counts'] as $type => $count) { + $this->debugData['file_counts'][$type] = ($this->debugData['file_counts'][$type] ?? 0) + $count; + } + + // Merge string counts + foreach ($otherDebug['string_counts'] as $type => $count) { + $this->debugData['string_counts'][$type] = ($this->debugData['string_counts'][$type] ?? 0) + $count; + } + + // Merge phase timings + foreach ($otherDebug['phase_timings'] as $phase => $timing) { + $this->debugData['phase_timings'][$phase] = ($this->debugData['phase_timings'][$phase] ?? 0) + $timing; + } + } + + // === Debug Data Methods === + + /** + * Set debug data for performance tracking. + * + * @param array $data debug data to set + */ + public function setDebugData(array $data): void + { + $this->debugData = array_merge($this->debugData, $data); + } + + /** + * Add debug timing for a specific phase. + * + * @param string $phase phase name + * @param float $time time in seconds + */ + public function addPhaseTime(string $phase, float $time): void + { + $this->debugData['phase_timings'][$phase] = ($this->debugData['phase_timings'][$phase] ?? 0) + $time; + } + + /** + * Add file count data. + * + * @param array $fileCounts array of file type => count pairs + */ + public function addFileCounts(array $fileCounts): void + { + foreach ($fileCounts as $type => $count) { + $this->debugData['file_counts'][$type] = ($this->debugData['file_counts'][$type] ?? 0) + $count; + } + } + + /** + * Add string count data. + * + * @param array $stringCounts array of string type => count pairs + */ + public function addStringCounts(array $stringCounts): void + { + foreach ($stringCounts as $type => $count) { + $this->debugData['string_counts'][$type] = ($this->debugData['string_counts'][$type] ?? 0) + $count; + } + } + + /** + * Increment plugin count. + */ + public function incrementPluginCount(): void + { + ++$this->debugData['plugin_count']; + } + + /** + * Increment subplugin count. + */ + public function incrementSubpluginCount(): void + { + ++$this->debugData['subplugin_count']; + } + + /** + * Set total processing time. + * + * @param float $time time in seconds + */ + public function setProcessingTime(float $time): void + { + $this->debugData['processing_time'] = $time; + } + + /** + * Get debug data. + * + * @return array debug performance data + */ + public function getDebugData(): array + { + return $this->debugData; + } +} diff --git a/tests/Command/MissingStringsCommandTest.php b/tests/Command/MissingStringsCommandTest.php new file mode 100644 index 00000000..dc2e8d97 --- /dev/null +++ b/tests/Command/MissingStringsCommandTest.php @@ -0,0 +1,400 @@ +testPluginPath = $this->createTempDir('test_plugin_'); + $this->createCommandTestPlugin(); + + // Set up command with mocked dependencies + $this->application = new Application(); + $this->command = new MissingStringsCommand(); + + // Mock the Moodle and plugin dependencies + $this->command->moodle = new DummyMoodle($this->createTempDir('moodle_')); + $this->command->plugin = new MoodlePlugin($this->testPluginPath); + + $this->application->add($this->command); + $this->commandTester = new CommandTester($this->command); + } + + protected function tearDown(): void + { + parent::tearDown(); + // Temp directories are cleaned up automatically by base class + } + + /** + * Test command configuration. + */ + public function testCommandConfiguration(): void + { + $this->assertSame('missingstrings', $this->command->getName()); + $this->assertContains('missing-strings', $this->command->getAliases()); + $this->assertStringContainsString('missing language strings', $this->command->getDescription()); + + $definition = $this->command->getDefinition(); + $this->assertTrue($definition->hasOption('lang')); + $this->assertTrue($definition->hasOption('strict')); + $this->assertTrue($definition->hasOption('unused')); + $this->assertTrue($definition->hasOption('exclude-patterns')); + $this->assertTrue($definition->hasOption('debug')); + } + + /** + * Test command with valid plugin. + */ + public function testCommandWithValidPlugin(): void + { + $this->createLanguageFile(['pluginname' => 'Test Plugin']); + + $exitCode = $this->commandTester->execute([ + 'plugin' => $this->testPluginPath, + ]); + + $this->assertSame(0, $exitCode); + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('All language strings are valid', $output); + $this->assertStringContainsString('No issues found', $output); + } + + /** + * Test command with missing required string. + */ + public function testCommandWithMissingRequiredString(): void + { + // Don't create language file, should fail + + $exitCode = $this->commandTester->execute([ + 'plugin' => $this->testPluginPath, + ]); + + $this->assertSame(1, $exitCode); + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Language string validation failed', $output); + $this->assertStringContainsString('Errors:', $output); + } + + /** + * Test command with strict mode option. + */ + public function testCommandWithStrictMode(): void + { + $this->createLanguageFile([ + 'pluginname' => 'Test Plugin', + 'unused_string' => 'Unused string', + ]); + + $exitCode = $this->commandTester->execute([ + 'plugin' => $this->testPluginPath, + '--strict' => true, + '--unused' => true, + ]); + + $this->assertSame(1, $exitCode); // Should fail due to unused string in strict mode + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Warnings:', $output); + } + + /** + * Test command with unused strings option. + */ + public function testCommandWithUnusedStringsOption(): void + { + $this->createLanguageFile([ + 'pluginname' => 'Test Plugin', + 'unused_string' => 'Unused string', + ]); + + $exitCode = $this->commandTester->execute([ + 'plugin' => $this->testPluginPath, + '--unused' => true, + ]); + + $this->assertSame(0, $exitCode); // Should pass (warnings don't fail in non-strict mode) + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Warnings:', $output); + $this->assertStringContainsString('unused_string', $output); + } + + /** + * Test command with exclude patterns option. + */ + public function testCommandWithExcludePatternsOption(): void + { + $this->createLanguageFile([ + 'pluginname' => 'Test Plugin', + 'test_string' => 'Test string', + 'debug_message' => 'Debug message', + ]); + + $exitCode = $this->commandTester->execute([ + 'plugin' => $this->testPluginPath, + '--unused' => true, + '--exclude-patterns' => 'test_*,debug_*', + ]); + + $this->assertSame(0, $exitCode); + $output = $this->commandTester->getDisplay(); + + // Should not report excluded strings as unused + $this->assertStringNotContainsString('test_string', $output); + $this->assertStringNotContainsString('debug_message', $output); + } + + /** + * Test command with language option. + */ + public function testCommandWithLanguageOption(): void + { + // Create French language file + $langDir = $this->testPluginPath . '/lang/fr'; + if (!is_dir($langDir)) { + mkdir($langDir, 0755, true); + } + $langContent = "testPluginPath . '/lang/fr/local_testplugin.php', $langContent); + + $exitCode = $this->commandTester->execute([ + 'plugin' => $this->testPluginPath, + '--lang' => 'fr', + ]); + + $this->assertSame(0, $exitCode); + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('All language strings are valid', $output); + } + + /** + * Test command with debug option. + */ + public function testCommandWithDebugOption(): void + { + $this->createLanguageFile(['pluginname' => 'Test Plugin']); + + $exitCode = $this->commandTester->execute([ + 'plugin' => $this->testPluginPath, + '--debug' => true, + ], ['verbosity' => OutputInterface::VERBOSITY_DEBUG]); + + $this->assertSame(0, $exitCode); + // Debug mode doesn't change success output much, but enables debug information for errors + } + + /** + * Test command with missing language file for specified language. + */ + public function testCommandWithMissingLanguageFileForSpecifiedLanguage(): void + { + $this->createLanguageFile(['pluginname' => 'Test Plugin']); // Creates English only + + $exitCode = $this->commandTester->execute([ + 'plugin' => $this->testPluginPath, + '--lang' => 'de', // German not available + ]); + + $this->assertSame(1, $exitCode); + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Language string validation failed', $output); + } + + /** + * Test command with plugin containing used strings. + */ + public function testCommandWithPluginContainingUsedStrings(): void + { + $this->createLanguageFile([ + 'pluginname' => 'Test Plugin', + 'used_string' => 'Used string', + 'missing_string' => 'This will be missing from code', + ]); + + // Create PHP file with string usage + file_put_contents( + $this->testPluginPath . '/lib.php', + "commandTester->execute([ + 'plugin' => $this->testPluginPath, + ]); + + $this->assertSame(1, $exitCode); + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('nonexistent', $output); // Should report missing used string + $this->assertStringContainsString('Errors:', $output); + } + + /** + * Test command shows proper summary. + */ + public function testCommandShowsProperSummary(): void + { + $this->createLanguageFile([ + 'pluginname' => 'Test Plugin', + 'unused_string' => 'Unused string', + ]); + + // Create PHP file that uses a non-existent string + file_put_contents( + $this->testPluginPath . '/lib.php', + "commandTester->execute([ + 'plugin' => $this->testPluginPath, + '--unused' => true, + ]); + + $this->assertSame(1, $exitCode); + $output = $this->commandTester->getDisplay(); + + // Should show summary + $this->assertStringContainsString('Summary:', $output); + $this->assertStringContainsString('Errors:', $output); + $this->assertStringContainsString('Warnings:', $output); + $this->assertStringContainsString('Total issues:', $output); + $this->assertStringContainsString('Language string validation failed', $output); + } + + /** + * Test command with empty exclude patterns. + */ + public function testCommandWithEmptyExcludePatterns(): void + { + $this->createLanguageFile(['pluginname' => 'Test Plugin']); + + $exitCode = $this->commandTester->execute([ + 'plugin' => $this->testPluginPath, + '--exclude-patterns' => '', + ]); + + $this->assertSame(0, $exitCode); + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('All language strings are valid', $output); + } + + /** + * Test command header output. + */ + public function testCommandHeaderOutput(): void + { + $this->createLanguageFile(['pluginname' => 'Test Plugin']); + + $exitCode = $this->commandTester->execute([ + 'plugin' => $this->testPluginPath, + ]); + + $this->assertSame(0, $exitCode); + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('Checking for missing language strings', $output); + } + + /** + * Test command with module plugin (different naming convention). + */ + public function testCommandWithModulePlugin(): void + { + // Create module plugin structure + $modulePath = $this->createTempDir('test_module_'); + // Directory is already created by createTempDir() + + // Create version.php for module + $versionContent = "component = 'mod_testmodule';\n\$plugin->version = 2023010100;\n"; + file_put_contents($modulePath . '/version.php', $versionContent); + + // Create language file with module naming convention + $langDir = $modulePath . '/lang/en'; + if (!is_dir($langDir)) { + mkdir($langDir, 0755, true); + } + $langContent = "command->plugin); + + $exitCode = $this->commandTester->execute([ + 'plugin' => $modulePath, + ]); + + $this->assertSame(0, $exitCode); + $output = $this->commandTester->getDisplay(); + $this->assertStringContainsString('All language strings are valid', $output); + + // Temp directories are cleaned up automatically by base class + } + + /** + * Create a test plugin directory structure. + */ + private function createCommandTestPlugin(): void + { + // Directory is already created by createTempDir() + + // Create version.php + $versionContent = "component = 'local_testplugin';\n\$plugin->version = 2023010100;\n"; + file_put_contents($this->testPluginPath . '/version.php', $versionContent); + } + + /** + * Create a language file for the test plugin. + * + * @param array $strings Array of string key => value pairs + */ + private function createLanguageFile(array $strings): void + { + $langDir = $this->testPluginPath . '/lang/en'; + if (!is_dir($langDir)) { + mkdir($langDir, 0755, true); + } + + $langContent = " $value) { + $langContent .= "\$string['{$key}'] = '{$value}';\n"; + } + + file_put_contents($this->testPluginPath . '/lang/en/local_testplugin.php', $langContent); + } +} diff --git a/tests/MissingStrings/Cache/FileContentCacheTest.php b/tests/MissingStrings/Cache/FileContentCacheTest.php new file mode 100644 index 00000000..2f3041af --- /dev/null +++ b/tests/MissingStrings/Cache/FileContentCacheTest.php @@ -0,0 +1,397 @@ +testDir = $this->createTempDir('cache_test_'); + FileContentCache::clearCache(); + } + + protected function tearDown(): void + { + FileContentCache::clearCache(); + parent::tearDown(); + } + + /** + * Test getContent with existing file. + */ + public function testGetContentWithExistingFile(): void + { + $testFile = $this->testDir . '/test.txt'; + $content = 'This is test content'; + file_put_contents($testFile, $content); + + $result = FileContentCache::getContent($testFile); + + $this->assertSame($content, $result); + } + + /** + * Test getContent with non-existing file. + */ + public function testGetContentWithNonExistingFile(): void + { + $testFile = $this->testDir . '/nonexistent.txt'; + + $result = FileContentCache::getContent($testFile); + + $this->assertFalse($result); + } + + /** + * Test getContent caching functionality. + */ + public function testGetContentCaching(): void + { + $testFile = $this->testDir . '/cached.txt'; + $content = 'Cached content'; + file_put_contents($testFile, $content); + + // First call + $result1 = FileContentCache::getContent($testFile); + $this->assertSame($content, $result1); + + // Second call should return cached version + $result2 = FileContentCache::getContent($testFile); + $this->assertSame($content, $result2); + + // Verify cache stats + $stats = FileContentCache::getStats(); + $this->assertSame(1, $stats['cached_files']); + } + + /** + * Test getContent cache invalidation when file is modified. + */ + public function testGetContentCacheInvalidation(): void + { + $testFile = $this->testDir . '/modified.txt'; + $content1 = 'Original content'; + file_put_contents($testFile, $content1); + + // First read + $result1 = FileContentCache::getContent($testFile); + $this->assertSame($content1, $result1); + + // Clear cache to force re-read (simulate time-based invalidation) + FileContentCache::clearCache(); + + // Modify file + $content2 = 'Modified content'; + file_put_contents($testFile, $content2); + + // Second read should get new content + $result2 = FileContentCache::getContent($testFile); + $this->assertSame($content2, $result2); + } + + /** + * Test getContent with unreadable file. + */ + public function testGetContentWithUnreadableFile(): void + { + // Test with a file that doesn't exist (simulates unreadable) + $testFile = $this->testDir . '/unreadable.txt'; + + // Don't create the file, so it's "unreadable" + $result = FileContentCache::getContent($testFile); + $this->assertFalse($result); + + // Also test with a file that exists but test the error path + $existingFile = $this->testDir . '/readable.txt'; + file_put_contents($existingFile, 'content'); + + // File should be readable normally + $result = FileContentCache::getContent($existingFile); + $this->assertSame('content', $result); + } + + /** + * Test getContent with invalid path. + */ + public function testGetContentWithInvalidPath(): void + { + $invalidPath = '/nonexistent/path/file.txt'; + + $result = FileContentCache::getContent($invalidPath); + + $this->assertFalse($result); + } + + /** + * Test fileExists with existing file. + */ + public function testFileExistsWithExistingFile(): void + { + $testFile = $this->testDir . '/exists.txt'; + file_put_contents($testFile, 'content'); + + $result = FileContentCache::fileExists($testFile); + + $this->assertTrue($result); + } + + /** + * Test fileExists with non-existing file. + */ + public function testFileExistsWithNonExistingFile(): void + { + $testFile = $this->testDir . '/nonexistent.txt'; + + $result = FileContentCache::fileExists($testFile); + + $this->assertFalse($result); + } + + /** + * Test fileExists with cached file. + */ + public function testFileExistsWithCachedFile(): void + { + $testFile = $this->testDir . '/cached_exists.txt'; + file_put_contents($testFile, 'content'); + + // Cache the file by reading it first + FileContentCache::getContent($testFile); + + // fileExists should return true for cached file + $result = FileContentCache::fileExists($testFile); + $this->assertTrue($result); + } + + /** + * Test getLines with existing file. + */ + public function testGetLinesWithExistingFile(): void + { + $testFile = $this->testDir . '/lines.txt'; + $content = "Line 1\nLine 2\nLine 3"; + file_put_contents($testFile, $content); + + $result = FileContentCache::getLines($testFile); + + $this->assertIsArray($result); + $this->assertCount(3, $result); + $this->assertSame(['Line 1', 'Line 2', 'Line 3'], $result); + } + + /** + * Test getLines with FILE_IGNORE_NEW_LINES flag. + */ + public function testGetLinesWithIgnoreNewLines(): void + { + $testFile = $this->testDir . '/lines_newlines.txt'; + $content = "Line 1\nLine 2\nLine 3\n"; + file_put_contents($testFile, $content); + + $result = FileContentCache::getLines($testFile, FILE_IGNORE_NEW_LINES); + + $this->assertSame(['Line 1', 'Line 2', 'Line 3'], $result); + } + + /** + * Test getLines without FILE_IGNORE_NEW_LINES flag. + */ + public function testGetLinesWithoutIgnoreNewLines(): void + { + $testFile = $this->testDir . '/lines_with_newlines.txt'; + $content = "Line 1\nLine 2\nLine 3\n"; + file_put_contents($testFile, $content); + + $result = FileContentCache::getLines($testFile, 0); + + $this->assertSame(['Line 1', 'Line 2', 'Line 3', ''], $result); + } + + /** + * Test getLines with non-existing file. + */ + public function testGetLinesWithNonExistingFile(): void + { + $testFile = $this->testDir . '/nonexistent_lines.txt'; + + $result = FileContentCache::getLines($testFile); + + $this->assertFalse($result); + } + + /** + * Test clearCache functionality. + */ + public function testClearCache(): void + { + $testFile = $this->testDir . '/clear_test.txt'; + file_put_contents($testFile, 'content'); + + // Cache a file + FileContentCache::getContent($testFile); + $stats = FileContentCache::getStats(); + $this->assertSame(1, $stats['cached_files']); + + // Clear cache + FileContentCache::clearCache(); + $stats = FileContentCache::getStats(); + $this->assertSame(0, $stats['cached_files']); + } + + /** + * Test getStats method. + */ + public function testGetStats(): void + { + $stats = FileContentCache::getStats(); + + $this->assertIsArray($stats); + $this->assertArrayHasKey('cached_files', $stats); + $this->assertArrayHasKey('max_files', $stats); + $this->assertArrayHasKey('memory_usage', $stats); + $this->assertIsInt($stats['cached_files']); + $this->assertIsInt($stats['max_files']); + $this->assertIsInt($stats['memory_usage']); + } + + /** + * Test cache size limit functionality. + */ + public function testCacheSizeLimit(): void + { + // Create more files than the cache limit (100) + $files = []; + for ($i = 0; $i < 105; ++$i) { + $testFile = $this->testDir . "/file{$i}.txt"; + file_put_contents($testFile, "Content for file {$i}"); + $files[] = $testFile; + } + + // Cache all files + foreach ($files as $file) { + FileContentCache::getContent($file); + } + + // Check that cache is limited + $stats = FileContentCache::getStats(); + $this->assertLessThanOrEqual(100, $stats['cached_files']); + } + + /** + * Test cache FIFO behavior. + */ + public function testCacheFIFOBehavior(): void + { + // Create exactly 101 files to trigger FIFO + $files = []; + for ($i = 0; $i < 101; ++$i) { + $testFile = $this->testDir . "/fifo{$i}.txt"; + file_put_contents($testFile, "Content {$i}"); + $files[] = $testFile; + } + + // Cache all files one by one + foreach ($files as $file) { + FileContentCache::getContent($file); + } + + // The first file should have been evicted + $stats = FileContentCache::getStats(); + $this->assertLessThanOrEqual(100, $stats['cached_files']); + } + + /** + * Test memory usage calculation in stats. + */ + public function testMemoryUsageCalculation(): void + { + $testFile = $this->testDir . '/memory_test.txt'; + $content = 'This is a test content for memory calculation'; + file_put_contents($testFile, $content); + + FileContentCache::getContent($testFile); + + $stats = FileContentCache::getStats(); + $this->assertGreaterThan(0, $stats['memory_usage']); + $this->assertGreaterThanOrEqual(strlen($content), $stats['memory_usage']); + } + + /** + * Test getContent with empty file. + */ + public function testGetContentWithEmptyFile(): void + { + $testFile = $this->testDir . '/empty.txt'; + file_put_contents($testFile, ''); + + $result = FileContentCache::getContent($testFile); + + $this->assertSame('', $result); + } + + /** + * Test getLines with empty file. + */ + public function testGetLinesWithEmptyFile(): void + { + $testFile = $this->testDir . '/empty_lines.txt'; + file_put_contents($testFile, ''); + + $result = FileContentCache::getLines($testFile); + + $this->assertSame([''], $result); + } + + /** + * Test path normalization. + */ + public function testPathNormalization(): void + { + $testFile = $this->testDir . '/normal.txt'; + file_put_contents($testFile, 'content'); + + // Test with the same file accessed via direct path + $result1 = FileContentCache::getContent($testFile); + $this->assertSame('content', $result1); + + // Test path normalization by accessing same file again + $result2 = FileContentCache::getContent($testFile); + $this->assertSame('content', $result2); + + // Should only have one cached entry since it's the same file + $stats = FileContentCache::getStats(); + $this->assertSame(1, $stats['cached_files']); + + // Test with a simple relative path that should work + $subdir = $this->testDir . '/subdir'; + mkdir($subdir, 0777, true); + $subdirFile = $subdir . '/file.txt'; + file_put_contents($subdirFile, 'subcontent'); + + $result3 = FileContentCache::getContent($subdirFile); + $this->assertSame('subcontent', $result3); + } +} diff --git a/tests/MissingStrings/Checker/CheckersRegistryTest.php b/tests/MissingStrings/Checker/CheckersRegistryTest.php new file mode 100644 index 00000000..07b49db7 --- /dev/null +++ b/tests/MissingStrings/Checker/CheckersRegistryTest.php @@ -0,0 +1,197 @@ +assertIsArray($checkers); + $this->assertNotEmpty($checkers); + $this->assertContainsOnlyInstancesOf(StringCheckerInterface::class, $checkers); + + // Should contain both database file and class method checkers + $this->assertCount(10, $checkers); // 6 database + 4 class method checkers + } + + /** + * Test databaseFileCheckers method returns correct checkers. + */ + public function testDatabaseFileCheckers(): void + { + $checkers = CheckersRegistry::databaseFileCheckers(); + + $this->assertIsArray($checkers); + $this->assertCount(6, $checkers); + $this->assertContainsOnlyInstancesOf(StringCheckerInterface::class, $checkers); + + // Check specific checker types + $checkerClasses = array_map('get_class', $checkers); + $this->assertContains(CapabilitiesChecker::class, $checkerClasses); + $this->assertContains(CachesChecker::class, $checkerClasses); + $this->assertContains(MessagesChecker::class, $checkerClasses); + $this->assertContains(MobileChecker::class, $checkerClasses); + $this->assertContains(SubpluginsChecker::class, $checkerClasses); + $this->assertContains(TagsChecker::class, $checkerClasses); + } + + /** + * Test classMethodCheckers method returns correct checkers. + */ + public function testClassMethodCheckers(): void + { + $checkers = CheckersRegistry::classMethodCheckers(); + + $this->assertIsArray($checkers); + $this->assertCount(4, $checkers); + $this->assertContainsOnlyInstancesOf(StringCheckerInterface::class, $checkers); + + // Check specific checker types + $checkerClasses = array_map('get_class', $checkers); + $this->assertContains(ExceptionChecker::class, $checkerClasses); + $this->assertContains(GradeItemChecker::class, $checkerClasses); + $this->assertContains(PrivacyProviderChecker::class, $checkerClasses); + $this->assertContains(SearchAreaChecker::class, $checkerClasses); + } + + /** + * Test that getCheckers returns combination of all checker types. + */ + public function testGetCheckersReturnsCombinedCheckers(): void + { + $allCheckers = CheckersRegistry::getCheckers(); + $databaseCheckers = CheckersRegistry::databaseFileCheckers(); + $classMethodCheckers = CheckersRegistry::classMethodCheckers(); + + $this->assertCount( + count($databaseCheckers) + count($classMethodCheckers), + $allCheckers + ); + + $allCheckerClasses = array_map('get_class', $allCheckers); + $databaseCheckerClasses = array_map('get_class', $databaseCheckers); + $classMethodCheckerClasses = array_map('get_class', $classMethodCheckers); + + // Verify all database checkers are included + foreach ($databaseCheckerClasses as $checkerClass) { + $this->assertContains($checkerClass, $allCheckerClasses); + } + + // Verify all class method checkers are included + foreach ($classMethodCheckerClasses as $checkerClass) { + $this->assertContains($checkerClass, $allCheckerClasses); + } + } + + /** + * Test that each checker method returns new instances. + */ + public function testCheckersReturnNewInstances(): void + { + $checkers1 = CheckersRegistry::getCheckers(); + $checkers2 = CheckersRegistry::getCheckers(); + + $this->assertCount(count($checkers1), $checkers2); + + // Each call should return new instances + for ($i = 0; $i < count($checkers1); ++$i) { + $this->assertSame(get_class($checkers1[$i]), get_class($checkers2[$i])); + $this->assertNotSame($checkers1[$i], $checkers2[$i]); + } + } + + /** + * Test that databaseFileCheckers returns new instances. + */ + public function testDatabaseFileCheckersReturnNewInstances(): void + { + $checkers1 = CheckersRegistry::databaseFileCheckers(); + $checkers2 = CheckersRegistry::databaseFileCheckers(); + + $this->assertCount(count($checkers1), $checkers2); + + // Each call should return new instances + for ($i = 0; $i < count($checkers1); ++$i) { + $this->assertSame(get_class($checkers1[$i]), get_class($checkers2[$i])); + $this->assertNotSame($checkers1[$i], $checkers2[$i]); + } + } + + /** + * Test that classMethodCheckers returns new instances. + */ + public function testClassMethodCheckersReturnNewInstances(): void + { + $checkers1 = CheckersRegistry::classMethodCheckers(); + $checkers2 = CheckersRegistry::classMethodCheckers(); + + $this->assertCount(count($checkers1), $checkers2); + + // Each call should return new instances + for ($i = 0; $i < count($checkers1); ++$i) { + $this->assertSame(get_class($checkers1[$i]), get_class($checkers2[$i])); + $this->assertNotSame($checkers1[$i], $checkers2[$i]); + } + } + + /** + * Test that all checkers implement the required interface. + */ + public function testAllCheckersImplementInterface(): void + { + $allCheckers = CheckersRegistry::getCheckers(); + + foreach ($allCheckers as $checker) { + $this->assertInstanceOf(StringCheckerInterface::class, $checker); + } + } + + /** + * Test that registry contains expected number of each checker type. + */ + public function testCheckerTypeDistribution(): void + { + $allCheckers = CheckersRegistry::getCheckers(); + $databaseCheckers = CheckersRegistry::databaseFileCheckers(); + $classMethodCheckers = CheckersRegistry::classMethodCheckers(); + + // Verify counts match expected numbers + $this->assertSame(6, count($databaseCheckers), 'Should have 6 database file checkers'); + $this->assertSame(4, count($classMethodCheckers), 'Should have 4 class method checkers'); + $this->assertSame(10, count($allCheckers), 'Should have 10 total checkers'); + } +} diff --git a/tests/MissingStrings/Checker/ClassMethodChecker/ExceptionCheckerTest.php b/tests/MissingStrings/Checker/ClassMethodChecker/ExceptionCheckerTest.php new file mode 100644 index 00000000..d4bfbbbf --- /dev/null +++ b/tests/MissingStrings/Checker/ClassMethodChecker/ExceptionCheckerTest.php @@ -0,0 +1,493 @@ +checker = new ExceptionChecker(); + } + + /** + * Test checker name. + */ + public function testGetName(): void + { + $this->assertSame('Exception', $this->checker->getName()); + } + + /** + * Test that checker applies to plugins with moodle_exception usage. + */ + public function testAppliesToWithMoodleException(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $this->assertTrue($this->checker->appliesTo($plugin)); + } + + /** + * Test that checker applies to plugins with custom exception classes. + */ + public function testAppliesToWithCustomException(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/exception/custom_exception.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $this->assertTrue($this->checker->appliesTo($plugin)); + } + + /** + * Test that checker applies to plugins with coding_exception usage. + */ + public function testAppliesToWithCodingException(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $this->assertTrue($this->checker->appliesTo($plugin)); + } + + /** + * Test that checker doesn't apply to plugins without exception usage. + */ + public function testAppliesToWithoutExceptions(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $this->assertFalse($this->checker->appliesTo($plugin)); + } + + /** + * Test detection of moodle_exception with explicit component. + */ + public function testCheckMoodleExceptionWithExplicitComponent(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(2, $requiredStrings); + $this->assertArrayHasKey('error_invalid_data', $requiredStrings); + $this->assertArrayHasKey('error_access_denied', $requiredStrings); + } + + /** + * Test that moodle_exception with different component is ignored. + */ + public function testCheckMoodleExceptionWithDifferentComponent(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(0, $requiredStrings); + } + + /** + * Test that moodle_exception with only error code is ignored (defaults to 'error'). + */ + public function testCheckMoodleExceptionWithOnlyErrorCode(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(0, $requiredStrings); + } + + /** + * Test detection of coding_exception with string key. + */ + public function testCheckCodingExceptionWithStringKey(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(2, $requiredStrings); + $this->assertArrayHasKey('invalid_parameter', $requiredStrings); + $this->assertArrayHasKey('missing_required_field', $requiredStrings); + } + + /** + * Test that coding_exception with plain message is ignored. + */ + public function testCheckCodingExceptionWithPlainMessage(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(0, $requiredStrings); + } + + /** + * Test detection of print_error with explicit component. + */ + public function testCheckPrintErrorWithExplicitComponent(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('error_access_denied', $requiredStrings); + } + + /** + * Test detection of print_error with only error code (defaults to current component). + */ + public function testCheckPrintErrorWithOnlyErrorCode(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('error_invalid_request', $requiredStrings); + } + + /** + * Test that print_error with different component is ignored. + */ + public function testCheckPrintErrorWithDifferentComponent(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(0, $requiredStrings); + } + + /** + * Test detection of various exception types. + */ + public function testCheckVariousExceptionTypes(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(3, $requiredStrings); + $this->assertArrayHasKey('invalid_param_type', $requiredStrings); + $this->assertArrayHasKey('file_not_found', $requiredStrings); + $this->assertArrayHasKey('database_error', $requiredStrings); + } + + /** + * Test detection of custom exception classes. + */ + public function testCheckCustomExceptionClass(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/exception/validation_exception.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + // Should detect potential string keys for the custom exception class + $requiredStrings = $result->getRequiredStrings(); + $this->assertGreaterThan(0, count($requiredStrings)); + + // Check that some expected string keys are present + $this->assertArrayHasKey('validation_exception', $requiredStrings); + } + + /** + * Test that non-exception classes are ignored. + */ + public function testCheckNonExceptionClass(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/helper/data_helper.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(0, $requiredStrings); + } + + /** + * Test error handling for unreadable files. + */ + public function testCheckUnreadableFile(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + // Should handle error gracefully + $this->assertInstanceOf(\MoodlePluginCI\MissingStrings\ValidationResult::class, $result); + + // Restore permissions for cleanup + chmod($libFile, 0644); + } + + /** + * Test string key validation logic. + */ + public function testLooksLikeStringKey(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + // Should only detect the valid string keys + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(3, $requiredStrings); + $this->assertArrayHasKey('error_invalid_parameter', $requiredStrings); + $this->assertArrayHasKey('missing_required_field', $requiredStrings); + $this->assertArrayHasKey('user:access_denied', $requiredStrings); + } + + /** + * Test that context information includes correct file and line numbers. + */ + public function testCheckContextInformation(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(3, $requiredStrings); + + $errors = $result->getErrors(); + $libFile = $pluginDir . '/lib.php'; + + foreach ($errors as $error) { + $this->assertSame($libFile, $error['file']); + $this->assertGreaterThan(0, $error['line']); + $this->assertNotEmpty($error['description']); + } + } +} diff --git a/tests/MissingStrings/Checker/ClassMethodChecker/GradeItemCheckerTest.php b/tests/MissingStrings/Checker/ClassMethodChecker/GradeItemCheckerTest.php new file mode 100644 index 00000000..8eec8fea --- /dev/null +++ b/tests/MissingStrings/Checker/ClassMethodChecker/GradeItemCheckerTest.php @@ -0,0 +1,633 @@ +checker = new GradeItemChecker(); + } + + /** + * Test checker name. + */ + public function testGetName(): void + { + $this->assertSame('Grade Item', $this->checker->getName()); + } + + /** + * Test that checker applies to plugins with gradeitems.php file. + */ + public function testAppliesToWithGradeItemsFile(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/gradeitems.php' => ' "submissions", + 1 => "grading" + ]; + } +} +', + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $this->assertTrue($this->checker->appliesTo($plugin)); + } + + /** + * Test that checker applies to plugins with itemnumber_mapping interface. + */ + public function testAppliesToWithItemNumberMapping(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/custom_grades.php' => ' "quiz_attempts", + 1 => "extra_credit" + ]; + } +} +', + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $this->assertTrue($this->checker->appliesTo($plugin)); + } + + /** + * Test that checker doesn't apply to plugins without grade item mappings. + */ + public function testAppliesToWithoutGradeItemMapping(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $this->assertFalse($this->checker->appliesTo($plugin)); + } + + /** + * Test basic grade items with standard gradeitems.php file. + */ + public function testCheckBasicGradeItemsFile(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/gradeitems.php' => ' "submissions", + 1 => "grading" + ]; + } +} +', + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(2, $requiredStrings); + $this->assertArrayHasKey('grade_submissions_name', $requiredStrings); + $this->assertArrayHasKey('grade_grading_name', $requiredStrings); + } + + /** + * Test grade items with empty item names (should be skipped). + */ + public function testCheckGradeItemsWithEmptyNames(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/gradeitems.php' => ' "", // Empty name should be skipped + 1 => "grading", + 2 => "feedback" + ]; + } +} +', + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + // Should only detect non-empty item names + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(2, $requiredStrings); + $this->assertArrayHasKey('grade_grading_name', $requiredStrings); + $this->assertArrayHasKey('grade_feedback_name', $requiredStrings); + } + + /** + * Test grade items in alternative file location. + */ + public function testCheckGradeItemsInAlternativeLocation(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/custom_gradeitems.php' => ' "quiz_attempts", + 1 => "bonus_points" + ]; + } +} +', + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(2, $requiredStrings); + $this->assertArrayHasKey('grade_quiz_attempts_name', $requiredStrings); + $this->assertArrayHasKey('grade_bonus_points_name', $requiredStrings); + } + + /** + * Test multiple grade item files in the same plugin. + */ + public function testCheckMultipleGradeItemFiles(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/gradeitems.php' => ' "submissions", + 1 => "grading" + ]; + } +} +', + 'classes/grades/additional_gradeitems.php' => ' "peer_review", + 1 => "self_assessment" + ]; + } +} +', + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(4, $requiredStrings); + $this->assertArrayHasKey('grade_submissions_name', $requiredStrings); + $this->assertArrayHasKey('grade_grading_name', $requiredStrings); + $this->assertArrayHasKey('grade_peer_review_name', $requiredStrings); + $this->assertArrayHasKey('grade_self_assessment_name', $requiredStrings); + } + + /** + * Test complex grade item mapping with various array formats. + */ + public function testCheckComplexGradeItemMapping(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/gradeitems.php' => ' "initial_submission", + 1 => "peer_reviews", + 2 => "final_grading", + 3 => "reflection_assignment" + ]; + } +} +', + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(4, $requiredStrings); + $this->assertArrayHasKey('grade_initial_submission_name', $requiredStrings); + $this->assertArrayHasKey('grade_peer_reviews_name', $requiredStrings); + $this->assertArrayHasKey('grade_final_grading_name', $requiredStrings); + $this->assertArrayHasKey('grade_reflection_assignment_name', $requiredStrings); + } + + /** + * Test grade item mapping with single quotes. + */ + public function testCheckGradeItemMappingWithSingleQuotes(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/gradeitems.php' => " 'assignment_draft', + 1 => 'assignment_final' + ]; + } +} +", + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(2, $requiredStrings); + $this->assertArrayHasKey('grade_assignment_draft_name', $requiredStrings); + $this->assertArrayHasKey('grade_assignment_final_name', $requiredStrings); + } + + /** + * Test grade item mapping with mixed quote types. + */ + public function testCheckGradeItemMappingWithMixedQuotes(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/gradeitems.php' => ' "written_work", + 1 => \'presentation\', + 2 => "group_project" + ]; + } +} +', + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(3, $requiredStrings); + $this->assertArrayHasKey('grade_written_work_name', $requiredStrings); + $this->assertArrayHasKey('grade_presentation_name', $requiredStrings); + $this->assertArrayHasKey('grade_group_project_name', $requiredStrings); + } + + /** + * Test that non-grade-item classes are ignored. + */ + public function testCheckNonGradeItemClassIgnored(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/helper.php' => 'createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(0, $requiredStrings); + } + + /** + * Test error handling for malformed grade items file. + */ + public function testCheckMalformedGradeItemsFile(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/gradeitems.php' => ' "submissions", + // Missing closing brace and return statement +', + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + // Should handle error gracefully + $this->assertInstanceOf(\MoodlePluginCI\MissingStrings\ValidationResult::class, $result); + } + + /** + * Test error handling for unreadable grade items file. + */ + public function testCheckUnreadableGradeItemsFile(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/gradeitems.php' => ' "submissions", + 1 => "grading" + ]; + } +} +', + ]); + + // Make the file unreadable + $gradeitemsFile = $pluginDir . '/classes/grades/gradeitems.php'; + chmod($gradeitemsFile, 0000); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + // Should handle error gracefully + $this->assertInstanceOf(\MoodlePluginCI\MissingStrings\ValidationResult::class, $result); + + // Restore permissions for cleanup + chmod($gradeitemsFile, 0644); + } + + /** + * Test context information includes correct file paths. + */ + public function testCheckContextInformation(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/gradeitems.php' => ' "submissions", + 1 => "grading" + ]; + } +} +', + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(2, $requiredStrings); + + $errors = $result->getErrors(); + $gradeitemsFile = $pluginDir . '/classes/grades/gradeitems.php'; + + foreach ($errors as $error) { + $this->assertSame($gradeitemsFile, $error['file']); + $this->assertGreaterThan(0, $error['line']); + $this->assertNotEmpty($error['description']); + $this->assertStringContainsString('Grade item', $error['description']); + } + } + + /** + * Test duplicate item names (should only appear once). + */ + public function testCheckDuplicateItemNames(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/gradeitems.php' => ' "submissions", + 1 => "submissions", // Duplicate + 2 => "grading" + ]; + } +} +', + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + // Should only detect unique item names + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(2, $requiredStrings); + $this->assertArrayHasKey('grade_submissions_name', $requiredStrings); + $this->assertArrayHasKey('grade_grading_name', $requiredStrings); + } + + /** + * Test grade item mapping with numeric keys and string values. + */ + public function testCheckNumericKeysWithStringValues(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/gradeitems.php' => ' "advanced_submission", + 20 => "peer_evaluation", + 30 => "final_grade" + ]; + } +} +', + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(3, $requiredStrings); + $this->assertArrayHasKey('grade_advanced_submission_name', $requiredStrings); + $this->assertArrayHasKey('grade_peer_evaluation_name', $requiredStrings); + $this->assertArrayHasKey('grade_final_grade_name', $requiredStrings); + } + + /** + * Test multi-line grade item mapping (realistic formatting). + */ + public function testCheckMultiLineGradeItemMapping(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/gradeitems.php' => ' "initial_draft", + 1 => "peer_review_score", + 2 => "instructor_feedback", + 3 => "final_submission" + ]; + } +} +', + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(4, $requiredStrings); + $this->assertArrayHasKey('grade_initial_draft_name', $requiredStrings); + $this->assertArrayHasKey('grade_peer_review_score_name', $requiredStrings); + $this->assertArrayHasKey('grade_instructor_feedback_name', $requiredStrings); + $this->assertArrayHasKey('grade_final_submission_name', $requiredStrings); + } + + /** + * Test get_advancedgrading_itemnames method support. + */ + public function testCheckAdvancedGradingItemNames(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/gradeitems.php' => 'createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(2, $requiredStrings); + $this->assertArrayHasKey('gradeitem:forum', $requiredStrings); + $this->assertArrayHasKey('gradeitem:discussion', $requiredStrings); + } + + /** + * Test both get_itemname_mapping_for_component and get_advancedgrading_itemnames methods. + */ + public function testCheckBothMappingMethods(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/grades/gradeitems.php' => ' "rating", + 1 => "forum" + ]; + } + + public static function get_advancedgrading_itemnames(): array { + return [ + "forum" + ]; + } +} +', + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + // Should have: gradeitem:forum (from get_advancedgrading_itemnames), grade_rating_name, grade_forum_name (from get_itemname_mapping_for_component) + $this->assertCount(3, $requiredStrings); + $this->assertArrayHasKey('gradeitem:forum', $requiredStrings); + $this->assertArrayHasKey('grade_rating_name', $requiredStrings); + $this->assertArrayHasKey('grade_forum_name', $requiredStrings); + } +} diff --git a/tests/MissingStrings/Checker/ClassMethodChecker/PrivacyProviderCheckerTest.php b/tests/MissingStrings/Checker/ClassMethodChecker/PrivacyProviderCheckerTest.php new file mode 100644 index 00000000..1debd99b --- /dev/null +++ b/tests/MissingStrings/Checker/ClassMethodChecker/PrivacyProviderCheckerTest.php @@ -0,0 +1,560 @@ +checker = new PrivacyProviderChecker(); + } + + /** + * Test checker name. + */ + public function testGetName(): void + { + $this->assertSame('Privacy Provider', $this->checker->getName()); + } + + /** + * Test that checker applies to plugins with privacy provider file. + */ + public function testAppliesToWithPrivacyProvider(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $this->assertTrue($this->checker->appliesTo($plugin)); + } + + /** + * Test that checker doesn't apply to plugins without privacy provider file. + */ + public function testAppliesToWithoutPrivacyProvider(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $this->assertFalse($this->checker->appliesTo($plugin)); + } + + /** + * Test null provider with explicit get_reason string. + */ + public function testCheckNullProviderWithExplicitReason(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('privacy:metadata', $requiredStrings); + } + + /** + * Test null provider without explicit get_reason string (fallback). + */ + public function testCheckNullProviderWithoutExplicitReason(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + // Should fall back to default privacy:metadata string + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('privacy:metadata', $requiredStrings); + } + + /** + * Test metadata provider with database table fields. + */ + public function testCheckMetadataProviderWithDatabaseTable(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'add_database_table("local_testplugin_data", [ + "userid" => "privacy:metadata:userid", + "data" => "privacy:metadata:data", + "timecreated" => "privacy:metadata:timecreated" + ]); + return $collection; + } +} +', + ]); + + $plugin = $this->createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(3, $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:userid', $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:data', $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:timecreated', $requiredStrings); + } + + /** + * Test metadata provider with external location. + */ + public function testCheckMetadataProviderWithExternalLocation(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'add_external_location_link("external_service", "privacy:metadata:external_service", "https://example.com"); + return $collection; + } +} +', + ]); + + $plugin = $this->createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:external_service', $requiredStrings); + } + + /** + * Test metadata provider with subsystem link. + */ + public function testCheckMetadataProviderWithSubsystemLink(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'add_subsystem_link("core_files", [], "privacy:metadata:core_files"); + return $collection; + } +} +', + ]); + + $plugin = $this->createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:core_files', $requiredStrings); + } + + /** + * Test metadata provider with user preference. + */ + public function testCheckMetadataProviderWithUserPreference(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'add_user_preference("testplugin_preference", "privacy:metadata:preference:testplugin_preference"); + return $collection; + } +} +', + ]); + + $plugin = $this->createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:preference:testplugin_preference', $requiredStrings); + } + + /** + * Test metadata provider with multiple types of metadata. + */ + public function testCheckMetadataProviderWithMultipleTypes(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'add_database_table("local_testplugin_data", [ + "userid" => "privacy:metadata:userid", + "content" => "privacy:metadata:content" + ]); + $collection->add_external_location_link("api_service", "privacy:metadata:api_service", "https://api.example.com"); + $collection->add_user_preference("testplugin_setting", "privacy:metadata:preference:setting"); + return $collection; + } +} +', + ]); + + $plugin = $this->createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(4, $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:userid', $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:content', $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:api_service', $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:preference:setting', $requiredStrings); + } + + /** + * Test that non-privacy strings are ignored. + */ + public function testCheckIgnoresNonPrivacyStrings(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'add_database_table("local_testplugin_data", [ + "userid" => "privacy:metadata:userid", + "content" => "regular_string_key", // Not a privacy string + "other" => "some:other:format" // Not a privacy string + ]); + return $collection; + } +} +', + ]); + + $plugin = $this->createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + // Should only detect the privacy string + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:userid', $requiredStrings); + } + + /** + * Test provider implementing multiple interfaces. + */ + public function testCheckProviderWithMultipleInterfaces(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'add_database_table("local_testplugin_data", [ + "userid" => "privacy:metadata:userid" + ]); + return $collection; + } + + // Request provider methods would be here... +} +', + ]); + + $plugin = $this->createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + // Should detect metadata strings (request provider strings depend on implementation) + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:userid', $requiredStrings); + } + + /** + * Test handling of malformed provider file. + */ + public function testCheckMalformedProviderFile(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + // Should handle error gracefully + $this->assertInstanceOf(\MoodlePluginCI\MissingStrings\ValidationResult::class, $result); + } + + /** + * Test handling of unreadable provider file. + */ + public function testCheckUnreadableProviderFile(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + // Should handle error gracefully + $this->assertInstanceOf(\MoodlePluginCI\MissingStrings\ValidationResult::class, $result); + + // Check that an error was recorded (file_get_contents should fail on a directory) + $errors = $result->getErrors(); + $this->assertNotEmpty($errors); + + // Verify error message content (file_get_contents on directory triggers exception) + $this->assertStringContainsString('Error analyzing privacy provider', $errors[0]); + + // Clean up + rmdir($providerFile); + } + + /** + * Test context information includes correct file paths. + */ + public function testCheckContextInformation(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'add_database_table("local_testplugin_data", [ + "userid" => "privacy:metadata:userid" + ]); + return $collection; + } +} +', + ]); + + $plugin = $this->createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + + $errors = $result->getErrors(); + $providerFile = $pluginDir . '/classes/privacy/provider.php'; + + foreach ($errors as $error) { + $this->assertSame($providerFile, $error['file']); + $this->assertNotEmpty($error['description']); + $this->assertStringContainsString('Privacy metadata', $error['description']); + } + } + + /** + * Test mixed case interface names (partial matching). + */ + public function testCheckMixedCaseInterfaceNames(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('privacy:no_data', $requiredStrings); + } + + /** + * Test complex metadata string patterns. + */ + public function testCheckComplexMetadataPatterns(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'add_database_table("local_testplugin_complex", [ + "id" => "privacy:metadata:table:id", + "userid" => "privacy:metadata:table:userid", + "data" => "privacy:metadata:table:data", + "timemodified" => "privacy:metadata:table:timemodified" + ]); + + // Multiple external services + $collection->add_external_location_link("service1", "privacy:metadata:external:service1", "https://service1.com"); + $collection->add_external_location_link("service2", "privacy:metadata:external:service2", "https://service2.com"); + + return $collection; + } +} +', + ]); + + $plugin = $this->createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(6, $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:table:id', $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:table:userid', $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:table:data', $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:table:timemodified', $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:external:service1', $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:external:service2', $requiredStrings); + } + + /** + * Test metadata provider with link_subsystem calls. + */ + public function testCheckMetadataProviderWithLinkSubsystem(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/privacy/provider.php' => 'add_database_table("test_table", [ + "userid" => "privacy:metadata:test_table:userid", + "data" => "privacy:metadata:test_table:data", + ], "privacy:metadata:test_table"); + + $collection->link_subsystem("core_rating", "privacy:metadata:core_rating"); + $collection->link_subsystem("core_tag", "privacy:metadata:core_tag"); + + return $collection; + } +} +', + ]); + + $plugin = $this->createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(5, $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:test_table:userid', $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:test_table:data', $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:test_table', $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:core_rating', $requiredStrings); + $this->assertArrayHasKey('privacy:metadata:core_tag', $requiredStrings); + } +} diff --git a/tests/MissingStrings/Checker/ClassMethodChecker/SearchAreaCheckerTest.php b/tests/MissingStrings/Checker/ClassMethodChecker/SearchAreaCheckerTest.php new file mode 100644 index 00000000..8433b8bf --- /dev/null +++ b/tests/MissingStrings/Checker/ClassMethodChecker/SearchAreaCheckerTest.php @@ -0,0 +1,546 @@ +checker = new SearchAreaChecker(); + } + + /** + * Test checker name. + */ + public function testGetName(): void + { + $this->assertSame('Search Area', $this->checker->getName()); + } + + /** + * Test that checker applies to plugins with search area classes. + */ + public function testAppliesToWithSearchAreaClass(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/search/content.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $this->assertTrue($this->checker->appliesTo($plugin)); + } + + /** + * Test that checker doesn't apply to plugins without search area classes. + */ + public function testAppliesToWithoutSearchAreaClass(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'lib.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $this->assertFalse($this->checker->appliesTo($plugin)); + } + + /** + * Test basic search area class extending core_search\base. + */ + public function testCheckBasicSearchAreaClass(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/search/content.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('search:content', $requiredStrings); + } + + /** + * Test search area class extending core_search\base_mod. + */ + public function testCheckSearchAreaBaseModClass(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/search/activity.php' => 'createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('search:activity', $requiredStrings); + } + + /** + * Test search area class extending core_search\base_activity. + */ + public function testCheckSearchAreaBaseActivityClass(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'classes/search/post.php' => 'createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('search:post', $requiredStrings); + } + + /** + * Test search area class with short class names (without namespace). + */ + public function testCheckSearchAreaShortClassName(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/search/data.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('search:data', $requiredStrings); + } + + /** + * Test multiple search area classes in the same plugin. + */ + public function testCheckMultipleSearchAreaClasses(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/search/content.php' => ' ' 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(3, $requiredStrings); + $this->assertArrayHasKey('search:content', $requiredStrings); + $this->assertArrayHasKey('search:document', $requiredStrings); + $this->assertArrayHasKey('search:item', $requiredStrings); + } + + /** + * Test that non-search classes in classes/search/ are ignored. + */ + public function testCheckNonSearchClassIgnored(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/search/helper.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(0, $requiredStrings); + } + + /** + * Test classes outside of classes/search/ directory are ignored. + */ + public function testCheckClassesOutsideSearchDirectoryIgnored(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/content.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(0, $requiredStrings); + } + + /** + * Test search area class with uppercase characters in name. + */ + public function testCheckSearchAreaClassWithUppercase(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/search/MyContent.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + // Should convert to lowercase for string key + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('search:mycontent', $requiredStrings); + } + + /** + * Test error handling for malformed search class file. + */ + public function testCheckMalformedSearchClassFile(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/search/invalid.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + // Should handle error gracefully + $this->assertInstanceOf(\MoodlePluginCI\MissingStrings\ValidationResult::class, $result); + } + + /** + * Test error handling for unreadable search class file. + */ + public function testCheckUnreadableSearchClassFile(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/search/content.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + // Should handle error gracefully + $this->assertInstanceOf(\MoodlePluginCI\MissingStrings\ValidationResult::class, $result); + + // Restore permissions for cleanup + chmod($searchFile, 0644); + } + + /** + * Test context information includes correct file paths. + */ + public function testCheckContextInformation(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/search/content.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + + $errors = $result->getErrors(); + $searchFile = $pluginDir . '/classes/search/content.php'; + + foreach ($errors as $error) { + $this->assertSame($searchFile, $error['file']); + $this->assertGreaterThan(0, $error['line']); + $this->assertNotEmpty($error['description']); + $this->assertStringContainsString('Search area display name', $error['description']); + } + } + + /** + * Test search area classes in subdirectories. + */ + public function testCheckSearchAreaClassInSubdirectory(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/search/forum/post.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('search:post', $requiredStrings); + } + + /** + * Test complex search area class with interfaces and additional methods. + */ + public function testCheckComplexSearchAreaClass(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/search/advanced_content.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('search:advanced_content', $requiredStrings); + } + + /** + * Test abstract search area class (should still require string). + */ + public function testCheckAbstractSearchAreaClass(): void + { + $pluginDir = $this->createTestPlugin('local', 'testplugin', [ + 'classes/search/base_content.php' => 'createPlugin('local', 'testplugin', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('search:base_content', $requiredStrings); + } +} diff --git a/tests/MissingStrings/Checker/DatabaseFileChecker/CachesCheckerTest.php b/tests/MissingStrings/Checker/DatabaseFileChecker/CachesCheckerTest.php new file mode 100644 index 00000000..9f547402 --- /dev/null +++ b/tests/MissingStrings/Checker/DatabaseFileChecker/CachesCheckerTest.php @@ -0,0 +1,335 @@ +checker = new CachesChecker(); + } + + /** + * Test that the checker has the correct name. + */ + public function testGetNameReturnsCorrectName(): void + { + $this->assertSame('Caches', $this->checker->getName()); + } + + /** + * Test that the checker applies when caches.php exists. + */ + public function testAppliesToWithCachesFileReturnsTrue(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/caches.php' => $this->createDatabaseFileContent('caches', []), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + $this->assertTrue($this->checker->appliesTo($plugin)); + } + + /** + * Test that the checker doesn't apply when caches.php doesn't exist. + */ + public function testAppliesToWithoutCachesFileReturnsFalse(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod'); + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + $this->assertFalse($this->checker->appliesTo($plugin)); + } + + /** + * Test processing single cache definition. + */ + public function testCheckWithSingleCacheDefinitionAddsRequiredString(): void + { + $definitions = [ + 'user_progress' => [ + 'mode' => 'application', + 'simplekeys' => true, + 'staticacceleration' => true, + ], + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/caches.php' => $this->createDatabaseFileContent('caches', $definitions), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('cachedef_user_progress', $requiredStrings); + + $context = $requiredStrings['cachedef_user_progress']; + $this->assertStringContainsString('db/caches.php', $context->getFile()); + $this->assertSame('Cache definition: user_progress', $context->getDescription()); + $this->assertNotNull($context->getLine()); + } + + /** + * Test processing multiple cache definitions. + */ + public function testCheckWithMultipleCacheDefinitionsAddsAllRequiredStrings(): void + { + $definitions = [ + 'user_progress' => [ + 'mode' => 'application', + 'simplekeys' => true, + ], + 'course_modules' => [ + 'mode' => 'request', + 'simplekeys' => false, + ], + 'activity_data' => [ + 'mode' => 'session', + 'staticacceleration' => true, + ], + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/caches.php' => $this->createDatabaseFileContent('caches', $definitions), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(3, $requiredStrings, 'Should have 3 required strings'); + + $this->assertArrayHasKey('cachedef_user_progress', $requiredStrings); + $this->assertArrayHasKey('cachedef_course_modules', $requiredStrings); + $this->assertArrayHasKey('cachedef_activity_data', $requiredStrings); + + // Check that each has correct context + foreach (['cachedef_user_progress', 'cachedef_course_modules', 'cachedef_activity_data'] as $expectedKey) { + $context = $requiredStrings[$expectedKey]; + $this->assertStringContainsString('db/caches.php', $context->getFile()); + $this->assertStringContainsString('Cache definition:', $context->getDescription()); + $this->assertNotNull($context->getLine()); + } + } + + /** + * Test cache definition string pattern generation. + */ + public function testCheckWithVariousCacheNamesGeneratesCorrectStringKeys(): void + { + $definitions = [ + 'simple_cache' => ['mode' => 'application'], + 'user_data_cache' => ['mode' => 'session'], + 'course123' => ['mode' => 'request'], + 'special-chars_cache' => ['mode' => 'application'], + ]; + + $pluginDir = $this->createTestPlugin('local', 'testlocal', [ + 'db/caches.php' => $this->createDatabaseFileContent('caches', $definitions), + ]); + + $plugin = $this->createPlugin('local', 'testlocal', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(4, $requiredStrings); + + // Check expected string keys with cachedef_ prefix + $this->assertArrayHasKey('cachedef_simple_cache', $requiredStrings); + $this->assertArrayHasKey('cachedef_user_data_cache', $requiredStrings); + $this->assertArrayHasKey('cachedef_course123', $requiredStrings); + $this->assertArrayHasKey('cachedef_special-chars_cache', $requiredStrings); + } + + /** + * Test line detection accuracy. + */ + public function testCheckWithCacheDefinitionsDetectsCorrectLineNumbers(): void + { + $cachesContent = " [\n" . // Line 6 + " 'mode' => 'application',\n" . + " 'simplekeys' => true\n" . + " ],\n" . + " 'course_modules' => [\n" . // Line 10 + " 'mode' => 'request',\n" . + " 'simplekeys' => false\n" . + " ]\n" . + "];\n"; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/caches.php' => $cachesContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertStringContextHasLine($requiredStrings['cachedef_user_progress'], 6); + $this->assertStringContextHasLine($requiredStrings['cachedef_course_modules'], 10); + } + + /** + * Test handling of empty caches file. + */ + public function testCheckWithEmptyCachesFileReturnsEmptyResult(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/caches.php' => $this->createDatabaseFileContent('caches', []), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertCount(0, $result->getErrors()); + $this->assertCount(0, $result->getWarnings()); + } + + /** + * Test handling of malformed cache definition. + */ + public function testCheckWithMalformedCacheDefinitionAddsWarning(): void + { + $cachesContent = " 'invalid_string_instead_of_array',\n" . + "];\n"; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/caches.php' => $cachesContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertWarningCount($result, 1); + + $warnings = $result->getWarnings(); + $this->assertStringContainsString('not an array', $warnings[0]); + } + + /** + * Test handling of invalid caches file. + */ + public function testCheckWithInvalidCachesFileAddsWarning(): void + { + $cachesContent = "createTestPlugin('mod', 'testmod', [ + 'db/caches.php' => $cachesContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertWarningCount($result, 1); + + $warnings = $result->getWarnings(); + $this->assertStringContainsString('Could not load db/caches.php file', $warnings[0]); + } + + /** + * Test complex cache definitions with various options. + */ + public function testCheckWithComplexCacheDefinitionsProcessesCorrectly(): void + { + $definitions = [ + 'complex_cache' => [ + 'mode' => 'application', + 'simplekeys' => true, + 'simpledata' => false, + 'staticacceleration' => true, + 'staticaccelerationsize' => 100, + 'ttl' => 300, + 'invalidationevents' => [ + 'changesincourse', + 'changesincoursecat', + ], + ], + 'minimal_cache' => [ + 'mode' => 'session', + ], + ]; + + $pluginDir = $this->createTestPlugin('local', 'testlocal', [ + 'db/caches.php' => $this->createDatabaseFileContent('caches', $definitions), + ]); + + $plugin = $this->createPlugin('local', 'testlocal', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(2, $requiredStrings); + + $this->assertArrayHasKey('cachedef_complex_cache', $requiredStrings); + $this->assertArrayHasKey('cachedef_minimal_cache', $requiredStrings); + + // Verify context information + $complexContext = $requiredStrings['cachedef_complex_cache']; + $this->assertSame('Cache definition: complex_cache', $complexContext->getDescription()); + + $minimalContext = $requiredStrings['cachedef_minimal_cache']; + $this->assertSame('Cache definition: minimal_cache', $minimalContext->getDescription()); + } + + /** + * Test error handling for corrupted caches file. + */ + public function testCheckWithCorruptedCachesFileHandlesGracefully(): void + { + $cachesContent = "createTestPlugin('mod', 'testmod', [ + 'db/caches.php' => $cachesContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertWarningCount($result, 1); + + $warnings = $result->getWarnings(); + $this->assertStringContainsString('Could not load db/caches.php file', $warnings[0]); + } +} diff --git a/tests/MissingStrings/Checker/DatabaseFileChecker/CapabilitiesCheckerTest.php b/tests/MissingStrings/Checker/DatabaseFileChecker/CapabilitiesCheckerTest.php new file mode 100644 index 00000000..e4626082 --- /dev/null +++ b/tests/MissingStrings/Checker/DatabaseFileChecker/CapabilitiesCheckerTest.php @@ -0,0 +1,324 @@ +checker = new CapabilitiesChecker(); + } + + /** + * Test that the checker has the correct name. + */ + public function testGetNameReturnsCorrectName(): void + { + $this->assertSame('Capabilities', $this->checker->getName()); + } + + /** + * Test that the checker applies when access.php exists. + */ + public function testAppliesToWithAccessFileReturnsTrue(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/access.php' => $this->createDatabaseFileContent('access', []), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + $this->assertTrue($this->checker->appliesTo($plugin)); + } + + /** + * Test that the checker doesn't apply when access.php doesn't exist. + */ + public function testAppliesToWithoutAccessFileReturnsFalse(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod'); + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + $this->assertFalse($this->checker->appliesTo($plugin)); + } + + /** + * Test processing single capability with full plugin path format. + */ + public function testCheckWithSingleCapabilityAddsRequiredString(): void + { + $capabilities = [ + 'mod/testmod:addinstance' => [ + 'riskbitmask' => 'RISK_XSS', + 'captype' => 'write', + 'contextlevel' => 'CONTEXT_COURSE', + 'archetypes' => [ + 'editingteacher' => 'CAP_ALLOW', + 'manager' => 'CAP_ALLOW', + ], + ], + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/access.php' => $this->createDatabaseFileContent('access', $capabilities), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('testmod:addinstance', $requiredStrings); + + $context = $requiredStrings['testmod:addinstance']; + $this->assertStringContainsString('db/access.php', $context->getFile()); + $this->assertSame('Capability: mod/testmod:addinstance', $context->getDescription()); + $this->assertNotNull($context->getLine()); + } + + /** + * Test processing multiple capabilities. + */ + public function testCheckWithMultipleCapabilitiesAddsAllRequiredStrings(): void + { + $capabilities = [ + 'mod/testmod:addinstance' => [ + 'captype' => 'write', + 'contextlevel' => 'CONTEXT_COURSE', + ], + 'mod/testmod:view' => [ + 'captype' => 'read', + 'contextlevel' => 'CONTEXT_MODULE', + ], + 'mod/testmod:submit' => [ + 'captype' => 'write', + 'contextlevel' => 'CONTEXT_MODULE', + ], + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/access.php' => $this->createDatabaseFileContent('access', $capabilities), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(3, $requiredStrings); + + $this->assertArrayHasKey('testmod:addinstance', $requiredStrings); + $this->assertArrayHasKey('testmod:view', $requiredStrings); + $this->assertArrayHasKey('testmod:submit', $requiredStrings); + + // Check that each has correct context + foreach (['testmod:addinstance', 'testmod:view', 'testmod:submit'] as $expectedKey) { + $context = $requiredStrings[$expectedKey]; + $this->assertStringContainsString('db/access.php', $context->getFile()); + $this->assertStringContainsString('Capability:', $context->getDescription()); + $this->assertNotNull($context->getLine()); + } + } + + /** + * Test capability name extraction for different formats. + */ + public function testCheckWithDifferentCapabilityFormatsExtractsCorrectStringKeys(): void + { + $capabilities = [ + 'mod/testmod:addinstance' => ['captype' => 'write'], + 'local/testlocal:manage' => ['captype' => 'write'], + 'block/testblock:addinstance' => ['captype' => 'write'], + 'simple_capability' => ['captype' => 'read'], // No slash format + ]; + + $pluginDir = $this->createTestPlugin('local', 'testlocal', [ + 'db/access.php' => $this->createDatabaseFileContent('access', $capabilities), + ]); + + $plugin = $this->createPlugin('local', 'testlocal', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(4, $requiredStrings); + + // Check expected string keys are extracted correctly + $this->assertArrayHasKey('testmod:addinstance', $requiredStrings); + $this->assertArrayHasKey('testlocal:manage', $requiredStrings); + $this->assertArrayHasKey('testblock:addinstance', $requiredStrings); + $this->assertArrayHasKey('simple_capability', $requiredStrings); + } + + /** + * Test line detection accuracy. + */ + public function testCheckWithCapabilitiesDetectsCorrectLineNumbers(): void + { + $accessContent = " [\n" . // Line 6 + " 'captype' => 'write',\n" . + " 'contextlevel' => 'CONTEXT_COURSE'\n" . + " ],\n" . + " 'mod/testmod:view' => [\n" . // Line 10 + " 'captype' => 'read',\n" . + " 'contextlevel' => 'CONTEXT_MODULE'\n" . + " ]\n" . + "];\n"; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/access.php' => $accessContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertStringContextHasLine($requiredStrings['testmod:addinstance'], 6); + $this->assertStringContextHasLine($requiredStrings['testmod:view'], 10); + } + + /** + * Test handling of empty access file. + */ + public function testCheckWithEmptyAccessFileReturnsEmptyResult(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/access.php' => $this->createDatabaseFileContent('access', []), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertCount(0, $result->getErrors()); + $this->assertCount(0, $result->getWarnings()); + } + + /** + * Test handling of malformed capability definition. + */ + public function testCheckWithMalformedCapabilityAddsWarning(): void + { + $accessContent = " 'invalid_string_instead_of_array',\n" . + "];\n"; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/access.php' => $accessContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertWarningCount($result, 1); + + $warnings = $result->getWarnings(); + $this->assertStringContainsString('not an array', $warnings[0]); + } + + /** + * Test handling of invalid access file. + */ + public function testCheckWithInvalidAccessFileAddsWarning(): void + { + $accessContent = "createTestPlugin('mod', 'testmod', [ + 'db/access.php' => $accessContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertWarningCount($result, 1); + + $warnings = $result->getWarnings(); + $this->assertStringContainsString('Could not load db/access.php file', $warnings[0]); + } + + /** + * Test FileDiscovery integration. + */ + public function testCheckWithFileDiscoveryUsesFileDiscoveryService(): void + { + $capabilities = [ + 'mod/testmod:addinstance' => ['captype' => 'write'], + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/access.php' => $this->createDatabaseFileContent('access', $capabilities), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + // Set up FileDiscovery + $fileDiscovery = new FileDiscovery($plugin); + $this->checker->setFileDiscovery($fileDiscovery); + + // Test appliesTo with FileDiscovery + $this->assertTrue($this->checker->appliesTo($plugin)); + + // Test check with FileDiscovery + $result = $this->checker->check($plugin); + $requiredStrings = $result->getRequiredStrings(); + + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('testmod:addinstance', $requiredStrings); + } + + /** + * Test error handling for corrupted access file. + */ + public function testCheckWithCorruptedAccessFileHandlesGracefully(): void + { + $accessContent = "createTestPlugin('mod', 'testmod', [ + 'db/access.php' => $accessContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertWarningCount($result, 1); + + $warnings = $result->getWarnings(); + $this->assertStringContainsString('Could not load db/access.php file', $warnings[0]); + } +} diff --git a/tests/MissingStrings/Checker/DatabaseFileChecker/MessagesCheckerTest.php b/tests/MissingStrings/Checker/DatabaseFileChecker/MessagesCheckerTest.php new file mode 100644 index 00000000..421aa533 --- /dev/null +++ b/tests/MissingStrings/Checker/DatabaseFileChecker/MessagesCheckerTest.php @@ -0,0 +1,364 @@ +checker = new MessagesChecker(); + } + + /** + * Test that the checker has the correct name. + */ + public function testGetNameReturnsCorrectName(): void + { + $this->assertSame('Messages', $this->checker->getName()); + } + + /** + * Test that the checker applies when messages.php exists. + */ + public function testAppliesToWithMessagesFileReturnsTrue(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/messages.php' => $this->createDatabaseFileContent('messages', []), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + $this->assertTrue($this->checker->appliesTo($plugin)); + } + + /** + * Test that the checker doesn't apply when messages.php doesn't exist. + */ + public function testAppliesToWithoutMessagesFileReturnsFalse(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod'); + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + $this->assertFalse($this->checker->appliesTo($plugin)); + } + + /** + * Test processing single message provider. + */ + public function testCheckWithSingleMessageProviderAddsRequiredString(): void + { + $messageproviders = [ + 'assignment_submission' => [ + 'capability' => 'mod/assign:receivegradernotifications', + 'defaults' => [ + 'popup' => 'MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF', + 'email' => 'MESSAGE_PERMITTED', + ], + ], + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/messages.php' => $this->createDatabaseFileContent('messages', $messageproviders), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('messageprovider:assignment_submission', $requiredStrings); + + $context = $requiredStrings['messageprovider:assignment_submission']; + $this->assertStringContainsString('db/messages.php', $context->getFile()); + $this->assertSame('Message provider: assignment_submission', $context->getDescription()); + $this->assertNotNull($context->getLine()); + } + + /** + * Test processing multiple message providers. + */ + public function testCheckWithMultipleMessageProvidersAddsAllRequiredStrings(): void + { + $messageproviders = [ + 'assignment_submission' => [ + 'capability' => 'mod/assign:receivegradernotifications', + 'defaults' => ['popup' => 'MESSAGE_PERMITTED'], + ], + 'assignment_graded' => [ + 'capability' => 'mod/assign:receivegradernotifications', + 'defaults' => ['email' => 'MESSAGE_PERMITTED'], + ], + 'course_update' => [ + 'defaults' => [ + 'popup' => 'MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN', + 'email' => 'MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDOFF', + ], + ], + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/messages.php' => $this->createDatabaseFileContent('messages', $messageproviders), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(3, $requiredStrings); + + $this->assertArrayHasKey('messageprovider:assignment_submission', $requiredStrings); + $this->assertArrayHasKey('messageprovider:assignment_graded', $requiredStrings); + $this->assertArrayHasKey('messageprovider:course_update', $requiredStrings); + + // Check that each has correct context + foreach (['messageprovider:assignment_submission', 'messageprovider:assignment_graded', 'messageprovider:course_update'] as $expectedKey) { + $context = $requiredStrings[$expectedKey]; + $this->assertStringContainsString('db/messages.php', $context->getFile()); + $this->assertStringContainsString('Message provider:', $context->getDescription()); + $this->assertNotNull($context->getLine()); + } + } + + /** + * Test message provider string pattern generation. + */ + public function testCheckWithVariousProviderNamesGeneratesCorrectStringKeys(): void + { + $messageproviders = [ + 'simple_notification' => ['defaults' => ['popup' => 'MESSAGE_PERMITTED']], + 'user_action_required' => ['defaults' => ['email' => 'MESSAGE_PERMITTED']], + 'system123_alert' => ['defaults' => ['popup' => 'MESSAGE_PERMITTED']], + 'special-chars_message' => ['defaults' => ['email' => 'MESSAGE_PERMITTED']], + ]; + + $pluginDir = $this->createTestPlugin('local', 'testlocal', [ + 'db/messages.php' => $this->createDatabaseFileContent('messages', $messageproviders), + ]); + + $plugin = $this->createPlugin('local', 'testlocal', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(4, $requiredStrings); + + // Check expected string keys with messageprovider: prefix + $this->assertArrayHasKey('messageprovider:simple_notification', $requiredStrings); + $this->assertArrayHasKey('messageprovider:user_action_required', $requiredStrings); + $this->assertArrayHasKey('messageprovider:system123_alert', $requiredStrings); + $this->assertArrayHasKey('messageprovider:special-chars_message', $requiredStrings); + } + + /** + * Test line detection accuracy. + */ + public function testCheckWithMessageProvidersDetectsCorrectLineNumbers(): void + { + $messagesContent = " [\n" . // Line 6 + " 'capability' => 'mod/assign:receivegradernotifications',\n" . + " 'defaults' => ['popup' => 'MESSAGE_PERMITTED']\n" . + " ],\n" . + " 'assignment_graded' => [\n" . // Line 10 + " 'capability' => 'mod/assign:receivegradernotifications',\n" . + " 'defaults' => ['email' => 'MESSAGE_PERMITTED']\n" . + " ]\n" . + "];\n"; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/messages.php' => $messagesContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + $this->assertStringContextHasLine($requiredStrings['messageprovider:assignment_submission'], 6); + $this->assertStringContextHasLine($requiredStrings['messageprovider:assignment_graded'], 10); + } + + /** + * Test handling of empty messages file. + */ + public function testCheckWithEmptyMessagesFileReturnsEmptyResult(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/messages.php' => $this->createDatabaseFileContent('messages', []), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertCount(0, $result->getErrors()); + $this->assertCount(0, $result->getWarnings()); + } + + /** + * Test handling of malformed message provider. + */ + public function testCheckWithMalformedMessageProviderAddsWarning(): void + { + $messagesContent = " 'invalid_string_instead_of_array',\n" . + "];\n"; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/messages.php' => $messagesContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertWarningCount($result, 1); + + $warnings = $result->getWarnings(); + $this->assertStringContainsString('not an array', $warnings[0]); + } + + /** + * Test handling of invalid messages file. + */ + public function testCheckWithInvalidMessagesFileAddsWarning(): void + { + $messagesContent = "createTestPlugin('mod', 'testmod', [ + 'db/messages.php' => $messagesContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertWarningCount($result, 1); + + $warnings = $result->getWarnings(); + $this->assertStringContainsString('not an array', $warnings[0]); + } + + /** + * Test missing messageproviders array. + */ + public function testCheckWithMissingMessageProvidersArrayAddsWarning(): void + { + $messagesContent = "createTestPlugin('mod', 'testmod', [ + 'db/messages.php' => $messagesContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertWarningCount($result, 1); + + $warnings = $result->getWarnings(); + $this->assertStringContainsString('No $messageproviders array found', $warnings[0]); + } + + /** + * Test complex message providers with various configurations. + */ + public function testCheckWithComplexMessageProvidersProcessesCorrectly(): void + { + $messageproviders = [ + 'complex_notification' => [ + 'capability' => 'local/testlocal:receivenotifications', + 'defaults' => [ + 'popup' => 'MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF', + 'email' => 'MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDOFF', + 'sms' => 'MESSAGE_DISALLOWED', + ], + ], + 'minimal_alert' => [ + 'defaults' => ['popup' => 'MESSAGE_PERMITTED'], + ], + 'conditional_message' => [ + 'capability' => 'moodle/site:config', + 'defaults' => [ + 'popup' => 'MESSAGE_FORCED + MESSAGE_DEFAULT_LOGGEDIN', + 'email' => 'MESSAGE_FORCED + MESSAGE_DEFAULT_LOGGEDOFF', + ], + ], + ]; + + $pluginDir = $this->createTestPlugin('local', 'testlocal', [ + 'db/messages.php' => $this->createDatabaseFileContent('messages', $messageproviders), + ]); + + $plugin = $this->createPlugin('local', 'testlocal', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(3, $requiredStrings); + + $this->assertArrayHasKey('messageprovider:complex_notification', $requiredStrings); + $this->assertArrayHasKey('messageprovider:minimal_alert', $requiredStrings); + $this->assertArrayHasKey('messageprovider:conditional_message', $requiredStrings); + + // Verify context information + $complexContext = $requiredStrings['messageprovider:complex_notification']; + $this->assertSame('Message provider: complex_notification', $complexContext->getDescription()); + + $minimalContext = $requiredStrings['messageprovider:minimal_alert']; + $this->assertSame('Message provider: minimal_alert', $minimalContext->getDescription()); + } + + /** + * Test error handling for corrupted messages file. + */ + public function testCheckWithCorruptedMessagesFileHandlesGracefully(): void + { + $messagesContent = "createTestPlugin('mod', 'testmod', [ + 'db/messages.php' => $messagesContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertWarningCount($result, 1); + + $warnings = $result->getWarnings(); + $this->assertStringContainsString('Error parsing db/messages.php', $warnings[0]); + } +} diff --git a/tests/MissingStrings/Checker/DatabaseFileChecker/MobileCheckerTest.php b/tests/MissingStrings/Checker/DatabaseFileChecker/MobileCheckerTest.php new file mode 100644 index 00000000..2fe05bcb --- /dev/null +++ b/tests/MissingStrings/Checker/DatabaseFileChecker/MobileCheckerTest.php @@ -0,0 +1,423 @@ +checker = new MobileChecker(); + } + + /** + * Test that the checker has the correct name. + */ + public function testGetNameReturnsCorrectName(): void + { + $this->assertSame('Mobile', $this->checker->getName()); + } + + /** + * Test that the checker applies when mobile.php exists. + */ + public function testAppliesToWithMobileFileReturnsTrue(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/mobile.php' => $this->createDatabaseFileContent('mobile', []), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + $this->assertTrue($this->checker->appliesTo($plugin)); + } + + /** + * Test that the checker doesn't apply when mobile.php doesn't exist. + */ + public function testAppliesToWithoutMobileFileReturnsFalse(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod'); + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + $this->assertFalse($this->checker->appliesTo($plugin)); + } + + /** + * Test processing single mobile addon with language strings. + */ + public function testCheckWithSingleMobileAddonAddsRequiredStrings(): void + { + $addons = [ + 'mod_testmod_course_module' => [ + 'handlers' => [ + 'mod_testmod_course_module' => [ + 'displaydata' => [ + 'title' => 'testmod_title', + 'icon' => 'icon.svg', + ], + ], + ], + 'lang' => [ + ['testmod_title', 'mod_testmod'], + ['testmod_description', 'mod_testmod'], + ['mobile_view_activity', 'mod_testmod'], + ], + ], + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/mobile.php' => $this->createDatabaseFileContent('mobile', $addons), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(3, $requiredStrings); + + $this->assertArrayHasKey('testmod_title', $requiredStrings); + $this->assertArrayHasKey('testmod_description', $requiredStrings); + $this->assertArrayHasKey('mobile_view_activity', $requiredStrings); + + // Check context information + $context = $requiredStrings['testmod_title']; + $this->assertStringContainsString('db/mobile.php', $context->getFile()); + $this->assertSame("Mobile addon 'mod_testmod_course_module' language string", $context->getDescription()); + $this->assertNotNull($context->getLine()); + } + + /** + * Test processing multiple mobile addons. + */ + public function testCheckWithMultipleMobileAddonsAddsAllRequiredStrings(): void + { + $addons = [ + 'mod_testmod_course_module' => [ + 'lang' => [ + ['testmod_title', 'mod_testmod'], + ['testmod_view', 'mod_testmod'], + ], + ], + 'mod_testmod_user_handler' => [ + 'lang' => [ + ['user_profile_view', 'mod_testmod'], + ['user_preferences', 'mod_testmod'], + ], + ], + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/mobile.php' => $this->createDatabaseFileContent('mobile', $addons), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(4, $requiredStrings); + + $this->assertArrayHasKey('testmod_title', $requiredStrings); + $this->assertArrayHasKey('testmod_view', $requiredStrings); + $this->assertArrayHasKey('user_profile_view', $requiredStrings); + $this->assertArrayHasKey('user_preferences', $requiredStrings); + + // Verify different addon names in context + $this->assertSame("Mobile addon 'mod_testmod_course_module' language string", + $requiredStrings['testmod_title']->getDescription()); + $this->assertSame("Mobile addon 'mod_testmod_user_handler' language string", + $requiredStrings['user_profile_view']->getDescription()); + } + + /** + * Test component filtering - only current plugin strings. + */ + public function testCheckWithMixedComponentsOnlyIncludesCurrentPluginStrings(): void + { + $addons = [ + 'mod_testmod_addon' => [ + 'lang' => [ + ['testmod_title', 'mod_testmod'], // Should be included + ['core_string', 'core'], // Should be excluded + ['other_plugin_string', 'mod_other'], // Should be excluded + ['testmod_description', 'mod_testmod'], // Should be included + ], + ], + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/mobile.php' => $this->createDatabaseFileContent('mobile', $addons), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(2, $requiredStrings); // Only strings for mod_testmod + + $this->assertArrayHasKey('testmod_title', $requiredStrings); + $this->assertArrayHasKey('testmod_description', $requiredStrings); + $this->assertArrayNotHasKey('core_string', $requiredStrings); + $this->assertArrayNotHasKey('other_plugin_string', $requiredStrings); + } + + /** + * Test line detection accuracy. + */ + public function testCheckWithMobileAddonsDetectsCorrectLineNumbers(): void + { + $mobileContent = " [\n" . + " 'lang' => [\n" . + " ['testmod_title', 'mod_testmod'],\n" . // Line 8 + " ['testmod_view', 'mod_testmod']\n" . // Line 9 + " ]\n" . + " ]\n" . + "];\n"; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/mobile.php' => $mobileContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + // Line detection is based on string literal search for the string key + $this->assertStringContextHasLine($requiredStrings['testmod_title'], 8); + $this->assertStringContextHasLine($requiredStrings['testmod_view'], 9); + } + + /** + * Test handling of empty mobile file. + */ + public function testCheckWithEmptyMobileFileReturnsEmptyResult(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/mobile.php' => $this->createDatabaseFileContent('mobile', []), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertCount(0, $result->getWarnings()); + $this->assertErrorCount($result, 0); // Empty addons array is valid, just means no mobile addons defined + } + + /** + * Test handling of addon without language strings. + */ + public function testCheckWithAddonWithoutLangStringsSkipsAddon(): void + { + $addons = [ + 'mod_testmod_no_lang' => [ + 'handlers' => [ + 'mod_testmod_course_module' => [ + 'displaydata' => [ + 'title' => 'testmod_title', + 'icon' => 'icon.svg', + ], + ], + ], + // No 'lang' array + ], + 'mod_testmod_with_lang' => [ + 'lang' => [ + ['testmod_title', 'mod_testmod'], + ], + ], + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/mobile.php' => $this->createDatabaseFileContent('mobile', $addons), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(1, $requiredStrings); // Only addon with lang strings + $this->assertArrayHasKey('testmod_title', $requiredStrings); + } + + /** + * Test handling of malformed language entries. + */ + public function testCheckWithMalformedLangEntriesSkipsInvalidEntries(): void + { + $mobileContent = " [\n" . + " 'lang' => [\n" . + " ['valid_string', 'mod_testmod'],\n" . + " 'invalid_string_not_array',\n" . + " ['incomplete_entry'],\n" . // Missing component + " ['another_valid_string', 'mod_testmod']\n" . + " ]\n" . + " ]\n" . + "];\n"; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/mobile.php' => $mobileContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(2, $requiredStrings); // Only valid entries should be processed + + $this->assertArrayHasKey('valid_string', $requiredStrings); + $this->assertArrayHasKey('another_valid_string', $requiredStrings); + } + + /** + * Test handling of missing addons array. + */ + public function testCheckWithMissingAddonsArrayAddsError(): void + { + $mobileContent = "createTestPlugin('mod', 'testmod', [ + 'db/mobile.php' => $mobileContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertErrorCount($result, 1); + + $errors = $result->getErrors(); + $this->assertStringContainsString('No valid $addons array found', $errors[0]); + } + + /** + * Test handling of invalid addons array. + */ + public function testCheckWithInvalidAddonsArrayAddsError(): void + { + $mobileContent = "createTestPlugin('mod', 'testmod', [ + 'db/mobile.php' => $mobileContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertErrorCount($result, 1); + + $errors = $result->getErrors(); + $this->assertStringContainsString('No valid $addons array found', $errors[0]); + } + + /** + * Test complex mobile addons with various configurations. + */ + public function testCheckWithComplexMobileAddonsProcessesCorrectly(): void + { + $addons = [ + 'mod_testmod_complex' => [ + 'handlers' => [ + 'mod_testmod_course_module' => [ + 'displaydata' => [ + 'title' => 'testmod_title', + 'icon' => 'icon.svg', + ], + 'restrict' => [ + 'courses' => true, + ], + ], + ], + 'lang' => [ + ['testmod_title', 'mod_testmod'], + ['testmod_complex_view', 'mod_testmod'], + ['core_string', 'core'], // Should be excluded + ], + ], + 'mod_testmod_minimal' => [ + 'lang' => [ + ['minimal_string', 'mod_testmod'], + ], + ], + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/mobile.php' => $this->createDatabaseFileContent('mobile', $addons), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(3, $requiredStrings); + + $this->assertArrayHasKey('testmod_title', $requiredStrings); + $this->assertArrayHasKey('testmod_complex_view', $requiredStrings); + $this->assertArrayHasKey('minimal_string', $requiredStrings); + + // Verify context information + $complexContext = $requiredStrings['testmod_title']; + $this->assertSame("Mobile addon 'mod_testmod_complex' language string", $complexContext->getDescription()); + + $minimalContext = $requiredStrings['minimal_string']; + $this->assertSame("Mobile addon 'mod_testmod_minimal' language string", $minimalContext->getDescription()); + } + + /** + * Test error handling for corrupted mobile file. + */ + public function testCheckWithCorruptedMobileFileHandlesGracefully(): void + { + $mobileContent = "createTestPlugin('mod', 'testmod', [ + 'db/mobile.php' => $mobileContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertErrorCount($result, 1); + + $errors = $result->getErrors(); + $this->assertStringContainsString('Error parsing db/mobile.php', $errors[0]); + } +} diff --git a/tests/MissingStrings/Checker/DatabaseFileChecker/SubpluginsCheckerTest.php b/tests/MissingStrings/Checker/DatabaseFileChecker/SubpluginsCheckerTest.php new file mode 100644 index 00000000..d509e7fe --- /dev/null +++ b/tests/MissingStrings/Checker/DatabaseFileChecker/SubpluginsCheckerTest.php @@ -0,0 +1,445 @@ +checker = new SubpluginsChecker(); + } + + /** + * Test that the checker has the correct name. + */ + public function testGetNameReturnsCorrectName(): void + { + $this->assertSame('Subplugins', $this->checker->getName()); + } + + /** + * Test that the checker applies when subplugins.json exists. + */ + public function testAppliesToWithSubpluginsJsonFileReturnsTrue(): void + { + $subpluginTypes = [ + 'testtype' => 'testmod/testtype', + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/subplugins.json' => json_encode(['subplugintypes' => $subpluginTypes]), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + $this->assertTrue($this->checker->appliesTo($plugin)); + } + + /** + * Test that the checker applies when subplugins.php exists. + */ + public function testAppliesToWithSubpluginsPhpFileReturnsTrue(): void + { + $subpluginsContent = " 'testmod/testtype'];\n"; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/subplugins.php' => $subpluginsContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + $this->assertTrue($this->checker->appliesTo($plugin)); + } + + /** + * Test that the checker doesn't apply when neither file exists. + */ + public function testAppliesToWithoutSubpluginsFilesReturnsFalse(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod'); + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + $this->assertFalse($this->checker->appliesTo($plugin)); + } + + /** + * Test processing JSON format with single subplugin type. + */ + public function testCheckWithJsonFormatSingleTypeAddsRequiredStrings(): void + { + $subpluginTypes = [ + 'customtype' => 'mod_testmod/customtype', + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/subplugins.json' => json_encode(['subplugintypes' => $subpluginTypes]), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(2, $requiredStrings); // Singular and plural + + $this->assertArrayHasKey('subplugintype_customtype', $requiredStrings); + $this->assertArrayHasKey('subplugintype_customtype_plural', $requiredStrings); + + // Check context information + $singularContext = $requiredStrings['subplugintype_customtype']; + $this->assertStringContainsString('db/subplugins.json', $singularContext->getFile()); + $this->assertSame('Subplugin type: customtype (singular)', $singularContext->getDescription()); + + $pluralContext = $requiredStrings['subplugintype_customtype_plural']; + $this->assertSame('Subplugin type: customtype (plural)', $pluralContext->getDescription()); + } + + /** + * Test processing PHP format with single subplugin type. + */ + public function testCheckWithPhpFormatSingleTypeAddsRequiredStrings(): void + { + $subpluginsContent = " 'mod_testmod/customtype'\n" . + "];\n"; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/subplugins.php' => $subpluginsContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(2, $requiredStrings); // Singular and plural + + $this->assertArrayHasKey('subplugintype_customtype', $requiredStrings); + $this->assertArrayHasKey('subplugintype_customtype_plural', $requiredStrings); + + // Check context information + $singularContext = $requiredStrings['subplugintype_customtype']; + $this->assertStringContainsString('db/subplugins.php', $singularContext->getFile()); + $this->assertSame('Subplugin type: customtype (singular)', $singularContext->getDescription()); + } + + /** + * Test processing multiple subplugin types. + */ + public function testCheckWithMultipleSubpluginTypesAddsAllRequiredStrings(): void + { + $subpluginTypes = [ + 'customtype' => 'mod_testmod/customtype', + 'reporttype' => 'mod_testmod/reporttype', + 'sourcetype' => 'mod_testmod/sourcetype', + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/subplugins.json' => json_encode(['subplugintypes' => $subpluginTypes]), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(6, $requiredStrings); // 3 types × 2 strings each + + // Check all singular strings + $this->assertArrayHasKey('subplugintype_customtype', $requiredStrings); + $this->assertArrayHasKey('subplugintype_reporttype', $requiredStrings); + $this->assertArrayHasKey('subplugintype_sourcetype', $requiredStrings); + + // Check all plural strings + $this->assertArrayHasKey('subplugintype_customtype_plural', $requiredStrings); + $this->assertArrayHasKey('subplugintype_reporttype_plural', $requiredStrings); + $this->assertArrayHasKey('subplugintype_sourcetype_plural', $requiredStrings); + } + + /** + * Test JSON format with legacy 'plugintypes' key. + */ + public function testCheckWithLegacyPluginTypesKeyAddsRequiredStrings(): void + { + $subpluginTypes = [ + 'legacytype' => 'mod_testmod/legacytype', + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/subplugins.json' => json_encode(['plugintypes' => $subpluginTypes]), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(2, $requiredStrings); + + $this->assertArrayHasKey('subplugintype_legacytype', $requiredStrings); + $this->assertArrayHasKey('subplugintype_legacytype_plural', $requiredStrings); + } + + /** + * Test preference for JSON format over PHP format. + */ + public function testCheckWithBothFormatsPrefersJsonFormat(): void + { + $jsonTypes = [ + 'jsontype' => 'mod_testmod/jsontype', + ]; + + $phpContent = " 'mod_testmod/phptype'];\n"; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/subplugins.json' => json_encode(['subplugintypes' => $jsonTypes]), + 'db/subplugins.php' => $phpContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(2, $requiredStrings); + + // Should have JSON types, not PHP types + $this->assertArrayHasKey('subplugintype_jsontype', $requiredStrings); + $this->assertArrayHasKey('subplugintype_jsontype_plural', $requiredStrings); + $this->assertArrayNotHasKey('subplugintype_phptype', $requiredStrings); + + // Context should reference JSON file + $context = $requiredStrings['subplugintype_jsontype']; + $this->assertStringContainsString('db/subplugins.json', $context->getFile()); + } + + /** + * Test line detection accuracy for JSON format. + */ + public function testCheckWithJsonFormatDetectsCorrectLineNumbers(): void + { + $jsonContent = "{\n" . // Line 1 + " \"subplugintypes\": {\n" . // Line 2 + " \"customtype\": \"mod_testmod/customtype\",\n" . // Line 3 + " \"reporttype\": \"mod_testmod/reporttype\"\n" . // Line 4 + " }\n" . // Line 5 + "}\n"; // Line 6 + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/subplugins.json' => $jsonContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + // Line detection is based on string literal search for the type name + $this->assertStringContextHasLine($requiredStrings['subplugintype_customtype'], 3); + $this->assertStringContextHasLine($requiredStrings['subplugintype_reporttype'], 4); + } + + /** + * Test line detection accuracy for PHP format. + */ + public function testCheckWithPhpFormatDetectsCorrectLineNumbers(): void + { + $phpContent = " 'mod_testmod/customtype',\n" . // Line 6 + " 'reporttype' => 'mod_testmod/reporttype'\n" . // Line 7 + "];\n"; // Line 8 + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/subplugins.php' => $phpContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + // Line detection is based on string literal search for the type name + $this->assertStringContextHasLine($requiredStrings['subplugintype_customtype'], 6); + $this->assertStringContextHasLine($requiredStrings['subplugintype_reporttype'], 7); + } + + /** + * Test handling of missing subplugins files. + */ + public function testCheckWithMissingFilesAddsError(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod'); + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertErrorCount($result, 1); + + $errors = $result->getErrors(); + $this->assertStringContainsString('No subplugins file found', $errors[0]); + } + + /** + * Test handling of invalid JSON format. + */ + public function testCheckWithInvalidJsonFormatAddsError(): void + { + $invalidJson = "{\n" . + " \"subplugintypes\": {\n" . + " \"customtype\": \"mod_testmod/customtype\",\n" . + " }\n" . // Trailing comma - invalid JSON + '}'; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/subplugins.json' => $invalidJson, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertErrorCount($result, 1); + + $errors = $result->getErrors(); + $this->assertStringContainsString('Error parsing subplugins file', $errors[0]); + } + + /** + * Test handling of JSON without valid subplugin types. + */ + public function testCheckWithJsonWithoutValidTypesAddsError(): void + { + $invalidStructure = json_encode([ + 'invalid_key' => [ + 'customtype' => 'mod_testmod/customtype', + ], + ]); + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/subplugins.json' => $invalidStructure, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertErrorCount($result, 1); + + $errors = $result->getErrors(); + $this->assertStringContainsString('Error parsing subplugins file', $errors[0]); + } + + /** + * Test handling of PHP without valid subplugins array. + */ + public function testCheckWithPhpWithoutValidArrayAddsError(): void + { + $phpContent = "createTestPlugin('mod', 'testmod', [ + 'db/subplugins.php' => $phpContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertErrorCount($result, 1); + + $errors = $result->getErrors(); + $this->assertStringContainsString('Error parsing subplugins file', $errors[0]); + } + + /** + * Test complex subplugin configurations. + */ + public function testCheckWithComplexSubpluginTypesProcessesCorrectly(): void + { + $subpluginTypes = [ + 'simple_type' => 'mod_testmod/simple', + 'complex_subtype' => 'mod_testmod/complex', + 'reporting123' => 'mod_testmod/reporting', + 'special-chars_type' => 'mod_testmod/special', + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/subplugins.json' => json_encode(['subplugintypes' => $subpluginTypes]), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(8, $requiredStrings); // 4 types × 2 strings each + + // Check all singular strings + $this->assertArrayHasKey('subplugintype_simple_type', $requiredStrings); + $this->assertArrayHasKey('subplugintype_complex_subtype', $requiredStrings); + $this->assertArrayHasKey('subplugintype_reporting123', $requiredStrings); + $this->assertArrayHasKey('subplugintype_special-chars_type', $requiredStrings); + + // Check all plural strings + $this->assertArrayHasKey('subplugintype_simple_type_plural', $requiredStrings); + $this->assertArrayHasKey('subplugintype_complex_subtype_plural', $requiredStrings); + $this->assertArrayHasKey('subplugintype_reporting123_plural', $requiredStrings); + $this->assertArrayHasKey('subplugintype_special-chars_type_plural', $requiredStrings); + + // Verify context information + $singularContext = $requiredStrings['subplugintype_simple_type']; + $this->assertSame('Subplugin type: simple_type (singular)', $singularContext->getDescription()); + + $pluralContext = $requiredStrings['subplugintype_simple_type_plural']; + $this->assertSame('Subplugin type: simple_type (plural)', $pluralContext->getDescription()); + } + + /** + * Test error handling for corrupted files. + */ + public function testCheckWithCorruptedFilesHandlesGracefully(): void + { + $corruptedContent = 'corrupted content - not valid JSON or PHP'; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/subplugins.json' => $corruptedContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertErrorCount($result, 1); + + $errors = $result->getErrors(); + $this->assertStringContainsString('Error parsing subplugins file', $errors[0]); + } +} diff --git a/tests/MissingStrings/Checker/DatabaseFileChecker/TagsCheckerTest.php b/tests/MissingStrings/Checker/DatabaseFileChecker/TagsCheckerTest.php new file mode 100644 index 00000000..675ded29 --- /dev/null +++ b/tests/MissingStrings/Checker/DatabaseFileChecker/TagsCheckerTest.php @@ -0,0 +1,391 @@ +checker = new TagsChecker(); + } + + /** + * Test that the checker has the correct name. + */ + public function testGetNameReturnsCorrectName(): void + { + $this->assertSame('Tags', $this->checker->getName()); + } + + /** + * Test that the checker applies when tag.php exists. + */ + public function testAppliesToWithTagFileReturnsTrue(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/tag.php' => $this->createDatabaseFileContent('tag', []), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + $this->assertTrue($this->checker->appliesTo($plugin)); + } + + /** + * Test that the checker doesn't apply when tag.php doesn't exist. + */ + public function testAppliesToWithoutTagFileReturnsFalse(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod'); + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + + $this->assertFalse($this->checker->appliesTo($plugin)); + } + + /** + * Test processing single tag area. + */ + public function testCheckWithSingleTagAreaAddsRequiredString(): void + { + $tagareas = [ + [ + 'itemtype' => 'course_modules', + 'component' => 'mod_testmod', + 'callback' => 'mod_testmod_get_tagged_course_modules', + ], + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/tag.php' => $this->createDatabaseFileContent('tag', $tagareas), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(1, $requiredStrings); + $this->assertArrayHasKey('tagarea_course_modules', $requiredStrings); + + $context = $requiredStrings['tagarea_course_modules']; + $this->assertStringContainsString('db/tag.php', $context->getFile()); + $this->assertSame('Tag area: course_modules', $context->getDescription()); + $this->assertNotNull($context->getLine()); + } + + /** + * Test processing multiple tag areas. + */ + public function testCheckWithMultipleTagAreasAddsAllRequiredStrings(): void + { + $tagareas = [ + [ + 'itemtype' => 'course_modules', + 'component' => 'mod_testmod', + 'callback' => 'mod_testmod_get_tagged_course_modules', + ], + [ + 'itemtype' => 'user_content', + 'component' => 'mod_testmod', + 'callback' => 'mod_testmod_get_tagged_user_content', + ], + [ + 'itemtype' => 'activities', + 'component' => 'mod_testmod', + 'callback' => 'mod_testmod_get_tagged_activities', + ], + ]; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/tag.php' => $this->createDatabaseFileContent('tag', $tagareas), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(3, $requiredStrings); + + $this->assertArrayHasKey('tagarea_course_modules', $requiredStrings); + $this->assertArrayHasKey('tagarea_user_content', $requiredStrings); + $this->assertArrayHasKey('tagarea_activities', $requiredStrings); + + // Check that each has correct context + foreach (['tagarea_course_modules', 'tagarea_user_content', 'tagarea_activities'] as $expectedKey) { + $context = $requiredStrings[$expectedKey]; + $this->assertStringContainsString('db/tag.php', $context->getFile()); + $this->assertStringContainsString('Tag area:', $context->getDescription()); + $this->assertNotNull($context->getLine()); + } + } + + /** + * Test tag area string pattern generation. + */ + public function testCheckWithVariousItemTypesGeneratesCorrectStringKeys(): void + { + $tagareas = [ + [ + 'itemtype' => 'simple_item', + 'component' => 'local_testlocal', + ], + [ + 'itemtype' => 'user_generated_content', + 'component' => 'local_testlocal', + ], + [ + 'itemtype' => 'resource123', + 'component' => 'local_testlocal', + ], + [ + 'itemtype' => 'special-chars_item', + 'component' => 'local_testlocal', + ], + ]; + + $pluginDir = $this->createTestPlugin('local', 'testlocal', [ + 'db/tag.php' => $this->createDatabaseFileContent('tag', $tagareas), + ]); + + $plugin = $this->createPlugin('local', 'testlocal', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(4, $requiredStrings); + + // Check expected string keys with tagarea_ prefix + $this->assertArrayHasKey('tagarea_simple_item', $requiredStrings); + $this->assertArrayHasKey('tagarea_user_generated_content', $requiredStrings); + $this->assertArrayHasKey('tagarea_resource123', $requiredStrings); + $this->assertArrayHasKey('tagarea_special-chars_item', $requiredStrings); + } + + /** + * Test line detection accuracy. + */ + public function testCheckWithTagAreasDetectsCorrectLineNumbers(): void + { + $tagContent = " 'course_modules',\n" . // Line 7 - this is what we're looking for + " 'component' => 'mod_testmod',\n" . + " 'callback' => 'mod_testmod_get_tagged_course_modules'\n" . + " ],\n" . + " [\n" . // Line 11 + " 'itemtype' => 'user_content',\n" . // Line 12 - this is what we're looking for + " 'component' => 'mod_testmod',\n" . + " 'callback' => 'mod_testmod_get_tagged_user_content'\n" . + " ]\n" . + "];\n"; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/tag.php' => $tagContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + + // Line detection is based on string literal search for the itemtype value + $this->assertStringContextHasLine($requiredStrings['tagarea_course_modules'], 7); + $this->assertStringContextHasLine($requiredStrings['tagarea_user_content'], 12); + } + + /** + * Test handling of empty tag file. + */ + public function testCheckWithEmptyTagFileReturnsEmptyResult(): void + { + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/tag.php' => $this->createDatabaseFileContent('tag', []), + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertCount(0, $result->getWarnings()); + $this->assertErrorCount($result, 0); // Empty tagareas array is valid, just means no tag areas defined + } + + /** + * Test handling of malformed tag area. + */ + public function testCheckWithMalformedTagAreaSkipsInvalidEntries(): void + { + $tagContent = " 'valid_item',\n" . + " 'component' => 'mod_testmod'\n" . + " ],\n" . + " 'invalid_string_instead_of_array',\n" . + " [\n" . + " // Missing itemtype key\n" . + " 'component' => 'mod_testmod'\n" . + " ],\n" . + " [\n" . + " 'itemtype' => 'another_valid_item',\n" . + " 'component' => 'mod_testmod'\n" . + " ]\n" . + "];\n"; + + $pluginDir = $this->createTestPlugin('mod', 'testmod', [ + 'db/tag.php' => $tagContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(2, $requiredStrings); // Only valid entries should be processed + + $this->assertArrayHasKey('tagarea_valid_item', $requiredStrings); + $this->assertArrayHasKey('tagarea_another_valid_item', $requiredStrings); + } + + /** + * Test handling of missing tagareas array. + */ + public function testCheckWithMissingTagAreasArrayAddsError(): void + { + $tagContent = "createTestPlugin('mod', 'testmod', [ + 'db/tag.php' => $tagContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertErrorCount($result, 1); + + $errors = $result->getErrors(); + $this->assertStringContainsString('No valid $tagareas array found', $errors[0]); + } + + /** + * Test handling of invalid tagareas array. + */ + public function testCheckWithInvalidTagAreasArrayAddsError(): void + { + $tagContent = "createTestPlugin('mod', 'testmod', [ + 'db/tag.php' => $tagContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertErrorCount($result, 1); + + $errors = $result->getErrors(); + $this->assertStringContainsString('No valid $tagareas array found', $errors[0]); + } + + /** + * Test complex tag areas with various configurations. + */ + public function testCheckWithComplexTagAreasProcessesCorrectly(): void + { + $tagareas = [ + [ + 'itemtype' => 'complex_content', + 'component' => 'local_testlocal', + 'callback' => 'local_testlocal_get_tagged_items', + 'showstandard' => true, + 'multiplecontexts' => false, + ], + [ + 'itemtype' => 'minimal_item', + 'component' => 'local_testlocal', + ], + [ + 'itemtype' => 'advanced_resource', + 'component' => 'local_testlocal', + 'callback' => 'local_testlocal_get_advanced_resources', + 'showstandard' => false, + 'multiplecontexts' => true, + 'collection' => 'local_testlocal', + ], + ]; + + $pluginDir = $this->createTestPlugin('local', 'testlocal', [ + 'db/tag.php' => $this->createDatabaseFileContent('tag', $tagareas), + ]); + + $plugin = $this->createPlugin('local', 'testlocal', $pluginDir); + $result = $this->checker->check($plugin); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(3, $requiredStrings); + + $this->assertArrayHasKey('tagarea_complex_content', $requiredStrings); + $this->assertArrayHasKey('tagarea_minimal_item', $requiredStrings); + $this->assertArrayHasKey('tagarea_advanced_resource', $requiredStrings); + + // Verify context information + $complexContext = $requiredStrings['tagarea_complex_content']; + $this->assertSame('Tag area: complex_content', $complexContext->getDescription()); + + $minimalContext = $requiredStrings['tagarea_minimal_item']; + $this->assertSame('Tag area: minimal_item', $minimalContext->getDescription()); + } + + /** + * Test error handling for corrupted tag file. + */ + public function testCheckWithCorruptedTagFileHandlesGracefully(): void + { + $tagContent = "createTestPlugin('mod', 'testmod', [ + 'db/tag.php' => $tagContent, + ]); + + $plugin = $this->createPlugin('mod', 'testmod', $pluginDir); + $result = $this->checker->check($plugin); + + $this->assertCount(0, $result->getRequiredStrings()); + $this->assertErrorCount($result, 1); + + $errors = $result->getErrors(); + $this->assertStringContainsString('Error parsing db/tag.php', $errors[0]); + } +} diff --git a/tests/MissingStrings/Core/ErrorHandlerTest.php b/tests/MissingStrings/Core/ErrorHandlerTest.php new file mode 100644 index 00000000..39d89ca9 --- /dev/null +++ b/tests/MissingStrings/Core/ErrorHandlerTest.php @@ -0,0 +1,511 @@ +result = new ValidationResult(); + $this->errorHandler = new ErrorHandler($this->result); + } + + /** + * Test constructor sets properties correctly. + */ + public function testConstructorSetsPropertiesCorrectly(): void + { + $result = new ValidationResult(); + $handler = new ErrorHandler($result, true); + + $this->assertSame($result, $handler->getResult()); + $this->assertTrue($handler->isDebugEnabled()); + + $handler2 = new ErrorHandler($result, false); + $this->assertFalse($handler2->isDebugEnabled()); + } + + /** + * Test handleException with error level exception. + */ + public function testHandleExceptionWithErrorLevel(): void + { + $exception = new StringValidationException( + 'Test error message', + ['file' => 'test.php', 'line' => 10], + 'error' + ); + + $this->errorHandler->handleException($exception); + + $this->assertSame(1, $this->result->getErrorCount()); + $this->assertSame(0, $this->result->getWarningCount()); + $this->assertSame(0, $this->result->getSuccessCount()); + + $errors = $this->result->getErrors(); + $this->assertStringContainsString('Test error message', $errors[0]); + $this->assertStringContainsString('test.php', $errors[0]); + $this->assertStringContainsString('10', $errors[0]); + } + + /** + * Test handleException with warning level exception. + */ + public function testHandleExceptionWithWarningLevel(): void + { + $exception = new StringValidationException( + 'Test warning message', + ['context' => 'test context'], + 'warning' + ); + + $this->errorHandler->handleException($exception); + + $this->assertSame(0, $this->result->getErrorCount()); + $this->assertSame(1, $this->result->getWarningCount()); + $this->assertSame(0, $this->result->getSuccessCount()); + + $warnings = $this->result->getWarnings(); + $this->assertStringContainsString('Test warning message', $warnings[0]); + $this->assertStringContainsString('test context', $warnings[0]); + } + + /** + * Test handleException with info level exception. + */ + public function testHandleExceptionWithInfoLevel(): void + { + $exception = new StringValidationException( + 'Test info message', + [], + 'info' + ); + + $this->errorHandler->handleException($exception); + + $this->assertSame(0, $this->result->getErrorCount()); + $this->assertSame(0, $this->result->getWarningCount()); + $this->assertSame(1, $this->result->getSuccessCount()); + } + + /** + * Test handleGenericException. + */ + public function testHandleGenericException(): void + { + $originalException = new \RuntimeException('Original error message', 123); + + $this->errorHandler->handleGenericException($originalException, 'Test context'); + + $this->assertSame(1, $this->result->getErrorCount()); + $errors = $this->result->getErrors(); + $this->assertStringContainsString('Original error message', $errors[0]); + $this->assertStringContainsString('Test context', $errors[0]); + } + + /** + * Test handleGenericException with warning severity. + */ + public function testHandleGenericExceptionWithWarningSeverity(): void + { + $originalException = new \InvalidArgumentException('Invalid argument'); + + $this->errorHandler->handleGenericException($originalException, 'Test context', 'warning'); + + $this->assertSame(0, $this->result->getErrorCount()); + $this->assertSame(1, $this->result->getWarningCount()); + + $warnings = $this->result->getWarnings(); + $this->assertStringContainsString('Invalid argument', $warnings[0]); + } + + /** + * Test handleCheckerError with continue on error. + */ + public function testHandleCheckerErrorWithContinueOnError(): void + { + $originalException = new \RuntimeException('Checker failed'); + + $result = $this->errorHandler->handleCheckerError('TestChecker', $originalException, true); + + $this->assertTrue($result); + $this->assertSame(0, $this->result->getErrorCount()); + $this->assertSame(1, $this->result->getWarningCount()); + + $warnings = $this->result->getWarnings(); + $this->assertStringContainsString('TestChecker', $warnings[0]); + $this->assertStringContainsString('Checker failed but validation continues', $warnings[0]); + } + + /** + * Test handleCheckerError without continue on error. + */ + public function testHandleCheckerErrorWithoutContinueOnError(): void + { + $originalException = new \RuntimeException('Critical checker error'); + + $result = $this->errorHandler->handleCheckerError('CriticalChecker', $originalException, false); + + $this->assertFalse($result); + $this->assertSame(1, $this->result->getErrorCount()); + $this->assertSame(0, $this->result->getWarningCount()); + + $errors = $this->result->getErrors(); + $this->assertStringContainsString('CriticalChecker', $errors[0]); + $this->assertStringContainsString('Critical checker error', $errors[0]); + } + + /** + * Test handleFileError. + */ + public function testHandleFileError(): void + { + $originalException = new \Exception('File parsing failed'); + + $this->errorHandler->handleFileError('/path/to/file.php', $originalException, 'parse'); + + $this->assertSame(1, $this->result->getErrorCount()); + $errors = $this->result->getErrors(); + $this->assertStringContainsString('file.php', $errors[0]); + $this->assertStringContainsString('Failed to parse file', $errors[0]); + $this->assertStringContainsString('File parsing failed', $errors[0]); + } + + /** + * Test addError method. + */ + public function testAddError(): void + { + $this->errorHandler->addError( + 'Custom error message', + ['component' => 'mod_test', 'string_key' => 'missing_string'] + ); + + $this->assertSame(1, $this->result->getErrorCount()); + $errors = $this->result->getErrors(); + $this->assertStringContainsString('Custom error message', $errors[0]); + $this->assertStringContainsString('mod_test', $errors[0]); + $this->assertStringContainsString('missing_string', $errors[0]); + } + + /** + * Test addWarning method. + */ + public function testAddWarning(): void + { + $this->errorHandler->addWarning( + 'Custom warning message', + ['unused_string' => 'old_feature'] + ); + + $this->assertSame(0, $this->result->getErrorCount()); + $this->assertSame(1, $this->result->getWarningCount()); + + $warnings = $this->result->getWarnings(); + $this->assertStringContainsString('Custom warning message', $warnings[0]); + $this->assertStringContainsString('old_feature', $warnings[0]); + } + + /** + * Test addInfo method. + */ + public function testAddInfo(): void + { + $this->errorHandler->addInfo( + 'Custom info message', + ['processed_files' => 5] + ); + + $this->assertSame(0, $this->result->getErrorCount()); + $this->assertSame(0, $this->result->getWarningCount()); + $this->assertSame(1, $this->result->getSuccessCount()); + } + + /** + * Test safeExecute with successful callback. + */ + public function testSafeExecuteWithSuccessfulCallback(): void + { + $callback = function () { + return 'success result'; + }; + + $result = $this->errorHandler->safeExecute($callback, 'Test operation'); + + $this->assertSame('success result', $result); + $this->assertSame(0, $this->result->getErrorCount()); + $this->assertSame(0, $this->result->getWarningCount()); + } + + /** + * Test safeExecute with StringValidationException. + */ + public function testSafeExecuteWithStringValidationException(): void + { + $callback = function () { + throw new StringValidationException('Validation failed', [], 'error'); + }; + + $result = $this->errorHandler->safeExecute($callback, 'Test operation'); + + $this->assertNull($result); + $this->assertSame(1, $this->result->getErrorCount()); + + $errors = $this->result->getErrors(); + $this->assertStringContainsString('Validation failed', $errors[0]); + } + + /** + * Test safeExecute with generic exception and continue on error. + */ + public function testSafeExecuteWithGenericExceptionContinueOnError(): void + { + $callback = function () { + throw new \RuntimeException('Generic error'); + }; + + $result = $this->errorHandler->safeExecute($callback, 'Test operation', true); + + $this->assertNull($result); + $this->assertSame(0, $this->result->getErrorCount()); + $this->assertSame(1, $this->result->getWarningCount()); + + $warnings = $this->result->getWarnings(); + $this->assertStringContainsString('Generic error', $warnings[0]); + $this->assertStringContainsString('Test operation', $warnings[0]); + } + + /** + * Test safeExecute with generic exception and stop on error. + */ + public function testSafeExecuteWithGenericExceptionStopOnError(): void + { + $callback = function () { + throw new \RuntimeException('Critical error'); + }; + + $result = $this->errorHandler->safeExecute($callback, 'Critical operation', false); + + $this->assertFalse($result); + $this->assertSame(1, $this->result->getErrorCount()); + $this->assertSame(0, $this->result->getWarningCount()); + + $errors = $this->result->getErrors(); + $this->assertStringContainsString('Critical error', $errors[0]); + } + + /** + * Test debug mode with exception handling. + */ + public function testDebugModeWithExceptionHandling(): void + { + $debugHandler = new ErrorHandler($this->result, true); + + $originalException = new \RuntimeException('Original error', 0); + $stringValidationException = new StringValidationException( + 'Validation error', + [], + 'error', + 0, + $originalException + ); + + $debugHandler->handleException($stringValidationException); + + $errors = $this->result->getErrors(); + $this->assertStringContainsString('Debug:', $errors[0]); + $this->assertStringContainsString('RuntimeException', $errors[0]); + } + + /** + * Test setDebug method. + */ + public function testSetDebug(): void + { + $this->assertFalse($this->errorHandler->isDebugEnabled()); + + $this->errorHandler->setDebug(true); + $this->assertTrue($this->errorHandler->isDebugEnabled()); + + $this->errorHandler->setDebug(false); + $this->assertFalse($this->errorHandler->isDebugEnabled()); + } + + /** + * Test exception formatting without debug info. + */ + public function testExceptionFormattingWithoutDebugInfo(): void + { + $exception = new StringValidationException( + 'Simple error', + ['key' => 'value'], + 'error' + ); + + $this->errorHandler->handleException($exception); + + $errors = $this->result->getErrors(); + $this->assertStringNotContainsString('Debug:', $errors[0]); + $this->assertStringContainsString('Simple error', $errors[0]); + $this->assertStringContainsString('value', $errors[0]); + } + + /** + * Test handling multiple errors. + */ + public function testHandlingMultipleErrors(): void + { + $this->errorHandler->addError('Error 1', ['context' => 'first']); + $this->errorHandler->addWarning('Warning 1', ['context' => 'second']); + $this->errorHandler->addError('Error 2', ['context' => 'third']); + $this->errorHandler->addInfo('Info 1', ['context' => 'fourth']); + + $this->assertSame(2, $this->result->getErrorCount()); + $this->assertSame(1, $this->result->getWarningCount()); + $this->assertSame(1, $this->result->getSuccessCount()); + + $errors = $this->result->getErrors(); + $this->assertStringContainsString('Error 1', $errors[0]); + $this->assertStringContainsString('Error 2', $errors[1]); + + $warnings = $this->result->getWarnings(); + $this->assertStringContainsString('Warning 1', $warnings[0]); + } + + /** + * Test with specific exception types. + */ + public function testWithSpecificExceptionTypes(): void + { + // Test FileException + $fileException = FileException::fileNotFound('/path/to/missing.php', ['component' => 'mod_test']); + $this->errorHandler->handleException($fileException); + + // Test CheckerException + $checkerException = CheckerException::checkerError('TestChecker', 'Checker failed', []); + $this->errorHandler->handleException($checkerException); + + $this->assertSame(2, $this->result->getErrorCount()); + + $errors = $this->result->getErrors(); + $this->assertStringContainsString('missing.php', $errors[0]); + $this->assertStringContainsString('TestChecker', $errors[1]); + } + + /** + * Test error handler with edge cases. + */ + public function testErrorHandlerWithEdgeCases(): void + { + // Empty message + $this->errorHandler->addError('', []); + $this->assertSame(1, $this->result->getErrorCount()); + + // Empty context + $this->errorHandler->addWarning('Warning message', []); + $this->assertSame(1, $this->result->getWarningCount()); + + // Null values in context + $this->errorHandler->addError('Error with nulls', ['key' => null, 'file' => '/test.php']); + $this->assertSame(2, $this->result->getErrorCount()); + + $errors = $this->result->getErrors(); + $this->assertStringContainsString('test.php', $errors[1]); + } + + /** + * Test safeExecute with callback returning false. + */ + public function testSafeExecuteWithCallbackReturningFalse(): void + { + $callback = function () { + return false; + }; + + $result = $this->errorHandler->safeExecute($callback, 'Test operation'); + + $this->assertFalse($result); + $this->assertSame(0, $this->result->getErrorCount()); + $this->assertSame(0, $this->result->getWarningCount()); + } + + /** + * Test safeExecute with callback returning null. + */ + public function testSafeExecuteWithCallbackReturningNull(): void + { + $callback = function () { + return null; + }; + + $result = $this->errorHandler->safeExecute($callback, 'Test operation'); + + $this->assertNull($result); + $this->assertSame(0, $this->result->getErrorCount()); + $this->assertSame(0, $this->result->getWarningCount()); + } + + /** + * Test performance with many errors. + */ + public function testPerformanceWithManyErrors(): void + { + $startTime = microtime(true); + + for ($i = 0; $i < 1000; ++$i) { + $this->errorHandler->addError("Error {$i}", ['index' => $i]); + } + + $endTime = microtime(true); + $duration = $endTime - $startTime; + + $this->assertSame(1000, $this->result->getErrorCount()); + $this->assertLessThan(1.0, $duration, 'Error handling should be reasonably fast'); + } + + /** + * Test error handler preserves original exception chain. + */ + public function testErrorHandlerPreservesExceptionChain(): void + { + $originalException = new \InvalidArgumentException('Original error'); + $wrappedException = new \RuntimeException('Wrapped error', 0, $originalException); + + $this->errorHandler->handleGenericException($wrappedException, 'Test context'); + + $this->assertSame(1, $this->result->getErrorCount()); + $errors = $this->result->getErrors(); + $this->assertStringContainsString('Wrapped error', $errors[0]); + } +} diff --git a/tests/MissingStrings/Core/StringContextTest.php b/tests/MissingStrings/Core/StringContextTest.php new file mode 100644 index 00000000..3c60af3a --- /dev/null +++ b/tests/MissingStrings/Core/StringContextTest.php @@ -0,0 +1,418 @@ +assertNull($context->getFile(), 'File should be null by default'); + $this->assertNull($context->getLine(), 'Line should be null by default'); + $this->assertNull($context->getDescription(), 'Description should be null by default'); + $this->assertFalse($context->hasLocation(), 'Should not have location with null values'); + } + + /** + * Test constructor with all parameters. + */ + public function testConstructorWithAllParametersSetsCorrectValues(): void + { + $file = 'test.php'; + $line = 42; + $description = 'Test description'; + + $context = new StringContext($file, $line, $description); + + $this->assertSame($file, $context->getFile(), 'File should be set correctly'); + $this->assertSame($line, $context->getLine(), 'Line should be set correctly'); + $this->assertSame($description, $context->getDescription(), 'Description should be set correctly'); + $this->assertTrue($context->hasLocation(), 'Should have location with file and line'); + } + + /** + * Test constructor with partial parameters. + */ + public function testConstructorWithPartialParametersSetsCorrectValues(): void + { + $file = 'partial.php'; + $context = new StringContext($file); + + $this->assertSame($file, $context->getFile(), 'File should be set correctly'); + $this->assertNull($context->getLine(), 'Line should be null'); + $this->assertNull($context->getDescription(), 'Description should be null'); + $this->assertFalse($context->hasLocation(), 'Should not have location without line number'); + } + + /** + * Test constructor with file and line only. + */ + public function testConstructorWithFileAndLineSetsCorrectValues(): void + { + $file = 'location.php'; + $line = 123; + + $context = new StringContext($file, $line); + + $this->assertSame($file, $context->getFile(), 'File should be set correctly'); + $this->assertSame($line, $context->getLine(), 'Line should be set correctly'); + $this->assertNull($context->getDescription(), 'Description should be null'); + $this->assertTrue($context->hasLocation(), 'Should have location with file and line'); + } + + /** + * Test setLine method. + */ + public function testSetLineUpdatesLineNumber(): void + { + $context = new StringContext('test.php'); + $this->assertFalse($context->hasLocation(), 'Should not have location initially'); + + $context->setLine(100); + + $this->assertSame(100, $context->getLine(), 'Line should be updated'); + $this->assertTrue($context->hasLocation(), 'Should have location after setting line'); + } + + /** + * Test setLine with zero line number. + */ + public function testSetLineWithZeroSetsCorrectly(): void + { + $context = new StringContext('test.php'); + $context->setLine(0); + + $this->assertSame(0, $context->getLine(), 'Line should be set to 0'); + $this->assertTrue($context->hasLocation(), 'Should have location with line 0'); + } + + /** + * Test setLine with negative line number. + */ + public function testSetLineWithNegativeSetsCorrectly(): void + { + $context = new StringContext('test.php'); + $context->setLine(-1); + + $this->assertSame(-1, $context->getLine(), 'Line should be set to -1'); + $this->assertTrue($context->hasLocation(), 'Should have location with negative line'); + } + + /** + * Test hasLocation with various combinations. + */ + public function testHasLocationWithVariousCombinationsReturnsCorrectly(): void + { + // No file, no line + $context1 = new StringContext(); + $this->assertFalse($context1->hasLocation(), 'Should not have location with null file and line'); + + // File only, no line + $context2 = new StringContext('file.php'); + $this->assertFalse($context2->hasLocation(), 'Should not have location with only file'); + + // No file, line only + $context3 = new StringContext(null, 42); + $this->assertFalse($context3->hasLocation(), 'Should not have location with only line'); + + // Both file and line + $context4 = new StringContext('file.php', 42); + $this->assertTrue($context4->hasLocation(), 'Should have location with both file and line'); + + // Empty string file with line + $context5 = new StringContext('', 42); + $this->assertFalse($context5->hasLocation(), 'Should not have location with empty file'); + } + + /** + * Test toArray method with complete context. + */ + public function testToArrayWithCompleteContextReturnsCorrectArray(): void + { + $file = 'complete.php'; + $line = 55; + $description = 'Complete context description'; + + $context = new StringContext($file, $line, $description); + $array = $context->toArray(); + + $expected = [ + 'file' => $file, + 'line' => $line, + 'context' => $description, + ]; + + $this->assertSame($expected, $array, 'Array should contain all context information'); + } + + /** + * Test toArray method with location only. + */ + public function testToArrayWithLocationOnlyReturnsLocationArray(): void + { + $file = 'location.php'; + $line = 77; + + $context = new StringContext($file, $line); + $array = $context->toArray(); + + $expected = [ + 'file' => $file, + 'line' => $line, + ]; + + $this->assertSame($expected, $array, 'Array should contain only location information'); + } + + /** + * Test toArray method with description only. + */ + public function testToArrayWithDescriptionOnlyReturnsDescriptionArray(): void + { + $description = 'Only description'; + + $context = new StringContext(null, null, $description); + $array = $context->toArray(); + + $expected = [ + 'context' => $description, + ]; + + $this->assertSame($expected, $array, 'Array should contain only description'); + } + + /** + * Test toArray method with no context information. + */ + public function testToArrayWithNoContextReturnsEmptyArray(): void + { + $context = new StringContext(); + $array = $context->toArray(); + + $this->assertEmpty($array, 'Array should be empty with no context information'); + } + + /** + * Test toArray method with empty string values. + */ + public function testToArrayWithEmptyStringValuesHandlesCorrectly(): void + { + $context = new StringContext('', null, ''); + $array = $context->toArray(); + + // Empty strings should not be included + $this->assertEmpty($array, 'Array should be empty with empty string values'); + } + + /** + * Test __toString method with complete context. + */ + public function testToStringWithCompleteContextReturnsFormattedString(): void + { + $file = 'example.php'; + $line = 99; + $description = 'Example description'; + + $context = new StringContext($file, $line, $description); + $result = (string) $context; + + $expected = 'Example description in example.php:99'; + $this->assertSame($expected, $result, 'String representation should be formatted correctly'); + } + + /** + * Test __toString method with location only. + */ + public function testToStringWithLocationOnlyReturnsLocationString(): void + { + $file = 'location.php'; + $line = 88; + + $context = new StringContext($file, $line); + $result = (string) $context; + + $expected = 'in location.php:88'; + $this->assertSame($expected, $result, 'String representation should show location only'); + } + + /** + * Test __toString method with description only. + */ + public function testToStringWithDescriptionOnlyReturnsDescriptionString(): void + { + $description = 'Just a description'; + + $context = new StringContext(null, null, $description); + $result = (string) $context; + + $expected = 'Just a description'; + $this->assertSame($expected, $result, 'String representation should show description only'); + } + + /** + * Test __toString method with no context. + */ + public function testToStringWithNoContextReturnsEmptyString(): void + { + $context = new StringContext(); + $result = (string) $context; + + $this->assertSame('', $result, 'String representation should be empty with no context'); + } + + /** + * Test __toString method with empty string values. + */ + public function testToStringWithEmptyStringValuesHandlesCorrectly(): void + { + $context = new StringContext('', null, ''); + $result = (string) $context; + + $this->assertSame('', $result, 'String representation should be empty with empty string values'); + } + + /** + * Test context with special characters in file names. + */ + public function testContextWithSpecialCharactersHandlesCorrectly(): void + { + $file = 'special-file_name.with.dots.php'; + $line = 123; + $description = 'Description with special chars: @#$%^&*()'; + + $context = new StringContext($file, $line, $description); + + $this->assertSame($file, $context->getFile(), 'Should handle special characters in file name'); + $this->assertSame($description, $context->getDescription(), 'Should handle special characters in description'); + + $stringResult = (string) $context; + $this->assertStringContainsString($file, $stringResult, 'String representation should contain file name'); + $this->assertStringContainsString($description, $stringResult, 'String representation should contain description'); + } + + /** + * Test context with Unicode characters. + */ + public function testContextWithUnicodeCharactersHandlesCorrectly(): void + { + $file = 'файл.php'; // Cyrillic characters + $line = 456; + $description = 'Описание с юникодом'; // Cyrillic description + + $context = new StringContext($file, $line, $description); + + $this->assertSame($file, $context->getFile(), 'Should handle Unicode characters in file name'); + $this->assertSame($description, $context->getDescription(), 'Should handle Unicode characters in description'); + + $array = $context->toArray(); + $this->assertSame($file, $array['file'], 'Array should contain Unicode file name'); + $this->assertSame($description, $array['context'], 'Array should contain Unicode description'); + } + + /** + * Test context with very long strings. + */ + public function testContextWithLongStringsHandlesCorrectly(): void + { + $longFile = str_repeat('very_long_file_name_', 50) . '.php'; + $longDescription = str_repeat('This is a very long description that goes on and on. ', 100); + + $context = new StringContext($longFile, 789, $longDescription); + + $this->assertSame($longFile, $context->getFile(), 'Should handle long file names'); + $this->assertSame($longDescription, $context->getDescription(), 'Should handle long descriptions'); + + // Test that toString doesn't break with long strings + $stringResult = (string) $context; + $this->assertStringContainsString($longFile, $stringResult, 'String representation should contain long file name'); + $this->assertStringContainsString($longDescription, $stringResult, 'String representation should contain long description'); + } + + /** + * Test immutability of context after creation. + */ + public function testContextIsImmutableExceptForSetLine(): void + { + $originalFile = 'original.php'; + $originalLine = 100; + $originalDescription = 'Original description'; + + $context = new StringContext($originalFile, $originalLine, $originalDescription); + + // The only way to modify context is through setLine + $context->setLine(200); + + $this->assertSame($originalFile, $context->getFile(), 'File should remain unchanged'); + $this->assertSame(200, $context->getLine(), 'Line should be updated'); + $this->assertSame($originalDescription, $context->getDescription(), 'Description should remain unchanged'); + } + + /** + * Test edge cases with boundary values. + */ + public function testContextWithBoundaryValuesHandlesCorrectly(): void + { + // Test with maximum integer value + $maxInt = PHP_INT_MAX; + $context1 = new StringContext('test.php', $maxInt); + $this->assertSame($maxInt, $context1->getLine(), 'Should handle maximum integer line number'); + + // Test with minimum integer value + $minInt = PHP_INT_MIN; + $context2 = new StringContext('test.php', $minInt); + $this->assertSame($minInt, $context2->getLine(), 'Should handle minimum integer line number'); + + // Test with very short strings + $context3 = new StringContext('a', 1, 'b'); + $this->assertSame('a', $context3->getFile(), 'Should handle single character file name'); + $this->assertSame('b', $context3->getDescription(), 'Should handle single character description'); + } + + /** + * Test context behavior with null values after construction. + */ + public function testContextWithNullValuesBehavesCorrectly(): void + { + // Test partial null values + $context1 = new StringContext('file.php', null, 'description'); + $this->assertFalse($context1->hasLocation(), 'Should not have location with null line'); + + $context2 = new StringContext(null, 42, 'description'); + $this->assertFalse($context2->hasLocation(), 'Should not have location with null file'); + + // Test array conversion with null values + $array1 = $context1->toArray(); + $this->assertArrayNotHasKey('file', $array1, 'Array should not contain null file'); + $this->assertArrayNotHasKey('line', $array1, 'Array should not contain null line'); + $this->assertArrayHasKey('context', $array1, 'Array should contain description'); + + $array2 = $context2->toArray(); + $this->assertArrayNotHasKey('file', $array2, 'Array should not contain null file'); + $this->assertArrayNotHasKey('line', $array2, 'Array should not contain null line'); + $this->assertArrayHasKey('context', $array2, 'Array should contain description'); + } +} diff --git a/tests/MissingStrings/Core/StringUsageFinderTest.php b/tests/MissingStrings/Core/StringUsageFinderTest.php new file mode 100644 index 00000000..475c4035 --- /dev/null +++ b/tests/MissingStrings/Core/StringUsageFinderTest.php @@ -0,0 +1,625 @@ +usageFinder = new StringUsageFinder(); + } + + /** + * Test finding array key lines in database files. + */ + public function testFindArrayKeyLineWithValidKeyReturnsCorrectLine(): void + { + $content = " [\n" . // Line 6 + " 'riskbitmask' => RISK_XSS,\n" . + " 'captype' => 'write',\n" . + " 'contextlevel' => CONTEXT_COURSE,\n" . + " 'archetypes' => [\n" . + " 'editingteacher' => CAP_ALLOW,\n" . + " 'manager' => CAP_ALLOW,\n" . + " ],\n" . + " ],\n" . + " 'mod/assign:grade' => [\n" . // Line 15 + " 'riskbitmask' => RISK_PERSONAL,\n" . + " 'captype' => 'write',\n" . + " ],\n" . + "];\n"; + + $filePath = $this->createTempFile($content); + + $lineNumber = $this->usageFinder->findArrayKeyLine($filePath, 'mod/assign:addinstance'); + $this->assertSame(6, $lineNumber); + + $lineNumber = $this->usageFinder->findArrayKeyLine($filePath, 'mod/assign:grade'); + $this->assertSame(15, $lineNumber); + } + + /** + * Test finding array key lines with various quote styles. + */ + public function testFindArrayKeyLineWithDifferentQuoteStylesReturnsCorrectLine(): void + { + $content = " [\n" . // Line 4 - single quotes + " 'mode' => cache_store::MODE_APPLICATION,\n" . + " ],\n" . + ' "cache_two" => [' . "\n" . // Line 7 - double quotes + " 'mode' => cache_store::MODE_SESSION,\n" . + " ],\n" . + "];\n"; + + $filePath = $this->createTempFile($content); + + $lineNumber = $this->usageFinder->findArrayKeyLine($filePath, 'cache_one'); + $this->assertSame(4, $lineNumber); + + $lineNumber = $this->usageFinder->findArrayKeyLine($filePath, 'cache_two'); + $this->assertSame(7, $lineNumber); + } + + /** + * Test finding string literals in PHP code. + */ + public function testFindStringLiteralLineWithGetStringReturnsCorrectLine(): void + { + $content = "createTempFile($content); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'modulename'); + $this->assertSame(5, $lineNumber); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'error_occurred'); + $this->assertSame(7, $lineNumber); + } + + /** + * Test finding lines with custom patterns. + */ + public function testFindLineInFileWithCustomPatternReturnsCorrectLine(): void + { + $content = "add_database_table('assign_submission', [\n" . // Line 5 + " 'assignment' => 'privacy:metadata:assign_submission:assignment',\n" . + " 'userid' => 'privacy:metadata:assign_submission:userid',\n" . + " ], 'privacy:metadata:assign_submission');\n" . + " return \$collection;\n" . + " }\n" . + "}\n"; + + $filePath = $this->createTempFile($content); + + // Find class declaration + $pattern = '~class\s+privacy_provider~'; + $lineNumber = $this->usageFinder->findLineInFile($filePath, 'privacy_provider', $pattern); + $this->assertSame(3, $lineNumber); + + // Find specific privacy string + $pattern = '~[\'"]privacy:metadata:assign_submission:assignment[\'"]~'; + $lineNumber = $this->usageFinder->findLineInFile($filePath, 'privacy:metadata:assign_submission:assignment', $pattern); + $this->assertSame(6, $lineNumber); + } + + /** + * Test handling of files with complex capability names (containing forward slashes). + */ + public function testFindArrayKeyLineWithSlashesInKeyReturnsCorrectLine(): void + { + $content = " [\n" . // Line 4 + " 'captype' => 'write',\n" . + " ],\n" . + " 'mod/forum:addquestion' => [\n" . // Line 7 + " 'captype' => 'write',\n" . + " ],\n" . + "];\n"; + + $filePath = $this->createTempFile($content); + + // This tests the fix where we changed delimiter from / to ~ to avoid escaping issues + $lineNumber = $this->usageFinder->findArrayKeyLine($filePath, 'mod/assign:submit'); + $this->assertSame(4, $lineNumber); + + $lineNumber = $this->usageFinder->findArrayKeyLine($filePath, 'mod/forum:addquestion'); + $this->assertSame(7, $lineNumber); + } + + /** + * Test with empty lines to ensure line numbering is correct. + */ + public function testFindArrayKeyLineWithEmptyLinesPreservesCorrectLineNumbers(): void + { + $content = " [\n" . // Line 9 + " 'captype' => 'read',\n" . + " ],\n" . + "];\n"; + + $filePath = $this->createTempFile($content); + + // This tests that we removed FILE_SKIP_EMPTY_LINES to preserve correct line numbers + $lineNumber = $this->usageFinder->findArrayKeyLine($filePath, 'mod/test:view'); + $this->assertSame(9, $lineNumber); + } + + /** + * Test handling of non-existent keys. + */ + public function testFindArrayKeyLineWithNonExistentKeyReturnsNull(): void + { + $content = " [\n" . + " 'captype' => 'read',\n" . + " ],\n" . + "];\n"; + + $filePath = $this->createTempFile($content); + + $lineNumber = $this->usageFinder->findArrayKeyLine($filePath, 'mod/assign:nonexistent'); + $this->assertNull($lineNumber); + } + + /** + * Test handling of non-existent files. + */ + public function testFindArrayKeyLineWithNonExistentFileReturnsNull(): void + { + $lineNumber = $this->usageFinder->findArrayKeyLine('/path/to/nonexistent/file.php', 'some_key'); + $this->assertNull($lineNumber); + } + + /** + * Test finding string literals with complex quote escaping. + */ + public function testFindStringLiteralLineWithEscapedQuotesReturnsCorrectLine(): void + { + $content = "createTempFile($content); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'simple_string'); + $this->assertSame(4, $lineNumber); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'double_quote_string'); + $this->assertSame(5, $lineNumber); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, "string_with'_apostrophe"); + $this->assertSame(6, $lineNumber); + } + + /** + * Test performance with large files. + */ + public function testFindArrayKeyLineWithLargeFilePerformsReasonably(): void + { + // Create a large file with many capabilities + $content = " ['captype' => 'read'],\n"; + } + + // Add our target capability near the end + $content .= " 'mod/test:target_capability' => ['captype' => 'write'],\n"; // Line 1004 + $content .= "];\n"; + + $filePath = $this->createTempFile($content); + + $startTime = microtime(true); + $lineNumber = $this->usageFinder->findArrayKeyLine($filePath, 'mod/test:target_capability'); + $endTime = microtime(true); + + $this->assertSame(1004, $lineNumber); + + // Should complete in reasonable time (less than 1 second) + $this->assertLessThan(1.0, $endTime - $startTime, 'Line detection should be performant'); + } + + /** + * Test handling of Unicode characters. + */ + public function testFindStringLiteralLineWithUnicodeCharactersReturnsCorrectLine(): void + { + $content = "createTempFile($content); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'unicode_test'); + $this->assertSame(4, $lineNumber); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'тест_кириллица'); + $this->assertSame(5, $lineNumber); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, '测试中文'); + $this->assertSame(6, $lineNumber); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'tëst_áccënts'); + $this->assertSame(7, $lineNumber); + } + + /** + * Test that exact matches are required (no partial matches). + */ + public function testFindArrayKeyLineRequiresExactMatchNoPartialMatches(): void + { + $content = " ['captype' => 'read'],\n" . // Line 4 + " 'mod/assign:viewall' => ['captype' => 'read'],\n" . // Line 5 + "];\n"; + + $filePath = $this->createTempFile($content); + + // Should find exact matches + $lineNumber = $this->usageFinder->findArrayKeyLine($filePath, 'mod/assign:view'); + $this->assertSame(4, $lineNumber); + + $lineNumber = $this->usageFinder->findArrayKeyLine($filePath, 'mod/assign:viewall'); + $this->assertSame(5, $lineNumber); + + // Should not find partial matches + $lineNumber = $this->usageFinder->findArrayKeyLine($filePath, 'assign:view'); + $this->assertNull($lineNumber); + + $lineNumber = $this->usageFinder->findArrayKeyLine($filePath, 'mod/assign'); + $this->assertNull($lineNumber); + } + + /** + * Test finding get_string() calls with optional parameters in PHP code. + * + * This tests the fix for the issue where get_string() calls with third or fourth + * parameters were not being detected by the regex patterns. + */ + public function testFindStringLiteralLineWithGetStringOptionalParametersReturnsCorrectLine(): void + { + $content = " 'value'));\n" . // Line 11 + " \$str6 = get_string('witharray2', 'component', ['key' => 'value']);\n" . // Line 12 + "\n" . + " // 4-parameter calls (fourth parameter: \$lazyload)\n" . + " \$str7 = get_string('lazyload', 'component', \$a, true);\n" . // Line 15 + " \$str8 = get_string('lazyload2', 'component', null, false);\n" . // Line 16 + " \$str9 = get_string('lazyload3', 'component', array(), true);\n" . // Line 17 + "\n" . + " // Complex parameter calls\n" . + " \$str10 = get_string('complex', 'component', \$this->getParams(), true);\n" . // Line 20 + " \$str11 = get_string('complex2', 'component', array('key' => 'value', 'another' => 'param'), false);\n" . // Line 21 + "\n" . + " // Various spacing patterns\n" . + " \$str12 = get_string( 'spaced' , 'component' , \$params );\n" . // Line 24 + " \$str13 = get_string('nospace','component',\$params,true);\n" . // Line 25 + "}\n"; + + $filePath = $this->createTempFile($content); + + // Test basic 2-parameter calls + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'basicstring'); + $this->assertSame(5, $lineNumber, 'Should find basic 2-parameter get_string call'); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'anotherstring'); + $this->assertSame(6, $lineNumber, 'Should find another basic 2-parameter get_string call'); + + // Test 3-parameter calls + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'withparams'); + $this->assertSame(9, $lineNumber, 'Should find get_string call with variable parameter'); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'withnull'); + $this->assertSame(10, $lineNumber, 'Should find get_string call with null parameter'); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'witharray'); + $this->assertSame(11, $lineNumber, 'Should find get_string call with array() parameter'); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'witharray2'); + $this->assertSame(12, $lineNumber, 'Should find get_string call with [] parameter'); + + // Test 4-parameter calls + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'lazyload'); + $this->assertSame(15, $lineNumber, 'Should find get_string call with lazyload=true'); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'lazyload2'); + $this->assertSame(16, $lineNumber, 'Should find get_string call with lazyload=false'); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'lazyload3'); + $this->assertSame(17, $lineNumber, 'Should find get_string call with empty array and lazyload'); + + // Test complex parameter calls + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'complex'); + $this->assertSame(20, $lineNumber, 'Should find get_string call with method call parameter'); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'complex2'); + $this->assertSame(21, $lineNumber, 'Should find get_string call with complex array parameter'); + + // Test various spacing patterns + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'spaced'); + $this->assertSame(24, $lineNumber, 'Should find get_string call with extra spaces'); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'nospace'); + $this->assertSame(25, $lineNumber, 'Should find get_string call with no spaces'); + } + + /** + * Test finding JavaScript str.get_string() calls with optional parameters. + * + * This tests the fix for JavaScript patterns that should also handle optional parameters. + * Note: This tests the pattern matching logic rather than the StringUsageFinder directly, + * since StringUsageFinder is designed for PHP patterns. + */ + public function testFindStringLiteralLineWithJavaScriptGetStringOptionalParametersReturnsCorrectLine(): void + { + $content = "// JavaScript file\n\n" . + "define(['core/str'], function(str) {\n" . + " // Basic 2-parameter calls\n" . + " var str1 = str.get_string('basicjs', 'component');\n" . // Line 5 + " var str2 = str.get_string('anotherjs', 'mod_assign');\n" . // Line 6 + "\n" . + " // 3-parameter calls (with data parameter)\n" . + " var str3 = str.get_string('withdata', 'component', data);\n" . // Line 9 + " var str4 = str.get_string('withobj', 'component', {key: 'value'});\n" . // Line 10 + " var str5 = str.get_string('withnull', 'component', null);\n" . // Line 11 + "\n" . + " // Various spacing patterns\n" . + " var str6 = str.get_string( 'spaced' , 'component' , params );\n" . // Line 14 + " var str7 = str.get_string('nospace','component',data);\n" . // Line 15 + "\n" . + " return {str1, str2, str3, str4, str5, str6, str7};\n" . + "});\n"; + + $filePath = $this->createTempFile($content); + + // Test the JavaScript pattern directly since StringUsageFinder is designed for PHP + $lines = file($filePath, FILE_IGNORE_NEW_LINES); + $pattern = '~str\.get_string\s*\(\s*[\'"]([^\'\"]+)[\'"]\s*,\s*[\'"]([^\'\"]+)[\'"](?:\s*,.*?)?\s*\)~'; + + $foundLines = []; + foreach ($lines as $lineNumber => $line) { + if (preg_match($pattern, $line, $matches)) { + $stringKey = $matches[1]; + $foundLines[$stringKey] = $lineNumber + 1; // Convert to 1-based line numbers + } + } + + // Test basic 2-parameter calls + $this->assertArrayHasKey('basicjs', $foundLines, 'Should find basicjs string key'); + $this->assertSame(5, $foundLines['basicjs'], 'Should find basicjs on line 5'); + + $this->assertArrayHasKey('anotherjs', $foundLines, 'Should find anotherjs string key'); + $this->assertSame(6, $foundLines['anotherjs'], 'Should find anotherjs on line 6'); + + // Test 3-parameter calls + $this->assertArrayHasKey('withdata', $foundLines, 'Should find withdata string key'); + $this->assertSame(9, $foundLines['withdata'], 'Should find withdata on line 9'); + + $this->assertArrayHasKey('withobj', $foundLines, 'Should find withobj string key'); + $this->assertSame(10, $foundLines['withobj'], 'Should find withobj on line 10'); + + $this->assertArrayHasKey('withnull', $foundLines, 'Should find withnull string key'); + $this->assertSame(11, $foundLines['withnull'], 'Should find withnull on line 11'); + + // Test various spacing patterns + $this->assertArrayHasKey('spaced', $foundLines, 'Should find spaced string key'); + $this->assertSame(14, $foundLines['spaced'], 'Should find spaced on line 14'); + + $this->assertArrayHasKey('nospace', $foundLines, 'Should find nospace string key'); + $this->assertSame(15, $foundLines['nospace'], 'Should find nospace on line 15'); + + // Verify that all expected patterns were found + $expectedKeys = ['basicjs', 'anotherjs', 'withdata', 'withobj', 'withnull', 'spaced', 'nospace']; + $this->assertCount(count($expectedKeys), $foundLines, 'Should find all expected string keys'); + } + + /** + * Test finding lang_string instantiations with optional parameters. + * + * This tests that new lang_string() calls with optional parameters are also handled correctly. + */ + public function testFindStringLiteralLineWithLangStringOptionalParametersReturnsCorrectLine(): void + { + $content = " 'value'));\n" . // Line 11 + "\n" . + " // 4-parameter calls (with language parameter)\n" . + " \$str6 = new lang_string('withlang', 'component', \$a, 'en');\n" . // Line 14 + " \$str7 = new lang_string('withlang2', 'component', null, 'es');\n" . // Line 15 + "\n" . + " // Various spacing\n" . + " \$str8 = new lang_string( 'spacedlang' , 'component' , \$params );\n" . // Line 18 + "}\n"; + + $filePath = $this->createTempFile($content); + + // Test basic 2-parameter calls + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'basiclang'); + $this->assertSame(5, $lineNumber, 'Should find basic 2-parameter lang_string call'); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'anotherlang'); + $this->assertSame(6, $lineNumber, 'Should find another basic 2-parameter lang_string call'); + + // Test 3-parameter calls + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'withlangparams'); + $this->assertSame(9, $lineNumber, 'Should find lang_string call with variable parameter'); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'withlangnull'); + $this->assertSame(10, $lineNumber, 'Should find lang_string call with null parameter'); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'withlangarray'); + $this->assertSame(11, $lineNumber, 'Should find lang_string call with array parameter'); + + // Test 4-parameter calls + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'withlang'); + $this->assertSame(14, $lineNumber, 'Should find lang_string call with language parameter'); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'withlang2'); + $this->assertSame(15, $lineNumber, 'Should find another lang_string call with language parameter'); + + // Test spacing variations + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'spacedlang'); + $this->assertSame(18, $lineNumber, 'Should find lang_string call with extra spaces'); + } + + /** + * Test finding addHelpButton() calls with optional parameters. + * + * This tests that addHelpButton() calls with optional parameters are also handled correctly. + */ + public function testFindStringLiteralLineWithAddHelpButtonOptionalParametersReturnsCorrectLine(): void + { + $content = "_form;\n" . + "\n" . + " // Basic addHelpButton calls\n" . + " \$mform->addHelpButton('element1', 'helpstring1', 'component');\n" . // Line 8 + " \$mform->addHelpButton('element2', 'helpstring2', 'mod_assign');\n" . // Line 9 + "\n" . + " // addHelpButton with additional parameters\n" . + " \$mform->addHelpButton('element3', 'helpstring3', 'component', true);\n" . // Line 12 + " \$mform->addHelpButton('element4', 'helpstring4', 'component', false, \$extra);\n" . // Line 13 + "\n" . + " // Various spacing\n" . + " \$mform->addHelpButton( 'element5' , 'helpstring5' , 'component' );\n" . // Line 16 + " \$mform->addHelpButton('element6','helpstring6','component',true);\n" . // Line 17 + " }\n" . + "}\n"; + + $filePath = $this->createTempFile($content); + + // Test basic addHelpButton calls + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'helpstring1'); + $this->assertSame(8, $lineNumber, 'Should find basic addHelpButton call'); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'helpstring2'); + $this->assertSame(9, $lineNumber, 'Should find another basic addHelpButton call'); + + // Test addHelpButton with additional parameters + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'helpstring3'); + $this->assertSame(12, $lineNumber, 'Should find addHelpButton call with boolean parameter'); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'helpstring4'); + $this->assertSame(13, $lineNumber, 'Should find addHelpButton call with multiple additional parameters'); + + // Test spacing variations + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'helpstring5'); + $this->assertSame(16, $lineNumber, 'Should find addHelpButton call with extra spaces'); + + $lineNumber = $this->usageFinder->findStringLiteralLine($filePath, 'helpstring6'); + $this->assertSame(17, $lineNumber, 'Should find addHelpButton call with no spaces'); + } + + /** + * Test the specific case mentioned in the GitHub issue. + * + * This reproduces the exact problem reported: get_string() with a third parameter + * was not being detected by the old regex pattern. + */ + public function testFindStringLiteralLineReproduceGitHubIssueBothCasesWork(): void + { + $content = "createTempFile($content); + + // Both cases should now work with our fixed regex patterns + $lineNumber1 = $this->usageFinder->findStringLiteralLine($filePath, 'notexistingstring'); + $this->assertSame(5, $lineNumber1, 'Should find first get_string call (2 parameters) - this always worked'); + + // Use a custom pattern to find the second occurrence + $pattern = '~get_string\s*\(\s*[\'"]notexistingstring[\'"]\s*,\s*[\'"]block_accessreview[\'"](?:\s*,.*?)?\s*\)~'; + $fileLines = file($filePath, FILE_IGNORE_NEW_LINES); + $foundLine = null; + + foreach ($fileLines as $lineNum => $line) { + if (preg_match($pattern, $line) && false !== strpos($line, '$params')) { + $foundLine = $lineNum + 1; // Convert to 1-based line number + break; + } + } + + $this->assertSame(8, $foundLine, 'Should find second get_string call (3 parameters) - this was broken before the fix'); + } +} diff --git a/tests/MissingStrings/Core/StringValidatorTest.php b/tests/MissingStrings/Core/StringValidatorTest.php new file mode 100644 index 00000000..f850ede3 --- /dev/null +++ b/tests/MissingStrings/Core/StringValidatorTest.php @@ -0,0 +1,506 @@ +testPluginPath = $this->createTempDir('test_plugin_'); + $this->createSimpleTestPlugin(); + + // Create plugin instance + $this->testPlugin = new Plugin('local_testplugin', 'local', 'testplugin', $this->testPluginPath); + + // Create moodle mock + $this->moodle = $this->createMock(Moodle::class); + $this->moodle->method('getBranch')->willReturn(401); + + // Create default config + $this->config = new ValidationConfig(); + } + + protected function tearDown(): void + { + parent::tearDown(); + // Temp directories are cleaned up automatically by base class + } + + /** + * Test constructor initializes correctly. + */ + public function testConstructorInitializesCorrectly(): void + { + $validator = new StringValidator($this->testPlugin, $this->moodle, $this->config); + + $this->assertInstanceOf(StringValidator::class, $validator); + } + + /** + * Test validation with valid plugin. + */ + public function testValidateWithValidPlugin(): void + { + $this->createLangFile(['pluginname' => 'Test Plugin']); + + $validator = new StringValidator($this->testPlugin, $this->moodle, $this->config); + $result = $validator->validate(); + + $this->assertInstanceOf(ValidationResult::class, $result); + $this->assertTrue($result->isValid()); + $this->assertSame(0, $result->getErrorCount()); + } + + /** + * Test validation with missing required string. + */ + public function testValidateWithMissingRequiredString(): void + { + // Create language file with some strings but missing the required 'pluginname' + $this->createLangFile(['somestring' => 'Some String']); + + $validator = new StringValidator($this->testPlugin, $this->moodle, $this->config); + $result = $validator->validate(); + + $this->assertFalse($result->isValid()); + $this->assertGreaterThan(0, $result->getErrorCount()); + + $errors = $result->getErrors(); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('Missing required string', $errors[0]); + $this->assertStringContainsString('pluginname', $errors[0]); + } + + /** + * Test validation with missing language file. + */ + public function testValidateWithMissingLanguageFile(): void + { + // Don't create language file + + $validator = new StringValidator($this->testPlugin, $this->moodle, $this->config); + $result = $validator->validate(); + + $this->assertFalse($result->isValid()); + $this->assertGreaterThan(0, $result->getErrorCount()); + + $errors = $result->getErrors(); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('File not found', $errors[0]); + } + + /** + * Test validation with unreadable language file. + */ + public function testValidateWithUnreadableLanguageFile(): void + { + $this->createLangFile(['pluginname' => 'Test Plugin']); + + $langFile = $this->testPluginPath . '/lang/en/local_testplugin.php'; + chmod($langFile, 0000); // Make file unreadable + + $validator = new StringValidator($this->testPlugin, $this->moodle, $this->config); + $result = $validator->validate(); + + // Restore permissions for cleanup + chmod($langFile, 0644); + + // In Docker/some environments, file permission restrictions may not work as expected + // So we check if the validation either succeeded or failed with appropriate error + if (!$result->isValid()) { + $this->assertGreaterThan(0, $result->getErrorCount()); + $errors = $result->getErrors(); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('File not readable', $errors[0]); + } else { + // If permissions didn't restrict access, validation should succeed + $this->assertTrue($result->isValid()); + } + } + + /** + * Test validation with corrupted language file. + */ + public function testValidateWithCorruptedLanguageFile(): void + { + if (!is_dir($this->testPluginPath . '/lang/en')) { + mkdir($this->testPluginPath . '/lang/en', 0755, true); + } + $langFile = $this->testPluginPath . '/lang/en/local_testplugin.php'; + file_put_contents($langFile, 'testPlugin, $this->moodle, $this->config); + $result = $validator->validate(); + + $this->assertFalse($result->isValid()); + $this->assertGreaterThan(0, $result->getErrorCount()); + + $errors = $result->getErrors(); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('Failed to parse file', $errors[0]); + } + + /** + * Test validation with used strings. + */ + public function testValidateWithUsedStrings(): void + { + $this->createLangFile([ + 'pluginname' => 'Test Plugin', + 'used_string' => 'Used string', + ]); + + // Create PHP file with string usage + file_put_contents( + $this->testPluginPath . '/lib.php', + "testPlugin, $this->moodle, $this->config); + $result = $validator->validate(); + + $this->assertTrue($result->isValid()); + $this->assertSame(0, $result->getErrorCount()); + } + + /** + * Test validation with missing used string. + */ + public function testValidateWithMissingUsedString(): void + { + $this->createLangFile(['pluginname' => 'Test Plugin']); + + // Create PHP file with string usage + file_put_contents( + $this->testPluginPath . '/lib.php', + "testPlugin, $this->moodle, $this->config); + $result = $validator->validate(); + + $this->assertFalse($result->isValid()); + $this->assertGreaterThan(0, $result->getErrorCount()); + + $errors = $result->getErrors(); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('Missing used string', $errors[0]); + $this->assertStringContainsString('missing_string', $errors[0]); + } + + /** + * Test validation with unused strings. + */ + public function testValidateWithUnusedStrings(): void + { + $this->createLangFile([ + 'pluginname' => 'Test Plugin', + 'unused_string' => 'Unused string', + ]); + + // Enable unused string checking + $config = new ValidationConfig('en', false, true); + + $validator = new StringValidator($this->testPlugin, $this->moodle, $config); + $result = $validator->validate(); + + $this->assertTrue($result->isValid()); // Warnings don't make it invalid in non-strict mode + $this->assertGreaterThan(0, $result->getWarningCount()); + + $warnings = $result->getWarnings(); + $this->assertNotEmpty($warnings); + $this->assertStringContainsString('Unused string', $warnings[0]); + $this->assertStringContainsString('unused_string', $warnings[0]); + } + + /** + * Test validation with strict mode. + */ + public function testValidateWithStrictMode(): void + { + $this->createLangFile([ + 'pluginname' => 'Test Plugin', + 'unused_string' => 'Unused string', + ]); + + // Enable strict mode and unused string checking + $config = new ValidationConfig('en', true, true); + + $validator = new StringValidator($this->testPlugin, $this->moodle, $config); + $result = $validator->validate(); + + $this->assertFalse($result->isValid()); // Warnings make it invalid in strict mode + $this->assertGreaterThan(0, $result->getWarningCount()); + } + + /** + * Test validation with excluded strings. + */ + public function testValidateWithExcludedStrings(): void + { + $this->createLangFile([ + 'pluginname' => 'Test Plugin', + 'excluded_string' => 'Excluded string', + ]); + + // Create PHP file with excluded string usage + file_put_contents( + $this->testPluginPath . '/lib.php', + "testPlugin, $this->moodle, $config); + $result = $validator->validate(); + + $this->assertTrue($result->isValid()); + $this->assertSame(0, $result->getErrorCount()); + $this->assertSame(0, $result->getWarningCount()); + } + + /** + * Test validation with custom checkers. + */ + public function testValidateWithCustomCheckers(): void + { + $this->createLangFile(['pluginname' => 'Test Plugin']); + + // Create mock checker + $mockChecker = $this->createMock(StringCheckerInterface::class); + $mockChecker->method('getName')->willReturn('Mock Checker'); + $mockChecker->method('appliesTo')->willReturn(true); + + $mockResult = new ValidationResult(); + $mockResult->addRawError('Mock checker error'); + $mockChecker->method('check')->willReturn($mockResult); + + $validator = new StringValidator($this->testPlugin, $this->moodle, $this->config); + $validator->addChecker($mockChecker); + + $result = $validator->validate(); + + $this->assertFalse($result->isValid()); + $this->assertGreaterThan(0, $result->getErrorCount()); + + $errors = $result->getErrors(); + $this->assertNotEmpty($errors); + $this->assertStringContainsString('Mock checker error', $errors[0]); + } + + /** + * Test validation with module plugin (special language file naming). + */ + public function testValidateWithModulePlugin(): void + { + // Create module plugin structure + $modulePath = $this->createTempDir('test_module_'); + if (!is_dir($modulePath)) { + mkdir($modulePath, 0755, true); + } + if (!is_dir($modulePath . '/lang/en')) { + mkdir($modulePath . '/lang/en', 0755, true); + } + + // Create version.php + $versionContent = "component = 'mod_testmodule';\n\$plugin->version = 2023010100;\n"; + file_put_contents($modulePath . '/version.php', $versionContent); + + // Create language file with module naming convention (testmodule.php instead of mod_testmodule.php) + $langContent = "moodle, $this->config); + $result = $validator->validate(); + + $this->assertTrue($result->isValid()); + $this->assertSame(0, $result->getErrorCount()); + + // Cleanup is handled automatically by base class + } + + /** + * Test validation with debug mode. + */ + public function testValidateWithDebugMode(): void + { + $this->createLangFile(['pluginname' => 'Test Plugin']); + + $config = new ValidationConfig('en', false, false, [], [], true, true); + + $validator = new StringValidator($this->testPlugin, $this->moodle, $config); + $result = $validator->validate(); + + $this->assertInstanceOf(ValidationResult::class, $result); + } + + /** + * Test adding and setting checkers. + */ + public function testAddAndSetCheckers(): void + { + $this->createLangFile(['pluginname' => 'Test Plugin']); + + $mockChecker1 = $this->createMock(StringCheckerInterface::class); + $mockChecker1->method('getName')->willReturn('Mock Checker 1'); + $mockChecker1->method('appliesTo')->willReturn(false); + + $mockChecker2 = $this->createMock(StringCheckerInterface::class); + $mockChecker2->method('getName')->willReturn('Mock Checker 2'); + $mockChecker2->method('appliesTo')->willReturn(true); + + $mockResult = new ValidationResult(); + $mockChecker2->method('check')->willReturn($mockResult); + + $validator = new StringValidator($this->testPlugin, $this->moodle, $this->config); + + // Add checkers + $validator->addChecker($mockChecker1); + $validator->addChecker($mockChecker2); + + $result = $validator->validate(); + + $this->assertTrue($result->isValid()); + + // Set checkers (replace existing) + $mockChecker3 = $this->createMock(StringCheckerInterface::class); + $mockChecker3->method('getName')->willReturn('Mock Checker 3'); + $mockChecker3->method('appliesTo')->willReturn(true); + $mockChecker3->method('check')->willReturn($mockResult); + + $validator->setCheckers([$mockChecker3]); + + $result2 = $validator->validate(); + + $this->assertTrue($result2->isValid()); + } + + /** + * Test validation with subplugins. + */ + public function testValidateWithSubplugins(): void + { + $this->createLangFile([ + 'pluginname' => 'Test Plugin', + 'subplugintype_testsubtype' => 'Test Subtype', + 'subplugintype_testsubtype_plural' => 'Test Subtypes', + ]); + + // Create subplugins.json - this test ensures the main plugin validates successfully + // even when it has subplugin definitions (the actual subplugin discovery is tested separately) + $subpluginsJson = [ + 'plugintypes' => [ + 'testsubtype' => 'local/testplugin/subplugins', + ], + ]; + + if (!is_dir($this->testPluginPath . '/db')) { + mkdir($this->testPluginPath . '/db', 0755, true); + } + file_put_contents( + $this->testPluginPath . '/db/subplugins.json', + json_encode($subpluginsJson, JSON_PRETTY_PRINT) + ); + + $validator = new StringValidator($this->testPlugin, $this->moodle, $this->config); + $result = $validator->validate(); + + // The main plugin should validate successfully with subplugin definitions + $this->assertTrue($result->isValid()); + $this->assertSame(0, $result->getErrorCount()); + } + + /** + * Test validation handles errors gracefully. + */ + public function testValidateHandlesErrorsGracefully(): void + { + $this->createLangFile(['pluginname' => 'Test Plugin']); + + // Create a checker that throws an exception + $mockChecker = $this->createMock(StringCheckerInterface::class); + $mockChecker->method('getName')->willReturn('Failing Checker'); + $mockChecker->method('appliesTo')->willReturn(true); + $mockChecker->method('check')->willThrowException(new \RuntimeException('Checker failed')); + + $validator = new StringValidator($this->testPlugin, $this->moodle, $this->config); + $validator->addChecker($mockChecker); + + $result = $validator->validate(); + + // Validation should continue despite checker failure + $this->assertInstanceOf(ValidationResult::class, $result); + } + + /** + * Create a test plugin directory structure. + */ + private function createSimpleTestPlugin(): void + { + // Create version.php + $versionContent = "component = 'local_testplugin';\n\$plugin->version = 2023010100;\n"; + file_put_contents($this->testPluginPath . '/version.php', $versionContent); + } + + /** + * Create a language file for the test plugin. + * + * @param array $strings Array of string key => value pairs + */ + private function createLangFile(array $strings): void + { + $langDir = $this->testPluginPath . '/lang/en'; + if (!is_dir($langDir)) { + mkdir($langDir, 0755, true); + } + + $langContent = " $value) { + $langContent .= "\$string['{$key}'] = '{$value}';\n"; + } + + file_put_contents($langDir . '/local_testplugin.php', $langContent); + } +} diff --git a/tests/MissingStrings/Core/ValidationConfigTest.php b/tests/MissingStrings/Core/ValidationConfigTest.php new file mode 100644 index 00000000..187e4cf4 --- /dev/null +++ b/tests/MissingStrings/Core/ValidationConfigTest.php @@ -0,0 +1,286 @@ +assertSame('en', $config->getLanguage(), 'Default language should be en'); + $this->assertFalse($config->isStrict(), 'Default strict mode should be false'); + $this->assertFalse($config->shouldCheckUnused(), 'Default unused checking should be false'); + $this->assertEmpty($config->getExcludePatterns(), 'Default exclude patterns should be empty'); + $this->assertEmpty($config->getCustomCheckers(), 'Default custom checkers should be empty'); + $this->assertTrue($config->shouldUseDefaultCheckers(), 'Default checkers should be enabled by default'); + $this->assertFalse($config->isDebugEnabled(), 'Default debug mode should be false'); + } + + /** + * Test constructor with custom values. + */ + public function testConstructorWithCustomValuesSetsCorrectValues(): void + { + $excludePatterns = ['test_*', 'debug_*']; + $customCheckers = ['MyChecker', 'AnotherChecker']; + + $config = new ValidationConfig( + 'es', + true, + true, + $excludePatterns, + $customCheckers, + false, + true + ); + + $this->assertSame('es', $config->getLanguage(), 'Language should be set to es'); + $this->assertTrue($config->isStrict(), 'Strict mode should be enabled'); + $this->assertTrue($config->shouldCheckUnused(), 'Unused checking should be enabled'); + $this->assertSame($excludePatterns, $config->getExcludePatterns(), 'Exclude patterns should match'); + $this->assertSame($customCheckers, $config->getCustomCheckers(), 'Custom checkers should match'); + $this->assertFalse($config->shouldUseDefaultCheckers(), 'Default checkers should be disabled'); + $this->assertTrue($config->isDebugEnabled(), 'Debug mode should be enabled'); + } + + /** + * Test fromOptions factory method with minimal options. + */ + public function testFromOptionsWithMinimalOptionsCreatesConfigWithDefaults(): void + { + $options = []; + $config = ValidationConfig::fromOptions($options); + + $this->assertSame('en', $config->getLanguage(), 'Should use default language'); + $this->assertFalse($config->isStrict(), 'Should use default strict mode'); + $this->assertFalse($config->shouldCheckUnused(), 'Should use default unused checking'); + $this->assertEmpty($config->getExcludePatterns(), 'Should use default exclude patterns'); + $this->assertFalse($config->isDebugEnabled(), 'Should use default debug mode'); + } + + /** + * Test fromOptions factory method with all options. + */ + public function testFromOptionsWithAllOptionsCreatesConfigCorrectly(): void + { + $options = [ + 'lang' => 'fr', + 'strict' => true, + 'unused' => true, + 'exclude-patterns' => 'test_*,debug_*,temp_*', + 'debug' => true, + ]; + + $config = ValidationConfig::fromOptions($options); + + $this->assertSame('fr', $config->getLanguage(), 'Should set language from options'); + $this->assertTrue($config->isStrict(), 'Should set strict mode from options'); + $this->assertTrue($config->shouldCheckUnused(), 'Should set unused checking from options'); + $this->assertTrue($config->isDebugEnabled(), 'Should set debug mode from options'); + + $expectedPatterns = ['test_*', 'debug_*', 'temp_*']; + $this->assertSame($expectedPatterns, $config->getExcludePatterns(), 'Should parse exclude patterns correctly'); + } + + /** + * Test fromOptions with empty exclude patterns. + */ + public function testFromOptionsWithEmptyExcludePatternsHandlesCorrectly(): void + { + $options = [ + 'exclude-patterns' => '', + ]; + + $config = ValidationConfig::fromOptions($options); + $this->assertEmpty($config->getExcludePatterns(), 'Empty exclude patterns should result in empty array'); + } + + /** + * Test fromOptions with exclude patterns containing spaces. + */ + public function testFromOptionsWithExcludePatternsWithSpacesTrimsCorrectly(): void + { + $options = [ + 'exclude-patterns' => ' test_* , debug_* , temp_* ', + ]; + + $config = ValidationConfig::fromOptions($options); + $expectedPatterns = ['test_*', 'debug_*', 'temp_*']; + $this->assertSame($expectedPatterns, $config->getExcludePatterns(), 'Should trim spaces from patterns'); + } + + /** + * Test shouldExcludeString with no patterns. + */ + public function testShouldExcludeStringWithNoPatternsReturnsFalse(): void + { + $config = new ValidationConfig(); + + $this->assertFalse($config->shouldExcludeString('any_string'), 'Should not exclude when no patterns'); + $this->assertFalse($config->shouldExcludeString('test_string'), 'Should not exclude when no patterns'); + } + + /** + * Test shouldExcludeString with exact match patterns. + */ + public function testShouldExcludeStringWithExactMatchPatternsReturnsCorrectly(): void + { + $config = new ValidationConfig('en', false, false, ['exact_match', 'another_exact']); + + $this->assertTrue($config->shouldExcludeString('exact_match'), 'Should exclude exact match'); + $this->assertTrue($config->shouldExcludeString('another_exact'), 'Should exclude another exact match'); + $this->assertFalse($config->shouldExcludeString('no_match'), 'Should not exclude non-matching string'); + $this->assertFalse($config->shouldExcludeString('exact_match_extended'), 'Should not exclude partial match'); + } + + /** + * Test shouldExcludeString with wildcard patterns. + */ + public function testShouldExcludeStringWithWildcardPatternsReturnsCorrectly(): void + { + $config = new ValidationConfig('en', false, false, ['test_*', '*_debug', 'temp*file']); + + // Test prefix wildcard + $this->assertTrue($config->shouldExcludeString('test_string'), 'Should exclude prefix wildcard match'); + $this->assertTrue($config->shouldExcludeString('test_'), 'Should exclude prefix wildcard match'); + $this->assertFalse($config->shouldExcludeString('testing_string'), 'Should not exclude non-matching prefix'); + + // Test suffix wildcard + $this->assertTrue($config->shouldExcludeString('my_debug'), 'Should exclude suffix wildcard match'); + $this->assertTrue($config->shouldExcludeString('_debug'), 'Should exclude suffix wildcard match'); + $this->assertFalse($config->shouldExcludeString('debug_mode'), 'Should not exclude non-matching suffix'); + + // Test middle wildcard - fnmatch matches anything between temp and file + $this->assertTrue($config->shouldExcludeString('tempfile'), 'Should exclude middle wildcard match'); + $this->assertTrue($config->shouldExcludeString('temp123file'), 'Should exclude middle wildcard match'); + $this->assertTrue($config->shouldExcludeString('temporary_file'), 'Should exclude middle wildcard match (fnmatch behavior)'); + } + + /** + * Test shouldExcludeString with complex patterns. + */ + public function testShouldExcludeStringWithComplexPatternsReturnsCorrectly(): void + { + $config = new ValidationConfig('en', false, false, ['*test*', '???_temp', 'a*b*c']); + + // Test multiple wildcards + $this->assertTrue($config->shouldExcludeString('mytest'), 'Should match *test*'); + $this->assertTrue($config->shouldExcludeString('teststring'), 'Should match *test*'); + $this->assertTrue($config->shouldExcludeString('myteststring'), 'Should match *test*'); + + // Test question mark wildcards + $this->assertTrue($config->shouldExcludeString('abc_temp'), 'Should match ???_temp'); + $this->assertTrue($config->shouldExcludeString('123_temp'), 'Should match ???_temp'); + $this->assertFalse($config->shouldExcludeString('ab_temp'), 'Should not match ???_temp (too short)'); + $this->assertFalse($config->shouldExcludeString('abcd_temp'), 'Should not match ???_temp (too long)'); + + // Test complex pattern + $this->assertTrue($config->shouldExcludeString('abc'), 'Should match a*b*c'); + $this->assertTrue($config->shouldExcludeString('a123b456c'), 'Should match a*b*c'); + $this->assertFalse($config->shouldExcludeString('ab'), 'Should not match a*b*c'); + } + + /** + * Test shouldExcludeString with case sensitivity. + */ + public function testShouldExcludeStringCaseSensitiveReturnsCorrectly(): void + { + $config = new ValidationConfig('en', false, false, ['Test_*', 'DEBUG']); + + $this->assertTrue($config->shouldExcludeString('Test_string'), 'Should match case-sensitive pattern'); + $this->assertFalse($config->shouldExcludeString('test_string'), 'Should not match different case'); + $this->assertTrue($config->shouldExcludeString('DEBUG'), 'Should match exact case'); + $this->assertFalse($config->shouldExcludeString('debug'), 'Should not match different case'); + } + + /** + * Test shouldExcludeString with empty string. + */ + public function testShouldExcludeStringWithEmptyStringHandlesCorrectly(): void + { + $config = new ValidationConfig('en', false, false, ['*', 'test_*']); + + $this->assertTrue($config->shouldExcludeString(''), 'Empty string should match * pattern'); + + $config2 = new ValidationConfig('en', false, false, ['test_*']); + $this->assertFalse($config2->shouldExcludeString(''), 'Empty string should not match specific patterns'); + } + + /** + * Test shouldExcludeString with special characters. + */ + public function testShouldExcludeStringWithSpecialCharactersHandlesCorrectly(): void + { + $config = new ValidationConfig('en', false, false, ['test-*', 'debug_*_info', 'temp.*.log']); + + $this->assertTrue($config->shouldExcludeString('test-string'), 'Should handle hyphens'); + $this->assertTrue($config->shouldExcludeString('debug_special_info'), 'Should handle underscores and wildcards'); + $this->assertTrue($config->shouldExcludeString('temp.error.log'), 'Should handle dots'); + } + + /** + * Test shouldExcludeString performance with many patterns. + */ + public function testShouldExcludeStringWithManyPatternsPerformsReasonably(): void + { + $patterns = []; + for ($i = 0; $i < 100; ++$i) { + $patterns[] = "pattern_{$i}_*"; + } + + $config = new ValidationConfig('en', false, false, $patterns); + + $startTime = microtime(true); + + // Test multiple strings + for ($i = 0; $i < 50; ++$i) { + $config->shouldExcludeString("test_string_{$i}"); + } + + $endTime = microtime(true); + $duration = $endTime - $startTime; + + // Should complete in reasonable time (less than 1 second for this test) + $this->assertLessThan(1.0, $duration, 'Pattern matching should be reasonably fast'); + } + + /** + * Test that configuration is immutable after creation. + */ + public function testConfigurationIsImmutable(): void + { + $originalPatterns = ['test_*']; + $config = new ValidationConfig('en', false, false, $originalPatterns); + + // Get patterns and modify the returned array + $patterns = $config->getExcludePatterns(); + $patterns[] = 'new_pattern'; + + // Original config should be unchanged + $this->assertSame($originalPatterns, $config->getExcludePatterns(), 'Config should be immutable'); + } +} diff --git a/tests/MissingStrings/Core/ValidationResultTest.php b/tests/MissingStrings/Core/ValidationResultTest.php new file mode 100644 index 00000000..b06b9404 --- /dev/null +++ b/tests/MissingStrings/Core/ValidationResultTest.php @@ -0,0 +1,424 @@ +assertEmpty($result->getRequiredStrings(), 'Required strings should be empty initially'); + $this->assertEmpty($result->getErrors(), 'Errors should be empty initially'); + $this->assertEmpty($result->getWarnings(), 'Warnings should be empty initially'); + $this->assertEmpty($result->getMessages(), 'Messages should be empty initially'); + $this->assertSame(0, $result->getSuccessCount(), 'Success count should be zero initially'); + $this->assertSame(0, $result->getErrorCount(), 'Error count should be zero initially'); + $this->assertSame(0, $result->getWarningCount(), 'Warning count should be zero initially'); + $this->assertSame(0, $result->getTotalIssues(), 'Total issues should be zero initially'); + $this->assertTrue($result->isValid(), 'Result should be valid initially (non-strict mode)'); + } + + /** + * Test constructor with strict mode. + */ + public function testConstructorWithStrictModeInitializesCorrectly(): void + { + $result = new ValidationResult(true); + + $this->assertTrue($result->isValid(), 'Result should be valid initially even in strict mode'); + } + + /** + * Test adding required strings. + */ + public function testAddRequiredStringAddsCorrectly(): void + { + $result = new ValidationResult(); + $context1 = new StringContext('file1.php', 10, 'Test context 1'); + $context2 = new StringContext('file2.php', 20, 'Test context 2'); + + $result->addRequiredString('string1', $context1); + $result->addRequiredString('string2', $context2); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(2, $requiredStrings, 'Should have 2 required strings'); + $this->assertArrayHasKey('string1', $requiredStrings, 'Should contain string1'); + $this->assertArrayHasKey('string2', $requiredStrings, 'Should contain string2'); + $this->assertSame($context1, $requiredStrings['string1'], 'Should store correct context for string1'); + $this->assertSame($context2, $requiredStrings['string2'], 'Should store correct context for string2'); + } + + /** + * Test adding required strings with duplicate keys. + */ + public function testAddRequiredStringWithDuplicateKeysOverwritesPrevious(): void + { + $result = new ValidationResult(); + $context1 = new StringContext('file1.php', 10); + $context2 = new StringContext('file2.php', 20); + + $result->addRequiredString('duplicate_key', $context1); + $result->addRequiredString('duplicate_key', $context2); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertCount(1, $requiredStrings, 'Should have only 1 required string'); + $this->assertSame($context2, $requiredStrings['duplicate_key'], 'Should store the last context'); + } + + /** + * Test adding raw errors. + */ + public function testAddRawErrorAddsCorrectly(): void + { + $result = new ValidationResult(); + + $result->addRawError('Error message 1'); + $result->addRawError('Error message 2'); + + $errors = $result->getErrors(); + $this->assertCount(2, $errors, 'Should have 2 errors'); + $this->assertSame('Error message 1', $errors[0], 'Should store first error message'); + $this->assertSame('Error message 2', $errors[1], 'Should store second error message'); + $this->assertSame(2, $result->getErrorCount(), 'Error count should be 2'); + } + + /** + * Test adding raw warnings. + */ + public function testAddRawWarningAddsCorrectly(): void + { + $result = new ValidationResult(); + + $result->addRawWarning('Warning message 1'); + $result->addRawWarning('Warning message 2'); + + $warnings = $result->getWarnings(); + $this->assertCount(2, $warnings, 'Should have 2 warnings'); + $this->assertSame('Warning message 1', $warnings[0], 'Should store first warning message'); + $this->assertSame('Warning message 2', $warnings[1], 'Should store second warning message'); + $this->assertSame(2, $result->getWarningCount(), 'Warning count should be 2'); + } + + /** + * Test adding formatted messages via addError and addWarning. + */ + public function testAddFormattedMessagesAddsCorrectly(): void + { + $result = new ValidationResult(); + + $result->addError('Error message 1'); + $result->addWarning('Warning message 1'); + + $messages = $result->getMessages(); + $this->assertCount(2, $messages, 'Should have 2 formatted messages'); + $this->assertStringContainsString('Error message 1', $messages[0], 'Should contain error message'); + $this->assertStringContainsString('Warning message 1', $messages[1], 'Should contain warning message'); + + // Check that formatted messages have proper formatting + $this->assertStringContainsString('✗', $messages[0], 'Error message should have error symbol'); + $this->assertStringContainsString('⚠', $messages[1], 'Warning message should have warning symbol'); + } + + /** + * Test adding successes. + */ + public function testAddSuccessIncrementsCount(): void + { + $result = new ValidationResult(); + + $this->assertSame(0, $result->getSuccessCount(), 'Initial success count should be 0'); + + $result->addSuccess('Success 1'); + $this->assertSame(1, $result->getSuccessCount(), 'Success count should be 1'); + + $result->addSuccess('Success 2'); + $this->assertSame(2, $result->getSuccessCount(), 'Success count should be 2'); + } + + /** + * Test total issues calculation. + */ + public function testGetTotalIssuesCalculatesCorrectly(): void + { + $result = new ValidationResult(); + + $this->assertSame(0, $result->getTotalIssues(), 'Initial total issues should be 0'); + + $result->addRawError('Error 1'); + $this->assertSame(1, $result->getTotalIssues(), 'Total issues should be 1 with 1 error'); + + $result->addRawWarning('Warning 1'); + $this->assertSame(2, $result->getTotalIssues(), 'Total issues should be 2 with 1 error + 1 warning'); + + $result->addRawError('Error 2'); + $result->addRawWarning('Warning 2'); + $this->assertSame(4, $result->getTotalIssues(), 'Total issues should be 4 with 2 errors + 2 warnings'); + } + + /** + * Test validation status in non-strict mode. + */ + public function testIsValidNonStrictModeOnlyConsidersErrors(): void + { + $result = new ValidationResult(false); + + // Initially valid + $this->assertTrue($result->isValid(), 'Should be valid with no errors or warnings'); + + // Still valid with only warnings + $result->addRawWarning('Warning message'); + $this->assertTrue($result->isValid(), 'Should be valid with only warnings in non-strict mode'); + + // Invalid with errors + $result->addRawError('Error message'); + $this->assertFalse($result->isValid(), 'Should be invalid with errors'); + } + + /** + * Test validation status in strict mode. + */ + public function testIsValidStrictModeConsidersErrorsAndWarnings(): void + { + $result = new ValidationResult(true); + + // Initially valid + $this->assertTrue($result->isValid(), 'Should be valid with no errors or warnings'); + + // Invalid with warnings in strict mode + $result->addRawWarning('Warning message'); + $this->assertFalse($result->isValid(), 'Should be invalid with warnings in strict mode'); + + // Create new strict result for error test + $result2 = new ValidationResult(true); + $result2->addRawError('Error message'); + $this->assertFalse($result2->isValid(), 'Should be invalid with errors in strict mode'); + } + + /** + * Test summary generation. + */ + public function testGetSummaryGeneratesCorrectSummary(): void + { + $result = new ValidationResult(); + + $result->addRawError('Error 1'); + $result->addRawError('Error 2'); + $result->addRawWarning('Warning 1'); + $result->addSuccess('Success 1'); + $result->addSuccess('Success 2'); + $result->addSuccess('Success 3'); + + $summary = $result->getSummary(); + + $expectedSummary = [ + 'errors' => 2, + 'warnings' => 1, + 'successes' => 3, + 'total_issues' => 3, + 'is_valid' => false, + ]; + + $this->assertSame($expectedSummary, $summary, 'Summary should match expected values'); + } + + /** + * Test summary generation in strict mode. + */ + public function testGetSummaryStrictModeReflectsStrictValidation(): void + { + $result = new ValidationResult(true); + $result->addRawWarning('Warning 1'); + + $summary = $result->getSummary(); + + $this->assertFalse($summary['is_valid'], 'Summary should show invalid in strict mode with warnings'); + } + + /** + * Test merging two results. + */ + public function testMergeCombinesResultsCorrectly(): void + { + $result1 = new ValidationResult(); + $result1->addRequiredString('string1', new StringContext('file1.php', 10)); + $result1->addRawError('Error from result1'); + $result1->addRawWarning('Warning from result1'); + $result1->addSuccess('Success from result1'); + + $result2 = new ValidationResult(); + $result2->addRequiredString('string2', new StringContext('file2.php', 20)); + $result2->addRawError('Error from result2'); + $result2->addRawWarning('Warning from result2'); + $result2->addSuccess('Success from result2'); + $result2->addSuccess('Another success from result2'); + + $result1->merge($result2); + + // Check required strings + $requiredStrings = $result1->getRequiredStrings(); + $this->assertCount(2, $requiredStrings, 'Should have 2 required strings after merge'); + $this->assertArrayHasKey('string1', $requiredStrings, 'Should contain string1'); + $this->assertArrayHasKey('string2', $requiredStrings, 'Should contain string2'); + + // Check errors + $errors = $result1->getErrors(); + $this->assertCount(2, $errors, 'Should have 2 errors after merge'); + $this->assertContains('Error from result1', $errors, 'Should contain error from result1'); + $this->assertContains('Error from result2', $errors, 'Should contain error from result2'); + + // Check warnings + $warnings = $result1->getWarnings(); + $this->assertCount(2, $warnings, 'Should have 2 warnings after merge'); + $this->assertContains('Warning from result1', $warnings, 'Should contain warning from result1'); + $this->assertContains('Warning from result2', $warnings, 'Should contain warning from result2'); + + // Check success count + $this->assertSame(3, $result1->getSuccessCount(), 'Should have combined success count'); + } + + /** + * Test merging with overlapping required strings. + */ + public function testMergeWithOverlappingRequiredStringsOverwritesDuplicates(): void + { + $result1 = new ValidationResult(); + $context1 = new StringContext('file1.php', 10); + $result1->addRequiredString('duplicate_key', $context1); + + $result2 = new ValidationResult(); + $context2 = new StringContext('file2.php', 20); + $result2->addRequiredString('duplicate_key', $context2); + + $result1->merge($result2); + + $requiredStrings = $result1->getRequiredStrings(); + $this->assertCount(1, $requiredStrings, 'Should have only 1 required string'); + $this->assertSame($context2, $requiredStrings['duplicate_key'], 'Should use context from result2'); + } + + /** + * Test merging empty results. + */ + public function testMergeWithEmptyResultDoesNotChangeOriginal(): void + { + $result1 = new ValidationResult(); + $result1->addRawError('Original error'); + $result1->addSuccess('Original success'); + + $result2 = new ValidationResult(); + + $originalErrorCount = $result1->getErrorCount(); + $originalSuccessCount = $result1->getSuccessCount(); + + $result1->merge($result2); + + $this->assertSame($originalErrorCount, $result1->getErrorCount(), 'Error count should not change'); + $this->assertSame($originalSuccessCount, $result1->getSuccessCount(), 'Success count should not change'); + } + + /** + * Test that result maintains immutability of returned arrays. + */ + public function testArrayGettersReturnImmutableArrays(): void + { + $result = new ValidationResult(); + $result->addRawError('Test error'); + $result->addRawWarning('Test warning'); + $result->addError('Formatted error'); // This adds to both errors and messages + + // Get arrays and modify them + $errors = $result->getErrors(); + $warnings = $result->getWarnings(); + $messages = $result->getMessages(); + + $errors[] = 'Modified error'; + $warnings[] = 'Modified warning'; + $messages[] = 'Modified message'; + + // Original result should be unchanged + $this->assertCount(2, $result->getErrors(), 'Errors should not be modified (1 raw + 1 formatted)'); + $this->assertCount(1, $result->getWarnings(), 'Warnings should not be modified'); + $this->assertCount(1, $result->getMessages(), 'Messages should not be modified (1 formatted error)'); + } + + /** + * Test large-scale operations for performance. + */ + public function testLargeScaleOperationsPerformReasonably(): void + { + $result = new ValidationResult(); + + $startTime = microtime(true); + + // Add many items + for ($i = 0; $i < 1000; ++$i) { + $result->addRequiredString("string_{$i}", new StringContext("file_{$i}.php", $i)); + $result->addRawError("Error {$i}"); + $result->addRawWarning("Warning {$i}"); + $result->addSuccess("Success {$i}"); + } + + // Check counts + $this->assertSame(1000, count($result->getRequiredStrings())); + $this->assertSame(1000, $result->getErrorCount()); + $this->assertSame(1000, $result->getWarningCount()); + $this->assertSame(1000, $result->getSuccessCount()); + + $endTime = microtime(true); + $duration = $endTime - $startTime; + + // Should complete in reasonable time (less than 1 second) + $this->assertLessThan(1.0, $duration, 'Large-scale operations should be reasonably fast'); + } + + /** + * Test edge cases with empty strings and null values. + */ + public function testEdgeCasesHandlesGracefully(): void + { + $result = new ValidationResult(); + + // Test empty strings + $result->addRawError(''); + $result->addRawWarning(''); + $result->addError(''); // This will add to both errors and messages + $result->addSuccess(''); + + $this->assertSame(2, $result->getErrorCount(), 'Should count both raw and formatted empty errors'); + $this->assertSame(1, $result->getWarningCount(), 'Should count empty warning'); + $this->assertCount(1, $result->getMessages(), 'Should store formatted empty error message'); + $this->assertSame(1, $result->getSuccessCount(), 'Should count empty success'); + + // Test with StringContext having null values + $context = new StringContext(null, null, null); + $result->addRequiredString('test_key', $context); + + $requiredStrings = $result->getRequiredStrings(); + $this->assertArrayHasKey('test_key', $requiredStrings, 'Should handle StringContext with null values'); + } +} diff --git a/tests/MissingStrings/Discovery/SubpluginDiscoveryTest.php b/tests/MissingStrings/Discovery/SubpluginDiscoveryTest.php new file mode 100644 index 00000000..5808d429 --- /dev/null +++ b/tests/MissingStrings/Discovery/SubpluginDiscoveryTest.php @@ -0,0 +1,571 @@ +discovery = new SubpluginDiscovery(); + + // Create mock Moodle root structure + $moodleRoot = $this->createTempDir('moodle_'); + + // Create Moodle config files + file_put_contents($moodleRoot . '/config.php', "createDirectorySafe($moodleRoot . '/local'); + $this->testPluginPath = $moodleRoot . '/local/testplugin'; + $this->createDirectorySafe($this->testPluginPath); + $this->createSimpleTestPlugin(); + + // Create plugin instance + $this->testPlugin = new Plugin('local_testplugin', 'local', 'testplugin', $this->testPluginPath); + } + + protected function tearDown(): void + { + parent::tearDown(); + // Temp directories are cleaned up automatically by base class + } + + /** + * Test discovering subplugins with no subplugin definitions. + */ + public function testDiscoverSubpluginsWithNoDefinitions(): void + { + $subplugins = $this->discovery->discoverSubplugins($this->testPlugin); + + $this->assertIsArray($subplugins); + $this->assertEmpty($subplugins); + } + + /** + * Test discovering subplugins from JSON definition. + */ + public function testDiscoverSubpluginsFromJson(): void + { + // Create subplugins.json + $subpluginsData = [ + 'plugintypes' => [ + 'testsubtype' => 'mod/testmod/subplugins', + ], + ]; + + $this->createSubpluginsJson($subpluginsData); + + // Create required directory structure + $moodleRoot = $this->getMoodleRoot(); + $this->createDirectorySafe($moodleRoot . '/mod'); + $this->createDirectorySafe($moodleRoot . '/mod/testmod'); + $this->createDirectorySafe($moodleRoot . '/mod/testmod/subplugins'); + + // Create subplugin structure + $this->createSubplugin('testsubtype', 'subplugin1', 'mod/testmod/subplugins'); + + $subplugins = $this->discovery->discoverSubplugins($this->testPlugin); + + $this->assertCount(1, $subplugins); + $this->assertInstanceOf(Plugin::class, $subplugins[0]); + $this->assertSame('testsubtype_subplugin1', $subplugins[0]->component); + $this->assertSame('testsubtype', $subplugins[0]->type); + $this->assertSame('subplugin1', $subplugins[0]->name); + } + + /** + * Test discovering subplugins from JSON with legacy key. + */ + public function testDiscoverSubpluginsFromJsonWithLegacyKey(): void + { + // Create subplugins.json with legacy 'subplugintypes' key + $subpluginsData = [ + 'subplugintypes' => [ + 'legacytype' => 'mod/testmod/legacy', + ], + ]; + + $this->createSubpluginsJson($subpluginsData); + + // Create required directory structure + $moodleRoot = $this->getMoodleRoot(); + $this->createDirectorySafe($moodleRoot . '/mod'); + $this->createDirectorySafe($moodleRoot . '/mod/testmod'); + $this->createDirectorySafe($moodleRoot . '/mod/testmod/legacy'); + + // Create subplugin structure + $this->createSubplugin('legacytype', 'legacy1', 'mod/testmod/legacy'); + + $subplugins = $this->discovery->discoverSubplugins($this->testPlugin); + + $this->assertCount(1, $subplugins); + $this->assertSame('legacytype_legacy1', $subplugins[0]->component); + } + + /** + * Test discovering subplugins from PHP definition. + */ + public function testDiscoverSubpluginsFromPhp(): void + { + // Create subplugins.php + $subpluginsPhp = " 'mod/testmod/phpsubplugins',\n];\n"; + $this->createSubpluginsPhp($subpluginsPhp); + + // Create required directory structure + $moodleRoot = $this->getMoodleRoot(); + $this->createDirectorySafe($moodleRoot . '/mod'); + $this->createDirectorySafe($moodleRoot . '/mod/testmod'); + $this->createDirectorySafe($moodleRoot . '/mod/testmod/phpsubplugins'); + + // Create subplugin structure + $this->createSubplugin('phptype', 'phpsubplugin1', 'mod/testmod/phpsubplugins'); + + $subplugins = $this->discovery->discoverSubplugins($this->testPlugin); + + $this->assertCount(1, $subplugins); + $this->assertSame('phptype_phpsubplugin1', $subplugins[0]->component); + } + + /** + * Test discovering multiple subplugins of different types. + */ + public function testDiscoverMultipleSubpluginTypes(): void + { + // Create multiple subplugin types + $subpluginsData = [ + 'plugintypes' => [ + 'type1' => 'mod/testmod/type1', + 'type2' => 'mod/testmod/type2', + ], + ]; + + $this->createSubpluginsJson($subpluginsData); + + // Create required directory structure + $moodleRoot = $this->getMoodleRoot(); + $this->createDirectorySafe($moodleRoot . '/mod'); + $this->createDirectorySafe($moodleRoot . '/mod/testmod'); + $this->createDirectorySafe($moodleRoot . '/mod/testmod/type1'); + $this->createDirectorySafe($moodleRoot . '/mod/testmod/type2'); + + // Create subplugins of different types + $this->createSubplugin('type1', 'subplugin1', 'mod/testmod/type1'); + $this->createSubplugin('type1', 'subplugin2', 'mod/testmod/type1'); + $this->createSubplugin('type2', 'subplugin3', 'mod/testmod/type2'); + + $subplugins = $this->discovery->discoverSubplugins($this->testPlugin); + + $this->assertCount(3, $subplugins); + + // Check components + $components = array_map(function ($plugin) { + return $plugin->component; + }, $subplugins); + + $this->assertContains('type1_subplugin1', $components); + $this->assertContains('type1_subplugin2', $components); + $this->assertContains('type2_subplugin3', $components); + } + + /** + * Test discovering subplugins with invalid JSON. + */ + public function testDiscoverSubpluginsWithInvalidJson(): void + { + // Create invalid JSON file + $this->createDirectorySafe($this->testPluginPath . '/db'); + file_put_contents($this->testPluginPath . '/db/subplugins.json', '{ invalid json }'); + + $subplugins = $this->discovery->discoverSubplugins($this->testPlugin); + + $this->assertIsArray($subplugins); + $this->assertEmpty($subplugins); + } + + /** + * Test discovering subplugins with corrupted PHP file. + */ + public function testDiscoverSubpluginsWithCorruptedPhp(): void + { + // Create corrupted PHP file + $this->createDirectorySafe($this->testPluginPath . '/db'); + file_put_contents($this->testPluginPath . '/db/subplugins.php', 'discovery->discoverSubplugins($this->testPlugin); + + $this->assertIsArray($subplugins); + $this->assertEmpty($subplugins); + } + + /** + * Test discovering subplugins with non-existent base path. + */ + public function testDiscoverSubpluginsWithNonExistentBasePath(): void + { + $subpluginsData = [ + 'plugintypes' => [ + 'nonexistent' => 'mod/nonexistent/path', + ], + ]; + + $this->createSubpluginsJson($subpluginsData); + + $subplugins = $this->discovery->discoverSubplugins($this->testPlugin); + + $this->assertIsArray($subplugins); + $this->assertEmpty($subplugins); + } + + /** + * Test discovering subplugins with invalid plugin structure. + */ + public function testDiscoverSubpluginsWithInvalidStructure(): void + { + $subpluginsData = [ + 'plugintypes' => [ + 'testtype' => 'mod/testmod/invalid', + ], + ]; + + $this->createSubpluginsJson($subpluginsData); + + // Create directory but no valid plugin files + $basePath = $this->getMoodleRoot() . '/mod/testmod/invalid'; + $this->createDirectorySafe($basePath . '/invalidplugin'); + + $subplugins = $this->discovery->discoverSubplugins($this->testPlugin); + + $this->assertIsArray($subplugins); + $this->assertEmpty($subplugins); + } + + /** + * Test discovering subplugins that have only lang files. + */ + public function testDiscoverSubpluginsWithOnlyLangFiles(): void + { + $subpluginsData = [ + 'plugintypes' => [ + 'langonly' => 'mod/testmod/langonly', + ], + ]; + + $this->createSubpluginsJson($subpluginsData); + + // Create required directory structure + $moodleRoot = $this->getMoodleRoot(); + $this->createDirectorySafe($moodleRoot . '/mod'); + $this->createDirectorySafe($moodleRoot . '/mod/testmod'); + $this->createDirectorySafe($moodleRoot . '/mod/testmod/langonly'); + + // Create subplugin with only language files + $basePath = $this->getMoodleRoot() . '/mod/testmod/langonly'; + $subpluginPath = $basePath . '/langplugin'; + $this->createDirectorySafe($subpluginPath . '/lang/en'); + file_put_contents($subpluginPath . '/lang/en/langonly_langplugin.php', "discovery->discoverSubplugins($this->testPlugin); + + $this->assertCount(1, $subplugins); + $this->assertSame('langonly_langplugin', $subplugins[0]->component); + } + + /** + * Test discovering subplugins with lib.php only. + */ + public function testDiscoverSubpluginsWithLibOnly(): void + { + $subpluginsData = [ + 'plugintypes' => [ + 'libonly' => 'mod/testmod/libonly', + ], + ]; + + $this->createSubpluginsJson($subpluginsData); + + // Create required directory structure + $moodleRoot = $this->getMoodleRoot(); + $this->createDirectorySafe($moodleRoot . '/mod'); + $this->createDirectorySafe($moodleRoot . '/mod/testmod'); + $this->createDirectorySafe($moodleRoot . '/mod/testmod/libonly'); + + // Create subplugin with only lib.php + $basePath = $this->getMoodleRoot() . '/mod/testmod/libonly'; + $subpluginPath = $basePath . '/libplugin'; + $this->createDirectorySafe($subpluginPath); + file_put_contents($subpluginPath . '/lib.php', "discovery->discoverSubplugins($this->testPlugin); + + $this->assertCount(1, $subplugins); + $this->assertSame('libonly_libplugin', $subplugins[0]->component); + } + + /** + * Test discovering subplugins skips hidden and common directories. + */ + public function testDiscoverSubpluginsSkipsHiddenAndCommonDirectories(): void + { + $subpluginsData = [ + 'plugintypes' => [ + 'skiptest' => 'mod/testmod/skiptest', + ], + ]; + + $this->createSubpluginsJson($subpluginsData); + + // Create required directory structure + $moodleRoot = $this->getMoodleRoot(); + $this->createDirectorySafe($moodleRoot . '/mod'); + $this->createDirectorySafe($moodleRoot . '/mod/testmod'); + $this->createDirectorySafe($moodleRoot . '/mod/testmod/skiptest'); + + $basePath = $this->getMoodleRoot() . '/mod/testmod/skiptest'; + + // Create directories that should be skipped + $this->createDirectorySafe($basePath . '/.hidden'); + $this->createDirectorySafe($basePath . '/tests'); + $this->createDirectorySafe($basePath . '/backup'); + $this->createDirectorySafe($basePath . '/tmp'); + + // Create valid subplugin + $this->createSubplugin('skiptest', 'validplugin', 'mod/testmod/skiptest'); + + $subplugins = $this->discovery->discoverSubplugins($this->testPlugin); + + $this->assertCount(1, $subplugins); + $this->assertSame('skiptest_validplugin', $subplugins[0]->component); + } + + /** + * Test getSubpluginPaths method directly. + */ + public function testGetSubpluginPaths(): void + { + $subpluginsData = [ + 'plugintypes' => [ + 'jsontype' => 'mod/testmod/json', + ], + ]; + + $this->createSubpluginsJson($subpluginsData); + + $paths = $this->discovery->getSubpluginPaths($this->testPlugin); + + $this->assertIsArray($paths); + $this->assertArrayHasKey('jsontype', $paths); + $this->assertSame('mod/testmod/json', $paths['jsontype']); + } + + /** + * Test getSubpluginPaths with both JSON and PHP definitions. + */ + public function testGetSubpluginPathsWithBothFormats(): void + { + // JSON takes precedence + $subpluginsJson = [ + 'plugintypes' => [ + 'jsontype' => 'mod/testmod/json', + ], + ]; + + $this->createSubpluginsJson($subpluginsJson); + + $subpluginsPhp = " 'mod/testmod/php',\n];\n"; + $this->createSubpluginsPhp($subpluginsPhp); + + $paths = $this->discovery->getSubpluginPaths($this->testPlugin); + + $this->assertCount(2, $paths); + $this->assertArrayHasKey('jsontype', $paths); + $this->assertArrayHasKey('phptype', $paths); + $this->assertSame('mod/testmod/json', $paths['jsontype']); + $this->assertSame('mod/testmod/php', $paths['phptype']); + } + + /** + * Test with unreadable subplugin directory. + */ + public function testDiscoverSubpluginsWithUnreadableDirectory(): void + { + $subpluginsData = [ + 'plugintypes' => [ + 'unreadable' => 'mod/testmod/unreadable', + ], + ]; + + $this->createSubpluginsJson($subpluginsData); + + $basePath = $this->getMoodleRoot() . '/mod/testmod/unreadable'; + $this->createDirectorySafe($basePath); + + // Make directory unreadable + chmod($basePath, 0000); + + $subplugins = $this->discovery->discoverSubplugins($this->testPlugin); + + // Restore permissions for cleanup + chmod($basePath, 0755); + + $this->assertIsArray($subplugins); + $this->assertEmpty($subplugins); + } + + /** + * Test Moodle root detection for various plugin types. + */ + public function testMoodleRootDetectionForDifferentPluginTypes(): void + { + // Test with different plugin types + $pluginTypes = [ + 'mod' => 'mod/testmod', + 'local' => 'local/testlocal', + 'block' => 'blocks/testblock', + 'theme' => 'theme/testtheme', + ]; + + foreach ($pluginTypes as $type => $path) { + $pluginPath = $this->getMoodleRoot() . '/' . $path; + $this->createDirectorySafe($pluginPath); + + $versionContent = "component = '{$type}_test';\n\$plugin->version = 2023010100;\n"; + file_put_contents($pluginPath . '/version.php', $versionContent); + + $plugin = new Plugin($type . '_test', $type, 'test', $pluginPath); + + $subpluginsData = [ + 'plugintypes' => [ + 'testtype' => $path . '/subplugins', + ], + ]; + + // Create db directory and subplugins.json + $this->createDirectorySafe($pluginPath . '/db'); + file_put_contents($pluginPath . '/db/subplugins.json', json_encode($subpluginsData)); + + // Create subplugin + $this->createSubplugin('testtype', 'testsub', $path . '/subplugins'); + + $subplugins = $this->discovery->discoverSubplugins($plugin); + + $this->assertNotEmpty($subplugins, "Failed to discover subplugins for {$type}"); + } + } + + /** + * Create a test plugin directory structure. + */ + private function createSimpleTestPlugin(): void + { + // Directory is already created by createTempDir in setUp + + // Create version.php + $versionContent = "component = 'local_testplugin';\n\$plugin->version = 2023010100;\n"; + file_put_contents($this->testPluginPath . '/version.php', $versionContent); + } + + /** + * Safely create a directory if it doesn't exist. + */ + private function createDirectorySafe(string $path): void + { + if (!is_dir($path)) { + mkdir($path, 0755, true); + } + } + + /** + * Create subplugins.json file. + * + * @param array $data The subplugin data + */ + private function createSubpluginsJson(array $data): void + { + $this->createDirectorySafe($this->testPluginPath . '/db'); + file_put_contents( + $this->testPluginPath . '/db/subplugins.json', + json_encode($data, JSON_PRETTY_PRINT) + ); + } + + /** + * Create subplugins.php file. + * + * @param string $content The PHP content + */ + private function createSubpluginsPhp(string $content): void + { + $this->createDirectorySafe($this->testPluginPath . '/db'); + file_put_contents($this->testPluginPath . '/db/subplugins.php', $content); + } + + /** + * Create a subplugin with proper structure. + * + * @param string $type The subplugin type + * @param string $name The subplugin name + * @param string $basePath The base path relative to Moodle root + */ + private function createSubplugin(string $type, string $name, string $basePath): void + { + $moodleRoot = $this->getMoodleRoot(); + $subpluginPath = $moodleRoot . '/' . $basePath . '/' . $name; + + $this->createDirectorySafe($subpluginPath); + + // Create version.php + $component = $type . '_' . $name; + $versionContent = "component = '{$component}';\n\$plugin->version = 2023010100;\n"; + file_put_contents($subpluginPath . '/version.php', $versionContent); + + // Create language file + $this->createDirectorySafe($subpluginPath . '/lang/en'); + $langContent = "testPluginPath, 2); + } +} diff --git a/tests/MissingStrings/Exception/CheckerExceptionTest.php b/tests/MissingStrings/Exception/CheckerExceptionTest.php new file mode 100644 index 00000000..527ec948 --- /dev/null +++ b/tests/MissingStrings/Exception/CheckerExceptionTest.php @@ -0,0 +1,207 @@ + 'local_test']; + $severity = 'warning'; + $previous = new \RuntimeException('Previous error'); + + $exception = new CheckerException($checkerName, $message, $context, $severity, $previous); + + $this->assertInstanceOf(StringValidationException::class, $exception); + $this->assertSame($message, $exception->getMessage()); + $this->assertSame($checkerName, $exception->getCheckerName()); + $this->assertSame($severity, $exception->getSeverity()); + $this->assertSame($previous, $exception->getPrevious()); + + $expectedContext = array_merge($context, ['checker' => $checkerName]); + $this->assertSame($expectedContext, $exception->getContext()); + } + + /** + * Test constructor with minimal parameters. + */ + public function testConstructorWithMinimalParameters(): void + { + $checkerName = 'TestChecker'; + + $exception = new CheckerException($checkerName); + + $this->assertSame('', $exception->getMessage()); + $this->assertSame($checkerName, $exception->getCheckerName()); + $this->assertSame(['checker' => $checkerName], $exception->getContext()); + $this->assertSame('error', $exception->getSeverity()); + $this->assertNull($exception->getPrevious()); + } + + /** + * Test constructor with default values. + */ + public function testConstructorWithDefaultValues(): void + { + $checkerName = 'TestChecker'; + $message = 'Test message'; + $context = ['key' => 'value']; + + $exception = new CheckerException($checkerName, $message, $context); + + $this->assertSame($message, $exception->getMessage()); + $this->assertSame($checkerName, $exception->getCheckerName()); + $this->assertSame('error', $exception->getSeverity()); + $this->assertNull($exception->getPrevious()); + + $expectedContext = array_merge($context, ['checker' => $checkerName]); + $this->assertSame($expectedContext, $exception->getContext()); + } + + /** + * Test getCheckerName method. + */ + public function testGetCheckerName(): void + { + $checkerName = 'CapabilitiesChecker'; + $exception = new CheckerException($checkerName, 'Test message'); + + $this->assertSame($checkerName, $exception->getCheckerName()); + } + + /** + * Test checkerError static method. + */ + public function testCheckerErrorStaticMethod(): void + { + $checkerName = 'TestChecker'; + $message = 'Error message'; + $context = ['component' => 'local_test']; + $previous = new \RuntimeException('Previous error'); + + $exception = CheckerException::checkerError($checkerName, $message, $context, $previous); + + $this->assertInstanceOf(CheckerException::class, $exception); + $this->assertSame($message, $exception->getMessage()); + $this->assertSame($checkerName, $exception->getCheckerName()); + $this->assertSame('error', $exception->getSeverity()); + $this->assertSame($previous, $exception->getPrevious()); + + $expectedContext = array_merge($context, ['checker' => $checkerName]); + $this->assertSame($expectedContext, $exception->getContext()); + $this->assertTrue($exception->isError()); + } + + /** + * Test checkerError static method with minimal parameters. + */ + public function testCheckerErrorStaticMethodWithMinimalParameters(): void + { + $checkerName = 'TestChecker'; + $message = 'Error message'; + + $exception = CheckerException::checkerError($checkerName, $message); + + $this->assertSame($message, $exception->getMessage()); + $this->assertSame($checkerName, $exception->getCheckerName()); + $this->assertSame(['checker' => $checkerName], $exception->getContext()); + $this->assertSame('error', $exception->getSeverity()); + $this->assertNull($exception->getPrevious()); + } + + /** + * Test checkerWarning static method. + */ + public function testCheckerWarningStaticMethod(): void + { + $checkerName = 'TestChecker'; + $message = 'Warning message'; + $context = ['component' => 'local_test']; + $previous = new \RuntimeException('Previous error'); + + $exception = CheckerException::checkerWarning($checkerName, $message, $context, $previous); + + $this->assertInstanceOf(CheckerException::class, $exception); + $this->assertSame($message, $exception->getMessage()); + $this->assertSame($checkerName, $exception->getCheckerName()); + $this->assertSame('warning', $exception->getSeverity()); + $this->assertSame($previous, $exception->getPrevious()); + + $expectedContext = array_merge($context, ['checker' => $checkerName]); + $this->assertSame($expectedContext, $exception->getContext()); + $this->assertTrue($exception->isWarning()); + } + + /** + * Test checkerWarning static method with minimal parameters. + */ + public function testCheckerWarningStaticMethodWithMinimalParameters(): void + { + $checkerName = 'TestChecker'; + $message = 'Warning message'; + + $exception = CheckerException::checkerWarning($checkerName, $message); + + $this->assertSame($message, $exception->getMessage()); + $this->assertSame($checkerName, $exception->getCheckerName()); + $this->assertSame(['checker' => $checkerName], $exception->getContext()); + $this->assertSame('warning', $exception->getSeverity()); + $this->assertNull($exception->getPrevious()); + } + + /** + * Test that checker name is added to context automatically. + */ + public function testCheckerNameAddedToContext(): void + { + $checkerName = 'ExampleChecker'; + $context = ['existing' => 'value', 'another' => 'item']; + + $exception = new CheckerException($checkerName, 'Test', $context); + + $resultContext = $exception->getContext(); + $this->assertArrayHasKey('checker', $resultContext); + $this->assertSame($checkerName, $resultContext['checker']); + $this->assertSame('value', $resultContext['existing']); + $this->assertSame('item', $resultContext['another']); + } + + /** + * Test that checker name overwrites existing 'checker' key in context. + */ + public function testCheckerNameOverwritesContextChecker(): void + { + $checkerName = 'NewChecker'; + $context = ['checker' => 'OldChecker', 'other' => 'value']; + + $exception = new CheckerException($checkerName, 'Test', $context); + + $resultContext = $exception->getContext(); + $this->assertSame($checkerName, $resultContext['checker']); + $this->assertSame('value', $resultContext['other']); + } +} diff --git a/tests/MissingStrings/Exception/FileExceptionTest.php b/tests/MissingStrings/Exception/FileExceptionTest.php new file mode 100644 index 00000000..67fad320 --- /dev/null +++ b/tests/MissingStrings/Exception/FileExceptionTest.php @@ -0,0 +1,198 @@ + 'local_test']; + + $exception = new FileException($filePath, $message, $context); + + $this->assertInstanceOf(StringValidationException::class, $exception); + $this->assertSame($message, $exception->getMessage()); + $this->assertSame($filePath, $exception->getFilePath()); + + $expectedContext = array_merge($context, ['file' => $filePath]); + $this->assertSame($expectedContext, $exception->getContext()); + $this->assertSame('error', $exception->getSeverity()); + } + + /** + * Test constructor with all parameters. + */ + public function testConstructorWithAllParameters(): void + { + $filePath = '/path/to/file.php'; + $message = 'Test error message'; + $context = ['component' => 'local_test']; + $severity = 'warning'; + $previous = new \RuntimeException('Previous error'); + + $exception = new FileException($filePath, $message, $context, $severity, $previous); + + $this->assertSame($message, $exception->getMessage()); + $this->assertSame($filePath, $exception->getFilePath()); + $this->assertSame($severity, $exception->getSeverity()); + $this->assertSame($previous, $exception->getPrevious()); + + $expectedContext = array_merge($context, ['file' => $filePath]); + $this->assertSame($expectedContext, $exception->getContext()); + } + + /** + * Test constructor with minimal parameters. + */ + public function testConstructorWithMinimalParameters(): void + { + $filePath = '/path/to/file.php'; + + $exception = new FileException($filePath); + + $this->assertSame('', $exception->getMessage()); + $this->assertSame($filePath, $exception->getFilePath()); + $this->assertSame(['file' => $filePath], $exception->getContext()); + $this->assertSame('error', $exception->getSeverity()); + $this->assertNull($exception->getPrevious()); + } + + /** + * Test fileNotFound static method. + */ + public function testFileNotFound(): void + { + $filePath = '/path/to/missing.php'; + $context = ['component' => 'local_test']; + + $exception = FileException::fileNotFound($filePath, $context); + + $this->assertInstanceOf(FileException::class, $exception); + $this->assertSame("File not found: {$filePath}", $exception->getMessage()); + $this->assertSame($filePath, $exception->getFilePath()); + $this->assertSame('error', $exception->getSeverity()); + + $expectedContext = array_merge($context, ['file' => $filePath]); + $this->assertSame($expectedContext, $exception->getContext()); + } + + /** + * Test fileNotFound static method without context. + */ + public function testFileNotFoundWithoutContext(): void + { + $filePath = '/path/to/missing.php'; + + $exception = FileException::fileNotFound($filePath); + + $this->assertSame("File not found: {$filePath}", $exception->getMessage()); + $this->assertSame($filePath, $exception->getFilePath()); + $this->assertSame(['file' => $filePath], $exception->getContext()); + } + + /** + * Test fileNotReadable static method. + */ + public function testFileNotReadable(): void + { + $filePath = '/path/to/unreadable.php'; + $context = ['component' => 'local_test', 'permissions' => '000']; + + $exception = FileException::fileNotReadable($filePath, $context); + + $this->assertInstanceOf(FileException::class, $exception); + $this->assertSame("File not readable: {$filePath}", $exception->getMessage()); + $this->assertSame($filePath, $exception->getFilePath()); + $this->assertSame('error', $exception->getSeverity()); + + $expectedContext = array_merge($context, ['file' => $filePath]); + $this->assertSame($expectedContext, $exception->getContext()); + } + + /** + * Test fileNotReadable static method without context. + */ + public function testFileNotReadableWithoutContext(): void + { + $filePath = '/path/to/unreadable.php'; + + $exception = FileException::fileNotReadable($filePath); + + $this->assertSame("File not readable: {$filePath}", $exception->getMessage()); + $this->assertSame($filePath, $exception->getFilePath()); + $this->assertSame(['file' => $filePath], $exception->getContext()); + } + + /** + * Test parsingError static method. + */ + public function testParsingError(): void + { + $filePath = '/path/to/invalid.php'; + $reason = 'Syntax error on line 10'; + $context = ['component' => 'local_test', 'line' => 10]; + $previous = new \ParseError('Parse error'); + + $exception = FileException::parsingError($filePath, $reason, $context, $previous); + + $this->assertInstanceOf(FileException::class, $exception); + $this->assertSame("Failed to parse file {$filePath}: {$reason}", $exception->getMessage()); + $this->assertSame($filePath, $exception->getFilePath()); + $this->assertSame('error', $exception->getSeverity()); + $this->assertSame($previous, $exception->getPrevious()); + + $expectedContext = array_merge($context, ['file' => $filePath]); + $this->assertSame($expectedContext, $exception->getContext()); + } + + /** + * Test parsingError static method with minimal parameters. + */ + public function testParsingErrorWithMinimalParameters(): void + { + $filePath = '/path/to/invalid.php'; + $reason = 'Syntax error'; + + $exception = FileException::parsingError($filePath, $reason); + + $this->assertSame("Failed to parse file {$filePath}: {$reason}", $exception->getMessage()); + $this->assertSame($filePath, $exception->getFilePath()); + $this->assertSame(['file' => $filePath], $exception->getContext()); + $this->assertNull($exception->getPrevious()); + } + + /** + * Test getFilePath method. + */ + public function testGetFilePath(): void + { + $filePath = '/some/path/to/file.php'; + $exception = new FileException($filePath, 'Test message'); + + $this->assertSame($filePath, $exception->getFilePath()); + } +} diff --git a/tests/MissingStrings/Exception/StringValidationExceptionTest.php b/tests/MissingStrings/Exception/StringValidationExceptionTest.php new file mode 100644 index 00000000..d048d477 --- /dev/null +++ b/tests/MissingStrings/Exception/StringValidationExceptionTest.php @@ -0,0 +1,300 @@ + 'local_test', 'string' => 'test_string']; + $severity = 'warning'; + $code = 123; + $previous = new \RuntimeException('Previous error'); + + $exception = new StringValidationException($message, $context, $severity, $code, $previous); + + $this->assertInstanceOf(\Exception::class, $exception); + $this->assertSame($message, $exception->getMessage()); + $this->assertSame($context, $exception->getContext()); + $this->assertSame($severity, $exception->getSeverity()); + $this->assertSame($code, $exception->getCode()); + $this->assertSame($previous, $exception->getPrevious()); + } + + /** + * Test constructor with minimal parameters. + */ + public function testConstructorWithMinimalParameters(): void + { + $exception = new StringValidationException(); + + $this->assertSame('', $exception->getMessage()); + $this->assertSame([], $exception->getContext()); + $this->assertSame('error', $exception->getSeverity()); + $this->assertSame(0, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } + + /** + * Test constructor with default values. + */ + public function testConstructorWithDefaultValues(): void + { + $message = 'Test message'; + $context = ['key' => 'value']; + + $exception = new StringValidationException($message, $context); + + $this->assertSame($message, $exception->getMessage()); + $this->assertSame($context, $exception->getContext()); + $this->assertSame('error', $exception->getSeverity()); + $this->assertSame(0, $exception->getCode()); + $this->assertNull($exception->getPrevious()); + } + + /** + * Test getContext method. + */ + public function testGetContext(): void + { + $context = [ + 'component' => 'local_test', + 'string' => 'test_string', + 'file' => '/path/to/file.php', + 'line' => 42, + ]; + + $exception = new StringValidationException('Test message', $context); + + $this->assertSame($context, $exception->getContext()); + } + + /** + * Test getSeverity method. + */ + public function testGetSeverity(): void + { + $exception1 = new StringValidationException('Test', [], 'error'); + $exception2 = new StringValidationException('Test', [], 'warning'); + $exception3 = new StringValidationException('Test', [], 'info'); + + $this->assertSame('error', $exception1->getSeverity()); + $this->assertSame('warning', $exception2->getSeverity()); + $this->assertSame('info', $exception3->getSeverity()); + } + + /** + * Test isWarning method. + */ + public function testIsWarning(): void + { + $errorException = new StringValidationException('Test', [], 'error'); + $warningException = new StringValidationException('Test', [], 'warning'); + $infoException = new StringValidationException('Test', [], 'info'); + + $this->assertFalse($errorException->isWarning()); + $this->assertTrue($warningException->isWarning()); + $this->assertFalse($infoException->isWarning()); + } + + /** + * Test isError method. + */ + public function testIsError(): void + { + $errorException = new StringValidationException('Test', [], 'error'); + $warningException = new StringValidationException('Test', [], 'warning'); + $infoException = new StringValidationException('Test', [], 'info'); + + $this->assertTrue($errorException->isError()); + $this->assertFalse($warningException->isError()); + $this->assertFalse($infoException->isError()); + } + + /** + * Test getFormattedMessage method. + */ + public function testGetFormattedMessage(): void + { + $message = 'Test error message'; + $context = [ + 'component' => 'local_test', + 'string' => 'test_string', + 'line' => 42, + ]; + + $exception = new StringValidationException($message, $context); + + $formattedMessage = $exception->getFormattedMessage(); + + $this->assertStringContainsString($message, $formattedMessage); + $this->assertStringContainsString('component: local_test', $formattedMessage); + $this->assertStringContainsString('string: test_string', $formattedMessage); + $this->assertStringContainsString('line: 42', $formattedMessage); + } + + /** + * Test getFormattedMessage with empty context. + */ + public function testGetFormattedMessageWithEmptyContext(): void + { + $message = 'Test error message'; + $exception = new StringValidationException($message); + + $formattedMessage = $exception->getFormattedMessage(); + + $this->assertSame($message, $formattedMessage); + } + + /** + * Test getFormattedMessage with non-scalar context values. + */ + public function testGetFormattedMessageWithNonScalarContext(): void + { + $message = 'Test error message'; + $context = [ + 'component' => 'local_test', + 'array_value' => ['item1', 'item2'], + 'object_value' => new \stdClass(), + 'string_value' => 'test_string', + ]; + + $exception = new StringValidationException($message, $context); + + $formattedMessage = $exception->getFormattedMessage(); + + $this->assertStringContainsString($message, $formattedMessage); + $this->assertStringContainsString('component: local_test', $formattedMessage); + $this->assertStringContainsString('string_value: test_string', $formattedMessage); + $this->assertStringNotContainsString('array_value', $formattedMessage); + $this->assertStringNotContainsString('object_value', $formattedMessage); + } + + /** + * Test error static method. + */ + public function testErrorStaticMethod(): void + { + $message = 'Error message'; + $context = ['component' => 'local_test']; + $previous = new \RuntimeException('Previous error'); + + $exception = StringValidationException::error($message, $context, $previous); + + $this->assertInstanceOf(StringValidationException::class, $exception); + $this->assertSame($message, $exception->getMessage()); + $this->assertSame($context, $exception->getContext()); + $this->assertSame('error', $exception->getSeverity()); + $this->assertSame(0, $exception->getCode()); + $this->assertSame($previous, $exception->getPrevious()); + $this->assertTrue($exception->isError()); + } + + /** + * Test error static method with minimal parameters. + */ + public function testErrorStaticMethodWithMinimalParameters(): void + { + $message = 'Error message'; + + $exception = StringValidationException::error($message); + + $this->assertSame($message, $exception->getMessage()); + $this->assertSame([], $exception->getContext()); + $this->assertSame('error', $exception->getSeverity()); + $this->assertNull($exception->getPrevious()); + } + + /** + * Test warning static method. + */ + public function testWarningStaticMethod(): void + { + $message = 'Warning message'; + $context = ['component' => 'local_test']; + $previous = new \RuntimeException('Previous error'); + + $exception = StringValidationException::warning($message, $context, $previous); + + $this->assertInstanceOf(StringValidationException::class, $exception); + $this->assertSame($message, $exception->getMessage()); + $this->assertSame($context, $exception->getContext()); + $this->assertSame('warning', $exception->getSeverity()); + $this->assertSame(0, $exception->getCode()); + $this->assertSame($previous, $exception->getPrevious()); + $this->assertTrue($exception->isWarning()); + } + + /** + * Test warning static method with minimal parameters. + */ + public function testWarningStaticMethodWithMinimalParameters(): void + { + $message = 'Warning message'; + + $exception = StringValidationException::warning($message); + + $this->assertSame($message, $exception->getMessage()); + $this->assertSame([], $exception->getContext()); + $this->assertSame('warning', $exception->getSeverity()); + $this->assertNull($exception->getPrevious()); + } + + /** + * Test info static method. + */ + public function testInfoStaticMethod(): void + { + $message = 'Info message'; + $context = ['component' => 'local_test']; + $previous = new \RuntimeException('Previous error'); + + $exception = StringValidationException::info($message, $context, $previous); + + $this->assertInstanceOf(StringValidationException::class, $exception); + $this->assertSame($message, $exception->getMessage()); + $this->assertSame($context, $exception->getContext()); + $this->assertSame('info', $exception->getSeverity()); + $this->assertSame(0, $exception->getCode()); + $this->assertSame($previous, $exception->getPrevious()); + $this->assertFalse($exception->isWarning()); + $this->assertFalse($exception->isError()); + } + + /** + * Test info static method with minimal parameters. + */ + public function testInfoStaticMethodWithMinimalParameters(): void + { + $message = 'Info message'; + + $exception = StringValidationException::info($message); + + $this->assertSame($message, $exception->getMessage()); + $this->assertSame([], $exception->getContext()); + $this->assertSame('info', $exception->getSeverity()); + $this->assertNull($exception->getPrevious()); + } +} diff --git a/tests/MissingStrings/Extractor/JavaScriptStringExtractorTest.php b/tests/MissingStrings/Extractor/JavaScriptStringExtractorTest.php new file mode 100644 index 00000000..a3f27342 --- /dev/null +++ b/tests/MissingStrings/Extractor/JavaScriptStringExtractorTest.php @@ -0,0 +1,281 @@ +extractor = new JavaScriptStringExtractor(); + } + + /** + * Test extraction of strings from str.get_string() calls. + */ + public function testExtractStrGetString(): void + { + $content = "str.get_string('hello', 'block_test');"; + $component = 'block_test'; + $filePath = 'test.js'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('hello', $result); + $this->assertSame('test.js', $result['hello'][0]['file']); + $this->assertSame(1, $result['hello'][0]['line']); + } + + /** + * Test extraction of strings from str.get_string() calls with third parameter. + */ + public function testExtractStrGetStringWithThirdParameter(): void + { + $content = "str.get_string('greeting', 'block_test', {name: 'John'});"; + $component = 'block_test'; + $filePath = 'test.js'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('greeting', $result); + $this->assertSame('test.js', $result['greeting'][0]['file']); + $this->assertSame(1, $result['greeting'][0]['line']); + } + + /** + * Test extraction of strings from str.get_strings() with object array format. + */ + public function testExtractStrGetStringsObjectArray(): void + { + $content = "str.get_strings([{key: 'hello', component: 'block_test'}, {key: 'goodbye', component: 'block_test'}]);"; + $component = 'block_test'; + $filePath = 'test.js'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('hello', $result); + $this->assertArrayHasKey('goodbye', $result); + $this->assertSame('test.js', $result['hello'][0]['file']); + $this->assertSame('test.js', $result['goodbye'][0]['file']); + } + + /** + * Test extraction of strings from str.get_strings() with separate arrays format. + */ + public function testExtractStrGetStringsSeparateArrays(): void + { + $content = "str.get_strings(['hello', 'goodbye', 'welcome'], 'block_test');"; + $component = 'block_test'; + $filePath = 'test.js'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('hello', $result); + $this->assertArrayHasKey('goodbye', $result); + $this->assertArrayHasKey('welcome', $result); + $this->assertSame('test.js', $result['hello'][0]['file']); + } + + /** + * Test extraction of strings from core/str getString() calls. + */ + public function testExtractGetString(): void + { + $content = "getString('message', 'block_test');"; + $component = 'block_test'; + $filePath = 'test.js'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('message', $result); + $this->assertSame('test.js', $result['message'][0]['file']); + $this->assertSame(1, $result['message'][0]['line']); + } + + /** + * Test extraction of strings from core/str getString() calls with third parameter. + */ + public function testExtractGetStringWithThirdParameter(): void + { + $content = "getString('error', 'block_test', {code: 404});"; + $component = 'block_test'; + $filePath = 'test.js'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('error', $result); + $this->assertSame('test.js', $result['error'][0]['file']); + $this->assertSame(1, $result['error'][0]['line']); + } + + /** + * Test extraction of strings from core/str getStrings() calls. + */ + public function testExtractGetStrings(): void + { + $content = "getStrings([{key: 'title', component: 'block_test'}, {key: 'subtitle', component: 'block_test'}]);"; + $component = 'block_test'; + $filePath = 'test.js'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('title', $result); + $this->assertArrayHasKey('subtitle', $result); + $this->assertSame('test.js', $result['title'][0]['file']); + } + + /** + * Test extraction of strings from Prefetch.prefetchString() calls. + */ + public function testExtractPrefetchString(): void + { + $content = "Prefetch.prefetchString('discussion', 'mod_forum');"; + $component = 'mod_forum'; + $filePath = 'test.js'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('discussion', $result); + $this->assertSame('test.js', $result['discussion'][0]['file']); + $this->assertSame(1, $result['discussion'][0]['line']); + } + + /** + * Test extraction of strings from Prefetch.prefetchStrings() calls. + */ + public function testExtractPrefetchStrings(): void + { + $content = "Prefetch.prefetchStrings('core', ['yes', 'no', 'maybe']);"; + $component = 'core'; + $filePath = 'test.js'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('yes', $result); + $this->assertArrayHasKey('no', $result); + $this->assertArrayHasKey('maybe', $result); + $this->assertSame('test.js', $result['yes'][0]['file']); + } + + /** + * Test that strings with different components are filtered out. + */ + public function testFilterByComponent(): void + { + $content = "str.get_string('test1', 'block_test'); str.get_string('test2', 'core');"; + $component = 'block_test'; + $filePath = 'test.js'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('test1', $result); + $this->assertArrayNotHasKey('test2', $result); + } + + /** + * Test extraction from multiple lines. + */ + public function testExtractMultipleLines(): void + { + $content = "str.get_string('line1', 'block_test');\ngetString('line2', 'block_test');\nPrefetch.prefetchString('line3', 'block_test');"; + $component = 'block_test'; + $filePath = 'test.js'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('line1', $result); + $this->assertArrayHasKey('line2', $result); + $this->assertArrayHasKey('line3', $result); + $this->assertSame(1, $result['line1'][0]['line']); + $this->assertSame(2, $result['line2'][0]['line']); + $this->assertSame(3, $result['line3'][0]['line']); + } + + /** + * Test real-world example with mixed quotes and spacing. + */ + public function testExtractRealWorldExample(): void + { + $content = 'str.get_string( "save_changes" , "block_uteluqchatbot" );'; + $component = 'block_uteluqchatbot'; + $filePath = 'chatbot.js'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('save_changes', $result); + $this->assertSame('chatbot.js', $result['save_changes'][0]['file']); + $this->assertSame(1, $result['save_changes'][0]['line']); + } + + /** + * Test canHandle method for AMD JavaScript files. + */ + public function testCanHandle(): void + { + $this->assertTrue($this->extractor->canHandle('/path/to/amd/src/module.js')); + $this->assertTrue($this->extractor->canHandle('/path/to/amd/build/module.min.js')); + $this->assertFalse($this->extractor->canHandle('/path/to/lib/script.js')); + $this->assertFalse($this->extractor->canHandle('template.mustache')); + $this->assertFalse($this->extractor->canHandle('code.php')); + } + + /** + * Test getName method. + */ + public function testGetName(): void + { + $this->assertSame('JavaScript String Extractor', $this->extractor->getName()); + } + + /** + * Test complex getStrings pattern with mixed spacing. + */ + public function testExtractComplexGetStringsPattern(): void + { + $content = "getStrings( [ { key: 'confirm', component: 'block_test' } , { key : 'cancel' , component : 'block_test' } ] );"; + $component = 'block_test'; + $filePath = 'test.js'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('confirm', $result); + $this->assertArrayHasKey('cancel', $result); + } + + /** + * Test that no extraction occurs for non-matching components. + */ + public function testNoExtractionForNonMatchingComponents(): void + { + $content = "str.get_string('test', 'different_component');"; + $component = 'block_test'; + $filePath = 'test.js'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertEmpty($result); + } +} diff --git a/tests/MissingStrings/Extractor/MustacheStringExtractorTest.php b/tests/MissingStrings/Extractor/MustacheStringExtractorTest.php new file mode 100644 index 00000000..8ed36a17 --- /dev/null +++ b/tests/MissingStrings/Extractor/MustacheStringExtractorTest.php @@ -0,0 +1,197 @@ +extractor = new MustacheStringExtractor(); + } + + /** + * Test extraction of strings from 2-parameter {{#str}} helpers. + */ + public function testExtractTwoParameterStrings(): void + { + $content = '{{#str}}modify_prompt, block_uteluqchatbot{{/str}}'; + $component = 'block_uteluqchatbot'; + $filePath = 'test.mustache'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('modify_prompt', $result); + $this->assertSame('test.mustache', $result['modify_prompt'][0]['file']); + $this->assertSame(1, $result['modify_prompt'][0]['line']); + } + + /** + * Test extraction of strings from 3-parameter {{#str}} helpers with simple parameters. + */ + public function testExtractThreeParameterStringsSimple(): void + { + $content = '{{#str}} backto, core, Moodle.org {{/str}}'; + $component = 'core'; + $filePath = 'test.mustache'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('backto', $result); + $this->assertSame('test.mustache', $result['backto'][0]['file']); + $this->assertSame(1, $result['backto'][0]['line']); + } + + /** + * Test extraction of strings from 3-parameter {{#str}} helpers with Mustache variables. + */ + public function testExtractThreeParameterStringsWithVariables(): void + { + $content = '{{#str}} backto, core, {{name}} {{/str}}'; + $component = 'core'; + $filePath = 'test.mustache'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('backto', $result); + $this->assertSame('test.mustache', $result['backto'][0]['file']); + $this->assertSame(1, $result['backto'][0]['line']); + } + + /** + * Test extraction with the exact case from the real template file. + */ + public function testExtractRealWorldExample(): void + { + $content = '{{#str}}modify_prompt1, block_uteluqchatbot, {{name}}{{/str}}'; + $component = 'block_uteluqchatbot'; + $filePath = 'prompt_modal.mustache'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('modify_prompt1', $result); + $this->assertSame('prompt_modal.mustache', $result['modify_prompt1'][0]['file']); + $this->assertSame(1, $result['modify_prompt1'][0]['line']); + } + + /** + * Test extraction of strings from 3-parameter {{#str}} helpers with JSON parameters. + */ + public function testExtractThreeParameterStringsWithJson(): void + { + $content = '{{#str}} counteditems, core, { "count": "42", "items": "courses" } {{/str}}'; + $component = 'core'; + $filePath = 'test.mustache'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('counteditems', $result); + $this->assertSame('test.mustache', $result['counteditems'][0]['file']); + $this->assertSame(1, $result['counteditems'][0]['line']); + } + + /** + * Test extraction of strings from 3-parameter {{#str}} helpers with complex nested parameters. + */ + public function testExtractThreeParameterStringsWithComplexParameters(): void + { + $content = '{{#str}} counteditems, core, { "count": {{count}}, "items": {{#quote}} {{itemname}} {{/quote}} } {{/str}}'; + $component = 'core'; + $filePath = 'test.mustache'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('counteditems', $result); + $this->assertSame('test.mustache', $result['counteditems'][0]['file']); + $this->assertSame(1, $result['counteditems'][0]['line']); + } + + /** + * Test extraction of strings from {{#cleanstr}} helpers with 3 parameters. + */ + public function testExtractCleanstrThreeParameters(): void + { + $content = '{{#cleanstr}}cleanstring, component, parameter{{/cleanstr}}'; + $component = 'component'; + $filePath = 'test.mustache'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('cleanstring', $result); + $this->assertSame('test.mustache', $result['cleanstring'][0]['file']); + $this->assertSame(1, $result['cleanstring'][0]['line']); + } + + /** + * Test that strings with different components are filtered out. + */ + public function testFilterByComponent(): void + { + $content = '{{#str}}string1, block_uteluqchatbot{{/str}}{{#str}}string2, core{{/str}}'; + $component = 'block_uteluqchatbot'; + $filePath = 'test.mustache'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('string1', $result); + $this->assertArrayNotHasKey('string2', $result); + } + + /** + * Test extraction from multiple lines. + */ + public function testExtractMultipleLines(): void + { + $content = "{{#str}}string1, block_test{{/str}}\n{{#str}}string2, block_test, param{{/str}}"; + $component = 'block_test'; + $filePath = 'test.mustache'; + + $result = $this->extractor->extract($content, $component, $filePath); + + $this->assertArrayHasKey('string1', $result); + $this->assertArrayHasKey('string2', $result); + $this->assertSame(1, $result['string1'][0]['line']); + $this->assertSame(2, $result['string2'][0]['line']); + } + + /** + * Test canHandle method. + */ + public function testCanHandle(): void + { + $this->assertTrue($this->extractor->canHandle('template.mustache')); + $this->assertFalse($this->extractor->canHandle('script.js')); + $this->assertFalse($this->extractor->canHandle('code.php')); + } + + /** + * Test getName method. + */ + public function testGetName(): void + { + $this->assertSame('Mustache String Extractor', $this->extractor->getName()); + } +} diff --git a/tests/MissingStrings/Extractor/PhpStringExtractorTest.php b/tests/MissingStrings/Extractor/PhpStringExtractorTest.php new file mode 100644 index 00000000..bd4a47af --- /dev/null +++ b/tests/MissingStrings/Extractor/PhpStringExtractorTest.php @@ -0,0 +1,437 @@ +extractor = new PhpStringExtractor(); + } + + /** + * Test canHandle method. + */ + public function testCanHandle(): void + { + $this->assertTrue($this->extractor->canHandle('test.php')); + $this->assertTrue($this->extractor->canHandle('/path/to/file.php')); + $this->assertTrue($this->extractor->canHandle('file.PHP')); // Case insensitive + + $this->assertFalse($this->extractor->canHandle('test.js')); + $this->assertFalse($this->extractor->canHandle('test.mustache')); + $this->assertFalse($this->extractor->canHandle('test.xml')); + $this->assertFalse($this->extractor->canHandle('test')); + } + + /** + * Test getName method. + */ + public function testGetName(): void + { + $this->assertSame('PHP String Extractor', $this->extractor->getName()); + } + + /** + * Test extract with get_string calls. + */ + public function testExtractGetStringCalls(): void + { + $content = "extractor->extract($content, 'mod_test', '/path/to/test.php'); + + $this->assertCount(3, $result); + $this->assertArrayHasKey('test_string', $result); + $this->assertArrayHasKey('another_string', $result); + $this->assertArrayHasKey('third_string', $result); + + // Check context information + $this->assertSame(2, $result['test_string'][0]['line']); + $this->assertStringContainsString('get_string', $result['test_string'][0]['context']); + $this->assertStringContainsString('test.php', $result['test_string'][0]['file']); + } + + /** + * Test extract with new lang_string calls. + */ + public function testExtractLangStringCalls(): void + { + $content = "extractor->extract($content, 'mod_test', '/path/to/test.php'); + + $this->assertCount(2, $result); + $this->assertArrayHasKey('lang_test', $result); + $this->assertArrayHasKey('lang_with_param', $result); + + $this->assertSame(2, $result['lang_test'][0]['line']); + $this->assertStringContainsString('new lang_string', $result['lang_test'][0]['context']); + } + + /** + * Test extract with addHelpButton calls. + */ + public function testExtractAddHelpButtonCalls(): void + { + $content = "addHelpButton('help_string', 'mod_test');\n" . + "\$form->addHelpButton('another_help', 'mod_test', \$plugin);\n"; + + $result = $this->extractor->extract($content, 'mod_test', '/path/to/test.php'); + + $this->assertCount(2, $result); + $this->assertArrayHasKey('help_string', $result); + $this->assertArrayHasKey('another_help', $result); + + $this->assertSame(2, $result['help_string'][0]['line']); + $this->assertStringContainsString('addHelpButton', $result['help_string'][0]['context']); + } + + /** + * Test extract with string manager calls. + */ + public function testExtractStringManagerCalls(): void + { + $content = "get_string('manager_string', 'mod_test');\n" . + "get_string_manager()->get_string('manager_with_param', 'mod_test', \$param);\n"; + + $result = $this->extractor->extract($content, 'mod_test', '/path/to/test.php'); + + $this->assertCount(2, $result); + $this->assertArrayHasKey('manager_string', $result); + $this->assertArrayHasKey('manager_with_param', $result); + + $this->assertSame(2, $result['manager_string'][0]['line']); + $this->assertStringContainsString('get_string_manager', $result['manager_string'][0]['context']); + } + + /** + * Test extract with component filtering. + */ + public function testExtractWithComponentFiltering(): void + { + $content = "extractor->extract($content, 'mod_test', '/path/to/test.php'); + + $this->assertCount(1, $result); + $this->assertArrayHasKey('test_string', $result); + $this->assertArrayNotHasKey('other_string', $result); + $this->assertArrayNotHasKey('core_string', $result); + } + + /** + * Test extract with module short component names. + */ + public function testExtractWithModuleShortComponentNames(): void + { + $content = "extractor->extract($content, 'mod_quiz', '/path/to/test.php'); + + $this->assertCount(2, $result); + $this->assertArrayHasKey('test_string', $result); + $this->assertArrayHasKey('another_string', $result); + } + + /** + * Test extract skips dynamic strings with variables. + */ + public function testExtractSkipsDynamicStringsWithVariables(): void + { + $content = "extractor->extract($content, 'mod_test', '/path/to/test.php'); + + $this->assertCount(1, $result); + $this->assertArrayHasKey('static_string', $result); + $this->assertArrayNotHasKey('dynamic_string', $result); + } + + /** + * Test extract with multiline strings. + */ + public function testExtractWithMultilineStrings(): void + { + $content = "extractor->extract($content, 'mod_test', '/path/to/test.php'); + + $this->assertEmpty($result); + } + + /** + * Test extract with multiple occurrences of same string. + */ + public function testExtractWithMultipleOccurrences(): void + { + $content = "extractor->extract($content, 'mod_test', '/path/to/test.php'); + + $this->assertCount(1, $result); + $this->assertArrayHasKey('same_string', $result); + $this->assertCount(2, $result['same_string']); // Two occurrences + + $this->assertSame(2, $result['same_string'][0]['line']); + $this->assertSame(4, $result['same_string'][1]['line']); + } + + /** + * Test extract with mixed quote types. + */ + public function testExtractWithMixedQuoteTypes(): void + { + $content = "extractor->extract($content, 'mod_test', '/path/to/test.php'); + + $this->assertCount(4, $result); + $this->assertArrayHasKey('single_quoted', $result); + $this->assertArrayHasKey('double_quoted', $result); + $this->assertArrayHasKey('mixed1', $result); + $this->assertArrayHasKey('mixed2', $result); + } + + /** + * Test extract with spacing variations. + */ + public function testExtractWithSpacingVariations(): void + { + $content = "extractor->extract($content, 'mod_test', '/path/to/test.php'); + + $this->assertCount(3, $result); + $this->assertArrayHasKey('spaced1', $result); + $this->assertArrayHasKey('nospace', $result); + $this->assertArrayHasKey('lots_of_spaces', $result); + } + + /** + * Test extract with commented out strings. + */ + public function testExtractWithCommentedOutStrings(): void + { + $content = "extractor->extract($content, 'mod_test', '/path/to/test.php'); + + // Comments are still matched by regex - this is intentional as some + // commented code might still be relevant for string analysis + $this->assertCount(3, $result); + $this->assertArrayHasKey('active_string', $result); + $this->assertArrayHasKey('commented_string', $result); + $this->assertArrayHasKey('block_commented', $result); + } + + /** + * Test extract with empty content. + */ + public function testExtractWithEmptyContent(): void + { + $result = $this->extractor->extract('', 'mod_test', '/path/to/test.php'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Test extract with no matching strings. + */ + public function testExtractWithNoMatchingStrings(): void + { + $content = "extractor->extract($content, 'mod_test', '/path/to/test.php'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + /** + * Test extract with invalid PHP syntax. + */ + public function testExtractWithInvalidPhpSyntax(): void + { + $content = "extractor->extract($content, 'mod_test', '/path/to/test.php'); + + $this->assertCount(2, $result); + $this->assertArrayHasKey('valid_string', $result); + $this->assertArrayHasKey('another_valid', $result); + } + + /** + * Test relative file path generation. + */ + public function testRelativeFilePathGeneration(): void + { + $testCases = [ + '/var/www/html/mod/test/lib.php' => 'mod/test/lib.php', + '/path/to/moodle/local/plugin/version.php' => 'local/plugin/version.php', + '/some/path/moodle.local/blocks/test/block_test.php' => 'blocks/test/block_test.php', + '/deep/path/structure/file.php' => 'path/structure/file.php', + '/short/file.php' => 'short/file.php', + 'file.php' => 'file.php', + ]; + + foreach ($testCases as $input => $expected) { + $content = "extractor->extract($content, 'mod_test', $input); + + $this->assertArrayHasKey('test', $result); + $this->assertStringContainsString($expected, $result['test'][0]['file']); + } + } + + /** + * Test extract with special characters in string keys. + */ + public function testExtractWithSpecialCharactersInStringKeys(): void + { + $content = "extractor->extract($content, 'mod_test', '/path/to/test.php'); + + $this->assertCount(5, $result); + $this->assertArrayHasKey('string_with_underscores', $result); + $this->assertArrayHasKey('string-with-dashes', $result); + $this->assertArrayHasKey('string:with:colons', $result); + $this->assertArrayHasKey('string.with.dots', $result); + $this->assertArrayHasKey('string123numbers', $result); + } + + /** + * Test dynamic string detection. + */ + public function testDynamicStringDetection(): void + { + $content = "extractor->extract($content, 'mod_test', '/path/to/test.php'); + + $this->assertCount(2, $result); + $this->assertArrayHasKey('static_string', $result); + $this->assertArrayHasKey('normal_string_name', $result); + + // Ensure dynamic strings are not included + $this->assertArrayNotHasKey('string_with_$var', $result); + $this->assertArrayNotHasKey('string_{$template}', $result); + $this->assertArrayNotHasKey('string_{placeholder}', $result); + $this->assertArrayNotHasKey('string_.$concat', $result); + } + + /** + * Test extract performance with large content. + */ + public function testExtractPerformanceWithLargeContent(): void + { + $lines = []; + for ($i = 0; $i < 1000; ++$i) { + $lines[] = "get_string('string_{$i}', 'mod_test');"; + } + $content = "extractor->extract($content, 'mod_test', '/path/to/test.php'); + $endTime = microtime(true); + + $this->assertCount(1000, $result); + $this->assertLessThan(1.0, $endTime - $startTime, 'Extraction should be reasonably fast'); + } + + /** + * Test extract with non-PHP file extension (edge case). + */ + public function testExtractWithNonPhpFile(): void + { + $content = "extractor->extract($content, 'mod_test', '/path/to/test.txt'); + + $this->assertCount(1, $result); + $this->assertArrayHasKey('test_string', $result); + $this->assertStringContainsString('test.txt', $result['test_string'][0]['file']); + } +} diff --git a/tests/MissingStrings/Extractor/StringExtractorTest.php b/tests/MissingStrings/Extractor/StringExtractorTest.php new file mode 100644 index 00000000..95d8d15c --- /dev/null +++ b/tests/MissingStrings/Extractor/StringExtractorTest.php @@ -0,0 +1,247 @@ +extractor = new StringExtractor(); + $this->testPluginPath = $this->createTempDir('test_plugin_'); + $this->createTestPluginStructure(); + } + + /** + * Create a test plugin structure. + */ + private function createTestPluginStructure(): void + { + // Create basic plugin structure + mkdir($this->testPluginPath . '/classes', 0777, true); + mkdir($this->testPluginPath . '/templates', 0777, true); + mkdir($this->testPluginPath . '/amd/src', 0777, true); + + // Create PHP file with strings + $phpContent = <<<'PHP' +testPluginPath . '/classes/test.php', $phpContent); + + // Create Mustache template with strings + $mustacheContent = <<<'MUSTACHE' +
+ {{#str}}template_string, local_testplugin{{/str}} + {{#cleanstr}}clean_string, local_testplugin{{/cleanstr}} +
+MUSTACHE; + file_put_contents($this->testPluginPath . '/templates/test.mustache', $mustacheContent); + + // Create JavaScript file with strings + $jsContent = <<<'JS' +define(['core/str'], function(str) { + str.get_string('js_string', 'local_testplugin'); + str.get_strings([ + {key: 'js_string2', component: 'local_testplugin'} + ]); +}); +JS; + file_put_contents($this->testPluginPath . '/amd/src/test.js', $jsContent); + } + + /** + * Test extractFromPlugin method. + */ + public function testExtractFromPlugin(): void + { + $plugin = new Plugin('local_testplugin', 'local', 'testplugin', $this->testPluginPath); + $fileDiscovery = new FileDiscovery($plugin); + $this->extractor->setFileDiscovery($fileDiscovery); + + $strings = $this->extractor->extractFromPlugin($plugin); + + // Should extract strings from all file types + $this->assertArrayHasKey('test_string', $strings); + $this->assertArrayHasKey('another_string', $strings); + $this->assertArrayHasKey('third_string', $strings); + $this->assertArrayHasKey('template_string', $strings); + $this->assertArrayHasKey('clean_string', $strings); + $this->assertArrayHasKey('js_string', $strings); + $this->assertArrayHasKey('js_string2', $strings); + + // Check string usage information + $this->assertNotEmpty($strings['test_string']); + $firstUsage = $strings['test_string'][0]; + $this->assertArrayHasKey('file', $firstUsage); + $this->assertArrayHasKey('line', $firstUsage); + $this->assertStringContainsString('test.php', $firstUsage['file']); + } + + /** + * Test extractFromPlugin with no file discovery throws exception. + */ + public function testExtractFromPluginWithoutFileDiscoveryThrowsException(): void + { + $plugin = new Plugin('local_testplugin', 'local', 'testplugin', $this->testPluginPath); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('File discovery service not set'); + + $this->extractor->extractFromPlugin($plugin); + } + + /** + * Test getPerformanceMetrics method. + */ + public function testGetPerformanceMetrics(): void + { + $plugin = new Plugin('local_testplugin', 'local', 'testplugin', $this->testPluginPath); + $fileDiscovery = new FileDiscovery($plugin); + $this->extractor->setFileDiscovery($fileDiscovery); + + // Extract strings to populate metrics + $this->extractor->extractFromPlugin($plugin); + + $metrics = $this->extractor->getPerformanceMetrics(); + + $this->assertArrayHasKey('extraction_time', $metrics); + $this->assertArrayHasKey('files_processed', $metrics); + $this->assertArrayHasKey('strings_extracted', $metrics); + $this->assertArrayHasKey('string_usages_found', $metrics); + + $this->assertIsFloat($metrics['extraction_time']); + $this->assertGreaterThan(0, $metrics['files_processed']); + $this->assertGreaterThan(0, $metrics['strings_extracted']); + $this->assertGreaterThan(0, $metrics['string_usages_found']); + } + + /** + * Test addExtractor method. + */ + public function testAddExtractor(): void + { + $mockExtractor = $this->createMock(StringExtractorInterface::class); + $mockExtractor->method('canHandle')->willReturn(true); + $mockExtractor->method('extract')->willReturn(['custom_string' => [['file' => 'test.php', 'line' => 1]]]); + + $this->extractor->addExtractor($mockExtractor); + + $extractors = $this->extractor->getExtractors(); + $this->assertCount(4, $extractors); // 3 default + 1 custom + $this->assertContains($mockExtractor, $extractors); + } + + /** + * Test setExtractors method. + */ + public function testSetExtractors(): void + { + $mockExtractor1 = $this->createMock(StringExtractorInterface::class); + $mockExtractor2 = $this->createMock(StringExtractorInterface::class); + + $customExtractors = [$mockExtractor1, $mockExtractor2]; + $this->extractor->setExtractors($customExtractors); + + $extractors = $this->extractor->getExtractors(); + $this->assertCount(2, $extractors); + $this->assertSame($customExtractors, $extractors); + } + + /** + * Test getExtractors method. + */ + public function testGetExtractors(): void + { + $extractors = $this->extractor->getExtractors(); + + $this->assertCount(3, $extractors); // Default extractors + $this->assertContainsOnlyInstancesOf(StringExtractorInterface::class, $extractors); + } + + /** + * Test extractFromPlugin with empty plugin directory. + */ + public function testExtractFromPluginWithEmptyDirectory(): void + { + $emptyPath = $this->createTempDir('empty_plugin_'); + $plugin = new Plugin('local_empty', 'local', 'empty', $emptyPath); + $fileDiscovery = new FileDiscovery($plugin); + $this->extractor->setFileDiscovery($fileDiscovery); + + $strings = $this->extractor->extractFromPlugin($plugin); + + $this->assertIsArray($strings); + $this->assertEmpty($strings); + + // Check metrics for empty plugin + $metrics = $this->extractor->getPerformanceMetrics(); + $this->assertSame(0, $metrics['files_processed']); + $this->assertSame(0, $metrics['strings_extracted']); + } + + /** + * Test extractFromPlugin with unreadable files. + */ + public function testExtractFromPluginWithUnreadableFiles(): void + { + $pluginPath = $this->createTempDir('unreadable_plugin_'); + + // Create a file + $testFile = $pluginPath . '/test.php'; + file_put_contents($testFile, 'extractor->setFileDiscovery($fileDiscovery); + + $strings = $this->extractor->extractFromPlugin($plugin); + + // Should handle unreadable files gracefully + $this->assertIsArray($strings); + + // Restore permissions for cleanup + if (function_exists('chmod')) { + chmod($testFile, 0644); + } + } +} diff --git a/tests/MissingStrings/Requirements/AbstractStringRequirementsTest.php b/tests/MissingStrings/Requirements/AbstractStringRequirementsTest.php new file mode 100644 index 00000000..c707fa54 --- /dev/null +++ b/tests/MissingStrings/Requirements/AbstractStringRequirementsTest.php @@ -0,0 +1,327 @@ +testPluginPath = $this->createTempDir('test_abstract_'); + $this->plugin = new Plugin('local_testplugin', 'local', 'testplugin', $this->testPluginPath); + + // Create a concrete implementation of the abstract class for testing + $this->requirements = new class($this->plugin, 400) extends AbstractStringRequirements { + public function getRequiredStrings(): array + { + return ['test_string']; + } + + // Expose protected methods for testing + public function testFileExists(string $file): bool + { + return $this->fileExists($file); + } + + public function testGetComponent(): string + { + return $this->getComponent(); + } + + public function testGetPluginType(): string + { + return $this->getPluginType(); + } + + public function testGetPluginName(): string + { + return $this->getPluginName(); + } + }; + } + + /** + * Test constructor sets properties correctly. + */ + public function testConstructor(): void + { + $plugin = new Plugin('block_test', 'block', 'test', '/path/to/plugin'); + $moodleVersion = 311; + + $requirements = new class($plugin, $moodleVersion) extends AbstractStringRequirements { + public function getRequiredStrings(): array + { + return []; + } + + public function getTestPlugin(): Plugin + { + return $this->plugin; + } + + public function getTestMoodleVersion(): int + { + return $this->moodleVersion; + } + }; + + $this->assertSame($plugin, $requirements->getTestPlugin()); + $this->assertSame($moodleVersion, $requirements->getTestMoodleVersion()); + } + + /** + * Test getPluginTypePatterns default implementation. + */ + public function testGetPluginTypePatternsDefault(): void + { + $patterns = $this->requirements->getPluginTypePatterns(); + + $this->assertIsArray($patterns); + $this->assertEmpty($patterns); + } + + /** + * Test getPluginTypePatterns can be overridden. + */ + public function testGetPluginTypePatternsOverride(): void + { + $customRequirements = new class($this->plugin, 400) extends AbstractStringRequirements { + public function getRequiredStrings(): array + { + return []; + } + + public function getPluginTypePatterns(): array + { + return ['pattern1', 'pattern2']; + } + }; + + $patterns = $customRequirements->getPluginTypePatterns(); + + $this->assertIsArray($patterns); + $this->assertCount(2, $patterns); + $this->assertContains('pattern1', $patterns); + $this->assertContains('pattern2', $patterns); + } + + /** + * Test fileExists method with existing file. + */ + public function testFileExistsWithExistingFile(): void + { + $testFile = 'existing.txt'; + file_put_contents($this->testPluginPath . '/' . $testFile, 'test content'); + + $this->assertTrue($this->requirements->testFileExists($testFile)); + } + + /** + * Test fileExists method with non-existing file. + */ + public function testFileExistsWithNonExistingFile(): void + { + $this->assertFalse($this->requirements->testFileExists('nonexistent.txt')); + } + + /** + * Test fileExists method with subdirectory file. + */ + public function testFileExistsWithSubdirectoryFile(): void + { + mkdir($this->testPluginPath . '/subdir', 0777, true); + $testFile = 'subdir/nested.php'; + file_put_contents($this->testPluginPath . '/' . $testFile, 'assertTrue($this->requirements->testFileExists($testFile)); + } + + /** + * Test fileExists method with file in non-existing subdirectory. + */ + public function testFileExistsWithNonExistingSubdirectory(): void + { + $this->assertFalse($this->requirements->testFileExists('nonexistent/file.txt')); + } + + /** + * Test getComponent method. + */ + public function testGetComponent(): void + { + $this->assertSame('local_testplugin', $this->requirements->testGetComponent()); + } + + /** + * Test getPluginType method. + */ + public function testGetPluginType(): void + { + $this->assertSame('local', $this->requirements->testGetPluginType()); + } + + /** + * Test getPluginName method. + */ + public function testGetPluginName(): void + { + $this->assertSame('testplugin', $this->requirements->testGetPluginName()); + } + + /** + * Test with different plugin types. + */ + public function testWithDifferentPluginTypes(): void + { + $testCases = [ + ['mod_quiz', 'mod', 'quiz'], + ['block_html', 'block', 'html'], + ['theme_boost', 'theme', 'boost'], + ['auth_manual', 'auth', 'manual'], + ['enrol_guest', 'enrol', 'guest'], + ]; + + foreach ($testCases as [$component, $type, $name]) { + $plugin = new Plugin($component, $type, $name, '/path/test'); + + $requirements = new class($plugin, 400) extends AbstractStringRequirements { + public function getRequiredStrings(): array + { + return []; + } + + public function testGetComponent(): string + { + return $this->getComponent(); + } + + public function testGetPluginType(): string + { + return $this->getPluginType(); + } + + public function testGetPluginName(): string + { + return $this->getPluginName(); + } + }; + + $this->assertSame($component, $requirements->testGetComponent()); + $this->assertSame($type, $requirements->testGetPluginType()); + $this->assertSame($name, $requirements->testGetPluginName()); + } + } + + /** + * Test with different Moodle versions. + */ + public function testWithDifferentMoodleVersions(): void + { + $versions = [29, 30, 31, 39, 40, 41, 42]; + + foreach ($versions as $version) { + $requirements = new class($this->plugin, $version) extends AbstractStringRequirements { + public function getRequiredStrings(): array + { + return []; + } + + public function getTestMoodleVersion(): int + { + return $this->moodleVersion; + } + }; + + $this->assertSame($version, $requirements->getTestMoodleVersion()); + } + } + + /** + * Test fileExists method with edge cases. + */ + public function testFileExistsEdgeCases(): void + { + // Test with parent directory reference + $this->assertFalse($this->requirements->testFileExists('../test.txt')); + + // Test with absolute path (should not work as it's relative to plugin dir) + $this->assertFalse($this->requirements->testFileExists('/etc/passwd')); + + // Test with non-existent nested path + $this->assertFalse($this->requirements->testFileExists('non/existent/path/file.txt')); + } + + /** + * Test protected method behavior with special characters in plugin data. + */ + public function testWithSpecialCharacters(): void + { + $specialPlugin = new Plugin('local_test-plugin_123', 'local', 'test-plugin_123', $this->testPluginPath); + + $requirements = new class($specialPlugin, 400) extends AbstractStringRequirements { + public function getRequiredStrings(): array + { + return []; + } + + public function testGetComponent(): string + { + return $this->getComponent(); + } + + public function testGetPluginType(): string + { + return $this->getPluginType(); + } + + public function testGetPluginName(): string + { + return $this->getPluginName(); + } + }; + + $this->assertSame('local_test-plugin_123', $requirements->testGetComponent()); + $this->assertSame('local', $requirements->testGetPluginType()); + $this->assertSame('test-plugin_123', $requirements->testGetPluginName()); + } + + /** + * Test that abstract method must be implemented. + */ + public function testAbstractMethodImplementation(): void + { + $requiredStrings = $this->requirements->getRequiredStrings(); + + $this->assertIsArray($requiredStrings); + $this->assertContains('test_string', $requiredStrings); + } +} diff --git a/tests/MissingStrings/Requirements/GenericStringRequirementsTest.php b/tests/MissingStrings/Requirements/GenericStringRequirementsTest.php new file mode 100644 index 00000000..9fabca1d --- /dev/null +++ b/tests/MissingStrings/Requirements/GenericStringRequirementsTest.php @@ -0,0 +1,196 @@ +testPluginPath = $this->createTempDir('test_plugin_'); + $this->plugin = new Plugin('local_testplugin', 'local', 'testplugin', $this->testPluginPath); + $this->requirements = new GenericStringRequirements($this->plugin, 400); + } + + /** + * Test constructor. + */ + public function testConstructor(): void + { + $plugin = new Plugin('block_test', 'block', 'test', '/path/to/plugin'); + $requirements = new GenericStringRequirements($plugin, 311); + + $this->assertInstanceOf(AbstractStringRequirements::class, $requirements); + } + + /** + * Test getRequiredStrings method. + */ + public function testGetRequiredStrings(): void + { + $requiredStrings = $this->requirements->getRequiredStrings(); + + $this->assertIsArray($requiredStrings); + $this->assertContains('pluginname', $requiredStrings); + $this->assertCount(1, $requiredStrings); + } + + /** + * Test getRequiredStrings returns same strings for different instances. + */ + public function testGetRequiredStringsConsistency(): void + { + $plugin1 = new Plugin('local_test1', 'local', 'test1', '/path/1'); + $plugin2 = new Plugin('block_test2', 'block', 'test2', '/path/2'); + $requirements1 = new GenericStringRequirements($plugin1, 400); + $requirements2 = new GenericStringRequirements($plugin2, 311); + + $strings1 = $requirements1->getRequiredStrings(); + $strings2 = $requirements2->getRequiredStrings(); + + $this->assertSame($strings1, $strings2); + } + + /** + * Test getRequiredStrings with different Moodle versions. + */ + public function testGetRequiredStringsWithDifferentMoodleVersions(): void + { + $requirements39 = new GenericStringRequirements($this->plugin, 39); + $requirements40 = new GenericStringRequirements($this->plugin, 40); + $requirements41 = new GenericStringRequirements($this->plugin, 41); + + $strings39 = $requirements39->getRequiredStrings(); + $strings40 = $requirements40->getRequiredStrings(); + $strings41 = $requirements41->getRequiredStrings(); + + // Generic requirements should be same regardless of Moodle version + $this->assertSame($strings39, $strings40); + $this->assertSame($strings40, $strings41); + $this->assertContains('pluginname', $strings39); + } + + /** + * Test inheritance from AbstractStringRequirements. + */ + public function testInheritance(): void + { + $this->assertInstanceOf(AbstractStringRequirements::class, $this->requirements); + } + + /** + * Test getPluginTypePatterns method (inherited from parent). + */ + public function testGetPluginTypePatterns(): void + { + $patterns = $this->requirements->getPluginTypePatterns(); + + $this->assertIsArray($patterns); + $this->assertEmpty($patterns); // Generic requirements have no specific patterns + } + + /** + * Test plugin property access through protected methods. + */ + public function testPluginPropertyAccess(): void + { + // Create a test subclass to access protected methods + $testRequirements = new class($this->plugin, 400) extends GenericStringRequirements { + public function getTestComponent(): string + { + return $this->getComponent(); + } + + public function getTestPluginType(): string + { + return $this->getPluginType(); + } + + public function getTestPluginName(): string + { + return $this->getPluginName(); + } + + public function testFileExists(string $file): bool + { + return $this->fileExists($file); + } + }; + + $this->assertSame('local_testplugin', $testRequirements->getTestComponent()); + $this->assertSame('local', $testRequirements->getTestPluginType()); + $this->assertSame('testplugin', $testRequirements->getTestPluginName()); + } + + /** + * Test fileExists method functionality. + */ + public function testFileExists(): void + { + // Create a test subclass to access protected fileExists method + $testRequirements = new class($this->plugin, 400) extends GenericStringRequirements { + public function testFileExists(string $file): bool + { + return $this->fileExists($file); + } + }; + + // Test with non-existent file + $this->assertFalse($testRequirements->testFileExists('nonexistent.php')); + + // Create a test file + $testFile = 'test.txt'; + file_put_contents($this->testPluginPath . '/' . $testFile, 'test content'); + + // Test with existing file + $this->assertTrue($testRequirements->testFileExists($testFile)); + } + + /** + * Test with different plugin types. + */ + public function testWithDifferentPluginTypes(): void + { + $localPlugin = new Plugin('local_test', 'local', 'test', '/path/local'); + $blockPlugin = new Plugin('block_test', 'block', 'test', '/path/block'); + $modPlugin = new Plugin('mod_test', 'mod', 'test', '/path/mod'); + + $localReq = new GenericStringRequirements($localPlugin, 400); + $blockReq = new GenericStringRequirements($blockPlugin, 400); + $modReq = new GenericStringRequirements($modPlugin, 400); + + // All should have same required strings since they're generic + $this->assertSame($localReq->getRequiredStrings(), $blockReq->getRequiredStrings()); + $this->assertSame($blockReq->getRequiredStrings(), $modReq->getRequiredStrings()); + } +} diff --git a/tests/MissingStrings/Requirements/ModuleStringRequirementsTest.php b/tests/MissingStrings/Requirements/ModuleStringRequirementsTest.php new file mode 100644 index 00000000..ddfc5092 --- /dev/null +++ b/tests/MissingStrings/Requirements/ModuleStringRequirementsTest.php @@ -0,0 +1,217 @@ +testPluginPath = $this->createTempDir('test_module_'); + $this->plugin = new Plugin('mod_testactivity', 'mod', 'testactivity', $this->testPluginPath); + $this->requirements = new ModuleStringRequirements($this->plugin, 400); + } + + /** + * Test constructor. + */ + public function testConstructor(): void + { + $plugin = new Plugin('mod_quiz', 'mod', 'quiz', '/path/to/quiz'); + $requirements = new ModuleStringRequirements($plugin, 311); + + $this->assertInstanceOf(GenericStringRequirements::class, $requirements); + } + + /** + * Test getRequiredStrings method includes module-specific strings. + */ + public function testGetRequiredStrings(): void + { + $requiredStrings = $this->requirements->getRequiredStrings(); + + $this->assertIsArray($requiredStrings); + $this->assertContains('pluginname', $requiredStrings); // From parent + $this->assertContains('modulename', $requiredStrings); // Module specific + $this->assertContains('modulenameplural', $requiredStrings); // Module specific + $this->assertCount(3, $requiredStrings); + } + + /** + * Test getRequiredStrings includes parent requirements. + */ + public function testGetRequiredStringsIncludesParent(): void + { + $genericRequirements = new GenericStringRequirements($this->plugin, 400); + $moduleRequirements = $this->requirements; + + $genericStrings = $genericRequirements->getRequiredStrings(); + $moduleStrings = $moduleRequirements->getRequiredStrings(); + + // Module requirements should include all generic requirements + foreach ($genericStrings as $string) { + $this->assertContains($string, $moduleStrings); + } + + // Module requirements should have more strings than generic + $this->assertGreaterThan(count($genericStrings), count($moduleStrings)); + } + + /** + * Test module-specific strings are added. + */ + public function testModuleSpecificStrings(): void + { + $requiredStrings = $this->requirements->getRequiredStrings(); + + $moduleSpecificStrings = ['modulename', 'modulenameplural']; + + foreach ($moduleSpecificStrings as $string) { + $this->assertContains($string, $requiredStrings); + } + } + + /** + * Test getRequiredStrings with different Moodle versions. + */ + public function testGetRequiredStringsWithDifferentMoodleVersions(): void + { + $requirements39 = new ModuleStringRequirements($this->plugin, 39); + $requirements40 = new ModuleStringRequirements($this->plugin, 40); + $requirements41 = new ModuleStringRequirements($this->plugin, 41); + + $strings39 = $requirements39->getRequiredStrings(); + $strings40 = $requirements40->getRequiredStrings(); + $strings41 = $requirements41->getRequiredStrings(); + + // Module requirements should be same regardless of Moodle version + $this->assertSame($strings39, $strings40); + $this->assertSame($strings40, $strings41); + + // Should contain both generic and module-specific strings + $this->assertContains('pluginname', $strings39); + $this->assertContains('modulename', $strings39); + $this->assertContains('modulenameplural', $strings39); + } + + /** + * Test inheritance from GenericStringRequirements. + */ + public function testInheritance(): void + { + $this->assertInstanceOf(GenericStringRequirements::class, $this->requirements); + } + + /** + * Test with different module plugins. + */ + public function testWithDifferentModulePlugins(): void + { + $quizPlugin = new Plugin('mod_quiz', 'mod', 'quiz', '/path/quiz'); + $assignPlugin = new Plugin('mod_assign', 'mod', 'assign', '/path/assign'); + $forumPlugin = new Plugin('mod_forum', 'mod', 'forum', '/path/forum'); + + $quizReq = new ModuleStringRequirements($quizPlugin, 400); + $assignReq = new ModuleStringRequirements($assignPlugin, 400); + $forumReq = new ModuleStringRequirements($forumPlugin, 400); + + // All module plugins should have same required strings + $this->assertSame($quizReq->getRequiredStrings(), $assignReq->getRequiredStrings()); + $this->assertSame($assignReq->getRequiredStrings(), $forumReq->getRequiredStrings()); + } + + /** + * Test getRequiredStrings returns consistent array. + */ + public function testGetRequiredStringsConsistency(): void + { + $strings1 = $this->requirements->getRequiredStrings(); + $strings2 = $this->requirements->getRequiredStrings(); + + $this->assertSame($strings1, $strings2); + } + + /** + * Test that module requirements contain expected string count. + */ + public function testRequiredStringCount(): void + { + $requiredStrings = $this->requirements->getRequiredStrings(); + + // Should have: pluginname (from generic) + modulename + modulenameplural + $this->assertCount(3, $requiredStrings); + } + + /** + * Test string ordering in requirements. + */ + public function testStringOrdering(): void + { + $requiredStrings = $this->requirements->getRequiredStrings(); + + // Generic strings should come first (from parent::getRequiredStrings()) + $this->assertSame('pluginname', $requiredStrings[0]); + + // Module-specific strings should follow + $this->assertContains('modulename', array_slice($requiredStrings, 1)); + $this->assertContains('modulenameplural', array_slice($requiredStrings, 1)); + } + + /** + * Test plugin property access through protected methods. + */ + public function testPluginPropertyAccess(): void + { + // Create a test subclass to access protected methods + $testRequirements = new class($this->plugin, 400) extends ModuleStringRequirements { + public function getTestComponent(): string + { + return $this->getComponent(); + } + + public function getTestPluginType(): string + { + return $this->getPluginType(); + } + + public function getTestPluginName(): string + { + return $this->getPluginName(); + } + }; + + $this->assertSame('mod_testactivity', $testRequirements->getTestComponent()); + $this->assertSame('mod', $testRequirements->getTestPluginType()); + $this->assertSame('testactivity', $testRequirements->getTestPluginName()); + } +} diff --git a/tests/MissingStrings/Requirements/StringRequirementsResolverTest.php b/tests/MissingStrings/Requirements/StringRequirementsResolverTest.php new file mode 100644 index 00000000..8da04d71 --- /dev/null +++ b/tests/MissingStrings/Requirements/StringRequirementsResolverTest.php @@ -0,0 +1,240 @@ +resolver = new StringRequirementsResolver(); + } + + /** + * Test resolve method with module plugin returns ModuleStringRequirements. + */ + public function testResolveWithModulePlugin(): void + { + $plugin = new Plugin('mod_quiz', 'mod', 'quiz', '/path/to/quiz'); + + $requirements = $this->resolver->resolve($plugin, 400); + + $this->assertInstanceOf(ModuleStringRequirements::class, $requirements); + $this->assertInstanceOf(AbstractStringRequirements::class, $requirements); + } + + /** + * Test resolve method with local plugin returns GenericStringRequirements. + */ + public function testResolveWithLocalPlugin(): void + { + $plugin = new Plugin('local_test', 'local', 'test', '/path/to/local'); + + $requirements = $this->resolver->resolve($plugin, 400); + + $this->assertInstanceOf(GenericStringRequirements::class, $requirements); + $this->assertInstanceOf(AbstractStringRequirements::class, $requirements); + $this->assertNotInstanceOf(ModuleStringRequirements::class, $requirements); + } + + /** + * Test resolve method with block plugin returns GenericStringRequirements. + */ + public function testResolveWithBlockPlugin(): void + { + $plugin = new Plugin('block_html', 'block', 'html', '/path/to/block'); + + $requirements = $this->resolver->resolve($plugin, 400); + + $this->assertInstanceOf(GenericStringRequirements::class, $requirements); + $this->assertInstanceOf(AbstractStringRequirements::class, $requirements); + $this->assertNotInstanceOf(ModuleStringRequirements::class, $requirements); + } + + /** + * Test resolve method with theme plugin returns GenericStringRequirements. + */ + public function testResolveWithThemePlugin(): void + { + $plugin = new Plugin('theme_boost', 'theme', 'boost', '/path/to/theme'); + + $requirements = $this->resolver->resolve($plugin, 400); + + $this->assertInstanceOf(GenericStringRequirements::class, $requirements); + $this->assertInstanceOf(AbstractStringRequirements::class, $requirements); + $this->assertNotInstanceOf(ModuleStringRequirements::class, $requirements); + } + + /** + * Test resolve method with auth plugin returns GenericStringRequirements. + */ + public function testResolveWithAuthPlugin(): void + { + $plugin = new Plugin('auth_manual', 'auth', 'manual', '/path/to/auth'); + + $requirements = $this->resolver->resolve($plugin, 400); + + $this->assertInstanceOf(GenericStringRequirements::class, $requirements); + $this->assertInstanceOf(AbstractStringRequirements::class, $requirements); + $this->assertNotInstanceOf(ModuleStringRequirements::class, $requirements); + } + + /** + * Test resolve method with different Moodle versions. + */ + public function testResolveWithDifferentMoodleVersions(): void + { + $modPlugin = new Plugin('mod_assign', 'mod', 'assign', '/path/to/assign'); + $localPlugin = new Plugin('local_test', 'local', 'test', '/path/to/local'); + + // Test with different Moodle versions + $requirements39 = $this->resolver->resolve($modPlugin, 39); + $requirements40 = $this->resolver->resolve($modPlugin, 40); + $requirements41 = $this->resolver->resolve($modPlugin, 41); + + $localRequirements39 = $this->resolver->resolve($localPlugin, 39); + $localRequirements40 = $this->resolver->resolve($localPlugin, 40); + + // All module plugins should return ModuleStringRequirements regardless of version + $this->assertInstanceOf(ModuleStringRequirements::class, $requirements39); + $this->assertInstanceOf(ModuleStringRequirements::class, $requirements40); + $this->assertInstanceOf(ModuleStringRequirements::class, $requirements41); + + // All non-module plugins should return GenericStringRequirements + $this->assertInstanceOf(GenericStringRequirements::class, $localRequirements39); + $this->assertInstanceOf(GenericStringRequirements::class, $localRequirements40); + $this->assertNotInstanceOf(ModuleStringRequirements::class, $localRequirements39); + } + + /** + * Test resolve method with multiple module plugins. + */ + public function testResolveWithMultipleModulePlugins(): void + { + $plugins = [ + new Plugin('mod_quiz', 'mod', 'quiz', '/path/quiz'), + new Plugin('mod_assign', 'mod', 'assign', '/path/assign'), + new Plugin('mod_forum', 'mod', 'forum', '/path/forum'), + new Plugin('mod_book', 'mod', 'book', '/path/book'), + ]; + + foreach ($plugins as $plugin) { + $requirements = $this->resolver->resolve($plugin, 400); + + $this->assertInstanceOf(ModuleStringRequirements::class, $requirements); + $this->assertInstanceOf(AbstractStringRequirements::class, $requirements); + } + } + + /** + * Test resolve method with multiple non-module plugins. + */ + public function testResolveWithMultipleNonModulePlugins(): void + { + $plugins = [ + new Plugin('local_test', 'local', 'test', '/path/local'), + new Plugin('block_html', 'block', 'html', '/path/block'), + new Plugin('theme_boost', 'theme', 'boost', '/path/theme'), + new Plugin('auth_manual', 'auth', 'manual', '/path/auth'), + new Plugin('enrol_manual', 'enrol', 'manual', '/path/enrol'), + new Plugin('filter_tex', 'filter', 'tex', '/path/filter'), + new Plugin('qtype_essay', 'qtype', 'essay', '/path/qtype'), + new Plugin('format_topics', 'format', 'topics', '/path/format'), + ]; + + foreach ($plugins as $plugin) { + $requirements = $this->resolver->resolve($plugin, 400); + + $this->assertInstanceOf(GenericStringRequirements::class, $requirements); + $this->assertInstanceOf(AbstractStringRequirements::class, $requirements); + $this->assertNotInstanceOf(ModuleStringRequirements::class, $requirements); + } + } + + /** + * Test resolve method returns different instances for same plugin. + */ + public function testResolveReturnsDifferentInstances(): void + { + $plugin = new Plugin('mod_quiz', 'mod', 'quiz', '/path/to/quiz'); + + $requirements1 = $this->resolver->resolve($plugin, 400); + $requirements2 = $this->resolver->resolve($plugin, 400); + + $this->assertNotSame($requirements1, $requirements2); + $this->assertSame(get_class($requirements1), get_class($requirements2)); + $this->assertInstanceOf(ModuleStringRequirements::class, $requirements1); + $this->assertInstanceOf(ModuleStringRequirements::class, $requirements2); + } + + /** + * Test resolve method case sensitivity. + */ + public function testResolveCaseSensitivity(): void + { + // Plugin type should be exactly 'mod', not 'MOD' or 'Mod' + $plugin1 = new Plugin('mod_test', 'mod', 'test', '/path/test'); + $plugin2 = new Plugin('MOD_test', 'MOD', 'test', '/path/test'); // Uppercase + $plugin3 = new Plugin('Mod_test', 'Mod', 'test', '/path/test'); // Mixed case + + $requirements1 = $this->resolver->resolve($plugin1, 400); + $requirements2 = $this->resolver->resolve($plugin2, 400); + $requirements3 = $this->resolver->resolve($plugin3, 400); + + // Only exact 'mod' should return ModuleStringRequirements + $this->assertInstanceOf(ModuleStringRequirements::class, $requirements1); + $this->assertInstanceOf(GenericStringRequirements::class, $requirements2); + $this->assertInstanceOf(GenericStringRequirements::class, $requirements3); + $this->assertNotInstanceOf(ModuleStringRequirements::class, $requirements2); + $this->assertNotInstanceOf(ModuleStringRequirements::class, $requirements3); + } + + /** + * Test resolve method with edge case plugin types. + */ + public function testResolveWithEdgeCasePluginTypes(): void + { + $edgeCases = [ + new Plugin('unknown_test', '', 'test', '/path/test'), // Empty type + new Plugin('space_test', ' mod ', 'test', '/path/test'), // Type with spaces + new Plugin('null_test', 'null', 'test', '/path/test'), // Literal 'null' + new Plugin('false_test', 'false', 'test', '/path/test'), // Literal 'false' + new Plugin('numeric_test', '123', 'test', '/path/test'), // Numeric type + ]; + + foreach ($edgeCases as $plugin) { + $requirements = $this->resolver->resolve($plugin, 400); + + // All edge cases should fall back to GenericStringRequirements + $this->assertInstanceOf(GenericStringRequirements::class, $requirements); + $this->assertInstanceOf(AbstractStringRequirements::class, $requirements); + $this->assertNotInstanceOf(ModuleStringRequirements::class, $requirements); + } + } +} diff --git a/tests/MissingStrings/TestBase/MissingStringsTestCase.php b/tests/MissingStrings/TestBase/MissingStringsTestCase.php new file mode 100644 index 00000000..1987c2ac --- /dev/null +++ b/tests/MissingStrings/TestBase/MissingStringsTestCase.php @@ -0,0 +1,339 @@ +tempFiles as $file) { + if (file_exists($file)) { + unlink($file); + } + } + + // Clean up temporary directories + foreach (array_reverse($this->tempDirs) as $dir) { + if (is_dir($dir)) { + $this->removeDirectory($dir); + } + } + + $this->tempFiles = []; + $this->tempDirs = []; + + parent::tearDown(); + } + + /** + * Create a temporary file with the given content. + * + * @param string $content File content + * @param string $extension File extension (default: 'php') + * + * @return string Full path to the created file + */ + protected function createTempFile(string $content, string $extension = 'php'): string + { + $tempFile = tempnam(sys_get_temp_dir(), 'mci_test_') . '.' . $extension; + file_put_contents($tempFile, $content); + $this->tempFiles[] = $tempFile; + + return $tempFile; + } + + /** + * Create a temporary directory. + * + * @param string $prefix Directory name prefix + * + * @return string Full path to the created directory + */ + protected function createTempDir(string $prefix = 'mci_test_'): string + { + $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $prefix . uniqid(); + mkdir($tempDir, 0755, true); + $this->tempDirs[] = $tempDir; + + return $tempDir; + } + + /** + * Create a test plugin structure. + * + * @param string $pluginType Plugin type (e.g., 'mod', 'local', 'theme') + * @param string $pluginName Plugin name + * @param array $files Array of files to create [relativePath => content] + * + * @return string Path to the plugin directory + */ + protected function createTestPlugin(string $pluginType, string $pluginName, array $files = []): string + { + $pluginDir = $this->createTempDir("plugin_{$pluginType}_{$pluginName}_"); + + // Create version.php if not provided + if (!isset($files['version.php'])) { + $files['version.php'] = $this->createVersionFileContent($pluginType, $pluginName); + } + + // Create lang file if not provided + $langFile = "lang/en/{$pluginType}_{$pluginName}.php"; + if (!isset($files[$langFile])) { + $files[$langFile] = $this->createLanguageFileContent([ + 'pluginname' => ucfirst($pluginName) . ' Plugin', + ]); + } + + // Create all files + foreach ($files as $relativePath => $content) { + $fullPath = $pluginDir . DIRECTORY_SEPARATOR . $relativePath; + $dir = dirname($fullPath); + + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + file_put_contents($fullPath, $content); + } + + return $pluginDir; + } + + /** + * Create a Plugin object for testing. + * + * @param string $pluginType Plugin type (e.g., 'mod', 'local', 'theme') + * @param string $pluginName Plugin name + * @param string $pluginDir Plugin directory path + * + * @return Plugin Plugin object + */ + protected function createPlugin(string $pluginType, string $pluginName, string $pluginDir): Plugin + { + $component = $pluginType . '_' . $pluginName; + + return new Plugin($component, $pluginType, $pluginName, $pluginDir); + } + + /** + * Create version.php file content. + * + * @param string $pluginType Plugin type + * @param string $pluginName Plugin name + * + * @return string Version file content + */ + protected function createVersionFileContent(string $pluginType, string $pluginName): string + { + $component = $pluginType . '_' . $pluginName; + + return "version = 2023060100;\n" . + "\$plugin->requires = 2020061500;\n" . + "\$plugin->component = '{$component}';\n"; + } + + /** + * Create language file content. + * + * @param array $strings Array of string key => value pairs + * + * @return string Language file content + */ + protected function createLanguageFileContent(array $strings): string + { + $content = " $value) { + $content .= "\$string['{$key}'] = '{$value}';\n"; + } + + return $content; + } + + /** + * Create database file content (access.php, caches.php, etc.). + * + * @param string $type Database file type (e.g., 'access', 'caches') + * @param array $data Data structure for the file + * + * @return string Database file content + */ + protected function createDatabaseFileContent(string $type, array $data): string + { + $content = "assertSame($expected->getFile(), $actual->getFile(), $message . ' - file path mismatch'); + $this->assertSame($expected->getLine(), $actual->getLine(), $message . ' - line number mismatch'); + $this->assertSame($expected->getDescription(), $actual->getDescription(), $message . ' - description mismatch'); + } + + /** + * Assert that a StringContext has the expected line number. + * + * @param StringContext $context StringContext to check + * @param int $expectedLine Expected line number + * @param string $message Optional message + */ + protected function assertStringContextHasLine(StringContext $context, int $expectedLine, string $message = ''): void + { + $this->assertSame($expectedLine, $context->getLine(), $message ?: "Expected line {$expectedLine}, got {$context->getLine()}"); + } + + /** + * Assert that a ValidationResult has the expected error count. + * + * @param ValidationResult $result Validation result + * @param int $expectedCount Expected error count + * @param string $message Optional message + */ + protected function assertErrorCount(ValidationResult $result, int $expectedCount, string $message = ''): void + { + $actualCount = count($result->getErrors()); + $this->assertSame($expectedCount, $actualCount, $message ?: "Expected {$expectedCount} errors, got {$actualCount}"); + } + + /** + * Assert that a ValidationResult has the expected warning count. + * + * @param ValidationResult $result Validation result + * @param int $expectedCount Expected warning count + * @param string $message Optional message + */ + protected function assertWarningCount(ValidationResult $result, int $expectedCount, string $message = ''): void + { + $actualCount = count($result->getWarnings()); + $this->assertSame($expectedCount, $actualCount, $message ?: "Expected {$expectedCount} warnings, got {$actualCount}"); + } + + /** + * Assert that a ValidationResult contains a missing string error. + * + * @param ValidationResult $result Validation result + * @param string $stringKey Expected missing string key + * @param string $message Optional message + */ + protected function assertHasMissingString(ValidationResult $result, string $stringKey, string $message = ''): void + { + $errors = $result->getErrors(); + $found = false; + + foreach ($errors as $error) { + if (isset($error['string_key']) && $error['string_key'] === $stringKey) { + $found = true; + break; + } + } + + $this->assertTrue($found, $message ?: "Missing string '{$stringKey}' not found in errors"); + } + + /** + * Assert that a ValidationResult contains an unused string warning. + * + * @param ValidationResult $result Validation result + * @param string $stringKey Expected unused string key + * @param string $message Optional message + */ + protected function assertHasUnusedString(ValidationResult $result, string $stringKey, string $message = ''): void + { + $warnings = $result->getWarnings(); + $found = false; + + foreach ($warnings as $warning) { + if (isset($warning['string_key']) && $warning['string_key'] === $stringKey) { + $found = true; + break; + } + } + + $this->assertTrue($found, $message ?: "Unused string '{$stringKey}' not found in warnings"); + } + + /** + * Recursively remove a directory and its contents. + * + * @param string $dir Directory path + */ + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir . DIRECTORY_SEPARATOR . $file; + if (is_dir($path)) { + $this->removeDirectory($path); + } else { + unlink($path); + } + } + rmdir($dir); + } +} diff --git a/tests/MissingStrings/ValidationConfigTest.php b/tests/MissingStrings/ValidationConfigTest.php new file mode 100644 index 00000000..99c945bb --- /dev/null +++ b/tests/MissingStrings/ValidationConfigTest.php @@ -0,0 +1,312 @@ +assertSame('en', $config->getLanguage()); + $this->assertFalse($config->isStrict()); + $this->assertFalse($config->shouldCheckUnused()); + $this->assertSame([], $config->getExcludePatterns()); + $this->assertSame([], $config->getCustomCheckers()); + $this->assertTrue($config->shouldUseDefaultCheckers()); + $this->assertFalse($config->isDebugEnabled()); + } + + /** + * Test constructor with all parameters. + */ + public function testConstructorWithAllParameters(): void + { + $language = 'fr'; + $strict = true; + $checkUnused = true; + $excludePatterns = ['pattern1', 'pattern2']; + $customCheckers = ['checker1', 'checker2']; + $useDefaultCheckers = false; + $debug = true; + + $config = new ValidationConfig( + $language, + $strict, + $checkUnused, + $excludePatterns, + $customCheckers, + $useDefaultCheckers, + $debug + ); + + $this->assertSame($language, $config->getLanguage()); + $this->assertTrue($config->isStrict()); + $this->assertTrue($config->shouldCheckUnused()); + $this->assertSame($excludePatterns, $config->getExcludePatterns()); + $this->assertSame($customCheckers, $config->getCustomCheckers()); + $this->assertFalse($config->shouldUseDefaultCheckers()); + $this->assertTrue($config->isDebugEnabled()); + } + + /** + * Test fromOptions static method with empty options. + */ + public function testFromOptionsWithEmptyOptions(): void + { + $config = ValidationConfig::fromOptions([]); + + $this->assertSame('en', $config->getLanguage()); + $this->assertFalse($config->isStrict()); + $this->assertFalse($config->shouldCheckUnused()); + $this->assertSame([], $config->getExcludePatterns()); + $this->assertSame([], $config->getCustomCheckers()); + $this->assertTrue($config->shouldUseDefaultCheckers()); + $this->assertFalse($config->isDebugEnabled()); + } + + /** + * Test fromOptions static method with all options. + */ + public function testFromOptionsWithAllOptions(): void + { + $options = [ + 'lang' => 'de', + 'strict' => true, + 'unused' => true, + 'exclude-patterns' => 'pattern1,pattern2,pattern3', + 'debug' => true, + ]; + + $config = ValidationConfig::fromOptions($options); + + $this->assertSame('de', $config->getLanguage()); + $this->assertTrue($config->isStrict()); + $this->assertTrue($config->shouldCheckUnused()); + $this->assertSame(['pattern1', 'pattern2', 'pattern3'], $config->getExcludePatterns()); + $this->assertSame([], $config->getCustomCheckers()); + $this->assertTrue($config->shouldUseDefaultCheckers()); + $this->assertTrue($config->isDebugEnabled()); + } + + /** + * Test fromOptions with exclude patterns containing spaces. + */ + public function testFromOptionsWithSpacedExcludePatterns(): void + { + $options = [ + 'exclude-patterns' => 'pattern1, pattern2 , pattern3, pattern4 ', + ]; + + $config = ValidationConfig::fromOptions($options); + + $this->assertSame(['pattern1', 'pattern2', 'pattern3', 'pattern4'], $config->getExcludePatterns()); + } + + /** + * Test fromOptions with empty exclude patterns. + */ + public function testFromOptionsWithEmptyExcludePatterns(): void + { + $options = [ + 'exclude-patterns' => '', + ]; + + $config = ValidationConfig::fromOptions($options); + + $this->assertSame([], $config->getExcludePatterns()); + } + + /** + * Test fromOptions with exclude patterns containing empty values. + */ + public function testFromOptionsWithEmptyExcludePatternValues(): void + { + $options = [ + 'exclude-patterns' => 'pattern1,,pattern2, ,pattern3', + ]; + + $config = ValidationConfig::fromOptions($options); + + // Empty values should be filtered out, but array_filter preserves keys + $patterns = $config->getExcludePatterns(); + $this->assertContains('pattern1', $patterns); + $this->assertContains('pattern2', $patterns); + $this->assertContains('pattern3', $patterns); + $this->assertCount(3, $patterns); + } + + /** + * Test fromOptions with single exclude pattern. + */ + public function testFromOptionsWithSingleExcludePattern(): void + { + $options = [ + 'exclude-patterns' => 'single_pattern', + ]; + + $config = ValidationConfig::fromOptions($options); + + $this->assertSame(['single_pattern'], $config->getExcludePatterns()); + } + + /** + * Test shouldExcludeString with no patterns. + */ + public function testShouldExcludeStringWithNoPatterns(): void + { + $config = new ValidationConfig(); + + $this->assertFalse($config->shouldExcludeString('any_string')); + $this->assertFalse($config->shouldExcludeString('')); + $this->assertFalse($config->shouldExcludeString('test_string')); + } + + /** + * Test shouldExcludeString with exact match patterns. + */ + public function testShouldExcludeStringWithExactMatch(): void + { + $config = new ValidationConfig('en', false, false, ['test_string', 'another_string']); + + $this->assertTrue($config->shouldExcludeString('test_string')); + $this->assertTrue($config->shouldExcludeString('another_string')); + $this->assertFalse($config->shouldExcludeString('different_string')); + $this->assertFalse($config->shouldExcludeString('test_string_suffix')); + } + + /** + * Test shouldExcludeString with wildcard patterns. + */ + public function testShouldExcludeStringWithWildcardPatterns(): void + { + $config = new ValidationConfig('en', false, false, ['test_*', '*_suffix', 'exact_match']); + + // Test prefix wildcard + $this->assertTrue($config->shouldExcludeString('test_string')); + $this->assertTrue($config->shouldExcludeString('test_anything')); + $this->assertTrue($config->shouldExcludeString('test_')); + $this->assertFalse($config->shouldExcludeString('prefix_test_string')); + + // Test suffix wildcard + $this->assertTrue($config->shouldExcludeString('anything_suffix')); + $this->assertTrue($config->shouldExcludeString('test_suffix')); + $this->assertFalse($config->shouldExcludeString('suffix_other')); + + // Test exact match + $this->assertTrue($config->shouldExcludeString('exact_match')); + + // Test no match + $this->assertFalse($config->shouldExcludeString('no_match')); + } + + /** + * Test shouldExcludeString with complex wildcard patterns. + */ + public function testShouldExcludeStringWithComplexPatterns(): void + { + $config = new ValidationConfig('en', false, false, ['*test*', 'prefix_*_suffix']); + + // Test patterns with wildcards in middle + $this->assertTrue($config->shouldExcludeString('anything_test_anything')); + $this->assertTrue($config->shouldExcludeString('test')); + $this->assertTrue($config->shouldExcludeString('test_suffix')); + $this->assertTrue($config->shouldExcludeString('prefix_test')); + + // Test specific pattern + $this->assertTrue($config->shouldExcludeString('prefix_anything_suffix')); + $this->assertTrue($config->shouldExcludeString('prefix__suffix')); + $this->assertFalse($config->shouldExcludeString('prefix_suffix')); // No underscore in middle + $this->assertFalse($config->shouldExcludeString('wrong_anything_suffix')); + } + + /** + * Test shouldExcludeString with case sensitivity. + */ + public function testShouldExcludeStringCaseSensitivity(): void + { + $config = new ValidationConfig('en', false, false, ['Test_String', 'UPPER_CASE']); + + // fnmatch is case-sensitive + $this->assertTrue($config->shouldExcludeString('Test_String')); + $this->assertFalse($config->shouldExcludeString('test_string')); + $this->assertFalse($config->shouldExcludeString('TEST_STRING')); + + $this->assertTrue($config->shouldExcludeString('UPPER_CASE')); + $this->assertFalse($config->shouldExcludeString('upper_case')); + } + + /** + * Test shouldExcludeString with empty string. + */ + public function testShouldExcludeStringWithEmptyString(): void + { + $config = new ValidationConfig('en', false, false, ['', '*']); + + $this->assertTrue($config->shouldExcludeString('')); // Matches empty pattern + $this->assertTrue($config->shouldExcludeString('test')); // Matches wildcard + } + + /** + * Test shouldExcludeString with special characters. + */ + public function testShouldExcludeStringWithSpecialCharacters(): void + { + $config = new ValidationConfig('en', false, false, ['test*special', 'exact_match']); + + // Test basic wildcard functionality + $this->assertTrue($config->shouldExcludeString('testAnythingspecial')); + $this->assertTrue($config->shouldExcludeString('test_special')); + $this->assertFalse($config->shouldExcludeString('test_other')); + + // Test exact match + $this->assertTrue($config->shouldExcludeString('exact_match')); + $this->assertFalse($config->shouldExcludeString('not_exact_match')); + } + + /** + * Test getters return correct types. + */ + public function testGettersReturnCorrectTypes(): void + { + $config = new ValidationConfig( + 'es', + true, + true, + ['pattern'], + ['checker'], + false, + true + ); + + $this->assertIsString($config->getLanguage()); + $this->assertIsBool($config->isStrict()); + $this->assertIsBool($config->shouldCheckUnused()); + $this->assertIsArray($config->getExcludePatterns()); + $this->assertIsArray($config->getCustomCheckers()); + $this->assertIsBool($config->shouldUseDefaultCheckers()); + $this->assertIsBool($config->isDebugEnabled()); + $this->assertIsBool($config->shouldExcludeString('test')); + } +}