diff --git a/SongTidy.Tests/RenameFormatsTests.cs b/SongTidy.Tests/Models/RenameFormatsTests.cs similarity index 99% rename from SongTidy.Tests/RenameFormatsTests.cs rename to SongTidy.Tests/Models/RenameFormatsTests.cs index a26994c..f346463 100644 --- a/SongTidy.Tests/RenameFormatsTests.cs +++ b/SongTidy.Tests/Models/RenameFormatsTests.cs @@ -1,6 +1,6 @@ using SongTidy.Models; -namespace SongTidy.Tests; +namespace SongTidy.Tests.Models; /// /// Contains tests for . diff --git a/SongTidy.Tests/AudioFileScannerTests.cs b/SongTidy.Tests/Services/AudioFileScannerTests.cs similarity index 76% rename from SongTidy.Tests/AudioFileScannerTests.cs rename to SongTidy.Tests/Services/AudioFileScannerTests.cs index 1dcaa18..02913d1 100644 --- a/SongTidy.Tests/AudioFileScannerTests.cs +++ b/SongTidy.Tests/Services/AudioFileScannerTests.cs @@ -2,31 +2,23 @@ using System.IO; using System.Linq; using SongTidy.Services; +using SongTidy.Tests.TestUtilities; -namespace SongTidy.Tests; +namespace SongTidy.Tests.Services; /// /// Contains tests for . /// [TestClass] public sealed class AudioFileScannerTests { - private string _tempDirectory = string.Empty; - - /// - /// Creates a unique temporary directory for the current test. - /// - [TestInitialize] - public void Initialize() { - _tempDirectory = Path.Combine(Path.GetTempPath(), $"SongTidy Scanner {Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDirectory); - } + private readonly TempDirectory _tempDirectory = new(); /// /// Removes the temporary directory created for the current test. /// [TestCleanup] public void Cleanup() { - Directory.Delete(_tempDirectory, recursive: true); + _tempDirectory.Dispose(); } /// @@ -38,7 +30,7 @@ public sealed class AudioFileScannerTests { CreateFile("b.FLAC"); CreateFile("c.txt"); - IReadOnlyList files = AudioFileScanner.Scan(_tempDirectory); + IReadOnlyList files = AudioFileScanner.Scan(_tempDirectory.FullPath); Assert.HasCount(2, files); Assert.IsTrue(files.Any(file => file.EndsWith("a.mp3", StringComparison.OrdinalIgnoreCase))); @@ -53,7 +45,7 @@ public sealed class AudioFileScannerTests { CreateFile("root.mp3"); CreateFile(Path.Combine("sub", "nested.wav")); - IReadOnlyList files = AudioFileScanner.Scan(_tempDirectory); + IReadOnlyList files = AudioFileScanner.Scan(_tempDirectory.FullPath); Assert.HasCount(1, files); Assert.IsTrue(files[0].EndsWith("root.mp3", StringComparison.OrdinalIgnoreCase)); @@ -64,8 +56,8 @@ public sealed class AudioFileScannerTests { /// /// The relative file name, which may include a subdirectory. private void CreateFile(string fileName) { - string path = Path.Combine(_tempDirectory, fileName); - string directory = Path.GetDirectoryName(path) ?? _tempDirectory; + string path = Path.Combine(_tempDirectory.FullPath, fileName); + string directory = Path.GetDirectoryName(path) ?? _tempDirectory.FullPath; Directory.CreateDirectory(directory); File.WriteAllText(path, string.Empty); diff --git a/SongTidy.Tests/FilenameParserTests.cs b/SongTidy.Tests/Services/FilenameParserTests.cs similarity index 99% rename from SongTidy.Tests/FilenameParserTests.cs rename to SongTidy.Tests/Services/FilenameParserTests.cs index 637cc93..1d0f77a 100644 --- a/SongTidy.Tests/FilenameParserTests.cs +++ b/SongTidy.Tests/Services/FilenameParserTests.cs @@ -1,6 +1,6 @@ using SongTidy.Services; -namespace SongTidy.Tests; +namespace SongTidy.Tests.Services; /// /// Contains tests for . diff --git a/SongTidy.Tests/PlanXmlIOTests.cs b/SongTidy.Tests/Services/PlanXmlIOTests.cs similarity index 89% rename from SongTidy.Tests/PlanXmlIOTests.cs rename to SongTidy.Tests/Services/PlanXmlIOTests.cs index 68240fb..02e76f6 100644 --- a/SongTidy.Tests/PlanXmlIOTests.cs +++ b/SongTidy.Tests/Services/PlanXmlIOTests.cs @@ -6,8 +6,9 @@ using System.Xml.Linq; using SongTidy.Models; using SongTidy.Services; +using SongTidy.Tests.TestUtilities; -namespace SongTidy.Tests; +namespace SongTidy.Tests.Services; /// /// Contains tests for . @@ -210,32 +211,27 @@ public sealed class PlanXmlIOTests { /// [TestMethod] public void SavesAndLoadsFileWithSpacesInPath() { - string directory = Path.Combine(Path.GetTempPath(), $"SongTidy Plan Test {Guid.NewGuid():N}"); - string filePath = Path.Combine(directory, "plan with spaces.xml"); - Directory.CreateDirectory(directory); + using TempDirectory tempDirectory = new(); + string filePath = Path.Combine(tempDirectory.FullPath, "plan with spaces.xml"); - try { - SongPlan plan = CreateSamplePlan(); + SongPlan plan = CreateSamplePlan(); - PlanXmlIO.Save(plan, filePath); + PlanXmlIO.Save(plan, filePath); - Assert.IsTrue(File.Exists(filePath)); + Assert.IsTrue(File.Exists(filePath)); - byte[] bytes = File.ReadAllBytes(filePath); - Assert.AreEqual((byte)'<', bytes[0]); + byte[] bytes = File.ReadAllBytes(filePath); + Assert.AreEqual((byte)'<', bytes[0]); - string xml = Encoding.UTF8.GetString(bytes); - Assert.IsTrue( - xml.StartsWith("", StringComparison.Ordinal), - "The file should be written as UTF-8 without a BOM."); + string xml = Encoding.UTF8.GetString(bytes); + Assert.IsTrue( + xml.StartsWith("", StringComparison.Ordinal), + "The file should be written as UTF-8 without a BOM."); - SongPlan loaded = PlanXmlIO.Load(filePath); - Assert.AreEqual(plan.Format, loaded.Format); - Assert.HasCount(plan.Items.Count, loaded.Items); - AssertPlanItem(plan.Items[0], loaded.Items[0]); - } finally { - Directory.Delete(directory, recursive: true); - } + SongPlan loaded = PlanXmlIO.Load(filePath); + Assert.AreEqual(plan.Format, loaded.Format); + Assert.HasCount(plan.Items.Count, loaded.Items); + AssertPlanItem(plan.Items[0], loaded.Items[0]); } #endregion diff --git a/SongTidy.Tests/RenameValidatorTests.cs b/SongTidy.Tests/Services/RenameValidatorTests.cs similarity index 94% rename from SongTidy.Tests/RenameValidatorTests.cs rename to SongTidy.Tests/Services/RenameValidatorTests.cs index a94defa..932b451 100644 --- a/SongTidy.Tests/RenameValidatorTests.cs +++ b/SongTidy.Tests/Services/RenameValidatorTests.cs @@ -3,31 +3,23 @@ using System.Linq; using SongTidy.Models; using SongTidy.Services; +using SongTidy.Tests.TestUtilities; -namespace SongTidy.Tests; +namespace SongTidy.Tests.Services; /// /// Contains tests for . /// [TestClass] public sealed class RenameValidatorTests { - private string _tempDirectory = string.Empty; - - /// - /// Creates a unique temporary directory for the current test. - /// - [TestInitialize] - public void Initialize() { - _tempDirectory = Path.Combine(Path.GetTempPath(), $"SongTidy Validation {Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDirectory); - } + private readonly TempDirectory _tempDirectory = new(); /// /// Removes the temporary directory created for the current test. /// [TestCleanup] public void Cleanup() { - Directory.Delete(_tempDirectory, recursive: true); + _tempDirectory.Dispose(); } #region Per-entry validation @@ -105,7 +97,7 @@ public sealed class RenameValidatorTests { /// [TestMethod] public void ReportsMissingSourceFile() { - string sourcePath = Path.Combine(_tempDirectory, "Missing.mp3"); + string sourcePath = Path.Combine(_tempDirectory.FullPath, "Missing.mp3"); RenameValidationResult result = Validate(CreateItem(sourcePath, "Title")); @@ -140,8 +132,8 @@ public sealed class RenameValidatorTests { [TestMethod] public void TreatsSourcePathsAsCaseSensitive() { CreateFile("Case.mp3"); - PlanItem first = CreateItem(Path.Combine(_tempDirectory, "Case.mp3"), "One"); - PlanItem second = CreateItem(Path.Combine(_tempDirectory, "case.mp3"), "Two"); + PlanItem first = CreateItem(Path.Combine(_tempDirectory.FullPath, "Case.mp3"), "One"); + PlanItem second = CreateItem(Path.Combine(_tempDirectory.FullPath, "case.mp3"), "Two"); RenameValidationResult result = Validate(first, second); @@ -283,7 +275,7 @@ public sealed class RenameValidatorTests { public void ReportsAllProblemKinds() { string sourcePath = CreateFile("A.mp3"); PlanItem emptyTitle = CreateItem(sourcePath, ""); - PlanItem missingSource = CreateItem(Path.Combine(_tempDirectory, "Missing.mp3"), "Title"); + PlanItem missingSource = CreateItem(Path.Combine(_tempDirectory.FullPath, "Missing.mp3"), "Title"); PlanItem invalidCharacter = CreateItem(sourcePath, "Bad/Title"); RenameValidationResult result = Validate(emptyTitle, missingSource, invalidCharacter); @@ -319,8 +311,8 @@ public sealed class RenameValidatorTests { /// The relative file name, which may include a subdirectory. /// The full path of the created file. private string CreateFile(string fileName) { - string path = Path.Combine(_tempDirectory, fileName); - string directory = Path.GetDirectoryName(path) ?? _tempDirectory; + string path = Path.Combine(_tempDirectory.FullPath, fileName); + string directory = Path.GetDirectoryName(path) ?? _tempDirectory.FullPath; Directory.CreateDirectory(directory); File.WriteAllText(path, string.Empty); diff --git a/SongTidy.Tests/TestUtilities/TempDirectory.cs b/SongTidy.Tests/TestUtilities/TempDirectory.cs new file mode 100644 index 0000000..cca61d4 --- /dev/null +++ b/SongTidy.Tests/TestUtilities/TempDirectory.cs @@ -0,0 +1,40 @@ +namespace SongTidy.Tests.TestUtilities; + +/// +/// Creates a unique temporary directory per test instance and removes it on disposal. +/// +public sealed class TempDirectory : IDisposable { + /// + /// Gets the unique directory path managed by this instance. + /// + public string FullPath { get; } + + /// + /// Creates a unique temporary directory. + /// + public TempDirectory() { + FullPath = Path.Combine(Path.GetTempPath(), "songtidy-tests-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(FullPath); + } + + /// + /// Deletes the directory tree, retrying once after a short delay. + /// + public void Dispose() { + try { + // SAFETY: Recursive deletion is confined to the GUID-named directory + // created by this instance under the OS temp folder. + Directory.Delete(FullPath, recursive: true); + } catch (IOException) { + // A file handle may still be closing; retry once before giving up. + Thread.Sleep(100); + + try { + // SAFETY: The retry targets the same instance-owned temp directory. + Directory.Delete(FullPath, recursive: true); + } catch (IOException) { + // Leftovers in the OS temp folder are harmless; never fail a test over cleanup. + } + } + } +} diff --git a/SongTidy.Tests/MainWindowViewModelTests.cs b/SongTidy.Tests/ViewModels/MainWindowViewModelTests.cs similarity index 81% rename from SongTidy.Tests/MainWindowViewModelTests.cs rename to SongTidy.Tests/ViewModels/MainWindowViewModelTests.cs index 4b5f232..1127365 100644 --- a/SongTidy.Tests/MainWindowViewModelTests.cs +++ b/SongTidy.Tests/ViewModels/MainWindowViewModelTests.cs @@ -3,32 +3,24 @@ using System.Linq; using SongTidy.Models; using SongTidy.Services; +using SongTidy.Tests.TestUtilities; using SongTidy.ViewModels; -namespace SongTidy.Tests; +namespace SongTidy.Tests.ViewModels; /// /// Contains tests for . /// [TestClass] public sealed class MainWindowViewModelTests { - private string _tempDirectory = string.Empty; - - /// - /// Creates a unique temporary directory for the current test. - /// - [TestInitialize] - public void Initialize() { - _tempDirectory = Path.Combine(Path.GetTempPath(), $"SongTidy ViewModel {Guid.NewGuid():N}"); - Directory.CreateDirectory(_tempDirectory); - } + private readonly TempDirectory _tempDirectory = new(); /// /// Removes the temporary directory created for the current test. /// [TestCleanup] public void Cleanup() { - Directory.Delete(_tempDirectory, recursive: true); + _tempDirectory.Dispose(); } #region Input and merge rules @@ -39,7 +31,7 @@ public sealed class MainWindowViewModelTests { [TestMethod] public void AddFilesParsesAndAppendsEntries() { MainWindowViewModel viewModel = new(); - string sourcePath = Path.Combine(_tempDirectory, "A - B (Live).mp3"); + string sourcePath = Path.Combine(_tempDirectory.FullPath, "A - B (Live).mp3"); viewModel.AddFiles(new[] { sourcePath }); @@ -62,7 +54,7 @@ public sealed class MainWindowViewModelTests { [TestMethod] public void AddFilesSkipsDuplicatesAndPreservesEdits() { MainWindowViewModel viewModel = new(); - string sourcePath = Path.Combine(_tempDirectory, "A - B.mp3"); + string sourcePath = Path.Combine(_tempDirectory.FullPath, "A - B.mp3"); viewModel.AddFiles(new[] { sourcePath }); viewModel.Entries[0].Artist = "Edited"; @@ -80,8 +72,8 @@ public sealed class MainWindowViewModelTests { MainWindowViewModel viewModel = new(); viewModel.AddFiles(new[] { - Path.Combine(_tempDirectory, "a.mp3"), - Path.Combine(_tempDirectory, "A.mp3"), + Path.Combine(_tempDirectory.FullPath, "a.mp3"), + Path.Combine(_tempDirectory.FullPath, "A.mp3"), }); Assert.HasCount(2, viewModel.Entries); @@ -93,9 +85,9 @@ public sealed class MainWindowViewModelTests { [TestMethod] public void AddFilesWithOnlyDuplicatesDoesNotDirty() { MainWindowViewModel viewModel = new(); - string sourcePath = Path.Combine(_tempDirectory, "A.mp3"); + string sourcePath = Path.Combine(_tempDirectory.FullPath, "A.mp3"); viewModel.AddFiles(new[] { sourcePath }); - viewModel.SavePlan(Path.Combine(_tempDirectory, "plan.xml")); + viewModel.SavePlan(Path.Combine(_tempDirectory.FullPath, "plan.xml")); viewModel.AddFiles(new[] { sourcePath }); @@ -113,7 +105,7 @@ public sealed class MainWindowViewModelTests { CreateFile("C.txt"); CreateFile(Path.Combine("sub", "D.wav")); - viewModel.AddFilesFromFolder(_tempDirectory); + viewModel.AddFilesFromFolder(_tempDirectory.FullPath); Assert.HasCount(2, viewModel.Entries); } @@ -128,9 +120,9 @@ public sealed class MainWindowViewModelTests { [TestMethod] public void EntryEditMarksDirtyAndRevertingDoesNotClear() { MainWindowViewModel viewModel = new(); - string sourcePath = Path.Combine(_tempDirectory, "A.mp3"); + string sourcePath = Path.Combine(_tempDirectory.FullPath, "A.mp3"); viewModel.AddFiles(new[] { sourcePath }); - viewModel.SavePlan(Path.Combine(_tempDirectory, "plan.xml")); + viewModel.SavePlan(Path.Combine(_tempDirectory.FullPath, "plan.xml")); SongEntryViewModel entry = viewModel.Entries[0]; string originalArtist = entry.Artist; @@ -147,9 +139,9 @@ public sealed class MainWindowViewModelTests { [TestMethod] public void FormatChangeUpdatesPreviewsAndMarksDirty() { MainWindowViewModel viewModel = new(); - string sourcePath = Path.Combine(_tempDirectory, "A - B (Live).mp3"); + string sourcePath = Path.Combine(_tempDirectory.FullPath, "A - B (Live).mp3"); viewModel.AddFiles(new[] { sourcePath }); - viewModel.SavePlan(Path.Combine(_tempDirectory, "plan.xml")); + viewModel.SavePlan(Path.Combine(_tempDirectory.FullPath, "plan.xml")); viewModel.SelectedFormat = RenameFormat.TitleFirst; @@ -163,9 +155,9 @@ public sealed class MainWindowViewModelTests { [TestMethod] public void SettingSameFormatDoesNotDirty() { MainWindowViewModel viewModel = new(); - string sourcePath = Path.Combine(_tempDirectory, "A.mp3"); + string sourcePath = Path.Combine(_tempDirectory.FullPath, "A.mp3"); viewModel.AddFiles(new[] { sourcePath }); - viewModel.SavePlan(Path.Combine(_tempDirectory, "plan.xml")); + viewModel.SavePlan(Path.Combine(_tempDirectory.FullPath, "plan.xml")); viewModel.SelectedFormat = RenameFormat.ArtistFirst; @@ -182,9 +174,9 @@ public sealed class MainWindowViewModelTests { [TestMethod] public void NewPlanClearsDocument() { MainWindowViewModel viewModel = new(); - string sourcePath = Path.Combine(_tempDirectory, "A.mp3"); + string sourcePath = Path.Combine(_tempDirectory.FullPath, "A.mp3"); viewModel.AddFiles(new[] { sourcePath }); - viewModel.SavePlan(Path.Combine(_tempDirectory, "plan.xml")); + viewModel.SavePlan(Path.Combine(_tempDirectory.FullPath, "plan.xml")); viewModel.NewPlan(); @@ -200,9 +192,9 @@ public sealed class MainWindowViewModelTests { [TestMethod] public void ClosePlanClearsDocument() { MainWindowViewModel viewModel = new(); - string sourcePath = Path.Combine(_tempDirectory, "A.mp3"); + string sourcePath = Path.Combine(_tempDirectory.FullPath, "A.mp3"); viewModel.AddFiles(new[] { sourcePath }); - viewModel.SavePlan(Path.Combine(_tempDirectory, "plan.xml")); + viewModel.SavePlan(Path.Combine(_tempDirectory.FullPath, "plan.xml")); viewModel.ClosePlan(); @@ -217,17 +209,17 @@ public sealed class MainWindowViewModelTests { /// [TestMethod] public void LoadPlanRestoresDocument() { - string planPath = Path.Combine(_tempDirectory, "plan.xml"); + string planPath = Path.Combine(_tempDirectory.FullPath, "plan.xml"); SongPlan plan = new() { Format = RenameFormat.TitleFirst, Items = new List { new PlanItem { - Path = Path.Combine(_tempDirectory, "A - B.mp3"), + Path = Path.Combine(_tempDirectory.FullPath, "A - B.mp3"), Artist = "A", Title = "B", }, new PlanItem { - Path = Path.Combine(_tempDirectory, "C.mp3"), + Path = Path.Combine(_tempDirectory.FullPath, "C.mp3"), Title = "C", Comment = "Live", }, @@ -253,10 +245,10 @@ public sealed class MainWindowViewModelTests { [TestMethod] public void SavePlanWritesDocumentAndClearsDirty() { MainWindowViewModel viewModel = new(); - string sourcePath = Path.Combine(_tempDirectory, "A - B (Live).mp3"); + string sourcePath = Path.Combine(_tempDirectory.FullPath, "A - B (Live).mp3"); viewModel.AddFiles(new[] { sourcePath }); viewModel.SelectedFormat = RenameFormat.TitleFirst; - string planPath = Path.Combine(_tempDirectory, "saved.xml"); + string planPath = Path.Combine(_tempDirectory.FullPath, "saved.xml"); viewModel.SavePlan(planPath); @@ -306,8 +298,8 @@ public sealed class MainWindowViewModelTests { /// The relative file name, which may include a subdirectory. /// The full path of the created file. private string CreateFile(string fileName) { - string path = Path.Combine(_tempDirectory, fileName); - string directory = Path.GetDirectoryName(path) ?? _tempDirectory; + string path = Path.Combine(_tempDirectory.FullPath, fileName); + string directory = Path.GetDirectoryName(path) ?? _tempDirectory.FullPath; Directory.CreateDirectory(directory); File.WriteAllText(path, string.Empty); diff --git a/SongTidy.Tests/SongEntryViewModelTests.cs b/SongTidy.Tests/ViewModels/SongEntryViewModelTests.cs similarity index 99% rename from SongTidy.Tests/SongEntryViewModelTests.cs rename to SongTidy.Tests/ViewModels/SongEntryViewModelTests.cs index 9926558..79fe690 100644 --- a/SongTidy.Tests/SongEntryViewModelTests.cs +++ b/SongTidy.Tests/ViewModels/SongEntryViewModelTests.cs @@ -3,7 +3,7 @@ using System.IO; using SongTidy.Models; using SongTidy.ViewModels; -namespace SongTidy.Tests; +namespace SongTidy.Tests.ViewModels; /// /// Contains tests for .