Add player search read model

This commit is contained in:
devRaGonSa
2026-06-09 10:31:21 +02:00
parent 3834662b23
commit 73a6c1afe1
6 changed files with 1144 additions and 3 deletions

View File

@@ -128,11 +128,16 @@ from app.routes import resolve_get_payload
from app.config import use_postgres_rcon_storage
import app.postgres_rcon_storage as postgres_rcon_storage
import app.rcon_historical_leaderboards as ranking_leaderboards
import app.rcon_historical_player_stats as player_search_stats
initialize_ranking_snapshot_storage = ranking_leaderboards.initialize_ranking_snapshot_storage
generate_ranking_snapshot = ranking_leaderboards.generate_ranking_snapshot
get_latest_ranking_snapshot = ranking_leaderboards.get_latest_ranking_snapshot
refresh_ranking_snapshots = ranking_leaderboards.refresh_ranking_snapshots
initialize_player_search_index_storage = player_search_stats.initialize_player_search_index_storage
refresh_player_search_index = player_search_stats.refresh_player_search_index
search_rcon_materialized_players = player_search_stats.search_rcon_materialized_players
MATCH_RESULT_SOURCE = player_search_stats.MATCH_RESULT_SOURCE
def require(condition, message):
@@ -539,6 +544,283 @@ def validate_ranking_snapshot_postgres_selection():
postgres_rcon_storage.connect_postgres_compat = original_connect_postgres_compat
def validate_postgres_player_search_index_schema_path():
require(
"CREATE TABLE IF NOT EXISTS player_search_index" in postgres_rcon_storage.RCON_SCHEMA_SQL,
"PostgreSQL schema should define player_search_index.",
)
require(
"CREATE INDEX IF NOT EXISTS idx_player_search_index_name" in postgres_rcon_storage.RCON_SCHEMA_SQL,
"PostgreSQL schema should define the player_search_index normalized name index.",
)
original_initialize_materialized = player_search_stats.initialize_rcon_materialized_storage
original_use_postgres_storage = player_search_stats.use_postgres_rcon_storage
original_initialize_postgres_storage = postgres_rcon_storage.initialize_postgres_rcon_storage
calls = {"postgres_init": 0}
player_search_stats.initialize_rcon_materialized_storage = (
lambda db_path=None: Path("backend/data/hll_vietnam_dev.sqlite3")
)
player_search_stats.use_postgres_rcon_storage = lambda explicit_sqlite_path=None: True
def fake_initialize_postgres_storage():
calls["postgres_init"] += 1
postgres_rcon_storage.initialize_postgres_rcon_storage = fake_initialize_postgres_storage
try:
initialize_player_search_index_storage()
finally:
player_search_stats.initialize_rcon_materialized_storage = original_initialize_materialized
player_search_stats.use_postgres_rcon_storage = original_use_postgres_storage
postgres_rcon_storage.initialize_postgres_rcon_storage = original_initialize_postgres_storage
require(
calls["postgres_init"] == 1,
"Player search index initialization should delegate to initialize_postgres_rcon_storage exactly once.",
)
def validate_player_search_index_cli_defaults():
original_refresh_player_search_index = player_search_stats.refresh_player_search_index
captured = {}
def fake_refresh_player_search_index(**kwargs):
captured.update(kwargs)
return {
"status": "ok",
"generated_at": datetime(2026, 6, 9, 8, 0, 0, tzinfo=timezone.utc),
"year": 2026,
"source": "rcon-materialized-admin-log",
"server_ids": ["all-servers", "comunidad-hispana-01", "comunidad-hispana-02"],
"total_rows": 3,
"results": [{"server_id": "all-servers", "row_count": 3}],
}
player_search_stats.refresh_player_search_index = fake_refresh_player_search_index
try:
stdout_buffer = StringIO()
with redirect_stdout(stdout_buffer):
exit_code = player_search_stats._main(["refresh-player-search-index"])
require(exit_code == 0, "Player search index CLI should exit 0 for a valid command.")
require(
captured.get("db_path") is None,
"Player search index CLI should use PostgreSQL-compatible db_path=None by default.",
)
serialized_default_payload = json.loads(stdout_buffer.getvalue())
require(
serialized_default_payload.get("data", {}).get("generated_at") == "2026-06-09T08:00:00Z",
"Player search index CLI should serialize datetime values as ISO strings.",
)
captured.clear()
stdout_buffer = StringIO()
with redirect_stdout(stdout_buffer):
exit_code = player_search_stats._main([
"refresh-player-search-index",
"--sqlite-path", "backend/data/hll_vietnam_dev.sqlite3",
])
require(exit_code == 0, "Player search index CLI with --sqlite-path should exit 0.")
require(
captured.get("db_path") == Path("backend/data/hll_vietnam_dev.sqlite3"),
"Player search index CLI should pass the explicit --sqlite-path override through to refresh_player_search_index.",
)
finally:
player_search_stats.refresh_player_search_index = original_refresh_player_search_index
def build_player_search_fixture():
stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
db_path = Path(f"backend/data/hll_vietnam_player_search_validation_{stamp}.sqlite3")
if db_path.exists():
try:
db_path.unlink()
except PermissionError:
pass
initialize_player_search_index_storage(db_path=db_path)
connection = sqlite3.connect(db_path)
connection.row_factory = sqlite3.Row
with connection:
connection.execute(
"""
INSERT INTO rcon_materialized_matches (
target_key, external_server_id, match_key, map_name, map_pretty_name, game_mode,
started_server_time, ended_server_time, started_at, ended_at,
allied_score, axis_score, winner, confidence_mode, source_basis
) VALUES
('comunidad-hispana-01', 'comunidad-hispana-01', 'm1', 'stmarie', 'St. Marie Du Mont', 'warfare', 1, 2, '2026-03-01T18:00:00Z', '2026-03-01T19:00:00Z', 5, 3, 'allied', 'exact', ?),
('comunidad-hispana-02', 'comunidad-hispana-02', 'm2', 'foy', 'Foy', 'warfare', 3, 4, '2026-04-01T18:00:00Z', '2026-04-01T19:00:00Z', 2, 5, 'axis', 'exact', ?),
('comunidad-hispana-01', 'comunidad-hispana-01', 'm3', 'kursk', 'Kursk', 'warfare', 5, 6, '2025-12-01T18:00:00Z', '2025-12-01T19:00:00Z', 1, 0, 'allied', 'exact', ?)
""",
(MATCH_RESULT_SOURCE, MATCH_RESULT_SOURCE, MATCH_RESULT_SOURCE),
)
connection.execute(
"""
INSERT INTO rcon_match_player_stats (
target_key, match_key, player_id, player_name, team,
kills, deaths, teamkills, deaths_by_teamkill,
weapons_json, death_by_weapons_json, most_killed_json, death_by_json,
first_seen_server_time, last_seen_server_time
) VALUES
('comunidad-hispana-01', 'm1', 'player-rambo', 'Rámbó', 'allied', 12, 4, 1, 0, '{}', '{}', '{}', '{}', 1, 2),
('comunidad-hispana-02', 'm2', 'player-rambo', 'Rambo', 'axis', 8, 6, 0, 0, '{}', '{}', '{}', '{}', 3, 4),
('comunidad-hispana-01', 'm3', 'player-rambo', 'Old Rambo', 'allied', 99, 1, 0, 0, '{}', '{}', '{}', '{}', 5, 6),
('comunidad-hispana-01', 'm1', 'player-ghost', 'Ghost', 'axis', 3, 9, 0, 0, '{}', '{}', '{}', '{}', 1, 2)
"""
)
connection.close()
return db_path
def validate_player_search_index_refresh_and_search():
db_path = build_player_search_fixture()
try:
payload = refresh_player_search_index(
db_path=db_path,
now=datetime(2026, 6, 9, 8, 0, 0, tzinfo=timezone.utc),
)
require(payload.get("status") == "ok", "Player search index refresh should return ok.")
require(payload.get("year") == 2026, "Player search index refresh should use the current UTC year.")
require_int(payload.get("total_rows"), "Player search index refresh should report numeric total_rows.")
connection = sqlite3.connect(db_path)
connection.row_factory = sqlite3.Row
rows = connection.execute(
"""
SELECT server_id, player_id, player_name, normalized_player_name,
matches_current_year, kills_current_year, deaths_current_year,
teamkills_current_year, servers_seen
FROM player_search_index
ORDER BY server_id ASC, player_id ASC
"""
).fetchall()
connection.close()
require(len(rows) >= 4, "Player search index refresh should materialize rows for all and per-server scopes.")
all_scope_row = next(
(
dict(row)
for row in rows
if row["server_id"] == "all-servers" and row["player_id"] == "player-rambo"
),
None,
)
require(all_scope_row is not None, "Player search index should contain an all-servers row for player-rambo.")
require(
all_scope_row["player_name"] == "Rambo",
"Player search index should preserve the latest current-year player display name.",
)
require(
all_scope_row["normalized_player_name"] == "rambo",
"Player search index should persist an accent-insensitive normalized player name.",
)
require(
int(all_scope_row["matches_current_year"] or 0) == 2,
"Player search index should only count current-year matches.",
)
require(
int(all_scope_row["kills_current_year"] or 0) == 20,
"Player search index should aggregate current-year kills.",
)
require(
json.loads(all_scope_row["servers_seen"]) == ["comunidad-hispana-01", "comunidad-hispana-02"],
"Player search index should preserve the list of servers seen in scope order.",
)
read_model_result = search_rcon_materialized_players(
query="Rambo",
server_id="all",
limit=10,
db_path=db_path,
)
require(
(read_model_result.get("source") or {}).get("read_model") == "player-search-index",
"Player search should prefer player_search_index when populated.",
)
require(
(read_model_result.get("source") or {}).get("fallback_used") is False,
"Player search should not report fallback when player_search_index is used.",
)
items = read_model_result.get("items") or []
require(len(items) >= 1, "Player search read model should return at least one item for Rambo.")
require(
items[0].get("player_id") == "player-rambo",
"Player search read model should return player-rambo first for a direct-name query.",
)
require(
items[0].get("matches_considered") == 2,
"Player search read model should preserve the current-year match count in the public contract.",
)
server_scoped_result = search_rcon_materialized_players(
query="Rambo",
server_id="comunidad-hispana-01",
limit=10,
db_path=db_path,
)
server_items = server_scoped_result.get("items") or []
require(
len(server_items) >= 1 and server_items[0].get("matches_considered") == 1,
"Server-scoped player search should use the server-specific index row when available.",
)
finally:
if db_path.exists():
try:
db_path.unlink()
except PermissionError:
pass
def validate_player_search_index_fallbacks():
db_path = build_player_search_fixture()
try:
fallback_result = search_rcon_materialized_players(
query="Rambo",
server_id="all",
limit=10,
db_path=db_path,
)
require(
(fallback_result.get("source") or {}).get("fallback_used") is True,
"Player search should fall back to runtime when player_search_index is empty.",
)
require(
(fallback_result.get("source") or {}).get("fallback_reason") == "player-search-index-empty",
"Player search should expose an explicit empty-index fallback reason.",
)
original_search_player_search_index = player_search_stats._search_player_search_index
def fake_search_player_search_index(**kwargs):
return None, "player-search-index-unavailable"
player_search_stats._search_player_search_index = fake_search_player_search_index
try:
unavailable_result = search_rcon_materialized_players(
query="Rambo",
server_id="all",
limit=10,
db_path=db_path,
)
finally:
player_search_stats._search_player_search_index = original_search_player_search_index
require(
(unavailable_result.get("source") or {}).get("fallback_used") is True,
"Player search should preserve runtime fallback when the read model is unavailable.",
)
require(
(unavailable_result.get("source") or {}).get("fallback_reason") == "player-search-index-unavailable",
"Player search should expose an explicit unavailable-index fallback reason.",
)
finally:
if db_path.exists():
try:
db_path.unlink()
except PermissionError:
pass
def cleanup_snapshot_fixture(db_path):
connection = sqlite3.connect(db_path)
with connection:
@@ -584,6 +866,10 @@ validate_ranking_snapshot_cli_defaults()
validate_ranking_snapshot_postgres_selection()
validate_ranking_snapshot_bulk_refresh()
validate_ranking_snapshot_bulk_partial_failure()
validate_postgres_player_search_index_schema_path()
validate_player_search_index_cli_defaults()
validate_player_search_index_refresh_and_search()
validate_player_search_index_fallbacks()
kd_metric_sql, _, _ = ranking_leaderboards._resolve_metric_sql("kd_ratio")
require(
@@ -611,6 +897,8 @@ search_data = search_payload.get("data") or {}
require(search_payload.get("status") == "ok", "Stats player search should return ok status.")
require(search_data.get("query") == "regression-check", "Stats player search should preserve query.")
require(search_data.get("server_id"), "Stats player search should include server_id.")
require(isinstance(search_data.get("source"), dict), "Stats player search should expose source metadata.")
require((search_data.get("source") or {}).get("read_model") in {"player-search-index", "rcon-materialized-admin-log-player-stats"}, "Stats player search should expose a recognized read model.")
require(isinstance(search_data.get("items"), list), "Stats player search items must be a list.")
for item in search_data["items"]: