feat(ui): remember the selected Auto Nest engine

This commit is contained in:
aj
2026-09-29 10:43:36 -04:00
parent 43253cd9ab
commit ac901f7ee9
4 changed files with 304 additions and 1 deletions
+95
View File
@@ -0,0 +1,95 @@
using System.Text.Json;
namespace OpenNest.Data;
/// <summary>
/// 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.
/// </summary>
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;
/// <summary>%APPDATA%\OpenNest\engine-selection.json.</summary>
public static string DefaultPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OpenNest", "engine-selection.json");
/// <summary>Missing, unreadable or corrupt settings safely use Default without writing.</summary>
public static EngineSelectionSettings Load(string path)
{
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
return new();
try
{
var settings = JsonSerializer.Deserialize<EngineSelectionSettings>(
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();
}
}
/// <summary>
/// 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.
/// </summary>
public string Resolve(IEnumerable<string> 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;
}
/// <summary>
/// Writes camelCase JSON, creating the parent directory and retrying IO collisions as
/// LocalJsonProvider does. The host handles a persistent write failure.
/// </summary>
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();
}
@@ -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<string> { "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<IOException>(() => new EngineSelectionSettings().Save(path));
}
}
+28 -1
View File
@@ -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
/// </summary>
public static class EngineSelection
{
public const string DefaultEngineName = "Default";
public const string DefaultEngineName = EngineSelectionSettings.DefaultEngineName;
/// <summary>Registered jobs engine deliberately kept out of the desktop combo.</summary>
public const string HiddenEngineName = "StockLadder";
private static string engineName = DefaultEngineName;
/// <summary>Current selection. User changes are persisted for the next launch.</summary>
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}");
}
}
}
/// <summary>
/// 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.
/// </summary>
public static string LoadSavedSelection()
{
var settings = EngineSelectionSettings.Load(EngineSelectionSettings.DefaultPath);
engineName = settings.Resolve(UiEngineNames, out var statusMessage);
return statusMessage ?? string.Empty;
}
/// <summary>Desktop combo contents: registered jobs engines minus StockLadder.</summary>
public static IEnumerable<string> UiEngineNames =>
NestingEngineRegistry.AvailableEngines
+3
View File
@@ -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();