Add UnitTests and made code testable with DI

This commit is contained in:
2025-11-04 20:27:51 +10:30
parent e5787c9321
commit 17be096ae2
22 changed files with 865 additions and 140 deletions
+91 -34
View File
@@ -1,14 +1,17 @@
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Extensions.Options;
using TinfoilVibeServer.Models;
using TinfoilVibeServer.Utilities;
namespace TinfoilVibeServer.Services;
public interface ISnapshotService
{
event EventHandler SnapshotRebuilt; // raised after a rebuild
void RebuildSnapshot();
IReadOnlyList<FileEntry> GetSnapshot();
SnapshotService.ROMSnapshot GetSnapshot();
void BuildSnapshot();
}
/// <summary>
@@ -17,38 +20,54 @@ public interface ISnapshotService
/// </summary>
public sealed class SnapshotService : IDisposable, ISnapshotService
{
private readonly ConfigManager _config;
private readonly NSPExtractor _nspExtractor;
private readonly ArchiveHandler _archiveHandler;
private readonly SnapshotOptions _options;
private readonly INSPExtractor _nspExtractor;
private readonly IArchiveHandler _archiveHandler;
private readonly ILogger<SnapshotService> _logger;
private readonly string _jsonPath;
private readonly string _snapshotPath;
private readonly List<FileSystemWatcher> _watchers = new();
private readonly ConcurrentDictionary<string, CachedFile> _cache = new();
private string? _currentSnapshotHash;
private readonly Timer _debounceTimer;
public event EventHandler? SnapshotRebuilt;
public SnapshotService(ConfigManager config, NSPExtractor nspExtractor, ArchiveHandler archiveHandler, ILogger<SnapshotService> logger)
public SnapshotService(
IOptionsMonitor<SnapshotOptions> options,
INSPExtractor nspExtractor,
IArchiveHandler archiveHandler,
ILogger<SnapshotService> logger)
{
_config = config;
_options = options.CurrentValue;
_nspExtractor = nspExtractor;
_archiveHandler = archiveHandler;
_logger = logger;
_jsonPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotFile);
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _config.Settings.SnapshotBackupFile);
_jsonPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotFile);
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_jsonPath));
if (!File.Exists(_jsonPath))
{
File.WriteAllText(_jsonPath, "[]");
}
_snapshotPath = Path.Combine(AppContext.BaseDirectory, _options.SnapshotBackupFile);
FileSystemExtensions.EnsureDirectoryExists(Path.GetDirectoryName(_snapshotPath));
// 1️⃣ Register for *property* changes
_options.PropertyChanged += (s, e) => OnOptionsChanged(e.PropertyName);
BuildSnapshot(); // initial scan
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(GetSnapshot()));
_debounceTimer = new Timer(_ => DebounceElapsed(), null, Timeout.Infinite, Timeout.Infinite);
foreach (var path in _config.Settings.RootDirectories)
foreach (var path in _options.RootDirectories)
{
InitializeFileSystemWatcher(path);
}
_config.OnChange += cfg =>
}
// --------- Private helpers ---------
private void OnOptionsChanged(string propertyName)
{
if (propertyName == nameof(SnapshotOptions.RootDirectories))
{
var fileSystemWatchers = _watchers.Where(watcher => !cfg.RootDirectories.Contains(watcher.Path));
var fileSystemWatchers = _watchers.Where(watcher => !_options.RootDirectories.Contains(watcher.Path));
foreach (var watcher in fileSystemWatchers)
{
watcher.EnableRaisingEvents = false;
@@ -56,7 +75,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
_watchers.Remove(watcher);
}
var newWatchedDirectories = cfg.RootDirectories.Where(newWatchedDirectory =>
var newWatchedDirectories = _options.RootDirectories.Where(newWatchedDirectory =>
!_watchers.Any(watcher =>
string.Equals(watcher.Path, newWatchedDirectory, StringComparison.OrdinalIgnoreCase)));
@@ -65,11 +84,11 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
InitializeFileSystemWatcher(newWatchedDirectory);
}
BuildSnapshot(); // rebuild everything
PersistSnapshot();
};
}
}
private void InitializeFileSystemWatcher(string path)
{
if (!Directory.Exists(path)) return;
@@ -96,42 +115,65 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
private void ThrottleSnapshotUpdate(FileSystemEventArgs fileSystemEventArgs)
{
Task.Run(async () =>
lock (_lock)
{
_debounceTimer.Change(_debounceMs, Timeout.Infinite); // reset the timer
_logger.LogDebug("File system event {EventType} on {Path} at {Time}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath, DateTime.Now.ToString("HH:mm:ss"));
}
/*Task.Run(async () =>
{
await Task.Delay(250);
_logger.LogDebug("File system event {EventType} on {Path}", fileSystemEventArgs.ChangeType, fileSystemEventArgs.FullPath);
UpdateSnapshot();
});
});*/
}
private readonly object _lock = new object();
private int _debounceMs = 200;
private void DebounceElapsed()
{
UpdateSnapshot();
}
#endregion
#region Snapshot logic
private void BuildSnapshot()
public void BuildSnapshot()
{
var cfg = _config.Settings;
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", cfg.RootDirectories.Length);
var entries = new List<FileEntry>();
var index = LoadSnapshotIndex();
var latestModifiedUtcParallel = FileSystemExtensions.GetLatestModifiedUtcParallel(_options.RootDirectories);
var fileInfo = new FileInfo(_snapshotPath);
if (latestModifiedUtcParallel.HasValue && latestModifiedUtcParallel.Value < fileInfo.LastWriteTimeUtc)
{
_logger.LogInformation("Snapshot is up to date");
return;
}
_logger.LogInformation("Rebuilding snapshot (root dirs: {Count})", _options.RootDirectories.Count);
var entries = new List<FileEntry>();
var snapshotChanged = false;
foreach (var dir in cfg.RootDirectories)
foreach (var dir in _options.RootDirectories)
{
if (!Directory.Exists(dir)) continue;
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
{
var ext = Path.GetExtension(file).ToLowerInvariant();
if (!(cfg.WhitelistExtensions.Contains(ext) || cfg.RomExtensions.Contains(ext)))
if (!(_options.WhitelistExtensions.Contains(ext) || _options.RomExtensions.Contains(ext)))
continue;
if (index.ContainsKey(file)) continue;
if (index.TryGetValue(file, out var value))
{
entries.Add(value);
continue;
}
// 3) extract title if applicable
string hash;
NcaMetadataWithHash? title = null;
if (cfg.RomExtensions.Contains(ext))
if (_options.RomExtensions.Contains(ext))
{
using var nspStream = File.OpenRead(file);
hash = ComputeFirstStreamHash(nspStream);
@@ -170,6 +212,7 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
if (snapshotChanged)
{
_logger.LogInformation("Snapshot rebuilt");
SnapshotRebuilt?.Invoke(this, EventArgs.Empty);
}
}
@@ -183,14 +226,14 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
private void PersistSnapshot()
{
var entries = GetSnapshot();
var newHash = ComputeSnapshotHash(entries);
var snapshot = GetSnapshot();
var newHash = ComputeSnapshotHash(snapshot.Files);
if (_currentSnapshotHash != newHash)
{
_logger.LogInformation("Snapshot hash changed persisting new snapshot");
_currentSnapshotHash = newHash;
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(entries));
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(entries));
File.WriteAllText(_jsonPath, JsonSerializer.Serialize(snapshot.Files));
File.WriteAllText(_snapshotPath, JsonSerializer.Serialize(snapshot.Files));
}
}
@@ -219,10 +262,18 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
}
#endregion
public IReadOnlyList<FileEntry> GetSnapshot()
public ROMSnapshot GetSnapshot()
{
if (!File.Exists(_jsonPath)) return new();
var json = File.ReadAllText(_jsonPath);
return JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json, new JsonSerializerOptions(){IncludeFields = true})!;
var hash = ComputeHash(_jsonPath);
var romSnapshot = new ROMSnapshot()
{
Hash = hash,
Files = JsonSerializer.Deserialize<IReadOnlyList<FileEntry>>(json,
new JsonSerializerOptions() { IncludeFields = true })!
};
return romSnapshot;
}
public void RebuildSnapshot()
@@ -285,4 +336,10 @@ public sealed class SnapshotService : IDisposable, ISnapshotService
var hash = sha256.ComputeHash(stream);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
public class ROMSnapshot
{
public string Hash { get; set; }
public IReadOnlyList<FileEntry> Files { get; set; } = new List<FileEntry>();
}
}