From ac901f7ee9add31a496442eb1211040521a23349 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Tue, 29 Sep 2026 10:43:36 -0400 Subject: [PATCH] feat(ui): remember the selected Auto Nest engine --- OpenNest.Data/EngineSelectionSettings.cs | 95 ++++++++++ .../Data/EngineSelectionSettingsTests.cs | 178 ++++++++++++++++++ OpenNest/Forms/EngineSelection.cs | 29 ++- OpenNest/Forms/MainForm.cs | 3 + 4 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 OpenNest.Data/EngineSelectionSettings.cs create mode 100644 OpenNest.Tests/Data/EngineSelectionSettingsTests.cs diff --git a/OpenNest.Data/EngineSelectionSettings.cs b/OpenNest.Data/EngineSelectionSettings.cs new file mode 100644 index 0000000..6d7bd42 --- /dev/null +++ b/OpenNest.Data/EngineSelectionSettings.cs @@ -0,0 +1,95 @@ +using System.Text.Json; + +namespace OpenNest.Data; + +/// +/// Last-used Auto Nest engine, stored separately from nest/plate defaults. +/// Loading does not resolve the name: the host must finish plug-in discovery first. +/// +public sealed class EngineSelectionSettings +{ + public const string DefaultEngineName = "Default"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + public string EngineName { get; set; } = DefaultEngineName; + + /// %APPDATA%\OpenNest\engine-selection.json. + public static string DefaultPath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenNest", "engine-selection.json"); + + /// Missing, unreadable or corrupt settings safely use Default without writing. + public static EngineSelectionSettings Load(string path) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + return new(); + + try + { + var settings = JsonSerializer.Deserialize( + File.ReadAllText(path), JsonOptions) ?? new(); + settings.EngineName = NormalizeName(settings.EngineName); + return settings; + } + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) + { + return new(); + } + } + + /// + /// Resolves against the host's selectable engines AFTER plug-in loading. A missing engine + /// returns Default plus a status-bar warning, without replacing the saved preference. + /// Names use registry casing so desktop combo-box selection remains exact. + /// + public string Resolve(IEnumerable availableEngineNames, out string? statusMessage) + { + ArgumentNullException.ThrowIfNull(availableEngineNames); + var requestedName = NormalizeName(EngineName); + var registeredName = availableEngineNames.FirstOrDefault( + name => string.Equals(name, requestedName, StringComparison.OrdinalIgnoreCase)); + if (registeredName is not null) + { + statusMessage = null; + return registeredName; + } + + statusMessage = $"Saved Auto Nest engine '{requestedName}' is unavailable. Using Default."; + return DefaultEngineName; + } + + /// + /// Writes camelCase JSON, creating the parent directory and retrying IO collisions as + /// LocalJsonProvider does. The host handles a persistent write failure. + /// + public void Save(string path) + { + var json = JsonSerializer.Serialize( + new EngineSelectionSettings { EngineName = NormalizeName(EngineName) }, JsonOptions); + var directory = Path.GetDirectoryName(Path.GetFullPath(path)); + + for (var attempt = 0; attempt < 3; attempt++) + { + try + { + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + File.WriteAllText(path, json); + return; + } + catch (IOException) when (attempt < 2) + { + Thread.Sleep(100); + } + } + } + + private static string NormalizeName(string? name) => + string.IsNullOrWhiteSpace(name) ? DefaultEngineName : name.Trim(); +} diff --git a/OpenNest.Tests/Data/EngineSelectionSettingsTests.cs b/OpenNest.Tests/Data/EngineSelectionSettingsTests.cs new file mode 100644 index 0000000..7457510 --- /dev/null +++ b/OpenNest.Tests/Data/EngineSelectionSettingsTests.cs @@ -0,0 +1,178 @@ +using System.Text.Json; +using OpenNest.Data; + +namespace OpenNest.Tests.Data; + +public class EngineSelectionSettingsTests : IDisposable +{ + private readonly string _directory = Path.Combine( + Path.GetTempPath(), "OpenNestTests", Guid.NewGuid().ToString()); + private readonly string _path; + + public EngineSelectionSettingsTests() + { + Directory.CreateDirectory(_directory); + _path = Path.Combine(_directory, "engine-selection.json"); + } + + public void Dispose() => Directory.Delete(_directory, recursive: true); + + [Fact] + public void DefaultPath_UsesApplicationDataOpenNestDirectory() + { + Assert.Equal( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "OpenNest", "engine-selection.json"), + EngineSelectionSettings.DefaultPath); + } + + [Fact] + public void SaveThenLoad_RoundTripsPluginNameAsCamelCaseJson() + { + new EngineSelectionSettings { EngineName = "Opus55NestingEngine" }.Save(_path); + + var loaded = EngineSelectionSettings.Load(_path); + using var json = JsonDocument.Parse(File.ReadAllText(_path)); + + Assert.Equal("Opus55NestingEngine", loaded.EngineName); + Assert.Equal("Opus55NestingEngine", json.RootElement.GetProperty("engineName").GetString()); + } + + [Fact] + public void Save_OverwritesPreviousSelection() + { + new EngineSelectionSettings { EngineName = "Opus55NestingEngine" }.Save(_path); + new EngineSelectionSettings { EngineName = "Strip" }.Save(_path); + + Assert.Equal("Strip", EngineSelectionSettings.Load(_path).EngineName); + } + + [Fact] + public void Save_CreatesMissingParentDirectory() + { + var path = Path.Combine(_directory, "nested", "engine-selection.json"); + + new EngineSelectionSettings { EngineName = "Strip" }.Save(path); + + Assert.Equal("Strip", EngineSelectionSettings.Load(path).EngineName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Save_EmptySelectionWritesDefault(string? engineName) + { + new EngineSelectionSettings { EngineName = engineName! }.Save(_path); + + using var json = JsonDocument.Parse(File.ReadAllText(_path)); + Assert.Equal("Default", json.RootElement.GetProperty("engineName").GetString()); + } + + [Fact] + public void Load_MissingFileReturnsDefaultWithoutCreatingFile() + { + var loaded = EngineSelectionSettings.Load(_path); + + Assert.Equal("Default", loaded.EngineName); + Assert.False(File.Exists(_path)); + Assert.Equal("Default", loaded.Resolve(new[] { "Default" }, out var message)); + Assert.Null(message); + } + + [Theory] + [InlineData("{ broken json")] + [InlineData("null")] + [InlineData("[]")] + [InlineData("{}")] + [InlineData("{\"engineName\":null}")] + [InlineData("{\"engineName\":\" \"}")] + [InlineData("{\"engineName\":42}")] + public void Load_CorruptOrEmptySettingsReturnsDefault(string json) + { + File.WriteAllText(_path, json); + + Assert.Equal("Default", EngineSelectionSettings.Load(_path).EngineName); + Assert.Equal(json, File.ReadAllText(_path)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("\0")] + public void Load_InvalidPathReturnsDefault(string? path) + { + Assert.Equal("Default", EngineSelectionSettings.Load(path!).EngineName); + } + + [Fact] + public void Load_UnreadablePathReturnsDefault() + { + Directory.CreateDirectory(_path); + + Assert.Equal("Default", EngineSelectionSettings.Load(_path).EngineName); + } + + [Fact] + public void Load_IgnoresUnknownFieldsAndTrimsSelection() + { + File.WriteAllText(_path, + """{"EngineName":" Opus55NestingEngine ","futureSetting":true}"""); + + Assert.Equal("Opus55NestingEngine", EngineSelectionSettings.Load(_path).EngineName); + } + + [Theory] + [InlineData("Default")] + [InlineData("Strip")] + [InlineData("Vertical Remnant")] + [InlineData("Horizontal Remnant")] + [InlineData("Opus55NestingEngine")] + public void Resolve_AvailableSelectionUsesRegisteredCasingWithoutWarning(string engineName) + { + var settings = new EngineSelectionSettings { EngineName = engineName.ToLowerInvariant() }; + + var resolved = settings.Resolve(new[] { "Default", engineName }, out var message); + + Assert.Equal(engineName, resolved); + Assert.Null(message); + } + + [Fact] + public void Resolve_MissingPluginFallsBackWithVisibleMessageWithoutOverwritingSavedChoice() + { + new EngineSelectionSettings { EngineName = "Opus55NestingEngine" }.Save(_path); + var settings = EngineSelectionSettings.Load(_path); + + var resolved = settings.Resolve(new[] { "Default", "Strip" }, out var message); + + Assert.Equal("Default", resolved); + Assert.Equal("Saved Auto Nest engine 'Opus55NestingEngine' is unavailable. Using Default.", message); + Assert.Equal("Opus55NestingEngine", settings.EngineName); + Assert.Equal("Opus55NestingEngine", EngineSelectionSettings.Load(_path).EngineName); + } + + [Fact] + public void Load_DoesNotResolveBeforePluginDiscovery() + { + new EngineSelectionSettings { EngineName = "Opus55NestingEngine" }.Save(_path); + var settings = EngineSelectionSettings.Load(_path); + var availableEngines = new List { "Default" }; + + // The host loads settings independently, then supplies the completed registry. + availableEngines.Add("Opus55NestingEngine"); + var resolved = settings.Resolve(availableEngines, out var message); + + Assert.Equal("Opus55NestingEngine", resolved); + Assert.Null(message); + } + + [Fact] + public void Save_UnwritablePathReportsFailureToCaller() + { + File.WriteAllText(_path, "not a directory"); + var path = Path.Combine(_path, "engine-selection.json"); + + Assert.Throws(() => new EngineSelectionSettings().Save(path)); + } +} diff --git a/OpenNest/Forms/EngineSelection.cs b/OpenNest/Forms/EngineSelection.cs index 04a1c50..0062bcf 100644 --- a/OpenNest/Forms/EngineSelection.cs +++ b/OpenNest/Forms/EngineSelection.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics; +using System.IO; using System.Linq; +using OpenNest.Data; using OpenNest.Engine.Jobs; using OpenNest.Engine.Jobs.Placement; @@ -14,13 +17,14 @@ namespace OpenNest.Forms /// public static class EngineSelection { - public const string DefaultEngineName = "Default"; + public const string DefaultEngineName = EngineSelectionSettings.DefaultEngineName; /// Registered jobs engine deliberately kept out of the desktop combo. public const string HiddenEngineName = "StockLadder"; private static string engineName = DefaultEngineName; + /// Current selection. User changes are persisted for the next launch. public static string EngineName { get { return engineName; } @@ -29,9 +33,32 @@ namespace OpenNest.Forms engineName = string.IsNullOrWhiteSpace(value) ? DefaultEngineName : value.Trim(); + + try + { + new EngineSelectionSettings { EngineName = engineName } + .Save(EngineSelectionSettings.DefaultPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // A read-only profile must not prevent selecting an engine for this session. + Debug.WriteLine($"[EngineSelection] Could not save engine selection: {ex.Message}"); + } } } + /// + /// Call after NestingEngineRegistry.LoadPlugins and before populating the desktop combo. + /// Returns a status-bar warning when the saved engine is unavailable, otherwise empty. + /// Loading never saves the fallback over a temporarily missing plug-in preference. + /// + public static string LoadSavedSelection() + { + var settings = EngineSelectionSettings.Load(EngineSelectionSettings.DefaultPath); + engineName = settings.Resolve(UiEngineNames, out var statusMessage); + return statusMessage ?? string.Empty; + } + /// Desktop combo contents: registered jobs engines minus StockLadder. public static IEnumerable UiEngineNames => NestingEngineRegistry.AvailableEngines diff --git a/OpenNest/Forms/MainForm.cs b/OpenNest/Forms/MainForm.cs index b34d66e..2a43ede 100644 --- a/OpenNest/Forms/MainForm.cs +++ b/OpenNest/Forms/MainForm.cs @@ -78,6 +78,9 @@ namespace OpenNest.Forms // contract and expose a public parameterless constructor. var enginesDir = Path.Combine(Application.StartupPath, "Engines"); NestingEngineRegistry.LoadPlugins(enginesDir); + var engineWarning = EngineSelection.LoadSavedSelection(); + if (!string.IsNullOrEmpty(engineWarning)) + Shown += (_, _) => statusLabel1.Text = engineWarning; OptionsForm.ApplyDisabledStrategies(); ColorSchemeRegistry.ApplyActiveFromSettings();