mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-27 09:32:11 +03:00
fix(dashboard): keep Profile XP and level consistent (#11604)
Merged via /merge-batch (lote 2026-08-26 batch 2, v3.8.51). Boarded no worktree combinado junto com outras ~20 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e 292 testes focados (unit) + 18 (vitest) passando. Obrigado pela contribuição.
This commit is contained in:
1
changelog.d/fixes/11604-profile-xp-consistency.md
Normal file
1
changelog.d/fixes/11604-profile-xp-consistency.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(dashboard):** Keep the Profile level and progress aligned with aggregate XP, including bounded handling for invalid totals ([#11604](https://github.com/diegosouzapw/OmniRoute/pull/11604)) — thanks @pacocartones
|
||||
@@ -299,22 +299,22 @@ export function getBadgeDefinitions(category?: string): BadgeDefinition[] {
|
||||
|
||||
/**
|
||||
* Aggregate XP across every API key (the operator-wide profile view used by the
|
||||
* dashboard profile page, which is not scoped to a single key). Sums total XP and
|
||||
* takes the highest reached level. (#3484)
|
||||
* dashboard profile page, which is not scoped to a single key). The aggregate
|
||||
* level must be derived from the same summed XP displayed by the profile. (#3484)
|
||||
*/
|
||||
export function getAggregateXp(): UserLevelRow {
|
||||
const row = db()
|
||||
.prepare(
|
||||
`SELECT COALESCE(SUM(total_xp), 0) AS total_xp,
|
||||
COALESCE(MAX(current_level), 1) AS current_level,
|
||||
MAX(updated_at) AS updated_at
|
||||
FROM user_levels`
|
||||
)
|
||||
.get() as { total_xp: number; current_level: number; updated_at: string | null };
|
||||
.get() as { total_xp: number; updated_at: string | null };
|
||||
const totalXp = row?.total_xp ?? 0;
|
||||
return {
|
||||
apiKeyId: "*",
|
||||
totalXp: row?.total_xp ?? 0,
|
||||
currentLevel: row?.current_level ?? 1,
|
||||
totalXp,
|
||||
currentLevel: calculateLevel(totalXp),
|
||||
updatedAt: row?.updated_at ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,22 +44,32 @@ export function cumulativeXpForLevel(level: number): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate level from total XP using the inverse of the cumulative XP curve.
|
||||
* Calculate the exact level from total XP.
|
||||
*
|
||||
* The cumulative XP for level L approximates to `100 * L^2.5 / 2.5`.
|
||||
* Solving for L gives `L ≈ (totalXp * 2.5 / 100) ^ 0.4`.
|
||||
* The inverse cumulative curve supplies a fast initial estimate. The result is
|
||||
* then reconciled against the exact floored thresholds from `xpForLevel`.
|
||||
*
|
||||
* @param totalXp - Total accumulated XP
|
||||
* @returns Current level (minimum 1)
|
||||
*
|
||||
* @example
|
||||
* calculateLevel(0) // 1
|
||||
* calculateLevel(5000) // ~8
|
||||
* calculateLevel(100000) // ~100
|
||||
* calculateLevel(cumulativeXpForLevel(10)) // 10
|
||||
*/
|
||||
export function calculateLevel(totalXp: number): number {
|
||||
if (totalXp <= 0) return 1;
|
||||
return Math.max(1, Math.floor(Math.pow((totalXp * 2.5) / 100, 0.4)));
|
||||
if (!Number.isFinite(totalXp) || totalXp <= 0) return 1;
|
||||
const boundedXp = Math.min(totalXp, Number.MAX_SAFE_INTEGER);
|
||||
|
||||
// Use the inverse curve only as a fast starting point, then reconcile it
|
||||
// against the exact floored cumulative thresholds used by the XP engine.
|
||||
let level = Math.max(1, Math.floor(Math.pow((boundedXp * 2.5) / 100, 0.4)));
|
||||
while (level > 1 && cumulativeXpForLevel(level) > boundedXp) {
|
||||
level -= 1;
|
||||
}
|
||||
while (cumulativeXpForLevel(level + 1) <= boundedXp) {
|
||||
level += 1;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { addXp, getXp } from "../../../src/lib/db/gamification";
|
||||
import { addXp, getAggregateXp, getXp } from "../../../src/lib/db/gamification";
|
||||
import { calculateLevel } from "../../../src/lib/gamification/xp";
|
||||
import { getDbInstance } from "../../../src/lib/db/core";
|
||||
|
||||
@@ -32,4 +32,28 @@ describe("DB Gamification — addXp level computation", () => {
|
||||
db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(testKey);
|
||||
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(testKey);
|
||||
});
|
||||
|
||||
it("derives the operator level from aggregate XP instead of the highest key level", () => {
|
||||
const firstKey = `test-aggregate-xp-a-${Date.now()}`;
|
||||
const secondKey = `test-aggregate-xp-b-${Date.now()}`;
|
||||
const db = getDbInstance();
|
||||
const existing = getAggregateXp();
|
||||
const firstXp = 9000;
|
||||
const secondXp = 8153;
|
||||
|
||||
try {
|
||||
addXp(firstKey, "request", firstXp);
|
||||
addXp(secondKey, "request", secondXp);
|
||||
|
||||
const aggregate = getAggregateXp();
|
||||
const expectedTotal = existing.totalXp + firstXp + secondXp;
|
||||
assert.equal(aggregate.totalXp, expectedTotal);
|
||||
assert.equal(aggregate.currentLevel, calculateLevel(expectedTotal));
|
||||
} finally {
|
||||
for (const key of [firstKey, secondKey]) {
|
||||
db.prepare("DELETE FROM user_levels WHERE api_key_id = ?").run(key);
|
||||
db.prepare("DELETE FROM xp_audit_log WHERE api_key_id = ?").run(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,31 +20,50 @@ describe("XP/Level Engine", () => {
|
||||
assert.equal(calculateLevel(-100), 1);
|
||||
});
|
||||
|
||||
it("falls back to level 1 for non-finite XP", () => {
|
||||
assert.equal(calculateLevel(Number.NaN), 1);
|
||||
assert.equal(calculateLevel(Number.POSITIVE_INFINITY), 1);
|
||||
assert.equal(calculateLevel(Number.NEGATIVE_INFINITY), 1);
|
||||
});
|
||||
|
||||
it("bounds finite XP above the safe integer range", () => {
|
||||
assert.equal(calculateLevel(Number.MAX_VALUE), calculateLevel(Number.MAX_SAFE_INTEGER));
|
||||
});
|
||||
|
||||
it("returns level 1 for small XP", () => {
|
||||
assert.equal(calculateLevel(50), 1);
|
||||
});
|
||||
|
||||
it("returns level ~5 for 3162 XP (xpForLevel(10))", () => {
|
||||
const level = calculateLevel(3162);
|
||||
assert.ok(level >= 4 && level <= 7, `Expected ~5, got ${level}`);
|
||||
it("returns level 5 for 3162 XP", () => {
|
||||
assert.equal(calculateLevel(3162), 5);
|
||||
});
|
||||
|
||||
it("returns level ~10 for cumulative level-10 XP", () => {
|
||||
it("returns level 10 for cumulative level-10 XP", () => {
|
||||
const xp = cumulativeXpForLevel(10);
|
||||
const level = calculateLevel(xp);
|
||||
assert.ok(level >= 9 && level <= 11, `Expected ~10, got ${level}`);
|
||||
assert.equal(calculateLevel(xp), 10);
|
||||
});
|
||||
|
||||
it("returns level ~25 for cumulative level-25 XP", () => {
|
||||
it("returns level 25 for cumulative level-25 XP", () => {
|
||||
const xp = cumulativeXpForLevel(25);
|
||||
const level = calculateLevel(xp);
|
||||
assert.ok(level >= 24 && level <= 26, `Expected ~25, got ${level}`);
|
||||
assert.equal(calculateLevel(xp), 25);
|
||||
});
|
||||
|
||||
it("returns level ~50 for cumulative level-50 XP", () => {
|
||||
it("returns level 50 for cumulative level-50 XP", () => {
|
||||
const xp = cumulativeXpForLevel(50);
|
||||
const level = calculateLevel(xp);
|
||||
assert.ok(level >= 49 && level <= 51, `Expected ~50, got ${level}`);
|
||||
assert.equal(calculateLevel(xp), 50);
|
||||
});
|
||||
|
||||
it("matches every exact cumulative level boundary", () => {
|
||||
for (let expected = 1; expected <= 100; expected++) {
|
||||
assert.equal(calculateLevel(cumulativeXpForLevel(expected)), expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not advance until the next cumulative boundary", () => {
|
||||
for (let current = 1; current < 100; current++) {
|
||||
const beforeNext = cumulativeXpForLevel(current + 1) - 1;
|
||||
assert.equal(calculateLevel(beforeNext), current);
|
||||
}
|
||||
});
|
||||
|
||||
it("monotonically increases", () => {
|
||||
|
||||
Reference in New Issue
Block a user