diff --git a/.gitattributes b/.gitattributes index 0b163738..5a09aa31 100644 --- a/.gitattributes +++ b/.gitattributes @@ -9,6 +9,9 @@ gradlew text eol=lf *.cmd text eol=crlf *.ps1 text eol=crlf +# Empty TSV fields and trailing lyric whitespace are intentional test data. +android/app/src/test/resources/lyrics_usability_cases.tsv whitespace=-blank-at-eol + # Binary files *.png binary *.jpg binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db02c1f9..e46ae208 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: dart: - 'lib/**' - 'test/**' + - 'android/app/src/test/resources/**' - 'assets/**' - 'pubspec.yaml' - 'pubspec.lock' @@ -41,6 +42,7 @@ jobs: - '.github/workflows/ci.yml' go: - 'go_backend/**' + - 'android/app/src/test/resources/**' - '.github/workflows/ci.yml' android: - 'android/**' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8067b528..7d715357 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -112,6 +112,10 @@ Dart should consume that declaration without knowing which extension uses it. Run checks that cover the code you changed. Before opening a PR, the relevant commands should pass. +Cross-language lyric usability cases live in +`android/app/src/test/resources/lyrics_usability_cases.tsv`. Dart, Go, and +Android tests read the same cases; add a case there when changing that policy. + Flutter and Dart: ```bash diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt index f78c02da..799b367c 100644 --- a/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/NativeFinalizationPolicyTest.kt @@ -7,6 +7,29 @@ import org.junit.Assert.assertTrue import org.junit.Test class NativeFinalizationPolicyTest { + @Test + fun matchesSharedLyricUsabilityCases() { + val stream = checkNotNull( + javaClass.getResourceAsStream("/lyrics_usability_cases.tsv"), + ) + stream.bufferedReader().useLines { lines -> + for (line in lines) { + if (line.isBlank() || line.startsWith("#")) continue + val fields = line.split('\t') + assertEquals("invalid shared fixture: $line", 3, fields.size) + val lyrics = fields[2] + .replace("\\n", "\n") + .replace("\\r", "\r") + .replace("\\t", "\t") + assertEquals( + fields[0], + fields[1].toBooleanStrict(), + NativeFinalizationPolicy.hasUsableLyricsContent(lyrics), + ) + } + } + } + @Test fun usableLyricsRejectsHeadersButKeepsRealAndInstrumentalContent() { assertFalse( diff --git a/android/app/src/test/resources/lyrics_usability_cases.tsv b/android/app/src/test/resources/lyrics_usability_cases.tsv new file mode 100644 index 00000000..852bcc0a --- /dev/null +++ b/android/app/src/test/resources/lyrics_usability_cases.tsv @@ -0,0 +1,23 @@ +# Shared Dart/Go/Kotlin lyric-usability cases. +# Columns: nameexpected booleanlyrics. Decode literal \n, \r, \t in lyrics. +empty false +whitespace false \t\r\n +metadata_only false [ar:Artist]\n[ti:Title]\n[offset:0] +metadata_case false [AR:Artist]\n[BY:SpotiFLAC] +instrumental true [Instrumental:TRUE] +timestamps_only false [00:01.00]\n<00:01.10> +multiple_timestamps false [00:01.00][01:02:500] +speaker_only false v1:\n V2: +inline_before_spaced_speaker false [00:00.00]<00:00.01> v1: +inline_before_tabbed_speaker false [00:00.00]<00:00.01>\tV2:\t +multiple_inline_speaker false [00:00.00]<00:00.01> <00:00.02> v2: +empty_background false [bg: ] +empty_timed_background false [bg:[00:00.00]<00:00.01> v1: ] +plain_text true First line\nSecond line +timed_text true [00:01.00]First line +timed_speaker_text true [00:02:500]<00:02.500> v2: Harmony line +background_text true [bg:Backing vocal] +timed_background_text true [BG:[00:00.00]<00:00.01> v1: Backing vocal] +multiple_timed_text true [00:01.00][00:02.00]Repeated line +metadata_and_text true [ti:Title]\n[00:00.00]Actual lyric +unicode_text true [00:00.00]<00:00.01> v1: 日本語の歌詞 diff --git a/go_backend/lyrics_usability_test.go b/go_backend/lyrics_usability_test.go new file mode 100644 index 00000000..04913a32 --- /dev/null +++ b/go_backend/lyrics_usability_test.go @@ -0,0 +1,35 @@ +package gobackend + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +func TestSharedLyricsUsabilityCases(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "android", "app", "src", "test", "resources", "lyrics_usability_cases.tsv")) + if err != nil { + t.Fatal(err) + } + decode := strings.NewReplacer(`\n`, "\n", `\r`, "\r", `\t`, "\t") + for _, line := range strings.Split(string(data), "\n") { + if line == "" || strings.HasPrefix(line, "#") { + continue + } + fields := strings.Split(line, "\t") + if len(fields) != 3 { + t.Fatalf("invalid shared fixture: %q", line) + } + t.Run(fields[0], func(t *testing.T) { + want, err := strconv.ParseBool(fields[1]) + if err != nil { + t.Fatal(err) + } + if got := rawLyricsHasUsableContent(decode.Replace(fields[2])); got != want { + t.Errorf("rawLyricsHasUsableContent(%q) = %v, want %v", fields[2], got, want) + } + }) + } +} diff --git a/lib/utils/lyrics_metadata_helper.dart b/lib/utils/lyrics_metadata_helper.dart index 0bcaf3f2..9aa23abd 100644 --- a/lib/utils/lyrics_metadata_helper.dart +++ b/lib/utils/lyrics_metadata_helper.dart @@ -48,7 +48,7 @@ String cleanLyricsForDisplay(String lyrics) { while (_lrcDisplayTimestampPattern.hasMatch(cleaned)) { cleaned = cleaned.replaceFirst(_lrcDisplayTimestampPattern, '').trim(); } - cleaned = cleaned.replaceAll(_lrcDisplayInlineTimestampPattern, ''); + cleaned = cleaned.replaceAll(_lrcDisplayInlineTimestampPattern, '').trim(); cleaned = cleaned.replaceFirst(_lrcDisplaySpeakerPrefixPattern, ''); cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim(); diff --git a/test/lyrics_metadata_helper_test.dart b/test/lyrics_metadata_helper_test.dart index aadf3041..dd90b192 100644 --- a/test/lyrics_metadata_helper_test.dart +++ b/test/lyrics_metadata_helper_test.dart @@ -1,7 +1,27 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; void main() { + group('shared lyric usability cases', () { + final cases = File( + 'android/app/src/test/resources/lyrics_usability_cases.tsv', + ).readAsLinesSync(); + for (final line in cases) { + if (line.isEmpty || line.startsWith('#')) continue; + final fields = line.split('\t'); + test(fields.first, () { + expect(fields, hasLength(3)); + final lyrics = fields[2] + .replaceAll(r'\n', '\n') + .replaceAll(r'\r', '\r') + .replaceAll(r'\t', '\t'); + expect(hasUsableLyricsContent(lyrics), fields[1] == 'true'); + }); + } + }); + group('lyrics display normalization', () { test('rejects metadata-only embedded LRC', () { const raw = '''