Compare commits

5 Commits

Author SHA1 Message Date
1513bb3658 Split e2e tests into manual workflow_dispatch workflow
All checks were successful
CI / frontend-tests (push) Successful in 27s
CI / backend-tests (push) Successful in 26s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:54:25 +01:00
3b63285bd1 Fix FK violations when pruning stale routes
Some checks failed
CI / backend-tests (push) Successful in 26s
CI / frontend-tests (push) Successful in 27s
CI / e2e-tests (push) Has been cancelled
Bulk delete bypasses ORM-level cascades, so manually delete
route_encounters, nullify boss_battle.after_route_id, and skip
routes referenced by user encounters before deleting stale routes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:50:54 +01:00
4f0f881736 Update remaining FireRed boss sprites
All checks were successful
CI / backend-tests (push) Successful in 25s
CI / frontend-tests (push) Successful in 27s
CI / e2e-tests (push) Successful in 5m29s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:39:14 +01:00
dde20c932b Update Brock and Misty boss sprites
Some checks failed
CI / backend-tests (push) Successful in 26s
CI / frontend-tests (push) Successful in 27s
CI / e2e-tests (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:37:29 +01:00
efa0b5f855 Add --prune flag to seed command to remove stale data
Without --prune, seeds continue to only upsert (add/update).
With --prune, routes, encounters, and bosses not present in the
seed JSON files are deleted from the database.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 17:33:54 +01:00
15 changed files with 174 additions and 37 deletions

View File

@@ -0,0 +1,19 @@
---
# nuzlocke-tracker-ecn3
title: Prune stale seed data during seeding
status: completed
type: bug
priority: normal
created_at: 2026-02-21T16:28:37Z
updated_at: 2026-02-21T16:29:43Z
---
Seeds only upsert (add/update), they never remove routes, encounters, or bosses that no longer exist in the seed JSON. When routes are renamed, old route names persist in production.
## Fix
After upserting each entity type, delete rows not present in the seed data:
1. **Routes**: After upserting all routes for a version group, delete routes whose names are not in the seed set. FK cascades handle child routes and encounters.
2. **Encounters**: After upserting encounters for a route+game, delete encounters not in the seed data for that route+game pair.
3. **Bosses**: After upserting bosses for a version group, delete bosses with order values beyond what the seed provides.

View File

@@ -0,0 +1,11 @@
---
# nuzlocke-tracker-m8ki
title: Split e2e tests into manual workflow
status: completed
type: task
priority: normal
created_at: 2026-02-21T16:53:37Z
updated_at: 2026-02-21T16:54:04Z
---
Remove e2e-tests job from ci.yml and create a new e2e.yml workflow with workflow_dispatch trigger only.

View File

@@ -68,31 +68,4 @@ jobs:
working-directory: frontend working-directory: frontend
- name: Run tests - name: Run tests
run: npm test run: npm test
working-directory: frontend working-directory: frontend
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "24"
- name: Install dependencies
run: npm ci
working-directory: frontend
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
working-directory: frontend
- name: Run e2e tests
run: npm run test:e2e
working-directory: frontend
env:
E2E_API_URL: http://192.168.1.10:8100
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-report
path: frontend/playwright-report/

35
.github/workflows/e2e.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: E2E Tests
on:
workflow_dispatch:
permissions:
contents: read
jobs:
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "24"
- name: Install dependencies
run: npm ci
working-directory: frontend
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
working-directory: frontend
- name: Run e2e tests
run: npm run test:e2e
working-directory: frontend
env:
E2E_API_URL: http://192.168.1.10:8100
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-report
path: frontend/playwright-report/

View File

@@ -2,6 +2,7 @@
Usage: Usage:
python -m app.seeds # Run seed python -m app.seeds # Run seed
python -m app.seeds --prune # Run seed and remove stale data not in seed files
python -m app.seeds --verify # Run seed + verification python -m app.seeds --verify # Run seed + verification
python -m app.seeds --export # Export all seed data from DB to JSON files python -m app.seeds --export # Export all seed data from DB to JSON files
""" """
@@ -21,7 +22,8 @@ async def main():
await export_all() await export_all()
return return
await seed() prune = "--prune" in sys.argv
await seed(prune=prune)
if "--verify" in sys.argv: if "--verify" in sys.argv:
await verify() await verify()

View File

@@ -1,11 +1,12 @@
"""Database upsert helpers for seed data.""" """Database upsert helpers for seed data."""
from sqlalchemy import delete, select from sqlalchemy import delete, select, update
from sqlalchemy.dialects.postgresql import insert from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models.boss_battle import BossBattle from app.models.boss_battle import BossBattle
from app.models.boss_pokemon import BossPokemon from app.models.boss_pokemon import BossPokemon
from app.models.encounter import Encounter
from app.models.evolution import Evolution from app.models.evolution import Evolution
from app.models.game import Game from app.models.game import Game
from app.models.pokemon import Pokemon from app.models.pokemon import Pokemon
@@ -124,11 +125,14 @@ async def upsert_routes(
session: AsyncSession, session: AsyncSession,
version_group_id: int, version_group_id: int,
routes: list[dict], routes: list[dict],
*,
prune: bool = False,
) -> dict[str, int]: ) -> dict[str, int]:
"""Upsert route records for a version group, return {name: id} mapping. """Upsert route records for a version group, return {name: id} mapping.
Handles hierarchical routes: routes with 'children' are parent routes, Handles hierarchical routes: routes with 'children' are parent routes,
and their children get parent_route_id set accordingly. and their children get parent_route_id set accordingly.
When prune is True, deletes routes not present in the seed data.
""" """
# First pass: upsert all parent routes (without parent_route_id) # First pass: upsert all parent routes (without parent_route_id)
for route in routes: for route in routes:
@@ -185,6 +189,43 @@ async def upsert_routes(
await session.flush() await session.flush()
if prune:
seed_names: set[str] = set()
for route in routes:
seed_names.add(route["name"])
for child in route.get("children", []):
seed_names.add(child["name"])
# Find stale route IDs, excluding routes with user encounters
in_use_subq = select(Encounter.route_id).distinct().subquery()
stale_route_ids_result = await session.execute(
select(Route.id).where(
Route.version_group_id == version_group_id,
Route.name.not_in(seed_names),
Route.id.not_in(select(in_use_subq)),
)
)
stale_route_ids = [row.id for row in stale_route_ids_result]
if stale_route_ids:
# Delete encounters referencing stale routes (no DB-level cascade)
await session.execute(
delete(RouteEncounter).where(
RouteEncounter.route_id.in_(stale_route_ids)
)
)
# Nullify boss battle references to stale routes
await session.execute(
update(BossBattle)
.where(BossBattle.after_route_id.in_(stale_route_ids))
.values(after_route_id=None)
)
# Now safe to delete the routes
await session.execute(delete(Route).where(Route.id.in_(stale_route_ids)))
print(f" Pruned {len(stale_route_ids)} stale route(s)")
await session.flush()
# Return full mapping including children # Return full mapping including children
result = await session.execute( result = await session.execute(
select(Route.name, Route.id).where(Route.version_group_id == version_group_id) select(Route.name, Route.id).where(Route.version_group_id == version_group_id)
@@ -233,8 +274,15 @@ async def upsert_route_encounters(
encounters: list[dict], encounters: list[dict],
dex_to_id: dict[int, int], dex_to_id: dict[int, int],
game_id: int, game_id: int,
*,
prune: bool = False,
) -> int: ) -> int:
"""Upsert encounters for a route and game, return count of upserted rows.""" """Upsert encounters for a route and game, return count of upserted rows.
When prune is True, deletes encounters not present in the seed data.
"""
seed_keys: set[tuple[int, str, str]] = set()
count = 0 count = 0
for enc in encounters: for enc in encounters:
pokemon_id = dex_to_id.get(enc["pokeapi_id"]) pokemon_id = dex_to_id.get(enc["pokeapi_id"])
@@ -245,6 +293,7 @@ async def upsert_route_encounters(
conditions = enc.get("conditions") conditions = enc.get("conditions")
if conditions: if conditions:
for condition_name, rate in conditions.items(): for condition_name, rate in conditions.items():
seed_keys.add((pokemon_id, enc["method"], condition_name))
await _upsert_single_encounter( await _upsert_single_encounter(
session, session,
route_id, route_id,
@@ -258,6 +307,7 @@ async def upsert_route_encounters(
) )
count += 1 count += 1
else: else:
seed_keys.add((pokemon_id, enc["method"], ""))
await _upsert_single_encounter( await _upsert_single_encounter(
session, session,
route_id, route_id,
@@ -270,6 +320,23 @@ async def upsert_route_encounters(
) )
count += 1 count += 1
if prune:
existing = await session.execute(
select(RouteEncounter).where(
RouteEncounter.route_id == route_id,
RouteEncounter.game_id == game_id,
)
)
stale_ids = [
row.id
for row in existing.scalars()
if (row.pokemon_id, row.encounter_method, row.condition) not in seed_keys
]
if stale_ids:
await session.execute(
delete(RouteEncounter).where(RouteEncounter.id.in_(stale_ids))
)
return count return count
@@ -280,8 +347,13 @@ async def upsert_bosses(
dex_to_id: dict[int, int], dex_to_id: dict[int, int],
route_name_to_id: dict[str, int] | None = None, route_name_to_id: dict[str, int] | None = None,
slug_to_game_id: dict[str, int] | None = None, slug_to_game_id: dict[str, int] | None = None,
*,
prune: bool = False,
) -> int: ) -> int:
"""Upsert boss battles for a version group, return count of bosses upserted.""" """Upsert boss battles for a version group, return count of bosses upserted.
When prune is True, deletes boss battles not present in the seed data.
"""
count = 0 count = 0
for boss in bosses: for boss in bosses:
# Resolve after_route_name to an ID # Resolve after_route_name to an ID
@@ -364,6 +436,20 @@ async def upsert_bosses(
count += 1 count += 1
if prune:
seed_orders = {boss["order"] for boss in bosses}
pruned = await session.execute(
delete(BossBattle)
.where(
BossBattle.version_group_id == version_group_id,
BossBattle.order.not_in(seed_orders),
)
.returning(BossBattle.id)
)
pruned_count = len(pruned.all())
if pruned_count:
print(f" Pruned {pruned_count} stale boss battle(s)")
await session.flush() await session.flush()
return count return count

View File

@@ -38,9 +38,12 @@ def load_json(filename: str):
return json.load(f) return json.load(f)
async def seed(): async def seed(*, prune: bool = False):
"""Run the full seed process.""" """Run the full seed process.
print("Starting seed...")
When prune is True, removes DB rows not present in seed data.
"""
print("Starting seed..." + (" (with pruning)" if prune else ""))
async with async_session() as session, session.begin(): async with async_session() as session, session.begin():
# 1. Upsert version groups # 1. Upsert version groups
@@ -88,7 +91,7 @@ async def seed():
continue continue
# Upsert routes once per version group # Upsert routes once per version group
route_map = await upsert_routes(session, vg_id, routes_data) route_map = await upsert_routes(session, vg_id, routes_data, prune=prune)
route_maps_by_vg[vg_id] = route_map route_maps_by_vg[vg_id] = route_map
total_routes += len(route_map) total_routes += len(route_map)
print(f" {vg_slug}: {len(route_map)} routes") print(f" {vg_slug}: {len(route_map)} routes")
@@ -119,6 +122,7 @@ async def seed():
route["encounters"], route["encounters"],
dex_to_id, dex_to_id,
game_id, game_id,
prune=prune,
) )
total_encounters += enc_count total_encounters += enc_count
@@ -137,6 +141,7 @@ async def seed():
child["encounters"], child["encounters"],
dex_to_id, dex_to_id,
game_id, game_id,
prune=prune,
) )
total_encounters += enc_count total_encounters += enc_count
@@ -160,7 +165,13 @@ async def seed():
route_name_to_id = route_maps_by_vg.get(vg_id, {}) route_name_to_id = route_maps_by_vg.get(vg_id, {})
boss_count = await upsert_bosses( boss_count = await upsert_bosses(
session, vg_id, bosses_data, dex_to_id, route_name_to_id, slug_to_id session,
vg_id,
bosses_data,
dex_to_id,
route_name_to_id,
slug_to_id,
prune=prune,
) )
total_bosses += boss_count total_bosses += boss_count
print(f" {vg_slug}: {boss_count} bosses") print(f" {vg_slug}: {boss_count} bosses")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 905 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 758 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 703 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 774 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 826 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 707 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 754 B