diff --git a/CLAUDE.md b/CLAUDE.md
index bacf47f..48cb46e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -160,6 +160,14 @@ hero_001,1001|1002|1003
```
- ID만 저장, `Evaluate(context)`로 해결
+## YAML 저장 (주석 보존)
+
+- `KeyValueDataRepository` / `SingleDataRepository` 가 YAML 파일을 저장할 때 `YamlCommentPreserver.Reconcile()` 을 거친다.
+ 디스크의 현재 파일, 그 파일을 다시 직렬화한 정규 텍스트, 저장할 데이터의 정규 텍스트를 비교해 바뀐 값만 원본 텍스트에 덮어쓴다.
+- 안 바뀐 행은 바이트 단위로 그대로, 주석·빈 줄·따옴표·flow 리스트(`[1, 2]`)도 유지된다.
+- 패치할 수 없거나, 결과를 다시 읽었을 때 데이터가 다르면 예전처럼 정규 텍스트를 쓴다 (데이터는 항상 정확, 주석만 best effort).
+- 테스트: `Datra.Tests/YamlCommentPreservationTests.cs` (에디터 저장 경로), `YamlCommentPreserverTests.cs` (재정렬·폴백 등)
+
## 테스트 방법
### 통합 테스트 (권장)
diff --git a/Datra.Tests/YamlCommentPreservationTests.cs b/Datra.Tests/YamlCommentPreservationTests.cs
new file mode 100644
index 0000000..16d0b88
--- /dev/null
+++ b/Datra.Tests/YamlCommentPreservationTests.cs
@@ -0,0 +1,533 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using Datra.Editor.DataSources;
+using Datra.Interfaces;
+using Datra.Repositories;
+using Datra.SampleData.Generated;
+using Datra.SampleData.Models;
+using Datra.Serializers;
+using Xunit;
+
+namespace Datra.Tests
+{
+ ///
+ /// Saving a YAML table from the editor must keep the comments a designer wrote in it.
+ /// Every test goes through the path the Unity Data Editor uses:
+ /// EditableKeyValueDataSource / EditableSingleDataSource -> repository.SaveAsync -> IRawDataProvider.
+ ///
+ public class YamlCommentPreservationTests
+ {
+ #region Fixtures
+
+ public class UpgradeRow : ITableData
+ {
+ public string Id { get; set; } = string.Empty;
+ public string? Name { get; set; } = string.Empty;
+ public double Base { get; set; }
+ public List Levels { get; set; } = new List();
+ public int FirstCost { get; set; }
+ public string RequiresUpgrade { get; set; } = string.Empty;
+ public bool Implemented { get; set; }
+ }
+
+ public class EconomyConfig
+ {
+ public int StartingDeposit { get; set; }
+ public double GoldenBottleChance { get; set; }
+ public List CenterCells { get; set; } = new List();
+ public List Targets { get; set; } = new List();
+ }
+
+ public class Target
+ {
+ public int Run { get; set; }
+ public int Deposit { get; set; }
+ }
+
+ private const string UpgradesYaml =
+ "# Upgrades: what the player can buy between runs.\n" +
+ "# Costs grow by CostGrowth each level.\n" +
+ "\n" +
+ "# The first thing a new player buys.\n" +
+ "- Id: push-strength\n" +
+ " Name: Push strength\n" +
+ " Base: 0.5 # metres per second at level 0\n" +
+ " Levels: [0.9, 1.3, 1.6]\n" +
+ " FirstCost: 50\n" +
+ " RequiresUpgrade: \"\"\n" +
+ " Implemented: true\n" +
+ "\n" +
+ "# --- Line upgrades ---\n" +
+ "\n" +
+ "- Id: bottle-count\n" +
+ " Name: Longer line\n" +
+ " Base: 3\n" +
+ " # one bottle per level, capped by the stage length\n" +
+ " Levels:\n" +
+ " - 4 # fits the short stage\n" +
+ " - 5\n" +
+ " - 6 # needs the long stage\n" +
+ " FirstCost: 80\n" +
+ " RequiresUpgrade: push-strength\n" +
+ " Implemented: true\n" +
+ "\n" +
+ "- Id: domes\n" +
+ " Name: Balance dome\n" +
+ " Base: 3\n" +
+ " Levels: [4, 5]\n" +
+ " FirstCost: 150 # tuned in playtest 3\n" +
+ " RequiresUpgrade: \"\"\n" +
+ "# end of table\n";
+
+ private const string EconomyYaml =
+ "# Economy knobs. Money is in coins.\n" +
+ "StartingDeposit: 0 # nothing in the jar on run 1\n" +
+ "GoldenBottleChance: 0.02\n" +
+ "CenterCells: [B2, B3]\n" +
+ "\n" +
+ "# Deposit goals per run\n" +
+ "Targets:\n" +
+ " - Run: 1\n" +
+ " Deposit: 60\n" +
+ " # the first real wall\n" +
+ " - Run: 5\n" +
+ " Deposit: 250 # playtest 2\n" +
+ " - Run: 10\n" +
+ " Deposit: 600\n";
+
+ private sealed class MemoryRawDataProvider : IRawDataProvider
+ {
+ public readonly Dictionary Files = new Dictionary();
+
+ public Task LoadTextAsync(string path)
+ => Files.TryGetValue(path, out var text)
+ ? Task.FromResult(text)
+ : throw new FileNotFoundException(path);
+
+ public Task SaveTextAsync(string path, string content)
+ {
+ Files[path] = content;
+ return Task.CompletedTask;
+ }
+
+ public bool Exists(string path) => Files.ContainsKey(path);
+
+ public string ResolveFilePath(string path) => path;
+ }
+
+ private static async Task<(EditableKeyValueDataSource source, MemoryRawDataProvider provider, KeyValueDataRepository repository)>
+ OpenTableAsync(string yaml, string path = "Upgrades.yaml")
+ {
+ var provider = new MemoryRawDataProvider();
+ provider.Files[path] = yaml;
+ var repository = new KeyValueDataRepository(
+ path,
+ provider,
+ new DataSerializerFactory(),
+ (data, serializer) => serializer.DeserializeTable(data),
+ (table, serializer) => serializer.SerializeTable(table));
+ await repository.InitializeAsync();
+ var source = new EditableKeyValueDataSource(repository);
+ return (source, provider, repository);
+ }
+
+ private static async Task SaveAsync(EditableKeyValueDataSource source, MemoryRawDataProvider provider, string path = "Upgrades.yaml")
+ {
+ await source.SaveAsync();
+ return provider.Files[path];
+ }
+
+ private static Dictionary Parse(string yaml)
+ => new YamlDataSerializer().DeserializeTable(yaml);
+
+ private static string Replace(string text, string oldValue, string newValue)
+ {
+ Assert.Contains(oldValue, text);
+ Assert.Equal(1, CountOf(text, oldValue));
+ return text.Replace(oldValue, newValue);
+ }
+
+ private static int CountOf(string text, string value)
+ {
+ var count = 0;
+ for (var i = text.IndexOf(value, StringComparison.Ordinal); i >= 0; i = text.IndexOf(value, i + 1, StringComparison.Ordinal))
+ count++;
+ return count;
+ }
+
+ #endregion
+
+ #region Changing values
+
+ [Fact]
+ public async Task ChangingOneValue_KeepsEveryCommentAndEveryOtherByte()
+ {
+ var (source, provider, _) = await OpenTableAsync(UpgradesYaml);
+
+ source.TrackPropertyChange("domes", nameof(UpgradeRow.FirstCost), 175, out _);
+ var saved = await SaveAsync(source, provider);
+
+ Assert.Equal(Replace(UpgradesYaml, "FirstCost: 150 #", "FirstCost: 175 #"), saved);
+ }
+
+ [Fact]
+ public async Task ChangingValueWithTrailingComment_KeepsTheComment()
+ {
+ var (source, provider, _) = await OpenTableAsync(UpgradesYaml);
+
+ source.TrackPropertyChange("push-strength", nameof(UpgradeRow.Base), 0.75, out _);
+ var saved = await SaveAsync(source, provider);
+
+ Assert.Equal(Replace(UpgradesYaml, "Base: 0.5 #", "Base: 0.75 #"), saved);
+ }
+
+ [Fact]
+ public async Task ChangingOneEntryOfFlowList_KeepsTheListFlow()
+ {
+ var (source, provider, _) = await OpenTableAsync(UpgradesYaml);
+
+ source.TrackPropertyChange("push-strength", nameof(UpgradeRow.Levels), new List { 0.9, 1.4, 1.6 }, out _);
+ var saved = await SaveAsync(source, provider);
+
+ Assert.Equal(Replace(UpgradesYaml, "Levels: [0.9, 1.3, 1.6]", "Levels: [0.9, 1.4, 1.6]"), saved);
+ }
+
+ [Fact]
+ public async Task ChangingOneEntryOfBlockList_KeepsCommentsInsideTheList()
+ {
+ var (source, provider, _) = await OpenTableAsync(UpgradesYaml);
+
+ source.TrackPropertyChange("bottle-count", nameof(UpgradeRow.Levels), new List { 4, 5, 7 }, out _);
+ var saved = await SaveAsync(source, provider);
+
+ Assert.Equal(Replace(UpgradesYaml, " - 6 # needs", " - 7 # needs"), saved);
+ }
+
+ [Fact]
+ public async Task AppendingToBlockList_AddsALineInTheListsStyle()
+ {
+ var (source, provider, _) = await OpenTableAsync(UpgradesYaml);
+
+ source.TrackPropertyChange("bottle-count", nameof(UpgradeRow.Levels), new List { 4, 5, 6, 8 }, out _);
+ var saved = await SaveAsync(source, provider);
+
+ Assert.Equal(Replace(UpgradesYaml, " - 6 # needs the long stage\n", " - 6 # needs the long stage\n - 8\n"), saved);
+ }
+
+ [Fact]
+ public async Task RemovingFromBlockList_TakesTheEntrysCommentWithIt()
+ {
+ var (source, provider, _) = await OpenTableAsync(UpgradesYaml);
+
+ source.TrackPropertyChange("bottle-count", nameof(UpgradeRow.Levels), new List { 5, 6 }, out _);
+ var saved = await SaveAsync(source, provider);
+
+ Assert.Equal(Replace(UpgradesYaml, " - 4 # fits the short stage\n", ""), saved);
+ }
+
+ [Fact]
+ public async Task ChangingQuotedString_KeepsTheQuotes()
+ {
+ var (source, provider, _) = await OpenTableAsync(UpgradesYaml);
+
+ source.TrackPropertyChange("domes", nameof(UpgradeRow.RequiresUpgrade), "push-strength", out _);
+ var saved = await SaveAsync(source, provider);
+
+ var expected = UpgradesYaml.Replace(
+ " FirstCost: 150 # tuned in playtest 3\n RequiresUpgrade: \"\"\n",
+ " FirstCost: 150 # tuned in playtest 3\n RequiresUpgrade: \"push-strength\"\n");
+ Assert.NotEqual(UpgradesYaml, expected);
+ Assert.Equal(expected, saved);
+ }
+
+ [Fact]
+ public async Task SettingAFieldTheFileLeftOut_InsertsItAfterItsNeighbour()
+ {
+ // domes has no "Implemented" line; it was false by default.
+ var (source, provider, _) = await OpenTableAsync(UpgradesYaml);
+
+ source.TrackPropertyChange("domes", nameof(UpgradeRow.Implemented), true, out _);
+ var saved = await SaveAsync(source, provider);
+
+ var expected = UpgradesYaml.Replace(
+ " FirstCost: 150 # tuned in playtest 3\n RequiresUpgrade: \"\"\n",
+ " FirstCost: 150 # tuned in playtest 3\n RequiresUpgrade: \"\"\n Implemented: true\n");
+ Assert.Equal(expected, saved);
+ }
+
+ [Fact]
+ public async Task ClearingAField_RemovesItsLineAndTheCommentAboveIt()
+ {
+ // Null is omitted on save, so the field's line (and the comment directly above it) goes.
+ var yaml = UpgradesYaml.Replace(" Name: Balance dome\n", " # shown in the shop\n Name: Balance dome\n");
+ var (source, provider, _) = await OpenTableAsync(yaml);
+
+ source.TrackPropertyChange("domes", nameof(UpgradeRow.Name), null, out _);
+ var saved = await SaveAsync(source, provider);
+
+ Assert.Equal(Replace(yaml, " # shown in the shop\n Name: Balance dome\n", ""), saved);
+ }
+
+ [Fact]
+ public async Task SaveWithoutChanges_LeavesTheFileByteIdentical()
+ {
+ var (source, provider, repository) = await OpenTableAsync(UpgradesYaml);
+
+ // Force-save path: the repository rewrites the file even though nothing changed.
+ await repository.SaveAsync();
+ Assert.Equal(UpgradesYaml, provider.Files["Upgrades.yaml"]);
+
+ await source.SaveAsync();
+ Assert.Equal(UpgradesYaml, provider.Files["Upgrades.yaml"]);
+ }
+
+ [Fact]
+ public async Task SavingTwice_KeepsComments()
+ {
+ var (source, provider, _) = await OpenTableAsync(UpgradesYaml);
+
+ source.TrackPropertyChange("domes", nameof(UpgradeRow.FirstCost), 175, out _);
+ await source.SaveAsync();
+ source.TrackPropertyChange("push-strength", nameof(UpgradeRow.FirstCost), 55, out _);
+ var saved = await SaveAsync(source, provider);
+
+ var expected = Replace(UpgradesYaml, "FirstCost: 150 #", "FirstCost: 175 #");
+ expected = Replace(expected, "FirstCost: 50\n", "FirstCost: 55\n");
+ Assert.Equal(expected, saved);
+ }
+
+ [Fact]
+ public async Task CrlfFile_StaysCrlf()
+ {
+ var crlf = UpgradesYaml.Replace("\n", "\r\n");
+ var (source, provider, _) = await OpenTableAsync(crlf);
+
+ source.TrackPropertyChange("domes", nameof(UpgradeRow.FirstCost), 175, out _);
+ var saved = await SaveAsync(source, provider);
+
+ Assert.Equal(Replace(crlf, "FirstCost: 150 #", "FirstCost: 175 #"), saved);
+ }
+
+ #endregion
+
+ #region Adding and deleting rows
+
+ [Fact]
+ public async Task AddingRow_AppendsItWithoutTouchingTheRest()
+ {
+ var (source, provider, _) = await OpenTableAsync(UpgradesYaml);
+
+ source.Add("carpet", new UpgradeRow
+ {
+ Id = "carpet",
+ Name = "Stair carpet",
+ Base = 0,
+ Levels = new List { 1, 2 },
+ FirstCost = 350,
+ });
+ var saved = await SaveAsync(source, provider);
+
+ // Everything up to the end of the last row is untouched...
+ var lastRowEnd = UpgradesYaml.IndexOf("# end of table", StringComparison.Ordinal);
+ Assert.StartsWith(UpgradesYaml.Substring(0, lastRowEnd - 1), saved);
+ // ...the new row follows it, separated the way the other rows are...
+ var newRow = saved.Substring(lastRowEnd - 1);
+ Assert.StartsWith("\n\n- Id: carpet\n Name: Stair carpet\n", newRow);
+ // ...and the footer comment is still last.
+ Assert.EndsWith("\n# end of table\n", saved);
+
+ var rows = Parse(saved);
+ Assert.Equal(new[] { "push-strength", "bottle-count", "domes", "carpet" }, rows.Keys);
+ Assert.Equal(new List { 1, 2 }, rows["carpet"].Levels);
+ Assert.Equal(350, rows["carpet"].FirstCost);
+ }
+
+ [Fact]
+ public async Task DeletingRow_TakesItsOwnCommentsButKeepsTheFileHeader()
+ {
+ var (source, provider, _) = await OpenTableAsync(UpgradesYaml);
+
+ source.Delete("push-strength");
+ var saved = await SaveAsync(source, provider);
+
+ var expected =
+ "# Upgrades: what the player can buy between runs.\n" +
+ "# Costs grow by CostGrowth each level.\n" +
+ "\n" +
+ UpgradesYaml.Substring(UpgradesYaml.IndexOf("# --- Line upgrades ---", StringComparison.Ordinal));
+ Assert.Equal(expected, saved);
+ Assert.DoesNotContain("The first thing a new player buys", saved);
+ }
+
+ [Fact]
+ public async Task DeletingRow_RemovesTheCommentsInsideIt_AndKeepsTheSectionComment()
+ {
+ var (source, provider, _) = await OpenTableAsync(UpgradesYaml);
+
+ source.Delete("bottle-count");
+ var saved = await SaveAsync(source, provider);
+
+ var start = UpgradesYaml.IndexOf("- Id: bottle-count", StringComparison.Ordinal);
+ var end = UpgradesYaml.IndexOf("- Id: domes", StringComparison.Ordinal);
+ var expected = UpgradesYaml.Remove(start, end - start);
+ Assert.Equal(expected, saved);
+ Assert.DoesNotContain("one bottle per level", saved);
+ Assert.DoesNotContain("fits the short stage", saved);
+ Assert.Contains("# --- Line upgrades ---", saved);
+ }
+
+ [Fact]
+ public async Task DeletingLastRow_KeepsTheFooter()
+ {
+ var (source, provider, _) = await OpenTableAsync(UpgradesYaml);
+
+ source.Delete("domes");
+ var saved = await SaveAsync(source, provider);
+
+ var start = UpgradesYaml.IndexOf("\n\n- Id: domes", StringComparison.Ordinal);
+ var end = UpgradesYaml.IndexOf("\n# end of table", StringComparison.Ordinal);
+ Assert.Equal(UpgradesYaml.Remove(start, end - start), saved);
+ }
+
+ [Fact]
+ public async Task AddDeleteAndChangeInOneSave()
+ {
+ var (source, provider, _) = await OpenTableAsync(UpgradesYaml);
+
+ source.Delete("push-strength");
+ source.TrackPropertyChange("bottle-count", nameof(UpgradeRow.Base), 4.0, out _);
+ source.Add("mat", new UpgradeRow { Id = "mat", Name = "Rubber mat", FirstCost = 90 });
+ var saved = await SaveAsync(source, provider);
+
+ Assert.Contains("# Upgrades: what the player can buy between runs.", saved);
+ Assert.Contains("# --- Line upgrades ---", saved);
+ Assert.Contains(" # one bottle per level, capped by the stage length\n", saved);
+ Assert.Contains(" - 4 # fits the short stage\n", saved);
+ Assert.Contains(" Base: 4\n # one bottle per level", saved);
+ Assert.Contains(" FirstCost: 150 # tuned in playtest 3\n", saved);
+ Assert.DoesNotContain("The first thing a new player buys", saved);
+
+ var rows = Parse(saved);
+ Assert.Equal(new[] { "bottle-count", "domes", "mat" }, rows.Keys);
+ Assert.Equal(4, rows["bottle-count"].Base);
+ }
+
+ #endregion
+
+ #region Single data
+
+ [Fact]
+ public async Task SingleData_ChangingValues_KeepsComments()
+ {
+ var provider = new MemoryRawDataProvider();
+ provider.Files["Economy.yaml"] = EconomyYaml;
+ var repository = new SingleDataRepository(
+ "Economy.yaml",
+ provider,
+ new DataSerializerFactory(),
+ (data, serializer) => serializer.DeserializeSingle(data),
+ (data, serializer) => serializer.SerializeSingle(data));
+ await repository.InitializeAsync();
+ var source = new EditableSingleDataSource(repository);
+
+ source.TrackPropertyChange(EditableSingleDataSource.SingleKey, nameof(EconomyConfig.GoldenBottleChance), 0.03, out _);
+ source.TrackPropertyChange(EditableSingleDataSource.SingleKey, nameof(EconomyConfig.Targets), new List
+ {
+ new Target { Run = 1, Deposit = 60 },
+ new Target { Run = 5, Deposit = 300 },
+ new Target { Run = 10, Deposit = 600 },
+ }, out _);
+ await source.SaveAsync();
+
+ var expected = Replace(EconomyYaml, "GoldenBottleChance: 0.02", "GoldenBottleChance: 0.03");
+ expected = Replace(expected, "Deposit: 250 #", "Deposit: 300 #");
+ Assert.Equal(expected, provider.Files["Economy.yaml"]);
+ }
+
+ #endregion
+
+ #region Generated context
+
+ [Fact]
+ public async Task GeneratedContext_SavingPolymorphicYamlTable_KeepsComments()
+ {
+ // Skills.yaml is a sample table with a header comment and polymorphic ($type) effects,
+ // loaded through the generated GameDataContext exactly as the Unity editor does.
+ var directory = Path.Combine(Path.GetTempPath(), "datra-yaml-comments-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ try
+ {
+ var original = File.ReadAllText(Path.Combine(TestDataHelper.FindDataPath(), "Skills.yaml"));
+ var annotated = Replace(original, " ManaCost: 30\n", " ManaCost: 30 # cheap on purpose\n");
+ File.WriteAllText(Path.Combine(directory, "Skills.yaml"), annotated);
+
+ var context = new GameDataContext(new TestRawDataProvider(directory), new DataSerializerFactory(new[] { typeof(SkillEffect) }));
+ await context.Skill.InitializeAsync();
+ var source = new EditableKeyValueDataSource(context.Skill);
+
+ source.TrackPropertyChange("skill_heal", nameof(SkillData.ManaCost), 35, out _);
+ await source.SaveAsync();
+
+ var saved = File.ReadAllText(Path.Combine(directory, "Skills.yaml"));
+ Assert.Equal(Replace(annotated, "ManaCost: 30 #", "ManaCost: 35 #"), saved);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ #endregion
+
+ #region Other formats and scale
+
+ [Fact]
+ public async Task JsonTable_IsStillWrittenByTheSerializerAsBefore()
+ {
+ const string json = "[{\"Id\":\"a\",\"Name\":\"A\",\"Base\":1,\"Levels\":[],\"FirstCost\":10,\"RequiresUpgrade\":\"\",\"Implemented\":false}]";
+ var (source, provider, _) = await OpenTableAsync(json, "Upgrades.json");
+
+ source.TrackPropertyChange("a", nameof(UpgradeRow.FirstCost), 20, out _);
+ var saved = await SaveAsync(source, provider, "Upgrades.json");
+
+ var serializer = new DataSerializerFactory().GetSerializer("Upgrades.json");
+ var expected = serializer.SerializeTable(serializer.DeserializeTable(json.Replace("\"FirstCost\":10", "\"FirstCost\":20")));
+ Assert.Equal(expected, saved);
+ }
+
+ [Fact]
+ public async Task LargeTable_ChangingOneRow_IsFastAndExact()
+ {
+ var sb = new StringBuilder("# generated table\n\n");
+ for (var i = 0; i < 3000; i++)
+ {
+ sb.Append("# row ").Append(i).Append('\n');
+ sb.Append("- Id: row-").Append(i).Append('\n');
+ sb.Append(" Name: Row ").Append(i).Append(" # name\n");
+ sb.Append(" Base: ").Append(i).Append('\n');
+ sb.Append(" Levels: [1, 2, 3]\n");
+ sb.Append(" FirstCost: ").Append(i * 10).Append('\n');
+ sb.Append(" RequiresUpgrade: \"\"\n");
+ sb.Append(" Implemented: true\n\n");
+ }
+ var yaml = sb.ToString();
+ var (source, provider, _) = await OpenTableAsync(yaml);
+
+ source.TrackPropertyChange("row-1500", nameof(UpgradeRow.FirstCost), 1, out _);
+ var stopwatch = Stopwatch.StartNew();
+ var saved = await SaveAsync(source, provider);
+ stopwatch.Stop();
+
+ Assert.Equal(Replace(yaml, "FirstCost: 15000\n", "FirstCost: 1\n"), saved);
+ Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(10), $"Saving took {stopwatch.Elapsed}.");
+ }
+
+ #endregion
+ }
+}
diff --git a/Datra.Tests/YamlCommentPreserverTests.cs b/Datra.Tests/YamlCommentPreserverTests.cs
new file mode 100644
index 0000000..88c0907
--- /dev/null
+++ b/Datra.Tests/YamlCommentPreserverTests.cs
@@ -0,0 +1,250 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+using Datra.Interfaces;
+using Datra.Serializers;
+using Xunit;
+
+namespace Datra.Tests
+{
+ ///
+ /// Cases for that the repository API cannot reach
+ /// (reordering) or that are about when it must give up.
+ ///
+ public class YamlCommentPreserverTests
+ {
+ public class Row : ITableData
+ {
+ public string Id { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public List Tags { get; set; } = new List();
+ public Stats Stats { get; set; } = new Stats();
+ public string Notes { get; set; } = string.Empty;
+ }
+
+ public class Stats
+ {
+ public int Hp { get; set; }
+ public int Speed { get; set; }
+ }
+
+ private static readonly YamlDataSerializer Serializer = new YamlDataSerializer();
+
+ private static Dictionary Load(string yaml) => Serializer.DeserializeTable(yaml);
+
+ private static string Save(string original, Dictionary table)
+ => YamlCommentPreserver.Reconcile(
+ original,
+ Serializer.SerializeTable(table),
+ text => Serializer.DeserializeTable(text),
+ t => Serializer.SerializeTable(t));
+
+ private const string Yaml =
+ "# header\n" +
+ "\n" +
+ "# about a\n" +
+ "- Id: a\n" +
+ " Name: Alpha\n" +
+ " Tags: [x, y]\n" +
+ " Stats:\n" +
+ " Hp: 10 # base hp\n" +
+ " # speed is tiles per second\n" +
+ " Speed: 2\n" +
+ "\n" +
+ "# about b\n" +
+ "- Id: b\n" +
+ " Name: Beta # placeholder name\n" +
+ " Tags:\n" +
+ " - one # first\n" +
+ " - two # second\n" +
+ " - three # third\n" +
+ "\n" +
+ "# about c\n" +
+ "- Id: c\n" +
+ " Name: Gamma\n";
+
+ [Fact]
+ public void ReorderingRows_MovesEachRowWithItsComments()
+ {
+ var table = Load(Yaml);
+ var reordered = new Dictionary { ["c"] = table["c"], ["a"] = table["a"], ["b"] = table["b"] };
+
+ var saved = Save(Yaml, reordered);
+
+ var expected =
+ "# header\n" +
+ "\n" +
+ "# about c\n" +
+ "- Id: c\n" +
+ " Name: Gamma\n" +
+ "# about a\n" +
+ "- Id: a\n" +
+ " Name: Alpha\n" +
+ " Tags: [x, y]\n" +
+ " Stats:\n" +
+ " Hp: 10 # base hp\n" +
+ " # speed is tiles per second\n" +
+ " Speed: 2\n" +
+ "\n" +
+ "# about b\n" +
+ "- Id: b\n" +
+ " Name: Beta # placeholder name\n" +
+ " Tags:\n" +
+ " - one # first\n" +
+ " - two # second\n" +
+ " - three # third\n";
+ Assert.Equal(expected, saved);
+ }
+
+ [Fact]
+ public void RemovingMiddleOfPlainList_KeepsTheOtherEntriesComments()
+ {
+ var table = Load(Yaml);
+ table["b"].Tags = new List { "one", "three" };
+
+ var saved = Save(Yaml, table);
+
+ Assert.Equal(Yaml.Replace(" - two # second\n", ""), saved);
+ }
+
+ [Fact]
+ public void ChangingNestedObjectField_KeepsCommentsInTheNestedObject()
+ {
+ var table = Load(Yaml);
+ table["a"].Stats.Speed = 3;
+
+ var saved = Save(Yaml, table);
+
+ Assert.Equal(Yaml.Replace(" Speed: 2\n", " Speed: 3\n"), saved);
+ }
+
+ [Fact]
+ public void ChangingToMultiLineText_WritesABlockScalarUnderTheField()
+ {
+ var table = Load(Yaml);
+ table["c"].Notes = "line one\nline two";
+
+ var saved = Save(Yaml, table);
+
+ Assert.StartsWith(Yaml, saved);
+ Assert.Equal("line one\nline two", Load(saved)["c"].Notes);
+ }
+
+ [Fact]
+ public void ChangingLiteralBlockText_KeepsTheCommentsAroundIt()
+ {
+ var yaml = Yaml.Replace(
+ " Name: Gamma\n",
+ " Name: Gamma\n # designer notes\n Notes: |\n first line\n second line\n # after notes\n");
+ var table = Load(yaml);
+ table["c"].Notes = "only line";
+
+ var saved = Save(yaml, table);
+
+ Assert.Equal(yaml.Replace("Notes: |\n first line\n second line\n", "Notes: only line\n"), saved);
+ }
+
+ [Fact]
+ public void FillingAnEmptyField_WritesTheValueAfterTheColon()
+ {
+ var yaml = Yaml.Replace(" Name: Gamma\n", " Name: # to be named\n");
+ var table = Load(yaml);
+ table["c"].Name = "Gamma";
+
+ var saved = Save(yaml, table);
+
+ Assert.Equal(yaml.Replace(" Name: #", " Name: Gamma #"), saved);
+ }
+
+ [Fact]
+ public void FieldsTheTypeDoesNotKnow_AreKeptWhenAnotherFieldChanges()
+ {
+ var yaml = Yaml.Replace(" Name: Gamma\n", " Name: Gamma\n Legacy: keep me\n");
+ var table = Load(yaml);
+ table["c"].Name = "Gamma 2";
+
+ var saved = Save(yaml, table);
+
+ Assert.Equal(yaml.Replace("Name: Gamma\n", "Name: Gamma 2\n"), saved);
+ }
+
+ [Fact]
+ public void EmptyTableWithHeader_AddingARowKeepsTheHeader()
+ {
+ const string yaml = "# nothing yet\n[]\n";
+ var table = Load(yaml);
+ table["a"] = new Row { Id = "a", Name = "Alpha" };
+
+ var saved = Save(yaml, table);
+
+ Assert.StartsWith("# nothing yet\n- Id: a\n", saved);
+ Assert.Equal("Alpha", Load(saved)["a"].Name);
+ }
+
+ [Fact]
+ public void DeletingEveryRow_KeepsTheHeader()
+ {
+ var saved = Save(Yaml, new Dictionary());
+
+ Assert.Equal("# header\n\n[]\n", saved);
+ Assert.Empty(Load(saved));
+ }
+
+ [Fact]
+ public void DocumentStartMarker_IsKept()
+ {
+ var yaml = "---\n" + Yaml;
+ var table = Load(yaml);
+ table["c"].Name = "Gamma 2";
+
+ var saved = Save(yaml, table);
+
+ Assert.Equal(yaml.Replace("Name: Gamma\n", "Name: Gamma 2\n"), saved);
+ }
+
+ [Fact]
+ public void AnchorsAndAliases_ChangingTheAnchoredValue_KeepsTheAliasUserCorrect()
+ {
+ const string yaml =
+ "# shared tags\n" +
+ "- Id: a\n" +
+ " Tags: &shared [x, y]\n" +
+ "- Id: b\n" +
+ " Tags: *shared\n";
+ var table = Load(yaml);
+ table["a"].Tags = new List { "z" };
+
+ var saved = Save(yaml, table);
+
+ // The anchored value cannot be patched in place without leaving *shared
+ // dangling; both rows are rewritten and the data stays right.
+ var reloaded = Load(saved);
+ Assert.Equal(new List { "z" }, reloaded["a"].Tags);
+ Assert.Equal(new List { "x", "y" }, reloaded["b"].Tags);
+ Assert.StartsWith("# shared tags\n", saved);
+ }
+
+ [Fact]
+ public void Merge_ReturnsNullForMultipleDocuments()
+ {
+ var canonical = Serializer.SerializeTable(Load(Yaml));
+ Assert.Null(YamlCommentPreserver.Merge(Yaml + "---\n- Id: z\n", canonical, canonical));
+ }
+
+ [Fact]
+ public void Reconcile_WithoutOriginal_ReturnsTheSerializerOutput()
+ {
+ var canonical = Serializer.SerializeTable(Load(Yaml));
+ Assert.Same(canonical, YamlCommentPreserver.Reconcile>(null, canonical, Load, t => Serializer.SerializeTable(t)));
+ Assert.Same(canonical, YamlCommentPreserver.Reconcile>(" \n", canonical, Load, t => Serializer.SerializeTable(t)));
+ }
+
+ [Fact]
+ public void Reconcile_WithUnreadableOriginal_ReturnsTheSerializerOutput()
+ {
+ var canonical = Serializer.SerializeTable(Load(Yaml));
+ var saved = YamlCommentPreserver.Reconcile>("- Id: [unclosed\n", canonical, Load, t => Serializer.SerializeTable(t));
+ Assert.Equal(canonical, saved);
+ }
+ }
+}
diff --git a/Datra.Unity.Sample/Assets/Tests/Editor/YamlCommentPreservationTests.cs b/Datra.Unity.Sample/Assets/Tests/Editor/YamlCommentPreservationTests.cs
new file mode 100644
index 0000000..5da778a
--- /dev/null
+++ b/Datra.Unity.Sample/Assets/Tests/Editor/YamlCommentPreservationTests.cs
@@ -0,0 +1,99 @@
+#nullable disable
+using System.Collections;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using Datra.Editor.DataSources;
+using Datra.Interfaces;
+using Datra.Repositories;
+using Datra.Serializers;
+using Datra.Unity.Editor.Providers;
+using NUnit.Framework;
+using UnityEditor;
+using UnityEngine.TestTools;
+
+namespace Datra.Unity.Tests
+{
+ ///
+ /// The Data Editor's save path inside Unity: AssetDatabaseRawDataProvider reads the
+ /// file back through the AssetDatabase at save time, so this checks that comments
+ /// survive that path, including a second save after the asset was re-imported.
+ ///
+ public class YamlCommentPreservationTests
+ {
+ public class UpgradeRow : ITableData
+ {
+ public string Id { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public List Levels { get; set; } = new List();
+ public int FirstCost { get; set; }
+ }
+
+ private const string Folder = "Assets/DatraYamlCommentTest";
+ private const string FileName = "Upgrades.yaml";
+
+ private const string Yaml =
+ "# Upgrades: what the player can buy between runs.\n" +
+ "\n" +
+ "# The first thing a new player buys.\n" +
+ "- Id: push-strength\n" +
+ " Name: Push strength\n" +
+ " Levels: [0.9, 1.3, 1.6]\n" +
+ " FirstCost: 50 # cheap on purpose\n" +
+ "\n" +
+ "- Id: domes\n" +
+ " Name: Balance dome\n" +
+ " Levels:\n" +
+ " - 4 # fits the short stage\n" +
+ " - 5\n" +
+ " FirstCost: 150\n";
+
+ [SetUp]
+ public void SetUp()
+ {
+ Directory.CreateDirectory(Folder);
+ File.WriteAllText(Path.Combine(Folder, FileName), Yaml);
+ AssetDatabase.ImportAsset(Folder + "/" + FileName);
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ AssetDatabase.DeleteAsset(Folder);
+ }
+
+ [UnityTest]
+ public IEnumerator SavingFromTheEditor_KeepsComments()
+ {
+ var repository = new KeyValueDataRepository(
+ FileName,
+ new AssetDatabaseRawDataProvider(basePath: Folder),
+ new DataSerializerFactory(),
+ (data, serializer) => serializer.DeserializeTable(data),
+ (table, serializer) => serializer.SerializeTable(table));
+ yield return Wait(repository.InitializeAsync());
+
+ var source = new EditableKeyValueDataSource(repository);
+ source.TrackPropertyChange("push-strength", nameof(UpgradeRow.FirstCost), 55, out _);
+ yield return Wait(source.SaveAsync());
+
+ var expected = Yaml.Replace("FirstCost: 50 #", "FirstCost: 55 #");
+ Assert.AreEqual(expected, File.ReadAllText(Path.Combine(Folder, FileName)));
+
+ // Second save: the file is read back through the re-imported TextAsset.
+ source.TrackPropertyChange("domes", nameof(UpgradeRow.Levels), new List { 4, 6 }, out _);
+ yield return Wait(source.SaveAsync());
+
+ expected = expected.Replace(" - 5\n", " - 6\n");
+ Assert.AreEqual(expected, File.ReadAllText(Path.Combine(Folder, FileName)));
+ }
+
+ private static IEnumerator Wait(Task task)
+ {
+ while (!task.IsCompleted)
+ yield return null;
+ if (task.IsFaulted)
+ Assert.Fail(task.Exception?.ToString());
+ }
+ }
+}
diff --git a/Datra.Unity.Sample/Assets/Tests/Editor/YamlCommentPreservationTests.cs.meta b/Datra.Unity.Sample/Assets/Tests/Editor/YamlCommentPreservationTests.cs.meta
new file mode 100644
index 0000000..7bb1fc0
--- /dev/null
+++ b/Datra.Unity.Sample/Assets/Tests/Editor/YamlCommentPreservationTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9934a38c7d664f70bd26931b6a0c0ba8
\ No newline at end of file
diff --git a/Datra/Repositories/KeyValueDataRepository.cs b/Datra/Repositories/KeyValueDataRepository.cs
index 96eb443..0210be9 100644
--- a/Datra/Repositories/KeyValueDataRepository.cs
+++ b/Datra/Repositories/KeyValueDataRepository.cs
@@ -4,8 +4,10 @@
using System.IO;
using System.Linq;
using System.Threading.Tasks;
+using Datra.Attributes;
using Datra.Interfaces;
using Datra.Serializers;
+using Datra.Utilities;
namespace Datra.Repositories
{
@@ -25,6 +27,12 @@ public class KeyValueDataRepository : EditableTableRepository>? _csvDeserializeFunc;
private readonly Func, string>? _csvSerializeFunc;
+ ///
+ /// The text the table was last loaded from or saved as. Used to keep a YAML file's
+ /// comments when the provider cannot read the file back at save time.
+ ///
+ private string? _lastKnownText;
+
///
/// 로드된 파일 경로
///
@@ -91,6 +99,7 @@ public KeyValueDataRepository(
}
LoadedFilePath = _rawDataProvider.ResolveFilePath(_filePath);
+ _lastKnownText = rawData;
Dictionary data;
@@ -143,6 +152,20 @@ protected override async Task SaveAllDataAsync(
{
var serializer = ResolveSerializer();
rawData = _serializeFunc(allData, serializer);
+
+ // A YAML file is hand-written: carry its comments and layout over to the
+ // rewritten text instead of replacing it wholesale.
+ if (_deserializeFunc != null && IsYaml())
+ {
+ var serializeFunc = _serializeFunc;
+ var deserializeFunc = _deserializeFunc;
+ var original = await ReadCurrentTextAsync();
+ rawData = YamlCommentPreserver.Reconcile(
+ original,
+ rawData,
+ text => deserializeFunc(text, serializer),
+ table => serializeFunc(table, serializer));
+ }
}
else
{
@@ -150,6 +173,35 @@ protected override async Task SaveAllDataAsync(
}
await _rawDataProvider.SaveTextAsync(_filePath, rawData);
+ _lastKnownText = rawData;
+ }
+
+ ///
+ /// The file as it is now, so comments added outside the editor since loading are
+ /// kept too; the text last loaded or saved if the provider cannot read it.
+ ///
+ private async Task ReadCurrentTextAsync()
+ {
+ try
+ {
+ if (!_rawDataProvider.Exists(_filePath))
+ return _lastKnownText;
+ return await _rawDataProvider.LoadTextAsync(_filePath);
+ }
+ catch (Exception)
+ {
+ return _lastKnownText;
+ }
+ }
+
+ private bool IsYaml()
+ {
+ if (_rawDataProvider is IFormatAwareRawDataProvider fa &&
+ fa.GetFormat(_filePath) is { } overrideFmt)
+ {
+ return overrideFmt == DataFormat.Yaml;
+ }
+ return DataFormatHelper.TryDetectFormat(_filePath, out var format) && format == DataFormat.Yaml;
}
///
diff --git a/Datra/Repositories/SingleDataRepository.cs b/Datra/Repositories/SingleDataRepository.cs
index ec65fd0..44ac17e 100644
--- a/Datra/Repositories/SingleDataRepository.cs
+++ b/Datra/Repositories/SingleDataRepository.cs
@@ -3,8 +3,10 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
+using Datra.Attributes;
using Datra.Interfaces;
using Datra.Serializers;
+using Datra.Utilities;
namespace Datra.Repositories
{
@@ -21,6 +23,12 @@ public class SingleDataRepository : EditableSingleRepository, IEdi
private readonly Func _deserializeFunc;
private readonly Func? _serializeFunc;
+ ///
+ /// The text the data was last loaded from or saved as; see
+ /// .
+ ///
+ private string? _lastKnownText;
+
public SingleDataRepository(
string filePath,
IRawDataProvider rawDataProvider,
@@ -62,6 +70,7 @@ public IEnumerable