From fcfb40750e9c6c3c845e2911847789b9b22ec010 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sun, 27 Sep 2026 20:40:22 -0400 Subject: [PATCH 1/2] chore(lint): ban double-cast ROM fixtures in tests Add a no-restricted-syntax rule for `as unknown as DetailedRom` and `as unknown as SimpleRom` in non-v1 test files, add a makeDetailedRom factory next to makeRom, and move the eleven files that used the double cast onto the factories. Removing the casts surfaced a Stream test fixture that passed StateSchema where UserStateSchema is expected and set a platform_name field DetailedRom does not have. Co-Authored-By: Claude Opus 5.5 (1M context) --- frontend/eslint.config.js | 22 +++++++++++++++++++ frontend/src/plugins/router.test.ts | 7 ++---- frontend/src/stores/native.test.ts | 10 +++++---- frontend/src/utils/rom.fixtures.ts | 11 +++++++++- .../Home/Widgets/AnniversaryWidget.test.ts | 7 +++--- .../v2/components/Player/PlayerShell.test.ts | 5 +++-- .../v2/composables/useCanPlay/index.test.ts | 6 ++--- .../composables/useGameActions/index.test.ts | 8 +++---- .../composables/usePlaySession/index.test.ts | 3 ++- .../v2/composables/useRomSync/index.test.ts | 9 +++----- .../src/v2/utils/soundtrackTracks.test.ts | 3 ++- .../src/v2/views/Player/EmulatorJS.test.ts | 20 +++++++++-------- frontend/src/v2/views/Player/Stream.test.ts | 10 ++++----- 13 files changed, 77 insertions(+), 44 deletions(-) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index d4feaadfb..04b2ba899 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -213,6 +213,28 @@ export default tseslint.config( "romm/no-layout-media-query": "error", }, }, + // A double cast hides a ROM fixture from the type checker; the frozen v1 + // tree is left as is. + { + files: ["src/**/*.test.ts"], + ignores: [ + "src/views/**", + "src/components/**", + "src/console/**", + "src/layouts/**", + ], + rules: { + "no-restricted-syntax": [ + "error", + { + selector: + "TSAsExpression[expression.type='TSAsExpression'][expression.typeAnnotation.type='TSUnknownKeyword'][typeAnnotation.typeName.name=/^(DetailedRom|SimpleRom)$/]", + message: + "Build ROM fixtures with makeRom or makeDetailedRom from @/utils/rom.fixtures instead of `as unknown as`.", + }, + ], + }, + }, // Keep last: Prettier owns formatting, so this switches off every // stylistic rule the two tools would otherwise fight over. prettierConfig, diff --git a/frontend/src/plugins/router.test.ts b/frontend/src/plugins/router.test.ts index 6304b0b92..ad08091a0 100644 --- a/frontend/src/plugins/router.test.ts +++ b/frontend/src/plugins/router.test.ts @@ -6,6 +6,7 @@ import router, { applyRouteTitle, ROUTES } from "@/plugins/router"; import storeAuth from "@/stores/auth"; import storeRoms, { type DetailedRom } from "@/stores/roms"; import type { User } from "@/stores/users"; +import { makeDetailedRom } from "@/utils/rom.fixtures"; const { getRom, stubView } = vi.hoisted(() => ({ getRom: vi.fn(), @@ -24,11 +25,7 @@ vi.mock("@/v2/layouts/AppLayout.vue", stubView); vi.mock("@/v2/views/GameDetails.vue", stubView); function makeRom(overrides: Partial = {}): DetailedRom { - return { - id: 1, - name: "Chrono Trigger", - ...overrides, - } as unknown as DetailedRom; + return makeDetailedRom({ name: "Chrono Trigger", ...overrides }); } describe("route titles", () => { diff --git a/frontend/src/stores/native.test.ts b/frontend/src/stores/native.test.ts index 3c3639e36..75c314897 100644 --- a/frontend/src/stores/native.test.ts +++ b/frontend/src/stores/native.test.ts @@ -8,6 +8,7 @@ import type { PlatformSupport, PlatformSupportQuery, } from "@/types/rommNative"; +import { makeRom as baseRom } from "@/utils/rom.fixtures"; const shellPresent = { value: true }; const fetchPlatformSupport = vi.fn( @@ -74,8 +75,7 @@ vi.mock("@/utils", () => ({ const { useNativeStore } = await import("@/stores/native"); function makeRom(overrides: Partial = {}): SimpleRom { - return { - id: 1, + return baseRom({ name: "Chrono Trigger", fs_name: "Chrono Trigger.sfc", fs_name_no_ext: "Chrono Trigger", @@ -84,9 +84,11 @@ function makeRom(overrides: Partial = {}): SimpleRom { platform_slug: "snes", has_file_on_disk: true, // As a single-rom endpoint answers. A gallery card passes `files: []`. - files: [{ full_path: "snes/game.sfc", file_size_bytes: 4194304 }], + files: [ + { full_path: "snes/game.sfc", file_size_bytes: 4194304 }, + ] as SimpleRom["files"], ...overrides, - } as unknown as SimpleRom; + }); } beforeEach(() => { diff --git a/frontend/src/utils/rom.fixtures.ts b/frontend/src/utils/rom.fixtures.ts index ae1cfe965..eef06f0aa 100644 --- a/frontend/src/utils/rom.fixtures.ts +++ b/frontend/src/utils/rom.fixtures.ts @@ -1,4 +1,4 @@ -import type { SimpleRom } from "@/stores/roms"; +import type { DetailedRom, SimpleRom } from "@/stores/roms"; export function makeRom(overrides: Partial): SimpleRom { return { @@ -8,3 +8,12 @@ export function makeRom(overrides: Partial): SimpleRom { ...overrides, } as SimpleRom; } + +export function makeDetailedRom(overrides: Partial): DetailedRom { + return { + id: 1, + fs_name: "Game", + files: [], + ...overrides, + } as DetailedRom; +} diff --git a/frontend/src/v2/components/Home/Widgets/AnniversaryWidget.test.ts b/frontend/src/v2/components/Home/Widgets/AnniversaryWidget.test.ts index 9e2710f1a..1a55073fc 100644 --- a/frontend/src/v2/components/Home/Widgets/AnniversaryWidget.test.ts +++ b/frontend/src/v2/components/Home/Widgets/AnniversaryWidget.test.ts @@ -3,6 +3,7 @@ import { flushPromises, mount } from "@vue/test-utils"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { defineComponent } from "vue"; import type { SimpleRom } from "@/stores/roms"; +import { makeRom } from "@/utils/rom.fixtures"; import AnniversaryWidget from "./AnniversaryWidget.vue"; const { getRoms } = vi.hoisted(() => ({ getRoms: vi.fn() })); @@ -58,15 +59,15 @@ function releasedOn(year: number, month: number, day: number): number { } function rom(id: number, name: string, released: number): SimpleRom { - return { + return makeRom({ id, name, fs_name: `${name}.sfc`, platform_slug: "snes", platform_display_name: "Super Nintendo", is_identified: true, - metadatum: { first_release_date: released }, - } as unknown as SimpleRom; + metadatum: { first_release_date: released } as SimpleRom["metadatum"], + }); } /** A page of the shared rom list, as `getRoms` resolves it. */ diff --git a/frontend/src/v2/components/Player/PlayerShell.test.ts b/frontend/src/v2/components/Player/PlayerShell.test.ts index 3e6f19d0f..2c540ff16 100644 --- a/frontend/src/v2/components/Player/PlayerShell.test.ts +++ b/frontend/src/v2/components/Player/PlayerShell.test.ts @@ -1,6 +1,7 @@ import { mount, type VueWrapper } from "@vue/test-utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SimpleRom } from "@/stores/roms"; +import { makeRom } from "@/utils/rom.fixtures"; import PlayerShell from "./PlayerShell.vue"; const mocks = vi.hoisted(() => ({ setStageActive: vi.fn() })); @@ -21,7 +22,7 @@ vi.mock("@/v2/components/shared/GameCover.vue", () => ({ default: { template: "
" }, })); -const heroRom = { id: 1, platform_id: 2 } as unknown as SimpleRom; +const heroRom = makeRom({ id: 1, platform_id: 2 }); function mountShell( props: Partial<{ @@ -106,7 +107,7 @@ describe("PlayerShell", () => { it("disables the gallery link when the hero carries no platform", () => { const { toPlatform } = backLinks( - mountShell({ heroRom: { id: 1 } as unknown as SimpleRom }), + mountShell({ heroRom: makeRom({ id: 1 }) }), ); expect(toPlatform.props("to")).toBeUndefined(); diff --git a/frontend/src/v2/composables/useCanPlay/index.test.ts b/frontend/src/v2/composables/useCanPlay/index.test.ts index 141089952..f1cd6de16 100644 --- a/frontend/src/v2/composables/useCanPlay/index.test.ts +++ b/frontend/src/v2/composables/useCanPlay/index.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ref } from "vue"; import type { SimpleRom } from "@/stores/roms"; +import { makeRom as baseRom } from "@/utils/rom.fixtures"; import { useCanPlay } from "./index"; // Each engine's support check is stubbed so a test can enable one route at a @@ -49,13 +50,12 @@ vi.mock("@/utils", () => ({ })); function makeRom(overrides: Partial = {}): SimpleRom { - return { - id: 1, + return baseRom({ name: "Chrono Trigger", platform_slug: "snes", has_file_on_disk: true, ...overrides, - } as unknown as SimpleRom; + }); } beforeEach(() => { diff --git a/frontend/src/v2/composables/useGameActions/index.test.ts b/frontend/src/v2/composables/useGameActions/index.test.ts index 45b3afb94..b179213f0 100644 --- a/frontend/src/v2/composables/useGameActions/index.test.ts +++ b/frontend/src/v2/composables/useGameActions/index.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ActionKey } from "@/__generated__"; import type { SimpleRom } from "@/stores/roms"; +import { makeRom as baseRom } from "@/utils/rom.fixtures"; import { useGameActions } from "./index"; // Controllable stubs shared with the mocked modules below. @@ -124,14 +125,13 @@ vi.mock("@/v2/composables/useViewTransition", () => ({ })); function makeRom(status: SimpleRom["rom_user"]["status"] = null): SimpleRom { - return { - id: 1, + return baseRom({ name: "Chrono Trigger", fs_name_no_ext: "Chrono Trigger", platform_slug: "snes", has_file_on_disk: true, - rom_user: { status }, - } as unknown as SimpleRom; + rom_user: { status } as SimpleRom["rom_user"], + }); } beforeEach(() => { diff --git a/frontend/src/v2/composables/usePlaySession/index.test.ts b/frontend/src/v2/composables/usePlaySession/index.test.ts index 44448f3e9..09cbcc9cb 100644 --- a/frontend/src/v2/composables/usePlaySession/index.test.ts +++ b/frontend/src/v2/composables/usePlaySession/index.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import playSessionApi from "@/services/api/play-session"; import type { SimpleRom } from "@/stores/roms"; +import { makeRom as baseRom } from "@/utils/rom.fixtures"; import { usePlaySession } from "./index"; vi.mock("@/services/api/play-session", () => ({ @@ -21,7 +22,7 @@ vi.mock("@/stores/auth", () => ({ const ingest = vi.mocked(playSessionApi.ingestPlaySessionsKeepalive); function makeRom(id = 7): SimpleRom { - return { id } as unknown as SimpleRom; + return baseRom({ id }); } beforeEach(() => { diff --git a/frontend/src/v2/composables/useRomSync/index.test.ts b/frontend/src/v2/composables/useRomSync/index.test.ts index 69865014b..b8e583e36 100644 --- a/frontend/src/v2/composables/useRomSync/index.test.ts +++ b/frontend/src/v2/composables/useRomSync/index.test.ts @@ -4,6 +4,7 @@ import storeCollections, { type Collection } from "@/stores/collections"; import storeGalleryFilter from "@/stores/galleryFilter"; import type { Platform } from "@/stores/platforms"; import storeRoms, { type DetailedRom, type SimpleRom } from "@/stores/roms"; +import { makeDetailedRom, makeRom as baseRom } from "@/utils/rom.fixtures"; import storeGalleryRoms from "@/v2/stores/galleryRoms"; import storeGallerySelection from "@/v2/stores/gallerySelection"; import { useRomSync } from "./index"; @@ -18,11 +19,7 @@ vi.mock("@/services/api/rom", () => ({ })); function makeRom(overrides: Partial = {}): SimpleRom { - return { - id: 1, - name: "Chrono Trigger", - ...overrides, - } as unknown as SimpleRom; + return baseRom({ name: "Chrono Trigger", ...overrides }); } /** Put the gallery in a platform context with one loaded window so @@ -257,7 +254,7 @@ describe("useRomSync", () => { describe("refetchRom", () => { const detailed = (id: number) => - ({ id, name: "Chrono Trigger" }) as unknown as DetailedRom; + makeDetailedRom({ id, name: "Chrono Trigger" }); it("applies the fresh rom to the open view and the caches", async () => { const romsStore = storeRoms(); diff --git a/frontend/src/v2/utils/soundtrackTracks.test.ts b/frontend/src/v2/utils/soundtrackTracks.test.ts index c6516adcb..6b2f74ac3 100644 --- a/frontend/src/v2/utils/soundtrackTracks.test.ts +++ b/frontend/src/v2/utils/soundtrackTracks.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { MusicTrackSchema, TrackMetaSchema } from "@/__generated__"; import type { DetailedRom } from "@/stores/roms"; +import { makeDetailedRom } from "@/utils/rom.fixtures"; import { isAudioFile, nowPlayingCaption, @@ -15,7 +16,7 @@ function romFile(id: number, fileName: string, category = "soundtrack") { } function rom(files: unknown[]): DetailedRom { - return { id: 7, files } as unknown as DetailedRom; + return makeDetailedRom({ id: 7, files: files as DetailedRom["files"] }); } describe("isAudioFile", () => { diff --git a/frontend/src/v2/views/Player/EmulatorJS.test.ts b/frontend/src/v2/views/Player/EmulatorJS.test.ts index 6c10f1354..095888a13 100644 --- a/frontend/src/v2/views/Player/EmulatorJS.test.ts +++ b/frontend/src/v2/views/Player/EmulatorJS.test.ts @@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { defineComponent } from "vue"; import type { DetailedRom } from "@/stores/roms"; import type { LaunchState, SaveSyncOutcome } from "@/types/rommNative"; +import { makeDetailedRom } from "@/utils/rom.fixtures"; import EmulatorJS from "./EmulatorJS.vue"; const mocks = vi.hoisted(() => ({ @@ -168,7 +169,7 @@ vi.mock("@/v2/composables/useStageActive", () => ({ useStageActive: vi.fn() })); vi.mock("@/v2/composables/useUnloadGuard", () => ({ useUnloadGuard: vi.fn() })); -const ROM = { +const ROM = makeDetailedRom({ id: 7, name: "Shadow of the Colossus", platform_id: 4, @@ -176,25 +177,26 @@ const ROM = { has_file_on_disk: true, files: [], user_saves: [], - user_states: [], user_screenshots: [], -} as unknown as DetailedRom; +}); // Two discs, so the panel renders its disc selector and there is a pick to // carry. Ids rather than names: a pick is a rom file's id. -const DISC_SET = { +const DISC_SET = makeDetailedRom({ ...ROM, files: [ { id: 101, file_name: "Game (Disc 1).chd" }, { id: 102, file_name: "Game (Disc 2).chd" }, - ], -} as unknown as DetailedRom; + ] as DetailedRom["files"], +}); // A scraped bezel, which is what puts the panel's bezel switch on screen. -const BEZELED_ROM = { +const BEZELED_ROM = makeDetailedRom({ ...ROM, - ss_metadata: { bezel_path: "roms/7/bezel/bezel.png" }, -} as unknown as DetailedRom; + ss_metadata: { + bezel_path: "roms/7/bezel/bezel.png", + } as DetailedRom["ss_metadata"], +}); // One BIOS file for the platform, which is what puts the firmware picker on // screen. Its contents do not matter: the panel lists what the server returns. diff --git a/frontend/src/v2/views/Player/Stream.test.ts b/frontend/src/v2/views/Player/Stream.test.ts index 834c3eb7b..b59d17cb2 100644 --- a/frontend/src/v2/views/Player/Stream.test.ts +++ b/frontend/src/v2/views/Player/Stream.test.ts @@ -5,6 +5,7 @@ import { defineComponent, type Slots, type VNodeChild } from "vue"; import type { SaveSchema, StateSchema } from "@/__generated__"; import type { DetailedRom } from "@/stores/roms"; import { saveFixture, stateFixture } from "@/utils/assets.fixtures"; +import { makeDetailedRom } from "@/utils/rom.fixtures"; import AssetPreview from "@/v2/components/Player/AssetPreview.vue"; import SaveDataPanel from "@/v2/components/Player/SaveDataPanel.vue"; import AssetList from "@/v2/components/shared/AssetList.vue"; @@ -214,19 +215,18 @@ const ARCHIVES = [ ]; function romWith(saves: SaveSchema[], states: StateSchema[] = []): DetailedRom { - return { + return makeDetailedRom({ id: 3, name: "Archer Maclean's 3D Pool (USA)", platform_slug: "gba", - platform_name: "Game Boy Advance", platform_display_name: "Game Boy Advance", fs_name: "Archer Maclean's 3D Pool (USA).gba", files: [], user_saves: saves, - all_user_states: states, + all_user_states: states.map((state) => ({ ...state, username: "admin" })), user_screenshots: [], - metadatum: {}, - } as unknown as DetailedRom; + metadatum: {} as DetailedRom["metadatum"], + }); } // The view listens on document and window, so a mount left standing would From 21437d4add79ff7006751dbd8bed98ef266dc752 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi <3247106+gantoine@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:09:58 -0400 Subject: [PATCH 2/2] Update frontend/eslint.config.js Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- frontend/eslint.config.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 04b2ba899..eda854640 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -213,8 +213,6 @@ export default tseslint.config( "romm/no-layout-media-query": "error", }, }, - // A double cast hides a ROM fixture from the type checker; the frozen v1 - // tree is left as is. { files: ["src/**/*.test.ts"], ignores: [