Giving Genshin's "To Temper Thyself and Journey Far" its five-of-seven goal meant finding it in the list, opening its sheet, saying it repeats and only then setting the goal. The strip now has an Edit mode beside Catch up that lists its events by game with their goals, opens the sheet's goal control in place, and adds any running event a game at a time. It calls the same setDaily/setGoal the sheet does, so it can store nothing the sheet would not. Weekly and monthly tasks are not due tonight, so they move to a row under the dailies, and each period can be switched off (showWeeklyTasks, showMonthlyTasks; default on, so nothing disappears on upgrade). Co-Authored-By: Claude Opus 5.5 <[email protected]>
219 lines
8.1 KiB
TypeScript
219 lines
8.1 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("weekly and monthly tasks under the dailies", () => {
|
|
test("both are shown until someone says otherwise", () => {
|
|
// A reader who set up tasks before these switches existed must not find
|
|
// them gone on upgrade.
|
|
expect(defaults().showWeeklyTasks).toBe(true);
|
|
expect(defaults().showMonthlyTasks).toBe(true);
|
|
const stored = { region: "europe", showChores: false } as const;
|
|
expect({ ...defaults(), ...stored }.showWeeklyTasks).toBe(true);
|
|
});
|
|
|
|
test("a restored value that is not a boolean falls back to shown", () => {
|
|
const restored = restorePrefsValue({ showWeeklyTasks: "no", showMonthlyTasks: false });
|
|
expect(restored.showWeeklyTasks).toBe(true);
|
|
expect(restored.showMonthlyTasks).toBe(false);
|
|
});
|
|
});
|
|
|
|
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,
|
|
showWeeklyTasks: false,
|
|
showMonthlyTasks: true,
|
|
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);
|
|
});
|
|
});
|