Skip to content

Commit

Permalink
Add ChordOverLyricTransformer tests
Browse files Browse the repository at this point in the history
  • Loading branch information
menees committed Oct 18, 2023
1 parent a528028 commit 2558491
Show file tree
Hide file tree
Showing 15 changed files with 202 additions and 28 deletions.
3 changes: 2 additions & 1 deletion src/Menees.Chords/ChordDefinitions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public sealed class ChordDefinitions : Entry
{
#region Constructors

internal ChordDefinitions(IReadOnlyList<ChordDefinition> definitions)
internal ChordDefinitions(IReadOnlyList<ChordDefinition> definitions, IEnumerable<Entry>? annotations = null)
: base(annotations)
{
this.Definitions = definitions;
}
Expand Down
4 changes: 2 additions & 2 deletions src/Menees.Chords/ChordProGridLine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,10 @@ private ChordProGridLine(IReadOnlyList<TextSegment> segments)

// The ChordPro grid line syntax is very loose, and ChordPro's examples include things
// that the documentation says are not allowed. So, we'll require the line to be inside
// an open start_of_grid/end_of_grid section, or the line has to have several '|' separators.
// an open start_of_grid/end_of_grid section, or the line has to have multiple '|' separators.
// If we're not in an explicit grid, then we have to be careful not to match tab lines.
bool inExplicitGrid = context.State.TryGetValue(ChordProDirectiveLine.GridStateKey, out object? gridState) && gridState is ChordProDirectiveLine;
const int MinSeparators = 3;
const int MinSeparators = 2;
if (inExplicitGrid || context.LineText.Count(ch => ch == '|') >= MinSeparators)
{
IReadOnlyList<TextSegment> segments = TryGetSegments(
Expand Down
13 changes: 12 additions & 1 deletion src/Menees.Chords/ChordProLyricLine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,12 @@ void AddLyrics(int length)

void AppendChord(string chordText, Func<string, TextSegment> createChordSegment)
{
// Ensure there's at least some whitespace between chords.
if (chordLineSegments.Count > 0 && chordLineSegments[^1] is not WhiteSpaceSegment)
{
indentChord = Math.Max(indentChord, 1);
}

if (indentChord > 0)
{
chordLineSegments.Add(new WhiteSpaceSegment(new string(' ', indentChord)));
Expand All @@ -224,7 +230,12 @@ void AppendChord(string chordText, Func<string, TextSegment> createChordSegment)
indentChord = -chordText.Length;
}

IEnumerable<Entry>? annotations = this.Annotations;
// ChordProTransformer.AddAnnotations formats each annotation like [*Xxx]
// for ChordProLyricLine, so we'll change their prefix and suffix.
IEnumerable<Entry>? annotations = this.Annotations.Select(entry
=> entry is Comment comment && comment.Prefix == "[*" && comment.Suffix == "]"
? new Comment(comment.Text, "(", ")", comment.Annotations)
: entry);
ChordLine? chords = null;
if (chordLineSegments.Count > 0)
{
Expand Down
5 changes: 5 additions & 0 deletions src/Menees.Chords/SegmentedEntry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ protected SegmentedEntry(IReadOnlyList<TextSegment> segments)
{
result.Add(new ChordSegment(chord, lexer.Token.ToString()));
}
else if (requiredBracketedChords && chordTokenType == TokenType.Bracketed && lexer.Token.Text.StartsWith('*'))
{
// Handle ChordPro [*Xxx] annotations.
result.Add(new TextSegment($"[{lexer.Token.Text}]"));
}
else
{
TextSegment? segment = getSegment?.Invoke(lexer.Token);
Expand Down
110 changes: 102 additions & 8 deletions src/Menees.Chords/Transformers/ChordOverLyricTransformer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
/// </summary>
public sealed class ChordOverLyricTransformer : DocumentTransformer
{
#region Private Data Members

private const StringComparison Comparison = ChordParser.Comparison;

#endregion

#region Constructors

/// <summary>
Expand Down Expand Up @@ -73,7 +79,7 @@ private static IReadOnlyList<Entry> TransformEntries(IReadOnlyList<Entry> input)

case ChordProGridLine grid:
// A ChordPro grid line isn't the same as a TablatureLine, so LyricLine is the best fit.
output.Add(new LyricLine(grid.ToString(false), grid.Annotations));
output.Add(new LyricLine(grid.ToString(false).TrimEnd(), grid.Annotations));
break;

case ChordProLyricLine lyric:
Expand Down Expand Up @@ -109,9 +115,10 @@ private static IReadOnlyList<Entry> TransformEntries(IReadOnlyList<Entry> input)

private static void TryConvertDirective(List<Entry> output, ChordProDirectiveLine directive)
{
const StringComparison Comparison = ChordParser.Comparison;
const string StartOf = "start_of_";

const string CommentPrefix = "** ";
const string CommentSuffix = " **";
string longName = directive.LongName;
if (longName.StartsWith("end_of_", Comparison)
|| longName.Equals("start_of_grid", Comparison)
Expand All @@ -121,32 +128,119 @@ private static void TryConvertDirective(List<Entry> output, ChordProDirectiveLin
}
else if (longName == "comment")
{
output.Add(new Comment(directive.Argument ?? string.Empty, "** ", " **", directive.Annotations));
output.Add(new Comment(directive.Argument ?? string.Empty, CommentPrefix, CommentSuffix, directive.Annotations));
}
else if (longName.StartsWith(StartOf, Comparison) && longName.Length > StartOf.Length)
{
string header = directive.Argument ?? CultureInfo.CurrentCulture.TextInfo.ToTitleCase(longName[StartOf.Length..]);
output.Add(new HeaderLine($"[{header}]", directive.Annotations));
}
else if ((longName.Equals("title", Comparison) || longName.Equals("artist", Comparison))
&& !string.IsNullOrEmpty(directive.Argument))
else if (string.IsNullOrWhiteSpace(directive.Argument))
{
// Other argument-less directives are formatting related, so we'll just pass them through.
output.Add(directive);
}
else if (longName.Equals("title", Comparison) || longName.Equals("artist", Comparison))
{
output.Add(new LyricLine(directive.Argument!, directive.Annotations));
}
else if (longName.Equals("tempo", Comparison) && !string.IsNullOrEmpty(directive.Argument))
else if (longName.Equals("tempo", Comparison))
{
output.Add(new LyricLine($"{directive.Argument} bpm"));
}
else if (longName.Equals("key", Comparison) && !string.IsNullOrEmpty(directive.Argument))
else if (longName.Equals("key", Comparison))
{
output.Add(new LyricLine($"Key: {directive.Argument}"));
}
else if (longName.Equals("capo", Comparison))
{
output.Add(new Comment($"Capo @ {directive.Argument}", CommentPrefix, CommentSuffix, directive.Annotations));
}
else if (longName.Equals("chord", Comparison) || longName.Equals("define", Comparison))
{
output.Add(ParseChordDirective(directive.Argument!, directive.Annotations));
}
else
{
// Other directives are formatting related, so we'll just pass them through.
// Other argument-ed directives are formatting related, so we'll just pass them through.
output.Add(directive);
}
}

private static Entry ParseChordDirective(string text, IReadOnlyList<Entry> annotations)
{
Entry result;

// https://www.chordpro.org/chordpro/directives-chord/
string[] parts = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length == 0)
{
result = BlankLine.Instance;
}
else
{
string initialSectionName = string.Empty;
string name = parts[0];
List<string> current = new();
Dictionary<string, List<string>> sections = new(ChordParser.Comparer) { { initialSectionName, current } };
foreach (string part in parts.Skip(1))
{
// If it's a fret or finger position, then add it to the current list.
// Otherwise, make a new section with a new list.
if (byte.TryParse(part, out _) || part.Equals("x", Comparison))
{
current.Add(part);
}
else if (sections.TryGetValue(part, out List<string>? list))
{
current = list;
}
else
{
current = new();
sections.Add(part, current);
}
}

const string CommentPrefix = "(";
const string CommentSuffix = ")";
ChordDefinition? definition;
if (!sections.TryGetValue("frets", out List<string>? frets)
|| (definition = ChordDefinition.TryParse(name, string.Join("-", frets))) is null)
{
// A chord directive can have just a name (e.g., {chord: Am}).
result = new Comment(text, CommentPrefix, CommentSuffix, annotations);
}
else
{
sections.Remove(nameof(frets));
if (sections.TryGetValue("base-fret", out List<string>? baseFrets)
&& baseFrets.Count == 1
&& frets.Contains(baseFrets[0]))
{
sections.Remove("base-fret");
}

if (sections.TryGetValue(initialSectionName, out List<string>? initialSection)
&& initialSection.Count == 0)
{
sections.Remove(initialSectionName);
}

// If there are leftover sections (e.g. fingers # # # #), then add them as a comment annotation.
string annotationText = string.Join(" ", sections.OrderBy(pair => pair.Key)
.Select(pair => $"{pair.Key} {string.Join(" ", pair.Value)}"));
if (!string.IsNullOrEmpty(annotationText))
{
annotations = annotations.Concat(new[] { new Comment(annotationText, CommentPrefix, CommentSuffix) }).ToList();
}

result = new ChordDefinitions(new[] { definition }, annotations);
}
}

return result;
}

#endregion
}
4 changes: 3 additions & 1 deletion tests/Menees.Chords.Tests/ChordProGridLineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ public class ChordProGridLineTests
[TestMethod]
public void TryParseInvalidTest()
{
Test("| A . . . |"); // Too short; not enough separators or chords
Test("| A . . . "); // Too short; not enough separators or chords
Test(" A . . . |"); // Too short; not enough separators or chords
Test("|--1-2-3---|--1-2-3---|"); // Tab line
Test("| 1 . . . | 4 . . . | 5 . . . | 1 . . . |"); // Nashville numbered-chords are too much like tab lines.

Expand All @@ -24,6 +25,7 @@ static void Test(string text)
[TestMethod]
public void TryParseValidTest()
{
Test("| A . . . |", "A");
Test("| A . | D . | E . |", "A", "D", "E");
Test("|| Am . . . | C . . . | D . . . | F . . . |", "Am", "C", "D", "F");
Test("| Am . . . | E . . . | Am . . . | Am . . . ||", "Am", "E", "Am", "Am");
Expand Down
8 changes: 7 additions & 1 deletion tests/Menees.Chords.Tests/ChordProLyricLineTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ static void Test(string chordLyricLines, string expectedText)
[TestMethod]
public void SplitTest()
{
// Test some simple lines with no lyrics.
Test("[G] [*4x]", "G 4x");
Test("[C] [Em] [D] [D]", "C Em D D");
Test("[C] [Em] [D] [D] [*2x]", "C Em D D 2x");

// Test lines with chords and lyrics interlaced.
Test(
"[A]All right[G] now",
"A G",
Expand Down Expand Up @@ -176,7 +182,7 @@ public void SplitTest()
" D ↓ G↑ D* (* Use higher D second time) D* x57775 ** Sing \"low\" as bass **",
"Swing low, sweet chariot,");

static void Test(string text, string? expectedChords, string? expectedLyrics)
static void Test(string text, string expectedChords, string? expectedLyrics = null)
{
LineContext context = LineContextTests.Create(text);
ChordProLyricLine line = ChordProLyricLine.TryParse(context).ShouldNotBeNull();
Expand Down
20 changes: 20 additions & 0 deletions tests/Menees.Chords.Tests/Menees.Chords.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
<None Remove="Samples\Alone With You.txt" />
<None Remove="Samples\Bring Him Home.txt" />
<None Remove="Samples\Every Rose Has Its Thorn.txt" />
<None Remove="Samples\Expected ChordOverLyric\Alone With You.txt" />
<None Remove="Samples\Expected ChordOverLyric\Bring Him Home.txt" />
<None Remove="Samples\Expected ChordOverLyric\Every Rose Has Its Thorn.txt" />
<None Remove="Samples\Expected ChordOverLyric\Swing Low Sweet Chariot.txt" />
<None Remove="Samples\Expected ChordOverLyric\Test Only.txt" />
<None Remove="Samples\Expected ChordPro\Alone With You.cho" />
<None Remove="Samples\Expected ChordPro\Bring Him Home.cho" />
<None Remove="Samples\Expected ChordPro\Every Rose Has Its Thorn.cho" />
Expand All @@ -30,6 +35,21 @@
<Content Include="Samples\Every Rose Has Its Thorn.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Samples\Expected ChordOverLyric\Alone With You.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Samples\Expected ChordOverLyric\Bring Him Home.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Samples\Expected ChordOverLyric\Every Rose Has Its Thorn.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Samples\Expected ChordOverLyric\Swing Low Sweet Chariot.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Samples\Expected ChordOverLyric\Test Only.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Samples\Expected ChordPro\Alone With You.cho">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Key: B
** Capo @ 4 **

[Intro]
G 4x
G (4x)


[Verse 1]
Expand Down Expand Up @@ -38,7 +38,7 @@ I feel like a child, It seems that I can't think of anyone else, Yeah!


[Solo Chords]
C Em D D 2x
C Em D D (2x)


[Verse 3]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
Bring Him Home (Les Misérables). Bpm: 64. Key: G. Capo: 2.
Bring Him Home (Les Misérables)
64 bpm
Key: G
** Capo @ 2 **

** Capo @ 2 **

[Testing]

[Intro]
G C 4x
G C (4x)


[Verse 1]
Expand Down Expand Up @@ -64,6 +67,7 @@ C C* G*


[Notes]
* Use higher chords:
C* = 8-10-10-9-8-8
G* = x-10-12-12-12-10 (arpeggiate slowly)
** Use higher chords: **
C* 8-10-10-9-8-8
G* x-10-12-12-12-10
** arpeggiate slowly **
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
Every Rose Has Its Thorn – Poison. Bpm: 72. Original ½ step down.
Every Rose Has Its Thorn
Poison
72 bpm
** Original ½ step down **


[Verse 1]
Expand Down Expand Up @@ -67,7 +70,8 @@ Cadd9 G


[Solo]
(G) Cadd9 G Cadd9
Cadd9 G Cadd9
(G)
Em D Cadd9 G Em D Cadd9


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# A simple ChordPro song.
# From https://www.chordpro.org/chordpro/chordpro-introduction/

Swing Low Sweet Chariot

[Chorus]
D G D
Swing low, sweet chariot,
A7
Comin' for to carry me home.
D7 G D
Swing low, sweet chariot,
A7 D
Comin' for to carry me home.

D G D
I looked over Jordan, and what did I see,
A7
Comin' for to carry me home.
D G D
A band of angels comin' after me,
A7 D
Comin' for to carry me home.

** Chorus **

0 comments on commit 2558491

Please sign in to comment.