import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/utils/lyrics_parser.dart';
String _tag(String kind, int time, String text) =>
'[x-$kind:$time:${base64.encode(utf8.encode(text))}]';
void main() {
test('all eLRC voices retain timing and supplements without visible IDs', () {
final lyrics = LyricsParser.parse('''
[offset:100]
${_tag('romaji', 2000, 'Second reading')}
${_tag('translation', 2000, 'Second translation')}
[00:01.00]v1:<00:01.00>First<00:03.00>
[00:02.00]<00:02.00> V2: Second<00:04.00>
[00:04.00]v3:Third
[00:06.00]v12:Twelfth
[00:08.00]The v2: label stays inside a sentence
''');
expect(lyrics.lines.map((line) => line.voice?.id), [
'v1',
'v2',
'v3',
'v12',
null,
]);
expect(lyrics.lines.map((line) => line.voice?.index), [0, 1, 2, 11, null]);
final second = lyrics.lines[1];
expect(second.text, 'Second');
expect(second.words.single.text, 'Second');
expect(second.words.single.time.inMilliseconds, 1900);
expect(second.end?.inMilliseconds, 3900);
expect(second.romanization, 'Second reading');
expect(second.translation, 'Second translation');
expect(lyrics.lines.last.text, 'The v2: label stays inside a sentence');
});
test('background eLRC retains the parent voice and its own word times', () {
final lyrics = LyricsParser.parse('''
[00:01.00]v2:<00:01.00>Main<00:05.00>
[bg:<00:02.00>Backing<00:04.00>]
''');
expect(lyrics.lines.map((line) => line.text), ['Main', 'Backing']);
final backing = lyrics.lines.last;
expect(backing.voice?.id, 'v2');
expect(backing.isBackground, isTrue);
expect(backing.time.inMilliseconds, 2000);
expect(backing.end?.inMilliseconds, 4000);
expect(backing.vocalGroup, lyrics.lines.first.vocalGroup);
});
test('V3 is a collaboration even without TTML group metadata', () {
for (final source in [
'[00:01.00]V1:Lead\n[00:02.00]V3:Together\n[00:03.00]V2:Guest',
''
'
Lead
'
'Together
'
'Guest
',
]) {
final lyrics = LyricsParser.parse(source);
expect(lyrics.lines[1].voice?.isGroup, isTrue);
expect(lyrics.lines[2].voice?.isGroup, isFalse);
}
});
test(
'backing groups survive sorting, offsets and repeated backing parts',
() {
final lyrics = LyricsParser.parse('''
[offset:100]
[00:01.00]v2:<00:01.00>Lead<00:06.00>
[bg:<00:04.00>Echo<00:05.00>]
[bg:[00:05.00]<00:05.00>Again<00:06.00>]
[00:03.00]v1:<00:03.00>Other lead<00:06.00>
''');
expect(lyrics.lines.map((line) => line.text), [
'Lead',
'Other lead',
'Echo',
'Again',
]);
final lead = lyrics.lines.first;
expect(lyrics.lines[1].vocalGroup, isNot(lead.vocalGroup));
for (final backing in lyrics.lines.skip(2)) {
expect(backing.vocalGroup, lead.vocalGroup);
expect(backing.voice?.id, 'v2');
expect(backing.isBackground, isTrue);
}
expect(lyrics.lines[2].time.inMilliseconds, 3900);
},
);
test('TTML resolves inherited voices and explicit groups by namespace', () {
final lyrics = LyricsParser.parse('''
Lead
Guest
Together
Third
''');
expect(lyrics.lines.map((line) => line.voice?.id), [
'lead',
'guest',
'v3',
'third',
]);
expect(lyrics.lines.map((line) => line.voice?.index), [0, 1, 2, 2]);
expect(lyrics.lines.map((line) => line.voice?.isGroup), [
false,
false,
true,
false,
]);
});
test(
'TTML splits span voices and backing parts without adding syllable gaps',
() {
final lyrics = LyricsParser.parse('''
''');
expect(lyrics.lines.map((line) => line.text), ['日本語', 'Reply', 'Echo']);
expect(lyrics.lines.first.words.map((word) => word.text), ['日本', '語']);
expect(lyrics.lines.map((line) => line.time.inSeconds), [1, 2, 2]);
expect(lyrics.lines.map((line) => line.end?.inSeconds), [3, 4, 3]);
expect(lyrics.lines.last.isBackground, isTrue);
expect(lyrics.lines.last.voice?.id, 'v1');
expect(lyrics.lines.last.vocalGroup, lyrics.lines.first.vocalGroup);
expect(lyrics.lines[1].vocalGroup, isNot(lyrics.lines.first.vocalGroup));
},
);
test('TTML partial word timing never loses untimed text', () {
final line = LyricsParser.parse('''
Untimed timed ending
''').lines.single;
expect(line.text, 'Untimed timed ending');
expect(line.words, isEmpty);
});
test(
'writer tags and stored provider attribution remain separate from lyric rows',
() {
final lyrics = LyricsParser.parse('''
[au:Example Writer, Another Writer]
[by:SpotiFLAC-Mobile via Example Lyrics API (source: upstream)]
[00:01.00]First line
[00:04.00]Last line
''');
expect(lyrics.writers, 'Example Writer, Another Writer');
expect(lyrics.provider, 'Example Lyrics');
expect(lyrics.lines, hasLength(2));
expect(lyrics.lines.last.time, const Duration(seconds: 4));
},
);
test(
'an LRC uploader or performing artist is not invented as the songwriter or provider',
() {
final lyrics = LyricsParser.parse(
'[ar:Performer]\n[by:Uploader]\n[00:01.00]Line',
);
expect(lyrics.writers, isNull);
expect(lyrics.provider, isNull);
expect(
LyricsParser.parse(
'[by:SpotiFLAC-Mobile (source: extension:example.lyrics)]\n[00:01]Line',
).provider,
'example.lyrics',
);
},
);
test(
'explicit ending credits become a footer rather than a seekable lyric',
() {
final lyrics = LyricsParser.parse(
'[00:01]Last lyric\n[00:04]Written By: Example Writer',
);
expect(lyrics.lines.single.text, 'Last lyric');
expect(lyrics.writers, 'Example Writer');
expect(lyrics.plainText, 'Last lyric');
},
);
test(
'TTML songwriter metadata is retained without treating performers as writers',
() {
final lyrics = LyricsParser.parse('''
Example Writer
Second Writer
''');
expect(lyrics.writers, 'Example Writer, Second Writer');
expect(lyrics.lines.single.text, 'Lyric');
},
);
test('retains all three texts, word ends and millisecond timing', () {
final lyrics = LyricsParser.parse('''
${_tag('romaji', 1009, 'Romanized')}
${_tag('translation', 1009, 'English')}
[00:01.009]<00:01.009>First <00:01.307><00:02.003>second<00:02.497>
''');
final line = lyrics.lines.single;
expect(lyrics.wordSynced, isTrue);
expect(line.text, 'First second');
expect(line.romanization, 'Romanized');
expect(line.translation, 'English');
expect(line.time.inMilliseconds, 1009);
expect(line.end?.inMilliseconds, 2497);
expect(line.words.map((word) => word.end?.inMilliseconds), [1307, 2497]);
expect(lyrics.plainText, 'First second');
});
test('matches legacy and rounded eLRC tags within ten milliseconds', () {
final lyrics = LyricsParser.parse('''
${_tag('romaji', 1009, 'Rounded up')}
${_tag('translation', 2009, 'Truncated')}
${_tag('romaji', 2990, 'Boundary')}
${_tag('translation', 4011, 'Too far')}
[00:01.01]A
[00:02.00]B
[00:03.00]C
[00:04.00]D
''');
expect(lyrics.lines[0].romanization, 'Rounded up');
expect(lyrics.lines[1].translation, 'Truncated');
expect(lyrics.lines[2].romanization, 'Boundary');
expect(lyrics.lines[3].translation, isNull);
});
test(
'romanization timing aligns with rounded lines and applies the offset',
() {
final words = [
{'text': 'Ro', 'startTimeMs': 1009, 'endTimeMs': 1107},
{'text': 'ma ', 'startTimeMs': 1107, 'endTimeMs': 1307},
{'text': 'nized', 'startTimeMs': 2003, 'endTimeMs': 2497},
];
final line = LyricsParser.parse('''
[offset:109]
${_tag('romaji-words', 1009, jsonEncode(words))}
${_tag('romaji', 1009, 'Roma nized')}
[00:01.01]Original
''').lines.single;
expect(line.romanization, 'Roma nized');
expect(line.romanizationWords.map((word) => word.text), [
'Ro',
'ma ',
'nized',
]);
expect(line.romanizationWords.first.time.inMilliseconds, 900);
expect(line.romanizationWords[1].end?.inMilliseconds, 1198);
expect(line.romanizationWords.last.time.inMilliseconds, 1894);
expect(line.romanizationWords.last.end?.inMilliseconds, 2388);
},
);
test(
'missing or corrupt romanization timings preserve plain romanization',
() {
for (final timing in [
'bad json',
'{}',
'[null]',
'[{"text":"Other","startTimeMs":1000,"endTimeMs":1300}]',
'[{"text":"Romanized","startTimeMs":1000,"endTimeMs":900}]',
]) {
final line = LyricsParser.parse('''
${_tag('romaji', 1000, 'Romanized')}
${_tag('romaji-words', 1000, timing)}
[00:01.00]Original
''').lines.single;
expect(line.romanization, 'Romanized');
expect(line.romanizationWords, isEmpty);
}
},
);
test('exact matches win collisions and a tie goes to one earlier line', () {
final lyrics = LyricsParser.parse('''
${_tag('romaji', 1009, 'Near')}
${_tag('romaji', 1010, 'Exact')}
${_tag('romaji', 1001, 'Further')}
${_tag('translation', 2005, 'Tie')}
[00:01.010]A
[00:02.000]B
[00:02.010]C
''');
expect(lyrics.lines[0].romanization, 'Exact');
expect(lyrics.lines[1].translation, 'Tie');
expect(lyrics.lines[2].translation, isNull);
});
test('offset moves word ends and line end after supplement matching', () {
final lyrics = LyricsParser.parse('''
[offset:109]
${_tag('romaji', 1009, 'Romanized')}
[00:01.009]<00:01.009>Word<00:01.307>
''');
final line = lyrics.lines.single;
expect(line.romanization, 'Romanized');
expect(line.time.inMilliseconds, 900);
expect(line.end?.inMilliseconds, 1198);
expect(line.words.single.time.inMilliseconds, 900);
expect(line.words.single.end?.inMilliseconds, 1198);
});
test('invalid optional tags do not appear or hide the original text', () {
const tags = '''
[x-romaji:1000:%%%]
[x-romaji:1000:/w==]
[x-translation:bad:YQ==]
[x-translation:1000:]
''';
expect(LyricsParser.parse(tags).isEmpty, isTrue);
final lyrics = LyricsParser.parse('$tags[00:01.00]Original');
expect(lyrics.lines.single.text, 'Original');
expect(lyrics.lines.single.romanization, isNull);
expect(lyrics.lines.single.translation, isNull);
expect(lyrics.plainText, 'Original');
expect(LyricsParser.parse('${tags}Plain text').plainText, 'Plain text');
});
test('supplement text safely retains Unicode and embedded newlines', () {
const text = '日本語 ]\nSecond line';
final lyrics = LyricsParser.parse(
'${_tag('translation', 1000, text)}\n[00:01.00]Original',
);
expect(lyrics.lines.single.translation, text);
});
test('start-only eLRC retains its existing fallback timing', () {
final line = LyricsParser.parse(
'[00:01.00]<00:01.00>First <00:02.00>second',
).lines.single;
expect(line.words.map((word) => word.end), [null, null]);
expect(line.end, isNull);
});
test('empty tags retain the first valid end and reject backward ends', () {
final line = LyricsParser.parse(
'[00:01.00]<00:01.00>First <00:00.90><00:01.30><00:01.40>'
'<00:02.00>second<00:02.00>',
).lines.single;
expect(line.words.map((word) => word.end?.inMilliseconds), [1300, 2000]);
});
test('TTML span ends survive independently of the paragraph end', () {
final line = LyricsParser.parse('''
''').lines.single;
expect(line.end?.inMilliseconds, 5000);
expect(line.words.map((word) => word.end?.inMilliseconds), [1307, 2497]);
});
}