Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion backend/endpoints/heartbeat.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from endpoints.responses.heartbeat import HeartbeatResponse
from exceptions.fs_exceptions import PlatformAlreadyExistsException
from handler.auth.constants import Scope
from handler.database import db_user_handler
from handler.database import db_stats_handler, db_user_handler
from handler.filesystem import fs_platform_handler
from handler.filesystem.base_handler import LibraryStructure
from handler.metadata import (
Expand Down Expand Up @@ -214,6 +214,17 @@ async def get_setup_library_info(request: Request):

detected_structure = fs_platform_handler.detect_library_structure()

# The per-platform rom counts below are a first-run hint, so a fresh
# instance can show what RomM already sees on disk. Once the database
# holds ROMs that hint is dead weight, and building it walks every
# platform directory: tens of seconds on a large library.
if db_stats_handler.get_roms_count() > 0:
return {
"detected_structure": detected_structure,
"existing_platforms": [],
"supported_platforms": get_supported_platforms(),
}

# Get existing platforms from filesystem
try:
existing_platform_slugs = await fs_platform_handler.get_platforms()
Expand Down
66 changes: 66 additions & 0 deletions backend/tests/endpoints/test_heartbeat.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,72 @@ def test_get_setup_library_info_handles_errors(client, admin_user, access_token)
assert data["existing_platforms"] == []


def test_get_setup_library_info_skips_filesystem_walk_when_roms_exist(
client, rom, access_token
):
"""A library with scanned ROMs never needs the on-disk hint, so skip the walk."""
with (
patch(
"endpoints.heartbeat.fs_platform_handler.detect_library_structure"
) as mock_detect,
patch(
"endpoints.heartbeat.fs_platform_handler.get_platforms"
) as mock_get_platforms,
):
mock_detect.return_value = "struct_a"
mock_get_platforms.return_value = ["n64"]

response = client.get(
"/api/setup/library",
headers={"Authorization": f"Bearer {access_token}"},
)

assert response.status_code == status.HTTP_200_OK
data = response.json()

assert data["detected_structure"] == "struct_a"
assert data["existing_platforms"] == []
assert len(data["supported_platforms"]) > 0
mock_get_platforms.assert_not_called()


def test_get_setup_library_info_walks_when_platforms_have_no_roms(
client, platform, access_token
):
"""Platform rows without ROMs still need the hint: that is the case it exists for."""
with (
patch(
"endpoints.heartbeat.fs_platform_handler.detect_library_structure"
) as mock_detect,
patch(
"endpoints.heartbeat.fs_platform_handler.get_platforms"
) as mock_get_platforms,
patch("endpoints.heartbeat.AnyioPath") as mock_anyio_path,
):
mock_detect.return_value = "struct_a"
mock_get_platforms.return_value = ["n64"]

async def mock_iterdir():
entry = MagicMock()
entry.name = "game1.z64"
yield entry

mock_path = AsyncMock()
mock_path.exists = AsyncMock(return_value=True)
mock_path.iterdir = mock_iterdir
mock_anyio_path.return_value = mock_path

response = client.get(
"/api/setup/library",
headers={"Authorization": f"Bearer {access_token}"},
)

assert response.status_code == status.HTTP_200_OK
data = response.json()

assert data["existing_platforms"] == [{"fs_slug": "n64", "rom_count": 1}]


def test_create_setup_platforms_success(client, admin_user, access_token):
"""Test create_setup_platforms successfully creates platforms"""
platform_slugs = ["n64", "psx", "gba"]
Expand Down
223 changes: 223 additions & 0 deletions frontend/src/v2/views/Home.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/* eslint-disable vue/one-component-per-file */
import { flushPromises, mount } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { defineComponent, ref } from "vue";
import storeCollections, { type Collection } from "@/stores/collections";
import storePlatforms, { type Platform } from "@/stores/platforms";
import storeRoms, { type SimpleRom } from "@/stores/roms";
import Home from "./Home.vue";

vi.mock("vue-i18n", () => ({
useI18n: () => ({ t: (key: string) => key }),
}));

const { getLibraryInfo } = vi.hoisted(() => ({
getLibraryInfo: vi.fn(),
}));

vi.mock("@/services/api/setup", () => ({
default: { getLibraryInfo },
}));

vi.mock("@v2/lib", () => ({
RChip: defineComponent({ template: "<span><slot /></span>" }),
RDivider: defineComponent({ template: "<hr />" }),
RIcon: defineComponent({ template: "<i />" }),
RSkeletonBlock: defineComponent({ template: "<div />" }),
}));

vi.mock("@/v2/components/Collections/CollectionTile.vue", () => ({
default: defineComponent({ template: "<div />" }),
}));

vi.mock("@/v2/components/GameCard", () => ({
GameCard: defineComponent({ template: "<div />" }),
GameCardSkeleton: defineComponent({ template: "<div />" }),
}));

vi.mock("@/v2/components/Home/CardRow.vue", () => ({
default: defineComponent({ template: "<section><slot /></section>" }),
}));

vi.mock("@/v2/components/Home/Widgets/WidgetBar.vue", () => ({
default: defineComponent({ template: "<div />" }),
}));

vi.mock("@/v2/components/Platforms/PlatformTile.vue", () => ({
default: defineComponent({ template: "<div />" }),
}));

vi.mock("@/v2/composables/useGridNav", () => ({
useGridNav: vi.fn(),
}));

vi.mock("@/v2/composables/useWebpSupport", () => ({
useWebpSupport: () => ({
supportsWebp: ref(false),
toWebp: (url: string) => url,
}),
}));

vi.mock("@/composables/useUISettings", () => ({
useUISettings: () => ({
showHomeWidgets: ref(true),
showRecentRoms: ref(true),
showContinuePlaying: ref(true),
showPlatforms: ref(true),
showCollections: ref(true),
showSmartCollections: ref(false),
showVirtualCollections: ref(false),
virtualCollectionType: ref("collection"),
}),
}));

function platform(id: number): Platform {
return {
id,
display_name: `Platform ${id}`,
name: `Platform ${id}`,
slug: `platform-${id}`,
fs_slug: `platform-${id}`,
rom_count: 12,
} as Platform;
}

function collection(id: number): Collection {
return {
id,
name: `Collection ${id}`,
rom_count: 3,
rom_ids: [],
} as unknown as Collection;
}

function rom(id: number): SimpleRom {
return { id, name: `Rom ${id}` } as SimpleRom;
}

/**
* Resolve on a later microtask, the way a real request does. Anything
* asserting on the home page's initial-load ordering has to see the
* stores fill in after setup, not during it.
*/
async function afterRoundTrip<T>(populate: () => T): Promise<T> {
await Promise.resolve();
return populate();
}

/** Wire up every home page fetch; `populated` decides what comes back. */
function stubHomeFetches(populated: boolean) {
const platforms = storePlatforms();
const collections = storeCollections();
const roms = storeRoms();

vi.spyOn(platforms, "fetchPlatforms").mockImplementation(() => {
platforms.fetchingPlatforms = true;
return afterRoundTrip(() => {
const loaded = populated ? [platform(1)] : [];
platforms.set(loaded);
platforms.fetchingPlatforms = false;
return loaded;
});
});

vi.spyOn(collections, "fetchCollections").mockImplementation(() => {
collections.fetchingCollections = true;
return afterRoundTrip(() => {
const loaded = populated ? [collection(1)] : [];
collections.setCollections(loaded);
collections.fetchingCollections = false;
return loaded;
});
});

vi.spyOn(collections, "fetchSmartCollections").mockImplementation(() => {
collections.fetchingSmartCollections = true;
return afterRoundTrip(() => {
collections.fetchingSmartCollections = false;
return [];
});
});

vi.spyOn(collections, "fetchVirtualCollections").mockImplementation(() => {
collections.fetchingVirtualCollections = true;
return afterRoundTrip(() => {
collections.fetchingVirtualCollections = false;
return [];
});
});

vi.spyOn(roms, "fetchRecentRoms").mockImplementation(() =>
afterRoundTrip(() => {
const loaded = populated ? [rom(1)] : [];
roms.setRecentRoms(loaded);
return loaded;
}),
);

vi.spyOn(roms, "fetchContinuePlayingRoms").mockImplementation(() =>
afterRoundTrip(() => {
const loaded = populated ? [rom(2)] : [];
roms.setContinuePlayingRoms(loaded);
return loaded;
}),
);

return { platforms, collections, roms };
}

function mountHome() {
return mount(Home, {
global: {
stubs: { RouterLink: defineComponent({ template: "<a><slot /></a>" }) },
},
});
}

describe("Home", () => {
beforeEach(() => {
setActivePinia(createPinia());
getLibraryInfo.mockReset();
getLibraryInfo.mockResolvedValue({
data: { detected_structure: "struct_a", existing_platforms: [] },
});
});

it("never walks the filesystem for a populated library", async () => {
stubHomeFetches(true);

const wrapper = mountHome();

// Setup has run but nothing has resolved: the stores are still empty,
// which is exactly the transient state that used to fire the request.
expect(getLibraryInfo).not.toHaveBeenCalled();

await flushPromises();

expect(getLibraryInfo).not.toHaveBeenCalled();
expect(wrapper.text()).not.toContain("home.empty-headline");
});

it("fetches the filesystem hint once the library is confirmed empty", async () => {
stubHomeFetches(false);

const wrapper = mountHome();
await flushPromises();

expect(getLibraryInfo).toHaveBeenCalledTimes(1);
expect(wrapper.text()).toContain("home.empty-headline");
});

it("does not render the empty state before the initial loads settle", async () => {
stubHomeFetches(false);

const wrapper = mountHome();

expect(wrapper.text()).not.toContain("home.empty-headline");

await flushPromises();

expect(wrapper.text()).toContain("home.empty-headline");
});
});
Loading
Loading