Merge branch 'front' of https://git.tempo.co.mz/nala/Investidor-V2
tests / ci (push) Waiting to run

This commit is contained in:
2026-11-05 14:59:29 +02:00
3 changed files with 615 additions and 51 deletions
@@ -29,7 +29,8 @@ class GlobalSearchController extends Controller
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)));
$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)
?: strcmp($a['title'], $b['title']) ?: strcmp($a['url'], $b['url']));
+559 -40
View File
@@ -1,54 +1,573 @@
import { Head, Link } from '@inertiajs/react';
import { useEffect, useState } from 'react';
import { Search as SearchIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
ArrowUpRight,
ChevronLeft,
ChevronRight,
Compass,
Factory,
FileText,
Handshake,
Landmark,
Layers,
MapPin,
Newspaper,
Search as SearchIcon,
Store,
TrendingUp,
} from 'lucide-react';
import type { LucideIcon } 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[] };
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[];
};
type CategoryMeta = { icon: LucideIcon; iconClass: string; badgeClass: string };
const CATEGORY_META: Record<string, CategoryMeta> = {
'Páginas e serviços': {
icon: Compass,
iconClass: 'bg-slate-100 text-slate-700',
badgeClass: 'bg-slate-100 text-slate-700',
},
Oportunidades: {
icon: TrendingUp,
iconClass: 'bg-red-50 text-[#b5121b]',
badgeClass: 'bg-red-50 text-[#b5121b]',
},
Negócios: {
icon: Store,
iconClass: 'bg-amber-50 text-amber-700',
badgeClass: 'bg-amber-50 text-amber-700',
},
'Carteira: projectos, clusters e corredores': {
icon: Layers,
iconClass: 'bg-indigo-50 text-indigo-700',
badgeClass: 'bg-indigo-50 text-indigo-700',
},
'Documentos e legislação': {
icon: FileText,
iconClass: 'bg-blue-50 text-blue-700',
badgeClass: 'bg-blue-50 text-blue-700',
},
Artigos: {
icon: Newspaper,
iconClass: 'bg-violet-50 text-violet-700',
badgeClass: 'bg-violet-50 text-violet-700',
},
Sectores: {
icon: Factory,
iconClass: 'bg-sky-50 text-sky-700',
badgeClass: 'bg-sky-50 text-sky-700',
},
Localizações: {
icon: MapPin,
iconClass: 'bg-teal-50 text-teal-700',
badgeClass: 'bg-teal-50 text-teal-700',
},
'Zonas de investimento': {
icon: Landmark,
iconClass: 'bg-cyan-50 text-cyan-700',
badgeClass: 'bg-cyan-50 text-cyan-700',
},
Parcerias: {
icon: Handshake,
iconClass: 'bg-orange-50 text-orange-700',
badgeClass: 'bg-orange-50 text-orange-700',
},
};
const DEFAULT_META: CategoryMeta = {
icon: Compass,
iconClass: 'bg-slate-100 text-slate-700',
badgeClass: 'bg-slate-100 text-slate-700',
};
const VARIANT_GROUPS = [
['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'],
];
const ACCENT_CLASSES: Record<string, string> = {
a: '[aáàâãä]',
e: '[eéèêë]',
i: '[iíìîï]',
o: '[oóòôõö]',
u: '[uúùûü]',
c: '[cç]',
n: '[nñ]',
};
const QUICK_LINKS = [
{
label: 'Sectores de investimento',
description: 'Áreas prioritárias',
href: '/sectores',
icon: Factory,
},
{
label: 'Oportunidades',
description: 'Projectos por sector e localização',
href: '/oportunidades',
icon: TrendingUp,
},
{
label: 'Documentos',
description: 'Acervo e legislação',
href: '/documentos',
icon: FileText,
},
{
label: 'Zonas de investimento',
description: 'ZEE, ZFI e parques',
href: '/zonas',
icon: Landmark,
},
{
label: 'Parcerias',
description: 'Parceiros e propostas',
href: '/parcerias',
icon: Handshake,
},
];
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function baseLetters(value: string): string {
return value
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
function accentInsensitivePattern(token: string): string {
return baseLetters(token)
.split('')
.map((letter) => ACCENT_CLASSES[letter] ?? escapeRegExp(letter))
.join('');
}
function tokenVariants(token: string): string[] {
const group = VARIANT_GROUPS.find((variants) =>
variants.includes(baseLetters(token)),
);
return group ?? [token];
}
function buildHighlightRegex(term: string): RegExp | null {
const tokens = [
...new Set(
term
.toLowerCase()
.split(/\s+/u)
.map((token) => token.replace(/[.,;:!?"'()[\]{}]/g, ''))
.filter((token) => token.length > 1),
),
];
if (!tokens.length) return null;
const source = tokens
.map((token) => {
const variants = tokenVariants(token).map(accentInsensitivePattern);
return variants.length > 1
? `(?:${variants.join('|')})`
: variants[0];
})
.join('|');
try {
return new RegExp(`(${source})`, 'gi');
} catch {
return null;
}
}
function Highlight({
text,
regex,
}: {
text: string | null;
regex: RegExp | null;
}) {
if (!text) return null;
if (!regex) return <>{text}</>;
return (
<>
{text.split(regex).map((part, index) =>
index % 2 === 1 ? (
<mark
key={index}
className="rounded-sm bg-[#ed1c24]/10 px-0.5 text-inherit"
>
{part}
</mark>
) : (
<span key={index}>{part}</span>
),
)}
</>
);
}
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]);
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 }) {
function QuickLinks() {
return (
<div className="grid gap-2 text-left sm:grid-cols-2 lg:grid-cols-3">
{QUICK_LINKS.map((link) => {
const Icon = link.icon;
return (
<Link
key={link.href}
href={link.href}
className="group flex items-center gap-3 rounded-xl border border-slate-200 bg-white p-3 transition-colors hover:border-[#ed1c24]/40 hover:bg-red-50/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-700 transition-colors group-hover:bg-[#ed1c24] group-hover:text-white">
<Icon size={18} aria-hidden="true" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-[#101820]">
{link.label}
</span>
<span className="block truncate text-xs text-slate-500">
{link.description}
</span>
</span>
<ArrowUpRight
size={16}
aria-hidden="true"
className="shrink-0 text-slate-300 transition-colors group-hover:text-[#ed1c24]"
/>
</Link>
);
})}
</div>
);
}
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 {
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);
const highlight = useMemo(() => buildHighlightRegex(query), [query]);
return (
<main className="min-h-[65vh] bg-slate-50 text-[#101820]">
<Head title="Pesquisa" />
<section className="relative overflow-hidden bg-[#101820] text-white">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0"
>
<div className="absolute -top-32 right-0 size-80 rounded-full bg-[#ed1c24]/25 blur-3xl" />
<div className="absolute -bottom-40 left-1/4 size-80 rounded-full bg-sky-500/20 blur-3xl" />
</div>
<div className="relative mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-14">
<p className="text-xs font-bold tracking-[0.2em] text-white/60 uppercase">
Pesquisa global
</p>
<h1 className="mt-2 text-3xl font-bold tracking-tight sm:text-4xl">
Pesquisa no InvesteMoz
</h1>
<p className="mt-3 max-w-2xl text-sm leading-relaxed text-white/70">
Oportunidades, sectores, documentos, zonas e serviços
oficiais num só lugar.
</p>
<form
action="/pesquisa"
role="search"
className="mt-6 flex max-w-3xl flex-col gap-2 sm:flex-row"
>
<label htmlFor="search-query" className="sr-only">
Pesquisar no InvesteMoz
</label>
<div className="relative min-w-0 flex-1">
<SearchIcon
size={18}
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-4 -translate-y-1/2 text-slate-400"
/>
<input
id="search-query"
type="search"
name="q"
value={input}
onChange={(event) =>
setInput(event.target.value)
}
maxLength={120}
className="min-h-12 w-full rounded-xl border border-white/10 bg-white pr-4 pl-11 text-base text-[#101820] shadow-lg shadow-black/20 outline-none placeholder:text-slate-400 focus:ring-2 focus:ring-[#ed1c24]"
placeholder="Oportunidades, documentos, sectores…"
/>
</div>
<button className="inline-flex min-h-12 items-center justify-center gap-2 rounded-xl bg-[#ed1c24] px-6 font-semibold text-white shadow-lg shadow-[#ed1c24]/25 transition-colors 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>
<p className="mt-3 max-w-3xl text-xs leading-relaxed text-white/55">
Pesquise por palavras em qualquer ordem. Pode escrever
com ou sem acentos. Os documentos são pesquisados pelos
seus dados descritivos.
</p>
</div>
</section>
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>
{unavailable.length > 0 && (
<p
role="alert"
className="rounded-xl border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900"
>
Não foi possível consultar: {unavailable.join(', ')}. Os
restantes resultados continuam disponíveis; tente
novamente mais tarde.
</p>
)}
{query ? (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<p
aria-live="polite"
className="text-sm text-slate-600"
>
{total > 0
? `Resultados ${start}–${end} de ${total} para “${query}”.`
: `Nenhum resultado nas fontes consultadas para “${query}”.`}
</p>
{last > 1 && (
<span className="rounded-full bg-white px-3 py-1 text-xs font-semibold text-slate-600 ring-1 ring-slate-200">
Página {current} de {last}
</span>
)}
</div>
{total === 0 ? (
<div className="mt-6 rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center">
<span className="mx-auto flex size-12 items-center justify-center rounded-full bg-slate-100 text-slate-500">
<SearchIcon size={22} aria-hidden="true" />
</span>
<p className="mt-4 text-lg font-semibold">
Sem correspondências
</p>
<p className="mt-1 text-sm text-slate-600">
Experimente outras palavras ou uma expressão
mais curta.
</p>
<div className="mt-5">
<QuickLinks />
</div>
</div>
) : (
<ol start={start || 1} className="mt-5 space-y-3">
{data.map((item) => {
const meta =
CATEGORY_META[item.category] ??
DEFAULT_META;
const Icon = meta.icon;
return (
<li
key={`${item.category}:${item.url}`}
>
<article className="group relative flex gap-4 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm transition duration-200 hover:-translate-y-0.5 hover:border-[#ed1c24]/40 hover:shadow-lg motion-reduce:transition-none sm:p-5">
<span
aria-hidden="true"
className={`flex size-11 shrink-0 items-center justify-center rounded-xl ${meta.iconClass}`}
>
<Icon size={20} />
</span>
<div className="min-w-0 flex-1">
<p
className={`inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-bold tracking-wider uppercase ${meta.badgeClass}`}
>
{item.category}
</p>
<h2 className="mt-2 text-lg leading-snug font-bold">
<Link
href={item.url}
className="text-[#101820] underline-offset-4 transition-colors group-hover:text-[#b5121b] after:absolute after:inset-0 focus-visible:rounded-sm focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#ed1c24]"
>
<Highlight
text={
item.title
}
regex={
highlight
}
/>
</Link>
</h2>
{item.description && (
<p className="mt-1.5 line-clamp-3 text-sm leading-relaxed text-slate-600">
<Highlight
text={
item.description
}
regex={
highlight
}
/>
</p>
)}
</div>
<ArrowUpRight
size={20}
aria-hidden="true"
className="mt-1 size-5 shrink-0 text-slate-300 transition-colors group-hover:text-[#ed1c24]"
/>
</article>
</li>
);
})}
</ol>
)}
{last > 1 && (
<nav
className="mt-8 flex flex-wrap items-center justify-center gap-1.5"
aria-label="Páginas de resultados"
>
{current > 1 && (
<Link
href={pageUrl(current - 1)}
rel="prev"
className="inline-flex min-h-10 items-center gap-1 rounded-lg px-3 text-sm font-semibold text-[#b5121b] transition-colors hover:bg-red-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]"
>
<ChevronLeft
size={16}
aria-hidden="true"
/>
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
? 'inline-flex min-h-10 min-w-10 items-center justify-center rounded-lg bg-[#101820] px-3 text-sm font-bold text-white'
: 'inline-flex min-h-10 min-w-10 items-center justify-center rounded-lg px-3 text-sm font-semibold text-[#b5121b] transition-colors hover:bg-red-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]'
}
>
{page}
</Link>
),
)}
{current < last && (
<Link
href={pageUrl(current + 1)}
rel="next"
className="inline-flex min-h-10 items-center gap-1 rounded-lg px-3 text-sm font-semibold text-[#b5121b] transition-colors hover:bg-red-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#ed1c24]"
>
Seguinte
<ChevronRight
size={16}
aria-hidden="true"
/>
</Link>
)}
</nav>
)}
</>
) : (
<div className="rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center sm:p-10">
<span className="mx-auto flex size-14 items-center justify-center rounded-full bg-slate-100 text-slate-500">
<SearchIcon size={24} aria-hidden="true" />
</span>
<h2 className="mt-4 text-xl font-bold">
Comece por uma pesquisa
</h2>
<p className="mx-auto mt-2 max-w-md text-sm leading-relaxed text-slate-600">
Encontre oportunidades, sectores, documentos e
serviços do portal. Os resultados reúnem as fontes
públicas disponíveis.
</p>
<div className="mt-5">
<QuickLinks />
</div>
</div>
)}
</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>;
</main>
);
}
+54 -10
View File
@@ -7,18 +7,53 @@ 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 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);
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'
? new Proxy({}, { get: () => () => 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 }));
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"/);
@@ -28,8 +63,17 @@ test('shows five results in one sequence with numbered pagination and the query
});
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 }));
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/);