Skip to content

Commit 163dcde

Browse files
authored
Merge pull request #4113 from Spinnich/fix/gallery-random-stale-platform
fix(v2): drop a random pick after leaving the platform
2 parents 70f47fa + ccf3582 commit 163dcde

4 files changed

Lines changed: 151 additions & 13 deletions

File tree

frontend/src/v2/views/Gallery/Collection.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,37 @@ describe("Collection view random rom", () => {
237237
expect(push).not.toHaveBeenCalled();
238238
});
239239

240+
it("stays quiet when a pick fails after the view moved to another collection", async () => {
241+
let failPick: (reason: Error) => void = () => {};
242+
getRandomRom.mockReturnValueOnce(
243+
new Promise((_resolve, reject) => {
244+
failPick = reject;
245+
}),
246+
);
247+
248+
const wrapper = await mountView();
249+
await wrapper.get("button.random").trigger("click");
250+
251+
routeState.params = { collection: "2" };
252+
routeGuards.forEach((guard) =>
253+
guard({ name: "collection", params: { collection: "2" } }),
254+
);
255+
await flushPromises();
256+
257+
failPick(new Error("boom"));
258+
await flushPromises();
259+
260+
expect(snackbarError).not.toHaveBeenCalled();
261+
expect(push).not.toHaveBeenCalled();
262+
263+
// The button still works on the collection the user landed on.
264+
getRandomRom.mockResolvedValue({ data: rom(7) });
265+
await wrapper.get("button.random").trigger("click");
266+
await flushPromises();
267+
268+
expect(push).toHaveBeenCalledWith({ name: "rom", params: { rom: 7 } });
269+
});
270+
240271
it("ignores a click while a pick is in flight", async () => {
241272
let resolvePick: (value: { data: SimpleRom }) => void = () => {};
242273
getRandomRom.mockReturnValue(

frontend/src/v2/views/Gallery/Collection.vue

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -288,17 +288,18 @@ async function onRandomGame() {
288288
if (!c || randomLoading.value) return;
289289
randomLoading.value = true;
290290
const scopeId = c.id;
291+
// A pick from the collection the user just left leads nowhere useful.
292+
const stale = () => currentCollection.value?.id !== scopeId;
291293
try {
292294
const { data } = await romApi.getRandomRom(randomScope());
293-
// A pick from the collection the user just left leads nowhere useful.
294-
if (currentCollection.value?.id !== scopeId) return;
295+
if (stale()) return;
295296
if (!data) {
296297
snackbar.info(t("collection.empty"));
297298
return;
298299
}
299300
router.push({ name: ROUTES.ROM, params: { rom: data.id } });
300301
} catch {
301-
snackbar.error(t("platform.random-rom-error"));
302+
if (!stale()) snackbar.error(t("platform.random-rom-error"));
302303
} finally {
303304
randomLoading.value = false;
304305
}

frontend/src/v2/views/Gallery/Platform.test.ts

Lines changed: 108 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,18 @@ const {
1313
getRoms,
1414
getPlatform,
1515
push,
16+
routeGuards,
1617
snackbarError,
1718
snackbarInfo,
1819
} = vi.hoisted(() => ({
1920
getRandomRom: vi.fn(),
2021
getRoms: vi.fn(),
2122
getPlatform: vi.fn(),
2223
push: vi.fn(),
24+
routeGuards: [] as ((to: {
25+
name: string;
26+
params: Record<string, string>;
27+
}) => unknown)[],
2328
snackbarError: vi.fn(),
2429
snackbarInfo: vi.fn(),
2530
}));
@@ -41,7 +46,9 @@ vi.mock("vue-router", async (importOriginal) => ({
4146
...(await importOriginal<typeof import("vue-router")>()),
4247
useRoute: () => routeState,
4348
useRouter: () => ({ push, replace: vi.fn() }),
44-
onBeforeRouteUpdate: vi.fn(),
49+
// Captured rather than dropped: calling the guard is how a test moves
50+
// the view to another platform, which is what the stale check watches.
51+
onBeforeRouteUpdate: vi.fn((guard) => routeGuards.push(guard)),
4552
}));
4653

4754
vi.mock("@/plugins/router", () => ({
@@ -104,13 +111,13 @@ vi.mock("@/v2/composables/useSnackbar", () => ({
104111
useSnackbar: () => ({ error: snackbarError, info: snackbarInfo }),
105112
}));
106113

107-
function platform(id: number): Platform {
114+
function platform(id: number, name = "Super Nintendo"): Platform {
108115
return {
109116
id,
110-
name: "Super Nintendo",
111-
display_name: "Super Nintendo",
112-
slug: "snes",
113-
fs_slug: "snes",
117+
name,
118+
display_name: name,
119+
slug: `platform-${id}`,
120+
fs_slug: `platform-${id}`,
114121
rom_count: 83000,
115122
} as Platform;
116123
}
@@ -119,9 +126,41 @@ function rom(id: number): SimpleRom {
119126
return { id, name: "Chrono Trigger" } as SimpleRom;
120127
}
121128

129+
/** Resolves the promise the next `getRandomRom` call returns, on demand. */
130+
function deferRandomRom() {
131+
let settle: (value: { data: SimpleRom | null }) => void = () => {};
132+
getRandomRom.mockReturnValueOnce(
133+
new Promise((resolve) => {
134+
settle = resolve;
135+
}),
136+
);
137+
return (pick: SimpleRom | null) => settle({ data: pick });
138+
}
139+
140+
/** Rejects the promise the next `getRandomRom` call returns, on demand. */
141+
function deferRandomRomFailure() {
142+
let fail: (reason: Error) => void = () => {};
143+
getRandomRom.mockReturnValueOnce(
144+
new Promise((_resolve, reject) => {
145+
fail = reject;
146+
}),
147+
);
148+
return () => fail(new Error("boom"));
149+
}
150+
151+
/** Moves the view to another platform the way the router would. */
152+
async function navigateTo(platformId: number) {
153+
routeState.params = { platform: String(platformId) };
154+
routeState.path = `/platform/${platformId}`;
155+
routeGuards.forEach((guard) =>
156+
guard({ name: "platform", params: { platform: String(platformId) } }),
157+
);
158+
await flushPromises();
159+
}
160+
122161
async function mountView() {
123162
const platforms = storePlatforms();
124-
platforms.set([platform(1)]);
163+
platforms.set([platform(1), platform(2, "Mega Drive")]);
125164
const galleryRoms = storeGalleryRoms();
126165
vi.spyOn(galleryRoms, "fetchInitialMetadata").mockResolvedValue();
127166

@@ -136,7 +175,13 @@ describe("Platform view random rom", () => {
136175
beforeEach(() => {
137176
setActivePinia(createPinia());
138177
vi.clearAllMocks();
139-
getPlatform.mockResolvedValue({ data: platform(1) });
178+
routeState.name = "platform";
179+
routeState.path = "/platform/1";
180+
routeState.params = { platform: "1" };
181+
routeGuards.length = 0;
182+
getPlatform.mockImplementation((id: number) =>
183+
Promise.resolve({ data: platform(id) }),
184+
);
140185
getRoms.mockResolvedValue({ data: { items: [], total: 0 } });
141186
});
142187

@@ -178,6 +223,61 @@ describe("Platform view random rom", () => {
178223
expect(push).not.toHaveBeenCalled();
179224
});
180225

226+
// Issue #4104: the pick is scoped to the platform that was on screen when
227+
// the button was clicked, so following it after the user moved on drops
228+
// them into a game from a gallery they already left.
229+
it("drops a pick that lands after the view moved to another platform", async () => {
230+
const resolvePick = deferRandomRom();
231+
232+
const wrapper = await mountView();
233+
await wrapper.get("button.random").trigger("click");
234+
235+
await navigateTo(2);
236+
237+
resolvePick(rom(42));
238+
await flushPromises();
239+
240+
expect(push).not.toHaveBeenCalled();
241+
});
242+
243+
it("stays quiet when a pick fails after the view moved to another platform", async () => {
244+
const failPick = deferRandomRomFailure();
245+
246+
const wrapper = await mountView();
247+
await wrapper.get("button.random").trigger("click");
248+
249+
await navigateTo(2);
250+
251+
failPick();
252+
await flushPromises();
253+
254+
expect(snackbarError).not.toHaveBeenCalled();
255+
expect(push).not.toHaveBeenCalled();
256+
257+
// The button still works on the platform the user landed on.
258+
getRandomRom.mockResolvedValue({ data: rom(7) });
259+
await wrapper.get("button.random").trigger("click");
260+
await flushPromises();
261+
262+
expect(push).toHaveBeenCalledWith({ name: "rom", params: { rom: 7 } });
263+
});
264+
265+
// A same-platform reload swaps in a fresh `Platform` record, so the check
266+
// has to compare ids rather than object identity.
267+
it("still navigates when the view reloaded the same platform", async () => {
268+
const resolvePick = deferRandomRom();
269+
270+
const wrapper = await mountView();
271+
await wrapper.get("button.random").trigger("click");
272+
273+
await navigateTo(1);
274+
275+
resolvePick(rom(42));
276+
await flushPromises();
277+
278+
expect(push).toHaveBeenCalledWith({ name: "rom", params: { rom: 42 } });
279+
});
280+
181281
it("ignores a click while a pick is in flight", async () => {
182282
let resolvePick: (value: { data: SimpleRom }) => void = () => {};
183283
getRandomRom.mockReturnValue(

frontend/src/v2/views/Gallery/Platform.vue

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,15 +333,21 @@ async function onRandomGame() {
333333
const p = currentPlatform.value;
334334
if (!p || randomLoading.value) return;
335335
randomLoading.value = true;
336+
const scopeId = p.id;
337+
// The pick belongs to the platform that was on screen when the button was
338+
// clicked; following it after the user moved on would drop them into a
339+
// game from a gallery they already left.
340+
const stale = () => currentPlatform.value?.id !== scopeId;
336341
try {
337-
const { data } = await romApi.getRandomRom({ platformIds: [p.id] });
342+
const { data } = await romApi.getRandomRom({ platformIds: [scopeId] });
343+
if (stale()) return;
338344
if (!data) {
339345
snackbar.info(t("platform.random-rom-empty"));
340346
return;
341347
}
342348
router.push({ name: ROUTES.ROM, params: { rom: data.id } });
343349
} catch {
344-
snackbar.error(t("platform.random-rom-error"));
350+
if (!stale()) snackbar.error(t("platform.random-rom-error"));
345351
} finally {
346352
randomLoading.value = false;
347353
}

0 commit comments

Comments
 (0)