✗ %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'));
+ }
+}