1- using System . Reflection ;
21using MuConvert . chu ;
32using MuConvert . utils ;
43using Rationals ;
@@ -7,6 +6,9 @@ namespace MuConvert.Tests.chu;
76
87public 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}
0 commit comments