Skip to content

Commit bcfbe78

Browse files
committed
[Test] 重构测试代码比较音符相等的整体逻辑;同时修复若干问题。
1 parent 317ea02 commit bcfbe78

6 files changed

Lines changed: 90 additions & 108 deletions

File tree

parser/chu/C2sParser.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ public override (ChuChart, List<Alert>) Parse(string text)
5555
}
5656

5757
FillAllPrevious(chart, alerts, _rawTargetNote);
58+
chart.Sort();
5859
return (chart, alerts);
5960
}
6061

parser/chu/SusParser.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ public override (ChuChart, List<Alert>) Parse(string text)
5858
}
5959

6060
FillAllPrevious(chart, alerts);
61+
chart.Sort();
6162
return (chart, alerts);
6263
}
6364

parser/chu/UgcParser.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public override (ChuChart, List<Alert>) Parse(string text)
4949

5050
FinalizeUgcSflDurations(chart);
5151
FillAllPrevious(chart, alerts);
52+
chart.Sort();
5253
return (chart, alerts);
5354
}
5455

tests/chu/ChuTests.cs

Lines changed: 73 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
using System.Reflection;
21
using MuConvert.chu;
32
using MuConvert.utils;
43
using Rationals;
@@ -7,6 +6,9 @@ namespace MuConvert.Tests.chu;
76

87
public class ChuTests
98
{
9+
private static readonly Rational Tol768 = new(1, 768);
10+
private static readonly Rational Tol384 = new(1, 384);
11+
1012
private static string TestsetDir => Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "chu", "testset");
1113
private static string OfficialDir => Path.Combine(TestsetDir, "官谱");
1214
private static string CustomDir => Path.Combine(TestsetDir, "自制谱");
@@ -32,130 +34,97 @@ public void C2sRoundTrip(string c2sPath)
3234
var (reparsed, _) = new C2sParser().Parse(rt);
3335

3436
Assert.Equal(chart.Notes.Count, reparsed.Notes.Count);
35-
36-
var originalSnapshots = chart.Notes
37-
.Select(SnapshotNote)
38-
.OrderBy(s => s)
39-
.ToArray();
40-
41-
var reparsedSnapshots = reparsed.Notes
42-
.Select(SnapshotNote)
43-
.OrderBy(s => s)
44-
.ToArray();
45-
46-
Assert.Equal(originalSnapshots, reparsedSnapshots);
37+
AssertNotesEqual(chart.Notes, reparsed.Notes);
4738
}
4839

