fix(sat): a frequency we shipped wrong can now be mended

"la lilacsat je le vois en DATA ???!" — and the LO-90 fix could not have reached
him. The satellite file is copied out on the first run and was the operator's
from then on, so the merge could only ADD birds, never repair one. LilacSat-2
went out with an APRS digipeater and no FM transponder, and that mistake had
become his data, permanently.

The merge now distinguishes three cases, and the middle one is the whole point:

  - a satellite he does not have is added;
  - one he has, UNCHANGED from the plan he was given, is replaced — he never
    edited it, so it is not his to keep: it is our data and ours was wrong;
  - one he EDITED is left exactly alone, and named in the log. A frequency
    somebody corrected by hand outranks anything shipped; they were on the air
    and we were not.

"Unchanged" is decided against a baseline — satellites.shipped.json, the plan
this station was last handed — so the comparison is with what THEY were given
rather than with whatever ships today. Their edits survive every future release,
not just the next one.

The first run after this has no baseline, and there an edit of theirs and a
mistake of ours are indistinguishable. The shipped plan wins, once, with the
whole file copied to satellites.json.bak first and every replacement named. The
safe-looking alternative was the wrong one: standing down would have written a
baseline recording their entry as "edited" and frozen a known-wrong frequency
for the life of the install.

Verified against his own file: LILACSAT-2 becomes LO-90 with the FM transponder
first, and the twelve curated entries that were missing their catalog numbers
get them — which also closes the NORAD gap left open when the exact join went in.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-09-10 10:41:16 +02:00
co-authored by Claude Opus 5
parent 3ce74ef8d7
commit 580e5782f8
3 changed files with 334 additions and 45 deletions
+143
View File
@@ -268,3 +268,146 @@ func TestGeneratedBirdsAreSane(t *testing.T) {
}
}
}
// The three cases the merge has to tell apart. Getting the middle one wrong is
// how LilacSat-2 shipped with no FM transponder and could never be mended.
func TestMergeShippedRespectsEditsAndFixesOurs(t *testing.T) {
dir := t.TempDir()
// First run: the shipped plan and its baseline are written.
if _, err := LoadBirds(dir); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, BaselineName)); err != nil {
t.Fatalf("no baseline was recorded: %v", err)
}
path := filepath.Join(dir, BirdsName)
// The operator corrects SO-50's tone and adds a satellite of their own.
var list []Bird
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &list); err != nil {
t.Fatal(err)
}
var untouched Bird
for i := range list {
if list[i].Name == "SO-50" {
list[i].Transponders[0].CTCSS = 74.4
}
if list[i].Name == "AO-7" {
untouched = list[i] // left exactly as shipped
}
}
list = append(list, Bird{Name: "MY-SAT", Transponders: []Transponder{{Label: "mine", Mode: "FM", DownLo: 145000000, UpLo: 435000000}}})
out, _ := json.MarshalIndent(list, "", " ")
if err := os.WriteFile(path, out, 0o644); err != nil {
t.Fatal(err)
}
// Load again. Nothing shipped has changed, so nothing should move.
b, err := LoadBirds(dir)
if err != nil {
t.Fatal(err)
}
got, ok := b.Find("SO-50")
if !ok || got.Transponders[0].CTCSS != 74.4 {
t.Errorf("the operator's tone was lost: %+v", got.Transponders)
}
if _, ok := b.Find("MY-SAT"); !ok {
t.Error("the operator's own satellite was dropped")
}
if a, ok := b.Find("AO-7"); !ok || !sameBird(a, untouched) {
t.Error("an untouched satellite was altered for no reason")
}
}
// An entry the operator never touched is REPLACED when the shipped plan
// changes. That is the whole point: our data, and ours was wrong.
func TestMergeShippedUpdatesWhatWasNeverEdited(t *testing.T) {
dir := t.TempDir()
if _, err := LoadBirds(dir); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, BirdsName)
// Pretend an older release shipped SO-50 with a wrong downlink, and that the
// operator simply took it: their file AND the baseline both hold the wrong
// value, which is exactly what "never edited" looks like.
rewrite := func(p string, mutate func(*Bird)) {
raw, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
var l []Bird
if err := json.Unmarshal(raw, &l); err != nil {
t.Fatal(err)
}
for i := range l {
if l[i].Name == "SO-50" {
mutate(&l[i])
}
}
out, _ := json.MarshalIndent(l, "", " ")
if err := os.WriteFile(p, out, 0o644); err != nil {
t.Fatal(err)
}
}
wrong := func(x *Bird) { x.Transponders[0].DownLo = 1 }
rewrite(path, wrong)
rewrite(filepath.Join(dir, BaselineName), wrong)
b, err := LoadBirds(dir)
if err != nil {
t.Fatal(err)
}
got, ok := b.Find("SO-50")
if !ok {
t.Fatal("SO-50 vanished")
}
if got.Transponders[0].DownLo == 1 {
t.Error("a value the operator never edited was not brought up to date — a shipped mistake is unfixable")
}
}
// With NO baseline — the first run after baselines existed — the shipped plan
// wins and the file is backed up. Standing down would freeze a known-wrong
// frequency for the life of the install.
func TestMergeShippedWithNoBaselineTakesShippedAndBacksUp(t *testing.T) {
dir := t.TempDir()
if _, err := LoadBirds(dir); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, BirdsName)
if err := os.Remove(filepath.Join(dir, BaselineName)); err != nil {
t.Fatal(err)
}
// A wrong value, with nothing to say whether it is ours or theirs.
raw, _ := os.ReadFile(path)
var l []Bird
if err := json.Unmarshal(raw, &l); err != nil {
t.Fatal(err)
}
for i := range l {
if l[i].Name == "SO-50" {
l[i].Transponders[0].DownLo = 1
}
}
out, _ := json.MarshalIndent(l, "", " ")
if err := os.WriteFile(path, out, 0o644); err != nil {
t.Fatal(err)
}
b, err := LoadBirds(dir)
if err != nil {
t.Fatal(err)
}
got, _ := b.Find("SO-50")
if got.Transponders[0].DownLo == 1 {
t.Error("the shipped plan did not take over, so the wrong value is now frozen for ever")
}
if _, err := os.Stat(path + ".bak"); err != nil {
t.Errorf("no backup was written before overwriting: %v", err)
}
}