From 8245725b9879a20cc836a8eff51206c0757d6d13 Mon Sep 17 00:00:00 2001 From: Andrew Clement <djac@djac.ca> Date: Thu, 11 Aug 2022 16:40:50 -0400 Subject: [PATCH 1/7] Support multiple spelling suggestions and retain original search terms --- src/Plugin/views/area/FusionSpellcheck.php | 60 +++++++++++++--------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/src/Plugin/views/area/FusionSpellcheck.php b/src/Plugin/views/area/FusionSpellcheck.php index aaf0187..73f5dfd 100644 --- a/src/Plugin/views/area/FusionSpellcheck.php +++ b/src/Plugin/views/area/FusionSpellcheck.php @@ -82,45 +82,48 @@ 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']); - // 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' => $this->getSuggestionLink($best_suggestions), ]; } /** * 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. */ - protected function getSuggestionLink($suggestion) { + protected function getSuggestionLink(array $suggestions) { if ($this->view->hasUrl()) { $path = '/' . $this->view->getPath(); } @@ -129,15 +132,22 @@ 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. + $suggested_text = str_ireplace(array_keys($suggestions), array_values($suggestions), $currentQuery['query']); + $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 +156,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(); } -- GitLab From 46ea1f30272dc5a7d468493050d9c437a2bdd969 Mon Sep 17 00:00:00 2001 From: Andrew Clement <djac@djac.ca> Date: Thu, 11 Aug 2022 16:42:30 -0400 Subject: [PATCH 2/7] Fix existing tests and add new tests for multiple spelling suggestions --- tests/src/Unit/FusionSpellcheckTest.php | 107 ++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 7 deletions(-) diff --git a/tests/src/Unit/FusionSpellcheckTest.php b/tests/src/Unit/FusionSpellcheckTest.php index 71d2473..9b9019b 100644 --- a/tests/src/Unit/FusionSpellcheckTest.php +++ b/tests/src/Unit/FusionSpellcheckTest.php @@ -26,6 +26,15 @@ 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 + * @var \Symfony\Component\HttpFoundation\ParameterBag|\PHPUnit\Framework\MockObject\MockObject + */ + protected $viewRequestStub; + /** * Mock of current path, used for testing. * @@ -37,6 +46,7 @@ class FusionSpellcheckTest extends UnitTestCase { * FusionSpellcheck instance being tested. * * @var \Drupal\search_api_fusion\Plugin\views\area\FusionSpellcheck + * @var \Drupal\views\ViewExecutable */ protected $fusionSpellcheck; @@ -88,16 +98,12 @@ class FusionSpellcheckTest extends UnitTestCase { ->disableOriginalConstructor() ->getMock(); - $viewRequestStub = $this->getMockBuilder(Request::class) + $this->viewRequestStub = $this->getMockBuilder(Request::class) ->disableOriginalConstructor() ->getMock(); - $viewRequestStub->query = $this->getMockBuilder(ParameterBag::class) + $this->viewRequestStub->query = $this->getMockBuilder(ParameterBag::class) ->disableOriginalConstructor() ->getMock(); - $viewRequestStub->query->method('all')->willReturn([]); - $this->fusionSpellcheck->view - ->method('getRequest') - ->willReturn($viewRequestStub); } /** @@ -120,11 +126,88 @@ class FusionSpellcheckTest extends UnitTestCase { $this->resultSet->method('getExtraData') ->willReturn($response); + $this->viewRequestStub->query->method('all')->willReturn(['query' => 'artz']); + $this->fusionSpellcheck->view + ->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->fusionSpellcheck->view + ->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->fusionSpellcheck->view + ->method('getRequest') + ->willReturn($this->viewRequestStub); + + $result = $this->fusionSpellcheck->render(); + + $this->assertStringContainsString('arts department', $result['#link']->getText()); + } + /** * Test render spellcheck when no suggestion was provided. */ @@ -144,13 +227,18 @@ class FusionSpellcheckTest extends UnitTestCase { $this->resultSet->method('getExtraData') ->willReturn($response); + $this->viewRequestStub->query->method('all')->willReturn([]); + $this->fusionSpellcheck->view + ->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 +248,11 @@ class FusionSpellcheckTest extends UnitTestCase { $this->resultSet->method('getExtraData') ->willReturn($response); + $this->viewRequestStub->query->method('all')->willReturn([]); + $this->fusionSpellcheck->view + ->method('getRequest') + ->willReturn($this->viewRequestStub); + $this->assertEmpty($this->fusionSpellcheck->render()); } -- GitLab From 47d2085eebdfe1be4ebed54e3f7d60f1435e18c4 Mon Sep 17 00:00:00 2001 From: karolinam <karolinam@3434943.no-reply.drupal.org> Date: Thu, 11 Aug 2022 17:35:44 -0400 Subject: [PATCH 3/7] Fix phpstan error for Drupal\views\ViewExecutable. --- tests/src/Unit/FusionSpellcheckTest.php | 33 ++++++++++++++++--------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/tests/src/Unit/FusionSpellcheckTest.php b/tests/src/Unit/FusionSpellcheckTest.php index 9b9019b..8444f4d 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; @@ -31,10 +32,16 @@ class FusionSpellcheckTest extends UnitTestCase { * Symfony request. * * @var \Symfony\Component\HttpFoundation\Request|\PHPUnit\Framework\MockObject\MockObject - * @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. * @@ -46,7 +53,6 @@ class FusionSpellcheckTest extends UnitTestCase { * FusionSpellcheck instance being tested. * * @var \Drupal\search_api_fusion\Plugin\views\area\FusionSpellcheck - * @var \Drupal\views\ViewExecutable */ protected $fusionSpellcheck; @@ -80,6 +86,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) @@ -94,16 +105,16 @@ class FusionSpellcheckTest extends UnitTestCase { ->method('getSearchApiResults') ->willReturn($this->resultSet); - $this->fusionSpellcheck->view = $this->getMockBuilder(ViewExecutable::class) - ->disableOriginalConstructor() - ->getMock(); $this->viewRequestStub = $this->getMockBuilder(Request::class) ->disableOriginalConstructor() ->getMock(); - $this->viewRequestStub->query = $this->getMockBuilder(ParameterBag::class) + + /** @var \Symfony\Component\HttpFoundation\ParameterBag|\PHPUnit\Framework\MockObject\MockObject $queryMock */ + $queryMock = $this->getMockBuilder(ParameterBag::class) ->disableOriginalConstructor() ->getMock(); + $this->viewRequestStub->query = $queryMock; } /** @@ -127,7 +138,7 @@ class FusionSpellcheckTest extends UnitTestCase { ->willReturn($response); $this->viewRequestStub->query->method('all')->willReturn(['query' => 'artz']); - $this->fusionSpellcheck->view + $this->viewMock ->method('getRequest') ->willReturn($this->viewRequestStub); @@ -168,7 +179,7 @@ class FusionSpellcheckTest extends UnitTestCase { ->willReturn($response); $this->viewRequestStub->query->method('all')->willReturn(['query' => 'artz departmant']); - $this->fusionSpellcheck->view + $this->viewMock ->method('getRequest') ->willReturn($this->viewRequestStub); @@ -199,7 +210,7 @@ class FusionSpellcheckTest extends UnitTestCase { ->willReturn($response); $this->viewRequestStub->query->method('all')->willReturn(['query' => 'artz department']); - $this->fusionSpellcheck->view + $this->viewMock ->method('getRequest') ->willReturn($this->viewRequestStub); @@ -228,7 +239,7 @@ class FusionSpellcheckTest extends UnitTestCase { ->willReturn($response); $this->viewRequestStub->query->method('all')->willReturn([]); - $this->fusionSpellcheck->view + $this->viewMock ->method('getRequest') ->willReturn($this->viewRequestStub); @@ -249,7 +260,7 @@ class FusionSpellcheckTest extends UnitTestCase { ->willReturn($response); $this->viewRequestStub->query->method('all')->willReturn([]); - $this->fusionSpellcheck->view + $this->viewMock ->method('getRequest') ->willReturn($this->viewRequestStub); -- GitLab From a8eba3db3d6240885d71637c2ee77a89b4ea6ae9 Mon Sep 17 00:00:00 2001 From: Andrew Clement <djac@djac.ca> Date: Tue, 16 Aug 2022 14:17:14 -0400 Subject: [PATCH 4/7] Type hint ParameterBag --- tests/src/Unit/FusionSpellcheckTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/src/Unit/FusionSpellcheckTest.php b/tests/src/Unit/FusionSpellcheckTest.php index 8444f4d..5a0c9a5 100644 --- a/tests/src/Unit/FusionSpellcheckTest.php +++ b/tests/src/Unit/FusionSpellcheckTest.php @@ -32,6 +32,8 @@ class FusionSpellcheckTest extends UnitTestCase { * 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; -- GitLab From 26501b223320d34679263a49613b1adc77836eec Mon Sep 17 00:00:00 2001 From: Andrew Clement <djac@djac.ca> Date: Wed, 17 Aug 2022 15:29:46 -0400 Subject: [PATCH 5/7] Replace only full words and not part of a word --- src/Plugin/views/area/FusionSpellcheck.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Plugin/views/area/FusionSpellcheck.php b/src/Plugin/views/area/FusionSpellcheck.php index 73f5dfd..bbf7398 100644 --- a/src/Plugin/views/area/FusionSpellcheck.php +++ b/src/Plugin/views/area/FusionSpellcheck.php @@ -136,10 +136,20 @@ class FusionSpellcheck extends AreaPluginBase { 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. - $suggested_text = str_ireplace(array_keys($suggestions), array_values($suggestions), $currentQuery['query']); + // 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 + ); + } $url = Url::fromUserInput($path, [ 'query' => [ -- GitLab From 0c095cc5f6e9952cb049d3b91597e28970ab19a1 Mon Sep 17 00:00:00 2001 From: Andrew Clement <djac@djac.ca> Date: Wed, 17 Aug 2022 15:31:31 -0400 Subject: [PATCH 6/7] Handle the rare possibility of the original search term(s) matching the suggested text --- src/Plugin/views/area/FusionSpellcheck.php | 17 +++++++-- tests/src/Unit/FusionSpellcheckTest.php | 42 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/Plugin/views/area/FusionSpellcheck.php b/src/Plugin/views/area/FusionSpellcheck.php index bbf7398..e202908 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; @@ -106,11 +106,15 @@ class FusionSpellcheck extends AreaPluginBase { return []; } + $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_suggestions), + '#link' => $link, ]; } @@ -121,7 +125,8 @@ class FusionSpellcheck extends AreaPluginBase { * Array of suggested spellcheck words keyed by the original search terms. * * @return Link|NULL - * The suggestion link or NULL if the query is not set. + * The suggestion link or NULL if the query is not set or the suggested + * text matches the original search terms. */ protected function getSuggestionLink(array $suggestions) { if ($this->view->hasUrl()) { @@ -151,6 +156,12 @@ class FusionSpellcheck extends AreaPluginBase { ); } + // 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' => $suggested_text, diff --git a/tests/src/Unit/FusionSpellcheckTest.php b/tests/src/Unit/FusionSpellcheckTest.php index 5a0c9a5..83cb3a7 100644 --- a/tests/src/Unit/FusionSpellcheckTest.php +++ b/tests/src/Unit/FusionSpellcheckTest.php @@ -221,6 +221,48 @@ class FusionSpellcheckTest extends UnitTestCase { $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->fusionSpellcheck->view + ->method('getRequest') + ->willReturn($this->viewRequestStub); + + $result = $this->fusionSpellcheck->render(); + + $this->assertEmpty($this->fusionSpellcheck->render()); + } + /** * Test render spellcheck when no suggestion was provided. */ -- GitLab From 9b5efd834d51a07717322e2fb192ad3721306a69 Mon Sep 17 00:00:00 2001 From: Andrew Clement <djac@djac.ca> Date: Wed, 17 Aug 2022 15:38:10 -0400 Subject: [PATCH 7/7] Fix test --- tests/src/Unit/FusionSpellcheckTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/src/Unit/FusionSpellcheckTest.php b/tests/src/Unit/FusionSpellcheckTest.php index 83cb3a7..c3d3f9b 100644 --- a/tests/src/Unit/FusionSpellcheckTest.php +++ b/tests/src/Unit/FusionSpellcheckTest.php @@ -254,7 +254,7 @@ class FusionSpellcheckTest extends UnitTestCase { ->willReturn($response); $this->viewRequestStub->query->method('all')->willReturn(['query' => 'artz galleryz']); - $this->fusionSpellcheck->view + $this->viewMock ->method('getRequest') ->willReturn($this->viewRequestStub); -- GitLab