49-
/// <summary>
50-
/// Builds a stable, comparable string from a note's public instance properties (name-sorted)
51-
/// so round-trip tests verify no field loss without hard-coding each property in the test.
52-
/// Omits <see cref="ChuNote.ExtraData"/> and <see cref="ChuNote.EndTime"/> (redundant with Time/Duration or not stable across formats).
53-
/// </summary>
54-
private static string SnapshotNote(ChuNote note)
40+
private static void AssertNotesEqual(IReadOnlyList<ChuNote> expected_, IReadOnlyList<ChuNote> actual_)
5541
{
56-
string F(object? v, string field) => v switch
42+
const string EOF = "<EOF>";
43+
List<ChuNote> expected = expected_.ToList();
44+
List<ChuNote> actual = actual_.ToList();
45+
46+
for (var i = 0; i < Math.Max(expected.Count, actual.Count); i++)
5747
{
58-
Rational r => r.CanonicalForm.ToString(),
59-
List<int> list => string.Join(",", list),
60-
string s => s switch
48+
bool result;
49+
if (i >= expected.Count || i >= actual.Count) result = false;
50+
else
6151
{
62-
"DEF" => "",
63-
"A" when note.Type == "FLK" => "L",
64-
_ => s
65-
},
66-
null => "",
67-
_ => v.ToString() ?? "",
68-
};
69-
70-
var firstParts = new[]
71-
{
72-
$"Type={note.Type}",
73-
$"Time={note.Time}",
74-
$"Cell={note.Cell}",
75-
$"Width={note.Width}",
76-
};
77-
var propParts = typeof(ChuNote).GetProperties(BindingFlags.Instance | BindingFlags.Public)
78-
.OrderBy(p => p.Name)
79-
.Where(p => p.Name is not ("EndTime" or "Type" or "Time" or "Cell" or "Width"))
80-
.Select(p => $"{p.Name}={F(p.GetValue(note), p.Name)}");
81-
var fieldParts = typeof(ChuNote).GetFields(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)
82-
.OrderBy(f => f.Name)
83-
.Select(f => $"{f.Name}={F(f.GetValue(note), f.Name)}");
84-
return string.Join("|", firstParts.Concat(propParts).Concat(fieldParts));
52+
result = CompareNote(expected[i], actual[i]);
53+
if (!result)
54+
{
55+
// 尝试同一时刻的其他行有无相同的,如果有,交换之
56+
var j = i + 1;
57+
while (j < expected.Count && expected[j].Time == actual[i].Time)
58+
{
59+
if (CompareNote(expected[j], actual[i]))
60+
{
61+
(expected[j], expected[i]) = (expected[i], expected[j]);
62+
result = true;
63+
break;
64+
}
65+
j++;
66+
}
67+
}
68+
}
69+
70+
if (!result) {
71+
Assert.Fail(
72+
$"Note mismatch at index {i}:{Environment.NewLine}" +
73+
$"EXPECTED: {(i < expected.Count ? FormatNote(expected[i]) : EOF)}{Environment.NewLine}" +
74+
$"ACTUAL : {(i < actual.Count ? FormatNote(actual[i]) : EOF)}");
75+
}
76+
}
8577
}
8678

8779
/// <summary>
88-
/// 将 UGC 网格上的 <see cref="ChuNote"/> 的 Time / Duration 投影为「经 C2S 生成器写出再解析」后等价的分数(C2S 小节 tick = <paramref name="c2sResolution"/>)。
80+
/// 比较两个音符是否实质等同;时间与时长等字段可命中宽容规则(见测试类内常量与分支注释)。
8981
/// </summary>
90-
private static ChuNote UgcNoteScaledToC2sTicks(ChuNote n, int ugcTicksPerBeat, int c2sResolution)
82+
public static bool CompareNote(ChuNote expected, ChuNote actual)
9183
{
92-
var tpmUgc = ugcTicksPerBeat * 4;
93-
var (m, oU) = Utils.BarAndTick(n.Time, tpmUgc);
94-
var oC = (int)Math.Round((double)oU * c2sResolution / tpmUgc);
95-
var time = m + new Rational(oC, c2sResolution);
96-
var dur = new Rational(Utils.Tick(n.Duration, c2sResolution), c2sResolution);
97-
return CloneChuNoteWithTiming(n, time, dur);
84+
if (expected.Type != actual.Type) return false;
85+
if (!TimesEquivalent(expected.Time, actual.Time)) return false;
86+
if (!DurationsEquivalent(expected, actual)) return false;
87+
if (expected.Cell != actual.Cell || expected.Width != actual.Width) return false;
88+
if (expected.EndCell != actual.EndCell || expected.EndWidth != actual.EndWidth) return false;
89+
if (expected.Height != actual.Height || expected.EndHeight != actual.EndHeight) return false;
90+
if (expected.CrushInterval != actual.CrushInterval) return false;
91+
if (!TagsEquivalent(expected, actual)) return false;
92+
if (expected.TargetNote != actual.TargetNote) return false;
93+
return true;
9894
}
9995

