From 4b6df7d5ed0d09b96d93b83d738324cb9957b2e6 Mon Sep 17 00:00:00 2001 From: devRaGonSa Date: Mon, 8 Jun 2026 18:38:08 +0200 Subject: [PATCH] Fix Stats review follow-ups --- .../TASK-179-fix-stats-review-followups.md | 116 ++++++++++ docs/stats-section-functional-plan.md | 4 +- frontend/assets/js/stats.js | 207 +++++++++++++----- scripts/run-stats-validation.ps1 | 152 ++++++++----- 4 files changed, 370 insertions(+), 109 deletions(-) create mode 100644 ai/tasks/done/TASK-179-fix-stats-review-followups.md diff --git a/ai/tasks/done/TASK-179-fix-stats-review-followups.md b/ai/tasks/done/TASK-179-fix-stats-review-followups.md new file mode 100644 index 0000000..c4c0beb --- /dev/null +++ b/ai/tasks/done/TASK-179-fix-stats-review-followups.md @@ -0,0 +1,116 @@ +--- +id: TASK-179 +title: Fix Stats Review Follow-ups +status: done +type: documentation +team: Analista +supporting_teams: + - Frontend Senior + - Backend Senior + - PM +roadmap_item: foundation +priority: medium +--- + +# TASK-179 - Fix Stats Review Follow-ups + +## Goal + +Resolver los hallazgos no bloqueantes detectados en TASK-178 sobre el bloque Stats después del hardening. + +## Context + +Tras la revisión de TASK-178 quedaron pendientes 3 correcciones de consistencia y resiliencia: +- documentación desincronizada en `timeframe`, +- fallo total de perfil al fallar en paralelo una ventana semanal/mensual, +- validación de regresión demasiado dependiente de texto literal en UI. + +El objetivo es cerrar estos puntos sin ampliar el alcance funcional del bloque Stats ni de ranking. + +## Files to Read First + +- `AGENTS.md` +- `ai/repo-context.md` +- `ai/architecture-index.md` +- `ai/tasks/done/TASK-178-review-stats-implementation-after-hardening.md` +- `docs/stats-section-functional-plan.md` +- `frontend/stats.html` +- `frontend/assets/js/stats.js` +- `scripts/run-stats-validation.ps1` +- `scripts/run-integration-tests.ps1` +- `backend/app/routes.py` + +## Steps + +1. Leer los archivos listados arriba. +2. Corregir únicamente los cambios de alcance identificados: + - actualizar documentación para reflejar `timeframe=weekly|monthly`, + - tolerar fallo parcial entre `weekly` y `monthly` en el frontend, + - endurecer el script de validación para no depender de textos literales cuando no sea necesario. +3. Ejecutar validaciones: + - `node --check frontend/assets/js/stats.js` + - `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1` + - `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` + - Servidor HTTP local para validar HTTP 200 de `stats.html` y `assets/js/stats.js`. +4. Documentar resultado en el apartado Outcome y cerrar la task. + +## Expected Files to Modify + +- `docs/stats-section-functional-plan.md` +- `frontend/assets/js/stats.js` +- `scripts/run-stats-validation.ps1` +- `ai/tasks/done/TASK-179-fix-stats-review-followups.md` + +## Constraints + +- Corregir sin agregar nuevas funcionalidades. +- No cambiar la lógica de ranking. +- No introducir o reactivar Elo/MMR. +- No reintroducir `Comunidad Hispana #03`. +- No crear migraciones. +- No modificar backend salvo bug menor estrictamente necesario. +- Mantener estilos y comportamiento existente salvo el mensaje mínimo necesario para fallo parcial. +- No ampliar la sección Stats con nuevas funcionalidades. + +## Validation + +- `node --check frontend/assets/js/stats.js` +- `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1` +- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` +- Servir frontend y confirmar HTTP 200 para `stats.html` y `assets/js/stats.js`. +- `git diff --name-only` y verificación de scope. + +## Outcome + +- Cambios aplicados: + - `docs/stats-section-functional-plan.md`: + - Se actualizó la especificación de `timeframe` en + `/api/stats/players/{player_id}` a `weekly|monthly`. + - Se explicitó que `timeframe=all` no está soportado en el contrato actual. + - `frontend/assets/js/stats.js`: + - Se reemplazó `Promise.all` por `Promise.allSettled` para carga semanal/mensual. + - Se añadio render parcial para escenarios de éxito parcial por ventana. + - Se mantiene fallback de error total si ambas ventanas fallan. + - `scripts/run-stats-validation.ps1`: + - Se reforzaron validaciones de IDs, endpoints y shape de payload. + - Se quitaron chequeos frágiles dependientes de texto visible de UI. + - Se añadieron verificaciones para `requested_limit`, `effective_limit`, `snapshot_limit` y `item_count`. +- Modo de fallo parcial semanal/mensual: + - Si una ventana responde y la otra falla: + - se muestran sus datos y estado disponible. + - la otra muestra aviso de bloque no disponible y se evita degradar todo el panel. + - se agrega aviso global de carga parcial. + - Si ambas fallan: + - se conserva el estado de error completo y backend-inusable para ese perfil. +- Validaciones endurecidas: + - Validación de contrato en JS y HTML basada en funciones/rutas/IDs. + - Validación de forma de payload y metadatos de snapshot en bloque anual. +- Validaciones ejecutadas: + - `node --check frontend/assets/js/stats.js` + - `powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1` + - `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1` + - `python -m http.server` (servido desde `frontend/`) con `HTTP 200` para: + - `stats.html` + - `assets/js/stats.js` +- Limitaciones o follow-ups: + - No se identificaron follow-ups de producto inmediatos dentro del alcance de TASK-179. diff --git a/docs/stats-section-functional-plan.md b/docs/stats-section-functional-plan.md index c883df1..67faeec 100644 --- a/docs/stats-section-functional-plan.md +++ b/docs/stats-section-functional-plan.md @@ -87,7 +87,7 @@ Search result notes: ### 2. Personal player stats ```http -GET /api/stats/players/{player_id}?server_id=&timeframe=weekly|monthly|all +GET /api/stats/players/{player_id}?server_id=&timeframe=weekly|monthly ``` Purpose: @@ -143,7 +143,7 @@ Field rules: - `timeframe=weekly` returns the player totals within the same weekly window policy used by leaderboard reads - `timeframe=monthly` returns the player totals within the same monthly window policy used by leaderboard reads -- `timeframe=all` returns all-time totals for the selected scope, but still includes weekly and monthly ranking blocks as separate comparative context +- `timeframe=all` is not implemented in the current contract and remains out of scope until a dedicated implementation is added. - `kd_ratio`, `kills_per_match` and `deaths_per_match` should be rounded for display-safe payloads ### 3. Annual ranking snapshot diff --git a/frontend/assets/js/stats.js b/frontend/assets/js/stats.js index 453962c..b4ca6d3 100644 --- a/frontend/assets/js/stats.js +++ b/frontend/assets/js/stats.js @@ -60,12 +60,18 @@ document.addEventListener("DOMContentLoaded", () => { "Sin datos semanales. El ranking semanal se actualiza al cargar un jugador.", monthlyPlaceholder: "Sin datos mensuales. El ranking mensual se actualiza al cargar un jugador.", + weeklyWindowUnavailable: + "No se pudo cargar el bloque semanal; se muestran los datos mensuales.", + monthlyWindowUnavailable: + "No se pudo cargar el bloque mensual; se muestran los datos semanales.", + weeklyWindowUnavailableSummary: + "Resumen semanal no disponible temporalmente.", + monthlyWindowUnavailableSummary: + "Resumen mensual no disponible temporalmente.", profileReadyTitle: "Perfil personal", profileEmptyTitle: "Selecciona un jugador para ver sus estadisticas.", - weeklySummaryHelp: - "Resumen semanal: muestra la ventana activa del jugador.", - monthlySummaryHelp: - "Resumen mensual: muestra la ventana activa del jugador.", + partialProfileLoadWarning: + "Algunos bloques de ranking no se cargaron; se mantienen los disponibles.", }; let isBackendOnline = false; @@ -427,17 +433,32 @@ document.addEventListener("DOMContentLoaded", () => { } try { - const [weeklyData, monthlyData] = await Promise.all([ + const [weeklyProfileResult, monthlyProfileResult] = await Promise.allSettled([ fetchPlayerProfile(playerId, "weekly"), fetchPlayerProfile(playerId, "monthly"), ]); + + const weeklyData = + weeklyProfileResult.status === "fulfilled" ? weeklyProfileResult.value : null; + const monthlyData = + monthlyProfileResult.status === "fulfilled" ? monthlyProfileResult.value : null; + const weeklyFailed = weeklyProfileResult.status === "rejected"; + const monthlyFailed = monthlyProfileResult.status === "rejected"; + + if (!weeklyData && !monthlyData) { + throw new Error("Both weekly and monthly profile windows failed."); + } + profilePanel.hidden = false; profileTitle.textContent = messages.profileReadyTitle; - const playerName = String(weeklyData.player_name || monthlyData.player_name || "Sin nombre"); - const playerIdText = String(weeklyData.player_id || monthlyData.player_id || playerId); - const hasWeeklyStats = Number(weeklyData.matches_considered || 0) > 0; - const hasMonthlyStats = Number(monthlyData.matches_considered || 0) > 0; + const playerName = String( + (weeklyData?.player_name || monthlyData?.player_name || "Sin nombre"), + ); + const playerIdText = String(weeklyData?.player_id || monthlyData?.player_id || playerId); + const hasWeeklyStats = Number(weeklyData?.matches_considered || 0) > 0; + const hasMonthlyStats = Number(monthlyData?.matches_considered || 0) > 0; const hasStats = hasWeeklyStats || hasMonthlyStats; + const partialLoadWarning = weeklyFailed || monthlyFailed; profileStateNode.textContent = hasStats ? `${playerName} (${playerIdText})` @@ -446,42 +467,46 @@ document.addEventListener("DOMContentLoaded", () => { ? "stats-state stats-state--neutral" : "stats-state stats-state--warning"; - summaryGrid.innerHTML = ` -
-

