package sat import ( "encoding/json" "os" "path/filepath" "testing" ) // The shipped list has to be readable and consistent — it is embedded, so a // mistake in it is a mistake in every build. func TestShippedBirds(t *testing.T) { b := &Birds{} if err := b.parse(shippedBirds); err != nil { t.Fatalf("birds.json does not parse: %v", err) } if b.Len() < 5 { t.Fatalf("only %d satellites shipped", b.Len()) } for _, bird := range b.All() { if len(bird.Transponders) == 0 { t.Errorf("%s has no transponder", bird.Name) } for _, tr := range bird.Transponders { if tr.DownLo <= 0 { t.Errorf("%s / %s: no downlink", bird.Name, tr.Label) } if tr.DownHi != 0 && tr.DownHi <= tr.DownLo { t.Errorf("%s / %s: downlink passband runs backwards", bird.Name, tr.Label) } if tr.UpHi != 0 && tr.UpHi <= tr.UpLo { t.Errorf("%s / %s: uplink passband runs backwards", bird.Name, tr.Label) } // A linear transponder whose two passbands are different widths cannot // map one onto the other, and the split would drift across the pass. if tr.Linear() && (tr.DownHi-tr.DownLo) != (tr.UpHi-tr.UpLo) { t.Errorf("%s / %s: passbands are %d and %d Hz wide", bird.Name, tr.Label, tr.DownHi-tr.DownLo, tr.UpHi-tr.UpLo) } } } } func TestFindByAlias(t *testing.T) { b := &Birds{} if err := b.parse(shippedBirds); err != nil { t.Fatal(err) } // Every spelling on the left is one an operator or a feed actually uses. for _, tc := range []struct{ query, want string }{ {"AO-91", "AO-91"}, {"RADFXSAT (FOX-1B)", "AO-91"}, {"radfxsat", "AO-91"}, {"ISS (ZARYA)", "ISS (ZARYA)"}, {"ISS", "ISS (ZARYA)"}, {"SAUDISAT 1C (SO-50)", "SO-50"}, {"so 50", "SO-50"}, {"QO-100", "QO-100"}, {"ESHAIL-2", "QO-100"}, {"Es'hail 2", "QO-100"}, } { got, ok := b.Find(tc.query) if !ok { t.Errorf("%q was not found", tc.query) continue } if got.Name != tc.want { t.Errorf("%q found %q, wanted %q", tc.query, got.Name, tc.want) } } if _, ok := b.Find("NOAA 15"); ok { t.Error("a weather satellite should not carry an amateur frequency plan") } } // Matches is the other direction: a bird in hand, scanning a feed's names. func TestBirdMatches(t *testing.T) { b := Bird{Name: "AO-91", Aliases: []string{"RADFXSAT", "FOX-1B"}} for _, feed := range []string{"AO-91", "RADFXSAT (FOX-1B)", "radfxsat", "FOX 1B"} { if !b.Matches(feed) { t.Errorf("%q was not recognised as AO-91", feed) } } for _, feed := range []string{"AO-92", "NOAA 15", "FOX-1A"} { if b.Matches(feed) { t.Errorf("%q was wrongly taken for AO-91", feed) } } // A bracketed catalogue name matched from the other side. iss := Bird{Name: "ISS (ZARYA)"} if !iss.Matches("ISS") || !iss.Matches("ZARYA") { t.Error("the ISS was not recognised by either half of its catalogue name") } } // The uplink maths is the part that matters on the air: a station worked at one // end of an inverting transponder has to be answered at the other. func TestUplinkFor(t *testing.T) { inv := Transponder{ DownLo: 435800000, DownHi: 435900000, UpLo: 145900000, UpHi: 146000000, Inverting: true, } straight := Transponder{ DownLo: 29400000, DownHi: 29500000, UpLo: 145850000, UpHi: 145950000, } fm := Transponder{DownLo: 436795000, UpLo: 145850000} for _, tc := range []struct { name string tr Transponder down int64 want int64 }{ {"inverting, bottom of the downlink", inv, 435800000, 146000000}, {"inverting, top of the downlink", inv, 435900000, 145900000}, {"inverting, 30 kHz up", inv, 435830000, 145970000}, {"straight, bottom", straight, 29400000, 145850000}, {"straight, 25 kHz up", straight, 29425000, 145875000}, {"FM channel ignores the tuned downlink", fm, 436798000, 145850000}, {"below the passband is clamped", inv, 435700000, 146000000}, {"above the passband is clamped", inv, 436000000, 145900000}, } { if got := tc.tr.UplinkFor(tc.down); got != tc.want { t.Errorf("%s: got %d, wanted %d", tc.name, got, tc.want) } } // Receive-only: a beacon has nothing to answer on. if got := (Transponder{DownLo: 145800000}).UplinkFor(145800000); got != 0 { t.Errorf("a receive-only transponder gave an uplink of %d", got) } } // Whichever end the operator takes hold of, the pair has to agree. func TestDownlinkForRoundTrip(t *testing.T) { for _, tr := range []Transponder{ {DownLo: 435800000, DownHi: 435900000, UpLo: 145900000, UpHi: 146000000, Inverting: true}, {DownLo: 29400000, DownHi: 29500000, UpLo: 145850000, UpHi: 145950000}, } { for _, down := range []int64{tr.DownLo, tr.Centre(), tr.DownHi} { if got := tr.DownlinkFor(tr.UplinkFor(down)); got != down { t.Errorf("inverting=%v: %d → uplink → %d", tr.Inverting, down, got) } } } } func TestLoadBirdsWritesTheEditableCopy(t *testing.T) { dir := t.TempDir() b, err := LoadBirds(dir) if err != nil { t.Fatal(err) } shipped := b.Len() path := filepath.Join(dir, BirdsName) if _, err := os.Stat(path); err != nil { t.Fatalf("the editable copy was not written: %v", err) } // An operator's own list is kept, AND the shipped satellites they have never // seen are added to it. // // The merge is what carries a new satellite to a station that has already // run OpsLog once: without it, the copy written on the very first launch was // the operator's list for ever, and a release adding nine Tevel-2 birds // reached nobody. What it must never do is take something back — so the // operator's own satellite, and their correction to a shipped one, both have // to survive it. mine := `[ {"name":"MY-SAT","transponders":[{"label":"FM","mode":"FM","down_lo":1,"up_lo":2}]}, {"name":"SO-50","norad":27607,"transponders":[{"label":"corrected","mode":"FM","down_lo":436796000,"up_lo":145850000,"ctcss":74.4}]} ]` if err := os.WriteFile(path, []byte(mine), 0o644); err != nil { t.Fatal(err) } b, err = LoadBirds(dir) if err != nil { t.Fatal(err) } if b.Len() < shipped { t.Fatalf("the shipped satellites were not merged in: %d, expected at least %d", b.Len(), shipped) } if _, ok := b.Find("MY-SAT"); !ok { t.Error("the operator's own satellite was dropped by the merge") } // Their correction stands: same tone, same frequency, not the shipped one. got, ok := b.Find("SO-50") if !ok { t.Fatal("SO-50 vanished") } if len(got.Transponders) != 1 || got.Transponders[0].CTCSS != 74.4 || got.Transponders[0].DownLo != 436796000 { t.Errorf("the operator's correction to SO-50 was overwritten: %+v", got.Transponders) } // And a shipped satellite they had never seen is now there. if _, ok := b.Find("AO-7"); !ok { t.Error("a shipped satellite was not added to the operator's list") } // And a broken one falls back without destroying what they wrote. if err := os.WriteFile(path, []byte("[{oops"), 0o644); err != nil { t.Fatal(err) } b, err = LoadBirds(dir) if err == nil { t.Error("a broken list was accepted silently") } if b.Len() != shipped { t.Errorf("the shipped list did not take over: %d satellites", b.Len()) } if data, _ := os.ReadFile(path); string(data) != "[{oops" { t.Error("the operator's broken file was overwritten") } } // The shipped plan is generated (cmd/satgen) from public databases, so it is // worth its own guard, on top of TestShippedBirds above: a bad regeneration // should fail here rather than mistune an antenna on somebody first pass. func TestGeneratedBirdsAreSane(t *testing.T) { var list []Bird if err := json.Unmarshal(shippedBirds, &list); err != nil { t.Fatalf("birds.json does not parse: %v", err) } // The generator joins three feeds. If one of them answered with nothing, the // output silently shrinks, and this is where that shows up. if len(list) < 30 { t.Errorf("only %d satellites shipped — the generator probably ran against an empty feed", len(list)) } seenNORAD := map[int]string{} seenName := map[string]bool{} for _, b := range list { if b.Name == "" { t.Error("a satellite with no name") } if seenName[loose(b.Name)] { t.Errorf("%s appears twice", b.Name) } seenName[loose(b.Name)] = true // Two entries for one catalog number is one satellite the operator can // pick twice, with two different sets of frequencies. if b.NORAD != 0 { if other, dup := seenNORAD[b.NORAD]; dup { t.Errorf("NORAD %d is both %s and %s", b.NORAD, other, b.Name) } seenNORAD[b.NORAD] = b.Name } for _, tp := range b.Transponders { // An amateur satellite works between 15 m and 24 GHz. Anything outside // that is a units mistake, and a units mistake is how a rig ends up // commanded somewhere it cannot go. for _, hz := range []int64{tp.DownLo, tp.DownHi, tp.UpLo, tp.UpHi} { if hz != 0 && (hz < 21_000_000 || hz > 24_000_000_000) { t.Errorf("%s: %d Hz is not an amateur satellite frequency", b.Name, hz) } } // Inversion only means something across a passband. The code ignores // the flag on a channel, but a file that claims an FM repeater inverts // will mislead whoever reads it next. if tp.Inverting && !tp.Linear() { t.Errorf("%s: %q is a channel and cannot invert", b.Name, tp.Label) } switch tp.Mode { case "FM", "SSB", "CW", "DATA": default: t.Errorf("%s: %q is not an ADIF mode the log can store", b.Name, tp.Mode) } } } }