diff --git a/src/main/java/kst4contest/controller/ActiveOperatorProfile.java b/src/main/java/kst4contest/controller/ActiveOperatorProfile.java new file mode 100644 index 00000000..9a1febd5 --- /dev/null +++ b/src/main/java/kst4contest/controller/ActiveOperatorProfile.java @@ -0,0 +1,48 @@ +package kst4contest.controller; + +import kst4contest.model.OperatorProfileSelection; + +/** + * Holds the operator profile the current runtime works with. + * + *

The state is deliberately static, which is safe here for reasons that did not apply + * to the former static database connection: the value is immutable, it holds no live + * resource, and it is set on the JavaFX Application Thread before anything reads it - + * during startup, and again during a profile switch after the previous runtime has been + * shut down completely.

+ */ +public final class ActiveOperatorProfile { + + private static volatile OperatorProfileSelection currentSelection; + + private ActiveOperatorProfile() { + // Utility class. + } + + /** + * Returns the active profile selection. + * + * @return the active selection, or null when startup has not resolved one yet + */ + public static OperatorProfileSelection get() { + return currentSelection; + } + + /** + * Sets the active profile selection. + * + * @param selection selection to activate + */ + public static void set(final OperatorProfileSelection selection) { + currentSelection = selection; + } + + /** + * Returns whether a profile has already been resolved for this runtime. + * + * @return true if a selection is present + */ + public static boolean isInitialized() { + return currentSelection != null; + } +} diff --git a/src/main/java/kst4contest/controller/OperatorProfilePaths.java b/src/main/java/kst4contest/controller/OperatorProfilePaths.java new file mode 100644 index 00000000..effdb2a6 --- /dev/null +++ b/src/main/java/kst4contest/controller/OperatorProfilePaths.java @@ -0,0 +1,199 @@ +package kst4contest.controller; + +import kst4contest.model.ChatPreferences; +import kst4contest.model.OperatorProfile; +import kst4contest.model.OperatorProfileSelection; + +import java.util.Collection; +import java.util.Locale; + +/** + * Derives the file names of an operator profile. + * + *

This is the single place that knows how a profile maps onto files. The registry + * stores only the shared/own flag, never a path, so the two can never drift apart.

+ */ +public final class OperatorProfilePaths { + + /** + * Directory below the application directory that holds the additional profiles. + */ + public static final String PROFILES_DIRECTORY = "profiles"; + + /** + * Identifier of the profile that uses the historic flat installation layout. + */ + public static final String ROOT_PROFILE_ID = "default"; + + /** + * Maximum length of a generated profile identifier. + */ + private static final int MAX_PROFILE_ID_LENGTH = 32; + + private OperatorProfilePaths() { + // Utility class. + } + + /** + * Builds the profile descriptor of the historic flat installation. + * + * @param displayName name to show for the root profile + * @return the root profile descriptor + */ + public static OperatorProfile buildRootProfile(final String displayName) { + return new OperatorProfile(ROOT_PROFILE_ID, displayName, true, true); + } + + /** + * Returns the profile directory relative to the application directory. + * + * @param profile profile to resolve + * @return relative directory name + */ + public static String profileRelativeDirectory(final OperatorProfile profile) { + return PROFILES_DIRECTORY + "/" + profile.getProfileId(); + } + + /** + * Returns the preferences file name relative to the application directory. + * + * @param profile profile to resolve + * @return relative preferences file name + */ + public static String preferencesRelativeFileName(final OperatorProfile profile) { + + if (profile.isRootProfile()) { + return ChatPreferences.PREFERENCES_FILE; + } + + return profileRelativeDirectory(profile) + "/" + ChatPreferences.PREFERENCES_FILE; + } + + /** + * Returns the worked-station database file name relative to the application directory. + * + *

A profile using the common station database always resolves to the historic flat + * file, which is what a multi operator station wants: the existing contest state stays + * the shared one.

+ * + * @param profile profile to resolve + * @return relative database file name + */ + public static String workedDatabaseRelativeFileName(final OperatorProfile profile) { + + if (profile.isRootProfile() || profile.isSharedWorkedDatabase()) { + return DBController.DATABASE_FILE; + } + + return profileRelativeDirectory(profile) + "/" + DBController.DATABASE_FILE; + } + + /** + * Resolves a profile descriptor into the runtime selection used during startup. + * + * @param profile profile to resolve + * @return resolved selection + */ + public static OperatorProfileSelection resolve(final OperatorProfile profile) { + + boolean usesSharedStationDatabase = profile.isRootProfile() || profile.isSharedWorkedDatabase(); + + return new OperatorProfileSelection( + profile, + preferencesRelativeFileName(profile), + workedDatabaseRelativeFileName(profile), + usesSharedStationDatabase + ); + } + + /** + * Derives a stable, file system safe identifier from a display name. + * + *

The identifier becomes a directory name and is never changed afterwards, so a + * later rename of the profile does not move any file.

+ * + * @param displayName name entered by the operator + * @param takenProfileIds identifiers that are already in use + * @return an identifier that is not yet taken + */ + public static String toProfileId(final String displayName, final Collection takenProfileIds) { + + StringBuilder sanitized = new StringBuilder(); + + if (displayName != null) { + String foldedDisplayName = foldGermanUmlauts(displayName.toUpperCase(Locale.ROOT)); + + for (char currentCharacter : foldedDisplayName.toCharArray()) { + boolean isAcceptable = (currentCharacter >= 'A' && currentCharacter <= 'Z') + || (currentCharacter >= '0' && currentCharacter <= '9') + || currentCharacter == '-'; + + if (isAcceptable) { + sanitized.append(currentCharacter); + } else if (sanitized.length() > 0 && sanitized.charAt(sanitized.length() - 1) != '_') { + sanitized.append('_'); + } + } + } + + while (sanitized.length() > 0 && sanitized.charAt(sanitized.length() - 1) == '_') { + sanitized.setLength(sanitized.length() - 1); + } + + if (sanitized.length() > MAX_PROFILE_ID_LENGTH) { + sanitized.setLength(MAX_PROFILE_ID_LENGTH); + } + + String candidate = sanitized.toString(); + + if (candidate.isEmpty() || ROOT_PROFILE_ID.equalsIgnoreCase(candidate)) { + candidate = "OP"; + } + + if (!isProfileIdTaken(candidate, takenProfileIds)) { + return candidate; + } + + int suffix = 2; + + while (isProfileIdTaken(candidate + "_" + suffix, takenProfileIds)) { + suffix++; + } + + return candidate + "_" + suffix; + } + + /** + * Folds German umlauts so a name like "Muller" written with an umlaut still produces a + * readable identifier instead of a placeholder character. + * + * @param upperCaseText already upper-cased text + * @return text with umlauts replaced by their base letters + */ + private static String foldGermanUmlauts(final String upperCaseText) { + return upperCaseText + .replace("\u00C4", "A") + .replace("\u00D6", "O") + .replace("\u00DC", "U") + .replace("\u00DF", "SS"); + } + + private static boolean isProfileIdTaken(final String candidate, final Collection takenProfileIds) { + + if (ROOT_PROFILE_ID.equalsIgnoreCase(candidate)) { + return true; + } + + if (takenProfileIds == null) { + return false; + } + + for (String takenProfileId : takenProfileIds) { + if (candidate.equalsIgnoreCase(takenProfileId)) { + return true; + } + } + + return false; + } +} diff --git a/src/main/java/kst4contest/controller/OperatorProfileStore.java b/src/main/java/kst4contest/controller/OperatorProfileStore.java new file mode 100644 index 00000000..b2354e19 --- /dev/null +++ b/src/main/java/kst4contest/controller/OperatorProfileStore.java @@ -0,0 +1,380 @@ +package kst4contest.controller; + +import kst4contest.ApplicationConstants; +import kst4contest.model.OperatorProfile; +import kst4contest.utils.ApplicationFileUtils; + +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; + +import java.io.File; +import java.io.OutputStream; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Reads and writes the operator profile registry. + * + *

The registry file is created lazily. As long as an installation has only the + * historic flat layout, no registry exists and none is written, so a single operator + * installation behaves exactly as before. The file appears when the second profile is + * created; at that moment the root profile is materialised as well.

+ * + *

A missing, unreadable or malformed registry is never fatal. It is logged and + * treated like an installation without additional profiles.

+ */ +public class OperatorProfileStore { + + private static final Logger LOGGER = Logger.getLogger(OperatorProfileStore.class.getName()); + + /** + * Name of the registry file inside the application directory. + */ + public static final String PROFILES_REGISTRY_FILE = "profiles.xml"; + + private static final String TAG_ROOT = "praktiKSTProfiles"; + private static final String TAG_REGISTRY_VERSION = "registryVersion"; + private static final String TAG_LAST_USED_PROFILE_ID = "lastUsedProfileId"; + private static final String TAG_PROFILE = "profile"; + private static final String TAG_PROFILE_ID = "profileId"; + private static final String TAG_DISPLAY_NAME = "displayName"; + private static final String TAG_ROOT_PROFILE = "rootProfile"; + private static final String TAG_SHARED_WORKED_DATABASE = "sharedWorkedDatabase"; + private static final String TAG_LAST_USED_EPOCH_MS = "lastUsedEpochMs"; + + private static final int REGISTRY_VERSION = 1; + + private final String registryFilePath; + + /** + * Creates a store working on the registry of the current installation. + */ + public OperatorProfileStore() { + this(ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, PROFILES_REGISTRY_FILE)); + } + + /** + * Creates a store working on an explicit registry file. + * + * @param registryFilePath absolute path of the registry file + */ + public OperatorProfileStore(final String registryFilePath) { + this.registryFilePath = registryFilePath; + } + + /** + * Returns whether a registry file exists at all. + * + * @return true if the installation already has more than the historic flat layout + */ + public boolean isRegistryPresent() { + return new File(registryFilePath).isFile(); + } + + /** + * Builds the in-memory descriptor of the historic flat installation. + * + *

Nothing is written. This keeps a single operator installation untouched.

+ * + * @return the implicit root profile + */ + public OperatorProfile buildImplicitRootProfile() { + return OperatorProfilePaths.buildRootProfile("Default"); + } + + /** + * Reads all stored profiles. + * + * @return the stored profiles, or an empty list when no usable registry exists + */ + public List loadProfiles() { + + List loadedProfiles = new ArrayList<>(); + Document document = readRegistryDocument(); + + if (document == null) { + return loadedProfiles; + } + + NodeList profileNodes = document.getElementsByTagName(TAG_PROFILE); + + for (int profileIndex = 0; profileIndex < profileNodes.getLength(); profileIndex++) { + Node currentNode = profileNodes.item(profileIndex); + + if (currentNode.getNodeType() != Node.ELEMENT_NODE) { + continue; + } + + Element profileElement = (Element) currentNode; + String profileId = readText(profileElement, TAG_PROFILE_ID); + + if (profileId == null || profileId.isBlank()) { + LOGGER.log(Level.WARNING, "Skipping operator profile entry without an identifier"); + continue; + } + + OperatorProfile loadedProfile = new OperatorProfile(); + loadedProfile.setProfileId(profileId.trim()); + loadedProfile.setDisplayName(readText(profileElement, TAG_DISPLAY_NAME)); + loadedProfile.setRootProfile(readBoolean(profileElement, TAG_ROOT_PROFILE, false)); + loadedProfile.setSharedWorkedDatabase( + readBoolean(profileElement, TAG_SHARED_WORKED_DATABASE, true)); + loadedProfile.setLastUsedEpochMs(readLong(profileElement, TAG_LAST_USED_EPOCH_MS)); + + if (loadedProfile.getDisplayName() == null || loadedProfile.getDisplayName().isBlank()) { + loadedProfile.setDisplayName(loadedProfile.getProfileId()); + } + + // The root profile always uses the common station database, because its + // database is the historic flat file itself. + if (loadedProfile.isRootProfile()) { + loadedProfile.setSharedWorkedDatabase(true); + } + + loadedProfiles.add(loadedProfile); + } + + return loadedProfiles; + } + + /** + * Reads the identifier of the profile that was activated last. + * + * @return the identifier, or empty when unknown + */ + public Optional loadLastUsedProfileId() { + + Document document = readRegistryDocument(); + + if (document == null) { + return Optional.empty(); + } + + Element rootElement = document.getDocumentElement(); + + if (rootElement == null) { + return Optional.empty(); + } + + String lastUsedProfileId = readText(rootElement, TAG_LAST_USED_PROFILE_ID); + + if (lastUsedProfileId == null || lastUsedProfileId.isBlank()) { + return Optional.empty(); + } + + return Optional.of(lastUsedProfileId.trim()); + } + + /** + * Writes the complete registry. + * + * @param profiles profiles to store + * @param lastUsedProfileId identifier of the profile that was activated last, may be null + * @return true if the registry was written + */ + public boolean saveProfiles(final List profiles, final String lastUsedProfileId) { + + try { + DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder(); + Document document = documentBuilder.newDocument(); + + Element rootElement = document.createElement(TAG_ROOT); + document.appendChild(rootElement); + + appendTextElement(document, rootElement, TAG_REGISTRY_VERSION, String.valueOf(REGISTRY_VERSION)); + + if (lastUsedProfileId != null && !lastUsedProfileId.isBlank()) { + appendTextElement(document, rootElement, TAG_LAST_USED_PROFILE_ID, lastUsedProfileId); + } + + for (OperatorProfile currentProfile : profiles) { + Element profileElement = document.createElement(TAG_PROFILE); + rootElement.appendChild(profileElement); + + appendTextElement(document, profileElement, TAG_PROFILE_ID, currentProfile.getProfileId()); + appendTextElement(document, profileElement, TAG_DISPLAY_NAME, currentProfile.getDisplayName()); + appendTextElement(document, profileElement, TAG_ROOT_PROFILE, + String.valueOf(currentProfile.isRootProfile())); + appendTextElement(document, profileElement, TAG_SHARED_WORKED_DATABASE, + String.valueOf(currentProfile.isRootProfile() || currentProfile.isSharedWorkedDatabase())); + appendTextElement(document, profileElement, TAG_LAST_USED_EPOCH_MS, + String.valueOf(currentProfile.getLastUsedEpochMs())); + } + + return writeDocumentAtomically(document); + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Could not write the operator profile registry", e); + return false; + } + } + + /** + * Records that a profile has been activated. + * + *

Does nothing when no registry exists, so a single operator installation is not + * turned into a multi profile installation by merely starting the application.

+ * + * @param profileId identifier of the activated profile + * @return true if the registry was updated + */ + public boolean recordLastUsed(final String profileId) { + + if (!isRegistryPresent()) { + return false; + } + + List storedProfiles = loadProfiles(); + + if (storedProfiles.isEmpty()) { + return false; + } + + for (OperatorProfile currentProfile : storedProfiles) { + if (currentProfile.getProfileId().equalsIgnoreCase(profileId)) { + currentProfile.setLastUsedEpochMs(System.currentTimeMillis()); + } + } + + return saveProfiles(storedProfiles, profileId); + } + + /** + * Returns the absolute path of the registry file. + * + * @return absolute registry path + */ + public String getRegistryFilePath() { + return registryFilePath; + } + + private Document readRegistryDocument() { + + File registryFile = new File(registryFilePath); + + if (!registryFile.isFile()) { + return null; + } + + try { + DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance(); + documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true); + + return documentBuilderFactory.newDocumentBuilder().parse(registryFile); + } catch (Exception e) { + LOGGER.log(Level.WARNING, + "Could not read the operator profile registry, continuing without additional profiles", e); + return null; + } + } + + private static String readText(final Element parentElement, final String tagName) { + + NodeList matchingNodes = parentElement.getElementsByTagName(tagName); + + if (matchingNodes.getLength() == 0) { + return null; + } + + return matchingNodes.item(0).getTextContent(); + } + + private static boolean readBoolean(final Element parentElement, + final String tagName, + final boolean defaultValue) { + + String rawValue = readText(parentElement, tagName); + + if (rawValue == null || rawValue.isBlank()) { + return defaultValue; + } + + return Boolean.parseBoolean(rawValue.trim()); + } + + private static long readLong(final Element parentElement, final String tagName) { + + String rawValue = readText(parentElement, tagName); + + if (rawValue == null || rawValue.isBlank()) { + return 0L; + } + + try { + return Long.parseLong(rawValue.trim()); + } catch (NumberFormatException e) { + return 0L; + } + } + + private static void appendTextElement(final Document document, + final Element parentElement, + final String tagName, + final String textContent) { + + Element createdElement = document.createElement(tagName); + createdElement.setTextContent(textContent == null ? "" : textContent); + parentElement.appendChild(createdElement); + } + + /** + * Writes the registry through a temporary file so a crash can never leave a + * half-written registry behind. This mirrors the established preferences writer. + * + * @param document document to write + * @return true if the registry file was replaced + */ + private boolean writeDocumentAtomically(final Document document) { + + Path targetPath = Path.of(registryFilePath).toAbsolutePath(); + Path parentDirectory = targetPath.getParent(); + + try { + if (parentDirectory != null) { + Files.createDirectories(parentDirectory); + } + + Path temporaryPath = Files.createTempFile( + parentDirectory, PROFILES_REGISTRY_FILE, ".tmp"); + + Transformer transformer = TransformerFactory.newInstance().newTransformer(); + transformer.setOutputProperty(OutputKeys.INDENT, "yes"); + transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4"); + + try (OutputStream outputStream = Files.newOutputStream(temporaryPath)) { + transformer.transform(new DOMSource(document), new StreamResult(outputStream)); + } + + try { + Files.move(temporaryPath, targetPath, + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException atomicMoveUnsupported) { + Files.move(temporaryPath, targetPath, StandardCopyOption.REPLACE_EXISTING); + } + + return true; + } catch (Exception e) { + LOGGER.log(Level.SEVERE, "Could not store the operator profile registry", e); + return false; + } + } +} diff --git a/src/main/java/kst4contest/model/OperatorProfile.java b/src/main/java/kst4contest/model/OperatorProfile.java new file mode 100644 index 00000000..bcb91ab6 --- /dev/null +++ b/src/main/java/kst4contest/model/OperatorProfile.java @@ -0,0 +1,133 @@ +package kst4contest.model; + +import java.util.Objects; + +/** + * Descriptor of one operator profile. + * + *

A profile always owns its own preferences file. Whether it also owns its own + * worked-station database is decided by {@link #isSharedWorkedDatabase()}: a multi + * operator contest station keeps one common log and therefore shares the database, + * while two operators sharing a private computer usually want their worked data kept + * apart.

+ * + *

The descriptor deliberately carries no file paths. They are derived in exactly one + * place, {@link kst4contest.controller.OperatorProfilePaths}, so a stored path can never + * drift apart from the flag that produced it.

+ */ +public class OperatorProfile { + + /** + * Stable identifier of the profile. It is assigned once and never changes, so + * renaming a profile never moves a directory. + */ + private String profileId; + + /** + * Name shown in the profile picker and in the settings window. + */ + private String displayName; + + /** + * True for the profile that uses the historic flat installation layout directly. + */ + private boolean rootProfile; + + /** + * True if this profile uses the common station worked-station database. + */ + private boolean sharedWorkedDatabase; + + /** + * Timestamp of the last activation, used to preselect an entry in the picker. + */ + private long lastUsedEpochMs; + + public OperatorProfile() { + // Default constructor for stepwise construction while reading the registry. + } + + public OperatorProfile(final String profileId, + final String displayName, + final boolean rootProfile, + final boolean sharedWorkedDatabase) { + this.profileId = profileId; + this.displayName = displayName; + this.rootProfile = rootProfile; + this.sharedWorkedDatabase = sharedWorkedDatabase; + } + + public String getProfileId() { + return profileId; + } + + public void setProfileId(final String profileId) { + this.profileId = profileId; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(final String displayName) { + this.displayName = displayName; + } + + public boolean isRootProfile() { + return rootProfile; + } + + public void setRootProfile(final boolean rootProfile) { + this.rootProfile = rootProfile; + } + + public boolean isSharedWorkedDatabase() { + return sharedWorkedDatabase; + } + + public void setSharedWorkedDatabase(final boolean sharedWorkedDatabase) { + this.sharedWorkedDatabase = sharedWorkedDatabase; + } + + public long getLastUsedEpochMs() { + return lastUsedEpochMs; + } + + public void setLastUsedEpochMs(final long lastUsedEpochMs) { + this.lastUsedEpochMs = lastUsedEpochMs; + } + + @Override + public boolean equals(final Object other) { + + if (this == other) { + return true; + } + + if (!(other instanceof OperatorProfile)) { + return false; + } + + return Objects.equals(profileId, ((OperatorProfile) other).profileId); + } + + @Override + public int hashCode() { + return Objects.hashCode(profileId); + } + + /** + * Returns the display name so the descriptor can be shown in a list control directly. + * + * @return the display name, or the profile id when no name was set + */ + @Override + public String toString() { + + if (displayName == null || displayName.isBlank()) { + return String.valueOf(profileId); + } + + return displayName; + } +} diff --git a/src/main/java/kst4contest/model/OperatorProfileSelection.java b/src/main/java/kst4contest/model/OperatorProfileSelection.java new file mode 100644 index 00000000..50d40375 --- /dev/null +++ b/src/main/java/kst4contest/model/OperatorProfileSelection.java @@ -0,0 +1,70 @@ +package kst4contest.model; + +import kst4contest.ApplicationConstants; +import kst4contest.utils.ApplicationFileUtils; + +import java.util.Objects; + +/** + * Resolved runtime view of the active operator profile. + * + *

This is the only profile information the rest of the application needs: two file + * names relative to the application directory plus the flag whether a missing + * worked-station database may be seeded from the bundled template. Everything else is + * derived from the descriptor.

+ */ +public class OperatorProfileSelection { + + private final OperatorProfile profile; + private final String preferencesRelativeFileName; + private final String workedDatabaseRelativeFileName; + private final boolean seedWorkedDatabaseFromResource; + + public OperatorProfileSelection(final OperatorProfile profile, + final String preferencesRelativeFileName, + final String workedDatabaseRelativeFileName, + final boolean seedWorkedDatabaseFromResource) { + this.profile = Objects.requireNonNull(profile, "profile"); + this.preferencesRelativeFileName = + Objects.requireNonNull(preferencesRelativeFileName, "preferencesRelativeFileName"); + this.workedDatabaseRelativeFileName = + Objects.requireNonNull(workedDatabaseRelativeFileName, "workedDatabaseRelativeFileName"); + this.seedWorkedDatabaseFromResource = seedWorkedDatabaseFromResource; + } + + public OperatorProfile getProfile() { + return profile; + } + + public String getPreferencesRelativeFileName() { + return preferencesRelativeFileName; + } + + public String getWorkedDatabaseRelativeFileName() { + return workedDatabaseRelativeFileName; + } + + public boolean isSeedWorkedDatabaseFromResource() { + return seedWorkedDatabaseFromResource; + } + + /** + * Returns the absolute preferences path, for display in the settings window. + * + * @return absolute path of the preferences file + */ + public String getPreferencesAbsolutePath() { + return ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, preferencesRelativeFileName); + } + + /** + * Returns the absolute worked-station database path, for display in the settings window. + * + * @return absolute path of the worked-station database + */ + public String getWorkedDatabaseAbsolutePath() { + return ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, workedDatabaseRelativeFileName); + } +} diff --git a/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java b/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java new file mode 100644 index 00000000..c71f339e --- /dev/null +++ b/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java @@ -0,0 +1,81 @@ +package kst4contest.controller; + +import kst4contest.model.OperatorProfile; +import kst4contest.model.OperatorProfileSelection; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OperatorProfilePathsTest { + + @Test + void rootProfileKeepsTheHistoricFlatFileNames() { + + OperatorProfileSelection resolved = + OperatorProfilePaths.resolve(OperatorProfilePaths.buildRootProfile("Default")); + + // This is the downgrade guard: an older KST4Contest release reads exactly these + // two files. If this test ever fails, existing installations would silently lose + // their configuration and worked data when the operator reverts a version. + assertEquals("preferences.xml", resolved.getPreferencesRelativeFileName()); + assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName()); + assertTrue(resolved.isSeedWorkedDatabaseFromResource()); + } + + @Test + void additionalProfileWithSharedDatabaseUsesItsOwnPreferencesButTheStationDatabase() { + + OperatorProfile sharedProfile = new OperatorProfile("OP2", "DN9APW", false, true); + OperatorProfileSelection resolved = OperatorProfilePaths.resolve(sharedProfile); + + assertEquals("profiles/OP2/preferences.xml", resolved.getPreferencesRelativeFileName()); + assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName()); + assertTrue(resolved.isSeedWorkedDatabaseFromResource()); + } + + @Test + void additionalProfileWithOwnDatabaseIsFullySeparatedAndNotSeeded() { + + OperatorProfile ownDatabaseProfile = new OperatorProfile("OP2", "DN9APW", false, false); + OperatorProfileSelection resolved = OperatorProfilePaths.resolve(ownDatabaseProfile); + + assertEquals("profiles/OP2/preferences.xml", resolved.getPreferencesRelativeFileName()); + assertEquals("profiles/OP2/praktiKST.db", resolved.getWorkedDatabaseRelativeFileName()); + + // Seeding would hand a new operator the several thousand callsigns of the + // bundled template database. + assertFalse(resolved.isSeedWorkedDatabaseFromResource()); + } + + @Test + void profileIdIsFileSystemSafe() { + + assertEquals("DN9APW", OperatorProfilePaths.toProfileId("dn9apw", Set.of())); + assertEquals("DM5M_CONTEST", OperatorProfilePaths.toProfileId("DM5M Contest", Set.of())); + assertEquals("A_B", OperatorProfilePaths.toProfileId("a/../b", Set.of())); + assertEquals("MULLER", OperatorProfilePaths.toProfileId("Müller", Set.of())); + assertEquals("OP", OperatorProfilePaths.toProfileId(" ", Set.of())); + assertEquals("OP", OperatorProfilePaths.toProfileId(null, Set.of())); + + String longName = "A".repeat(60); + assertEquals(32, OperatorProfilePaths.toProfileId(longName, Set.of()).length()); + } + + @Test + void profileIdNeverCollidesAndNeverClaimsTheRootIdentifier() { + + assertEquals("DN9APW_2", OperatorProfilePaths.toProfileId("DN9APW", List.of("DN9APW"))); + assertEquals("DN9APW_3", + OperatorProfilePaths.toProfileId("DN9APW", List.of("DN9APW", "DN9APW_2"))); + + // "default" is reserved for the historic flat installation. + assertNotEquals(OperatorProfilePaths.ROOT_PROFILE_ID, + OperatorProfilePaths.toProfileId("default", Set.of())); + } +} diff --git a/src/test/java/kst4contest/controller/OperatorProfileStoreTest.java b/src/test/java/kst4contest/controller/OperatorProfileStoreTest.java new file mode 100644 index 00000000..4e23067f --- /dev/null +++ b/src/test/java/kst4contest/controller/OperatorProfileStoreTest.java @@ -0,0 +1,117 @@ +package kst4contest.controller; + +import kst4contest.model.OperatorProfile; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OperatorProfileStoreTest { + + @TempDir + Path temporaryDirectory; + + @Test + void missingRegistryIsNotAnErrorAndIsNotCreated() { + + OperatorProfileStore store = storeAt("profiles.xml"); + + assertFalse(store.isRegistryPresent()); + assertTrue(store.loadProfiles().isEmpty()); + assertEquals(Optional.empty(), store.loadLastUsedProfileId()); + + // A single operator installation must stay untouched by merely starting up. + assertFalse(Files.exists(temporaryDirectory.resolve("profiles.xml"))); + } + + @Test + void profilesSurviveAWriteReadRoundTrip() { + + OperatorProfileStore store = storeAt("profiles.xml"); + + OperatorProfile rootProfile = OperatorProfilePaths.buildRootProfile("DM5M station"); + OperatorProfile secondProfile = new OperatorProfile("OP2", "DN9APW", false, false); + secondProfile.setLastUsedEpochMs(1757328000000L); + + assertTrue(store.saveProfiles(List.of(rootProfile, secondProfile), "OP2")); + assertTrue(store.isRegistryPresent()); + + List restored = store.loadProfiles(); + + assertEquals(2, restored.size()); + assertEquals("default", restored.get(0).getProfileId()); + assertEquals("DM5M station", restored.get(0).getDisplayName()); + assertTrue(restored.get(0).isRootProfile()); + assertTrue(restored.get(0).isSharedWorkedDatabase()); + + assertEquals("OP2", restored.get(1).getProfileId()); + assertEquals("DN9APW", restored.get(1).getDisplayName()); + assertFalse(restored.get(1).isRootProfile()); + assertFalse(restored.get(1).isSharedWorkedDatabase()); + assertEquals(1757328000000L, restored.get(1).getLastUsedEpochMs()); + + assertEquals(Optional.of("OP2"), store.loadLastUsedProfileId()); + } + + @Test + void atomicWriteLeavesNoTemporaryFileBehind() throws IOException { + + OperatorProfileStore store = storeAt("profiles.xml"); + store.saveProfiles(List.of(OperatorProfilePaths.buildRootProfile("Default")), "default"); + + try (var directoryEntries = Files.list(temporaryDirectory)) { + assertTrue(directoryEntries.noneMatch(entry -> entry.getFileName().toString().endsWith(".tmp"))); + } + } + + @Test + void malformedRegistryFallsBackToNoAdditionalProfiles() throws IOException { + + Path registryFile = temporaryDirectory.resolve("profiles.xml"); + Files.writeString(registryFile, "OP2"); + + OperatorProfileStore store = storeAt("profiles.xml"); + + assertTrue(store.isRegistryPresent()); + assertTrue(store.loadProfiles().isEmpty()); + assertEquals(Optional.empty(), store.loadLastUsedProfileId()); + } + + @Test + void entriesWithoutAnIdentifierAreSkippedInsteadOfBreakingTheRegistry() throws IOException { + + Path registryFile = temporaryDirectory.resolve("profiles.xml"); + Files.writeString(registryFile, + "" + + "broken" + + "OP2DN9APW" + + "false" + + ""); + + List restored = storeAt("profiles.xml").loadProfiles(); + + assertEquals(1, restored.size()); + assertEquals("OP2", restored.get(0).getProfileId()); + } + + @Test + void recordLastUsedDoesNothingWhenNoRegistryExists() { + + OperatorProfileStore store = storeAt("profiles.xml"); + + assertFalse(store.recordLastUsed("default")); + assertFalse(store.isRegistryPresent()); + } + + private OperatorProfileStore storeAt(final String fileName) { + return new OperatorProfileStore(temporaryDirectory.resolve(fileName).toString()); + } +}