From 4504544cbe51fa52ffe7868b4d3b5fd7358b272e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 02:26:54 +0000 Subject: [PATCH 1/3] [minor] Keep scalar types and nulls through a frontmatter round trip Every scalar was read back as a string and written bare, so quoted "true", "1.0", "01234" and "null" came back as a bool, number or null, and an empty value became ''. The reader now keeps plain scalars typed (bool, integer, float, null) while quoted scalars stay strings, the writer quotes strings that would otherwise read as another type, and a null is kept as a null. PropertyMerger keeps a null-valued key instead of dropping it or throwing. ExtractFrontmatter now returns typed values for plain scalars rather than strings, hence [minor]. Fixes #137 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AFCuHRLN6NWMQ4DJNZApHa --- Frontmatter.Test/PropertyMergerTests.cs | 2 +- Frontmatter.Test/ScalarTypeRoundTripTests.cs | 70 ++++++++++++++++++++ Frontmatter/PropertyMerger.cs | 8 ++- Frontmatter/YamlSerializer.cs | 6 +- 4 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 Frontmatter.Test/ScalarTypeRoundTripTests.cs diff --git a/Frontmatter.Test/PropertyMergerTests.cs b/Frontmatter.Test/PropertyMergerTests.cs index 4314c03..200b1a5 100644 --- a/Frontmatter.Test/PropertyMergerTests.cs +++ b/Frontmatter.Test/PropertyMergerTests.cs @@ -769,6 +769,6 @@ public void CombineFrontmatter_SingleBlockWithRepeatedListItems_RoundTripsTheLis Assert.IsNotNull(frontmatter); CollectionAssert.AreEqual(new object[] { "mix", "wait", "mix" }, (System.Collections.ICollection)frontmatter["steps"]); - CollectionAssert.AreEqual(new object[] { "1", "1", "2" }, (System.Collections.ICollection)frontmatter["scores"]); + CollectionAssert.AreEqual(new long[] { 1, 1, 2 }, ((System.Collections.IEnumerable)frontmatter["scores"]).Cast().Select(score => Convert.ToInt64(score, System.Globalization.CultureInfo.InvariantCulture)).ToArray()); } } diff --git a/Frontmatter.Test/ScalarTypeRoundTripTests.cs b/Frontmatter.Test/ScalarTypeRoundTripTests.cs new file mode 100644 index 0000000..b027050 --- /dev/null +++ b/Frontmatter.Test/ScalarTypeRoundTripTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Frontmatter.Test; + +using System.Collections.Generic; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +using YamlDotNet.Serialization; + +/// +/// Regression tests for #137: a round trip must keep what each scalar means. Quoted strings that look +/// like other types stay strings, plain scalars keep their types, and nulls stay null. +/// +[TestClass] +public class ScalarTypeRoundTripTests +{ + private static readonly string Nl = Environment.NewLine; + + private static readonly IDeserializer TypedReader = new DeserializerBuilder() + .WithAttemptingUnquotedStringTypeDeserialization() + .Build(); + + private static Dictionary ReadHeader(string document) + { + string[] parts = document.Split(["---"], 3, StringSplitOptions.None); + return TypedReader.Deserialize>(parts[1]); + } + + [TestMethod] + [DataRow(FrontmatterMergeStrategy.None)] + [DataRow(FrontmatterMergeStrategy.Conservative)] + public void CombineFrontmatter_QuotedLookalikesNullsAndBooleans_KeepTheirTypes(FrontmatterMergeStrategy strategy) + { + string input = $"---{Nl}title: \"true\"{Nl}version: \"1.0\"{Nl}zip: \"01234\"{Nl}null_str: \"null\"{Nl}empty:{Nl}draft: false{Nl}count: 3{Nl}---{Nl}body{Nl}"; + + string result = Frontmatter.CombineFrontmatter(input, FrontmatterNaming.AsIs, FrontmatterOrder.AsIs, strategy); + Dictionary header = ReadHeader(result); + + Assert.AreEqual("true", header["title"], result); + Assert.AreEqual("1.0", header["version"], result); + Assert.AreEqual("01234", header["zip"], result); + Assert.AreEqual("null", header["null_str"], result); + Assert.IsTrue(header.ContainsKey("empty"), result); + Assert.IsNull(header["empty"], result); + Assert.AreEqual(false, header["draft"], result); + Assert.AreEqual(3L, Convert.ToInt64(header["count"], System.Globalization.CultureInfo.InvariantCulture), result); + StringAssert.Contains(result, $"draft: false{Nl}"); + } + + [TestMethod] + public void AddFrontmatter_StringsThatLookLikeOtherTypes_AreWrittenQuoted() + { + string result = Frontmatter.AddFrontmatter("body", new Dictionary { ["title"] = "true", ["id"] = "007" }); + Dictionary header = ReadHeader(result); + + Assert.AreEqual("true", header["title"], result); + Assert.AreEqual("007", header["id"], result); + } + + [TestMethod] + public void ExtractFrontmatter_PlainAndQuotedScalars_KeepDistinctTypes() + { + Dictionary? frontmatter = Frontmatter.ExtractFrontmatter($"---{Nl}draft: false{Nl}label: \"false\"{Nl}---{Nl}body{Nl}"); + + Assert.IsNotNull(frontmatter); + Assert.AreEqual(false, frontmatter["draft"]); + Assert.AreEqual("false", frontmatter["label"]); + } +} diff --git a/Frontmatter/PropertyMerger.cs b/Frontmatter/PropertyMerger.cs index baa50d2..121fb4c 100644 --- a/Frontmatter/PropertyMerger.cs +++ b/Frontmatter/PropertyMerger.cs @@ -125,6 +125,12 @@ private static void MergePropertyGroup( object firstValue = source[firstKey]; if (firstValue == null) { + // A null has no type to merge on, so every key keeps its own value rather than being dropped. + foreach (string key in originalKeys) + { + target[key] = source[key]; + } + return; } @@ -138,7 +144,7 @@ private static void MergePropertyGroup( // If types are different, keep all properties separate foreach (string key in originalKeys) { - target[key] = source[key] ?? throw new InvalidOperationException($"Value for key {key} is null"); + target[key] = source[key]; } return; diff --git a/Frontmatter/YamlSerializer.cs b/Frontmatter/YamlSerializer.cs index b82ab6e..5552aff 100644 --- a/Frontmatter/YamlSerializer.cs +++ b/Frontmatter/YamlSerializer.cs @@ -27,6 +27,7 @@ public static class YamlSerializer private static readonly IDeserializer Deserializer = new DeserializerBuilder() .WithNamingConvention(NullNamingConvention.Instance) .IgnoreUnmatchedProperties() + .WithAttemptingUnquotedStringTypeDeserialization() .Build(); /// @@ -35,6 +36,7 @@ public static class YamlSerializer private static readonly ISerializer Serializer = new SerializerBuilder() .WithNamingConvention(NullNamingConvention.Instance) .ConfigureDefaultValuesHandling(DefaultValuesHandling.Preserve) + .WithQuotingNecessaryStrings() .Build(); /// @@ -159,7 +161,9 @@ private static object ConvertValue(object? value) { return value switch { - null => string.Empty, + // A null stays null so it is written back as a null rather than as an empty string. The + // dictionaries this feeds are typed as non-nullable for compatibility, so the null is forgiven. + null => null!, Dictionary dict => dict.ToDictionary( kvp => kvp.Key?.ToString() ?? string.Empty, kvp => ConvertValue(kvp.Value)), From 87f5bd8158282dee50e563b405356d07baa63491 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 02:29:22 +0000 Subject: [PATCH 2/3] Use TryGetValue instead of ContainsKey plus indexer in scalar round-trip test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AFCuHRLN6NWMQ4DJNZApHa --- Frontmatter.Test/ScalarTypeRoundTripTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Frontmatter.Test/ScalarTypeRoundTripTests.cs b/Frontmatter.Test/ScalarTypeRoundTripTests.cs index b027050..615a6bd 100644 --- a/Frontmatter.Test/ScalarTypeRoundTripTests.cs +++ b/Frontmatter.Test/ScalarTypeRoundTripTests.cs @@ -41,8 +41,8 @@ public void CombineFrontmatter_QuotedLookalikesNullsAndBooleans_KeepTheirTypes(F Assert.AreEqual("1.0", header["version"], result); Assert.AreEqual("01234", header["zip"], result); Assert.AreEqual("null", header["null_str"], result); - Assert.IsTrue(header.ContainsKey("empty"), result); - Assert.IsNull(header["empty"], result); + Assert.IsTrue(header.TryGetValue("empty", out object? emptyValue), result); + Assert.IsNull(emptyValue, result); Assert.AreEqual(false, header["draft"], result); Assert.AreEqual(3L, Convert.ToInt64(header["count"], System.Globalization.CultureInfo.InvariantCulture), result); StringAssert.Contains(result, $"draft: false{Nl}"); From 06027e9f0b2ecbf788e9566b0c6214589658f33a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 02:37:43 +0000 Subject: [PATCH 3/3] Assert the boolean round trip with IsFalse and Contains in scalar type tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AFCuHRLN6NWMQ4DJNZApHa --- Frontmatter.Test/ScalarTypeRoundTripTests.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Frontmatter.Test/ScalarTypeRoundTripTests.cs b/Frontmatter.Test/ScalarTypeRoundTripTests.cs index 615a6bd..eff7760 100644 --- a/Frontmatter.Test/ScalarTypeRoundTripTests.cs +++ b/Frontmatter.Test/ScalarTypeRoundTripTests.cs @@ -43,9 +43,10 @@ public void CombineFrontmatter_QuotedLookalikesNullsAndBooleans_KeepTheirTypes(F Assert.AreEqual("null", header["null_str"], result); Assert.IsTrue(header.TryGetValue("empty", out object? emptyValue), result); Assert.IsNull(emptyValue, result); - Assert.AreEqual(false, header["draft"], result); + Assert.IsInstanceOfType(header["draft"], result); + Assert.IsFalse((bool)header["draft"]!, result); Assert.AreEqual(3L, Convert.ToInt64(header["count"], System.Globalization.CultureInfo.InvariantCulture), result); - StringAssert.Contains(result, $"draft: false{Nl}"); + Assert.Contains($"draft: false{Nl}", result); } [TestMethod] @@ -64,7 +65,8 @@ public void ExtractFrontmatter_PlainAndQuotedScalars_KeepDistinctTypes() Dictionary? frontmatter = Frontmatter.ExtractFrontmatter($"---{Nl}draft: false{Nl}label: \"false\"{Nl}---{Nl}body{Nl}"); Assert.IsNotNull(frontmatter); - Assert.AreEqual(false, frontmatter["draft"]); + Assert.IsInstanceOfType(frontmatter["draft"]); + Assert.IsFalse((bool)frontmatter["draft"]); Assert.AreEqual("false", frontmatter["label"]); } }