diff --git a/app/[username]/vs/[opponent]/page.tsx b/app/[username]/vs/[opponent]/page.tsx index 4f45375..bcd27de 100644 --- a/app/[username]/vs/[opponent]/page.tsx +++ b/app/[username]/vs/[opponent]/page.tsx @@ -8,6 +8,7 @@ import { loadCard } from "@/lib/scout"; import { getRepoStars } from "@/lib/github/stars"; import { pickFlag } from "@/lib/flagPriority"; import { recordScout } from "@/lib/analytics"; +import { recordLeaderboardEntry } from "@/lib/leaderboard"; import { computeDuel } from "@/lib/duel"; import type { Card } from "@/lib/scoring/types"; @@ -118,7 +119,16 @@ export default async function Page({ params }: Params) { ); } - after(() => Promise.all([recordScout(), recordScout()])); // a duel is two scouts + // a duel is two scouts — record both, and add both to the leaderboard. + // Returned so after()/waitUntil keeps the Redis writes alive until they settle. + after(() => + Promise.all([ + recordScout(), + recordScout(), + recordLeaderboardEntry(a.card), + recordLeaderboardEntry(b.card), + ]), + ); const duel = computeDuel(withFlag(a.card), withFlag(b.card)); return ( diff --git a/app/api/card/[username]/route.ts b/app/api/card/[username]/route.ts index 983d656..107f410 100644 --- a/app/api/card/[username]/route.ts +++ b/app/api/card/[username]/route.ts @@ -2,6 +2,7 @@ import { type GithubError } from "@/lib/github/client"; import { scoutCard } from "@/lib/scout"; import { pickFlag } from "@/lib/flagPriority"; import { recordScout } from "@/lib/analytics"; +import { recordLeaderboardEntry } from "@/lib/leaderboard"; import { after } from "next/server"; import type { Card } from "@/lib/scoring/types"; @@ -18,7 +19,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ username // just resolve the visitor's flag and record the scout after the response. try { const card = await scoutCard(username); - after(() => recordScout()); + after(() => Promise.all([recordScout(), recordLeaderboardEntry(card)])); return Response.json(resolveCountry(card, override)); } catch (e) { const err = e as GithubError; diff --git a/app/leaderboard/page.tsx b/app/leaderboard/page.tsx new file mode 100644 index 0000000..1113f02 --- /dev/null +++ b/app/leaderboard/page.tsx @@ -0,0 +1,117 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import Background from "@/components/Background"; +import LeaderboardRow from "@/components/LeaderboardRow"; +import LeaderboardSearch from "@/components/LeaderboardSearch"; +import { getLeaderboardPage, getRankFor, getNeighbors, recordLeaderboardEntry } from "@/lib/leaderboard"; +import { loadCard } from "@/lib/scout"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Global Leaderboard · GitFut", + description: "Every scouted GitFut card, ranked by overall rating.", +}; + +const PAGE_SIZE = 25; + +export default async function LeaderboardPage({ + searchParams, +}: { + searchParams: Promise<{ page?: string; focus?: string }>; +}) { + const { page: pageParam, focus } = await searchParams; + const requested = Number(pageParam) || 1; + const { entries, total, page, totalPages } = await getLeaderboardPage(requested, PAGE_SIZE); + + // Focused search: scout the login (adds a new profile to the board), then read + // back its rank + neighbours. A scout failure yields a friendly note, not a crash. + let focusBlock: React.ReactNode = null; + if (focus) { + const res = await loadCard(focus); + if ("card" in res) { + await recordLeaderboardEntry(res.card); + const ranked = await getRankFor(focus); + const neighbors = await getNeighbors(focus, 3); + focusBlock = ( +
+
YOUR POSITION
+ {ranked ? ( +

+ @{res.card.login} is ranked{" "} + #{ranked.rank} of {ranked.total.toLocaleString()}. +

+ ) : ( +

@{res.card.login} isn’t ranked yet — try again in a moment.

+ )} +
    + {neighbors.map((e) => ( +
  1. + +
  2. + ))} +
