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 / service / antenna_test.go
antenna_test.go 18.9K
1package service23import (4 "bytes"5 "context"6 "encoding/json"7 "encoding/xml"8 "errors"9 "io"10 "net/http"11 "net/http/httptest"12 "path/filepath"13 "slices"14 "strings"15 "sync"16 "testing"17 "time"1819 "airwaves/internal/guide"20 "airwaves/internal/hdhr"21 "airwaves/internal/hdhr/hdhrtest"22 "airwaves/internal/lineup/lineuptest"23 "airwaves/internal/signal"24 "airwaves/internal/ts"25 "airwaves/internal/ts/tstest"26 "airwaves/internal/tuner"27)2829// fastTiming measures in milliseconds rather than seconds.30var fastTiming = measureTiming{31 sample: 20 * time.Millisecond,32 acquire: 300 * time.Millisecond, settle: 20 * time.Millisecond, interval: 20 * time.Millisecond, readings: 3,33 idleWait: 300 * time.Millisecond,34 poll: 20 * time.Millisecond, noLock: 300 * time.Millisecond,35}3637// fakeTuner is an HDHomeRun whose scan found 38 channels around Denver,38// with RF channels that can be made to lose their signal.39type fakeTuner struct {40 *hdhrtest.Server41 mu sync.Mutex42 muxes map[int64][]byte43 signal map[int64][3]int44}4546func (f *fakeTuner) mux(freq int64) []byte {47 f.mu.Lock()48 defer f.mu.Unlock()49 return f.muxes[freq]50}5152func (f *fakeTuner) sig(freq int64) (int, int, int) {53 f.mu.Lock()54 defer f.mu.Unlock()55 if v, ok := f.signal[freq]; ok {56 return v[0], v[1], v[2]57 }58 if f.muxes[freq] != nil {59 return 90, 95, 10060 }61 return 40, 0, 062}6364// set makes the RF channel at freq carry mux (nil: nothing to lock), with65// the given signal.66func (f *fakeTuner) set(freq int64, mux []byte, strength, quality, symbol int) {67 f.mu.Lock()68 defer f.mu.Unlock()69 f.muxes[freq] = mux70 f.signal[freq] = [3]int{strength, quality, symbol}71}7273// newTunerService is a server with the fake tuner, and Denver's records74// and listings already loaded.75func newTunerService(t *testing.T, change ...func(*Options)) (*Service, *fakeTuner) {76 t.Helper()77 fake := &fakeTuner{muxes: lineuptest.Muxes(), signal: map[int64][3]int{}}78 fake.Server = hdhrtest.New(t, hdhrtest.Device{Lineup: lineuptest.Lineup(), Mux: fake.mux, Signal: fake.sig})79 dir := t.TempDir()80 opt := Options{81 Name: "nas", Mode: "server", CacheDir: filepath.Join(dir, "cache"), Config: Config{ZIP: "80302"}, HDHomeRun: fake.URL(),82 DVRPath: filepath.Join(dir, "dvr.json"), RecordingsDir: filepath.Join(dir, "recordings"), SignalPath: filepath.Join(dir, "signal.json"),83 }84 for _, f := range change {85 f(&opt)86 }87 s, err := New(opt)88 if err != nil {89 t.Fatal(err)90 }91 t.Cleanup(s.Close)92 s.timing = fastTiming93 if s.tuners != nil {94 s.tuners.Linger, s.tuners.StatusAge = 0, 095 }96 s.findTuner(t.Context())97 s.mu.Lock()98 s.report, s.guide, s.built = lineuptest.Report(), lineuptest.Guide(), time.Now()99 s.mu.Unlock()100 return s, fake101}102103func lineupNumbers() []string {104 var out []string105 for _, c := range lineuptest.Lineup() {106 out = append(out, c.Number)107 }108 return out109}110111// TestTunerLineup: the app's antenna channels are exactly the tuner's, the112// records and listings only describing them; nothing estimated is served.113func TestTunerLineup(t *testing.T) {114 s, _ := newTunerService(t)115 ctx := t.Context()116 snap, err := s.Snapshot(ctx, false)117 if err != nil {118 t.Fatal(err)119 }120 a := snap.Antenna121 var numbers []string122 by := map[string]AntennaChannel{}123 for _, c := range a.Channels {124 numbers = append(numbers, c.Number)125 by[c.Number] = c126 }127 want := lineupNumbers()128 slices.SortFunc(want, guide.CompareNumbers)129 if len(want) != 38 || !slices.Equal(numbers, want) {130 t.Fatalf("antenna channels %v, want the tuner's %v", numbers, want)131 }132 // Listed and licensed, but not found by the scan.133 for _, n := range []string{"7.1", "9.1"} {134 if _, ok := by[n]; ok {135 t.Errorf("%s listed without the tuner having it", n)136 }137 }138 if c := by["20.1"]; c.CallSign != "KTVD" || c.GuideCallSign != "KTVDDT" || c.Transmitter != "KTVD" || c.RF != 31 || c.Signal != nil || c.Name != "KTVD-HD" {139 t.Errorf("20.1 = %+v", c)140 }141 if c := by["2.1"]; c.Via != "KDVR" || c.NextGen == nil || c.NextGen.HostCall != "KWGN-TV" {142 t.Errorf("2.1 = %+v", c)143 }144 ti := a.Tuner145 if !ti.Ready || ti.Tuners != 2 || ti.Name != "HDHomeRun FLEX DUO" || ti.Model != "HDFX-2US" || !slices.Equal(ti.Standards, []string{"ATSC 1.0"}) || ti.ATSC3 ||146 ti.InUse != 0 || ti.Reason != "" || ti.Scanning {147 t.Errorf("tuner = %+v", ti)148 }149150 // The RF channels the scan found channels on, and those licensed151 // nearby, with what's licensed on each.152 mux := map[int]MuxSignal{}153 var rfs []int154 for _, m := range a.Muxes {155 mux[m.RF] = m156 rfs = append(rfs, m.RF)157 }158 if !slices.Equal(rfs, []int{7, 15, 16, 28, 29, 31, 32, 33, 34, 35, 36}) {159 t.Fatalf("RF channels %v", rfs)160 }161 if m := mux[31]; m.FrequencyMHz != 575 || m.Band != "UHF" || !slices.Equal(m.Channels, []string{"9.4", "9.7", "20.1", "20.2", "20.3", "20.4"}) ||162 len(m.Stations) != 2 || m.Stations[0].CallSign != "K31AB-D" || m.Stations[1].CallSign != "KTVD" || m.Signal != nil || m.History != nil || m.Tuned {163 t.Errorf("RF 31 = %+v", m)164 }165 if m := mux[7]; len(m.Channels) != 0 || len(m.Stations) != 1 || m.Stations[0].CallSign != "KMGH-TV" || m.Band != "VHF-Hi" {166 t.Errorf("RF 7 = %+v", m)167 }168 if m := mux[34]; len(m.Stations) != 1 || !m.Stations[0].ATSC3 || len(m.Channels) != 0 {169 t.Errorf("RF 34 = %+v", m)170 }171172 // The report: the records with no estimates and no channels of its173 // own (they're the tuner's); the listings of the tuner's channels only.174 if snap.Report.Channels == nil || len(snap.Report.Channels) != 0 {175 t.Errorf("report channels %v", snap.Report.Channels)176 }177 var ids []string178 for _, c := range snap.Guide.Channels {179 ids = append(ids, c.ID)180 }181 if !slices.Equal(ids, []string{"g2", "g4", "g6", "g94", "g14", "g20", "g31"}) || snap.Guide.Programs["g7"] != nil || len(snap.Guide.Programs["g20"]) != 2 {182 t.Errorf("guide channels %v, programs for 7.1: %v", ids, snap.Guide.Programs["g7"])183 }184 if len(s.guide.Channels) != 9 {185 t.Error("the server's own listings were trimmed")186 }187 raw, _ := json.Marshal(snap)188 for _, bad := range []string{"noiseMarginDb", `"presets"`, `"strong"`, `"fair"`, `"tier"`, `"signal":{}`, `"scan"`, "169.254"} {189 if strings.Contains(string(raw), bad) {190 t.Errorf("snapshot has %s", bad)191 }192 }193 for _, want := range []string{`"channels":[]`, `"antenna":{"tuner":{"ready":true`, `"standards":["ATSC 1.0"],"atsc3":false`, `"history":null`} {194 if !strings.Contains(string(raw), want) {195 t.Errorf("snapshot lacks %s", want)196 }197 }198199 info, _ := s.Info(ctx)200 if info.Tuner.Kind != "hdhomerun" || info.Tuner.Tuners != 2 || info.Tuner.Model != "HDFX-2US" || info.Tuner.Name != "HDHomeRun FLEX DUO" ||201 !slices.Equal(info.Tuner.Standards, []string{"ATSC 1.0"}) || info.Tuner.ATSC3 || !info.DVR {202 t.Errorf("info = %+v", info)203 }204 if got := s.AntennaChannels(ctx); len(got) != 38 || got["20.1"] != "KTVD" || got["9.7"] != "KUSA" || got["7.1"] != "" {205 t.Errorf("antenna channels = %v", got)206 }207}208209// TestNoTuner: no tuner, no antenna channels; nothing made up in their210// place, and tuning one says why.211func TestNoTuner(t *testing.T) {212 s, _ := newTunerService(t, func(o *Options) { o.HDHomeRun = "http://127.0.0.1:1" })213 ctx := t.Context()214 snap, err := s.Snapshot(ctx, false)215 if err != nil {216 t.Fatal(err)217 }218 if a := snap.Antenna; len(a.Channels) != 0 || a.Tuner.Ready || !strings.HasPrefix(a.Tuner.Reason, "no tuner: ") || len(snap.Report.Channels) != 0 || len(snap.Guide.Channels) != 0 {219 t.Errorf("antenna = %+v", a)220 }221 if !slices.ContainsFunc(snap.Report.Warnings, func(w string) bool { return strings.HasPrefix(w, "No antenna channels: no tuner") }) {222 t.Errorf("warnings = %q", snap.Report.Warnings)223 }224 if err := s.tuneAntenna(ctx, "tv", "20.1", nil); !errors.Is(err, ErrNoTuner) {225 t.Errorf("tune: %v", err)226 }227 if _, err := s.Measure(ctx); !errors.Is(err, ErrNoTuner) {228 t.Errorf("measure: %v", err)229 }230 if l, err := s.HDHR().Lineup(ctx); err != nil || len(l) != 0 {231 t.Errorf("HDHomeRun lineup = %v, %v", l, err)232 }233 if info, _ := s.Info(ctx); info.Tuner.Kind != "none" || info.Tuner.Tuners != 0 {234 t.Errorf("info = %+v", info.Tuner)235 }236 if s.TunerCount() != 2 {237 t.Errorf("emulated tuners %d before the real ones are found", s.TunerCount())238 }239}240241// readProgram reads in's stream for a moment and lists its programs.242func readProgram(t *testing.T, in tuner.Input) []ts.Program {243 t.Helper()244 var buf bytes.Buffer245 ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond)246 defer cancel()247 _ = in.Source(ctx, &buf)248 d := ts.NewDemux()249 var split ts.Splitter250 split.Write(buf.Bytes(), d.Packet)251 return d.Programs()252}253254// TestTune: a channel starts with its program alone and its audio tracks255// described; one whose RF channel won't lock fails with what the tuner256// measured, not ffmpeg's complaint; with every tuner busy, it says who257// has them.258func TestTune(t *testing.T) {259 s, fake := newTunerService(t)260 ctx := t.Context()261 var got tuner.Input262 start := func(_ context.Context, in tuner.Input) error {263 got = in264 return nil265 }266 if err := s.tuneAntenna(ctx, "den", "4.1", start); err != nil {267 t.Fatal(err)268 }269 if len(got.Audio) != 2 || !got.Audio[1].Described || got.Audio[0].Lang != "eng" || !got.Broadcast || got.Args[len(got.Args)-1] != "pipe:0" {270 t.Errorf("input = %+v", got)271 }272 if ps := readProgram(t, got); len(ps) != 1 || ps[0].Number != 1 {273 t.Errorf("4.1 carries %+v, want its program alone", ps)274 }275276 // No lock on KTVD's RF channel.277 fake.set(575_000_000, nil, 41, 0, 0)278 err := s.tuneAntenna(ctx, "den", "20.1", start)279 var ns *NoSignalError280 if !errors.As(err, &ns) || ns.RF != 31 || ns.Label != "KTVD 20.x" ||281 err.Error() != "No signal on RF 31 (KTVD 20.x) right now: the tuner measured strength 41%, quality 0% and couldn't lock" {282 t.Fatalf("tune 20.1: %v", err)283 }284 if m, ok := s.signals.Get(575_000_000); !ok || m.Latest.Lock || *m.Latest.StrengthPct != 41 || m.Latest.Source != signal.Active {285 t.Errorf("RF 31 = %+v", m)286 }287 if err := s.tuneAntenna(ctx, "den", "9.4", start); err == nil || !strings.HasPrefix(err.Error(), "No signal on RF 31 (KUSA 9.x on KTVD) right now") {288 t.Errorf("tune 9.4: %v", err)289 }290 // Not a channel the tuner found.291 if err := s.tuneAntenna(ctx, "den", "7.1", start); err == nil || err.Error() != "channel 7.1 isn't in the tuner's lineup" {292 t.Errorf("tune 7.1: %v", err)293 }294295 // Both tuners recording: no one can watch something else, but the296 // den can still watch what's recording.297 for _, n := range []string{"6.1", "50.1"} {298 freq, prog, _ := s.tuning(ctx, n)299 r, err := s.tuners.Open(ctx, tuner.Request{FrequencyHz: freq, Program: prog, Priority: tuner.Recording, Who: "news on " + n})300 if err != nil {301 t.Fatal(err)302 }303 defer r.Close()304 }305 if err := s.tuneAntenna(ctx, "den", "31.1", start); err == nil || !strings.HasPrefix(err.Error(), "every tuner is in use right now (recording news on 50.1; recording news on 6.1)") {306 t.Errorf("tune with both tuners recording: %v", err)307 }308 if err := s.tuneAntenna(ctx, "den", "6.2", start); err != nil {309 t.Errorf("tune what shares a recording's RF channel: %v", err)310 }311}312313// TestSampleTuners: while a tuner is in use, its readings are recorded314// for its RF channel, once it has been on it a while, and saved.315func TestSampleTuners(t *testing.T) {316 s, fake := newTunerService(t)317 ctx := t.Context()318 // KTVD's RF channel arrives with errors.319 damaged := fake.mux(575_000_000)320 for i := 50; i < len(damaged)/188; i += 50 {321 tstest.SetTEI(damaged, i)322 }323 fake.set(575_000_000, damaged, 85, 83, 97)324 sub, err := s.tuners.Open(ctx, tuner.Request{FrequencyHz: 575_000_000, Program: 3, Priority: tuner.Watching, Who: "20.1"})325 if err != nil {326 t.Fatal(err)327 }328 defer sub.Close()329 if _, err := sub.Wait(ctx); err != nil {330 t.Fatal(err)331 }332 s.sampleTuners(ctx)333 if _, ok := s.signals.Get(575_000_000); ok {334 t.Fatal("read a tuner just tuned")335 }336 time.Sleep(50 * time.Millisecond)337 s.sampleTuners(ctx)338 m, ok := s.signals.Get(575_000_000)339 if !ok || m.RF != 31 || m.Latest == nil || !m.Latest.Lock || *m.Latest.StrengthPct != 85 || *m.Latest.QualityPct != 83 || *m.Latest.SymbolPct != 97 ||340 m.Latest.Source != signal.Active || m.Latest.ErrorsPerSec == nil || *m.Latest.ErrorsPerSec <= 0 {341 t.Fatalf("RF 31 = %+v, %+v", m, m.Latest)342 }343344 sig, err := s.Signal(ctx)345 if err != nil {346 t.Fatal(err)347 }348 if sig.Tuner.InUse != 1 {349 t.Errorf("in use: %d", sig.Tuner.InUse)350 }351 for _, c := range sig.Channels {352 if (c.RF == 31) != (c.Signal != nil) || (c.RF == 31) != (c.Recent != nil) {353 t.Errorf("%s (RF %d) signal %+v, recent %+v", c.Number, c.RF, c.Signal, c.Recent)354 }355 }356 for _, m := range sig.Muxes {357 if m.RF == 31 && (!m.Tuned || m.History == nil || m.History.Samples != 1 || m.History.LockedPct != 100) {358 t.Errorf("RF 31 = %+v", m)359 }360 }361 raw, _ := json.Marshal(sig)362 if !strings.Contains(string(raw), `"number":"4.1","rf":35,"signal":null,"recent":null`) {363 t.Errorf("unmeasured channel: %s", raw)364 }365366 // Saved on the way out, and read back.367 s.Close()368 again, err := signal.Open(s.opt.SignalPath)369 if err != nil {370 t.Fatal(err)371 }372 if m, ok := again.Get(575_000_000); !ok || m.Latest == nil || !m.Latest.Lock {373 t.Errorf("saved RF 31 = %+v", m)374 }375}376377// waitSweep waits for Measure now to finish.378func waitSweep(t *testing.T, s *Service) SweepStatus {379 t.Helper()380 deadline := time.Now().Add(20 * time.Second)381 for {382 st := s.sweepStatus()383 if !st.Running {384 return st385 }386 if time.Now().After(deadline) {387 t.Fatalf("sweep still running: %+v", st)388 }389 time.Sleep(10 * time.Millisecond)390 }391}392393// TestMeasure: Measure now reads every RF channel with channels, then394// those with a licensed station, one at a time on a free tuner.395func TestMeasure(t *testing.T) {396 s, fake := newTunerService(t)397 ctx := t.Context()398 // KMGH's VHF signal arrives strong, but the tuner can't lock to it.399 fake.set(177_000_000, nil, 81, 0, 0)400 if _, err := s.Measure(ctx); err != nil {401 t.Fatal(err)402 }403 st := waitSweep(t, s)404 if st.Total != 11 || st.Found != 8 || st.Done != 11 || st.Note != "" || st.FinishedAt == nil {405 t.Fatalf("sweep = %+v", st)406 }407 if m, ok := s.signals.Get(587_000_000); !ok || len(m.Windows) != 1 || m.Windows[0].Samples != 3 || !m.Latest.Lock || m.Latest.Source != signal.Sweep {408 t.Errorf("RF 33 = %+v", m)409 }410 if m, ok := s.signals.Get(177_000_000); !ok || m.Latest.Lock || *m.Latest.StrengthPct != 81 {411 t.Errorf("RF 7 = %+v", m)412 }413 if n := fake.Streams(); n != 0 {414 t.Errorf("%d streams left open", n)415 }416}417418// TestMeasureLeavesTunersAlone: measuring never takes a tuner someone is419// using, and stops when someone needs the one it has.420func TestMeasureLeavesTunersAlone(t *testing.T) {421 s, _ := newTunerService(t)422 ctx := t.Context()423 open := func(n string) *tuner.Sub {424 freq, prog, _ := s.tuning(ctx, n)425 sub, err := s.tuners.Open(ctx, tuner.Request{FrequencyHz: freq, Program: prog, Priority: tuner.Watching, Who: n})426 if err != nil {427 t.Fatal(err)428 }429 t.Cleanup(sub.Close)430 return sub431 }432 a, b := open("4.1"), open("6.1")433 if _, err := s.Measure(ctx); err != nil {434 t.Fatal(err)435 }436 if st := waitSweep(t, s); st.Note != "stopped at RF 15: no tuner was free (they're left to viewers and recordings)" {437 t.Errorf("sweep with both tuners watched = %+v", st)438 }439 if a.Err() != nil || b.Err() != nil {440 t.Fatalf("measuring ended a viewer: %v, %v", a.Err(), b.Err())441 }442443 // One free: measuring takes it, until a viewer wants it.444 b.Close()445 s.timing.settle = 200 * time.Millisecond446 if _, err := s.Measure(ctx); err != nil {447 t.Fatal(err)448 }449 for deadline := time.Now().Add(5 * time.Second); s.sweepStatus().Done < 9; time.Sleep(5 * time.Millisecond) {450 if time.Now().After(deadline) {451 t.Fatalf("sweep = %+v", s.sweepStatus())452 }453 }454 open("50.1")455 if st := waitSweep(t, s); !strings.Contains(st.Note, "a viewer or a recording needed the tuner") && !strings.Contains(st.Note, "no tuner was free") {456 t.Errorf("sweep = %+v", st)457 }458}459460// TestHDHomeRunAntenna: the emulated HDHomeRun lists the tuner's channels,461// named and guided as the app has them, and streams them as broadcast.462func TestHDHomeRunAntenna(t *testing.T) {463 s, fake := newTunerService(t)464 dev := &hdhr.Server{Backend: s.HDHR(), FriendlyName: "Airwaves", Tuners: s.TunerCount}465 srv := httptest.NewServer(dev.Handler())466 defer srv.Close()467 resp, err := http.Get(srv.URL + "/lineup.json")468 if err != nil {469 t.Fatal(err)470 }471 var entries []map[string]any472 err = json.NewDecoder(resp.Body).Decode(&entries)473 resp.Body.Close()474 if err != nil {475 t.Fatal(err)476 }477 var numbers []string478 names := map[string]string{}479 for _, e := range entries {480 n := e["GuideNumber"].(string)481 numbers = append(numbers, n)482 names[n] = e["GuideName"].(string)483 }484 want := lineupNumbers()485 slices.SortFunc(want, guide.CompareNumbers)486 if !slices.Equal(numbers, want) || names["20.1"] != "KTVDDT" || names["20.2"] != "H & I" || names["9.7"] != "TBD" {487 t.Errorf("lineup %v, names %v", numbers, names)488 }489 if s.TunerCount() != 2 {490 t.Errorf("tuners %d", s.TunerCount())491 }492493 resp, err = http.Get(srv.URL + "/xmltv.xml")494 if err != nil {495 t.Fatal(err)496 }497 raw, _ := io.ReadAll(resp.Body)498 resp.Body.Close()499 var doc xmltvOut500 if err := xml.Unmarshal(raw, &doc); err != nil {501 t.Fatal(err)502 }503 progs := map[string]int{}504 for _, p := range doc.Programmes {505 progs[p.Channel]++506 }507 if len(doc.Channels) != 38 || progs["airwaves.20.1"] != 2 || progs["airwaves.7.1"] != 0 || len(progs) != 7 {508 t.Errorf("%d channels, programmes %v", len(doc.Channels), progs)509 }510511 // A stream is the channel's program alone, and frees its tuner after.512 ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond)513 defer cancel()514 req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/auto/v20.2", nil)515 resp, err = http.DefaultClient.Do(req)516 if err != nil {517 t.Fatal(err)518 }519 body, _ := io.ReadAll(resp.Body)520 resp.Body.Close()521 d := ts.NewDemux()522 var split ts.Splitter523 split.Write(body, d.Packet)524 if ps := d.Programs(); len(ps) != 1 || ps[0].Number != 4 {525 t.Errorf("20.2's stream carries %+v", ps)526 }527 for deadline := time.Now().Add(2 * time.Second); fake.Streams() > 0; time.Sleep(10 * time.Millisecond) {528 if time.Now().After(deadline) {529 t.Fatal("the tuner wasn't freed")530 }531 }532}533534// TestScanChannels: a scan lets go of the tuners, and its result becomes535// the lineup.536func TestScanChannels(t *testing.T) {537 fake := &fakeTuner{muxes: lineuptest.Muxes(), signal: map[int64][3]int{}}538 rescan := append(lineuptest.Lineup(), hdhr.Channel{Number: "9.1", Name: "KUSA-HD", FrequencyHz: 189_000_000, Program: 3})539 fake.Server = hdhrtest.New(t, hdhrtest.Device{Lineup: lineuptest.Lineup(), Rescan: rescan, ScanTime: 200 * time.Millisecond, Mux: fake.mux, Signal: fake.sig})540 s, _ := newTunerService(t, func(o *Options) { o.HDHomeRun = fake.URL() })541 ctx := t.Context()542 sub, err := s.tuners.Open(ctx, tuner.Request{FrequencyHz: 575_000_000, Program: 3, Priority: tuner.Watching, Who: "20.1"})543 if err != nil {544 t.Fatal(err)545 }546 defer sub.Close()547 ti, err := s.ScanChannels(ctx)548 if err != nil || !ti.Scanning {549 t.Fatalf("scan: %+v, %v", ti, err)550 }551 <-sub.Done()552 for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(20 * time.Millisecond) {553 s.mu.Lock()554 s.scanAt = time.Time{}555 s.mu.Unlock()556 if !s.tunerInfo(ctx).Scanning {557 break558 }559 if time.Now().After(deadline) {560 t.Fatal("still scanning")561 }562 }563 if got := s.AntennaChannels(ctx); got["9.1"] == "" || len(got) != 39 {564 t.Errorf("after the scan: %d channels, 9.1 %q", len(got), got["9.1"])565 }566}