diff --git a/OpenNest.Desktop.Tests/PostProcessorLoaderTests.cs b/OpenNest.Desktop.Tests/PostProcessorLoaderTests.cs
new file mode 100644
index 00000000..6fdf9014
--- /dev/null
+++ b/OpenNest.Desktop.Tests/PostProcessorLoaderTests.cs
@@ -0,0 +1,78 @@
+namespace OpenNest.Desktop.Tests;
+
+///
+/// Post processors load from a Posts directory one DLL at a time: a DLL that fails is
+/// reported and the others still load, and the materials they provide are registered.
+///
+public sealed class PostProcessorLoaderTests : IDisposable
+{
+ private readonly string directory = Path.Combine(Path.GetTempPath(), "OpenNestDesktopTests", Guid.NewGuid().ToString("N"));
+
+ public PostProcessorLoaderTests() => Directory.CreateDirectory(directory);
+
+ public void Dispose()
+ {
+ try { Directory.Delete(directory, recursive: true); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
+ }
+
+ [Fact]
+ public void Load_IsolatesAFailingDllReportsItAndRegistersTheOthersMaterials()
+ {
+ // This test assembly is a post plug-in (it holds MaterialPost); the broken DLL is not .NET at all.
+ File.Copy(typeof(MaterialPost).Assembly.Location, Path.Combine(directory, "Good.Posts.dll"));
+ File.WriteAllText(Path.Combine(directory, "Broken.Posts.dll"), "not an assembly");
+ File.WriteAllText(Path.Combine(directory, "Readme.txt"), "ignored");
+
+ var result = PostProcessorLoader.Load(directory);
+
+ var failure = Assert.Single(result.Failures);
+ Assert.StartsWith("Broken.Posts.dll: ", failure);
+ Assert.Contains(result.PostProcessors, post => post.Name == MaterialPost.PostName);
+ Assert.Contains("Unobtainium", PostProcessorMaterials.Names, StringComparer.Ordinal);
+ Assert.Contains("aluminum 5052", PostProcessorMaterials.Names, StringComparer.Ordinal);
+ }
+
+ [Fact]
+ public void Load_WithoutAPostsDirectory_LoadsNothing()
+ {
+ var result = PostProcessorLoader.Load(Path.Combine(directory, "missing"));
+
+ Assert.Empty(result.PostProcessors);
+ Assert.Empty(result.Failures);
+ }
+
+ [Fact]
+ public void Materials_AreAddedOnceIgnoringCaseAndBlanksInNameOrder()
+ {
+ PostProcessorMaterials.AddFrom(new MaterialPost(["zz Order B", "ZZ ORDER a", " ", "zz order b"]));
+
+ var names = PostProcessorMaterials.Names.Where(n => n.StartsWith("zz", StringComparison.OrdinalIgnoreCase)).ToList();
+ Assert.Equal(["ZZ ORDER a", "zz Order B"], names);
+ Assert.DoesNotContain(" ", PostProcessorMaterials.Names);
+ }
+
+ internal sealed class MaterialPost : IPostProcessor, IMaterialProvidingPostProcessor
+ {
+ public const string PostName = "Material Test Post";
+
+ private readonly IReadOnlyList materials;
+
+ public MaterialPost() : this(["Unobtainium", "aluminum 5052"])
+ {
+ }
+
+ public MaterialPost(IReadOnlyList materials) => this.materials = materials;
+
+ public string Name => PostName;
+
+ public string Author => "Tests";
+
+ public string Description => "Provides materials.";
+
+ public IEnumerable GetMaterialNames() => materials;
+
+ public void Post(Nest nest, Stream outputStream) { }
+
+ public void Post(Nest nest, string outputFile) { }
+ }
+}
diff --git a/OpenNest/PostProcessorLoader.cs b/OpenNest.Desktop/PostProcessorLoader.cs
similarity index 100%
rename from OpenNest/PostProcessorLoader.cs
rename to OpenNest.Desktop/PostProcessorLoader.cs
diff --git a/OpenNest/PostProcessorMaterials.cs b/OpenNest.Desktop/PostProcessorMaterials.cs
similarity index 100%
rename from OpenNest/PostProcessorMaterials.cs
rename to OpenNest.Desktop/PostProcessorMaterials.cs