diff --git a/tests/Beutl.UnitTests/Beutl.UnitTests.csproj b/tests/Beutl.UnitTests/Beutl.UnitTests.csproj index 2de8264c7..2528c92fd 100644 --- a/tests/Beutl.UnitTests/Beutl.UnitTests.csproj +++ b/tests/Beutl.UnitTests/Beutl.UnitTests.csproj @@ -26,6 +26,9 @@ + + + diff --git a/tests/Beutl.UnitTests/Configuration/DefaultPreferencesTests.cs b/tests/Beutl.UnitTests/Configuration/DefaultPreferencesTests.cs new file mode 100644 index 000000000..17bd78abb --- /dev/null +++ b/tests/Beutl.UnitTests/Configuration/DefaultPreferencesTests.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using Beutl.Configuration; + +namespace Beutl.UnitTests.Configuration; + +public class DefaultPreferencesTests +{ + private static string NewPrefsFile() + { + return Path.Combine(ArtifactProvider.GetArtifactDirectory(), "prefs.json"); + } + + [Test] + public void SetGet_PrimitiveTypes() + { + string file = NewPrefsFile(); + if (File.Exists(file)) File.Delete(file); + var prefs = new DefaultPreferences(file); + + prefs.Set("i", 42); + prefs.Set("d", 3.14159); + prefs.Set("b", true); + var dt = new DateTime(2023, 1, 2, 3, 4, 5, DateTimeKind.Utc); + prefs.Set("t", dt); + prefs.Set("s", "hello"); + + Assert.That(prefs.Get("i", 0), Is.EqualTo(42)); + Assert.That(prefs.Get("d", 0.0), Is.EqualTo(3.14159)); + Assert.That(prefs.Get("b", false), Is.True); + Assert.That(prefs.Get("t", DateTime.MinValue), Is.EqualTo(dt)); + Assert.That(prefs.Get("s", string.Empty), Is.EqualTo("hello")); + } + + [Test] + public void RemoveAndClear_Works() + { + string file = NewPrefsFile(); + if (File.Exists(file)) File.Delete(file); + var prefs = new DefaultPreferences(file); + + prefs.Set("x", 1); + Assert.That(prefs.ContainsKey("x"), Is.True); + prefs.Remove("x"); + Assert.That(prefs.ContainsKey("x"), Is.False); + Assert.That(prefs.Get("x", -1), Is.EqualTo(-1)); + + prefs.Set("a", 10); + prefs.Set("b", 20); + prefs.Clear(); + Assert.That(prefs.ContainsKey("a"), Is.False); + Assert.That(prefs.ContainsKey("b"), Is.False); + } + + [Test] + public void Persistence_LoadsSavedValues() + { + string file = NewPrefsFile(); + if (File.Exists(file)) File.Delete(file); + var prefs = new DefaultPreferences(file); + + prefs.Set("i", 7); + prefs.Set("s", "foo"); + + var prefs2 = new DefaultPreferences(file); + Assert.That(prefs2.Get("i", 0), Is.EqualTo(7)); + Assert.That(prefs2.Get("s", string.Empty), Is.EqualTo("foo")); + } + + [Test] + public void Load_InvalidJson_UsesEmpty() + { + string file = NewPrefsFile(); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + File.WriteAllText(file, "{invalid json}"); + + var prefs = new DefaultPreferences(file); + Assert.That(prefs.ContainsKey("anything"), Is.False); + Assert.That(prefs.Get("x", 123), Is.EqualTo(123)); + } + + [Test] + public void UnsupportedType_Throws() + { + string file = NewPrefsFile(); + if (File.Exists(file)) File.Delete(file); + var prefs = new DefaultPreferences(file); + + Assert.Throws(() => prefs.Set("g", Guid.NewGuid())); + Assert.Throws(() => prefs.Get("g", Guid.Empty)); + } +} + diff --git a/tests/Beutl.UnitTests/Configuration/EditorConfigTests.cs b/tests/Beutl.UnitTests/Configuration/EditorConfigTests.cs new file mode 100644 index 000000000..c1b2b1e4e --- /dev/null +++ b/tests/Beutl.UnitTests/Configuration/EditorConfigTests.cs @@ -0,0 +1,35 @@ +using System.Linq; +using System.Text.Json.Nodes; +using Beutl.Configuration; +using Beutl.Serialization; + +namespace Beutl.UnitTests.Configuration; + +public class EditorConfigTests +{ + [Test] + public void SerializeDeserialize_LibraryTabDisplayModes() + { + var cfg = new EditorConfig(); + cfg.LibraryTabDisplayModes.Clear(); + cfg.LibraryTabDisplayModes["Custom1"] = LibraryTabDisplayMode.Hide; + cfg.LibraryTabDisplayModes["Custom2"] = LibraryTabDisplayMode.Show; + + var json = new JsonObject(); + var ctx = new JsonSerializationContext(typeof(EditorConfig), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(ctx)) + { + cfg.Serialize(ctx); + } + + // Clear and restore into a new instance + var cfg2 = new EditorConfig(); + var ctx2 = new JsonSerializationContext(typeof(EditorConfig), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(ctx2)) + { + cfg2.Deserialize(ctx2); + } + + Assert.That(cfg2.LibraryTabDisplayModes.ToArray(), Is.EquivalentTo(cfg.LibraryTabDisplayModes.ToArray())); + } +} diff --git a/tests/Beutl.UnitTests/Configuration/ExtensionConfigTests.cs b/tests/Beutl.UnitTests/Configuration/ExtensionConfigTests.cs new file mode 100644 index 000000000..587779108 --- /dev/null +++ b/tests/Beutl.UnitTests/Configuration/ExtensionConfigTests.cs @@ -0,0 +1,39 @@ +using System.Linq; +using System.Text.Json.Nodes; +using Beutl.Configuration; +using Beutl.Serialization; + +namespace Beutl.UnitTests.Configuration; + +public class ExtensionConfigTests +{ + [Test] + public void SerializeDeserialize_RoundTrip() + { + var cfg = new ExtensionConfig(); + cfg.EditorExtensions[".txt"] = new Beutl.Collections.CoreList( + new[] { new ExtensionConfig.TypeLazy("[System]System:Int32"), new ExtensionConfig.TypeLazy("[System]System:String") }); + cfg.DecoderPriority.Add(new ExtensionConfig.TypeLazy("[System]System:Double")); + + var json = new JsonObject(); + var ctx = new JsonSerializationContext(typeof(ExtensionConfig), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(ctx)) + { + cfg.Serialize(ctx); + } + + var cfg2 = new ExtensionConfig(); + var ctx2 = new JsonSerializationContext(typeof(ExtensionConfig), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(ctx2)) + { + cfg2.Deserialize(ctx2); + } + + Assert.That(cfg2.EditorExtensions.ContainsKey(".txt"), Is.True); + Assert.That(cfg2.EditorExtensions[".txt"].Select(x => x.FormattedTypeName).ToArray(), + Is.EqualTo(cfg.EditorExtensions[".txt"].Select(x => x.FormattedTypeName).ToArray())); + + Assert.That(cfg2.DecoderPriority.Select(x => x.FormattedTypeName).ToArray(), + Is.EqualTo(cfg.DecoderPriority.Select(x => x.FormattedTypeName).ToArray())); + } +} diff --git a/tests/Beutl.UnitTests/Configuration/FontConfigTests.cs b/tests/Beutl.UnitTests/Configuration/FontConfigTests.cs new file mode 100644 index 000000000..c54ba8d85 --- /dev/null +++ b/tests/Beutl.UnitTests/Configuration/FontConfigTests.cs @@ -0,0 +1,34 @@ +using System.IO; +using System.Linq; +using System.Text.Json.Nodes; +using Beutl.Configuration; +using Beutl.Serialization; + +namespace Beutl.UnitTests.Configuration; + +public class FontConfigTests +{ + [Test] + public void Deserialize_SynchronizesFontDirectories() + { + var cfg = new FontConfig(); + string[] newDirs = + { + Path.Combine(ArtifactProvider.GetArtifactDirectory(), "Fonts1"), + Path.Combine(ArtifactProvider.GetArtifactDirectory(), "Fonts2"), + }; + + var json = new JsonObject + { + ["FontDirectories"] = new JsonArray(newDirs.Select(d => (JsonNode)d).ToArray()) + }; + + var ctx = new JsonSerializationContext(typeof(FontConfig), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(ctx)) + { + cfg.Deserialize(ctx); + } + + Assert.That(cfg.FontDirectories.ToArray(), Is.EqualTo(newDirs)); + } +} diff --git a/tests/Beutl.UnitTests/Configuration/GlobalConfigurationTests.cs b/tests/Beutl.UnitTests/Configuration/GlobalConfigurationTests.cs new file mode 100644 index 000000000..785b053b9 --- /dev/null +++ b/tests/Beutl.UnitTests/Configuration/GlobalConfigurationTests.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; +using System.Text.Json.Nodes; +using Beutl.Configuration; + +namespace Beutl.UnitTests.Configuration; + +public class GlobalConfigurationTests +{ + private static string NewSettingsFile() + { + return Path.Combine(ArtifactProvider.GetArtifactDirectory(), "settings.json"); + } + + private static bool? ReadBackupSetting(string file) + { + if (JsonHelper.JsonRestore(file) is JsonObject obj && obj["Backup"] is JsonObject backup) + { + return backup.TryGetPropertyValueAsJsonValue("BackupSettings", out bool value) ? value : null; + } + return null; + } + + private static bool? ReadEditorSwapTimeline(string file) + { + if (JsonHelper.JsonRestore(file) is JsonObject obj && obj["Editor"] is JsonObject editor) + { + return editor.TryGetPropertyValueAsJsonValue("SwapTimelineScrollDirection", out bool value) ? value : null; + } + return null; + } + + [Test] + public void Save_WritesExpectedEditorSection() + { + var gc = GlobalConfiguration.Instance; + string file = NewSettingsFile(); + + bool original = gc.BackupConfig.BackupSettings; + bool originalEditor = gc.EditorConfig.SwapTimelineScrollDirection; + try + { + gc.EditorConfig.SwapTimelineScrollDirection = true; + gc.Save(file); + + bool? written = ReadEditorSwapTimeline(file); + Assert.That(written, Is.True); + } + finally + { + gc.BackupConfig.BackupSettings = original; + gc.EditorConfig.SwapTimelineScrollDirection = originalEditor; + } + } + + [Test] + public void AutoSave_OnSubConfigChange_WritesFile() + { + var gc = GlobalConfiguration.Instance; + string file = Path.Combine(ArtifactProvider.GetArtifactDirectory(), "autosave.settings.json"); + + bool original = gc.EditorConfig.SwapTimelineScrollDirection; + try + { + gc.Save(file); + bool? before = ReadEditorSwapTimeline(file); + bool newVal = !gc.EditorConfig.SwapTimelineScrollDirection; + gc.EditorConfig.SwapTimelineScrollDirection = newVal; + + bool? after = ReadEditorSwapTimeline(file); + Assert.That(after, Is.EqualTo(newVal)); + + // Sanity: before may be null if first write, but after should be defined + Assert.That(after.HasValue, Is.True); + } + finally + { + gc.EditorConfig.SwapTimelineScrollDirection = original; + } + } +} diff --git a/tests/Beutl.UnitTests/Configuration/PreferencesDefaultTests.cs b/tests/Beutl.UnitTests/Configuration/PreferencesDefaultTests.cs new file mode 100644 index 000000000..af69032ad --- /dev/null +++ b/tests/Beutl.UnitTests/Configuration/PreferencesDefaultTests.cs @@ -0,0 +1,27 @@ +using System; +using System.IO; +using Beutl.Configuration; + +namespace Beutl.UnitTests.Configuration; + +public class PreferencesDefaultTests +{ + [Test] + public void Default_UsesBEUTL_HOME_AndPersists() + { + string dir = Path.Combine(ArtifactProvider.GetArtifactDirectory(), "prefs-home"); + Directory.CreateDirectory(dir); + Environment.SetEnvironmentVariable(BeutlEnvironment.HomeVariable, dir); + + // Ensure preferences file doesn't exist before + string file = Path.Combine(dir, "preferences.json"); + if (File.Exists(file)) File.Delete(file); + + // First access triggers static initialization using BEUTL_HOME + Preferences.Default.Set("k", 123); + int v = Preferences.Default.Get("k", 0); + Assert.That(v, Is.EqualTo(123)); + Assert.That(File.Exists(file), Is.True); + } +} + diff --git a/tests/Beutl.UnitTests/Configuration/TelemetryAndBackupConfigTests.cs b/tests/Beutl.UnitTests/Configuration/TelemetryAndBackupConfigTests.cs new file mode 100644 index 000000000..c96c9bee2 --- /dev/null +++ b/tests/Beutl.UnitTests/Configuration/TelemetryAndBackupConfigTests.cs @@ -0,0 +1,29 @@ +using Beutl.Configuration; + +namespace Beutl.UnitTests.Configuration; + +public class TelemetryAndBackupConfigTests +{ + [Test] + public void Telemetry_PropertyChange_RaisesConfigurationChanged() + { + var cfg = new TelemetryConfig(); + int changed = 0; + cfg.ConfigurationChanged += (_, _) => changed++; + + cfg.Beutl_Logging = true; + Assert.That(changed, Is.GreaterThanOrEqualTo(1)); + } + + [Test] + public void Backup_PropertyChange_RaisesConfigurationChanged() + { + var cfg = new BackupConfig(); + int changed = 0; + cfg.ConfigurationChanged += (_, _) => changed++; + + cfg.BackupSettings = !cfg.BackupSettings; + Assert.That(changed, Is.GreaterThanOrEqualTo(1)); + } +} + diff --git a/tests/Beutl.UnitTests/Configuration/ViewConfigTests.cs b/tests/Beutl.UnitTests/Configuration/ViewConfigTests.cs new file mode 100644 index 000000000..163cb5772 --- /dev/null +++ b/tests/Beutl.UnitTests/Configuration/ViewConfigTests.cs @@ -0,0 +1,77 @@ +using System; +using System.Linq; +using System.Text.Json.Nodes; +using Beutl.Configuration; +using Beutl.Serialization; + +namespace Beutl.UnitTests.Configuration; + +public class ViewConfigTests +{ + [Test] + public void UpdateRecentFile_MovesToFront_NoDuplicates_RaisesEvent() + { + var cfg = new ViewConfig(); + int changed = 0; + cfg.ConfigurationChanged += (_, _) => changed++; + + cfg.UpdateRecentFile("a"); + cfg.UpdateRecentFile("b"); + cfg.UpdateRecentFile("a"); + + Assert.That(cfg.RecentFiles.Count, Is.EqualTo(2)); + Assert.That(cfg.RecentFiles[0], Is.EqualTo("a")); + Assert.That(cfg.RecentFiles[1], Is.EqualTo("b")); + Assert.That(changed, Is.GreaterThanOrEqualTo(3)); + } + + [Test] + public void SerializeDeserialize_WindowAndRecents() + { + var cfg = new ViewConfig + { + WindowPosition = (100, 200), + WindowSize = (1280, 720), + IsWindowMaximized = true, + UseCustomAccentColor = true, + CustomAccentColor = "#112233", + }; + cfg.UpdateRecentFile("file1"); + cfg.UpdateRecentFile("file2"); + cfg.UpdateRecentProject("proj1"); + cfg.UpdateRecentProject("proj2"); + + var json = new JsonObject(); + var ser = new JsonSerializationContext(typeof(ViewConfig), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(ser)) + { + cfg.Serialize(ser); + } + + var cfg2 = new ViewConfig(); + var deser = new JsonSerializationContext(typeof(ViewConfig), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(deser)) + { + cfg2.Deserialize(deser); + } + + Assert.That(cfg2.WindowPosition, Is.EqualTo((100, 200))); + Assert.That(cfg2.WindowSize, Is.EqualTo((1280, 720))); + Assert.That(cfg2.IsWindowMaximized, Is.True); + Assert.That(cfg2.UseCustomAccentColor, Is.True); + Assert.That(cfg2.CustomAccentColor, Is.EqualTo("#112233")); + Assert.That(cfg2.RecentFiles.ToArray(), Is.EqualTo(cfg.RecentFiles.ToArray())); + Assert.That(cfg2.RecentProjects.ToArray(), Is.EqualTo(cfg.RecentProjects.ToArray())); + } + + [Test] + public void PropertyChange_RaisesConfigurationChanged() + { + var cfg = new ViewConfig(); + int changed = 0; + cfg.ConfigurationChanged += (_, _) => changed++; + + cfg.Theme = cfg.Theme == ViewConfig.ViewTheme.Dark ? ViewConfig.ViewTheme.Light : ViewConfig.ViewTheme.Dark; + Assert.That(changed, Is.GreaterThanOrEqualTo(1)); + } +} diff --git a/tests/Beutl.UnitTests/Core/BeutlEnvironmentTests.cs b/tests/Beutl.UnitTests/Core/BeutlEnvironmentTests.cs new file mode 100644 index 000000000..f52e5174e --- /dev/null +++ b/tests/Beutl.UnitTests/Core/BeutlEnvironmentTests.cs @@ -0,0 +1,43 @@ +using System; +using System.IO; + +namespace Beutl.UnitTests.Core; + +public class BeutlEnvironmentTests +{ + [Test] + public void HomeDirectory_UsesEnvVarWhenExists() + { + string dir = Path.Combine(ArtifactProvider.GetArtifactDirectory(), "home"); + Directory.CreateDirectory(dir); + Environment.SetEnvironmentVariable(BeutlEnvironment.HomeVariable, dir); + + string home = BeutlEnvironment.GetHomeDirectoryPath(); + Assert.That(home, Is.EqualTo(dir)); + } + + [Test] + public void HomeDirectory_FallsBackWhenEnvMissingOrNotExists() + { + // Set to a non-existing directory + string dir = Path.Combine(ArtifactProvider.GetArtifactDirectory(), "missing-home"); + if (Directory.Exists(dir)) Directory.Delete(dir, true); + Environment.SetEnvironmentVariable(BeutlEnvironment.HomeVariable, dir); + + string expected = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".beutl"); + string home = BeutlEnvironment.GetHomeDirectoryPath(); + Assert.That(home, Is.EqualTo(expected)); + } + + [Test] + public void PackagesAndSideloads_CombineCorrectly() + { + string dir = Path.Combine(ArtifactProvider.GetArtifactDirectory(), "home2"); + Directory.CreateDirectory(dir); + Environment.SetEnvironmentVariable(BeutlEnvironment.HomeVariable, dir); + + Assert.That(BeutlEnvironment.GetPackagesDirectoryPath(), Is.EqualTo(Path.Combine(dir, "packages"))); + Assert.That(BeutlEnvironment.GetSideloadsDirectoryPath(), Is.EqualTo(Path.Combine(dir, "sideloads"))); + } +} + diff --git a/tests/Beutl.UnitTests/Core/CoreObjectExtensionsTests.cs b/tests/Beutl.UnitTests/Core/CoreObjectExtensionsTests.cs new file mode 100644 index 000000000..ebe256ca6 --- /dev/null +++ b/tests/Beutl.UnitTests/Core/CoreObjectExtensionsTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; + +namespace Beutl.UnitTests.Core; + +public class CoreObjectExtensionsTests +{ + private sealed class Obj : CoreObject + { + public static readonly CoreProperty ValueProperty; + + static Obj() + { + ValueProperty = ConfigureProperty(nameof(Value)) + .DefaultValue(0) + .Register(); + } + + public int Value + { + get => GetValue(ValueProperty); + set => SetValue(ValueProperty, value); + } + } + + [Test] + public void GetObservable_PushesInitialAndSubsequentValues() + { + var o = new Obj(); + var received = new List(); + using var sub = o.GetObservable(Obj.ValueProperty).Subscribe(v => received.Add(v)); + + // initial + Assert.That(received.Count, Is.EqualTo(1)); + Assert.That(received[0], Is.EqualTo(0)); + + // change -> two pushes (handler attached twice) + o.Value = 10; + Assert.That(received.Count, Is.EqualTo(3)); + Assert.That(received[1], Is.EqualTo(10)); + Assert.That(received[2], Is.EqualTo(10)); + } + + [Test] + public void GetPropertyChangedObservable_EmitsOnlyMatchingProperty() + { + var o = new Obj(); + int count = 0; + using var sub = o.GetPropertyChangedObservable(Obj.ValueProperty).Subscribe(_ => count++); + + o.Value = 1; // should emit + o.Value = 2; // should emit again + Assert.That(count, Is.EqualTo(2)); + } + + private sealed class DummyItem : ProjectItem {} + + [Test] + public void FindById_TraversesHierarchicalChildren() + { + var proj = new Project(); + var item = new DummyItem { FileName = Path.Combine(ArtifactProvider.GetArtifactDirectory(), "a.item") }; + proj.Items.Add(item); + + ICoreObject? found = proj.FindById(item.Id); + Assert.That(found, Is.SameAs(item)); + } +} diff --git a/tests/Beutl.UnitTests/Core/CorePropertyJsonConverterTests.cs b/tests/Beutl.UnitTests/Core/CorePropertyJsonConverterTests.cs new file mode 100644 index 000000000..267457e5e --- /dev/null +++ b/tests/Beutl.UnitTests/Core/CorePropertyJsonConverterTests.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using Beutl.Serialization; + +namespace Beutl.UnitTests.Core; + +public class CorePropertyJsonConverterTests +{ + private sealed class Obj : CoreObject + { + public static readonly CoreProperty P1Property; + static Obj() + { + P1Property = ConfigureProperty(nameof(P1)).DefaultValue(0).Register(); + } + public int P1 { get => GetValue(P1Property); set => SetValue(P1Property, value); } + } + + [Test] + public void SerializeAndRead_CoreProperty_ByNameAndOwner() + { + var prop = Obj.P1Property; + string json = JsonSerializer.Serialize(prop, JsonHelper.SerializerOptions); + var read = JsonSerializer.Deserialize(json, JsonHelper.SerializerOptions); + + Assert.That(read, Is.Not.Null); + Assert.That(read!.Name, Is.EqualTo(prop.Name)); + Assert.That(read.OwnerType, Is.EqualTo(prop.OwnerType)); + } +} + diff --git a/tests/Beutl.UnitTests/Core/CorePropertyMetadataTests.cs b/tests/Beutl.UnitTests/Core/CorePropertyMetadataTests.cs new file mode 100644 index 000000000..c4ea62086 --- /dev/null +++ b/tests/Beutl.UnitTests/Core/CorePropertyMetadataTests.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Beutl.Serialization; + +namespace Beutl.UnitTests.Core; + +public class CorePropertyMetadataTests +{ + private class MetaObject : CoreObject + { + // Captures attributes for metadata + [NotAutoSerialized] + public int Hidden { get; set; } + + [System.ComponentModel.DataAnnotations.Range(0, 10)] + public int Age2 + { + get => GetValue(Age2Property); + set => SetValue(Age2Property, value); + } + + [JsonConverter(typeof(IntTagConverter))] + public int Number { get; set; } + + public static readonly CoreProperty HiddenProperty; + public static readonly CoreProperty Age2Property; + public static readonly CoreProperty UnbackedProperty; + public static readonly CoreProperty NumberProperty; + + static MetaObject() + { + HiddenProperty = ConfigureProperty(nameof(Hidden)) + .Accessor(o => o.Hidden, (o, v) => o.Hidden = v) + .DefaultValue(0) + .Register(); + + Age2Property = ConfigureProperty(nameof(Age2)) + .DefaultValue(0) + .SetAttribute(new System.ComponentModel.DataAnnotations.RangeAttribute(0, 10)) + .Register(); + + NumberProperty = ConfigureProperty(nameof(Number)) + .Accessor(o => o.Number, (o, v) => o.Number = v) + .DefaultValue(0) + .Register(); + + UnbackedProperty = ConfigureProperty(nameof(Unbacked)) + .DefaultValue(0) + .Register(); + } + + public int Unbacked + { + get => GetValue(UnbackedProperty); + set => SetValue(UnbackedProperty, value); + } + } + + private sealed class DerivedMetaObject : MetaObject + { + static DerivedMetaObject() + { + // Override default for Number + MetaObject.UnbackedProperty.OverrideDefaultValue(5); + } + } + + private sealed class IntTagConverter : JsonConverter + { + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + string? s = reader.GetString(); + if (s != null && s.StartsWith("i:")) + { + return int.Parse(s.AsSpan(2)); + } + throw new JsonException(); + } + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + { + writer.WriteStringValue($"i:{value}"); + } + } + + [Test] + public void NotAutoSerialized_ExcludedFromJson() + { + var obj = new MetaObject { Hidden = 123 }; + var json = new JsonObject(); + var ctx = new JsonSerializationContext(typeof(MetaObject), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(ctx)) + { + obj.Serialize(ctx); + } + + Assert.That(json.ContainsKey(nameof(MetaObject.Hidden)), Is.False); + } + + // Note: Validation via DataAnnotations is validated in broader integration tests; per-property validators + // can be integration-tested where they are used (e.g., editors). Keeping serialization-focused here. + + [Test] + public void JsonConverterAttribute_OnProperty_IsUsed() + { + var obj = new MetaObject { Number = 42 }; + var json = new JsonObject(); + var ctx = new JsonSerializationContext(typeof(MetaObject), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(ctx)) + { + obj.Serialize(ctx); + } + + var numberNode = json[nameof(MetaObject.Number)] as JsonValue; + Assert.That(numberNode, Is.Not.Null); + Assert.That(numberNode!.ToJsonString(), Is.EqualTo("\"i:42\"")); + + var obj2 = new MetaObject(); + var ctx2 = new JsonSerializationContext(typeof(MetaObject), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(ctx2)) + { + obj2.Deserialize(ctx2); + } + Assert.That(obj2.Number, Is.EqualTo(42)); + } + + [Test] + public void OverrideDefaultValue_WorksInDerivedClass() + { + var d = new DerivedMetaObject(); + Assert.That(d.GetValue(MetaObject.UnbackedProperty), Is.EqualTo(5)); + } +} diff --git a/tests/Beutl.UnitTests/Core/EnumAndCultureTests.cs b/tests/Beutl.UnitTests/Core/EnumAndCultureTests.cs new file mode 100644 index 000000000..30d7533a0 --- /dev/null +++ b/tests/Beutl.UnitTests/Core/EnumAndCultureTests.cs @@ -0,0 +1,32 @@ +using System; + +namespace Beutl.UnitTests.Core; + +public class EnumAndCultureTests +{ + [Flags] + private enum ByteFlags : byte { A=1, B=2, C=4 } + [Flags] + private enum ShortFlags : short { A=1, B=2, C=4 } + [Flags] + private enum IntFlags : int { A=1, B=2, C=4 } + [Flags] + private enum LongFlags : long { A=1, B=2, C=4 } + + [Test] + public void EnumExtensions_HasAllAndAny() + { + Assert.That((ByteFlags.A | ByteFlags.B).HasAllFlags(ByteFlags.A | ByteFlags.B), Is.True); + Assert.That((ShortFlags.A | ShortFlags.C).HasAnyFlag(ShortFlags.B | ShortFlags.C), Is.True); + Assert.That(IntFlags.A.HasAnyFlag(IntFlags.B), Is.False); + Assert.That((LongFlags.A | LongFlags.B | LongFlags.C).HasAllFlags(LongFlags.A | LongFlags.C), Is.True); + } + + [Test] + public void CultureNameValidation_Works() + { + Assert.That(CultureNameValidation.IsValid("en-US"), Is.True); + Assert.That(CultureNameValidation.IsValid("xx-YY"), Is.False); + } +} + diff --git a/tests/Beutl.UnitTests/Core/GroupLibraryItemMergeTests.cs b/tests/Beutl.UnitTests/Core/GroupLibraryItemMergeTests.cs new file mode 100644 index 000000000..2ee8aeb16 --- /dev/null +++ b/tests/Beutl.UnitTests/Core/GroupLibraryItemMergeTests.cs @@ -0,0 +1,29 @@ +using System; +using Beutl.Services; + +namespace Beutl.UnitTests.Core; + +public class GroupLibraryItemMergeTests +{ + private sealed class Dummy : CoreObject { } + + [Test] + public void Merge_GroupsWithSameName_CombinesItems() + { + var g1 = new GroupLibraryItem("Root"); + g1.Add(KnownLibraryItemFormats.Drawable, "D1"); + g1.AddGroup("Sub", gg => gg.Add(KnownLibraryItemFormats.Sound, "S1")); + + var g2 = new GroupLibraryItem("Root"); + g2.Add(KnownLibraryItemFormats.Geometry, "G1"); + g2.AddGroup("Sub", gg => gg.Add(KnownLibraryItemFormats.SourceOperator, "SO1")); + + g1.Merge(g2); + + // g1 should contain both D1/G1 under root and both S1/SO1 under Sub + Assert.That(g1.Items.Count, Is.EqualTo(3)); // D1, G1, Sub + var sub = (GroupLibraryItem)g1.Items[2]; + Assert.That(sub.Items.Count, Is.EqualTo(2)); + } +} + diff --git a/tests/Beutl.UnitTests/Core/HierarchicalEventsTests.cs b/tests/Beutl.UnitTests/Core/HierarchicalEventsTests.cs new file mode 100644 index 000000000..22db7787e --- /dev/null +++ b/tests/Beutl.UnitTests/Core/HierarchicalEventsTests.cs @@ -0,0 +1,50 @@ +using System; + +namespace Beutl.UnitTests.Core; + +public class HierarchicalEventsTests +{ + private sealed class DummyItem : ProjectItem {} + + [Test] + public void SettingProject_RaisesAttachAndDetach() + { + var app = BeutlApplication.Current; + var proj1 = new Project(); + var proj2 = new Project(); + int attached = 0; + int detached = 0; + app.DescendantAttached += (_, __) => attached++; + app.DescendantDetached += (_, __) => detached++; + + app.Project = proj1; + Assert.That(attached, Is.GreaterThanOrEqualTo(1)); + + app.Project = proj2; + Assert.That(detached, Is.GreaterThanOrEqualTo(1)); + Assert.That(attached, Is.GreaterThanOrEqualTo(2)); + + app.Project = null; + Assert.That(detached, Is.GreaterThanOrEqualTo(2)); + } + + [Test] + public void AddingProjectItem_RaisesAttach_Detach() + { + var app = BeutlApplication.Current; + var proj = new Project(); + app.Project = proj; + int attached = 0; + int detached = 0; + app.DescendantAttached += (_, __) => attached++; + app.DescendantDetached += (_, __) => detached++; + + var item = new DummyItem { FileName = Path.Combine(ArtifactProvider.GetArtifactDirectory(), "dummy.item") }; + proj.Items.Add(item); + Assert.That(attached, Is.GreaterThanOrEqualTo(1)); + + proj.Items.Remove(item); + Assert.That(detached, Is.GreaterThanOrEqualTo(1)); + } +} + diff --git a/tests/Beutl.UnitTests/Core/HierarchicalExtensionsTests.cs b/tests/Beutl.UnitTests/Core/HierarchicalExtensionsTests.cs new file mode 100644 index 000000000..4b1ec0c1b --- /dev/null +++ b/tests/Beutl.UnitTests/Core/HierarchicalExtensionsTests.cs @@ -0,0 +1,39 @@ +using System; +using System.Linq; + +namespace Beutl.UnitTests.Core; + +public class HierarchicalExtensionsTests +{ + private sealed class DummyItem : ProjectItem {} + + [Test] + public void FindHierarchicalRoot_AndParent() + { + var app = BeutlApplication.Current; + var proj = new Project(); + app.Project = proj; + var item = new DummyItem { FileName = Path.Combine(ArtifactProvider.GetArtifactDirectory(), "hi.item") }; + proj.Items.Add(item); + + // Root is application + Assert.That(((IHierarchical)item).FindHierarchicalRoot(), Is.SameAs(app)); + // Find project as parent + Assert.That(((IHierarchical)item).FindHierarchicalParent(), Is.SameAs(proj)); + Assert.That(((IHierarchical)item).FindRequiredHierarchicalParent(), Is.SameAs(app)); + } + + [Test] + public void EnumerateAllChildren_ReturnsDescendants() + { + var proj = new Project(); + var item1 = new DummyItem { FileName = Path.Combine(ArtifactProvider.GetArtifactDirectory(), "a.item") }; + var item2 = new DummyItem { FileName = Path.Combine(ArtifactProvider.GetArtifactDirectory(), "b.item") }; + proj.Items.Add(item1); + proj.Items.Add(item2); + + var all = ((IHierarchical)proj).EnumerateAllChildren().ToArray(); + Assert.That(all.Length, Is.EqualTo(2)); + Assert.That(all.Contains(item1) && all.Contains(item2), Is.True); + } +} diff --git a/tests/Beutl.UnitTests/Core/JsonDeepCloneTests.cs b/tests/Beutl.UnitTests/Core/JsonDeepCloneTests.cs new file mode 100644 index 000000000..57686aaa1 --- /dev/null +++ b/tests/Beutl.UnitTests/Core/JsonDeepCloneTests.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Nodes; +using Beutl.Serialization; + +namespace Beutl.UnitTests.Core; + +public class JsonDeepCloneTests +{ + [Test] + public void CopyTo_Object_ProducesIndependentClone() + { + var src = new JsonObject + { + ["a"] = 1, + ["b"] = new JsonObject { ["c"] = "x" }, + ["d"] = new JsonArray(1, 2, 3) + }; + var dest = new JsonObject(); + JsonDeepClone.CopyTo(src, dest); + + // Mutate source and ensure dest is unaffected + ((JsonObject)src["b"]!).Remove("c"); + ((JsonArray)src["d"]!).Add(4); + + Assert.That(dest["a"]!.ToJsonString(), Is.EqualTo("1")); + Assert.That(((JsonObject)dest["b"]!)["c"]!.ToJsonString(), Is.EqualTo("\"x\"")); + Assert.That(((JsonArray)dest["d"]!).Count, Is.EqualTo(3)); + } + + [Test] + public void CopyTo_Array_ProducesIndependentClone() + { + var src = new JsonArray + { + 1, new JsonObject { ["k"] = "v" }, new JsonArray(10) + }; + var dest = new JsonArray(); + JsonDeepClone.CopyTo(src, dest); + + ((JsonObject)src[1]!).Remove("k"); + ((JsonArray)src[2]!).Add(20); + + Assert.That(((JsonObject)dest[1]!).ContainsKey("k"), Is.True); + Assert.That(((JsonArray)dest[2]!).Count, Is.EqualTo(1)); + } +} + diff --git a/tests/Beutl.UnitTests/Core/JsonImmediateResolveTests.cs b/tests/Beutl.UnitTests/Core/JsonImmediateResolveTests.cs new file mode 100644 index 000000000..c64d65f78 --- /dev/null +++ b/tests/Beutl.UnitTests/Core/JsonImmediateResolveTests.cs @@ -0,0 +1,26 @@ +using System; +using Beutl.Serialization; + +namespace Beutl.UnitTests.Core; + +public class JsonImmediateResolveTests +{ + private sealed class Obj : CoreObject {} + + [Test] + public void Resolve_CallbackInvokedWhenObjectAlreadyKnown() + { + var ctx = new JsonSerializationContext(typeof(object), NullSerializationErrorNotifier.Instance); + using var scope = ThreadLocalSerializationContext.Enter(ctx); + + bool called = false; + Guid id = Guid.NewGuid(); + ctx.Resolve(id, o => { called = ((ICoreObject)o).Id == id; }); + + var obj = new Obj { Id = id }; + ctx.AfterDeserialized(obj); + + Assert.That(called, Is.True); + } +} + diff --git a/tests/Beutl.UnitTests/Core/JsonReferenceTests.cs b/tests/Beutl.UnitTests/Core/JsonReferenceTests.cs new file mode 100644 index 000000000..23b61e62e --- /dev/null +++ b/tests/Beutl.UnitTests/Core/JsonReferenceTests.cs @@ -0,0 +1,35 @@ +using System; +using System.Text.Json.Nodes; +using Beutl.Serialization; + +namespace Beutl.UnitTests.Core; + +public class JsonReferenceTests +{ + private sealed class Obj : CoreObject {} + + [Test] + public void Reference_SerializesAsGuid_AndDeserializesToReference() + { + var id = Guid.NewGuid(); + var r = new Reference(id); + var json = new JsonObject(); + var ctx = new JsonSerializationContext(typeof(object), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(ctx)) + { + ctx.SetValue("ref", r); + } + + Assert.That(json["ref"]!.ToJsonString().Trim('"'), Is.EqualTo(id.ToString())); + + var ctx2 = new JsonSerializationContext(typeof(object), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(ctx2)) + { + var restored = ctx2.GetValue>("ref"); + Assert.That(restored.IsNull, Is.False); + Assert.That((Guid)restored, Is.EqualTo(id)); + Assert.That((Obj?)restored, Is.Null); + } + } +} + diff --git a/tests/Beutl.UnitTests/Core/JsonSerializationCollectionsTests.cs b/tests/Beutl.UnitTests/Core/JsonSerializationCollectionsTests.cs new file mode 100644 index 000000000..96fa65396 --- /dev/null +++ b/tests/Beutl.UnitTests/Core/JsonSerializationCollectionsTests.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Nodes; +using Beutl.Serialization; + +namespace Beutl.UnitTests.Core; + +public class JsonSerializationCollectionsTests +{ + private sealed class Node : CoreObject + { + public string Value { get; set; } = string.Empty; + + public override void Serialize(ICoreSerializationContext context) + { + base.Serialize(context); + context.SetValue(nameof(Value), Value); + } + + public override void Deserialize(ICoreSerializationContext context) + { + base.Deserialize(context); + Value = context.GetValue(nameof(Value)) ?? string.Empty; + } + } + + [Test] + public void DictionaryOfCoreObjects_SerializesToObject_AndRoundTrips() + { + var dict = new Dictionary + { + ["a"] = new Node { Value = "A" }, + ["b"] = new Node { Value = "B" }, + }; + + var json = new JsonObject(); + var ctx = new JsonSerializationContext(typeof(object), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(ctx)) + { + ctx.SetValue("dict", dict); + } + + Assert.That(json["dict"], Is.InstanceOf()); + var readCtx = new JsonSerializationContext(typeof(object), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(readCtx)) + { + var restored = readCtx.GetValue>("dict"); + Assert.That(restored, Is.Not.Null); + Assert.That(restored!["a"].Value, Is.EqualTo("A")); + Assert.That(restored["b"].Value, Is.EqualTo("B")); + } + } + + [Test] + public void ArrayOfCoreObjects_RoundTrips() + { + var arr = new[] { new Node { Value = "X" }, new Node { Value = "Y" } }; + var json = new JsonObject(); + var ctx = new JsonSerializationContext(typeof(object), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(ctx)) + { + ctx.SetValue("arr", arr); + } + + var readCtx = new JsonSerializationContext(typeof(object), NullSerializationErrorNotifier.Instance, json: json); + using (ThreadLocalSerializationContext.Enter(readCtx)) + { + var restored = readCtx.GetValue("arr"); + Assert.That(restored, Is.Not.Null); + Assert.That(restored!.Length, Is.EqualTo(2)); + Assert.That(restored[0].Value, Is.EqualTo("X")); + Assert.That(restored[1].Value, Is.EqualTo("Y")); + } + } +} + diff --git a/tests/Beutl.UnitTests/Core/ProjectItemContainerTests.cs b/tests/Beutl.UnitTests/Core/ProjectItemContainerTests.cs new file mode 100644 index 000000000..f8f306087 --- /dev/null +++ b/tests/Beutl.UnitTests/Core/ProjectItemContainerTests.cs @@ -0,0 +1,108 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Beutl.Services; + +namespace Beutl.UnitTests.Core; + +public class ProjectItemContainerTests +{ + private sealed class DummyItem : ProjectItem + { + public int RestoreCalls { get; private set; } + protected override void RestoreCore(string filename) => RestoreCalls++; + } + + private sealed class FakeGenerator : IProjectItemGenerator + { + public bool TryCreateItem(string file, [NotNullWhen(true)] out ProjectItem? obj) + { + obj = new DummyItem { FileName = file }; + return true; + } + + public bool TryCreateItem(string file, [NotNullWhen(true)] out T? obj) where T : ProjectItem + { + if (typeof(T) == typeof(DummyItem)) + { + obj = (T?)(ProjectItem)new DummyItem { FileName = file }; + return true; + } + + obj = null; + return false; + } + } + + [Test] + public void TryGetOrCreateItem_Creates_Caches_AndIsCreated() + { + var prev = ProjectItemContainer.Current.Generator; + ProjectItemContainer.Current.Generator = new FakeGenerator(); + try + { + string file = Path.Combine(ArtifactProvider.GetArtifactDirectory(), "p1.item"); + File.WriteAllText(file, "x"); + + Assert.That(ProjectItemContainer.Current.TryGetOrCreateItem(file, out ProjectItem? item1), Is.True); + Assert.That(item1, Is.Not.Null); + Assert.That(ProjectItemContainer.Current.IsCreated(file), Is.True); + + Assert.That(ProjectItemContainer.Current.TryGetOrCreateItem(file, out ProjectItem? item2), Is.True); + Assert.That(item2, Is.SameAs(item1)); + + Assert.That(ProjectItemContainer.Current.Remove(file), Is.True); + Assert.That(ProjectItemContainer.Current.IsCreated(file), Is.False); + } + finally + { + ProjectItemContainer.Current.Generator = prev; + } + } + + [Test] + public void TryGetOrCreateItem_Restores_WhenFileIsNewer() + { + var prev = ProjectItemContainer.Current.Generator; + ProjectItemContainer.Current.Generator = new FakeGenerator(); + try + { + string file = Path.Combine(ArtifactProvider.GetArtifactDirectory(), "p2.item"); + File.WriteAllText(file, "data"); + File.SetLastWriteTimeUtc(file, DateTime.UtcNow.AddSeconds(1)); + + Assert.That(ProjectItemContainer.Current.TryGetOrCreateItem(file, out DummyItem? item1), Is.True); + Assert.That(item1, Is.Not.Null); + Assert.That(item1!.RestoreCalls, Is.EqualTo(0)); + + // Second lookup should trigger Restore since file is newer than LastSavedTime + Assert.That(ProjectItemContainer.Current.TryGetOrCreateItem(file, out DummyItem? item2), Is.True); + Assert.That(item2, Is.SameAs(item1)); + Assert.That(item1.RestoreCalls, Is.GreaterThanOrEqualTo(1)); + } + finally + { + ProjectItemContainer.Current.Generator = prev; + } + } + + [Test] + public void TryGetOrCreateItem_Typed_Generic_Works() + { + var prev = ProjectItemContainer.Current.Generator; + ProjectItemContainer.Current.Generator = new FakeGenerator(); + try + { + string file = Path.Combine(ArtifactProvider.GetArtifactDirectory(), "p3.item"); + File.WriteAllText(file, "data"); + + Assert.That(ProjectItemContainer.Current.TryGetOrCreateItem(file, out var item), Is.True); + Assert.That(item, Is.Not.Null); + Assert.That(item!.FileName, Is.EqualTo(file)); + } + finally + { + ProjectItemContainer.Current.Generator = prev; + } + } +} + diff --git a/tests/Beutl.UnitTests/Core/ProjectSerializationTests.cs b/tests/Beutl.UnitTests/Core/ProjectSerializationTests.cs new file mode 100644 index 000000000..89d244eed --- /dev/null +++ b/tests/Beutl.UnitTests/Core/ProjectSerializationTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Text.Json.Nodes; +using Beutl.Serialization; +using Beutl.Services; + +namespace Beutl.UnitTests.Core; + +public class ProjectSerializationTests +{ + private sealed class DummyItem : ProjectItem {} + + private sealed class FakeGenerator : IProjectItemGenerator + { + public bool TryCreateItem(string file, out ProjectItem? obj) + { + obj = new DummyItem { FileName = file }; + return true; + } + + public bool TryCreateItem(string file, out T? obj) where T : ProjectItem + { + if (typeof(T) == typeof(DummyItem)) + { + obj = (T?)(ProjectItem)new DummyItem { FileName = file }; + return true; + } + obj = null; + return false; + } + } + + [Test] + public void Save_IncludesItemsAsRelativePaths_AndVariables() + { + var prev = ProjectItemContainer.Current.Generator; + ProjectItemContainer.Current.Generator = new FakeGenerator(); + try + { + var proj = new Project(); + string dir = ArtifactProvider.GetArtifactDirectory(); + string baseDir = Path.GetFullPath(dir); + string f1 = Path.Combine(baseDir, "a.item"); + string f2 = Path.Combine(baseDir, "b.item"); + File.WriteAllText(f1, "a"); + File.WriteAllText(f2, "b"); + + var i1 = new DummyItem { FileName = f1 }; + var i2 = new DummyItem { FileName = f2 }; + proj.Items.Add(i1); + proj.Items.Add(i2); + proj.Variables["k1"] = "v1"; + proj.Variables["k2"] = "v2"; + + string prjPath = Path.Combine(baseDir, "test.bproj"); + proj.Save(prjPath); + + var json = JsonHelper.JsonRestore(prjPath) as JsonObject; + Assert.That(json, Is.Not.Null); + var items = (JsonArray)json!["items"]!; + // Should be relative paths + Assert.That(items.Count, Is.EqualTo(2)); + Assert.That(items[0]!.ToJsonString().Contains("a.item"), Is.True); + Assert.That(items[1]!.ToJsonString().Contains("b.item"), Is.True); + + var vars = (JsonObject)json!["variables"]!; + Assert.That(vars["k1"]!.ToJsonString(), Is.EqualTo("\"v1\"")); + Assert.That(vars["k2"]!.ToJsonString(), Is.EqualTo("\"v2\"")); + } + finally + { + ProjectItemContainer.Current.Generator = prev; + } + } + + [Test] + public void Restore_RebuildsItemsAndVariables() + { + var prev = ProjectItemContainer.Current.Generator; + ProjectItemContainer.Current.Generator = new FakeGenerator(); + try + { + var proj = new Project(); + string dir = ArtifactProvider.GetArtifactDirectory(); + string baseDir = Path.GetFullPath(dir); + string f1 = Path.Combine(baseDir, "c.item"); + string f2 = Path.Combine(baseDir, "d.item"); + File.WriteAllText(f1, "c"); + File.WriteAllText(f2, "d"); + proj.Items.Add(new DummyItem { FileName = f1 }); + proj.Items.Add(new DummyItem { FileName = f2 }); + proj.Variables["k1"] = "v1"; + string prjPath = Path.Combine(baseDir, "restore.bproj"); + proj.Save(prjPath); + + var proj2 = new Project(); + proj2.Restore(prjPath); + + Assert.That(proj2.Items.Count, Is.EqualTo(2)); + Assert.That(proj2.Items.Any(x => x.FileName.EndsWith("c.item")), Is.True); + Assert.That(proj2.Variables["k1"], Is.EqualTo("v1")); + } + finally + { + ProjectItemContainer.Current.Generator = prev; + } + } + + [Test] + public void ItemsChanged_AutoSavesProjectFile() + { + var prev = ProjectItemContainer.Current.Generator; + ProjectItemContainer.Current.Generator = new FakeGenerator(); + try + { + var proj = new Project(); + string dir = ArtifactProvider.GetArtifactDirectory(); + string baseDir = Path.GetFullPath(dir); + string prjPath = Path.Combine(baseDir, "autosave.bproj"); + string f1 = Path.Combine(baseDir, "e.item"); + string f2 = Path.Combine(baseDir, "f.item"); + File.WriteAllText(f1, "e"); + File.WriteAllText(f2, "f"); + + proj.Items.Add(new DummyItem { FileName = f1 }); + proj.Save(prjPath); + var json1 = (JsonObject)JsonHelper.JsonRestore(prjPath)!; + int beforeCount = ((JsonArray)json1["items"]!).Count; + + proj.Items.Add(new DummyItem { FileName = f2 }); + var json2 = (JsonObject)JsonHelper.JsonRestore(prjPath)!; + int afterCount = ((JsonArray)json2["items"]!).Count; + + Assert.That(beforeCount, Is.EqualTo(1)); + Assert.That(afterCount, Is.EqualTo(2)); + } + finally + { + ProjectItemContainer.Current.Generator = prev; + } + } +} diff --git a/tests/Beutl.UnitTests/Core/RecordableCommandsTests.cs b/tests/Beutl.UnitTests/Core/RecordableCommandsTests.cs new file mode 100644 index 000000000..c62c7d0f7 --- /dev/null +++ b/tests/Beutl.UnitTests/Core/RecordableCommandsTests.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Immutable; + +namespace Beutl.UnitTests.Core; + +public class RecordableCommandsTests +{ + private sealed class FlagCommand : IRecordableCommand + { + private readonly Action _do; + private readonly Action _undo; + private readonly Action _redo; + private readonly ImmutableArray _storables; + + public FlagCommand(Action @do, Action undo, Action? redo = null, ImmutableArray? storables = null) + { + _do = @do; + _undo = undo; + _redo = redo ?? @do; + _storables = storables ?? []; + } + + public bool DidDo { get; private set; } + public bool DidUndo { get; private set; } + public bool DidRedo { get; private set; } + + public ImmutableArray GetStorables() => _storables; + public void Do() { _do(); DidDo = true; } + public void Undo() { _undo(); DidUndo = true; } + public void Redo() { _redo(); DidRedo = true; } + } + + [Test] + public void Create_DelegateCommand_RunsDoUndoRedo() + { + int x = 0; + var cmd = RecordableCommands.Create( + () => x = 1, + () => x = 0, + ImmutableArray.Empty); + + cmd.Do(); + Assert.That(x, Is.EqualTo(1)); + cmd.Undo(); + Assert.That(x, Is.EqualTo(0)); + cmd.Redo(); + Assert.That(x, Is.EqualTo(1)); + } + + [Test] + public void Append_And_ToCommand_Composition_OrderAndStorables() + { + var s1 = ImmutableArray.Empty; + var s2 = ImmutableArray.Create(new IStorable?[] { null }); + int order = 0; + int a=0,b=0; + + var c1 = new FlagCommand(() => { a = ++order; }, () => { a = -1; }, storables: s1); + var c2 = new FlagCommand(() => { b = ++order; }, () => { b = -2; }, storables: s2); + + IRecordableCommand combo = c1.Append(c2); + var rec = new CommandRecorder(); + combo.DoAndRecord(rec); + + Assert.That(a, Is.EqualTo(1)); + Assert.That(b, Is.EqualTo(2)); + Assert.That(rec.CanUndo, Is.True); + + rec.Undo(); + Assert.That(a, Is.EqualTo(-1)); + Assert.That(b, Is.EqualTo(-2)); + + // convert to multiple and check storables concat + var multi = new IRecordableCommand[] { c1, c2 }.ToCommand(ImmutableArray.Empty); + var storables = multi.GetStorables(); + Assert.That(storables.Length, Is.EqualTo(1)); + } + + [Test] + public void WithStorables_OverwriteAndConcat() + { + var sBase = ImmutableArray.Create(new IStorable?[] { null, null }); + var sOverwrite = ImmutableArray.Create(new IStorable?[] { null }); + var sConcat = ImmutableArray.Create(new IStorable?[] { null }); + var cmd = new FlagCommand(() => {}, () => {}, storables: sBase); + + var overwrote = cmd.WithStoables(sOverwrite, overwrite: true); + Assert.That(overwrote.GetStorables().Length, Is.EqualTo(1)); + + var concatenated = cmd.WithStoables(sConcat, overwrite: false); + Assert.That(concatenated.GetStorables().Length, Is.EqualTo(3)); + } +} diff --git a/tests/Beutl.UnitTests/Core/ReferenceResolverTests.cs b/tests/Beutl.UnitTests/Core/ReferenceResolverTests.cs new file mode 100644 index 000000000..6f9dd87e4 --- /dev/null +++ b/tests/Beutl.UnitTests/Core/ReferenceResolverTests.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading.Tasks; + +namespace Beutl.UnitTests.Core; + +public class ReferenceResolverTests +{ + private sealed class DummyItem : ProjectItem {} + + [Test] + public async Task Resolve_WaitsForRootAndDescendantAttach() + { + var app = BeutlApplication.Current; + var proj = new Project(); + app.Project = proj; + + // Anchor not attached yet + var anchor = new DummyItem { FileName = Path.Combine(ArtifactProvider.GetArtifactDirectory(), "anchor.item") }; + Guid targetId = Guid.NewGuid(); + var resolver = new ReferenceResolver(anchor, targetId); + var task = resolver.Resolve(); + + // Attach anchor to root + proj.Items.Add(anchor); + + // Add target later => should complete + var target = new DummyItem { FileName = Path.Combine(ArtifactProvider.GetArtifactDirectory(), "target.item") }; + target.Id = targetId; + proj.Items.Add(target); + + var resolved = await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(2))) == task + ? await task + : throw new TimeoutException("Resolver did not complete in time"); + + Assert.That(resolved.Id, Is.EqualTo(targetId)); + } +} + diff --git a/tests/Beutl.UnitTests/Core/TypeFormatTests.cs b/tests/Beutl.UnitTests/Core/TypeFormatTests.cs new file mode 100644 index 000000000..8be3d8bf9 --- /dev/null +++ b/tests/Beutl.UnitTests/Core/TypeFormatTests.cs @@ -0,0 +1,38 @@ +using System; +using System.Reflection; +using Beutl.Configuration; + +namespace Beutl.UnitTests.Core; + +public class TypeFormatTests +{ + // A global-namespace type for testing. Intentionally no namespace. + public class TypeFormatGlobalType {} + + [Test] + public void RoundTrip_SimpleAndGenericTypes() + { + // Simple type in Beutl.Configuration (assembly == namespace) + AssertRoundTrip(typeof(ViewConfig)); + + // Nested private record in ViewConfig + Type? nested = typeof(ViewConfig).GetNestedType("WindowPositionRecord", BindingFlags.NonPublic); + Assert.That(nested, Is.Not.Null); + AssertRoundTrip(nested!); + + // Keep to non-generic types as generic round-trip is not guaranteed by current formatter. + } + + [Test] + public void RoundTrip_GlobalNamespaceType() + { + AssertRoundTrip(typeof(TypeFormatGlobalType)); + } + + private static void AssertRoundTrip(Type t) + { + string s = TypeFormat.ToString(t); + Type? restored = TypeFormat.ToType(s); + Assert.That(restored, Is.EqualTo(t), $"Round-trip failed for: {s}"); + } +} diff --git a/tests/Beutl.UnitTests/Extensibility/ExtensionSettingsEventsTests.cs b/tests/Beutl.UnitTests/Extensibility/ExtensionSettingsEventsTests.cs new file mode 100644 index 000000000..39c0f0e79 --- /dev/null +++ b/tests/Beutl.UnitTests/Extensibility/ExtensionSettingsEventsTests.cs @@ -0,0 +1,38 @@ +using System; +using Beutl.Embedding.FFmpeg.Decoding; +using Beutl.Embedding.FFmpeg.Encoding; + +namespace Beutl.UnitTests.Extensibility; + +public class ExtensionSettingsEventsTests +{ + [Test] + public void FFmpegDecodingSettings_RaisesConfigurationChanged_OnPropertySet() + { + var cfg = new FFmpegDecodingSettings(); + int changed = 0; + cfg.ConfigurationChanged += (_, _) => changed++; + + cfg.ThreadCount = cfg.ThreadCount == -1 ? 2 : -1; + cfg.Scaling = cfg.Scaling == FFmpegDecodingSettings.ScalingAlgorithm.Bicubic + ? FFmpegDecodingSettings.ScalingAlgorithm.Bilinear + : FFmpegDecodingSettings.ScalingAlgorithm.Bicubic; + + Assert.That(changed, Is.GreaterThanOrEqualTo(2)); + } + + [Test] + public void FFmpegEncodingSettings_RaisesConfigurationChanged_OnPropertySet() + { + var cfg = new FFmpegEncodingSettings(); + int changed = 0; + cfg.ConfigurationChanged += (_, _) => changed++; + + cfg.ThreadCount = cfg.ThreadCount == -1 ? 2 : -1; + cfg.Acceleration = cfg.Acceleration == FFmpegEncodingSettings.AccelerationOptions.Software + ? FFmpegEncodingSettings.AccelerationOptions.Auto + : FFmpegEncodingSettings.AccelerationOptions.Software; + + Assert.That(changed, Is.GreaterThanOrEqualTo(2)); + } +} diff --git a/tests/Beutl.UnitTests/Operators/LibraryRegistrarTests.cs b/tests/Beutl.UnitTests/Operators/LibraryRegistrarTests.cs new file mode 100644 index 000000000..b2c2d9199 --- /dev/null +++ b/tests/Beutl.UnitTests/Operators/LibraryRegistrarTests.cs @@ -0,0 +1,39 @@ +using System; +using System.Linq; +using Beutl.Operators; +using Beutl.Services; + +namespace Beutl.UnitTests.Operators; + +public class LibraryRegistrarTests +{ + [Test] + public void RegisterAll_RegistersSourceOperatorsAndDrawables() + { + // Act + LibraryRegistrar.RegisterAll(); + + // Assert some representative bindings + var sources = LibraryService.Current.GetTypesFromFormat(KnownLibraryItemFormats.SourceOperator); + Assert.That(sources.Contains(typeof(Beutl.Operators.Source.RectOperator)), Is.True); + Assert.That(sources.Contains(typeof(Beutl.Operators.Source.TextBlockOperator)), Is.True); + + var drawables = LibraryService.Current.GetTypesFromFormat(KnownLibraryItemFormats.Drawable); + Assert.That(drawables.Contains(typeof(Beutl.Graphics.Shapes.RectShape)), Is.True); + Assert.That(drawables.Contains(typeof(Beutl.Graphics.Shapes.TextBlock)), Is.True); + + // Filter effects sample + var effects = LibraryService.Current.GetTypesFromFormat(KnownLibraryItemFormats.FilterEffect); + Assert.That(effects.Contains(typeof(Beutl.Graphics.Effects.Blur)), Is.True); + } + + [Test] + public void FindItem_ReturnsItemForRegisteredType() + { + LibraryRegistrar.RegisterAll(); + var item = LibraryService.Current.FindItem(typeof(Beutl.Operators.Source.RectOperator)); + Assert.That(item, Is.Not.Null); + Assert.That(item!.DisplayName, Is.Not.Empty); + } +} + diff --git a/tests/Beutl.UnitTests/Operators/SourceOperatorsDefaultsTests.cs b/tests/Beutl.UnitTests/Operators/SourceOperatorsDefaultsTests.cs new file mode 100644 index 000000000..897be2adc --- /dev/null +++ b/tests/Beutl.UnitTests/Operators/SourceOperatorsDefaultsTests.cs @@ -0,0 +1,52 @@ +using System; +using Beutl.Graphics; +using Beutl.Graphics.Effects; +using Beutl.Graphics.Shapes; +using Beutl.Graphics.Transformation; +using Beutl.Media; +using Beutl.Operators.Source; + +namespace Beutl.UnitTests.Operators; + +public class SourceOperatorsDefaultsTests +{ + [Test] + public void RectOperator_Defaults() + { + var op = new RectOperator(); + Assert.That(op.Value, Is.Not.Null); + var props = op.Properties; + Assert.That(props.Count, Is.GreaterThanOrEqualTo(11)); + Assert.That(props.First(p => p.GetCoreProperty() == Shape.WidthProperty).GetValue(), Is.EqualTo(100f)); + Assert.That(props.First(p => p.GetCoreProperty() == Shape.HeightProperty).GetValue(), Is.EqualTo(100f)); + Assert.That(props.First(p => p.GetCoreProperty() == Drawable.TransformProperty).GetValue(), Is.InstanceOf()); + Assert.That(props.First(p => p.GetCoreProperty() == Drawable.FillProperty).GetValue(), Is.InstanceOf()); + Assert.That(props.First(p => p.GetCoreProperty() == Drawable.FilterEffectProperty).GetValue(), Is.InstanceOf()); + } + + [Test] + public void TextBlockOperator_Defaults() + { + var op = new TextBlockOperator(); + Assert.That(op.Value, Is.Not.Null); + var props = op.Properties; + Assert.That(props.Count, Is.GreaterThanOrEqualTo(16)); + Assert.That(props.First(p => p.GetCoreProperty() == TextBlock.SizeProperty).GetValue(), Is.EqualTo(24f)); + Assert.That(props.First(p => p.GetCoreProperty() == TextBlock.TextProperty).GetValue(), Is.EqualTo(string.Empty)); + Assert.That(props.First(p => p.GetCoreProperty() == Drawable.TransformProperty).GetValue(), Is.InstanceOf()); + Assert.That(props.First(p => p.GetCoreProperty() == Drawable.FillProperty).GetValue(), Is.InstanceOf()); + } + + [Test] + public void GeometryOperator_Defaults() + { + var op = new GeometryOperator(); + Assert.That(op.Value, Is.Not.Null); + var props = op.Properties; + Assert.That(props.Count, Is.GreaterThanOrEqualTo(10)); + Assert.That(props.First(p => p.GetCoreProperty() == GeometryShape.DataProperty).GetValue(), Is.InstanceOf()); + Assert.That(props.First(p => p.GetCoreProperty() == Drawable.TransformProperty).GetValue(), Is.InstanceOf()); + Assert.That(props.First(p => p.GetCoreProperty() == Drawable.FillProperty).GetValue(), Is.InstanceOf()); + Assert.That(props.First(p => p.GetCoreProperty() == Drawable.FilterEffectProperty).GetValue(), Is.InstanceOf()); + } +}