From 6c59047704c0ca880744b7548a52da08b7a6d0cc Mon Sep 17 00:00:00 2001 From: ole kristian homelien Date: Sat, 6 Jun 2026 22:13:54 +0200 Subject: [PATCH 01/26] Code --- .../DependencyInjectionContainer.cs | 1 + .../Containers/SystemFolderContainer.cs | 582 ++++++++++++++++++ .../Shared.Core/PackFiles/PackFileService.cs | 3 + .../Shared.Core/Services/FileSystemAccess.cs | 30 + .../Services/FileSystemWatcherWrapper.cs | 26 + .../Shared.Core/Services/IFileSystemAccess.cs | 6 + .../Services/IFileSystemWatcher.cs | 13 + .../SystemFolderContainerTests_Dispose.cs | 90 +++ .../SystemFolderContainerTests_Integration.cs | 196 ++++++ .../SystemFolderContainerTests_Read.cs | 240 ++++++++ .../SystemFolderContainerTests_SaveToDisk.cs | 148 +++++ .../SystemFolderContainerTests_Watcher.cs | 216 +++++++ .../SystemFolderContainerTests_Write.cs | 225 +++++++ ...temFolderContainer_PackFileServiceTests.cs | 179 ++++++ .../Services/FileSystemWatcherWrapperTests.cs | 55 ++ system-folder-container-plan.md | 554 +++++++++++++++++ 16 files changed, 2564 insertions(+) create mode 100644 Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/SystemFolderContainer.cs create mode 100644 Shared/SharedCore/Shared.Core/Services/FileSystemWatcherWrapper.cs create mode 100644 Shared/SharedCore/Shared.Core/Services/IFileSystemWatcher.cs create mode 100644 Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Dispose.cs create mode 100644 Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Integration.cs create mode 100644 Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Read.cs create mode 100644 Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_SaveToDisk.cs create mode 100644 Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Watcher.cs create mode 100644 Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Write.cs create mode 100644 Shared/SharedCore/Shared.CoreTest/PackFiles/SystemFolderContainer_PackFileServiceTests.cs create mode 100644 Shared/SharedCore/Shared.CoreTest/Services/FileSystemWatcherWrapperTests.cs create mode 100644 system-folder-container-plan.md diff --git a/Shared/SharedCore/Shared.Core/DependencyInjectionContainer.cs b/Shared/SharedCore/Shared.Core/DependencyInjectionContainer.cs index c91e23ecf..75cd9a79e 100644 --- a/Shared/SharedCore/Shared.Core/DependencyInjectionContainer.cs +++ b/Shared/SharedCore/Shared.Core/DependencyInjectionContainer.cs @@ -46,6 +46,7 @@ public override void Register(IServiceCollection services) services.AddSingleton(); services.AddScoped(); services.AddTransient(); + services.AddTransient(); } } diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/SystemFolderContainer.cs b/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/SystemFolderContainer.cs new file mode 100644 index 000000000..0599ff397 --- /dev/null +++ b/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/SystemFolderContainer.cs @@ -0,0 +1,582 @@ +using Shared.Core.Events; +using Shared.Core.Events.Global; +using Shared.Core.ErrorHandling; +using Shared.Core.Misc; +using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.PackFiles.Serialization; +using Shared.Core.PackFiles.Utility; +using Shared.Core.Services; +using Shared.Core.Settings; + +namespace Shared.Core.PackFiles.Models.Containers +{ + internal class SystemFolderContainer : IPackFileContainerInternal, IDisposable + { + private static readonly ILogger _logger = Logging.Create(); + + private readonly IFileSystemAccess _fileSystemAccess; + private readonly IFileSystemWatcher? _watcher; + private readonly IGlobalEventHub? _eventHub; + private readonly SynchronizationContext? _syncContext; + private readonly Dictionary _fileList = new(); + private readonly List _pendingEvents = new(); + private Timer? _debounceTimer; + internal volatile bool _suppressWatcher = false; + private bool _disposed = false; + + public string Name { get; set; } + public bool IsCaPackFile { get; set; } = false; + public string? SystemFilePath { get; } + + public SystemFolderContainer(string folderPath, IFileSystemAccess fileSystemAccess, IFileSystemWatcher? watcher = null, IGlobalEventHub? eventHub = null, SynchronizationContext? syncContext = null) + { + if (string.IsNullOrWhiteSpace(folderPath)) + throw new ArgumentException("Folder path cannot be empty.", nameof(folderPath)); + if (!fileSystemAccess.DirectoryExists(folderPath)) + throw new DirectoryNotFoundException($"Directory not found: {folderPath}"); + + _fileSystemAccess = fileSystemAccess; + _watcher = watcher; + _eventHub = eventHub; + _syncContext = syncContext ?? SynchronizationContext.Current; + SystemFilePath = folderPath; + Name = Path.GetFileName(folderPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + + ScanFolder(); + StartWatching(); + } + + private void ScanFolder() + { + var files = _fileSystemAccess.DirectoryGetFiles(SystemFilePath!, "*.*", SearchOption.AllDirectories); + foreach (var absolutePath in files) + { + var relativePath = Path.GetRelativePath(SystemFilePath!, absolutePath); + var normalizedPath = PathNormalization.NormalizeFileName(relativePath); + var fileName = Path.GetFileName(absolutePath); + var packFile = new PackFile(fileName, new FileSystemSource(absolutePath)); + _fileList[normalizedPath] = packFile; + } + } + + // --- IPackFileContainer read operations --- + + public int GetFileCount() => _fileList.Count; + + public PackFile? FindFile(string path) + { + var normalizedPath = PathNormalization.NormalizeFileName(path); + return _fileList.TryGetValue(normalizedPath, out var value) ? value : null; + } + + public bool ContainsFile(string path) + { + var normalizedPath = PathNormalization.NormalizeFileName(path); + return _fileList.ContainsKey(normalizedPath); + } + + public string? GetFullPath(PackFile file) + { + var pathByReference = _fileList.FirstOrDefault(x => ReferenceEquals(x.Value, file)).Key; + if (!string.IsNullOrWhiteSpace(pathByReference)) + return pathByReference; + + var pathByName = _fileList.FirstOrDefault(x => string.Equals(x.Value.Name, file.Name, StringComparison.OrdinalIgnoreCase)).Key; + return string.IsNullOrWhiteSpace(pathByName) ? null : pathByName; + } + + public Dictionary GetAllFiles() => _fileList; + + public SortedDictionary> GetAllFilesByFolder() + { + var result = new SortedDictionary>(StringComparer.InvariantCultureIgnoreCase); + + foreach (var fullPath in _fileList.Keys) + { + var lastSep = fullPath.LastIndexOf('\\'); + var folder = lastSep == -1 ? string.Empty : fullPath.Substring(0, lastSep); + var fileName = lastSep == -1 ? fullPath : fullPath.Substring(lastSep + 1); + + if (!result.TryGetValue(folder, out var files)) + { + files = new List(); + result[folder] = files; + } + + files.Add(fileName); + } + + return result; + } + + public List<(string FileName, PackFile Pack)> FindAllWithExtention(string extention) + { + extention = extention.ToLower(); + var output = new List<(string, PackFile)>(); + foreach (var file in _fileList) + { + if (Path.GetExtension(file.Key) == extention) + output.Add((file.Key, file.Value)); + } + return output; + } + + public List<(string Path, PackFile File)> SearchFiles(string? textFilter, IReadOnlyList? extensions) + { + var results = new List<(string Path, PackFile File)>(); + + foreach (var (path, packFile) in _fileList) + { + if (extensions != null && extensions.Count > 0) + { + var matchesExtension = false; + foreach (var ext in extensions) + { + if (packFile.Name.Contains(ext, StringComparison.OrdinalIgnoreCase)) + { + matchesExtension = true; + break; + } + } + if (!matchesExtension) + continue; + } + + if (!string.IsNullOrWhiteSpace(textFilter)) + { + if (!packFile.Name.Contains(textFilter, StringComparison.OrdinalIgnoreCase)) + continue; + } + + results.Add((path, packFile)); + } + + results.Sort((a, b) => StringComparer.OrdinalIgnoreCase.Compare(a.Path, b.Path)); + return results; + } + + public List<(string Path, PackFile File)> GetDirectoryContent(string directoryPath) + { + var prefix = string.IsNullOrEmpty(directoryPath) ? "" : directoryPath + "\\"; + var results = new List<(string Path, PackFile File)>(); + var directFileSlashCount = string.IsNullOrEmpty(directoryPath) ? 0 : directoryPath.Count(c => c == '\\') + 1; + + foreach (var (path, packFile) in _fileList) + { + if ((string.IsNullOrEmpty(prefix) || path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + && path.Count(c => c == '\\') == directFileSlashCount) + results.Add((path, packFile)); + } + + results.Sort((a, b) => StringComparer.CurrentCultureIgnoreCase.Compare(a.Path, b.Path)); + return results; + } + + // --- IPackFileContainerInternal write operations --- + + public void AddOrUpdateFile(string path, PackFile file) + { + if (string.IsNullOrWhiteSpace(file.Name)) + throw new Exception("PackFile name can not be empty"); + + var normalizedPath = PathNormalization.NormalizeFileName(path); + var absolutePath = Path.Combine(SystemFilePath!, normalizedPath); + var directory = Path.GetDirectoryName(absolutePath); + if (!string.IsNullOrEmpty(directory) && !_fileSystemAccess.DirectoryExists(directory)) + _fileSystemAccess.DirectoryCreateDirectory(directory); + + var data = file.DataSource.ReadData(); + using (SuppressWatcher()) + _fileSystemAccess.FileWriteAllBytes(absolutePath, data); + + var newPackFile = new PackFile(file.Name, new FileSystemSource(absolutePath)); + _fileList[normalizedPath] = newPackFile; + } + + public List AddFiles(List newFiles) + { + foreach (var file in newFiles) + { + if (string.IsNullOrWhiteSpace(file.PackFile.Name)) + throw new Exception("PackFile name can not be empty"); + } + + var added = new List(); + using (SuppressWatcher()) + { + foreach (var file in newFiles) + { + var fileName = file.PackFile.Name.Trim(); + var normalizedDir = PathNormalization.NormalizeDirectoryPath(file.DirectoyPath); + var normalizedPath = string.IsNullOrEmpty(normalizedDir) + ? PathNormalization.NormalizeFileName(fileName) + : PathNormalization.NormalizeFileName(normalizedDir + "\\" + fileName); + + var absolutePath = Path.Combine(SystemFilePath!, normalizedPath); + var directory = Path.GetDirectoryName(absolutePath); + if (!string.IsNullOrEmpty(directory) && !_fileSystemAccess.DirectoryExists(directory)) + _fileSystemAccess.DirectoryCreateDirectory(directory); + + var data = file.PackFile.DataSource.ReadData(); + _fileSystemAccess.FileWriteAllBytes(absolutePath, data); + + var newPackFile = new PackFile(fileName, new FileSystemSource(absolutePath)); + _fileList[normalizedPath] = newPackFile; + added.Add(newPackFile); + } + } + + return added; + } + + public PackFile? DeleteFile(PackFile file) + { + var key = _fileList.FirstOrDefault(x => ReferenceEquals(x.Value, file)).Key; + if (key == null) + return null; + + var absolutePath = Path.Combine(SystemFilePath!, key); + using (SuppressWatcher()) + { + if (_fileSystemAccess.FileExists(absolutePath)) + _fileSystemAccess.FileDelete(absolutePath); + } + + _fileList.Remove(key); + return file; + } + + public void DeleteFolder(string folder) + { + var normalizedFolder = PathNormalization.NormalizeFileName(folder); + var absoluteFolderPath = Path.Combine(SystemFilePath!, normalizedFolder); + + using (SuppressWatcher()) + { + if (_fileSystemAccess.DirectoryExists(absoluteFolderPath)) + _fileSystemAccess.DirectoryDelete(absoluteFolderPath, true); + } + + var keysToRemove = _fileList.Keys + .Where(k => k.Equals(normalizedFolder, StringComparison.InvariantCultureIgnoreCase) + || k.StartsWith(normalizedFolder + "\\", StringComparison.InvariantCultureIgnoreCase)) + .ToList(); + + foreach (var key in keysToRemove) + _fileList.Remove(key); + } + + public void MoveFile(PackFile file, string newFolderPath) + { + var oldKey = _fileList.FirstOrDefault(x => ReferenceEquals(x.Value, file)).Key; + if (oldKey == null) + throw new Exception($"File '{file.Name}' not found in container."); + + var normalizedDir = PathNormalization.NormalizeDirectoryPath(newFolderPath); + var newRelativePath = string.IsNullOrEmpty(normalizedDir) + ? PathNormalization.NormalizeFileName(file.Name) + : PathNormalization.NormalizeFileName(normalizedDir + "\\" + file.Name); + + var oldAbsolutePath = Path.Combine(SystemFilePath!, oldKey); + var newAbsolutePath = Path.Combine(SystemFilePath!, newRelativePath); + + var newDirectory = Path.GetDirectoryName(newAbsolutePath); + if (!string.IsNullOrEmpty(newDirectory) && !_fileSystemAccess.DirectoryExists(newDirectory)) + _fileSystemAccess.DirectoryCreateDirectory(newDirectory); + + using (SuppressWatcher()) + _fileSystemAccess.FileMove(oldAbsolutePath, newAbsolutePath); + + _fileList.Remove(oldKey); + var newPackFile = new PackFile(file.Name, new FileSystemSource(newAbsolutePath)); + _fileList[newRelativePath] = newPackFile; + } + + public string RenameDirectory(string currentNodeName, string newName) + { + var oldNodePath = PathNormalization.NormalizeFileName(currentNodeName); + var newNodePath = newName; + var lastSeparatorIndex = currentNodeName.LastIndexOf(Path.DirectorySeparatorChar); + if (lastSeparatorIndex != -1) + { + var parentPath = currentNodeName.Substring(0, lastSeparatorIndex); + newNodePath = parentPath + Path.DirectorySeparatorChar + newName; + } + newNodePath = PathNormalization.NormalizeFileName(newNodePath); + + var oldAbsolutePath = Path.Combine(SystemFilePath!, oldNodePath); + var newAbsolutePath = Path.Combine(SystemFilePath!, newNodePath); + + using (SuppressWatcher()) + _fileSystemAccess.DirectoryMove(oldAbsolutePath, newAbsolutePath); + + var oldPathPrefix = oldNodePath + "\\"; + var filesToUpdate = _fileList + .Where(x => x.Key.Equals(oldNodePath, StringComparison.InvariantCultureIgnoreCase) + || x.Key.StartsWith(oldPathPrefix, StringComparison.InvariantCultureIgnoreCase)) + .ToList(); + + foreach (var (path, packFile) in filesToUpdate) + { + _fileList.Remove(path); + var newPath = newNodePath; + if (path.Length > oldNodePath.Length) + newPath = newNodePath + path.Substring(oldNodePath.Length); + newPath = PathNormalization.NormalizeFileName(newPath); + + var newFileAbsolutePath = Path.Combine(SystemFilePath!, newPath); + var updatedPackFile = new PackFile(packFile.Name, new FileSystemSource(newFileAbsolutePath)); + _fileList[newPath] = updatedPackFile; + } + + return newNodePath; + } + + public void RenameFile(PackFile file, string newName) + { + var key = _fileList.FirstOrDefault(x => ReferenceEquals(x.Value, file)).Key; + if (key == null) + throw new Exception($"File '{file.Name}' not found in container."); + + var dir = Path.GetDirectoryName(key); + var newRelativePath = string.IsNullOrEmpty(dir) + ? PathNormalization.NormalizeFileName(newName) + : PathNormalization.NormalizeFileName(dir + "\\" + newName); + + var oldAbsolutePath = Path.Combine(SystemFilePath!, key); + var newAbsolutePath = Path.Combine(SystemFilePath!, newRelativePath); + + using (SuppressWatcher()) + _fileSystemAccess.FileMove(oldAbsolutePath, newAbsolutePath); + + _fileList.Remove(key); + file.Name = newName; + var newPackFile = new PackFile(newName, new FileSystemSource(newAbsolutePath)); + _fileList[newRelativePath] = newPackFile; + } + + public void SaveFileData(PackFile file, byte[] data) + { + var key = _fileList.FirstOrDefault(x => ReferenceEquals(x.Value, file)).Key; + if (key == null) + throw new Exception($"File '{file.Name}' not found in container."); + + var absolutePath = Path.Combine(SystemFilePath!, key); + using (SuppressWatcher()) + _fileSystemAccess.FileWriteAllBytes(absolutePath, data); + + file.DataSource = new FileSystemSource(absolutePath); + } + + public void SaveToDisk(string path, bool createBackup, GameInformation gameInformation) + { + if (_fileSystemAccess.FileExists(path) && DirectoryHelper.IsFileLocked(path)) + throw new IOException($"Cannot access {path} because another process has locked it."); + + var tempPath = path + "_temp"; + if (_fileSystemAccess.FileExists(tempPath) && DirectoryHelper.IsFileLocked(tempPath)) + throw new IOException($"Cannot access {tempPath} because another process has locked it."); + + if (createBackup) + SaveUtility.CreateFileBackup(path); + + // Build a transient PackFileContainer with in-memory data for serialization + var versionString = PackFileVersionConverter.ToString(PackFileVersion.PFH5); + var transientContainer = new PackFileContainer(Name) + { + Header = new PFHeader(versionString, PackFileCAType.MOD), + SystemFilePath = path + }; + + foreach (var (relativePath, packFile) in _fileList) + { + var data = packFile.DataSource.ReadData(); + var memFile = new PackFile(packFile.Name, new MemorySource(data)); + transientContainer.AddOrUpdateFile(relativePath, memFile); + } + + using (var fileStream = new FileStream(tempPath, FileMode.Create)) + { + using var writer = new BinaryWriter(fileStream); + PackFileSerializerWriter.SaveToByteArray(path, transientContainer, writer, gameInformation); + } + + if (_fileSystemAccess.FileExists(path)) + _fileSystemAccess.FileDelete(path); + _fileSystemAccess.FileMove(tempPath, path); + } + + // --- FileSystemWatcher integration --- + + private void StartWatching() + { + if (_watcher == null) + return; + + _watcher.Path = SystemFilePath!; + _watcher.IncludeSubdirectories = true; + _watcher.Created += OnExternalFileCreated; + _watcher.Deleted += OnExternalFileDeleted; + _watcher.Renamed += OnExternalFileRenamed; + _watcher.EnableRaisingEvents = true; + _debounceTimer = new Timer(OnDebounceElapsed, null, Timeout.Infinite, Timeout.Infinite); + } + + private void OnExternalFileCreated(object? sender, FileSystemEventArgs e) + { + if (_suppressWatcher) return; + lock (_pendingEvents) { _pendingEvents.Add(e); } + ResetDebounceTimer(); + } + + private void OnExternalFileDeleted(object? sender, FileSystemEventArgs e) + { + if (_suppressWatcher) return; + lock (_pendingEvents) { _pendingEvents.Add(e); } + ResetDebounceTimer(); + } + + private void OnExternalFileRenamed(object? sender, RenamedEventArgs e) + { + if (_suppressWatcher) return; + lock (_pendingEvents) { _pendingEvents.Add(e); } + ResetDebounceTimer(); + } + + private void ResetDebounceTimer() + { + _debounceTimer?.Change(300, Timeout.Infinite); + } + + private void OnDebounceElapsed(object? state) + { + if (_syncContext != null) + _syncContext.Post(_ => ProcessPendingEvents(null), null); + else + ProcessPendingEvents(null); + } + + internal void ProcessPendingEvents(object? state) + { + List events; + lock (_pendingEvents) + { + events = new List(_pendingEvents); + _pendingEvents.Clear(); + } + + var addedFiles = new List(); + var removedFiles = new List(); + + foreach (var e in events) + { + switch (e.ChangeType) + { + case WatcherChangeTypes.Created: + HandleExternalCreated(e.FullPath, addedFiles); + break; + case WatcherChangeTypes.Deleted: + HandleExternalDeleted(e.FullPath, removedFiles); + break; + case WatcherChangeTypes.Renamed: + var renamedArgs = (RenamedEventArgs)e; + HandleExternalDeleted(renamedArgs.OldFullPath, removedFiles); + HandleExternalCreated(renamedArgs.FullPath, addedFiles); + break; + } + } + + if (addedFiles.Count > 0) + _eventHub?.PublishGlobalEvent(new PackFileContainerFilesAddedEvent(this, addedFiles)); + if (removedFiles.Count > 0) + _eventHub?.PublishGlobalEvent(new PackFileContainerFilesRemovedEvent(this, removedFiles)); + } + + private void HandleExternalCreated(string absolutePath, List addedFiles) + { + // Skip directories — we only track files + if (Directory.Exists(absolutePath) && !File.Exists(absolutePath)) + return; + + var relativePath = Path.GetRelativePath(SystemFilePath!, absolutePath); + var normalizedPath = PathNormalization.NormalizeFileName(relativePath); + + if (_fileList.ContainsKey(normalizedPath)) + return; // Already tracked + + try + { + var fileName = Path.GetFileName(absolutePath); + var packFile = new PackFile(fileName, new FileSystemSource(absolutePath)); + _fileList[normalizedPath] = packFile; + addedFiles.Add(packFile); + } + catch (Exception ex) + { + // File may be locked or still being written by another process + _logger.Here().Warning($"Failed to add externally created file '{absolutePath}': {ex.Message}"); + } + } + + private void HandleExternalDeleted(string absolutePath, List removedFiles) + { + var relativePath = Path.GetRelativePath(SystemFilePath!, absolutePath); + var normalizedPath = PathNormalization.NormalizeFileName(relativePath); + + // Check if this is an exact file match + if (_fileList.TryGetValue(normalizedPath, out var packFile)) + { + _fileList.Remove(normalizedPath); + removedFiles.Add(packFile); + return; + } + + // Handle folder deletion — remove all files with this prefix + var folderPrefix = normalizedPath + "\\"; + var keysToRemove = _fileList.Keys + .Where(k => k.StartsWith(folderPrefix, StringComparison.InvariantCultureIgnoreCase)) + .ToList(); + + foreach (var key in keysToRemove) + { + removedFiles.Add(_fileList[key]); + _fileList.Remove(key); + } + } + + // --- Watcher suppression --- + + private WatcherSuppression SuppressWatcher() => new(this); + + private readonly struct WatcherSuppression : IDisposable + { + private readonly SystemFolderContainer _container; + + public WatcherSuppression(SystemFolderContainer container) + { + _container = container; + _container._suppressWatcher = true; + } + + public void Dispose() => _container._suppressWatcher = false; + } + + // --- IDisposable --- + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + if (_watcher != null) + { + _watcher.EnableRaisingEvents = false; + _watcher.Dispose(); + } + _debounceTimer?.Dispose(); + _debounceTimer = null; + _fileList.Clear(); + } + } +} diff --git a/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs b/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs index 81fef8f6f..a1ce1de66 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/PackFileService.cs @@ -161,6 +161,9 @@ public void UnloadPackContainer(IPackFileContainer pf) _logger.Here().Information($"Unloaded pack file container '{DescribeContainer(container)}'. Remaining containers: {_packFileContainers.Count}"); _globalEventHub?.PublishGlobalEvent(new PackFileContainerRemovedEvent(container)); + + if (container is IDisposable disposable) + disposable.Dispose(); } public List<(string FileName, PackFile Pack)> FindAllWithExtention(string extention, IPackFileContainer? pf = null) diff --git a/Shared/SharedCore/Shared.Core/Services/FileSystemAccess.cs b/Shared/SharedCore/Shared.Core/Services/FileSystemAccess.cs index 9cd4260a9..441c7a547 100644 --- a/Shared/SharedCore/Shared.Core/Services/FileSystemAccess.cs +++ b/Shared/SharedCore/Shared.Core/Services/FileSystemAccess.cs @@ -27,8 +27,38 @@ public byte[] FileReadAllBytes(string path) public bool FileExists(string path) => File.Exists(path); + public void FileDelete(string path) + { + File.Delete(path); + _logger.Here().Information($"Deleted file '{path}'"); + } + + public void FileMove(string sourceFileName, string destFileName) + { + File.Move(sourceFileName, destFileName); + _logger.Here().Information($"Moved file '{sourceFileName}' to '{destFileName}'"); + } + public bool DirectoryExists(string path) => Directory.Exists(path); + public void DirectoryCreateDirectory(string path) + { + Directory.CreateDirectory(path); + _logger.Here().Information($"Created directory '{path}'"); + } + + public void DirectoryDelete(string path, bool recursive) + { + Directory.Delete(path, recursive); + _logger.Here().Information($"Deleted directory '{path}' (recursive={recursive})"); + } + + public void DirectoryMove(string sourceDirName, string destDirName) + { + Directory.Move(sourceDirName, destDirName); + _logger.Here().Information($"Moved directory '{sourceDirName}' to '{destDirName}'"); + } + public string[] DirectoryGetFiles(string path, string searchPattern, SearchOption searchOption) { var files = Directory.GetFiles(path, searchPattern, searchOption); diff --git a/Shared/SharedCore/Shared.Core/Services/FileSystemWatcherWrapper.cs b/Shared/SharedCore/Shared.Core/Services/FileSystemWatcherWrapper.cs new file mode 100644 index 000000000..d31e3e273 --- /dev/null +++ b/Shared/SharedCore/Shared.Core/Services/FileSystemWatcherWrapper.cs @@ -0,0 +1,26 @@ +namespace Shared.Core.Services +{ + public class FileSystemWatcherWrapper : IFileSystemWatcher + { + private readonly FileSystemWatcher _watcher; + + public FileSystemWatcherWrapper() + { + _watcher = new FileSystemWatcher(); + _watcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName; + _watcher.Created += (s, e) => Created?.Invoke(s, e); + _watcher.Deleted += (s, e) => Deleted?.Invoke(s, e); + _watcher.Renamed += (s, e) => Renamed?.Invoke(s, e); + } + + public string Path { get => _watcher.Path; set => _watcher.Path = value; } + public bool IncludeSubdirectories { get => _watcher.IncludeSubdirectories; set => _watcher.IncludeSubdirectories = value; } + public bool EnableRaisingEvents { get => _watcher.EnableRaisingEvents; set => _watcher.EnableRaisingEvents = value; } + + public event FileSystemEventHandler? Created; + public event FileSystemEventHandler? Deleted; + public event RenamedEventHandler? Renamed; + + public void Dispose() => _watcher.Dispose(); + } +} diff --git a/Shared/SharedCore/Shared.Core/Services/IFileSystemAccess.cs b/Shared/SharedCore/Shared.Core/Services/IFileSystemAccess.cs index 066d94640..cd91bcb71 100644 --- a/Shared/SharedCore/Shared.Core/Services/IFileSystemAccess.cs +++ b/Shared/SharedCore/Shared.Core/Services/IFileSystemAccess.cs @@ -11,7 +11,13 @@ public interface IFileSystemAccess byte[] FileReadAllBytes(string path); bool FileExists(string path); + void FileDelete(string path); + void FileMove(string sourceFileName, string destFileName); + bool DirectoryExists(string path); + void DirectoryCreateDirectory(string path); + void DirectoryDelete(string path, bool recursive); + void DirectoryMove(string sourceDirName, string destDirName); string[] DirectoryGetFiles(string path, string searchPattern, SearchOption searchOption); DirectoryInfo CreateDirectoryInfo(string path); diff --git a/Shared/SharedCore/Shared.Core/Services/IFileSystemWatcher.cs b/Shared/SharedCore/Shared.Core/Services/IFileSystemWatcher.cs new file mode 100644 index 000000000..8f47a982c --- /dev/null +++ b/Shared/SharedCore/Shared.Core/Services/IFileSystemWatcher.cs @@ -0,0 +1,13 @@ +namespace Shared.Core.Services +{ + public interface IFileSystemWatcher : IDisposable + { + string Path { get; set; } + bool IncludeSubdirectories { get; set; } + bool EnableRaisingEvents { get; set; } + + event FileSystemEventHandler? Created; + event FileSystemEventHandler? Deleted; + event RenamedEventHandler? Renamed; + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Dispose.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Dispose.cs new file mode 100644 index 000000000..b3808bf09 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Dispose.cs @@ -0,0 +1,90 @@ +using Moq; +using Shared.Core.Events; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.Services; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture] + internal class SystemFolderContainerTests_Dispose + { + private string _tempDir = null!; + private Mock _fileSystemAccess = null!; + private Mock _mockWatcher = null!; + private Mock _mockEventHub = null!; + + [SetUp] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), "SysFolderDispose_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + + var seedPath = Path.Combine(_tempDir, "file.txt"); + File.WriteAllText(seedPath, "content"); + + _fileSystemAccess = new Mock(); + _fileSystemAccess.Setup(x => x.DirectoryExists(_tempDir)).Returns(true); + _fileSystemAccess.Setup(x => x.DirectoryGetFiles(_tempDir, "*.*", SearchOption.AllDirectories)) + .Returns([seedPath]); + + _mockWatcher = new Mock(); + _mockEventHub = new Mock(); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + [Test] + public void Dispose_StopsRaisingEvents() + { + var container = new SystemFolderContainer(_tempDir, _fileSystemAccess.Object, _mockWatcher.Object, _mockEventHub.Object); + + container.Dispose(); + + _mockWatcher.VerifySet(w => w.EnableRaisingEvents = false); + } + + [Test] + public void Dispose_DisposesWatcher() + { + var container = new SystemFolderContainer(_tempDir, _fileSystemAccess.Object, _mockWatcher.Object, _mockEventHub.Object); + + container.Dispose(); + + _mockWatcher.Verify(w => w.Dispose(), Times.Once); + } + + [Test] + public void Dispose_ClearsFileList() + { + var container = new SystemFolderContainer(_tempDir, _fileSystemAccess.Object, _mockWatcher.Object, _mockEventHub.Object); + Assert.That(container.GetFileCount(), Is.GreaterThan(0)); + + container.Dispose(); + + Assert.That(container.GetFileCount(), Is.EqualTo(0)); + } + + [Test] + public void Dispose_CalledTwice_NoException() + { + var container = new SystemFolderContainer(_tempDir, _fileSystemAccess.Object, _mockWatcher.Object, _mockEventHub.Object); + + container.Dispose(); + Assert.DoesNotThrow(() => container.Dispose()); + } + + [Test] + public void Dispose_WithoutWatcher_NoException() + { + var container = new SystemFolderContainer(_tempDir, _fileSystemAccess.Object); + + Assert.DoesNotThrow(() => container.Dispose()); + Assert.That(container.GetFileCount(), Is.EqualTo(0)); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Integration.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Integration.cs new file mode 100644 index 000000000..c9ce1f249 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Integration.cs @@ -0,0 +1,196 @@ +using Moq; +using Shared.Core.Events; +using Shared.Core.Events.Global; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.PackFiles.Serialization; +using Shared.Core.PackFiles.Utility; +using Shared.Core.Services; +using Shared.Core.Settings; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture] + internal class SystemFolderContainerTests_Integration + { + private string _tempDir = null!; + + [SetUp] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), "SysFolderInteg_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + private static IFileSystemAccess CreateRealFileSystemAccess() + { + return new FileSystemAccess(); + } + + [Test] + public void FullWorkflow_CreateFolder_AddFile_DeleteFile_Save() + { + // Arrange: seed a file in the temp directory + var seedPath = Path.Combine(_tempDir, "models", "unit.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(seedPath)!); + File.WriteAllText(seedPath, "original"); + + var eventHub = new Mock(); + var fileSystem = CreateRealFileSystemAccess(); + var watcher = new Mock(); + + // Act: create container from real folder + var container = new SystemFolderContainer(_tempDir, fileSystem, watcher.Object, eventHub.Object); + Assert.That(container.GetFileCount(), Is.EqualTo(1)); + Assert.That(container.FindFile(@"models\unit.txt"), Is.Not.Null); + + // Register with PackFileService + var pfs = new PackFileService(eventHub.Object); + pfs.MessageBoxProvider = new Mock().Object; + pfs.EnforceGameFilesMustBeLoaded = false; + pfs.AddContainer(container); + + // Add a file via service + var newFileData = "new file content"u8.ToArray(); + var newFile = new PackFile("added.bin", new MemorySource(newFileData)); + pfs.AddFilesToPack(container, [new NewPackFileEntry("scripts", newFile)]); + + Assert.That(container.GetFileCount(), Is.EqualTo(2)); + Assert.That(File.Exists(Path.Combine(_tempDir, "scripts", "added.bin")), Is.True); + Assert.That(File.ReadAllBytes(Path.Combine(_tempDir, "scripts", "added.bin")), Is.EqualTo(newFileData)); + + // Delete the original file via service + var originalFile = container.FindFile(@"models\unit.txt")!; + pfs.DeleteFile(container, originalFile); + + Assert.That(container.GetFileCount(), Is.EqualTo(1)); + Assert.That(File.Exists(seedPath), Is.False); + + // Save as pack + var packPath = Path.Combine(_tempDir, "output.pack"); + var gameInfo = GameInformationDatabase.GetGameById(GameTypeEnum.Warhammer3); + pfs.SavePackContainer(container, packPath, false, gameInfo); + + Assert.That(File.Exists(packPath), Is.True); + + // Verify the pack file is valid and contains only the added file + using var fs = File.OpenRead(packPath); + using var reader = new BinaryReader(fs); + var loaded = PackFileSerializerLoader.Load(packPath, fs.Length, reader, new CaPackDuplicateFileResolver()); + Assert.That(loaded.GetFileCount(), Is.EqualTo(1)); + Assert.That(loaded.FindFile(@"scripts\added.bin"), Is.Not.Null); + + // Container should remain active + Assert.That(container.GetFileCount(), Is.EqualTo(1)); + Assert.That(container.SystemFilePath, Is.EqualTo(_tempDir)); + + // Unload disposes cleanly + pfs.UnloadPackContainer(container); + watcher.Verify(w => w.Dispose(), Times.Once); + } + + [Test] + public void FullWorkflow_ExternalAdd_DetectedAndEventsPublished() + { + // Arrange + var seedPath = Path.Combine(_tempDir, "initial.txt"); + File.WriteAllText(seedPath, "init"); + + var eventHub = new Mock(); + var fileSystem = CreateRealFileSystemAccess(); + var mockWatcher = new Mock(); + + var container = new SystemFolderContainer(_tempDir, fileSystem, mockWatcher.Object, eventHub.Object); + Assert.That(container.GetFileCount(), Is.EqualTo(1)); + + // Simulate an external file being created on disk + var externalPath = Path.Combine(_tempDir, "external.txt"); + File.WriteAllText(externalPath, "external content"); + + // Simulate the watcher firing a Created event + mockWatcher.Raise(w => w.Created += null, new FileSystemEventArgs(WatcherChangeTypes.Created, _tempDir, "external.txt")); + + // Process the pending events (as the debounce timer would) + container.ProcessPendingEvents(null); + + // Verify file was added to container + Assert.That(container.GetFileCount(), Is.EqualTo(2)); + Assert.That(container.ContainsFile("external.txt"), Is.True); + + // Verify event was published + eventHub.Verify(x => x.PublishGlobalEvent(It.Is(e => + e.Container == container && + e.AddedFiles.Count == 1 && + e.AddedFiles[0].Name == "external.txt" + )), Times.Once); + + // Simulate external deletion + File.Delete(externalPath); + mockWatcher.Raise(w => w.Deleted += null, new FileSystemEventArgs(WatcherChangeTypes.Deleted, _tempDir, "external.txt")); + container.ProcessPendingEvents(null); + + Assert.That(container.GetFileCount(), Is.EqualTo(1)); + Assert.That(container.ContainsFile("external.txt"), Is.False); + eventHub.Verify(x => x.PublishGlobalEvent(It.Is(e => + e.Container == container && + e.RemovedFiles.Count == 1 && + e.RemovedFiles[0].Name == "external.txt" + )), Times.Once); + } + + [Test] + public void FullWorkflow_RenameAndMoveFile_ThenSave() + { + // Seed files + var filePath = Path.Combine(_tempDir, "folder", "original.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, "data"); + + var eventHub = new Mock(); + var fileSystem = CreateRealFileSystemAccess(); + var mockWatcher = new Mock(); + + var container = new SystemFolderContainer(_tempDir, fileSystem, mockWatcher.Object, eventHub.Object); + var pfs = new PackFileService(eventHub.Object); + pfs.MessageBoxProvider = new Mock().Object; + pfs.EnforceGameFilesMustBeLoaded = false; + pfs.AddContainer(container); + + // Rename file + var file = container.FindFile(@"folder\original.txt")!; + pfs.RenameFile(container, file, "renamed.txt"); + + Assert.That(container.ContainsFile(@"folder\original.txt"), Is.False); + Assert.That(container.ContainsFile(@"folder\renamed.txt"), Is.True); + Assert.That(File.Exists(Path.Combine(_tempDir, "folder", "renamed.txt")), Is.True); + + // Move file to root + var renamedFile = container.FindFile(@"folder\renamed.txt")!; + pfs.MoveFile(container, renamedFile, ""); + + Assert.That(container.ContainsFile(@"folder\renamed.txt"), Is.False); + Assert.That(container.ContainsFile("renamed.txt"), Is.True); + Assert.That(File.Exists(Path.Combine(_tempDir, "renamed.txt")), Is.True); + + // Save to pack and verify content + var packPath = Path.Combine(_tempDir, "result.pack"); + var gameInfo = GameInformationDatabase.GetGameById(GameTypeEnum.Warhammer3); + pfs.SavePackContainer(container, packPath, false, gameInfo); + + using var fs = File.OpenRead(packPath); + using var reader = new BinaryReader(fs); + var loaded = PackFileSerializerLoader.Load(packPath, fs.Length, reader, new CaPackDuplicateFileResolver()); + Assert.That(loaded.GetFileCount(), Is.EqualTo(1)); + Assert.That(loaded.FindFile("renamed.txt"), Is.Not.Null); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Read.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Read.cs new file mode 100644 index 000000000..d43646e04 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Read.cs @@ -0,0 +1,240 @@ +using Moq; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.Services; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture] + internal class SystemFolderContainerTests_Read + { + private string _tempDir = null!; + private Mock _fileSystemAccess = null!; + private SystemFolderContainer _container = null!; + + // Test file structure: + // folder/file.txt + // folder/sub/nested.bin + // root_file.txt + // models/unit.model + + private static readonly string[] TestRelativePaths = + [ + @"folder\file.txt", + @"folder\sub\nested.bin", + "root_file.txt", + @"models\unit.model", + ]; + + [SetUp] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), "SystemFolderContainerTest_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + + // Create real files on disk so FileSystemSource can read sizes + foreach (var rel in TestRelativePaths) + { + var abs = Path.Combine(_tempDir, rel); + Directory.CreateDirectory(Path.GetDirectoryName(abs)!); + File.WriteAllText(abs, $"content of {rel}"); + } + + // Set up mock to return the file list + _fileSystemAccess = new Mock(); + _fileSystemAccess.Setup(x => x.DirectoryExists(_tempDir)).Returns(true); + _fileSystemAccess + .Setup(x => x.DirectoryGetFiles(_tempDir, "*.*", SearchOption.AllDirectories)) + .Returns(TestRelativePaths.Select(r => Path.Combine(_tempDir, r)).ToArray()); + + _container = new SystemFolderContainer(_tempDir, _fileSystemAccess.Object); + } + + [TearDown] + public void TearDown() + { + _container.Dispose(); + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + [Test] + public void Constructor_ScansFolder_PopulatesFileList() + { + Assert.That(_container.GetFileCount(), Is.EqualTo(TestRelativePaths.Length)); + } + + [Test] + public void Constructor_SetsNameToFolderName() + { + var expectedName = Path.GetFileName(_tempDir); + Assert.That(_container.Name, Is.EqualTo(expectedName)); + } + + [Test] + public void Constructor_SetsSystemFilePath() + { + Assert.That(_container.SystemFilePath, Is.EqualTo(_tempDir)); + } + + [Test] + public void Constructor_IsCaPackFile_IsFalse() + { + Assert.That(_container.IsCaPackFile, Is.False); + } + + [Test] + public void Constructor_EmptyPath_Throws() + { + Assert.Throws(() => new SystemFolderContainer("", _fileSystemAccess.Object)); + } + + [Test] + public void Constructor_NonExistentDirectory_Throws() + { + var fs = new Mock(); + fs.Setup(x => x.DirectoryExists(It.IsAny())).Returns(false); + + Assert.Throws(() => new SystemFolderContainer(@"C:\nonexistent", fs.Object)); + } + + [Test] + public void FindFile_NormalizedPath_ReturnsPackFile() + { + var result = _container.FindFile(@"folder\file.txt"); + Assert.That(result, Is.Not.Null); + Assert.That(result!.Name, Is.EqualTo("file.txt")); + } + + [Test] + public void FindFile_CaseInsensitive_ReturnsPackFile() + { + var result = _container.FindFile(@"FOLDER\FILE.TXT"); + Assert.That(result, Is.Not.Null); + Assert.That(result!.Name, Is.EqualTo("file.txt")); + } + + [Test] + public void FindFile_NonExistent_ReturnsNull() + { + var result = _container.FindFile(@"does\not\exist.txt"); + Assert.That(result, Is.Null); + } + + [Test] + public void ContainsFile_ExistingFile_ReturnsTrue() + { + Assert.That(_container.ContainsFile(@"root_file.txt"), Is.True); + } + + [Test] + public void ContainsFile_MissingFile_ReturnsFalse() + { + Assert.That(_container.ContainsFile(@"missing.txt"), Is.False); + } + + [Test] + public void GetAllFiles_ReturnsAllScannedFiles() + { + var all = _container.GetAllFiles(); + Assert.That(all.Count, Is.EqualTo(TestRelativePaths.Length)); + } + + [Test] + public void GetFullPath_ReturnsCorrectRelativePath() + { + var file = _container.FindFile(@"models\unit.model"); + Assert.That(file, Is.Not.Null); + + var path = _container.GetFullPath(file!); + Assert.That(path, Is.EqualTo(@"models\unit.model")); + } + + [Test] + public void GetFullPath_UnknownFile_ReturnsNull() + { + var unknownFile = new PackFile("unknown.txt", null!); + var path = _container.GetFullPath(unknownFile); + Assert.That(path, Is.Null); + } + + [Test] + public void GetAllFilesByFolder_GroupsCorrectly() + { + var byFolder = _container.GetAllFilesByFolder(); + + Assert.That(byFolder.ContainsKey("folder"), Is.True); + Assert.That(byFolder["folder"], Does.Contain("file.txt")); + + Assert.That(byFolder.ContainsKey(@"folder\sub"), Is.True); + Assert.That(byFolder[@"folder\sub"], Does.Contain("nested.bin")); + + Assert.That(byFolder.ContainsKey(string.Empty), Is.True); + Assert.That(byFolder[string.Empty], Does.Contain("root_file.txt")); + + Assert.That(byFolder.ContainsKey("models"), Is.True); + Assert.That(byFolder["models"], Does.Contain("unit.model")); + } + + [Test] + public void FindAllWithExtention_ReturnsMatchingFiles() + { + var results = _container.FindAllWithExtention(".txt"); + Assert.That(results.Count, Is.EqualTo(2)); // folder\file.txt + root_file.txt + Assert.That(results.All(r => r.FileName.EndsWith(".txt")), Is.True); + } + + [Test] + public void FindAllWithExtention_NoMatch_ReturnsEmpty() + { + var results = _container.FindAllWithExtention(".xyz"); + Assert.That(results, Is.Empty); + } + + [Test] + public void SearchFiles_FilterByName_ReturnsMatch() + { + var results = _container.SearchFiles("nested", null); + Assert.That(results.Count, Is.EqualTo(1)); + Assert.That(results[0].File.Name, Is.EqualTo("nested.bin")); + } + + [Test] + public void SearchFiles_FilterByExtension_ReturnsMatch() + { + var results = _container.SearchFiles(null, [".model"]); + Assert.That(results.Count, Is.EqualTo(1)); + Assert.That(results[0].File.Name, Is.EqualTo("unit.model")); + } + + [Test] + public void SearchFiles_NoFilter_ReturnsAll() + { + var results = _container.SearchFiles(null, null); + Assert.That(results.Count, Is.EqualTo(TestRelativePaths.Length)); + } + + [Test] + public void GetDirectoryContent_RootLevel_ReturnsRootFiles() + { + var results = _container.GetDirectoryContent(""); + Assert.That(results.Count, Is.EqualTo(1)); // root_file.txt + Assert.That(results[0].File.Name, Is.EqualTo("root_file.txt")); + } + + [Test] + public void GetDirectoryContent_SubFolder_ReturnsDirectChildren() + { + var results = _container.GetDirectoryContent("folder"); + Assert.That(results.Count, Is.EqualTo(1)); // folder\file.txt (not nested) + Assert.That(results[0].File.Name, Is.EqualTo("file.txt")); + } + + [Test] + public void Dispose_ClearsFileList() + { + _container.Dispose(); + Assert.That(_container.GetFileCount(), Is.EqualTo(0)); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_SaveToDisk.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_SaveToDisk.cs new file mode 100644 index 000000000..c0be36526 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_SaveToDisk.cs @@ -0,0 +1,148 @@ +using Moq; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.PackFiles.Serialization; +using Shared.Core.PackFiles.Utility; +using Shared.Core.Services; +using Shared.Core.Settings; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture] + internal class SystemFolderContainerTests_SaveToDisk + { + private string _tempDir = null!; + private string _outputDir = null!; + private SystemFolderContainer _container = null!; + private Mock _fileSystemAccess = null!; + private GameInformation _gameInfo = null!; + + [SetUp] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), "SysFolderSave_" + Guid.NewGuid().ToString("N")); + _outputDir = Path.Combine(Path.GetTempPath(), "SysFolderSaveOutput_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + Directory.CreateDirectory(_outputDir); + + // Create test files + var fileA = Path.Combine(_tempDir, "folder", "fileA.txt"); + var fileB = Path.Combine(_tempDir, "fileB.bin"); + Directory.CreateDirectory(Path.GetDirectoryName(fileA)!); + File.WriteAllText(fileA, "content A"); + File.WriteAllBytes(fileB, new byte[] { 1, 2, 3, 4, 5 }); + + _fileSystemAccess = new Mock(); + _fileSystemAccess.Setup(x => x.DirectoryExists(It.IsAny())) + .Returns((string p) => Directory.Exists(p)); + _fileSystemAccess.Setup(x => x.DirectoryGetFiles(_tempDir, "*.*", SearchOption.AllDirectories)) + .Returns([fileA, fileB]); + _fileSystemAccess.Setup(x => x.FileExists(It.IsAny())) + .Returns((string p) => File.Exists(p)); + _fileSystemAccess.Setup(x => x.FileDelete(It.IsAny())) + .Callback((string p) => File.Delete(p)); + _fileSystemAccess.Setup(x => x.FileMove(It.IsAny(), It.IsAny())) + .Callback((string s, string d) => File.Move(s, d)); + + _container = new SystemFolderContainer(_tempDir, _fileSystemAccess.Object); + _gameInfo = GameInformationDatabase.GetGameById(GameTypeEnum.Warhammer3); + } + + [TearDown] + public void TearDown() + { + _container.Dispose(); + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + if (Directory.Exists(_outputDir)) + Directory.Delete(_outputDir, true); + } + + [Test] + public void SaveToDisk_GeneratesValidPackFile() + { + var outputPath = Path.Combine(_outputDir, "output.pack"); + + _container.SaveToDisk(outputPath, false, _gameInfo); + + Assert.That(File.Exists(outputPath), Is.True); + + // Verify the .pack can be reloaded + using var fileStream = File.OpenRead(outputPath); + using var reader = new BinaryReader(fileStream); + var loaded = PackFileSerializerLoader.Load(outputPath, fileStream.Length, reader, new CaPackDuplicateFileResolver()); + + Assert.That(loaded.GetFileCount(), Is.EqualTo(2)); + Assert.That(loaded.FindFile(@"folder\filea.txt"), Is.Not.Null); + Assert.That(loaded.FindFile(@"fileb.bin"), Is.Not.Null); + } + + [Test] + public void SaveToDisk_ContainerRemainsActive() + { + var outputPath = Path.Combine(_outputDir, "output.pack"); + + _container.SaveToDisk(outputPath, false, _gameInfo); + + // Container should still work normally + Assert.That(_container.GetFileCount(), Is.EqualTo(2)); + Assert.That(_container.FindFile(@"folder\fileA.txt"), Is.Not.Null); + Assert.That(_container.SystemFilePath, Is.EqualTo(_tempDir)); + } + + [Test] + public void SaveToDisk_CreateBackup_BackupCreated() + { + var outputPath = Path.Combine(_outputDir, "output.pack"); + + // First save - creates the file + _container.SaveToDisk(outputPath, false, _gameInfo); + Assert.That(File.Exists(outputPath), Is.True); + + // Second save with backup + _container.SaveToDisk(outputPath, true, _gameInfo); + + // Backup folder should exist with a file + var backupFolder = Path.Combine(_outputDir, "Backup"); + Assert.That(Directory.Exists(backupFolder), Is.True); + var backupFiles = Directory.GetFiles(backupFolder, "output*"); + Assert.That(backupFiles.Length, Is.GreaterThan(0)); + } + + [Test] + public void SaveToDisk_LockedFile_ThrowsIOException() + { + var outputPath = Path.Combine(_outputDir, "locked.pack"); + + // Create and lock the file + using var lockStream = new FileStream(outputPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None); + + Assert.Throws(() => _container.SaveToDisk(outputPath, false, _gameInfo)); + } + + [Test] + public void SaveToDisk_FileContentPreserved() + { + var outputPath = Path.Combine(_outputDir, "output.pack"); + + _container.SaveToDisk(outputPath, false, _gameInfo); + + using var fileStream = File.OpenRead(outputPath); + using var reader = new BinaryReader(fileStream); + var loaded = PackFileSerializerLoader.Load(outputPath, fileStream.Length, reader, new CaPackDuplicateFileResolver()); + + var fileA = loaded.FindFile(@"folder\filea.txt"); + Assert.That(fileA, Is.Not.Null); + + var dataA = ((PackedFileSource)fileA!.DataSource).ReadData(fileStream); + Assert.That(System.Text.Encoding.UTF8.GetString(dataA), Is.EqualTo("content A")); + + var fileB = loaded.FindFile(@"fileb.bin"); + Assert.That(fileB, Is.Not.Null); + + var dataB = ((PackedFileSource)fileB!.DataSource).ReadData(fileStream); + Assert.That(dataB, Is.EqualTo(new byte[] { 1, 2, 3, 4, 5 })); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Watcher.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Watcher.cs new file mode 100644 index 000000000..c5558f203 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Watcher.cs @@ -0,0 +1,216 @@ +using Moq; +using Shared.Core.Events; +using Shared.Core.Events.Global; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.Services; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture] + internal class SystemFolderContainerTests_Watcher + { + private string _tempDir = null!; + private Mock _fileSystemAccess = null!; + private Mock _mockWatcher = null!; + private Mock _mockEventHub = null!; + private SystemFolderContainer _container = null!; + + [SetUp] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), "SysFolderWatcher_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + + // Create a seed file + var seedPath = Path.Combine(_tempDir, "existing.txt"); + File.WriteAllText(seedPath, "seed"); + + _fileSystemAccess = new Mock(); + _fileSystemAccess.Setup(x => x.DirectoryExists(_tempDir)).Returns(true); + _fileSystemAccess.Setup(x => x.DirectoryGetFiles(_tempDir, "*.*", SearchOption.AllDirectories)) + .Returns([seedPath]); + _fileSystemAccess.Setup(x => x.DirectoryExists(It.IsAny())) + .Returns((string p) => Directory.Exists(p)); + _fileSystemAccess.Setup(x => x.DirectoryCreateDirectory(It.IsAny())) + .Callback((string p) => Directory.CreateDirectory(p)); + _fileSystemAccess.Setup(x => x.FileWriteAllBytes(It.IsAny(), It.IsAny())) + .Callback((string p, byte[] d) => { Directory.CreateDirectory(Path.GetDirectoryName(p)!); File.WriteAllBytes(p, d); }); + _fileSystemAccess.Setup(x => x.FileExists(It.IsAny())) + .Returns((string p) => File.Exists(p)); + _fileSystemAccess.Setup(x => x.FileDelete(It.IsAny())) + .Callback((string p) => File.Delete(p)); + + _mockWatcher = new Mock(); + _mockEventHub = new Mock(); + + _container = new SystemFolderContainer(_tempDir, _fileSystemAccess.Object, _mockWatcher.Object, _mockEventHub.Object); + } + + [TearDown] + public void TearDown() + { + _container.Dispose(); + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + [Test] + public void Constructor_StartsWatcher() + { + _mockWatcher.VerifySet(w => w.Path = _tempDir); + _mockWatcher.VerifySet(w => w.IncludeSubdirectories = true); + _mockWatcher.VerifySet(w => w.EnableRaisingEvents = true); + } + + [Test] + public void ExternalFileCreated_PublishesFilesAddedEvent() + { + // Create the file on disk so FileSystemSource can work + var newFilePath = Path.Combine(_tempDir, "newfile.txt"); + File.WriteAllText(newFilePath, "new content"); + + // Simulate the watcher event + _mockWatcher.Raise(w => w.Created += null, new FileSystemEventArgs(WatcherChangeTypes.Created, _tempDir, "newfile.txt")); + + // Process debounced events immediately + _container.ProcessPendingEvents(null); + + _mockEventHub.Verify(x => x.PublishGlobalEvent(It.Is(e => + e.AddedFiles.Count == 1 && e.AddedFiles[0].Name == "newfile.txt" + )), Times.Once); + + Assert.That(_container.ContainsFile("newfile.txt"), Is.True); + } + + [Test] + public void ExternalFileDeleted_PublishesFilesRemovedEvent() + { + // Simulate deletion of the existing file + _mockWatcher.Raise(w => w.Deleted += null, new FileSystemEventArgs(WatcherChangeTypes.Deleted, _tempDir, "existing.txt")); + + _container.ProcessPendingEvents(null); + + _mockEventHub.Verify(x => x.PublishGlobalEvent(It.Is(e => + e.RemovedFiles.Count == 1 && e.RemovedFiles[0].Name == "existing.txt" + )), Times.Once); + + Assert.That(_container.ContainsFile("existing.txt"), Is.False); + } + + [Test] + public void ExternalFileRenamed_PublishesRemovedAndAddedEvents() + { + // Create the renamed file on disk + var renamedPath = Path.Combine(_tempDir, "renamed.txt"); + File.WriteAllText(renamedPath, "seed"); + + // Simulate rename event + var args = new RenamedEventArgs(WatcherChangeTypes.Renamed, _tempDir, "renamed.txt", "existing.txt"); + _mockWatcher.Raise(w => w.Renamed += null, args); + + _container.ProcessPendingEvents(null); + + _mockEventHub.Verify(x => x.PublishGlobalEvent(It.Is(e => + e.RemovedFiles.Count == 1 && e.RemovedFiles[0].Name == "existing.txt" + )), Times.Once); + + _mockEventHub.Verify(x => x.PublishGlobalEvent(It.Is(e => + e.AddedFiles.Count == 1 && e.AddedFiles[0].Name == "renamed.txt" + )), Times.Once); + + Assert.That(_container.ContainsFile("existing.txt"), Is.False); + Assert.That(_container.ContainsFile("renamed.txt"), Is.True); + } + + [Test] + public void InternalAdd_DoesNotTriggerExternalEvent() + { + // Create file on disk so it could be picked up + var filePath = Path.Combine(_tempDir, "suppressed.txt"); + File.WriteAllText(filePath, "data"); + + // Simulate watcher firing while suppression is active (e.g., during internal write) + _container._suppressWatcher = true; + _mockWatcher.Raise(w => w.Created += null, new FileSystemEventArgs(WatcherChangeTypes.Created, _tempDir, "suppressed.txt")); + _container._suppressWatcher = false; + + _container.ProcessPendingEvents(null); + + // The suppressed event should not have been queued + _mockEventHub.Verify(x => x.PublishGlobalEvent(It.IsAny()), Times.Never); + // File should NOT be in container since the event was suppressed + Assert.That(_container.ContainsFile("suppressed.txt"), Is.False); + } + + [Test] + public void InternalDelete_DoesNotTriggerExternalEvent() + { + _container._suppressWatcher = true; + _mockWatcher.Raise(w => w.Deleted += null, new FileSystemEventArgs(WatcherChangeTypes.Deleted, _tempDir, "existing.txt")); + _container._suppressWatcher = false; + + _container.ProcessPendingEvents(null); + + // Event should not be published since it was suppressed + _mockEventHub.Verify(x => x.PublishGlobalEvent(It.IsAny()), Times.Never); + // File should still be in the list + Assert.That(_container.ContainsFile("existing.txt"), Is.True); + } + + [Test] + public void MultipleRapidCreates_BatchedIntoSingleEvent() + { + // Create files on disk + var file1 = Path.Combine(_tempDir, "batch1.txt"); + var file2 = Path.Combine(_tempDir, "batch2.txt"); + File.WriteAllText(file1, "1"); + File.WriteAllText(file2, "2"); + + // Simulate multiple rapid creates + _mockWatcher.Raise(w => w.Created += null, new FileSystemEventArgs(WatcherChangeTypes.Created, _tempDir, "batch1.txt")); + _mockWatcher.Raise(w => w.Created += null, new FileSystemEventArgs(WatcherChangeTypes.Created, _tempDir, "batch2.txt")); + + // Process them all at once + _container.ProcessPendingEvents(null); + + // Should be a single event with both files + _mockEventHub.Verify(x => x.PublishGlobalEvent(It.Is(e => + e.AddedFiles.Count == 2 + )), Times.Once); + } + + [Test] + public void Dispose_StopsWatcher() + { + _container.Dispose(); + + _mockWatcher.VerifySet(w => w.EnableRaisingEvents = false); + _mockWatcher.Verify(w => w.Dispose(), Times.Once); + } + + [Test] + public void ExternalFileCreated_DuplicatePath_IgnoredGracefully() + { + // existing.txt is already in the container + // Try to trigger a Created event for the same path + _mockWatcher.Raise(w => w.Created += null, new FileSystemEventArgs(WatcherChangeTypes.Created, _tempDir, "existing.txt")); + + _container.ProcessPendingEvents(null); + + // Should not publish event since file is already tracked + _mockEventHub.Verify(x => x.PublishGlobalEvent(It.IsAny()), Times.Never); + } + + [Test] + public void ExternalFileDeleted_UnknownFile_IgnoredGracefully() + { + _mockWatcher.Raise(w => w.Deleted += null, new FileSystemEventArgs(WatcherChangeTypes.Deleted, _tempDir, "unknown.txt")); + + _container.ProcessPendingEvents(null); + + _mockEventHub.Verify(x => x.PublishGlobalEvent(It.IsAny()), Times.Never); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Write.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Write.cs new file mode 100644 index 000000000..3fe52f330 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Write.cs @@ -0,0 +1,225 @@ +using Moq; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.Services; + +namespace Shared.CoreTest.PackFiles.Models.Containers +{ + [TestFixture] + internal class SystemFolderContainerTests_Write + { + private string _tempDir = null!; + private SystemFolderContainer _container = null!; + private Mock _fileSystemAccess = null!; + + [SetUp] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), "SysFolderWrite_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + + // Seed with one file + var seedPath = Path.Combine(_tempDir, "existing", "seed.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(seedPath)!); + File.WriteAllText(seedPath, "seed content"); + + _fileSystemAccess = new Mock(); + _fileSystemAccess.Setup(x => x.DirectoryExists(_tempDir)).Returns(true); + _fileSystemAccess.Setup(x => x.DirectoryGetFiles(_tempDir, "*.*", SearchOption.AllDirectories)) + .Returns([seedPath]); + + // Default pass-through for directory/file operations + _fileSystemAccess.Setup(x => x.DirectoryExists(It.IsAny())) + .Returns((string p) => Directory.Exists(p)); + _fileSystemAccess.Setup(x => x.DirectoryCreateDirectory(It.IsAny())) + .Callback((string p) => Directory.CreateDirectory(p)); + _fileSystemAccess.Setup(x => x.FileWriteAllBytes(It.IsAny(), It.IsAny())) + .Callback((string p, byte[] d) => { Directory.CreateDirectory(Path.GetDirectoryName(p)!); File.WriteAllBytes(p, d); }); + _fileSystemAccess.Setup(x => x.FileExists(It.IsAny())) + .Returns((string p) => File.Exists(p)); + _fileSystemAccess.Setup(x => x.FileDelete(It.IsAny())) + .Callback((string p) => File.Delete(p)); + _fileSystemAccess.Setup(x => x.FileMove(It.IsAny(), It.IsAny())) + .Callback((string s, string d) => { Directory.CreateDirectory(Path.GetDirectoryName(d)!); File.Move(s, d); }); + _fileSystemAccess.Setup(x => x.DirectoryMove(It.IsAny(), It.IsAny())) + .Callback((string s, string d) => Directory.Move(s, d)); + _fileSystemAccess.Setup(x => x.DirectoryDelete(It.IsAny(), It.IsAny())) + .Callback((string p, bool r) => Directory.Delete(p, r)); + + _container = new SystemFolderContainer(_tempDir, _fileSystemAccess.Object); + } + + [TearDown] + public void TearDown() + { + _container.Dispose(); + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + [Test] + public void AddOrUpdateFile_WritesFileToDisk() + { + var data = "hello world"u8.ToArray(); + var file = new PackFile("new.txt", new MemorySource(data)); + + _container.AddOrUpdateFile(@"folder\new.txt", file); + + var absolutePath = Path.Combine(_tempDir, "folder", "new.txt"); + Assert.That(File.Exists(absolutePath), Is.True); + Assert.That(File.ReadAllBytes(absolutePath), Is.EqualTo(data)); + } + + [Test] + public void AddOrUpdateFile_UpdatesFileList() + { + var data = "test"u8.ToArray(); + var file = new PackFile("new.txt", new MemorySource(data)); + + _container.AddOrUpdateFile(@"folder\new.txt", file); + + Assert.That(_container.ContainsFile(@"folder\new.txt"), Is.True); + } + + [Test] + public void AddOrUpdateFile_EmptyName_Throws() + { + var file = new PackFile("", new MemorySource([1, 2, 3])); + Assert.Throws(() => _container.AddOrUpdateFile(@"folder\", file)); + } + + [Test] + public void AddOrUpdateFile_SuppressesWatcher() + { + var data = "test"u8.ToArray(); + var file = new PackFile("new.txt", new MemorySource(data)); + + bool wasSuppressed = false; + _fileSystemAccess.Setup(x => x.FileWriteAllBytes(It.IsAny(), It.IsAny())) + .Callback((string p, byte[] d) => { wasSuppressed = _container._suppressWatcher; File.WriteAllBytes(p, d); }); + + _container.AddOrUpdateFile(@"test\new.txt", file); + + Assert.That(wasSuppressed, Is.True); + Assert.That(_container._suppressWatcher, Is.False); // restored after + } + + [Test] + public void AddFiles_MultipleFiles_AllWrittenToDisk() + { + var newFiles = new List + { + new("dir", new PackFile("a.txt", new MemorySource("aaa"u8.ToArray()))), + new("", new PackFile("root.txt", new MemorySource("bbb"u8.ToArray()))), + }; + + var added = _container.AddFiles(newFiles); + + Assert.That(added.Count, Is.EqualTo(2)); + Assert.That(_container.ContainsFile(@"dir\a.txt"), Is.True); + Assert.That(_container.ContainsFile("root.txt"), Is.True); + Assert.That(File.Exists(Path.Combine(_tempDir, "dir", "a.txt")), Is.True); + Assert.That(File.Exists(Path.Combine(_tempDir, "root.txt")), Is.True); + } + + [Test] + public void AddFiles_EmptyFileName_Throws() + { + var newFiles = new List + { + new("dir", new PackFile("", new MemorySource([1]))), + }; + + Assert.Throws(() => _container.AddFiles(newFiles)); + } + + [Test] + public void DeleteFile_RemovesFromDiskAndFileList() + { + var seedFile = _container.FindFile(@"existing\seed.txt"); + Assert.That(seedFile, Is.Not.Null); + + var result = _container.DeleteFile(seedFile!); + + Assert.That(result, Is.Not.Null); + Assert.That(_container.ContainsFile(@"existing\seed.txt"), Is.False); + Assert.That(File.Exists(Path.Combine(_tempDir, "existing", "seed.txt")), Is.False); + } + + [Test] + public void DeleteFile_NonExistentFile_ReturnsNull() + { + var unknownFile = new PackFile("nope.txt", new MemorySource([1])); + var result = _container.DeleteFile(unknownFile); + Assert.That(result, Is.Null); + } + + [Test] + public void DeleteFolder_RemovesRecursively() + { + // Add more files in a folder + var data = "x"u8.ToArray(); + _container.AddOrUpdateFile(@"todelete\a.txt", new PackFile("a.txt", new MemorySource(data))); + _container.AddOrUpdateFile(@"todelete\sub\b.txt", new PackFile("b.txt", new MemorySource(data))); + + _container.DeleteFolder("todelete"); + + Assert.That(_container.ContainsFile(@"todelete\a.txt"), Is.False); + Assert.That(_container.ContainsFile(@"todelete\sub\b.txt"), Is.False); + Assert.That(Directory.Exists(Path.Combine(_tempDir, "todelete")), Is.False); + } + + [Test] + public void MoveFile_UpdatesDiskAndFileList() + { + var file = _container.FindFile(@"existing\seed.txt")!; + _container.MoveFile(file, "newfolder"); + + Assert.That(_container.ContainsFile(@"existing\seed.txt"), Is.False); + Assert.That(_container.ContainsFile(@"newfolder\seed.txt"), Is.True); + Assert.That(File.Exists(Path.Combine(_tempDir, "newfolder", "seed.txt")), Is.True); + Assert.That(File.Exists(Path.Combine(_tempDir, "existing", "seed.txt")), Is.False); + } + + [Test] + public void RenameFile_UpdatesDiskAndFileList() + { + var file = _container.FindFile(@"existing\seed.txt")!; + _container.RenameFile(file, "renamed.txt"); + + Assert.That(_container.ContainsFile(@"existing\seed.txt"), Is.False); + Assert.That(_container.ContainsFile(@"existing\renamed.txt"), Is.True); + Assert.That(File.Exists(Path.Combine(_tempDir, "existing", "renamed.txt")), Is.True); + } + + [Test] + public void RenameDirectory_UpdatesAllChildPaths() + { + // Add files in a directory + var data = "x"u8.ToArray(); + _container.AddOrUpdateFile(@"olddir\a.txt", new PackFile("a.txt", new MemorySource(data))); + _container.AddOrUpdateFile(@"olddir\sub\b.txt", new PackFile("b.txt", new MemorySource(data))); + + _container.RenameDirectory("olddir", "newdir"); + + Assert.That(_container.ContainsFile(@"olddir\a.txt"), Is.False); + Assert.That(_container.ContainsFile(@"olddir\sub\b.txt"), Is.False); + Assert.That(_container.ContainsFile(@"newdir\a.txt"), Is.True); + Assert.That(_container.ContainsFile(@"newdir\sub\b.txt"), Is.True); + } + + [Test] + public void SaveFileData_WritesNewContent() + { + var file = _container.FindFile(@"existing\seed.txt")!; + var newData = "updated content"u8.ToArray(); + + _container.SaveFileData(file, newData); + + var absolutePath = Path.Combine(_tempDir, "existing", "seed.txt"); + Assert.That(File.ReadAllBytes(absolutePath), Is.EqualTo(newData)); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/PackFiles/SystemFolderContainer_PackFileServiceTests.cs b/Shared/SharedCore/Shared.CoreTest/PackFiles/SystemFolderContainer_PackFileServiceTests.cs new file mode 100644 index 000000000..ad42a8801 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/PackFiles/SystemFolderContainer_PackFileServiceTests.cs @@ -0,0 +1,179 @@ +using Moq; +using Shared.Core.Events; +using Shared.Core.Events.Global; +using Shared.Core.PackFiles; +using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; +using Shared.Core.PackFiles.Models.FileSources; +using Shared.Core.Services; +using Shared.Core.Settings; + +namespace Shared.CoreTest.PackFiles +{ + [TestFixture] + internal class SystemFolderContainer_PackFileServiceTests + { + private string _tempDir = null!; + private Mock _eventHub = null!; + private Mock _fileSystemAccess = null!; + private Mock _mockWatcher = null!; + private PackFileService _pfs = null!; + + [SetUp] + public void Setup() + { + _tempDir = Path.Combine(Path.GetTempPath(), "SysFolderPFS_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDir); + + // Seed a file + var seedPath = Path.Combine(_tempDir, "data", "file.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(seedPath)!); + File.WriteAllText(seedPath, "hello"); + + _fileSystemAccess = new Mock(); + _fileSystemAccess.Setup(x => x.DirectoryExists(It.IsAny())) + .Returns((string p) => Directory.Exists(p)); + _fileSystemAccess.Setup(x => x.DirectoryGetFiles(_tempDir, "*.*", SearchOption.AllDirectories)) + .Returns([seedPath]); + _fileSystemAccess.Setup(x => x.DirectoryCreateDirectory(It.IsAny())) + .Callback((string p) => Directory.CreateDirectory(p)); + _fileSystemAccess.Setup(x => x.FileWriteAllBytes(It.IsAny(), It.IsAny())) + .Callback((string p, byte[] d) => { Directory.CreateDirectory(Path.GetDirectoryName(p)!); File.WriteAllBytes(p, d); }); + _fileSystemAccess.Setup(x => x.FileExists(It.IsAny())) + .Returns((string p) => File.Exists(p)); + _fileSystemAccess.Setup(x => x.FileDelete(It.IsAny())) + .Callback((string p) => File.Delete(p)); + _fileSystemAccess.Setup(x => x.FileMove(It.IsAny(), It.IsAny())) + .Callback((string s, string d) => { Directory.CreateDirectory(Path.GetDirectoryName(d)!); File.Move(s, d); }); + _fileSystemAccess.Setup(x => x.DirectoryMove(It.IsAny(), It.IsAny())) + .Callback((string s, string d) => Directory.Move(s, d)); + _fileSystemAccess.Setup(x => x.DirectoryDelete(It.IsAny(), It.IsAny())) + .Callback((string p, bool r) => Directory.Delete(p, r)); + + _mockWatcher = new Mock(); + _eventHub = new Mock(); + + _pfs = new PackFileService(_eventHub.Object); + _pfs.MessageBoxProvider = new Mock().Object; + _pfs.EnforceGameFilesMustBeLoaded = false; + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + private SystemFolderContainer CreateContainer() + { + return new SystemFolderContainer(_tempDir, _fileSystemAccess.Object, _mockWatcher.Object, _eventHub.Object); + } + + [Test] + public void AddContainer_SystemFolder_RegistersSuccessfully() + { + var container = CreateContainer(); + + var result = _pfs.AddContainer(container); + + Assert.That(result, Is.Not.Null); + Assert.That(_pfs.GetAllPackfileContainers(), Does.Contain(container)); + _eventHub.Verify(x => x.PublishGlobalEvent(It.Is(e => e.Container == container)), Times.Once); + } + + [Test] + public void AddContainer_DuplicateFolderPath_Rejected() + { + var container1 = CreateContainer(); + _pfs.AddContainer(container1); + + var container2 = CreateContainer(); + var result = _pfs.AddContainer(container2); + + Assert.That(result, Is.Null); + Assert.That(_pfs.GetAllPackfileContainers().Count, Is.EqualTo(1)); + } + + [Test] + public void UnloadContainer_DisposesWatcher() + { + var container = CreateContainer(); + _pfs.AddContainer(container); + + _pfs.UnloadPackContainer(container); + + _mockWatcher.VerifySet(w => w.EnableRaisingEvents = false); + _mockWatcher.Verify(w => w.Dispose(), Times.Once); + Assert.That(_pfs.GetAllPackfileContainers(), Does.Not.Contain(container)); + } + + [Test] + public void SetEditablePack_SystemFolder_Works() + { + var container = CreateContainer(); + _pfs.AddContainer(container); + + _pfs.SetEditablePack(container); + + Assert.That(_pfs.GetEditablePack(), Is.EqualTo(container)); + } + + [Test] + public void AddFilesToPack_SystemFolder_WritesThrough() + { + var container = CreateContainer(); + _pfs.AddContainer(container); + + var newFile = new PackFile("added.txt", new MemorySource("new content"u8.ToArray())); + var entries = new List { new("newdir", newFile) }; + + _pfs.AddFilesToPack(container, entries); + + Assert.That(container.ContainsFile(@"newdir\added.txt"), Is.True); + Assert.That(File.Exists(Path.Combine(_tempDir, "newdir", "added.txt")), Is.True); + } + + [Test] + public void DeleteFile_SystemFolder_DeletesFromDisk() + { + var container = CreateContainer(); + _pfs.AddContainer(container); + var file = container.FindFile(@"data\file.txt"); + Assert.That(file, Is.Not.Null); + + _pfs.DeleteFile(container, file!); + + Assert.That(container.ContainsFile(@"data\file.txt"), Is.False); + Assert.That(File.Exists(Path.Combine(_tempDir, "data", "file.txt")), Is.False); + } + + [Test] + public void SavePackContainer_SystemFolder_GeneratesPackFile() + { + var container = CreateContainer(); + _pfs.AddContainer(container); + + var outputPath = Path.Combine(_tempDir, "output.pack"); + var gameInfo = GameInformationDatabase.GetGameById(GameTypeEnum.Warhammer3); + + _pfs.SavePackContainer(container, outputPath, false, gameInfo); + + Assert.That(File.Exists(outputPath), Is.True); + // Container should remain active + Assert.That(container.GetFileCount(), Is.EqualTo(1)); + } + + [Test] + public void FindFile_AcrossContainers_FindsInSystemFolder() + { + var container = CreateContainer(); + _pfs.AddContainer(container); + + var result = _pfs.FindFile(@"data\file.txt"); + + Assert.That(result, Is.Not.Null); + Assert.That(result!.Name, Is.EqualTo("file.txt")); + } + } +} diff --git a/Shared/SharedCore/Shared.CoreTest/Services/FileSystemWatcherWrapperTests.cs b/Shared/SharedCore/Shared.CoreTest/Services/FileSystemWatcherWrapperTests.cs new file mode 100644 index 000000000..1eb2aca90 --- /dev/null +++ b/Shared/SharedCore/Shared.CoreTest/Services/FileSystemWatcherWrapperTests.cs @@ -0,0 +1,55 @@ +using Shared.Core.Services; + +namespace Shared.CoreTest.Services +{ + [TestFixture] + internal class FileSystemWatcherWrapperTests + { + [Test] + public void Dispose_DoesNotThrow() + { + var wrapper = new FileSystemWatcherWrapper(); + Assert.DoesNotThrow(() => wrapper.Dispose()); + } + + [Test] + public void SetPath_PropagatesCorrectly() + { + using var wrapper = new FileSystemWatcherWrapper(); + var tempDir = Path.GetTempPath(); + + wrapper.Path = tempDir; + + Assert.That(wrapper.Path, Is.EqualTo(tempDir)); + } + + [Test] + public void EnableRaisingEvents_DefaultFalse() + { + using var wrapper = new FileSystemWatcherWrapper(); + wrapper.Path = Path.GetTempPath(); + + Assert.That(wrapper.EnableRaisingEvents, Is.False); + } + + [Test] + public void IncludeSubdirectories_DefaultFalse() + { + using var wrapper = new FileSystemWatcherWrapper(); + wrapper.Path = Path.GetTempPath(); + + Assert.That(wrapper.IncludeSubdirectories, Is.False); + } + + [Test] + public void IncludeSubdirectories_SetTrue_PropagatesCorrectly() + { + using var wrapper = new FileSystemWatcherWrapper(); + wrapper.Path = Path.GetTempPath(); + + wrapper.IncludeSubdirectories = true; + + Assert.That(wrapper.IncludeSubdirectories, Is.True); + } + } +} diff --git a/system-folder-container-plan.md b/system-folder-container-plan.md new file mode 100644 index 000000000..e72e71f44 --- /dev/null +++ b/system-folder-container-plan.md @@ -0,0 +1,554 @@ +# SystemFolderContainer Implementation Plan + +## Overview + +Add a new `SystemFolderContainer` — a third implementation of `IPackFileContainerInternal` that mirrors a folder on the local file system. Changes made in the tool are written to disk; changes made on disk (add/delete/rename) are detected and reflected in the tool via existing pack-file events. + +--- + +## Architecture Summary + +``` +IPackFileContainer (public) +└── IPackFileContainerInternal (internal) + ├── PackFileContainer — mutable, in-memory dictionary + ├── CachedPackFileContainer — read-only, SQLite-backed + └── SystemFolderContainer — mutable, file-system-backed (NEW) +``` + +**Key design decisions:** +- Files are represented as `PackFile` with `FileSystemSource` data sources (already exists at `Shared.Core/PackFiles/Models/FileSources/FileSystemSource.cs`). +- File-system monitoring uses `FileSystemWatcher` wrapped behind a testable `IFileSystemWatcher` interface. +- All mutations (add, delete, rename, move) write-through to disk immediately. +- `SaveToDisk` generates a `.pack` file via `PackFileSerializerWriter` but the container remains connected to the folder. +- External changes (add/delete/rename only — not content modifications) are detected, debounced (~300ms), and published via existing `PackFileContainerFilesAddedEvent` / `PackFileContainerFilesRemovedEvent` / `PackFileContainerFilesUpdatedEvent`. +- Reuse `IFileSystemAccess` for direct file I/O. Introduce a thin `IFileSystemWatcher` for monitoring. +- The container implements `IDisposable` to stop the watcher and release handles. +- `PackFileService.UnloadPackContainer` already calls `Dispose` on `IDisposable` containers (see `CachedPackFileContainer`). We follow the same pattern. + +**Project locations:** +- Container class: `Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/SystemFolderContainer.cs` +- Watcher abstraction: `Shared/SharedCore/Shared.Core/Services/IFileSystemWatcher.cs` +- Watcher implementation: `Shared/SharedCore/Shared.Core/Services/FileSystemWatcherWrapper.cs` +- Tests: `Shared/SharedCore/Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests*.cs` + +--- + +## Step 1: Create `IFileSystemWatcher` Abstraction + +**Goal:** Wrap `System.IO.FileSystemWatcher` behind a testable interface so unit tests can simulate disk events without touching the real file system. + +**File:** `Shared/SharedCore/Shared.Core/Services/IFileSystemWatcher.cs` + +```csharp +namespace Shared.Core.Services +{ + public interface IFileSystemWatcher : IDisposable + { + string Path { get; set; } + bool IncludeSubdirectories { get; set; } + bool EnableRaisingEvents { get; set; } + + event FileSystemEventHandler? Created; + event FileSystemEventHandler? Deleted; + event RenamedEventHandler? Renamed; + } +} +``` + +**File:** `Shared/SharedCore/Shared.Core/Services/FileSystemWatcherWrapper.cs` + +```csharp +namespace Shared.Core.Services +{ + public class FileSystemWatcherWrapper : IFileSystemWatcher + { + private readonly FileSystemWatcher _watcher; + + public FileSystemWatcherWrapper() + { + _watcher = new FileSystemWatcher(); + _watcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.DirectoryName; + _watcher.Created += (s, e) => Created?.Invoke(s, e); + _watcher.Deleted += (s, e) => Deleted?.Invoke(s, e); + _watcher.Renamed += (s, e) => Renamed?.Invoke(s, e); + } + + public string Path { get => _watcher.Path; set => _watcher.Path = value; } + public bool IncludeSubdirectories { get => _watcher.IncludeSubdirectories; set => _watcher.IncludeSubdirectories = value; } + public bool EnableRaisingEvents { get => _watcher.EnableRaisingEvents; set => _watcher.EnableRaisingEvents = value; } + + public event FileSystemEventHandler? Created; + public event FileSystemEventHandler? Deleted; + public event RenamedEventHandler? Renamed; + + public void Dispose() => _watcher.Dispose(); + } +} +``` + +**DI registration** (in `Shared.Core` DI container): register `IFileSystemWatcher` as transient factory. + +### Unit Tests (`Shared.CoreTest/Services/FileSystemWatcherWrapperTests.cs`) + +| Test Name | Validates | +|-----------|-----------| +| `Dispose_DoesNotThrow` | Wrapper disposes cleanly | +| `SetPath_PropagatesCorrectly` | Setting Path on wrapper reaches underlying watcher | +| `EnableRaisingEvents_DefaultFalse` | Events not raised until explicitly enabled | + +**Verification:** Build succeeds, tests green. + +--- + +## Step 2: Create `SystemFolderContainer` — Core Structure + Read Operations + +**Goal:** Implement `IPackFileContainerInternal` backed by a real directory. Populate `FileList` by scanning the folder recursively on construction. Each `PackFile` uses `FileSystemSource`. + +**File:** `Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/SystemFolderContainer.cs` + +**Key properties:** +```csharp +internal class SystemFolderContainer : IPackFileContainerInternal, IDisposable +{ + public string Name { get; set; } + public bool IsCaPackFile { get; set; } = false; + public string? SystemFilePath { get; } // the root folder path + + // internal dictionary: relative-path → PackFile + private Dictionary _fileList = new(); +} +``` + +**Constructor:** +- Takes `string folderPath` and `IFileSystemAccess fileSystemAccess`. +- Scans `folderPath` recursively using `IFileSystemAccess.DirectoryGetFiles(folderPath, "*.*", SearchOption.AllDirectories)`. +- For each file, computes relative path (normalized via `PathNormalization`), creates `PackFile` with `FileSystemSource`. +- Sets `Name` to the folder name. +- Sets `SystemFilePath` to the absolute folder path. + +**Read operations** (same logic as `PackFileContainer`): +- `GetFileCount()` → `_fileList.Count` +- `FindFile(path)` → dictionary lookup +- `ContainsFile(path)` → dictionary contains +- `GetFullPath(file)` → reverse lookup +- `GetAllFiles()` → return dictionary +- `GetAllFilesByFolder()` → group by folder prefix +- `FindAllWithExtention(ext)` → filter by extension +- `SearchFiles(filter, extensions)` → filter by name/extension +- `GetDirectoryContent(directoryPath)` → prefix match + +### Unit Tests (`Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Read.cs`) + +| Test Name | Validates | +|-----------|-----------| +| `Constructor_ScansFolder_PopulatesFileList` | File count matches disk | +| `FindFile_NormalizedPath_ReturnsPackFile` | Case-insensitive lookup works | +| `GetAllFiles_ReturnsAllScannedFiles` | Dictionary has expected count | +| `GetFullPath_ReturnsCorrectRelativePath` | Reverse lookup works | +| `ContainsFile_ExistingFile_ReturnsTrue` | Positive containment check | +| `ContainsFile_MissingFile_ReturnsFalse` | Negative containment check | +| `GetAllFilesByFolder_GroupsCorrectly` | Folder grouping is correct | +| `SearchFiles_FilterByName_ReturnsMatch` | Text filter works | +| `SearchFiles_FilterByExtension_ReturnsMatch` | Extension filter works | + +**Strategy:** Use a temporary directory with known files, mock `IFileSystemAccess` for isolation. + +**Verification:** Build succeeds, all tests green. + +--- + +## Step 3: Implement Write-Through Mutations (Add, Delete, Rename, Move) + +**Goal:** Mutations to the container immediately write through to the file system. + +**Methods to implement:** + +```csharp +public void AddOrUpdateFile(string path, PackFile file) +{ + // 1. Compute absolute path: SystemFilePath + relative path + // 2. Ensure directory exists + // 3. Write file bytes to disk via IFileSystemAccess.FileWriteAllBytes + // 4. Create new FileSystemSource pointing to the absolute path + // 5. Update _fileList dictionary + // IMPORTANT: Temporarily disable watcher to avoid self-triggered events +} + +public List AddFiles(List newFiles) +{ + // For each entry: build path, write to disk, add to _fileList +} + +public PackFile? DeleteFile(PackFile file) +{ + // 1. Find path in _fileList + // 2. Delete from disk via File.Delete + // 3. Remove from _fileList +} + +public void DeleteFolder(string folder) +{ + // 1. Compute absolute folder path + // 2. Delete directory recursively from disk + // 3. Remove all matching entries from _fileList +} + +public void MoveFile(PackFile file, string newFolderPath) +{ + // 1. Find old path, compute new absolute path + // 2. Ensure target directory exists + // 3. Move file on disk (File.Move) + // 4. Update _fileList + // 5. Create new FileSystemSource for the new location +} + +public string RenameDirectory(string currentNodeName, string newName) +{ + // 1. Compute old and new absolute paths + // 2. Rename directory on disk (Directory.Move) + // 3. Update all _fileList entries with new prefix + // 4. Recreate FileSystemSource for moved files +} + +public void RenameFile(PackFile file, string newName) +{ + // 1. Find old path + // 2. Compute new path (same directory, new name) + // 3. Move file on disk + // 4. Update _fileList entry + // 5. Update file.Name and file.DataSource +} + +public void SaveFileData(PackFile file, byte[] data) +{ + // 1. Find absolute path + // 2. Write bytes to disk + // 3. Update DataSource to new FileSystemSource (to reflect new size) +} +``` + +**Important:** Each mutation should temporarily suppress the file watcher (`EnableRaisingEvents = false`) around disk writes to avoid reacting to our own changes. Use a `_suppressWatcher` flag or a RAII guard. + +### Unit Tests (`Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Write.cs`) + +| Test Name | Validates | +|-----------|-----------| +| `AddOrUpdateFile_WritesFileToDisk` | File appears on disk after add | +| `AddOrUpdateFile_UpdatesFileList` | _fileList contains new entry | +| `AddFiles_MultipleFiles_AllWrittenToDisk` | Bulk add works | +| `DeleteFile_RemovesFromDiskAndFileList` | File deleted from both | +| `DeleteFile_NonExistentFile_ReturnsNull` | Graceful no-op | +| `DeleteFolder_RemovesRecursively` | All files in folder removed | +| `MoveFile_UpdatesDiskAndFileList` | File moved, old path gone, new path exists | +| `RenameFile_UpdatesDiskAndFileList` | File renamed on disk and in dictionary | +| `RenameDirectory_UpdatesAllChildPaths` | All nested paths updated | +| `SaveFileData_WritesNewContent` | Read-back matches written data | + +**Strategy:** Use real temp directory (via `Path.GetTempPath()`) for write tests. Clean up in `[TearDown]`. + +**Verification:** Build succeeds, all tests green. + +--- + +## Step 4: Implement `SaveToDisk` — Pack File Generation + +**Goal:** `SaveToDisk` generates a `.pack` file from the folder contents using the existing `PackFileSerializerWriter`, but the container remains active (folder is still watched). + +```csharp +public void SaveToDisk(string path, bool createBackup, GameInformation gameInformation) +{ + // 1. Create backup if requested (reuse SaveUtility.CreateFileBackup) + // 2. Open temp file stream at path + "_temp" + // 3. Build a transient PackFileContainer from _fileList + // - Each file needs its data loaded into memory (ReadData from FileSystemSource) + // - OR pass directly to PackFileSerializerWriter if it supports IDataSource + // 4. Call PackFileSerializerWriter.SaveToByteArray(path, tempContainer, writer, gameInformation) + // 5. Finalize: delete original, move temp → target path + // NOTE: Do NOT update SystemFilePath (it stays as the folder path) +} +``` + +**Reference:** See `PackFileContainer.SaveToDisk` for the pattern (temp file + rename). + +### Unit Tests (`Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_SaveToDisk.cs`) + +| Test Name | Validates | +|-----------|-----------| +| `SaveToDisk_GeneratesValidPackFile` | Output file can be re-loaded as PackFileContainer | +| `SaveToDisk_ContainerRemainsActive` | FileList unchanged after save, container usable | +| `SaveToDisk_CreateBackup_BackupCreated` | Old file backed up when backup=true | +| `SaveToDisk_LockedFile_ThrowsIOException` | Handles locked output path | + +**Verification:** Build succeeds, all tests green, generated .pack can be loaded by the standard loader. + +--- + +## Step 5: Implement FileSystemWatcher Integration + Debouncing + +**Goal:** Detect external file system changes (created, deleted, renamed) and publish existing pack-file events so the UI tree and other consumers update automatically. + +**Design:** + +```csharp +// In SystemFolderContainer: +private IFileSystemWatcher _watcher; +private IGlobalEventHub _eventHub; +private Timer _debounceTimer; +private readonly List _pendingEvents = new(); +private bool _suppressWatcher = false; + +private void StartWatching() +{ + _watcher.Path = SystemFilePath; + _watcher.IncludeSubdirectories = true; + _watcher.EnableRaisingEvents = true; + _watcher.Created += OnFileCreated; + _watcher.Deleted += OnFileDeleted; + _watcher.Renamed += OnFileRenamed; +} + +private void OnFileCreated(object sender, FileSystemEventArgs e) +{ + if (_suppressWatcher) return; + lock (_pendingEvents) { _pendingEvents.Add(e); } + ResetDebounceTimer(); +} + +// Similar for OnFileDeleted, OnFileRenamed + +private void ResetDebounceTimer() +{ + _debounceTimer?.Dispose(); + _debounceTimer = new Timer(ProcessPendingEvents, null, 300, Timeout.Infinite); +} + +private void ProcessPendingEvents(object? state) +{ + List events; + lock (_pendingEvents) + { + events = new List(_pendingEvents); + _pendingEvents.Clear(); + } + + var addedFiles = new List(); + var removedFiles = new List(); + + foreach (var e in events) + { + switch (e.ChangeType) + { + case WatcherChangeTypes.Created: + // Add to _fileList, create PackFile with FileSystemSource + break; + case WatcherChangeTypes.Deleted: + // Remove from _fileList, track removed PackFile + break; + case WatcherChangeTypes.Renamed: + // Remove old entry, add new entry + break; + } + } + + // Publish existing events + if (addedFiles.Count > 0) + _eventHub.PublishGlobalEvent(new PackFileContainerFilesAddedEvent(this, addedFiles)); + if (removedFiles.Count > 0) + _eventHub.PublishGlobalEvent(new PackFileContainerFilesRemovedEvent(this, removedFiles)); +} +``` + +**Watcher suppression guard:** +```csharp +private IDisposable SuppressWatcher() +{ + _suppressWatcher = true; + return new ActionDisposable(() => _suppressWatcher = false); +} +``` + +### Unit Tests (`Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Watcher.cs`) + +| Test Name | Validates | +|-----------|-----------| +| `ExternalFileCreated_PublishesFilesAddedEvent` | New file on disk → event raised | +| `ExternalFileDeleted_PublishesFilesRemovedEvent` | Deleted file → event raised | +| `ExternalFileRenamed_PublishesRemovedAndAddedEvents` | Rename → old removed + new added | +| `InternalAdd_DoesNotTriggerExternalEvent` | Write-through suppresses watcher | +| `InternalDelete_DoesNotTriggerExternalEvent` | Delete suppresses watcher | +| `MultipleRapidCreates_BatchedIntoSingleEvent` | Debouncing batches multiple events | +| `Dispose_StopsWatcher` | After Dispose, no events fire | + +**Strategy:** Use mock `IFileSystemWatcher` that manually raises events. Verify via mock `IGlobalEventHub`. + +**Verification:** Build succeeds, all tests green. + +--- + +## Step 6: Integrate with `PackFileService` + +**Goal:** Ensure `PackFileService` can add, unload, and manage `SystemFolderContainer` like other containers. Handle disposal on unload. + +**Changes:** + +1. **`PackFileService.UnloadPackContainer`** — already publishes `PackFileContainerRemovedEvent` and removes from list. Add explicit `IDisposable` check: + ```csharp + // After removing from list: + if (container is IDisposable disposable) + disposable.Dispose(); + ``` + *(Check if this is already done — CachedPackFileContainer is IDisposable.)* + +2. **`PackFileService.AddContainer`** — duplicate check uses `SystemFilePath`. For `SystemFolderContainer`, `SystemFilePath` is the folder path, so duplicates are naturally prevented. + +3. **No changes to `IPackFileService` interface** — `AddContainer` already accepts `IPackFileContainer`. + +4. **Factory method** (optional convenience): + ```csharp + public IPackFileContainer LoadSystemFolder(string folderPath, bool setToMainPack = false); + ``` + This creates a new `SystemFolderContainer`, adds it via `AddContainer`, and returns it. + +### Unit Tests (`Shared.CoreTest/PackFiles/SystemFolderContainer_PackFileServiceTests.cs`) + +| Test Name | Validates | +|-----------|-----------| +| `AddContainer_SystemFolder_RegistersSuccessfully` | Container added to list, event published | +| `AddContainer_DuplicateFolderPath_Rejected` | Same folder path rejects second load | +| `UnloadContainer_DisposesWatcher` | Unloading disposes the container | +| `SetEditablePack_SystemFolder_Works` | Can mark as editable | +| `AddFilesToPack_SystemFolder_WritesThrough` | Files written to disk via service | +| `DeleteFile_SystemFolder_DeletesFromDisk` | File removed from disk via service | +| `SavePackContainer_SystemFolder_GeneratesPackFile` | Pack file generated on disk | +| `FindFile_AcrossContainers_FindsInSystemFolder` | Global search finds files in folder container | + +**Verification:** Build succeeds, all tests green. + +--- + +## Step 7: Disposal & Handle Cleanup + +**Goal:** Ensure no dangling handles when container is unloaded or app shuts down. + +**Cleanup responsibilities:** +1. `IFileSystemWatcher.Dispose()` — stops watching, releases OS handles. +2. `Timer.Dispose()` — stops debounce timer. +3. `PackFileService` unload path calls `Dispose`. +4. Application shutdown (`ForceShutdownEvent`) should unload all containers (already handled by existing shutdown flow). + +**Implementation in `SystemFolderContainer.Dispose()`:** +```csharp +public void Dispose() +{ + _watcher.EnableRaisingEvents = false; + _watcher.Dispose(); + _debounceTimer?.Dispose(); + _fileList.Clear(); +} +``` + +**Check `PackFileService.UnloadPackContainer`:** +```csharp +// Existing code removes from list. Ensure IDisposable.Dispose is called: +_packFileContainers.Remove(container); +(container as IDisposable)?.Dispose(); +``` + +### Unit Tests (`Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Dispose.cs`) + +| Test Name | Validates | +|-----------|-----------| +| `Dispose_StopsRaisingEvents` | EnableRaisingEvents set to false | +| `Dispose_ClearsFileList` | No lingering references | +| `Dispose_CalledTwice_NoException` | Idempotent disposal | +| `UnloadPackContainer_CallsDispose` | Service calls Dispose on SystemFolderContainer | + +**Verification:** Build succeeds, all tests green, no resource leaks. + +--- + +## Step 8: DI Registration & Integration Test + +**Goal:** Register new types in DI container and create an integration test that exercises the full workflow. + +**DI registration** (in `Shared.Core` DI container — likely `DependencyInjectionContainer.cs` under SharedCore): +```csharp +services.AddTransient(); +// SystemFolderContainer is created via factory, not directly resolved +``` + +**Integration test** (`Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Integration.cs`): + +| Test Name | Validates | +|-----------|-----------| +| `FullWorkflow_CreateFolder_AddFile_DeleteFile_Save` | End-to-end: create container from temp dir, add file via service, verify on disk, delete via service, verify removed, save as pack | +| `FullWorkflow_ExternalAdd_DetectedAndEventsPublished` | Create real watcher, drop file into dir, verify event fires | + +**Verification:** Full build succeeds, all tests green. + +--- + +## File Inventory + +| File | Purpose | New/Modified | +|------|---------|------| +| `Shared.Core/Services/IFileSystemWatcher.cs` | Watcher abstraction | New | +| `Shared.Core/Services/FileSystemWatcherWrapper.cs` | Real watcher impl | New | +| `Shared.Core/PackFiles/Models/Containers/SystemFolderContainer.cs` | Main container | New | +| `Shared.Core/PackFiles/PackFileService.cs` | Dispose on unload, optional factory | Modified | +| `Shared.Core/DependencyInjection/...` | Register new services | Modified | +| `Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Read.cs` | Read operation tests | New | +| `Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Write.cs` | Write operation tests | New | +| `Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_SaveToDisk.cs` | Pack generation tests | New | +| `Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Watcher.cs` | Watcher event tests | New | +| `Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Dispose.cs` | Cleanup tests | New | +| `Shared.CoreTest/PackFiles/SystemFolderContainer_PackFileServiceTests.cs` | Service integration tests | New | +| `Shared.CoreTest/PackFiles/Models/Containers/SystemFolderContainerTests_Integration.cs` | Full workflow tests | New | +| `Shared.CoreTest/Services/FileSystemWatcherWrapperTests.cs` | Watcher wrapper tests | New | + +--- + +## Existing Code to Reference + +| Class / File | Why | +|---|---| +| `PackFileContainer` (`Shared.Core/PackFiles/Models/Containers/PackFileContainer.cs`) | Base pattern for dictionary-based container, read/write methods | +| `CachedPackFileContainer` (same folder) | IDisposable pattern, read-only throws | +| `FileSystemSource` (`Shared.Core/PackFiles/Models/FileSources/FileSystemSource.cs`) | Data source for disk files | +| `IFileSystemAccess` / `FileSystemAccess` (`Shared.Core/Services/`) | Testable file I/O wrapper | +| `PackFileService` (`Shared.Core/PackFiles/PackFileService.cs`) | Container lifecycle, event publishing | +| `PackFileSavedEvent.cs` (`Shared.Core/Events/Global/`) | All existing pack-file events | +| `PackFileSerializerWriter` (`Shared.Core/PackFiles/Serialization/`) | .pack file generation | +| `PathNormalization` (`Shared.Core/PackFiles/Utility/`) | Path normalization rules | +| `PackFileContainerTests_TestBase` (`Shared.CoreTest/PackFiles/Models/Containers/`) | Test pattern for containers | + +--- + +## Execution Order & Dependencies + +``` +Step 1 ──► Step 2 ──► Step 3 ──► Step 4 + │ │ + └──► Step 5 ◄──────┘ + │ + ▼ + Step 6 ──► Step 7 ──► Step 8 +``` + +- Steps 1–3 are strictly sequential (each builds on previous). +- Step 4 (SaveToDisk) depends on Step 3 (write-through established). +- Step 5 (Watcher) depends on Steps 1 + 2 (watcher interface and file list). +- Step 6 (Service integration) depends on Steps 3 + 5. +- Step 7 (Disposal) depends on Steps 5 + 6. +- Step 8 (DI + Integration) depends on all previous steps. + +--- + +## Notes + +- **Thread safety:** FileSystemWatcher callbacks come on thread-pool threads. `_pendingEvents` list is locked. Event publishing should marshal to UI thread if needed (check how `PackFileBrowserViewModel` handles this — it likely dispatches to `Application.Current.Dispatcher`). +- **Path separator:** The pack-file system uses `\` as separator. `SystemFolderContainer` should normalize all paths via `PathNormalization.NormalizeFileName` (which lowercases and converts `/` to `\`). +- **IsCaPackFile:** Always `false` for `SystemFolderContainer`. This allows mutations in `PackFileService`. +- **Empty directories:** The internal dictionary only tracks files, not empty directories. Empty directories on disk are ignored (same as PackFileContainer behavior). +- **Large folders:** Initial scan may be slow for very large directories. Consider async loading if needed (out of scope for initial implementation). From 3cffcf3eb950a239f8e0f486b7e63806635d6dff Mon Sep 17 00:00:00 2001 From: ole kristian homelien Date: Sun, 7 Jun 2026 08:26:42 +0200 Subject: [PATCH 02/26] Code --- AssetEditor/ViewModels/MenuBarViewModel.cs | 33 ++++++-- .../Containers/SystemFolderContainer.cs | 2 +- .../NewPackFile/NewPackFileWindow.xaml | 42 ++++++++++ .../NewPackFile/NewPackFileWindow.xaml.cs | 79 +++++++++++++++++++ 4 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/NewPackFile/NewPackFileWindow.xaml create mode 100644 Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/NewPackFile/NewPackFileWindow.xaml.cs diff --git a/AssetEditor/ViewModels/MenuBarViewModel.cs b/AssetEditor/ViewModels/MenuBarViewModel.cs index 2ef5629e2..4389b8115 100644 --- a/AssetEditor/ViewModels/MenuBarViewModel.cs +++ b/AssetEditor/ViewModels/MenuBarViewModel.cs @@ -18,6 +18,7 @@ using Shared.Core.Misc; using Shared.Core.PackFiles; using Shared.Core.PackFiles.Models; +using Shared.Core.PackFiles.Models.Containers; using Shared.Core.PackFiles.Utility; using Shared.Core.Services; using Shared.Core.Settings; @@ -36,6 +37,7 @@ public partial class MenuBarViewModel : IDisposable private readonly IFileSaveService _packFileSaveService; private readonly IPackFileContainerLoader _packFileContainerLoader; private readonly IStandardDialogs _standardDialogs; + private readonly IFileSystemAccess _fileSystemAccess; public ObservableCollection RecentPackFiles { get; set; } = []; public ObservableCollection Editors { get; set; } = []; @@ -47,7 +49,8 @@ public MenuBarViewModel(IPackFileService packfileService, TouchedFilesRecorder touchedFilesRecorder, IFileSaveService packFileSaveService, IPackFileContainerLoader packFileContainerLoader, - IStandardDialogs standardDialogs) + IStandardDialogs standardDialogs, + IFileSystemAccess fileSystemAccess) { _packfileService = packfileService; _settingsService = settingsService; @@ -57,6 +60,7 @@ public MenuBarViewModel(IPackFileService packfileService, _packFileSaveService = packFileSaveService; _packFileContainerLoader = packFileContainerLoader; _standardDialogs = standardDialogs; + _fileSystemAccess = fileSystemAccess; var settings = settingsService.CurrentSettings; settings.RecentPackFilePaths.CollectionChanged += OnRecentPackFilePathsChanged; CreateRecentPackFilesItems(); @@ -77,21 +81,36 @@ public void Dispose() [RelayCommand] private void OpenPackFile() => _uiCommandFactory.Create().Execute(); [RelayCommand] private void CreateNewPackFile() { - var window = new TextInputWindow("New Pack Name", ""); - if (window.ShowDialog() == true) + var window = new NewPackFileWindow(); + if (window.ShowDialog() != true) + return; + + if (window.SelectedType == NewPackFileType.GamePack) { - if (string.IsNullOrWhiteSpace(window.TextValue)) + if (string.IsNullOrWhiteSpace(window.PackName)) { - _standardDialogs.ShowDialogBox($"'{window.TextValue}' is not a valid packfile name", "Error"); + _standardDialogs.ShowDialogBox($"'{window.PackName}' is not a valid pack name", "Error"); return; } var currentGame = _settingsService.CurrentSettings.CurrentGame; - var pfsVersion = GameInformationDatabase.Games[currentGame].PackFileVersion; + var pfsVersion = GameInformationDatabase.Games[currentGame].PackFileVersion; - var newPackFile = _packfileService.CreateNewPackFileContainer(window.TextValue.Trim(), pfsVersion, PackFileCAType.MOD); + var newPackFile = _packfileService.CreateNewPackFileContainer(window.PackName.Trim(), pfsVersion, PackFileCAType.MOD); _packfileService.SetEditablePack(newPackFile); } + else + { + if (string.IsNullOrWhiteSpace(window.SelectedFolderPath)) + { + _standardDialogs.ShowDialogBox("No folder was selected", "Error"); + return; + } + + var folderPack = new SystemFolderContainer(window.SelectedFolderPath, _fileSystemAccess); + _packfileService.AddContainer(folderPack); + _packfileService.SetEditablePack(folderPack); + } } [RelayCommand] private void CreateAnimPackWarhammer3() => _uiCommandFactory.Create().CreateAnimationDbWarhammer3(); diff --git a/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/SystemFolderContainer.cs b/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/SystemFolderContainer.cs index 0599ff397..9ee8bf88e 100644 --- a/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/SystemFolderContainer.cs +++ b/Shared/SharedCore/Shared.Core/PackFiles/Models/Containers/SystemFolderContainer.cs @@ -10,7 +10,7 @@ namespace Shared.Core.PackFiles.Models.Containers { - internal class SystemFolderContainer : IPackFileContainerInternal, IDisposable + public class SystemFolderContainer : IPackFileContainerInternal, IDisposable { private static readonly ILogger _logger = Logging.Create(); diff --git a/Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/NewPackFile/NewPackFileWindow.xaml b/Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/NewPackFile/NewPackFileWindow.xaml new file mode 100644 index 000000000..3788db0f2 --- /dev/null +++ b/Shared/SharedUI/Shared.Ui/BaseDialogs/StandardDialog/NewPackFile/NewPackFileWindow.xaml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + +