Files
OpenNest/OpenNest.Desktop.Tests/ColorSchemeRegistryTests.cs
T
ajandClaude Opus 5.5 cc3711c11c refactor(desktop): move colour schemes into OpenNest.Desktop
ColorScheme, ColorSchemeRegistry, ColorSchemeSerializer and the Schemes
folder move into the toolkit-neutral library, keeping the OpenNest
namespace, so the Avalonia app can share the built-in palettes, the
scheme files and the recolour-on-open rule.

ColorScheme is now pure colour data with a Version that changes with
any colour. Its GDI+ pens and brushes move to GdiSchemeTools in the
WinForms project (still one set per thread, rebuilt after a colour
change), reached through scheme.Gdi(). The legacy part outline uses
ColorShade.Dark, a port of ControlPaint.Dark's HLS arithmetic; a
WinForms test compares the two across a colour sweep on Windows.

The registry no longer walks Application.OpenForms: Apply raises
Applied, and EditNestForm subscribes once to recolour every open nest
window as before. The saved scheme is read under the same
ActiveColorScheme setting (SavedSchemeName/ApplySaved for the shared
user-settings file); WinForms still reads it through Settings.Default.
Schemes/*.json now copies beside both desktop apps from OpenNest.Desktop.

Pure scheme tests move to OpenNest.Desktop.Tests, with new ones for
built-in round trips, scheme files, bad files, the saved name and the
recolour rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 21:23:48 -05:00

245 lines
10 KiB
C#

using System.Drawing;
using OpenNest.Data;
namespace OpenNest.Desktop.Tests;
// Applying a scheme changes ColorScheme.Default and the drawing palette for the whole process.
[Collection(ColorSchemeState.Collection)]
public class ColorSchemeRegistryTests : IDisposable
{
private static readonly string[] BuiltInNames =
["Classic", "Pastel", "Dark", "Workshop", "Halloween", "Christmas", "4th of July"];
private readonly ColorSchemeState saved = new();
private readonly string directory = Path.Combine(Path.GetTempPath(), "opennest-schemes-" + Guid.NewGuid().ToString("N"));
public ColorSchemeRegistryTests() => Directory.CreateDirectory(directory);
public void Dispose()
{
saved.Restore();
Directory.Delete(directory, true);
}
[Fact]
public void BuiltInSchemesAreListedInOrder()
{
ColorSchemeRegistry.SchemesDirectory = directory;
Assert.Equal(BuiltInNames, ColorSchemeRegistry.AllSchemes.Select(s => s.Name));
}
[Theory]
[InlineData("Classic")]
[InlineData("Pastel")]
[InlineData("Dark")]
[InlineData("Workshop")]
[InlineData("Halloween")]
[InlineData("Christmas")]
[InlineData("4th of July")]
public void EveryBuiltInSchemeRoundTripsUnchanged(string name)
{
var scheme = ColorSchemeRegistry.Get(name);
var json = ColorSchemeSerializer.Serialize(scheme);
var restored = ColorSchemeSerializer.Deserialize(json);
Assert.Equal(Snapshot(scheme), Snapshot(restored));
Assert.Equal(json, ColorSchemeSerializer.Serialize(restored));
}
[Fact]
public void ASchemeFileInTheSchemesFolderLoads()
{
File.WriteAllText(Path.Combine(directory, "shop.json"), """
{
"name": "Shop Floor",
"backgroundColor": "#102030",
"layoutOutlineColor": "#405060",
"layoutFillColor": "#F0F0E0",
"boundingBoxColor": "#8080FF",
"rapidColor": "#1E90FF",
"originColor": "#808080",
"edgeSpacingColor": "#B4B4B4",
"previewPartColor": "#FF8C00",
"partOutlineColor": "#202020",
"partColors": ["#AA0000", "#00AA00"]
}
""");
ColorSchemeRegistry.SchemesDirectory = directory;
var scheme = ColorSchemeRegistry.Get("shop floor");
Assert.Equal("Shop Floor", scheme.Name);
Assert.Equal(Color.FromArgb(16, 32, 48), scheme.BackgroundColor);
Assert.Equal(Color.FromArgb(32, 32, 32), scheme.PartOutlineColor);
Assert.Equal([Color.FromArgb(170, 0, 0), Color.FromArgb(0, 170, 0)], scheme.PartColors);
// Missing entries keep their defaults, as WinForms reads them.
Assert.Equal(Color.Lime, scheme.EtchColor);
Assert.True(scheme.SelectedPartColor.IsEmpty);
Assert.Equal(new ColorScheme().LibraryFillColor, scheme.LibraryFillColor);
Assert.Equal(BuiltInNames.Append("Shop Floor"), ColorSchemeRegistry.AllSchemes.Select(s => s.Name));
}
[Fact]
public void BadSchemeFilesAreSkippedAndTheirNamesFallBackToClassic()
{
File.WriteAllText(Path.Combine(directory, "broken.json"), "{ \"name\": \"Broken\", ");
File.WriteAllText(Path.Combine(directory, "badhex.json"), "{ \"name\": \"Bad Hex\", \"backgroundColor\": \"#GG0000\" }");
File.WriteAllText(Path.Combine(directory, "null.json"), "null");
File.WriteAllText(Path.Combine(directory, "classic.json"), "{ \"name\": \"classic\", \"backgroundColor\": \"#FF0000\" }");
File.WriteAllText(Path.Combine(directory, "notes.txt"), "{ \"name\": \"Text\" }");
ColorSchemeRegistry.SchemesDirectory = directory;
Assert.Equal(BuiltInNames, ColorSchemeRegistry.AllSchemes.Select(s => s.Name));
Assert.Equal("Classic", ColorSchemeRegistry.Get("Broken").Name);
Assert.Equal("Classic", ColorSchemeRegistry.Get("Bad Hex").Name);
// A file cannot replace a built-in scheme.
Assert.Equal(Color.DarkGray, ColorSchemeRegistry.Get("Classic").BackgroundColor);
Assert.Equal("Classic", ColorSchemeRegistry.Get(null).Name);
Assert.Equal("Classic", ColorSchemeRegistry.Get(" ").Name);
}
[Fact]
public void AMissingSchemesFolderListsOnlyTheBuiltIns()
{
ColorSchemeRegistry.SchemesDirectory = Path.Combine(directory, "missing");
Assert.Equal(BuiltInNames, ColorSchemeRegistry.AllSchemes.Select(s => s.Name));
}
[Fact]
public void TheSavedSchemeNameResolvesFromTheSharedUserSettings()
{
var path = Path.Combine(directory, "user-settings.json");
UserSettingsFile.Write(path, new Dictionary<string, string>
{
["MainWindowState"] = "Normal",
[ColorSchemeRegistry.SettingName] = "Halloween",
});
var applied = ColorSchemeRegistry.ApplySaved(UserSettingsFile.Read(path));
Assert.Equal("ActiveColorScheme", ColorSchemeRegistry.SettingName);
Assert.Equal("Halloween", applied.Name);
Assert.Equal("Halloween", ColorScheme.Default.Name);
Assert.Equal(applied.BackgroundColor, ColorScheme.Default.BackgroundColor);
Assert.Equal(applied.PartColors, Drawing.PartColors);
}
[Theory]
[InlineData(null, "Classic")]
[InlineData("", "Classic")]
[InlineData("dark", "Dark")]
[InlineData("Gone Scheme", "Classic")]
public void TheSavedSchemeFallsBackToClassic(string? savedName, string expected)
{
var settings = new Dictionary<string, string>();
if (savedName != null)
settings[ColorSchemeRegistry.SettingName] = savedName;
Assert.Equal(savedName ?? "Classic", ColorSchemeRegistry.SavedSchemeName(settings));
Assert.Equal(expected, ColorSchemeRegistry.ApplySaved(settings).Name);
Assert.Equal(expected, ColorScheme.Default.Name);
}
[Fact]
public void ApplyingCopiesTheSchemeIntoTheDefaultAndRaisesApplied()
{
var workshop = ColorSchemeRegistry.Get("Workshop");
var defaultVersion = ColorScheme.Default.Version;
ColorScheme? raised = null;
EventHandler<ColorScheme> handler = (_, scheme) => raised = scheme;
ColorSchemeRegistry.Applied += handler;
try
{
ColorSchemeRegistry.Apply(workshop);
}
finally
{
ColorSchemeRegistry.Applied -= handler;
}
Assert.Same(workshop, raised);
Assert.Equal(Snapshot(workshop), Snapshot(ColorScheme.Default));
Assert.NotEqual(defaultVersion, ColorScheme.Default.Version);
Assert.True(Drawing.UseGoldenAngleColors);
Assert.Equal(PartColorPalette.GoldenAngle(30), Drawing.GetPartColor(30));
}
[Fact]
public void RecolorKeepsCustomColorsInTheirSlotsAndSkipsCutoffs()
{
ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get("Classic"));
var nest = new Nest("Recolor");
var first = new Drawing("First") { Color = Color.Coral };
var custom = new Drawing("Custom") { Color = Color.Red, HasCustomColor = true };
var cutoff = new Drawing("Cutoff") { IsCutOff = true, Color = Color.Gray };
var third = new Drawing("Third") { Color = Color.Coral };
nest.Drawings.Add(first);
nest.Drawings.Add(custom);
nest.Drawings.Add(cutoff);
nest.Drawings.Add(third);
ColorSchemeRegistry.RecolorDrawings(nest);
var classic = ColorSchemeRegistry.Get("Classic").PartColors;
Assert.Equal(classic[0], first.Color);
Assert.Equal(Color.Red, custom.Color);
Assert.Equal(Color.Gray, cutoff.Color);
Assert.Equal(classic[2], third.Color);
}
[Fact]
public void RecolorFollowsTheActiveSchemesPalette()
{
var nest = new Nest("Recolor");
var drawings = Enumerable.Range(0, 14).Select(i => new Drawing($"D{i}") { Color = Color.Coral }).ToList();
foreach (var drawing in drawings)
nest.Drawings.Add(drawing);
ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get("Dark"));
ColorSchemeRegistry.RecolorDrawings(nest);
var dark = ColorSchemeRegistry.Get("Dark").PartColors;
Assert.Equal(Enumerable.Range(0, 14).Select(i => dark[i % dark.Length]), drawings.Select(d => d.Color));
ColorSchemeRegistry.Apply(ColorSchemeRegistry.Get("Workshop"));
ColorSchemeRegistry.RecolorDrawings(nest);
Assert.Equal(Enumerable.Range(0, 14).Select(PartColorPalette.GoldenAngle), drawings.Select(d => d.Color));
}
private static string Snapshot(ColorScheme s) => string.Join(";",
s.Name, s.BackgroundColor.ToArgb(), s.LayoutOutlineColor.ToArgb(), s.LayoutFillColor.ToArgb(),
s.BoundingBoxColor.ToArgb(), s.RapidColor.ToArgb(), s.OriginColor.ToArgb(), s.EdgeSpacingColor.ToArgb(),
s.PreviewPartColor.ToArgb(), s.PartOutlineColor.IsEmpty ? "empty" : s.PartOutlineColor.ToArgb().ToString(),
s.SelectedPartColor.IsEmpty ? "empty" : s.SelectedPartColor.ToArgb().ToString(), s.EtchColor.ToArgb(),
s.LibraryBackgroundColor.ToArgb(), s.LibraryFillColor.ToArgb(), s.LibraryOutlineColor.ToArgb(),
s.LibraryEtchColor.ToArgb(), s.LibraryBendColor.ToArgb(), s.UseGoldenAngleColors,
string.Join(",", s.PartColors.Select(c => c.ToArgb())));
}
[CollectionDefinition(ColorSchemeState.Collection, DisableParallelization = true)]
public sealed class ColorSchemeStateCollection
{
}
/// <summary>The process-wide scheme state a test changes, restored afterwards.</summary>
public sealed class ColorSchemeState
{
public const string Collection = "Colour scheme state";
private readonly ColorScheme previous =
ColorSchemeSerializer.Deserialize(ColorSchemeSerializer.Serialize(ColorScheme.Default));
private readonly Color[] previousPartColors = Drawing.PartColors;
private readonly bool previousGoldenAngle = Drawing.UseGoldenAngleColors;
private readonly string previousDirectory = ColorSchemeRegistry.SchemesDirectory;
public void Restore()
{
ColorSchemeRegistry.Apply(previous);
Drawing.PartColors = previousPartColors;
Drawing.UseGoldenAngleColors = previousGoldenAngle;
ColorSchemeRegistry.SchemesDirectory = previousDirectory;
}
}