diff --git a/src/SharpYaml.Tests/Serialization/YamlTagOnlyDerivedTypeTests.cs b/src/SharpYaml.Tests/Serialization/YamlTagOnlyDerivedTypeTests.cs new file mode 100644 index 0000000..3c5eff4 --- /dev/null +++ b/src/SharpYaml.Tests/Serialization/YamlTagOnlyDerivedTypeTests.cs @@ -0,0 +1,304 @@ +#nullable enable + +using System.Collections.Generic; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using SharpYaml.Serialization; + +namespace SharpYaml.Tests.Serialization; + +[TestClass] +public class YamlTagOnlyDerivedTypeTests +{ + // ---- Model types: tag-only entries (no discriminator) via attributes ---- + + [YamlPolymorphic(DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag)] + [YamlDerivedType(typeof(AttrCheckin), Tag = "!checkin")] + [YamlDerivedType(typeof(AttrDns), Tag = "!dns")] + [YamlDerivedType(typeof(AttrHttp), Tag = "!http")] + private class AttrMonitor + { + public string Interval { get; set; } = string.Empty; + } + + private sealed class AttrCheckin : AttrMonitor + { + public string Endpoint { get; set; } = string.Empty; + } + + private sealed class AttrDns : AttrMonitor + { + public string Host { get; set; } = string.Empty; + } + + private sealed class AttrHttp : AttrMonitor + { + public string Url { get; set; } = string.Empty; + } + + // ---- Model types: runtime tag-only entries ---- + + private class RuntimeMonitor + { + public string Interval { get; set; } = string.Empty; + } + + private sealed class RuntimeCheckin : RuntimeMonitor + { + public string Endpoint { get; set; } = string.Empty; + } + + private sealed class RuntimeDns : RuntimeMonitor + { + public string Host { get; set; } = string.Empty; + } + + private sealed class RuntimeHttp : RuntimeMonitor + { + public string Url { get; set; } = string.Empty; + } + + // ---- Attribute-based: tag-only entries should NOT set default ---- + + [TestMethod] + public void TagOnlyAttribute_NoTagDeserializesAsBaseType() + { + // All derived types have tags but no discriminators. + // An untagged mapping should deserialize as the base type, not the first entry. + var yaml = "Interval: 00:00:10\n"; + var value = YamlSerializer.Deserialize(yaml); + + Assert.IsNotNull(value); + Assert.IsInstanceOfType(value); + Assert.IsFalse(value is AttrCheckin, "Should not be AttrCheckin — tag-only entries must not become default"); + Assert.IsFalse(value is AttrDns); + Assert.IsFalse(value is AttrHttp); + Assert.AreEqual("00:00:10", value.Interval); + } + + [TestMethod] + public void TagOnlyAttribute_TaggedDeserializesAsDerivedType() + { + var yaml = "!dns\nHost: google.com\nInterval: 00:01:00\n"; + var value = YamlSerializer.Deserialize(yaml); + + Assert.IsNotNull(value); + Assert.IsInstanceOfType(value); + Assert.AreEqual("google.com", ((AttrDns)value).Host); + } + + [TestMethod] + public void TagOnlyAttribute_AllTagsWork() + { + var checkinYaml = "!checkin\nEndpoint: /health\nInterval: 00:00:30\n"; + var dnsYaml = "!dns\nHost: dns.google\nInterval: 00:01:00\n"; + var httpYaml = "!http\nUrl: https://example.com\nInterval: 00:05:00\n"; + + var checkin = YamlSerializer.Deserialize(checkinYaml); + var dns = YamlSerializer.Deserialize(dnsYaml); + var http = YamlSerializer.Deserialize(httpYaml); + + Assert.IsInstanceOfType(checkin); + Assert.IsInstanceOfType(dns); + Assert.IsInstanceOfType(http); + Assert.AreEqual("/health", ((AttrCheckin)checkin).Endpoint); + Assert.AreEqual("dns.google", ((AttrDns)dns).Host); + Assert.AreEqual("https://example.com", ((AttrHttp)http).Url); + } + + [TestMethod] + public void TagOnlyAttribute_DictionaryWithMixedTagsAndUntagged() + { + var yaml = """ + google: !http + Url: https://google.com + Interval: 00:05:00 + router: !dns + Host: 192.168.1.1 + Interval: 00:01:00 + base: + Interval: 00:00:10 + """; + + var dict = YamlSerializer.Deserialize>(yaml); + + Assert.IsNotNull(dict); + Assert.AreEqual(3, dict.Count); + Assert.IsInstanceOfType(dict["google"]); + Assert.IsInstanceOfType(dict["router"]); + Assert.IsInstanceOfType(dict["base"]); + Assert.IsFalse(dict["base"] is AttrCheckin, "Untagged entries must not resolve to first tag-only entry"); + } + + // ---- Runtime: tag-only entries should NOT set default ---- + + [TestMethod] + public void TagOnlyRuntime_NoTagDeserializesAsBaseType() + { + var options = new YamlSerializerOptions + { + PolymorphismOptions = new YamlPolymorphismOptions + { + DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag, + DerivedTypeMappings = + { + [typeof(RuntimeMonitor)] = new List + { + new YamlDerivedType(typeof(RuntimeCheckin)) { Tag = "!checkin" }, + new YamlDerivedType(typeof(RuntimeDns)) { Tag = "!dns" }, + new YamlDerivedType(typeof(RuntimeHttp)) { Tag = "!http" }, + } + } + } + }; + + var yaml = "Interval: 00:00:10\n"; + var value = YamlSerializer.Deserialize(yaml, options); + + Assert.IsNotNull(value); + Assert.IsInstanceOfType(value); + Assert.IsFalse(value is RuntimeCheckin, "Should not be RuntimeCheckin — tag-only entries must not become default"); + Assert.IsFalse(value is RuntimeDns); + Assert.IsFalse(value is RuntimeHttp); + Assert.AreEqual("00:00:10", value.Interval); + } + + [TestMethod] + public void TagOnlyRuntime_TaggedDeserializesAsDerivedType() + { + var options = new YamlSerializerOptions + { + PolymorphismOptions = new YamlPolymorphismOptions + { + DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag, + DerivedTypeMappings = + { + [typeof(RuntimeMonitor)] = new List + { + new YamlDerivedType(typeof(RuntimeCheckin)) { Tag = "!checkin" }, + new YamlDerivedType(typeof(RuntimeHttp)) { Tag = "!http" }, + } + } + } + }; + + var yaml = "!http\nUrl: https://example.com\nInterval: 00:05:00\n"; + var value = YamlSerializer.Deserialize(yaml, options); + + Assert.IsNotNull(value); + Assert.IsInstanceOfType(value); + Assert.AreEqual("https://example.com", ((RuntimeHttp)value).Url); + } + + [TestMethod] + public void TagOnlyRuntime_DictionaryWithMixedTagsAndUntagged() + { + var options = new YamlSerializerOptions + { + PolymorphismOptions = new YamlPolymorphismOptions + { + DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag, + DerivedTypeMappings = + { + [typeof(RuntimeMonitor)] = new List + { + new YamlDerivedType(typeof(RuntimeCheckin)) { Tag = "!checkin" }, + new YamlDerivedType(typeof(RuntimeDns)) { Tag = "!dns" }, + } + } + } + }; + + var yaml = """ + health: !checkin + Endpoint: /ping + Interval: 00:00:30 + plain: + Interval: 00:00:10 + """; + + var dict = YamlSerializer.Deserialize>(yaml, options); + + Assert.IsNotNull(dict); + Assert.AreEqual(2, dict.Count); + Assert.IsInstanceOfType(dict["health"]); + Assert.IsInstanceOfType(dict["plain"]); + Assert.IsFalse(dict["plain"] is RuntimeCheckin); + } + + // ---- Explicit default still works when a separate no-tag no-discriminator entry exists ---- + + [TestMethod] + public void ExplicitDefaultDerivedTypeWithTagOnlyEntries() + { + var options = new YamlSerializerOptions + { + PolymorphismOptions = new YamlPolymorphismOptions + { + DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag, + DerivedTypeMappings = + { + [typeof(RuntimeMonitor)] = new List + { + new YamlDerivedType(typeof(RuntimeCheckin)) { Tag = "!checkin" }, + new YamlDerivedType(typeof(RuntimeDns)) { Tag = "!dns" }, + new YamlDerivedType(typeof(RuntimeHttp)), // no tag, no discriminator → explicit default + } + } + } + }; + + // Untagged entries should resolve to RuntimeHttp (the explicit default) + var yaml = "Url: https://fallback.com\nInterval: 00:01:00\n"; + var value = YamlSerializer.Deserialize(yaml, options); + + Assert.IsNotNull(value); + Assert.IsInstanceOfType(value); + Assert.AreEqual("https://fallback.com", ((RuntimeHttp)value).Url); + } + + // ---- Serialization roundtrip for tag-only entries ---- + + [TestMethod] + public void TagOnlyAttribute_RoundtripSerialization() + { + AttrMonitor monitor = new AttrDns { Host = "google.com", Interval = "00:01:00" }; + var yaml = YamlSerializer.Serialize(monitor, typeof(AttrMonitor)); + + StringAssert.Contains(yaml, "!dns"); + StringAssert.Contains(yaml, "Host: google.com"); + + var deserialized = YamlSerializer.Deserialize(yaml); + Assert.IsInstanceOfType(deserialized); + Assert.AreEqual("google.com", ((AttrDns)deserialized).Host); + } + + [TestMethod] + public void TagOnlyRuntime_RoundtripSerialization() + { + var options = new YamlSerializerOptions + { + PolymorphismOptions = new YamlPolymorphismOptions + { + DiscriminatorStyle = YamlTypeDiscriminatorStyle.Tag, + DerivedTypeMappings = + { + [typeof(RuntimeMonitor)] = new List + { + new YamlDerivedType(typeof(RuntimeCheckin)) { Tag = "!checkin" }, + new YamlDerivedType(typeof(RuntimeDns)) { Tag = "!dns" }, + } + } + } + }; + + RuntimeMonitor monitor = new RuntimeDns { Host = "dns.google", Interval = "00:01:00" }; + var yaml = YamlSerializer.Serialize(monitor, typeof(RuntimeMonitor), options); + + StringAssert.Contains(yaml, "!dns"); + StringAssert.Contains(yaml, "Host: dns.google"); + + var deserialized = YamlSerializer.Deserialize(yaml, options); + Assert.IsInstanceOfType(deserialized); + Assert.AreEqual("dns.google", ((RuntimeDns)deserialized).Host); + } +} diff --git a/src/SharpYaml/Serialization/YamlDerivedTypeAttribute.cs b/src/SharpYaml/Serialization/YamlDerivedTypeAttribute.cs index 01245d2..96a2650 100644 --- a/src/SharpYaml/Serialization/YamlDerivedTypeAttribute.cs +++ b/src/SharpYaml/Serialization/YamlDerivedTypeAttribute.cs @@ -14,7 +14,9 @@ public sealed class YamlDerivedTypeAttribute : YamlAttribute { /// /// Initializes a new instance of the class - /// with no discriminator, marking this derived type as the default when no discriminator matches. + /// with no discriminator. When is also , this derived type + /// becomes the default when no discriminator or tag matches. When is set, this entry + /// participates only in tag-based dispatch and does not become the default. /// /// The derived CLR type. /// is . diff --git a/src/SharpYaml/YamlDerivedType.cs b/src/SharpYaml/YamlDerivedType.cs index cd8442c..7558b7a 100644 --- a/src/SharpYaml/YamlDerivedType.cs +++ b/src/SharpYaml/YamlDerivedType.cs @@ -13,8 +13,10 @@ namespace SharpYaml; public sealed class YamlDerivedType { /// - /// Initializes a new instance with no discriminator, marking this derived type - /// as the default when no discriminator matches. + /// Initializes a new instance with no discriminator. + /// When is also , this derived type becomes the default + /// when no discriminator or tag matches. When is set, this entry participates + /// only in tag-based dispatch and does not become the default. /// /// The derived CLR type. /// is .