clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / cc / cue_test.go
cue_test.go 10.2K
1package cc23import (4 "errors"5 "fmt"6 "os"7 "slices"8 "strings"9 "testing"10 "time"11)1213func ms(n int) time.Duration { return time.Duration(n) * time.Millisecond }1415func TestParseSRT(t *testing.T) {16 srt := "\xef\xbb\xbf1\r\n00:00:01,000 --> 00:00:03,500\r\n<i>Where are you</i>\r\n<font color=\"#ffff00\">going?</font>\r\n\r\n" +17 "2\r\n00:00:04,250 --> 00:00:06,000 X1:100 X2:200 Y1:10 Y2:20\r\n{\\an8}Tom &amp; Jerry\\Nare here\r\n\r\n" +18 "3\r\n00:00:07,000 --> 00:00:07,000\r\nNo time at all\r\n\r\n" +19 "4\r\n00:00:08,000 --> 00:00:09,000\r\n<b></b>\r\n\r\n" +20 "5\r\n01:02:03,4 --> 01:02:05,000\r\nCafé – naïve… “quoted” ♫\r\n" +21 "6\r\n01:02:06,000 --> 01:02:07,000\r\nNo blank line before this one\r\n"22 got, err := Parse([]byte(srt))23 if err != nil {24 t.Fatal(err)25 }26 want := []Cue{27 {Start: ms(1000), End: ms(3500), Text: "Where are you\ngoing?"},28 {Start: ms(4250), End: ms(6000), Text: "Tom & Jerry\nare here"},29 {Start: time.Hour + 2*time.Minute + ms(3400), End: time.Hour + 2*time.Minute + ms(5000), Text: "Café – naïve… “quoted” ♫"},30 {Start: time.Hour + 2*time.Minute + ms(6000), End: time.Hour + 2*time.Minute + ms(7000), Text: "No blank line before this one"},31 }32 if !equalCues(got, want) {33 t.Errorf("got %q\nwant %q", got, want)34 }35}3637func TestParseVTT(t *testing.T) {38 vtt := `WEBVTT39X-TIMESTAMP-MAP=LOCAL:00:00:00.000,MPEGTS:9000004041STYLE42::cue { color: yellow }4344NOTE a comment4546intro4700:01.000 --> 00:02.000 align:start position:10%48<v Roger>Hi &lt;there&gt;</v> <c.loud>you</c>495000:00:03.000 --> 00:00:04.00051- Line one52- Line two53`54 got, err := Parse([]byte(vtt))55 if err != nil {56 t.Fatal(err)57 }58 want := []Cue{59 {Start: ms(1000), End: ms(2000), Text: "Hi <there> you"},60 {Start: ms(3000), End: ms(4000), Text: "- Line one\n- Line two"},61 }62 if !equalCues(got, want) {63 t.Errorf("got %q\nwant %q", got, want)64 }65}6667// YouTube's automatic captions roll: each repeats the last one's line.68func TestParseRollingVTT(t *testing.T) {69 vtt := "WEBVTT\nKind: captions\nLanguage: en\n\n" +70 "00:00:00.320 --> 00:00:02.389 align:start position:0%\n \nhello<00:00:00.640><c> everybody</c>\n\n" +71 "00:00:02.389 --> 00:00:02.399 align:start position:0%\nhello everybody\n \n\n" +72 "00:00:02.399 --> 00:00:05.030 align:start position:0%\nhello everybody\nand<00:00:02.720><c> welcome</c>\n\n" +73 "00:00:05.030 --> 00:00:05.040 align:start position:0%\nand welcome\n \n\n" +74 "00:00:05.040 --> 00:00:07.000 align:start position:0%\nand welcome\nback\n"75 got, err := Parse([]byte(vtt))76 if err != nil {77 t.Fatal(err)78 }79 var texts []string80 for _, c := range got {81 texts = append(texts, c.Text)82 }83 if want := []string{"hello everybody", "and welcome", "back"}; !slices.Equal(texts, want) {84 t.Errorf("captions %q, want %q", texts, want)85 }86}8788// A long line stays whole: the 608 encoder and players lay it out.89func TestParseKeepsLongLines(t *testing.T) {90 long := "This line is a good deal longer than thirty-two characters, and then some"91 got, err := Parse([]byte("1\n00:00:00,000 --> 00:00:02,000\n" + long + "\n"))92 if err != nil || len(got) != 1 || got[0].Text != long {93 t.Errorf("got %q, %v", got, err)94 }95}9697func TestParseRejectsNonsense(t *testing.T) {98 if _, err := Parse([]byte("<html>not found</html>")); !errors.Is(err, ErrNoCues) {99 t.Errorf("err = %v", err)100 }101 // A real file whose captions are all empty is fine, just empty.102 got, err := Parse([]byte("WEBVTT\n\n00:00.000 --> 00:01.000\n<i></i>\n"))103 if err != nil || len(got) != 0 {104 t.Errorf("got %q, %v", got, err)105 }106}107108func TestFormatSRTRoundTrip(t *testing.T) {109 cues := []Cue{{Start: ms(1500), End: time.Hour + ms(61001), Text: "One\nTwo"}, {Start: ms(-5), End: ms(500), Text: "Clipped"}}110 srt := string(FormatSRT(cues))111 if !strings.HasPrefix(srt, "1\n00:00:01,500 --> 01:01:01,001\nOne\nTwo\n\n2\n00:00:00,000 --> ") {112 t.Errorf("SRT:\n%s", srt)113 }114 back, err := Parse([]byte(srt))115 if err != nil || len(back) != 2 || !equalCues(back[1:], cues[:1]) { // in time order116 t.Errorf("back %q, %v", back, err)117 }118}119120func TestShift(t *testing.T) {121 cues := []Cue{122 {Start: ms(0), End: ms(1000), Text: "a"},123 {Start: ms(1500), End: ms(3000), Text: "b c", Words: []Word{{ms(1500), "b"}, {ms(2500), "c"}}},124 {Start: ms(4000), End: ms(5000), Text: "d"},125 }126 got := Shift(cues, -2*time.Second)127 want := []Cue{128 {Start: 0, End: ms(1000), Text: "b c", Words: []Word{{0, "b"}, {ms(500), "c"}}},129 {Start: ms(2000), End: ms(3000), Text: "d"},130 }131 if !equalCues(got, want) {132 t.Errorf("got %v, want %v", got, want)133 }134 if cues[1].Words[0].Start != ms(1500) {135 t.Error("Shift changed its argument's words")136 }137}138139func TestLanguages(t *testing.T) {140 for _, l := range []string{"eng", "en", "EN-us", "English", "en_GB"} {141 if !English(l) || Foreign(l) {142 t.Errorf("%q isn't English", l)143 }144 }145 for _, l := range []string{"", "und", "mul", "zxx"} {146 if English(l) || Foreign(l) {147 t.Errorf("%q: English %v, foreign %v; want neither", l, English(l), Foreign(l))148 }149 }150 for _, l := range []string{"jpn", "ja", "fre", "spa", "Korean"} {151 if !Foreign(l) {152 t.Errorf("%q isn't foreign", l)153 }154 }155}156157func equalCues(a, b []Cue) bool {158 return slices.EqualFunc(a, b, func(x, y Cue) bool {159 return x.Start == y.Start && x.End == y.End && x.Text == y.Text && slices.Equal(x.Words, y.Words)160 })161}162163func TestParseJSON3(t *testing.T) {164 raw, err := os.ReadFile("testdata/asr.json3")165 if err != nil {166 t.Fatal(err)167 }168 got, err := Parse(raw)169 if err != nil {170 t.Fatal(err)171 }172 if len(got) < 10 {173 t.Fatalf("%d cues", len(got))174 }175 // The first lines of a Northernlion video, YouTube's speech recognition176 // timing each word.177 first := got[1]178 if first.Start != ms(640) || first.End != ms(640+5679) || !strings.HasPrefix(first.Text, "right, gamers. We are back") {179 t.Errorf("line %+v", first)180 }181 if w := first.Words; len(w) < 4 || w[0] != (Word{ms(640), "right,"}) || w[1] != (Word{ms(960), "gamers."}) || w[3] != (Word{ms(1680), "are"}) {182 t.Errorf("words %+v", first.Words)183 }184 for _, c := range got {185 if strings.Contains(c.Text, "\n") || c.Text == "" || len(c.Words) == 0 || strings.Join(wordTexts(c.Words), " ") != c.Text {186 t.Errorf("cue %+v", c)187 }188 }189 // Subtitles a person wrote have no word times.190 manual := `{"events":[{"tStartMs":1000,"dDurationMs":2000,"segs":[{"utf8":"Hello &amp; welcome\nto the show"}]},{"tStartMs":0,"dDurationMs":9000,"id":1}]}`191 got, err = Parse([]byte(manual))192 if err != nil || !equalCues(got, []Cue{{Start: ms(1000), End: ms(3000), Text: "Hello & welcome\nto the show"}}) {193 t.Errorf("manual: %+v, %v", got, err)194 }195 if _, err := Parse([]byte(`{"not": "captions"}`)); !errors.Is(err, ErrNoCues) {196 t.Errorf("not json3: %v", err)197 }198}199200func wordTexts(ws []Word) []string {201 var out []string202 for _, w := range ws {203 out = append(out, w.Text)204 }205 return out206}207208// Subtitles made from broadcast roll-up captions repeat each caption's209// last lines atop the next; only the new lines are kept.210func TestParseUnrollsSRT(t *testing.T) {211 srt := "1\n00:00:29,120 --> 00:00:32,250\nON STAGE IN MY HEELS IS WHERE I\nBELONG DOWN AT THE ¶\n¶ PINK PONY CLUB, I'M GONNA KEEP\n\n" +212 "2\n00:00:32,250 --> 00:00:33,320\nBELONG DOWN AT THE ¶\n¶ PINK PONY CLUB, I'M GONNA KEEP\nON DANCING AT THE PINK PONY\n\n" +213 "3\n00:00:33,320 --> 00:00:35,000\n¶ PINK PONY CLUB, I'M GONNA KEEP\nON DANCING AT THE PINK PONY\nCLUB ¶\n\n" +214 "4\n00:00:40,000 --> 00:00:42,000\nCLUB ♪\nA new caption after a pause\n"215 got, err := Parse([]byte(srt))216 if err != nil {217 t.Fatal(err)218 }219 var texts []string220 for _, c := range got {221 texts = append(texts, c.Text)222 }223 want := []string{224 "ON STAGE IN MY HEELS IS WHERE I\nBELONG DOWN AT THE ♪\n♪ PINK PONY CLUB, I'M GONNA KEEP",225 "ON DANCING AT THE PINK PONY", "CLUB ♪", "CLUB ♪\nA new caption after a pause",226 }227 if !slices.Equal(texts, want) {228 t.Errorf("got %q\nwant %q", texts, want)229 }230}231232// YouTube's speech recognition sends lines that overlap (each shows until233// the line after next begins); text players get one screen at a time,234// rolling up: the older line on top, each line once, the bottom timed.235func TestScreenRollsUp(t *testing.T) {236 raw, err := os.ReadFile("testdata/asr.json3")237 if err != nil {238 t.Fatal(err)239 }240 cues, err := Parse(raw)241 if err != nil {242 t.Fatal(err)243 }244 overlaps := 0245 for i := 1; i < len(cues); i++ {246 if cues[i].Start < cues[i-1].End {247 overlaps++248 }249 }250 if overlaps < len(cues)/2 {251 t.Fatalf("the excerpt should overlap: %d of %d", overlaps, len(cues))252 }253 screens := Screen(cues)254 if len(screens) != len(cues) {255 t.Errorf("%d screens for %d lines", len(screens), len(cues))256 }257 for i, s := range screens {258 lines := strings.Split(s.Text, "\n")259 if len(lines) > 2 || (len(lines) == 2 && lines[0] == lines[1]) {260 t.Errorf("screen %d: %q", i, s.Text)261 }262 if i > 0 && s.Start < screens[i-1].End {263 t.Errorf("screen %d starts at %v, before %d ends at %v", i, s.Start, i-1, screens[i-1].End)264 }265 if last := lines[len(lines)-1]; strings.Join(wordTexts(s.Words), " ") != last || s.Words[0].Start != s.Start {266 t.Errorf("screen %d: words %v for its last line %q", i, s.Words, last)267 }268 if i > 0 && len(lines) == 2 {269 prev := strings.Split(screens[i-1].Text, "\n")270 if lines[0] != prev[len(prev)-1] {271 t.Errorf("screen %d's top line %q isn't the last screen's bottom %q", i, lines[0], prev[len(prev)-1])272 }273 }274 }275 vtt := string(AppendVTT(nil, screens[2]))276 lines := strings.Split(strings.TrimSpace(vtt), "\n")277 if len(lines) != 3 || strings.Contains(lines[1], "<") || !strings.Contains(lines[2], "<00:00:") {278 t.Errorf("WebVTT:\n%s", vtt)279 }280 t.Logf("screens:\n%s", strings.Join(func() (out []string) {281 for _, s := range screens[:6] {282 out = append(out, fmt.Sprintf("%v-%v %q", s.Start, s.End, s.Text))283 }284 return out285 }(), "\n"))286}287288// Subtitles that overlap show one at a time too.289func TestScreenSubtitles(t *testing.T) {290 got := Screen([]Cue{291 {Start: ms(0), End: ms(3000), Text: "One"},292 {Start: ms(2000), End: ms(4000), Text: "Two"},293 {Start: ms(4000), End: ms(5000), Text: "Two"},294 {Start: ms(6000), End: ms(7000), Text: "[door slams]"},295 {Start: ms(6000), End: ms(8000), Text: "Who's there?"},296 })297 want := []Cue{298 {Start: ms(0), End: ms(2000), Text: "One"},299 {Start: ms(2000), End: ms(5000), Text: "Two"},300 {Start: ms(6000), End: ms(8000), Text: "[door slams]\nWho's there?"},301 }302 if !equalCues(got, want) {303 t.Errorf("got %v\nwant %v", got, want)304 }305}