The "Running now" header had a hint reading "next after this ends in …", and the commit before this one fixed a real defect in the string it produced — an unannounced end became the word "ended". Both were beside the point: the branch cannot render at all. `Section` shows `action ?? hint`. The hint needed a second live row to have anything to say, and two live rows are two visible rows, which is exactly the condition that puts the sort control in the same slot. So the action was present whenever the hint was, and won every time. What it would have said is already on the page. The headline panel's "Then" list names the deadlines behind the closest one and counts each of them down, which is the same answer with more room. So the hint slot goes, `followingDeadlineMs` goes with it as its only caller, and `Section` is left with the one control slot it actually uses. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
189 lines
6.1 KiB
TypeScript
189 lines
6.1 KiB
TypeScript
import { describe, expect, test } from "bun:test";
|
|
import {
|
|
advanceFocus,
|
|
countByGame,
|
|
firstToExpire,
|
|
nextToExpire,
|
|
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) => ({
|
|
event: { id, game },
|
|
clock: { msRemaining },
|
|
});
|
|
|
|
const none = () => false;
|
|
|
|
describe("outstanding", () => {
|
|
const rows = [
|
|
row("a", "genshin", 1000),
|
|
row("b", "hsr", 2000),
|
|
row("c", "zzz", 3000),
|
|
];
|
|
|
|
test("drops what the reader has finished", () => {
|
|
// The headline and the dailies strip both tell the reader what to do, and
|
|
// pointing at a job they already ticked off is the app arguing with them.
|
|
expect(outstanding(rows, (id) => id === "b", none).map((r) => r.event.id)).toEqual(
|
|
["a", "c"],
|
|
);
|
|
});
|
|
|
|
test("drops what they have ignored", () => {
|
|
expect(outstanding(rows, none, (id) => id === "a").map((r) => r.event.id)).toEqual(
|
|
["b", "c"],
|
|
);
|
|
});
|
|
|
|
test("done and ignored at once is still just gone", () => {
|
|
expect(outstanding(rows, (id) => id === "a", (id) => id === "a")).toHaveLength(2);
|
|
});
|
|
|
|
test("nothing outstanding is an empty list, not a null", () => {
|
|
expect(outstanding(rows, () => true, none)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("firstToExpire", () => {
|
|
test("takes the soonest, not the first row", () => {
|
|
// The list arrives sorted by whatever mode the reader chose. Under "doing
|
|
// first" its head is what they are partway through, which is not what a
|
|
// panel headed "next to expire" claims to be showing.
|
|
const rows = [
|
|
row("mid-run", "genshin", 9 * 86_400_000),
|
|
row("tonight", "hsr", 3 * 3_600_000),
|
|
];
|
|
expect(firstToExpire(rows)?.event.id).toBe("tonight");
|
|
});
|
|
|
|
test("an unannounced end is never the deadline while a real one exists", () => {
|
|
const rows = [row("unknown", "zzz", null), row("real", "wuwa", 5000)];
|
|
expect(firstToExpire(rows)?.event.id).toBe("real");
|
|
});
|
|
|
|
test("falls back to an unknown end when it is all there is", () => {
|
|
// It is still a live event and still worth showing; it is just not a
|
|
// countdown. Showing nothing would be worse.
|
|
expect(firstToExpire([row("unknown", "zzz", null)])?.event.id).toBe("unknown");
|
|
});
|
|
|
|
test("no rows is null rather than a crash", () => {
|
|
expect(firstToExpire([])).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("nextToExpire", () => {
|
|
test("orders by deadline, whatever order it was handed", () => {
|
|
// Same argument as firstToExpire, three rows deep: the reader asked for the
|
|
// three closest deadlines, not the first three rows of a list they had
|
|
// sorted by what they are partway through.
|
|
const rows = [
|
|
row("mid-run", "genshin", 9 * 86_400_000),
|
|
row("tomorrow", "hsr", 30 * 3_600_000),
|
|
row("tonight", "zzz", 3 * 3_600_000),
|
|
];
|
|
expect(nextToExpire(rows, 3).map((r) => r.event.id)).toEqual([
|
|
"tonight",
|
|
"tomorrow",
|
|
"mid-run",
|
|
]);
|
|
});
|
|
|
|
test("takes only as many as asked for", () => {
|
|
const rows = [
|
|
row("a", "genshin", 1000),
|
|
row("b", "hsr", 2000),
|
|
row("c", "zzz", 3000),
|
|
];
|
|
expect(nextToExpire(rows, 2).map((r) => r.event.id)).toEqual(["a", "b"]);
|
|
});
|
|
|
|
test("asking for more than there is returns what there is", () => {
|
|
expect(nextToExpire([row("a", "genshin", 1000)], 3)).toHaveLength(1);
|
|
expect(nextToExpire([], 3)).toEqual([]);
|
|
});
|
|
|
|
test("unannounced ends sort behind every real deadline", () => {
|
|
// A panel of deadlines that leads with "unknown" is not a panel of
|
|
// deadlines. They still appear once the dated ones run out, because the
|
|
// event is real — it just is not a countdown.
|
|
const rows = [
|
|
row("unknown", "zzz", null),
|
|
row("late", "wuwa", 90 * 86_400_000),
|
|
];
|
|
expect(nextToExpire(rows, 2).map((r) => r.event.id)).toEqual([
|
|
"late",
|
|
"unknown",
|
|
]);
|
|
});
|
|
|
|
test("leaves the list it was given alone", () => {
|
|
// It is handed the same array the list on screen is rendering from, and
|
|
// sorting that in place would reorder the reader's list from under them.
|
|
const rows = [row("b", "hsr", 2000), row("a", "genshin", 1000)];
|
|
nextToExpire(rows, 2);
|
|
expect(rows.map((r) => r.event.id)).toEqual(["b", "a"]);
|
|
});
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|