Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` (재정렬·폴백 등)

## 테스트 방법

### 통합 테스트 (권장)
Expand Down
533 changes: 533 additions & 0 deletions Datra.Tests/YamlCommentPreservationTests.cs

Large diffs are not rendered by default.

250 changes: 250 additions & 0 deletions Datra.Tests/YamlCommentPreserverTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using Datra.Interfaces;
using Datra.Serializers;
using Xunit;

namespace Datra.Tests
{
/// <summary>
/// Cases for <see cref="YamlCommentPreserver"/> that the repository API cannot reach
/// (reordering) or that are about when it must give up.
/// </summary>
public class YamlCommentPreserverTests
{
public class Row : ITableData<string>
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public List<string> Tags { get; set; } = new List<string>();
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<string, Row> Load(string yaml) => Serializer.DeserializeTable<string, Row>(yaml);

private static string Save(string original, Dictionary<string, Row> table)
=> YamlCommentPreserver.Reconcile(
original,
Serializer.SerializeTable(table),
text => Serializer.DeserializeTable<string, Row>(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<string, Row> { ["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<string> { "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<string, Row>());

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<string> { "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<string> { "z" }, reloaded["a"].Tags);
Assert.Equal(new List<string> { "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<Dictionary<string, Row>>(null, canonical, Load, t => Serializer.SerializeTable(t)));
Assert.Same(canonical, YamlCommentPreserver.Reconcile<Dictionary<string, Row>>(" \n", canonical, Load, t => Serializer.SerializeTable(t)));
}

[Fact]
public void Reconcile_WithUnreadableOriginal_ReturnsTheSerializerOutput()
{
var canonical = Serializer.SerializeTable(Load(Yaml));
var saved = YamlCommentPreserver.Reconcile<Dictionary<string, Row>>("- Id: [unclosed\n", canonical, Load, t => Serializer.SerializeTable(t));
Assert.Equal(canonical, saved);
}
}
}
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 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.
/// </summary>
public class YamlCommentPreservationTests
{
public class UpgradeRow : ITableData<string>
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public List<double> Levels { get; set; } = new List<double>();
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<string, UpgradeRow>(
FileName,
new AssetDatabaseRawDataProvider(basePath: Folder),
new DataSerializerFactory(),
(data, serializer) => serializer.DeserializeTable<string, UpgradeRow>(data),
(table, serializer) => serializer.SerializeTable(table));
yield return Wait(repository.InitializeAsync());

var source = new EditableKeyValueDataSource<string, UpgradeRow>(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<double> { 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());
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading