refactor(v2): extract gallery cover-ratio measurement into a composable

Move the measured-ratio map, debounced ratioVersion, onCardRatio handler,
and ratioAt resolver out of GalleryShell into useGalleryCoverRatios. The
composable owns its debounce-timer cleanup (onBeforeUnmount), so the shell
no longer tracks ratioBumpTimer. Behaviour is unchanged; adds a unit test
for the dedup / debounce / position→rom→ratio mapping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Georges-Antoine Assi
2026-06-21 19:10:36 -04:00
parent d5f30d1fab
commit d1a9dbb4fb
3 changed files with 137 additions and 24 deletions

View File

@@ -61,6 +61,7 @@ import { type ListSortKey } from "@/v2/components/Gallery/listColumns";
import { GameCard, GameCardSkeleton } from "@/v2/components/GameCard";
import { useBreakpoint } from "@/v2/composables/useBreakpoint";
import { coverRatio, isBoxartStyle } from "@/v2/composables/useCoverArt";
import { useGalleryCoverRatios } from "@/v2/composables/useGalleryCoverRatios";
import { useGalleryFilterUrl } from "@/v2/composables/useGalleryFilterUrl";
import { useGalleryMode } from "@/v2/composables/useGalleryMode";
import { useGalleryViewModeUrl } from "@/v2/composables/useGalleryViewModeUrl";
@@ -262,29 +263,10 @@ const coverAspectRatio = computed(() =>
),
);
// Measured natural cover ratios, keyed by rom id (the key survives gallery
// switches, so a re-visited platform re-packs without re-waiting on images).
// Updates batch behind `ratioVersion` → one re-pack per burst, not per cover.
// Intentionally unbounded: two numbers per rom, reset on page reload.
const ratioByRomId = new Map<number, number>();
const ratioVersion = ref(0);
let ratioBumpTimer: ReturnType<typeof setTimeout> | null = null;
function onCardRatio(payload: { romId: number; ratio: number }) {
const prev = ratioByRomId.get(payload.romId);
// Ignore no-op / sub-pixel changes so we don't re-pack for nothing.
if (prev != null && Math.abs(prev - payload.ratio) < 0.01) return;
ratioByRomId.set(payload.romId, payload.ratio);
if (ratioBumpTimer) return;
ratioBumpTimer = setTimeout(() => {
ratioBumpTimer = null;
ratioVersion.value++;
}, 150);
}
function ratioAt(position: number): number {
const romId = galleryRoms.romIdIndex[position];
if (romId == null) return 0; // → packer falls back to the default ratio
return ratioByRomId.get(romId) ?? 0;
}
// Measured natural cover ratios feeding the flow-packer — GameCard reports
// each cover's ratio on load (`onCardRatio`), the packer reads `ratioAt`,
// and `ratioVersion` bumps (debounced) to trigger a single re-pack.
const { ratioVersion, ratioAt, onCardRatio } = useGalleryCoverRatios();
// 2D arrow / gamepad nav for both layouts of the gallery. Two passes:
// * Grid mode — rows are `.r-v2-shell__row` (the per-virtualizer-item
@@ -671,7 +653,6 @@ onBeforeUnmount(() => {
gallerySelection.clear();
if (searchDebounce) clearTimeout(searchDebounce);
if (fetchDebounceTimer) clearTimeout(fetchDebounceTimer);
if (ratioBumpTimer) clearTimeout(ratioBumpTimer);
// Cancel any per-position fetches still in flight for our last
// visible set — `invalidateWindows` / `resetGallery` already aborts
// window-level fetches; this covers per-card cleanup on unmount.

View File

@@ -0,0 +1,77 @@
import { mount } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent } from "vue";
import storeGalleryRoms from "@/v2/stores/galleryRoms";
import { useGalleryCoverRatios } from "./index";
// Run the composable inside a real component so its `onBeforeUnmount`
// cleanup has a host instance. Returns the composable's result.
function withComposable<T>(fn: () => T): T {
let result!: T;
mount(
defineComponent({
setup() {
result = fn();
return () => null;
},
}),
);
return result;
}
describe("useGalleryCoverRatios", () => {
beforeEach(() => {
setActivePinia(createPinia());
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("ratioAt maps position → rom id → measured ratio, else 0", () => {
storeGalleryRoms().romIdIndex = [101, 102, 103];
const { ratioAt, onCardRatio } = withComposable(() =>
useGalleryCoverRatios(),
);
expect(ratioAt(1)).toBe(0); // not yet measured
expect(ratioAt(99)).toBe(0); // position outside the index
onCardRatio({ romId: 102, ratio: 1.2 });
expect(ratioAt(1)).toBeCloseTo(1.2); // position 1 → rom 102
expect(ratioAt(0)).toBe(0); // rom 101 still unmeasured
});
it("bumps ratioVersion once per debounce burst", () => {
storeGalleryRoms().romIdIndex = [101, 102];
const { ratioVersion, onCardRatio } = withComposable(() =>
useGalleryCoverRatios(),
);
expect(ratioVersion.value).toBe(0);
onCardRatio({ romId: 101, ratio: 0.7 });
onCardRatio({ romId: 102, ratio: 0.5 });
expect(ratioVersion.value).toBe(0); // still debouncing
vi.advanceTimersByTime(150);
expect(ratioVersion.value).toBe(1); // one bump for the whole burst
});
it("ignores sub-epsilon changes (no re-store, no extra re-pack)", () => {
storeGalleryRoms().romIdIndex = [101];
const { ratioVersion, ratioAt, onCardRatio } = withComposable(() =>
useGalleryCoverRatios(),
);
onCardRatio({ romId: 101, ratio: 0.7 });
vi.advanceTimersByTime(150);
expect(ratioVersion.value).toBe(1);
expect(ratioAt(0)).toBeCloseTo(0.7);
onCardRatio({ romId: 101, ratio: 0.705 }); // delta 0.005 < 0.01
vi.advanceTimersByTime(150);
expect(ratioVersion.value).toBe(1); // no new bump
expect(ratioAt(0)).toBeCloseTo(0.7); // value unchanged
});
});

View File

@@ -0,0 +1,55 @@
// useGalleryCoverRatios — the gallery's measured natural cover ratios.
//
// Covers render at their image's natural aspect, so the flow-packer needs
// each card's ratio (width = cardHeight * ratio) to decide where rows break.
// The API doesn't ship cover dimensions, so the only source is the image
// itself: GameCard reports `@ratio` once its cover loads, and this collects
// the values for the packer to read via `ratioAt(position)`.
//
// Keyed by rom id (not position), so the key survives a gallery context
// switch — a re-visited platform re-packs without re-waiting on images.
// Intentionally unbounded: two numbers per rom, reset on page reload.
//
// Updates batch behind `ratioVersion`: a burst of image loads bumps it once
// (after a short debounce) so the packed layout recomputes a single time
// instead of once per cover.
import { onBeforeUnmount, ref } from "vue";
import storeGalleryRoms from "@/v2/stores/galleryRoms";
// Below this delta a new measurement isn't worth a re-pack (sub-pixel noise).
const RATIO_EPSILON = 0.01;
// Wait this long after the last new ratio before bumping `ratioVersion`.
const REPACK_DEBOUNCE_MS = 150;
export function useGalleryCoverRatios() {
const galleryRoms = storeGalleryRoms();
const ratioByRomId = new Map<number, number>();
const ratioVersion = ref(0);
let bumpTimer: ReturnType<typeof setTimeout> | null = null;
/** Record a cover's measured ratio (GameCard's `@ratio` handler). */
function onCardRatio(payload: { romId: number; ratio: number }) {
const prev = ratioByRomId.get(payload.romId);
if (prev != null && Math.abs(prev - payload.ratio) < RATIO_EPSILON) return;
ratioByRomId.set(payload.romId, payload.ratio);
if (bumpTimer) return;
bumpTimer = setTimeout(() => {
bumpTimer = null;
ratioVersion.value++;
}, REPACK_DEBOUNCE_MS);
}
/** Measured ratio for a position, or 0 when unknown (the packer then
* falls back to its default ratio). */
function ratioAt(position: number): number {
const romId = galleryRoms.romIdIndex[position];
if (romId == null) return 0;
return ratioByRomId.get(romId) ?? 0;
}
onBeforeUnmount(() => {
if (bumpTimer) clearTimeout(bumpTimer);
});
return { ratioVersion, ratioAt, onCardRatio };
}