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 / signal / signal_test.go
signal_test.go 5.3K
1package signal23import (4 "os"5 "path/filepath"6 "testing"7 "time"89 "airwaves/internal/hdhr"10 "airwaves/internal/tuner"11)1213// TestFromTuner reads a tuner: whether data arrives, and the HDHomeRun's14// percentages; an idle tuner has nothing to read.15func TestFromTuner(t *testing.T) {16 at := time.Date(2026, 10, 4, 18, 0, 0, 400_000_000, time.UTC)17 ss, snq, seq := 96, 0, 018 since := at.Add(-time.Minute)19 st := tuner.State{Number: 1, FrequencyHz: 189_000_000, Since: since, Locked: true, Packets: 9000, Errors: 40,20 Device: hdhr.TunerStatus{Tuner: 1, FrequencyHz: 189_000_000, StrengthPct: &ss, QualityPct: &snq, SymbolPct: &seq}}21 r := FromTuner(st, at, Active)22 if !r.Lock || *r.StrengthPct != 96 || *r.QualityPct != 0 || *r.SymbolPct != 0 || r.Source != Active || !r.At.Equal(at.Truncate(time.Second)) || r.ErrorsPerSec != nil {23 t.Fatalf("reading = %+v", r)24 }2526 // Errors a second, against an earlier reading of the same tuning.27 prev := st28 prev.Errors = 1029 r.SetErrors(prev, st, 10)30 if r.ErrorsPerSec == nil || *r.ErrorsPerSec != 3 {31 t.Fatalf("errors a second = %v", r.ErrorsPerSec)32 }33 retuned := prev34 retuned.Since = since.Add(-time.Hour)35 if r.SetErrors(retuned, st, 10); r.ErrorsPerSec != nil {36 t.Fatal("errors counted across tunings")37 }3839 if r := FromTuner(tuner.State{Number: 0}, at, Sweep); r.Lock || r.StrengthPct != nil || r.QualityPct != nil || r.SymbolPct != nil {40 t.Fatalf("idle tuner reading = %+v", r)41 }42}4344func TestFrequency(t *testing.T) {45 for rf := 2; rf <= 36; rf++ {46 if got := RF(Frequency(rf)); got != rf {47 t.Errorf("RF(Frequency(%d)) = %d", rf, got)48 }49 }50 if Frequency(1) != 0 || Frequency(37) != 0 {51 t.Error("not broadcast RF channels have frequencies")52 }53}5455func TestRF(t *testing.T) {56 for freq, want := range map[int64]int{57 57_000_000: 2, 69_000_000: 4, 79_000_000: 5, 85_000_000: 6, 177_000_000: 7, 213_000_000: 13,58 473_000_000: 14, 479_000_000: 15, 575_000_000: 31, 605_000_000: 36, 611_000_000: 37, 0: 0, 100_000_000: 0,59 } {60 if got := RF(freq); got != want {61 t.Errorf("RF(%d) = %d, want %d", freq, got, want)62 }63 }64}6566func reading(at time.Time, source string, lock bool, strength, quality float64) Reading {67 return Reading{At: at, Source: source, Lock: lock, StrengthPct: &strength, QualityPct: &quality}68}6970// TestHistory: readings gather into windows by source and time, the71// windows into a summary with lows and means, and only so many are kept.72func TestHistory(t *testing.T) {73 s, err := Open("")74 if err != nil {75 t.Fatal(err)76 }77 t0 := time.Date(2026, 10, 4, 18, 0, 0, 0, time.UTC)78 const freq = 575_000_00079 if _, ok := s.Get(freq); ok {80 t.Fatal("measured before any reading")81 }82 // Watching for a minute, every 10 s; the picture breaks up once.83 for i, q := range []float64{83, 82, 40, 83, 84, 83} {84 s.Record(freq, 31, reading(t0.Add(time.Duration(i)*10*time.Second), Active, q > 50, 85, q))85 }86 // Measure now, an hour later.87 s.Record(freq, 31, reading(t0.Add(time.Hour), Sweep, true, 86, 84))88 m, ok := s.Get(freq)89 if !ok || m.RF != 31 || len(m.Windows) != 2 || m.Latest.Source != Sweep || *m.Latest.QualityPct != 84 {90 t.Fatalf("mux = %+v", m)91 }92 h := m.History()93 if h.Samples != 7 || h.QualityPct.Min != 40 || h.QualityPct.Max != 84 || h.QualityPct.Avg != 77 || h.StrengthPct.Avg != 85.1 ||94 h.LockedPct != 85.7 || h.SymbolPct != nil || !h.From.Equal(t0) || !h.To.Equal(t0.Add(time.Hour)) || len(h.Windows) != 2 {95 t.Errorf("history = %+v", h)96 }97 if w := h.Windows[0]; w.Source != Active || w.Samples != 6 || w.LockedPct != 83.3 || w.QualityPct.Min != 40 || !w.To.Equal(t0.Add(50*time.Second)) {98 t.Errorf("first window = %+v", w)99 }100101 // A long stretch of watching splits into windows, and the oldest go.102 for i := range 3 * 60 * 6 {103 s.Record(freq, 31, reading(t0.Add(2*time.Hour+time.Duration(i)*10*time.Second), Active, true, 85, 83))104 }105 m, _ = s.Get(freq)106 if len(m.Windows) != 8 {107 t.Errorf("3 hours of watching: %d windows", len(m.Windows))108 }109 for i := range 3 * MaxWindows {110 s.Record(freq, 31, reading(t0.Add(6*time.Hour+time.Duration(i)*time.Hour), Sweep, true, 85, 83))111 }112 if m, _ = s.Get(freq); len(m.Windows) != MaxWindows {113 t.Errorf("windows kept = %d", len(m.Windows))114 }115}116117// TestStoreSaves: measurements outlive a restart.118func TestStoreSaves(t *testing.T) {119 path := filepath.Join(t.TempDir(), "signal.json")120 s, err := Open(path)121 if err != nil {122 t.Fatal(err)123 }124 t0 := time.Date(2026, 10, 4, 18, 0, 0, 0, time.UTC)125 s.Record(605_000_000, 36, reading(t0, Sweep, true, 85, 83))126 s.Record(177_000_000, 7, reading(t0, Sweep, false, 40, 0))127 if err := s.Save(); err != nil {128 t.Fatal(err)129 }130131 again, err := Open(path)132 if err != nil {133 t.Fatal(err)134 }135 all := again.All()136 if len(all) != 2 || all[0].RF != 7 || all[1].RF != 36 {137 t.Fatalf("muxes = %+v", all)138 }139 if m := all[0]; m.Latest.Lock || *m.Latest.StrengthPct != 40 {140 t.Errorf("RF 7 = %+v", m)141 }142 if m := all[1]; !m.Latest.Lock || *m.Latest.StrengthPct != 85 || m.History().Samples != 1 {143 t.Errorf("RF 36 = %+v", m)144 }145146 // An unreadable file is set aside rather than lost or fatal, with no147 // backup (one save made none) starting empty.148 if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {149 t.Fatal(err)150 }151 bad, err := Open(path)152 if err == nil || bad == nil || len(bad.All()) != 0 {153 t.Errorf("unreadable: %v, %+v", err, bad)154 }155 if aside, _ := filepath.Glob(path + ".corrupt-*"); len(aside) != 1 {156 t.Errorf("not set aside: %v", aside)157 }158}