96+
/// <summary>规则 (a):time 相差 ≤ 1/768 视为相等。</summary>
97+
private static bool TimesEquivalent(Rational a, Rational b) => (a - b).Abs() <= Tol768;
98+
10099
/// <summary>
101-
/// 将 C2S 网格上的音符投影为「经 UGC 生成器写出再解析」后等价的分数(UGC 小节 tick = <paramref name="ugcTicksPerBeat"/> × 4)
100+
/// 规则 (b):|Δduration| ≤ 1/768,或(|Δduration| ≤ 1/384 且 |ΔendTime| ≤ 1/768)时视为 duration 语义相等
102101
/// </summary>
103-
private static ChuNote C2sNoteScaledToUgcTicks(ChuNote n, int ugcTicksPerBeat, int c2sResolution)
102+
private static bool DurationsEquivalent(ChuNote e, ChuNote a)
104103
{
105-
var tpmUgc = ugcTicksPerBeat * 4;
106-
var (m, oC) = Utils.BarAndTick(n.Time, c2sResolution);
107-
var oU = (int)Math.Round((double)oC * tpmUgc / c2sResolution);
108-
var time = m + new Rational(oU, tpmUgc);
109-
var dur = new Rational(Utils.Tick(n.Duration, tpmUgc), tpmUgc);
110-
return CloneChuNoteWithTiming(n, time, dur);
104+
var dd = (e.Duration - a.Duration).Abs().CanonicalForm;
105+
return dd <= Tol768 || (dd <= Tol384 && (e.EndTime - a.EndTime).Abs() <= Tol768);
111106
}
112107

113-
private static ChuNote CloneChuNoteWithTiming(ChuNote n, Rational time, Rational duration) => new()
114-
{
115-
Type = n.Type,
116-
Time = time,
117-
Cell = n.Cell,
118-
Width = n.Width,
119-
Duration = duration,
120-
EndCell = n.EndCell,
121-
EndWidth = n.EndWidth,
122-
Previous = n.Previous,
123-
Tag = n.Tag,
124-
Height = n.Height,
125-
EndHeight = n.EndHeight,
126-
CrushInterval = n.CrushInterval,
127-
};
128-
129-
/// <summary>
130-
/// 比较 UGC 与 C2S 的音符 IR:因 tick 网格不同,在各自「经对方格式写回再解析」的量化意义下比较快照。
131-
/// </summary>
132-
private static void AssertUgcNotesEquivalentToReparsedC2s(ChuChart ugc, ChuChart c2s, bool isUgcReference)
108+
/// <summary>规则 (c)(d):广义 Air 的 DEF/空串;FLK 的 A/L。</summary>
109+
private static bool TagsEquivalent(ChuNote e, ChuNote a)
133110
{
134-
if (isUgcReference)
111+
if (e.Tag == a.Tag) return true;
112+
if (ChuUtils.IsGeneralizedAir(e))
135113
{
136-
var ugcSnaps = ugc.Notes.Where(n=>n.Type != "CLICK")
137-
.OrderBy(n=>n.Time).ThenBy(n=>n.Cell).ThenBy(n=>n.Width).ThenBy(n=>n.Duration).ThenBy(n=>n.Type)
138-
.Select(n => SnapshotNote(UgcNoteScaledToC2sTicks(n, 480, 384)))
139-
.ToArray();
140-
var c2sSnaps = c2s.Notes
141-
.OrderBy(n=>n.Time).ThenBy(n=>n.Cell).ThenBy(n=>n.Width).ThenBy(n=>n.Duration).ThenBy(n=>n.Type)
142-
.Select(SnapshotNote)
143-
.ToArray();
144-
Assert.Equal(ugcSnaps, c2sSnaps);
114+
if ((e.Tag == "DEF" && a.Tag == "") || (e.Tag == "" && a.Tag == "DEF"))
115+
return true;
145116
}
146-
else
117+
if (e.Type == "FLK")
147118
{
148-
var ugcSnaps = ugc.Notes.Where(n=>n.Type != "CLICK")
149-
.OrderBy(n=>n.Time).ThenBy(n=>n.Cell).ThenBy(n=>n.Width).ThenBy(n=>n.Duration).ThenBy(n=>n.Type).ThenBy(n=>n.TargetNote)
150-
.Select(SnapshotNote)
151-
.ToArray();
152-
var c2sSnaps = c2s.Notes
153-
.OrderBy(n=>n.Time).ThenBy(n=>n.Cell).ThenBy(n=>n.Width).ThenBy(n=>n.Duration).ThenBy(n=>n.Type).ThenBy(n=>n.TargetNote)
154-
.Select(n => SnapshotNote(C2sNoteScaledToUgcTicks(n, 480, 384)))
155-
.ToArray();
156-
Assert.Equal(c2sSnaps, ugcSnaps);
119+
if ((e.Tag == "A" && a.Tag == "L") || (e.Tag == "L" && a.Tag == "A"))
120+
return true;
157121
}
122+
return false;
158123
}
124+
125+
private static string FormatNote(ChuNote n) =>
126+
$"{n.Type} t={n.Time} start=({n.Cell},{n.Width}) dur={n.Duration} end=({n.EndCell},{n.EndWidth}) " +
127+
$"tag={n.Tag} tgt={n.TargetNote} h=({n.Height},{n.EndHeight}) crush={n.CrushInterval}";
159128

