Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7db28ae
Track the installed version of a game
claude Oct 5, 2026
3450885
Remove PR screenshots from the tree
claude Oct 5, 2026
f68c34f
Share the PATCH handler of per-user game fields
claude Oct 5, 2026
6553acb
Address SonarCloud findings on the version helpers
claude Oct 5, 2026
a62337d
Record the installed version once the files are really in place
claude Oct 5, 2026
88e1c2c
Re-read the game before recording its version, refresh games app-wide
claude Oct 5, 2026
ae48e18
Take an update's target version, including build targets
claude Oct 5, 2026
6e975c2
Record edition releases' versions, keep suggestion clicks, write vers…
claude Oct 5, 2026
710b6ae
Add a 1.5.1-rc1 changelog entry for the installed version
claude Oct 5, 2026
499fc90
Skip pre-release versions, take build-range targets, serialize versio…
claude Oct 5, 2026
7ab4130
Queue a typed-back value during a save, and file the changelog entry …
claude Oct 5, 2026
17779cd
Keep the category picked when claiming a download, and trust it for t…
claude Oct 5, 2026
f5e5d48
Skip early-access and hotfix versions, take the highest build of any …
claude Oct 5, 2026
8c69408
Store the release category of searched and auto-grabbed downloads, ig…
claude Oct 5, 2026
dd29ac8
Read versions after Patch markers, store full-game editions as main
claude Oct 5, 2026
5f2b8c3
Ignore an Enter that confirms an IME composition in the version field
claude Oct 5, 2026
c8a5fa5
Keep an edited version draft across refreshes, skip versions after a …
claude Oct 5, 2026
76225b1
Suggest versions only from completed downloads, reject letter-suffixe…
claude Oct 5, 2026
497d4af
Read unprefixed update ranges, split DLC names on underscores
claude Oct 5, 2026
e31371b
Keep the version save pending until the games refetch lands
claude Oct 5, 2026
5ae8ed7
Broadcast manual version edits, read mixed-prefix update ranges
claude Oct 5, 2026
4b4f20d
Read unprefixed targets in ranges before a trailing patch marker
claude Oct 5, 2026
421b894
Take an unprefixed number as an update target only after "to"
claude Oct 5, 2026
837cde2
Read bare build range targets, normalize all separators before catego…
claude Oct 5, 2026
ab36a72
Keep hyphenated markers like Add-On when categorizing release names
claude Oct 5, 2026
e2822c9
Compare again after losing the version write to a concurrent import
claude Oct 5, 2026
e6b5e20
Merge the installed-version migrations into one per dialect
claude Oct 5, 2026
c257078
Fix SonarCloud findings on the installed-version code
claude Oct 5, 2026
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
27 changes: 27 additions & 0 deletions client/__tests__/GameDetailsModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,33 @@ describe("GameDetailsModal", () => {
expect(screen.getByText("Show less")).toBeInTheDocument();
});

it("suggests installed versions only from downloads that completed", async () => {
const download = (id: string, downloadTitle: string, status: string) => ({
id,
gameId: "1",
downloadTitle,
status,
category: "main",
});
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(
makeFetchMock({
"/api/games/1/downloads": [
download("a", "Test.Game.v1.1-RUNE", "imported"),
download("b", "Test.Game.v1.2-RUNE", "completed"),
download("c", "Test.Game.v1.3-RUNE", "failed"),
download("d", "Test.Game.v1.4-RUNE", "downloading"),
],
})
);

renderComponent();

expect(await screen.findByTestId("button-version-suggestion-v1.2")).toBeInTheDocument();
expect(screen.getByTestId("button-version-suggestion-v1.1")).toBeInTheDocument();
expect(screen.queryByTestId("button-version-suggestion-v1.3")).not.toBeInTheDocument();
expect(screen.queryByTestId("button-version-suggestion-v1.4")).not.toBeInTheDocument();
});

