From 64ee4f0d41fa52b113083217b84f05afbf67d6cc Mon Sep 17 00:00:00 2001 From: Alexander Burchenko Date: Sat, 18 Jul 2026 00:44:32 +0300 Subject: [PATCH 1/4] docs: note exile affects collection faction filter --- CONTEXT.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 4bebb0b..f6d9e81 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -56,8 +56,12 @@ the game, it does not police it. **Exile**: A cross-faction link on a card: the card belongs to one faction but may be played in decks of another faction (its exile faction), reflecting the -game's exile-forces mechanic. Deck import falls back to the exile link when -a card is not found under its own faction. +game's exile-forces mechanic. The card keeps its own faction (a Polish +Exile card is still Poland); the link only grants a second faction it can +be used by. Deck import falls back to the exile link when a card is not +found under its own faction. Collection filtering by faction includes a +faction's Exile cards by default (as the game client does), toggleable in +the web UI. **Diff**: The comparison of card content between the local database and a fresh API From 9751cab821b8235aabae6920daaf1e340554136f Mon Sep 17 00:00:00 2001 From: Alexander Burchenko Date: Sat, 18 Jul 2026 01:02:02 +0300 Subject: [PATCH 2/4] test: add failing tests for exile collection filter --- tests/web/test_queries.py | 65 +++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 6 deletions(-) diff --git a/tests/web/test_queries.py b/tests/web/test_queries.py index eb62fdd..e9737f0 100644 --- a/tests/web/test_queries.py +++ b/tests/web/test_queries.py @@ -33,6 +33,7 @@ def _make_card( can_create: str | None = None, quantity: int = 0, abilities: frozenset[str] = frozenset(), + exile: str | None = None, ) -> tuple: title = json.dumps({"en-EN": title_en, "ru-RU": title_ru}) text = json.dumps({"en-EN": text_en, "ru-RU": text_ru}) @@ -55,7 +56,7 @@ def _make_card( reserved, "", # image can_create, - None, # exile + exile, quantity, ) @@ -137,6 +138,20 @@ def conn() -> sqlite3.Connection: title_ru="Спаунованная карта", quantity=0, ), + _make_card( + card_id="pol_exile_tank", + faction="Poland", + card_type="tank", + rarity="Standard", + card_set="Legions", + title_en="T-34 76 PL", + title_ru="Т-34 76 PL", + kredits=4, + attack=4, + defense=4, + quantity=0, + exile="Soviet", + ), ] ability_cols = ", ".join(_ABILITY_COLS) ability_placeholders = ", ".join("?" for _ in KNOWN_ABILITIES) @@ -167,18 +182,20 @@ def test_no_filters_excludes_reserved_and_spawnable(self, conn): result = query_cards(conn, CardFilters()) assert "reserved_card" not in _ids(result) assert "spawnable_card" not in _ids(result) - assert len(result) == 4 + # An exile card is a normal card under its own faction: shown by default. + assert "pol_exile_tank" in _ids(result) + assert len(result) == 5 def test_default_sort_is_faction_then_title(self, conn): result = query_cards(conn, CardFilters(), locale_key="en-EN") ids = _ids(result) - # Germany < Soviet < USA; within Soviet alphabetical by title - assert ids == ["ger_inf_1", "sov_inf_1", "sov_tank_1", "usa_order"] + # Germany < Poland < Soviet < USA; within a faction alphabetical by title + assert ids == ["ger_inf_1", "pol_exile_tank", "sov_inf_1", "sov_tank_1", "usa_order"] class TestFilters: def test_filter_by_faction(self, conn): - result = query_cards(conn, CardFilters(factions=["Soviet"])) + result = query_cards(conn, CardFilters(factions=["Soviet"], include_exiles=False)) assert _ids(result) == ["sov_inf_1", "sov_tank_1"] def test_filter_by_multiple_factions(self, conn): @@ -270,11 +287,47 @@ def test_card_with_can_create_not_filtered_as_spawnable(self, conn): def test_filters_combined_with_and(self, conn): result = query_cards( conn, - CardFilters(factions=["Soviet"], types=["tank"]), + CardFilters(factions=["Soviet"], types=["tank"], include_exiles=False), ) assert _ids(result) == ["sov_tank_1"] +class TestExileFilter: + def test_include_exiles_defaults_to_true(self): + assert CardFilters().include_exiles is True + + def test_faction_filter_includes_exiles_by_default(self, conn): + # Filtering by Soviet surfaces the Polish exile tank (exile=Soviet), + # the way the game client shows forces-in-exile in a nation's view. + result = query_cards(conn, CardFilters(factions=["Soviet"])) + assert "pol_exile_tank" in _ids(result) + assert {"sov_inf_1", "sov_tank_1"} <= set(_ids(result)) + + def test_exiles_off_excludes_them_from_faction_filter(self, conn): + result = query_cards( + conn, CardFilters(factions=["Soviet"], include_exiles=False) + ) + assert "pol_exile_tank" not in _ids(result) + + def test_exile_card_keeps_its_own_faction(self, conn): + result = query_cards(conn, CardFilters(factions=["Soviet"])) + row = next(r for r in result if r["cardId"] == "pol_exile_tank") + assert row["faction"] == "Poland" + + def test_exiles_flag_is_inert_without_a_faction_filter(self, conn): + # With no nation selected every card is already shown, so the toggle + # changes nothing either way. + on = _ids(query_cards(conn, CardFilters(include_exiles=True))) + off = _ids(query_cards(conn, CardFilters(include_exiles=False))) + assert on == off + assert "pol_exile_tank" in on + + def test_exile_only_matches_the_selected_faction(self, conn): + # The Polish tank's exile is Soviet, so a Germany filter must not pull it. + result = query_cards(conn, CardFilters(factions=["Germany"])) + assert "pol_exile_tank" not in _ids(result) + + class TestAbilityFilter: def test_no_ability_filter_returns_all(self, conn): result = query_cards(conn, CardFilters()) From 0d947ad4c69ce67427a4966825237fb40a660bf3 Mon Sep 17 00:00:00 2001 From: Alexander Burchenko Date: Sat, 18 Jul 2026 01:25:27 +0300 Subject: [PATCH 3/4] feat: exile-aware collection faction filter --- kardscm/locales/de.toml | 1 + kardscm/locales/en.toml | 1 + kardscm/locales/es.toml | 1 + kardscm/locales/fr.toml | 1 + kardscm/locales/it.toml | 1 + kardscm/locales/ja.toml | 1 + kardscm/locales/ko.toml | 1 + kardscm/locales/pl.toml | 1 + kardscm/locales/pt.toml | 1 + kardscm/locales/ru.toml | 1 + kardscm/locales/zh-Hant.toml | 1 + kardscm/locales/zh.toml | 1 + kardscm/web/deps.py | 10 +++++- kardscm/web/queries.py | 14 ++++++-- kardscm/web/templates/_filters.html | 7 ++++ tests/web/test_queries.py | 30 +++++++++-------- tests/web/test_routes.py | 51 +++++++++++++++++++++++++++-- 17 files changed, 106 insertions(+), 18 deletions(-) diff --git a/kardscm/locales/de.toml b/kardscm/locales/de.toml index 53e4e90..cd59be2 100644 --- a/kardscm/locales/de.toml +++ b/kardscm/locales/de.toml @@ -93,6 +93,7 @@ page_title = "Kards Kollektion" search_placeholder = "nach Name suchen…" toggle_spawnable = "Erstellbare" toggle_reserved = "Reservierte" +toggle_exiles = "exiles" toggle_owned = "Nur im Besitz" col_cost = "Kosten" card_id_label = "Karten-ID" diff --git a/kardscm/locales/en.toml b/kardscm/locales/en.toml index 9624cb4..5644e92 100644 --- a/kardscm/locales/en.toml +++ b/kardscm/locales/en.toml @@ -101,6 +101,7 @@ page_title = "kardscm collection" search_placeholder = "search by name…" toggle_spawnable = "spawnable" toggle_reserved = "reserved" +toggle_exiles = "exiles" toggle_owned = "only owned" filter_abilities = "Abilities" filter_extra_abilities = "Extra abilities" diff --git a/kardscm/locales/es.toml b/kardscm/locales/es.toml index 9752d21..275a493 100644 --- a/kardscm/locales/es.toml +++ b/kardscm/locales/es.toml @@ -92,6 +92,7 @@ page_title = "colección kardscm" search_placeholder = "buscar por nombre…" toggle_spawnable = "invocable" toggle_reserved = "reservado" +toggle_exiles = "exiles" toggle_owned = "solo en posesión" col_cost = "Costo" card_id_label = "ID de carta" diff --git a/kardscm/locales/fr.toml b/kardscm/locales/fr.toml index 735c48c..604dc71 100644 --- a/kardscm/locales/fr.toml +++ b/kardscm/locales/fr.toml @@ -92,6 +92,7 @@ page_title = "kardscm collection" search_placeholder = "search by name…" toggle_spawnable = "spawnable" toggle_reserved = "reserved" +toggle_exiles = "exiles" toggle_owned = "only owned" col_cost = "Cost" card_id_label = "cardId" diff --git a/kardscm/locales/it.toml b/kardscm/locales/it.toml index 0186f00..ecc6e36 100644 --- a/kardscm/locales/it.toml +++ b/kardscm/locales/it.toml @@ -92,6 +92,7 @@ page_title = "collezione kardscm" search_placeholder = "cerca per nome…" toggle_spawnable = "generabile" toggle_reserved = "riservato" +toggle_exiles = "exiles" toggle_owned = "solo possedute" col_cost = "Costo" card_id_label = "ID carta" diff --git a/kardscm/locales/ja.toml b/kardscm/locales/ja.toml index 92b2b45..812ff7d 100644 --- a/kardscm/locales/ja.toml +++ b/kardscm/locales/ja.toml @@ -93,6 +93,7 @@ page_title = "kardscm collection" search_placeholder = "search by name…" toggle_spawnable = "spawnable" toggle_reserved = "reserved" +toggle_exiles = "exiles" toggle_owned = "only owned" col_cost = "Cost" card_id_label = "cardId" diff --git a/kardscm/locales/ko.toml b/kardscm/locales/ko.toml index 4d4ad38..2938197 100644 --- a/kardscm/locales/ko.toml +++ b/kardscm/locales/ko.toml @@ -92,6 +92,7 @@ page_title = "kardscm collection" search_placeholder = "search by name…" toggle_spawnable = "spawnable" toggle_reserved = "reserved" +toggle_exiles = "exiles" toggle_owned = "only owned" col_cost = "Cost" card_id_label = "cardId" diff --git a/kardscm/locales/pl.toml b/kardscm/locales/pl.toml index 00425db..d78daa7 100644 --- a/kardscm/locales/pl.toml +++ b/kardscm/locales/pl.toml @@ -92,6 +92,7 @@ page_title = "kolekcja kardscm" search_placeholder = "szukaj po nazwie…" toggle_spawnable = "do wystawienia" toggle_reserved = "zarezerwowane" +toggle_exiles = "exiles" toggle_owned = "tylko posiadane" col_cost = "Koszt" card_id_label = "cardId" diff --git a/kardscm/locales/pt.toml b/kardscm/locales/pt.toml index 5aad38a..5c09eb5 100644 --- a/kardscm/locales/pt.toml +++ b/kardscm/locales/pt.toml @@ -92,6 +92,7 @@ page_title = "coleção kardscm" search_placeholder = "buscar por nome…" toggle_spawnable = "gerável" toggle_reserved = "reservado" +toggle_exiles = "exiles" toggle_owned = "apenas possuídas" col_cost = "Custo" card_id_label = "ID da carta" diff --git a/kardscm/locales/ru.toml b/kardscm/locales/ru.toml index 12e8794..0299904 100644 --- a/kardscm/locales/ru.toml +++ b/kardscm/locales/ru.toml @@ -92,6 +92,7 @@ page_title = "Коллекция kardscm" search_placeholder = "поиск по названию…" toggle_spawnable = "спаунятся" toggle_reserved = "в резерве" +toggle_exiles = "в изгнании" toggle_owned = "только мои" filter_abilities = "Способности" filter_extra_abilities = "Доп-способности" diff --git a/kardscm/locales/zh-Hant.toml b/kardscm/locales/zh-Hant.toml index 3f7b11f..d435f14 100644 --- a/kardscm/locales/zh-Hant.toml +++ b/kardscm/locales/zh-Hant.toml @@ -92,6 +92,7 @@ page_title = "kardscm collection" search_placeholder = "search by name…" toggle_spawnable = "spawnable" toggle_reserved = "reserved" +toggle_exiles = "exiles" toggle_owned = "only owned" col_cost = "Cost" card_id_label = "cardId" diff --git a/kardscm/locales/zh.toml b/kardscm/locales/zh.toml index 94bedb3..f8c4fc5 100644 --- a/kardscm/locales/zh.toml +++ b/kardscm/locales/zh.toml @@ -92,6 +92,7 @@ page_title = "kardscm collection" search_placeholder = "search by name…" toggle_spawnable = "spawnable" toggle_reserved = "reserved" +toggle_exiles = "exiles" toggle_owned = "only owned" col_cost = "Cost" card_id_label = "cardId" diff --git a/kardscm/web/deps.py b/kardscm/web/deps.py index dd407d0..722bf25 100644 --- a/kardscm/web/deps.py +++ b/kardscm/web/deps.py @@ -40,9 +40,16 @@ def card_filters_dep( q: str = Query(default=""), spawnable: bool = Query(default=False), reserved: bool = Query(default=False), + exiles: list[str] = Query(default=[]), owned: bool = Query(default=False), ) -> CardFilters: - """FastAPI dependency that builds CardFilters from query string params.""" + """FastAPI dependency that builds CardFilters from query string params. + + `exiles` arrives as a list because the exile toggle defaults to ON: the + template pairs a hidden ``exiles=false`` with the checkbox's ``exiles=true`` + so an unchecked box still submits a value. Absent entirely (a bare page + load with no form) means "use the default", which is ON. + """ return CardFilters( factions=factions, types=types, @@ -54,6 +61,7 @@ def card_filters_dep( text_query=q.strip(), include_spawnable=spawnable, include_reserved=reserved, + include_exiles=("true" in exiles) if exiles else True, owned_only=owned, ) diff --git a/kardscm/web/queries.py b/kardscm/web/queries.py index a6977b1..eec60ff 100644 --- a/kardscm/web/queries.py +++ b/kardscm/web/queries.py @@ -50,6 +50,7 @@ class CardFilters: text_query: str = "" include_spawnable: bool = False include_reserved: bool = False + include_exiles: bool = True owned_only: bool = False @@ -70,8 +71,17 @@ def _build_where(filters: CardFilters, locale_key: str) -> tuple[list[str], list if filters.factions: placeholders = ",".join("?" for _ in filters.factions) - where.append(f"faction IN ({placeholders})") - params.extend(filters.factions) + if filters.include_exiles: + # An exile card keeps its own faction but may be played by its + # exile faction, so a nation view surfaces it too — matching the + # game client. Without a nation filter this branch is moot: every + # card is already listed under its own faction. + where.append(f"(faction IN ({placeholders}) OR exile IN ({placeholders}))") + params.extend(filters.factions) + params.extend(filters.factions) + else: + where.append(f"faction IN ({placeholders})") + params.extend(filters.factions) if filters.types: placeholders = ",".join("?" for _ in filters.types) diff --git a/kardscm/web/templates/_filters.html b/kardscm/web/templates/_filters.html index e7c887d..efe5f55 100644 --- a/kardscm/web/templates/_filters.html +++ b/kardscm/web/templates/_filters.html @@ -104,6 +104,13 @@ {% if filters.include_reserved %}checked{% endif %}> {{ ui.toggle_reserved }} +