Identidad

-

Jugador: ${escapeHtml(playerName)}

-

ID: ${escapeHtml(playerIdText)}

-

Partidas semanales: ${safeInt(weeklyData.matches_considered, 0)}

-

Partidas mensuales: ${safeInt(monthlyData.matches_considered, 0)}

-
-
-

Ventana semanal

-

Kills: ${safeInt(weeklyData.kills, 0)}

-

Deaths: ${safeInt(weeklyData.deaths, 0)}

-

Teamkills: ${safeInt(weeklyData.teamkills, 0)}

-
-
-

Ventana mensual

-

Kills: ${safeInt(monthlyData.kills, 0)}

-

Deaths: ${safeInt(monthlyData.deaths, 0)}

-

Teamkills: ${safeInt(monthlyData.teamkills, 0)}

-
- `; + summaryGrid.innerHTML = renderProfileSummaryCards({ + playerName, + playerIdText, + weeklyData, + monthlyData, + weeklyFailed, + monthlyFailed, + }); - if (comparisonGrid) { - comparisonGrid.innerHTML = renderComparisonCards(weeklyData, monthlyData); + if (partialLoadWarning) { + profileStateNode.textContent += ` ${messages.partialProfileLoadWarning}`; } - weeklySummaryNode.innerHTML = formatRankingSummary( - weeklyData.weekly_ranking, - "Semanal", - weeklyData.matches_considered, - ); - monthlySummaryNode.innerHTML = formatRankingSummary( - monthlyData.monthly_ranking, - "Mensual", - monthlyData.matches_considered, - ); + if (comparisonGrid) { + comparisonGrid.innerHTML = renderComparisonCards({ + weeklyData, + weeklyFailed, + monthlyData, + monthlyFailed, + }); + } + + if (weeklySummaryNode) { + weeklySummaryNode.innerHTML = weeklyFailed + ? `