160129
[Theory]
161130
[MemberData(nameof(CustomUgcChartPaths))]
@@ -171,7 +140,7 @@ public void UgcToC2sViaGenerator(string ugcPath)
171140
// 再把转出来的c2s,parse回去,比较是否和一开始的ugc等价(注意不是文本 round-trip,而是 IR 等价,允许字段重排但不允许信息丢失)
172141
var (c2sChart, _) = new C2sParser().Parse(c2sText);
173142
Assert.NotEmpty(c2sChart.Notes);
174-
AssertUgcNotesEquivalentToReparsedC2s(ugc, c2sChart, true);
143+
AssertNotesEqual(ugc.Notes.Where(n => n.Type != "CLICK").ToList(), c2sChart.Notes);
175144
}
176145

177146
[Theory]
@@ -188,6 +157,6 @@ public void C2sToUgcViaGenerator(string c2sPath)
188157
// 再把转出来的ugc,parse回去,比较是否和一开始的c2s等价
189158
var (ugcReparsed, _) = new UgcParser().Parse(ugcText);
190159
Assert.NotEmpty(ugcReparsed.Notes);
191-
AssertUgcNotesEquivalentToReparsedC2s(ugcReparsed, c2s, false);
160+
AssertNotesEqual(c2s.Notes, ugcReparsed.Notes.Where(n => n.Type != "CLICK").ToList());
192161
}
193162
}

tests/mai/Simai转MA2测试.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,19 +74,24 @@ private static void AssertTextEqual(string expected, string actual)
7474
var exp = i < expectedLines.Length ? expectedLines[i] : "<EOF>";
7575
var act = i < actualLines.Length ? actualLines[i] : "<EOF>";
7676
var result = CompareLine(exp, act);
77-
if (!result)
77+
if (!result && i < actualLines.Length)
7878
{
7979
// 尝试同一时刻的其他行有无相同的,如果有,交换之
8080
var j = i + 1;
81-
while (j < expectedLines.Length && IsSameTime(expectedLines[i], actualLines[j]))
81+
while (j < expectedLines.Length)
8282
{
8383
if (CompareLine(expectedLines[j], act))
84-
{
84+
{ // 匹配成功。交换之
8585
(expectedLines[j], expectedLines[i]) = (expectedLines[i], expectedLines[j]);
8686
result = true;
8787
break;
8888
}
89-
j++;
89+
else if (IsSameTime(expectedLines[j], act))
90+
{ // 虽然暂时匹配失败,但是游标j还在,act同一时刻的窗口范围内。则应该允许继续比较。
91+
j++;
92+
continue;
93+
}
94+
else break; // 否则(匹配失败且j已经离开了同时刻的滑动窗口、说明未来也不再具有匹配上的可能性了),则中止匹配
9095
}
9196
}
9297

utils/Utils.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,11 @@ public static Rational Sum(this IEnumerable<Rational> source)
177177
return source.Aggregate(Rational.Zero, (acc, r) => acc + r);
178178
}
179179

180+
public static Rational Abs(this Rational r)
181+
{
182+
return r * r.Sign;
183+
}
184+
180185
internal static Dictionary<K, V> RemoveRange<K, V>(this Dictionary<K, V> dict, IEnumerable<K> keys) where K : notnull
181186
{
182187
foreach (var key in keys) dict.Remove(key);

0 commit comments

Comments
 (0)