diff --git a/src/Plugin/views/area/FusionSpellcheck.php b/src/Plugin/views/area/FusionSpellcheck.php index aaf0187d26a649adc52697a26722dcc029be92a2..e2029089763ac9b48b10793623538492cacf9dac 100644 --- a/src/Plugin/views/area/FusionSpellcheck.php +++ b/src/Plugin/views/area/FusionSpellcheck.php @@ -2,9 +2,9 @@ namespace Drupal\search_api_fusion\Plugin\views\area; -use Drupal\Core\Url; use Drupal\Core\Link; use Drupal\Core\Path\CurrentPathStack; +use Drupal\Core\Url; use Drupal\views\Plugin\views\area\AreaPluginBase; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -82,45 +82,53 @@ class FusionSpellcheck extends AreaPluginBase { return []; } - // If Fusion provided spellcheck suggestions: order those by frequency. $suggestions = $response['spellcheck']['suggestions']; - $word = NULL; - foreach ($suggestions as $word => $data) { - if (isset($data['suggestion'])) { - usort($suggestions[$word]['suggestion'], - function ($suggestion1, $suggestion2): int { - return $suggestion1['freq'] <=> $suggestion2['freq']; - }); + $best_suggestions = []; + foreach ($suggestions as $delta => &$data) { + // Ensure the suggestion array is defined. This skips string elements in + // the array that solely contain a search term. + if (isset($data['suggestion']) && !empty($data['suggestion'])) { + // Order Fusion provided spellcheck suggestions by frequency. + usort($data['suggestion'], + function ($suggestion1, $suggestion2): int { + return $suggestion1['freq'] <=> $suggestion2['freq']; + }); + // The original search term is in the array element previous to the + // suggestions. + $search_term = $suggestions[$delta - 1]; + // Extract suggested word with the highest frequency (last array + // element). Use the original search term as the array key. + $best_suggestions[$search_term] = $data['suggestion'][array_key_last($data['suggestion'])]['word']; } } - if (empty($suggestions[$word]['suggestion']) - || !is_array($suggestions[$word]['suggestion'])) { + if (empty($best_suggestions)) { return []; } - // Extract suggestion with highest frequency. - end($suggestions[$word]['suggestion']); - $best_suggestion = current($suggestions[$word]['suggestion']); - + $link = $this->getSuggestionLink($best_suggestions); + if (is_null($link)) { + return []; + } // Return hyperlink "Did you mean: <best spellcheck suggestion> ?". return [ '#theme' => 'search_api_fusion_spellcheck', '#label' => $this->t('Did you mean:'), - '#link' => $this->getSuggestionLink($best_suggestion['word']), + '#link' => $link, ]; } /** * Gets the suggestion link. * - * @param string $suggestion - * The suggested search query. + * @param array $suggestions + * Array of suggested spellcheck words keyed by the original search terms. * - * @return \Drupal\Core\Link - * The suggestion link. + * @return Link|NULL + * The suggestion link or NULL if the query is not set or the suggested + * text matches the original search terms. */ - protected function getSuggestionLink($suggestion) { + protected function getSuggestionLink(array $suggestions) { if ($this->view->hasUrl()) { $path = '/' . $this->view->getPath(); } @@ -129,15 +137,38 @@ class FusionSpellcheck extends AreaPluginBase { } $currentQuery = $this->getCurrentQuery(); - unset($currentQuery['q']); + if (!isset($currentQuery['query'])) { + return NULL; + } + + // Search and replace the original search terms with the returned suggested + // words. This way the original search terms without a spelling suggestion + // are preserved. + // Use the original search term(s) as a default. + $suggested_text = $currentQuery['query']; + foreach ($suggestions as $original_term => $suggested_term) { + // Replace only full words and not part of a word. + $suggested_text = preg_replace( + '/\b' . $original_term . '\b/u', + $suggested_term, + $suggested_text + ); + } + + // Handle the rare possibility of the original search term(s) matching the + // suggested text. + if ($suggested_text == $currentQuery['query']) { + return NULL; + } + $url = Url::fromUserInput($path, [ 'query' => [ - 'query' => $suggestion, - ] + $currentQuery, + 'query' => $suggested_text, + ] + $currentQuery, ]); - return Link::fromTextAndUrl($suggestion, $url); + return Link::fromTextAndUrl($suggested_text, $url); } /** @@ -146,7 +177,7 @@ class FusionSpellcheck extends AreaPluginBase { * @return array * Key value of parameters. */ - protected function getCurrentQuery() { + public function getCurrentQuery(): array { if (NULL === $this->currentQuery) { $this->currentQuery = $this->view->getRequest()->query->all(); } diff --git a/tests/src/Unit/FusionSpellcheckTest.php b/tests/src/Unit/FusionSpellcheckTest.php index 71d2473b2025887f99d3e6556346848284f604bd..c3d3f9bff2aef92e74c2284b7e2d0bdf64af7f76 100644 --- a/tests/src/Unit/FusionSpellcheckTest.php +++ b/tests/src/Unit/FusionSpellcheckTest.php @@ -8,6 +8,7 @@ use Drupal\search_api\Query\ResultSetInterface; use Drupal\search_api\Plugin\views\query\SearchApiQuery; use Drupal\search_api_fusion\Plugin\views\area\FusionSpellcheck; use Drupal\Tests\UnitTestCase; +use Drupal\views\Plugin\views\display\DisplayPluginBase; use Drupal\views\ViewExecutable; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\ParameterBag; @@ -26,6 +27,23 @@ class FusionSpellcheckTest extends UnitTestCase { * @var \Symfony\Component\DependencyInjection\ContainerInterface|\PHPUnit\Framework\MockObject\MockObject */ protected $container; + + /** + * Symfony request. + * + * @var \Symfony\Component\HttpFoundation\Request|\PHPUnit\Framework\MockObject\MockObject + * TODO Mock ParameterBag. + * @var \Symfony\Component\HttpFoundation\ParameterBag\PHPUnit\Framework\MockObject|MockObject + */ + protected $viewRequestStub; + + /** + * View mock. + * + * @var \Drupal\views\ViewExecutable|\PHPUnit\Framework\MockObject\MockObject + */ + protected $viewMock; + /** * Mock of current path, used for testing. * @@ -70,6 +88,11 @@ class FusionSpellcheckTest extends UnitTestCase { $this->fusionSpellcheck = new FusionSpellcheck(['configuration array'], 'plugin_id', [], $this->path); + $this->viewMock = $this->getMockBuilder('\Drupal\views\ViewExecutable') + ->disableOriginalConstructor() + ->getMock(); + $this->fusionSpellcheck->init($this->viewMock, $this->createStub(DisplayPluginBase::class)); + $this->fusionSpellcheck->setStringTranslation($this->createStub(TranslationInterface::class)); $this->fusionSpellcheck->query = $this->getMockBuilder(SearchApiQuery::class) @@ -84,20 +107,16 @@ class FusionSpellcheckTest extends UnitTestCase { ->method('getSearchApiResults') ->willReturn($this->resultSet); - $this->fusionSpellcheck->view = $this->getMockBuilder(ViewExecutable::class) - ->disableOriginalConstructor() - ->getMock(); - $viewRequestStub = $this->getMockBuilder(Request::class) + $this->viewRequestStub = $this->getMockBuilder(Request::class) ->disableOriginalConstructor() ->getMock(); - $viewRequestStub->query = $this->getMockBuilder(ParameterBag::class) + + /** @var \Symfony\Component\HttpFoundation\ParameterBag|\PHPUnit\Framework\MockObject\MockObject $queryMock */ + $queryMock = $this->getMockBuilder(ParameterBag::class) ->disableOriginalConstructor() ->getMock(); - $viewRequestStub->query->method('all')->willReturn([]); - $this->fusionSpellcheck->view - ->method('getRequest') - ->willReturn($viewRequestStub); + $this->viewRequestStub->query = $queryMock; } /** @@ -120,11 +139,130 @@ class FusionSpellcheckTest extends UnitTestCase { $this->resultSet->method('getExtraData') ->willReturn($response); + $this->viewRequestStub->query->method('all')->willReturn(['query' => 'artz']); + $this->viewMock + ->method('getRequest') + ->willReturn($this->viewRequestStub); + $result = $this->fusionSpellcheck->render(); $this->assertStringContainsString('arts', $result['#link']->getText()); } + /** + * Test render spellcheck returns multiple suggestions with the highest + * frequencies. + */ + public function testRenderSpellcheckMultipleSuggestions(): void { + $response['spellcheck']['suggestions'] = [ + 'artz', + [ + 'suggestion' => + [ + ['word' => "arts", 'freq' => 39026], + ['word' => "arth", 'freq' => 677], + ['word' => "arti", 'freq' => 176], + ['word' => "ariz", 'freq' => 90], + ], + ], + 'departmant', + [ + 'suggestion' => + [ + ['word' => "department", 'freq' => 81730], + ['word' => "departmrnt", 'freq' => 2], + ['word' => "departments", 'freq' => 18156], + ['word' => "departement", 'freq' => 333], + ], + ], + ]; + + $this->resultSet->method('getExtraData') + ->willReturn($response); + + $this->viewRequestStub->query->method('all')->willReturn(['query' => 'artz departmant']); + $this->viewMock + ->method('getRequest') + ->willReturn($this->viewRequestStub); + + $result = $this->fusionSpellcheck->render(); + + $this->assertStringContainsString('arts department', $result['#link']->getText()); + } + + /** + * Test render spellcheck returns suggestion with the highest frequency and + * retains original term without a spelling mistake. + */ + public function testRenderSpellcheckRetainOriginalSearchTerm(): void { + $response['spellcheck']['suggestions'] = [ + 'artz', + [ + 'suggestion' => + [ + ['word' => "arts", 'freq' => 39026], + ['word' => "arth", 'freq' => 677], + ['word' => "arti", 'freq' => 176], + ['word' => "ariz", 'freq' => 90], + ], + ], + ]; + + $this->resultSet->method('getExtraData') + ->willReturn($response); + + $this->viewRequestStub->query->method('all')->willReturn(['query' => 'artz department']); + $this->viewMock + ->method('getRequest') + ->willReturn($this->viewRequestStub); + + $result = $this->fusionSpellcheck->render(); + + $this->assertStringContainsString('arts department', $result['#link']->getText()); + } + + /** + * Test render spellcheck does not return a link in the rare chance that the + * original search query matches the suggested text. + */ + public function testRenderSpellcheckOriginalMatchesSuggestion(): void { + // Since it's hard to come up with a real example, let's assume searching + // for "artz galleryz" returns "artz" and "galleryz" as the top suggestions. + $response['spellcheck']['suggestions'] = [ + 'artz', + [ + 'suggestion' => + [ + ['word' => "artz", 'freq' => 39026], + ['word' => "arth", 'freq' => 677], + ['word' => "arti", 'freq' => 176], + ['word' => "ariz", 'freq' => 90], + ], + ], + 'galleryz', + [ + 'suggestion' => + [ + ['word' => "galleryz", 'freq' => 2106], + ['word' => "galleria", 'freq' => 38], + ['word' => "gallerie", 'freq' => 8], + ], + ], + ]; + + $this->resultSet->method('getExtraData') + ->willReturn($response); + + $this->viewRequestStub->query->method('all')->willReturn(['query' => 'artz galleryz']); + $this->viewMock + ->method('getRequest') + ->willReturn($this->viewRequestStub); + + $result = $this->fusionSpellcheck->render(); + + $this->assertEmpty($this->fusionSpellcheck->render()); + } + /** * Test render spellcheck when no suggestion was provided. */ @@ -144,13 +282,18 @@ class FusionSpellcheckTest extends UnitTestCase { $this->resultSet->method('getExtraData') ->willReturn($response); + $this->viewRequestStub->query->method('all')->willReturn([]); + $this->viewMock + ->method('getRequest') + ->willReturn($this->viewRequestStub); + $this->assertEmpty($this->fusionSpellcheck->render()); } /** * Test render spellcheck when invalid suggestions are provided. */ - public function testRenderSpellcheckWhenSuggetionsDataHasNoSuggestions() : void { + public function testRenderSpellcheckWhenSuggestionsDataHasNoSuggestions() : void { $response['spellcheck']['suggestions'] = [ 'tom', [ @@ -160,6 +303,11 @@ class FusionSpellcheckTest extends UnitTestCase { $this->resultSet->method('getExtraData') ->willReturn($response); + $this->viewRequestStub->query->method('all')->willReturn([]); + $this->viewMock + ->method('getRequest') + ->willReturn($this->viewRequestStub); + $this->assertEmpty($this->fusionSpellcheck->render()); }