diff --git a/.gitignore b/.gitignore index 0c324a1..e8722ed 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,10 @@ riderModule.iml .idea/ .idea/* +exports/ +imports/ +*.smo + # If we set this up !.idea/codeStyles/ !.idea/inspectionProfiles/ diff --git a/SubathonManager.Core/Config.cs b/SubathonManager.Core/Config.cs index 929d774..2b9dfd4 100644 --- a/SubathonManager.Core/Config.cs +++ b/SubathonManager.Core/Config.cs @@ -1,6 +1,7 @@ using IniParser; using IniParser.Model; using System.Diagnostics.CodeAnalysis; +using SubathonManager.Core.Events; using SubathonManager.Core.Interfaces; namespace SubathonManager.Core @@ -115,6 +116,7 @@ public virtual void Save() { Parser.WriteFile(ConfigPath, Data); PendingChanges = false; + SettingsEvents.RaiseSettingsUnsavedChanges(PendingChanges); } public virtual string GetDatabasePath() @@ -146,6 +148,7 @@ public virtual bool Set(string section, string key, string? value) { Data[section][key] = value ?? string.Empty; PendingChanges = true; + SettingsEvents.RaiseSettingsUnsavedChanges(PendingChanges); return true; } return false; diff --git a/SubathonManager.Core/Events/SettingsEvents.cs b/SubathonManager.Core/Events/SettingsEvents.cs new file mode 100644 index 0000000..87ae864 --- /dev/null +++ b/SubathonManager.Core/Events/SettingsEvents.cs @@ -0,0 +1,14 @@ +using System.Diagnostics.CodeAnalysis; + +namespace SubathonManager.Core.Events; + +[ExcludeFromCodeCoverage] +public static class SettingsEvents +{ + public static event Action? SettingsUnsavedChanges; + + public static void RaiseSettingsUnsavedChanges(bool hasPendingChanges) + { + SettingsUnsavedChanges?.Invoke(hasPendingChanges); + } +} \ No newline at end of file diff --git a/SubathonManager.Core/Models/Route.cs b/SubathonManager.Core/Models/Route.cs index 163d8d3..3918008 100644 --- a/SubathonManager.Core/Models/Route.cs +++ b/SubathonManager.Core/Models/Route.cs @@ -3,6 +3,7 @@ namespace SubathonManager.Core.Models; using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; [ExcludeFromCodeCoverage] public class Route @@ -24,4 +25,52 @@ public string GetRouteUrl(IConfig config, bool editMode = false) string qString = editMode ? "?edit=true" : ""; return $"http://localhost:{config.Get("Server", "Port", "14040")}/route/{Id}{qString}"; } + + /* + public JsonElement ToJson() + { + var obj = new + { + name = Name, + + resolution = new + { + width = Width, + height = Height + }, + + widgets = Widgets.Select(w => w.ToJson("")).ToArray(), + + meta = new + { + created = CreatedTimestamp, + updated = UpdatedTimestamp + } + }; + + return JsonSerializer.SerializeToElement(obj); + } + + public static Route? FromJson(JsonElement json) + { + var name = json.GetProperty("name").GetString() ?? string.Empty; + if (string.IsNullOrWhiteSpace(name)) return null; + + var route = new Route + { + Name = name + }; + + var res = json.GetProperty("resolution"); + route.Width = res.GetProperty("width").GetInt32(); + route.Height = res.GetProperty("height").GetInt32(); + + foreach (var widget in json.GetProperty("widgets").EnumerateArray().Select(widgetJson => Widget.FromJson(widgetJson, "", route.Id)).OfType()) + { + route.Widgets.Add(widget); + } + + return route; + } + */ } \ No newline at end of file diff --git a/SubathonManager.Core/Models/Widget.cs b/SubathonManager.Core/Models/Widget.cs index 1dcf6d9..86b5b12 100644 --- a/SubathonManager.Core/Models/Widget.cs +++ b/SubathonManager.Core/Models/Widget.cs @@ -5,6 +5,7 @@ using System.Text; using System.Text.Json; using System.Text.RegularExpressions; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Core.Models; @@ -35,6 +36,30 @@ public CssVariable Clone(Guid newWidgetId) Description = Description }; } + + public static CssVariable? FromJson(JsonElement json, Guid widgetId) + { + var name = json.GetProperty("name").GetString() ?? string.Empty; + var value = json.GetProperty("value").GetString() ?? string.Empty; + if (string.IsNullOrEmpty(name)) return null; + return new CssVariable + { + Name = name, + Value = value, + WidgetId = widgetId + }; + } + + public JsonElement ToJson() + { + var obj = new + { + name = Name, + value = Value + }; + + return JsonSerializer.SerializeToElement(obj); + } } [ExcludeFromCodeCoverage] @@ -69,9 +94,9 @@ public string GetInjectLine() string val = Value.Split(',')[0].Trim(); sb.Append($"\"{val}\""); } - else if (Type == WidgetVariableType.EventTypeList || - Type == WidgetVariableType.StringList || - Type == WidgetVariableType.EventSubTypeList) + else if (Type is WidgetVariableType.EventTypeList or + WidgetVariableType.StringList or + WidgetVariableType.EventSubTypeList) { string val = string.Join(",", Value.Split(',').Select(s => { @@ -110,6 +135,44 @@ public JsVariable Clone(Guid newWidgetId) WidgetId = newWidgetId }; } + + public static JsVariable? FromJson(JsonElement json, Guid widgetId) + { + var name = json.GetProperty("name").GetString() ?? string.Empty; + if (string.IsNullOrEmpty(name)) return null; + + var typeEl = json.GetProperty("type"); + WidgetVariableType type; + if (typeEl.ValueKind == JsonValueKind.Number + && typeEl.TryGetInt32(out var typeInt) + && Enum.IsDefined(typeof(WidgetVariableType), typeInt)) + { + type = (WidgetVariableType)typeInt; + } + else if (!Enum.TryParse(typeEl.GetString() ?? string.Empty, out type)) + { + return null; + } + return new JsVariable + { + Name = name, + Value = json.GetProperty("value").GetString() ?? string.Empty, + Type = type, + WidgetId = widgetId + }; + } + + public JsonElement ToJson() + { + var obj = new + { + name = Name, + value = Value, + type = Type + }; + + return JsonSerializer.SerializeToElement(obj); + } } [ExcludeFromCodeCoverage] @@ -168,6 +231,8 @@ public Widget Clone(Guid? routeId, string? newName, int? newZ) return widget; } + + public string GetPath() => Path.GetDirectoryName(HtmlPath) ?? HtmlPath; public void ScanCssVariables() { @@ -251,5 +316,85 @@ public override string ToString() private static partial Regex CssLinkRegex(); [GeneratedRegex(@"--([a-zA-Z0-9-_]+)\s*:\s*([^;]+);")] + private static partial Regex CssVarRegex(); + + public JsonElement ToJson(string htmlRelPath) + { + var obj = new + { + name = Name, + htmlPath = htmlRelPath, + + position = new + { + x = X, + y = Y, + z = Z + }, + + size = new + { + width = Width, + height = Height + }, + + scale = new + { + x = ScaleX, + y = ScaleY + }, + + visibility = Visibility, + docsUrl = DocsUrl, + + cssVariables = CssVariables.Select(v => v.ToJson()).ToArray(), + jsVariables = JsVariables.Select(v => v.ToJson()).ToArray() + }; + + return JsonSerializer.SerializeToElement(obj); + } + + /* + public static Widget? FromJson(JsonElement json, string rootPath, Guid routeId) + { + var htmlPath = Path.Join(rootPath, json.GetProperty("htmlPath").GetString()); + var name = json.GetProperty("name").GetString() ?? string.Empty; + if (string.IsNullOrEmpty(name)) return null; + + var widget = new Widget(name, htmlPath) + { + RouteId = routeId, + Visibility = json.GetProperty("visibility").GetBoolean(), + DocsUrl = json.TryGetProperty("docsUrl", out var d) ? d.GetString() : null + }; + + var pos = json.GetProperty("position"); + widget.X = pos.GetProperty("x").GetSingle(); + widget.Y = pos.GetProperty("y").GetSingle(); + widget.Z = pos.GetProperty("z").GetInt32(); + + var size = json.GetProperty("size"); + widget.Width = size.GetProperty("width").GetInt32(); + widget.Height = size.GetProperty("height").GetInt32(); + + var scale = json.GetProperty("scale"); + widget.ScaleX = scale.GetProperty("x").GetSingle(); + widget.ScaleY = scale.GetProperty("y").GetSingle(); + + foreach (var cssVar in json.GetProperty("cssVariables").EnumerateArray().Select(css => + CssVariable.FromJson(css, widget.Id)).OfType()) + { + widget.CssVariables.Add(cssVar); + } + + foreach (var jsVar in json.GetProperty("jsVariables").EnumerateArray().Select(js => + JsVariable.FromJson(js, widget.Id)).OfType()) + { + widget.JsVariables.Add(jsVar); + } + + return widget; + } + */ } diff --git a/SubathonManager.Data/OverlayPorter.cs b/SubathonManager.Data/OverlayPorter.cs new file mode 100644 index 0000000..dd24e84 --- /dev/null +++ b/SubathonManager.Data/OverlayPorter.cs @@ -0,0 +1,461 @@ +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO.Compression; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using SubathonManager.Core.Models; +using SubathonManager.Core.Enums; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Data; + +public class OverlayPorter +{ + private const int SegmentHashLength = 4; + private const string ExternalFolder = "_external"; + private const string ManifestFileName = "overlay.json"; + + private static readonly JsonSerializerOptions SerializeOptions = new JsonSerializerOptions + { + WriteIndented = true, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } + }; + + #region EXPORT + + public static async Task ExportRouteAsync(Route route, string outputPath, string exportName, HashSet? excludedZipEntries = null) + { + var widgets = route.Widgets.ToList(); + var plan = BuildExportPlan(widgets); + + await using var fileStream = new FileStream(outputPath, FileMode.Create, FileAccess.Write); + using var archive = new ZipArchive(fileStream, ZipArchiveMode.Create, leaveOpen: false); + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var (srcFile, zipEntry) in plan.FileCopies) + { + if (!File.Exists(srcFile)) continue; + if (!seen.Add(zipEntry)) continue; + if (excludedZipEntries != null && excludedZipEntries.Contains(zipEntry)) continue; + archive.CreateEntryFromFile(srcFile, zipEntry, CompressionLevel.Optimal); + } + + var manifest = BuildManifest(route, widgets, plan, exportName); + var manifestJson = JsonSerializer.Serialize(manifest, SerializeOptions); + var manifestEntry = archive.CreateEntry(ManifestFileName, CompressionLevel.Optimal); + await using var manifestStream = manifestEntry.Open(); + await manifestStream.WriteAsync(Encoding.UTF8.GetBytes(manifestJson)); + OpenExportFolder(outputPath); + } + + [ExcludeFromCodeCoverage] + private static void OpenExportFolder(string outputPath) + { + bool isTest = + AppDomain.CurrentDomain.GetAssemblies() + .Any(a => a.FullName!.StartsWith("xunit", StringComparison.OrdinalIgnoreCase)); + if (isTest) return; + try + { + Process.Start(new ProcessStartInfo + { + FileName = Path.GetDirectoryName(Path.GetFullPath(outputPath)), + UseShellExecute = true, + Verb = "open" + }); + } + catch {/**/} + } + + private static ExportPlan BuildExportPlan(List widgets) + { + var plan = new ExportPlan(); + + var widgetRoots = widgets.Select(w => w.GetPath()).ToList(); + var zipRoots = GetZipWidgetRoots(widgetRoots); + + for (int i = 0; i < widgets.Count; i++) + { + var widget = widgets[i]; + string widgetRoot = widgetRoots[i]; + string zipWidgetRoot = zipRoots[i]; + + plan.WidgetFolderMap[widget.Id] = zipWidgetRoot; + + if (Directory.Exists(widgetRoot)) + { + foreach (var file in Directory.EnumerateFiles(widgetRoot, "*", SearchOption.AllDirectories)) + { + string relative = Path.GetRelativePath(widgetRoot, file).Replace('\\', '/'); + plan.FileCopies.Add((file, $"{zipWidgetRoot}/{relative}")); + } + } + + foreach (var jsVar in widget.JsVariables) + { + if (!((WidgetVariableType?)jsVar.Type).IsFileVariable()) continue; + if (string.IsNullOrWhiteSpace(jsVar.Value)) continue; + + bool isAbsolute = !jsVar.Value.StartsWith("./") && !jsVar.Value.StartsWith("../") + && Path.IsPathRooted(jsVar.Value); + if (!isAbsolute) continue; + + bool isFolderType = jsVar.Type == WidgetVariableType.FolderPath; + if (isFolderType && Directory.Exists(jsVar.Value)) + { + string varFolderName = SanitizeName(jsVar.Name); + foreach (var file in Directory.EnumerateFiles(jsVar.Value, "*", SearchOption.AllDirectories)) + { + string relative = Path.GetRelativePath(jsVar.Value, file).Replace('\\', '/'); + plan.FileCopies.Add((file, $"{zipWidgetRoot}/{ExternalFolder}/{varFolderName}/{relative}")); + } + SetRewrite(plan.VariableRewrites, widget.Id, jsVar.Name, $"./{ExternalFolder}/{varFolderName}"); + } + else if (!isFolderType && File.Exists(jsVar.Value)) + { + string fileName = Path.GetFileName(jsVar.Value); + plan.FileCopies.Add((jsVar.Value, $"{zipWidgetRoot}/{ExternalFolder}/{fileName}")); + SetRewrite(plan.VariableRewrites, widget.Id, jsVar.Name, $"./{ExternalFolder}/{fileName}"); + } + } + } + + return plan; + } + + private static JsonElement BuildManifest(Route route, List widgets, ExportPlan plan, string exportName) + { + var widgetList = widgets.Select(w => + { + if (!plan.WidgetFolderMap.TryGetValue(w.Id, out var zipWidgetRoot)) return null!; + string htmlFileName = Path.GetFileName(w.HtmlPath); + string htmlZipRelPath = $"{zipWidgetRoot}/{htmlFileName}"; + + var rewrites = plan.VariableRewrites.TryGetValue(w.Id, out var r) ? r : new(); + var jsVars = w.JsVariables.Select(v => + { + string value = rewrites.TryGetValue(v.Name, out var rewritten) ? rewritten : v.Value; + return new { name = v.Name, value, type = v.Type }; + }); + + return new + { + id = w.Id, + name = w.Name, + htmlPath = htmlZipRelPath, + position = new { x = w.X, y = w.Y, z = w.Z }, + size = new { width = w.Width, height = w.Height }, + scale = new { x = w.ScaleX, y = w.ScaleY }, + visibility = w.Visibility, + docsUrl = w.DocsUrl, + cssVariables = w.CssVariables.Select(v => new { name = v.Name, value = v.Value }), + jsVariables = jsVars + }; + }); + + var obj = new + { + version = 1, + exported_at = DateTime.UtcNow, + route = new + { + id = route.Id, + name = exportName, + resolution = new { width = route.Width, height = route.Height }, + created = route.CreatedTimestamp, + updated = route.UpdatedTimestamp + }, + // helpful for debug + widget_folder_map = plan.WidgetFolderMap.ToDictionary( + kvp => kvp.Key.ToString(), + kvp => kvp.Value + ), + widgets = widgetList + }; + + return JsonSerializer.SerializeToElement(obj); + } + #endregion + + #region IMPORT + + public static async Task ImportRouteAsync( + string smoPath, + string extractRoot, + IDbContextFactory factory) + { + string archiveName = Path.GetFileNameWithoutExtension(smoPath); + string extractDir = Path.Combine(extractRoot, SanitizeName(archiveName)); + Directory.CreateDirectory(extractDir); + ZipFile.ExtractToDirectory(smoPath, extractDir, overwriteFiles: true); + + string manifestPath = Path.Combine(extractDir, ManifestFileName); + if (!File.Exists(manifestPath)) return ImportResult.Fail("overlay.json not found in archive"); + + var manifestJson = await File.ReadAllTextAsync(manifestPath); + using var doc = JsonDocument.Parse(manifestJson); + var root = doc.RootElement; + + await using var db = await factory.CreateDbContextAsync(); + + var routeEl = root.GetProperty("route"); + string routeName = routeEl.GetProperty("name").GetString() ?? archiveName; + + var existingWidgetPaths = new HashSet( + db.Widgets.Select(w => w.HtmlPath), + StringComparer.OrdinalIgnoreCase); + + var manifestWidgets = root.GetProperty("widgets").EnumerateArray() + .Select(wEl => + { + string htmlZipRelPath = wEl.GetProperty("htmlPath").GetString() ?? ""; + string htmlAbsPath = Path.GetFullPath( + Path.Combine(extractDir, htmlZipRelPath.Replace('/', Path.DirectorySeparatorChar))); + return (wEl, htmlAbsPath); + }) + .ToList(); + + bool anyWidgetExists = manifestWidgets.Any(w => existingWidgetPaths.Contains(w.htmlAbsPath)); + + Route? route; + bool routeIsNew; + + if (anyWidgetExists) + { + string? matchedPath = manifestWidgets + .Select(w => w.htmlAbsPath) + .FirstOrDefault(p => existingWidgetPaths.Contains(p)); + + route = await db.Routes + .Include(r => r.Widgets).ThenInclude(w => w.JsVariables) + .Include(r => r.Widgets).ThenInclude(w => w.CssVariables) + .FirstOrDefaultAsync(r => r.Widgets.Any(w => w.HtmlPath == matchedPath)); + + if (route == null) { route = BuildNewRoute(routeEl, routeName); routeIsNew = true; } + else routeIsNew = false; + } + else + { + route = BuildNewRoute(routeEl, routeName); + routeIsNew = true; + } + + var newWidgets = new List(); + var newCssVariables = new List(); + var newJsVariables = new List(); + + foreach (var (wEl, htmlAbsPath) in manifestWidgets) + { + string widgetExtractFolder = Path.GetDirectoryName(htmlAbsPath)!; + Widget? existingWidget = routeIsNew ? null + : route.Widgets.FirstOrDefault(w => + string.Equals(w.HtmlPath, htmlAbsPath, StringComparison.OrdinalIgnoreCase)); + + if (existingWidget == null) + { + var widget = new Widget(wEl.GetProperty("name").GetString() ?? "Imported Widget", htmlAbsPath) + { + Id = Guid.NewGuid(), RouteId = route.Id, + Visibility = wEl.GetProperty("visibility").GetBoolean(), + DocsUrl = wEl.TryGetProperty("docsUrl", out var du) ? du.GetString() : null + }; + + var pos = wEl.GetProperty("position"); + widget.X = pos.GetProperty("x").GetSingle(); + widget.Y = pos.GetProperty("y").GetSingle(); + widget.Z = pos.GetProperty("z").GetInt32(); + var size = wEl.GetProperty("size"); + widget.Width = size.GetProperty("width").GetInt32(); + widget.Height = size.GetProperty("height").GetInt32(); + var scale = wEl.GetProperty("scale"); + widget.ScaleX = scale.GetProperty("x").GetSingle(); + widget.ScaleY = scale.GetProperty("y").GetSingle(); + + foreach (var v in wEl.GetProperty("cssVariables").EnumerateArray().Select(cssEl => + CssVariable.FromJson(cssEl, widget.Id)).OfType()) + { + widget.CssVariables.Add(v); + } + foreach (var v in wEl.GetProperty("jsVariables").EnumerateArray().Select(jsEl => + BuildJsVariable(jsEl, widget.Id, widgetExtractFolder)).OfType()) + { + widget.JsVariables.Add(v); + } + + newWidgets.Add(widget); + } + else + { + var existingCssNames = new HashSet(existingWidget.CssVariables.Select(v => v.Name), StringComparer.OrdinalIgnoreCase); + foreach (var cssEl in wEl.GetProperty("cssVariables").EnumerateArray()) + { + string? name = cssEl.TryGetProperty("name", out var n) ? n.GetString() : null; + if (name == null || existingCssNames.Contains(name)) continue; + var v = CssVariable.FromJson(cssEl, existingWidget.Id); + if (v != null) newCssVariables.Add(v); + } + + var existingJsNames = new HashSet(existingWidget.JsVariables.Select(v => v.Name), StringComparer.OrdinalIgnoreCase); + foreach (var jsEl in wEl.GetProperty("jsVariables").EnumerateArray()) + { + string? name = jsEl.TryGetProperty("name", out var n) ? n.GetString() : null; + if (name == null || existingJsNames.Contains(name)) continue; + var v = BuildJsVariable(jsEl, existingWidget.Id, widgetExtractFolder); + if (v != null) newJsVariables.Add(v); + } + } + } + + return new ImportResult + { + Route = routeIsNew ? route : null, + NewWidgets = newWidgets, + NewCssVariables = newCssVariables, + NewJsVariables = newJsVariables, + RouteIsNew = routeIsNew + }; + } + + private static Route BuildNewRoute(JsonElement routeEl, string fallbackName) => new Route + { + Id = Guid.NewGuid(), + Name = routeEl.TryGetProperty("name", out var n) ? n.GetString() ?? fallbackName : fallbackName, + Width = routeEl.GetProperty("resolution").GetProperty("width").GetInt32(), + Height = routeEl.GetProperty("resolution").GetProperty("height").GetInt32(), + }; + + private static JsVariable? BuildJsVariable(JsonElement jsEl, Guid widgetId, string widgetFolder) + { + var v = JsVariable.FromJson(jsEl, widgetId); + if (v == null) return null; + + if (!((WidgetVariableType?)v.Type).IsFileVariable() || string.IsNullOrWhiteSpace(v.Value)) + return v; + + string resolvedValue = v.Value.StartsWith("./") || v.Value.StartsWith("../") + ? Path.GetFullPath(Path.Combine(widgetFolder, v.Value.Replace('/', Path.DirectorySeparatorChar))) + : v.Value; + + resolvedValue = resolvedValue.Replace('\\', '/'); + string normWidgetFolder = widgetFolder.Replace('\\', '/').TrimEnd('/') + "/"; + + v.Value = resolvedValue.StartsWith(normWidgetFolder, StringComparison.OrdinalIgnoreCase) + ? "./" + resolvedValue[normWidgetFolder.Length..] + : resolvedValue; + + return v; + } + + #endregion + + #region HELPERS + + private static void SetRewrite(Dictionary> rewrites, Guid widgetId, string varName, string value) + { + if (!rewrites.TryGetValue(widgetId, out var inner)) + { + inner = new Dictionary(); + rewrites[widgetId] = inner; + } + inner[varName] = value; + } + + public static List GetZipWidgetRoots(List absoluteFolderPaths) + { + if (absoluteFolderPaths.Count == 0) return new(); + + var segmentSets = absoluteFolderPaths + .Select(p => p.Replace('\\', '/').TrimEnd('/').Split('/', StringSplitOptions.RemoveEmptyEntries)) + .ToList(); + + var ancestors = segmentSets.Select(s => s[..^1]).ToList(); + + int commonLength = ancestors[0].Length; + for (int i = 1; i < ancestors.Count; i++) + { + int shared = 0; + int max = Math.Min(commonLength, ancestors[i].Length); + for (int j = 0; j < max; j++) + { + if (string.Equals(ancestors[0][j], ancestors[i][j], StringComparison.OrdinalIgnoreCase)) + shared++; + else + break; + } + commonLength = shared; + } + + var results = new List(absoluteFolderPaths.Count); + foreach (var segment in segmentSets) + { + string leaf = SanitizeName(segment[^1]); + var unique = segment[..^1][commonLength..]; + + var sb = new StringBuilder("widgets"); + if (unique.Length > 0) + { + sb.Append('/'); + sb.Append(HashSegment(unique[0])); + for (int j = 1; j < unique.Length; j++) + { + sb.Append('/'); + sb.Append(SanitizeName(unique[j])); + } + } + else + { + string bucketSource = commonLength > 0 + ? ancestors[0][commonLength - 1] + : leaf; + sb.Append('/'); + sb.Append(HashSegment(bucketSource)); + } + + sb.Append('/'); + sb.Append(leaf); + results.Add(sb.ToString()); + } + + return results; + } + + private static string HashSegment(string segment) + { + using var sha = SHA256.Create(); + var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(segment)); + return Convert.ToHexString(bytes)[..SegmentHashLength].ToLowerInvariant(); + } + + private static string SanitizeName(string name) + { + var invalid = Path.GetInvalidFileNameChars(); + return new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray()); + } + #endregion + + #region TYPES + public class ImportResult + { + public Route? Route { get; init; } + public List NewWidgets { get; init; } = new(); + public List NewCssVariables { get; init; } = new(); + public List NewJsVariables { get; init; } = new(); + public bool RouteIsNew { get; init; } + public bool Failed { get; init; } + public string? FailReason { get; init; } + public bool HasAnythingNew => + RouteIsNew || NewWidgets.Count > 0 || NewCssVariables.Count > 0 || NewJsVariables.Count > 0; + public static ImportResult Fail(string reason) => + new ImportResult { Failed = true, FailReason = reason }; + } + + private class ExportPlan + { + public Dictionary WidgetFolderMap { get; } = new(); // debug helper + public List<(string Src, string ZipEntry)> FileCopies { get; } = new(); + public Dictionary> VariableRewrites { get; } = new(); + } + #endregion +} \ No newline at end of file diff --git a/SubathonManager.Data/SubathonManager.Data.csproj b/SubathonManager.Data/SubathonManager.Data.csproj index 3b00446..91abdeb 100644 --- a/SubathonManager.Data/SubathonManager.Data.csproj +++ b/SubathonManager.Data/SubathonManager.Data.csproj @@ -13,12 +13,12 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/SubathonManager.Integration/SubathonManager.Integration.csproj b/SubathonManager.Integration/SubathonManager.Integration.csproj index b0fbdbd..850d39a 100644 --- a/SubathonManager.Integration/SubathonManager.Integration.csproj +++ b/SubathonManager.Integration/SubathonManager.Integration.csproj @@ -13,7 +13,7 @@ - + diff --git a/SubathonManager.Integration/YouTubeService.cs b/SubathonManager.Integration/YouTubeService.cs index 6d35cb6..8623ed3 100644 --- a/SubathonManager.Integration/YouTubeService.cs +++ b/SubathonManager.Integration/YouTubeService.cs @@ -10,6 +10,7 @@ using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; using SubathonManager.Services; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Integration; @@ -22,11 +23,6 @@ public class YouTubeService : IDisposable, IAppService private string? _ytHandle; public bool Running; - // private readonly ILogger _chatLogger; - //NullLogger.Instance; - // private readonly ILogger _httpClientLogger; - //NullLogger.Instance; - private readonly Utils.ServiceReconnectState _reconnectState = new(TimeSpan.FromSeconds(5), maxRetries: 100, maxBackoff: TimeSpan.FromMinutes(2)); diff --git a/SubathonManager.Services/EventService.cs b/SubathonManager.Services/EventService.cs index 0be8f3d..1b5c1bd 100644 --- a/SubathonManager.Services/EventService.cs +++ b/SubathonManager.Services/EventService.cs @@ -129,9 +129,17 @@ private async Task LoopAsync() ev.CurrentTime = (int)subathon.TimeRemaining().TotalSeconds; ev.CurrentPoints = subathon.Points; } - db.Add(ev); - await db.SaveChangesAsync(); - + + try + { + db.Add(ev); + await db.SaveChangesAsync(); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Error in Event Loop: {ExMessage}", ex.Message); + } + return (false, false); } diff --git a/SubathonManager.Tests/DataUnitTests/OverlayPorterTests.cs b/SubathonManager.Tests/DataUnitTests/OverlayPorterTests.cs new file mode 100644 index 0000000..4a2e996 --- /dev/null +++ b/SubathonManager.Tests/DataUnitTests/OverlayPorterTests.cs @@ -0,0 +1,1024 @@ +using System.IO.Compression; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SubathonManager.Core.Enums; +using SubathonManager.Core.Models; +using SubathonManager.Data; +using SubathonManager.Tests.Utility; +// ReSharper disable NullableWarningSuppressionIsUsed + +namespace SubathonManager.Tests.DataUnitTests; + +[Collection("ProviderOverrideTests")] +public class OverlayPorterTests : IDisposable +{ + private readonly List _tempDirs = new(); + + private static IDbContextFactory SetupServices() + { + var dbName = Guid.NewGuid().ToString(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDbContextFactory(o => o.UseInMemoryDatabase(dbName)); + services.AddSingleton(MockConfig.MakeMockConfig(new Dictionary<(string, string), string> + { + [("Server", "Port")] = "14045" + })); + AppServices.Provider = services.BuildServiceProvider(); + return AppServices.Provider.GetRequiredService>(); + } + + private string MakeTempWidget(string folderName, string htmlFileName = "widget.html") + { + var dir = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", Guid.NewGuid().ToString(), folderName); + Directory.CreateDirectory(dir); + _tempDirs.Add(Path.GetDirectoryName(dir)!); + var htmlPath = Path.Combine(dir, htmlFileName); + File.WriteAllText(htmlPath, "test"); + return htmlPath; + } + + private string MakeSmoFile(object manifest, Dictionary? extraFiles = null) + { + var dir = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(dir); + _tempDirs.Add(dir); + var smoPath = Path.Combine(dir, "test.smo"); + + using var zip = ZipFile.Open(smoPath, ZipArchiveMode.Create); + + var manifestJson = JsonSerializer.Serialize(manifest, new JsonSerializerOptions + { + WriteIndented = true, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } + }); + var entry = zip.CreateEntry("overlay.json"); + using (var s = entry.Open()) + using (var w = new StreamWriter(s)) + w.Write(manifestJson); + + if (extraFiles != null) + { + foreach (var (zipPath, content) in extraFiles) + { + var e = zip.CreateEntry(zipPath); + using var s = e.Open(); + using var w = new StreamWriter(s); + w.Write(content); + } + } + + return smoPath; + } + + private string MakeTempExtractRoot() + { + var dir = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", Guid.NewGuid().ToString(), "imports"); + Directory.CreateDirectory(dir); + _tempDirs.Add(dir); + return dir; + } + + public void Dispose() + { + AppServices.Provider = null!; + foreach (var dir in _tempDirs) + { + try { Directory.Delete(dir, recursive: true); } catch { /* */ } + } + } + + [Fact] + public void GetZipWidgetRoots_EmptyList_ReturnsEmpty() + { + var result = OverlayPorter.GetZipWidgetRoots(new List()); + Assert.Empty(result); + } + + [Fact] + public void GetZipWidgetRoots_SinglePath_AlwaysHasHash() + { + var result = OverlayPorter.GetZipWidgetRoots(new List + { + @"C:\stream\widgets\timer" + }); + + Assert.Single(result); + var parts = result[0].Split('/'); + Assert.Equal("widgets", parts[0]); + Assert.Equal("timer", parts[^1]); + Assert.True(parts.Length >= 3, $"Expected at least 3 parts but got: {result[0]}"); + } + + [Fact] + public void GetZipWidgetRoots_SameParent_SharesHash() + { + var paths = new List + { + @"C:\stream\widgets\timer", + @"C:\stream\widgets\alerts" + }; + var result = OverlayPorter.GetZipWidgetRoots(paths); + + Assert.Equal(2, result.Count); + + var timerParts = result[0].Split('/'); + var alertsParts = result[1].Split('/'); + + Assert.Equal("widgets", timerParts[0]); + Assert.Equal("widgets", alertsParts[0]); + Assert.Equal(timerParts[1], alertsParts[1]); + Assert.Equal("timer", timerParts[^1]); + Assert.Equal("alerts", alertsParts[^1]); + } + + [Fact] + public void GetZipWidgetRoots_DifferentDrives_GetDifferentHashs() + { + var paths = new List + { + @"C:\Path\To\steampunk", + @"G:\My\Path\steampunk" + }; + var result = OverlayPorter.GetZipWidgetRoots(paths); + + Assert.Equal(2, result.Count); + var cParts = result[0].Split('/'); + var gParts = result[1].Split('/'); + + Assert.NotEqual(cParts[1], gParts[1]); + Assert.Equal("steampunk", cParts[^1]); + Assert.Equal("steampunk", gParts[^1]); + } + + [Fact] + public void GetZipWidgetRoots_SharedPrefixWithDifferentLeafs_SameParentDifferentLeafs() + { + var paths = new List + { + @"G:\My\Path\steampunk", + @"G:\My\Path\other" + }; + var result = OverlayPorter.GetZipWidgetRoots(paths); + + var p1 = result[0].Split('/'); + var p2 = result[1].Split('/'); + + Assert.Equal(p1[1], p2[1]); + Assert.Equal("steampunk", p1[^1]); + Assert.Equal("other", p2[^1]); + } + + [Fact] + public void GetZipWidgetRoots_ThreePaths_TwoSameDriveOneDifferent() + { + var paths = new List + { + @"C:\Path\To\steampunk", + @"G:\My\Path\steampunk", + @"G:\My\Path\other" + }; + var result = OverlayPorter.GetZipWidgetRoots(paths); + + Assert.Equal(3, result.Count); + var cBucket = result[0].Split('/')[1]; + var g1Bucket = result[1].Split('/')[1]; + var g2Bucket = result[2].Split('/')[1]; + + Assert.NotEqual(cBucket, g1Bucket); + Assert.Equal(g1Bucket, g2Bucket); + } + + [Fact] + public void GetZipWidgetRoots_AlwaysStartsWithWidgets() + { + var paths = new List { @"C:\foo\bar\baz", @"D:\qux\baz" }; + var result = OverlayPorter.GetZipWidgetRoots(paths); + Assert.All(result, r => Assert.StartsWith("widgets/", r)); + } + + [Fact] + public void GetZipWidgetRoots_LeafIsAlwaysLiteralNotHashed() + { + var paths = new List { @"C:\stream\my_timer_widget" }; + var result = OverlayPorter.GetZipWidgetRoots(paths); + Assert.EndsWith("/my_timer_widget", result[0]); + } + + [Fact] + public void GetZipWidgetRoots_ResultCountMatchesInputCount() + { + var paths = Enumerable.Range(0, 5) + .Select(i => $@"C:\widgets\widget{i}") + .ToList(); + var result = OverlayPorter.GetZipWidgetRoots(paths); + Assert.Equal(paths.Count, result.Count); + } + + [Fact] + public async Task ExportRouteAsync_CreatesZipFile() + { + SetupServices(); + var htmlPath = MakeTempWidget("mytimer"); + var route = new Route { Name = "Test Route", Width = 1920, Height = 1080 }; + var widget = new Widget("Timer", htmlPath) { RouteId = route.Id }; + route.Widgets.Add(widget); + + var outDir = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(outDir); + _tempDirs.Add(outDir); + var outPath = Path.Combine(outDir, "test.smo"); + + await OverlayPorter.ExportRouteAsync(route, outPath, "My Export"); + + Assert.True(File.Exists(outPath)); + } + + [Fact] + public async Task ExportRouteAsync_ZipContainsManifest() + { + SetupServices(); + var htmlPath = MakeTempWidget("mytimer"); + var route = new Route { Name = "Test", Width = 1920, Height = 1080 }; + route.Widgets.Add(new Widget("Timer", htmlPath) { RouteId = route.Id }); + + var outPath = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", + Guid.NewGuid().ToString(), "out.smo"); + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + _tempDirs.Add(Path.GetDirectoryName(outPath)!); + + await OverlayPorter.ExportRouteAsync(route, outPath, "Export"); + + using var zip = ZipFile.OpenRead(outPath); + Assert.NotNull(zip.GetEntry("overlay.json")); + } + + [Fact] + public async Task ExportRouteAsync_ManifestContainsWidgetHtmlPath() + { + SetupServices(); + var htmlPath = MakeTempWidget("mytimer"); + var route = new Route { Name = "Test", Width = 1920, Height = 1080 }; + route.Widgets.Add(new Widget("Timer", htmlPath) { RouteId = route.Id }); + + var outPath = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", + Guid.NewGuid().ToString(), "out.smo"); + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + _tempDirs.Add(Path.GetDirectoryName(outPath)!); + + await OverlayPorter.ExportRouteAsync(route, outPath, "Export"); + + using var zip = ZipFile.OpenRead(outPath); + using var ms = new MemoryStream(); + zip.GetEntry("overlay.json")!.Open().CopyTo(ms); + var doc = JsonDocument.Parse(ms.ToArray()); + var widgets = doc.RootElement.GetProperty("widgets").EnumerateArray().ToList(); + + Assert.Single(widgets); + var htmlZipPath = widgets[0].GetProperty("htmlPath").GetString()!; + Assert.EndsWith("/widget.html", htmlZipPath); + Assert.StartsWith("widgets/", htmlZipPath); + } + + [Fact] + public async Task ExportRouteAsync_ManifestUsesExportName() + { + SetupServices(); + var htmlPath = MakeTempWidget("mytimer"); + var route = new Route { Name = "Original Name", Width = 1920, Height = 1080 }; + route.Widgets.Add(new Widget("Timer", htmlPath) { RouteId = route.Id }); + + var outPath = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", + Guid.NewGuid().ToString(), "out.smo"); + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + _tempDirs.Add(Path.GetDirectoryName(outPath)!); + + await OverlayPorter.ExportRouteAsync(route, outPath, "Custom Export Name"); + + using var zip = ZipFile.OpenRead(outPath); + using var ms = new MemoryStream(); + zip.GetEntry("overlay.json")!.Open().CopyTo(ms); + var doc = JsonDocument.Parse(ms.ToArray()); + var name = doc.RootElement.GetProperty("route").GetProperty("name").GetString(); + Assert.Equal("Custom Export Name", name); + } + + [Fact] + public async Task ExportRouteAsync_ExcludedEntries_NotInZip() + { + SetupServices(); + var htmlPath = MakeTempWidget("mytimer"); + var assetPath = Path.Combine(Path.GetDirectoryName(htmlPath)!, "bg.png"); + await File.WriteAllBytesAsync(assetPath, [0x89, 0x50]); + + var route = new Route { Name = "Test", Width = 1920, Height = 1080 }; + route.Widgets.Add(new Widget("Timer", htmlPath) { RouteId = route.Id }); + + var outPath = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", + Guid.NewGuid().ToString(), "out.smo"); + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + _tempDirs.Add(Path.GetDirectoryName(outPath)!); + + var zipRoots = OverlayPorter.GetZipWidgetRoots(new List + { Path.GetDirectoryName(htmlPath)! }); + var bgZipEntry = $"{zipRoots[0]}/bg.png"; + + var excluded = new HashSet(StringComparer.OrdinalIgnoreCase) { bgZipEntry }; + await OverlayPorter.ExportRouteAsync(route, outPath, "Export", excluded); + + using var zip = ZipFile.OpenRead(outPath); + Assert.Null(zip.GetEntry(bgZipEntry)); + Assert.NotNull(zip.GetEntry($"{zipRoots[0]}/widget.html")); + } + + [Fact] + public async Task ExportRouteAsync_WidgetFilesIncludedInZip() + { + SetupServices(); + var htmlPath = MakeTempWidget("mytimer"); + var extraFile = Path.Combine(Path.GetDirectoryName(htmlPath)!, "style.css"); + await File.WriteAllTextAsync(extraFile, "body { color: red; }"); + + var route = new Route { Name = "Test", Width = 1920, Height = 1080 }; + route.Widgets.Add(new Widget("Timer", htmlPath) { RouteId = route.Id }); + + var outPath = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", + Guid.NewGuid().ToString(), "out.smo"); + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + _tempDirs.Add(Path.GetDirectoryName(outPath)!); + + await OverlayPorter.ExportRouteAsync(route, outPath, "Export"); + + using var zip = ZipFile.OpenRead(outPath); + var entries = zip.Entries.Select(e => e.FullName).ToList(); + Assert.Contains(entries, e => e.EndsWith("widget.html")); + Assert.Contains(entries, e => e.EndsWith("style.css")); + } + + [Fact] + public async Task ImportRouteAsync_MissingManifest_ReturnsFailed() + { + var factory = SetupServices(); + var dir = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(dir); + _tempDirs.Add(dir); + + var smoPath = Path.Combine(dir, "empty.smo"); + using (var zip = ZipFile.Open(smoPath, ZipArchiveMode.Create)) + zip.CreateEntry("dummy.txt"); + + var result = await OverlayPorter.ImportRouteAsync(smoPath, MakeTempExtractRoot(), factory); + + Assert.True(result.Failed); + Assert.False(string.IsNullOrWhiteSpace(result.FailReason)); + } + + [Fact] + public async Task ImportRouteAsync_NewRoute_RouteIsNew() + { + var factory = SetupServices(); + var htmlZipPath = "widgets/abc1/mytimer/widget.html"; + var manifest = new + { + version = 1, + route = new { name = "My Overlay", resolution = new { width = 1920, height = 1080 } }, + widget_folder_map = new { }, + widgets = new[] + { + new + { + name = "Timer", htmlPath = htmlZipPath, + position = new { x = 0f, y = 0f, z = 0 }, + size = new { width = 400, height = 300 }, + scale = new { x = 1f, y = 1f }, + visibility = true, docsUrl = (string?)null, + cssVariables = Array.Empty(), + jsVariables = Array.Empty() + } + } + }; + var smo = MakeSmoFile(manifest, new Dictionary + { + [htmlZipPath] = "" + }); + + var result = await OverlayPorter.ImportRouteAsync(smo, MakeTempExtractRoot(), factory); + + Assert.False(result.Failed); + Assert.True(result.RouteIsNew); + Assert.NotNull(result.Route); + Assert.Equal("My Overlay", result.Route!.Name); + } + + [Fact] + public async Task ImportRouteAsync_NewRoute_WidgetInNewWidgets() + { + var factory = SetupServices(); + var htmlZipPath = "widgets/abc1/mytimer/widget.html"; + var manifest = new + { + version = 1, + route = new { name = "Overlay", resolution = new { width = 1920, height = 1080 } }, + widget_folder_map = new { }, + widgets = new[] + { + new + { + name = "MyWidget", htmlPath = htmlZipPath, + position = new { x = 10f, y = 20f, z = 1 }, + size = new { width = 500, height = 400 }, + scale = new { x = 1f, y = 1f }, + visibility = true, docsUrl = (string?)null, + cssVariables = Array.Empty(), + jsVariables = Array.Empty() + } + } + }; + var smo = MakeSmoFile(manifest, new Dictionary { [htmlZipPath] = "" }); + var result = await OverlayPorter.ImportRouteAsync(smo, MakeTempExtractRoot(), factory); + + Assert.Single(result.NewWidgets); + var w = result.NewWidgets[0]; + Assert.Equal("MyWidget", w.Name); + Assert.Equal(10f, w.X); + Assert.Equal(20f, w.Y); + Assert.Equal(1, w.Z); + Assert.Equal(500, w.Width); + Assert.Equal(400, w.Height); + } + + [Fact] + public async Task ImportRouteAsync_NewRoute_WidgetHtmlPathPointsInsideExtractDir() + { + var factory = SetupServices(); + var htmlZipPath = "widgets/abc1/mytimer/widget.html"; + var manifest = new + { + version = 1, + route = new { name = "R", resolution = new { width = 1920, height = 1080 } }, + widget_folder_map = new { }, + widgets = new[] + { + new + { + name = "W", htmlPath = htmlZipPath, + position = new { x = 0f, y = 0f, z = 0 }, + size = new { width = 400, height = 300 }, + scale = new { x = 1f, y = 1f }, + visibility = true, docsUrl = (string?)null, + cssVariables = Array.Empty(), + jsVariables = Array.Empty() + } + } + }; + var extractRoot = MakeTempExtractRoot(); + var smo = MakeSmoFile(manifest, new Dictionary { [htmlZipPath] = "" }); + var result = await OverlayPorter.ImportRouteAsync(smo, extractRoot, factory); + + var widget = result.NewWidgets[0]; + Assert.StartsWith(extractRoot, widget.HtmlPath, StringComparison.OrdinalIgnoreCase); + Assert.EndsWith("widget.html", widget.HtmlPath, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task ImportRouteAsync_ReImport_RouteNotNew_NoDuplicateWidgets() + { + var factory = SetupServices(); + var htmlZipPath = "widgets/abc1/mytimer/widget.html"; + var extractRoot = MakeTempExtractRoot(); + var manifest = new + { + version = 1, + route = new { name = "R", resolution = new { width = 1920, height = 1080 } }, + widget_folder_map = new { }, + widgets = new[] + { + new + { + name = "W", htmlPath = htmlZipPath, + position = new { x = 0f, y = 0f, z = 0 }, + size = new { width = 400, height = 300 }, + scale = new { x = 1f, y = 1f }, + visibility = true, docsUrl = (string?)null, + cssVariables = Array.Empty(), + jsVariables = Array.Empty() + } + } + }; + var smo = MakeSmoFile(manifest, new Dictionary { [htmlZipPath] = "" }); + + //seed + var first = await OverlayPorter.ImportRouteAsync(smo, extractRoot, factory); + Assert.True(first.RouteIsNew); + await using (var db = await factory.CreateDbContextAsync()) + { + db.Routes.Add(first.Route!); + db.Widgets.AddRange(first.NewWidgets); + await db.SaveChangesAsync(); + } + + var second = await OverlayPorter.ImportRouteAsync(smo, extractRoot, factory); + + Assert.False(second.RouteIsNew); + Assert.Null(second.Route); + Assert.Empty(second.NewWidgets); + Assert.False(second.HasAnythingNew); + } + + [Fact] + public async Task ImportRouteAsync_ReImport_NewVariable_AddedToExistingWidget() + { + var factory = SetupServices(); + var htmlZipPath = "widgets/abc1/mytimer/widget.html"; + var extractRoot = MakeTempExtractRoot(); + + object MakeManifest(object[] jsVars) => new + { + version = 1, + route = new { name = "R", resolution = new { width = 1920, height = 1080 } }, + widget_folder_map = new { }, + widgets = new[] + { + new + { + name = "W", htmlPath = htmlZipPath, + position = new { x = 0f, y = 0f, z = 0 }, + size = new { width = 400, height = 300 }, + scale = new { x = 1f, y = 1f }, + visibility = true, docsUrl = (string?)null, + cssVariables = Array.Empty(), + jsVariables = jsVars + } + } + }; + + var smo1 = MakeSmoFile(MakeManifest([]), new Dictionary { [htmlZipPath] = "" }); + var first = await OverlayPorter.ImportRouteAsync(smo1, extractRoot, factory); + await using (var db = await factory.CreateDbContextAsync()) + { + db.Routes.Add(first.Route!); + db.Widgets.AddRange(first.NewWidgets); + await db.SaveChangesAsync(); + } + + var smo2 = MakeSmoFile(MakeManifest([ + new { name = "myVar", value = "hello", type = WidgetVariableType.String } + ]), new Dictionary { [htmlZipPath] = "" }); + var second = await OverlayPorter.ImportRouteAsync(smo2, extractRoot, factory); + + Assert.False(second.RouteIsNew); + Assert.Empty(second.NewWidgets); + Assert.Single(second.NewJsVariables); + Assert.Equal("myVar", second.NewJsVariables[0].Name); + Assert.Equal("hello", second.NewJsVariables[0].Value); + } + + [Fact] + public async Task ImportRouteAsync_CssVariables_ImportedOnNewWidget() + { + var factory = SetupServices(); + var htmlZipPath = "widgets/abc1/mytimer/widget.html"; + var manifest = new + { + version = 1, + route = new { name = "R", resolution = new { width = 1920, height = 1080 } }, + widget_folder_map = new { }, + widgets = new[] + { + new + { + name = "W", htmlPath = htmlZipPath, + position = new { x = 0f, y = 0f, z = 0 }, + size = new { width = 400, height = 300 }, + scale = new { x = 1f, y = 1f }, + visibility = true, docsUrl = (string?)null, + cssVariables = new[] { new { name = "primary-color", value = "#ff0000" } }, + jsVariables = Array.Empty() + } + } + }; + var smo = MakeSmoFile(manifest, new Dictionary { [htmlZipPath] = "" }); + var result = await OverlayPorter.ImportRouteAsync(smo, MakeTempExtractRoot(), factory); + + Assert.Single(result.NewWidgets); + var cssVars = result.NewWidgets[0].CssVariables; + Assert.Single(cssVars); + Assert.Equal("primary-color", cssVars[0].Name); + Assert.Equal("#ff0000", cssVars[0].Value); + } + + [Fact] + public async Task ImportRouteAsync_RelativeFileVariable_ResolvedToAbsoluteInsideExtractDir() + { + var factory = SetupServices(); + var htmlZipPath = "widgets/abc1/mytimer/widget.html"; + var assetZipPath = "widgets/abc1/mytimer/bg.png"; + var manifest = new + { + version = 1, + route = new { name = "R", resolution = new { width = 1920, height = 1080 } }, + widget_folder_map = new { }, + widgets = new[] + { + new + { + name = "W", htmlPath = htmlZipPath, + position = new { x = 0f, y = 0f, z = 0 }, + size = new { width = 400, height = 300 }, + scale = new { x = 1f, y = 1f }, + visibility = true, docsUrl = (string?)null, + cssVariables = Array.Empty(), + jsVariables = new[] + { + new { name = "bgImage", value = "./bg.png", type = WidgetVariableType.ImageFile } + } + } + } + }; + var extractRoot = MakeTempExtractRoot(); + var smo = MakeSmoFile(manifest, new Dictionary + { + [htmlZipPath] = "", + [assetZipPath] = "fake png bytes" + }); + var result = await OverlayPorter.ImportRouteAsync(smo, extractRoot, factory); + + var jsVar = result.NewWidgets[0].JsVariables.FirstOrDefault(v => v.Name == "bgImage"); + Assert.NotNull(jsVar); + Assert.StartsWith("./", jsVar!.Value); + Assert.Contains("bg.png", jsVar.Value); + } + + [Fact] + public async Task ImportRouteAsync_RouteResolution_CorrectWidthHeight() + { + var factory = SetupServices(); + var htmlZipPath = "widgets/abc1/mytimer/widget.html"; + var manifest = new + { + version = 1, + route = new { name = "R", resolution = new { width = 2560, height = 1440 } }, + widget_folder_map = new { }, + widgets = new[] + { + new + { + name = "W", htmlPath = htmlZipPath, + position = new { x = 0f, y = 0f, z = 0 }, + size = new { width = 400, height = 300 }, + scale = new { x = 1f, y = 1f }, + visibility = true, docsUrl = (string?)null, + cssVariables = Array.Empty(), + jsVariables = Array.Empty() + } + } + }; + var smo = MakeSmoFile(manifest, new Dictionary { [htmlZipPath] = "" }); + var result = await OverlayPorter.ImportRouteAsync(smo, MakeTempExtractRoot(), factory); + + Assert.NotNull(result.Route); + Assert.Equal(2560, result.Route!.Width); + Assert.Equal(1440, result.Route.Height); + } + + [Fact] + public async Task RoundTrip_ExportThenImport_WidgetHtmlPathResolvesCorrectly() + { + var factory = SetupServices(); + var htmlPath = MakeTempWidget("roundtrip"); + var route = new Route { Name = "RT", Width = 1920, Height = 1080 }; + var widget = new Widget("RT Widget", htmlPath) { RouteId = route.Id }; + route.Widgets.Add(widget); + + var outDir = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(outDir); + _tempDirs.Add(outDir); + var smoPath = Path.Combine(outDir, "rt.smo"); + + await OverlayPorter.ExportRouteAsync(route, smoPath, "RT Export"); + + var extractRoot = MakeTempExtractRoot(); + var result = await OverlayPorter.ImportRouteAsync(smoPath, extractRoot, factory); + + Assert.False(result.Failed); + Assert.True(result.RouteIsNew); + Assert.Single(result.NewWidgets); + + var imported = result.NewWidgets[0]; + Assert.True(File.Exists(imported.HtmlPath), $"HtmlPath should exist on disk: {imported.HtmlPath}"); + Assert.EndsWith("widget.html", imported.HtmlPath, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task RoundTrip_ExportThenImport_WidgetPropertiesPreserved() + { + var factory = SetupServices(); + var htmlPath = MakeTempWidget("props"); + var route = new Route { Name = "Props Test", Width = 1280, Height = 720 }; + var widget = new Widget("Props Widget", htmlPath) + { + RouteId = route.Id, + X = 100f, Y = 200f, Z = 3, + Width = 640, Height = 480, + ScaleX = 1.5f, ScaleY = 2f, + Visibility = false + }; + route.Widgets.Add(widget); + + var outDir = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", Guid.NewGuid().ToString()); + Directory.CreateDirectory(outDir); + _tempDirs.Add(outDir); + var smoPath = Path.Combine(outDir, "props.smo"); + await OverlayPorter.ExportRouteAsync(route, smoPath, "Props"); + + var result = await OverlayPorter.ImportRouteAsync(smoPath, MakeTempExtractRoot(), factory); + var imported = result.NewWidgets[0]; + + Assert.Equal(100f, imported.X); + Assert.Equal(200f, imported.Y); + Assert.Equal(3, imported.Z); + Assert.Equal(640, imported.Width); + Assert.Equal(480, imported.Height); + Assert.Equal(1.5f, imported.ScaleX); + Assert.Equal(2f, imported.ScaleY); + Assert.False(imported.Visibility); + } + + [Fact] + public async Task ExportRouteAsync_AbsoluteFileVariable_RewrittenToRelativeInManifest() + { + SetupServices(); + var htmlPath = MakeTempWidget("timerfx"); + + var extDir = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", Guid.NewGuid().ToString(), "sounds"); + Directory.CreateDirectory(extDir); + _tempDirs.Add(Path.GetDirectoryName(extDir)!); + var soundFile = Path.Combine(extDir, "alert.mp3"); + await File.WriteAllBytesAsync(soundFile, [0x49, 0x44, 0x33]); + + var route = new Route { Name = "Test", Width = 1920, Height = 1080 }; + var widget = new Widget("FX", htmlPath) + { + RouteId = route.Id, + JsVariables = new List + { + new JsVariable + { + Name = "alertSound", + Value = soundFile, // absolute path + Type = WidgetVariableType.SoundFile, + WidgetId = Guid.NewGuid() + } + } + }; + route.Widgets.Add(widget); + + var outPath = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", + Guid.NewGuid().ToString(), "fx.smo"); + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + _tempDirs.Add(Path.GetDirectoryName(outPath)!); + + await OverlayPorter.ExportRouteAsync(route, outPath, "FX"); + + using var zip = ZipFile.OpenRead(outPath); + using var ms = new MemoryStream(); + await zip.GetEntry("overlay.json")!.Open().CopyToAsync(ms); + var doc = JsonDocument.Parse(ms.ToArray()); + var jsVars = doc.RootElement + .GetProperty("widgets")[0] + .GetProperty("jsVariables") + .EnumerateArray() + .ToList(); + + var alertVar = jsVars.FirstOrDefault(v => v.GetProperty("name").GetString() == "alertSound"); + Assert.True(alertVar.ValueKind != JsonValueKind.Undefined, "alertSound variable not found in manifest"); + var rewrittenValue = alertVar.GetProperty("value").GetString()!; + Assert.StartsWith("./", rewrittenValue); + Assert.Contains("alert.mp3", rewrittenValue); + } + + [Fact] + public async Task ExportRouteAsync_AbsoluteFileVariable_FileIsInExternalFolderInZip() + { + SetupServices(); + var htmlPath = MakeTempWidget("timerfx2"); + + var extDir = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", Guid.NewGuid().ToString(), "assets"); + Directory.CreateDirectory(extDir); + _tempDirs.Add(Path.GetDirectoryName(extDir)!); + var imageFile = Path.Combine(extDir, "logo.png"); + await File.WriteAllBytesAsync(imageFile, [0x89, 0x50]); + + var route = new Route { Name = "Test", Width = 1920, Height = 1080 }; + var widget = new Widget("FX2", htmlPath) + { + RouteId = route.Id, + JsVariables = new List + { + new JsVariable + { + Name = "logo", + Value = imageFile, + Type = WidgetVariableType.ImageFile, + WidgetId = Guid.NewGuid() + } + } + }; + route.Widgets.Add(widget); + + var outPath = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", + Guid.NewGuid().ToString(), "fx2.smo"); + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + _tempDirs.Add(Path.GetDirectoryName(outPath)!); + + await OverlayPorter.ExportRouteAsync(route, outPath, "FX2"); + + using var zip = ZipFile.OpenRead(outPath); + var entries = zip.Entries.Select(e => e.FullName).ToList(); + Assert.Contains(entries, e => e.Contains("_external") && e.EndsWith("logo.png")); + } + + [Fact] + public async Task ExportRouteAsync_FolderPathVariable_AllFilesInExternalSubfolder() + { + SetupServices(); + var htmlPath = MakeTempWidget("timerFolder"); + + var extDir = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", Guid.NewGuid().ToString(), "media"); + Directory.CreateDirectory(extDir); + _tempDirs.Add(Path.GetDirectoryName(extDir)!); + await File.WriteAllTextAsync(Path.Combine(extDir, "a.mp4"), "fake"); + await File.WriteAllTextAsync(Path.Combine(extDir, "b.mp4"), "fake"); + + var route = new Route { Name = "Test", Width = 1920, Height = 1080 }; + var widget = new Widget("Folder", htmlPath) + { + RouteId = route.Id, + JsVariables = + [ + new JsVariable + { + Name = "videoFolder", + Value = extDir, + Type = WidgetVariableType.FolderPath, + WidgetId = Guid.NewGuid() + } + ] + }; + route.Widgets.Add(widget); + + var outPath = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", + Guid.NewGuid().ToString(), "folder.smo"); + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + _tempDirs.Add(Path.GetDirectoryName(outPath)!); + + await OverlayPorter.ExportRouteAsync(route, outPath, "Folder"); + + using var zip = ZipFile.OpenRead(outPath); + var entries = zip.Entries.Select(e => e.FullName).ToList(); + + Assert.Contains(entries, e => e.Contains("_external/videoFolder") && e.EndsWith("a.mp4")); + Assert.Contains(entries, e => e.Contains("_external/videoFolder") && e.EndsWith("b.mp4")); + using var ms = new MemoryStream(); + await zip.GetEntry("overlay.json")!.Open().CopyToAsync(ms); + var doc = JsonDocument.Parse(ms.ToArray()); + var folderVar = doc.RootElement + .GetProperty("widgets")[0] + .GetProperty("jsVariables") + .EnumerateArray() + .First(v => v.GetProperty("name").GetString() == "videoFolder"); + Assert.StartsWith("./", folderVar.GetProperty("value").GetString()!); + } + + [Fact] + public async Task ExportRouteAsync_RelativeFileVariable_NotMovedToExternal() + { + SetupServices(); + var htmlPath = MakeTempWidget("relvar"); + var assetPath = Path.Combine(Path.GetDirectoryName(htmlPath)!, "asset.png"); + await File.WriteAllBytesAsync(assetPath, [0x89, 0x50]); + + var route = new Route { Name = "Test", Width = 1920, Height = 1080 }; + var widget = new Widget("Rel", htmlPath) + { + RouteId = route.Id, + JsVariables = + [ + new JsVariable + { + Name = "img", + Value = "./asset.png", + Type = WidgetVariableType.ImageFile, + WidgetId = Guid.NewGuid() + } + ] + }; + route.Widgets.Add(widget); + + var outPath = Path.Combine(Path.GetTempPath(), "OverlayPorterTests", + Guid.NewGuid().ToString(), "rel.smo"); + Directory.CreateDirectory(Path.GetDirectoryName(outPath)!); + _tempDirs.Add(Path.GetDirectoryName(outPath)!); + + await OverlayPorter.ExportRouteAsync(route, outPath, "Rel"); + + using var zip = ZipFile.OpenRead(outPath); + var entries = zip.Entries.Select(e => e.FullName).ToList(); + + Assert.Contains(entries, e => e.EndsWith("asset.png") && !e.Contains("_external")); + Assert.DoesNotContain(entries, e => e.Contains("_external") && e.EndsWith("asset.png")); + } + + [Fact] + public async Task ImportRouteAsync_ReImport_NewCssVariable_AddedToExistingWidget() + { + var factory = SetupServices(); + var htmlZipPath = "widgets/abc1/mytimer/widget.html"; + var extractRoot = MakeTempExtractRoot(); + + object MakeManifest(object[] cssVars) => new + { + version = 1, + route = new { name = "R", resolution = new { width = 1920, height = 1080 } }, + widget_folder_map = new { }, + widgets = new[] + { + new + { + name = "W", htmlPath = htmlZipPath, + position = new { x = 0f, y = 0f, z = 0 }, + size = new { width = 400, height = 300 }, + scale = new { x = 1f, y = 1f }, + visibility = true, docsUrl = (string?)null, + cssVariables = cssVars, + jsVariables = Array.Empty() + } + } + }; + + var smo1 = MakeSmoFile(MakeManifest([]), new Dictionary { [htmlZipPath] = "" }); + var first = await OverlayPorter.ImportRouteAsync(smo1, extractRoot, factory); + await using (var db = await factory.CreateDbContextAsync()) + { + db.Routes.Add(first.Route!); + db.Widgets.AddRange(first.NewWidgets); + await db.SaveChangesAsync(); + } + + var smo2 = MakeSmoFile(MakeManifest([ + new { name = "accent-color", value = "#00ff00" } + ]), new Dictionary { [htmlZipPath] = "" }); + var second = await OverlayPorter.ImportRouteAsync(smo2, extractRoot, factory); + + Assert.False(second.RouteIsNew); + Assert.Empty(second.NewWidgets); + Assert.Single(second.NewCssVariables); + Assert.Equal("accent-color", second.NewCssVariables[0].Name); + Assert.Equal("#00ff00", second.NewCssVariables[0].Value); + } + + [Fact] + public async Task ImportRouteAsync_ReImport_ExistingCssVariable_NotDuplicated() + { + var factory = SetupServices(); + var htmlZipPath = "widgets/abc1/mytimer/widget.html"; + var extractRoot = MakeTempExtractRoot(); + + var cssVars = new object[] { new { name = "bg-color", value = "#ffffff" } }; + object manifest = new + { + version = 1, + route = new { name = "R", resolution = new { width = 1920, height = 1080 } }, + widget_folder_map = new { }, + widgets = new[] + { + new + { + name = "W", htmlPath = htmlZipPath, + position = new { x = 0f, y = 0f, z = 0 }, + size = new { width = 400, height = 300 }, + scale = new { x = 1f, y = 1f }, + visibility = true, docsUrl = (string?)null, + cssVariables = cssVars, + jsVariables = Array.Empty() + } + } + }; + var smo = MakeSmoFile(manifest, new Dictionary { [htmlZipPath] = "" }); + + var first = await OverlayPorter.ImportRouteAsync(smo, extractRoot, factory); + await using (var db = await factory.CreateDbContextAsync()) + { + db.Routes.Add(first.Route!); + db.Widgets.AddRange(first.NewWidgets); + db.CssVariables.AddRange(first.NewWidgets.SelectMany(w => w.CssVariables)); + await db.SaveChangesAsync(); + } + + var second = await OverlayPorter.ImportRouteAsync(smo, extractRoot, factory); + + Assert.Empty(second.NewCssVariables); + Assert.False(second.HasAnythingNew); + } +} \ No newline at end of file diff --git a/SubathonManager.Tests/IntegrationUnitTests/BlerpChatServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/BlerpChatServiceTests.cs index 4fe4d22..3943486 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/BlerpChatServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/BlerpChatServiceTests.cs @@ -1,38 +1,23 @@ using SubathonManager.Core.Enums; -using SubathonManager.Core.Events; using SubathonManager.Core.Models; using SubathonManager.Integration; -using System.Reflection; +using SubathonManager.Tests.Utility; + namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("IntegrationEventTests")] +[Collection("SharedEventBusTests")] public class BlerpChatServiceTests { - private static SubathonEvent CaptureEvent(Action trigger) - { - SubathonEvent? captured = null; - void EventCaptureHandler(SubathonEvent e) => captured = e; - - SubathonEvents.SubathonEventCreated += EventCaptureHandler; - try - { - trigger(); - return captured!; - } - finally - { - SubathonEvents.SubathonEventCreated -= EventCaptureHandler; - } - } + private static SubathonEvent? CaptureEvent(Action trigger) => + EventUtil.SubathonEventCapture.CaptureRequired(trigger); [Fact] public void SimulateBlerpBits_RaiseEvent() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); + var ev = CaptureEvent(() => BlerpChatService.SimulateBlerpMessage(500, "bits")); + Assert.NotNull(ev); Assert.Equal(SubathonEventType.BlerpBits, ev.EventType); Assert.Equal("bits", ev.Currency); Assert.Equal("500", ev.Value); @@ -42,9 +27,7 @@ public void SimulateBlerpBits_RaiseEvent() [Fact] public void SimulateBlerpBits_NoEvent() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); + var ev = CaptureEvent(() => BlerpChatService.SimulateBlerpMessage(500, "something")); Assert.Null(ev); } @@ -52,11 +35,10 @@ public void SimulateBlerpBits_NoEvent() [Fact] public void SimulateBlerpBeets_RaiseEvent() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); + var ev = CaptureEvent(() => BlerpChatService.SimulateBlerpMessage(500, "beets")); + Assert.NotNull(ev); Assert.Equal(SubathonEventType.BlerpBeets, ev.EventType); Assert.Equal("beets", ev.Currency); Assert.Equal("500", ev.Value); @@ -66,11 +48,10 @@ public void SimulateBlerpBeets_RaiseEvent() [Fact] public void BlerpBeets_RaiseEvent() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); + var ev = CaptureEvent(() => BlerpChatService.ParseMessage("SomeGuy used 500 beets to play XYZ", SubathonEventSource.Twitch)); + Assert.NotNull(ev); Assert.Equal(SubathonEventType.BlerpBeets, ev.EventType); Assert.Equal("beets", ev.Currency); Assert.Equal("500", ev.Value); @@ -80,11 +61,10 @@ public void BlerpBeets_RaiseEvent() [Fact] public void BlerpBits_RaiseEvent() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); + var ev = CaptureEvent(() => BlerpChatService.ParseMessage("SomeGuy used 500 bits to play XYZ", SubathonEventSource.Twitch)); + Assert.NotNull(ev); Assert.Equal(SubathonEventType.BlerpBits, ev.EventType); Assert.Equal("bits", ev.Currency); Assert.Equal("500", ev.Value); @@ -94,9 +74,7 @@ public void BlerpBits_RaiseEvent() [Fact] public void BlerpBits_DoesNotRaiseEvent() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); + var ev = CaptureEvent(() => BlerpChatService.ParseMessage("SomeGuy used 400 currency to play XYZ", SubathonEventSource.Twitch)); Assert.Null(ev); diff --git a/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs index e1d6600..0ba6750 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/ExternalEventServiceTests.cs @@ -5,20 +5,18 @@ using SubathonManager.Integration; using System.Reflection; using Moq; +using SubathonManager.Tests.Utility; + // ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("IntegrationEventTests")] +[Collection("SharedEventBusTests")] public class ExternalEventServiceTests { - public ExternalEventServiceTests() - { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - } - + private static SubathonEvent? CaptureEvent(Action trigger) => + EventUtil.SubathonEventCapture.CaptureRequired(trigger); + [Fact] public void ProcessExternalCommand_ShouldReturnFalse_WhenCommandMissing() { @@ -32,12 +30,8 @@ public void ProcessExternalCommand_ShouldReturnFalse_WhenCommandMissing() [Fact] public void ProcessExternalCommand_ShouldRaiseEvent_WhenValidCommand() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); + SubathonEvent? ev = null; - Action handler = e => ev = e; - SubathonEvents.SubathonEventCreated += handler; var json = """ { @@ -49,7 +43,9 @@ public void ProcessExternalCommand_ShouldRaiseEvent_WhenValidCommand() var data = JsonSerializer.Deserialize>(json)!; - bool result = ExternalEventService.ProcessExternalCommand(data); + + bool result = false; + ev = CaptureEvent(() => result = ExternalEventService.ProcessExternalCommand(data)); Assert.True(result); Assert.NotNull(ev); @@ -58,18 +54,13 @@ public void ProcessExternalCommand_ShouldRaiseEvent_WhenValidCommand() Assert.Equal(SubathonEventSource.External, ev.Source); Assert.Equal(SubathonCommandType.Pause, ev.Command); Assert.Equal(SubathonEventType.Command, ev.EventType); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] public void ProcessExternalCommand_ShouldRaiseEvent_WhenValidCommandWithParam() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); + SubathonEvent? ev = null; - Action handler = e => ev = e; - SubathonEvents.SubathonEventCreated += handler; var json = """ { @@ -81,7 +72,8 @@ public void ProcessExternalCommand_ShouldRaiseEvent_WhenValidCommandWithParam() var data = JsonSerializer.Deserialize>(json)!; - bool result = ExternalEventService.ProcessExternalCommand(data); + bool result = false; + ev = CaptureEvent( () => result = ExternalEventService.ProcessExternalCommand(data)); Assert.True(result); Assert.NotNull(ev); @@ -90,19 +82,12 @@ public void ProcessExternalCommand_ShouldRaiseEvent_WhenValidCommandWithParam() Assert.Equal(SubathonEventSource.External, ev.Source); Assert.Equal(SubathonCommandType.AddPoints, ev.Command); Assert.Equal(SubathonEventType.Command, ev.EventType); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] public void ProcessExternalCommand_ShouldRaiseEvent_WhenValidCommandWithParam2() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - Action handler = e => ev = e; - SubathonEvents.SubathonEventCreated += handler; - + var json = """ { "command": "SubtractTime", @@ -113,7 +98,9 @@ public void ProcessExternalCommand_ShouldRaiseEvent_WhenValidCommandWithParam2() var data = JsonSerializer.Deserialize>(json)!; - bool result = ExternalEventService.ProcessExternalCommand(data); + bool result = false; + + SubathonEvent? ev = CaptureEvent( () => result = ExternalEventService.ProcessExternalCommand(data)); Assert.True(result); Assert.NotNull(ev); @@ -122,19 +109,11 @@ public void ProcessExternalCommand_ShouldRaiseEvent_WhenValidCommandWithParam2() Assert.Equal(SubathonEventSource.External, ev.Source); Assert.Equal(SubathonCommandType.SubtractTime, ev.Command); Assert.Equal(SubathonEventType.Command, ev.EventType); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] public void ProcessExternalCommand_ShouldRaiseEvent_WhenValidCommandWithParam3() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - Action handler = e => ev = e; - SubathonEvents.SubathonEventCreated += handler; - var json = """ { "command": "SetMultiplier", @@ -145,7 +124,8 @@ public void ProcessExternalCommand_ShouldRaiseEvent_WhenValidCommandWithParam3() var data = JsonSerializer.Deserialize>(json)!; - bool result = ExternalEventService.ProcessExternalCommand(data); + bool result = false; + SubathonEvent? ev = CaptureEvent(() => result = ExternalEventService.ProcessExternalCommand(data)); Assert.True(result); Assert.NotNull(ev); @@ -154,18 +134,13 @@ public void ProcessExternalCommand_ShouldRaiseEvent_WhenValidCommandWithParam3() Assert.Equal(SubathonEventSource.External, ev.Source); Assert.Equal(SubathonCommandType.SetMultiplier, ev.Command); Assert.Equal(SubathonEventType.Command, ev.EventType); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] public void ProcessExternalCommand_ShouldNotRaiseEvent_InvalidCommand() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - Action handler = e => ev = e; - SubathonEvents.SubathonEventCreated += handler; + + var json = """ { @@ -177,23 +152,19 @@ public void ProcessExternalCommand_ShouldNotRaiseEvent_InvalidCommand() var data = JsonSerializer.Deserialize>(json)!; - bool result = ExternalEventService.ProcessExternalCommand(data); + bool result = true; + SubathonEvent? ev = CaptureEvent( () => result = ExternalEventService.ProcessExternalCommand(data)); Assert.False(result); Assert.Null(ev); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] public void ProcessExternalCommand_ShouldRaiseEvent_WhenInvalidCommandWithParam2() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - Action handler = e => ev = e; - SubathonEvents.SubathonEventCreated += handler; + + var json = """ { @@ -205,7 +176,8 @@ public void ProcessExternalCommand_ShouldRaiseEvent_WhenInvalidCommandWithParam2 var data = JsonSerializer.Deserialize>(json)!; - bool result = ExternalEventService.ProcessExternalCommand(data); + bool result = false; + SubathonEvent? ev = CaptureEvent( () => result = ExternalEventService.ProcessExternalCommand(data)); Assert.True(result); Assert.NotNull(ev); @@ -214,19 +186,11 @@ public void ProcessExternalCommand_ShouldRaiseEvent_WhenInvalidCommandWithParam2 Assert.Equal(SubathonEventSource.External, ev.Source); Assert.Equal(SubathonCommandType.StopMultiplier, ev.Command); Assert.Equal(SubathonEventType.Command, ev.EventType); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] public void ProcessExternalSub_ShouldRaiseEvent_WithDefaults() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - Action handler = e => ev = e; - SubathonEvents.SubathonEventCreated += handler; - var json = """ { "type": "ExternalSub", @@ -241,7 +205,8 @@ public void ProcessExternalSub_ShouldRaiseEvent_WithDefaults() var data = JsonSerializer.Deserialize>(json)!; - bool result = ExternalEventService.ProcessExternalSub(data); + bool result = false; + SubathonEvent? ev = CaptureEvent( () => result = ExternalEventService.ProcessExternalSub(data)); Assert.True(result); Assert.NotNull(ev); @@ -253,21 +218,12 @@ public void ProcessExternalSub_ShouldRaiseEvent_WithDefaults() Assert.Equal(SubathonEventSource.External, ev.Source); Assert.Equal(SubathonEventType.ExternalSub, ev.EventType); Assert.Equal(Guid.Parse("b3e1f7e2-1234-4a5b-9e8f-123456789abc"), ev.Id); - - SubathonEvents.SubathonEventCreated -= handler; } [Fact] public void ProcessKoFiSub_ShouldRaiseEvent_WithDefaults() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - Action handler = e => ev = e; - SubathonEvents.SubathonEventCreated += handler; - var json = """ { "type": "KoFiSub", @@ -280,7 +236,8 @@ public void ProcessKoFiSub_ShouldRaiseEvent_WithDefaults() var data = JsonSerializer.Deserialize>(json)!; - bool result = ExternalEventService.ProcessExternalSub(data); + bool result = false; + SubathonEvent? ev = CaptureEvent( () => result =ExternalEventService.ProcessExternalSub(data)); Assert.True(result); Assert.NotNull(ev); @@ -290,20 +247,11 @@ public void ProcessKoFiSub_ShouldRaiseEvent_WithDefaults() Assert.Equal(SubathonEventSource.KoFi, ev.Source); Assert.Equal(SubathonEventType.KoFiSub, ev.EventType); Assert.Equal(Guid.Parse("b3e1f7e2-1234-4a5b-9e8f-123456789abc"), ev.Id); - - SubathonEvents.SubathonEventCreated -= handler; } [Fact] public void ProcessExternalDonation_ShouldRaiseEvent_WithValidData() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - Action handler = e => ev = e; - SubathonEvents.SubathonEventCreated += handler; - var json = """ { "type": "ExternalDonation", @@ -316,7 +264,8 @@ public void ProcessExternalDonation_ShouldRaiseEvent_WithValidData() var data = JsonSerializer.Deserialize>(json)!; - bool result = ExternalEventService.ProcessExternalDonation(data); + bool result = false; + SubathonEvent? ev = CaptureEvent( () => result = ExternalEventService.ProcessExternalDonation(data)); Assert.True(result); Assert.NotNull(ev); @@ -326,20 +275,11 @@ public void ProcessExternalDonation_ShouldRaiseEvent_WithValidData() Assert.Equal(SubathonEventSource.External, ev.Source); Assert.Equal(SubathonEventType.ExternalDonation, ev.EventType); Assert.Equal(Guid.Parse("c1e2d3f4-5678-4abc-9def-987654321abc"), ev.Id); - - SubathonEvents.SubathonEventCreated -= handler; } [Fact] public void ProcessKoFiDonation_ShouldRaiseEvent_WithValidData() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - Action handler = e => ev = e; - SubathonEvents.SubathonEventCreated += handler; - var json = """ { "type": "KoFiDonation", @@ -352,7 +292,8 @@ public void ProcessKoFiDonation_ShouldRaiseEvent_WithValidData() var data = JsonSerializer.Deserialize>(json)!; - bool result = ExternalEventService.ProcessExternalDonation(data); + bool result = false; + SubathonEvent? ev = CaptureEvent( () => result =ExternalEventService.ProcessExternalDonation(data)); Assert.True(result); Assert.NotNull(ev); @@ -362,8 +303,6 @@ public void ProcessKoFiDonation_ShouldRaiseEvent_WithValidData() Assert.Equal(SubathonEventSource.KoFi, ev.Source); Assert.Equal(SubathonEventType.KoFiDonation, ev.EventType); Assert.Equal(Guid.Parse("c1e2d3f4-5678-4abc-9def-987654321abc"), ev.Id); - - SubathonEvents.SubathonEventCreated -= handler; } [Fact] @@ -410,16 +349,11 @@ public void ProcessExternalCommand_ShouldReturnFalse_WhenCommandIsValidStringBut [Fact] public void ProcessExternalCommand_EmptyUser_DefaultsToExternal() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - SubathonEvents.SubathonEventCreated += e => ev = e; - + var json = """{ "command": "Pause", "user": " ", "message": "" }"""; var data = JsonSerializer.Deserialize>(json)!; - ExternalEventService.ProcessExternalCommand(data); + SubathonEvent? ev = CaptureEvent( () => ExternalEventService.ProcessExternalCommand(data)); Assert.NotNull(ev); Assert.Equal("EXTERNAL", ev!.User); @@ -428,16 +362,11 @@ public void ProcessExternalCommand_EmptyUser_DefaultsToExternal() [Fact] public void ProcessExternalCommand_MissingMessage_DefaultsToEmpty() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - SubathonEvents.SubathonEventCreated += e => ev = e; - var json = """{ "command": "Pause", "user": "Tester" }"""; var data = JsonSerializer.Deserialize>(json)!; - bool result = ExternalEventService.ProcessExternalCommand(data); + bool result = false; + SubathonEvent? ev = CaptureEvent( () => result = ExternalEventService.ProcessExternalCommand(data)); Assert.True(result); Assert.NotNull(ev); @@ -446,10 +375,6 @@ public void ProcessExternalCommand_MissingMessage_DefaultsToEmpty() [Fact] public void ProcessExternalSub_ShouldReturnFalse_WhenSecondsOrPointsMissing() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - var json = """ { "type": "ExternalSub", @@ -468,10 +393,6 @@ public void ProcessExternalSub_ShouldReturnFalse_WhenSecondsOrPointsMissing() [Fact] public void ProcessExternalSub_ShouldReturnFalse_WhenPointsMissingButSecondsPresent() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - var json = """ { "type": "ExternalSub", @@ -491,12 +412,7 @@ public void ProcessExternalSub_ShouldReturnFalse_WhenPointsMissingButSecondsPres [Fact] public void ProcessExternalSub_EmptyUser_DefaultsToExternal() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - SubathonEvents.SubathonEventCreated += e => ev = e; - + var json = """ { "type": "ExternalSub", @@ -509,7 +425,8 @@ public void ProcessExternalSub_EmptyUser_DefaultsToExternal() """; var data = JsonSerializer.Deserialize>(json)!; - bool result = ExternalEventService.ProcessExternalSub(data); + bool result = false; + SubathonEvent? ev = CaptureEvent( () => result = ExternalEventService.ProcessExternalSub(data)); Assert.True(result); Assert.Equal("EXTERNAL", ev!.User); @@ -518,12 +435,6 @@ public void ProcessExternalSub_EmptyUser_DefaultsToExternal() [Fact] public void ProcessExternalSub_MissingValue_DefaultsToExternal() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - SubathonEvents.SubathonEventCreated += e => ev = e; - var json = """ { "type": "ExternalSub", @@ -535,7 +446,8 @@ public void ProcessExternalSub_MissingValue_DefaultsToExternal() """; var data = JsonSerializer.Deserialize>(json)!; - bool result = ExternalEventService.ProcessExternalSub(data); + bool result = false; + SubathonEvent? ev = CaptureEvent( () => result =ExternalEventService.ProcessExternalSub(data)); Assert.True(result); Assert.Equal("External", ev!.Value); @@ -544,12 +456,6 @@ public void ProcessExternalSub_MissingValue_DefaultsToExternal() [Fact] public void ProcessExternalSub_MissingAmount_DefaultsToOne() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - SubathonEvents.SubathonEventCreated += e => ev = e; - var json = """ { "type": "ExternalSub", @@ -561,7 +467,8 @@ public void ProcessExternalSub_MissingAmount_DefaultsToOne() """; var data = JsonSerializer.Deserialize>(json)!; - bool result = ExternalEventService.ProcessExternalSub(data); + bool result = false; + SubathonEvent? ev = CaptureEvent( () => result =ExternalEventService.ProcessExternalSub(data)); Assert.True(result); Assert.Equal(1, ev!.Amount); @@ -570,12 +477,6 @@ public void ProcessExternalSub_MissingAmount_DefaultsToOne() [Fact] public void ProcessExternalSub_SystemUser_SetsSimulatedSource() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - SubathonEvents.SubathonEventCreated += e => ev = e; - var json = """ { "type": "ExternalSub", @@ -588,7 +489,7 @@ public void ProcessExternalSub_SystemUser_SetsSimulatedSource() """; var data = JsonSerializer.Deserialize>(json)!; - ExternalEventService.ProcessExternalSub(data); + SubathonEvent? ev = CaptureEvent( () => ExternalEventService.ProcessExternalSub(data)); Assert.Equal(SubathonEventSource.Simulated, ev!.Source); } @@ -596,12 +497,6 @@ public void ProcessExternalSub_SystemUser_SetsSimulatedSource() [Fact] public void ProcessExternalSub_MissingId_KeepsGeneratedGuid() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - SubathonEvents.SubathonEventCreated += e => ev = e; - var json = """ { "type": "ExternalSub", @@ -614,7 +509,7 @@ public void ProcessExternalSub_MissingId_KeepsGeneratedGuid() """; var data = JsonSerializer.Deserialize>(json)!; - ExternalEventService.ProcessExternalSub(data); + SubathonEvent? ev = CaptureEvent( () => ExternalEventService.ProcessExternalSub(data)); Assert.NotEqual(Guid.Empty, ev!.Id); } @@ -622,12 +517,6 @@ public void ProcessExternalSub_MissingId_KeepsGeneratedGuid() [Fact] public void ProcessExternalSub_InvalidId_KeepsGeneratedGuid() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - SubathonEvents.SubathonEventCreated += e => ev = e; - var json = """ { "type": "ExternalSub", @@ -641,7 +530,7 @@ public void ProcessExternalSub_InvalidId_KeepsGeneratedGuid() """; var data = JsonSerializer.Deserialize>(json)!; - ExternalEventService.ProcessExternalSub(data); + SubathonEvent? ev = CaptureEvent( () => ExternalEventService.ProcessExternalSub(data)); Assert.NotEqual(Guid.Empty, ev!.Id); } @@ -667,12 +556,6 @@ public void ProcessExternalDonation_ShouldReturnFalse_WhenCurrencyMissing() [Fact] public void ProcessExternalDonation_EmptyUser_DefaultsToExternal() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - SubathonEvents.SubathonEventCreated += e => ev = e; - var json = """ { "type": "ExternalDonation", @@ -683,7 +566,7 @@ public void ProcessExternalDonation_EmptyUser_DefaultsToExternal() """; var data = JsonSerializer.Deserialize>(json)!; - ExternalEventService.ProcessExternalDonation(data); + SubathonEvent? ev = CaptureEvent( () => ExternalEventService.ProcessExternalDonation(data)); Assert.Equal("EXTERNAL", ev!.User); } @@ -708,12 +591,6 @@ public void ProcessExternalDonation_ShouldReturnFalse_WhenAmountNotString() [Fact] public void ProcessExternalDonation_SystemUser_SetsSimulatedSource() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - SubathonEvents.SubathonEventCreated += e => ev = e; - var json = """ { "type": "ExternalDonation", @@ -724,7 +601,7 @@ public void ProcessExternalDonation_SystemUser_SetsSimulatedSource() """; var data = JsonSerializer.Deserialize>(json)!; - ExternalEventService.ProcessExternalDonation(data); + SubathonEvent? ev = CaptureEvent( () => ExternalEventService.ProcessExternalDonation(data)); Assert.Equal(SubathonEventSource.Simulated, ev!.Source); } @@ -732,12 +609,6 @@ public void ProcessExternalDonation_SystemUser_SetsSimulatedSource() [Fact] public void ProcessExternalDonation_MissingId_KeepsGeneratedGuid() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - SubathonEvents.SubathonEventCreated += e => ev = e; - var json = """ { "type": "ExternalDonation", @@ -748,7 +619,7 @@ public void ProcessExternalDonation_MissingId_KeepsGeneratedGuid() """; var data = JsonSerializer.Deserialize>(json)!; - ExternalEventService.ProcessExternalDonation(data); + SubathonEvent? ev = CaptureEvent( () => ExternalEventService.ProcessExternalDonation(data)); Assert.NotEqual(Guid.Empty, ev!.Id); } @@ -756,12 +627,6 @@ public void ProcessExternalDonation_MissingId_KeepsGeneratedGuid() [Fact] public void ProcessExternalDonation_InvalidId_KeepsGeneratedGuid() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? ev = null; - SubathonEvents.SubathonEventCreated += e => ev = e; - var json = """ { "type": "ExternalDonation", @@ -773,7 +638,7 @@ public void ProcessExternalDonation_InvalidId_KeepsGeneratedGuid() """; var data = JsonSerializer.Deserialize>(json)!; - ExternalEventService.ProcessExternalDonation(data); + SubathonEvent? ev = CaptureEvent( () => ExternalEventService.ProcessExternalDonation(data)); Assert.NotEqual(Guid.Empty, ev!.Id); } diff --git a/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs index 1f20d92..69cb57c 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/GoAffProServiceTests.cs @@ -9,25 +9,11 @@ namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("IntegrationEventTests")] +[Collection("SharedEventBusTests")] public class GoAffProServiceTests { - private static SubathonEvent CaptureEvent(Action trigger) - { - SubathonEvent? captured = null; - void EventCaptureHandler(SubathonEvent e) => captured = e; - - SubathonEvents.SubathonEventCreated += EventCaptureHandler; - try - { - trigger(); - return captured!; - } - finally - { - SubathonEvents.SubathonEventCreated -= EventCaptureHandler; - } - } + private static SubathonEvent? CaptureEvent(Action trigger) => + EventUtil.SubathonEventCapture.CaptureRequired(trigger); private static async Task<(bool?, SubathonEventSource, string, string)> CaptureIntegrationEvent(Func trigger) { @@ -82,8 +68,8 @@ public void SimulateOrder_RaisesEvent(GoAffProSource store, decimal total, decim .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); var ev = CaptureEvent(() => service.SimulateOrder(total, quantity, commission, store)); - + Assert.NotNull(ev); Assert.Equal(expectedEventType, ev.EventType); Assert.Equal(SubathonEventSubType.OrderLike, ev.EventType.GetSubType()); Assert.Equal(expectedCurrency, ev.Currency); diff --git a/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs index a0a0054..e18b65a 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/PicartoServiceTests.cs @@ -13,40 +13,26 @@ using PicartoEventsLib.Clients; using PicartoEventsLib.Options; using SubathonManager.Core.Interfaces; +using SubathonManager.Tests.Utility; + +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("IntegrationEventTests")] +[Collection("SharedEventBusTests")] public class PicartoServiceTests { public PicartoServiceTests() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - var path = Path.GetFullPath(Path.Combine(string.Empty , "data")); Directory.CreateDirectory(path); } - private static SubathonEvent CaptureEvent(Action trigger) - { - SubathonEvent? captured = null; - void EventCaptureHandler(SubathonEvent e) => captured = e; + // istg if this works + private static SubathonEvent? CaptureEvent(Action trigger) => + EventUtil.SubathonEventCapture.CaptureRequired(trigger); - SubathonEvents.SubathonEventCreated += EventCaptureHandler; - try - { - trigger(); - return captured!; - } - finally - { - SubathonEvents.SubathonEventCreated -= EventCaptureHandler; - } - } - private static IConfig MockConfig(Dictionary<(string, string), string>? values = null) { var mock = new Mock(); @@ -121,11 +107,9 @@ public void SimulateTip_RaisesTipEvent() Channel = "TestChannel" }; - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); var ev = CaptureEvent(() => PicartoService.ProcessAlert(tip)); + Assert.NotNull(ev); Assert.Equal(SubathonEventType.PicartoTip, ev.EventType); Assert.Equal(SubathonEventSubType.TokenLike, ev.EventType.GetSubType()); Assert.Equal("kudos", ev.Currency); @@ -148,15 +132,12 @@ public void SimulateSubscription_ShouldRaiseSubEvent(decimal amount, int tier, i Amount = amount, Channel = "TestChannel" }; - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); SubathonEvent? ev = CaptureEvent(() => PicartoService.ProcessAlert(sub)); Assert.NotNull(ev); - Assert.Equal(SubathonEventType.PicartoSub, ev!.EventType); - Assert.Equal(SubathonEventSource.Picarto, ev!.Source); + Assert.Equal(SubathonEventType.PicartoSub, ev.EventType); + Assert.Equal(SubathonEventSource.Picarto, ev.Source); Assert.Equal("sub", ev.Currency); Assert.Equal(months, ev.Amount); Assert.Equal($"T{tier}", ev.Value); @@ -181,15 +162,12 @@ public void SimulateGiftSubscription_ShouldRaiseSubEvent(decimal amount, int mon Channel = "TestChannel", IsGift = true }; - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); SubathonEvent? ev = CaptureEvent(() => PicartoService.ProcessAlert(sub)); Assert.NotNull(ev); - Assert.Equal(SubathonEventType.PicartoGiftSub, ev!.EventType); - Assert.Equal(SubathonEventSource.Picarto, ev!.Source); + Assert.Equal(SubathonEventType.PicartoGiftSub, ev.EventType); + Assert.Equal(SubathonEventSource.Picarto, ev.Source); Assert.Equal("sub", ev.Currency); Assert.Equal(months * quantity, ev.Amount); Assert.Equal($"T1", ev.Value); @@ -207,12 +185,9 @@ public void SimulateFollow_RaisesFollowEvent(SubathonEventSource src, string use Username = user, Channel = "TestChannel" }; - - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); var ev = CaptureEvent(() => PicartoService.ProcessAlert(fl)); + Assert.NotNull(ev); Assert.Equal(SubathonEventType.PicartoFollow, ev.EventType); Assert.Equal(SubathonEventSubType.FollowLike, ev.EventType.GetSubType()); Assert.Equal(user, ev.User); @@ -226,10 +201,6 @@ public void SimulateFollow_RaisesFollowEvent(SubathonEventSource src, string use [InlineData("hey wassup", false, "specialguy", "TestStreamer", false)] public void OnChatReceived_ChatCommand_InvokesCommandService(string cmd, bool isBroadcaster, string user, string channel, bool output) { - SubathonEvent? captured = null; - Action handler = e => captured = e; - SubathonEvents.SubathonEventCreated += handler; - var configCs = MockConfig(new() { { ("Chat", "Commands.Pause"), "pause" }, @@ -256,7 +227,8 @@ public void OnChatReceived_ChatCommand_InvokesCommandService(string cmd, bool is MsgId = Guid.Empty }; - PicartoService.ProcessChatMessage(msg); + + SubathonEvent? captured = CaptureEvent( () => PicartoService.ProcessChatMessage(msg)); if (output) { @@ -270,8 +242,6 @@ public void OnChatReceived_ChatCommand_InvokesCommandService(string cmd, bool is { Assert.Null(captured); } - - SubathonEvents.SubathonEventCreated -= handler; } [Fact] diff --git a/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs index 6eda03d..7edf486 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/StreamElementsServiceTests.cs @@ -7,18 +7,16 @@ using StreamElements.WebSocket.Models.Tip; using StreamElements.WebSocket.Models.Internal; using Microsoft.Extensions.Logging; +using SubathonManager.Tests.Utility; + namespace SubathonManager.Tests.IntegrationUnitTests; -[Collection("IntegrationEventTests")] +[Collection("SharedEventBusTests")] public class StreamElementsServiceTests { - public StreamElementsServiceTests() - { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - } + private static SubathonEvent? CaptureEvent(Action trigger) => + EventUtil.SubathonEventCapture.CaptureRequired(trigger); [Fact] public async Task InitClient_ShouldReturnFalse_WhenJwtIsEmpty() @@ -91,19 +89,15 @@ public void SimulateTip_ShouldRaiseSubathonEvent() typeof(SubathonEvents) .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); - SubathonEvent? capturedEvent = null; - Action handler = ev => capturedEvent = ev; - SubathonEvents.SubathonEventCreated += handler; - StreamElementsService.SimulateTip("15.5", "USD"); + + SubathonEvent? capturedEvent = CaptureEvent( () => StreamElementsService.SimulateTip("15.5", "USD")); Assert.NotNull(capturedEvent); - Assert.Equal("15.5", capturedEvent!.Value); + Assert.Equal("15.5", capturedEvent.Value); Assert.Equal("USD", capturedEvent.Currency); Assert.Equal(SubathonEventSource.Simulated, capturedEvent.Source); Assert.Equal(SubathonEventType.StreamElementsDonation, capturedEvent.EventType); - - SubathonEvents.SubathonEventCreated -= handler; } [Fact] @@ -117,10 +111,6 @@ public void OnTip_ShouldRaiseSubathonEvent() .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); - SubathonEvent? capturedEvent = null; - Action handler = ev => capturedEvent = ev; - SubathonEvents.SubathonEventCreated += handler; - var tip = new Tip( tipId: Guid.NewGuid().ToString(), username: "Test", @@ -133,17 +123,17 @@ public void OnTip_ShouldRaiseSubathonEvent() var method = typeof(StreamElementsService) .GetMethod("_OnTip", BindingFlags.NonPublic | BindingFlags.Instance); - method?.Invoke(service, new object?[] { null, tip }); + + SubathonEvent? capturedEvent = CaptureEvent( () => method?.Invoke(service, new object?[] { null, tip })); Assert.NotNull(capturedEvent); - Assert.Equal("Test", capturedEvent!.User); + Assert.Equal("Test", capturedEvent.User); Assert.Equal("USD", capturedEvent.Currency); Assert.Equal("12.5", capturedEvent.Value); Assert.Equal(SubathonEventSource.StreamElements, capturedEvent.Source); Assert.Equal(SubathonEventType.StreamElementsDonation, capturedEvent.EventType); Assert.Equal(Guid.Parse(tip.TipId), capturedEvent.Id); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] diff --git a/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs index 94b84c7..1247dd6 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/StreamLabsServiceTests.cs @@ -9,17 +9,18 @@ using Streamlabs.SocketClient; using Streamlabs.SocketClient.Messages.DataTypes; using SubathonManager.Core.Interfaces; +using SubathonManager.Tests.Utility; namespace SubathonManager.Tests.IntegrationUnitTests { - [Collection("IntegrationEventTests")] + [Collection("SharedEventBusTests")] public class StreamLabsServiceTests { + private static SubathonEvent? CaptureEvent(Action trigger) => + EventUtil.SubathonEventCapture.CaptureRequired(trigger); + public StreamLabsServiceTests() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); typeof(IntegrationEvents) .GetField("RaiseConnectionUpdate", BindingFlags.Static | BindingFlags.NonPublic) ?.SetValue(null, null); @@ -230,27 +231,15 @@ public async Task StopAsync_Works_WhenConnected() [Fact] public void SimulateTip_ShouldRaiseSubathonEvent() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? capturedEvent = null; - void Handler(SubathonEvent ev) => capturedEvent = ev; + + SubathonEvent? capturedEvent = CaptureEvent( () => + StreamLabsService.SimulateTip("25.5", "USD")); - SubathonEvents.SubathonEventCreated += Handler; - try - { - StreamLabsService.SimulateTip("25.5", "USD"); - - Assert.NotNull(capturedEvent); - Assert.Equal("25.5", capturedEvent!.Value); - Assert.Equal("USD", capturedEvent.Currency); - Assert.Equal(SubathonEventSource.Simulated, capturedEvent.Source); - Assert.Equal(SubathonEventType.StreamLabsDonation, capturedEvent.EventType); - } - finally - { - SubathonEvents.SubathonEventCreated -= Handler; - } + Assert.NotNull(capturedEvent); + Assert.Equal("25.5", capturedEvent.Value); + Assert.Equal("USD", capturedEvent.Currency); + Assert.Equal(SubathonEventSource.Simulated, capturedEvent.Source); + Assert.Equal(SubathonEventType.StreamLabsDonation, capturedEvent.EventType); } [Fact] @@ -273,14 +262,6 @@ public async Task OnDonation_ShouldRaiseSubathonEvent() var config = new Mock(); var service = new StreamLabsService(logger.Object, config.Object); - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - - SubathonEvent? capturedEvent = null; - Action handler = ev => capturedEvent = ev; - SubathonEvents.SubathonEventCreated += handler; - var donation = new DonationMessage { Id = long.MaxValue, @@ -302,17 +283,17 @@ public async Task OnDonation_ShouldRaiseSubathonEvent() var method = typeof(StreamLabsService) .GetMethod("OnDonation", BindingFlags.NonPublic | BindingFlags.Instance); - method?.Invoke(service, new object?[] { null, donation }); + SubathonEvent? capturedEvent = CaptureEvent( () => + method?.Invoke(service, new object?[] { null, donation })); Assert.NotNull(capturedEvent); - Assert.Equal("Donor", capturedEvent!.User); + Assert.Equal("Donor", capturedEvent.User); Assert.Equal("USD", capturedEvent.Currency); // should be uppercased Assert.Equal("5", capturedEvent.Value); Assert.Equal(SubathonEventSource.StreamLabs, capturedEvent.Source); Assert.Equal(SubathonEventType.StreamLabsDonation, capturedEvent.EventType); Assert.Equal(Guid.Parse(donation.MessageId), capturedEvent.Id); - SubathonEvents.SubathonEventCreated -= handler; await service.DisconnectAsync(); } @@ -322,10 +303,6 @@ public async Task OnDonation_RaisesSubathonEvent_WithCorrectFields() var (service, _) = MakeService(); await service.InitClientAsync(); - SubathonEvent? captured = null; - void Handler(SubathonEvent ev) => captured = ev; - SubathonEvents.SubathonEventCreated += Handler; - var msgId = Guid.NewGuid().ToString(); var donation = new DonationMessage { @@ -345,9 +322,10 @@ public async Task OnDonation_RaisesSubathonEvent_WithCorrectFields() Priority = 0 }; - typeof(StreamLabsService) - .GetMethod("OnDonation", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, donation]); + SubathonEvent? captured = CaptureEvent( () => + typeof(StreamLabsService) + .GetMethod("OnDonation", BindingFlags.NonPublic | BindingFlags.Instance)! + .Invoke(service, [null, donation])); Assert.NotNull(captured); Assert.Equal("Donor", captured!.User); @@ -356,7 +334,6 @@ public async Task OnDonation_RaisesSubathonEvent_WithCorrectFields() Assert.Equal(SubathonEventSource.StreamLabs, captured.Source); Assert.Equal(SubathonEventType.StreamLabsDonation, captured.EventType); Assert.Equal(Guid.Parse(msgId), captured.Id); - SubathonEvents.SubathonEventCreated -= Handler; } } } diff --git a/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs index d2f4193..3d80dcd 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/TwitchServiceTests.cs @@ -20,39 +20,23 @@ using Microsoft.Extensions.Logging; using SubathonManager.Tests.Utility; using UserType = TwitchLib.Client.Enums.UserType; +// ReSharper disable NullableWarningSuppressionIsUsed +// ReSharper disable SuggestVarOrType_SimpleTypes namespace SubathonManager.Tests.IntegrationUnitTests { - [Collection("IntegrationEventTests")] + [Collection("SharedEventBusTests")] public class TwitchServiceTests { public TwitchServiceTests() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - var path = Path.GetFullPath(Path.Combine(string.Empty , "data")); Directory.CreateDirectory(path); } - private static SubathonEvent CaptureEvent(Action trigger) - { - SubathonEvent? captured = null; - void EventCaptureHandler(SubathonEvent e) => captured = e; - - SubathonEvents.SubathonEventCreated += EventCaptureHandler; - try - { - trigger(); - return captured!; - } - finally - { - SubathonEvents.SubathonEventCreated -= EventCaptureHandler; - } - } + private static SubathonEvent? CaptureEvent(Action trigger) => + EventUtil.SubathonEventCapture.CaptureRequired(trigger); public class MockEventSubServer : IAsyncDisposable { @@ -218,11 +202,9 @@ public async ValueTask DisposeAsync() [Fact] public void SimulateRaid_RaisesRaidEvent() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); var ev = CaptureEvent(() => TwitchService.SimulateRaid(123)); + Assert.NotNull(ev); Assert.Equal(SubathonEventSource.Simulated, ev.Source); Assert.Equal(SubathonEventType.TwitchRaid, ev.EventType); Assert.Equal("123", ev.Value); @@ -233,11 +215,9 @@ public void SimulateRaid_RaisesRaidEvent() [Fact] public void SimulateCheer_RaisesCheerEvent() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); var ev = CaptureEvent(() => TwitchService.SimulateCheer(500)); + Assert.NotNull(ev); Assert.Equal(SubathonEventType.TwitchCheer, ev.EventType); Assert.Equal("bits", ev.Currency); Assert.Equal("500", ev.Value); @@ -246,22 +226,8 @@ public void SimulateCheer_RaisesCheerEvent() [Fact] public void SimulateSubscription_InvalidTier_DoesNotRaise() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? captured = null; - void Handler(SubathonEvent e) => captured = e; - - SubathonEvents.SubathonEventCreated += Handler; - try - { - TwitchService.SimulateSubscription("9000"); - Assert.Null(captured); - } - finally - { - SubathonEvents.SubathonEventCreated -= Handler; - } + SubathonEvent? captured = CaptureEvent(() => TwitchService.SimulateSubscription("9000")); + Assert.Null(captured); } @@ -271,9 +237,6 @@ public void SimulateSubscription_InvalidTier_DoesNotRaise() [InlineData("3000")] public void SimulateSubscription_ShouldRaiseSubEvent(string tier) { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); SubathonEvent? ev = CaptureEvent(() => TwitchService.SimulateSubscription(tier)); TwitchService.SimulateSubscription(tier); @@ -289,9 +252,6 @@ public void SimulateSubscription_ShouldRaiseSubEvent(string tier) [Fact] public void SimulateGiftSubscriptions_ShouldRaiseGiftSubEvent() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); SubathonEvent? ev = CaptureEvent(() => TwitchService.SimulateGiftSubscriptions("1000", 5)); Assert.NotNull(ev); Assert.Equal(SubathonEventType.TwitchGiftSub, ev.EventType); @@ -304,9 +264,6 @@ public void SimulateGiftSubscriptions_ShouldRaiseGiftSubEvent() [Fact] public void SimulateFollow_ShouldRaiseFollowEvent() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); SubathonEvent? ev = CaptureEvent(TwitchService.SimulateFollow); Assert.NotNull(ev); Assert.Equal(SubathonEventType.TwitchFollow, ev.EventType); @@ -316,9 +273,6 @@ public void SimulateFollow_ShouldRaiseFollowEvent() [Fact] public void SimulateCharityDonation_ShouldRaiseDonationEvent() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); SubathonEvent? ev = CaptureEvent(() => TwitchService.SimulateCharityDonation("25.50", "CAD")); Assert.NotNull(ev); @@ -331,11 +285,9 @@ public void SimulateCharityDonation_ShouldRaiseDonationEvent() [Fact] public void SimulateHypeTrainStart_ShouldRaiseStartEvent() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); SubathonEvent? ev = CaptureEvent(TwitchService.SimulateHypeTrainStart); + Assert.NotNull(ev); Assert.Equal(SubathonEventSource.Simulated, ev.Source); Assert.NotNull(ev); Assert.Equal(SubathonEventType.TwitchHypeTrain, ev.EventType); @@ -346,11 +298,10 @@ public void SimulateHypeTrainStart_ShouldRaiseStartEvent() [Fact] public void SimulateHypeTrainProgress_ShouldOnlyRaiseIfLevelIncreases() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); SubathonEvent? ev = CaptureEvent(TwitchService.SimulateHypeTrainStart); SubathonEvent? ev2 = CaptureEvent(() => TwitchService.SimulateHypeTrainProgress(5)); + + Assert.NotNull(ev2); Assert.Equal(SubathonEventSource.Simulated, ev2.Source); Assert.Equal(SubathonEventType.TwitchHypeTrain, ev2.EventType); Assert.NotNull(ev2); @@ -361,10 +312,8 @@ public void SimulateHypeTrainProgress_ShouldOnlyRaiseIfLevelIncreases() [Fact] public void SimulateHypeTrainEnd_ShouldRun() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); SubathonEvent? ev = CaptureEvent(() => TwitchService.SimulateHypeTrainEnd(10)); + Assert.NotNull(ev); Assert.Equal(SubathonEventSource.Simulated, ev.Source); Assert.Equal(SubathonEventType.TwitchHypeTrain, ev.EventType); Assert.NotNull(ev); @@ -373,7 +322,7 @@ public void SimulateHypeTrainEnd_ShouldRun() } [Fact] - public void HandleChannelOnline_ResumeOnStart_RaisesResumeCommand() + public async Task HandleChannelOnline_ResumeOnStart_RaisesResumeCommand() { var config = MockConfig.MakeMockConfig(new() { @@ -381,13 +330,7 @@ public void HandleChannelOnline_ResumeOnStart_RaisesResumeCommand() }); var service = new TwitchService(null, config); - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); var ev = CaptureEvent(() => service .GetType() @@ -399,10 +342,11 @@ public void HandleChannelOnline_ResumeOnStart_RaisesResumeCommand() Assert.Equal(SubathonCommandType.Resume, ev.Command); Assert.Equal(SubathonEventType.Command, ev.EventType); Assert.Equal("AUTO", ev.User); + await service.StopAsync(); } [Fact] - public void HandleChannelOnline_UnlockOnStart_RaisesResumeCommand() + public async Task HandleChannelOnline_UnlockOnStart_RaisesResumeCommand() { var config =MockConfig.MakeMockConfig(new() { @@ -411,9 +355,6 @@ public void HandleChannelOnline_UnlockOnStart_RaisesResumeCommand() var service = new TwitchService(null, config); - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); var ev = CaptureEvent(() => service .GetType() @@ -421,13 +362,15 @@ public void HandleChannelOnline_UnlockOnStart_RaisesResumeCommand() .Invoke(service, [null, new StreamOnlineArgs()]) ); + Assert.NotNull(ev); Assert.Equal(SubathonCommandType.Unlock, ev.Command); Assert.Equal(SubathonEventType.Command, ev.EventType); Assert.Equal("AUTO", ev.User); + await service.StopAsync(); } [Fact] - public void HandleChannelOffline_PauseOnEnd_RaisesPauseCommand() + public async Task HandleChannelOffline_PauseOnEnd_RaisesPauseCommand() { var config =MockConfig.MakeMockConfig(new() { @@ -436,9 +379,6 @@ public void HandleChannelOffline_PauseOnEnd_RaisesPauseCommand() var service = new TwitchService(null, config); - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); var ev = CaptureEvent(() => service .GetType() @@ -446,13 +386,15 @@ public void HandleChannelOffline_PauseOnEnd_RaisesPauseCommand() .Invoke(service, [null, new StreamOfflineArgs()]) ); + Assert.NotNull(ev); Assert.Equal(SubathonCommandType.Pause, ev.Command); Assert.Equal(SubathonEventType.Command, ev.EventType); Assert.Equal("AUTO", ev.User); + await service.StopAsync(); } [Fact] - public void HandleChannelOffline_LockOnEnd_RaisesPauseCommand() + public async Task HandleChannelOffline_LockOnEnd_RaisesPauseCommand() { var config =MockConfig.MakeMockConfig(new() { @@ -461,9 +403,6 @@ public void HandleChannelOffline_LockOnEnd_RaisesPauseCommand() var service = new TwitchService(null, config); - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); var ev = CaptureEvent(() => service .GetType() @@ -471,13 +410,15 @@ public void HandleChannelOffline_LockOnEnd_RaisesPauseCommand() .Invoke(service, [null, new StreamOfflineArgs()]) ); + Assert.NotNull(ev); Assert.Equal(SubathonCommandType.Lock, ev.Command); Assert.Equal(SubathonEventType.Command, ev.EventType); Assert.Equal("AUTO", ev.User); + await service.StopAsync(); } [Fact] - public void HandleChannelFollow_RaisesFollowEvent() + public async Task HandleChannelFollow_RaisesFollowEvent() { var service = new TwitchService(null,MockConfig.MakeMockConfig()); @@ -499,9 +440,6 @@ public void HandleChannelFollow_RaisesFollowEvent() } }; - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); var ev = CaptureEvent(() => service .GetType() @@ -509,9 +447,11 @@ public void HandleChannelFollow_RaisesFollowEvent() .Invoke(service, [null, args]) ); + Assert.NotNull(ev); Assert.Equal(SubathonEventType.TwitchFollow, ev.EventType); Assert.Equal("Follower123", ev.User); Assert.Equal(SubathonEventSource.Twitch, ev.Source); + await service.StopAsync(); } [Fact] @@ -519,26 +459,13 @@ public void HandleHypeTrainProgress_IgnoresSameOrLowerLevel() { TwitchService.SimulateHypeTrainStart(); - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - SubathonEvent? captured = null; - void Handler(SubathonEvent e) => captured = e; - - SubathonEvents.SubathonEventCreated += Handler; - try - { - TwitchService.SimulateHypeTrainProgress(1); - Assert.Null(captured); - } - finally - { - SubathonEvents.SubathonEventCreated -= Handler; - } + SubathonEvent? capturedEvent = CaptureEvent(() => + TwitchService.SimulateHypeTrainProgress(1)); + Assert.Null(capturedEvent); } [Fact] - public void HandleChatMessage_Command_RaisesCommandEvent() + public async Task HandleChatMessage_Command_RaisesCommandEvent() { var config =MockConfig.MakeMockConfig(new() { @@ -599,9 +526,6 @@ ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcast ChatMessage = chatMsg }; - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); var ev = CaptureEvent(() => service @@ -688,29 +612,32 @@ ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcast ); Assert.Null(ev); + await service.StopAsync(); } [Fact] - public void HasTokenFile_ReturnsCorrectValue() + public async Task HasTokenFile_ReturnsCorrectValue() { var filePath = Path.GetFullPath(Path.Combine(string.Empty , "data/twitch_token.json")); var service = new TwitchService(null,MockConfig.MakeMockConfig()); - File.WriteAllText(filePath, "{}"); + await File.WriteAllTextAsync(filePath, "{}"); Assert.True(service.HasTokenFile()); File.Delete(filePath); Assert.False(service.HasTokenFile()); + await service.StopAsync(); } [Fact] - public void RevokeTokenFile_DeletesFileAndClearsAccessToken() + public async Task RevokeTokenFile_DeletesFileAndClearsAccessToken() { var filePath = Path.GetFullPath(Path.Combine(string.Empty , "data/twitch_token.json")); - File.WriteAllText(filePath, "{}"); + await File.WriteAllTextAsync(filePath, "{}"); var service = new TwitchService(null,MockConfig.MakeMockConfig()); service.RevokeTokenFile(); Assert.False(File.Exists(filePath)); + await service.StopAsync(); } [Fact] @@ -724,6 +651,7 @@ public async Task ValidateTokenAsync_ReturnsFalse_WhenFileMissing() bool result = await service.ValidateTokenAsync(); Assert.False(result); + await service.StopAsync(); } [Fact] @@ -738,6 +666,7 @@ public async Task ValidateTokenAsync_ReturnsFalse_WhenTokenInvalid() Assert.False(result); File.Delete(filePath); + await service.StopAsync(); } [Fact] @@ -762,6 +691,7 @@ public async Task HandleSubGift_RaisesGiftSubEvent() Assert.Equal(SubathonEventSource.Twitch, ev.Source); Assert.Equal(3, ev.Amount); Assert.Equal("gifter", ev.User); + await service.StopAsync(); } [Fact] @@ -786,6 +716,7 @@ public async Task HandleBitsUse_RaisesCheerEvent() Assert.Equal(SubathonEventSource.Twitch, ev.Source); Assert.Equal("cheerer", ev.User); Assert.Equal("500", ev.Value); + await service.StopAsync(); } [Fact] @@ -814,10 +745,12 @@ public async Task HandleChannelSubscribe_RaisesSubEvent() var ev = CaptureEvent(() => service.InvokePrivate("HandleChannelSubscribe", null, args).Wait()); + Assert.NotNull(ev); Assert.Equal(SubathonEventType.TwitchSub, ev.EventType); Assert.Equal(SubathonEventSource.Twitch, ev.Source); Assert.Equal("subscriber", ev.User); Assert.Equal("1000", ev.Value); + await service.StopAsync(); } @@ -845,10 +778,12 @@ public async Task HandleSubscriptionMsg_RaisesSubEvent() var ev = CaptureEvent(() => service.InvokePrivate("HandleSubscriptionMsg", null, args).Wait()); + Assert.NotNull(ev); Assert.Equal(SubathonEventType.TwitchSub, ev.EventType); Assert.Equal(SubathonEventSource.Twitch, ev.Source); Assert.Equal("subscriber", ev.User); Assert.Equal("2000", ev.Value); + await service.StopAsync(); } [Fact] @@ -875,10 +810,12 @@ public async Task HandleChannelRaid_RaisesRaidEvent() var ev = CaptureEvent(() => service.InvokePrivate("HandleChannelRaid", null, args).Wait()); + Assert.NotNull(ev); Assert.Equal(SubathonEventType.TwitchRaid, ev.EventType); Assert.Equal(SubathonEventSource.Twitch, ev.Source); Assert.Equal("raider", ev.User); Assert.Equal("42", ev.Value); + await service.StopAsync(); } [Fact] @@ -905,11 +842,13 @@ public async Task HandleHypeTrainBeginV2_RaisesStartEvent() var ev = CaptureEvent(() => service.InvokePrivate("HandleHypeTrainBeginV2", null, args).Wait()); + Assert.NotNull(ev); Assert.Equal(SubathonEventType.TwitchHypeTrain, ev.EventType); Assert.Equal(SubathonEventSource.Twitch, ev.Source); Assert.Equal("start", ev.Value); Assert.Equal("broadcaster", ev.User); Assert.Equal(1, ev.Amount); + await service.StopAsync(); } [Fact] @@ -952,11 +891,13 @@ public async Task HandleHypeTrainProgressV2_RaisesProgressEvent() var ev = CaptureEvent(() => service.InvokePrivate("HandleHypeTrainProgressV2", null, args).Wait()); + Assert.NotNull(ev); Assert.Equal(SubathonEventType.TwitchHypeTrain, ev.EventType); Assert.Equal(SubathonEventSource.Twitch, ev.Source); Assert.Equal("progress", ev.Value); Assert.Equal("broadcaster", ev.User); Assert.Equal(2, ev.Amount); + await service.StopAsync(); } [Fact] @@ -983,11 +924,13 @@ public async Task HandleHypeTrainEndV2_RaisesEndEvent() var ev = CaptureEvent(() => service.InvokePrivate("HandleHypeTrainEndV2", null, args).Wait()); + Assert.NotNull(ev); Assert.Equal(SubathonEventType.TwitchHypeTrain, ev.EventType); Assert.Equal(SubathonEventSource.Twitch, ev.Source); Assert.Equal("end", ev.Value); Assert.Equal("broadcaster", ev.User); Assert.Equal(5, ev.Amount); + await service.StopAsync(); } [Fact] @@ -1019,11 +962,13 @@ public async Task HandleCharityEvent_RaisesDonationEvent() var ev = CaptureEvent(() => service.InvokePrivate("HandleCharityEvent", null, args).Wait()); + Assert.NotNull(ev); Assert.Equal(SubathonEventType.TwitchCharityDonation, ev.EventType); Assert.Equal(SubathonEventSource.Twitch, ev.Source); Assert.Equal("donor", ev.User); Assert.Equal("25.50", ev.Value); Assert.Equal("CAD", ev.Currency); + await service.StopAsync(); } [Fact] @@ -1038,7 +983,7 @@ public async Task StopAsync_CanBeCalledTwice_Safely() } [Fact] - public void HandleChatMessage_BlerpNotification() + public async Task HandleChatMessage_BlerpNotification() { var config =MockConfig.MakeMockConfig(); var service = new TwitchService(null, config); @@ -1092,9 +1037,6 @@ ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcast ChatMessage = chatMsg }; - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); var ev = CaptureEvent(() => service @@ -1107,10 +1049,11 @@ ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcast Assert.Equal(SubathonEventType.BlerpBits, ev.EventType); Assert.Equal(SubathonEventSource.Blerp, ev.Source); Assert.Equal("SomeGuy", ev.User); + await service.StopAsync(); } [Fact] - public void HandleChatMessage_BlerpNotificationWrongChat() + public async Task HandleChatMessage_BlerpNotificationWrongChat() { var config =MockConfig.MakeMockConfig(); var service = new TwitchService(null, config); @@ -1164,9 +1107,6 @@ ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcast ChatMessage = chatMsg }; - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); var ev = CaptureEvent(() => service @@ -1176,6 +1116,7 @@ ChatMessage MakeMessage(string message, bool isVip, bool isMod, bool isBroadcast ); Assert.Null(ev); + await service.StopAsync(); } [Fact] @@ -1237,6 +1178,7 @@ await Task.WhenAll( Assert.Equal(fakeToken, data!["access_token"]); File.Delete(tokenFilePath); + await service.StopAsync(); } [Fact] @@ -1282,4 +1224,4 @@ void Handler(bool b, SubathonEventSource source, string name, string svc) } } } -} +} \ No newline at end of file diff --git a/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs b/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs index b670863..50a64d5 100644 --- a/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs +++ b/SubathonManager.Tests/IntegrationUnitTests/YouTubeServiceTests.cs @@ -13,67 +13,50 @@ namespace SubathonManager.Tests.IntegrationUnitTests { - [Collection("IntegrationEventTests")] + [Collection("SharedEventBusTests")] public class YouTubeServiceTests { - public YouTubeServiceTests() - { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - } + private static SubathonEvent? CaptureEvent(Action trigger) => + EventUtil.SubathonEventCapture.CaptureRequired(trigger); + [Fact] public void SimulateSuperChat_ShouldRaiseEvent() { - SubathonEvent? captured = null; - Action handler = ev => captured = ev; - SubathonEvents.SubathonEventCreated += handler; - YouTubeService.SimulateSuperChat("12.5", "USD"); + + SubathonEvent? captured = CaptureEvent( () => YouTubeService.SimulateSuperChat("12.5", "USD")); Assert.NotNull(captured); Assert.Equal("12.5", captured!.Value); Assert.Equal("USD", captured.Currency); Assert.Equal(SubathonEventSource.Simulated, captured.Source); Assert.Equal(SubathonEventType.YouTubeSuperChat, captured.EventType); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] public void SimulateMembership_ShouldRaiseEvent() { - typeof(SubathonEvents) - .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) - ?.SetValue(null, null); - - SubathonEvent? captured = null; - Action handler = ev => captured = ev; - SubathonEvents.SubathonEventCreated += handler; - - YouTubeService.SimulateMembership("Gold"); + SubathonEvent? captured = CaptureEvent( () => + YouTubeService.SimulateMembership("Gold")); Assert.NotNull(captured); Assert.Equal("Gold", captured!.Value); Assert.Equal("member", captured.Currency); Assert.Equal(SubathonEventSource.Simulated, captured.Source); Assert.Equal(SubathonEventType.YouTubeMembership, captured.EventType); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] public void SimulateGiftMemberships_ShouldRaiseEvent() { - SubathonEvent? captured = null; - Action handler = ev => captured = ev; - SubathonEvents.SubathonEventCreated += handler; - - YouTubeService.SimulateGiftMemberships(3); + + SubathonEvent? captured = CaptureEvent( () => + YouTubeService.SimulateGiftMemberships(3)); Assert.NotNull(captured); Assert.Equal("member", captured!.Currency); Assert.Equal(SubathonEventSource.Simulated, captured.Source); Assert.Equal(SubathonEventType.YouTubeGiftMembership, captured.EventType); Assert.Equal(3, captured.Amount); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] @@ -165,52 +148,42 @@ public void OnChatReceived_ShouldRaiseSuperChatEvent() var httpLogger = new Mock>(); var config = new Mock(); var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - - SubathonEvent? capturedEvent = null; - Action handler = e => capturedEvent = e; - SubathonEvents.SubathonEventCreated += handler; - - try - { - var field = typeof(YouTubeService) - .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance); - field!.SetValue(service, "@TestChannel"); - service.Running = true; - - var method = typeof(YouTubeService) - .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance); + var field = typeof(YouTubeService) + .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance); + field!.SetValue(service, "@TestChannel"); - var chatItem = new ChatItem - { - Id = Guid.NewGuid().ToString(), - Timestamp = DateTimeOffset.UtcNow, - Author = new Author { Name = "TestUser", ChannelId = "TestChannelId" }, - Message = [], - Superchat = new Superchat - { - AmountString = "$5.00", AmountValue = (decimal)5.00, Currency = "CA$", - BodyBackgroundColor = "", - } - }; + service.Running = true; - var eventArgs = new ChatReceivedEventArgs { ChatItem = chatItem }; - method?.Invoke(service, [null, eventArgs]); + var method = typeof(YouTubeService) + .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance); - Assert.NotNull(capturedEvent); - Assert.Equal("TestUser", capturedEvent!.User); - Assert.Equal("CA$", capturedEvent.Currency); - Assert.Equal("5", capturedEvent.Value); - Assert.Equal(SubathonEventSource.YouTube, capturedEvent.Source); - Assert.Equal(SubathonEventType.YouTubeSuperChat, capturedEvent.EventType); - } - finally + var chatItem = new ChatItem { - SubathonEvents.SubathonEventCreated -= handler; - } + Id = Guid.NewGuid().ToString(), + Timestamp = DateTimeOffset.UtcNow, + Author = new Author { Name = "TestUser", ChannelId = "TestChannelId" }, + Message = [], + Superchat = new Superchat + { + AmountString = "$5.00", AmountValue = (decimal)5.00, Currency = "CA$", + BodyBackgroundColor = "", + } + }; + + var eventArgs = new ChatReceivedEventArgs { ChatItem = chatItem }; + + SubathonEvent? capturedEvent = CaptureEvent( () => method?.Invoke(service, [null, eventArgs])); + + Assert.NotNull(capturedEvent); + Assert.Equal("TestUser", capturedEvent!.User); + Assert.Equal("CA$", capturedEvent.Currency); + Assert.Equal("5", capturedEvent.Value); + Assert.Equal(SubathonEventSource.YouTube, capturedEvent.Source); + Assert.Equal(SubathonEventType.YouTubeSuperChat, capturedEvent.EventType); } - - + + [Fact] public void OnChatReceived_Membership_Gift_RaisesEvent() { @@ -219,9 +192,6 @@ public void OnChatReceived_Membership_Gift_RaisesEvent() var httpLogger = new Mock>(); var config = new Mock(); var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - SubathonEvent? captured = null; - Action handler = e => captured = e; - SubathonEvents.SubathonEventCreated += handler; service.Running = true; typeof(YouTubeService) @@ -243,9 +213,10 @@ public void OnChatReceived_Membership_Gift_RaisesEvent() }; var args = new ChatReceivedEventArgs { ChatItem = chatItem }; - typeof(YouTubeService) + + SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args]); + .Invoke(service, [null, args])); Assert.NotNull(captured); Assert.Equal(SubathonEventSource.YouTube, captured!.Source); @@ -253,7 +224,6 @@ public void OnChatReceived_Membership_Gift_RaisesEvent() Assert.Equal("Gifter", captured.User); Assert.Equal(3, captured.Amount); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] @@ -291,7 +261,6 @@ public void OnChatReceived_Membership_GiftRedemption_DoesNotRaiseEvent() .Invoke(service, [null, args]); Assert.False(eventRaised); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] @@ -302,9 +271,6 @@ public void OnChatReceived_ChatCommand_InvokesCommandService() var httpLogger = new Mock>(); var config = new Mock(); var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - SubathonEvent? captured = null; - Action handler = e => captured = e; - SubathonEvents.SubathonEventCreated += handler; var configCs = MockConfig.MakeMockConfig(new() { @@ -331,16 +297,16 @@ public void OnChatReceived_ChatCommand_InvokesCommandService() }; var args = new ChatReceivedEventArgs { ChatItem = chatItem }; - typeof(YouTubeService) + + SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args]); + .Invoke(service, [null, args])); Assert.NotNull(captured); Assert.Equal(SubathonEventSource.YouTube, captured!.Source); Assert.Equal(SubathonEventType.Command, captured!.EventType); Assert.Equal(SubathonCommandType.Pause, captured!.Command); Assert.Equal("User", captured.User); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] @@ -435,9 +401,6 @@ public void OnChatReceived_BlerpMessage() var httpLogger = new Mock>(); var config = new Mock(); var service = new YouTubeService(logger.Object, config.Object, httpLogger.Object, chatLogger.Object); - SubathonEvent? captured = null; - Action handler = e => captured = e; - SubathonEvents.SubathonEventCreated += handler; service.Running = true; typeof(YouTubeService) @@ -455,15 +418,15 @@ public void OnChatReceived_BlerpMessage() }; var args = new ChatReceivedEventArgs { ChatItem = chatItem }; - typeof(YouTubeService) + + SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, args]); + .Invoke(service, [null, args])); Assert.NotNull(captured); Assert.Equal(SubathonEventSource.Blerp, captured!.Source); Assert.Equal(SubathonEventType.BlerpBeets, captured!.EventType); Assert.Equal("SomeGuy", captured.User); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] @@ -476,7 +439,6 @@ public void SimulateSuperChat_ShouldNotRaiseEvent_WhenValueInvalid() YouTubeService.SimulateSuperChat("notanumber", "USD"); Assert.False(raised); - SubathonEvents.SubathonEventCreated -= handler; } [Theory] @@ -547,7 +509,6 @@ public void OnChatReceived_ShouldReturnEarly_WhenHandleIsEmpty() .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); Assert.False(raised); - SubathonEvents.SubathonEventCreated -= handler; } @@ -636,7 +597,6 @@ public void OnChatReceived_ShouldReturnEarly_WhenMessageIsTooOld() .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); Assert.False(raised); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] @@ -698,10 +658,6 @@ public void OnChatReceived_SuperChat_USD_WithoutDollarSign_ParsesCurrency() .SetValue(service, "@test"); service.Running = true; - SubathonEvent? captured = null; - Action handler = e => captured = e; - SubathonEvents.SubathonEventCreated += handler; - var chatItem = new ChatItem { Id = Guid.NewGuid().ToString(), @@ -716,14 +672,13 @@ public void OnChatReceived_SuperChat_USD_WithoutDollarSign_ParsesCurrency() BodyBackgroundColor = "" } }; - - typeof(YouTubeService) + + SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); Assert.NotNull(captured); Assert.NotEqual("USD", captured!.Currency); - SubathonEvents.SubathonEventCreated -= handler; } [Theory] @@ -766,7 +721,6 @@ public void OnChatReceived_Ticker_DoesNotRaiseEvent(string itemType) .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); Assert.False(raised); - SubathonEvents.SubathonEventCreated -= handler; } [Theory] @@ -785,10 +739,6 @@ public void OnChatReceived_Membership_IdHandling(bool isValidGuid) .SetValue(service, "@test"); service.Running = true; - SubathonEvent? captured = null; - Action handler = e => captured = e; - SubathonEvents.SubathonEventCreated += handler; - var knownGuid = Guid.NewGuid(); string itemId = isValidGuid ? knownGuid.ToString() : "yt-not-a-guid"; @@ -801,16 +751,16 @@ public void OnChatReceived_Membership_IdHandling(bool isValidGuid) MembershipDetails = new MembershipDetails { EventType = MembershipEventType.New, LevelName = "Gold" } }; - typeof(YouTubeService) + + SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); Assert.NotNull(captured); Assert.NotEqual(Guid.Empty, captured!.Id); if (isValidGuid) Assert.Equal(knownGuid, captured.Id); - SubathonEvents.SubathonEventCreated -= handler; } [Theory] @@ -831,11 +781,7 @@ public void OnChatReceived_Membership_RaisesYouTubeMembership( .GetField("_ytHandle", BindingFlags.NonPublic | BindingFlags.Instance)! .SetValue(service, "@test"); service.Running = true; - - SubathonEvent? captured = null; - Action handler = e => captured = e; - SubathonEvents.SubathonEventCreated += handler; - + var chatItem = new ChatItem { Id = Guid.NewGuid().ToString(), @@ -851,15 +797,15 @@ public void OnChatReceived_Membership_RaisesYouTubeMembership( } }; - typeof(YouTubeService) + + SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); Assert.NotNull(captured); Assert.Equal(expectedEventType, captured!.EventType); Assert.Equal(SubathonEventSource.YouTube, captured.Source); Assert.Equal("MemberUser", captured.User); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] @@ -876,10 +822,6 @@ public void OnChatReceived_Membership_Milestone_TierIsMember_UsesHeaderSubtext() .SetValue(service, "@test"); service.Running = true; - SubathonEvent? captured = null; - Action handler = e => captured = e; - SubathonEvents.SubathonEventCreated += handler; - var chatItem = new ChatItem { Id = Guid.NewGuid().ToString(), @@ -895,13 +837,13 @@ public void OnChatReceived_Membership_Milestone_TierIsMember_UsesHeaderSubtext() } }; - typeof(YouTubeService) + + SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); Assert.NotNull(captured); Assert.Equal("Gold", captured!.Value); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] @@ -918,10 +860,6 @@ public void OnChatReceived_Membership_GiftPurchase_NullGifterUsername_UsesAuthor .SetValue(service, "@test"); service.Running = true; - SubathonEvent? captured = null; - Action handler = e => captured = e; - SubathonEvents.SubathonEventCreated += handler; - var chatItem = new ChatItem { Id = Guid.NewGuid().ToString(), @@ -936,14 +874,14 @@ public void OnChatReceived_Membership_GiftPurchase_NullGifterUsername_UsesAuthor } }; - typeof(YouTubeService) + + SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); Assert.NotNull(captured); Assert.Equal("AuthorFallback", captured!.User); Assert.Equal(2, captured.Amount); - SubathonEvents.SubathonEventCreated -= handler; } @@ -966,10 +904,6 @@ public void OnChatReceived_Membership_TierNormalizesToDefault(string levelName, .SetValue(service, "@test"); service.Running = true; - SubathonEvent? captured = null; - Action handler = e => captured = e; - SubathonEvents.SubathonEventCreated += handler; - var chatItem = new ChatItem { Id = Guid.NewGuid().ToString(), @@ -984,13 +918,13 @@ public void OnChatReceived_Membership_TierNormalizesToDefault(string levelName, } }; - typeof(YouTubeService) + + SubathonEvent? captured = CaptureEvent( () => typeof(YouTubeService) .GetMethod("OnChatReceived", BindingFlags.NonPublic | BindingFlags.Instance)! - .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }]); + .Invoke(service, [null, new ChatReceivedEventArgs { ChatItem = chatItem }])); Assert.NotNull(captured); Assert.Equal("DEFAULT", captured!.Value); - SubathonEvents.SubathonEventCreated -= handler; } [Fact] diff --git a/SubathonManager.Tests/SequentialCollectionDefinition.cs b/SubathonManager.Tests/SequentialCollectionDefinition.cs index 8ec7b3a..ea64071 100644 --- a/SubathonManager.Tests/SequentialCollectionDefinition.cs +++ b/SubathonManager.Tests/SequentialCollectionDefinition.cs @@ -8,4 +8,7 @@ public class SequentialCollectionDefinition [CollectionDefinition("SequentialParallel", DisableParallelization = false)] public class SequentialParallelCollectionDefinition { -} \ No newline at end of file +} + +[CollectionDefinition("SharedEventBusTests", DisableParallelization = true)] // slowdown but might fix the eventbus issue in websocket consumers +public class SharedEventBusTestsCollection { } \ No newline at end of file diff --git a/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs b/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs index eb0538b..8bb3e37 100644 --- a/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs +++ b/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs @@ -1,6 +1,4 @@ using System.Text; -using Moq; -using IniParser.Model; using SubathonManager.Core.Models; using SubathonManager.Core.Enums; using System.Net.WebSockets; @@ -17,6 +15,102 @@ namespace SubathonManager.Tests.ServerUnitTests; +[Collection("SharedEventBusTests")] +public class WebServerWebSocketEventBusTests +{ + + private static SubathonEvent? CaptureEvent(Action trigger) => + EventUtil.SubathonEventCapture.CaptureRequired(trigger); + + [Fact] + public async Task WebSocket_ReceiveIntegrationSource_AddsSourceAndEvent() + { + WebServerWebSocketTests.SetupServices(); + var server = WebServerWebSocketTests.CreateServer(); + + var sourceTcs = new TaskCompletionSource(); + Action handler = (src, connected) => + { + if (connected) + sourceTcs.TrySetResult(src); + }; + + WebServerEvents.WebSocketIntegrationSourceChange += handler; + + try + { + var ctx = new MockHttpContext + { + IsWebSocket = true + }; + ctx.Socket.EnqueueReceive( + "{\"ws_type\":\"IntegrationSource\",\"source\":\"KoFi\", \"type\": \"KoFiSub\", \"tier\":\"DEFAULT\", \"amount\": 1, \"user\":\"test\"}" + ); + ctx.Socket.EnqueueClose(); + + SubathonEvent? ev = CaptureEvent( async void () => + await server.HandleWebSocketRequestAsync(ctx)); + + var result = await sourceTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.Equal(nameof(SubathonEventSource.KoFi), result); + Assert.NotNull(ev); + Assert.Equal(SubathonEventSource.KoFi, ev.Source); + Assert.Equal(SubathonEventType.KoFiSub, ev.EventType); + } + finally + { + WebServerEvents.WebSocketIntegrationSourceChange -= handler; + AppServices.Provider = null!; + await server.StopAsync(); + } + } + + [Fact] + public async Task WebSocket_ReceiveCommand() + { + WebServerWebSocketTests.SetupServices(); + var server = WebServerWebSocketTests.CreateServer(); + + var sourceTcs = new TaskCompletionSource(); + + Action handler = (src, connected) => + { + if (connected) + sourceTcs.TrySetResult(src); + }; + + + WebServerEvents.WebSocketIntegrationSourceChange += handler; + + try + { + var ctx = new MockHttpContext + { + IsWebSocket = true + }; + + ctx.Socket.EnqueueReceive( + "{\"ws_type\":\"Command\", \"type\": \"Command\", \"message\":\"\", \"command\": \"pause\", \"user\":\"test\"}"); + ctx.Socket.EnqueueClose(); + + + SubathonEvent? ev = CaptureEvent( async void () => + await server.HandleWebSocketRequestAsync(ctx)); + + Assert.NotNull(ev); + Assert.Equal(SubathonEventSource.External, ev.Source); + Assert.Equal(SubathonEventType.Command, ev.EventType); + } + finally + { + WebServerEvents.WebSocketIntegrationSourceChange -= handler; + AppServices.Provider = null!; + await server.StopAsync(); + } + } +} + [Collection("ProviderOverrideTests")] public class WebServerWebSocketTests { @@ -26,6 +120,21 @@ public WebServerWebSocketTests(ITestOutputHelper testOutputHelper) { _testOutputHelper = testOutputHelper; } + + private static async Task WaitForMessageMatchingAsync( + MockWebSocket socket, + Func predicate, + TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + while (!cts.IsCancellationRequested) + { + if (socket.SentMessages.Any(m => predicate(Encoding.UTF8.GetString(m)))) + return; + await Task.Delay(10, cts.Token); + } + throw new TimeoutException("No matching websocket message received within timeout."); + } private static async Task WaitForMessageAsync(MockWebSocket socket, TimeSpan timeout) { @@ -50,7 +159,7 @@ private static IConfig MakeMockConfig(Dictionary<(string, string), string>? valu return config; } - private static void SetupServices() + internal static void SetupServices() { var dbName = Guid.NewGuid().ToString(); var services = new ServiceCollection(); @@ -64,7 +173,7 @@ private static void SetupServices() AppServices.Provider = services.BuildServiceProvider(); } - private static WebServer CreateServer() + internal static WebServer CreateServer() { SetupServices(); var logger = AppServices.Provider.GetRequiredService>(); @@ -210,11 +319,13 @@ public async Task WebSocket_SendGoalsUpdated_List() server.AddSocketClient(client); // ACTUAL adding to clients list server.SendGoalsUpdated(goals,10, GoalsType.Points); - await WaitForMessageAsync(ctx.Socket, TimeSpan.FromSeconds(5)); - Assert.NotEmpty(ctx.Socket.SentMessages); - var sent = Encoding.UTF8.GetString(ctx.Socket.SentMessages[0]); + await WaitForMessageMatchingAsync(ctx.Socket, m => m.Contains("goals_list"), TimeSpan.FromSeconds(5)); + var sent = ctx.Socket.SentMessages + .Select(m => Encoding.UTF8.GetString(m)) + .First(m => m.Contains("goals_list")); Assert.Equal("{\"type\":\"goals_list\",\"points\":10,\"goals\":[{\"text\":\"Test Goal\",\"points\":5,\"completed\":true}],\"goals_type\":\"Points\"}", sent); AppServices.Provider = null!; + await server.StopAsync(); } [Fact] @@ -235,11 +346,13 @@ public async Task WebSocket_SendSubathonValues() server.AddSocketClient(client); // ACTUAL adding to clients list server.SendSubathonValues("[{}]"); - await WaitForMessageAsync(ctx.Socket, TimeSpan.FromSeconds(5)); - Assert.NotEmpty(ctx.Socket.SentMessages); - var sent = Encoding.UTF8.GetString(ctx.Socket.SentMessages[0]); + await WaitForMessageMatchingAsync(ctx.Socket, m => m.Contains("value_config"), TimeSpan.FromSeconds(5)); + var sent = ctx.Socket.SentMessages + .Select(m => Encoding.UTF8.GetString(m)) + .First(m => m.Contains("value_config")); Assert.Equal("{ \"type\": \"value_config\", \"ws_type\": \"ValueConfig\", \"data\": [{}] }", sent); AppServices.Provider = null!; + await server.StopAsync(); } [Fact] @@ -264,13 +377,16 @@ public async Task WebSocket_SendGoalComplete() server.AddSocketClient(client); // ACTUAL adding to clients list server.SendGoalCompleted(goal,10); - await WaitForMessageAsync(ctx.Socket, TimeSpan.FromSeconds(5)); - Assert.NotEmpty(ctx.Socket.SentMessages); - var sent = Encoding.UTF8.GetString(ctx.Socket.SentMessages[0]); + await WaitForMessageMatchingAsync(ctx.Socket, m => m.Contains("goal_completed"), TimeSpan.FromSeconds(5)); + var sent = ctx.Socket.SentMessages + .Select(m => Encoding.UTF8.GetString(m)) + .First(m => m.Contains("goal_completed")); Assert.Equal("{\"type\":\"goal_completed\",\"goal_text\":\"Test Goal\",\"goal_points\":5,\"points\":10}", sent); AppServices.Provider = null!; + await server.StopAsync(); } - + + [Fact] public async Task WebSocket_SendSubathonEvent() { @@ -298,21 +414,39 @@ public async Task WebSocket_SendSubathonEvent() WebSocketClient client = new WebSocketClient(ctx.Socket); client.ClientTypes.Add(WebsocketClientMessageType.Widget); server.AddSocketClient(client); // ACTUAL adding to clients list - server.SendSubathonEventProcessed(subathonEvent,true); - await Task.Delay(25); - Assert.Empty(ctx.Socket.SentMessages); + + server.SendSubathonEventProcessed(subathonEvent, true); + await Task.Delay(50); + var messagesAfterUnprocessed = ctx.Socket.SentMessages + .Select(m => Encoding.UTF8.GetString(m)) + .ToList(); + Assert.DoesNotContain(messagesAfterUnprocessed, + m => m.Contains("\"type\":\"event\"") && m.Contains("TwitchGiftSub")); + + ctx.Socket.SentMessages.Clear(); + subathonEvent.ProcessedToSubathon = true; - server.SendSubathonEventProcessed(subathonEvent,true); + server.SendSubathonEventProcessed(subathonEvent, true); + + await WaitForMessageMatchingAsync( + ctx.Socket, + m => m.Contains("\"type\":\"event\"") && m.Contains("TwitchGiftSub"), + TimeSpan.FromSeconds(5)); - await WaitForMessageAsync(ctx.Socket, TimeSpan.FromSeconds(5)); Assert.NotEmpty(ctx.Socket.SentMessages); - - var sent = Encoding.UTF8.GetString(ctx.Socket.SentMessages[0]); - Assert.Contains("{\"type\":\"event\",\"event_type\":\"TwitchGiftSub\",\"source\":\"Twitch\",\"seconds_added\":300,\"points_added\":5,\"user\":\"Test User\",\"value\":\"1000\",\"amount\":5,\"currency\":\"sub\",\"command\":\"None\"", sent); + var sent = ctx.Socket.SentMessages + .Select(m => Encoding.UTF8.GetString(m)) + .First(m => m.Contains("\"type\":\"event\"") && m.Contains("TwitchGiftSub")); + + Assert.Contains( + "{\"type\":\"event\",\"event_type\":\"TwitchGiftSub\",\"source\":\"Twitch\"", + sent); + Assert.Contains("\"user\":\"Test User\"", sent); AppServices.Provider = null!; - } - + await server.StopAsync(); + } + [Fact] public async Task WebSocket_SendRefreshRequest() { @@ -330,11 +464,13 @@ public async Task WebSocket_SendRefreshRequest() Guid guid = Guid.Empty; server.SendRefreshRequest(guid); - await WaitForMessageAsync(ctx.Socket, TimeSpan.FromSeconds(5)); - Assert.NotEmpty(ctx.Socket.SentMessages); - var sent = Encoding.UTF8.GetString(ctx.Socket.SentMessages[0]); + await WaitForMessageMatchingAsync(ctx.Socket, m => m.Contains("refresh_request"), TimeSpan.FromSeconds(5)); + var sent = ctx.Socket.SentMessages + .Select(m => Encoding.UTF8.GetString(m)) + .First(m => m.Contains("refresh_request")); Assert.Equal($"{{\"type\":\"refresh_request\",\"id\":\"{guid}\"}}", sent); AppServices.Provider = null!; + await server.StopAsync(); } [Fact] @@ -353,9 +489,10 @@ public async Task WebSocket_SendRefreshRequest_NoConsumers() server.AddSocketClient(client); Guid guid = Guid.Empty; server.SendRefreshRequest(guid); - await Task.Delay(25); + await Task.Delay(50); Assert.Empty(ctx.Socket.SentMessages); AppServices.Provider = null!; + await server.StopAsync(); } [Fact] @@ -395,11 +532,13 @@ public async Task WebSocket_SendSubathonData() }; server.SendSubathonDataUpdate(subathon, DateTime.Now); - await WaitForMessageAsync(ctx.Socket, TimeSpan.FromSeconds(5)); - Assert.NotEmpty(ctx.Socket.SentMessages); - var sent = Encoding.UTF8.GetString(ctx.Socket.SentMessages[0]); + await WaitForMessageMatchingAsync(ctx.Socket, m => m.Contains("subathon_timer"), TimeSpan.FromSeconds(5)); + var sent = ctx.Socket.SentMessages + .Select(m => Encoding.UTF8.GetString(m)) + .First(m => m.Contains("subathon_timer")); Assert.Equal("{\"type\":\"subathon_timer\",\"total_seconds\":172800,\"days\":2,\"hours\":0,\"minutes\":0,\"seconds\":0,\"total_points\":5678,\"rounded_money\":6769,\"fractional_money\":6769.55,\"currency\":\"CAD\",\"is_paused\":false,\"is_locked\":false,\"is_reversed\":false,\"multiplier_points\":1,\"multiplier_time\":2,\"multiplier_start_time\":null,\"multiplier_seconds_total\":0,\"multiplier_seconds_remaining\":0,\"total_seconds_elapsed\":259200,\"total_seconds_added\":432000}", sent); AppServices.Provider = null!; + await server.StopAsync(); } @@ -425,11 +564,13 @@ public async Task WebSocket_SelectSend() }; await server.SelectSendAsync(client, data); - await WaitForMessageAsync(ctx.Socket, TimeSpan.FromSeconds(5)); - Assert.NotEmpty(ctx.Socket.SentMessages); - var sent = Encoding.UTF8.GetString(ctx.Socket.SentMessages[0]); + await WaitForMessageMatchingAsync(ctx.Socket, m => m.Contains("\"type\":\"test\""), TimeSpan.FromSeconds(5)); + var sent = ctx.Socket.SentMessages + .Select(m => Encoding.UTF8.GetString(m)) + .First(m => m.Contains("\"type\":\"test\"")); Assert.Equal("{\"type\":\"test\",\"points\":5}", sent); AppServices.Provider = null!; + await server.StopAsync(); } [Fact] @@ -447,13 +588,14 @@ public async Task WebSocket_ReceivePing_ReturnsPong() ctx.Socket.EnqueueClose(); await server.HandleWebSocketRequestAsync(ctx); - await WaitForMessageAsync(ctx.Socket, TimeSpan.FromSeconds(5)); - Assert.NotEmpty(ctx.Socket.SentMessages); - - var sent = Encoding.UTF8.GetString(ctx.Socket.SentMessages[0]); + await WaitForMessageMatchingAsync(ctx.Socket, m => m.Contains("pong"), TimeSpan.FromSeconds(5)); + var sent = ctx.Socket.SentMessages + .Select(m => Encoding.UTF8.GetString(m)) + .First(m => m.Contains("pong")); Assert.Equal("{\"ws_type\":\"pong\"}", sent); AppServices.Provider = null!; + await server.StopAsync(); } [Fact] @@ -475,6 +617,7 @@ public async Task WebSocket_ReceiveHello_DoesNotSendMessage() Assert.Empty(ctx.Socket.SentMessages); AppServices.Provider = null!; + await server.StopAsync(); } [Fact] @@ -509,110 +652,7 @@ public async Task WebSocket_ReceiveIntegrationSource_AddsSource_AndRaisesEvent() Assert.Equal(nameof(SubathonEventSource.KoFi), result); WebServerEvents.WebSocketIntegrationSourceChange -= handler; AppServices.Provider = null!; - } - - - [Fact] - public async Task WebSocket_ReceiveIntegrationSource_AddsSourceAndEvent() - { - SetupServices(); - var server = CreateServer(); - - var sourceTcs = new TaskCompletionSource(); - var eventTcs = new TaskCompletionSource(); - - Action handler = (src, connected) => - { - if (connected) - sourceTcs.TrySetResult(src); - }; - - Action handler2 = e => - { - eventTcs.TrySetResult(e); - }; - - WebServerEvents.WebSocketIntegrationSourceChange += handler; - SubathonEvents.SubathonEventCreated += handler2; - - try - { - var ctx = new MockHttpContext - { - IsWebSocket = true - }; - - ctx.Socket.EnqueueReceive( - "{\"ws_type\":\"IntegrationSource\",\"source\":\"KoFi\", \"type\": \"KoFiSub\", \"tier\":\"DEFAULT\", \"amount\": 1, \"user\":\"test\"}" - ); - ctx.Socket.EnqueueClose(); - - await server.HandleWebSocketRequestAsync(ctx); - - var result = await sourceTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); - var ev = await eventTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); - - Assert.Equal(nameof(SubathonEventSource.KoFi), result); - Assert.Equal(SubathonEventSource.KoFi, ev.Source); - Assert.Equal(SubathonEventType.KoFiSub, ev.EventType); - } - finally - { - WebServerEvents.WebSocketIntegrationSourceChange -= handler; - SubathonEvents.SubathonEventCreated -= handler2; - AppServices.Provider = null!; - } - } - - - [Fact] - public async Task WebSocket_ReceiveCommand() - { - SetupServices(); - var server = CreateServer(); - - var sourceTcs = new TaskCompletionSource(); - var eventTcs = new TaskCompletionSource(); - - Action handler = (src, connected) => - { - if (connected) - sourceTcs.TrySetResult(src); - }; - - Action handler2 = e => - { - eventTcs.TrySetResult(e); - }; - - WebServerEvents.WebSocketIntegrationSourceChange += handler; - SubathonEvents.SubathonEventCreated += handler2; - - try - { - var ctx = new MockHttpContext - { - IsWebSocket = true - }; - - ctx.Socket.EnqueueReceive( - "{\"ws_type\":\"Command\", \"type\": \"Command\", \"message\":\"\", \"command\": \"pause\", \"user\":\"test\"}" - ); - ctx.Socket.EnqueueClose(); - - await server.HandleWebSocketRequestAsync(ctx); - - var ev = await eventTcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); - - Assert.Equal(SubathonEventSource.External, ev.Source); - Assert.Equal(SubathonEventType.Command, ev.EventType); - } - finally - { - WebServerEvents.WebSocketIntegrationSourceChange -= handler; - SubathonEvents.SubathonEventCreated -= handler2; - AppServices.Provider = null!; - } + await server.StopAsync(); } diff --git a/SubathonManager.Tests/ServicesUnitTests/CommandServiceTests.cs b/SubathonManager.Tests/ServicesUnitTests/CommandServiceTests.cs index cf0fe39..a00a208 100644 --- a/SubathonManager.Tests/ServicesUnitTests/CommandServiceTests.cs +++ b/SubathonManager.Tests/ServicesUnitTests/CommandServiceTests.cs @@ -7,6 +7,7 @@ using IniParser.Model; using SubathonManager.Core.Enums; using SubathonManager.Core.Interfaces; +// ReSharper disable NullableWarningSuppressionIsUsed namespace SubathonManager.Tests.ServicesUnitTests; diff --git a/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs b/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs index b93e874..6c10879 100644 --- a/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs +++ b/SubathonManager.Tests/ServicesUnitTests/EventServiceTests.cs @@ -314,66 +314,6 @@ public async Task DonationEvent_CalculatesPointsAndSeconds() await conn.CloseAsync(); } - [Fact] - public async Task DonationEvent_InvalidCurrency() - { - var (service, options, conn) = await SetupServiceWithDb(0, false); - var ev = new SubathonEvent - { - Id = Guid.NewGuid(), - EventType = SubathonEventType.KoFiDonation, - Currency = "EEE", - Value = "10" - }; - - var (processed, _) = await service.ProcessSubathonEvent(ev); - await service.StopAsync(); - Assert.True(processed); // will have 0 values - - await using var db = new AppDbContext(options); - var sub = await db.SubathonDatas.FirstAsync(); - Assert.False(sub.Points > 0); - Assert.False(sub.MillisecondsCumulative > 0); - - var ev2 = await db.SubathonEvents.FirstAsync(); - Assert.Equal("EEE", ev2.Currency); - Assert.True(ev2.ProcessedToSubathon); - Assert.Equal(0, ev2.PointsValue); - Assert.Equal(0, ev2.SecondsValue); - - await conn.CloseAsync(); - } - - [Fact] - public async Task DonationEvent_InvalidCurrency2() - { - var (service, options, conn) = await SetupServiceWithDb(0, false); - var ev = new SubathonEvent - { - Id = Guid.NewGuid(), - EventType = SubathonEventType.KoFiDonation, - Currency = "", - Value = "10" - }; - - var (processed, _) = await service.ProcessSubathonEvent(ev); - await service.StopAsync(); - Assert.False(processed); // will have 0 values - - await using var db = new AppDbContext(options); - - var sub = await db.SubathonDatas.FirstAsync(); - Assert.False(sub.Points > 0); - Assert.False(sub.MillisecondsCumulative > 0); - - var ev2 = await db.SubathonEvents.FirstAsync(); - Assert.Equal("???", ev2.Currency); - Assert.False(ev2.ProcessedToSubathon); - Assert.Equal(0, ev2.PointsValue); - Assert.Equal(0, ev2.SecondsValue); - await conn.CloseAsync(); - } - [Fact] public async Task OrderEvent_WithCommission() { @@ -1528,6 +1468,67 @@ public async Task Command_SetMultiplier_WithDuration_SetsMultiplier() public class EventServiceSequentialTests { + [Fact] + public async Task DonationEvent_InvalidCurrency() + { + var (service, options, conn) = await EventServiceTests.SetupServiceWithDb(0, false); + var ev = new SubathonEvent + { + Id = Guid.NewGuid(), + EventType = SubathonEventType.KoFiDonation, + Currency = "EEE", + Value = "10" + }; + + var (processed, _) = await service.ProcessSubathonEvent(ev); + await service.StopAsync(); + Assert.True(processed); // will have 0 values + + await using var db = new AppDbContext(options); + var sub = await db.SubathonDatas.FirstAsync(); + Assert.False(sub.Points > 0); + Assert.False(sub.MillisecondsCumulative > 0); + + var ev2 = await db.SubathonEvents.FirstAsync(); + Assert.Equal("EEE", ev2.Currency); + Assert.True(ev2.ProcessedToSubathon); + Assert.Equal(0, ev2.PointsValue); + Assert.Equal(0, ev2.SecondsValue); + + await conn.CloseAsync(); + } + + [Fact] + public async Task DonationEvent_InvalidCurrency2() + { + var (service, options, conn) = await EventServiceTests.SetupServiceWithDb(0, false); + var ev = new SubathonEvent + { + Id = Guid.NewGuid(), + EventType = SubathonEventType.KoFiDonation, + Currency = "", + Value = "10" + }; + + var (processed, _) = await service.ProcessSubathonEvent(ev); + await service.StopAsync(); + Assert.False(processed); // will have 0 values + + await using var db = new AppDbContext(options); + + var sub = await db.SubathonDatas.FirstAsync(); + Assert.False(sub.Points > 0); + Assert.False(sub.MillisecondsCumulative > 0); + + var ev2 = await db.SubathonEvents.FirstAsync(); + Assert.Equal("???", ev2.Currency); + Assert.False(ev2.ProcessedToSubathon); + Assert.Equal(0, ev2.PointsValue); + Assert.Equal(0, ev2.SecondsValue); + await conn.CloseAsync(); + } + + [Fact] public async Task DeleteSubathonEvent_SubtractPoints_Command_ReversesPoints() { diff --git a/SubathonManager.Tests/SubathonManager.Tests.csproj b/SubathonManager.Tests/SubathonManager.Tests.csproj index dd5e7af..e1e0710 100644 --- a/SubathonManager.Tests/SubathonManager.Tests.csproj +++ b/SubathonManager.Tests/SubathonManager.Tests.csproj @@ -11,10 +11,10 @@ - + - - + + diff --git a/SubathonManager.Tests/Utility/EventUtil.cs b/SubathonManager.Tests/Utility/EventUtil.cs new file mode 100644 index 0000000..d5b87dc --- /dev/null +++ b/SubathonManager.Tests/Utility/EventUtil.cs @@ -0,0 +1,42 @@ +using System.Reflection; +using SubathonManager.Core.Events; +using SubathonManager.Core.Models; +namespace SubathonManager.Tests.Utility; + +public class EventUtil +{ + public static class SubathonEventCapture + { + private static readonly Lock Lock = new(); + + public static SubathonEvent? Capture(Action trigger) + { + lock (Lock) + { + typeof(SubathonEvents) + .GetField("SubathonEventCreated", BindingFlags.Static | BindingFlags.NonPublic) + ?.SetValue(null, null); + + SubathonEvent? captured = null; + void Handler(SubathonEvent e) => captured = e; + + SubathonEvents.SubathonEventCreated += Handler; + try + { + trigger(); + return captured; + } + finally + { + SubathonEvents.SubathonEventCreated -= Handler; + } + } + } + + public static SubathonEvent? CaptureRequired(Action trigger) + { + var ev = Capture(trigger); + return ev; + } + } +} diff --git a/SubathonManager.UI/EditRouteWindow.Handlers.cs b/SubathonManager.UI/EditRouteWindow.Handlers.cs index 3703677..597e199 100644 --- a/SubathonManager.UI/EditRouteWindow.Handlers.cs +++ b/SubathonManager.UI/EditRouteWindow.Handlers.cs @@ -4,6 +4,7 @@ using System.IO; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Media; using System.Windows.Threading; @@ -16,9 +17,11 @@ using SubathonManager.Core.Interfaces; using SubathonManager.Core.Models; using SubathonManager.Data; +using SubathonManager.UI.Views; using Wpf.Ui.Controls; using TextBox = Wpf.Ui.Controls.TextBox; // ReSharper disable NullableWarningSuppressionIsUsed +// ReSharper disable UnusedVariable namespace SubathonManager.UI; @@ -37,7 +40,7 @@ private async void CopyOverlayUrl_Click(object sender, RoutedEventArgs e) { if (_route == null) return; var config = AppServices.Provider.GetRequiredService(); - await UiUtils.UiUtils.TrySetClipboardTextAsync(_route.GetRouteUrl(config!)); + await UiUtils.UiUtils.TrySetClipboardTextAsync(_route.GetRouteUrl(config)); } catch (Exception ex) { @@ -54,7 +57,7 @@ private void OpenOverlayInBrowser_Click(object sender, RoutedEventArgs e) var config = AppServices.Provider.GetRequiredService(); Process.Start(new ProcessStartInfo { - FileName = _route.GetRouteUrl(config!), + FileName = _route.GetRouteUrl(config), UseShellExecute = true }); } @@ -172,6 +175,7 @@ await Task.Run(async () => { await Dispatcher.InvokeAsync(() => { SaveWidgetButton.Content = "Saved!"; } ); + UpdateSaveButtonBorder(SaveButtonBorder,false); await Task.Delay(1500); await Dispatcher.InvokeAsync(() => { SaveWidgetButton.Content = "Save"; } ); @@ -383,7 +387,7 @@ private void NumberOrNegativeOnly_PreviewTextInput(object sender, TextCompositio return; } - if (sender is Wpf.Ui.Controls.TextBox tb) + if (sender is TextBox tb) { if (e.Text == "-" && tb.SelectionStart == 0 && !tb.Text.Contains('-')) { @@ -417,7 +421,7 @@ private void NumberOrDecimalOnly_PreviewTextInput(object sender, TextComposition return; } - if (sender is Wpf.Ui.Controls.TextBox tb) + if (sender is TextBox tb) { if (e.Text == "." && !tb.Text.Contains('.')) { @@ -466,7 +470,64 @@ private void OpenEditorInBrowser_Click(object sender, RoutedEventArgs e) } catch { /**/ } } -#endregion GeneralHandlers + + private void ExportRoute_Click(object sender, RoutedEventArgs e) + { + if (_route == null) return; + var dialog = new ExportOverlayDialog(_route) + { + Owner = Application.Current.Windows + .OfType() + .FirstOrDefault(w => w.IsActive), + WindowStartupLocation = WindowStartupLocation.CenterOwner + }; + dialog.ShowDialog(); + } + + private void SuppressUnsavedChanges(Action action) + { + _suppressCount++; + try { action(); } + finally { _suppressCount--; } + } + + private void AttachChangeHandler(object sender, RoutedEventArgs routedEventArgs) + { + void Attach() + { + switch (sender) + { + case TextBox tb: + tb.TextChanged += Value_OnChanged; + break; + case ComboBox cb: + cb.SelectionChanged += Value_OnChanged; + break; + case CheckBox chk: + chk.Checked += Value_OnChanged; + chk.Unchecked += Value_OnChanged; + break; + case ToggleButton tb2: + tb2.Checked += Value_OnChanged; + tb2.Unchecked += Value_OnChanged; + break; + case Slider sld: + sld.ValueChanged += Value_OnChanged; + break; + case CssColorPicker csscp: + csscp.ColorChanged += Value_OnChanged; + break; + } + } + SuppressUnsavedChanges(Attach); + } + private void Value_OnChanged(object sender, RoutedEventArgs e) + { + if (_suppressCount > 0) return; + Dispatcher.Invoke( () => UpdateSaveButtonBorder(SaveButtonBorder, true)); + } + + #endregion GeneralHandlers #region CSSHandlers @@ -474,6 +535,7 @@ private void SizeValueBox_Loaded(object sender, RoutedEventArgs e) { if (sender is not TextBox { Tag: CssVariable cssVar } tb) return; tb.TextChanged += SizeValueBox_TextChanged; + AttachChangeHandler(sender, e); } private void SizeValueBox_TextChanged(object sender, TextChangedEventArgs e) @@ -486,7 +548,10 @@ private void SizeValueBox_TextChanged(object sender, TextChangedEventArgs e) private void SizeUnitBox_Loaded(object sender, RoutedEventArgs e) { if (sender is ComboBox cb) + { cb.SelectionChanged += SizeUnitBox_SelectionChanged; + AttachChangeHandler(sender, e); + } } private void SizeUnitBox_SelectionChanged(object sender, SelectionChangedEventArgs e) @@ -495,7 +560,7 @@ private void SizeUnitBox_SelectionChanged(object sender, SelectionChangedEventAr if (e.AddedItems.Count == 0) return; var unit = cb.SelectedItem as string ?? "px"; - if ((cssVar.Value ?? "").EndsWith(unit)) return; + if (cssVar.Value.EndsWith(unit)) return; var numericPart = IsNumberRegex().Match(cssVar.Value ?? "").Value; cssVar.Value = numericPart + unit; @@ -520,6 +585,7 @@ private void JsIntBox_Loaded(object sender, RoutedEventArgs e) else ev.Handled = true; }; + AttachChangeHandler(sender, e); } private void JsFloatBox_Loaded(object sender, RoutedEventArgs e) @@ -535,6 +601,7 @@ private void JsFloatBox_Loaded(object sender, RoutedEventArgs e) else ev.Handled = true; }; + AttachChangeHandler(sender, e); } private void JsBoolBox_Loaded(object sender, RoutedEventArgs e) @@ -545,6 +612,7 @@ private void JsBoolBox_Loaded(object sender, RoutedEventArgs e) cb.Checked += (_, __) => jsVar.Value = "True"; cb.Unchecked += (_, __) => jsVar.Value = "False"; }); + AttachChangeHandler(sender, e); } private void JsEventTypeSelectBox_Loaded(object sender, RoutedEventArgs e) @@ -561,6 +629,8 @@ private void JsEventTypeSelectBox_Loaded(object sender, RoutedEventArgs e) { cb.SelectionChanged += (_, __) => jsVar.Value = $"{cb.SelectedValue}"; }); + + AttachChangeHandler(sender, e); } private void JsEventSubTypeSelectBox_Loaded(object sender, RoutedEventArgs e) @@ -576,6 +646,7 @@ private void JsEventSubTypeSelectBox_Loaded(object sender, RoutedEventArgs e) { cb.SelectionChanged += (_, __) => jsVar.Value = $"{cb.SelectedValue}"; }); + AttachChangeHandler(sender, e); } private void JsStringSelectBox_Loaded(object sender, RoutedEventArgs e) @@ -596,6 +667,7 @@ private void JsStringSelectBox_Loaded(object sender, RoutedEventArgs e) jsVar.Value = string.Join(',', newVal); }; }); + AttachChangeHandler(sender, e); } private void JsFileVar_Loaded(object sender, RoutedEventArgs e) @@ -620,11 +692,12 @@ private void JsFileVar_Loaded(object sender, RoutedEventArgs e) jsVar.Value = path; valueBtn.Content = path == "./" ? "./" : path.Split('/').Last(); valueBtn.ToolTip = path; + UpdateSaveButtonBorder(SaveButtonBorder,true); }; var openBtn = new Wpf.Ui.Controls.Button { - Icon = new Wpf.Ui.Controls.SymbolIcon { Symbol = Wpf.Ui.Controls.SymbolRegular.Open24 }, + Icon = new SymbolIcon { Symbol = SymbolRegular.Open24 }, ToolTip = "Open", Width = 40, Height = 30, Margin = new Thickness(0, 0, 55, 2), Padding = new Thickness(2) }; openBtn.Click += (_, __) => @@ -638,7 +711,7 @@ private void JsFileVar_Loaded(object sender, RoutedEventArgs e) var removeBtn = new Wpf.Ui.Controls.Button { - Icon = new Wpf.Ui.Controls.SymbolIcon { Symbol = Wpf.Ui.Controls.SymbolRegular.Delete24 }, + Icon = new SymbolIcon { Symbol = SymbolRegular.Delete24 }, Width = 40, Height = 30, ToolTip = "Clear Value", Foreground = Brushes.Red, Cursor = Cursors.Hand, Margin = new Thickness(15, 0, 0, 0), Padding = new Thickness(2) @@ -694,6 +767,7 @@ private void JsEventTypeList_Loaded(object sender, RoutedEventArgs e) chkBox.Checked += (_, __) => UpdateEventListValues(jsVar, outerPanel); chkBox.Unchecked += (_, __) => UpdateEventListValues(jsVar, outerPanel); chkboxList.Children.Add(chkBox); + AttachChangeHandler(chkBox, e); } groupExpander.Content = chkboxList; outerPanel.Children.Add(groupExpander); @@ -722,6 +796,7 @@ private void JsEventSubTypeList_Loaded(object sender, RoutedEventArgs e) chkBox.Checked += (_, __) => UpdateEventListValues(jsVar, chkboxList); chkBox.Unchecked += (_, __) => UpdateEventListValues(jsVar, chkboxList); chkboxList.Children.Add(chkBox); + AttachChangeHandler(chkBox, e); } expander.Content = chkboxList; } @@ -743,6 +818,7 @@ private void JsPercentSlider_Loaded(object sender, RoutedEventArgs e) tb.Text = intVal.ToString(); }; }); + AttachChangeHandler(sender, e); } private void JsPercentBox_Loaded(object sender, RoutedEventArgs e) @@ -767,6 +843,7 @@ private void JsPercentBox_Loaded(object sender, RoutedEventArgs e) if (FindPercentSiblingSlider(tb) is { } slider && (int)slider.Value != val) slider.Value = val; }; + AttachChangeHandler(sender, e); } private Slider? FindPercentSiblingSlider(System.Windows.Controls.TextBox tb) diff --git a/SubathonManager.UI/EditRouteWindow.xaml b/SubathonManager.UI/EditRouteWindow.xaml index d088ca6..856a9e5 100644 --- a/SubathonManager.UI/EditRouteWindow.xaml +++ b/SubathonManager.UI/EditRouteWindow.xaml @@ -116,9 +116,15 @@ + + + @@ -260,7 +266,7 @@ - + - + - + - + - + - + - + @@ -301,8 +314,17 @@