Add name suggestion engine with API endpoint and tests

Expand services/naming.py with suggest_names() that picks random words
from a category while excluding nicknames already used in the run. Add
GET /runs/{run_id}/name-suggestions?count=10 endpoint that reads the
run's naming_scheme and returns filtered suggestions. Includes 12 unit
tests covering selection, exclusion, exhaustion, and cross-category
independence.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-11 21:45:04 +01:00
parent 15283ede91
commit 2ac6c23577
5 changed files with 146 additions and 10 deletions

View File

@@ -19,7 +19,7 @@ from app.schemas.run import (
RunResponse,
RunUpdate,
)
from app.services.naming import get_naming_categories
from app.services.naming import get_naming_categories, suggest_names
router = APIRouter()
@@ -29,6 +29,31 @@ async def list_naming_categories():
return get_naming_categories()
@router.get("/{run_id}/name-suggestions", response_model=list[str])
async def get_name_suggestions(
run_id: int,
count: int = 10,
session: AsyncSession = Depends(get_session),
):
run = await session.get(NuzlockeRun, run_id)
if run is None:
raise HTTPException(status_code=404, detail="Run not found")
if not run.naming_scheme:
return []
# Collect nicknames already used in this run
result = await session.execute(
select(Encounter.nickname).where(
Encounter.run_id == run_id,
Encounter.nickname.isnot(None),
)
)
used_names = {row[0] for row in result}
return suggest_names(run.naming_scheme, used_names, count)
@router.post("", response_model=RunResponse, status_code=201)
async def create_run(data: RunCreate, session: AsyncSession = Depends(get_session)):
# Validate game exists

View File

@@ -1,4 +1,5 @@
import json
import random
from functools import lru_cache
from pathlib import Path
@@ -16,3 +17,29 @@ def _load_dictionary() -> dict[str, list[str]]:
def get_naming_categories() -> list[str]:
"""Return sorted list of available naming category names."""
return sorted(_load_dictionary().keys())
def get_words_for_category(category: str) -> list[str]:
"""Return the word list for a category, or empty list if not found."""
return _load_dictionary().get(category, [])
def suggest_names(
category: str,
used_names: set[str],
count: int = 10,
) -> list[str]:
"""Pick random name suggestions from a category, excluding used names.
Returns up to `count` names. If the category is nearly exhausted,
returns fewer.
"""
words = get_words_for_category(category)
if not words:
return []
available = [w for w in words if w not in used_names]
if not available:
return []
return random.sample(available, min(count, len(available)))

View File

@@ -0,0 +1,84 @@
from unittest.mock import patch
import pytest
from app.services.naming import (
get_naming_categories,
get_words_for_category,
suggest_names,
)
MOCK_DICTIONARY = {
"mythology": ["Apollo", "Athena", "Loki", "Thor", "Zeus"],
"food": ["Basil", "Sage", "Pepper", "Saffron", "Mango"],
"space": ["Apollo", "Nova", "Nebula", "Comet", "Vega"],
}
@pytest.fixture(autouse=True)
def _mock_dictionary():
with patch("app.services.naming._load_dictionary", return_value=MOCK_DICTIONARY):
yield
class TestGetNamingCategories:
def test_returns_sorted_categories(self):
result = get_naming_categories()
assert result == ["food", "mythology", "space"]
def test_returns_empty_for_empty_dictionary(self):
with patch("app.services.naming._load_dictionary", return_value={}):
assert get_naming_categories() == []
class TestGetWordsForCategory:
def test_returns_words_for_valid_category(self):
result = get_words_for_category("mythology")
assert result == ["Apollo", "Athena", "Loki", "Thor", "Zeus"]
def test_returns_empty_for_unknown_category(self):
assert get_words_for_category("nonexistent") == []
class TestSuggestNames:
def test_returns_requested_count(self):
result = suggest_names("mythology", set(), count=3)
assert len(result) == 3
assert all(name in MOCK_DICTIONARY["mythology"] for name in result)
def test_excludes_used_names(self):
used = {"Apollo", "Athena", "Loki"}
result = suggest_names("mythology", used, count=10)
assert set(result) <= {"Thor", "Zeus"}
assert not set(result) & used
def test_returns_fewer_when_category_nearly_exhausted(self):
used = {"Apollo", "Athena", "Loki", "Thor"}
result = suggest_names("mythology", used, count=10)
assert result == ["Zeus"]
def test_returns_empty_when_category_fully_exhausted(self):
used = {"Apollo", "Athena", "Loki", "Thor", "Zeus"}
result = suggest_names("mythology", used, count=10)
assert result == []
def test_returns_empty_for_unknown_category(self):
result = suggest_names("nonexistent", set(), count=10)
assert result == []
def test_no_duplicates_in_suggestions(self):
result = suggest_names("mythology", set(), count=5)
assert len(result) == len(set(result))
def test_default_count_is_ten(self):
# food has 5 words, so we should get all 5
result = suggest_names("food", set())
assert len(result) == 5
def test_cross_category_names_handled_independently(self):
# "Apollo" used in mythology shouldn't affect space
used = {"Apollo"}
mythology_result = suggest_names("mythology", used, count=10)
space_result = suggest_names("space", used, count=10)
assert "Apollo" not in mythology_result
assert "Apollo" not in space_result