From ca736e0f396a3356b6bd186c208d65d63d02e7f1 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sat, 21 Feb 2026 13:05:24 +0100 Subject: [PATCH] Add unit tests for services layer 36 tests covering build_families (linear chains, branching, disjoint, Shedinja case), resolve_base_form, to_roman (parametrized), and strip_roman_suffix including round-trip verification. Co-Authored-By: Claude Opus 4.6 --- ...ker-iam7--unit-tests-for-services-layer.md | 16 +- backend/tests/test_services.py | 174 ++++++++++++++++++ 2 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 backend/tests/test_services.py diff --git a/.beans/nuzlocke-tracker-iam7--unit-tests-for-services-layer.md b/.beans/nuzlocke-tracker-iam7--unit-tests-for-services-layer.md index d8b542a..3ff9e10 100644 --- a/.beans/nuzlocke-tracker-iam7--unit-tests-for-services-layer.md +++ b/.beans/nuzlocke-tracker-iam7--unit-tests-for-services-layer.md @@ -1,10 +1,11 @@ --- # nuzlocke-tracker-iam7 title: Unit tests for services layer -status: draft +status: completed type: task +priority: normal created_at: 2026-02-10T09:33:08Z -updated_at: 2026-02-10T09:33:08Z +updated_at: 2026-02-21T12:01:23Z parent: nuzlocke-tracker-yzpb --- @@ -12,10 +13,13 @@ Write unit tests for the business logic in `backend/src/app/services/`. Currentl ## Checklist -- [ ] Test family resolution with simple linear evolution chains (e.g. A → B → C) -- [ ] Test family resolution with branching evolutions (e.g. Eevee) -- [ ] Test family resolution with region-specific evolutions -- [ ] Test edge cases: single-stage Pokemon, circular references (if possible), missing data +- [x] Test family resolution with simple linear evolution chains (e.g. A → B → C) +- [x] Test family resolution with branching evolutions (e.g. Eevee / Shedinja) +- [x] Test disjoint chains remain separate families +- [x] Test edge cases: empty list, single-stage Pokemon, base form, middle form +- [x] Test resolve_base_form: linear, branching, Shedinja, not-in-any-evolution +- [x] Test to_roman: parametrized 1–100, genlocke sequence I–V +- [x] Test strip_roman_suffix: II/III/IV/X, no suffix, round-trip with to_roman ## Notes diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py new file mode 100644 index 0000000..15085e6 --- /dev/null +++ b/backend/tests/test_services.py @@ -0,0 +1,174 @@ +"""Unit tests for the services layer (families, naming utilities).""" + +import pytest + +from app.services.families import build_families, resolve_base_form +from app.services.naming import strip_roman_suffix, to_roman + +# --------------------------------------------------------------------------- +# Minimal Evolution stand-in — only the two fields the services touch +# --------------------------------------------------------------------------- + + +class Evo: + """Lightweight stand-in for app.models.evolution.Evolution.""" + + def __init__(self, from_id: int, to_id: int) -> None: + self.from_pokemon_id = from_id + self.to_pokemon_id = to_id + + +# --------------------------------------------------------------------------- +# build_families +# --------------------------------------------------------------------------- + + +class TestBuildFamilies: + def test_empty_evolutions_returns_empty_dict(self): + assert build_families([]) == {} + + def test_linear_chain(self): + # A(1) → B(2) → C(3) + evos = [Evo(1, 2), Evo(2, 3)] + families = build_families(evos) + assert set(families[1]) == {1, 2, 3} + assert set(families[2]) == {1, 2, 3} + assert set(families[3]) == {1, 2, 3} + + def test_branching_evolutions(self): + # Eevee-like: 1 → 2, 1 → 3, 1 → 4 + evos = [Evo(1, 2), Evo(1, 3), Evo(1, 4)] + families = build_families(evos) + assert set(families[1]) == {1, 2, 3, 4} + assert set(families[2]) == {1, 2, 3, 4} + assert set(families[4]) == {1, 2, 3, 4} + + def test_disjoint_chains_are_separate_families(self): + # Chain 1→2 and independent chain 3→4 + evos = [Evo(1, 2), Evo(3, 4)] + families = build_families(evos) + assert set(families[1]) == {1, 2} + assert set(families[3]) == {3, 4} + assert 3 not in set(families[1]) + assert 1 not in set(families[3]) + + def test_shedinja_case(self): + # Nincada(1) → Ninjask(2) and Nincada(1) → Shedinja(3) + evos = [Evo(1, 2), Evo(1, 3)] + families = build_families(evos) + assert set(families[1]) == {1, 2, 3} + assert set(families[3]) == {1, 2, 3} + + def test_pokemon_not_in_any_evolution_not_in_result(self): + evos = [Evo(1, 2)] + families = build_families(evos) + assert 99 not in families + + def test_all_family_members_have_identical_family_list(self): + evos = [Evo(10, 11), Evo(11, 12)] + families = build_families(evos) + assert set(families[10]) == set(families[11]) == set(families[12]) + + +# --------------------------------------------------------------------------- +# resolve_base_form +# --------------------------------------------------------------------------- + + +class TestResolveBaseForm: + def test_pokemon_not_in_any_evolution_returns_itself(self): + assert resolve_base_form(99, []) == 99 + + def test_base_form_returns_itself(self): + # A(1) → B(2): base of 1 is still 1 + evos = [Evo(1, 2)] + assert resolve_base_form(1, evos) == 1 + + def test_final_form_returns_base(self): + # A(1) → B(2) → C(3): base of 3 is 1 + evos = [Evo(1, 2), Evo(2, 3)] + assert resolve_base_form(3, evos) == 1 + + def test_middle_form_returns_base(self): + # A(1) → B(2) → C(3): base of 2 is 1 + evos = [Evo(1, 2), Evo(2, 3)] + assert resolve_base_form(2, evos) == 1 + + def test_branching_evolution_base(self): + # 1 → 2, 1 → 3: base of both 2 and 3 is 1 + evos = [Evo(1, 2), Evo(1, 3)] + assert resolve_base_form(2, evos) == 1 + assert resolve_base_form(3, evos) == 1 + + def test_shedinja_resolves_to_nincada(self): + # Nincada(1) → Ninjask(2), Nincada(1) → Shedinja(3) + evos = [Evo(1, 2), Evo(1, 3)] + assert resolve_base_form(3, evos) == 1 + + def test_empty_evolutions_returns_self(self): + assert resolve_base_form(42, []) == 42 + + +# --------------------------------------------------------------------------- +# to_roman +# --------------------------------------------------------------------------- + + +class TestToRoman: + @pytest.mark.parametrize( + "n, expected", + [ + (1, "I"), + (2, "II"), + (3, "III"), + (4, "IV"), + (5, "V"), + (6, "VI"), + (9, "IX"), + (10, "X"), + (11, "XI"), + (14, "XIV"), + (40, "XL"), + (50, "L"), + (90, "XC"), + (100, "C"), + ], + ) + def test_converts_integer_to_roman(self, n: int, expected: str): + assert to_roman(n) == expected + + def test_typical_genlocke_sequence(self): + # Lineage names: Heracles I, II, III, IV, V + assert [to_roman(i) for i in range(1, 6)] == ["I", "II", "III", "IV", "V"] + + +# --------------------------------------------------------------------------- +# strip_roman_suffix +# --------------------------------------------------------------------------- + + +class TestStripRomanSuffix: + def test_strips_roman_numeral_ii(self): + assert strip_roman_suffix("Heracles II") == "Heracles" + + def test_strips_roman_numeral_iii(self): + assert strip_roman_suffix("Athena III") == "Athena" + + def test_strips_roman_numeral_iv(self): + assert strip_roman_suffix("Nova IV") == "Nova" + + def test_strips_roman_numeral_x(self): + assert strip_roman_suffix("Zeus X") == "Zeus" + + def test_no_suffix_returns_unchanged(self): + assert strip_roman_suffix("Apollo") == "Apollo" + + def test_name_with_i_suffix(self): + # Single "I" at end is a valid roman numeral suffix + assert strip_roman_suffix("Heracles I") == "Heracles" + + def test_round_trip_with_to_roman(self): + base = "Heracles" + for n in range(1, 6): + suffixed = f"{base} {to_roman(n)}" + assert strip_roman_suffix(suffixed) == base