Skip to content

Commit 092d463

Browse files
authored
Merge pull request #200 from ArgumentumGames/feat/issue-193-gsheet-sync
feat(sync): bidirectional GSheet ↔ CSV sync (#193)
2 parents 984d40e + 24b181f commit 092d463

15 files changed

Lines changed: 1689 additions & 0 deletions
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
using Argumentum.AssetConverter.GSheetSync;
2+
using FluentAssertions;
3+
using Xunit;
4+
5+
namespace Argumentum.AssetConverter.Tests.GSheetSync
6+
{
7+
public class CsvDiffEngineTests
8+
{
9+
private const string HeaderPk = "pk";
10+
11+
private static string MakeCsv(string[] headers, params string[][] rows)
12+
{
13+
var sb = new System.Text.StringBuilder();
14+
sb.AppendLine(string.Join(",", headers));
15+
foreach (var row in rows)
16+
{
17+
sb.AppendLine(string.Join(",", row));
18+
}
19+
return sb.ToString();
20+
}
21+
22+
[Fact]
23+
public void Compare_IdenticalCsv_NoDiff()
24+
{
25+
var csv = MakeCsv(
26+
new[] { HeaderPk, "Name", "Value" },
27+
new[] { "1", "Foo", "100" },
28+
new[] { "2", "Bar", "200" }
29+
);
30+
31+
var engine = new CsvDiffEngine(HeaderPk);
32+
var result = engine.Compare(csv, csv);
33+
34+
result.RowsAdded.Should().Be(0);
35+
result.RowsDeleted.Should().Be(0);
36+
result.RowsModified.Should().Be(0);
37+
result.RowsUnchanged.Should().Be(2);
38+
result.CellsModified.Should().Be(0);
39+
result.SampleOverwrites.Should().BeEmpty();
40+
result.HasColumnStructureChange.Should().BeFalse();
41+
}
42+
43+
[Fact]
44+
public void Compare_AddedRows_Detected()
45+
{
46+
var oldCsv = MakeCsv(
47+
new[] { HeaderPk, "Name" },
48+
new[] { "1", "Alpha" }
49+
);
50+
var newCsv = MakeCsv(
51+
new[] { HeaderPk, "Name" },
52+
new[] { "1", "Alpha" },
53+
new[] { "2", "Beta" },
54+
new[] { "3", "Gamma" }
55+
);
56+
57+
var engine = new CsvDiffEngine(HeaderPk);
58+
var result = engine.Compare(oldCsv, newCsv);
59+
60+
result.RowsAdded.Should().Be(2);
61+
result.RowsDeleted.Should().Be(0);
62+
result.TotalRowsOld.Should().Be(1);
63+
result.TotalRowsNew.Should().Be(3);
64+
}
65+
66+
[Fact]
67+
public void Compare_DeletedRows_Detected()
68+
{
69+
var oldCsv = MakeCsv(
70+
new[] { HeaderPk, "Name" },
71+
new[] { "1", "Alpha" },
72+
new[] { "2", "Beta" },
73+
new[] { "3", "Gamma" }
74+
);
75+
var newCsv = MakeCsv(
76+
new[] { HeaderPk, "Name" },
77+
new[] { "1", "Alpha" }
78+
);
79+
80+
var engine = new CsvDiffEngine(HeaderPk);
81+
var result = engine.Compare(oldCsv, newCsv);
82+
83+
result.RowsAdded.Should().Be(0);
84+
result.RowsDeleted.Should().Be(2);
85+
result.RowsUnchanged.Should().Be(1);
86+
result.DeletionPercentage.Should().BeApproximately(66.67, 0.1);
87+
}
88+
89+
[Fact]
90+
public void Compare_ModifiedCells_DetectedWithSamples()
91+
{
92+
var oldCsv = MakeCsv(
93+
new[] { HeaderPk, "Name", "Value" },
94+
new[] { "1", "Alpha", "100" },
95+
new[] { "2", "Beta", "200" }
96+
);
97+
var newCsv = MakeCsv(
98+
new[] { HeaderPk, "Name", "Value" },
99+
new[] { "1", "Alpha-Modified", "100" },
100+
new[] { "2", "Beta", "250" }
101+
);
102+
103+
var engine = new CsvDiffEngine(HeaderPk, maxOverwriteExamples: 5);
104+
var result = engine.Compare(oldCsv, newCsv);
105+
106+
result.RowsModified.Should().Be(2);
107+
result.CellsModified.Should().Be(2);
108+
result.SampleOverwrites.Should().HaveCount(2);
109+
110+
result.SampleOverwrites[0].PrimaryKey.Should().Be("1");
111+
result.SampleOverwrites[0].ColumnName.Should().Be("Name");
112+
result.SampleOverwrites[0].OldValue.Should().Be("Alpha");
113+
result.SampleOverwrites[0].NewValue.Should().Be("Alpha-Modified");
114+
115+
result.SampleOverwrites[1].PrimaryKey.Should().Be("2");
116+
result.SampleOverwrites[1].ColumnName.Should().Be("Value");
117+
result.SampleOverwrites[1].OldValue.Should().Be("200");
118+
result.SampleOverwrites[1].NewValue.Should().Be("250");
119+
}
120+
121+
[Fact]
122+
public void Compare_SampleOverwrites_LimitedByMax()
123+
{
124+
var oldCsv = MakeCsv(
125+
new[] { HeaderPk, "Name" },
126+
new[] { "1", "A" },
127+
new[] { "2", "B" },
128+
new[] { "3", "C" },
129+
new[] { "4", "D" }
130+
);
131+
var newCsv = MakeCsv(
132+
new[] { HeaderPk, "Name" },
133+
new[] { "1", "A1" },
134+
new[] { "2", "B1" },
135+
new[] { "3", "C1" },
136+
new[] { "4", "D1" }
137+
);
138+
139+
var engine = new CsvDiffEngine(HeaderPk, maxOverwriteExamples: 2);
140+
var result = engine.Compare(oldCsv, newCsv);
141+
142+
result.CellsModified.Should().Be(4);
143+
result.SampleOverwrites.Should().HaveCount(2);
144+
}
145+
146+
[Fact]
147+
public void Compare_ColumnsAddedRemoved_Detected()
148+
{
149+
var oldCsv = MakeCsv(
150+
new[] { HeaderPk, "Name", "OldCol" },
151+
new[] { "1", "Alpha", "x" }
152+
);
153+
var newCsv = MakeCsv(
154+
new[] { HeaderPk, "Name", "NewCol" },
155+
new[] { "1", "Alpha", "y" }
156+
);
157+
158+
var engine = new CsvDiffEngine(HeaderPk);
159+
var result = engine.Compare(oldCsv, newCsv);
160+
161+
result.HasColumnStructureChange.Should().BeTrue();
162+
result.ColumnsRemoved.Should().Contain("OldCol");
163+
result.ColumnsAdded.Should().Contain("NewCol");
164+
}
165+
166+
[Fact]
167+
public void Compare_MissingPrimaryKey_FallsBackToRowPosition()
168+
{
169+
var oldCsv = MakeCsv(
170+
new[] { "Name", "Value" },
171+
new[] { "Alpha", "100" },
172+
new[] { "Beta", "200" }
173+
);
174+
var newCsv = MakeCsv(
175+
new[] { "Name", "Value" },
176+
new[] { "Alpha-Modified", "100" },
177+
new[] { "Beta", "250" }
178+
);
179+
180+
var engine = new CsvDiffEngine("nonexistent_pk");
181+
var result = engine.Compare(oldCsv, newCsv);
182+
183+
// Falls back to row position indexing — rows match by position
184+
result.RowsModified.Should().Be(2);
185+
result.CellsModified.Should().Be(2);
186+
}
187+
188+
[Fact]
189+
public void Compare_EmptyCsv_NoDiff()
190+
{
191+
var csv = "pk,Name\n";
192+
193+
var engine = new CsvDiffEngine(HeaderPk);
194+
var result = engine.Compare(csv, csv);
195+
196+
result.RowsAdded.Should().Be(0);
197+
result.RowsDeleted.Should().Be(0);
198+
result.RowsModified.Should().Be(0);
199+
result.TotalRowsOld.Should().Be(0);
200+
result.TotalRowsNew.Should().Be(0);
201+
}
202+
203+
[Fact]
204+
public void Compare_SemicolonDelimiter_Parsed()
205+
{
206+
var oldCsv = "pk;Name\n1;Alpha\n";
207+
var newCsv = "pk;Name\n1;Alpha-Modified\n";
208+
209+
var engine = new CsvDiffEngine(HeaderPk, delimiter: ';');
210+
var result = engine.Compare(oldCsv, newCsv);
211+
212+
result.RowsModified.Should().Be(1);
213+
result.CellsModified.Should().Be(1);
214+
result.SampleOverwrites[0].OldValue.Should().Be("Alpha");
215+
result.SampleOverwrites[0].NewValue.Should().Be("Alpha-Modified");
216+
}
217+
218+
[Fact]
219+
public void Compare_NewlineNormalization_IgnoresCRLFDiff()
220+
{
221+
var oldCsv = "pk,Name\n1,\"Alpha\r\nBeta\"\n";
222+
var newCsv = "pk,Name\n1,\"Alpha\nBeta\"\n";
223+
224+
var engine = new CsvDiffEngine(HeaderPk);
225+
var result = engine.Compare(oldCsv, newCsv);
226+
227+
result.CellsModified.Should().Be(0);
228+
result.RowsModified.Should().Be(0);
229+
}
230+
231+
[Fact]
232+
public void Compare_ModificationPercentage_Calculated()
233+
{
234+
var oldCsv = MakeCsv(
235+
new[] { HeaderPk, "A", "B" },
236+
new[] { "1", "x", "y" },
237+
new[] { "2", "x", "y" }
238+
);
239+
var newCsv = MakeCsv(
240+
new[] { HeaderPk, "A", "B" },
241+
new[] { "1", "x-modified", "y" },
242+
new[] { "2", "x", "y" }
243+
);
244+
245+
var engine = new CsvDiffEngine(HeaderPk);
246+
var result = engine.Compare(oldCsv, newCsv);
247+
248+
// 2 rows * 3 common cols (pk, A, B) = 6 total cells, 1 modified
249+
result.TotalCellsOld.Should().Be(6);
250+
result.CellsModified.Should().Be(1);
251+
result.ModificationPercentage.Should().BeApproximately(16.67, 0.1);
252+
}
253+
254+
[Fact]
255+
public void Compare_LongValue_TruncatedInSample()
256+
{
257+
var longValue = new string('A', 200);
258+
var oldCsv = MakeCsv(
259+
new[] { HeaderPk, "Name" },
260+
new[] { "1", "Short" }
261+
);
262+
var newCsv = MakeCsv(
263+
new[] { HeaderPk, "Name" },
264+
new[] { "1", longValue }
265+
);
266+
267+
var engine = new CsvDiffEngine(HeaderPk);
268+
var result = engine.Compare(oldCsv, newCsv);
269+
270+
(result.SampleOverwrites[0].NewValue.Length <= 80).Should().BeTrue("long values should be truncated to max 80 chars");
271+
}
272+
}
273+
}

0 commit comments

Comments
 (0)