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,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