Release: test infrastructure, rules overhaul, and design refresh #30
@@ -1,10 +1,11 @@
|
|||||||
---
|
---
|
||||||
# nuzlocke-tracker-iam7
|
# nuzlocke-tracker-iam7
|
||||||
title: Unit tests for services layer
|
title: Unit tests for services layer
|
||||||
status: draft
|
status: completed
|
||||||
type: task
|
type: task
|
||||||
|
priority: normal
|
||||||
created_at: 2026-02-10T09:33:08Z
|
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
|
parent: nuzlocke-tracker-yzpb
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -12,10 +13,13 @@ Write unit tests for the business logic in `backend/src/app/services/`. Currentl
|
|||||||
|
|
||||||
## Checklist
|
## Checklist
|
||||||
|
|
||||||
- [ ] Test family resolution with simple linear evolution chains (e.g. A → B → C)
|
- [x] Test family resolution with simple linear evolution chains (e.g. A → B → C)
|
||||||
- [ ] Test family resolution with branching evolutions (e.g. Eevee)
|
- [x] Test family resolution with branching evolutions (e.g. Eevee / Shedinja)
|
||||||
- [ ] Test family resolution with region-specific evolutions
|
- [x] Test disjoint chains remain separate families
|
||||||
- [ ] Test edge cases: single-stage Pokemon, circular references (if possible), missing data
|
- [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
|
## Notes
|
||||||
|
|
||||||
|
|||||||
174
backend/tests/test_services.py
Normal file
174
backend/tests/test_services.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user