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
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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) => (
<TableBodyRow key={gv.version} onClick={() => onSelect(gv)} selected={version?.version === gv.version}>
<TableCell className="w-full">{gv.version}</TableCell>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 2 additions & 9 deletions src/renderer/src/features/versions/pages/ListVersions.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -94,14 +94,7 @@ function ListVersions(): JSX.Element {
</div>
{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) => (
<ListItem key={gv.version}>
<div className="w-full h-8 flex gap-2 p-1 justify-between items-center">
Expand Down
23 changes: 23 additions & 0 deletions src/renderer/src/utils/gameVersionOrder.ts
Original file line number Diff line number Diff line change
@@ -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)
}
23 changes: 23 additions & 0 deletions tests/renderer-dom/installationFormAdd.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
25 changes: 25 additions & 0 deletions tests/renderer-dom/installationFormEdit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()] })) } })

Expand Down
36 changes: 36 additions & 0 deletions tests/renderer/gameVersionOrder.test.ts
Original file line number Diff line number Diff line change
@@ -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"])
})
})
Loading