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( '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('''

日本語ReplyEcho

'''); 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

Lyric

'''); 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('''

First second

''').lines.single; expect(line.end?.inMilliseconds, 5000); expect(line.words.map((word) => word.end?.inMilliseconds), [1307, 2497]); }); }