+
+ ); + } else { + focusBlock = ( +

+ Couldn’t scout @{focus} — check the username and try again. +

+ ); + } + } + + return ( +
+ +
+
+
+
GITFUT
+

+ Global Leaderboard +

+
+ + ← HOME + +
+

+ {total.toLocaleString()} card{total === 1 ? "" : "s"} scouted, ranked by overall. +

+ + + {focusBlock} + +
    + {entries.map((e) => ( +
  1. + +
  2. + ))} +
+ + +
+
+ ); +} diff --git a/app/u/[username]/page.tsx b/app/u/[username]/page.tsx index 83f177b..dc70cdb 100644 --- a/app/u/[username]/page.tsx +++ b/app/u/[username]/page.tsx @@ -7,6 +7,7 @@ import { loadCard } from "@/lib/scout"; import { getRepoStars } from "@/lib/github/stars"; import { pickFlag } from "@/lib/flagPriority"; import { recordScout } from "@/lib/analytics"; +import { recordLeaderboardEntry } from "@/lib/leaderboard"; import type { Card } from "@/lib/scoring/types"; import ScoutRoute from "./ScoutRoute"; @@ -72,7 +73,10 @@ export default async function Page({ let card: Card | null = "card" in res ? res.card : null; let canonicalCountry = ""; // GitHub-derived flag; share links omit ?country= unless overridden if (card) { - after(() => recordScout()); // analytics, flushed after the response (serverless-safe) + const scouted = card; // canonical card, before the display-flag override below + // analytics + leaderboard, flushed after the response (serverless-safe). + // Returned so after()/waitUntil keeps the Redis writes alive until they settle. + after(() => Promise.all([recordScout(), recordLeaderboardEntry(scouted)])); canonicalCountry = pickFlag(null, card.country) ?? ""; // GitHub-derived only const displayCountry = pickFlag(override, card.country) ?? ""; card = { ...card, country: displayCountry }; @@ -81,7 +85,15 @@ export default async function Page({
{card ? ( - + <> + + LEADERBOARD + + + ) : ( )} diff --git a/components/AppShell.tsx b/components/AppShell.tsx index b89aaa6..4921ae9 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; import ScoutForm from "@/components/ScoutForm"; import CardFan from "@/components/CardFan"; import LoadingScreen from "@/components/LoadingScreen"; @@ -57,7 +58,13 @@ export default function AppShell({
{/* Overlaid in the corner (not a flow header) so it never pushes the vertically-centered hero down. */} -
+
+ + LEADERBOARD +
diff --git a/components/LeaderboardRow.tsx b/components/LeaderboardRow.tsx new file mode 100644 index 0000000..4da8906 --- /dev/null +++ b/components/LeaderboardRow.tsx @@ -0,0 +1,54 @@ +import Link from "next/link"; +import type { LeaderboardEntry } from "@/lib/leaderboard-core"; +import { RESULT_THEME } from "@/components/finishTheme"; +import { languageLogoUrl } from "@/lib/github/languages"; + +// One ranked row. Tier ink/chip come from RESULT_THEME so the OVR pill reads in +// the card's own finish colour. `highlight` lifts the searched player's row. +export default function LeaderboardRow({ + entry, + highlight = false, +}: { + entry: LeaderboardEntry; + highlight?: boolean; +}) { + const theme = RESULT_THEME[entry.finish]; + return ( + + + {entry.rank} + + {/* eslint-disable-next-line @next/next/no-img-element */} + + {entry.name} + {entry.country && ( + // eslint-disable-next-line @next/next/no-img-element + + )} + {entry.langSlug && ( + // eslint-disable-next-line @next/next/no-img-element + {entry.topLanguage + )} + + {entry.position} + + + {entry.overall} + + + ); +} diff --git a/components/LeaderboardSearch.tsx b/components/LeaderboardSearch.tsx new file mode 100644 index 0000000..fd53383 --- /dev/null +++ b/components/LeaderboardSearch.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; + +// Navigates to /leaderboard?focus=. The server then scouts the login (so +// a never-seen user is added to the board) and renders their rank + neighbours. +export default function LeaderboardSearch({ initial = "" }: { initial?: string }) { + const router = useRouter(); + const [value, setValue] = useState(initial); + const [isPending, startTransition] = useTransition(); + + const submit = (e: React.FormEvent) => { + e.preventDefault(); + const login = value.trim().replace(/^@/, ""); + if (!login) return; + startTransition(() => router.push(`/leaderboard?focus=${encodeURIComponent(login)}`)); + }; + + return ( +
+ setValue(e.target.value)} + placeholder="find your rank — github username" + aria-label="GitHub username" + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + className="h-11 flex-1 rounded-xl border border-white/12 bg-white/[.04] px-4 text-[15px] text-ink outline-none transition focus:border-brand/60" + /> + +
+ ); +} diff --git a/lib/leaderboard-core.ts b/lib/leaderboard-core.ts new file mode 100644 index 0000000..2d06fd4 --- /dev/null +++ b/lib/leaderboard-core.ts @@ -0,0 +1,151 @@ +import type { Card, Finish, Position } from "@/lib/scoring/types"; +import { SAMPLE_CARDS } from "@/lib/github/samples"; + +// A single rendered leaderboard row. +export interface LeaderboardEntry { + rank: number; // 1-based display rank + login: string; + name: string; + avatarUrl: string; + overall: number; + finish: Finish; + finishLabel: string; + position: Position; + country: string; + topLanguage: string | null; + langSlug: string | null; +} + +// Everything a row needs except its rank — this is what we persist per login in +// the meta hash (rank is derived at read time from the sorted set). +export type MetaEntry = Omit; + +// Card -> the compact display record. Single source of truth so serializeMeta +// (write path) and sampleSeedEntries (fallback) never drift apart. +export function cardToMeta(card: Card): MetaEntry { + return { + login: card.login, + name: card.name, + avatarUrl: card.avatarUrl, + overall: card.overall, + finish: card.finish, + finishLabel: card.finishLabel, + position: card.position, + country: card.country, + topLanguage: card.topLanguage ?? null, + langSlug: card.languageLogo?.slug ?? null, + }; +} + +export const serializeMeta = (card: Card): string => JSON.stringify(cardToMeta(card)); + +// Runtime allow-lists for the enum fields, declared as Record so the +// compiler forces every Finish/Position variant to be listed here (add a new +// finish to the type and this stops compiling until it's added). isFinish / +// isPosition then narrow an unknown value against them. +const FINISH_VALUES: Record = { + bronze: true, + silver: true, + gold: true, + totw: true, + toty: true, + icon: true, + founder: true, +}; +const POSITION_VALUES: Record = { + ST: true, + RW: true, + CAM: true, + CM: true, + CDM: true, + CB: true, +}; +const has = (o: object, v: unknown): boolean => typeof v === "string" && Object.prototype.hasOwnProperty.call(o, v); +const isFinish = (v: unknown): v is Finish => has(FINISH_VALUES, v); +const isPosition = (v: unknown): v is Position => has(POSITION_VALUES, v); + +// Full-shape guard: every MetaEntry field must be present and the right type, and +// finish/position must be real enum values. Downstream consumers (e.g. the row's +// RESULT_THEME[finish] lookup) assume a complete, valid record, so a partial or +// legacy blob must be rejected, not passed through. +function isMetaEntry(v: unknown): v is MetaEntry { + if (typeof v !== "object" || v === null) return false; + const m = v as Record; + return ( + typeof m.login === "string" && + typeof m.name === "string" && + typeof m.avatarUrl === "string" && + typeof m.overall === "number" && + Number.isFinite(m.overall) && + isFinish(m.finish) && + typeof m.finishLabel === "string" && + isPosition(m.position) && + typeof m.country === "string" && + (m.topLanguage === null || typeof m.topLanguage === "string") && + (m.langSlug === null || typeof m.langSlug === "string") + ); +} + +// Tolerant parse: any bad/missing/legacy/corrupt JSON yields null so the row is +// skipped rather than crashing the page (best-effort, mirrors lib/scout.ts +// readCache). Validates the FULL MetaEntry shape — a partially-populated blob like +// { login, overall } is rejected, since consumers assume a complete record. +export function parseMeta(json: string | null): MetaEntry | null { + if (!json) return null; + let raw: unknown; + try { + raw = JSON.parse(json); + } catch { + return null; + } + return isMetaEntry(raw) ? raw : null; +} + +// Zip parallel members + metas into ranked entries. rank is tied to the member's +// POSITION (startRank + i), so a missing-meta skip leaves later ranks correct. +export function assembleEntries( + members: string[], + metas: (MetaEntry | null)[], + startRank: number, +): LeaderboardEntry[] { + const out: LeaderboardEntry[] = []; + for (let i = 0; i < members.length; i++) { + const m = metas[i]; + if (!m) continue; + out.push({ rank: startRank + i, ...m }); + } + return out; +} + +// Inclusive ZREVRANGE indices for a page, with the page clamped into range. +export function pageBounds( + page: number, + size: number, + total: number, +): { start: number; stop: number; page: number; totalPages: number } { + const totalPages = Math.max(1, Math.ceil(total / size)); + const clamped = Math.min(Math.max(1, Math.floor(page) || 1), totalPages); + const start = (clamped - 1) * size; + return { start, stop: start + size - 1, page: clamped, totalPages }; +} + +// Inclusive ZREVRANGE indices for a ±span window around a 0-based rank index, +// clamped to the board. +export function neighborBounds( + rankIndex: number, + span: number, + total: number, +): { start: number; stop: number } { + return { + start: Math.max(0, rankIndex - span), + stop: Math.min(total - 1, rankIndex + span), + }; +} + +// Redis-off / empty-board fallback: rank the baked sample cards by overall desc +// (login as a stable tiebreak) so the page always renders something real. +export function sampleSeedEntries(): LeaderboardEntry[] { + return [...SAMPLE_CARDS] + .sort((a, b) => b.overall - a.overall || a.login.localeCompare(b.login)) + .map((c, i) => ({ rank: i + 1, ...cardToMeta(c) })); +} diff --git a/lib/leaderboard.ts b/lib/leaderboard.ts new file mode 100644 index 0000000..38db7cb --- /dev/null +++ b/lib/leaderboard.ts @@ -0,0 +1,114 @@ +import "server-only"; +import { redis } from "./redis"; +import type { Card } from "./scoring/types"; +import { + type LeaderboardEntry, + type MetaEntry, + serializeMeta, + parseMeta, + assembleEntries, + pageBounds, + neighborBounds, + sampleSeedEntries, +} from "./leaderboard-core"; + +// Sorted set = the ranking (score = overall); hash = per-login display fields. +// Versioned so a meta-shape change can invalidate the board by bumping v1. +const ZKEY = "gitfut:leaderboard:v1"; +const METAKEY = "gitfut:leaderboard:meta:v1"; +const DEFAULT_PAGE_SIZE = 25; + +const norm = (u: string) => u.trim().replace(/^@/, "").toLowerCase(); + +// Add / update a card on the board. Best-effort: never throws, no-ops with no +// Redis. The ZSET member and hash field are the normalized login; the stored +// meta keeps the card's display-case login for the row link. +export async function recordLeaderboardEntry(card: Card): Promise { + if (!redis) return; + const login = norm(card.login); + try { + await Promise.all([ + redis.zadd(ZKEY, card.overall, login), + redis.hset(METAKEY, login, serializeMeta(card)), + ]); + } catch (e) { + console.error("[leaderboard] record failed:", (e as Error).message); + } +} + +async function metasFor(logins: string[]): Promise<(MetaEntry | null)[]> { + if (logins.length === 0 || !redis) return logins.map(() => null); + const raw = await redis.hmget(METAKEY, ...logins); + return raw.map(parseMeta); +} + +// One page of the board, newest ranking first. Falls back to the sample seed +// when Redis is off, empty, or errors. +export async function getLeaderboardPage( + page: number, + size: number = DEFAULT_PAGE_SIZE, +): Promise<{ entries: LeaderboardEntry[]; total: number; page: number; totalPages: number }> { + if (redis) { + try { + const total = await redis.zcard(ZKEY); + if (total > 0) { + const { start, stop, page: clamped, totalPages } = pageBounds(page, size, total); + const members = await redis.zrevrange(ZKEY, start, stop); + const metas = await metasFor(members); + return { entries: assembleEntries(members, metas, start + 1), total, page: clamped, totalPages }; + } + } catch (e) { + console.error("[leaderboard] page read failed:", (e as Error).message); + } + } + return seedPage(page, size); +} + +function seedPage(page: number, size: number) { + const all = sampleSeedEntries(); + const { start, stop, page: clamped, totalPages } = pageBounds(page, size, all.length); + return { entries: all.slice(start, stop + 1), total: all.length, page: clamped, totalPages }; +} + +// A single login's rank + row, or null if they're on neither the board nor the seed. +export async function getRankFor( + login: string, +): Promise<{ rank: number; total: number; entry: LeaderboardEntry } | null> { + const key = norm(login); + if (redis) { + try { + const [rankIdx, total] = await Promise.all([redis.zrevrank(ZKEY, key), redis.zcard(ZKEY)]); + if (rankIdx != null) { + const m = parseMeta(await redis.hget(METAKEY, key)); + if (m) return { rank: rankIdx + 1, total, entry: { rank: rankIdx + 1, ...m } }; + } + } catch (e) { + console.error("[leaderboard] rank read failed:", (e as Error).message); + } + } + const seed = sampleSeedEntries(); + const idx = seed.findIndex((e) => e.login.toLowerCase() === key); + return idx >= 0 ? { rank: idx + 1, total: seed.length, entry: seed[idx] } : null; +} + +// The rows immediately above and below a login (±span). Empty when they're not ranked. +export async function getNeighbors(login: string, span: number): Promise { + const key = norm(login); + if (redis) { + try { + const [rankIdx, total] = await Promise.all([redis.zrevrank(ZKEY, key), redis.zcard(ZKEY)]); + if (rankIdx != null && total > 0) { + const { start, stop } = neighborBounds(rankIdx, span, total); + const members = await redis.zrevrange(ZKEY, start, stop); + return assembleEntries(members, await metasFor(members), start + 1); + } + } catch (e) { + console.error("[leaderboard] neighbors read failed:", (e as Error).message); + } + } + const seed = sampleSeedEntries(); + const idx = seed.findIndex((e) => e.login.toLowerCase() === key); + if (idx < 0) return []; + const { start, stop } = neighborBounds(idx, span, seed.length); + return seed.slice(start, stop + 1); +} diff --git a/tests/leaderboard.test.ts b/tests/leaderboard.test.ts new file mode 100644 index 0000000..e4305ec --- /dev/null +++ b/tests/leaderboard.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import { + cardToMeta, + serializeMeta, + parseMeta, + assembleEntries, + pageBounds, + neighborBounds, + sampleSeedEntries, + type MetaEntry, +} from "@/lib/leaderboard-core"; +import type { Card } from "@/lib/scoring/types"; + +const mkCard = (login: string, overall: number, over: Partial = {}): Card => ({ + login, + name: login, + avatarUrl: `https://avatars.githubusercontent.com/${login}.png`, + country: "us", + club: "neutral", + stats: { pac: 70, sho: 70, pas: 70, dri: 70, def: 70, phy: 70 }, + position: "CM", + family: "Playmaker", + baseOVR: overall, + overall, + finish: "gold", + finishLabel: "GOLD", + archetype: "Mezzala", + archetypeBlurb: "", + legacy: { L: 0 }, + topLanguage: "TypeScript", + languageLogo: { name: "TypeScript", slug: "typescript" }, + report: { + skillMoves: 3, + weakFoot: 3, + workRate: { attack: "Med", defense: "Med" }, + style: "Measured", + reasons: { skillMoves: "", weakFoot: "", workRate: "", style: "" }, + playstyles: [], + metrics: [], + }, + ...over, +}); + +const meta = (login: string, overall: number): MetaEntry => cardToMeta(mkCard(login, overall)); + +describe("cardToMeta / serialize / parse", () => { + it("captures the display fields and survives a JSON round-trip", () => { + const m = cardToMeta(mkCard("torvalds", 99)); + expect(m).toMatchObject({ login: "torvalds", overall: 99, langSlug: "typescript", topLanguage: "TypeScript" }); + expect(parseMeta(serializeMeta(mkCard("torvalds", 99)))).toEqual(m); + }); + + it("maps a card with no language logo to null slug", () => { + const m = cardToMeta(mkCard("nolang", 60, { languageLogo: null, topLanguage: null })); + expect(m.langSlug).toBeNull(); + expect(m.topLanguage).toBeNull(); + }); + + it("parseMeta returns null on null / garbage", () => { + expect(parseMeta(null)).toBeNull(); + expect(parseMeta("not json")).toBeNull(); + expect(parseMeta("{}")).toBeNull(); + }); + + it("parseMeta rejects partial/legacy JSON missing required fields", () => { + // The exact review case: enough to pass a login+overall check, but incomplete + // — must be rejected so consumers never see a MetaEntry with an undefined + // finish/position (which would crash RESULT_THEME[finish].chip downstream). + expect(parseMeta(JSON.stringify({ login: "x", overall: 1 }))).toBeNull(); + expect(parseMeta(JSON.stringify(cardToMeta(mkCard("x", 70))))).not.toBeNull(); + }); + + it("parseMeta rejects invalid enum values for finish / position", () => { + const good = cardToMeta(mkCard("x", 70)); + expect(parseMeta(JSON.stringify({ ...good, finish: "platinum" }))).toBeNull(); + expect(parseMeta(JSON.stringify({ ...good, position: "GK" }))).toBeNull(); + }); + + it("parseMeta rejects wrong field types", () => { + const good = cardToMeta(mkCard("x", 70)); + expect(parseMeta(JSON.stringify({ ...good, overall: "70" }))).toBeNull(); + expect(parseMeta(JSON.stringify({ ...good, name: 5 }))).toBeNull(); + expect(parseMeta(JSON.stringify({ ...good, topLanguage: 3 }))).toBeNull(); + const { country: _country, ...noCountry } = good; + expect(parseMeta(JSON.stringify(noCountry))).toBeNull(); + }); + + it("parseMeta accepts a fully-valid record incl. null topLanguage/langSlug", () => { + const m = cardToMeta(mkCard("x", 70, { languageLogo: null, topLanguage: null })); + expect(parseMeta(JSON.stringify(m))).toEqual(m); + }); +}); + +describe("assembleEntries", () => { + it("assigns sequential ranks from startRank", () => { + const entries = assembleEntries(["a", "b", "c"], [meta("a", 90), meta("b", 85), meta("c", 80)], 1); + expect(entries.map((e) => [e.rank, e.login])).toEqual([[1, "a"], [2, "b"], [3, "c"]]); + }); + + it("starts ranks at an arbitrary offset (page 2)", () => { + const entries = assembleEntries(["k", "l"], [meta("k", 70), meta("l", 69)], 21); + expect(entries.map((e) => e.rank)).toEqual([21, 22]); + }); + + it("skips members with missing meta but keeps ranks aligned to position", () => { + const entries = assembleEntries(["a", "b", "c"], [meta("a", 90), null, meta("c", 80)], 1); + expect(entries.map((e) => [e.rank, e.login])).toEqual([[1, "a"], [3, "c"]]); + }); +}); + +describe("pageBounds", () => { + it("computes inclusive ZREVRANGE bounds for page 1", () => { + expect(pageBounds(1, 20, 100)).toEqual({ start: 0, stop: 19, page: 1, totalPages: 5 }); + }); + it("computes bounds for a middle page", () => { + expect(pageBounds(3, 20, 100)).toMatchObject({ start: 40, stop: 59, page: 3 }); + }); + it("clamps an out-of-range page to the last page", () => { + expect(pageBounds(99, 20, 100)).toMatchObject({ page: 5, start: 80, stop: 99 }); + }); + it("clamps a non-positive / NaN page to 1", () => { + expect(pageBounds(0, 20, 100)).toMatchObject({ page: 1 }); + expect(pageBounds(NaN, 20, 100)).toMatchObject({ page: 1 }); + }); + it("reports at least one page when the board is empty", () => { + expect(pageBounds(1, 20, 0)).toMatchObject({ page: 1, totalPages: 1, start: 0, stop: 19 }); + }); +}); + +describe("neighborBounds", () => { + it("windows ±span around a middle rank", () => { + expect(neighborBounds(50, 3, 100)).toEqual({ start: 47, stop: 53 }); + }); + it("clamps at the top edge", () => { + expect(neighborBounds(1, 3, 100)).toEqual({ start: 0, stop: 4 }); + }); + it("clamps at the bottom edge", () => { + expect(neighborBounds(99, 3, 100)).toEqual({ start: 96, stop: 99 }); + }); +}); + +describe("sampleSeedEntries", () => { + it("ranks the baked sample cards by overall desc with sequential ranks", () => { + const seed = sampleSeedEntries(); + expect(seed.length).toBeGreaterThan(0); + expect(seed[0].rank).toBe(1); + for (let i = 1; i < seed.length; i++) { + expect(seed[i - 1].overall).toBeGreaterThanOrEqual(seed[i].overall); + expect(seed[i].rank).toBe(i + 1); + } + }); +});