Files
gacha-event-tracker/test/prefs.test.ts
T

200 lines
7.3 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { adoptNewLanes, adoptRenamed, defaults, restorePrefsValue } from "../src/client/state/usePrefs.ts";
import type { LaneId } from "../src/shared/custom.ts";
/**
* What happens to a reader's games when we add a source.
*
* Adding one is our decision, not theirs: a reader who plays two games did not
* ask for the other twelve, and a calendar that fills up on its own is the
* thing the first-run picker exists to prevent. So a lane that is new to them
* arrives switched off — and the one case that must never misfire is the
* reader who installed before any of this was recorded.
*/
const TRACKED: LaneId[] = ["genshin", "hsr", "zzz"];
describe("adoptNewLanes", () => {
test("an unrecorded reader has everything on their screen recorded, and nothing switched off", () => {
// Every existing install is in this state. Reading "no record" as "has been
// offered nothing" would switch off every game they already read.
expect(adoptNewLanes(TRACKED, undefined, [])).toEqual({
knownGames: TRACKED,
});
});
test("a lane they were never offered arrives switched off", () => {
const patch = adoptNewLanes([...TRACKED, "holodori"], TRACKED, []);
expect(patch).toEqual({
knownGames: [...TRACKED, "holodori"],
hiddenGames: ["holodori"],
});
});
test("their own game is recorded but never hidden", () => {
// They asked for it by typing it in. Hiding it would be the app arguing
// with the reader about a game they just created.
const patch = adoptNewLanes(
[...TRACKED, "mygame:limbus-company"],
TRACKED,
[],
);
expect(patch).toEqual({
knownGames: [...TRACKED, "mygame:limbus-company"],
hiddenGames: [],
});
});
test("nothing new is nothing to write", () => {
expect(adoptNewLanes(TRACKED, TRACKED, ["zzz"])).toBeNull();
});
test("an empty list is a feed that has not arrived, not a reader with no games", () => {
// Seeding from it would record nothing and then treat every real game as
// new the moment the feed lands.
expect(adoptNewLanes([], undefined, [])).toBeNull();
expect(adoptNewLanes([], TRACKED, [])).toBeNull();
});
test("a game they had already switched off is not listed twice", () => {
const patch = adoptNewLanes([...TRACKED, "wuwa"], TRACKED, ["wuwa"]);
expect(patch?.hiddenGames).toEqual(["wuwa"]);
});
test("their existing choices are left exactly as they were", () => {
const patch = adoptNewLanes([...TRACKED, "fgo"], TRACKED, ["hsr"]);
expect(patch?.hiddenGames).toEqual(["hsr", "fgo"]);
});
});
/**
* A preference that changed its name.
*
* `timelineUpcoming` governed the board alone; `showUpcoming` governs the
* checklist too. Nothing is lost by dropping the old name — `prefs` is one blob
* under one key — but a reader who had switched the future on would find it off
* again with no explanation, which is the same failure as forgetting their view.
*/
describe("adoptRenamed", () => {
test("a reader's old answer is carried across", () => {
expect(adoptRenamed({ timelineUpcoming: true })).toEqual({
showUpcoming: true,
});
// Both directions: having said no is also an answer.
expect(adoptRenamed({ timelineUpcoming: false })).toEqual({
showUpcoming: false,
});
});
test("the old name never overwrites a fresher one", () => {
// Once written back under the new name, the leftover must not undo it —
// otherwise the setting would spring back on every load.
expect(
adoptRenamed({ timelineUpcoming: true, showUpcoming: false }),
).toEqual({ showUpcoming: false });
});
test("it is dropped rather than carried into the stored object", () => {
// Kept, it would be written straight back and outlive the migration.
expect(
Object.keys(adoptRenamed({ timelineUpcoming: true, sort: "doing" })),
).toEqual(["sort", "showUpcoming"]);
});
test("a reader with neither is left alone", () => {
expect(adoptRenamed({ sort: "doing" })).toEqual({ sort: "doing" });
expect(adoptRenamed({})).toEqual({});
});
});
describe("defaults that a reader would notice losing", () => {
test("the chores are on until someone says otherwise", () => {
// Flipping this one line silently empties Today's dailies for every
// existing reader — it is the whole argument of the comment above it, and
// nothing else in the suite was watching it.
expect(defaults().showChores).toBe(true);
});
test("a stored blob without the key keeps the default", () => {
// The upgrade path. Prefs saved before this key existed must not read as
// "off" — `{...defaults(), ...stored}` is what guarantees that, and JSON
// cannot carry an `undefined` that would override it.
const stored = { region: "europe", detectDaily: false } as const;
expect({ ...defaults(), ...stored }.showChores).toBe(true);
});
});
describe("restorePrefsValue", () => {
test("returns defaults when passed null or non-object", () => {
expect(restorePrefsValue(null)).toEqual(defaults());
expect(restorePrefsValue(undefined)).toEqual(defaults());
expect(restorePrefsValue("invalid")).toEqual(defaults());
});
test("restores full valid preferences from an export", () => {
const exported = {
region: "asia" as const,
hiddenGames: ["genshin", "hsr"],
knownGames: ["genshin", "hsr", "zzz"],
gameOrder: ["zzz", "genshin"],
focusGame: "zzz",
sort: "doing" as const,
view: "timeline" as const,
timelineDayWidth: 48,
timelineGroup: "ending" as const,
showUpcoming: true,
timelineSplitUpcoming: false,
detectDaily: true,
showChores: false,
showCompleted: false,
showIgnored: true,
theme: "light" as const,
regionConfirmed: true,
onboarded: true,
};
expect(restorePrefsValue(exported)).toEqual(exported);
});
test("falls back to defaults for missing fields from older exports", () => {
const older = {
region: "europe" as const,
hiddenGames: ["zzz"],
theme: "dark" as const,
};
const restored = restorePrefsValue(older);
expect(restored.region).toBe("europe");
expect(restored.hiddenGames).toEqual(["zzz"]);
expect(restored.theme).toBe("dark");
// missing fields use defaults
expect(restored.showChores).toBe(defaults().showChores);
expect(restored.timelineGroup).toBe(defaults().timelineGroup);
expect(restored.sort).toBe(defaults().sort);
});
test("adopts renamed fields like timelineUpcoming to showUpcoming", () => {
const legacy = {
timelineUpcoming: true,
};
const restored = restorePrefsValue(legacy);
expect(restored.showUpcoming).toBe(true);
});
test("sanitizes corrupt or invalid fields", () => {
const corrupt = {
region: "mars",
hiddenGames: "not-an-array",
gameOrder: 123,
timelineDayWidth: -99,
theme: "neon",
view: "grid",
};
const restored = restorePrefsValue(corrupt);
expect(restored.region).toBe(defaults().region);
expect(restored.hiddenGames).toEqual([]);
expect(restored.gameOrder).toBeUndefined();
expect(restored.timelineDayWidth).toBe(defaults().timelineDayWidth);
expect(restored.theme).toBe(defaults().theme);
expect(restored.view).toBe(defaults().view);
});
});