Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Exile-aware nation filter (web UI).** Filtering the collection by nation now
includes that nation's exile cards by default — cards of another nation that
can be played in its decks (for example the Polish *T-34 76 PL* under Soviet),
matching the game client. A new **exiles** toggle (on by default) restricts
the view to a nation's own cards. The toggle is inert when no nation is
selected.
- **Web UI Sync flow**: a **Sync** button in the page header opens a
confirmation modal, runs the same fetch + diff the CLI does (with a
spinner during the request), and shows a categorized preview (new /
Expand Down
8 changes: 6 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,13 @@ browsing, sorting, filters, card details, quantity editing, and full
**Sync** and **Export** flows so the browser is the only surface needed to
manage a collection.

Filtering by nation includes that nation's **exile** cards by default — cards
that belong to another nation but can be played in its decks (for example the
Polish *T-34 76 PL* under Soviet), the way the game client shows them. Uncheck
the **exiles** toggle to restrict the view to a nation's own cards. The toggle
has no effect when no nation is selected, since every card is already listed
under its own nation.

Click **Edit** to enable quantity changes. Quantity writes are server-side
validated by rarity caps:

Expand Down
1 change: 1 addition & 0 deletions kardscm/locales/de.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions kardscm/locales/en.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions kardscm/locales/es.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions kardscm/locales/fr.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions kardscm/locales/it.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions kardscm/locales/ja.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions kardscm/locales/ko.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions kardscm/locales/pl.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions kardscm/locales/pt.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions kardscm/locales/ru.toml
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ page_title = "Коллекция kardscm"
search_placeholder = "поиск по названию…"
toggle_spawnable = "спаунятся"
toggle_reserved = "в резерве"
toggle_exiles = "в изгнании"
toggle_owned = "только мои"
filter_abilities = "Способности"
filter_extra_abilities = "Доп-способности"
Expand Down
1 change: 1 addition & 0 deletions kardscm/locales/zh-Hant.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions kardscm/locales/zh.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 9 additions & 1 deletion kardscm/web/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)

Expand Down
14 changes: 12 additions & 2 deletions kardscm/web/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions kardscm/web/templates/_filters.html
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,13 @@
{% if filters.include_reserved %}checked{% endif %}>
{{ ui.toggle_reserved }}
</label>
<label class="toggle">
{# Defaults to ON, so a hidden field submits "false" when unchecked. #}
<input type="hidden" name="exiles" value="false">
<input type="checkbox" name="exiles" value="true"
{% if filters.include_exiles %}checked{% endif %}>
{{ ui.toggle_exiles }}
</label>
<label class="toggle">
<input type="checkbox" name="owned" value="true"
{% if filters.owned_only %}checked{% endif %}>
Expand Down
89 changes: 73 additions & 16 deletions tests/web/test_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand All @@ -55,7 +56,7 @@ def _make_card(
reserved,
"", # image
can_create,
None, # exile
exile,
quantity,
)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -203,7 +220,7 @@ def test_filter_by_kredits(self, conn):

def test_filter_by_kredits_multiple(self, conn):
result = query_cards(conn, CardFilters(kredits=[3, 4]))
assert set(_ids(result)) == {"sov_tank_1", "usa_order"}
assert set(_ids(result)) == {"sov_tank_1", "usa_order", "pol_exile_tank"}

def test_text_search_matches_title_en(self, conn):
result = query_cards(conn, CardFilters(text_query="rifles"), locale_key="en-EN")
Expand Down Expand Up @@ -270,15 +287,49 @@ 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())
assert len(result) == 4
assert len(result) == 5

def test_filter_by_single_ability(self, conn):
result = query_cards(conn, CardFilters(abilities=["guard"]))
Expand All @@ -292,7 +343,7 @@ def test_filter_by_multiple_abilities_uses_or(self, conn):
def test_filter_unknown_ability_silently_ignored(self, conn):
# Unknown name not in KNOWN_ABILITIES → treated as if not provided
result = query_cards(conn, CardFilters(abilities=["bogus_ability_x"]))
assert len(result) == 4
assert len(result) == 5

def test_unknown_mixed_with_known_keeps_known(self, conn):
result = query_cards(conn, CardFilters(abilities=["guard", "bogus"]))
Expand Down Expand Up @@ -321,7 +372,7 @@ def conn_with_extras(self, conn):

def test_no_extra_ability_filter_returns_all(self, conn_with_extras):
result = query_cards(conn_with_extras, CardFilters())
assert len(result) == 4
assert len(result) == 5

def test_filter_by_single_extra_ability(self, conn_with_extras):
result = query_cards(conn_with_extras, CardFilters(extra_abilities=["resistance"]))
Expand All @@ -336,7 +387,7 @@ def test_filter_by_multiple_extra_abilities_uses_or(self, conn_with_extras):

def test_filter_unknown_extra_ability_silently_ignored(self, conn_with_extras):
result = query_cards(conn_with_extras, CardFilters(extra_abilities=["bogus_extra_x"]))
assert len(result) == 4
assert len(result) == 5

def test_extra_ability_combines_with_faction_via_and(self, conn_with_extras):
# Soviet ∩ pincer → only sov_inf_1 (usa_order has pincer but not Soviet)
Expand All @@ -359,24 +410,30 @@ def test_extra_ability_independent_from_ability(self, conn_with_extras):
class TestSort:
def test_sort_by_kredits_asc(self, conn):
result = query_cards(conn, CardFilters(), sort_col="kredits", sort_dir="asc")
assert [r["kredits"] for r in result] == [2, 2, 3, 4]
assert [r["kredits"] for r in result] == [2, 2, 3, 4, 4]

def test_sort_by_kredits_desc(self, conn):
result = query_cards(conn, CardFilters(), sort_col="kredits", sort_dir="desc")
assert [r["kredits"] for r in result] == [4, 3, 2, 2]
assert [r["kredits"] for r in result] == [4, 4, 3, 2, 2]

def test_sort_by_title_uses_locale(self, conn):
result = query_cards(
conn, CardFilters(), sort_col="title", sort_dir="asc", locale_key="en-EN"
)
ids = _ids(result)
# Air Strike < Soviet Rifles < T-34 < Wehrmacht
assert ids == ["usa_order", "sov_inf_1", "sov_tank_1", "ger_inf_1"]
# Air Strike < Soviet Rifles < T-34 < T-34 76 PL < Wehrmacht
assert ids == ["usa_order", "sov_inf_1", "sov_tank_1", "pol_exile_tank", "ger_inf_1"]

def test_invalid_sort_column_falls_back_to_default(self, conn):
result = query_cards(conn, CardFilters(), sort_col="bogus", sort_dir="asc")
# default sort = faction -> title
assert _ids(result) == ["ger_inf_1", "sov_inf_1", "sov_tank_1", "usa_order"]
assert _ids(result) == [
"ger_inf_1",
"pol_exile_tank",
"sov_inf_1",
"sov_tank_1",
"usa_order",
]

def test_invalid_sort_dir_falls_back_to_asc(self, conn):
result_default = query_cards(conn, CardFilters(), sort_col="kredits", sort_dir="asc")
Expand Down
Loading
Loading