it("renders the Your rating section", () => {
renderComponent();
// Links tab is forceMount-ed; always in DOM
Expand Down
208 changes: 208 additions & 0 deletions client/__tests__/InstalledVersionField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/** @vitest-environment jsdom */
import React from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { createTestQueryClient } from "./test-utils";
import InstalledVersionField, { getVersionSuggestions } from "@/components/InstalledVersionField";

const mockToast = vi.fn();
vi.mock("@/hooks/use-toast", () => ({
useToast: () => ({ toast: mockToast }),
}));

const apiRequest = vi.fn();
vi.mock("@/lib/queryClient", () => ({
apiRequest: (...args: unknown[]) => apiRequest(...args),
}));

const gameId = "game-1";

function renderField(installedVersion: string | null, releaseNames: string[] = []) {
render(
<QueryClientProvider client={createTestQueryClient()}>
<InstalledVersionField
gameId={gameId}
installedVersion={installedVersion}
releaseNames={releaseNames}
/>
</QueryClientProvider>
);
return screen.getByLabelText("Installed version");
}

describe("getVersionSuggestions", () => {
it("lists distinct versions newest first, without the current one", () => {
expect(
getVersionSuggestions(
[
"Game.v1.2-RUNE",
"Game.Update.v1.10-RUNE",
"Game.v1.2-GOG",
"Game.Update.v1.3-RUNE",
"Game-NoVersion",
],
"v1.3"
)
).toEqual(["v1.10", "v1.2"]);
});
});

describe("InstalledVersionField", () => {
beforeEach(() => {
vi.clearAllMocks();
apiRequest.mockResolvedValue({ ok: true, json: async () => ({}) });
});

afterEach(() => {
cleanup();
});

it("shows the stored version", () => {
expect(renderField("v1.2.3")).toHaveValue("v1.2.3");
});

it("saves the typed version on Enter", async () => {
const input = renderField(null);
fireEvent.change(input, { target: { value: " v2.0 " } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() =>
expect(apiRequest).toHaveBeenCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
installedVersion: "v2.0",
})
);
});

it("does not save on an Enter that confirms an IME composition", () => {
const input = renderField(null);
fireEvent.change(input, { target: { value: "v2" } });
fireEvent.keyDown(input, { key: "Enter", isComposing: true });
expect(apiRequest).not.toHaveBeenCalled();
});

it("clears the version when emptied and blurred", async () => {
const input = renderField("v1.0");
fireEvent.change(input, { target: { value: "" } });
fireEvent.blur(input);
await waitFor(() =>
expect(apiRequest).toHaveBeenCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
installedVersion: null,
})
);
});

it("follows a new server value but keeps a draft being edited", () => {
const client = createTestQueryClient();
const view = (installedVersion: string | null, id = gameId) => (
<QueryClientProvider client={client}>
<InstalledVersionField gameId={id} installedVersion={installedVersion} releaseNames={[]} />
</QueryClientProvider>
);
const { rerender } = render(view("v1.0"));
const input = screen.getByLabelText("Installed version");

rerender(view("v1.1"));
expect(input).toHaveValue("v1.1");

fireEvent.change(input, { target: { value: "v2" } });
rerender(view("v1.2"));
expect(input).toHaveValue("v2");

rerender(view("v3.0", "game-2"));
expect(input).toHaveValue("v3.0");
});

it("does not save when the value did not change", () => {
const input = renderField("v1.0");
fireEvent.blur(input);
expect(apiRequest).not.toHaveBeenCalled();
});

it("applies a suggestion from the game's downloads", async () => {
renderField(null, ["Game.Update.v1.4-RUNE"]);
fireEvent.click(screen.getByRole("button", { name: "Set installed version to v1.4" }));
await waitFor(() =>
expect(apiRequest).toHaveBeenCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
installedVersion: "v1.4",
})
);
});

it("keeps focus in the input when a suggestion is pressed", () => {
renderField(null, ["Game.Update.v1.4-RUNE"]);
const button = screen.getByRole("button", { name: "Set installed version to v1.4" });
// A cancelled mousedown means the input never blurs (and never saves its draft) first.
expect(fireEvent.mouseDown(button)).toBe(false);
});

it("sends a value typed during a save only after that save settles", async () => {
let finishFirst: (value: unknown) => void = () => {};
apiRequest.mockReturnValueOnce(new Promise((resolve) => (finishFirst = resolve)));
const input = renderField(null);
fireEvent.change(input, { target: { value: "v2" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(1));
fireEvent.change(input, { target: { value: "v3" } });
fireEvent.blur(input);
expect(apiRequest).toHaveBeenCalledTimes(1);

finishFirst({ ok: true, json: async () => ({}) });
await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(2));
expect(apiRequest).toHaveBeenLastCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
installedVersion: "v3",
});
});

it("undoes an in-flight save when the stored value is typed back", async () => {
let finishFirst: (value: unknown) => void = () => {};
apiRequest.mockReturnValueOnce(new Promise((resolve) => (finishFirst = resolve)));
const input = renderField("v1");
fireEvent.change(input, { target: { value: "v2" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(1));
fireEvent.change(input, { target: { value: "v1" } });
fireEvent.blur(input);

finishFirst({ ok: true, json: async () => ({}) });
await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(2));
expect(apiRequest).toHaveBeenLastCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
installedVersion: "v1",
});
});

