pesquisa avancada no portal
This commit is contained in:
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\ExistingBusiness;
|
||||
use App\Models\Sector;
|
||||
use App\Services\EditorialReading;
|
||||
use App\Services\FederatedSearch;
|
||||
use App\Services\InvestmentCatalogue;
|
||||
use App\Services\InvestmentGeography;
|
||||
use App\Services\InvestmentZones;
|
||||
use App\Services\SearchTerms;
|
||||
use App\Services\SectorOrganization;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class GlobalSearchController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, InvestmentCatalogue $catalogue, SearchTerms $search, FederatedSearch $federated): Response
|
||||
{
|
||||
$input = $request->validate(['q' => ['nullable', 'string', 'max:120'], 'page' => ['nullable', 'integer', 'min:1']]);
|
||||
$term = trim($input['q'] ?? '');
|
||||
$tokens = $search->tokens($term);
|
||||
$results = ['data' => [], 'total' => 0, 'current_page' => 1, 'last_page' => 1, 'per_page' => 5, 'unavailable' => []];
|
||||
if ($tokens !== []) {
|
||||
$factories = [
|
||||
'Páginas e serviços' => function () use ($search, $tokens, $term) {
|
||||
$pages = array_values(array_filter($this->pages(), fn ($page) => $search->matches(implode(' ', $page), $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']));
|
||||
|
||||
return ['count' => count($pages), 'fetch' => fn ($offset, $limit) => array_slice($pages, $offset, $limit)];
|
||||
},
|
||||
'Oportunidades' => function () use ($catalogue, $search, $tokens, $term) {
|
||||
$query = $catalogue->query(['include_national' => true]);
|
||||
foreach ($tokens as $token) {
|
||||
$query->where(function ($match) use ($catalogue, $search, $token) {
|
||||
$search->orFields($match, ['opportunities.title', 'opportunities.summary', 'opportunities.description', 'opportunities.reference', 'opportunities.province'], $token);
|
||||
$match->orWhereHas('sector', fn ($sector) => $search->constrain($sector, ['name'], [$token]));
|
||||
$match->orWhereExists(fn ($location) => $location->selectRaw('1')->from('geo_locations as search_location')
|
||||
->whereIn('search_location.id', app(InvestmentGeography::class)->publicReferences()->select('id'))
|
||||
->where(fn ($name) => $search->orFields($name, ['search_location.name'], $token))
|
||||
->where(fn ($direct) => $direct->whereColumn('search_location.id', 'opportunities.primary_territory_id')
|
||||
->orWhereExists(fn ($link) => $link->selectRaw('1')->from('opportunity_locations as search_link')
|
||||
->whereColumn('search_link.opportunity_id', 'opportunities.id')->whereColumn('search_link.geo_location_id', 'search_location.id'))));
|
||||
if (preg_match('/^[a-z0-9]+$/', $token)) {
|
||||
$match->orWhereIn('opportunities.id', $catalogue->query(['q' => $token, 'include_national' => true])->reorder()->select('opportunities.id'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $this->source($query, $search, $term, $tokens, 'opportunities.title', 'opportunities.id',
|
||||
fn ($r) => ['title' => $r->title, 'description' => $r->summary, 'url' => '/oportunidades/'.rawurlencode($r->slug)]);
|
||||
},
|
||||
'Negócios' => function () use ($search, $tokens, $term) {
|
||||
$query = ExistingBusiness::query()->published();
|
||||
$search->constrain($query, ['public_title', 'activity_code', 'objective'], $tokens);
|
||||
|
||||
return $this->source($query, $search, $term, $tokens, 'public_title', 'existing_businesses.id',
|
||||
fn ($r) => ['title' => $r->public_title, 'description' => $r->objective, 'url' => '/negocios/'.rawurlencode($r->public_slug)]);
|
||||
},
|
||||
'Carteira: projectos, clusters e corredores' => function () use ($search, $tokens, $term) {
|
||||
$query = InvestmentPortfolioController::visible();
|
||||
$search->constrain($query, ['investment_dossiers.title', 'investment_dossiers.content'], $tokens);
|
||||
|
||||
return $this->source($query, $search, $term, $tokens, 'investment_dossiers.title', 'investment_dossiers.id',
|
||||
fn ($r) => ['title' => $r->title, 'description' => $r->corridor_id ? 'Corredor' : ($r->cluster_id ? 'Cluster' : 'Projecto âncora'), 'url' => '/carteira/'.$r->id]);
|
||||
},
|
||||
'Documentos e legislação' => function () use ($search, $tokens, $term) {
|
||||
$query = DB::table('portal_resources as r')->join('documents as d', 'd.id', '=', 'r.document_id')
|
||||
->where('r.published', true)->where('r.rights_status', '!=', 'restricted')->where('d.rights_status', '!=', 'restricted')
|
||||
->where('d.status', 'active')->where('d.data_classification', 'public')->whereNull('d.deleted_at')
|
||||
->where('d.malware_scan_status', 'clean')->whereColumn('d.malware_scanned_sha256', 'd.sha256');
|
||||
$search->constrain($query, ['r.title', 'r.description', 'r.document_number', 'd.issuer', 'r.source_reference', 'r.category', 'r.topic'], $tokens);
|
||||
$query->select('r.id', 'r.title', 'r.description');
|
||||
|
||||
return $this->source($query, $search, $term, $tokens, 'r.title', 'r.id',
|
||||
fn ($r) => ['title' => $r->title, 'description' => $r->description, 'url' => '/documentos/'.$r->id]);
|
||||
},
|
||||
'Artigos' => function () use ($search, $tokens, $term) {
|
||||
if (! Schema::hasColumn('portal_resources', 'editorial_content')) {
|
||||
return ['count' => 0, 'fetch' => fn ($offset, $limit) => []];
|
||||
}
|
||||
$query = app(EditorialReading::class)->publicResources()
|
||||
->whereRaw("nullif(btrim(r.editorial_content->>'body'), '') is not null");
|
||||
$search->constrain($query, ['r.title', "(r.editorial_content->>'body')"], $tokens);
|
||||
$query->select('r.id', 'r.title')->selectRaw("r.editorial_content->>'body' as body");
|
||||
|
||||
return $this->source($query, $search, $term, $tokens, 'r.title', 'r.id',
|
||||
fn ($r) => ['title' => $r->title, 'description' => Str::limit(trim(strip_tags($r->body)), 220), 'url' => '/artigos/'.$r->id]);
|
||||
},
|
||||
'Sectores' => function () use ($search, $tokens, $term) {
|
||||
$query = Sector::query()->where('is_active', true)->whereNotNull('published_at');
|
||||
$search->constrain($query, ['name', 'description', 'short_description'], $tokens);
|
||||
|
||||
return $this->source($query, $search, $term, $tokens, 'name', 'sectors.id',
|
||||
fn ($r) => ['title' => $r->name, 'description' => $r->short_description, 'url' => SectorOrganization::href($r->slug)]);
|
||||
},
|
||||
'Localizações' => function () use ($search, $tokens, $term) {
|
||||
$query = app(InvestmentGeography::class)->publicReferences()
|
||||
->whereIn('location_type', ['province', 'district', 'admin_post', 'locality']);
|
||||
$search->constrain($query, ['geo_locations.name', 'geo_locations.source_code'], $tokens);
|
||||
$query->select('geo_locations.id', 'geo_locations.name', 'geo_locations.location_type');
|
||||
|
||||
return $this->source($query, $search, $term, $tokens, 'geo_locations.name', 'geo_locations.id',
|
||||
fn ($r) => ['title' => $r->name, 'description' => ['province' => 'Província', 'district' => 'Distrito', 'admin_post' => 'Posto administrativo', 'locality' => 'Localidade'][$r->location_type],
|
||||
'url' => '/oportunidades?'.http_build_query([$r->location_type.'_id' => $r->id])]);
|
||||
},
|
||||
'Zonas de investimento' => function () use ($search, $tokens, $term) {
|
||||
$query = app(InvestmentZones::class)->visible();
|
||||
$search->constrain($query, ['name', 'code'], $tokens);
|
||||
|
||||
return $this->source($query, $search, $term, $tokens, 'name', 'investment_zones.id',
|
||||
fn ($r) => ['title' => $r->name, 'description' => $r->code, 'url' => '/zonas/'.$r->id]);
|
||||
},
|
||||
'Parcerias' => function () use ($search, $tokens, $term) {
|
||||
$query = DB::table('portal_partnerships as p')->where('p.status', 'published');
|
||||
$search->constrain($query, ['p.title', 'p.sector', 'p.province'], $tokens);
|
||||
$query->select('p.id', 'p.title', 'p.sector', 'p.province');
|
||||
|
||||
return $this->source($query, $search, $term, $tokens, 'p.title', 'p.id',
|
||||
fn ($r) => ['title' => $r->title, 'description' => implode(' · ', array_filter([$r->sector, $r->province])), 'url' => '/parcerias/'.$r->id]);
|
||||
},
|
||||
];
|
||||
$results = $federated->page($factories, (int) ($input['page'] ?? 1), 5,
|
||||
fn ($item) => $search->score($item['title'], $term, $tokens));
|
||||
}
|
||||
|
||||
return Inertia::render('portal/search', ['query' => $term, 'results' => $results]);
|
||||
}
|
||||
|
||||
private function source($query, SearchTerms $search, string $term, array $tokens, string $title, string $id, callable $present): array
|
||||
{
|
||||
$count = (clone $query)->reorder()->count();
|
||||
$query->reorder();
|
||||
$search->orderByRelevance($query, $title, $term, $tokens, $id);
|
||||
|
||||
return ['count' => $count, 'fetch' => fn ($offset, $limit) => (clone $query)->offset($offset)->limit($limit)->get()->map($present)->all()];
|
||||
}
|
||||
|
||||
/** Public service entry points and terms used to find them. */
|
||||
private function pages(): array
|
||||
{
|
||||
return [
|
||||
['title' => 'Como investir em Moçambique', 'description' => 'Procedimentos e etapas para investir', 'url' => '/como-investir'],
|
||||
['title' => 'Iniciar ou continuar investimento', 'description' => 'Preparar uma ideia e dossier de investimento', 'url' => '/investimentos/iniciar'],
|
||||
['title' => 'Oportunidades de investimento', 'description' => 'Pesquisar por sector, localização e maturidade', 'url' => '/oportunidades'],
|
||||
['title' => 'Carteira de investimento', 'description' => 'Projectos âncora, clusters e corredores', 'url' => '/carteira'],
|
||||
['title' => 'Negócios existentes', 'description' => 'Compra, venda e participação em negócios', 'url' => '/negocios'],
|
||||
['title' => 'Sectores de investimento', 'description' => 'Actividades económicas e sectores prioritários', 'url' => '/sectores'],
|
||||
['title' => 'Zonas de investimento', 'description' => 'Zonas económicas especiais, ZEE, ZFI e parques industriais', 'url' => '/zonas'],
|
||||
['title' => 'Contextos de investimento', 'description' => 'Formas de iniciar e preparar um investimento', 'url' => '/contextos-de-investimento'],
|
||||
['title' => 'Documentos e formulários', 'description' => 'Acervo documental e formulários para investidores e empresas', 'url' => '/documentos'],
|
||||
['title' => 'Formulário do investidor', 'description' => 'Dados e contacto para investir', 'url' => '/formularios/investidor'],
|
||||
['title' => 'Formulário de empresa', 'description' => 'Dados e contacto para empresas', 'url' => '/formularios/empresa'],
|
||||
['title' => 'Legislação e incentivos', 'description' => 'Leis, decretos, benefícios e enquadramento legal', 'url' => '/legislacao'],
|
||||
['title' => 'Simulador fiscal', 'description' => 'Simular impostos e cenários fiscais de investimento', 'url' => '/simulador-fiscal'],
|
||||
['title' => 'Formulários fiscais', 'description' => 'Preparação dos formulários IVA e IRPC', 'url' => '/formularios/fiscais'],
|
||||
['title' => 'Terra e DUAT', 'description' => 'Apoio ao uso e aproveitamento da terra', 'url' => '/terra-duat'],
|
||||
['title' => 'Bolsa de parcerias', 'description' => 'Encontrar parceiros e propostas', 'url' => '/parcerias'],
|
||||
['title' => 'Apoio ao investidor', 'description' => 'Contactos e pedido de apoio', 'url' => '/apoio'],
|
||||
['title' => 'Educação para investimento', 'description' => 'Conteúdos educativos e orientação', 'url' => '/educacao'],
|
||||
['title' => 'Sobre o InvesteMoz', 'description' => 'Informação institucional do portal', 'url' => '/sobre'],
|
||||
['title' => 'Contacto', 'description' => 'Contactar o portal InvesteMoz', 'url' => '/contacto'],
|
||||
['title' => 'Política de privacidade', 'description' => 'Privacidade e tratamento de dados pessoais', 'url' => '/politica-de-privacidade'],
|
||||
['title' => 'Termos de uso', 'description' => 'Condições de utilização do portal', 'url' => '/termos-de-uso'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
|
||||
/** Combines independent, already-authorised sources without loading complete catalogues. */
|
||||
final class FederatedSearch
|
||||
{
|
||||
/** @param array<string, callable(): array{count:int, fetch:callable(int,int): iterable}> $factories */
|
||||
public function page(array $factories, int $requestedPage, int $size = 5, ?callable $score = null): array
|
||||
{
|
||||
$sources = [];
|
||||
$unavailable = [];
|
||||
foreach ($factories as $label => $factory) {
|
||||
try {
|
||||
$sources[$label] = $factory();
|
||||
} catch (QueryException $exception) {
|
||||
report($exception);
|
||||
$unavailable[] = $label;
|
||||
}
|
||||
}
|
||||
|
||||
// If a source fails after its count, remove it and recompute the page offsets.
|
||||
do {
|
||||
$failed = false;
|
||||
$total = array_sum(array_column($sources, 'count'));
|
||||
$last = max(1, (int) ceil($total / $size));
|
||||
$page = min(max(1, $requestedPage), $last);
|
||||
$first = ($page - 1) * $size;
|
||||
$items = [];
|
||||
$positions = array_fill_keys(array_keys($sources), 0);
|
||||
$buffers = array_fill_keys(array_keys($sources), []);
|
||||
for ($position = 0; $position < min($total, $first + $size); $position++) {
|
||||
$best = null;
|
||||
$bestScore = PHP_INT_MIN;
|
||||
foreach ($sources as $label => $source) {
|
||||
if ($buffers[$label] === [] && $positions[$label] < $source['count']) {
|
||||
try {
|
||||
$batch = iterator_to_array((function () use ($source, $positions, $label, $size) {
|
||||
yield from ($source['fetch'])($positions[$label], $size);
|
||||
})(), false);
|
||||
$positions[$label] += count($batch);
|
||||
$buffers[$label] = $batch;
|
||||
if ($batch === []) {
|
||||
$positions[$label] = $source['count'];
|
||||
}
|
||||
} catch (QueryException $exception) {
|
||||
report($exception);
|
||||
$unavailable[] = $label;
|
||||
unset($sources[$label]);
|
||||
$failed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($buffers[$label] === []) {
|
||||
continue;
|
||||
}
|
||||
$candidateScore = $score ? $score($buffers[$label][0]) : 0;
|
||||
if ($best === null || $candidateScore > $bestScore) {
|
||||
$best = $label;
|
||||
$bestScore = $candidateScore;
|
||||
}
|
||||
}
|
||||
if ($failed || $best === null) {
|
||||
break;
|
||||
}
|
||||
$item = array_shift($buffers[$best]);
|
||||
if ($position >= $first) {
|
||||
$items[] = [...$item, 'category' => $best];
|
||||
}
|
||||
}
|
||||
} while ($failed);
|
||||
|
||||
return ['data' => $items, 'total' => $total, 'current_page' => $page, 'last_page' => $last,
|
||||
'per_page' => $size, 'unavailable' => $unavailable];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/** Text matching shared by the database and the built-in page catalogue. */
|
||||
final class SearchTerms
|
||||
{
|
||||
/** @return list<string> */
|
||||
public function tokens(string $query): array
|
||||
{
|
||||
$normalized = mb_strtolower(Str::ascii($query));
|
||||
$tokens = array_values(array_filter(array_unique(array_map(
|
||||
fn ($word) => trim($word, ".,;:!?\"'()[]{}"),
|
||||
preg_split('/\s+/u', trim($normalized), -1, PREG_SPLIT_NO_EMPTY) ?: []
|
||||
)), fn ($word) => $word !== ''));
|
||||
if (count($tokens) > 1) {
|
||||
$meaningful = array_values(array_diff($tokens, ['a', 'as', 'o', 'os', 'de', 'do', 'da', 'dos', 'das', 'em', 'no', 'na', 'nos', 'nas', 'para', 'por', 'e', 'como', 'qual', 'quais', 'um', 'uma']));
|
||||
|
||||
return $meaningful === [] ? $tokens : $meaningful;
|
||||
}
|
||||
|
||||
return $tokens;
|
||||
}
|
||||
|
||||
/** Known Portuguese spelling and number variants; do not infer semantic equivalence. */
|
||||
public function variants(string $token): array
|
||||
{
|
||||
foreach ([['sector', 'sectores', 'setor', 'setores'], ['projecto', 'projectos', 'projeto', 'projetos'],
|
||||
['investir', 'investimento', 'investimentos'], ['investidor', 'investidores'], ['oportunidade', 'oportunidades'], ['documento', 'documentos'],
|
||||
['empresa', 'empresas'], ['parceria', 'parcerias'], ['zona', 'zonas'], ['formulario', 'formularios'],
|
||||
['provincia', 'provincias'], ['corredor', 'corredores'], ['cluster', 'clusters'], ['imposto', 'impostos'], ['licenca', 'licencas']] as $group) {
|
||||
if (in_array($token, $group, true)) {
|
||||
return $group;
|
||||
}
|
||||
}
|
||||
|
||||
return [$token];
|
||||
}
|
||||
|
||||
public function pattern(string $token): string
|
||||
{
|
||||
return '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], $token).'%';
|
||||
}
|
||||
|
||||
/** Columns are trusted application constants, never user input. */
|
||||
public function orFields($query, array $columns, string $token): void
|
||||
{
|
||||
foreach ($columns as $column) {
|
||||
foreach ($this->variants($token) as $variant) {
|
||||
$query->orWhereRaw($this->normalizedColumn($column)." LIKE ? ESCAPE '!'", [$this->pattern($variant)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function constrain($query, array $columns, array $tokens): void
|
||||
{
|
||||
foreach ($tokens as $token) {
|
||||
$query->where(fn ($match) => $this->orFields($match, $columns, $token));
|
||||
}
|
||||
}
|
||||
|
||||
public function matches(string $text, array $tokens): bool
|
||||
{
|
||||
$text = mb_strtolower(Str::ascii($text));
|
||||
foreach ($tokens as $token) {
|
||||
if (! array_filter($this->variants($token), fn ($variant) => str_contains($text, $variant))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function normalizedColumn(string $column): string
|
||||
{
|
||||
return "translate(lower(coalesce({$column}::text, '')), 'áàâãäéèêëíìîïóòôõöúùûüçñ', 'aaaaaeeeeiiiiooooouuuucn')";
|
||||
}
|
||||
|
||||
public function score(string $title, string $phrase, array $tokens): int
|
||||
{
|
||||
$title = mb_strtolower(Str::ascii($title));
|
||||
$phrase = mb_strtolower(Str::ascii(trim($phrase)));
|
||||
$score = $title === $phrase ? 1000 : (str_contains($title, $phrase) ? 400 : 0);
|
||||
foreach ($tokens as $token) {
|
||||
if ($this->matches($title, [$token])) {
|
||||
$score += 20;
|
||||
}
|
||||
}
|
||||
|
||||
return $score;
|
||||
}
|
||||
|
||||
/** The same title score used for in-memory pages, with bound search terms. */
|
||||
public function orderByRelevance($query, string $titleColumn, string $phrase, array $tokens, string $tieColumn): void
|
||||
{
|
||||
$column = $this->normalizedColumn($titleColumn);
|
||||
$phrase = mb_strtolower(Str::ascii(trim($phrase)));
|
||||
$sql = "CASE WHEN {$column} = ? THEN 1000 WHEN {$column} LIKE ? ESCAPE '!' THEN 400 ELSE 0 END";
|
||||
$bindings = [$phrase, $this->pattern($phrase)];
|
||||
foreach ($tokens as $token) {
|
||||
$conditions = [];
|
||||
foreach ($this->variants($token) as $variant) {
|
||||
$conditions[] = "{$column} LIKE ? ESCAPE '!'";
|
||||
$bindings[] = $this->pattern($variant);
|
||||
}
|
||||
$sql .= ' + CASE WHEN ('.implode(' OR ', $conditions).') THEN 20 ELSE 0 END';
|
||||
}
|
||||
$query->orderByRaw('('.$sql.') DESC', $bindings)->orderBy($titleColumn)->orderBy($tieColumn);
|
||||
}
|
||||
}
|
||||
@@ -7384,3 +7384,20 @@ Estado actual: `investor-usability-git-delivery-20261028.json`. Os relatórios Q
|
||||
Pedido expresso da Tempo: preencher formulário de IVA ou IRPC, em vez de abrir legislação. Base preservada: entrega instalada 789fffd0, Modelo A e aquisições/apuramento já aceites, L6 M20/M22 instalado em 5aaf1a56 e incrementos de L8. Nenhuma perda confirmada neste recorte; não se apagam as aceitações nem se repetem provas fiscais anteriores. Fontes reutilizadas do inventário de 20 exemplares acessíveis em outputs/irpc-l6-values-forms/accessible-source-inventory.json da QA anterior, incluindo temporários identificados; inventário do corpus e originais preservados. M22: três páginas conferidas visualmente nesta revisão, SHA256 4b61bd83d1fbcfd3b7b5c5523cea5923f7d0f65dc4c004eff58e5db38f763c88. Modelo A: correspondência e divergências aceites em modelo-a-reconciliacao-20261021.md; sem nova validação de proveniência.
|
||||
|
||||
Incremento em preparação QA: entrada directa em Documentos e Formulários, preenchimento manual dos campos correspondidos de IVA e subconjunto M22, identificação/período, revisão, impressão e ficheiro privado para recuperação mediante confirmação. Escolha técnica: sem persistência no servidor nem ligação automática ao dossier neste incremento; não altera schema, dados, permissões ou cálculos. Ficheiro não publicado. IRPC ajustamentos completos, outros regimes, anexos, assinatura e submissão à AT fora da cobertura; L8 permanece parcial. Ainda sem instalação deste incremento. Próximas provas apenas do delta: fidelidade da recuperação, rejeição de ficheiros incompatíveis sem perda, formatos/valores desconhecidos, acesso público e compilação.
|
||||
|
||||
|
||||
### 03/11/2026 — pesquisa rápida, incremento local em preparação
|
||||
|
||||
Inventário reutilizado: a pesquisa da página inicial encaminhava ao catálogo de oportunidades; continuam disponíveis os catálogos de oportunidades, carteira, documentos, sectores, zonas e negócios, com regras próprias de publicação. Nenhuma perda foi demonstrada nesta comparação de código. A alteração local encaminha para uma página agrupada com estes seis domínios e paginação por grupo, reutilizando os filtros públicos dos destinos; não altera dados nem permissões. O incremento ainda não comprova pesquisa integral: faltam outros módulos, pesquisa de conteúdo dos ficheiros, correspondência mais flexível e prova funcional com dados na base QA isolada. A pesquisa vazia e a validação de comprimento passaram; a prova com dados ficou pendente por falta de credenciais da base QA. Não instalado no activo. A referência ao pedido de pesquisa na matriz existente (MASTER-9dee08d0-0016 e MASTER-471012ab-0124) não altera a aceitação anterior nem encerra estes requisitos.
|
||||
|
||||
|
||||
### 03/11/2026 — pesquisa sem acesso à base
|
||||
|
||||
A pedido da Tempo, continuação por análise do código e testes isolados, sem ligar à base. A pesquisa local passou a combinar palavras em qualquer ordem, ignorar maiúsculas e acentos portugueses nos campos pesquisados e tratar % e _ literalmente. Agrupa páginas/serviços, oportunidades, negócios, carteira, documentos/legislação, sectores, zonas e parcerias públicas; preserva os filtros de visibilidade dos percursos e sinaliza fontes indisponíveis. O catálogo estático de serviços pode ser pesquisado sem consultar tabelas; o acesso HTTP ao portal continua dependente da sua configuração. Documentos são pesquisados pelos metadados, não pelo conteúdo dos ficheiros; outros módulos e dados privados não estão cobertos. Testes isolados não demonstram resultados nem desempenho contra dados reais ou o funcionamento de toda a aplicação sem base. Sem instalação, migração, publicação ou alteração de dados no activo. Referência na matriz existente: MASTER-9dee08d0-0016, com aceitação anterior preservada.
|
||||
|
||||
|
||||
### 04/11/2026 — pesquisa unificada, candidata local
|
||||
|
||||
A pedido da Tempo, mantida a execução sem acesso à base e preservados os catálogos e provas anteriores. A pesquisa passa a apresentar uma sequência global de cinco resultados por página, números de página e a expressão efectiva, com cores vermelho, preto e branco; as fontes públicas são combinadas por relevância do título e identificadas por tipo. Aceita algumas variantes portuguesas de escrita, singular/plural e termos de ligação; acrescenta páginas públicas, localizações públicas e o corpo de edições editoriais publicadas e autorizadas. Contagens e blocos são obtidos por fonte, sem carregar integralmente os catálogos; uma fonte indisponível é identificada e as restantes mantidas. Escolhas técnicas de pesquisa, não novas regras organizacionais.
|
||||
|
||||
Foram efectuados testes isolados de correspondência, sequência e paginação entre fontes, indisponibilidade, renderização e compilação; não há demonstração com dados reais ou de desempenho com volumes de produção. A pesquisa não indexa o texto dos ficheiros originais nem cobre todos os módulos e dados privados autorizados; não declarar pesquisa universal. Sem alteração de dados, permissões, ficheiros originais ou instalação no activo. Cobertura e limites registados em MASTER-9dee08d0-0016 na matriz existente, sem alterar aceitações anteriores.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -231,14 +231,14 @@ export default function PortalHero({
|
||||
<form
|
||||
onSubmit={onSearch}
|
||||
role="search"
|
||||
aria-label="Pesquisar oportunidades"
|
||||
aria-label="Pesquisar no InvesteMoz"
|
||||
className="mt-7 w-full max-w-4xl"
|
||||
>
|
||||
<label
|
||||
htmlFor="portal-opportunity-search"
|
||||
className="mb-2 block text-left text-sm font-medium text-white/90"
|
||||
>
|
||||
Pesquisa rápida de oportunidades
|
||||
Pesquisa rápida no InvesteMoz
|
||||
</label>
|
||||
<div className="flex min-w-0 flex-col gap-2 rounded-2xl border border-white/30 bg-white p-1.5 shadow-2xl shadow-black/20 focus-within:ring-2 focus-within:ring-white focus-within:ring-offset-4 focus-within:ring-offset-[#101820] min-[400px]:flex-row">
|
||||
<input
|
||||
@@ -247,7 +247,7 @@ export default function PortalHero({
|
||||
name="q"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Ex.: agronegócio, Nampula, energia..."
|
||||
placeholder="Ex.: agronegócio, Nampula, legislação..."
|
||||
className="h-12 w-full min-w-0 flex-1 rounded-xl border-0 bg-white px-4 text-base text-slate-900 outline-none placeholder:text-slate-500"
|
||||
/>
|
||||
<button
|
||||
|
||||
@@ -973,7 +973,7 @@ export default function PortalInvestidorApp({
|
||||
|
||||
function handleHeroSearch(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
router.get('/oportunidades', { q: heroQuery.trim() });
|
||||
router.get('/pesquisa', { q: heroQuery.trim() });
|
||||
}
|
||||
|
||||
function handleView(opportunity: Opportunity) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PortalMenu } from '@/layouts/portal-navigation';
|
||||
import { PortalMenu } from '@/components/portal-menu';
|
||||
import { fiscalForms, fiscalFormFields, fiscalPreparationIssues, newFiscalPreparation, readFiscalPreparation, type FiscalFormKind, type FiscalPreparation } from '@/lib/fiscal-form-preparation';
|
||||
|
||||
const input = 'mt-1 block w-full rounded border border-slate-400 bg-white p-3 text-slate-900';
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Search as SearchIcon } from 'lucide-react';
|
||||
|
||||
type Result = { title: string; description: string | null; url: string; category: string };
|
||||
type Results = { data: Result[]; total: number; current_page: number; last_page: number; per_page: number; unavailable: string[] };
|
||||
|
||||
function pageNumbers(current: number, last: number): (number | string)[] {
|
||||
const pages = [...new Set([1, last, ...Array.from({ length: 5 }, (_, i) => current - 2 + i)])]
|
||||
.filter((page) => page >= 1 && page <= last).sort((a, b) => a - b);
|
||||
return pages.flatMap((page, i) => i > 0 && page - pages[i - 1] > 1 ? [`gap-${page}`, page] : [page]);
|
||||
}
|
||||
|
||||
export default function Search({ query, results }: { query: string; results: Results }) {
|
||||
const [input, setInput] = useState(query);
|
||||
useEffect(() => setInput(query), [query]);
|
||||
const { data, total, current_page: current, last_page: last, per_page: perPage, unavailable } = results;
|
||||
const pageUrl = (page: number) => `/pesquisa?${new URLSearchParams({ q: query, page: String(page) })}`;
|
||||
const start = total ? (current - 1) * perPage + 1 : 0;
|
||||
const end = Math.min(start + data.length - 1, total);
|
||||
|
||||
return <main className="min-h-[65vh] bg-white text-[#101820]">
|
||||
<Head title="Pesquisa" />
|
||||
<div className="border-b border-slate-200 bg-[#101820] text-white">
|
||||
<div className="mx-auto max-w-5xl px-4 py-8 sm:px-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Pesquisa no InvesteMoz</h1>
|
||||
<form action="/pesquisa" role="search" className="mt-5 flex max-w-3xl flex-col gap-2 sm:flex-row">
|
||||
<label htmlFor="search-query" className="sr-only">Pesquisar no InvesteMoz</label>
|
||||
<input id="search-query" type="search" name="q" value={input} onChange={(event) => setInput(event.target.value)} maxLength={120} className="min-h-12 min-w-0 flex-1 rounded-lg border border-slate-300 bg-white px-4 text-base text-[#101820] outline-none focus:ring-2 focus:ring-[#ed1c24]" placeholder="Oportunidades, documentos, sectores..." />
|
||||
<button className="inline-flex min-h-12 items-center justify-center gap-2 rounded-lg bg-[#ed1c24] px-6 font-semibold text-white hover:bg-[#c9141b] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white"><SearchIcon size={18} aria-hidden="true" />Pesquisar</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-5xl px-4 py-7 sm:px-6">
|
||||
<p className="max-w-3xl text-sm leading-relaxed text-slate-600">Pesquise por palavras em qualquer ordem. Pode escrever com ou sem acentos. Os documentos são pesquisados pelos seus dados descritivos.</p>
|
||||
{unavailable.length > 0 && <p role="alert" className="mt-4 rounded-lg border border-amber-400 bg-amber-50 p-3 text-sm">Não foi possível consultar: {unavailable.join(', ')}. Os restantes resultados continuam disponíveis; tente novamente mais tarde.</p>}
|
||||
{query && <p className="mt-6 text-sm text-slate-600" aria-live="polite">{total > 0 ? `Resultados ${start}–${end} de ${total}` : 'Nenhum resultado nas fontes consultadas'} para “{query}”.</p>}
|
||||
{query && total === 0 && <p className="mt-4 text-slate-600">Experimente outras palavras ou uma expressão mais curta.</p>}
|
||||
<ol start={start || 1} className="mt-2 max-w-3xl divide-y divide-slate-200">
|
||||
{data.map((item) => <li key={`${item.category}:${item.url}`} className="py-5">
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-wide text-slate-600">{item.category}</p>
|
||||
<Link href={item.url} className="text-lg font-semibold text-[#b5121b] underline-offset-4 hover:underline focus-visible:rounded focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#ed1c24]">{item.title}</Link>
|
||||
{item.description && <p className="mt-1 line-clamp-3 text-sm leading-relaxed text-slate-600">{item.description}</p>}
|
||||
</li>)}
|
||||
</ol>
|
||||
{last > 1 && <nav className="mt-8 flex flex-wrap items-center gap-2" aria-label="Páginas de resultados">
|
||||
{current > 1 && <Link href={pageUrl(current - 1)} rel="prev" className="rounded-md px-3 py-2 text-sm font-semibold text-[#b5121b] hover:bg-red-50">Anterior</Link>}
|
||||
{pageNumbers(current, last).map((page) => typeof page === 'string' ? <span key={page} className="px-1 text-slate-500" aria-hidden="true">…</span> :
|
||||
<Link key={page} href={pageUrl(page)} aria-label={`Página ${page}`} aria-current={page === current ? 'page' : undefined} className={page === current ? 'rounded-md bg-[#101820] px-3 py-2 text-sm font-bold text-white' : 'rounded-md px-3 py-2 text-sm font-semibold text-[#b5121b] hover:bg-red-50'}>{page}</Link>)}
|
||||
{current < last && <Link href={pageUrl(current + 1)} rel="next" className="rounded-md px-3 py-2 text-sm font-semibold text-[#b5121b] hover:bg-red-50">Seguinte</Link>}
|
||||
</nav>}
|
||||
</div>
|
||||
</main>;
|
||||
}
|
||||
@@ -98,6 +98,7 @@ Route::get('/artigos/{id}', [\App\Http\Controllers\EditorialArticleController::c
|
||||
Route::get('/carteira', [InvestmentPortfolioController::class, 'index'])->name('portfolio.index');
|
||||
Route::get('/carteira/{id}', [InvestmentPortfolioController::class, 'show'])->whereUuid('id')->name('portfolio.show');
|
||||
Route::get('/carteira/{id}/interesse', [InvestmentPortfolioController::class, 'resumeInterest'])->whereUuid('id')->middleware(['auth', \App\Http\Middleware\VerifiedOrSimpleParticipation::class])->name('portfolio.interest');
|
||||
Route::get('/pesquisa', \App\Http\Controllers\GlobalSearchController::class)->name('portal.search');
|
||||
Route::get('/oportunidades', [InvestmentDiscoveryController::class, 'index'])->name('portal.opportunities');
|
||||
Route::get('/oportunidades/{slug}', [InvestmentDiscoveryController::class, 'detail'])->name('portal.opportunity');
|
||||
Route::get('/negocios', [PublicBusinessOpportunityController::class, 'index'])->name('business-marketplace.index');
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Controllers\InvestmentPortfolioController;
|
||||
use App\Services\FederatedSearch;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Testing\AssertableInertia as Assert;
|
||||
use Tests\TestCase;
|
||||
|
||||
final class GlobalSearchTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
}
|
||||
|
||||
public function test_empty_and_overlong_queries(): void
|
||||
{
|
||||
$this->get('/pesquisa')->assertOk()->assertInertia(fn (Assert $page) => $page->component('portal/search')->where('results.total', 0));
|
||||
$this->get('/pesquisa?q='.str_repeat('a', 121))->assertSessionHasErrors('q');
|
||||
}
|
||||
|
||||
public function test_source_failure_recalculates_the_page_without_hiding_the_other_results(): void
|
||||
{
|
||||
$sources = [
|
||||
'Documentos' => fn () => ['count' => 1, 'fetch' => fn () => throw new QueryException('testing', 'select 1', [], new \RuntimeException('Source unavailable'))],
|
||||
'Páginas' => fn () => ['count' => 1, 'fetch' => fn ($offset, $limit) => array_slice([['title' => 'Como investir', 'url' => '/como-investir']], $offset, $limit)],
|
||||
];
|
||||
$result = app(FederatedSearch::class)->page($sources, 1);
|
||||
$this->assertSame(1, $result['total']);
|
||||
$this->assertSame(['Documentos'], $result['unavailable']);
|
||||
$this->assertSame('Como investir', $result['data'][0]['title']);
|
||||
}
|
||||
|
||||
public function test_public_search_uses_the_existing_portfolio_visibility_scope(): void
|
||||
{
|
||||
if (getenv('GLOBAL_SEARCH_QA') !== '1') {
|
||||
$this->markTestSkipped('A prova com dados exige a base QA explicitamente seleccionada.');
|
||||
}
|
||||
$identity = DB::selectOne('select current_database() as name, inet_server_port() as port');
|
||||
$this->assertSame('simulator_fiscal_delta_20261021_test', $identity->name);
|
||||
$this->assertSame(55441, (int) $identity->port);
|
||||
$record = InvestmentPortfolioController::visible()->first();
|
||||
if (! $record) {
|
||||
$this->markTestSkipped('No published portfolio records in isolated QA.');
|
||||
}
|
||||
$term = mb_substr($record->title, 0, 15);
|
||||
$this->get('/pesquisa?q='.urlencode($term))->assertOk()->assertInertia(fn (Assert $page) => $page
|
||||
->component('portal/search')->where('query', $term)
|
||||
->where('results.total', fn ($total) => $total >= 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\FederatedSearch;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class FederatedSearchTest extends TestCase
|
||||
{
|
||||
public function test_global_pagination_spans_sources_without_loading_the_complete_sets(): void
|
||||
{
|
||||
$calls = [];
|
||||
$factory = function (string $prefix, int $count) use (&$calls) {
|
||||
return function () use (&$calls, $prefix, $count) {
|
||||
return ['count' => $count, 'fetch' => function ($offset, $limit) use (&$calls, $prefix, $count) {
|
||||
$calls[] = [$prefix, $offset, $limit];
|
||||
|
||||
return array_map(fn ($i) => ['title' => $prefix.$i, 'url' => '/'.$prefix.$i], array_slice(range(1, $count), $offset, $limit));
|
||||
}];
|
||||
};
|
||||
};
|
||||
$sources = ['Páginas' => $factory('p', 3), 'Oportunidades' => $factory('o', 7), 'Documentos' => $factory('d', 2)];
|
||||
$service = new FederatedSearch;
|
||||
$first = $service->page($sources, 1);
|
||||
$this->assertSame(['p1', 'p2', 'p3', 'o1', 'o2'], array_column($first['data'], 'title'));
|
||||
$this->assertSame(['Páginas', 'Páginas', 'Páginas', 'Oportunidades', 'Oportunidades'], array_column($first['data'], 'category'));
|
||||
$this->assertSame(12, $first['total']);
|
||||
$this->assertSame(3, $first['last_page']);
|
||||
$calls = [];
|
||||
$second = $service->page($sources, 2);
|
||||
$this->assertSame(['o3', 'o4', 'o5', 'o6', 'o7'], array_column($second['data'], 'title'));
|
||||
$this->assertSame([['p', 0, 5], ['o', 0, 5], ['d', 0, 5], ['o', 5, 5]], $calls);
|
||||
$third = $service->page($sources, 99);
|
||||
$this->assertSame(3, $third['current_page']);
|
||||
$this->assertSame(['d1', 'd2'], array_column($third['data'], 'title'));
|
||||
}
|
||||
|
||||
public function test_more_relevant_titles_appear_first_across_sources(): void
|
||||
{
|
||||
$factories = [
|
||||
'Serviços' => fn () => ['count' => 1, 'fetch' => fn ($offset, $limit) => array_slice([['title' => 'Informação', 'url' => '/info', 'score' => 10]], $offset, $limit)],
|
||||
'Sectores' => fn () => ['count' => 1, 'fetch' => fn ($offset, $limit) => array_slice([['title' => 'Sectores', 'url' => '/sectores', 'score' => 100]], $offset, $limit)],
|
||||
];
|
||||
$result = (new FederatedSearch)->page($factories, 1, 5, fn ($item) => $item['score']);
|
||||
$this->assertSame(['Sectores', 'Informação'], array_column($result['data'], 'title'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Http\Controllers\GlobalSearchController;
|
||||
use App\Services\SearchTerms;
|
||||
use Illuminate\Database\PostgresConnection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SearchTermsTest extends TestCase
|
||||
{
|
||||
public function test_matches_all_words_in_any_order_without_case_or_accents(): void
|
||||
{
|
||||
$search = new SearchTerms;
|
||||
$tokens = $search->tokens(' NAMPULA agronegócio Agronegócio ');
|
||||
|
||||
$this->assertSame(['nampula', 'agronegocio'], $tokens);
|
||||
$this->assertTrue($search->matches('Agronegócio em Nampula', $tokens));
|
||||
$this->assertFalse($search->matches('Agronegócio em Gaza', $tokens));
|
||||
$this->assertSame([], $search->tokens(' '));
|
||||
}
|
||||
|
||||
public function test_sql_matches_each_word_across_fields_with_bound_literal_patterns(): void
|
||||
{
|
||||
$connection = new PostgresConnection(fn () => throw new \LogicException('A database must not be opened.'));
|
||||
$query = $connection->table('records');
|
||||
$search = new SearchTerms;
|
||||
$search->constrain($query, ['title', 'description'], $search->tokens('100% a_b'));
|
||||
|
||||
$sql = $query->toSql();
|
||||
$this->assertStringContainsString('translate(lower(coalesce(title::text', $sql);
|
||||
$this->assertStringContainsString(' or ', $sql);
|
||||
$this->assertStringContainsString(' and ', $sql);
|
||||
$this->assertSame(['%100!%%', '%100!%%', '%a!_b%', '%a!_b%'], $query->getBindings());
|
||||
$this->assertStringNotContainsString('100%', $sql);
|
||||
}
|
||||
|
||||
public function test_static_services_can_be_found_without_a_database(): void
|
||||
{
|
||||
$pages = (new \ReflectionMethod(GlobalSearchController::class, 'pages'))->invoke(new GlobalSearchController);
|
||||
$search = new SearchTerms;
|
||||
$matches = array_values(array_filter($pages, fn ($page) => $search->matches(implode(' ', $page), $search->tokens('FISCAL simulador'))));
|
||||
|
||||
$this->assertCount(1, $matches);
|
||||
$this->assertSame('/simulador-fiscal', $matches[0]['url']);
|
||||
}
|
||||
|
||||
public function test_spelling_variants_common_words_and_title_relevance(): void
|
||||
{
|
||||
$search = new SearchTerms;
|
||||
$tokens = $search->tokens('setores de investimento');
|
||||
$this->assertTrue($search->matches('Sectores de investimento', $tokens));
|
||||
$this->assertFalse($search->matches('Sector de saúde', $tokens));
|
||||
$this->assertTrue($search->matches('Projectos âncora', $search->tokens('projeto')));
|
||||
$this->assertTrue($search->matches('Oportunidades de investimento em energia', $search->tokens('Como investir em energia?')));
|
||||
$this->assertGreaterThan($search->score('Como investir', 'investir', ['investir']), $search->score('Investir', 'investir', ['investir']));
|
||||
$this->assertSame(['de'], $search->tokens('de'));
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const require=createRequire(import.meta.url);
|
||||
const source=fs.readFileSync(new URL('../../resources/js/pages/portal/fiscal-forms.tsx',import.meta.url),'utf8');
|
||||
const compiled=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText;
|
||||
const module={exports:{}};
|
||||
new Function('require','module','exports',compiled)(name=>name==='@inertiajs/react'?{Head:()=>null,Link:({href,children,...props})=>React.createElement('a',{href,...props},children),router:{on:()=>()=>{}}}:name==='@/layouts/portal-navigation'?{PortalMenu:()=>null}:name==='@/lib/fiscal-form-preparation'?forms:require(name),module,module.exports);
|
||||
new Function('require','module','exports',compiled)(name=>name==='@inertiajs/react'?{Head:()=>null,Link:({href,children,...props})=>React.createElement('a',{href,...props},children),router:{on:()=>()=>{}}}:name==='@/components/portal-menu'?{PortalMenu:()=>null}:name==='@/lib/fiscal-form-preparation'?forms:require(name),module,module.exports);
|
||||
test('fiscal entry renders labelled editable fields and a guided preparation rather than legislation',()=>{
|
||||
const html=renderToStaticMarkup(React.createElement(module.exports.default));
|
||||
for(const label of ['Identificação e período','Nome ou designação social','NUIT','Período — ano e mês','Conferir e guardar','Guardar ficheiro para continuar','Recuperar ficheiro guardado','Confirmar recuperação']) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import ts from 'typescript';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const source = fs.readFileSync(new URL('../../resources/js/pages/portal/search.tsx', import.meta.url), 'utf8');
|
||||
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText;
|
||||
const module = { exports: {} };
|
||||
new Function('require', 'module', 'exports', compiled)(name => name === '@inertiajs/react' ? {
|
||||
Head: () => null,
|
||||
Link: ({ href, children, ...props }) => React.createElement('a', { href, ...props }, children),
|
||||
} : name === 'lucide-react' ? { Search: () => null } : require(name), module, module.exports);
|
||||
const Search = module.exports.default;
|
||||
|
||||
test('shows five results in one sequence with numbered pagination and the query preserved', () => {
|
||||
const results = { data: [{ title: 'Energia em Nampula', description: 'Projecto público', url: '/oportunidades/energia', category: 'Oportunidades' }], total: 12, current_page: 1, last_page: 3, per_page: 5, unavailable: [] };
|
||||
const html = renderToStaticMarkup(React.createElement(Search, { query: 'energia', results }));
|
||||
assert.match(html, /Oportunidades/);
|
||||
assert.match(html, /Resultados 1–1 de 12/);
|
||||
assert.match(html, /href="\/oportunidades\/energia"/);
|
||||
assert.match(html, /q=energia&page=2/);
|
||||
assert.match(html, /aria-current="page"/);
|
||||
assert.doesNotMatch(html, /emerald/);
|
||||
});
|
||||
|
||||
test('makes source failures explicit without hiding other results', () => {
|
||||
const results = { data: [], total: 0, current_page: 1, last_page: 1, per_page: 5, unavailable: ['Documentos e legislação'] };
|
||||
const html = renderToStaticMarkup(React.createElement(Search, { query: 'terra', results }));
|
||||
assert.match(html, /role="alert"/);
|
||||
assert.match(html, /Não foi possível consultar: Documentos e legislação/);
|
||||
assert.match(html, /Nenhum resultado nas fontes consultadas/);
|
||||
});
|
||||
Reference in New Issue
Block a user