Complete Stats validation, comparison cards, and annual ranking hardening
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: TASK-175-add-stats-regression-validation-script
|
||||
title: Add Stats regression validation script
|
||||
status: pending
|
||||
status: done
|
||||
type: platform
|
||||
team: PM
|
||||
supporting_teams:
|
||||
@@ -67,5 +67,31 @@ The Stats section already has public-facing pages, JS assets, and backend endpoi
|
||||
|
||||
## Outcome
|
||||
|
||||
Document validated endpoints, controlled limitations, and the immediate next task recommendation.
|
||||
Validated surfaces:
|
||||
|
||||
- `frontend/stats.html` asset wiring and required Stats UI anchors
|
||||
- `GET /health`
|
||||
- `GET /api/stats/players/search`
|
||||
- `GET /api/stats/players/{player_id}`
|
||||
- `GET /api/stats/rankings/annual`
|
||||
|
||||
Implemented validation:
|
||||
|
||||
- Added `scripts/run-stats-validation.ps1` to check Stats asset presence, route-contract behavior, invalid parameter handling, and controlled backend-unavailable messaging.
|
||||
- Wired `scripts/run-integration-tests.ps1` to execute the Stats validation and fail on non-zero child process exit codes.
|
||||
|
||||
Controlled limitations observed during validation:
|
||||
|
||||
- The local live backend was not running during task validation, so live HTTP checks reported the expected offline guidance instead of ambiguous failure.
|
||||
- Current normalized all-server scope is returned as `all-servers`.
|
||||
- Annual ranking `data.limit` currently reflects the effective stored snapshot size when a ready snapshot contains fewer rows than the requested limit; the validator accepts that current behavior without changing runtime logic.
|
||||
|
||||
Validation run:
|
||||
|
||||
- `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`
|
||||
|
||||
Immediate follow-up recommendation:
|
||||
|
||||
- Continue with `TASK-176-add-stats-player-comparison-cards`, using the new Stats validation script as the regression guard for the existing Stats surface.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: TASK-176-add-stats-player-comparison-cards
|
||||
title: Add stats player comparison cards
|
||||
status: pending
|
||||
status: done
|
||||
type: frontend
|
||||
team: Frontend Senior
|
||||
supporting_teams:
|
||||
@@ -71,5 +71,54 @@ The Stats page already exposes player lookup and detail payloads. This task shou
|
||||
|
||||
## Outcome
|
||||
|
||||
Document which comparison cards were added, payload fields consumed, behavior with no data, and any remaining follow-up improvements.
|
||||
Implemented comparison cards:
|
||||
|
||||
- Added a dedicated comparison-card grid inside the selected-player profile panel.
|
||||
- Added weekly and monthly window cards using only existing profile payload fields.
|
||||
- Added a comparison card that contrasts weekly vs monthly KPM, K/D, kills delta, and matches delta.
|
||||
|
||||
Payload fields consumed:
|
||||
|
||||
- `player_id`
|
||||
- `player_name`
|
||||
- `matches_considered`
|
||||
- `kills`
|
||||
- `deaths`
|
||||
- `teamkills`
|
||||
- `kd_ratio`
|
||||
- `kills_per_match`
|
||||
- `deaths_per_match`
|
||||
- `weekly_ranking`
|
||||
- `monthly_ranking`
|
||||
- `window_kind`
|
||||
- `window_start`
|
||||
- `window_end`
|
||||
|
||||
Behavior with no data or limited ranking:
|
||||
|
||||
- If both weekly and monthly windows have zero matches, the existing warning state remains and the comparison cards show explicit "Sin actividad" messaging.
|
||||
- If a ranking block is absent, the cards show `Ranking ausente`.
|
||||
- If a ranking exists but `ranking_position` is empty and the active window is a fallback `previous-*` window, the cards show `Profundidad insuficiente`.
|
||||
- If a ranking exists but the player is not positioned in the visible ranking, the cards show `Fuera del ranking visible`.
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- The frontend now fetches the existing player profile endpoint twice in parallel, once for `weekly` and once for `monthly`, without adding endpoints or changing backend contracts.
|
||||
- The tactical visual style stays within the current Stats surface and reuses the existing palette and panel language.
|
||||
|
||||
Validation run:
|
||||
|
||||
- `node --check frontend/assets/js/stats.js`
|
||||
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
|
||||
- Local static server:
|
||||
- `http://127.0.0.1:8091/stats.html` -> 200
|
||||
- `http://127.0.0.1:8091/assets/js/stats.js` -> 200
|
||||
|
||||
Known limitations:
|
||||
|
||||
- Visual validation against a live local backend was not possible because `http://127.0.0.1:8000` was unavailable during this task.
|
||||
- The Browser plugin surface was not available as `iab` in this session, so browser-level visual inspection could not be completed through the in-app browser.
|
||||
|
||||
Immediate follow-up improvements:
|
||||
|
||||
- Continue with `TASK-177-harden-annual-ranking-snapshot-operations` so the annual ranking endpoint behavior around effective limits and missing years is better defined and easier for the Stats UI to explain.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: TASK-177-harden-annual-ranking-snapshot-operations
|
||||
title: Harden annual ranking snapshot operations
|
||||
status: pending
|
||||
status: done
|
||||
type: backend
|
||||
team: Backend Senior
|
||||
supporting_teams:
|
||||
@@ -74,5 +74,45 @@ The annual snapshot flow is implemented and documented, but edge cases around mi
|
||||
|
||||
## Outcome
|
||||
|
||||
Document applied hardening, validated responses, known limits, and whether a future task is needed for scheduled/hardening automation.
|
||||
Applied hardening:
|
||||
|
||||
- Added explicit annual year normalization in `backend/app/rcon_annual_rankings.py` for supported `1..9999` values.
|
||||
- Added reusable limit normalization in the annual snapshot reader/generator with a bounded cap of `100`.
|
||||
- Added response metadata for annual snapshot reads:
|
||||
- `requested_limit`
|
||||
- `effective_limit`
|
||||
- `snapshot_limit`
|
||||
- `item_count`
|
||||
- Hardened effective-limit resolution so a ready snapshot serves no more rows than:
|
||||
- the requested limit,
|
||||
- the persisted snapshot limit,
|
||||
- and the actual stored item count.
|
||||
- Wrapped the annual route payload build in a `ValueError` guard so invalid annual requests return controlled `400` payloads.
|
||||
|
||||
Documentation updated:
|
||||
|
||||
- `docs/annual-ranking-snapshot-runbook.md` now documents annual limit metadata and explains why `effective_limit` can be lower than `requested_limit` for ready snapshots.
|
||||
|
||||
Validated responses:
|
||||
|
||||
- current year with `metric=kills` -> `200`
|
||||
- past year with no guaranteed data -> controlled `200` with `ready` or `missing` depending on stored snapshot presence
|
||||
- future year without snapshot -> `200` with `snapshot_status="missing"`
|
||||
- unsupported metric -> `400`
|
||||
- low limit `3` -> `200`, `requested_limit=3`, `effective_limit<=3`
|
||||
- high limit `101` -> `400`
|
||||
- unsupported year `10000` -> `400`
|
||||
|
||||
Validation run:
|
||||
|
||||
- Inline Python route validation for the annual endpoint cases above
|
||||
- `powershell -ExecutionPolicy Bypass -File scripts/run-integration-tests.ps1`
|
||||
|
||||
Known limits:
|
||||
|
||||
- The API still keeps `limit` aligned with the served effective limit for ready snapshots so current frontend behavior remains unchanged.
|
||||
- This task does not add scheduling or regeneration automation; it only hardens the existing read/generate contract.
|
||||
|
||||
Future task recommendation:
|
||||
|
||||
- If operations need stronger guarantees, create a follow-up task for scheduled annual snapshot generation/audit reporting rather than broadening this read-path hardening further.
|
||||
@@ -684,6 +684,10 @@ def build_annual_ranking_snapshot_payload(
|
||||
"server_id": result.get("server_id"),
|
||||
"metric": result.get("metric"),
|
||||
"limit": result.get("limit"),
|
||||
"requested_limit": result.get("requested_limit"),
|
||||
"effective_limit": result.get("effective_limit"),
|
||||
"snapshot_limit": result.get("snapshot_limit"),
|
||||
"item_count": result.get("item_count"),
|
||||
"source": result.get("source"),
|
||||
"snapshot_status": result.get("snapshot_status"),
|
||||
"generated_at": result.get("generated_at"),
|
||||
|
||||
@@ -24,10 +24,11 @@ def generate_annual_ranking_snapshot(
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Generate and persist an annual top-k ranking snapshot for materialized RCON data."""
|
||||
normalized_year = _normalize_year(year)
|
||||
normalized_server_key = _normalize_server_key(server_key)
|
||||
normalized_metric = _normalize_metric(metric)
|
||||
normalized_limit = max(1, int(limit))
|
||||
window_start, window_end = _annual_window(year)
|
||||
normalized_limit = _normalize_limit(limit)
|
||||
window_start, window_end = _annual_window(normalized_year)
|
||||
|
||||
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
|
||||
scope_sql, scope_params = _build_scope_sql(normalized_server_key)
|
||||
@@ -48,7 +49,7 @@ def generate_annual_ranking_snapshot(
|
||||
)
|
||||
existing_snapshot_id = _find_existing_snapshot(
|
||||
connection=connection,
|
||||
year=year,
|
||||
year=normalized_year,
|
||||
server_key=normalized_server_key,
|
||||
metric=normalized_metric,
|
||||
)
|
||||
@@ -76,13 +77,13 @@ def generate_annual_ranking_snapshot(
|
||||
|
||||
_delete_existing_snapshot(
|
||||
connection=connection,
|
||||
year=year,
|
||||
year=normalized_year,
|
||||
server_key=normalized_server_key,
|
||||
metric=normalized_metric,
|
||||
)
|
||||
snapshot_id = _insert_snapshot(
|
||||
connection=connection,
|
||||
year=year,
|
||||
year=normalized_year,
|
||||
server_key=normalized_server_key,
|
||||
metric=normalized_metric,
|
||||
limit=normalized_limit,
|
||||
@@ -119,13 +120,10 @@ def get_annual_ranking_snapshot(
|
||||
db_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Load one annual ranking snapshot without recalculating the ranking."""
|
||||
normalized_year = int(year)
|
||||
if normalized_year < 1:
|
||||
raise ValueError("year must be greater than zero")
|
||||
|
||||
normalized_year = _normalize_year(year)
|
||||
normalized_server_key = _normalize_server_key(server_key)
|
||||
normalized_metric = _normalize_metric(metric)
|
||||
normalized_limit = max(1, int(limit))
|
||||
normalized_limit = _normalize_limit(limit)
|
||||
|
||||
resolved_path = initialize_rcon_materialized_storage(db_path=db_path)
|
||||
if use_postgres_rcon_storage(explicit_sqlite_path=db_path):
|
||||
@@ -149,6 +147,10 @@ def get_annual_ranking_snapshot(
|
||||
"server_id": normalized_server_key,
|
||||
"metric": normalized_metric,
|
||||
"limit": normalized_limit,
|
||||
"requested_limit": normalized_limit,
|
||||
"effective_limit": 0,
|
||||
"snapshot_limit": None,
|
||||
"item_count": 0,
|
||||
"source": "rcon-annual-ranking-snapshot",
|
||||
"generated_at": None,
|
||||
"window_start": None,
|
||||
@@ -157,11 +159,20 @@ def get_annual_ranking_snapshot(
|
||||
"items": [],
|
||||
}
|
||||
|
||||
effective_limit = min(normalized_limit, int(snapshot.get("limit_size") or normalized_limit))
|
||||
snapshot_limit = _normalize_limit(snapshot.get("limit_size") or normalized_limit)
|
||||
item_count = _count_items(
|
||||
connection=connection,
|
||||
snapshot_id=int(snapshot["id"]),
|
||||
)
|
||||
effective_limit = _resolve_effective_limit(
|
||||
requested_limit=normalized_limit,
|
||||
snapshot_limit=snapshot_limit,
|
||||
item_count=item_count,
|
||||
)
|
||||
items = _list_items(
|
||||
connection=connection,
|
||||
snapshot_id=int(snapshot["id"]),
|
||||
limit=effective_limit,
|
||||
limit=effective_limit if effective_limit > 0 else None,
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -170,6 +181,10 @@ def get_annual_ranking_snapshot(
|
||||
"server_id": normalized_server_key,
|
||||
"metric": normalized_metric,
|
||||
"limit": effective_limit,
|
||||
"requested_limit": normalized_limit,
|
||||
"effective_limit": effective_limit,
|
||||
"snapshot_limit": snapshot_limit,
|
||||
"item_count": item_count,
|
||||
"source": "rcon-annual-ranking-snapshot",
|
||||
"generated_at": snapshot.get("generated_at"),
|
||||
"window_start": snapshot.get("window_start"),
|
||||
@@ -194,6 +209,29 @@ def _normalize_metric(metric: str) -> str:
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_year(year: int) -> int:
|
||||
normalized_year = int(year)
|
||||
if normalized_year < 1 or normalized_year > 9999:
|
||||
raise ValueError("year must be between 1 and 9999")
|
||||
return normalized_year
|
||||
|
||||
|
||||
def _normalize_limit(limit: object, *, maximum: int = 100) -> int:
|
||||
normalized_limit = int(limit or 1)
|
||||
if normalized_limit < 1:
|
||||
raise ValueError("limit must be greater than zero")
|
||||
return min(normalized_limit, maximum)
|
||||
|
||||
|
||||
def _resolve_effective_limit(
|
||||
*,
|
||||
requested_limit: int,
|
||||
snapshot_limit: int,
|
||||
item_count: int,
|
||||
) -> int:
|
||||
return max(0, min(requested_limit, snapshot_limit, item_count))
|
||||
|
||||
|
||||
def _annual_window(year: int) -> tuple[str, str]:
|
||||
start = datetime(year, 1, 1, 0, 0, 0, tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
end = datetime(year + 1, 1, 1, 0, 0, 0, tzinfo=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
@@ -486,6 +524,18 @@ def _list_items(*, connection: object, snapshot_id: int, limit: int | None = Non
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def _count_items(*, connection: object, snapshot_id: int) -> int:
|
||||
row = connection.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS item_count
|
||||
FROM rcon_annual_ranking_snapshot_items
|
||||
WHERE snapshot_id = ?
|
||||
""",
|
||||
[snapshot_id],
|
||||
).fetchone()
|
||||
return int(row["item_count"] or 0) if row else 0
|
||||
|
||||
|
||||
def _build_scope_sql(server_key: str, *, table_alias: str = "matches") -> tuple[str, list[object]]:
|
||||
if server_key == ALL_SERVERS_SLUG:
|
||||
return "", []
|
||||
|
||||
@@ -101,12 +101,15 @@ def resolve_get_payload(path: str) -> tuple[HTTPStatus | None, dict[str, object]
|
||||
server_id = params.get("server_id", [None])[0]
|
||||
if server_id is None:
|
||||
server_id = params.get("server", [None])[0]
|
||||
try:
|
||||
return HTTPStatus.OK, build_annual_ranking_snapshot_payload(
|
||||
year=year,
|
||||
server_id=server_id,
|
||||
metric=metric,
|
||||
limit=limit,
|
||||
)
|
||||
except ValueError as error:
|
||||
return HTTPStatus.BAD_REQUEST, build_error_payload(str(error))
|
||||
|
||||
if parsed.path == "/api/current-match":
|
||||
server_slug = parse_qs(parsed.query).get("server", [None])[0]
|
||||
|
||||
@@ -94,6 +94,11 @@ Verificación local (reconstruir estado esperable):
|
||||
- `ready`: existe snapshot;
|
||||
- `missing`: no existe snapshot.
|
||||
3. Verificar `generated_at`, `window_start`, `window_end`, `source_matches_count`.
|
||||
4. Verificar metadatos de limite:
|
||||
- `requested_limit`: limite pedido por el cliente;
|
||||
- `snapshot_limit`: limite persistido en snapshot;
|
||||
- `effective_limit`: limite realmente servido;
|
||||
- `item_count`: filas actualmente disponibles.
|
||||
|
||||
Se considera snapshot existente si API responde `snapshot_status="ready"`.
|
||||
|
||||
@@ -109,6 +114,8 @@ Checklist:
|
||||
|
||||
- HTTP 200 esperado para parámetros válidos de V1.
|
||||
- `status` debe ser `"ok"` con estructura de data consistente.
|
||||
- Para snapshots `ready`, `effective_limit` puede ser menor que `requested_limit`
|
||||
cuando el snapshot fue generado con un limite menor o contiene menos filas.
|
||||
- en V1 con `metric` no soportada, esperar error de request (400) sin recomputar ranking.
|
||||
|
||||
## 8) Como validar desde frontend Stats
|
||||
|
||||
@@ -1069,6 +1069,123 @@ h2 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
|
||||
}
|
||||
|
||||
.stats-comparison-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin: 16px 0;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
|
||||
}
|
||||
|
||||
.stats-comparison-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100%;
|
||||
border: 1px solid rgba(183, 201, 125, 0.18);
|
||||
border-radius: 18px;
|
||||
padding: 16px 18px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(30, 36, 27, 0.94), rgba(11, 14, 10, 0.98));
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03),
|
||||
var(--shadow-soft);
|
||||
}
|
||||
|
||||
.stats-comparison-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, rgba(210, 182, 118, 0.5), transparent 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stats-comparison-card__eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: var(--accent-warm);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stats-comparison-card__title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.stats-comparison-card__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
margin-bottom: 12px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border: 1px solid rgba(159, 168, 141, 0.22);
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 18, 13, 0.84);
|
||||
color: var(--text-soft);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stats-comparison-card__badge--ok {
|
||||
border-color: rgba(183, 201, 125, 0.34);
|
||||
color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.stats-comparison-card__badge--warning {
|
||||
border-color: rgba(210, 182, 118, 0.32);
|
||||
color: #e2d7a9;
|
||||
}
|
||||
|
||||
.stats-comparison-card__badge--error {
|
||||
border-color: rgba(210, 182, 118, 0.45);
|
||||
color: var(--accent-warm);
|
||||
}
|
||||
|
||||
.stats-comparison-card__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.stats-comparison-card__metric {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(159, 168, 141, 0.14);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, rgba(15, 18, 13, 0.5), rgba(8, 10, 7, 0.3));
|
||||
}
|
||||
|
||||
.stats-comparison-card__metric-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stats-comparison-card__metric-value {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
font-size: 1.02rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stats-comparison-card__detail,
|
||||
.stats-comparison-card__note {
|
||||
margin: 0;
|
||||
color: var(--text-soft);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.stats-comparison-card__detail + .stats-comparison-card__detail,
|
||||
.stats-comparison-card__detail + .stats-comparison-card__note {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.stats-summary-card {
|
||||
margin: 0;
|
||||
border: 1px solid rgba(159, 168, 141, 0.2);
|
||||
@@ -1171,6 +1288,10 @@ h2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-comparison-card__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stats-search-form__fields .discord-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const profilePanel = document.getElementById("stats-profile-panel");
|
||||
const profileTitle = document.getElementById("stats-profile-title");
|
||||
const profileStateNode = document.getElementById("stats-profile-state");
|
||||
const comparisonGrid = document.getElementById("stats-comparison-grid");
|
||||
const summaryGrid = document.getElementById("stats-summary-grid");
|
||||
const weeklySummaryNode = document.getElementById("stats-weekly-summary");
|
||||
const monthlySummaryNode = document.getElementById("stats-monthly-summary");
|
||||
@@ -143,6 +144,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
profilePanel.hidden = false;
|
||||
profileStateNode.textContent = message;
|
||||
profileStateNode.className = `stats-state stats-state--${state}`;
|
||||
if (comparisonGrid) {
|
||||
comparisonGrid.innerHTML = "";
|
||||
}
|
||||
summaryGrid.innerHTML = "";
|
||||
if (weeklySummaryNode) {
|
||||
weeklySummaryNode.textContent = "Cargando ...";
|
||||
@@ -423,24 +427,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${backendBaseUrl}/api/stats/players/${encodeURIComponent(playerId)}?timeframe=weekly`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Profile request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (!payload || payload.status !== "ok") {
|
||||
throw new Error(payload?.message || "Respuesta de perfil invalida");
|
||||
}
|
||||
|
||||
const data = payload.data || {};
|
||||
const [weeklyData, monthlyData] = await Promise.all([
|
||||
fetchPlayerProfile(playerId, "weekly"),
|
||||
fetchPlayerProfile(playerId, "monthly"),
|
||||
]);
|
||||
profilePanel.hidden = false;
|
||||
profileTitle.textContent = messages.profileReadyTitle;
|
||||
const playerName = String(data.player_name || "Sin nombre");
|
||||
const playerIdText = String(data.player_id || playerId);
|
||||
const hasStats = Number(data.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;
|
||||
|
||||
profileStateNode.textContent = hasStats
|
||||
? `${playerName} (${playerIdText})`
|
||||
@@ -454,29 +451,36 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
<p class="stats-summary-title">Identidad</p>
|
||||
<p><strong>Jugador:</strong> ${escapeHtml(playerName)}</p>
|
||||
<p><strong>ID:</strong> ${escapeHtml(playerIdText)}</p>
|
||||
<p><strong>Partidas:</strong> ${safeInt(data.matches_considered, 0)}</p>
|
||||
<p><strong>Partidas semanales:</strong> ${safeInt(weeklyData.matches_considered, 0)}</p>
|
||||
<p><strong>Partidas mensuales:</strong> ${safeInt(monthlyData.matches_considered, 0)}</p>
|
||||
</article>
|
||||
<article class="stats-summary-card">
|
||||
<p class="stats-summary-title">Totales</p>
|
||||
<p><strong>Kills:</strong> ${safeInt(data.kills, 0)}</p>
|
||||
<p><strong>Deaths:</strong> ${safeInt(data.deaths, 0)}</p>
|
||||
<p><strong>Teamkills:</strong> ${safeInt(data.teamkills, 0)}</p>
|
||||
<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">Ratios</p>
|
||||
<p><strong>K/D:</strong> ${safeDecimal(data.kd_ratio, 2, "0.00")}</p>
|
||||
<p><strong>Kills/partida:</strong> ${safeDecimal(data.kills_per_match, 2, "0.00")}</p>
|
||||
<p><strong>Deaths/partida:</strong> ${safeDecimal(data.deaths_per_match, 2, "0.00")}</p>
|
||||
<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) {
|
||||
comparisonGrid.innerHTML = renderComparisonCards(weeklyData, monthlyData);
|
||||
}
|
||||
|
||||
weeklySummaryNode.innerHTML = formatRankingSummary(
|
||||
data.weekly_ranking,
|
||||
weeklyData.weekly_ranking,
|
||||
"Semanal",
|
||||
weeklyData.matches_considered,
|
||||
);
|
||||
monthlySummaryNode.innerHTML = formatRankingSummary(
|
||||
data.monthly_ranking,
|
||||
monthlyData.monthly_ranking,
|
||||
"Mensual",
|
||||
monthlyData.matches_considered,
|
||||
);
|
||||
|
||||
setBackendState(messages.backendOnline, true);
|
||||
@@ -490,6 +494,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
profileTitle.textContent = messages.profileReadyTitle;
|
||||
profileStateNode.textContent = messages.profileError;
|
||||
profileStateNode.className = "stats-state stats-state--error";
|
||||
if (comparisonGrid) {
|
||||
comparisonGrid.innerHTML = "";
|
||||
}
|
||||
summaryGrid.innerHTML = "";
|
||||
if (weeklySummaryNode) {
|
||||
weeklySummaryNode.textContent = messages.weeklyPlaceholder;
|
||||
@@ -507,6 +514,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (openPanel) {
|
||||
profilePanel.hidden = true;
|
||||
}
|
||||
if (comparisonGrid) {
|
||||
comparisonGrid.innerHTML = "";
|
||||
}
|
||||
summaryGrid.innerHTML = "";
|
||||
if (weeklySummaryNode) {
|
||||
weeklySummaryNode.textContent = messages.weeklyPlaceholder;
|
||||
@@ -518,27 +528,213 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
profileStateNode.className = "stats-state stats-state--neutral";
|
||||
}
|
||||
|
||||
function formatRankingSummary(ranking, timeframeLabel) {
|
||||
if (!ranking || !Number.isFinite(safeParseNumber(ranking.ranking_position))) {
|
||||
return `Sin ranking ${timeframeLabel.toLowerCase()} registrado para este jugador.`;
|
||||
async function fetchPlayerProfile(playerId, timeframe) {
|
||||
const response = await fetch(
|
||||
`${backendBaseUrl}/api/stats/players/${encodeURIComponent(playerId)}?timeframe=${encodeURIComponent(timeframe)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Profile request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (!payload || payload.status !== "ok") {
|
||||
throw new Error(payload?.message || "Respuesta de perfil invalida");
|
||||
}
|
||||
|
||||
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 renderWindowComparisonCard(label, data, ranking) {
|
||||
const rankingState = describeRankingState(ranking, data?.matches_considered, label);
|
||||
const matches = safeInt(data?.matches_considered, 0);
|
||||
const kills = safeInt(data?.kills, 0);
|
||||
const deaths = safeInt(data?.deaths, 0);
|
||||
const kdRatio = safeDecimal(data?.kd_ratio, 2, "0.00");
|
||||
const killsPerMatch = safeDecimal(data?.kills_per_match, 2, "0.00");
|
||||
|
||||
return `
|
||||
<article class="stats-comparison-card">
|
||||
<p class="stats-comparison-card__eyebrow">Comparativa ${label.toLowerCase()}</p>
|
||||
<h3 class="stats-comparison-card__title">Ventana ${label.toLowerCase()}</h3>
|
||||
<span class="stats-comparison-card__badge stats-comparison-card__badge--${rankingState.tone}">
|
||||
${escapeHtml(rankingState.title)}
|
||||
</span>
|
||||
<div class="stats-comparison-card__grid">
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">Partidas</span>
|
||||
<span class="stats-comparison-card__metric-value">${matches}</span>
|
||||
</div>
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">Kills</span>
|
||||
<span class="stats-comparison-card__metric-value">${kills}</span>
|
||||
</div>
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">Deaths</span>
|
||||
<span class="stats-comparison-card__metric-value">${deaths}</span>
|
||||
</div>
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">K/D</span>
|
||||
<span class="stats-comparison-card__metric-value">${kdRatio}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="stats-comparison-card__detail">
|
||||
<strong>Kills por partida:</strong> ${killsPerMatch}
|
||||
</p>
|
||||
<p class="stats-comparison-card__note">${escapeHtml(rankingState.detail)}</p>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderDeltaComparisonCard(weeklyData, monthlyData) {
|
||||
const weeklyKpm = safeParseNumber(weeklyData?.kills_per_match);
|
||||
const monthlyKpm = safeParseNumber(monthlyData?.kills_per_match);
|
||||
const weeklyKd = safeParseNumber(weeklyData?.kd_ratio);
|
||||
const monthlyKd = safeParseNumber(monthlyData?.kd_ratio);
|
||||
const killsDelta = safeInt(monthlyData?.kills, 0) - safeInt(weeklyData?.kills, 0);
|
||||
const matchesDelta =
|
||||
safeInt(monthlyData?.matches_considered, 0) - safeInt(weeklyData?.matches_considered, 0);
|
||||
|
||||
return `
|
||||
<article class="stats-comparison-card">
|
||||
<p class="stats-comparison-card__eyebrow">Lectura comparada</p>
|
||||
<h3 class="stats-comparison-card__title">Pulso semanal vs mensual</h3>
|
||||
<span class="stats-comparison-card__badge stats-comparison-card__badge--ok">
|
||||
${escapeHtml(buildDeltaHeadline(killsDelta, matchesDelta))}
|
||||
</span>
|
||||
<div class="stats-comparison-card__grid">
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">KPM semanal</span>
|
||||
<span class="stats-comparison-card__metric-value">${safeDecimal(weeklyKpm, 2, "0.00")}</span>
|
||||
</div>
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">KPM mensual</span>
|
||||
<span class="stats-comparison-card__metric-value">${safeDecimal(monthlyKpm, 2, "0.00")}</span>
|
||||
</div>
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">K/D semanal</span>
|
||||
<span class="stats-comparison-card__metric-value">${safeDecimal(weeklyKd, 2, "0.00")}</span>
|
||||
</div>
|
||||
<div class="stats-comparison-card__metric">
|
||||
<span class="stats-comparison-card__metric-label">K/D mensual</span>
|
||||
<span class="stats-comparison-card__metric-value">${safeDecimal(monthlyKd, 2, "0.00")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="stats-comparison-card__detail">
|
||||
<strong>Delta de kills:</strong> ${formatSignedNumber(killsDelta)}
|
||||
</p>
|
||||
<p class="stats-comparison-card__detail">
|
||||
<strong>Delta de partidas:</strong> ${formatSignedNumber(matchesDelta)}
|
||||
</p>
|
||||
<p class="stats-comparison-card__note">
|
||||
La lectura compara solo datos existentes del backend actual, sin recalcular rankings ni pedir nuevos endpoints.
|
||||
</p>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatRankingSummary(ranking, timeframeLabel, matchesConsidered) {
|
||||
const rankingState = describeRankingState(ranking, matchesConsidered, timeframeLabel);
|
||||
if (!ranking) {
|
||||
return escapeHtml(rankingState.detail);
|
||||
}
|
||||
|
||||
if (!Number.isFinite(safeParseNumber(ranking.ranking_position))) {
|
||||
return `
|
||||
<p><strong>Estado:</strong> ${escapeHtml(rankingState.title)}</p>
|
||||
<p>${escapeHtml(rankingState.detail)}</p>
|
||||
<p>${escapeHtml(formatWindowRange(ranking))}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
const rank = safeInt(ranking.ranking_position, 0);
|
||||
const metric = escapeHtml(String(ranking.metric || annualMetric));
|
||||
const windowLabel = escapeHtml(String(ranking.window_kind || ""));
|
||||
const windowStart = formatDateTime(ranking.window_start);
|
||||
const windowEnd = formatDateTime(ranking.window_end);
|
||||
const windowRange = windowLabel
|
||||
? `${windowLabel}: ${windowStart} - ${windowEnd}`
|
||||
: `Ventana: ${windowStart} - ${windowEnd}`;
|
||||
|
||||
return `
|
||||
<p><strong>Posicion:</strong> ${rank}</p>
|
||||
<p><strong>Metric:</strong> ${metric}</p>
|
||||
<p>${windowRange}</p>
|
||||
<p>${escapeHtml(formatWindowRange(ranking))}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
function describeRankingState(ranking, matchesConsidered, timeframeLabel) {
|
||||
const matches = safeInt(matchesConsidered, 0);
|
||||
const label = String(timeframeLabel || "ranking").toLowerCase();
|
||||
if (!ranking) {
|
||||
return {
|
||||
tone: "error",
|
||||
title: "Ranking ausente",
|
||||
detail: `No se recibio bloque de ranking ${label} desde el backend.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (Number.isFinite(safeParseNumber(ranking.ranking_position))) {
|
||||
return {
|
||||
tone: "ok",
|
||||
title: `Posicion #${safeInt(ranking.ranking_position, 0)}`,
|
||||
detail: `Jugador posicionado en el ranking ${label} activo por kills.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (matches <= 0) {
|
||||
return {
|
||||
tone: "warning",
|
||||
title: "Sin actividad",
|
||||
detail: `No hay actividad cerrada del jugador en la ventana ${label} actual.`,
|
||||
};
|
||||
}
|
||||
|
||||
const windowKind = String(ranking.window_kind || "").toLowerCase();
|
||||
if (windowKind.startsWith("previous-")) {
|
||||
return {
|
||||
tone: "warning",
|
||||
title: "Profundidad insuficiente",
|
||||
detail: `La ventana ${label} activa usa fallback previo y el jugador no aparece posicionado.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: "warning",
|
||||
title: "Fuera del ranking visible",
|
||||
detail: `El jugador tiene actividad en la ventana ${label}, pero no figura en el ranking visible actual.`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDeltaHeadline(killsDelta, matchesDelta) {
|
||||
if (killsDelta === 0 && matchesDelta === 0) {
|
||||
return "Ritmo estable";
|
||||
}
|
||||
if (killsDelta > 0 || matchesDelta > 0) {
|
||||
return "Presion mensual superior";
|
||||
}
|
||||
return "Ventana corta mas intensa";
|
||||
}
|
||||
|
||||
function formatSignedNumber(value) {
|
||||
const parsed = safeInt(value, 0);
|
||||
if (parsed > 0) {
|
||||
return `+${parsed}`;
|
||||
}
|
||||
return String(parsed);
|
||||
}
|
||||
|
||||
function formatWindowRange(ranking) {
|
||||
const windowLabel = String(ranking?.window_kind || "").trim();
|
||||
const windowStart = formatDateTime(ranking?.window_start);
|
||||
const windowEnd = formatDateTime(ranking?.window_end);
|
||||
if (windowLabel) {
|
||||
return `${windowLabel}: ${windowStart} - ${windowEnd}`;
|
||||
}
|
||||
return `Ventana: ${windowStart} - ${windowEnd}`;
|
||||
}
|
||||
|
||||
function safeInt(value, fallback = 0) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
<p class="panel__intro panel__intro--tight" id="stats-profile-state">
|
||||
Selecciona un jugador para ver su panel personal.
|
||||
</p>
|
||||
<div class="stats-comparison-grid" id="stats-comparison-grid"></div>
|
||||
<div class="stats-summary-grid" id="stats-summary-grid"></div>
|
||||
<div class="stats-ranking-grid">
|
||||
<article class="stats-summary-card">
|
||||
|
||||
@@ -2,6 +2,14 @@ $ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "HLL Vietnam platform validation"
|
||||
|
||||
function Assert-LastExitCode {
|
||||
param([string] $Message)
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw $Message
|
||||
}
|
||||
}
|
||||
|
||||
$configPath = "ai-platform.json"
|
||||
if (-not (Test-Path $configPath)) {
|
||||
throw "Missing $configPath"
|
||||
@@ -54,8 +62,12 @@ if status is None or payload.get("status") != "ok":
|
||||
'@
|
||||
|
||||
$backendImportCheck | python -
|
||||
Assert-LastExitCode "Backend startup import check failed."
|
||||
|
||||
powershell -ExecutionPolicy Bypass -File scripts/run-historical-ui-regression-tests.ps1
|
||||
Assert-LastExitCode "Historical UI regression validation failed."
|
||||
powershell -ExecutionPolicy Bypass -File scripts/run-stats-validation.ps1
|
||||
Assert-LastExitCode "Stats regression validation failed."
|
||||
|
||||
Write-Host "No product integration tests are configured for this platform-only scope."
|
||||
Write-Host "Backend startup import check passed."
|
||||
|
||||
226
scripts/run-stats-validation.ps1
Normal file
226
scripts/run-stats-validation.ps1
Normal file
@@ -0,0 +1,226 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "Stats regression validation"
|
||||
|
||||
function Assert-FileExists {
|
||||
param(
|
||||
[string] $Path,
|
||||
[string] $Message
|
||||
)
|
||||
|
||||
if (-not (Test-Path $Path)) {
|
||||
throw $Message
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-Contains {
|
||||
param(
|
||||
[string] $Content,
|
||||
[string] $Pattern,
|
||||
[string] $Message
|
||||
)
|
||||
|
||||
if ($Content -notmatch [regex]::Escape($Pattern)) {
|
||||
throw $Message
|
||||
}
|
||||
}
|
||||
|
||||
function Get-HttpStatusCode {
|
||||
param([string] $Url)
|
||||
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 5
|
||||
return [int] $response.StatusCode
|
||||
} catch [System.Net.WebException] {
|
||||
if ($_.Exception.Response) {
|
||||
return [int] $_.Exception.Response.StatusCode
|
||||
}
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-LastExitCode {
|
||||
param([string] $Message)
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw $Message
|
||||
}
|
||||
}
|
||||
|
||||
Assert-FileExists "frontend/stats.html" "Missing frontend/stats.html"
|
||||
Assert-FileExists "frontend/assets/js/stats.js" "Missing frontend/assets/js/stats.js"
|
||||
|
||||
$statsHtml = Get-Content -Raw "frontend/stats.html"
|
||||
$statsJs = Get-Content -Raw "frontend/assets/js/stats.js"
|
||||
|
||||
Assert-Contains $statsHtml 'id="stats-search-form"' `
|
||||
"Stats page no longer exposes the player search form."
|
||||
Assert-Contains $statsHtml 'id="stats-profile-panel"' `
|
||||
"Stats page no longer exposes the player profile panel."
|
||||
Assert-Contains $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" `
|
||||
"Stats frontend no longer targets the player search endpoint."
|
||||
Assert-Contains $statsJs "/api/stats/rankings/annual" `
|
||||
"Stats frontend no longer targets the annual ranking endpoint."
|
||||
Assert-Contains $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."
|
||||
|
||||
$backendContractCheck = @'
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
sys.path.insert(0, "backend")
|
||||
|
||||
from app.routes import resolve_get_payload
|
||||
|
||||
|
||||
def require(condition, message):
|
||||
if not condition:
|
||||
raise SystemExit(message)
|
||||
|
||||
|
||||
def read_payload(path):
|
||||
status, payload = resolve_get_payload(path)
|
||||
require(status is not None, f"{path} did not resolve")
|
||||
return int(status), payload
|
||||
|
||||
|
||||
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.")
|
||||
|
||||
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.")
|
||||
|
||||
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.")
|
||||
|
||||
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.")
|
||||
|
||||
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.")
|
||||
|
||||
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.")
|
||||
|
||||
current_year = datetime.now(timezone.utc).year
|
||||
annual_status, annual_payload = read_payload(
|
||||
f"/api/stats/rankings/annual?year={current_year}&server_id=all&metric=kills&limit=20"
|
||||
)
|
||||
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.")
|
||||
|
||||
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.")
|
||||
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.",
|
||||
)
|
||||
|
||||
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.")
|
||||
|
||||
unsupported_metric_status, unsupported_metric_payload = 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.")
|
||||
|
||||
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.")
|
||||
|
||||
print(json.dumps({
|
||||
"checked": [
|
||||
"health",
|
||||
"stats-player-search",
|
||||
"stats-player-profile",
|
||||
"stats-annual-ranking",
|
||||
],
|
||||
"annual_snapshot_status": annual_data.get("snapshot_status"),
|
||||
"search_items_count": len(search_data.get("items") or []),
|
||||
"profile_matches_considered": profile_data.get("matches_considered"),
|
||||
}))
|
||||
'@
|
||||
|
||||
$backendContractCheck | python -
|
||||
Assert-LastExitCode "Stats route-contract validation failed."
|
||||
|
||||
$backendBaseUrl = "http://127.0.0.1:8000"
|
||||
$backendAvailable = $false
|
||||
|
||||
try {
|
||||
$healthPayload = Invoke-RestMethod -Uri "$backendBaseUrl/health" -TimeoutSec 5
|
||||
if ($healthPayload.status -ne "ok") {
|
||||
throw "Live backend health payload did not return status=ok."
|
||||
}
|
||||
$backendAvailable = $true
|
||||
Write-Host "Live backend available at $backendBaseUrl"
|
||||
} 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."
|
||||
}
|
||||
|
||||
$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."
|
||||
}
|
||||
|
||||
$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."
|
||||
}
|
||||
|
||||
$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."
|
||||
}
|
||||
|
||||
Write-Host "Live HTTP checks passed for Stats endpoints."
|
||||
}
|
||||
|
||||
Write-Host "Stats regression validation passed."
|
||||
Reference in New Issue
Block a user