Files
OpsLog/internal/sat/birds_test.go
T
rouggy 1009d06a4c feat(sat): the station side — elements, plan, passes and tuning
What internal/sat could not know: where the antenna is, which birds the
operator cares about, and where the files live.

Startup reads the cached elements and the frequency plan from disk and
nothing else — one file and a few hundred parses, so the tab is full the
moment it is opened, on a shack PC with no internet as much as on one
with. Fetching is the slow, optional half and never blocks a launch; it
happens on its own only when the set is stale and the operator asked for
it.

Elements pasted in by hand go in their own file. The feed cache is
replaced wholesale on every refresh, so a freshly launched satellite —
whose elements circulate on a mailing list days before any feed carries
it, which is exactly the week everybody wants to hear it — would
otherwise be wiped by the first automatic update.

The list joins both halves and shows what is missing on either side. A
bird with elements and no plan is one the operator can still track; a
bird with a plan and no elements is the visible symptom of an element set
that is too old. Dropping either turns a fixable configuration problem
into a satellite that "does not exist".

GetSatelliteTuning is the working answer, and everything that will later
drive a radio is built on top of it rather than beside it, so the display
and the rig can never disagree. It keeps the operator's frequency
nominal and applies Doppler only on the way out: on a linear pass the
station being answered stays put on the dial while both radios chase the
shift. A geostationary bird is corrected by nothing at all.
2026-09-07 10:51:16 +02:00

189 lines
5.8 KiB
Go

package sat
import (
"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 what gets used from then on.
mine := `[{"name":"MY-SAT","transponders":[{"label":"FM","mode":"FM","down_lo":1,"up_lo":2}]}]`
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() != 1 {
t.Fatalf("the operator's list was not used: %d satellites", b.Len())
}
// 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")
}
}