import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/utils/lyrics_parser.dart'; import 'package:spotiflac_android/utils/lyrics_timeline.dart'; void main() { test('backing stays under its lead without changing the timing order', () { final lines = LyricsParser.parse(''' [00:01.00]v1:<00:01.00>Lead<00:06.00> [bg:<00:04.00>Echo<00:06.00>] [bg:<00:05.00>Again<00:06.00>] [00:03.00]v2:<00:03.00>Guest<00:06.00> ''').lines; final layout = LyricDisplayLayout(lines); expect(lines.map((line) => line.text), ['Lead', 'Guest', 'Echo', 'Again']); expect(layout.lineOrder.map((i) => lines[i].text), [ 'Lead', 'Echo', 'Again', 'Guest', ]); expect(layout.rowForLine, [0, 3, 1, 2]); expect(layout.leadForLine, [0, 1, 0, 0]); expect(layout.focusForLine, [0, 1, 1, 1]); expect(activeLyricIndices(lines, const Duration(seconds: 5), 3), { 0, 1, 2, 3, }); }); test('background onset keeps focus on the lead and gaps remain separate', () { final lines = lyricsTimelineWithGaps( LyricsParser.parse(''' [00:05.00]v2:<00:05.00>Lead<00:10.00> [bg:<00:06.00>Echo<00:11.00>] [00:15.00]Next ''').lines, ); final layout = LyricDisplayLayout(lines); expect(lines.map((line) => line.text), ['', 'Lead', 'Echo', '', 'Next']); expect(layout.lineOrder, [0, 1, 2, 3, 4]); expect(layout.focusForLine, [0, 1, 1, 3, 4]); expect(lines[3].time, const Duration(seconds: 11)); }); test( 'a backing pickup can start before the lead without losing its parent', () { final lines = LyricsParser.parse('''

PickupLead

''').lines; final layout = LyricDisplayLayout(lines); expect(lines.map((line) => line.text), ['Pickup', 'Lead']); expect(layout.lineOrder, [1, 0]); expect(layout.focusForLine, [1, 1]); }, ); test( 'backing vocals do not dim an unlabelled lead that is still singing', () { final lines = LyricsParser.parse(''' [00:01.00]<00:01.00>Main<00:05.00> [bg:<00:02.00>Echo<00:03.00>] ''').lines; expect(activeLyricIndices(lines, const Duration(seconds: 2), 1), {0, 1}); expect(activeLyricIndices(lines, const Duration(seconds: 4), 1), {0}); }, ); test( 'overlapping singers stay active until their ends, including seek back', () { final lines = LyricsParser.parse(''' [00:01.00]v1:<00:01.00>Lead<00:05.00> [00:02.00]v2:<00:02.00>Guest<00:04.00> [00:06.00]v3:Third ''').lines; Set active(int seconds) { final position = Duration(seconds: seconds); return activeLyricIndices( lines, position, LyricsParser.activeIndex(lines, position), ); } expect(active(0), isEmpty); expect(active(1), {0}); expect(active(3), {0, 1}); expect(active(4), {0}); expect(active(5), isEmpty); expect(active(6), {2}); expect(active(3), {0, 1}); expect(active(1), {0}); }, ); List<(int, int)> gaps(String text) => lyricsTimelineWithGaps(LyricsParser.parse(text).lines) .where((line) => line.text.isEmpty) .map((line) => (line.time.inMilliseconds, line.end!.inMilliseconds)) .toList(); test('intro and explicit LRC breaks count down, never the outro', () { const text = ''' [00:00.00] [00:09.00]First [00:12.00] [00:14.00] [00:21.00]Second [00:25.00] [00:30.00] '''; expect(gaps(text), [(0, 9000), (12000, 21000)]); expect( lyricsTimelineWithGaps(LyricsParser.parse(text).lines).last.text, 'Second', ); }); test('TTML line ends identify breaks without empty timestamps', () { expect( gaps('''

First

Second

Third

'''), [(0, 9000), (12000, 21000)], ); }); test('enhanced LRC word ends retain held vocals before a break', () { expect( gaps(''' [00:00.00]<00:00.00>Held<00:16.00> [00:20.00]<00:20.00>Last<00:24.00> '''), [(16000, 20000)], ); }); test('overlapping lines cannot start a countdown during a held vocal', () { expect( gaps('''

Held

Backing vocal

Last

'''), [(16000, 20000)], ); }); test('plain LRC does not invent an end time from distance between lines', () { expect(gaps('[00:00]Held\n[00:30]Last'), isEmpty); expect(gaps('[00:00]\n[00:30]'), isEmpty); expect(gaps('Unsynced text'), isEmpty); }); test('short rests and immediate vocals have no flashing countdown', () { expect(gaps('[00:02]First\n[00:04]\n[00:06]Last'), isEmpty); expect(gaps('[00:00]First\n[00:04]\n[00:07]Last'), [(4000, 7000)]); }); test('offset correction also moves the countdown boundaries', () { expect(gaps('[offset:500]\n[00:09]First\n[00:12]\n[00:21]Last'), [ (0, 8500), (11500, 20500), ]); }); test('word ends protect against an early or invalid paragraph end', () { expect( gaps('''

Held

Last

'''), [(8000, 12000)], ); expect( gaps('''

Unknown end

Last

'''), isEmpty, ); }); }