Compare commits

..
3 Commits
7 changed files with 942 additions and 517 deletions
@@ -29,8 +29,7 @@ class GlobalSearchController extends Controller
if ($tokens !== []) { if ($tokens !== []) {
$factories = [ $factories = [
'Páginas e serviços' => function () use ($search, $tokens, $term) { 'Páginas e serviços' => function () use ($search, $tokens, $term) {
$pages = array_values(array_filter($this->pages(), fn ($page) => $search->matches(implode(' ', $page), $tokens) $pages = array_values(array_filter($this->pages(), fn ($page) => $search->matches(implode(' ', $page), $tokens)));
&& $search->score($page['title'], $term, $tokens) > 0));
usort($pages, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens) usort($pages, fn ($a, $b) => $search->score($b['title'], $term, $tokens) <=> $search->score($a['title'], $term, $tokens)
?: strcmp($a['title'], $b['title']) ?: strcmp($a['url'], $b['url'])); ?: strcmp($a['title'], $b['title']) ?: strcmp($a['url'], $b['url']));
+5 -5
View File
@@ -82,10 +82,10 @@ final class SearchTerms
{ {
$title = mb_strtolower(Str::ascii($title)); $title = mb_strtolower(Str::ascii($title));
$phrase = mb_strtolower(Str::ascii(trim($phrase))); $phrase = mb_strtolower(Str::ascii(trim($phrase)));
$score = $title === $phrase ? 1000 : (str_contains($title, $phrase) ? 400 : 0); $score = $title === $phrase ? 1000 : (str_starts_with($title, $phrase) ? 600 : (str_contains($title, $phrase) ? 400 : 0));
foreach ($tokens as $token) { foreach ($tokens as $token) {
if ($this->matches($title, [$token])) { if ($this->matches($title, [$token])) {
$score += 20; $score++;
} }
} }
@@ -97,15 +97,15 @@ final class SearchTerms
{ {
$column = $this->normalizedColumn($titleColumn); $column = $this->normalizedColumn($titleColumn);
$phrase = mb_strtolower(Str::ascii(trim($phrase))); $phrase = mb_strtolower(Str::ascii(trim($phrase)));
$sql = "CASE WHEN {$column} = ? THEN 1000 WHEN {$column} LIKE ? ESCAPE '!' THEN 400 ELSE 0 END"; $sql = "CASE WHEN {$column} = ? THEN 1000 WHEN {$column} LIKE ? ESCAPE '!' THEN 600 WHEN {$column} LIKE ? ESCAPE '!' THEN 400 ELSE 0 END";
$bindings = [$phrase, $this->pattern($phrase)]; $bindings = [$phrase, ltrim($this->pattern($phrase), '%'), $this->pattern($phrase)];
foreach ($tokens as $token) { foreach ($tokens as $token) {
$conditions = []; $conditions = [];
foreach ($this->variants($token) as $variant) { foreach ($this->variants($token) as $variant) {
$conditions[] = "{$column} LIKE ? ESCAPE '!'"; $conditions[] = "{$column} LIKE ? ESCAPE '!'";
$bindings[] = $this->pattern($variant); $bindings[] = $this->pattern($variant);
} }
$sql .= ' + CASE WHEN ('.implode(' OR ', $conditions).') THEN 20 ELSE 0 END'; $sql .= ' + CASE WHEN ('.implode(' OR ', $conditions).') THEN 1 ELSE 0 END';
} }
$query->orderByRaw('('.$sql.') DESC', $bindings)->orderBy($titleColumn)->orderBy($tieColumn); $query->orderByRaw('('.$sql.') DESC', $bindings)->orderBy($titleColumn)->orderBy($tieColumn);
} }
+18 -1
View File
@@ -54,9 +54,26 @@
.catalogue-filters-heading { .catalogue-filters-heading {
display: none; display: none;
} }
#catalogue-filter-fields { #catalogue-filter-fields,
.catalogue-service-fields {
padding: 0 1.15rem 1.15rem; padding: 0 1.15rem 1.15rem;
} }
.catalogue-service-fields label {
min-width: 0;
font-size: 0.875rem;
font-weight: 600;
}
.catalogue-service-fields label input:not([type='checkbox']),
.catalogue-service-fields label select {
margin-top: 0.35rem;
font-weight: 400;
}
.catalogue-service-fields .catalogue-filter-submit > button {
display: inline-flex;
align-items: center;
justify-content: center;
}
.catalogue-filters input:not([type='checkbox']), .catalogue-filters input:not([type='checkbox']),
.catalogue-filters select { .catalogue-filters select {
min-height: 44px; min-height: 44px;
File diff suppressed because it is too large Load Diff
+21
View File
@@ -3,6 +3,7 @@
namespace Tests\Unit; namespace Tests\Unit;
use App\Services\FederatedSearch; use App\Services\FederatedSearch;
use App\Services\SearchTerms;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
final class FederatedSearchTest extends TestCase final class FederatedSearchTest extends TestCase
@@ -44,4 +45,24 @@ final class FederatedSearchTest extends TestCase
$result = (new FederatedSearch)->page($factories, 1, 5, fn ($item) => $item['score']); $result = (new FederatedSearch)->page($factories, 1, 5, fn ($item) => $item['score']);
$this->assertSame(['Sectores', 'Informação'], array_column($result['data'], 'title')); $this->assertSame(['Sectores', 'Informação'], array_column($result['data'], 'title'));
} }
public function test_direct_matches_precede_associations_across_sources_and_pages(): void
{
$search = new SearchTerms;
$term = 'corredor da beira';
$tokens = $search->tokens($term);
$sources = [
'Oportunidades' => fn () => ['count' => 2, 'fetch' => fn ($offset, $limit) => array_slice([
['title' => 'Açúcar no Corredor da Beira'], ['title' => 'Produção de açúcar'],
], $offset, $limit)],
'Carteira' => fn () => ['count' => 1, 'fetch' => fn ($offset, $limit) => array_slice([
['title' => 'Corredor da Beira'],
], $offset, $limit)],
];
$score = fn ($item) => $search->score($item['title'], $term, $tokens);
$service = new FederatedSearch;
$this->assertSame(['Corredor da Beira', 'Açúcar no Corredor da Beira'], array_column($service->page($sources, 1, 2, $score)['data'], 'title'));
$this->assertSame(['Produção de açúcar'], array_column($service->page($sources, 2, 2, $score)['data'], 'title'));
}
} }
+25
View File
@@ -56,4 +56,29 @@ final class SearchTermsTest extends TestCase
$this->assertGreaterThan($search->score('Como investir', 'investir', ['investir']), $search->score('Investir', 'investir', ['investir'])); $this->assertGreaterThan($search->score('Como investir', 'investir', ['investir']), $search->score('Investir', 'investir', ['investir']));
$this->assertSame(['de'], $search->tokens('de')); $this->assertSame(['de'], $search->tokens('de'));
} }
public function test_direct_titles_precede_titles_and_descriptions_that_reference_the_query(): void
{
$search = new SearchTerms;
$phrase = 'corredor da beira';
$tokens = $search->tokens($phrase);
$titles = ['Açúcar no Corredor da Beira', 'Corredor da Beira e investimentos', 'Corredor da Beira', 'Produção de açúcar'];
usort($titles, fn ($a, $b) => $search->score($b, $phrase, $tokens) <=> $search->score($a, $phrase, $tokens));
$this->assertSame(['Corredor da Beira', 'Corredor da Beira e investimentos', 'Açúcar no Corredor da Beira', 'Produção de açúcar'], $titles);
$this->assertSame($search->score('Corredor da Beira', $phrase, $tokens), $search->score('CORREDOR DA BEIRA', $phrase, $tokens));
}
public function test_database_relevance_uses_the_same_levels_and_escaped_prefix(): void
{
$connection = new PostgresConnection(fn () => throw new \LogicException('A database must not be opened.'));
$query = $connection->table('records');
$search = new SearchTerms;
$search->orderByRelevance($query, 'title', '100% açúcar', $search->tokens('100% açúcar'), 'id');
$this->assertStringContainsString('THEN 1000', $query->toSql());
$this->assertStringContainsString('THEN 600', $query->toSql());
$this->assertStringContainsString('THEN 400', $query->toSql());
$this->assertSame(['100% acucar', '100!% acucar%', '%100!% acucar%'], array_slice($query->getBindings(), 0, 3));
}
} }
@@ -0,0 +1,133 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { compileFunction } from 'node:vm';
import { createRequire } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ts from 'typescript';
const require = createRequire(import.meta.url);
const Empty = () => null;
function render(section, filters = {}) {
const source = fs.readFileSync(
new URL(
'../../resources/js/pages/portal/services.tsx',
import.meta.url,
),
'utf8',
);
const code = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX,
},
}).outputText;
const module = { exports: {} };
const props = { auth: { user: null } };
const mocks = {
'lucide-react': {
ChevronDown: Empty,
Search: Empty,
SlidersHorizontal: Empty,
},
'@inertiajs/react': {
Head: Empty,
Link: 'a',
usePage: () => ({ props, url: '/documentos' }),
useForm: (data) => ({ data, setData() {}, get() {} }),
},
'@/lib/document-facet-label': { documentFacetLabel: (value) => value },
'@/lib/portal-presentation': { PORTAL_MORE_LINK: '' },
'../../../css/investment-catalogue.css': {},
};
compileFunction(code, ['require', 'module', 'exports'])(
(name) => {
if (mocks[name]) return mocks[name];
if (name.startsWith('@/'))
return new Proxy(
{ __esModule: true, default: Empty },
{ get: (target, key) => target[key] ?? Empty },
);
return require(name);
},
module,
module.exports,
);
return renderToStaticMarkup(
createElement(module.exports.default, {
section,
filters,
topics: ['Fiscalidade'],
categories: ['Legislação'],
documentTypes: ['guide'],
activities: ['CAE exemplo'],
stages: ['Etapa exemplo'],
modules: ['simulator'],
issuers: ['Instituição exemplo'],
languages: ['Português'],
legalStatuses: ['amended'],
territories: [{ id: 'territory', name: 'Cabo Delgado' }],
resources: {
data: [],
total: 0,
prev_page_url: null,
next_page_url: null,
},
}),
);
}
test('biblioteca e legislação mantêm todos os critérios e opções visíveis no painel aberto', () => {
for (const section of ['documents', 'legislation']) {
const html = render(section);
for (const label of [
'Pesquisar',
'Tema',
'Fiscalidade',
'Categoria',
'Legislação',
'Tipo documental',
'Guia',
'Actividade ou CAE',
'CAE exemplo',
'Etapa do projecto',
'Etapa exemplo',
'Módulo',
'Simulador fiscal',
'Entidade emitente',
'Instituição exemplo',
'Idioma',
'Português',
'Situação jurídica indicada',
'Alterada; consulte alterações posteriores',
'Confirmação da fonte',
'Fonte oficial confirmada',
'Âmbito territorial',
'Cabo Delgado',
'Limpar filtros',
])
assert.ok(html.includes(label), `${section}: ${label}`);
assert.match(html, /aria-expanded="true"/);
}
});
test('critérios seleccionados continuam acessíveis quando já não constam das opções', () => {
const html = render('documents', {
topic: 'Tema antigo',
activity: ['CAE antigo'],
stage: ['Etapa antiga'],
module: ['outro'],
territory_id: 'old-territory',
});
for (const value of [
'Tema antigo',
'CAE antigo',
'Etapa antiga',
'Documentos de apoio',
'old-territory',
'Localização seleccionada indisponível',
])
assert.ok(html.includes(value), value);
});