Repository navigation
Track the installed version of a game #1142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+6,181
−24
Merged
Changes from 25 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 3450885
Remove PR screenshots from the tree
claude f68c34f
Share the PATCH handler of per-user game fields
claude 6553acb
Address SonarCloud findings on the version helpers
claude a62337d
Record the installed version once the files are really in place
claude 88e1c2c
Re-read the game before recording its version, refresh games app-wide
claude ae48e18
Take an update's target version, including build targets
claude 6e975c2
Record edition releases' versions, keep suggestion clicks, write vers…
claude 710b6ae
Add a 1.5.1-rc1 changelog entry for the installed version
claude 499fc90
Skip pre-release versions, take build-range targets, serialize versio…
claude 7ab4130
Queue a typed-back value during a save, and file the changelog entry …
claude 17779cd
Keep the category picked when claiming a download, and trust it for t…
claude f5e5d48
Skip early-access and hotfix versions, take the highest build of any …
claude 8c69408
Store the release category of searched and auto-grabbed downloads, ig…
claude dd29ac8
Read versions after Patch markers, store full-game editions as main
claude 5f2b8c3
Ignore an Enter that confirms an IME composition in the version field
claude c8a5fa5
Keep an edited version draft across refreshes, skip versions after a …
claude 76225b1
Suggest versions only from completed downloads, reject letter-suffixe…
claude 497d4af
Read unprefixed update ranges, split DLC names on underscores
claude e31371b
Keep the version save pending until the games refetch lands
claude 5ae8ed7
Broadcast manual version edits, read mixed-prefix update ranges
claude 4b4f20d
Read unprefixed targets in ranges before a trailing patch marker
claude 421b894
Take an unprefixed number as an update target only after "to"
claude 837cde2
Read bare build range targets, normalize all separators before catego…
claude ab36a72
Keep hyphenated markers like Add-On when categorizing release names
claude e2822c9
Compare again after losing the version write to a concurrent import
claude e6b5e20
Merge the installed-version migrations into one per dialect
claude c257078
Fix SonarCloud findings on the installed-version code
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.