feat: decide the order games are listed in
Nothing did. Every surface that lists a game rendered App's `games`, which is the order lanes first appear in the feed — whichever game happened to hold the first event row. It is arbitrary, it moves as events come and go, and it was the same complaint the first-run picker had. So `orderGames` is one rule in one place, and `prefs.gameOrder` is where the reader's own answer goes. Absent means they have never placed a game rather than an empty order, which is the distinction `knownGames` already draws and the same trap: every install predating the field is in that state, and reading it the other way would hand them a blank list. They get A–Z by the name on screen instead. Two properties carry the weight, and both are tested rather than asserted. The result is always a permutation of the lanes given, because a game dropped here looks exactly like a game switched off and switching it on would not bring it back. And a lane the order does not name trails the ones it does, so a game we add later never lands in the middle of a hand-made order and a retired source keeps its slot for when it returns. Nothing reads it yet. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
7b6ee62235
commit
88d5ea2e43
@@ -0,0 +1,94 @@
|
||||
import type { LaneId } from "../../shared/custom.ts";
|
||||
|
||||
/**
|
||||
* The order games appear in — the reader's, when they have given one.
|
||||
*
|
||||
* Nothing used to decide this. The focus bar, the settings chips and the
|
||||
* timeline's lanes all render `App`'s `games`, which is the order lanes first
|
||||
* appear in the feed — whichever game happened to hold the first event row. It
|
||||
* is arbitrary, it shifts as events come and go, and it is the same problem the
|
||||
* first-run picker had (`docs/PRD.md` F8).
|
||||
*
|
||||
* Pure and its own module for the reason `lens.ts`, `zoom.ts` and `lanes.ts` are:
|
||||
* `prefs` stores the answer, four surfaces read it, and a rule that lives in one
|
||||
* place cannot drift between them.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Put lanes in the reader's order, falling back to alphabetical.
|
||||
*
|
||||
* `stored` absent means the reader has never placed a game — not that they have
|
||||
* no order. Every install predating this is in that state, which is the same
|
||||
* distinction `knownGames` draws in `usePrefs.ts`, and the fallback is what the
|
||||
* first-run picker already does.
|
||||
*
|
||||
* The result is **always a permutation of `lanes`**: never a lane dropped, never
|
||||
* one invented, and never a duplicate even if `stored` carries one. That is the
|
||||
* property worth testing rather than the ordering itself — a game missing from
|
||||
* the focus bar or from settings looks exactly like a game the reader switched
|
||||
* off, and their fix for that, switching it back on, would do nothing at all.
|
||||
*
|
||||
* Sorted on the name the reader sees and not on the `LaneId`, because the id is
|
||||
* not what is printed: `hsr` is Honkai: Star Rail and `nikke` is Goddess of
|
||||
* Victory: Nikke. Through `localeCompare`, because `<` orders by code point and
|
||||
* files hololive Dreams after every capitalised game in `games.ts`.
|
||||
*/
|
||||
export function orderGames(
|
||||
lanes: readonly LaneId[],
|
||||
stored: readonly LaneId[] | undefined,
|
||||
nameOf: (id: LaneId) => string,
|
||||
): LaneId[] {
|
||||
const alphabetical = (ids: readonly LaneId[]): LaneId[] =>
|
||||
[...ids].sort((a, b) => nameOf(a).localeCompare(nameOf(b)));
|
||||
|
||||
if (stored === undefined) return alphabetical(lanes);
|
||||
|
||||
const present = new Set(lanes);
|
||||
const placed: LaneId[] = [];
|
||||
const seen = new Set<LaneId>();
|
||||
for (const id of stored) {
|
||||
// A stored id naming a lane that is not here is skipped rather than
|
||||
// rendered as a gap — and left in `stored`, so a source that goes away and
|
||||
// comes back returns to the slot the reader chose. Nothing prunes a stored
|
||||
// order against the feed, for the reason nothing else in the client prunes
|
||||
// against it either (AGENTS.md § Retiring a game).
|
||||
if (!present.has(id) || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
placed.push(id);
|
||||
}
|
||||
|
||||
// A game we added later is not entitled to a position in an order the reader
|
||||
// made by hand, so it trails what they placed rather than slotting into the
|
||||
// middle of it. It also arrives switched off (`adoptNewLanes`), so settings —
|
||||
// where they would move it anyway — is where they meet it.
|
||||
return [...placed, ...alphabetical(lanes.filter((id) => !seen.has(id)))];
|
||||
}
|
||||
|
||||
/**
|
||||
* Move one entry, shifting the rest.
|
||||
*
|
||||
* Applied to the list **as displayed**, and the whole result is what gets
|
||||
* stored. Both halves of that matter. The indices a drag or an arrow produces
|
||||
* are positions on screen, so applying them to a stored order that names only
|
||||
* some of the lanes would move the wrong game; and storing only the game that
|
||||
* moved would leave `orderGames` reading "that one, then everything else
|
||||
* alphabetically", which is not what dragging one row one notch means. Writing
|
||||
* back what the reader is looking at gets both right at once.
|
||||
*
|
||||
* An index off either end is a no-op rather than an error, which is what makes
|
||||
* the first row's ↑ and the last row's ↓ harmless to press — they are rendered
|
||||
* either way, because a control that vanishes at the ends moves the other one
|
||||
* under the reader's finger.
|
||||
*/
|
||||
export function moveGame(
|
||||
order: readonly LaneId[],
|
||||
from: number,
|
||||
to: number,
|
||||
): LaneId[] {
|
||||
const next = [...order];
|
||||
if (from < 0 || from >= next.length || to < 0 || to >= next.length) return next;
|
||||
const [moved] = next.splice(from, 1);
|
||||
if (moved === undefined) return [...order];
|
||||
next.splice(to, 0, moved);
|
||||
return next;
|
||||
}
|
||||
@@ -47,6 +47,28 @@ export interface Prefs {
|
||||
* them in.
|
||||
*/
|
||||
knownGames?: LaneId[];
|
||||
/**
|
||||
* The order the reader put their games in.
|
||||
*
|
||||
* Absent means they have never placed one — not that they have no order, and
|
||||
* not an empty order. Every install predating this is in that state, so the
|
||||
* fallback has to be a rule rather than a stored value: `orderGames` sorts
|
||||
* alphabetically by name, which is what the first-run picker already does.
|
||||
* A game added later trails everything they placed by hand.
|
||||
*
|
||||
* `| undefined` is explicit because `exactOptionalPropertyTypes` is on and
|
||||
* "Reset to A–Z" is a patch that writes the field away — `{ gameOrder:
|
||||
* undefined }` has to be assignable for the reset to typecheck, and
|
||||
* `JSON.stringify` drops it on the way to storage.
|
||||
*
|
||||
* Not a key space and not a migration: one more field in the single `prefs`
|
||||
* blob, like `timelineGroup`. It rides the export for free, though note that
|
||||
* `importProgress` restores progress, dailies, ignores and the reader's own
|
||||
* games — never prefs — so an imported file does not bring an order back.
|
||||
* That asymmetry predates this field and applies to region, theme and view
|
||||
* alike.
|
||||
*/
|
||||
gameOrder?: LaneId[] | undefined;
|
||||
/**
|
||||
* One game to look at right now, or null for all of them.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { moveGame, orderGames } from "../src/client/state/gameOrder.ts";
|
||||
import { metaFor } from "../src/shared/games.ts";
|
||||
import type { LaneId } from "../src/shared/custom.ts";
|
||||
|
||||
/**
|
||||
* The reader's game order.
|
||||
*
|
||||
* The property that matters most here is not the order itself but that this
|
||||
* can never lose a lane: a game missing from the focus bar or from settings
|
||||
* looks exactly like a game that has been switched off, and the reader's fix
|
||||
* for that — go and switch it back on — does nothing.
|
||||
*/
|
||||
|
||||
const nameOf = (id: LaneId) => metaFor(id, {}).name;
|
||||
|
||||
/** Deliberately neither alphabetical by name nor sorted by id. */
|
||||
const LANES: LaneId[] = ["zzz", "genshin", "holodori", "hsr", "nikke", "arknights"];
|
||||
|
||||
const BY_NAME = [
|
||||
"arknights", // Arknights
|
||||
"genshin", // Genshin Impact
|
||||
"nikke", // Goddess of Victory: Nikke
|
||||
"holodori", // hololive Dreams
|
||||
"hsr", // Honkai: Star Rail
|
||||
"zzz", // Zenless Zone Zero
|
||||
];
|
||||
|
||||
describe("orderGames", () => {
|
||||
test("no stored order: alphabetical by the name the reader sees", () => {
|
||||
// Not by LaneId — `hsr` is Honkai: Star Rail, and `nikke` is Goddess of
|
||||
// Victory: Nikke, which sorts fifth by id and third by name.
|
||||
expect(orderGames(LANES, undefined, nameOf)).toEqual(BY_NAME);
|
||||
});
|
||||
|
||||
test("no stored order: a lowercase name sorts by letter, not by code point", () => {
|
||||
// hololive Dreams is the one lowercase name in `games.ts`. A `<` comparison
|
||||
// files it after every capitalised game instead of between Goddess and
|
||||
// Honkai.
|
||||
const order = orderGames(["hsr", "holodori", "nikke"], undefined, nameOf);
|
||||
expect(order).toEqual(["nikke", "holodori", "hsr"]);
|
||||
});
|
||||
|
||||
test("a stored order is obeyed exactly", () => {
|
||||
const stored = ["zzz", "hsr", "genshin"];
|
||||
expect(orderGames(["genshin", "hsr", "zzz"], stored, nameOf)).toEqual(stored);
|
||||
});
|
||||
|
||||
test("lanes the reader never placed come after the ones they did, alphabetically", () => {
|
||||
// The reader ordered two games; four more exist. Their two stay put and the
|
||||
// rest arrive in name order behind them — a game we added is not entitled
|
||||
// to a position in an order the reader made.
|
||||
const order = orderGames(LANES, ["zzz", "hsr"], nameOf);
|
||||
expect(order).toEqual(["zzz", "hsr", "arknights", "genshin", "nikke", "holodori"]);
|
||||
});
|
||||
|
||||
test("a game that has left the feed is skipped, not rendered as a gap", () => {
|
||||
const order = orderGames(["genshin", "hsr"], ["zzz", "hsr", "genshin"], nameOf);
|
||||
expect(order).toEqual(["hsr", "genshin"]);
|
||||
});
|
||||
|
||||
test("a retired game keeps its slot for when it comes back", () => {
|
||||
// We filter on output rather than pruning the stored list, so a source that
|
||||
// goes away and returns does not cost the reader the position they chose.
|
||||
const stored = ["zzz", "hsr", "genshin"];
|
||||
const gone = orderGames(["hsr", "genshin"], stored, nameOf);
|
||||
expect(gone).toEqual(["hsr", "genshin"]);
|
||||
expect(orderGames(["genshin", "hsr", "zzz"], stored, nameOf)).toEqual(stored);
|
||||
});
|
||||
|
||||
test("the reader's own lanes sort with everything else", () => {
|
||||
const meta = (id: LaneId) =>
|
||||
metaFor(id, {
|
||||
"mygame:aether": {
|
||||
id: "mygame:aether",
|
||||
name: "Aether Gazer",
|
||||
hue: "#888888",
|
||||
at: "2026-08-01T00:00:00.000Z",
|
||||
},
|
||||
}).name;
|
||||
expect(orderGames(["hsr", "mygame:aether"], undefined, meta)).toEqual([
|
||||
"mygame:aether",
|
||||
"hsr",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a lane with no metadata still sorts instead of throwing", () => {
|
||||
// `metaFor` is total and answers "Unknown game" for a lane it does not
|
||||
// know, which is what an import carrying an event whose game did not come
|
||||
// with it looks like.
|
||||
const order = orderGames(["zzz", "mygame:ghost"], undefined, nameOf);
|
||||
expect(order).toHaveLength(2);
|
||||
expect(new Set(order)).toEqual(new Set(["zzz", "mygame:ghost"]));
|
||||
});
|
||||
|
||||
test("always a permutation of the lanes it was given", () => {
|
||||
// The safety property: a game dropped here is indistinguishable, on screen,
|
||||
// from a game the reader switched off — and switching it on would not bring
|
||||
// it back.
|
||||
const cases: Array<LaneId[] | undefined> = [
|
||||
undefined,
|
||||
[],
|
||||
["zzz"],
|
||||
["nope", "zzz", "hsr"],
|
||||
[...LANES].reverse(),
|
||||
["zzz", "zzz", "hsr"],
|
||||
];
|
||||
for (const stored of cases) {
|
||||
const order = orderGames(LANES, stored, nameOf);
|
||||
expect([...order].sort()).toEqual([...LANES].sort());
|
||||
}
|
||||
});
|
||||
|
||||
test("an empty lane list is not an error", () => {
|
||||
expect(orderGames([], ["zzz"], nameOf)).toEqual([]);
|
||||
expect(orderGames([], undefined, nameOf)).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not mutate what it is given", () => {
|
||||
const lanes = [...LANES];
|
||||
const stored = ["zzz", "hsr"];
|
||||
orderGames(lanes, stored, nameOf);
|
||||
expect(lanes).toEqual(LANES);
|
||||
expect(stored).toEqual(["zzz", "hsr"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveGame", () => {
|
||||
test("a move records the whole displayed list, so a partial stored order cannot mis-map", () => {
|
||||
// The indices come from what is on screen. Applied to the displayed list,
|
||||
// the result names every lane — which is what gets stored, so the next read
|
||||
// needs no fallback for the games the reader never touched.
|
||||
const displayed = ["a", "b", "c", "d"];
|
||||
expect(moveGame(displayed, 3, 0)).toEqual(["d", "a", "b", "c"]);
|
||||
expect(moveGame(displayed, 3, 0)).toHaveLength(displayed.length);
|
||||
});
|
||||
|
||||
test("moves one game and shifts the rest", () => {
|
||||
expect(moveGame(["a", "b", "c", "d"], 2, 0)).toEqual(["c", "a", "b", "d"]);
|
||||
expect(moveGame(["a", "b", "c", "d"], 0, 3)).toEqual(["b", "c", "d", "a"]);
|
||||
});
|
||||
|
||||
test("a one-step move is the arrow buttons", () => {
|
||||
expect(moveGame(["a", "b", "c"], 1, 0)).toEqual(["b", "a", "c"]);
|
||||
expect(moveGame(["a", "b", "c"], 1, 2)).toEqual(["a", "c", "b"]);
|
||||
});
|
||||
|
||||
test("off either end is a no-op, so the first row's up arrow is harmless", () => {
|
||||
const order = ["a", "b", "c"];
|
||||
expect(moveGame(order, 0, -1)).toEqual(order);
|
||||
expect(moveGame(order, 2, 3)).toEqual(order);
|
||||
expect(moveGame(order, -1, 1)).toEqual(order);
|
||||
expect(moveGame(order, 9, 1)).toEqual(order);
|
||||
});
|
||||
|
||||
test("moving a game onto itself changes nothing", () => {
|
||||
expect(moveGame(["a", "b", "c"], 1, 1)).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
|
||||
test("never loses or duplicates a game", () => {
|
||||
const order = ["a", "b", "c", "d", "e"];
|
||||
for (let from = 0; from < order.length; from += 1) {
|
||||
for (let to = 0; to < order.length; to += 1) {
|
||||
expect([...moveGame(order, from, to)].sort()).toEqual([...order].sort());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("does not mutate what it is given", () => {
|
||||
const order = ["a", "b", "c"];
|
||||
moveGame(order, 0, 2);
|
||||
expect(order).toEqual(["a", "b", "c"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user