feat(filter): focus one game at a time
Switching games on and off says which games you play, and is set once. It's the wrong tool for what a player of four games does while reading — clear one game, move to the next — which cost two taps per game and left the settings panel no longer describing what they play. Focus is a lens over that filter, not a second filter: a bar at the top, above everything it narrows, with a "next game" control that steps through and ends by returning to all. It never touches hiddenGames, and a focus on a game since switched off is ignored rather than obeyed, so it can't strand you on a blank page whose cause is elsewhere. Each chip carries that game's outstanding count, so a game with nothing waiting says so before you visit it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2a7a117705
commit
d70720e2af
@@ -81,9 +81,10 @@ src/ingest/ html.ts, dates.ts (six formats), merge.ts, sanitize.ts, robots
|
||||
adapters/ index.ts — SOURCES registry binding url+game+parser, and the sanitize seam
|
||||
src/client/ React app, service worker, manifest
|
||||
state/ progress, daily log, ignores, prefs, sort — all localStorage
|
||||
lens.ts — who sees which rows (focus, outstanding, next-to-expire); pure
|
||||
scripts/ build-feed.ts, parse-fixture.ts (offline), refresh-sources.ts (fetches)
|
||||
serve.ts static server + /api/health
|
||||
test/ 309 tests
|
||||
test/ 318 tests
|
||||
fixtures/<game>/ raw HTML + .expected.json per source — pinned, kept forever
|
||||
snapshots/ current page per source, rewritten by refresh — see its README
|
||||
```
|
||||
|
||||
@@ -88,9 +88,10 @@ src/
|
||||
Timeline.tsx calendar lanes (F1)
|
||||
EventDetail.tsx detail sheet, ignore action (F9)
|
||||
ProgressControls status, effort, note (F12)
|
||||
Dailies.tsx today's strip of repeating jobs
|
||||
Dailies.tsx today's strip, per-game reset clocks
|
||||
DailyChecklist one repeating event's whole run
|
||||
Fireworks.tsx the burst when the last daily lands
|
||||
GameFocus.tsx one game at a time (F4a)
|
||||
Controls.tsx games, region, export/import(F4, F5, F6)
|
||||
Welcome.tsx first-run game picker (F8)
|
||||
Toast.tsx undo an ignore
|
||||
@@ -100,9 +101,9 @@ src/
|
||||
useMarkSet.ts ignores (and the superseded completions shape)
|
||||
useProgress.ts status, effort, note, daily override (F12)
|
||||
useDailyLog.ts which game-days are ticked off
|
||||
usePrefs.ts region, filters, onboarding flags
|
||||
usePrefs.ts region, filters, focus, onboarding flags
|
||||
sort.ts deadline order, or what you're partway through
|
||||
lens.ts who sees which rows — outstanding, next-to-expire
|
||||
lens.ts who sees which rows — focus, outstanding, next-to-expire
|
||||
serve.ts static server + /api/health ✓ built
|
||||
scripts/
|
||||
build-feed.ts fixtures → public/data/events.v1.json ✓ built
|
||||
|
||||
+2
-2
@@ -199,8 +199,8 @@ Namespaced, versioned, and small. Nothing here ever goes to the server.
|
||||
"gacha-tracker:v1:progress" // { [eventId]: { status?, effort?, note?, at } }
|
||||
"gacha-tracker:v1:daily" // { [id]: { days: ["2026-08-15", ...], at } }
|
||||
"gacha-tracker:v1:ignored" // { [eventId]: { at } } — "stop showing me this"
|
||||
"gacha-tracker:v1:prefs" // { region, hiddenGames[], sort, detectDaily, showCompleted,
|
||||
// showIgnored, regionConfirmed, onboarded }
|
||||
"gacha-tracker:v1:prefs" // { region, hiddenGames[], focusGame, sort, detectDaily,
|
||||
// showCompleted, showIgnored, regionConfirmed, onboarded }
|
||||
"gacha-tracker:v1:completions" // SUPERSEDED — read once to migrate, never written
|
||||
```
|
||||
|
||||
|
||||
+14
@@ -73,6 +73,20 @@ but de-emphasized; a filter toggles them out entirely.
|
||||
Filter by game (multi-select, persisted) and by event type. Hiding a game hides it from both views.
|
||||
Preferences persist in `localStorage`.
|
||||
|
||||
**F4a — Focus one game at a time.**
|
||||
Switching games on and off says *which games the reader plays*, and is set once. It is the wrong
|
||||
tool for the thing a player of four games actually does while reading: clear one game, move to the
|
||||
next. Doing that with the on/off switches costs two taps per game and leaves the settings panel no
|
||||
longer describing what they play.
|
||||
|
||||
So focus is a **lens over the filter, not a second filter**: a bar at the top of the page, above
|
||||
everything it affects, narrowing every view — headline, dailies, lists, calendar and counts — to one
|
||||
game, with a "next game" control that steps through them and ends by returning to all. It never
|
||||
changes `hiddenGames`, "All" is always one tap away, and a focus on a game that is switched off or
|
||||
has left the feed is ignored rather than obeyed, so it can never strand the reader on a blank page
|
||||
whose cause is elsewhere. Each chip carries that game's outstanding count, so a game with nothing
|
||||
waiting says so before it is visited.
|
||||
|
||||
**F5 — Region selection.**
|
||||
A user picks Asia / America / Europe once. For events where `regionScoped` is true, all displayed
|
||||
end times resolve to that region's server reset. This is stored in `localStorage` and defaults to a
|
||||
|
||||
+68
-12
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { fetchFeed, type FeedState } from "./api.ts";
|
||||
import { Controls } from "./components/Controls.tsx";
|
||||
import { Dailies } from "./components/Dailies.tsx";
|
||||
import { GameFocus } from "./components/GameFocus.tsx";
|
||||
import { EventDetail } from "./components/EventDetail.tsx";
|
||||
import { EventRow, type DailyBadge, type RowEvent } from "./components/EventRow.tsx";
|
||||
import { NextUp } from "./components/NextUp.tsx";
|
||||
@@ -16,9 +17,16 @@ import { useProgress } from "./state/useProgress.ts";
|
||||
import { useDailyLog, type DailyLogMap } from "./state/useDailyLog.ts";
|
||||
import { usePrefs } from "./state/usePrefs.ts";
|
||||
import { compareRows, SORT_MODES, type Activity, type SortMode } from "./state/sort.ts";
|
||||
import { firstToExpire, outstanding } from "./state/lens.ts";
|
||||
import {
|
||||
advanceFocus,
|
||||
countByGame,
|
||||
firstToExpire,
|
||||
outstanding,
|
||||
resolveFocus,
|
||||
} from "./state/lens.ts";
|
||||
import { clockFor, DAY, formatRemaining } from "../shared/time.ts";
|
||||
import { dailySummary, isDaily, resolveDaily } from "../shared/daily.ts";
|
||||
import { gameMeta } from "../shared/games.ts";
|
||||
import type { GameId } from "../shared/schema.ts";
|
||||
|
||||
type View = "soon" | "calendar";
|
||||
@@ -148,7 +156,24 @@ export function App() {
|
||||
[allRows],
|
||||
);
|
||||
|
||||
const visible = useMemo(
|
||||
/** Games the reader plays, in feed order. The focus bar rotates through these. */
|
||||
const enabled = useMemo(
|
||||
() => games.filter((g) => !prefs.hiddenGames.includes(g)),
|
||||
[games, prefs.hiddenGames],
|
||||
);
|
||||
|
||||
// A focus on a game they have since switched off is ignored, not obeyed —
|
||||
// otherwise the page is blank for a reason that lives in a panel at the
|
||||
// bottom. The stored value is left alone so switching the game back on
|
||||
// restores where they were.
|
||||
const focus = resolveFocus(prefs.focusGame, enabled);
|
||||
|
||||
/**
|
||||
* Everything the reader could be looking at, before focus narrows it. The
|
||||
* focus chips count off this, so a chip can say what is waiting in a game
|
||||
* that is not the one currently on screen.
|
||||
*/
|
||||
const inScope = useMemo(
|
||||
() =>
|
||||
allRows
|
||||
.filter((r) => !prefs.hiddenGames.includes(r.event.game))
|
||||
@@ -156,22 +181,27 @@ export function App() {
|
||||
// Ignored events are gone from both views unless deliberately revealed
|
||||
// — that is the whole point of ignoring one.
|
||||
.filter((r) => prefs.showIgnored || !isIgnored(r.event.id))
|
||||
.filter((r) => prefs.showCompleted || !isDone(r.event.id))
|
||||
// Sorting only ever groups: both modes fall back to soonest-ending
|
||||
// inside a group, so choosing one never costs the deadline order.
|
||||
.sort(compareRows(prefs.sort, activityOf)),
|
||||
.filter((r) => prefs.showCompleted || !isDone(r.event.id)),
|
||||
[
|
||||
allRows,
|
||||
prefs.hiddenGames,
|
||||
prefs.showCompleted,
|
||||
prefs.showIgnored,
|
||||
prefs.sort,
|
||||
prog.progress,
|
||||
daily.logs,
|
||||
ignored.marks,
|
||||
],
|
||||
);
|
||||
|
||||
const visible = useMemo(
|
||||
() =>
|
||||
inScope
|
||||
.filter((r) => focus === null || r.event.game === focus)
|
||||
// Sorting only ever groups: both modes fall back to soonest-ending
|
||||
// inside a group, so choosing one never costs the deadline order.
|
||||
.sort(compareRows(prefs.sort, activityOf)),
|
||||
[inScope, focus, prefs.sort, prog.progress, daily.logs],
|
||||
);
|
||||
|
||||
const live = visible.filter((r) => r.clock.live);
|
||||
const upcoming = visible.filter((r) => r.clock.upcoming);
|
||||
|
||||
@@ -188,6 +218,14 @@ export function App() {
|
||||
const todo = outstanding(live, isDone, isIgnored);
|
||||
const next = firstToExpire(todo);
|
||||
|
||||
// Counted across every game the reader plays, not just the focused one — a
|
||||
// chip has to say what is waiting behind it to be worth tapping.
|
||||
const scopedTodo = useMemo(
|
||||
() => outstanding(inScope, isDone, isIgnored),
|
||||
[inScope, prog.progress, ignored.marks],
|
||||
);
|
||||
const perGame = useMemo(() => countByGame(scopedTodo), [scopedTodo]);
|
||||
|
||||
const openRow = allRows.find((r) => r.event.id === openId) ?? null;
|
||||
|
||||
if (state.status === "loading") {
|
||||
@@ -274,14 +312,31 @@ export function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Working through games one at a time, which is how someone with four
|
||||
of them actually plays: clear one, move on. Above everything it
|
||||
filters, so what it is doing to the page is never a mystery. */}
|
||||
<GameFocus
|
||||
games={enabled}
|
||||
focus={focus}
|
||||
counts={perGame}
|
||||
total={scopedTodo.length}
|
||||
next={advanceFocus(focus, enabled)}
|
||||
onFocus={(focusGame) => update({ focusGame })}
|
||||
onAdvance={() => update({ focusGame: advanceFocus(focus, enabled) })}
|
||||
/>
|
||||
|
||||
{view === "soon" ? (
|
||||
<>
|
||||
<NextUp row={next} onOpen={setOpenId} />
|
||||
<NextUp
|
||||
row={next}
|
||||
focused={focus === null ? null : gameMeta(focus).name}
|
||||
onOpen={setOpenId}
|
||||
/>
|
||||
|
||||
{/* The chores no wiki publishes, and the only thing on this page
|
||||
that expires tonight rather than next patch. */}
|
||||
<Dailies
|
||||
games={games.filter((g) => !prefs.hiddenGames.includes(g))}
|
||||
games={focus === null ? enabled : [focus]}
|
||||
events={todo.filter(repeatsDaily).map((r) => r.event)}
|
||||
region={prefs.region}
|
||||
now={now}
|
||||
@@ -357,8 +412,9 @@ export function App() {
|
||||
|
||||
{visible.length === 0 && (
|
||||
<p className="px-4 py-12 text-sm leading-relaxed text-muted">
|
||||
Nothing to show. Every game is switched off, or you've finished
|
||||
everything and hidden completed events.
|
||||
{focus !== null
|
||||
? `Nothing running in ${gameMeta(focus).name}. Try another game, or show all of them.`
|
||||
: "Nothing to show. Every game is switched off, or you've finished everything and hidden completed events."}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -73,9 +73,9 @@ export function Dailies({
|
||||
previous.current = { total, complete };
|
||||
if (was === null || !allDone) return;
|
||||
// Celebrate finishing the last one, and only that. The list also gets
|
||||
// shorter when the reader marks a repeating event done, which can land on
|
||||
// "all complete" without them having ticked anything — a burst there is the
|
||||
// app congratulating them for filtering.
|
||||
// shorter when the reader focuses a single game or marks a repeating event
|
||||
// done, which can land on "all complete" without them having ticked
|
||||
// anything — a burst there is the app congratulating them for filtering.
|
||||
if (was.total === total && complete > was.complete) setBurst((n) => n + 1);
|
||||
}, [total, complete, allDone]);
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { gameMeta } from "../../shared/games.ts";
|
||||
import type { GameId } from "../../shared/schema.ts";
|
||||
|
||||
/**
|
||||
* One game at a time.
|
||||
*
|
||||
* The settings panel already lets a reader switch games on and off, but that is
|
||||
* a different job: it says which games they play, and it is set once. This is
|
||||
* the thing a reader does *while reading* — clear the deck down to one game,
|
||||
* finish it, move to the next. Doing that with the on/off switches means two
|
||||
* taps per game and a settings panel that no longer describes what they play.
|
||||
*
|
||||
* So focus is a lens, not a setting: it never changes which games are switched
|
||||
* on, "All" is always one tap away, and the rotation ends by returning to All
|
||||
* rather than looping forever.
|
||||
*/
|
||||
export function GameFocus({
|
||||
games,
|
||||
focus,
|
||||
counts,
|
||||
total,
|
||||
next,
|
||||
onFocus,
|
||||
onAdvance,
|
||||
}: {
|
||||
/** Games the reader has switched on, in feed order. */
|
||||
games: GameId[];
|
||||
focus: GameId | null;
|
||||
/** Outstanding rows per game, so a chip says whether it is worth a visit. */
|
||||
counts: Partial<Record<GameId, number>>;
|
||||
total: number;
|
||||
/** Where "next" goes — null means back to all games. */
|
||||
next: GameId | null;
|
||||
onFocus: (game: GameId | null) => void;
|
||||
onAdvance: () => void;
|
||||
}) {
|
||||
// With one game there is nothing to focus down to, and the bar would just be
|
||||
// a chip that does nothing.
|
||||
if (games.length < 2) return null;
|
||||
|
||||
return (
|
||||
<section className="border-b border-hairline px-4 py-3">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p className="eyebrow">Focus</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdvance}
|
||||
className="shrink-0 text-[0.6875rem] font-medium text-faint transition-colors hover:text-ink"
|
||||
>
|
||||
{next === null ? "Show all games" : `Next: ${gameMeta(next).short}`}
|
||||
<span aria-hidden> →</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Focus on one game"
|
||||
className="scroll-x -mx-4 mt-2 flex gap-1.5 px-4 pb-1"
|
||||
>
|
||||
<Chip
|
||||
label="All"
|
||||
count={total}
|
||||
on={focus === null}
|
||||
hue="var(--color-ink)"
|
||||
onClick={() => onFocus(null)}
|
||||
/>
|
||||
{games.map((id) => {
|
||||
const game = gameMeta(id);
|
||||
return (
|
||||
<Chip
|
||||
key={id}
|
||||
label={game.short}
|
||||
ariaLabel={game.name}
|
||||
count={counts[id] ?? 0}
|
||||
on={focus === id}
|
||||
hue={game.hue}
|
||||
// Tapping the focused game backs out to all of them, so the chip
|
||||
// that got you here is also the way back.
|
||||
onClick={() => onFocus(focus === id ? null : id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A game chip.
|
||||
*
|
||||
* Carries its hue whether or not it is selected — dimmer when it is not — so
|
||||
* the row reads as a set of games at a glance rather than as one coloured chip
|
||||
* among a row of grey ones. The count is what makes it worth tapping: a game
|
||||
* with nothing outstanding says so before you visit it.
|
||||
*/
|
||||
function Chip({
|
||||
label,
|
||||
ariaLabel,
|
||||
count,
|
||||
on,
|
||||
hue,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
ariaLabel?: string | undefined;
|
||||
count: number;
|
||||
on: boolean;
|
||||
hue: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={on}
|
||||
aria-label={`${ariaLabel ?? label}, ${count} outstanding`}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
style={{
|
||||
borderColor: on ? hue : `color-mix(in srgb, ${hue} 30%, transparent)`,
|
||||
color: on ? hue : "var(--color-muted)",
|
||||
background: on
|
||||
? `color-mix(in srgb, ${hue} 14%, transparent)`
|
||||
: "transparent",
|
||||
// Nothing to do here — still reachable, just not competing for the eye.
|
||||
opacity: count === 0 && !on ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<span className="tnum text-[0.625rem] opacity-70">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { Meter, URGENCY_COLOR } from "./Meter.tsx";
|
||||
*/
|
||||
export function NextUp({
|
||||
row,
|
||||
focused,
|
||||
onOpen,
|
||||
}: {
|
||||
/**
|
||||
@@ -21,6 +22,8 @@ export function NextUp({
|
||||
* they have chosen to keep it elsewhere.
|
||||
*/
|
||||
row: RowEvent | null;
|
||||
/** Name of the game being focused on, when the page is narrowed to one. */
|
||||
focused: string | null;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
if (row === null) {
|
||||
@@ -28,8 +31,9 @@ export function NextUp({
|
||||
<section className="border-b border-hairline px-4 py-8">
|
||||
<p className="eyebrow">Nothing running</p>
|
||||
<p className="mt-2 max-w-sm text-sm text-muted">
|
||||
Nothing live and unfinished in the games you have switched on. Turn a
|
||||
game back on below, or check again after the next patch.
|
||||
{focused === null
|
||||
? "Nothing live and unfinished in the games you have switched on. Turn a game back on below, or check again after the next patch."
|
||||
: `Nothing live and unfinished in ${focused}. Move to the next game, or show all of them.`}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -62,3 +62,46 @@ export function firstToExpire<T extends Row>(rows: readonly T[]): T | null {
|
||||
}
|
||||
return best ?? rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The focused game, if it is still a game the reader can see.
|
||||
*
|
||||
* A focus on a game they have since switched off, or that has dropped out of
|
||||
* the feed, is ignored rather than obeyed — the alternative is an empty page
|
||||
* whose reason is a setting two screens away.
|
||||
*/
|
||||
export function resolveFocus(
|
||||
focus: GameId | null,
|
||||
enabled: readonly GameId[],
|
||||
): GameId | null {
|
||||
return focus !== null && enabled.includes(focus) ? focus : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The next game in the rotation: all → first → … → last → all.
|
||||
*
|
||||
* Working through games one at a time is a loop, and it ends by coming back to
|
||||
* everything rather than silently starting over — otherwise there is no way out
|
||||
* of the rotation except finding the "all" chip again.
|
||||
*/
|
||||
export function advanceFocus(
|
||||
focus: GameId | null,
|
||||
enabled: readonly GameId[],
|
||||
): GameId | null {
|
||||
if (enabled.length === 0) return null;
|
||||
const at = focus === null ? -1 : enabled.indexOf(focus);
|
||||
// An unknown focus (switched-off game) restarts the rotation rather than
|
||||
// jumping to index 0 of nowhere.
|
||||
return enabled[at + 1] ?? null;
|
||||
}
|
||||
|
||||
/** How many rows each game still has outstanding, for the focus chips. */
|
||||
export function countByGame<T extends Row>(
|
||||
rows: readonly T[],
|
||||
): Partial<Record<GameId, number>> {
|
||||
const out: Partial<Record<GameId, number>> = {};
|
||||
for (const row of rows) {
|
||||
out[row.event.game] = (out[row.event.game] ?? 0) + 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,15 @@ export interface Prefs {
|
||||
region: Region;
|
||||
/** Games the reader has switched off. Stored as hidden so a newly added game shows up by default. */
|
||||
hiddenGames: GameId[];
|
||||
/**
|
||||
* One game to look at right now, or null for all of them.
|
||||
*
|
||||
* A lens, not a setting: it never changes `hiddenGames`, and a focus on a
|
||||
* game that is switched off or has left the feed is ignored rather than
|
||||
* obeyed (`resolveFocus`), so it can never leave the reader on a blank page
|
||||
* with no visible cause.
|
||||
*/
|
||||
focusGame: GameId | null;
|
||||
/** How the list is ordered. Deadline order is the default and the fallback. */
|
||||
sort: SortMode;
|
||||
/**
|
||||
@@ -29,6 +38,7 @@ function defaults(): Prefs {
|
||||
return {
|
||||
region: guessRegion(),
|
||||
hiddenGames: [],
|
||||
focusGame: null,
|
||||
sort: "ending",
|
||||
detectDaily: true,
|
||||
showCompleted: true,
|
||||
|
||||
+66
-1
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { firstToExpire, outstanding } from "../src/client/state/lens.ts";
|
||||
import {
|
||||
advanceFocus,
|
||||
countByGame,
|
||||
firstToExpire,
|
||||
outstanding,
|
||||
resolveFocus,
|
||||
} from "../src/client/state/lens.ts";
|
||||
import type { GameId } from "../src/shared/schema.ts";
|
||||
|
||||
const row = (id: string, game: GameId, msRemaining: number | null) => ({
|
||||
@@ -66,3 +72,62 @@ describe("firstToExpire", () => {
|
||||
expect(firstToExpire([])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveFocus", () => {
|
||||
const enabled: GameId[] = ["genshin", "hsr"];
|
||||
|
||||
test("a focus on a game they still play stands", () => {
|
||||
expect(resolveFocus("hsr", enabled)).toBe("hsr");
|
||||
});
|
||||
|
||||
test("a focus on a game they switched off is ignored, not obeyed", () => {
|
||||
// Obeying it leaves a blank page whose cause is a setting in a panel at the
|
||||
// bottom. The stored value is left alone, so switching the game back on
|
||||
// puts them back where they were.
|
||||
expect(resolveFocus("zzz", enabled)).toBeNull();
|
||||
});
|
||||
|
||||
test("no focus is all games", () => {
|
||||
expect(resolveFocus(null, enabled)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("advanceFocus", () => {
|
||||
const enabled: GameId[] = ["genshin", "hsr", "zzz"];
|
||||
|
||||
test("all games leads into the first one", () => {
|
||||
expect(advanceFocus(null, enabled)).toBe("genshin");
|
||||
});
|
||||
|
||||
test("steps through in order", () => {
|
||||
expect(advanceFocus("genshin", enabled)).toBe("hsr");
|
||||
expect(advanceFocus("hsr", enabled)).toBe("zzz");
|
||||
});
|
||||
|
||||
test("the last game leads back out to all of them", () => {
|
||||
// Not a wrap to the first: a rotation with no exit means the only way back
|
||||
// to everything is finding the "All" chip, which is the thing the rotation
|
||||
// was meant to save them.
|
||||
expect(advanceFocus("zzz", enabled)).toBeNull();
|
||||
});
|
||||
|
||||
test("a focus that is no longer enabled restarts the rotation", () => {
|
||||
expect(advanceFocus("wuwa", enabled)).toBe("genshin");
|
||||
});
|
||||
|
||||
test("no games to rotate through", () => {
|
||||
expect(advanceFocus(null, [])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("countByGame", () => {
|
||||
test("counts per game and omits games with nothing", () => {
|
||||
const counts = countByGame([
|
||||
row("a", "genshin", 1),
|
||||
row("b", "genshin", 2),
|
||||
row("c", "hsr", 3),
|
||||
]);
|
||||
expect(counts).toEqual({ genshin: 2, hsr: 1 });
|
||||
expect(counts.zzz).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user