diff --git a/src/main/java/kst4contest/view/CommandLineOptions.java b/src/main/java/kst4contest/view/CommandLineOptions.java new file mode 100644 index 00000000..b0c571b2 --- /dev/null +++ b/src/main/java/kst4contest/view/CommandLineOptions.java @@ -0,0 +1,102 @@ +package kst4contest.view; + +import java.util.List; + +/** + * Command line options of the application. + * + *

The parsed value is additionally kept in a process wide holder. JavaFX only knows + * the parameters of the {@code Application} instance it launched itself, so an instance + * created during a profile switch would see no parameters at all. Parsing once at + * startup and remembering the result avoids that entirely.

+ */ +public class CommandLineOptions { + + /** + * Command line switch selecting the operator profile to start with. + */ + public static final String PROFILE_ARGUMENT = "--profile"; + + /** + * System property used as an alternative to the command line switch. + */ + public static final String PROFILE_SYSTEM_PROPERTY = "kst4contest.profile"; + + private static volatile CommandLineOptions rememberedOptions = new CommandLineOptions(null); + + private final String requestedProfileName; + + public CommandLineOptions(final String requestedProfileName) { + this.requestedProfileName = requestedProfileName; + } + + /** + * Parses the raw application arguments. + * + *

Unknown arguments are ignored on purpose. A typo in a command line must never + * keep an operator out of the application shortly before a contest.

+ * + * @param rawArguments raw arguments, may be null + * @return the parsed options + */ + public static CommandLineOptions parse(final List rawArguments) { + + String requestedProfileName = null; + + if (rawArguments != null) { + for (int argumentIndex = 0; argumentIndex < rawArguments.size(); argumentIndex++) { + String currentArgument = rawArguments.get(argumentIndex); + + if (currentArgument == null) { + continue; + } + + if (currentArgument.startsWith(PROFILE_ARGUMENT + "=")) { + requestedProfileName = currentArgument.substring(PROFILE_ARGUMENT.length() + 1); + } else if (PROFILE_ARGUMENT.equals(currentArgument) + && argumentIndex + 1 < rawArguments.size()) { + requestedProfileName = rawArguments.get(argumentIndex + 1); + argumentIndex++; + } + } + } + + if (requestedProfileName == null || requestedProfileName.isBlank()) { + requestedProfileName = System.getProperty(PROFILE_SYSTEM_PROPERTY); + } + + if (requestedProfileName != null && requestedProfileName.isBlank()) { + requestedProfileName = null; + } + + return new CommandLineOptions( + requestedProfileName == null ? null : requestedProfileName.trim()); + } + + /** + * Stores the parsed options for the lifetime of the process. + * + * @param options options to remember + */ + public static void remember(final CommandLineOptions options) { + rememberedOptions = options == null ? new CommandLineOptions(null) : options; + } + + /** + * Returns the options parsed at application startup. + * + * @return the remembered options, never null + */ + public static CommandLineOptions remembered() { + return rememberedOptions; + } + + /** + * Returns the operator profile requested on the command line. + * + * @return the requested profile name, or null when none was given + */ + public String getRequestedProfileName() { + return requestedProfileName; + } +} diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java index 91debdc6..fcdcaf00 100644 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -70,6 +70,9 @@ import javafx.stage.Screen; import kst4contest.logic.BandOpportunityResolver; import kst4contest.utils.ApplicationFileUtils; import kst4contest.view.map.StationMapBridge; +import kst4contest.controller.ActiveOperatorProfile; +import kst4contest.controller.OperatorProfileStore; +import kst4contest.model.OperatorProfileSelection; import kst4contest.view.map.StationMapView; import kst4contest.view.map.OfflineDemImportService; import kst4contest.controller.WorkedGrossFieldCache; @@ -6227,6 +6230,66 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return txMessageButtons; } + /** + * Resolves the operator profile this runtime works with. + * + *

Only executed on the very first launch. A profile switch sets the profile before + * building the new runtime, so the resolution is skipped there.

+ * + *

An installation with no or exactly one profile is resolved without asking + * anything, which keeps the single operator startup exactly as it was.

+ * + * @return true if the application may continue starting up + */ + private boolean resolveOperatorProfileIfRequired() { + + if (ActiveOperatorProfile.isInitialized()) { + return true; + } + + OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap(); + OperatorProfileSelection resolvedProfile = bootstrap.resolveAtStartup( + new OperatorProfileStore(), + CommandLineOptions.remembered(), + OperatorProfilePickerDialog::showAndSelect); + + if (bootstrap.getStartupWarning() != null) { + Alert startupWarning = new Alert(AlertType.WARNING); + startupWarning.setTitle("Operator profile"); + startupWarning.setHeaderText("The requested operator profile was not found."); + startupWarning.setContentText(bootstrap.getStartupWarning()); + startupWarning.showAndWait(); + } + + if (resolvedProfile == null) { + Platform.exit(); + System.exit(0); + return false; + } + + ActiveOperatorProfile.set(resolvedProfile); + return true; + } + + /** + * Returns the window title suffix naming the active operator profile. + * + *

Empty for the historic single profile installation, so nothing changes visually + * for operators who never create a second profile.

+ * + * @return the suffix to append to a window title, never null + */ + private String buildOperatorProfileTitleSuffix() { + + OperatorProfileSelection activeProfile = ActiveOperatorProfile.get(); + + if (activeProfile == null || activeProfile.getProfile().isRootProfile()) { + return ""; + } + + return " - " + activeProfile.getProfile().getDisplayName(); + } + @Override public void stop() { System.out.println("[Main.java, Info:] Stage is closing, killing all resources"); @@ -6644,9 +6707,22 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL }; } + @Override + public void init() { + + Parameters applicationParameters = getParameters(); + + CommandLineOptions.remember(CommandLineOptions.parse( + applicationParameters == null ? null : applicationParameters.getRaw())); + } + @Override public void start(Stage primaryStage) throws InterruptedException, IOException, URISyntaxException { + if (!resolveOperatorProfileIfRequired()) { + return; + } + GuiUtils.applyApplicationIcon(primaryStage); VBox pnl_inputAndSendButtons = new VBox(); //gets the sendtext field, send button and the timeline @@ -6681,8 +6757,15 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, STYLE_DEFAULTCSSDAY_RESOURCE, STYLE_DEFAULTCSSDAY_FILE); ApplicationFileUtils.copyResourceIfRequired(ApplicationConstants.APPLICATION_NAME, STYLE_DEFAULTCSSEVENING_RESOURCE, STYLE_DEFAULTCSSEVENING_FILE); ChatMember ownChatMemberObject = new ChatMember(); + OperatorProfileSelection activeOperatorProfile = ActiveOperatorProfile.get(); - chatcontroller = new ChatController(ownChatMemberObject, this); // instantiate the Chatcontroller with the user object + // instantiate the Chatcontroller with the user object and the files of the active profile + chatcontroller = new ChatController( + ownChatMemberObject, + this, + activeOperatorProfile.getPreferencesRelativeFileName(), + activeOperatorProfile.getWorkedDatabaseRelativeFileName(), + activeOperatorProfile.isSeedWorkedDatabaseFromResource()); layoutAutosave = new LayoutAutosave(chatcontroller.getChatPreferences()); messageVariableResolver = new MessageVariableResolver(chatcontroller.getChatPreferences()); chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual @@ -7200,7 +7283,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL txt_ownqrgSecondCategory.setFocusTraversable(false); txt_ownqrgSecondCategory.setTooltip(new Tooltip("Enter frequency for second chat-category here by hand! ")); - primaryStage.setTitle(chatcontroller.getChatPreferences().getChatState()); + primaryStage.setTitle(chatcontroller.getChatPreferences().getChatState() + buildOperatorProfileTitleSuffix()); timer_buildWindowTitle = new Timer(); timer_buildWindowTitle.scheduleAtFixedRate(new TimerTask() { @@ -7255,7 +7338,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL chatcontroller.getChatPreferences().setChatState(chatState); } - primaryStage.setTitle(chatcontroller.getChatPreferences().getChatState()); + primaryStage.setTitle(chatcontroller.getChatPreferences().getChatState() + buildOperatorProfileTitleSuffix()); // System.out.println(chatcontroller.getChatPreferences().getChatState()); }); diff --git a/src/main/java/kst4contest/view/OperatorProfileBootstrap.java b/src/main/java/kst4contest/view/OperatorProfileBootstrap.java new file mode 100644 index 00000000..22e022bb --- /dev/null +++ b/src/main/java/kst4contest/view/OperatorProfileBootstrap.java @@ -0,0 +1,107 @@ +package kst4contest.view; + +import kst4contest.controller.OperatorProfilePaths; +import kst4contest.controller.OperatorProfileStore; +import kst4contest.model.OperatorProfile; +import kst4contest.model.OperatorProfileSelection; + +import java.util.List; +import java.util.Optional; + +/** + * Decides which operator profile the application starts with. + * + *

The class contains no user interface code so the decision can be tested headless. + * Asking the operator is delegated to an {@link OperatorProfileChoiceRequester}, and a + * problem worth telling the operator about is reported through + * {@link #getStartupWarning()} instead of being shown here.

+ * + *

The most important property of this logic is what it does not do: an + * installation with no or exactly one profile is resolved without asking anything and + * without touching a single file, so a single operator start stays exactly as fast and + * as quiet as it was before profiles existed.

+ */ +public class OperatorProfileBootstrap { + + private String startupWarning; + + /** + * Resolves the operator profile to start with. + * + * @param store registry to read the profiles from + * @param commandLineOptions parsed command line options + * @param choiceRequester requester used when the operator has to choose + * @return the resolved selection, or null when the operator chose to quit + */ + public OperatorProfileSelection resolveAtStartup(final OperatorProfileStore store, + final CommandLineOptions commandLineOptions, + final OperatorProfileChoiceRequester choiceRequester) { + + startupWarning = null; + + List availableProfiles = store.loadProfiles(); + String requestedProfileName = commandLineOptions == null + ? null + : commandLineOptions.getRequestedProfileName(); + + if (requestedProfileName != null) { + OperatorProfile requestedProfile = findProfile(availableProfiles, requestedProfileName); + + if (requestedProfile != null) { + return OperatorProfilePaths.resolve(requestedProfile); + } + + startupWarning = "The operator profile \"" + requestedProfileName + + "\" is unknown. KST4Contest continues with the normal profile selection."; + } + + if (availableProfiles.isEmpty()) { + // No registry at all: the historic flat installation is the only profile. + return OperatorProfilePaths.resolve(store.buildImplicitRootProfile()); + } + + if (availableProfiles.size() == 1) { + return OperatorProfilePaths.resolve(availableProfiles.get(0)); + } + + String preselectedProfileId = store.loadLastUsedProfileId().orElse(null); + Optional chosenProfile = + choiceRequester.requestProfileChoice(availableProfiles, preselectedProfileId); + + return chosenProfile.map(OperatorProfilePaths::resolve).orElse(null); + } + + /** + * Returns a message that should be shown to the operator after startup. + * + * @return the warning text, or null when startup was unremarkable + */ + public String getStartupWarning() { + return startupWarning; + } + + /** + * Finds a profile by identifier or display name, ignoring case. + * + * @param availableProfiles profiles to search + * @param requestedName identifier or display name entered by the operator + * @return the matching profile, or null + */ + private static OperatorProfile findProfile(final List availableProfiles, + final String requestedName) { + + for (OperatorProfile currentProfile : availableProfiles) { + if (requestedName.equalsIgnoreCase(currentProfile.getProfileId())) { + return currentProfile; + } + } + + for (OperatorProfile currentProfile : availableProfiles) { + if (requestedName.equalsIgnoreCase(currentProfile.getDisplayName())) { + return currentProfile; + } + } + + return null; + } +} diff --git a/src/main/java/kst4contest/view/OperatorProfileChoiceRequester.java b/src/main/java/kst4contest/view/OperatorProfileChoiceRequester.java new file mode 100644 index 00000000..1f026cca --- /dev/null +++ b/src/main/java/kst4contest/view/OperatorProfileChoiceRequester.java @@ -0,0 +1,26 @@ +package kst4contest.view; + +import kst4contest.model.OperatorProfile; + +import java.util.List; +import java.util.Optional; + +/** + * Asks the operator which profile to start with. + * + *

The startup logic depends on this interface rather than on a dialog, so the + * decision which profile to use can be tested without a JavaFX runtime.

+ */ +@FunctionalInterface +public interface OperatorProfileChoiceRequester { + + /** + * Requests a profile choice. + * + * @param selectableProfiles profiles to choose from, never empty + * @param preselectedProfileId identifier to preselect, may be null + * @return the chosen profile, or empty when the operator wants to quit + */ + Optional requestProfileChoice(List selectableProfiles, + String preselectedProfileId); +} diff --git a/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java b/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java new file mode 100644 index 00000000..d9710159 --- /dev/null +++ b/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java @@ -0,0 +1,143 @@ +package kst4contest.view; + +import kst4contest.model.OperatorProfile; + +import javafx.geometry.Insets; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.input.KeyCode; +import javafx.scene.input.MouseButton; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Modality; +import javafx.stage.Stage; + +import java.util.List; +import java.util.Optional; + +/** + * Asks the operator which profile to start with. + * + *

The dialog is shown only when more than one profile exists. It is intentionally + * minimal, because it stands between the operator and a contest: the last used profile + * is preselected, the list has the focus, and Enter or a double click start immediately.

+ */ +public final class OperatorProfilePickerDialog { + + private OperatorProfilePickerDialog() { + // Utility class. + } + + /** + * Shows the picker and waits for the operator's choice. + * + * @param selectableProfiles profiles to choose from + * @param preselectedProfileId identifier of the profile to preselect, may be null + * @return the chosen profile, or empty when the operator wants to quit + */ + public static Optional showAndSelect(final List selectableProfiles, + final String preselectedProfileId) { + + Stage dialogStage = new Stage(); + GuiUtils.applyApplicationIcon(dialogStage); + dialogStage.initModality(Modality.APPLICATION_MODAL); + dialogStage.setTitle("Select operator profile"); + + ListView profileListView = new ListView<>(); + profileListView.getItems().addAll(selectableProfiles); + profileListView.setCellFactory(listView -> new OperatorProfileListCell()); + VBox.setVgrow(profileListView, Priority.ALWAYS); + + selectPreselectedProfile(profileListView, selectableProfiles, preselectedProfileId); + + OperatorProfile[] chosenProfile = new OperatorProfile[1]; + + Button startButton = new Button("Start"); + startButton.setDefaultButton(true); + startButton.setOnAction(event -> { + chosenProfile[0] = profileListView.getSelectionModel().getSelectedItem(); + dialogStage.close(); + }); + + Button quitButton = new Button("Quit"); + quitButton.setCancelButton(true); + quitButton.setOnAction(event -> { + chosenProfile[0] = null; + dialogStage.close(); + }); + + profileListView.setOnMouseClicked(event -> { + if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) { + startButton.fire(); + } + }); + + profileListView.setOnKeyPressed(event -> { + if (event.getCode() == KeyCode.ENTER) { + startButton.fire(); + } + }); + + HBox buttonRow = new HBox(10, startButton, quitButton); + buttonRow.setPadding(new Insets(10, 0, 0, 0)); + + VBox dialogContent = new VBox(8, + new Label("More than one operator profile is configured."), + profileListView, + buttonRow); + dialogContent.setPadding(new Insets(15)); + + dialogStage.setScene(new Scene(dialogContent, 380, 280)); + profileListView.requestFocus(); + dialogStage.showAndWait(); + + return Optional.ofNullable(chosenProfile[0]); + } + + private static void selectPreselectedProfile(final ListView profileListView, + final List selectableProfiles, + final String preselectedProfileId) { + + int profileIndexToSelect = 0; + + if (preselectedProfileId != null) { + for (int profileIndex = 0; profileIndex < selectableProfiles.size(); profileIndex++) { + if (preselectedProfileId.equalsIgnoreCase( + selectableProfiles.get(profileIndex).getProfileId())) { + profileIndexToSelect = profileIndex; + break; + } + } + } + + profileListView.getSelectionModel().select(profileIndexToSelect); + profileListView.scrollTo(profileIndexToSelect); + } + + /** + * Renders a profile with its name and the kind of worked data it uses. + */ + private static final class OperatorProfileListCell extends ListCell { + + @Override + protected void updateItem(final OperatorProfile profile, final boolean empty) { + + super.updateItem(profile, empty); + + if (empty || profile == null) { + setText(null); + return; + } + + String workedDataDescription = profile.isRootProfile() || profile.isSharedWorkedDatabase() + ? "shared station worked database" + : "own worked database"; + + setText(profile.getDisplayName() + "\n" + workedDataDescription); + } + } +} diff --git a/src/test/java/kst4contest/view/CommandLineOptionsTest.java b/src/test/java/kst4contest/view/CommandLineOptionsTest.java new file mode 100644 index 00000000..e9256c02 --- /dev/null +++ b/src/test/java/kst4contest/view/CommandLineOptionsTest.java @@ -0,0 +1,46 @@ +package kst4contest.view; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class CommandLineOptionsTest { + + @Test + void profileArgumentIsAcceptedInBothSpellings() { + assertEquals("OP2", CommandLineOptions.parse(List.of("--profile=OP2")).getRequestedProfileName()); + assertEquals("OP2", CommandLineOptions.parse(List.of("--profile", "OP2")).getRequestedProfileName()); + assertEquals("DM5M Contest", + CommandLineOptions.parse(List.of("--profile", "DM5M Contest")).getRequestedProfileName()); + } + + @Test + void missingOrEmptyProfileArgumentsAreTreatedAsAbsent() { + assertNull(CommandLineOptions.parse(List.of()).getRequestedProfileName()); + assertNull(CommandLineOptions.parse(null).getRequestedProfileName()); + assertNull(CommandLineOptions.parse(List.of("--profile=")).getRequestedProfileName()); + assertNull(CommandLineOptions.parse(List.of("--profile")).getRequestedProfileName()); + } + + @Test + void unrelatedArgumentsAreIgnoredInsteadOfFailing() { + assertEquals("OP2", + CommandLineOptions.parse(List.of("--verbose", "--profile=OP2", "somefile.adi")) + .getRequestedProfileName()); + assertNull(CommandLineOptions.parse(List.of("--verbose", "-x")).getRequestedProfileName()); + } + + @Test + void rememberedOptionsDefaultToEmptyInsteadOfNull() { + CommandLineOptions.remember(null); + assertNull(CommandLineOptions.remembered().getRequestedProfileName()); + + CommandLineOptions.remember(new CommandLineOptions("OP2")); + assertEquals("OP2", CommandLineOptions.remembered().getRequestedProfileName()); + + CommandLineOptions.remember(null); + } +} diff --git a/src/test/java/kst4contest/view/OperatorProfileBootstrapTest.java b/src/test/java/kst4contest/view/OperatorProfileBootstrapTest.java new file mode 100644 index 00000000..908a68c9 --- /dev/null +++ b/src/test/java/kst4contest/view/OperatorProfileBootstrapTest.java @@ -0,0 +1,161 @@ +package kst4contest.view; + +import kst4contest.controller.OperatorProfilePaths; +import kst4contest.controller.OperatorProfileStore; +import kst4contest.model.OperatorProfile; +import kst4contest.model.OperatorProfileSelection; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OperatorProfileBootstrapTest { + + @TempDir + Path temporaryDirectory; + + @Test + void installationWithoutRegistryStartsSilentlyOnTheHistoricLayout() { + + AtomicInteger pickerInvocations = new AtomicInteger(); + OperatorProfileStore store = storeAt(); + + OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup( + store, new CommandLineOptions(null), countingPicker(pickerInvocations, null)); + + assertNotNull(resolved); + assertEquals("preferences.xml", resolved.getPreferencesRelativeFileName()); + assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName()); + + // Nothing may be asked, and nothing may be written. + assertEquals(0, pickerInvocations.get()); + assertTrue(store.loadProfiles().isEmpty()); + } + + @Test + void singleProfileStartsWithoutAskingAnything() { + + AtomicInteger pickerInvocations = new AtomicInteger(); + OperatorProfileStore store = storeAt(); + store.saveProfiles(List.of(OperatorProfilePaths.buildRootProfile("Default")), "default"); + + OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup( + store, new CommandLineOptions(null), countingPicker(pickerInvocations, null)); + + assertNotNull(resolved); + assertEquals(0, pickerInvocations.get()); + } + + @Test + void twoProfilesAskTheOperatorAndPreselectTheLastUsedOne() { + + OperatorProfileStore store = storeAt(); + OperatorProfile secondProfile = new OperatorProfile("OP2", "DN9APW", false, false); + store.saveProfiles( + List.of(OperatorProfilePaths.buildRootProfile("Default"), secondProfile), "OP2"); + + AtomicInteger pickerInvocations = new AtomicInteger(); + String[] observedPreselection = new String[1]; + + OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup( + store, + new CommandLineOptions(null), + (profiles, preselectedProfileId) -> { + pickerInvocations.incrementAndGet(); + observedPreselection[0] = preselectedProfileId; + return Optional.of(profiles.get(1)); + }); + + assertEquals(1, pickerInvocations.get()); + assertEquals("OP2", observedPreselection[0]); + assertNotNull(resolved); + assertEquals("profiles/OP2/praktiKST.db", resolved.getWorkedDatabaseRelativeFileName()); + } + + @Test + void aValidProfileArgumentSkipsThePicker() { + + OperatorProfileStore store = storeAt(); + store.saveProfiles( + List.of(OperatorProfilePaths.buildRootProfile("Default"), + new OperatorProfile("OP2", "DN9APW", false, false)), + "default"); + + AtomicInteger pickerInvocations = new AtomicInteger(); + OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap(); + + OperatorProfileSelection byId = bootstrap.resolveAtStartup( + store, new CommandLineOptions("op2"), countingPicker(pickerInvocations, null)); + + assertEquals(0, pickerInvocations.get()); + assertEquals("profiles/OP2/preferences.xml", byId.getPreferencesRelativeFileName()); + assertNull(bootstrap.getStartupWarning()); + + OperatorProfileSelection byDisplayName = bootstrap.resolveAtStartup( + store, new CommandLineOptions("DN9APW"), countingPicker(pickerInvocations, null)); + + assertEquals(0, pickerInvocations.get()); + assertEquals("profiles/OP2/preferences.xml", byDisplayName.getPreferencesRelativeFileName()); + } + + @Test + void anUnknownProfileArgumentWarnsAndFallsBackToTheNormalSelection() { + + OperatorProfileStore store = storeAt(); + store.saveProfiles( + List.of(OperatorProfilePaths.buildRootProfile("Default"), + new OperatorProfile("OP2", "DN9APW", false, false)), + "default"); + + AtomicInteger pickerInvocations = new AtomicInteger(); + OperatorProfileBootstrap bootstrap = new OperatorProfileBootstrap(); + + OperatorProfileSelection resolved = bootstrap.resolveAtStartup( + store, new CommandLineOptions("NOPE"), countingPicker(pickerInvocations, 0)); + + assertEquals(1, pickerInvocations.get()); + assertNotNull(resolved); + assertNotNull(bootstrap.getStartupWarning()); + assertTrue(bootstrap.getStartupWarning().contains("NOPE")); + } + + @Test + void quittingInThePickerYieldsNoSelection() { + + OperatorProfileStore store = storeAt(); + store.saveProfiles( + List.of(OperatorProfilePaths.buildRootProfile("Default"), + new OperatorProfile("OP2", "DN9APW", false, false)), + "default"); + + OperatorProfileSelection resolved = new OperatorProfileBootstrap().resolveAtStartup( + store, new CommandLineOptions(null), (profiles, preselected) -> Optional.empty()); + + assertNull(resolved); + } + + private OperatorProfileChoiceRequester countingPicker(final AtomicInteger invocationCounter, + final Integer profileIndexToChoose) { + return (profiles, preselectedProfileId) -> { + invocationCounter.incrementAndGet(); + + if (profileIndexToChoose == null) { + return Optional.of(profiles.get(0)); + } + + return Optional.of(profiles.get(profileIndexToChoose)); + }; + } + + private OperatorProfileStore storeAt() { + return new OperatorProfileStore(temporaryDirectory.resolve("profiles.xml").toString()); + } +}