feat(ui): remember the selected Auto Nest engine
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user