diff --git a/src/renderer/src/features/installations/components/GameVersionPicker.tsx b/src/renderer/src/features/installations/components/GameVersionPicker.tsx index e6a5613e..16938018 100644 --- a/src/renderer/src/features/installations/components/GameVersionPicker.tsx +++ b/src/renderer/src/features/installations/components/GameVersionPicker.tsx @@ -1,7 +1,7 @@ import { useTranslation, Trans } from "react-i18next" -import semver from "semver" import { PiWarningDuotone } from "react-icons/pi" +import { compareGameVersionsDesc } from "@renderer/utils/gameVersionOrder" import { FormBody, FormHead, FormLabel, FromGroup } from "@renderer/components/ui/FormComponents" import { TableBody, TableBodyRow, TableCell, TableHead, TableHeadRow, TableWrapper } from "@renderer/components/ui/Table" import { LinkButton } from "@renderer/components/ui/Buttons" @@ -63,7 +63,7 @@ export function GameVersionPicker({ gameVersions, version, onSelect, missingVers )} {gameVersions .slice() - .sort((a, b) => semver.rcompare(a.version, b.version)) + .sort((a, b) => compareGameVersionsDesc(a.version, b.version)) .map((gv) => ( onSelect(gv)} selected={version?.version === gv.version}> {gv.version} diff --git a/src/renderer/src/features/installations/pages/AddInstallation.tsx b/src/renderer/src/features/installations/pages/AddInstallation.tsx index 222d0f4e..27bd5010 100644 --- a/src/renderer/src/features/installations/pages/AddInstallation.tsx +++ b/src/renderer/src/features/installations/pages/AddInstallation.tsx @@ -2,10 +2,10 @@ import { useRef } from "react" import { useNavigate } from "react-router-dom" import { useTranslation } from "react-i18next" import { PiFloppyDiskBackDuotone, PiMagnifyingGlassDuotone, PiXCircleDuotone } from "react-icons/pi" -import semver from "semver" import { createInstallation, INSTALLATION_NAME_MAX_LENGTH, INSTALLATION_NAME_MIN_LENGTH } from "@domain/installations/create" import { DEFAULT_COMPRESSION_LEVEL } from "@domain/config/defaults" +import { compareGameVersionsDesc } from "@renderer/utils/gameVersionOrder" import { INSTALLATION_ICONS } from "@renderer/utils/installationIcons" import { useNotificationsContext } from "@renderer/contexts/NotificationsContext" @@ -54,7 +54,7 @@ function AddInslallation(): JSX.Element { const fields = useInstallationFormFields({ icon: INSTALLATION_ICONS[0], name: t("features.installations.defaultName"), - version: [...gameVersions].sort((a, b) => semver.compare(b.version, a.version))[0], + version: [...gameVersions].sort((a, b) => compareGameVersionsDesc(a.version, b.version))[0], startParams: "", backupsLimit: 3, backupsAuto: false, diff --git a/src/renderer/src/features/versions/pages/ListVersions.tsx b/src/renderer/src/features/versions/pages/ListVersions.tsx index e3a3c000..ef80ba2e 100644 --- a/src/renderer/src/features/versions/pages/ListVersions.tsx +++ b/src/renderer/src/features/versions/pages/ListVersions.tsx @@ -1,8 +1,8 @@ import { useRef, useState } from "react" import { PiFolderOpenDuotone, PiPlusCircleDuotone, PiTrashDuotone, PiMagnifyingGlassDuotone, PiXCircleDuotone, PiWarningDuotone, PiLinkDuotone } from "react-icons/pi" import { useTranslation } from "react-i18next" -import semver from "semver" +import { compareGameVersionsDesc } from "@renderer/utils/gameVersionOrder" import { useGameVersions, useInstallations } from "@renderer/features/config/contexts/ConfigContext" import { useNotificationsContext } from "@renderer/contexts/NotificationsContext" import { useUninstallGameVersion } from "@renderer/features/versions/hooks/useUninstallGameVersion" @@ -94,14 +94,7 @@ function ListVersions(): JSX.Element { {gameVersions .slice() - .sort((a, b) => { - const aValid = semver.valid(a.version) - const bValid = semver.valid(b.version) - if (aValid && bValid) return semver.rcompare(a.version, b.version) - if (aValid) return -1 - if (bValid) return 1 - return a.version.localeCompare(b.version) - }) + .sort((a, b) => compareGameVersionsDesc(a.version, b.version)) .map((gv) => (
diff --git a/src/renderer/src/utils/gameVersionOrder.ts b/src/renderer/src/utils/gameVersionOrder.ts new file mode 100644 index 00000000..8a8d735f --- /dev/null +++ b/src/renderer/src/utils/gameVersionOrder.ts @@ -0,0 +1,23 @@ +import semver from "semver" + +/** + * Orders VS Version strings newest first, tolerating one semver cannot parse. + * + * A registered version is whatever the game printed for `-v`: detect.ts trims + * that stdout and stores it as-is, so a modded build, a pre-release or a probe + * that answered something unexpected can leave a string like + * "Vintage Story 1.21.0" in the config. `semver.rcompare` throws on that, and a + * throw inside a component's sort callback takes the whole page down with it. + * + * Valid versions order among themselves exactly as they did. Anything + * unparseable sorts after them, alphabetically, so the order stays the same on + * every render. + */ +export function compareGameVersionsDesc(a: string, b: string): number { + const aValid = semver.valid(a) + const bValid = semver.valid(b) + if (aValid && bValid) return semver.rcompare(a, b) + if (aValid) return -1 + if (bValid) return 1 + return a.localeCompare(b) +} diff --git a/tests/renderer-dom/installationFormAdd.test.tsx b/tests/renderer-dom/installationFormAdd.test.tsx index c9ce5843..9675bc63 100644 --- a/tests/renderer-dom/installationFormAdd.test.tsx +++ b/tests/renderer-dom/installationFormAdd.test.tsx @@ -68,6 +68,29 @@ describe("AddInstallation", () => { await waitFor(() => expect(ensurePathExists).toHaveBeenCalled()) }) + it("renders the form when a registered VS Version string is not valid semver", async () => { + installMockWindowApi({ + configManager: { + getConfig: vi.fn(async () => + createMockConfig({ + defaultInstallationsFolder: "/installations", + gameVersions: [ + { version: "1.20.0", path: "/versions/1.20.0" }, + { version: "Vintage Story 1.21.0", path: "/games/vintagestory", linked: true } + ] + }) + ) + } + }) + + renderAddInstallation() + + // The default version is picked by sorting the list, so this page went blank on the same + // string that took the edit form down. + expect(await screen.findByText("1.20.0")).toBeTruthy() + expect(screen.getByText("Vintage Story 1.21.0")).toBeTruthy() + }) + it("notifies the name length failure and stays on the form when the name is too short", async () => { const user = userEvent.setup() installMockWindowApi({ diff --git a/tests/renderer-dom/installationFormEdit.test.tsx b/tests/renderer-dom/installationFormEdit.test.tsx index 04784bef..1f231f22 100644 --- a/tests/renderer-dom/installationFormEdit.test.tsx +++ b/tests/renderer-dom/installationFormEdit.test.tsx @@ -125,6 +125,31 @@ describe("EditInstallation", () => { expect(screen.queryByText(/is not installed anymore/)).toBeNull() }) + it("renders the form when a registered VS Version string is not valid semver", async () => { + installMockWindowApi({ + configManager: { + getConfig: vi.fn(async () => + createMockConfig({ + // What a player gets from "look for a version" when the game answered `-v` with more + // than a bare number: the string is stored as printed, and sorting it used to throw + // out of the picker and leave the page blank. + gameVersions: [ + { version: "1.20.0", path: "/versions/1.20.0" }, + { version: "Vintage Story 1.21.0", path: "/games/vintagestory", linked: true } + ], + installations: [anInstallation({ version: "1.20.0" })] + }) + ) + } + }) + + await openEditInstallation("install-a") + + await screen.findByDisplayValue("Install A") + expect(screen.getByText("1.20.0")).toBeTruthy() + expect(screen.getByText("Vintage Story 1.21.0")).toBeTruthy() + }) + it("shows the not-found message instead of the form for an unknown id", async () => { installMockWindowApi({ configManager: { getConfig: vi.fn(async () => createMockConfig({ installations: [anInstallation()] })) } }) diff --git a/tests/renderer/gameVersionOrder.test.ts b/tests/renderer/gameVersionOrder.test.ts new file mode 100644 index 00000000..85f9665b --- /dev/null +++ b/tests/renderer/gameVersionOrder.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict" +import { describe, it } from "vitest" + +import { compareGameVersionsDesc } from "../../src/renderer/src/utils/gameVersionOrder" + +/** + * The comparator behind every VS Version list in the launcher. A version string + * semver cannot parse used to throw out of the sort callback and take the page + * down with it, so the unparseable arms matter as much as the ordering ones. + */ +describe("compareGameVersionsDesc", () => { + it("puts the newer of two parseable versions first", () => { + assert.ok(compareGameVersionsDesc("1.20.4", "1.19.8") < 0) + assert.ok(compareGameVersionsDesc("1.19.8", "1.20.4") > 0) + assert.equal(compareGameVersionsDesc("1.20.4", "1.20.4"), 0) + }) + + it("orders a pre-release under the release it precedes", () => { + assert.ok(compareGameVersionsDesc("1.20.0", "1.20.0-rc.1") < 0) + }) + + it("sorts a version semver cannot parse after every parseable one", () => { + assert.ok(compareGameVersionsDesc("Vintage Story 1.21.0", "1.19.8") > 0) + assert.ok(compareGameVersionsDesc("1.19.8", "Vintage Story 1.21.0") < 0) + }) + + it("orders two unparseable versions alphabetically so the list stays stable", () => { + assert.ok(compareGameVersionsDesc("Vintage Story 1.21.0", "Zed build") < 0) + assert.ok(compareGameVersionsDesc("Zed build", "Vintage Story 1.21.0") > 0) + }) + + it("sorts a mixed list newest first with the unparseable entries last", () => { + const sorted = ["Vintage Story 1.21.0", "1.19.8", "custom", "1.20.4"].sort(compareGameVersionsDesc) + assert.deepEqual(sorted, ["1.20.4", "1.19.8", "custom", "Vintage Story 1.21.0"]) + }) +})