Compare commits
5 Commits
d535433583
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 1513bb3658 | |||
| 3b63285bd1 | |||
| 4f0f881736 | |||
| dde20c932b | |||
| efa0b5f855 |
@@ -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.
|
||||||
@@ -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.
|
||||||
29
.github/workflows/ci.yml
vendored
@@ -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
@@ -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/
|
||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 905 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 758 B |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 696 B |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 703 B |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 774 B |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 826 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 707 B |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 754 B |