mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +03:00
feat(radar): recognize supporters and add Intel UI
This commit is contained in:
197
src/app/(dashboard)/dashboard/radar/intel/page.tsx
Normal file
197
src/app/(dashboard)/dashboard/radar/intel/page.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import type { RadarIntelFeed } from "@/lib/radar/intelFeedSchema";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
interface IntelMeta {
|
||||
version: string;
|
||||
tier: "live";
|
||||
fetchedAt: string;
|
||||
supporterVerified: true;
|
||||
}
|
||||
|
||||
export default function RadarIntelPage() {
|
||||
const t = useTranslations("radarIntelPage");
|
||||
const [intel, setIntel] = useState<RadarIntelFeed | null>(null);
|
||||
const [meta, setMeta] = useState<IntelMeta | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [flagOff, setFlagOff] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const response = await fetch("/api/radar/intel");
|
||||
if (response.status === 404) {
|
||||
setFlagOff(true);
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error("intel_load_failed");
|
||||
const body = (await response.json()) as {
|
||||
intel?: RadarIntelFeed | null;
|
||||
meta?: IntelMeta | null;
|
||||
};
|
||||
setIntel(body.intel ?? null);
|
||||
setMeta(body.meta ?? null);
|
||||
}, []);
|
||||
|
||||
const sync = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/radar/intel/sync", { method: "POST" });
|
||||
if (response.status === 404) {
|
||||
setFlagOff(true);
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error("intel_sync_failed");
|
||||
const status = (await response.json()) as { status?: string };
|
||||
if (
|
||||
["error", "invalid_signature", "invalid_schema", "wrong_tier", "too_large"].includes(
|
||||
status.status ?? ""
|
||||
)
|
||||
) {
|
||||
setError(t("loadFailed"));
|
||||
}
|
||||
await load();
|
||||
} catch {
|
||||
setError(t("loadFailed"));
|
||||
await load().catch(() => undefined);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [load, t]);
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
.catch(() => setError(t("loadFailed")))
|
||||
.finally(() => setLoading(false));
|
||||
}, [load, t]);
|
||||
|
||||
if (flagOff) notFound();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<Link
|
||||
href="/dashboard/radar"
|
||||
className="text-sm text-text-muted hover:text-text-main transition-colors"
|
||||
>
|
||||
← {t("backToRadar")}
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="mt-1 text-sm text-text-muted">{t("subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{meta?.supporterVerified === true && (
|
||||
<span
|
||||
data-badge-id="radar-supporter"
|
||||
className="rounded-full border border-violet-500 px-3 py-1 text-sm text-violet-300"
|
||||
>
|
||||
{t("supporterBadge")}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void sync()}
|
||||
disabled={syncing}
|
||||
className="rounded-lg border border-violet-500 px-4 py-2 text-sm font-medium text-violet-400 disabled:opacity-50"
|
||||
>
|
||||
{syncing ? t("syncing") : t("refresh")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-lg bg-red-500/10 p-3 text-sm text-red-400">{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex min-h-48 items-center justify-center text-text-muted">
|
||||
{t("loading")}
|
||||
</div>
|
||||
) : !intel || !meta ? (
|
||||
<Card>
|
||||
<p className="py-8 text-center text-text-muted">{t("empty")}</p>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">{t("methodology")}</p>
|
||||
<p className="mt-2 font-semibold">
|
||||
{t("eloMethod", {
|
||||
initial: intel.methodology.initialRating,
|
||||
factor: intel.methodology.kFactor,
|
||||
})}
|
||||
</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">{t("freshness")}</p>
|
||||
<p className="mt-2 font-semibold">
|
||||
{t(`freshnessValues.${intel.catalog.freshness}`)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
{t("ageDays", { days: intel.catalog.ageDays })}
|
||||
</p>
|
||||
</Card>
|
||||
<Card>
|
||||
<p className="text-xs uppercase tracking-wide text-text-muted">{t("trend")}</p>
|
||||
<p className="mt-2 font-semibold">{t(`trendValues.${intel.catalog.trend}`)}</p>
|
||||
<p className="mt-1 text-sm text-text-muted">
|
||||
{t("modelDelta", {
|
||||
current: intel.catalog.models.current,
|
||||
added: intel.catalog.models.added,
|
||||
removed: intel.catalog.models.removed,
|
||||
})}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">{t("ranking")}</h2>
|
||||
<span className="text-xs text-text-muted">{meta.version}</span>
|
||||
</div>
|
||||
{intel.rankings.length === 0 ? (
|
||||
<p className="py-6 text-center text-text-muted">{t("noRankings")}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-text-muted">
|
||||
<tr>
|
||||
<th className="pb-3">#</th>
|
||||
<th className="pb-3">{t("model")}</th>
|
||||
<th className="pb-3">{t("category")}</th>
|
||||
<th className="pb-3 text-right">{t("rating")}</th>
|
||||
<th className="pb-3 text-right">{t("matches")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{intel.rankings.map((ranking) => (
|
||||
<tr
|
||||
key={`${ranking.category}:${ranking.provider}:${ranking.modelId}`}
|
||||
className="border-t border-border"
|
||||
>
|
||||
<td className="py-3">{ranking.rank}</td>
|
||||
<td className="py-3 font-mono">
|
||||
{ranking.provider}/{ranking.modelId}
|
||||
</td>
|
||||
<td className="py-3">{ranking.category}</td>
|
||||
<td className="py-3 text-right">{ranking.rating}</td>
|
||||
<td className="py-3 text-right">{ranking.matches}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -317,6 +317,14 @@ export default function RadarPage() {
|
||||
<p className="text-sm text-text-muted mt-1">{t("subtitle")}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(pageState === "empty" || pageState === "populated") && (
|
||||
<Link
|
||||
href="/dashboard/radar/intel"
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg border border-border text-text-main hover:border-violet-500 hover:text-violet-400 transition-colors"
|
||||
>
|
||||
{t("intel")}
|
||||
</Link>
|
||||
)}
|
||||
{(pageState === "empty" || pageState === "populated") && (
|
||||
<Link
|
||||
href="/dashboard/radar/offers"
|
||||
|
||||
@@ -11600,6 +11600,11 @@
|
||||
"rare": "Rare achievements"
|
||||
},
|
||||
"badges": {
|
||||
"radar-supporter": {
|
||||
"name": "Radar Supporter",
|
||||
"description": "Verified a live OmniRoute Radar supporter feed",
|
||||
"criteria": "Verify a signed live Radar supporter feed."
|
||||
},
|
||||
"first-token": {
|
||||
"name": "First Token",
|
||||
"description": "Made your first API request",
|
||||
@@ -12307,7 +12312,8 @@
|
||||
"modelEnabled": "Enabled locally",
|
||||
"localStateSaveFailed": "Failed to save local Radar settings",
|
||||
"guidedCombos": "Guided combos",
|
||||
"offers": "Offers"
|
||||
"offers": "Offers",
|
||||
"intel": "Intel"
|
||||
},
|
||||
"radarCombosPage": {
|
||||
"title": "Radar guided combos",
|
||||
@@ -12351,6 +12357,31 @@
|
||||
"addConnectionDescription": "Don't have a connection yet? Add one in the providers dashboard.",
|
||||
"addConnectionLink": "Go to providers →"
|
||||
},
|
||||
"radarIntelPage": {
|
||||
"title": "Radar Intel",
|
||||
"subtitle": "Radar-owned ELO rankings and factual catalog movement.",
|
||||
"backToRadar": "Back to Radar",
|
||||
"loading": "Loading Intel...",
|
||||
"refresh": "Refresh Intel",
|
||||
"syncing": "Refreshing...",
|
||||
"loadFailed": "We could not refresh Intel. The last verified local cache is kept.",
|
||||
"empty": "No verified Intel snapshot is available yet.",
|
||||
"supporterBadge": "Radar Supporter",
|
||||
"methodology": "Methodology",
|
||||
"eloMethod": "ELO, initial {initial}, K={factor}",
|
||||
"freshness": "Catalog freshness",
|
||||
"ageDays": "{days, plural, one {# day old} other {# days old}}",
|
||||
"trend": "Catalog trend",
|
||||
"modelDelta": "{current} models, +{added} / -{removed}",
|
||||
"ranking": "Model ranking",
|
||||
"noRankings": "No confirmed comparisons are available yet.",
|
||||
"model": "Model",
|
||||
"category": "Category",
|
||||
"rating": "Rating",
|
||||
"matches": "Matches",
|
||||
"freshnessValues": { "fresh": "Fresh", "aging": "Aging", "stale": "Stale" },
|
||||
"trendValues": { "growing": "Growing", "stable": "Stable", "shrinking": "Shrinking" }
|
||||
},
|
||||
"radarOffersPage": {
|
||||
"title": "Supporter offers",
|
||||
"subtitle": "Official discounts, credits, trials, and reviewed partner benefits from Radar.",
|
||||
|
||||
@@ -11600,6 +11600,11 @@
|
||||
"rare": "Conquistas raras"
|
||||
},
|
||||
"badges": {
|
||||
"radar-supporter": {
|
||||
"name": "Apoiador do Radar",
|
||||
"description": "Verificou um feed ativo de apoiador do OmniRoute Radar",
|
||||
"criteria": "Verifique um feed ativo e assinado de apoiador do Radar."
|
||||
},
|
||||
"first-token": {
|
||||
"name": "Primeiro Token",
|
||||
"description": "Fez sua primeira requisição de API",
|
||||
@@ -12307,7 +12312,8 @@
|
||||
"modelEnabled": "Ativado localmente",
|
||||
"localStateSaveFailed": "Falha ao salvar as configurações locais do Radar",
|
||||
"guidedCombos": "Combos guiados",
|
||||
"offers": "Ofertas"
|
||||
"offers": "Ofertas",
|
||||
"intel": "Intel"
|
||||
},
|
||||
"radarCombosPage": {
|
||||
"title": "Combos guiados pelo Radar",
|
||||
@@ -12351,6 +12357,31 @@
|
||||
"addConnectionDescription": "Ainda não tem uma conexão? Adicione uma no painel de provedores.",
|
||||
"addConnectionLink": "Ir para provedores →"
|
||||
},
|
||||
"radarIntelPage": {
|
||||
"title": "Intel do Radar",
|
||||
"subtitle": "Ranking ELO próprio do Radar e evolução factual do catálogo.",
|
||||
"backToRadar": "Voltar ao Radar",
|
||||
"loading": "Carregando Intel...",
|
||||
"refresh": "Atualizar Intel",
|
||||
"syncing": "Atualizando...",
|
||||
"loadFailed": "Não foi possível atualizar o Intel. O último cache local verificado foi preservado.",
|
||||
"empty": "Ainda não há um snapshot Intel verificado.",
|
||||
"supporterBadge": "Apoiador do Radar",
|
||||
"methodology": "Metodologia",
|
||||
"eloMethod": "ELO, inicial {initial}, K={factor}",
|
||||
"freshness": "Atualidade do catálogo",
|
||||
"ageDays": "{days, plural, one {# dia} other {# dias}}",
|
||||
"trend": "Tendência do catálogo",
|
||||
"modelDelta": "{current} modelos, +{added} / -{removed}",
|
||||
"ranking": "Ranking de modelos",
|
||||
"noRankings": "Ainda não há comparações confirmadas.",
|
||||
"model": "Modelo",
|
||||
"category": "Categoria",
|
||||
"rating": "Pontuação",
|
||||
"matches": "Partidas",
|
||||
"freshnessValues": { "fresh": "Atual", "aging": "Envelhecendo", "stale": "Desatualizado" },
|
||||
"trendValues": { "growing": "Crescendo", "stable": "Estável", "shrinking": "Diminuindo" }
|
||||
},
|
||||
"radarOffersPage": {
|
||||
"title": "Ofertas para apoiadores",
|
||||
"subtitle": "Descontos, créditos, testes oficiais e benefícios de parceiros revisados pelo Radar.",
|
||||
|
||||
@@ -11600,6 +11600,11 @@
|
||||
"rare": "Thành tích hiếm"
|
||||
},
|
||||
"badges": {
|
||||
"radar-supporter": {
|
||||
"name": "Người ủng hộ Radar",
|
||||
"description": "Đã xác minh nguồn dữ liệu trực tiếp dành cho người ủng hộ OmniRoute Radar",
|
||||
"criteria": "Xác minh nguồn dữ liệu Radar trực tiếp đã được ký dành cho người ủng hộ."
|
||||
},
|
||||
"first-token": {
|
||||
"name": "Token đầu tiên",
|
||||
"description": "Đã thực hiện yêu cầu API đầu tiên",
|
||||
@@ -12307,7 +12312,8 @@
|
||||
"modelEnabled": "Enabled locally",
|
||||
"localStateSaveFailed": "Failed to save local Radar settings",
|
||||
"guidedCombos": "Guided combos",
|
||||
"offers": "Offers"
|
||||
"offers": "Offers",
|
||||
"intel": "Thông tin chuyên sâu"
|
||||
},
|
||||
"radarSetupPage": {
|
||||
"title": "Thiết lập nhà cung cấp",
|
||||
@@ -12351,6 +12357,39 @@
|
||||
"loadFailed": "Failed to load Radar combo suggestions.",
|
||||
"createFailed": "Failed to create the combo. Review your provider connections and try again."
|
||||
},
|
||||
"radarIntelPage": {
|
||||
"title": "Thông tin chuyên sâu Radar",
|
||||
"subtitle": "Xếp hạng ELO do Radar quản lý và biến động thực tế của danh mục.",
|
||||
"backToRadar": "Quay lại Radar",
|
||||
"loading": "Đang tải thông tin chuyên sâu...",
|
||||
"refresh": "Làm mới thông tin chuyên sâu",
|
||||
"syncing": "Đang làm mới...",
|
||||
"loadFailed": "Không thể làm mới thông tin chuyên sâu. Bộ nhớ đệm cục bộ đã xác minh gần nhất được giữ lại.",
|
||||
"empty": "Chưa có bản chụp thông tin chuyên sâu đã xác minh.",
|
||||
"supporterBadge": "Người ủng hộ Radar",
|
||||
"methodology": "Phương pháp",
|
||||
"eloMethod": "ELO, khởi tạo {initial}, K={factor}",
|
||||
"freshness": "Độ mới của danh mục",
|
||||
"ageDays": "{days, plural, one {# ngày tuổi} other {# ngày tuổi}}",
|
||||
"trend": "Xu hướng danh mục",
|
||||
"modelDelta": "{current} mô hình, +{added} / -{removed}",
|
||||
"ranking": "Xếp hạng mô hình",
|
||||
"noRankings": "Chưa có phép so sánh nào được xác nhận.",
|
||||
"model": "Mô hình",
|
||||
"category": "Danh mục",
|
||||
"rating": "Điểm",
|
||||
"matches": "Lượt so sánh",
|
||||
"freshnessValues": {
|
||||
"fresh": "Mới",
|
||||
"aging": "Đang cũ dần",
|
||||
"stale": "Đã cũ"
|
||||
},
|
||||
"trendValues": {
|
||||
"growing": "Đang tăng",
|
||||
"stable": "Ổn định",
|
||||
"shrinking": "Đang giảm"
|
||||
}
|
||||
},
|
||||
"radarOffersPage": {
|
||||
"title": "Supporter offers",
|
||||
"subtitle": "Official discounts, credits, trials, and reviewed partner benefits from Radar.",
|
||||
|
||||
@@ -161,6 +161,16 @@ export const BUILTIN_BADGES: Omit<BadgeDefinition, "createdAt">[] = [
|
||||
criteria: JSON.stringify({ type: "threshold", metric: "uptime", threshold: 100, window: 7 }),
|
||||
hidden: 0,
|
||||
},
|
||||
{
|
||||
id: "radar-supporter",
|
||||
name: "Radar Supporter",
|
||||
description: "Verified a live OmniRoute Radar supporter feed",
|
||||
icon: "radar",
|
||||
category: "contribution",
|
||||
rarity: "rare",
|
||||
criteria: JSON.stringify({ type: "action_count", action: "radar_supporter", threshold: 1 }),
|
||||
hidden: 0,
|
||||
},
|
||||
|
||||
// ── Streak (Engagement) ──────────────────────────────────────────────────
|
||||
{
|
||||
|
||||
@@ -25,7 +25,8 @@ export async function emitGamificationEvent(params: {
|
||||
| "combo_use"
|
||||
| "token_share"
|
||||
| "invite_redeem"
|
||||
| "daily_login";
|
||||
| "daily_login"
|
||||
| "radar_supporter";
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const { apiKeyId, action, metadata } = params;
|
||||
@@ -33,6 +34,13 @@ export async function emitGamificationEvent(params: {
|
||||
if (!apiKeyId) return; // Skip if no API key
|
||||
|
||||
try {
|
||||
// A verified Radar supporter is a recognition event, not an XP or
|
||||
// leaderboard action. The caller supplies only a one-way key identity.
|
||||
if (action === "radar_supporter") {
|
||||
await checkAndUnlockBadge(apiKeyId, "radar-supporter", false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Award XP
|
||||
const xpAmount = getXpForAction(action);
|
||||
if (xpAmount > 0) {
|
||||
@@ -87,7 +95,7 @@ export async function emitGamificationEvent(params: {
|
||||
} catch (err) {
|
||||
// Never throw — gamification must not break the request pipeline
|
||||
log.error("events.error", {
|
||||
apiKeyId,
|
||||
...(action === "radar_supporter" ? {} : { apiKeyId }),
|
||||
action,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
@@ -114,22 +122,25 @@ function getXpForAction(action: string): number {
|
||||
/**
|
||||
* Check and unlock a specific badge.
|
||||
*/
|
||||
async function checkAndUnlockBadge(apiKeyId: string, badgeId: string): Promise<void> {
|
||||
async function checkAndUnlockBadge(
|
||||
apiKeyId: string,
|
||||
badgeId: string,
|
||||
logIdentity = true
|
||||
): Promise<void> {
|
||||
const { unlockBadge, hasBadge } = await import("../db/gamification");
|
||||
// #3472: dedup via user_badges directly. getBadges() INNER-JOINs badge_definitions, which is
|
||||
// empty until seeded, so it falsely reported "not earned" and re-emitted the unlock event on
|
||||
// every request.
|
||||
if (!hasBadge(apiKeyId, badgeId)) {
|
||||
unlockBadge(apiKeyId, badgeId);
|
||||
log.info("events.badge_unlocked", { apiKeyId, badgeId });
|
||||
log.info("events.badge_unlocked", logIdentity ? { apiKeyId, badgeId } : { badgeId });
|
||||
|
||||
// Look up badge details from badge_definitions
|
||||
const { getDbInstance } = await import("../db/core");
|
||||
const badgeRow = getDbInstance()
|
||||
.prepare("SELECT name, description, icon, rarity FROM badge_definitions WHERE id = ?")
|
||||
.get(badgeId) as
|
||||
| { name: string; description: string | null; icon: string | null; rarity: string }
|
||||
| undefined;
|
||||
{ name: string; description: string | null; icon: string | null; rarity: string } | undefined;
|
||||
|
||||
// Record notification for SSE toast
|
||||
const { recordBadgeUnlock } = await import("./notifications");
|
||||
|
||||
53
tests/unit/radar-intel-page.test.ts
Normal file
53
tests/unit/radar-intel-page.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const pagePath = path.resolve(process.cwd(), "src/app/(dashboard)/dashboard/radar/intel/page.tsx");
|
||||
const radarPagePath = path.resolve(process.cwd(), "src/app/(dashboard)/dashboard/radar/page.tsx");
|
||||
|
||||
test("Radar links to a dedicated local-only Intel page", () => {
|
||||
assert.ok(fs.existsSync(pagePath));
|
||||
assert.match(fs.readFileSync(radarPagePath, "utf8"), /href="\/dashboard\/radar\/intel"/);
|
||||
const source = fs.readFileSync(pagePath, "utf8");
|
||||
assert.match(source, /fetch\("\/api\/radar\/intel"\)/);
|
||||
assert.match(source, /fetch\("\/api\/radar\/intel\/sync",\s*\{\s*method:\s*"POST"/);
|
||||
assert.doesNotMatch(source, /RADAR_FEED_URL|radar\.omniroute\.online|omr_|getDbInstance/);
|
||||
});
|
||||
|
||||
test("Intel page exposes methodology, ranking, freshness, trend, and verified supporter badge only", () => {
|
||||
const source = fs.readFileSync(pagePath, "utf8");
|
||||
for (const marker of [
|
||||
"methodology",
|
||||
"rankings",
|
||||
"freshness",
|
||||
"trend",
|
||||
"supporterVerified",
|
||||
"radar-supporter",
|
||||
]) {
|
||||
assert.match(source, new RegExp(marker));
|
||||
}
|
||||
assert.doesNotMatch(source, /\bhealth\b|\buptime\b|\blatency\b|\btelemetry\b/i);
|
||||
});
|
||||
|
||||
test("Intel UI strings exist in English and Brazilian Portuguese", () => {
|
||||
for (const locale of ["en", "pt-BR"]) {
|
||||
const messages = JSON.parse(
|
||||
fs.readFileSync(path.resolve(process.cwd(), `src/i18n/messages/${locale}.json`), "utf8")
|
||||
) as { radarIntelPage?: Record<string, unknown>; radarPage?: Record<string, unknown> };
|
||||
for (const key of [
|
||||
"title",
|
||||
"subtitle",
|
||||
"methodology",
|
||||
"supporterBadge",
|
||||
"ranking",
|
||||
"freshness",
|
||||
"trend",
|
||||
"empty",
|
||||
"loadFailed",
|
||||
]) {
|
||||
assert.equal(typeof messages.radarIntelPage?.[key], "string", `${locale}: ${key}`);
|
||||
}
|
||||
assert.equal(typeof messages.radarPage?.intel, "string", `${locale}: radarPage.intel`);
|
||||
}
|
||||
});
|
||||
42
tests/unit/radar-supporter-gamification.test.ts
Normal file
42
tests/unit/radar-supporter-gamification.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-supporter-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const { getDbInstance, resetDbInstance } = await import("../../src/lib/db/core.ts");
|
||||
const { BUILTIN_BADGES } = await import("../../src/lib/gamification/badges.ts");
|
||||
const { emitGamificationEvent } = await import("../../src/lib/gamification/events.ts");
|
||||
|
||||
test.after(() => {
|
||||
resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("Radar supporter has a dedicated badge and zero-XP idempotent action", async () => {
|
||||
const identity = `radar:${"a".repeat(64)}`;
|
||||
const badge = BUILTIN_BADGES.find((item) => item.id === "radar-supporter");
|
||||
assert.ok(badge);
|
||||
assert.equal(JSON.parse(badge.criteria).action, "radar_supporter");
|
||||
|
||||
await emitGamificationEvent({ apiKeyId: identity, action: "radar_supporter" });
|
||||
await emitGamificationEvent({ apiKeyId: identity, action: "radar_supporter" });
|
||||
|
||||
const db = getDbInstance();
|
||||
const userBadges = db
|
||||
.prepare("SELECT badge_id AS badgeId FROM user_badges WHERE api_key_id = ?")
|
||||
.all(identity) as Array<{ badgeId: string }>;
|
||||
const xpRows = db
|
||||
.prepare("SELECT action FROM xp_audit_log WHERE api_key_id = ?")
|
||||
.all(identity) as Array<{ action: string }>;
|
||||
const scoreRows = db
|
||||
.prepare("SELECT score FROM leaderboard WHERE api_key_id = ?")
|
||||
.all(identity) as Array<{ score: number }>;
|
||||
|
||||
assert.deepEqual(userBadges, [{ badgeId: "radar-supporter" }]);
|
||||
assert.deepEqual(xpRows, []);
|
||||
assert.deepEqual(scoreRows, []);
|
||||
});
|
||||
Reference in New Issue
Block a user