feat(catalog): Katalogsuche Backend – RPC, Normalisierung, alternative Titel (KAT-01, 3a Backend) #64

Merged
cschlaefke merged 8 commits from feature/3a-katalog-suche-backend into main 2026-11-04 10:04:03 +00:00
3 changed files with 123 additions and 45 deletions
Showing only changes of commit 683b5c830e - Show all commits

View file

@ -305,6 +305,7 @@ Deno.test("alt_titles: Fehler anderer Art nennen die Zeilennummer", async () =>
Deno.test("approxNormalize: Marken- und Schutzzeichen, Akzente, ß, Æ, Sonderzeichen", () => {
assert.equal(approxNormalize("CATAN®"), "catan");
assert.equal(approxNormalize("Spiel™Ding"), "spielding"); // Schutzzeichen werden entfernt, nicht zum Trenner (wie search_normalize)
assert.equal(approxNormalize("Café Größe"), "cafe grosse");
assert.equal(approxNormalize("Æon’s End"), "aeon s end");
assert.equal(approxNormalize("7 Wonders: Duel"), "7 wonders duel");

View file

@ -152,7 +152,8 @@ declare
v_offset integer := coalesce(p_offset, 0);
v_q text;
v_qc text;
v_words text[] := '{}';
v_words text[] := '{}'::text[];
v_patterns text[] := '{}'::text[]; -- '%wort%' je Suchwort, für like all
v_has_query boolean := false;
v_genres uuid[];
v_mechanics uuid[];
@ -189,6 +190,7 @@ begin
v_qc := replace(v_q, ' ', '');
v_words := coalesce(
array(select distinct w from unnest(string_to_array(v_q, ' ')) as w where char_length(w) >= 2), '{}');
v_patterns := array(select '%' || w || '%' from unnest(v_words) as w);
end if;
end if;
@ -215,10 +217,10 @@ begin
where (p_players is null or (c.min_players <= p_players and p_players <= c.max_players))
and (p_max_minutes is null or coalesce(c.max_minutes, c.min_minutes) <= p_max_minutes)
and (p_age is null or c.min_age <= p_age)
and (v_genre_n = 0 or (select count(*) from public.game_genres gg
where gg.game_id = c.id and gg.genre_id = any (v_genres)) = v_genre_n)
and (v_mech_n = 0 or (select count(*) from public.game_mechanics gm
where gm.game_id = c.id and gm.mechanic_id = any (v_mechanics)) = v_mech_n)
and (v_genre_n = 0 or c.id in (select gg.game_id from public.game_genres gg where gg.genre_id = any (v_genres)
group by gg.game_id having count(*) = v_genre_n))
and (v_mech_n = 0 or c.id in (select gm.game_id from public.game_mechanics gm where gm.mechanic_id = any (v_mechanics)
group by gm.game_id having count(*) = v_mech_n))
order by g.title_search collate "C", c.id
limit v_limit offset v_offset
) s
@ -235,10 +237,10 @@ begin
where (p_players is null or (c.min_players <= p_players and p_players <= c.max_players))
and (p_max_minutes is null or coalesce(c.max_minutes, c.min_minutes) <= p_max_minutes)
and (p_age is null or c.min_age <= p_age)
and (v_genre_n = 0 or (select count(*) from public.game_genres gg
where gg.game_id = c.id and gg.genre_id = any (v_genres)) = v_genre_n)
and (v_mech_n = 0 or (select count(*) from public.game_mechanics gm
where gm.game_id = c.id and gm.mechanic_id = any (v_mechanics)) = v_mech_n)
and (v_genre_n = 0 or c.id in (select gg.game_id from public.game_genres gg where gg.genre_id = any (v_genres)
group by gg.game_id having count(*) = v_genre_n))
and (v_mech_n = 0 or c.id in (select gm.game_id from public.game_mechanics gm where gm.mechanic_id = any (v_mechanics)
group by gm.game_id having count(*) = v_mech_n))
),
-- Kandidaten-Titel: Haupttitel (alt_title null) und alle alternativen Titel
cand as (
@ -247,50 +249,72 @@ begin
select b.id, a.title_search, a.title
from base b join public.game_alt_titles a on a.game_id = b.id
),
-- Relevanzstufe je Kandidaten-Titel (null = kein Titeltreffer)
-- Relevanzstufe je Kandidaten-Titel (null = kein Titeltreffer). Nur günstige Vergleiche; die teure
-- Trigramm-Ähnlichkeit wird erst für die Treffer berechnet (sims, alt_pick).
ranked as (
select t.id, t.ts, t.alt_title,
case
when t.ts = v_q or replace(t.ts, ' ', '') = v_qc then 0
-- Stufe 0: gleich dem Suchbegriff (auch ohne Leerzeichen gleich; das schließt ts = v_q ein)
when replace(t.ts, ' ', '') = v_qc then 0
when t.ts like v_q || '%' then 1
when t.ts like '% ' || v_q || '%' then 2
when replace(t.ts, ' ', '') like '%' || v_qc || '%'
or (cardinality(v_words) > 0
and not exists (select 1 from unnest(v_words) as w where t.ts not like '%' || w || '%'))
or (cardinality(v_patterns) > 0 and t.ts like all (v_patterns))
then 3
end as rk,
extensions.similarity(t.ts, v_q) as sim
end as rk
from cand t
),
agg as (
select r.id,
min(r.rk) as best_rk,
min(r.rk) filter (where r.alt_title is null) as main_rk,
min(r.rk) filter (where r.alt_title is not null) as alt_rk,
(array_agg(r.alt_title order by r.rk, r.sim desc nulls last, r.ts, r.alt_title)
filter (where r.alt_title is not null and r.rk is not null))[1] as alt_title,
max(r.sim) as sim
min(r.rk) filter (where r.alt_title is not null) as alt_rk
from ranked r
group by r.id
),
-- der alternative Titel mit der besten Stufe (bei Gleichstand: größte Ähnlichkeit, dann Schreibweise)
alt_pick as (
select distinct on (r.id) r.id, r.alt_title
from ranked r
where r.alt_title is not null and r.rk is not null
order by r.id, r.rk, extensions.similarity(r.ts, v_q) desc, r.ts, r.alt_title
),
-- Rang 4: welches Suchwort kommt in welchem Spiel in einem Titel oder einem Verlagsnamen vor?
-- (als Mengenoperation statt korrelierter Unterabfrage je Spiel: sonst wächst die Zeit quadratisch)
word_hits as (
select r.id, w.w from ranked r cross join unnest(v_words) as w(w) where r.ts like '%' || w.w || '%'
union
select gp.game_id, w.w
from public.publishers p
join public.game_publishers gp on gp.publisher_id = p.id
cross join unnest(v_words) as w(w)
where p.name_search like '%' || w.w || '%'
),
word_cover as (
select wh.id, count(*) as n from word_hits wh group by wh.id
),
hit as (
select b.*, a.best_rk, a.main_rk, a.alt_rk, a.alt_title, a.sim
select b.*, a.best_rk, a.main_rk, a.alt_rk, ap.alt_title
from base b
join agg a on a.id = b.id
left join alt_pick ap on ap.id = b.id
left join word_cover wc on wc.id = b.id
where a.best_rk is not null
-- Rang 4: jedes Wort kommt in einem Titel oder einem Verlagsnamen des Spiels vor
or (cardinality(v_words) > 0
and not exists (
select 1 from unnest(v_words) as w
where not (exists (select 1 from ranked r2 where r2.id = b.id and r2.ts like '%' || w || '%')
or exists (select 1 from public.game_publishers gp
join public.publishers p on p.id = gp.publisher_id
where gp.game_id = b.id and p.name_search like '%' || w || '%'))))
or (cardinality(v_words) > 0 and wc.n = cardinality(v_words))
),
-- Ähnlichkeit (zweites Sortierkriterium): die höchste über Haupttitel und alle alternativen Titel
sims as (
select r.id, max(extensions.similarity(r.ts, v_q)) as sim
from ranked r
join hit h on h.id = r.id
group by r.id
),
paged as (
select h.*, coalesce(h.best_rk, 4) as rk, count(*) over () as total
select h.*, sm.sim, coalesce(h.best_rk, 4) as rk, count(*) over () as total
from hit h
order by coalesce(h.best_rk, 4), h.sim desc nulls last, h.title_search collate "C", h.id
join sims sm on sm.id = h.id
order by coalesce(h.best_rk, 4), sm.sim desc nulls last, h.title_search collate "C", h.id
limit v_limit offset v_offset
)
select s.id, s.title, s.year, s.min_players, s.max_players, s.min_minutes, s.max_minutes, s.min_age,

View file

@ -6,7 +6,7 @@
-- Ohne Suchbegriff wird nur auf Fixture-IDs geprüft (Hilfsfunktion pg_temp.hits blättert durch).
-- Die Include-Dateien in ../support/ (\ir) erzeugt supabase/catalog/build_startbestand.ts.
begin;
select plan(201);
select plan(211);
insert into auth.users (id, email, raw_user_meta_data) values
('11111111-1111-1111-1111-111111111111', 'anna@example.com', '{"age_confirmed":"true","terms_accepted":"true","terms_version":"test","display_name":"Anna"}'),
@ -116,13 +116,14 @@ select results_eq($$select polcmd::text, array(select rolname from pg_roles wher
insert into public.genres (id, name_de, name_en) values
('f9000000-0000-0000-0000-0000000000e1', 'Qxz Genre A', 'Qxz genre A'),
('f9000000-0000-0000-0000-0000000000e2', 'Qxz Genre B', 'Qxz genre B'),
('f9000000-0000-0000-0000-0000000000e3', 'Qxz Genre Q', 'Qxz genre Q');
('f9000000-0000-0000-0000-0000000000e3', 'Qxz Genre Q', 'Qxz genre Q'),
('f9000000-0000-0000-0000-0000000000e4', 'Qxz Genre R', 'Qxz genre R');
insert into public.mechanics (id, name_de, name_en) values
('f9000000-0000-0000-0000-0000000000d1', 'Qxz Mechanik A', 'Qxz mechanic A'),
('f9000000-0000-0000-0000-0000000000d2', 'Qxz Mechanik B', 'Qxz mechanic B');
insert into public.publishers (id, name) values
('f9000000-0000-0000-0000-0000000000b1', 'Qxz Hersteller Eins'),
('f9000000-0000-0000-0000-0000000000b2', 'Qxz Hersteller Zwei'),
('f9000000-0000-0000-0000-0000000000b2', 'Qxz Hersteller Eins'),
('f9000000-0000-0000-0000-0000000000b1', 'Qxz Hersteller Zwei'),
('f9000000-0000-0000-0000-0000000000b3', 'Qxz Zorb Verlag');
insert into public.games (id, title, visibility) values
@ -144,7 +145,18 @@ insert into public.games (id, title, visibility) values
('f9000000-0000-0000-0000-000000000016', 'QXZ CATAN®', 'catalog'),
('f9000000-0000-0000-0000-000000000040', 'Qxz Doppelspiel', 'catalog'),
('f9000000-0000-0000-0000-000000000041', 'Qxz Einzelspiel', 'catalog'),
('f9000000-0000-0000-0000-000000000060', 'Qxz Cabin', 'catalog');
('f9000000-0000-0000-0000-000000000060', 'Qxz Cabin', 'catalog'),
-- Sortierung ist binär (collate "C"): "qxz b" kommt vor "qxzab". Hinweis: Mit ICU-Sortierung (wie lokal) gilt das auch
-- ohne collate "C"; der Test ist dort nicht mutationsempfindlich und schützt vor libc-Sortierungen, die Leerzeichen ignorieren.
('f9000000-0000-0000-0000-000000000081', 'Qxzab', 'catalog'),
('f9000000-0000-0000-0000-000000000095', 'Qxz Zwilling', 'catalog'),
('f9000000-0000-0000-0000-000000000093', 'Qxz Zwilling', 'catalog'),
('f9000000-0000-0000-0000-000000000091', 'Qxz Zwilling', 'catalog'),
('f9000000-0000-0000-0000-000000000094', 'Qxz Zwilling', 'catalog'),
('f9000000-0000-0000-0000-000000000092', 'Qxz Zwilling', 'catalog'),
('f9000000-0000-0000-0000-000000000090', 'Qxz Zwilling', 'catalog'),
('f9000000-0000-0000-0000-000000000017', 'Qxz Wanderer', 'catalog'),
('f9000000-0000-0000-0000-000000000080', 'Qxz B', 'catalog');
-- Filter-Fixtures (20-25); 22 ohne jede Angabe, 23 nur max_minutes, 24 nur min_minutes
insert into public.games (id, title, visibility, min_players, max_players, min_minutes, max_minutes, min_age) values
@ -161,7 +173,9 @@ insert into public.game_genres (game_id, genre_id) values
('f9000000-0000-0000-0000-000000000022', 'f9000000-0000-0000-0000-0000000000e1'),
('f9000000-0000-0000-0000-000000000025', 'f9000000-0000-0000-0000-0000000000e2'),
('f9000000-0000-0000-0000-000000000005', 'f9000000-0000-0000-0000-0000000000e3'),
('f9000000-0000-0000-0000-000000000060', 'f9000000-0000-0000-0000-0000000000e3');
('f9000000-0000-0000-0000-000000000060', 'f9000000-0000-0000-0000-0000000000e3'),
('f9000000-0000-0000-0000-000000000080', 'f9000000-0000-0000-0000-0000000000e4'),
('f9000000-0000-0000-0000-000000000081', 'f9000000-0000-0000-0000-0000000000e4');
insert into public.game_mechanics (game_id, mechanic_id) values
('f9000000-0000-0000-0000-000000000020', 'f9000000-0000-0000-0000-0000000000d1'),
('f9000000-0000-0000-0000-000000000021', 'f9000000-0000-0000-0000-0000000000d1'),
@ -170,10 +184,10 @@ insert into public.game_mechanics (game_id, mechanic_id) values
-- Verlage: 01 und 30 bei Eins; 40 bei Eins und Zwei; 41 bei Zwei; 06 bei "Qxz Zorb Verlag"
insert into public.game_publishers (game_id, publisher_id) values
('f9000000-0000-0000-0000-000000000001', 'f9000000-0000-0000-0000-0000000000b1'),
('f9000000-0000-0000-0000-000000000040', 'f9000000-0000-0000-0000-0000000000b2'),
('f9000000-0000-0000-0000-000000000001', 'f9000000-0000-0000-0000-0000000000b2'),
('f9000000-0000-0000-0000-000000000040', 'f9000000-0000-0000-0000-0000000000b1'),
('f9000000-0000-0000-0000-000000000041', 'f9000000-0000-0000-0000-0000000000b2'),
('f9000000-0000-0000-0000-000000000040', 'f9000000-0000-0000-0000-0000000000b2'),
('f9000000-0000-0000-0000-000000000041', 'f9000000-0000-0000-0000-0000000000b1'),
('f9000000-0000-0000-0000-000000000006', 'f9000000-0000-0000-0000-0000000000b3');
-- Ausgeblendet, zusammengeführt, privat
@ -185,8 +199,8 @@ insert into public.games (id, title, visibility, owner_id) values
('f9000000-0000-0000-0000-000000000032', 'Qxz Privat', 'private', '11111111-1111-1111-1111-111111111111'),
('f9000000-0000-0000-0000-000000000033', 'QXZ ZORB', 'private', '22222222-2222-2222-2222-222222222222');
insert into public.game_publishers (game_id, publisher_id) values
('f9000000-0000-0000-0000-000000000030', 'f9000000-0000-0000-0000-0000000000b1'),
('f9000000-0000-0000-0000-000000000033', 'f9000000-0000-0000-0000-0000000000b1');
('f9000000-0000-0000-0000-000000000030', 'f9000000-0000-0000-0000-0000000000b2'),
('f9000000-0000-0000-0000-000000000033', 'f9000000-0000-0000-0000-0000000000b2');
-- Bilder: 50 mit Quelle ohne Erlaubnis (maskiert), 51 manuell gepflegt, 52 ohne Quelle
insert into public.games (id, title, visibility, image_path, catalog_source_id, source_ref) values
@ -211,18 +225,22 @@ insert into public.game_alt_titles (id, game_id, locale, title) values
('f9000000-0000-0000-0000-0000000000a6', 'f9000000-0000-0000-0000-000000000030', 'de', 'Qxz Verborgen Alt'),
('f9000000-0000-0000-0000-0000000000a7', 'f9000000-0000-0000-0000-000000000031', 'de', 'Qxz Verschmolzen Alt'),
('f9000000-0000-0000-0000-0000000000a8', 'f9000000-0000-0000-0000-000000000032', 'de', 'Qxz Privat Alt'),
('f9000000-0000-0000-0000-0000000000a9', 'f9000000-0000-0000-0000-000000000033', 'en', 'Qxz Bens Alt');
('f9000000-0000-0000-0000-0000000000a9', 'f9000000-0000-0000-0000-000000000033', 'en', 'Qxz Bens Alt'),
-- drei Treffer-Titel für "qxz nomad": Stufe 2, Stufe 1 (länger, weniger ähnlich), Stufe 1 (kürzer, ähnlicher)
('f9000000-0000-0000-0000-0000000000c1', 'f9000000-0000-0000-0000-000000000017', 'de', 'Stern Qxz Nomad'),
('f9000000-0000-0000-0000-0000000000c2', 'f9000000-0000-0000-0000-000000000017', 'en', 'Qxz Nomad Sternenwanderer'),
('f9000000-0000-0000-0000-0000000000c3', 'f9000000-0000-0000-0000-000000000017', 'de', 'Qxz Nomadisch');
select is((select title_search from public.games where id = 'f9000000-0000-0000-0000-000000000014'), 'qxz aeon s end',
'KAT-01 Kriterium 1: games.title_search wird beim Anlegen gefüllt');
select is((select title_search from public.game_alt_titles where id = 'f9000000-0000-0000-0000-0000000000a5'), 'qxz einzigartig alt',
'KAT-01 Kriterium 2: game_alt_titles.title_search wird gefüllt');
select is((select name_search from public.publishers where id = 'f9000000-0000-0000-0000-0000000000b2'), 'qxz hersteller zwei',
select is((select name_search from public.publishers where id = 'f9000000-0000-0000-0000-0000000000b1'), 'qxz hersteller zwei',
'KAT-01 Kriterium 3: publishers.name_search wird gefüllt');
update public.publishers set name = 'Qxz Hersteller Zwei & Söhne' where id = 'f9000000-0000-0000-0000-0000000000b2';
select is((select name_search from public.publishers where id = 'f9000000-0000-0000-0000-0000000000b2'), 'qxz hersteller zwei sohne',
update public.publishers set name = 'Qxz Hersteller Zwei & Söhne' where id = 'f9000000-0000-0000-0000-0000000000b1';
select is((select name_search from public.publishers where id = 'f9000000-0000-0000-0000-0000000000b1'), 'qxz hersteller zwei sohne',
'KAT-01: publishers.name_search folgt einer Namensänderung');
update public.publishers set name = 'Qxz Hersteller Zwei' where id = 'f9000000-0000-0000-0000-0000000000b2';
update public.publishers set name = 'Qxz Hersteller Zwei' where id = 'f9000000-0000-0000-0000-0000000000b1';
-- Hilfsfunktion: alle Fixture-Treffer durchblättern (nur als postgres, für Abfragen ohne Suchbegriff)
create function pg_temp.hits(p_query text default null, p_players smallint default null, p_max_minutes smallint default null,
@ -257,6 +275,28 @@ select results_eq($$select id, match_rank::int from public.search_catalog_games(
select results_eq($$select id from public.search_catalog_games('qxz twin')$$,
$$values ('f9000000-0000-0000-0000-000000000007'::uuid), ('f9000000-0000-0000-0000-000000000008')$$,
'KAT-01 Kriterium 5: bei völligem Gleichstand entscheidet die ID (stabile Reihenfolge)');
select results_eq($$select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 4, 0)$$,
$$values ('f9000000-0000-0000-0000-000000000001'::uuid), ('f9000000-0000-0000-0000-000000000002'), ('f9000000-0000-0000-0000-000000000003'), ('f9000000-0000-0000-0000-000000000004')$$,
'KAT-01 Kriterium 5: die erste Seite enthält die relevantesten Treffer (Stufe, dann Ähnlichkeit), nicht die ersten nach Titel');
select results_eq($$select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 2, 0)
union all select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 2, 2)
union all select id from public.search_catalog_games('qxz zorb', null, null, null, null, null, 2, 4)$$,
$$select id from public.search_catalog_games('qxz zorb')$$,
'KAT-01 Kriterium 5: Seiten zu je zwei ergeben dieselbe Reihenfolge wie die ganze Liste');
select results_eq($$select id from public.search_catalog_games('qxz twin', null, null, null, null, null, 1, 0)
union all select id from public.search_catalog_games('qxz twin', null, null, null, null, null, 1, 1)$$,
$$values ('f9000000-0000-0000-0000-000000000007'::uuid), ('f9000000-0000-0000-0000-000000000008')$$,
'KAT-01 Kriterium 5: bei völligem Gleichstand entscheidet die ID auch über die Seitengrenze hinweg');
select results_eq($$select id from public.search_catalog_games('qxz zwilling', null, null, null, null, null, 3, 0)
union all select id from public.search_catalog_games('qxz zwilling', null, null, null, null, null, 3, 3)$$,
$$select id from public.games where title = 'Qxz Zwilling' order by id$$,
'KAT-01 Kriterium 5: sechs gleich gute Treffer erscheinen seitenübergreifend in ID-Reihenfolge');
select results_eq($$select id, match_rank::int, matched_alt_title from public.search_catalog_games('qxz nomad')$$,
$$values ('f9000000-0000-0000-0000-000000000017'::uuid, 1, 'Qxz Nomadisch'::text)$$,
'KAT-01 Kriterium 2: von mehreren treffenden alternativen Titeln gewinnt die beste Stufe, dann die größte Ähnlichkeit');
select results_eq($$select id, match_rank::int, matched_alt_title from public.search_catalog_games('qxz einzig')$$,
$$values ('f9000000-0000-0000-0000-000000000012'::uuid, 1, null::text)$$,
'KAT-01 Kriterium 2: bei gleicher Stufe von Haupttitel und alternativem Titel gilt der Haupttitel (matched_alt_title null)');
select is((select count(*) from public.search_catalog_games('qxz käse')), 1::bigint, 'KAT-01 Kriterium 1: Umlaut im Suchbegriff findet den Umlaut-Titel');
select is((select count(*) from public.search_catalog_games('QXZ KASE GROSSE')), 1::bigint, 'KAT-01 Kriterium 1: ohne Umlaut und ß wird derselbe Titel gefunden');
select is((select count(*) from public.search_catalog_games('qxz grösse')), 1::bigint, 'KAT-01 Kriterium 1: ö und ß in der Eingabe');
@ -324,6 +364,9 @@ select results_eq($$select id, match_rank::int, publisher_names from public.sear
select results_eq($$select id from public.search_catalog_games('qxz hersteller eins') order by id$$,
$$values ('f9000000-0000-0000-0000-000000000001'::uuid), ('f9000000-0000-0000-0000-000000000040')$$,
'KAT-01 Kriterium 3: der andere Verlag des Spiels findet es ebenfalls; ausgeblendete Spiele des Verlags fehlen');
select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz zorb zorb eins')$$,
$$values ('f9000000-0000-0000-0000-000000000001'::uuid, 4)$$,
'KAT-01 Kriterium 3: ein doppelt eingegebenes Wort zählt einfach (Verlagstreffer bleibt auffindbar)');
select results_eq($$select id, match_rank::int from public.search_catalog_games('qxz zorb eins')$$,
$$values ('f9000000-0000-0000-0000-000000000001'::uuid, 4)$$,
'KAT-01 Kriterium 3: ein Wort aus dem Titel plus ein Wort aus dem Verlag (UND)');
@ -367,6 +410,9 @@ select results_eq($$select h from pg_temp.hits(p_genre_ids := array['f9000000-00
select results_eq($$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d2']::uuid[]) h order by 1$$,
$$values ('f9000000-0000-0000-0000-000000000021'::uuid), ('f9000000-0000-0000-0000-000000000025')$$,
'KAT-01 Kriterium 4: Mechanik allein');
select results_eq($$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d2', 'f9000000-0000-0000-0000-0000000000d2']::uuid[]) h order by 1$$,
$$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d2']::uuid[]) h order by 1$$,
'KAT-01 Kriterium 4: Duplikate im Mechanik-Array zählen einfach');
select results_eq($$select h from pg_temp.hits(p_mechanic_ids := array['f9000000-0000-0000-0000-0000000000d1', 'f9000000-0000-0000-0000-0000000000d2']::uuid[]) h$$,
$$values ('f9000000-0000-0000-0000-000000000021'::uuid)$$,
'KAT-01 Spec-F1: zwei Mechaniken: nur Spiele mit beiden');
@ -386,6 +432,13 @@ select results_eq($$select id, match_rank::int, matched_alt_title, total_count::
$$values ('f9000000-0000-0000-0000-000000000060'::uuid, null::int, null::text, 2),
('f9000000-0000-0000-0000-000000000005', null, null, 2)$$,
'KAT-01 Kriterium 8: ohne Suchbegriff nach Titel sortiert, match_rank und matched_alt_title null');
select results_eq($$select id from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[], null, 1, 0)
union all select id from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e3']::uuid[], null, 1, 1)$$,
$$values ('f9000000-0000-0000-0000-000000000060'::uuid), ('f9000000-0000-0000-0000-000000000005')$$,
'KAT-01 Kriterium 5: ohne Suchbegriff bestimmt der Titel (nicht die ID) auch die Seitengrenze');
select results_eq($$select id from public.search_catalog_games(null, null, null, null, array['f9000000-0000-0000-0000-0000000000e4']::uuid[])$$,
$$values ('f9000000-0000-0000-0000-000000000080'::uuid), ('f9000000-0000-0000-0000-000000000081')$$,
'KAT-01 Kriterium 8: ohne Suchbegriff wird binär nach dem normalisierten Titel sortiert (Leerzeichen vor Buchstaben)');
select results_eq($$select h from pg_temp.hits() h order by 1$$,
$$select id from public.games where id::text like 'f9000000%' and visibility = 'catalog' and hidden_at is null and merged_into_id is null order by id$$,
'KAT-01 Kriterium 8: ohne Suchbegriff und ohne Filter erscheinen alle sichtbaren Fixture-Spiele (ohne ausgeblendete, zusammengeführte, private)');