diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java index 1c3e8c9896..a0260f34ad 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java @@ -366,16 +366,23 @@ public static void forceOverwriteGameDirectoryFiles(GameDirectory gameDirectory) } /// Adds a game directory to the given store, replacing a game directory with the same ID in that store. + /// + /// The replacement keeps the selected game directory pointing at an entry of [#mergedGameDirectories], + /// so the selection does not silently become detached from the merged view. private static void addGameDirectory(GameDirectories gameDirectories, GameDirectory gameDirectory) { Objects.requireNonNull(gameDirectory); ObservableList entries = gameDirectories.getGameDirectories(); GameDirectoryID id = gameDirectory.getId(); for (int i = 0; i < entries.size(); i++) { - if (entries.get(i).getId().equals(id)) { - repositories.remove(entries.get(i)); + GameDirectory replaced = entries.get(i); + if (replaced.getId().equals(id)) { + repositories.remove(replaced); settings().setSelectedInstance(id, null); entries.set(i, gameDirectory); rebuildGameDirectories(); + if (replaced == selectedGameDirectory.get()) { + selectedGameDirectory.set(gameDirectory); + } return; } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/directory/GameDirectoryListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/directory/GameDirectoryListItem.java index e85de07a46..64cd4a43a2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/directory/GameDirectoryListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/directory/GameDirectoryListItem.java @@ -25,6 +25,7 @@ import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.ui.Controllers; import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -42,7 +43,14 @@ public GameDirectoryListItem(GameDirectory gameDirectory) { getStyleClass().setAll("game-directory-list-item", "navigation-drawer-item"); setUserData(gameDirectory); - this.selectedProperty().bind(Bindings.equal(gameDirectory, GameDirectoryManager.selectedGameDirectoryProperty())); + // The selected state is decided by the stable ID, so replacing a game directory entry with a new + // instance sharing the same ID keeps this row selected. + this.selectedProperty().bind(Bindings.createBooleanBinding( + () -> { + @Nullable GameDirectory selected = GameDirectoryManager.selectedGameDirectoryProperty().get(); + return selected != null && gameDirectory.getId().equals(selected.getId()); + }, + GameDirectoryManager.selectedGameDirectoryProperty())); } /// Creates the JavaFX skin for this item. diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java index 75eba993ae..74ed1c0723 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java @@ -22,7 +22,6 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.JsonParser; -import javafx.beans.property.ObjectProperty; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.collections.ObservableList; import org.jackhuang.hmcl.Metadata; @@ -42,13 +41,11 @@ import org.junit.jupiter.api.io.TempDir; import java.io.IOException; -import java.lang.reflect.Field; import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.Objects; import static org.jackhuang.hmcl.setting.SettingsManager.settings; @@ -231,7 +228,7 @@ public void keepsShadowedUserGameDirectoryInBackingStore() throws ReflectiveOper localDirectories.getGameDirectories().add(localGameDirectory); localDirectories.setUserFile(false); - try (GameDirectoryEnvironment ignored = new GameDirectoryEnvironment(localDirectories, userDirectories)) { + try (GameDirectoryTestEnvironment ignored = new GameDirectoryTestEnvironment(localDirectories, userDirectories)) { GameDirectoryManager.init(); ObservableList gameDirectories = GameDirectoryManager.getGameDirectories(); @@ -271,7 +268,7 @@ public void movesGameDirectoryBetweenStoresWhenPathTypeChanges() throws Reflecti localDirectories.getGameDirectories().add(gameDirectory); localDirectories.setUserFile(false); - try (GameDirectoryEnvironment ignored = new GameDirectoryEnvironment(localDirectories, userDirectories)) { + try (GameDirectoryTestEnvironment ignored = new GameDirectoryTestEnvironment(localDirectories, userDirectories)) { GameDirectoryManager.init(); PortablePath absolutePath = PortablePath.of("/workspace/Dev"); @@ -298,6 +295,67 @@ public void movesGameDirectoryBetweenStoresWhenPathTypeChanges() throws Reflecti } } + /// Tests that editing the selected game directory keeps the selection attached to the merged view. + @Test + public void editingSelectedGameDirectoryKeepsSelectionResolvableById() throws ReflectiveOperationException { + GameDirectoryID id = GameDirectoryID.parse("game-directory:123e4567-e89b-12d3-a456-426614174000"); + GameDirectory gameDirectory = new GameDirectory(id, LocalizedText.plain("Local"), PortablePath.of("local/Dev")); + GameDirectories userDirectories = new GameDirectories(); + userDirectories.setUserFile(true); + GameDirectories localDirectories = new GameDirectories(); + localDirectories.getGameDirectories().add(gameDirectory); + localDirectories.setUserFile(false); + + try (GameDirectoryTestEnvironment ignored = new GameDirectoryTestEnvironment(localDirectories, userDirectories)) { + GameDirectoryManager.init(); + assertSame(gameDirectory, GameDirectoryManager.getSelectedGameDirectory()); + + // Renaming the selected game directory in place. + GameDirectoryManager.updateGameDirectory( + gameDirectory, LocalizedText.plain("Renamed"), PortablePath.of("local/Dev")); + assertSelectedGameDirectoryResolvableById(id); + + // Changing the path of the selected game directory inside the same store. + GameDirectoryManager.updateGameDirectory( + gameDirectory, LocalizedText.plain("Renamed"), PortablePath.of("local/Renamed")); + assertSelectedGameDirectoryResolvableById(id); + + // Migrating the selected game directory to the store owning absolute paths. + GameDirectoryManager.updateGameDirectory( + gameDirectory, LocalizedText.plain("Renamed"), PortablePath.of("/workspace/Dev")); + assertSelectedGameDirectoryResolvableById(id); + assertTrue(localDirectories.getGameDirectories().isEmpty()); + assertEquals(List.of(gameDirectory), userDirectories.getGameDirectories()); + } + } + + /// Tests that replacing the selected game directory entry rebinds the selection to the new instance. + @Test + public void replacingSelectedGameDirectoryEntryRebindsSelectionToNewInstance() throws ReflectiveOperationException { + GameDirectoryID id = GameDirectoryID.parse("game-directory:123e4567-e89b-12d3-a456-426614174000"); + GameDirectory selectedGameDirectory = new GameDirectory( + id, LocalizedText.plain("Local"), PortablePath.of("local/Dev")); + GameDirectory replacement = new GameDirectory( + id, LocalizedText.plain("Local"), PortablePath.of("local/Dev")); + GameDirectories userDirectories = new GameDirectories(); + userDirectories.setUserFile(true); + GameDirectories localDirectories = new GameDirectories(); + localDirectories.getGameDirectories().add(selectedGameDirectory); + localDirectories.setUserFile(false); + + try (GameDirectoryTestEnvironment ignored = new GameDirectoryTestEnvironment(localDirectories, userDirectories)) { + GameDirectoryManager.init(); + assertSame(selectedGameDirectory, GameDirectoryManager.getSelectedGameDirectory()); + + GameDirectoryManager.addLocalGameDirectory(replacement); + + assertEquals(List.of(replacement), localDirectories.getGameDirectories()); + assertFalse(GameDirectoryManager.getGameDirectories().contains(selectedGameDirectory)); + assertSame(replacement, GameDirectoryManager.getSelectedGameDirectory()); + assertSelectedGameDirectoryResolvableById(id); + } + } + /// Tests that game directory mutations reject writes to read-only source and target stores. @Test public void rejectsGameDirectoryMutationsWithReadOnlyStores() throws ReflectiveOperationException { @@ -309,7 +367,7 @@ public void rejectsGameDirectoryMutationsWithReadOnlyStores() throws ReflectiveO localDirectories.getGameDirectories().add(gameDirectory); localDirectories.setUserFile(false); - try (GameDirectoryEnvironment environment = new GameDirectoryEnvironment(localDirectories, userDirectories)) { + try (GameDirectoryTestEnvironment environment = new GameDirectoryTestEnvironment(localDirectories, userDirectories)) { environment.setLocalGameDirectoriesAccess(SettingFileAccess.READ_ONLY); GameDirectoryManager.init(); @@ -333,7 +391,7 @@ public void rejectsGameDirectoryMutationsWithReadOnlyStores() throws ReflectiveO targetLocalDirectories.getGameDirectories().add(targetGameDirectory); targetLocalDirectories.setUserFile(false); - try (GameDirectoryEnvironment environment = new GameDirectoryEnvironment(targetLocalDirectories, targetUserDirectories)) { + try (GameDirectoryTestEnvironment environment = new GameDirectoryTestEnvironment(targetLocalDirectories, targetUserDirectories)) { environment.setUserGameDirectoriesAccess(SettingFileAccess.READ_ONLY); GameDirectoryManager.init(); @@ -357,7 +415,7 @@ public void createsDefaultGameDirectoriesWhenMergedGameDirectoriesAreEmpty() thr GameDirectories localDirectories = new GameDirectories(); localDirectories.setUserFile(false); - try (GameDirectoryEnvironment ignored = new GameDirectoryEnvironment(localDirectories, userDirectories)) { + try (GameDirectoryTestEnvironment ignored = new GameDirectoryTestEnvironment(localDirectories, userDirectories)) { GameDirectoryManager.init(); GameDirectory localGameDirectory = assertSingleDefaultGameDirectory(localDirectories, PortablePath.of(".minecraft")); @@ -382,7 +440,7 @@ public void createsBuiltInGameDirectoriesWithStableIdsForNewStores() throws Refl localDirectories.setUserFile(false); localDirectories.setNewlyCreated(true); - try (GameDirectoryEnvironment ignored = new GameDirectoryEnvironment(localDirectories, userDirectories)) { + try (GameDirectoryTestEnvironment ignored = new GameDirectoryTestEnvironment(localDirectories, userDirectories)) { GameDirectoryManager.init(); GameDirectory localGameDirectory = assertSingleDefaultGameDirectory(localDirectories, PortablePath.of(".minecraft")); @@ -402,7 +460,7 @@ public void selectedGameDirectoryGetterNeverReturnsNullWhenGameDirectoriesAreLoa GameDirectories localDirectories = new GameDirectories(); localDirectories.setUserFile(false); - try (GameDirectoryEnvironment ignored = new GameDirectoryEnvironment(localDirectories, userDirectories)) { + try (GameDirectoryTestEnvironment ignored = new GameDirectoryTestEnvironment(localDirectories, userDirectories)) { GameDirectoryManager.init(); GameDirectory selected = GameDirectoryManager.getSelectedGameDirectory(); @@ -426,7 +484,7 @@ public void repositoryDirectoryFollowsGameDirectoryPath() throws ReflectiveOpera PortablePath.of("local/Dev")); localDirectories.getGameDirectories().add(gameDirectory); - try (GameDirectoryEnvironment ignored = new GameDirectoryEnvironment(localDirectories, userDirectories)) { + try (GameDirectoryTestEnvironment ignored = new GameDirectoryTestEnvironment(localDirectories, userDirectories)) { GameDirectoryManager.init(); HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); @@ -458,8 +516,8 @@ public void newInstancePersistsDefaultIsolationDecisionAfterPublication(@TempDir localDirectories.getGameDirectories().add(gameDirectory); GameDirectories userDirectories = new GameDirectories(); - try (GameDirectoryEnvironment ignored = - new GameDirectoryEnvironment(localDirectories, userDirectories, presets)) { + try (GameDirectoryTestEnvironment ignored = + new GameDirectoryTestEnvironment(localDirectories, userDirectories, presets)) { settings().defaultGameSettingsPresetProperty().set(defaultPresetId); HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); GameInstanceID id = new GameInstanceID("1.21.11-fabric"); @@ -518,8 +576,8 @@ public void nullInstanceParentUsesDefaultPresetInsteadOfLegacyGameDirectoryPrese localDirectories.getGameDirectories().add(gameDirectory); GameDirectories userDirectories = new GameDirectories(); - try (GameDirectoryEnvironment ignored = - new GameDirectoryEnvironment(localDirectories, userDirectories, presets)) { + try (GameDirectoryTestEnvironment ignored = + new GameDirectoryTestEnvironment(localDirectories, userDirectories, presets)) { settings().defaultGameSettingsPresetProperty().set(defaultPresetId); HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); @@ -549,8 +607,8 @@ public void legacyInstanceSettingsMigrationStoresLegacyGameDirectoryPresetAsPare localDirectories.getGameDirectories().add(gameDirectory); GameDirectories userDirectories = new GameDirectories(); - try (GameDirectoryEnvironment ignored = - new GameDirectoryEnvironment(localDirectories, userDirectories, presets)) { + try (GameDirectoryTestEnvironment ignored = + new GameDirectoryTestEnvironment(localDirectories, userDirectories, presets)) { settings().defaultGameSettingsPresetProperty().set(defaultPresetId); HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); GameInstanceID instanceId = new GameInstanceID("1.20.1"); @@ -590,8 +648,8 @@ public void startupMigrationSkipsLegacyInstanceSettingsFile(@TempDir Path tempDi localDirectories.getGameDirectories().add(gameDirectory); GameDirectories userDirectories = new GameDirectories(); - try (GameDirectoryEnvironment ignored = - new GameDirectoryEnvironment(localDirectories, userDirectories, presets)) { + try (GameDirectoryTestEnvironment ignored = + new GameDirectoryTestEnvironment(localDirectories, userDirectories, presets)) { settings().defaultGameSettingsPresetProperty().set(defaultPresetId); HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); writeVersionJson(repository, "1.20.1"); @@ -636,8 +694,8 @@ public void absentInstanceSettingsStoreLegacyGameDirectoryPresetAsParent(@TempDi localDirectories.getGameDirectories().add(gameDirectory); GameDirectories userDirectories = new GameDirectories(); - try (GameDirectoryEnvironment ignored = - new GameDirectoryEnvironment(localDirectories, userDirectories, presets)) { + try (GameDirectoryTestEnvironment ignored = + new GameDirectoryTestEnvironment(localDirectories, userDirectories, presets)) { settings().defaultGameSettingsPresetProperty().set(defaultPresetId); HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); writeVersionJson(repository, "1.20.1"); @@ -674,8 +732,8 @@ public void newInstanceAfterMigrationDoesNotUseLegacyGameDirectoryParent(@TempDi localDirectories.getGameDirectories().add(gameDirectory); GameDirectories userDirectories = new GameDirectories(); - try (GameDirectoryEnvironment ignored = - new GameDirectoryEnvironment(localDirectories, userDirectories, presets)) { + try (GameDirectoryTestEnvironment ignored = + new GameDirectoryTestEnvironment(localDirectories, userDirectories, presets)) { settings().defaultGameSettingsPresetProperty().set(defaultPresetId); HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); LegacyConfigMigrator.migrateLegacyInstanceGameSettings(localDirectories, presets); @@ -696,8 +754,8 @@ public void instanceOwnsHmclSpecificFiles(@TempDir Path tempDirectory) throws Ex localDirectories.getGameDirectories().add(gameDirectory); GameDirectories userDirectories = new GameDirectories(); - try (GameDirectoryEnvironment ignored = - new GameDirectoryEnvironment(localDirectories, userDirectories)) { + try (GameDirectoryTestEnvironment ignored = + new GameDirectoryTestEnvironment(localDirectories, userDirectories)) { HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); GameInstanceID instanceId = new GameInstanceID("1.20.1"); repository.saveAsync(new GameInstanceManifest(instanceId)).run(); @@ -736,8 +794,8 @@ public void selectedInstanceTracksRepositorySnapshots(@TempDir Path tempDirector localDirectories.getGameDirectories().add(gameDirectory); GameDirectories userDirectories = new GameDirectories(); - try (GameDirectoryEnvironment ignored = - new GameDirectoryEnvironment(localDirectories, userDirectories)) { + try (GameDirectoryTestEnvironment ignored = + new GameDirectoryTestEnvironment(localDirectories, userDirectories)) { HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); GameInstanceID firstId = new GameInstanceID("1.20.1"); GameInstanceID secondId = new GameInstanceID("1.21.1"); @@ -781,183 +839,6 @@ public void selectedInstanceTracksRepositorySnapshots(@TempDir Path tempDirector } } - /// Temporary static state override for game directory tests. - private static final class GameDirectoryEnvironment implements AutoCloseable { - /// The reflected SettingsManager local game directories field. - private final Field localGameDirectoriesField; - - /// The reflected SettingsManager user game directories field. - private final Field userGameDirectoriesField; - - /// The reflected SettingsManager launcher settings field. - private final Field launcherSettingsField; - - /// The reflected SettingsManager game settings presets field. - private final Field gameSettingsPresetsField; - - /// The reflected SettingsManager local game directories access field. - private final Field localGameDirectoriesAccessField; - - /// The reflected SettingsManager user game directories access field. - private final Field userGameDirectoriesAccessField; - - /// The reflected GameDirectoryManager initialized field. - private final Field initializedField; - - /// The reflected GameDirectoryManager selected game directory property. - private final ObjectProperty selectedGameDirectory; - - /// The reflected GameDirectoryManager selected repository property. - private final ObjectProperty selectedRepository; - - /// The merged game directory list used by GameDirectoryManager. - private final ObservableList mergedGameDirectories; - - /// The repositories mapped by GameDirectoryManager. - private final Map repositories; - - /// The previous local game directories instance. - private final Object previousLocalGameDirectories; - - /// The previous user game directories instance. - private final Object previousUserGameDirectories; - - /// The previous launcher settings instance. - private final Object previousLauncherSettings; - - /// The previous game settings presets instance. - private final Object previousGameSettingsPresets; - - /// The previous local game directories access. - private final SettingFileAccess previousLocalGameDirectoriesAccess; - - /// The previous user game directories access. - private final SettingFileAccess previousUserGameDirectoriesAccess; - - /// The previous GameDirectoryManager initialization state. - private final boolean previousInitialized; - - /// The previous selected game directory. - private final GameDirectory previousSelectedGameDirectory; - - /// The previous selected repository. - private final HMCLGameRepository previousSelectedRepository; - - /// The previous merged game directories. - private final List previousMergedGameDirectories; - - /// The previous repository map entries. - private final Map previousRepositories; - - /// Replaces game-directory-related static state with the given stores and an empty preset store. - private GameDirectoryEnvironment(GameDirectories localDirectories, GameDirectories userDirectories) - throws ReflectiveOperationException { - this(localDirectories, userDirectories, new GameSettingsPresets()); - } - - /// Replaces game-directory-related static state with the given stores. - private GameDirectoryEnvironment( - GameDirectories localDirectories, - GameDirectories userDirectories, - GameSettingsPresets gameSettingsPresets) - throws ReflectiveOperationException { - localGameDirectoriesField = SettingsManager.class.getDeclaredField("localGameDirectories"); - userGameDirectoriesField = SettingsManager.class.getDeclaredField("userGameDirectories"); - launcherSettingsField = SettingsManager.class.getDeclaredField("launcherSettings"); - gameSettingsPresetsField = SettingsManager.class.getDeclaredField("gameSettingsPresets"); - localGameDirectoriesAccessField = SettingsManager.class.getDeclaredField("localGameDirectoriesAccess"); - userGameDirectoriesAccessField = SettingsManager.class.getDeclaredField("userGameDirectoriesAccess"); - initializedField = GameDirectoryManager.class.getDeclaredField("initialized"); - Field selectedGameDirectoryField = GameDirectoryManager.class.getDeclaredField("selectedGameDirectory"); - Field selectedRepositoryField = GameDirectoryManager.class.getDeclaredField("selectedRepository"); - Field mergedGameDirectoriesField = GameDirectoryManager.class.getDeclaredField("mergedGameDirectories"); - Field repositoriesField = GameDirectoryManager.class.getDeclaredField("repositories"); - localGameDirectoriesField.setAccessible(true); - userGameDirectoriesField.setAccessible(true); - launcherSettingsField.setAccessible(true); - gameSettingsPresetsField.setAccessible(true); - localGameDirectoriesAccessField.setAccessible(true); - userGameDirectoriesAccessField.setAccessible(true); - initializedField.setAccessible(true); - selectedGameDirectoryField.setAccessible(true); - selectedRepositoryField.setAccessible(true); - mergedGameDirectoriesField.setAccessible(true); - repositoriesField.setAccessible(true); - - previousLocalGameDirectories = localGameDirectoriesField.get(null); - previousUserGameDirectories = userGameDirectoriesField.get(null); - previousLauncherSettings = launcherSettingsField.get(null); - previousGameSettingsPresets = gameSettingsPresetsField.get(null); - previousLocalGameDirectoriesAccess = (SettingFileAccess) localGameDirectoriesAccessField.get(null); - previousUserGameDirectoriesAccess = (SettingFileAccess) userGameDirectoriesAccessField.get(null); - previousInitialized = initializedField.getBoolean(null); - - @SuppressWarnings("unchecked") - ObjectProperty selectedGameDirectory = - (ObjectProperty) selectedGameDirectoryField.get(null); - this.selectedGameDirectory = selectedGameDirectory; - previousSelectedGameDirectory = selectedGameDirectory.get(); - - @SuppressWarnings("unchecked") - ObjectProperty selectedRepository = - (ObjectProperty) selectedRepositoryField.get(null); - this.selectedRepository = selectedRepository; - previousSelectedRepository = selectedRepository.get(); - - @SuppressWarnings("unchecked") - ObservableList mergedGameDirectories = - (ObservableList) mergedGameDirectoriesField.get(null); - this.mergedGameDirectories = mergedGameDirectories; - previousMergedGameDirectories = List.copyOf(mergedGameDirectories); - - @SuppressWarnings("unchecked") - Map repositories = - (Map) repositoriesField.get(null); - this.repositories = repositories; - previousRepositories = Map.copyOf(repositories); - - localGameDirectoriesField.set(null, localDirectories); - userGameDirectoriesField.set(null, userDirectories); - launcherSettingsField.set(null, new LauncherSettings()); - gameSettingsPresetsField.set(null, gameSettingsPresets); - localGameDirectoriesAccessField.set(null, SettingFileAccess.READ_WRITE); - userGameDirectoriesAccessField.set(null, SettingFileAccess.READ_WRITE); - initializedField.setBoolean(null, false); - mergedGameDirectories.clear(); - repositories.clear(); - selectedRepository.set(null); - } - - /// Sets the local game directories access used by [SettingsManager]. - private void setLocalGameDirectoriesAccess(SettingFileAccess access) throws IllegalAccessException { - localGameDirectoriesAccessField.set(null, access); - } - - /// Sets the user game directories access used by [SettingsManager]. - private void setUserGameDirectoriesAccess(SettingFileAccess access) throws IllegalAccessException { - userGameDirectoriesAccessField.set(null, access); - } - - /// Restores the previous static state. - @Override - public void close() throws ReflectiveOperationException { - if (previousSelectedGameDirectory != null) { - selectedGameDirectory.set(previousSelectedGameDirectory); - } - selectedRepository.set(previousSelectedRepository); - mergedGameDirectories.setAll(previousMergedGameDirectories); - repositories.clear(); - repositories.putAll(previousRepositories); - localGameDirectoriesField.set(null, previousLocalGameDirectories); - userGameDirectoriesField.set(null, previousUserGameDirectories); - launcherSettingsField.set(null, previousLauncherSettings); - gameSettingsPresetsField.set(null, previousGameSettingsPresets); - localGameDirectoriesAccessField.set(null, previousLocalGameDirectoriesAccess); - userGameDirectoriesAccessField.set(null, previousUserGameDirectoriesAccess); - initializedField.setBoolean(null, previousInitialized); - } - } - /// Writes a minimal valid version json for repository refresh tests. private static void writeVersionJson(HMCLGameRepository repository, String id) throws IOException { GameInstanceID instanceId = new GameInstanceID(id); @@ -979,6 +860,17 @@ private static GameDirectory assertSingleDefaultGameDirectory(GameDirectories ga return gameDirectory; } + /// Asserts the selected game directory is the merged entry carrying the given ID. + private static void assertSelectedGameDirectoryResolvableById(GameDirectoryID id) { + GameDirectory selected = GameDirectoryManager.getSelectedGameDirectory(); + GameDirectory resolved = GameDirectoryManager.getGameDirectories().stream() + .filter(gameDirectory -> gameDirectory.getId().equals(id)) + .findFirst() + .orElseThrow(); + assertSame(selected, resolved); + assertEquals(id, settings().selectedGameDirectoryProperty().get()); + } + /// Tests that game directories must be deserialized with a non-nil ID. @Test public void rejectsNilGameDirectoryId() { diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoryTestEnvironment.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoryTestEnvironment.java new file mode 100644 index 0000000000..f8f12f790c --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoryTestEnvironment.java @@ -0,0 +1,224 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.setting; + +import javafx.beans.property.ObjectProperty; +import javafx.collections.ObservableList; +import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; + +/// Temporary static state override for tests exercising [GameDirectoryManager]. +/// +/// The environment replaces the game directory, launcher settings and game settings preset state owned by +/// [SettingsManager] together with the runtime state owned by [GameDirectoryManager], so that +/// [GameDirectoryManager#init()] can run against stores supplied by a test. All replaced state is restored +/// by [GameDirectoryTestEnvironment#close()]. +@NotNullByDefault +public final class GameDirectoryTestEnvironment implements AutoCloseable { + /// The reflected SettingsManager local game directories field. + private final Field localGameDirectoriesField; + + /// The reflected SettingsManager user game directories field. + private final Field userGameDirectoriesField; + + /// The reflected SettingsManager launcher settings field. + private final Field launcherSettingsField; + + /// The reflected SettingsManager game settings presets field. + private final Field gameSettingsPresetsField; + + /// The reflected SettingsManager local game directories access field. + private final Field localGameDirectoriesAccessField; + + /// The reflected SettingsManager user game directories access field. + private final Field userGameDirectoriesAccessField; + + /// The reflected GameDirectoryManager initialized field. + private final Field initializedField; + + /// The reflected GameDirectoryManager selected game directory property. + private final ObjectProperty selectedGameDirectory; + + /// The reflected GameDirectoryManager selected repository property. + private final ObjectProperty selectedRepository; + + /// The merged game directory list used by GameDirectoryManager. + private final ObservableList mergedGameDirectories; + + /// The repositories mapped by GameDirectoryManager. + private final Map repositories; + + /// The previous local game directories instance. + private final @Nullable Object previousLocalGameDirectories; + + /// The previous user game directories instance. + private final @Nullable Object previousUserGameDirectories; + + /// The previous launcher settings instance. + private final @Nullable Object previousLauncherSettings; + + /// The previous game settings presets instance. + private final @Nullable Object previousGameSettingsPresets; + + /// The previous local game directories access. + private final SettingFileAccess previousLocalGameDirectoriesAccess; + + /// The previous user game directories access. + private final SettingFileAccess previousUserGameDirectoriesAccess; + + /// The previous GameDirectoryManager initialization state. + private final boolean previousInitialized; + + /// The previous selected game directory. + private final @Nullable GameDirectory previousSelectedGameDirectory; + + /// The previous selected repository. + private final @Nullable HMCLGameRepository previousSelectedRepository; + + /// The previous merged game directories. + private final List previousMergedGameDirectories; + + /// The previous repository map entries. + private final Map previousRepositories; + + /// Creates an empty store marked as the per-workspace game directory store. + public static GameDirectories newLocalGameDirectories() { + GameDirectories gameDirectories = new GameDirectories(); + gameDirectories.setUserFile(false); + return gameDirectories; + } + + /// Creates an empty store marked as the user game directory store. + public static GameDirectories newUserGameDirectories() { + GameDirectories gameDirectories = new GameDirectories(); + gameDirectories.setUserFile(true); + return gameDirectories; + } + + /// Replaces game-directory-related static state with the given stores and an empty preset store. + public GameDirectoryTestEnvironment(GameDirectories localDirectories, GameDirectories userDirectories) + throws ReflectiveOperationException { + this(localDirectories, userDirectories, new GameSettingsPresets()); + } + + /// Replaces game-directory-related static state with the given stores. + public GameDirectoryTestEnvironment( + GameDirectories localDirectories, + GameDirectories userDirectories, + GameSettingsPresets gameSettingsPresets) throws ReflectiveOperationException { + localGameDirectoriesField = SettingsManager.class.getDeclaredField("localGameDirectories"); + userGameDirectoriesField = SettingsManager.class.getDeclaredField("userGameDirectories"); + launcherSettingsField = SettingsManager.class.getDeclaredField("launcherSettings"); + gameSettingsPresetsField = SettingsManager.class.getDeclaredField("gameSettingsPresets"); + localGameDirectoriesAccessField = SettingsManager.class.getDeclaredField("localGameDirectoriesAccess"); + userGameDirectoriesAccessField = SettingsManager.class.getDeclaredField("userGameDirectoriesAccess"); + initializedField = GameDirectoryManager.class.getDeclaredField("initialized"); + Field selectedGameDirectoryField = GameDirectoryManager.class.getDeclaredField("selectedGameDirectory"); + Field selectedRepositoryField = GameDirectoryManager.class.getDeclaredField("selectedRepository"); + Field mergedGameDirectoriesField = GameDirectoryManager.class.getDeclaredField("mergedGameDirectories"); + Field repositoriesField = GameDirectoryManager.class.getDeclaredField("repositories"); + localGameDirectoriesField.setAccessible(true); + userGameDirectoriesField.setAccessible(true); + launcherSettingsField.setAccessible(true); + gameSettingsPresetsField.setAccessible(true); + localGameDirectoriesAccessField.setAccessible(true); + userGameDirectoriesAccessField.setAccessible(true); + initializedField.setAccessible(true); + selectedGameDirectoryField.setAccessible(true); + selectedRepositoryField.setAccessible(true); + mergedGameDirectoriesField.setAccessible(true); + repositoriesField.setAccessible(true); + + previousLocalGameDirectories = localGameDirectoriesField.get(null); + previousUserGameDirectories = userGameDirectoriesField.get(null); + previousLauncherSettings = launcherSettingsField.get(null); + previousGameSettingsPresets = gameSettingsPresetsField.get(null); + previousLocalGameDirectoriesAccess = (SettingFileAccess) localGameDirectoriesAccessField.get(null); + previousUserGameDirectoriesAccess = (SettingFileAccess) userGameDirectoriesAccessField.get(null); + previousInitialized = initializedField.getBoolean(null); + + @SuppressWarnings("unchecked") + ObjectProperty selectedGameDirectory = + (ObjectProperty) selectedGameDirectoryField.get(null); + this.selectedGameDirectory = selectedGameDirectory; + previousSelectedGameDirectory = selectedGameDirectory.get(); + + @SuppressWarnings("unchecked") + ObjectProperty selectedRepository = + (ObjectProperty) selectedRepositoryField.get(null); + this.selectedRepository = selectedRepository; + previousSelectedRepository = selectedRepository.get(); + + @SuppressWarnings("unchecked") + ObservableList mergedGameDirectories = + (ObservableList) mergedGameDirectoriesField.get(null); + this.mergedGameDirectories = mergedGameDirectories; + previousMergedGameDirectories = List.copyOf(mergedGameDirectories); + + @SuppressWarnings("unchecked") + Map repositories = + (Map) repositoriesField.get(null); + this.repositories = repositories; + previousRepositories = Map.copyOf(repositories); + + localGameDirectoriesField.set(null, localDirectories); + userGameDirectoriesField.set(null, userDirectories); + launcherSettingsField.set(null, new LauncherSettings()); + gameSettingsPresetsField.set(null, gameSettingsPresets); + localGameDirectoriesAccessField.set(null, SettingFileAccess.READ_WRITE); + userGameDirectoriesAccessField.set(null, SettingFileAccess.READ_WRITE); + initializedField.setBoolean(null, false); + mergedGameDirectories.clear(); + repositories.clear(); + selectedRepository.set(null); + } + + /// Sets the local game directories access used by [SettingsManager]. + public void setLocalGameDirectoriesAccess(SettingFileAccess access) throws IllegalAccessException { + localGameDirectoriesAccessField.set(null, access); + } + + /// Sets the user game directories access used by [SettingsManager]. + public void setUserGameDirectoriesAccess(SettingFileAccess access) throws IllegalAccessException { + userGameDirectoriesAccessField.set(null, access); + } + + /// Restores the previous static state. + @Override + public void close() throws ReflectiveOperationException { + if (previousSelectedGameDirectory != null) { + selectedGameDirectory.set(previousSelectedGameDirectory); + } + selectedRepository.set(previousSelectedRepository); + mergedGameDirectories.setAll(previousMergedGameDirectories); + repositories.clear(); + repositories.putAll(previousRepositories); + localGameDirectoriesField.set(null, previousLocalGameDirectories); + userGameDirectoriesField.set(null, previousUserGameDirectories); + launcherSettingsField.set(null, previousLauncherSettings); + gameSettingsPresetsField.set(null, previousGameSettingsPresets); + localGameDirectoriesAccessField.set(null, previousLocalGameDirectoriesAccess); + userGameDirectoriesAccessField.set(null, previousUserGameDirectoriesAccess); + initializedField.setBoolean(null, previousInitialized); + } +} diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/ui/directory/GameDirectoryListItemTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/ui/directory/GameDirectoryListItemTest.java new file mode 100644 index 0000000000..ca11f85b2a --- /dev/null +++ b/HMCL/src/test/java/org/jackhuang/hmcl/ui/directory/GameDirectoryListItemTest.java @@ -0,0 +1,97 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.directory; + +import javafx.application.Platform; +import org.jackhuang.hmcl.setting.GameDirectory; +import org.jackhuang.hmcl.setting.GameDirectoryID; +import org.jackhuang.hmcl.setting.GameDirectoryManager; +import org.jackhuang.hmcl.setting.GameDirectories; +import org.jackhuang.hmcl.setting.GameDirectoryTestEnvironment; +import org.jackhuang.hmcl.util.PortablePath; +import org.jackhuang.hmcl.util.i18n.LocalizedText; +import org.jetbrains.annotations.NotNullByDefault; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIf; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Verifies game directory rows track the selected game directory by ID. +@NotNullByDefault +@EnabledIf("org.jackhuang.hmcl.JavaFXLauncher#isStarted") +public final class GameDirectoryListItemTest { + + /// Verifies a row stays selected when the merged entry is replaced by another instance with the same ID. + @Test + public void selectionFollowsGameDirectoryIdInsteadOfInstanceIdentity() throws ReflectiveOperationException { + GameDirectoryID id = GameDirectoryID.parse("game-directory:123e4567-e89b-12d3-a456-426614174000"); + GameDirectory selectedGameDirectory = new GameDirectory( + id, LocalizedText.plain("Dev"), PortablePath.of("local/Dev")); + GameDirectory otherGameDirectory = new GameDirectory( + GameDirectoryID.parse("game-directory:123e4567-e89b-12d3-a456-426614174001"), + LocalizedText.plain("Other"), + PortablePath.of("local/Other")); + GameDirectories userDirectories = GameDirectoryTestEnvironment.newUserGameDirectories(); + GameDirectories localDirectories = GameDirectoryTestEnvironment.newLocalGameDirectories(); + localDirectories.getGameDirectories().add(selectedGameDirectory); + localDirectories.getGameDirectories().add(otherGameDirectory); + + try (GameDirectoryTestEnvironment ignored = new GameDirectoryTestEnvironment(localDirectories, userDirectories)) { + GameDirectoryManager.init(); + GameDirectoryManager.setSelectedGameDirectory(selectedGameDirectory); + + onFxThread(() -> { + GameDirectoryListItem selectedItem = new GameDirectoryListItem(selectedGameDirectory); + GameDirectoryListItem otherItem = new GameDirectoryListItem(otherGameDirectory); + + assertTrue(selectedItem.isSelected()); + assertFalse(otherItem.isSelected()); + + // The selected entry is replaced by a new instance carrying the same ID. + GameDirectory replacement = new GameDirectory( + id, LocalizedText.plain("Dev"), PortablePath.of("local/Dev")); + GameDirectoryManager.addLocalGameDirectory(replacement); + assertSame(replacement, GameDirectoryManager.getSelectedGameDirectory()); + + assertTrue(selectedItem.isSelected()); + assertFalse(otherItem.isSelected()); + }); + } + } + + /// Runs assertions on the JavaFX thread and waits up to 30 seconds for completion. + private static void onFxThread(Runnable action) { + FutureTask task = new FutureTask<>(action, true); + Platform.runLater(task); + try { + task.get(30, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } catch (ExecutionException | TimeoutException e) { + throw new AssertionError(e); + } + } +}