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());
+ }
+}