${escapeHtml(messages.weeklyWindowUnavailableSummary)}

` + : formatRankingSummary( + weeklyData?.weekly_ranking, + "Semanal", + weeklyData?.matches_considered, + ); + } + if (monthlySummaryNode) { + monthlySummaryNode.innerHTML = monthlyFailed + ? `

${escapeHtml(messages.monthlyWindowUnavailableSummary)}

` + : formatRankingSummary( + monthlyData?.monthly_ranking, + "Mensual", + monthlyData?.matches_considered, + ); + } setBackendState(messages.backendOnline, true); isBackendOnline = true; @@ -507,6 +532,59 @@ document.addEventListener("DOMContentLoaded", () => { } } + function renderProfileSummaryCards({ + playerName, + playerIdText, + weeklyData, + monthlyData, + weeklyFailed, + monthlyFailed, + }) { + const weeklySummary = weeklyFailed + ? ` +
+

Ventana semanal

+

${escapeHtml(messages.weeklyWindowUnavailable)}

+
+ ` + : ` +
+

Ventana semanal

+

Kills: ${safeInt(weeklyData?.kills, 0)}

+

Deaths: ${safeInt(weeklyData?.deaths, 0)}

+

Teamkills: ${safeInt(weeklyData?.teamkills, 0)}

+
+ `; + + const monthlySummary = monthlyFailed + ? ` +
+

