Add test data injection script with varied runs and encounters
Creates 6 runs across FireRed, Platinum, Emerald, HeartGold, Black, and Crystal with mixed statuses (failed/completed/active), diverse encounter states (caught/fainted/missed/evolved/dead), and varied rule configs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,22 +1,38 @@
|
||||
---
|
||||
# nuzlocke-tracker-ycfs
|
||||
title: Inject test data into dev environment
|
||||
status: todo
|
||||
status: completed
|
||||
type: task
|
||||
priority: normal
|
||||
created_at: 2026-02-06T09:48:38Z
|
||||
updated_at: 2026-02-06T09:48:38Z
|
||||
updated_at: 2026-02-07T19:35:11Z
|
||||
---
|
||||
|
||||
Create a mechanism to populate the development database with realistic test data for manual testing and development.
|
||||
Create a script to populate the development database with realistic test data for manual testing.
|
||||
|
||||
## Goals
|
||||
- Enable developers to quickly set up a populated dev environment
|
||||
- Provide realistic data scenarios for testing UI components
|
||||
- Support resetting/refreshing test data easily
|
||||
## Requirements
|
||||
- Multiple runs across different games with varied statuses:
|
||||
- 2 failed runs (different games, progressed partway)
|
||||
- 2 completed runs (different games, most routes visited)
|
||||
- 2 active/in-progress runs (different games, early-to-mid progress)
|
||||
- Each run should have a mix of encounter states:
|
||||
- Caught (alive): with nicknames, catch levels
|
||||
- Caught (dead): with faint_level, death_cause
|
||||
- Fainted (failed to catch)
|
||||
- Missed (ran away / KOd)
|
||||
- Some caught Pokemon should be evolved (current_pokemon_id set via the evolutions table)
|
||||
- Runs should use varied rule configurations (some with pinwheel clause off, some with hardcore mode, etc.)
|
||||
- Respects the one-encounter-per-group constraint (only one child route per parent group gets an encounter, unless pinwheel zones apply)
|
||||
|
||||
## Potential Checklist
|
||||
- [ ] Define test data requirements (users, playthroughs, encounters, etc.)
|
||||
- [ ] Create test data fixtures or factory functions
|
||||
- [ ] Add CLI command or script to inject test data
|
||||
- [ ] Document how to use the test data injection
|
||||
- [ ] Ensure test data can be reset/cleared if needed
|
||||
## Implementation
|
||||
- Add inject_test_data.py to backend/src/app/seeds/
|
||||
- Follows the same async session pattern as run.py
|
||||
- Queries DB for real game_ids, route_ids, pokemon_ids, and evolution chains so data is always valid
|
||||
- Invocable via python -m app.seeds.inject_test_data (standalone entry block)
|
||||
- Clears existing runs+encounters before injecting (idempotent)
|
||||
- Prints summary of what was created
|
||||
|
||||
## Checklist
|
||||
- [x] Create inject_test_data.py with test data injection logic
|
||||
- [x] Wire up as standalone script (python -m app.seeds.inject_test_data)
|
||||
- [x] Verify it runs cleanly against a seeded database
|
||||
333
backend/src/app/seeds/inject_test_data.py
Normal file
333
backend/src/app/seeds/inject_test_data.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""Inject realistic test runs + encounters into the dev database.
|
||||
|
||||
Usage:
|
||||
python -m app.seeds.inject_test_data
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import async_session
|
||||
from app.models.encounter import Encounter
|
||||
from app.models.evolution import Evolution
|
||||
from app.models.game import Game
|
||||
from app.models.nuzlocke_run import NuzlockeRun
|
||||
from app.models.pokemon import Pokemon
|
||||
from app.models.route import Route
|
||||
|
||||
random.seed(42) # reproducible data
|
||||
|
||||
# --- Nicknames pool ---
|
||||
NICKNAMES = [
|
||||
"Blaze", "Thunder", "Shadow", "Luna", "Spike", "Rex", "Cinder", "Misty",
|
||||
"Rocky", "Breeze", "Fang", "Nova", "Scout", "Atlas", "Pepper", "Storm",
|
||||
"Bandit", "Echo", "Maple", "Titan", "Ziggy", "Bolt", "Rusty", "Pearl",
|
||||
"Ivy", "Ghost", "Sunny", "Dash", "Ember", "Frost", "Jade", "Onyx",
|
||||
"Willow", "Tank", "Pip", "Mochi", "Salem", "Patches", "Bean", "Rocket",
|
||||
]
|
||||
|
||||
DEATH_CAUSES = [
|
||||
"Crit from Gym Leader's ace",
|
||||
"Poison damage after battle",
|
||||
"Rival battle ambush",
|
||||
"Self-destruct from wild Geodude",
|
||||
"Underleveled against Elite Four",
|
||||
"Double battle gone wrong",
|
||||
"Missed the switch, one-shot by Hyper Beam",
|
||||
"Wild Wobbuffet trapped and KO'd",
|
||||
"Explosion from wild Graveler",
|
||||
"Unexpected crit from trainer's Pokemon",
|
||||
]
|
||||
|
||||
# --- Run definitions ---
|
||||
# Each run specifies a game slug, name, status, rules overrides, and
|
||||
# how far through the route list it should progress (as a fraction).
|
||||
RUN_DEFS = [
|
||||
# Failed runs
|
||||
{
|
||||
"game_slug": "firered",
|
||||
"name": "Kanto Heartbreak",
|
||||
"status": "failed",
|
||||
"progress": 0.45,
|
||||
"rules": {"hardcoreMode": True, "setModeOnly": True},
|
||||
"started_days_ago": 30,
|
||||
"ended_days_ago": 20,
|
||||
},
|
||||
{
|
||||
"game_slug": "platinum",
|
||||
"name": "Sinnoh Disaster",
|
||||
"status": "failed",
|
||||
"progress": 0.30,
|
||||
"rules": {"pinwheelClause": False},
|
||||
"started_days_ago": 60,
|
||||
"ended_days_ago": 45,
|
||||
},
|
||||
# Completed runs
|
||||
{
|
||||
"game_slug": "emerald",
|
||||
"name": "Hoenn Champion Run",
|
||||
"status": "completed",
|
||||
"progress": 0.90,
|
||||
"rules": {"levelCaps": True},
|
||||
"started_days_ago": 90,
|
||||
"ended_days_ago": 70,
|
||||
},
|
||||
{
|
||||
"game_slug": "heartgold",
|
||||
"name": "Johto Victory Lap",
|
||||
"status": "completed",
|
||||
"progress": 0.85,
|
||||
"rules": {},
|
||||
"started_days_ago": 120,
|
||||
"ended_days_ago": 95,
|
||||
},
|
||||
# Active runs
|
||||
{
|
||||
"game_slug": "black",
|
||||
"name": "Unova Adventure",
|
||||
"status": "active",
|
||||
"progress": 0.35,
|
||||
"rules": {},
|
||||
"started_days_ago": 5,
|
||||
"ended_days_ago": None,
|
||||
},
|
||||
{
|
||||
"game_slug": "crystal",
|
||||
"name": "Crystal Nuzlocke",
|
||||
"status": "active",
|
||||
"progress": 0.20,
|
||||
"rules": {"hardcoreMode": True, "levelCaps": True, "setModeOnly": True},
|
||||
"started_days_ago": 2,
|
||||
"ended_days_ago": None,
|
||||
},
|
||||
]
|
||||
|
||||
# Default rules (matches frontend DEFAULT_RULES)
|
||||
DEFAULT_RULES = {
|
||||
"firstEncounterOnly": True,
|
||||
"permadeath": True,
|
||||
"nicknameRequired": True,
|
||||
"duplicatesClause": True,
|
||||
"shinyClause": True,
|
||||
"pinwheelClause": True,
|
||||
"hardcoreMode": False,
|
||||
"levelCaps": False,
|
||||
"setModeOnly": False,
|
||||
}
|
||||
|
||||
|
||||
async def get_game_by_slug(session: AsyncSession, slug: str) -> Game | None:
|
||||
result = await session.execute(select(Game).where(Game.slug == slug))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_leaf_routes(session: AsyncSession, game_id: int) -> list[Route]:
|
||||
"""Get routes that can have encounters (no children)."""
|
||||
# Get all routes for the game
|
||||
result = await session.execute(
|
||||
select(Route)
|
||||
.where(Route.game_id == game_id)
|
||||
.order_by(Route.order)
|
||||
)
|
||||
all_routes = result.scalars().all()
|
||||
|
||||
parent_ids = {r.parent_route_id for r in all_routes if r.parent_route_id is not None}
|
||||
leaf_routes = [r for r in all_routes if r.id not in parent_ids]
|
||||
return leaf_routes
|
||||
|
||||
|
||||
async def get_encounterables(
|
||||
session: AsyncSession, game_id: int
|
||||
) -> list[int]:
|
||||
"""Get pokemon IDs that appear in route encounters for this game."""
|
||||
from app.models.route_encounter import RouteEncounter
|
||||
|
||||
result = await session.execute(
|
||||
select(RouteEncounter.pokemon_id)
|
||||
.join(Route, Route.id == RouteEncounter.route_id)
|
||||
.where(Route.game_id == game_id)
|
||||
.distinct()
|
||||
)
|
||||
return [row[0] for row in result]
|
||||
|
||||
|
||||
async def get_evolution_map(session: AsyncSession) -> dict[int, list[int]]:
|
||||
"""Return {from_pokemon_id: [to_pokemon_id, ...]} for all evolutions."""
|
||||
result = await session.execute(select(Evolution.from_pokemon_id, Evolution.to_pokemon_id))
|
||||
evo_map: dict[int, list[int]] = {}
|
||||
for from_id, to_id in result:
|
||||
evo_map.setdefault(from_id, []).append(to_id)
|
||||
return evo_map
|
||||
|
||||
|
||||
def pick_routes_for_run(
|
||||
leaf_routes: list[Route], progress: float
|
||||
) -> list[Route]:
|
||||
"""Pick a subset of leaf routes respecting one-per-group.
|
||||
|
||||
For routes with a parent, only one sibling per parent_route_id is chosen.
|
||||
"""
|
||||
count = max(1, int(len(leaf_routes) * progress))
|
||||
|
||||
# Separate standalone and grouped routes
|
||||
standalone = [r for r in leaf_routes if r.parent_route_id is None]
|
||||
by_parent: dict[int, list[Route]] = {}
|
||||
for r in leaf_routes:
|
||||
if r.parent_route_id is not None:
|
||||
by_parent.setdefault(r.parent_route_id, []).append(r)
|
||||
|
||||
# Build a pool: all standalone + one random child per group
|
||||
pool: list[Route] = list(standalone)
|
||||
for children in by_parent.values():
|
||||
pool.append(random.choice(children))
|
||||
|
||||
random.shuffle(pool)
|
||||
# Take routes in order up to the target count
|
||||
pool.sort(key=lambda r: r.order)
|
||||
return pool[:count]
|
||||
|
||||
|
||||
def generate_encounter(
|
||||
run_id: int,
|
||||
route: Route,
|
||||
pokemon_ids: list[int],
|
||||
evo_map: dict[int, list[int]],
|
||||
used_pokemon: set[int],
|
||||
encounter_index: int,
|
||||
) -> Encounter:
|
||||
"""Generate a single encounter with realistic varied states."""
|
||||
# Pick a pokemon, preferring ones not yet used (duplicates clause flavor)
|
||||
available = [p for p in pokemon_ids if p not in used_pokemon]
|
||||
if not available:
|
||||
available = pokemon_ids
|
||||
pokemon_id = random.choice(available)
|
||||
used_pokemon.add(pokemon_id)
|
||||
|
||||
# Decide status with weighted distribution
|
||||
roll = random.random()
|
||||
if roll < 0.55:
|
||||
status = "caught"
|
||||
elif roll < 0.80:
|
||||
status = "fainted"
|
||||
else:
|
||||
status = "missed"
|
||||
|
||||
nickname = None
|
||||
catch_level = None
|
||||
faint_level = None
|
||||
death_cause = None
|
||||
current_pokemon_id = None
|
||||
|
||||
if status == "caught":
|
||||
nickname = NICKNAMES[encounter_index % len(NICKNAMES)]
|
||||
catch_level = random.randint(5, 55)
|
||||
|
||||
# ~30% of caught pokemon die
|
||||
if random.random() < 0.30:
|
||||
faint_level = catch_level + random.randint(1, 15)
|
||||
death_cause = random.choice(DEATH_CAUSES)
|
||||
|
||||
# ~25% of caught pokemon that are alive evolve
|
||||
if faint_level is None and random.random() < 0.25:
|
||||
evos = evo_map.get(pokemon_id, [])
|
||||
if evos:
|
||||
current_pokemon_id = random.choice(evos)
|
||||
|
||||
elif status == "fainted":
|
||||
catch_level = random.randint(5, 45)
|
||||
|
||||
return Encounter(
|
||||
run_id=run_id,
|
||||
route_id=route.id,
|
||||
pokemon_id=pokemon_id,
|
||||
nickname=nickname,
|
||||
status=status,
|
||||
catch_level=catch_level,
|
||||
faint_level=faint_level,
|
||||
death_cause=death_cause,
|
||||
current_pokemon_id=current_pokemon_id,
|
||||
)
|
||||
|
||||
|
||||
async def inject():
|
||||
"""Clear existing runs and inject test data."""
|
||||
print("Injecting test data...")
|
||||
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
# Clear existing runs and encounters
|
||||
await session.execute(delete(Encounter))
|
||||
await session.execute(delete(NuzlockeRun))
|
||||
print("Cleared existing runs and encounters")
|
||||
|
||||
evo_map = await get_evolution_map(session)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
total_runs = 0
|
||||
total_encounters = 0
|
||||
|
||||
for run_def in RUN_DEFS:
|
||||
game = await get_game_by_slug(session, run_def["game_slug"])
|
||||
if game is None:
|
||||
print(f" Warning: game '{run_def['game_slug']}' not found, skipping")
|
||||
continue
|
||||
|
||||
# Build rules
|
||||
rules = {**DEFAULT_RULES, **run_def["rules"]}
|
||||
|
||||
# Compute dates
|
||||
started_at = now - timedelta(days=run_def["started_days_ago"])
|
||||
completed_at = None
|
||||
if run_def["ended_days_ago"] is not None:
|
||||
completed_at = now - timedelta(days=run_def["ended_days_ago"])
|
||||
|
||||
run = NuzlockeRun(
|
||||
game_id=game.id,
|
||||
name=run_def["name"],
|
||||
status=run_def["status"],
|
||||
rules=rules,
|
||||
started_at=started_at,
|
||||
completed_at=completed_at,
|
||||
)
|
||||
session.add(run)
|
||||
await session.flush() # get run.id
|
||||
|
||||
# Get routes and pokemon for this game
|
||||
leaf_routes = await get_leaf_routes(session, game.id)
|
||||
pokemon_ids = await get_encounterables(session, game.id)
|
||||
|
||||
if not leaf_routes or not pokemon_ids:
|
||||
print(f" {run_def['name']}: no routes or pokemon, skipping encounters")
|
||||
total_runs += 1
|
||||
continue
|
||||
|
||||
chosen_routes = pick_routes_for_run(leaf_routes, run_def["progress"])
|
||||
used_pokemon: set[int] = set()
|
||||
|
||||
run_encounters = 0
|
||||
for i, route in enumerate(chosen_routes):
|
||||
enc = generate_encounter(
|
||||
run.id, route, pokemon_ids, evo_map, used_pokemon, i
|
||||
)
|
||||
session.add(enc)
|
||||
run_encounters += 1
|
||||
|
||||
total_runs += 1
|
||||
total_encounters += run_encounters
|
||||
|
||||
print(
|
||||
f" {run_def['name']} ({game.name}, {run_def['status']}): "
|
||||
f"{run_encounters} encounters across {len(chosen_routes)} routes"
|
||||
)
|
||||
|
||||
print(f"\nCreated {total_runs} runs with {total_encounters} total encounters")
|
||||
|
||||
print("Test data injection complete!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(inject())
|
||||
Reference in New Issue
Block a user