Fix Stats review follow-ups
This commit is contained in:
116
ai/tasks/done/TASK-179-fix-stats-review-followups.md
Normal file
116
ai/tasks/done/TASK-179-fix-stats-review-followups.md
Normal file
@@ -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.
|
||||||
@@ -87,7 +87,7 @@ Search result notes:
|
|||||||
### 2. Personal player stats
|
### 2. Personal player stats
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/stats/players/{player_id}?server_id=<server-or-all>&timeframe=weekly|monthly|all
|
GET /api/stats/players/{player_id}?server_id=<server-or-all>&timeframe=weekly|monthly
|
||||||
```
|
```
|
||||||
|
|
||||||
Purpose:
|
Purpose:
|
||||||
@@ -143,7 +143,7 @@ Field rules:
|
|||||||
|
|
||||||
- `timeframe=weekly` returns the player totals within the same weekly window policy used by leaderboard reads
|
- `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=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
|
- `kd_ratio`, `kills_per_match` and `deaths_per_match` should be rounded for display-safe payloads
|
||||||
|
|
||||||
### 3. Annual ranking snapshot
|
### 3. Annual ranking snapshot
|
||||||
|
|||||||
@@ -60,12 +60,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
"Sin datos semanales. El ranking semanal se actualiza al cargar un jugador.",
|
"Sin datos semanales. El ranking semanal se actualiza al cargar un jugador.",
|
||||||
monthlyPlaceholder:
|
monthlyPlaceholder:
|
||||||
"Sin datos mensuales. El ranking mensual se actualiza al cargar un jugador.",
|
"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",
|
profileReadyTitle: "Perfil personal",
|
||||||
profileEmptyTitle: "Selecciona un jugador para ver sus estadisticas.",
|
profileEmptyTitle: "Selecciona un jugador para ver sus estadisticas.",
|
||||||
weeklySummaryHelp:
|
partialProfileLoadWarning:
|
||||||
"Resumen semanal: muestra la ventana activa del jugador.",
|
"Algunos bloques de ranking no se cargaron; se mantienen los disponibles.",
|
||||||
monthlySummaryHelp:
|
|
||||||
"Resumen mensual: muestra la ventana activa del jugador.",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let isBackendOnline = false;
|
let isBackendOnline = false;
|
||||||
@@ -427,17 +433,32 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [weeklyData, monthlyData] = await Promise.all([
|
const [weeklyProfileResult, monthlyProfileResult] = await Promise.allSettled([
|
||||||
fetchPlayerProfile(playerId, "weekly"),
|
fetchPlayerProfile(playerId, "weekly"),
|
||||||
fetchPlayerProfile(playerId, "monthly"),
|
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;
|
profilePanel.hidden = false;
|
||||||
profileTitle.textContent = messages.profileReadyTitle;
|
profileTitle.textContent = messages.profileReadyTitle;
|
||||||
const playerName = String(weeklyData.player_name || monthlyData.player_name || "Sin nombre");
|
const playerName = String(
|
||||||
const playerIdText = String(weeklyData.player_id || monthlyData.player_id || playerId);
|
(weeklyData?.player_name || monthlyData?.player_name || "Sin nombre"),
|
||||||
const hasWeeklyStats = Number(weeklyData.matches_considered || 0) > 0;
|
);
|
||||||
const hasMonthlyStats = Number(monthlyData.matches_considered || 0) > 0;
|
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 hasStats = hasWeeklyStats || hasMonthlyStats;
|
||||||
|
const partialLoadWarning = weeklyFailed || monthlyFailed;
|
||||||
|
|
||||||
profileStateNode.textContent = hasStats
|
profileStateNode.textContent = hasStats
|
||||||
? `${playerName} (${playerIdText})`
|
? `${playerName} (${playerIdText})`
|
||||||
@@ -446,42 +467,46 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
? "stats-state stats-state--neutral"
|
? "stats-state stats-state--neutral"
|
||||||
: "stats-state stats-state--warning";
|
: "stats-state stats-state--warning";
|
||||||
|
|
||||||
summaryGrid.innerHTML = `
|
summaryGrid.innerHTML = renderProfileSummaryCards({
|
||||||
<article class="stats-summary-card">
|
playerName,
|
||||||
<p class="stats-summary-title">Identidad</p>
|
playerIdText,
|
||||||
<p><strong>Jugador:</strong> ${escapeHtml(playerName)}</p>
|
weeklyData,
|
||||||
<p><strong>ID:</strong> ${escapeHtml(playerIdText)}</p>
|
monthlyData,
|
||||||
<p><strong>Partidas semanales:</strong> ${safeInt(weeklyData.matches_considered, 0)}</p>
|
weeklyFailed,
|
||||||
<p><strong>Partidas mensuales:</strong> ${safeInt(monthlyData.matches_considered, 0)}</p>
|
monthlyFailed,
|
||||||
</article>
|
});
|
||||||
<article class="stats-summary-card">
|
|
||||||
<p class="stats-summary-title">Ventana semanal</p>
|
|
||||||
<p><strong>Kills:</strong> ${safeInt(weeklyData.kills, 0)}</p>
|
|
||||||
<p><strong>Deaths:</strong> ${safeInt(weeklyData.deaths, 0)}</p>
|
|
||||||
<p><strong>Teamkills:</strong> ${safeInt(weeklyData.teamkills, 0)}</p>
|
|
||||||
</article>
|
|
||||||
<article class="stats-summary-card">
|
|
||||||
<p class="stats-summary-title">Ventana mensual</p>
|
|
||||||
<p><strong>Kills:</strong> ${safeInt(monthlyData.kills, 0)}</p>
|
|
||||||
<p><strong>Deaths:</strong> ${safeInt(monthlyData.deaths, 0)}</p>
|
|
||||||
<p><strong>Teamkills:</strong> ${safeInt(monthlyData.teamkills, 0)}</p>
|
|
||||||
</article>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (comparisonGrid) {
|
if (partialLoadWarning) {
|
||||||
comparisonGrid.innerHTML = renderComparisonCards(weeklyData, monthlyData);
|
profileStateNode.textContent += ` ${messages.partialProfileLoadWarning}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
weeklySummaryNode.innerHTML = formatRankingSummary(
|
if (comparisonGrid) {
|
||||||
weeklyData.weekly_ranking,
|
comparisonGrid.innerHTML = renderComparisonCards({
|
||||||
"Semanal",
|
weeklyData,
|
||||||
weeklyData.matches_considered,
|
weeklyFailed,
|
||||||
);
|
monthlyData,
|
||||||
monthlySummaryNode.innerHTML = formatRankingSummary(
|
monthlyFailed,
|
||||||
monthlyData.monthly_ranking,
|
});
|
||||||
"Mensual",
|
}
|
||||||
monthlyData.matches_considered,
|
|
||||||
);
|
if (weeklySummaryNode) {
|
||||||
|
weeklySummaryNode.innerHTML = weeklyFailed
|
||||||
|
? `<p>${escapeHtml(messages.weeklyWindowUnavailableSummary)}</p>`
|
||||||
|
: formatRankingSummary(
|
||||||
|
weeklyData?.weekly_ranking,
|
||||||
|
"Semanal",
|
||||||
|
weeklyData?.matches_considered,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (monthlySummaryNode) {
|
||||||
|
monthlySummaryNode.innerHTML = monthlyFailed
|
||||||
|
? `<p>${escapeHtml(messages.monthlyWindowUnavailableSummary)}</p>`
|
||||||
|
: formatRankingSummary(
|
||||||
|
monthlyData?.monthly_ranking,
|
||||||
|
"Mensual",
|
||||||
|
monthlyData?.matches_considered,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setBackendState(messages.backendOnline, true);
|
setBackendState(messages.backendOnline, true);
|
||||||
isBackendOnline = true;
|
isBackendOnline = true;
|
||||||
@@ -507,6 +532,59 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderProfileSummaryCards({
|
||||||
|
playerName,
|
||||||
|
playerIdText,
|
||||||
|
weeklyData,
|
||||||
|
monthlyData,
|
||||||
|
weeklyFailed,
|
||||||
|
monthlyFailed,
|
||||||
|
}) {
|
||||||
|
const weeklySummary = weeklyFailed
|
||||||
|
? `
|
||||||
|
<article class="stats-summary-card">
|
||||||
|
<p class="stats-summary-title">Ventana semanal</p>
|
||||||
|
<p>${escapeHtml(messages.weeklyWindowUnavailable)}</p>
|
||||||
|
</article>
|
||||||
|
`
|
||||||
|
: `
|
||||||
|
<article class="stats-summary-card">
|
||||||
|
<p class="stats-summary-title">Ventana semanal</p>
|
||||||
|
<p><strong>Kills:</strong> ${safeInt(weeklyData?.kills, 0)}</p>
|
||||||
|
<p><strong>Deaths:</strong> ${safeInt(weeklyData?.deaths, 0)}</p>
|
||||||
|
<p><strong>Teamkills:</strong> ${safeInt(weeklyData?.teamkills, 0)}</p>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const monthlySummary = monthlyFailed
|
||||||
|
? `
|
||||||
|
<article class="stats-summary-card">
|
||||||
|
<p class="stats-summary-title">Ventana mensual</p>
|
||||||
|
<p>${escapeHtml(messages.monthlyWindowUnavailable)}</p>
|
||||||
|
</article>
|
||||||
|
`
|
||||||
|
: `
|
||||||
|
<article class="stats-summary-card">
|
||||||
|
<p class="stats-summary-title">Ventana mensual</p>
|
||||||
|
<p><strong>Kills:</strong> ${safeInt(monthlyData?.kills, 0)}</p>
|
||||||
|
<p><strong>Deaths:</strong> ${safeInt(monthlyData?.deaths, 0)}</p>
|
||||||
|
<p><strong>Teamkills:</strong> ${safeInt(monthlyData?.teamkills, 0)}</p>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<article class="stats-summary-card">
|
||||||
|
<p class="stats-summary-title">Identidad</p>
|
||||||
|
<p><strong>Jugador:</strong> ${escapeHtml(playerName)}</p>
|
||||||
|
<p><strong>ID:</strong> ${escapeHtml(playerIdText)}</p>
|
||||||
|
<p><strong>Partidas semanales:</strong> ${safeInt(weeklyData?.matches_considered, 0)}</p>
|
||||||
|
<p><strong>Partidas mensuales:</strong> ${safeInt(monthlyData?.matches_considered, 0)}</p>
|
||||||
|
</article>
|
||||||
|
${weeklySummary}
|
||||||
|
${monthlySummary}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function clearProfilePanel(openPanel) {
|
function clearProfilePanel(openPanel) {
|
||||||
if (!profilePanel || !summaryGrid || !profileStateNode) {
|
if (!profilePanel || !summaryGrid || !profileStateNode) {
|
||||||
return;
|
return;
|
||||||
@@ -544,16 +622,35 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
return payload.data || {};
|
return payload.data || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderComparisonCards(weeklyData, monthlyData) {
|
function renderComparisonCards({
|
||||||
return [
|
weeklyData,
|
||||||
renderWindowComparisonCard("Semanal", weeklyData, weeklyData.weekly_ranking),
|
weeklyFailed,
|
||||||
renderWindowComparisonCard("Mensual", monthlyData, monthlyData.monthly_ranking),
|
monthlyData,
|
||||||
renderDeltaComparisonCard(weeklyData, monthlyData),
|
monthlyFailed,
|
||||||
].join("");
|
}) {
|
||||||
|
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) {
|
function renderWindowComparisonCard(label, data, ranking, failed) {
|
||||||
const rankingState = describeRankingState(ranking, data?.matches_considered, label);
|
const rankingState = describeRankingState(ranking, data?.matches_considered, label, failed);
|
||||||
const matches = safeInt(data?.matches_considered, 0);
|
const matches = safeInt(data?.matches_considered, 0);
|
||||||
const kills = safeInt(data?.kills, 0);
|
const kills = safeInt(data?.kills, 0);
|
||||||
const deaths = safeInt(data?.deaths, 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 matches = safeInt(matchesConsidered, 0);
|
||||||
const label = String(timeframeLabel || "ranking").toLowerCase();
|
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) {
|
if (!ranking) {
|
||||||
return {
|
return {
|
||||||
tone: "error",
|
tone: "error",
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ function Assert-FileExists {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Assert-Contains {
|
function Assert-ContainsText {
|
||||||
param(
|
param(
|
||||||
[string] $Content,
|
[string] $Content,
|
||||||
[string] $Pattern,
|
[string] $Text,
|
||||||
[string] $Message
|
[string] $Message
|
||||||
)
|
)
|
||||||
|
|
||||||
if ($Content -notmatch [regex]::Escape($Pattern)) {
|
if ($Content -notlike "*$Text*") {
|
||||||
throw $Message
|
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"
|
$statsHtml = Get-Content -Raw "frontend/stats.html"
|
||||||
$statsJs = Get-Content -Raw "frontend/assets/js/stats.js"
|
$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."
|
"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."
|
"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."
|
"Stats page no longer exposes the annual ranking form."
|
||||||
Assert-Contains $statsHtml './assets/js/stats.js' `
|
Assert-ContainsText $statsHtml 'id="stats-result-list"' `
|
||||||
"Stats page no longer loads the Stats JavaScript asset."
|
"Stats page no longer exposes player result list container."
|
||||||
Assert-Contains $statsJs "/api/stats/players/search" `
|
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."
|
"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."
|
"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."
|
"Stats frontend no longer targets the player profile endpoint."
|
||||||
Assert-Contains $statsJs "Backend no disponible" `
|
Assert-ContainsText $statsJs "Promise.allSettled" `
|
||||||
"Stats frontend no longer exposes the controlled backend-unavailable messaging."
|
"Stats profile loader should resolve weekly/monthly windows with partial-failure tolerance."
|
||||||
|
|
||||||
$backendContractCheck = @'
|
$backendContractCheck = @'
|
||||||
import json
|
import json
|
||||||
@@ -91,39 +106,54 @@ def read_payload(path):
|
|||||||
return int(status), payload
|
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")
|
health_status, health_payload = read_payload("/health")
|
||||||
require(health_status == 200, "Route resolver /health no longer returns 200.")
|
require(health_status == 200, "Route resolver /health should return 200.")
|
||||||
require(health_payload.get("status") == "ok", "Route resolver /health no longer returns ok status.")
|
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")
|
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.")
|
require(search_status == 200, "Stats player search should return 200 for a valid query.")
|
||||||
search_data = search_payload.get("data") or {}
|
search_data = search_payload.get("data") or {}
|
||||||
require(search_payload.get("status") == "ok", "Stats player search payload no longer returns ok status.")
|
require(search_payload.get("status") == "ok", "Stats player search should return ok status.")
|
||||||
require(search_data.get("query") == "regression-check", "Stats player search no longer echoes query.")
|
require(search_data.get("query") == "regression-check", "Stats player search should preserve query.")
|
||||||
require(search_data.get("server_id") == "all-servers", "Stats player search no longer defaults server scope to all-servers.")
|
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 remain a list.")
|
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")
|
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(missing_query_status == 400, "Stats search without q should return 400.")
|
||||||
require("required" in str(missing_query_payload.get("message", "")).lower(), "Missing-q error message changed unexpectedly.")
|
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")
|
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")
|
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.")
|
require(profile_status == 200, "Stats player profile should return 200 for a valid player lookup.")
|
||||||
profile_data = profile_payload.get("data") or {}
|
profile_data = profile_payload.get("data") or {}
|
||||||
require(profile_payload.get("status") == "ok", "Stats player profile payload no longer returns ok status.")
|
require(profile_payload.get("status") == "ok", "Stats player profile should return ok status.")
|
||||||
require(profile_data.get("player_id") == "regression-player", "Stats player profile no longer preserves player id.")
|
require(profile_data.get("player_id") == "regression-player", "Stats player profile should preserve player_id.")
|
||||||
require(profile_data.get("timeframe") == "weekly", "Stats player profile no longer preserves timeframe.")
|
require(profile_data.get("timeframe") == "weekly", "Stats player profile should preserve timeframe.")
|
||||||
require(profile_data.get("server_id") == "all-servers", "Stats player profile no longer defaults server scope to all-servers.")
|
require(profile_data.get("server_id"), "Stats player profile should include server_id.")
|
||||||
require(isinstance(profile_data.get("matches_considered"), int), "Stats player profile matches_considered must remain an int.")
|
require_int(profile_data.get("matches_considered"), "Profile matches_considered should be int.")
|
||||||
require(isinstance(profile_data.get("source"), dict), "Stats player profile source metadata must remain present.")
|
require(isinstance(profile_data.get("source"), dict), "Profile source metadata should be present.")
|
||||||
require(isinstance(profile_data.get("weekly_ranking"), (dict, type(None))), "Stats player profile weekly ranking shape changed unexpectedly.")
|
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))), "Stats player profile monthly ranking shape changed unexpectedly.")
|
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")
|
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
|
current_year = datetime.now(timezone.utc).year
|
||||||
annual_status, annual_payload = read_payload(
|
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.")
|
require(annual_status == 200, "Annual ranking should return 200 for metric=kills.")
|
||||||
annual_data = annual_payload.get("data") or {}
|
annual_data = annual_payload.get("data") or {}
|
||||||
require(annual_payload.get("status") == "ok", "Annual ranking payload no longer returns ok status.")
|
require(annual_payload.get("status") == "ok", "Annual ranking should return ok status.")
|
||||||
require(annual_data.get("year") == current_year, "Annual ranking no longer preserves requested year.")
|
require_int(annual_data.get("year"), "Annual payload should include numeric year.")
|
||||||
require(annual_data.get("server_id") == "all-servers", "Annual ranking no longer preserves the normalized all-servers scope.")
|
require_int(annual_data.get("limit"), "Annual payload should include limit.")
|
||||||
require(annual_data.get("metric") == "kills", "Annual ranking metric changed unexpectedly.")
|
require_int(annual_data.get("requested_limit"), "Annual payload should include requested_limit.")
|
||||||
require(
|
require_int(annual_data.get("effective_limit"), "Annual payload should include effective_limit.")
|
||||||
isinstance(annual_data.get("limit"), int) and 1 <= annual_data.get("limit") <= 20,
|
require_int(annual_data.get("snapshot_limit"), "Annual payload should include snapshot_limit.")
|
||||||
"Annual ranking limit must remain a positive int capped by the requested value.",
|
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(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 should be list.")
|
||||||
require(isinstance(annual_data.get("items"), list), "Annual ranking items must remain a 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(
|
low_limit_status, low_limit_payload = read_payload(
|
||||||
f"/api/stats/rankings/annual?year={current_year}&server_id=all&metric=kills&limit=3"
|
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")
|
low_limit_value = (low_limit_payload.get("data") or {}).get("limit")
|
||||||
require(
|
require(isinstance(low_limit_value, int) and 1 <= low_limit_value <= 3, "Annual low-limit normalization changed unexpectedly.")
|
||||||
isinstance(low_limit_value, int) and 1 <= low_limit_value <= 3,
|
|
||||||
"Annual ranking limit normalization for low limits changed unexpectedly.",
|
|
||||||
)
|
|
||||||
|
|
||||||
high_limit_status, _ = read_payload(
|
high_limit_status, _ = read_payload(
|
||||||
f"/api/stats/rankings/annual?year={current_year}&server_id=all&metric=kills&limit=101"
|
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"
|
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(unsupported_metric_status == 400, "Annual ranking with unsupported metric should return 400.")
|
||||||
require("metric" in str(unsupported_metric_payload.get("message", "")).lower(), "Unsupported metric error message changed unexpectedly.")
|
|
||||||
|
|
||||||
missing_year_status, _ = read_payload("/api/stats/rankings/annual?year=2999&server_id=all&metric=kills&limit=20")
|
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({
|
print(json.dumps({
|
||||||
"checked": [
|
"checked": [
|
||||||
@@ -195,29 +230,34 @@ try {
|
|||||||
} catch {
|
} catch {
|
||||||
Write-Warning "Live backend unavailable at $backendBaseUrl. Route-contract checks passed via local Python imports."
|
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 "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) {
|
if ($backendAvailable) {
|
||||||
$currentYear = (Get-Date).ToUniversalTime().Year
|
$currentYear = (Get-Date).ToUniversalTime().Year
|
||||||
$searchPayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/stats/players/search?q=regression-check&limit=5" -TimeoutSec 5
|
$searchPayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/stats/players/search?q=regression-check&limit=5" -TimeoutSec 5
|
||||||
if ($searchPayload.status -ne "ok") {
|
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
|
$profilePayload = Invoke-RestMethod -Uri "$backendBaseUrl/api/stats/players/regression-player?timeframe=weekly" -TimeoutSec 5
|
||||||
if ($profilePayload.status -ne "ok") {
|
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
|
$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") {
|
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"
|
$unsupportedMetricStatus = Get-HttpStatusCode -Url "$backendBaseUrl/api/stats/rankings/annual?year=$currentYear&server_id=all&metric=deaths&limit=20"
|
||||||
if ($unsupportedMetricStatus -ne 400) {
|
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."
|
Write-Host "Live HTTP checks passed for Stats endpoints."
|
||||||
|
|||||||
Reference in New Issue
Block a user