it("still saves the stored value typed back while the refetch is pending", async () => {
const client = createTestQueryClient();
let finishRefetch: () => void = () => {};
vi.spyOn(client, "invalidateQueries").mockReturnValueOnce(
new Promise<void>((resolve) => (finishRefetch = resolve))
);
render(
<QueryClientProvider client={client}>
<InstalledVersionField gameId={gameId} installedVersion="v1" releaseNames={[]} />
</QueryClientProvider>
);
const input = screen.getByLabelText("Installed version");
fireEvent.change(input, { target: { value: "v2" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(client.invalidateQueries).toHaveBeenCalled());
fireEvent.change(input, { target: { value: "v1" } });
fireEvent.blur(input);

finishRefetch();
await waitFor(() => expect(apiRequest).toHaveBeenCalledTimes(2));
expect(apiRequest).toHaveBeenLastCalledWith("PATCH", `/api/games/${gameId}/installed-version`, {
installedVersion: "v1",
});
});

it("restores the stored value and warns when saving fails", async () => {
apiRequest.mockRejectedValue(new Error("boom"));
const input = renderField("v1.0");
fireEvent.change(input, { target: { value: "v9" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(mockToast).toHaveBeenCalled());
expect(input).toHaveValue("v1.0");
});
});
2 changes: 2 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Header from "@/components/Header";
import MobileBottomNav from "@/components/MobileBottomNav";
import { getPageTitle } from "@/components/navigation-items";
import { useBackgroundNotifications } from "@/hooks/use-background-notifications";
import { useGameUpdatedSocket } from "@/hooks/use-game-updated-socket";
import { AuthProvider } from "@/lib/auth";
import { Suspense, lazy, useEffect, useLayoutEffect, useRef } from "react";
import LoadingFallback from "@/components/LoadingFallback";
Expand Down Expand Up @@ -145,6 +146,7 @@ function Router() {
function AppContent() {
// Enable background notifications for downloads
useBackgroundNotifications();
useGameUpdatedSocket();

return <Router />;
}
Expand Down
23 changes: 23 additions & 0 deletions client/src/components/GameDetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
import { NexusModsIcon } from "./NexusModsIcon";
import GameJournalTab from "./GameJournalTab";
import FileCategorySelect from "./FileCategorySelect";
import InstalledVersionField from "./InstalledVersionField";
import { getSocket } from "@/lib/socket";
import { useToast } from "@/hooks/use-toast";
import { useHiddenMutation } from "@/hooks/use-hidden-mutation";
Expand All @@ -97,6 +98,7 @@ import { type XrelGameStatus } from "@shared/xrel-types";
import StatusBadge, { getStatusLabel } from "./StatusBadge";
import { apiRequest } from "@/lib/queryClient";
import { cn, safeUrl, formatBytes, isDiscoveryId } from "@/lib/utils";
import { carriesBaseGameVersion } from "@shared/version-utils";

const GameDownloadDialog = lazy(() => import("./GameDownloadDialog"));

Expand Down Expand Up @@ -635,6 +637,19 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai
refetchInterval: 5000,
});

const downloadReleaseNames = useMemo(
() =>
gameDownloads
// Only downloads that really landed: a failed or pending one was never installed.
.filter(
(download) =>
(download.status === "completed" || download.status === "imported") &&
carriesBaseGameVersion(download.downloadTitle, download.category)
Comment thread
Doezer marked this conversation as resolved.
)
.map((download) => download.downloadTitle),
Comment thread
Doezer marked this conversation as resolved.
[gameDownloads]
);

const {
data: xrelStatus,
isLoading: xrelStatusLoading,
Expand Down Expand Up @@ -1241,6 +1256,14 @@ export default function GameDetailsModal({ game, open, onOpenChange }: GameDetai
</p>
</div>
)}
{!isDiscoveryId(game.id) && (
<InstalledVersionField
key={game.id}
gameId={game.id}
installedVersion={game.installedVersion ?? null}
releaseNames={downloadReleaseNames}
/>
)}
</div>
</div>
</ScrollArea>
Expand Down
6 changes: 6 additions & 0 deletions client/src/components/GameDownloadDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import {
groupDownloadsByCategory,
type DownloadCategory,
} from "@shared/download-categorizer";
import { inferReleaseCategory } from "@shared/version-utils";
import type { ReleaseType } from "@shared/typesafe-types";
import {
parseReleaseMetadata,
Expand Down Expand Up @@ -543,6 +544,11 @@ export default function GameDownloadDialog({ game, open, onOpenChange }: GameDow
title: download.title,
gameId: game?.id,
downloadType: isUsenetItem(download) ? "usenet" : "torrent",
releaseCategory: inferReleaseCategory(
download.title,
download.aiReleaseType,
download.aiReleaseTypeConfidence
),
});
results.push(await response.json());
}
Expand Down
Loading
Loading