1package vchan23import (4 "strings"5 "testing"6 "time"7)89func mustSched(t *testing.T, s Schedule, loc *time.Location) *sched {10 t.Helper()11 r, err := s.read(loc)12 if err != nil {13 t.Fatal(err)14 }15 return r16}1718// hotd is ten episodes, 60 to 69 minutes long.19func hotd() []time.Duration {20 var ls []time.Duration21 for i := range 10 {22 ls = append(ls, time.Duration(60+i)*time.Minute)23 }24 return ls25}2627// TestScheduleBlockCount: four episodes a night from 8 PM, from the first28// on the start date, picking up the next night where the last left off,29// and looping at the end.30func TestScheduleBlockCount(t *testing.T) {31 loc, _ := time.LoadLocation("America/Denver")32 s := mustSched(t, Schedule{Start: "2026-10-04", Blocks: []Block{{At: "20:00", Count: 4}}}, loc)33 ls := hotd()34 day := func(d int, h, m int) time.Time { return time.Date(2026, 10, 4+d, h, m, 0, 0, loc) }3536 got := s.slots(ls, day(0, 0, 0), day(3, 0, 0), 100)37 if len(got) != 12 {38 t.Fatalf("%d airings in three nights", len(got))39 }40 for n, a := range got {41 if a.I != n%10 {42 t.Fatalf("airing %d is item %d", n, a.I)43 }44 }45 if !got[0].Start.Equal(day(0, 20, 0)) || !got[4].Start.Equal(day(1, 20, 0)) || !got[1].Start.Equal(got[0].End) {46 t.Errorf("nights start %v and %v", got[0].Start, got[4].Start)47 }48 // The third night runs episodes 9 and 10, then 1 and 2 again.49 if got[8].I != 8 || got[10].I != 0 {50 t.Errorf("third night: %d, %d", got[8].I, got[10].I)51 }5253 // Mid-afternoon: off the air, with tonight's first episode next.54 on, onAir, ok := s.at(ls, day(1, 15, 0))55 if !ok || onAir || on.I != 4 || !on.Start.Equal(day(1, 20, 0)) {56 t.Errorf("afternoon: %+v on air %v", on, onAir)57 }58 // Half an hour into the second episode tonight.59 on, onAir, _ = s.at(ls, day(1, 21, 34))60 if !onAir || on.I != 5 {61 t.Errorf("evening: %+v on air %v", on, onAir)62 }63 // Before the start: the first episode is next.64 if on, onAir, ok := s.at(ls, day(-3, 12, 0)); !ok || onAir || on.I != 0 || !on.Start.Equal(day(0, 20, 0)) {65 t.Errorf("before the start: %+v", on)66 }67}6869// TestScheduleUntil: a block that stops at a time lets the episode on70// finish, and one stopping after midnight runs into the next day.71func TestScheduleUntil(t *testing.T) {72 loc, _ := time.LoadLocation("America/Denver")73 s := mustSched(t, Schedule{Start: "2026-10-04T21:00", First: 3, Blocks: []Block{{At: "21:00", Until: "00:30"}}}, loc)74 ls := hotd()75 got := s.slots(ls, time.Date(2026, 10, 4, 0, 0, 0, 0, loc), time.Date(2026, 10, 5, 12, 0, 0, 0, loc), 100)76 // Episodes 3 to 6 (62 to 65 minutes) start at 21:00, 22:02, 23:05 and77 // 0:09; 0:09 is before 0:30, so the fourth airs and ends at 1:14.78 if len(got) != 4 || got[0].I != 2 || got[3].I != 5 {79 t.Fatalf("night: %+v", got)80 }81 if end := got[3].End; !end.Equal(time.Date(2026, 10, 5, 1, 14, 0, 0, loc)) {82 t.Errorf("ends %v", end)83 }84}8586// TestScheduleDaysAndBlocks: weekend matinees and weeknight blocks, a block87// run into by the one before starting when it ends.88func TestScheduleDaysAndBlocks(t *testing.T) {89 loc := time.UTC90 s := mustSched(t, Schedule{Start: "2026-10-03", Blocks: []Block{ // a Saturday91 {Days: []string{"sat", "sun"}, At: "13:00", Count: 3},92 {Days: []string{"sat"}, At: "15:00", Count: 1},93 }}, loc)94 ls := []time.Duration{time.Hour, time.Hour, time.Hour, time.Hour}95 got := s.slots(ls, time.Date(2026, 10, 3, 0, 0, 0, 0, loc), time.Date(2026, 10, 6, 0, 0, 0, 0, loc), 100)96 if len(got) != 7 {97 t.Fatalf("%d airings", len(got))98 }99 // Saturday: 13:00, 14:00, 15:00, then the 15:00 block at 16:00.100 if !got[3].Start.Equal(time.Date(2026, 10, 3, 16, 0, 0, 0, loc)) || got[3].I != 3 {101 t.Errorf("saturday's second block: %+v", got[3])102 }103 // Sunday: three more, picking up at the first item; nothing Monday.104 if !got[4].Start.Equal(time.Date(2026, 10, 4, 13, 0, 0, 0, loc)) || got[4].I != 0 {105 t.Errorf("sunday: %+v", got[4])106 }107}108109// TestScheduleAroundTheClock: without blocks, the items loop from the110// start, and a window years on is found without walking there.111func TestScheduleAroundTheClock(t *testing.T) {112 loc := time.UTC113 s := mustSched(t, Schedule{Start: "2026-10-04T20:00", First: 2}, loc)114 ls := hotd()115 on, onAir, _ := s.at(ls, time.Date(2026, 10, 4, 21, 30, 0, 0, loc))116 if !onAir || on.I != 2 { // item 2 ran 20:00 to 21:01117 t.Errorf("on: %+v", on)118 }119 far := time.Date(2036, 1, 1, 0, 0, 0, 0, loc)120 got := s.slots(ls, far, far.Add(3*time.Hour), 10)121 if len(got) < 3 || got[0].Start.After(far) || !got[0].End.After(far) {122 t.Errorf("years on: %+v", got)123 }124 for i := 1; i < len(got); i++ {125 if !got[i].Start.Equal(got[i-1].End) || got[i].I != (got[i-1].I+1)%10 {126 t.Errorf("not back to back: %+v", got)127 }128 }129}130131// TestScheduleGuide: gaps are off the air until the next airing, past the132// window's end if need be, and say what's next.133func TestScheduleGuide(t *testing.T) {134 loc := time.UTC135 s := mustSched(t, Schedule{Start: "2026-10-04", Blocks: []Block{{At: "20:00", Count: 2}}}, loc)136 ls := hotd()137 from, to := time.Date(2026, 10, 4, 12, 0, 0, 0, loc), time.Date(2026, 10, 5, 12, 0, 0, 0, loc)138 g := s.guide(ls, from, to, 100)139 if len(g) != 4 || g[0].I != -1 || !g[0].Start.Equal(from) || !g[0].End.Equal(g[1].Start) ||140 g[1].I != 0 || g[2].I != 1 || g[3].I != -1 {141 t.Fatalf("guide: %+v", g)142 }143 if want := time.Date(2026, 10, 5, 20, 0, 0, 0, loc); !g[3].End.Equal(want) {144 t.Errorf("the gap after ends %v, want %v", g[3].End, want)145 }146 p := offAirProgram(g[3], "Episode 3", g[3].End, false)147 if p.Title != "Off air" || p.Description != "Back Mon, Oct 5 at 8:00 PM with Episode 3." {148 t.Errorf("off air: %+v", p)149 }150 p = offAirProgram(g[0], "Episode 1", g[0].End, true)151 if p.Description != "Starts at 8:00 PM with Episode 1." {152 t.Errorf("before the start: %q", p.Description)153 }154}155156func TestScheduleCheck(t *testing.T) {157 for _, c := range []struct {158 s Schedule159 want string160 }{161 {Schedule{Start: "Oct 4"}, "isn't a date"},162 {Schedule{Start: "2026-10-04", Blocks: []Block{{At: "8pm", Count: 4}}}, "like 20:00"},163 {Schedule{Start: "2026-10-04", Blocks: []Block{{At: "20:00"}}}, "needs a count or a time"},164 {Schedule{Start: "2026-10-04", Blocks: []Block{{At: "20:00", Count: 2, Until: "23:00"}}}, "give one"},165 {Schedule{Start: "2026-10-04", Blocks: []Block{{At: "20:00", Count: 2, Days: []string{"funday"}}}}, "isn't a day"},166 {Schedule{Start: "2026-10-04", First: -1}, "counts from 1"},167 } {168 if err := c.s.Check(); err == nil || !strings.Contains(err.Error(), c.want) {169 t.Errorf("%+v: %v, want %q", c.s, err, c.want)170 }171 }172 if err := (Schedule{Start: "2026-10-04T20:00", Blocks: []Block{{Days: []string{"Fri", "sat"}, At: "20:00", Until: "02:00"}}}).Check(); err != nil {173 t.Error(err)174 }175}