Ventana mensual

+

${escapeHtml(messages.monthlyWindowUnavailable)}

+
+ ` + : ` +
+

Ventana mensual

+

Kills: ${safeInt(monthlyData?.kills, 0)}

+

Deaths: ${safeInt(monthlyData?.deaths, 0)}

+

Teamkills: ${safeInt(monthlyData?.teamkills, 0)}

+
+ `; + + return ` +
+

Identidad

+

Jugador: ${escapeHtml(playerName)}

+

ID: ${escapeHtml(playerIdText)}

+

Partidas semanales: ${safeInt(weeklyData?.matches_considered, 0)}

+

Partidas mensuales: ${safeInt(monthlyData?.matches_considered, 0)}

+
+ ${weeklySummary} + ${monthlySummary} + `; + } + function clearProfilePanel(openPanel) { if (!profilePanel || !summaryGrid || !profileStateNode) { return; @@ -544,16 +622,35 @@ document.addEventListener("DOMContentLoaded", () => { return payload.data || {}; } - function renderComparisonCards(weeklyData, monthlyData) { - return [ - renderWindowComparisonCard("Semanal", weeklyData, weeklyData.weekly_ranking), - renderWindowComparisonCard("Mensual", monthlyData, monthlyData.monthly_ranking), - renderDeltaComparisonCard(weeklyData, monthlyData), - ].join(""); + function renderComparisonCards({ + weeklyData, + weeklyFailed, + monthlyData, + monthlyFailed, + }) { + const cards = [ + renderWindowComparisonCard( + "Semanal", + weeklyData, + weeklyData?.weekly_ranking, + weeklyFailed, + ), + renderWindowComparisonCard( + "Mensual", + monthlyData, + monthlyData?.monthly_ranking, + monthlyFailed, + ), + ]; + + if (!weeklyFailed && !monthlyFailed) { + cards.push(renderDeltaComparisonCard(weeklyData, monthlyData)); + } + return cards.join(""); } - function renderWindowComparisonCard(label, data, ranking) { - const rankingState = describeRankingState(ranking, data?.matches_considered, label); + function renderWindowComparisonCard(label, data, ranking, failed) { + const rankingState = describeRankingState(ranking, data?.matches_considered, label, failed); const matches = safeInt(data?.matches_considered, 0); const kills = safeInt(data?.kills, 0); const deaths = safeInt(data?.deaths, 0); @@ -664,9 +761,17 @@ document.addEventListener("DOMContentLoaded", () => { `; } - function describeRankingState(ranking, matchesConsidered, timeframeLabel) { + function describeRankingState(ranking, matchesConsidered, timeframeLabel, requestFailed = false) { const matches = safeInt(matchesConsidered, 0); const label = String(timeframeLabel || "ranking").toLowerCase(); + if (requestFailed) { + return { + tone: "warning", + title: "Bloque no disponible", + detail: `No fue posible cargar los datos de la ventana ${label} en este intento.`, + }; + } + if (!ranking) { return { tone: "error", diff --git a/scripts/run-stats-validation.ps1 b/scripts/run-stats-validation.ps1 index f79815d..ea06b76 100644 --- a/scripts/run-stats-validation.ps1 +++ b/scripts/run-stats-validation.ps1 @@ -13,14 +13,14 @@ function Assert-FileExists { } } -function Assert-Contains { +function Assert-ContainsText { param( [string] $Content, - [string] $Pattern, + [string] $Text, [string] $Message ) - if ($Content -notmatch [regex]::Escape($Pattern)) { + if ($Content -notlike "*$Text*") { throw $Message } } @@ -53,22 +53,37 @@ Assert-FileExists "frontend/assets/js/stats.js" "Missing frontend/assets/js/stat $statsHtml = Get-Content -Raw "frontend/stats.html" $statsJs = Get-Content -Raw "frontend/assets/js/stats.js" -Assert-Contains $statsHtml 'id="stats-search-form"' ` +Assert-ContainsText $statsHtml 'id="stats-search-form"' ` "Stats page no longer exposes the player search form." -Assert-Contains $statsHtml 'id="stats-profile-panel"' ` +Assert-ContainsText $statsHtml 'id="stats-profile-panel"' ` "Stats page no longer exposes the player profile panel." -Assert-Contains $statsHtml 'id="stats-annual-form"' ` +Assert-ContainsText $statsHtml 'id="stats-annual-form"' ` "Stats page no longer exposes the annual ranking form." -Assert-Contains $statsHtml './assets/js/stats.js' ` - "Stats page no longer loads the Stats JavaScript asset." -Assert-Contains $statsJs "/api/stats/players/search" ` +Assert-ContainsText $statsHtml 'id="stats-result-list"' ` + "Stats page no longer exposes player result list container." +Assert-ContainsText $statsHtml 'id="stats-weekly-summary"' ` + "Stats page no longer exposes weekly summary zone." +Assert-ContainsText $statsHtml 'id="stats-monthly-summary"' ` + "Stats page no longer exposes monthly summary zone." +Assert-ContainsText $statsHtml 'id="stats-search-state"' ` + "Stats page no longer exposes search state node." +Assert-ContainsText $statsHtml 'id="stats-annual-state"' ` + "Stats page no longer exposes annual ranking state node." +Assert-ContainsText $statsHtml 'id="stats-backend-state"' ` + "Stats page no longer exposes backend state chip." +Assert-ContainsText $statsJs 'getElementById("stats-search-form")' ` + "Stats JS no longer sets up search form lookup." +Assert-ContainsText $statsJs "loadPlayerProfile(" ` + "Stats JS no longer defines loadPlayerProfile." + +Assert-ContainsText $statsJs "/api/stats/players/search" ` "Stats frontend no longer targets the player search endpoint." -Assert-Contains $statsJs "/api/stats/rankings/annual" ` +Assert-ContainsText $statsJs "/api/stats/rankings/annual" ` "Stats frontend no longer targets the annual ranking endpoint." -Assert-Contains $statsJs "/api/stats/players/" ` +Assert-ContainsText $statsJs "/api/stats/players/" ` "Stats frontend no longer targets the player profile endpoint." -Assert-Contains $statsJs "Backend no disponible" ` - "Stats frontend no longer exposes the controlled backend-unavailable messaging." +Assert-ContainsText $statsJs "Promise.allSettled" ` + "Stats profile loader should resolve weekly/monthly windows with partial-failure tolerance." $backendContractCheck = @' import json @@ -91,39 +106,54 @@ def read_payload(path): return int(status), payload +def require_int(value, message): + require(isinstance(value, int), message) + + +def require_str(value, message): + require(isinstance(value, str), message) + + health_status, health_payload = read_payload("/health") -require(health_status == 200, "Route resolver /health no longer returns 200.") -require(health_payload.get("status") == "ok", "Route resolver /health no longer returns ok status.") +require(health_status == 200, "Route resolver /health should return 200.") +require(health_payload.get("status") == "ok", "/health payload should be ok.") search_status, search_payload = read_payload("/api/stats/players/search?q=regression-check&limit=5") require(search_status == 200, "Stats player search should return 200 for a valid query.") search_data = search_payload.get("data") or {} -require(search_payload.get("status") == "ok", "Stats player search payload no longer returns ok status.") -require(search_data.get("query") == "regression-check", "Stats player search no longer echoes query.") -require(search_data.get("server_id") == "all-servers", "Stats player search no longer defaults server scope to all-servers.") -require(isinstance(search_data.get("items"), list), "Stats player search items must remain a list.") +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("items"), list), "Stats player search items must be a list.") + +for item in search_data["items"]: + if item is None: + continue + require_str(item.get("player_id"), "Search result must include player_id.") + require_str(item.get("player_name"), "Search result should include player_name.") + require_int(item.get("matches_considered"), "Search result matches_considered should be int.") missing_query_status, missing_query_payload = read_payload("/api/stats/players/search") -require(missing_query_status == 400, "Stats player search without q must return 400.") -require("required" in str(missing_query_payload.get("message", "")).lower(), "Missing-q error message changed unexpectedly.") +require(missing_query_status == 400, "Stats search without q should return 400.") +require(missing_query_payload is not None, "Search validation must return a payload.") invalid_search_limit_status, _ = read_payload("/api/stats/players/search?q=regression-check&limit=0") -require(invalid_search_limit_status == 400, "Stats player search with limit=0 must return 400.") +require(invalid_search_limit_status == 400, "Stats search with limit=0 should return 400.") profile_status, profile_payload = read_payload("/api/stats/players/regression-player?timeframe=weekly") require(profile_status == 200, "Stats player profile should return 200 for a valid player lookup.") profile_data = profile_payload.get("data") or {} -require(profile_payload.get("status") == "ok", "Stats player profile payload no longer returns ok status.") -require(profile_data.get("player_id") == "regression-player", "Stats player profile no longer preserves player id.") -require(profile_data.get("timeframe") == "weekly", "Stats player profile no longer preserves timeframe.") -require(profile_data.get("server_id") == "all-servers", "Stats player profile no longer defaults server scope to all-servers.") -require(isinstance(profile_data.get("matches_considered"), int), "Stats player profile matches_considered must remain an int.") -require(isinstance(profile_data.get("source"), dict), "Stats player profile source metadata must remain present.") -require(isinstance(profile_data.get("weekly_ranking"), (dict, type(None))), "Stats player profile weekly ranking shape changed unexpectedly.") -require(isinstance(profile_data.get("monthly_ranking"), (dict, type(None))), "Stats player profile monthly ranking shape changed unexpectedly.") +require(profile_payload.get("status") == "ok", "Stats player profile should return ok status.") +require(profile_data.get("player_id") == "regression-player", "Stats player profile should preserve player_id.") +require(profile_data.get("timeframe") == "weekly", "Stats player profile should preserve timeframe.") +require(profile_data.get("server_id"), "Stats player profile should include server_id.") +require_int(profile_data.get("matches_considered"), "Profile matches_considered should be int.") +require(isinstance(profile_data.get("source"), dict), "Profile source metadata should be present.") +require(isinstance(profile_data.get("weekly_ranking"), (dict, type(None))), "Profile weekly_ranking should be dict or null.") +require(isinstance(profile_data.get("monthly_ranking"), (dict, type(None))), "Profile monthly_ranking should be dict or null.") invalid_timeframe_status, _ = read_payload("/api/stats/players/regression-player?timeframe=seasonal") -require(invalid_timeframe_status == 400, "Stats player profile with invalid timeframe must return 400.") +require(invalid_timeframe_status == 400, "Invalid player timeframe should return 400.") current_year = datetime.now(timezone.utc).year annual_status, annual_payload = read_payload( @@ -131,40 +161,45 @@ annual_status, annual_payload = read_payload( ) require(annual_status == 200, "Annual ranking should return 200 for metric=kills.") annual_data = annual_payload.get("data") or {} -require(annual_payload.get("status") == "ok", "Annual ranking payload no longer returns ok status.") -require(annual_data.get("year") == current_year, "Annual ranking no longer preserves requested year.") -require(annual_data.get("server_id") == "all-servers", "Annual ranking no longer preserves the normalized all-servers scope.") -require(annual_data.get("metric") == "kills", "Annual ranking metric changed unexpectedly.") -require( - isinstance(annual_data.get("limit"), int) and 1 <= annual_data.get("limit") <= 20, - "Annual ranking limit must remain a positive int capped by the requested value.", -) -require(annual_data.get("snapshot_status") in {"ready", "missing"}, "Annual ranking snapshot status must remain ready or missing.") -require(isinstance(annual_data.get("items"), list), "Annual ranking items must remain a list.") +require(annual_payload.get("status") == "ok", "Annual ranking should return ok status.") +require_int(annual_data.get("year"), "Annual payload should include numeric year.") +require_int(annual_data.get("limit"), "Annual payload should include limit.") +require_int(annual_data.get("requested_limit"), "Annual payload should include requested_limit.") +require_int(annual_data.get("effective_limit"), "Annual payload should include effective_limit.") +require_int(annual_data.get("snapshot_limit"), "Annual payload should include snapshot_limit.") +require_int(annual_data.get("item_count"), "Annual payload should include item_count.") +require(annual_data.get("snapshot_status") in {"ready", "missing"}, "Annual snapshot_status should be ready or missing.") +require(isinstance(annual_data.get("items"), list), "Annual ranking items should be list.") +require(isinstance(annual_data.get("server_id"), str), "Annual payload should include server_id.") +require(annual_data.get("metric") == "kills", "Annual payload should return kills metric.") + +for item in annual_data.get("items", []): + if item is None: + continue + require_int(item.get("ranking_position"), "Annual item ranking_position should be int.") + require_str(item.get("player_id"), "Annual item should include player_id.") + require_str(item.get("player_name"), "Annual item should include player_name.") + require_int(item.get("matches_considered"), "Annual item matches_considered should be int.") low_limit_status, low_limit_payload = read_payload( f"/api/stats/rankings/annual?year={current_year}&server_id=all&metric=kills&limit=3" ) -require(low_limit_status == 200, "Annual ranking with low limit must return 200.") +require(low_limit_status == 200, "Annual ranking low-limit requests should return 200.") low_limit_value = (low_limit_payload.get("data") or {}).get("limit") -require( - isinstance(low_limit_value, int) and 1 <= low_limit_value <= 3, - "Annual ranking limit normalization for low limits changed unexpectedly.", -) +require(isinstance(low_limit_value, int) and 1 <= low_limit_value <= 3, "Annual low-limit normalization changed unexpectedly.") high_limit_status, _ = read_payload( f"/api/stats/rankings/annual?year={current_year}&server_id=all&metric=kills&limit=101" ) -require(high_limit_status == 400, "Annual ranking with limit=101 must return 400.") +require(high_limit_status == 400, "Annual ranking with limit=101 should return 400.") -unsupported_metric_status, unsupported_metric_payload = read_payload( +unsupported_metric_status, _ = read_payload( f"/api/stats/rankings/annual?year={current_year}&server_id=all&metric=deaths&limit=20" ) -require(unsupported_metric_status == 400, "Annual ranking with unsupported metric must return 400.") -require("metric" in str(unsupported_metric_payload.get("message", "")).lower(), "Unsupported metric error message changed unexpectedly.") +require(unsupported_metric_status == 400, "Annual ranking with unsupported metric should return 400.") missing_year_status, _ = read_payload("/api/stats/rankings/annual?year=2999&server_id=all&metric=kills&limit=20") -require(missing_year_status == 200, "Annual ranking future-year missing snapshot must still return 200.") +require(missing_year_status == 200, "Future year annual ranking should still return 200.") print(json.dumps({ "checked": [ @@ -195,29 +230,34 @@ try { } catch { Write-Warning "Live backend unavailable at $backendBaseUrl. Route-contract checks passed via local Python imports." Write-Host "Next steps: start the backend, then rerun scripts/run-stats-validation.ps1 to verify live HTTP responses." - Write-Host "Expected limited behavior while offline: stats.html should show backend-unavailable states for search, profile, and annual ranking." } if ($backendAvailable) { $currentYear = (Get-Date).ToUniversalTime().Year $searchPayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/stats/players/search?q=regression-check&limit=5" -TimeoutSec 5 if ($searchPayload.status -ne "ok") { - throw "Live stats search no longer returns status=ok." + throw "Live stats search should return status=ok." + } + if (-not ($searchPayload.data -and ($searchPayload.data.items -is [array]))) { + throw "Live stats search payload must include item list." } $profilePayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/stats/players/regression-player?timeframe=weekly" -TimeoutSec 5 if ($profilePayload.status -ne "ok") { - throw "Live stats profile no longer returns status=ok." + throw "Live stats profile should return status=ok." } $annualPayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/stats/rankings/annual?year=$currentYear&server_id=all&metric=kills&limit=20" -TimeoutSec 5 if ($annualPayload.status -ne "ok") { - throw "Live annual ranking no longer returns status=ok." + throw "Live annual ranking should return status=ok." + } + if (-not ($annualPayload.data.snapshot_status -in @("ready", "missing"))) { + throw "Live annual ranking should expose ready/missing snapshot status." } $unsupportedMetricStatus = Get-HttpStatusCode -Url "$backendBaseUrl/api/stats/rankings/annual?year=$currentYear&server_id=all&metric=deaths&limit=20" if ($unsupportedMetricStatus -ne 400) { - throw "Live annual ranking with unsupported metric must return HTTP 400." + throw "Live annual ranking with unsupported metric should return HTTP 400." } Write-Host "Live HTTP checks passed for Stats endpoints."