From 3bfb1880180d1330d9b3c48ff235437a49b6ef3a Mon Sep 17 00:00:00 2001 From: Starrah Date: Fri, 9 Oct 2026 08:30:21 +0800 Subject: [PATCH 1/4] =?UTF-8?q?test(refactor):=20=E5=B0=86AssertMa2NotesEq?= =?UTF-8?q?ual=E5=92=8CAssertSimaiNotesEqual=EF=BC=8C=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E5=88=B0TestUtils=E4=B8=AD=20=E7=A7=BB=E9=99=A4=E4=B9=8B?= =?UTF-8?q?=E5=89=8D=E5=88=86=E7=AB=8B=E5=9C=A8=E5=90=84=E5=A4=84=E7=9A=84?= =?UTF-8?q?=E5=86=97=E4=BD=99=E5=AE=9E=E7=8E=B0=EF=BC=8C=E4=BE=BF=E4=BA=8E?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E7=AE=A1=E7=90=86=E3=80=81=E7=BB=B4=E6=8A=A4?= =?UTF-8?q?=E5=92=8C=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mai/MA2_103\346\265\213\350\257\225.cs" | 67 +--- ...0\275\254Simai\346\265\213\350\257\225.cs" | 281 +------------- ...07\346\256\265\346\265\213\350\257\225.cs" | 65 ---- ...350\275\254MA2\346\265\213\350\257\225.cs" | 70 +--- tests/mai/TestUtils.cs | 366 +++++++++++++++++- 5 files changed, 368 insertions(+), 481 deletions(-) diff --git "a/tests/mai/MA2_103\346\265\213\350\257\225.cs" "b/tests/mai/MA2_103\346\265\213\350\257\225.cs" index 53f68ca..bd0de8a 100644 --- "a/tests/mai/MA2_103\346\265\213\350\257\225.cs" +++ "b/tests/mai/MA2_103\346\265\213\350\257\225.cs" @@ -47,71 +47,6 @@ public void Simai转MA2_103(TestInput input) var (ma2, genAlerts) = new MA2_103Generator(isUtage: false).Generate(chart); Assert.DoesNotContain(genAlerts, a => a.Level >= Alert.LEVEL.Error); - ma2 = KeepNotesOnly(ma2); - expectedMa2 = KeepNotesOnly(expectedMa2); - AssertMa2NotesEqual(expectedMa2, ma2, input.ToString()); - } - - private static (int TimeTick, int Len, string Extra) GetSlideTime(string slide) - { - var values = slide.Split('\t'); - return (int.Parse(values[1]) * 384 + int.Parse(values[2]), - int.Parse(values[5]), - string.Join("\t", values[0], values[3], values[4], values[6])); - } - - private static bool CompareLine(string exp, string act) - { - var result = string.Equals(exp, act, StringComparison.Ordinal); - if (!result && exp.Length >= 5 && act.Length >= 5 && exp[..5] == act[..5] && SlideTypeTool.IsSlide(exp[2..5])) - { - var (expTime, expLen, expExtra) = GetSlideTime(exp); - var (actTime, actLen, actExtra) = GetSlideTime(act); - if (expExtra != actExtra) return result; - if (exp[..2] == "CN") - { - if (expTime + expLen == actTime + actLen || Math.Abs(expLen - actLen) <= 1) result = true; - } - else - { - if (expTime == actTime && Math.Abs(expLen - actLen) <= 1) result = true; - } - } - - return result; - } - - private static void AssertMa2NotesEqual(string expected, string actual, string context) - { - var expectedLines = expected.Split('\n'); - var actualLines = actual.Split('\n'); - var max = Math.Max(expectedLines.Length, actualLines.Length); - - for (var i = 0; i < max; i++) - { - var exp = i < expectedLines.Length ? expectedLines[i] : ""; - var act = i < actualLines.Length ? actualLines[i] : ""; - var result = CompareLine(exp, act); - if (!result) - { - for (var j = 1; j < Math.Min(expectedLines.Length, i + 5); j++) - { - if (CompareLine(expectedLines[j], act)) - { - (expectedLines[j], expectedLines[i]) = (expectedLines[i], expectedLines[j]); - result = true; - break; - } - } - } - - if (!result) - { - Assert.Fail( - $"{context}: first difference at line {i + 1}:{Environment.NewLine}" + - $"EXPECTED: {exp}{Environment.NewLine}" + - $"ACTUAL : {act}"); - } - } + AssertMa2NotesEqual(KeepNotesOnly(expectedMa2), KeepNotesOnly(ma2), input.ToString()); } } diff --git "a/tests/mai/MA2\350\275\254Simai\346\265\213\350\257\225.cs" "b/tests/mai/MA2\350\275\254Simai\346\265\213\350\257\225.cs" index 7a0c818..2998492 100644 --- "a/tests/mai/MA2\350\275\254Simai\346\265\213\350\257\225.cs" +++ "b/tests/mai/MA2\350\275\254Simai\346\265\213\350\257\225.cs" @@ -1,8 +1,5 @@ -using System.Globalization; using System.Text; -using System.Text.RegularExpressions; using MuConvert.mai; -using Rationals; using Xunit.Abstractions; namespace MuConvert.Tests.mai; @@ -35,286 +32,10 @@ private void TestChart(TestInput c) _output.WriteLine(string.Join('\n', alerts2)); Assert.Equal(TestUtils.TryParseMa2ClkDef(ma2Text), chart.ClockCount * 96); - var expectedTimeline = SimaiCommaTimeline.Flatten(inote); - var actualTimeline = SimaiCommaTimeline.Flatten(simai); - SimaiCommaTimeline.AssertTimelineEqual(expectedTimeline, actualTimeline, chart, _output); + TestUtils.AssertSimaiNotesEqual(inote, simai, chart, _output); // 转出来的simai,重新parse一次、确保没有任何错误 var (_, alertsReparsed) = new SimaiParser(strictLevel: SimaiParser.StrictLevelEnum.Strict).Parse(simai); Assert.Empty(alertsReparsed); } } - -/// -/// 按 Simai 文法 chart: (notations ',')* 将谱面切成顶层逗号分段,并在每个分段上复现与 -/// 一致的 now / step 推进规则, -/// 得到 (时刻, 原文) 序列;不构造 Note,不把片段再交给 SimaiParser。 -/// -internal static partial class SimaiCommaTimeline -{ - /// 与谱面语义相关的条目:BPM 标记、音符/休止以外的 met 变更等只影响 step,不单独出条。 - public readonly record struct Entry(Rational Time, string Text); - - public static List Flatten(string simai) - { - var parts = simai.Split(',').Select(x => x.Trim()).ToList(); - if (parts.Last() == "E") parts.RemoveAt(parts.Count - 1); - var now = new Rational(0); - var step = new Rational(1, 4); - var currentBpm = 60m; - decimal? absStepSeconds = null; - var list = new List(); - - foreach (var part in parts) - { - if (part.Length > 0) - { - ParseNotationsSegment(part.AsSpan(), now, ref currentBpm, ref step, ref absStepSeconds, list); - } - now = (now + step).CanonicalForm; - } - - return list; - } - - public static void AssertTimelineEqual( - IReadOnlyList expected, - IReadOnlyList actual, - MaiChart chart, - ITestOutputHelper? output = null) - { - static IEnumerable Canon(IReadOnlyList e) => - e.Select(x => new Entry(x.Time, NormalizeForCompare(x.Text))) - .OrderBy(p => p.Time) - .ThenBy(p => p.Text, StringComparer.Ordinal); - - expected = Canon(expected).ToList(); - actual = Canon(actual).ToList(); - Assert.Equal(expected.Count, actual.Count); - - for (var i = 0; i < expected.Count; i++) - { - try - { - Assert.Equal(expected[i].Time, actual[i].Time); - AssertNoteEqual(expected[i].Text, actual[i].Text, i, actual[i].Time, chart); - } - catch (Xunit.Sdk.XunitException) - { - output?.WriteLine(FormatNeighborhood(expected, actual, i).TrimEnd()); - throw; - } - } - } - - [GeneratedRegex(@"\[(?:([\d\.]+)##)?(?:(\d+):(\d+)|#?([\d\.]+))\]")] - private static partial Regex DurationStrRegex(); - - private static bool Near(double a, double b) => Math.Abs(a - b) < 1e-3; - - private static void AssertNoteEqual(string expected, string actual, int noteIdx, Rational time, MaiChart chart) - { - var expArr = RearrangeNote(expected).Split('/', '`', '*'); - var actArr = RearrangeNote(actual).Split('/', '`', '*'); - var max = Math.Max(expArr.Length, actArr.Length); - - for (var i = 0; i < max; i++) - { - var exp = i < expArr.Length ? expArr[i] : ""; - var act = i < actArr.Length ? actArr[i] : ""; - var result = exp == act; - - if (!result && exp.StartsWith("C1")) - { // groundtruth里有一部分是写成了C1,此时不要报错,应该给予兼容。 - exp = exp.Replace("C1", "C"); - result = exp == act; - } - - if (!result) result = CompareDurationStr(exp, act, time, chart); - - if (!result) Assert.Fail( - $"First difference at Notation {noteIdx + 1} (time {time}):{Environment.NewLine}" + - $"EXPECTED: {expected}{Environment.NewLine}" + - $"ACTUAL : {actual}" - ); - } - } - - private static bool CompareDurationStr(string exp, string act, Rational time, MaiChart chart) - { - bool result = false; - // 尝试是否是只有时间不匹配,如果是的话,允许一定的阈值 - var expTime = DurationStrRegex().Match(exp); - var actTime = DurationStrRegex().Match(act); - if (!expTime.Success || !actTime.Success) return result; - var expRemain = exp[..expTime.Index] + exp[(expTime.Index + expTime.Length)..]; - var actRemain = act[..expTime.Index] + act[(actTime.Index + actTime.Length)..]; - if (actRemain != expRemain) return result; // 如果除了时间以外还有其他不一样的,那么直接返回false - - // 对act产生的时间标记,做规范性检查。对齐到标准中的每一条 - if (actRemain.Contains('h')) - { // Hold / TouchHold - Assert.False(actTime.Groups[1].Success, $"Hold/TouchHold不应该有等待时间!{act}"); - if (actTime.Groups[4].Success) - { // 绝对时长的情况 - Assert.True(act[actTime.Groups[4].Index - 1] == '#', $"Hold/TouchHold格式不正确,绝对时长的前面必须带一个井号!{act}"); - } - } - else - { - if (actTime.Groups[4].Success) - { // 绝对时长的情况,前面必须是'bpm#'或'等待时间##'。我们不考虑前面一种情况,则应该断言一定是第二种情况出现了 - Assert.True(actTime.Groups[1].Success && actTime.Groups[1].Index + actTime.Groups[1].Length == actTime.Groups[4].Index, $"星星持续时长使用了非标准语法!{act}"); - } - } - - var bpm = chart.BpmList.Find(time).Bpm; - if (expTime.Groups[2].Success && actTime.Groups[4].Success) - { // exp中是分数时间、act中是小数时间的情况 - // 小数时间化为分数时间,看看是否对的上 - var numer = decimal.Parse(actTime.Groups[4].Value) / (240 / bpm) * int.Parse(expTime.Groups[2].Value); - if (Math.Round(numer) == int.Parse(expTime.Groups[3].Value)) result = true; // 如果对的上,则不判定为比较失败 - } - else if (actTime.Groups[2].Success && expTime.Groups[4].Success) - { // exp中是小数时间、act中是分数时间的情况 - // 分数时间化为小数时间,看是否对的上(差距<1ms) - var sec = new Rational(int.Parse(actTime.Groups[3].Value), int.Parse(actTime.Groups[2].Value)) * (240 / (Rational)bpm); - if (Near((double)sec, double.Parse(expTime.Groups[4].Value))) result = true; // 如果对的上,则不判定为比较失败 - } - else if (actTime.Groups[4].Success && expTime.Groups[4].Success) - { // exp中是小数时间、act中是小数时间的情况 - var expSec = double.Parse(expTime.Groups[4].Value); - var actSec = double.Parse(actTime.Groups[4].Value); - if (Near(expSec, actSec)) result = true; // 如果对的上,则不判定为比较失败 - } - - // 比较等待时间是否相等(没显式写出的就是1拍) - var expWait = expTime.Groups[1].Success ? double.Parse(expTime.Groups[1].Value) : 60 / (double)bpm; - var actWait = actTime.Groups[1].Success ? double.Parse(actTime.Groups[1].Value) : 60 / (double)bpm; - if (!Near(expWait, actWait)) result = false; // 如果等待时间对不上,则仍判定为比较失败 - return result; - } - - private static string RearrangeNote(string s) - { - return string.Join('`', s.Split('`').Select(x => - { - var s = x.Split('/'); - s.Sort(); - return string.Join('/', s); - })); - } - - private static string FormatNeighborhood(IReadOnlyList a, IReadOnlyList b, int i) - { - var sb = new StringBuilder(); - sb.AppendLine("--- context (expected) ---"); - for (var j = Math.Max(0, i - 3); j < Math.Min(a.Count, i + 5); j++) - sb.AppendLine($" [{j}] {a[j].Time} | {a[j].Text}"); - sb.AppendLine("--- context (actual) ---"); - for (var j = Math.Max(0, i - 3); j < Math.Min(b.Count, i + 5); j++) - sb.AppendLine($" [{j}] {b[j].Time} | {b[j].Text}"); - return sb.ToString(); - } - - /// - /// 去掉空白、统一 b/x/f 连续修饰符的字典序,便于与「别的转谱器」对照。 - /// - internal static string NormalizeForCompare(string s) - { - s = s.Trim().Replace("\r", "").Replace("\n", ""); - s = Regex.Replace(s, @"\s+", ""); - s = Regex.Replace(s, @"(\[[\d\.#:]+\])b", m => "b" + m.Groups[1].Value); // 其实严格根据文档,对星星"1-4[8:3]b"是正确的,而对hold"4hb[8:3]"才是正确的。我们的SimaiGenerator是严格按标准输出的,但出于比较的简单考虑,还是全部统一到"4hb[8:3]"这种情况下,处理起来简单一点。 - s = Regex.Replace(s, "[bxfh]{2,}", m => new string(m.Value.OrderBy(c => c).ToArray())); - return s; - } - - private static void ParseNotationsSegment( - ReadOnlySpan span, - Rational now, - ref decimal currentBpm, - ref Rational step, - ref decimal? absStepSeconds, - List list) - { - var i = 0; - while (i < span.Length) - { - while (i < span.Length && char.IsWhiteSpace(span[i])) - i++; - if (i >= span.Length) - break; - - if (span[i] == '(') - { - var close = span[i..].IndexOf(')'); - if (close < 0) - throw new InvalidOperationException("Unclosed '(' in simai segment: " + span.ToString()); - close += i; - var inner = span[(i + 1)..close]; - if (!decimal.TryParse(inner.Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out var bpm)) - throw new InvalidOperationException("Bad BPM: " + inner.ToString()); - list.Add(new Entry(now, $"({inner})")); - currentBpm = bpm; - if (absStepSeconds is { } abs) - step = (Rational)abs / (240 / (Rational)currentBpm); - i = close + 1; - continue; - } - - if (span[i] == '{') - { - var close = FindClosingBrace(span, i); - var inner = span[(i + 1)..close]; - i = close + 1; - if (inner.Length > 0 && inner[0] == '#') - { - var num = inner[1..].Trim(); - if (!decimal.TryParse(num, NumberStyles.Number, CultureInfo.InvariantCulture, out var sec)) - throw new InvalidOperationException("Bad absolute step: " + inner.ToString()); - absStepSeconds = sec; - step = (Rational)absStepSeconds.Value / (240 / (Rational)currentBpm); - } - else - { - absStepSeconds = null; - if (!int.TryParse(inner.Trim(), out var quaver) || quaver <= 0) - throw new InvalidOperationException("Bad met: {" + inner.ToString() + "}"); - step = new Rational(1, quaver); - } - continue; - } - - var bracket = 0; - var start = i; - while (i < span.Length) - { - var c = span[i]; - if (c == '[') bracket++; - else if (c == ']' && bracket > 0) bracket--; - if (bracket == 0 && (c == '(' || c == '{')) - break; - i++; - } - - var noteSpan = span[start..i].Trim(); - if (noteSpan.Length > 0) - list.Add(new Entry(now, noteSpan.ToString())); - } - } - - private static int FindClosingBrace(ReadOnlySpan span, int openIdx) - { - var d = 0; - for (var j = openIdx; j < span.Length; j++) - { - if (span[j] == '{') d++; - else if (span[j] == '}') - { - d--; - if (d == 0) return j; - } - } - throw new InvalidOperationException("Unclosed '{' in simai segment."); - } -} diff --git "a/tests/mai/Simai\347\211\207\346\256\265\346\265\213\350\257\225.cs" "b/tests/mai/Simai\347\211\207\346\256\265\346\265\213\350\257\225.cs" index 3ecd3d5..4cf627d 100644 --- "a/tests/mai/Simai\347\211\207\346\256\265\346\265\213\350\257\225.cs" +++ "b/tests/mai/Simai\347\211\207\346\256\265\346\265\213\350\257\225.cs" @@ -1,7 +1,5 @@ -using System.Globalization; using System.Text; using MuConvert.mai; -using MuConvert.utils; using Xunit.Abstractions; using static MuConvert.Tests.mai.TestUtils; @@ -52,67 +50,4 @@ private static string NormalizeMa2Block(string text) sb.Length--; return sb.ToString().TrimEnd(); } - - private static (int TimeTick, int Len, string Extra) GetSlideTime(string slide) - { - var values = slide.Split('\t'); - return (int.Parse(values[1], CultureInfo.InvariantCulture) * 384 + int.Parse(values[2], CultureInfo.InvariantCulture), - int.Parse(values[5], CultureInfo.InvariantCulture), - string.Join("\t", values[0], values[3], values[4], values[6])); - } - - private static bool CompareLine(string exp, string act) - { - var result = string.Equals(exp, act, StringComparison.Ordinal); - if (!result && exp.Length >= 5 && act.Length >= 5 && exp[..5] == act[..5] && SlideTypeTool.IsSlide(exp[2..5])) - { - var (expTime, expLen, expExtra) = GetSlideTime(exp); - var (actTime, actLen, actExtra) = GetSlideTime(act); - if (expExtra != actExtra) return result; - if (exp[..2] == "CN") - { - if (expTime + expLen == actTime + actLen || Math.Abs(expLen - actLen) <= 1) result = true; - } - else - { - if (expTime == actTime && Math.Abs(expLen - actLen) <= 1) result = true; - } - } - - return result; - } - - private static void AssertMa2NotesEqual(string expected, string actual, string context) - { - var expectedLines = expected.Split('\n'); - var actualLines = actual.Split('\n'); - var max = Math.Max(expectedLines.Length, actualLines.Length); - - for (var i = 0; i < max; i++) - { - var exp = i < expectedLines.Length ? expectedLines[i] : ""; - var act = i < actualLines.Length ? actualLines[i] : ""; - var result = CompareLine(exp, act); - if (!result) - { - for (var j = 1; j < Math.Min(expectedLines.Length, i + 5); j++) - { - if (CompareLine(expectedLines[j], act)) - { - (expectedLines[j], expectedLines[i]) = (expectedLines[i], expectedLines[j]); - result = true; - break; - } - } - } - - if (!result) - { - Assert.Fail( - $"{context}: first difference at line {i + 1}:{Environment.NewLine}" + - $"EXPECTED: {exp}{Environment.NewLine}" + - $"ACTUAL : {act}"); - } - } - } } diff --git "a/tests/mai/Simai\350\275\254MA2\346\265\213\350\257\225.cs" "b/tests/mai/Simai\350\275\254MA2\346\265\213\350\257\225.cs" index 285ffc2..b953a83 100644 --- "a/tests/mai/Simai\350\275\254MA2\346\265\213\350\257\225.cs" +++ "b/tests/mai/Simai\350\275\254MA2\346\265\213\350\257\225.cs" @@ -1,6 +1,5 @@ using System.Text; using MuConvert.mai; -using MuConvert.utils; using Xunit.Abstractions; using static MuConvert.Tests.mai.TestUtils; @@ -31,77 +30,10 @@ private void TestChart(TestInput input) _output.WriteLine(string.Join('\n', alerts2)); Assert.Equal(maidata.ClockCount * 96, TryParseMa2ClkDef(ma2)); - AssertTextEqual(KeepNotesOnly(expectedMa2), KeepNotesOnly(ma2)); + AssertMa2NotesEqual(KeepNotesOnly(expectedMa2), KeepNotesOnly(ma2), input.ToString()); // 转出来的MA2,重新parse一次、确保没有任何错误 var (_, alertsReparsed) = new MA2Parser().Parse(ma2); Assert.Empty(alertsReparsed); } - - private static (int, int, string) GetSlideTime(string slide) - { - var values = slide.Split("\t"); - return (int.Parse(values[1]) * 384 + int.Parse(values[2]), int.Parse(values[5]), - string.Join("\t", values[0], values[3], values[4], values[6])); - } - - private static bool CompareLine(string exp, string act) - { - var result = string.Equals(exp, act, StringComparison.Ordinal); - if (!result && exp[..5] == act[..5] && SlideTypeTool.IsSlide(exp[2..5])) - { // 如果是星星,则允许一定范围的误差。具体而言: - var (expTime, expLen, expExtra) = GetSlideTime(exp); - var (actTime, actLen, actExtra) = GetSlideTime(act); - if (expExtra != actExtra) return result; // 首先任何情况下,waitTime和按键等信息必须相等 - if (exp[..2] == "CN") - { // CN星星则要么尾时刻完全对,要么长度至多差1。 - if (expTime + expLen == actTime + actLen || Math.Abs(expLen - actLen) <= 1) result = true; - } - else - { // 第一段星星则开始时刻必须对且长度至多差1 - if (expTime == actTime && Math.Abs(expLen - actLen) <= 1) result = true; - } - } - return result; - } - - private static void AssertTextEqual(string expected, string actual) - { - var expectedLines = expected.Split('\n'); - var actualLines = actual.Split('\n'); - var max = Math.Max(expectedLines.Length, actualLines.Length); - - for (var i = 0; i < max; i++) - { - var exp = i < expectedLines.Length ? expectedLines[i] : ""; - var act = i < actualLines.Length ? actualLines[i] : ""; - var result = CompareLine(exp, act); - if (!result && i < actualLines.Length) - { - // 尝试同一时刻的其他行有无相同的,如果有,交换之 - var j = i + 1; - while (j < expectedLines.Length) - { - if (CompareLine(expectedLines[j], act)) - { // 匹配成功。交换之 - (expectedLines[j], expectedLines[i]) = (expectedLines[i], expectedLines[j]); - result = true; - break; - } - else if (IsSameTime(expectedLines[j], act)) - { // 虽然暂时匹配失败,但是游标j还在,act同一时刻的窗口范围内。则应该允许继续比较。 - j++; - continue; - } - else break; // 否则(匹配失败且j已经离开了同时刻的滑动窗口、说明未来也不再具有匹配上的可能性了),则中止匹配 - } - } - - if (!result) Assert.Fail( - $"First difference at line {i + 1}:{Environment.NewLine}" + - $"EXPECTED: {exp}{Environment.NewLine}" + - $"ACTUAL : {act}" - ); - } - } } diff --git a/tests/mai/TestUtils.cs b/tests/mai/TestUtils.cs index 7b3f706..66dd368 100644 --- a/tests/mai/TestUtils.cs +++ b/tests/mai/TestUtils.cs @@ -1,7 +1,10 @@ +using System.Globalization; using System.Text; using System.Text.RegularExpressions; using MuConvert.mai; using MuConvert.utils; +using Rationals; +using Xunit.Abstractions; using YamlDotNet.Serialization; namespace MuConvert.Tests.mai; @@ -66,7 +69,7 @@ public static (int, int) ExtractMa2Time(string ma2Line) /// /// 提取 MA2 音符段至 T_REC 之前:跳过头部与 BPM 行;若存在 MET\t 小节行则跳过该行; - /// 部分旧官谱 golden 无 MET,则在 BPM 块后的首条非头行开始收集。与 / 断言用逻辑一致。 + /// 部分旧官谱 golden 无 MET,则在 BPM 块后的首条非头行开始收集。 /// public static string KeepNotesOnly(string text) { @@ -102,6 +105,85 @@ private static bool IsMa2HeaderOrBpmLine(string line) => line.StartsWith("BPM\t", StringComparison.Ordinal) || line.StartsWith("MET\t", StringComparison.Ordinal) || line.StartsWith("CLK\t", StringComparison.Ordinal); + + /// + /// 比较 MA2 音符行:逐行对齐;同 tick 内允许顺序不同;slide 长度允许 ±1(CN 另允许尾时刻对齐)。 + /// + public static void AssertMa2NotesEqual(string expected, string actual, string? context = null) + { + var expectedLines = expected.Split('\n'); + var actualLines = actual.Split('\n'); + var max = Math.Max(expectedLines.Length, actualLines.Length); + var prefix = string.IsNullOrEmpty(context) ? "" : $"{context}: "; + + for (var i = 0; i < max; i++) + { + var exp = i < expectedLines.Length ? expectedLines[i] : ""; + var act = i < actualLines.Length ? actualLines[i] : ""; + var result = CompareMa2NoteLine(exp, act); + if (!result && i < actualLines.Length) + { + // 尝试同一时刻的其他 expected 行:匹配则交换到当前位置 + var j = i + 1; + while (j < expectedLines.Length) + { + if (CompareMa2NoteLine(expectedLines[j], act)) + { + (expectedLines[j], expectedLines[i]) = (expectedLines[i], expectedLines[j]); + result = true; + break; + } + + if (IsSameTime(expectedLines[j], act)) + { + j++; + continue; + } + + break; + } + } + + if (!result) + { + Assert.Fail( + $"{prefix}first difference at line {i + 1}:{Environment.NewLine}" + + $"EXPECTED: {exp}{Environment.NewLine}" + + $"ACTUAL : {act}"); + } + } + } + + private static (int TimeTick, int Len, string Extra) GetSlideTime(string slide) + { + var values = slide.Split('\t'); + return (int.Parse(values[1], CultureInfo.InvariantCulture) * 384 + int.Parse(values[2], CultureInfo.InvariantCulture), + int.Parse(values[5], CultureInfo.InvariantCulture), + string.Join("\t", values[0], values[3], values[4], values[6])); + } + + private static bool CompareMa2NoteLine(string exp, string act) + { + var result = string.Equals(exp, act, StringComparison.Ordinal); + if (!result && exp.Length >= 5 && act.Length >= 5 && exp[..5] == act[..5] && SlideTypeTool.IsSlide(exp[2..5])) + { + var (expTime, expLen, expExtra) = GetSlideTime(exp); + var (actTime, actLen, actExtra) = GetSlideTime(act); + if (expExtra != actExtra) return result; + if (exp[..2] == "CN") + { + // CN:要么尾时刻完全对,要么长度至多差 1 + if (expTime + expLen == actTime + actLen || Math.Abs(expLen - actLen) <= 1) result = true; + } + else + { + // 首段:开始时刻必须对且长度至多差 1 + if (expTime == actTime && Math.Abs(expLen - actLen) <= 1) result = true; + } + } + + return result; + } public static MaiChart LoadOneChart(out List alerts) { @@ -142,6 +224,14 @@ public static IEnumerable GetTestInputs(string dataDir, int? lv = null } } } + + public static void AssertSimaiNotesEqual(string expected, string actual, MaiChart chart, + ITestOutputHelper? outputHelper = null) + { + var expectedTimeline = SimaiCommaTimeline.Flatten(expected); + var actualTimeline = SimaiCommaTimeline.Flatten(actual); + SimaiCommaTimeline.AssertTimelineEqual(expectedTimeline, actualTimeline, chart, outputHelper); + } } public sealed class TestSegment @@ -199,3 +289,277 @@ public string MA2 public override string ToString() => $"{Path.GetFileName(Dir)}-lv{LevelId}"; } + +/// +/// 按 Simai 文法 chart: (notations ',')* 将谱面切成顶层逗号分段,并在每个分段上复现与 +/// 一致的 now / step 推进规则, +/// 得到 (时刻, 原文) 序列;不构造 Note,不把片段再交给 SimaiParser。 +/// +internal static partial class SimaiCommaTimeline +{ + /// 与谱面语义相关的条目:BPM 标记、音符/休止以外的 met 变更等只影响 step,不单独出条。 + public readonly record struct Entry(Rational Time, string Text); + + public static List Flatten(string simai) + { + var parts = simai.Split(',').Select(x => x.Trim()).ToList(); + if (parts.Last() == "E") parts.RemoveAt(parts.Count - 1); + var now = new Rational(0); + var step = new Rational(1, 4); + var currentBpm = 60m; + decimal? absStepSeconds = null; + var list = new List(); + + foreach (var part in parts) + { + if (part.Length > 0) + { + ParseNotationsSegment(part.AsSpan(), now, ref currentBpm, ref step, ref absStepSeconds, list); + } + now = (now + step).CanonicalForm; + } + + return list; + } + + public static void AssertTimelineEqual( + IReadOnlyList expected, + IReadOnlyList actual, + MaiChart chart, + ITestOutputHelper? output = null) + { + static IEnumerable Canon(IReadOnlyList e) => + e.Select(x => new Entry(x.Time, NormalizeForCompare(x.Text))) + .OrderBy(p => p.Time) + .ThenBy(p => p.Text, StringComparer.Ordinal); + + expected = Canon(expected).ToList(); + actual = Canon(actual).ToList(); + Assert.Equal(expected.Count, actual.Count); + + for (var i = 0; i < expected.Count; i++) + { + try + { + Assert.Equal(expected[i].Time, actual[i].Time); + AssertNoteEqual(expected[i].Text, actual[i].Text, i, actual[i].Time, chart); + } + catch (Xunit.Sdk.XunitException) + { + output?.WriteLine(FormatNeighborhood(expected, actual, i).TrimEnd()); + throw; + } + } + } + + [GeneratedRegex(@"\[(?:([\d\.]+)##)?(?:(\d+):(\d+)|#?([\d\.]+))\]")] + private static partial Regex DurationStrRegex(); + + private static bool Near(double a, double b) => Math.Abs(a - b) < 1e-3; + + private static void AssertNoteEqual(string expected, string actual, int noteIdx, Rational time, MaiChart chart) + { + var expArr = RearrangeNote(expected).Split('/', '`', '*'); + var actArr = RearrangeNote(actual).Split('/', '`', '*'); + var max = Math.Max(expArr.Length, actArr.Length); + + for (var i = 0; i < max; i++) + { + var exp = i < expArr.Length ? expArr[i] : ""; + var act = i < actArr.Length ? actArr[i] : ""; + var result = exp == act; + + if (!result && exp.StartsWith("C1")) + { // groundtruth里有一部分是写成了C1,此时不要报错,应该给予兼容。 + exp = exp.Replace("C1", "C"); + result = exp == act; + } + + if (!result) result = CompareDurationStr(exp, act, time, chart); + + if (!result) Assert.Fail( + $"First difference at Notation {noteIdx + 1} (time {time}):{Environment.NewLine}" + + $"EXPECTED: {expected}{Environment.NewLine}" + + $"ACTUAL : {actual}" + ); + } + } + + private static bool CompareDurationStr(string exp, string act, Rational time, MaiChart chart) + { + bool result = false; + // 尝试是否是只有时间不匹配,如果是的话,允许一定的阈值 + var expTime = DurationStrRegex().Match(exp); + var actTime = DurationStrRegex().Match(act); + if (!expTime.Success || !actTime.Success) return result; + var expRemain = exp[..expTime.Index] + exp[(expTime.Index + expTime.Length)..]; + var actRemain = act[..expTime.Index] + act[(actTime.Index + actTime.Length)..]; + if (actRemain != expRemain) return result; // 如果除了时间以外还有其他不一样的,那么直接返回false + + // 对act产生的时间标记,做规范性检查。对齐到标准中的每一条 + if (actRemain.Contains('h')) + { // Hold / TouchHold + Assert.False(actTime.Groups[1].Success, $"Hold/TouchHold不应该有等待时间!{act}"); + if (actTime.Groups[4].Success) + { // 绝对时长的情况 + Assert.True(act[actTime.Groups[4].Index - 1] == '#', $"Hold/TouchHold格式不正确,绝对时长的前面必须带一个井号!{act}"); + } + } + else + { + if (actTime.Groups[4].Success) + { // 绝对时长的情况,前面必须是'bpm#'或'等待时间##'。我们不考虑前面一种情况,则应该断言一定是第二种情况出现了 + Assert.True(actTime.Groups[1].Success && actTime.Groups[1].Index + actTime.Groups[1].Length == actTime.Groups[4].Index, $"星星持续时长使用了非标准语法!{act}"); + } + } + + var bpm = chart.BpmList.Find(time).Bpm; + if (expTime.Groups[2].Success && actTime.Groups[4].Success) + { // exp中是分数时间、act中是小数时间的情况 + // 小数时间化为分数时间,看看是否对的上 + var numer = decimal.Parse(actTime.Groups[4].Value) / (240 / bpm) * int.Parse(expTime.Groups[2].Value); + if (Math.Round(numer) == int.Parse(expTime.Groups[3].Value)) result = true; // 如果对的上,则不判定为比较失败 + } + else if (actTime.Groups[2].Success && expTime.Groups[4].Success) + { // exp中是小数时间、act中是分数时间的情况 + // 分数时间化为小数时间,看是否对的上(差距<1ms) + var sec = new Rational(int.Parse(actTime.Groups[3].Value), int.Parse(actTime.Groups[2].Value)) * (240 / (Rational)bpm); + if (Near((double)sec, double.Parse(expTime.Groups[4].Value))) result = true; // 如果对的上,则不判定为比较失败 + } + else if (actTime.Groups[4].Success && expTime.Groups[4].Success) + { // exp中是小数时间、act中是小数时间的情况 + var expSec = double.Parse(expTime.Groups[4].Value); + var actSec = double.Parse(actTime.Groups[4].Value); + if (Near(expSec, actSec)) result = true; // 如果对的上,则不判定为比较失败 + } + + // 比较等待时间是否相等(没显式写出的就是1拍) + var expWait = expTime.Groups[1].Success ? double.Parse(expTime.Groups[1].Value) : 60 / (double)bpm; + var actWait = actTime.Groups[1].Success ? double.Parse(actTime.Groups[1].Value) : 60 / (double)bpm; + if (!Near(expWait, actWait)) result = false; // 如果等待时间对不上,则仍判定为比较失败 + return result; + } + + private static string RearrangeNote(string s) + { + return string.Join('`', s.Split('`').Select(x => + { + var t = x.Split('/'); + t.Sort(); + return string.Join('/', t); + })); + } + + private static string FormatNeighborhood(IReadOnlyList a, IReadOnlyList b, int i) + { + var sb = new StringBuilder(); + sb.AppendLine("--- context (expected) ---"); + for (var j = Math.Max(0, i - 3); j < Math.Min(a.Count, i + 5); j++) + sb.AppendLine($" [{j}] {a[j].Time} | {a[j].Text}"); + sb.AppendLine("--- context (actual) ---"); + for (var j = Math.Max(0, i - 3); j < Math.Min(b.Count, i + 5); j++) + sb.AppendLine($" [{j}] {b[j].Time} | {b[j].Text}"); + return sb.ToString(); + } + + /// + /// 去掉空白、统一 b/x/f 连续修饰符的字典序,便于与「别的转谱器」对照。 + /// + internal static string NormalizeForCompare(string s) + { + s = s.Trim().Replace("\r", "").Replace("\n", ""); + s = Regex.Replace(s, @"\s+", ""); + s = Regex.Replace(s, @"(\[[\d\.#:]+\])b", m => "b" + m.Groups[1].Value); // 其实严格根据文档,对星星"1-4[8:3]b"是正确的,而对hold"4hb[8:3]"才是正确的。我们的SimaiGenerator是严格按标准输出的,但出于比较的简单考虑,还是全部统一到"4hb[8:3]"这种情况下,处理起来简单一点。 + s = Regex.Replace(s, "[bxfh]{2,}", m => new string(m.Value.OrderBy(c => c).ToArray())); + return s; + } + + private static void ParseNotationsSegment( + ReadOnlySpan span, + Rational now, + ref decimal currentBpm, + ref Rational step, + ref decimal? absStepSeconds, + List list) + { + var i = 0; + while (i < span.Length) + { + while (i < span.Length && char.IsWhiteSpace(span[i])) + i++; + if (i >= span.Length) + break; + + if (span[i] == '(') + { + var close = span[i..].IndexOf(')'); + if (close < 0) + throw new InvalidOperationException("Unclosed '(' in simai segment: " + span.ToString()); + close += i; + var inner = span[(i + 1)..close]; + if (!decimal.TryParse(inner.Trim(), NumberStyles.Number, CultureInfo.InvariantCulture, out var bpm)) + throw new InvalidOperationException("Bad BPM: " + inner.ToString()); + list.Add(new Entry(now, $"({inner})")); + currentBpm = bpm; + if (absStepSeconds is { } abs) + step = (Rational)abs / (240 / (Rational)currentBpm); + i = close + 1; + continue; + } + + if (span[i] == '{') + { + var close = FindClosingBrace(span, i); + var inner = span[(i + 1)..close]; + i = close + 1; + if (inner.Length > 0 && inner[0] == '#') + { + var num = inner[1..].Trim(); + if (!decimal.TryParse(num, NumberStyles.Number, CultureInfo.InvariantCulture, out var sec)) + throw new InvalidOperationException("Bad absolute step: " + inner.ToString()); + absStepSeconds = sec; + step = (Rational)absStepSeconds.Value / (240 / (Rational)currentBpm); + } + else + { + absStepSeconds = null; + if (!int.TryParse(inner.Trim(), out var quaver) || quaver <= 0) + throw new InvalidOperationException("Bad met: {" + inner.ToString() + "}"); + step = new Rational(1, quaver); + } + continue; + } + + var bracket = 0; + var start = i; + while (i < span.Length) + { + var c = span[i]; + if (c == '[') bracket++; + else if (c == ']' && bracket > 0) bracket--; + if (bracket == 0 && (c == '(' || c == '{')) + break; + i++; + } + + var noteSpan = span[start..i].Trim(); + if (noteSpan.Length > 0) + list.Add(new Entry(now, noteSpan.ToString())); + } + } + + private static int FindClosingBrace(ReadOnlySpan span, int openIdx) + { + var d = 0; + for (var j = openIdx; j < span.Length; j++) + { + if (span[j] == '{') d++; + else if (span[j] == '}') + { + d--; + if (d == 0) return j; + } + } + throw new InvalidOperationException("Unclosed '{' in simai segment."); + } +} From 6175fbaca8691c3566ea0e6a4720ed33495aa4e4 Mon Sep 17 00:00:00 2001 From: Starrah Date: Fri, 9 Oct 2026 08:52:12 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test:=20=E5=A2=9E=E5=8A=A0=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E2=80=9C=E5=A4=9A=E6=AE=B5=E6=98=9F=E6=98=9FCN?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E7=9A=84=E6=AD=A7=E4=B9=89.yaml=E2=80=9D?= =?UTF-8?q?=E6=B5=8B=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...07\346\256\265\346\265\213\350\257\225.cs" | 35 +++++++++++++++++-- tests/mai/TestUtils.cs | 9 ++--- ...\347\232\204\346\255\247\344\271\211.yaml" | 1 + 3 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 "tests/mai/testset/\347\211\207\346\256\265/\345\244\232\346\256\265\346\230\237\346\230\237CN\350\277\236\346\216\245\347\232\204\346\255\247\344\271\211.yaml" diff --git "a/tests/mai/Simai\347\211\207\346\256\265\346\265\213\350\257\225.cs" "b/tests/mai/Simai\347\211\207\346\256\265\346\265\213\350\257\225.cs" index 4cf627d..1b77dae 100644 --- "a/tests/mai/Simai\347\211\207\346\256\265\346\265\213\350\257\225.cs" +++ "b/tests/mai/Simai\347\211\207\346\256\265\346\265\213\350\257\225.cs" @@ -10,7 +10,7 @@ public class Simai片段测试 private readonly ITestOutputHelper _output; public Simai片段测试(ITestOutputHelper output) => _output = output; - public static IEnumerable FragmentYamlFiles() + public static IEnumerable FragmentYamlFiles(string? filter = null) { var root = Path.Combine(FindTestsetRoot().FullName, "片段"); if (!Directory.Exists(root)) @@ -18,11 +18,22 @@ public static IEnumerable FragmentYamlFiles() foreach (var path in Directory.EnumerateFiles(root, "*.yaml", SearchOption.TopDirectoryOnly) .OrderBy(p => p, StringComparer.Ordinal)) - yield return [TestSegment.Load(path)]; + { + var seg = TestSegment.Load(path); + Assert.True(seg.HasSimai || seg.HasMa2); // 至少要有一个,不然是无效测例 + var ok = filter switch + { + "both" => seg.HasSimai && seg.HasMa2, + "simaiOnly" => seg.HasSimai && !seg.HasMa2, + "ma2Only" => !seg.HasSimai && seg.HasMa2, + _ => true + }; + if (ok) yield return [seg]; + } } [Theory] - [MemberData(nameof(FragmentYamlFiles))] + [MemberData(nameof(FragmentYamlFiles), "both")] public void Simai片段转MA2(TestSegment c) { var (chart, parseAlerts) = new SimaiParser().Parse(c.Simai); @@ -34,6 +45,24 @@ public void Simai片段转MA2(TestSegment c) var expected = NormalizeMa2Block(c.Ma2); AssertMa2NotesEqual(expected, actual, c.ToString()); } + + [Theory] + [MemberData(nameof(FragmentYamlFiles), "simaiOnly")] + public void Simai_Roundtrip(TestSegment c) + { + var (chart, parseAlerts) = new SimaiParser().Parse(c.Simai); + var (ma2Full, genAlerts) = new MA2Generator().Generate(chart); + _output.WriteLine(string.Join('\n', parseAlerts)); + _output.WriteLine(string.Join('\n', genAlerts)); + + var (chart2, parseAlerts2) = new MA2Parser().Parse(ma2Full); + var (simaiRegenerated, genAlerts2) = new SimaiGenerator().Generate(chart2); + _output.WriteLine(string.Join('\n', parseAlerts2)); + _output.WriteLine(string.Join('\n', genAlerts2)); + + Assert.Equal(c.Simai, simaiRegenerated.ReplaceLineEndings("")); // 暂时直接做字符串完全匹配,这样暂时是够用的。 + // AssertSimaiNotesEqual(c.Simai, simaiRegenerated, chart2, _output); // 如果之后不够用了。可以优先考虑开启这个 + } private static string NormalizeMa2Block(string text) { diff --git a/tests/mai/TestUtils.cs b/tests/mai/TestUtils.cs index 66dd368..3026c36 100644 --- a/tests/mai/TestUtils.cs +++ b/tests/mai/TestUtils.cs @@ -260,14 +260,11 @@ public static TestSegment Load(string yamlPath) seg.YamlFileName = yamlFileName; seg.Simai = seg.Simai.Trim(); seg.Ma2 = seg.Ma2.Trim(); - - if (string.IsNullOrWhiteSpace(seg.Simai)) - throw new FormatException($"{yamlPath}: 缺少或为空 simai"); - if (string.IsNullOrWhiteSpace(seg.Ma2)) - throw new FormatException($"{yamlPath}: 缺少或为空 ma2"); - return seg; } + + public bool HasSimai => !string.IsNullOrWhiteSpace(Simai); + public bool HasMa2 => !string.IsNullOrWhiteSpace(Ma2); } public record TestInput(string Maidata, int LevelId) diff --git "a/tests/mai/testset/\347\211\207\346\256\265/\345\244\232\346\256\265\346\230\237\346\230\237CN\350\277\236\346\216\245\347\232\204\346\255\247\344\271\211.yaml" "b/tests/mai/testset/\347\211\207\346\256\265/\345\244\232\346\256\265\346\230\237\346\230\237CN\350\277\236\346\216\245\347\232\204\346\255\247\344\271\211.yaml" new file mode 100644 index 0000000..0f8806d --- /dev/null +++ "b/tests/mai/testset/\347\211\207\346\256\265/\345\244\232\346\256\265\346\230\237\346\230\237CN\350\277\236\346\216\245\347\232\204\346\255\247\344\271\211.yaml" @@ -0,0 +1 @@ +simai: "(120){4}4-7[8:1],4x-6p2[1:1],6hx[8:1],6hx[8:1],8pp2pp4[2:1],E" \ No newline at end of file From 14a06e1c88d0af497f23a6dd492172dae8ceeed6 Mon Sep 17 00:00:00 2001 From: Starrah Date: Fri, 9 Oct 2026 13:41:59 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E5=A6=82=E6=9E=9C=E6=9F=90=E4=B8=AA?= =?UTF-8?q?CN=E6=AE=B5=E6=81=B0=E5=A5=BD=E2=80=9C=E5=92=8C=E6=9F=90?= =?UTF-8?q?=E4=B8=AA=E5=9C=A8=E5=85=88=E7=9A=84Slide=E5=B0=BE=E9=83=A8?= =?UTF-8?q?=E5=AE=8C=E5=85=A8=E9=87=8D=E5=90=88=E2=80=9D=EF=BC=8C=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E9=80=BB=E8=BE=91=E4=BC=9A=E6=8A=8A=E8=BF=99=E4=B8=AA?= =?UTF-8?q?CN=E6=AE=B5=E6=8E=A5=E5=9C=A8=E8=BF=99=E4=B8=AA=E2=80=9C?= =?UTF-8?q?=E5=9C=A8=E5=85=88Slide=E2=80=9D=E7=9A=84=E5=90=8E=E9=9D=A2?= =?UTF-8?q?=EF=BC=8C=E9=80=A0=E6=88=90=E6=98=9F=E6=98=9F=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E3=80=82=20=E5=AE=9E=E7=8E=B0=E4=BA=86?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=E9=80=BB=E8=BE=91=EF=BC=8C=E5=AF=B9=E6=89=80?= =?UTF-8?q?=E6=9C=89CN=E6=AE=B5=EF=BC=8C=E4=B8=BB=E5=8A=A8=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E5=B9=B6=E9=80=9A=E8=BF=87=E5=BE=AE=E8=B0=83start/len?= =?UTF-8?q?=EF=BC=8C=E6=9D=A5=E8=A7=84=E9=81=BF=E4=B8=8A=E9=9D=A2=E7=9A=84?= =?UTF-8?q?=E6=83=85=E5=86=B5=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- generator/mai/MA2Generator.cs | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/generator/mai/MA2Generator.cs b/generator/mai/MA2Generator.cs index 7e7d488..004d55b 100644 --- a/generator/mai/MA2Generator.cs +++ b/generator/mai/MA2Generator.cs @@ -33,6 +33,7 @@ public MA2Generator(bool isUtage = false) protected MaiChart chart; protected List lines = []; protected readonly List alerts = []; + private HashSet<(int, int)> _slideEnds = []; // 登记一下每个Slide的最终结束时间。用于AddSlide函数中“防止多段星星的CN连接的歧义”的逻辑。 private string headTemplate = @"VERSION 0.00.00 {0} FES_MODE {1} @@ -51,6 +52,7 @@ GENERATED_BY MuConvert v{8} * 把Rational的时间近似到RESOLUTION允许的最接近tick上 */ private (int, int) BT(Rational r, int offset = 0) => Utils.BarAndTick(r, RSL, offset); + private (int, int) BT(int totalTick) => (totalTick / RSL, totalTick % RSL); // 持续时间/等待时间,使用"总tick数"(可超过1小节),不是小节内tick protected int T(Rational r, int offset = 0) => Utils.Tick(r, RSL, offset, r > 0 ? 1 : 0); @@ -96,6 +98,7 @@ protected bool hasSameTimeTap(MA2Line ma2Line) protected virtual List AddSlide(Slide slide, int bar, int tick) { List result = []; + List<(string name, int totalTick, SlideSegment seg, int waitTime, int len)> resSegs = []; if (slide.OwnHead != null) { var headTap = AddTap(slide.OwnHead, bar, tick); @@ -134,6 +137,7 @@ protected virtual List AddSlide(Slide slide, int bar, int tick) var toAssignValue = unassignedValue / unassignedCount; // 未分配的时间分配给所有未分配段,每段分配到的量 # endregion + int totalTick = bar * RSL + tick; int segIdx; for (segIdx = 0; segIdx < slide.segments.Count; segIdx++) { @@ -150,14 +154,31 @@ protected virtual List AddSlide(Slide slide, int bar, int tick) if (slide.IsBreak) prefix = "BR"; waitTime = T(slide.WaitTime.Bar, -slide.FalseEachIdx); } - else prefix = "CN"; + else + { + prefix = "CN"; + // 对于CN段,需要主动检查并规避“它恰好和某个在先的Slide尾部完全重合”的情况, + // 否则游戏逻辑会把这个CN段接在这个“在先Slide”的后面,造成星星连接错误。 + // 测试样例详见 tests/mai/testset/片段/多段星星CN连接的歧义.yaml,以及PR #8中的分析。 + while (_slideEnds.Contains((totalTick, seg.StartKey))) + { // 说明命中了上述情况。此时,应该调整当前段和上一段的时间分配来规避掉问题。 + resSegs[^1] = resSegs[^1] with { len = resSegs[^1].len + 1 }; + totalTick++; // 以上两行实现的是,把当前段的时刻往后挪1tick。 + if (len > 1) len--; // 那么长度自然就要补偿这1tick。要么在当前段的len里补偿、从而后面段的开始时刻不受影响; + else if (totalLen > 1) totalLen--; // 要么就在“剩余长度”中补偿,从而下一段也还是晚开始1tick。 + } + } - var name = seg.Type.ToString(); - - result.Add(new MA2Line(prefix + name, bar, tick, seg.StartKey - 1, - string.Join("\t", [waitTime, len, seg.EndKey - 1]))); - tick += waitTime + len; - while (tick >= RSL) { tick -= RSL; bar++; } + resSegs.Add((prefix + seg.Type, totalTick, seg, waitTime, len)); + totalTick += waitTime + len; + } + + _slideEnds.Add((totalTick, slide.EndKey)); + foreach (var r in resSegs) + { + var (b, t) = BT(r.totalTick); + result.Add(new MA2Line(r.name, b, t, r.seg.StartKey - 1, + string.Join("\t", [r.waitTime, r.len, r.seg.EndKey - 1]))); } if (slide.IsEx) Warn(Locale.ExSlideIn105, slide); From 3517b3a32e68c0a87b6c64ca09286c877faa696f Mon Sep 17 00:00:00 2001 From: Starrah Date: Fri, 9 Oct 2026 15:10:52 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(fix):=20=E4=BC=98=E5=8C=96=E4=B8=80?= =?UTF-8?q?=E4=BA=9B=E6=B5=8B=E8=AF=95=E4=BB=A3=E7=A0=81=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=E9=99=88=E5=B9=B4=E8=80=81bug/=E4=B8=8D=E4=BC=98=E7=BE=8E?= =?UTF-8?q?=E4=B9=8B=E5=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...07\346\256\265\346\265\213\350\257\225.cs" | 2 +- tests/mai/TestUtils.cs | 28 ++++++++++--------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git "a/tests/mai/Simai\347\211\207\346\256\265\346\265\213\350\257\225.cs" "b/tests/mai/Simai\347\211\207\346\256\265\346\265\213\350\257\225.cs" index 1b77dae..5de261f 100644 --- "a/tests/mai/Simai\347\211\207\346\256\265\346\265\213\350\257\225.cs" +++ "b/tests/mai/Simai\347\211\207\346\256\265\346\265\213\350\257\225.cs" @@ -60,7 +60,7 @@ public void Simai_Roundtrip(TestSegment c) _output.WriteLine(string.Join('\n', parseAlerts2)); _output.WriteLine(string.Join('\n', genAlerts2)); - Assert.Equal(c.Simai, simaiRegenerated.ReplaceLineEndings("")); // 暂时直接做字符串完全匹配,这样暂时是够用的。 + Assert.Equal(c.Simai.ReplaceLineEndings(""), simaiRegenerated.ReplaceLineEndings("")); // 暂时直接做字符串完全匹配,这样暂时是够用的。 // AssertSimaiNotesEqual(c.Simai, simaiRegenerated, chart2, _output); // 如果之后不够用了。可以优先考虑开启这个 } diff --git a/tests/mai/TestUtils.cs b/tests/mai/TestUtils.cs index 3026c36..48ad73c 100644 --- a/tests/mai/TestUtils.cs +++ b/tests/mai/TestUtils.cs @@ -352,7 +352,9 @@ static IEnumerable Canon(IReadOnlyList e) => [GeneratedRegex(@"\[(?:([\d\.]+)##)?(?:(\d+):(\d+)|#?([\d\.]+))\]")] private static partial Regex DurationStrRegex(); - private static bool Near(double a, double b) => Math.Abs(a - b) < 1e-3; + /// 时长比较容差:当前 BPM 下 1/384 小节对应的秒数(与 MA2 RESOLUTION 对齐)。 + private static bool Near(double a, double b, decimal bpm) => + Math.Abs(a - b) < (double)(240m / bpm / 384); private static void AssertNoteEqual(string expected, string actual, int noteIdx, Rational time, MaiChart chart) { @@ -390,7 +392,7 @@ private static bool CompareDurationStr(string exp, string act, Rational time, Ma var actTime = DurationStrRegex().Match(act); if (!expTime.Success || !actTime.Success) return result; var expRemain = exp[..expTime.Index] + exp[(expTime.Index + expTime.Length)..]; - var actRemain = act[..expTime.Index] + act[(actTime.Index + actTime.Length)..]; + var actRemain = act[..actTime.Index] + act[(actTime.Index + actTime.Length)..]; if (actRemain != expRemain) return result; // 如果除了时间以外还有其他不一样的,那么直接返回false // 对act产生的时间标记,做规范性检查。对齐到标准中的每一条 @@ -413,27 +415,27 @@ private static bool CompareDurationStr(string exp, string act, Rational time, Ma var bpm = chart.BpmList.Find(time).Bpm; if (expTime.Groups[2].Success && actTime.Groups[4].Success) { // exp中是分数时间、act中是小数时间的情况 - // 小数时间化为分数时间,看看是否对的上 - var numer = decimal.Parse(actTime.Groups[4].Value) / (240 / bpm) * int.Parse(expTime.Groups[2].Value); - if (Math.Round(numer) == int.Parse(expTime.Groups[3].Value)) result = true; // 如果对的上,则不判定为比较失败 + // 分数时间化为小数时间,看是否对的上;分子/分母顺序与 VisitBeats 一致 + var sec = new Rational(int.Parse(expTime.Groups[3].Value), int.Parse(expTime.Groups[2].Value)) * (240 / (Rational)bpm); + if (Near((double)sec, double.Parse(actTime.Groups[4].Value, CultureInfo.InvariantCulture), bpm)) result = true; // 如果对的上,则不判定为比较失败 } else if (actTime.Groups[2].Success && expTime.Groups[4].Success) { // exp中是小数时间、act中是分数时间的情况 - // 分数时间化为小数时间,看是否对的上(差距<1ms) + // 分数时间化为小数时间,看是否对的上 var sec = new Rational(int.Parse(actTime.Groups[3].Value), int.Parse(actTime.Groups[2].Value)) * (240 / (Rational)bpm); - if (Near((double)sec, double.Parse(expTime.Groups[4].Value))) result = true; // 如果对的上,则不判定为比较失败 + if (Near((double)sec, double.Parse(expTime.Groups[4].Value, CultureInfo.InvariantCulture), bpm)) result = true; // 如果对的上,则不判定为比较失败 } else if (actTime.Groups[4].Success && expTime.Groups[4].Success) { // exp中是小数时间、act中是小数时间的情况 - var expSec = double.Parse(expTime.Groups[4].Value); - var actSec = double.Parse(actTime.Groups[4].Value); - if (Near(expSec, actSec)) result = true; // 如果对的上,则不判定为比较失败 + var expSec = double.Parse(expTime.Groups[4].Value, CultureInfo.InvariantCulture); + var actSec = double.Parse(actTime.Groups[4].Value, CultureInfo.InvariantCulture); + if (Near(expSec, actSec, bpm)) result = true; // 如果对的上,则不判定为比较失败 } // 比较等待时间是否相等(没显式写出的就是1拍) - var expWait = expTime.Groups[1].Success ? double.Parse(expTime.Groups[1].Value) : 60 / (double)bpm; - var actWait = actTime.Groups[1].Success ? double.Parse(actTime.Groups[1].Value) : 60 / (double)bpm; - if (!Near(expWait, actWait)) result = false; // 如果等待时间对不上,则仍判定为比较失败 + var expWait = expTime.Groups[1].Success ? double.Parse(expTime.Groups[1].Value, CultureInfo.InvariantCulture) : 60 / (double)bpm; + var actWait = actTime.Groups[1].Success ? double.Parse(actTime.Groups[1].Value, CultureInfo.InvariantCulture) : 60 / (double)bpm; + if (!Near(expWait, actWait, bpm)) result = false; // 如果等待时间对不上,则仍判定为比较失败 return result; }