test: add characterization tests for lib layer

Cover classic-mode comparison rules, game-engine state machine and
localStorage persistence, and champion-data search/seeding/loading with
Vitest. Storage keys are asserted literally so returning players' saved
games and stats cannot silently break.

Node environment with a hand-rolled localStorage stub; fixtures are four
hand-written champions so the weekly data sync cannot destabilise the
suite.
This commit is contained in:
tiennm99 committed 2026-07-25 10:54:36 +07:00
1 parent 4b8bda7a1d
commit d0c75e84b1
8 files changed
+1376 -2

No files matched your search

+196
View File
@@ -0,0 +1,196 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getChampionImageUrl, getTodaySeed } from "../lib/champion-data";
import { AATROX, CHAMPIONS } from "./fixtures/champions";
/**
* `champions` is module-private with no reset export, so each group gets a fresh
* module instance instead of adding production code for testability.
*/
async function freshModule({ champions = CHAMPIONS, ok = true, status = 200 } = {}) {
vi.resetModules();
const fetchMock = vi.fn(async () => ({
ok,
status,
json: async () => champions,
}));
vi.stubGlobal("fetch", fetchMock);
const mod = await import("../lib/champion-data");
return { mod, fetchMock };
}
/** Load a module instance with its champion list already populated. */
async function loadedModule(champions = CHAMPIONS) {
const { mod, fetchMock } = await freshModule({ champions });
await mod.loadChampions();
return { mod, fetchMock };
}
function named(names) {
return names.map((name) => ({ ...AATROX, id: name, name }));
}
describe("loadChampions", () => {
it("fetches the static champions file and returns the parsed list", async () => {
const { mod, fetchMock } = await freshModule();
await expect(mod.loadChampions()).resolves.toEqual(CHAMPIONS);
expect(fetchMock).toHaveBeenCalledWith("/champions.json");
});
it("serves later calls from the cache without refetching", async () => {
const { mod, fetchMock } = await loadedModule();
await mod.loadChampions();
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("is single-flight — concurrent callers share one fetch", async () => {
const { mod, fetchMock } = await freshModule();
const [first, second] = await Promise.all([
mod.loadChampions(),
mod.loadChampions(),
]);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(first).toBe(second);
});
it("rejects with the status code when the response is not ok", async () => {
const { mod } = await freshModule({ ok: false, status: 404 });
await expect(mod.loadChampions()).rejects.toThrow(
"Failed to load champions: 404",
);
});
it("clears the in-flight promise on failure so a retry can succeed", async () => {
const { mod, fetchMock } = await freshModule({ ok: false, status: 500 });
await expect(mod.loadChampions()).rejects.toThrow();
fetchMock.mockResolvedValue({
ok: true,
status: 200,
json: async () => CHAMPIONS,
});
await expect(mod.loadChampions()).resolves.toEqual(CHAMPIONS);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe("searchChampions", () => {
it("returns nothing for an empty query", async () => {
const { mod } = await loadedModule();
expect(mod.searchChampions("")).toEqual([]);
});
it("matches case-insensitively on a substring", async () => {
const { mod } = await loadedModule();
expect(mod.searchChampions("AATR").map((c) => c.name)).toEqual(["Aatrox"]);
});
it("sorts prefix matches ahead of substring matches", async () => {
const { mod } = await loadedModule(named(["Akali", "Kayle", "Kayn"]));
// "Kayle"/"Kayn" start with "ka"; "Akali" only contains it
expect(mod.searchChampions("ka").map((c) => c.name)).toEqual([
"Kayle",
"Kayn",
"Akali",
]);
});
it("sorts alphabetically within each tier", async () => {
const { mod } = await loadedModule(named(["Azir", "Ahri", "Aatrox"]));
expect(mod.searchChampions("a").map((c) => c.name)).toEqual([
"Aatrox",
"Ahri",
"Azir",
]);
});
it("caps the result at 8", async () => {
const many = named([
"Aatrox", "Ahri", "Akali", "Alistar", "Amumu",
"Anivia", "Annie", "Aphelios", "Ashe", "Azir",
]);
const { mod } = await loadedModule(many);
expect(mod.searchChampions("a")).toHaveLength(8);
});
it("filters excluded names case-insensitively", async () => {
const { mod } = await loadedModule(named(["Kayle", "Kayn"]));
expect(mod.searchChampions("ka", ["kAyN"]).map((c) => c.name)).toEqual([
"Kayle",
]);
});
it("returns an empty list when nothing matches", async () => {
const { mod } = await loadedModule();
expect(mod.searchChampions("zzzz")).toEqual([]);
});
});
describe("getRandomChampion", () => {
it("is deterministic for a given seed", async () => {
const { mod } = await loadedModule();
expect(mod.getRandomChampion("2026-07-25")).toBe(
mod.getRandomChampion("2026-07-25"),
);
});
it("always returns a champion from the loaded list", async () => {
const { mod } = await loadedModule();
for (const seed of ["a", "b", "2026-07-25", "unlimited_1_0.5", ""]) {
expect(CHAMPIONS).toContain(mod.getRandomChampion(seed));
}
});
it("returns null before champions are loaded", async () => {
const { mod } = await freshModule();
expect(mod.getRandomChampion("2026-07-25")).toBeNull();
});
});
describe("getTodaySeed", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("formats as YYYY-MM-DD", () => {
vi.setSystemTime(new Date("2026-07-25T09:00:00Z"));
expect(getTodaySeed()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
});
it("reflects the local date, not the UTC date", () => {
// 18:00 UTC is already the next day in UTC+7 and still the same day in UTC,
// so this instant fails any implementation that reaches for UTC getters.
vi.setSystemTime(new Date("2026-07-25T18:00:00Z"));
// en-CA renders as YYYY-MM-DD — an independent derivation of the same date.
expect(getTodaySeed()).toBe(new Date().toLocaleDateString("en-CA"));
});
it("zero-pads single-digit months and days", () => {
vi.setSystemTime(new Date(2026, 0, 5, 12, 0, 0));
expect(getTodaySeed()).toBe("2026-01-05");
});
it("advances by one day across a local midnight", () => {
vi.setSystemTime(new Date(2026, 6, 25, 23, 59, 0));
const before = getTodaySeed();
vi.setSystemTime(new Date(2026, 6, 26, 0, 1, 0));
expect(getTodaySeed()).not.toBe(before);
expect(getTodaySeed()).toBe("2026-07-26");
});
});
describe("getChampionImageUrl", () => {
it("returns the absolute Data Dragon tile URL", () => {
expect(getChampionImageUrl("Aatrox")).toBe(
"https://ddragon.leagueoflegends.com/cdn/img/champion/tiles/Aatrox_0.jpg",
);
});
it("stays absolute — it must never be rewritten through a base path", () => {
expect(getChampionImageUrl("Belveth")).toMatch(/^https:\/\//);
});
});
+163
View File
@@ -0,0 +1,163 @@
import { describe, expect, it } from "vitest";
import { CLASSIC_ATTRIBUTES, compareChampions } from "../lib/classic-mode";
import { AATROX, AHRI, AKALI, BELVETH } from "./fixtures/champions";
/** Pull one attribute's comparison out of the 7-entry result array. */
function cell(guess, target, key) {
return compareChampions(guess, target).find((r) => r.key === key);
}
describe("CLASSIC_ATTRIBUTES", () => {
it("exposes 7 attributes with gender first and releaseDate last", () => {
expect(CLASSIC_ATTRIBUTES).toHaveLength(7);
expect(CLASSIC_ATTRIBUTES[0].key).toBe("gender");
expect(CLASSIC_ATTRIBUTES[6].key).toBe("releaseDate");
});
it("keeps the display order the grid header depends on", () => {
expect(CLASSIC_ATTRIBUTES.map((a) => a.key)).toEqual([
"gender",
"genre",
"attackType",
"resource",
"region",
"lane",
"releaseDate",
]);
});
});
describe("compareChampions — result shape", () => {
it("returns one entry per attribute, carrying label and type through", () => {
const results = compareChampions(AHRI, AATROX);
expect(results).toHaveLength(7);
expect(results[0]).toMatchObject({
key: "gender",
label: "Gender",
type: "exact",
});
});
});
describe("compareChampions — exact", () => {
it("marks an identical value correct", () => {
expect(cell(AKALI, AHRI, "gender").result).toBe("correct");
});
it("marks a differing value wrong", () => {
expect(cell(AHRI, AATROX, "gender").result).toBe("wrong");
});
it("compares case-insensitively", () => {
const shouty = { ...AATROX, gender: "MALE" };
expect(cell(shouty, AATROX, "gender").result).toBe("correct");
});
it("treats a missing value as not matching a present one", () => {
expect(cell(BELVETH, AATROX, "resource").result).toBe("wrong");
});
});
describe("compareChampions — multi", () => {
it("marks identical sets correct", () => {
expect(cell(BELVETH, AATROX, "genre").result).toBe("correct");
});
it("marks intersecting sets partial", () => {
// AKALI lane "top,mid" overlaps AATROX lane "top"
expect(cell(AKALI, AATROX, "lane").result).toBe("partial");
});
it("marks disjoint sets wrong", () => {
expect(cell(AHRI, AATROX, "lane").result).toBe("wrong");
});
it("marks two empty sets correct", () => {
const a = { ...AATROX, lane: "" };
const b = { ...AATROX, lane: "" };
expect(cell(a, b, "lane").result).toBe("correct");
});
it("marks one empty set against a populated one wrong", () => {
const empty = { ...AATROX, lane: "" };
expect(cell(empty, AATROX, "lane").result).toBe("wrong");
});
it("ignores order and whitespace when comparing sets", () => {
const spaced = { ...AKALI, lane: " mid , top " };
expect(cell(spaced, AKALI, "lane").result).toBe("correct");
});
});
describe("compareChampions — year", () => {
it("marks the same year correct with no direction hint", () => {
const twin = { ...AHRI, releaseDate: 2013 };
const result = cell(twin, AATROX, "releaseDate");
expect(result.result).toBe("correct");
expect(result.direction).toBeUndefined();
});
it("points up when the guess is older than the target", () => {
expect(cell(AHRI, AATROX, "releaseDate")).toMatchObject({
result: "wrong",
direction: "up",
});
});
it("points down when the guess is newer than the target", () => {
expect(cell(BELVETH, AATROX, "releaseDate")).toMatchObject({
result: "wrong",
direction: "down",
});
});
it("gives no direction when either year is unparseable", () => {
const undated = { ...AHRI, releaseDate: "" };
const result = cell(undated, AATROX, "releaseDate");
expect(result.result).toBe("wrong");
expect(result.direction).toBeUndefined();
});
it("renders a missing year as ?", () => {
const undated = { ...AHRI, releaseDate: "" };
expect(cell(undated, AATROX, "releaseDate").guessValue).toBe("?");
});
it("passes the year through unformatted", () => {
expect(cell(AHRI, AATROX, "releaseDate")).toMatchObject({
guessValue: 2011,
targetValue: 2013,
});
});
});
describe("compareChampions — value formatting", () => {
it("capitalizes gender", () => {
expect(cell(AATROX, AATROX, "gender").guessValue).toBe("Male");
});
it("renders attackType close as Melee and range as Ranged", () => {
expect(cell(AATROX, AATROX, "attackType").guessValue).toBe("Melee");
expect(cell(AHRI, AHRI, "attackType").guessValue).toBe("Ranged");
});
it("splits hyphenated regions into capitalized words", () => {
expect(cell(AKALI, AKALI, "region").guessValue).toBe("Shadow Isles");
});
it("title-cases comma lists and rejoins them with a space", () => {
expect(cell(AHRI, AHRI, "genre").guessValue).toBe("Mage, Assassin");
expect(cell(AKALI, AKALI, "lane").guessValue).toBe("Top, Mid");
});
it("renders an empty value as an em dash", () => {
expect(cell(BELVETH, BELVETH, "resource").guessValue).toBe("—");
});
it("formats the target value the same way as the guess value", () => {
expect(cell(AHRI, AKALI, "region")).toMatchObject({
guessValue: "Ionia",
targetValue: "Shadow Isles",
});
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Hand-written champion fixtures, deliberately small so the weekly champions.json
* sync can never break the suite. Shapes mirror the real data exactly:
* `releaseDate` is a number, `attackType` is "close" | "range".
*
* Chosen to exercise every compareChampions branch against AATROX as target:
* AHRI — disjoint genre, disjoint lane, older year (direction "up")
* AKALI — disjoint genre, overlapping lane (partial), hyphenated region
* BELVETH — identical genre (correct), disjoint lane, empty resource ("—"),
* newer year (direction "down")
*/
export const AATROX = {
id: "Aatrox",
name: "Aatrox",
title: "the Darkin Blade",
resource: "Blood Well",
genre: "Fighter",
gender: "male",
attackType: "close",
releaseDate: 2013,
region: "runeterra",
lane: "top",
};
export const AHRI = {
id: "Ahri",
name: "Ahri",
title: "the Nine-Tailed Fox",
resource: "Mana",
genre: "Mage,Assassin",
gender: "female",
attackType: "range",
releaseDate: 2011,
region: "ionia",
lane: "mid",
};
export const AKALI = {
id: "Akali",
name: "Akali",
title: "the Rogue Assassin",
resource: "Energy",
genre: "Assassin",
gender: "female",
attackType: "close",
releaseDate: 2010,
region: "shadow-isles",
lane: "top,mid",
};
export const BELVETH = {
id: "Belveth",
name: "Bel'Veth",
title: "the Empress of the Void",
resource: "",
genre: "Fighter",
gender: "female",
attackType: "close",
releaseDate: 2022,
region: "void",
lane: "jungle",
};
/** Load order matters for getRandomChampion index assertions. */
export const CHAMPIONS = [AATROX, AHRI, AKALI, BELVETH];
+357
View File
@@ -0,0 +1,357 @@
import { describe, expect, it } from "vitest";
import {
clearExpiredCache,
clearUnlimitedState,
createGame,
getOrCreateUnlimitedSeed,
loadUnlimitedStats,
saveUnlimitedStats,
submitGuess,
} from "../lib/game-engine";
import { AATROX, AHRI } from "./fixtures/champions";
/** Minimal stand-in for compareChampions — the engine only stores what this returns. */
const compareFn = (guess, target) => [
{ key: "name", result: guess.name === target.name ? "correct" : "wrong" },
];
/** Synthesize a distinct non-target champion. */
function other(name) {
return { ...AHRI, id: name, name };
}
function daily(overrides = {}) {
return createGame({
target: AATROX,
compareFn,
maxGuesses: 6,
mode: "daily",
seed: "2026-07-25",
...overrides,
});
}
function unlimited(overrides = {}) {
return createGame({
target: AATROX,
compareFn,
maxGuesses: 0,
mode: "unlimited",
seed: "unlimited_123",
...overrides,
});
}
describe("createGame", () => {
it("starts empty and open", () => {
const game = daily();
expect(game.guesses).toEqual([]);
expect(game.results).toEqual([]);
expect(game.isOver).toBe(false);
expect(game.isWon).toBe(false);
});
it("carries config through onto the game object", () => {
expect(daily()).toMatchObject({
target: AATROX,
maxGuesses: 6,
mode: "daily",
seed: "2026-07-25",
});
});
it("defaults to 6 guesses in daily mode when maxGuesses is omitted", () => {
const game = createGame({ target: AATROX, compareFn });
expect(game.maxGuesses).toBe(6);
expect(game.mode).toBe("daily");
});
});
describe("submitGuess", () => {
it("appends the guess and its comparison result", () => {
const game = submitGuess(daily(), AHRI);
expect(game.guesses).toEqual([AHRI]);
expect(game.results).toHaveLength(1);
});
it("does not mutate the game it was given", () => {
const before = daily();
submitGuess(before, AHRI);
expect(before.guesses).toHaveLength(0);
expect(before.results).toHaveLength(0);
});
it("returns fresh array identities so reassignment is observable", () => {
const before = daily();
const after = submitGuess(before, AHRI);
expect(after).not.toBe(before);
expect(after.guesses).not.toBe(before.guesses);
expect(after.results).not.toBe(before.results);
});
it("rejects a duplicate guess with null", () => {
const game = submitGuess(daily(), AHRI);
expect(submitGuess(game, AHRI)).toBeNull();
});
it("rejects any guess once the game is over", () => {
const game = submitGuess(daily(), AATROX);
expect(game.isOver).toBe(true);
expect(submitGuess(game, AHRI)).toBeNull();
});
it("wins on the correct champion", () => {
expect(submitGuess(daily(), AATROX)).toMatchObject({
isWon: true,
isOver: true,
});
});
it("still counts a win made with the final allowed guess", () => {
let game = daily();
for (const name of ["a", "b", "c", "d", "e"]) {
game = submitGuess(game, other(name));
}
expect(game.isOver).toBe(false);
game = submitGuess(game, AATROX);
expect(game).toMatchObject({ isWon: true, isOver: true });
expect(game.guesses).toHaveLength(6);
});
it("ends the game as a loss at maxGuesses wrong guesses", () => {
let game = daily();
for (const name of ["a", "b", "c", "d", "e", "f"]) {
game = submitGuess(game, other(name));
}
expect(game).toMatchObject({ isOver: true, isWon: false });
expect(game.guesses).toHaveLength(6);
});
it("never ends on guess count when maxGuesses is 0", () => {
let game = unlimited();
for (let i = 0; i < 10; i++) {
game = submitGuess(game, other(`c${i}`));
}
expect(game.isOver).toBe(false);
expect(game.guesses).toHaveLength(10);
});
});
describe("persistence — storage key contract", () => {
it("writes daily state to exactly loldle_daily_<seed>", () => {
submitGuess(daily(), AHRI);
expect(localStorage.getItem("loldle_daily_2026-07-25")).not.toBeNull();
});
it("writes unlimited state to exactly loldle_unlimited_current", () => {
submitGuess(unlimited(), AHRI);
expect(localStorage.getItem("loldle_unlimited_current")).not.toBeNull();
});
it("ignores the seed when keying unlimited state", () => {
submitGuess(unlimited({ seed: "unlimited_999" }), AHRI);
expect(localStorage.getItem("loldle_unlimited_current")).not.toBeNull();
expect(localStorage.getItem("loldle_unlimited_unlimited_999")).toBeNull();
});
it("stores the target name, guesses, results and flags", () => {
submitGuess(daily(), AHRI);
const saved = JSON.parse(localStorage.getItem("loldle_daily_2026-07-25"));
expect(saved).toMatchObject({
targetName: "Aatrox",
guesses: [AHRI],
isOver: false,
isWon: false,
});
expect(saved.results).toHaveLength(1);
});
});
describe("persistence — restore on createGame", () => {
it("restores an in-progress game for the same target", () => {
submitGuess(daily(), AHRI);
const restored = daily();
expect(restored.guesses).toEqual([AHRI]);
expect(restored.results).toHaveLength(1);
});
it("restores the over and won flags", () => {
submitGuess(daily(), AATROX);
expect(daily()).toMatchObject({ isOver: true, isWon: true });
});
it("discards saved state when the target changed — the date-rollover case", () => {
submitGuess(daily(), AHRI);
const fresh = createGame({
target: { ...AHRI, name: "Ahri" },
compareFn,
maxGuesses: 6,
mode: "daily",
seed: "2026-07-25",
});
expect(fresh.guesses).toEqual([]);
expect(fresh.isOver).toBe(false);
});
it("starts fresh when storage holds malformed JSON", () => {
localStorage.setItem("loldle_daily_2026-07-25", "{not json");
expect(daily().guesses).toEqual([]);
});
});
describe("clearExpiredCache", () => {
it("removes yesterday's daily entry but keeps today's", () => {
localStorage.setItem("loldle_daily_2026-07-24", "{}");
localStorage.setItem("loldle_daily_2026-07-25", "{}");
clearExpiredCache("2026-07-25");
expect(localStorage.getItem("loldle_daily_2026-07-24")).toBeNull();
expect(localStorage.getItem("loldle_daily_2026-07-25")).not.toBeNull();
});
it("leaves unlimited keys untouched", () => {
localStorage.setItem("loldle_unlimited_current", "{}");
localStorage.setItem("loldle_unlimited_stats", "{}");
localStorage.setItem("loldle_unlimited_seed", "s");
clearExpiredCache("2026-07-25");
expect(localStorage.getItem("loldle_unlimited_current")).not.toBeNull();
expect(localStorage.getItem("loldle_unlimited_stats")).not.toBeNull();
expect(localStorage.getItem("loldle_unlimited_seed")).not.toBeNull();
});
it("removes every stale daily entry, not just the first", () => {
localStorage.setItem("loldle_daily_2026-07-20", "{}");
localStorage.setItem("loldle_daily_2026-07-21", "{}");
localStorage.setItem("loldle_daily_2026-07-22", "{}");
clearExpiredCache("2026-07-25");
expect(localStorage.length).toBe(0);
});
it("ignores unrelated keys", () => {
localStorage.setItem("unrelated", "keep");
clearExpiredCache("2026-07-25");
expect(localStorage.getItem("unrelated")).toBe("keep");
});
});
describe("unlimited seed", () => {
it("creates and persists a seed on first call", () => {
const seed = getOrCreateUnlimitedSeed();
expect(seed).toMatch(/^unlimited_/);
expect(localStorage.getItem("loldle_unlimited_seed")).toBe(seed);
});
it("returns the same seed on subsequent calls", () => {
expect(getOrCreateUnlimitedSeed()).toBe(getOrCreateUnlimitedSeed());
});
it("clearUnlimitedState drops both the seed and the current game", () => {
submitGuess(unlimited(), AHRI);
getOrCreateUnlimitedSeed();
clearUnlimitedState();
expect(localStorage.getItem("loldle_unlimited_current")).toBeNull();
expect(localStorage.getItem("loldle_unlimited_seed")).toBeNull();
});
it("clearUnlimitedState preserves accumulated stats", () => {
localStorage.setItem("loldle_unlimited_stats", '{"gamesPlayed":3}');
clearUnlimitedState();
expect(loadUnlimitedStats().gamesPlayed).toBe(3);
});
});
describe("saveUnlimitedStats", () => {
/**
* Play an unlimited game to completion; `won` picks the ending.
* Clears saved state first, mirroring handleNewGame — otherwise createGame
* restores the previous finished game and submitGuess refuses to run.
*/
function finish(won, wrongGuesses = 0) {
clearUnlimitedState();
let game = unlimited();
for (let i = 0; i < wrongGuesses; i++) {
game = submitGuess(game, other(`c${i}`));
}
if (won) game = submitGuess(game, AATROX);
else game = { ...game, isOver: true };
return game;
}
it("counts a played game", () => {
saveUnlimitedStats(finish(false));
expect(loadUnlimitedStats().gamesPlayed).toBe(1);
});
it("counts a win and records its guess count", () => {
saveUnlimitedStats(finish(true, 2));
expect(loadUnlimitedStats()).toMatchObject({
gamesPlayed: 1,
gamesWon: 1,
guessDistribution: { 3: 1 },
});
});
it("does not count a win when the game was lost", () => {
saveUnlimitedStats(finish(false));
expect(loadUnlimitedStats()).toMatchObject({
gamesPlayed: 1,
gamesWon: 0,
guessDistribution: {},
});
});
it("accumulates across games", () => {
saveUnlimitedStats(finish(true, 1));
saveUnlimitedStats(finish(true, 1));
saveUnlimitedStats(finish(false));
expect(loadUnlimitedStats()).toMatchObject({
gamesPlayed: 3,
gamesWon: 2,
guessDistribution: { 2: 2 },
});
});
it("stamps lastPlayed", () => {
saveUnlimitedStats(finish(true, 0));
expect(typeof loadUnlimitedStats().lastPlayed).toBe("number");
});
it("is a no-op for daily mode", () => {
const game = submitGuess(daily(), AATROX);
saveUnlimitedStats(game);
expect(localStorage.getItem("loldle_unlimited_stats")).toBeNull();
});
it("is a no-op for an unfinished game", () => {
saveUnlimitedStats(submitGuess(unlimited(), AHRI));
expect(localStorage.getItem("loldle_unlimited_stats")).toBeNull();
});
});
describe("loadUnlimitedStats", () => {
it("returns zeroed stats when nothing is stored", () => {
expect(loadUnlimitedStats()).toEqual({
gamesPlayed: 0,
gamesWon: 0,
guessDistribution: {},
lastPlayed: null,
});
});
it("returns zeroed stats when storage holds malformed JSON", () => {
localStorage.setItem("loldle_unlimited_stats", "not json at all");
expect(loadUnlimitedStats()).toMatchObject({
gamesPlayed: 0,
gamesWon: 0,
});
});
});
+37
View File
@@ -0,0 +1,37 @@
import { beforeEach, vi } from "vitest";
/**
* Minimal spec-shaped localStorage stub.
* `key(i)` and `length` are required: clearExpiredCache iterates by index.
*/
class LocalStorageStub {
#store = new Map();
get length() {
return this.#store.size;
}
key(i) {
return [...this.#store.keys()][i] ?? null;
}
getItem(k) {
return this.#store.has(k) ? this.#store.get(k) : null;
}
setItem(k, v) {
this.#store.set(k, String(v));
}
removeItem(k) {
this.#store.delete(k);
}
clear() {
this.#store.clear();
}
}
beforeEach(() => {
vi.stubGlobal("localStorage", new LocalStorageStub());
});